from __future__ import annotations from concurrent.futures import ThreadPoolExecutor, wait import threading import time import os import re import html as html_lib from typing import List, Optional, Tuple, Union import requests from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel APP_NAME = "pr-tool-backend" VK_API_VERSION = os.getenv("VK_API_VERSION", "5.131") VK_ACCESS_TOKEN = os.getenv("VK_ACCESS_TOKEN", "") # --- Таймауты и параллелизм (всё настраивается через переменные окружения) --- # Таймаут одного HTTP-запроса задаём кортежем (connect, read), чтобы зависший # коннект не съедал всё время. # С датацентр-IP (Hugging Face) Telegram блокирует/глушит прямые запросы к t.me, # поэтому основной путь — прокси r.jina.ai (см. process_telegram_link). Прямой # t.me остаётся резервом и потому держится «fail-fast». # max(..., floor) — защита от слишком маленьких значений в env (например, # случайно выставленного TELEGRAM_OP_TIMEOUT=3.05), которые рвут TG-запросы. _TG_CONNECT_TIMEOUT = max(float(os.getenv("TELEGRAM_CONNECT_TIMEOUT", "8")), 4.0) _TG_READ_TIMEOUT = max(float(os.getenv("TELEGRAM_OP_TIMEOUT", "8")), 5.0) TG_HTTP_TIMEOUT = (_TG_CONNECT_TIMEOUT, _TG_READ_TIMEOUT) # Прокси ходит к Telegram со своей стороны — даём ему больше времени. _TG_PROXY_TIMEOUT = max(float(os.getenv("TELEGRAM_PROXY_TIMEOUT", "25")), 10.0) TG_PROXY_HTTP_TIMEOUT = (10.0, _TG_PROXY_TIMEOUT) _MAX_CONNECT_TIMEOUT = max(float(os.getenv("MAX_CONNECT_TIMEOUT", "10")), 8.0) _MAX_READ_TIMEOUT = max(float(os.getenv("MAX_OP_TIMEOUT", "12")), 8.0) MAX_HTTP_TIMEOUT = (_MAX_CONNECT_TIMEOUT, _MAX_READ_TIMEOUT) REQUEST_TIMEOUT = float(os.getenv("REQUEST_TIMEOUT", "15")) # для VK API TELEGRAM_CONCURRENCY = int(os.getenv("TELEGRAM_CONCURRENCY", "6")) MAX_CONCURRENCY = int(os.getenv("MAX_CONCURRENCY", "8")) VK_MAX_RETRIES = int(os.getenv("VK_MAX_RETRIES", "5")) VK_RETRY_DELAY = float(os.getenv("VK_RETRY_DELAY", "0.45")) VK_BATCH_SIZE = int(os.getenv("VK_BATCH_SIZE", "100")) # Прокси r.jina.ai — основной путь для Telegram на HF (прямой t.me заблокирован). # Включён по умолчанию; можно отключить через ENABLE_JINA_FALLBACK=0. # X-Return-Format: html заставляет jina вернуть СЫРОЙ HTML виджета t.me # (с tgme_widget_message_views и owner_name), который и парсит наш код. ENABLE_JINA_FALLBACK = os.getenv("ENABLE_JINA_FALLBACK", "1") != "0" _JINA_HEADERS = {"X-Return-Format": "html"} _JINA_API_KEY = os.getenv("JINA_API_KEY", "").strip() if _JINA_API_KEY: # Ключ снимает строгие лимиты бесплатного тарифа r.jina.ai. _JINA_HEADERS["Authorization"] = f"Bearer {_JINA_API_KEY}" # Общий бюджет времени на весь запрос. По истечении возвращаем то, что успели # обработать, а остальное помечаем как «превышено время» — вместо того, чтобы # мини-приложение отвалилось по таймауту целиком. TOTAL_TIME_BUDGET = float(os.getenv("TOTAL_TIME_BUDGET", "25")) # Предохранитель от слишком больших списков. Лишнее отрезаем с пометкой. MAX_INPUT_LINKS = int(os.getenv("MAX_INPUT_LINKS", "1200")) app = FastAPI(title=APP_NAME) # Для простоты разрешаем все источники. Можно сузить список доменов в проде. app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=False, allow_methods=["POST", "GET", "OPTIONS"], allow_headers=["*"], ) # ---------- HTTP-сессии с переиспользованием соединений ---------- # Отдельная сессия на поток (потокобезопасно) + пул соединений, чтобы не # открывать TCP/TLS заново на каждую ссылку. _thread_local = threading.local() def _get_session() -> requests.Session: sess = getattr(_thread_local, "session", None) if sess is None: sess = requests.Session() adapter = requests.adapters.HTTPAdapter( pool_connections=4, pool_maxsize=4, max_retries=0 ) sess.mount("http://", adapter) sess.mount("https://", adapter) sess.headers.update({"User-Agent": "Mozilla/5.0"}) _thread_local.session = sess return sess class ParseRequest(BaseModel): links: Union[List[str], str] class ParseResponse(BaseModel): html: str text: str telegram_total: int vk_total: int errors: List[str] max_count: int = 0 # число ссылок MAX (просмотры из MAX недоступны) # ---------- Вспомогательные функции ---------- def clean_telegram_title(raw: str, fallback: str) -> str: title = re.sub( r"]*class=[\"'][^\"']*emoji[^\"']*[\"'][^>]*>.*?", "", (raw or "").strip(), flags=re.IGNORECASE | re.DOTALL, ) title = re.sub(r"<[^>]+>", "", title) title = html_lib.unescape(title) title = re.sub(r"\s*[-|]\s*Telegram\s*$", "", title, flags=re.IGNORECASE) title = re.sub( r"[\U0001F1E6-\U0001F1FF\U0001F300-\U0001FAFF\u2600-\u27BF\uFE0E\uFE0F]", "", title, ) title = re.sub(r"\s+", " ", title).strip() return title or fallback def clean_max_title(raw: str, fallback: str) -> str: title = re.sub(r"<[^>]+>", "", (raw or "").strip()) title = html_lib.unescape(title) # убираем хвосты вида " — MAX", " | MAX", " - MAX" title = re.sub(r"\s*[-—|·]\s*MAX\s*$", "", title, flags=re.IGNORECASE) title = re.sub(r"\s+", " ", title).strip() return title or fallback def human_format_views(num: int) -> str: if num >= 1_000_000: value = num / 1_000_000 suffix = "M" else: value = num / 1000 suffix = "K" rounded = round(value, 1) if rounded.is_integer(): formatted = f"{int(rounded)}{suffix}" else: formatted = f"{rounded:.1f}{suffix}" return formatted.replace(".", ",") def _parse_views_number(raw: str) -> int: if not raw: return 0 normalized = raw.strip().replace("\u00a0", "").replace(" ", "").replace(",", ".") match = re.match(r"([\d\.]+)\s*([kKmM]?)", normalized) if not match: return 0 value = float(match.group(1)) suffix = match.group(2).lower() if suffix == "k": value *= 1_000 elif suffix == "m": value *= 1_000_000 return int(value) def detect_platform(link: str) -> str: link = link.strip().lower() if "t.me/" in link or "telegram.me/" in link: return "telegram" if "vk.com/wall" in link or "vk.ru/wall" in link: return "vk" if "max.ru/" in link: return "max" return "" def canonicalize_tg_link(link: str) -> Tuple[Optional[str], Optional[str], Optional[str]]: link = link.strip() private_match = re.match(r"https?://(?:t(?:elegram)?\.me)/c/(\d+)/(\d+)", link, re.IGNORECASE) if private_match: return link, None, None m = re.match(r"https?://(?:t(?:elegram)?\.me)/(?:s/)?([^/]+)/(?P\d+)", link, re.IGNORECASE) if not m: return None, None, None username = m.group(1) message_id = m.group("id") canonical = f"https://t.me/{username}/{message_id}" return canonical, username, message_id def canonicalize_vk_link(link: str) -> Tuple[Optional[str], Optional[int], Optional[str]]: link = link.strip() m = re.search(r"(?:vk\.com|vk\.ru)/wall(-?\d+)_(\d+)", link) if not m: return None, None, None owner_id = int(m.group(1)) post_id = m.group(2) canonical = f"https://vk.com/wall{owner_id}_{post_id}" return canonical, owner_id, post_id def canonicalize_max_link(link: str) -> Tuple[Optional[str], Optional[str], Optional[str]]: link = link.strip() m = re.match(r"https?://(?:[\w-]+\.)?max\.ru/([^/?#]+)/([^/?#]+)", link, re.IGNORECASE) if not m: return None, None, None slug = m.group(1) post_id = m.group(2) canonical = f"https://max.ru/{slug}/{post_id}" return canonical, slug, post_id # ---------- Обработчики одной ссылки ---------- def process_telegram_link(channel_username: str, message_id: str, canonical_link: str) -> Tuple[str, int]: urls = [] if ENABLE_JINA_FALLBACK: urls.extend([ ( f"https://r.jina.ai/https://t.me/{channel_username}/{message_id}?embed=1", _JINA_HEADERS, TG_PROXY_HTTP_TIMEOUT, ), ( f"https://r.jina.ai/http://t.me/{channel_username}/{message_id}?embed=1", _JINA_HEADERS, TG_PROXY_HTTP_TIMEOUT, ), ( f"https://r.jina.ai/http://t.me/s/{channel_username}/{message_id}", _JINA_HEADERS, TG_PROXY_HTTP_TIMEOUT, ), ( f"https://r.jina.ai/http://telegram.me/{channel_username}/{message_id}?embed=1", _JINA_HEADERS, TG_PROXY_HTTP_TIMEOUT, ), ]) urls.extend([ (f"https://t.me/{channel_username}/{message_id}?embed=1", None, TG_HTTP_TIMEOUT), (f"https://t.me/s/{channel_username}/{message_id}", None, TG_HTTP_TIMEOUT), (f"https://telegram.me/{channel_username}/{message_id}?embed=1", None, TG_HTTP_TIMEOUT), ]) session = _get_session() page_html = None last_err: Optional[Exception] = None for url, headers, timeout in urls: try: resp = session.get(url, headers=headers, timeout=timeout) if resp.status_code == 200 and resp.text: page_html = resp.text break except Exception as exc: # noqa: BLE001 last_err = exc if page_html is None: return f"Ошибка (TG) при обработке {canonical_link}: {last_err or 'нет ответа'}", 0 title = None meta_title = re.search( r"]*property=[\"']og:title[\"'][^>]*content=[\"']([^\"']+)[\"']", page_html, re.IGNORECASE, ) if meta_title: title = meta_title.group(1).strip() if not title: owner_title = re.search( r'class="tgme_widget_message_owner_name".*?]*>(.*?)', page_html, re.IGNORECASE | re.DOTALL, ) if owner_title: title = owner_title.group(1).strip() title = clean_telegram_title(title or "", channel_username) views = 0 widget_views = re.search( r'class="tgme_widget_message_views[^"]*">([\d\s\.,kKmM]+)<', page_html, re.IGNORECASE, ) if widget_views: views = _parse_views_number(widget_views.group(1)) else: candidates = re.findall( r"(\d[\d\s\.,]*)([kKmM]?)\s*(?:views|просмотр|просмотра|просмотров|переглядів|visualizações|visualizzazioni|ansichten|visninger|visitas)", page_html, re.IGNORECASE, ) if candidates: last_num, last_suffix = candidates[-1] views = _parse_views_number(last_num + last_suffix) return title, views def process_max_link(slug: str, post_id: str, canonical_link: str) -> Tuple[str, None]: """MAX: подхватываем название канала из страницы поста. Просмотры из MAX недоступны, поэтому всегда возвращаем None.""" title = slug try: session = _get_session() resp = session.get(canonical_link, timeout=MAX_HTTP_TIMEOUT) if resp.status_code == 200 and resp.text: page_html = resp.text m = re.search( r"]*property=[\"']og:title[\"'][^>]*content=[\"']([^\"']+)[\"']", page_html, re.IGNORECASE, ) if not m: m = re.search(r"]*>(.*?)", page_html, re.IGNORECASE | re.DOTALL) if m: title = clean_max_title(m.group(1), slug) except Exception: # noqa: BLE001 title = slug # тихо откатываемся к слагу из URL return title or slug, None def process_vk_link(owner_id: int, post_id: str, canonical_link: str) -> Tuple[str, int]: """Одиночный запрос к VK (запасной путь; основной — батч process_vk_batch).""" posts_param = f"{owner_id}_{post_id}" api_url = "https://api.vk.com/method/wall.getById" data = None for attempt in range(1, VK_MAX_RETRIES + 1): params = {"posts": posts_param, "v": VK_API_VERSION, "extended": 1} if VK_ACCESS_TOKEN: params["access_token"] = VK_ACCESS_TOKEN try: resp = requests.get(api_url, params=params, timeout=REQUEST_TIMEOUT) except Exception as exc: # noqa: BLE001 return f"**Ошибка (VK)**: {exc} — {canonical_link}", 0 if resp.status_code != 200: return f"**Ошибка (VK)**: HTTP {resp.status_code} — {canonical_link}", 0 data = resp.json() if "error" in data: error = data["error"] error_msg = error.get("error_msg", "") error_code = error.get("error_code") if error_code == 6 or "too many requests per second" in error_msg.lower(): time.sleep(VK_RETRY_DELAY * attempt) continue return f"**Ошибка (VK)**: {error_msg or 'API error'} — {canonical_link}", 0 break else: return f"**Ошибка (VK)**: Too many requests per second — {canonical_link}", 0 response_data = (data or {}).get("response", {}) items = response_data.get("items", []) if not items: return f"**Ошибка (VK)**: пост не найден — {canonical_link}", 0 post = items[0] views = post.get("views", {}).get("count", 0) post_owner_id = post.get("owner_id", owner_id) title = None if post_owner_id < 0: group_id = -post_owner_id for group in response_data.get("groups", []): if group.get("id") == group_id: title = group.get("name") break else: for profile in response_data.get("profiles", []): if profile.get("id") == post_owner_id: title = (profile.get("first_name", "") + " " + profile.get("last_name", "")).strip() break if not title: title = "VK пост" return title, views def process_vk_batch(batch_items: List[Tuple[str, int, str]]) -> dict: api_url = "https://api.vk.com/method/wall.getById" posts = ",".join(f"{owner_id}_{post_id}" for _, owner_id, post_id in batch_items) data = None for attempt in range(1, VK_MAX_RETRIES + 1): params = {"posts": posts, "v": VK_API_VERSION, "extended": 1} if VK_ACCESS_TOKEN: params["access_token"] = VK_ACCESS_TOKEN try: resp = requests.get(api_url, params=params, timeout=REQUEST_TIMEOUT) except Exception as exc: # noqa: BLE001 return {c: (f"**Ошибка (VK)**: {exc} — {c}", 0) for c, _, _ in batch_items} if resp.status_code != 200: return {c: (f"**Ошибка (VK)**: HTTP {resp.status_code} — {c}", 0) for c, _, _ in batch_items} data = resp.json() if "error" in data: error = data["error"] error_msg = error.get("error_msg", "") error_code = error.get("error_code") if error_code == 6 or "too many requests per second" in error_msg.lower(): time.sleep(VK_RETRY_DELAY * attempt) continue return {c: (f"**Ошибка (VK)**: {error_msg or 'API error'} — {c}", 0) for c, _, _ in batch_items} break else: return {c: (f"**Ошибка (VK)**: Too many requests per second — {c}", 0) for c, _, _ in batch_items} response_data = (data or {}).get("response", {}) items = response_data.get("items", []) groups = {group.get("id"): group for group in response_data.get("groups", [])} profiles = {profile.get("id"): profile for profile in response_data.get("profiles", [])} item_map = { f"{item.get('owner_id')}_{item.get('id')}": item for item in items if item.get("owner_id") is not None and item.get("id") is not None } result: dict = {} for canonical, owner_id, post_id in batch_items: key = f"{owner_id}_{post_id}" post = item_map.get(key) if not post: result[canonical] = (f"**Ошибка (VK)**: пост не найден — {canonical}", 0) continue views = post.get("views", {}).get("count", 0) post_owner_id = post.get("owner_id", owner_id) title = None if post_owner_id < 0: group = groups.get(-post_owner_id) if group: title = group.get("name") else: profile = profiles.get(post_owner_id) if profile: title = (profile.get("first_name", "") + " " + profile.get("last_name", "")).strip() result[canonical] = (title or "VK пост", views) return result # ---------- Параллельное разрешение ссылок с бюджетом времени ---------- def _resolve_pool(tasks, worker, max_workers, deadline, timeout_label, defaults=None): """tasks: список (canonical, args_tuple). worker(*args) -> (title, views). Возвращаем dict canonical -> (title, views). Незавершённые к дедлайну помечаем timeout_label (или оставляем значение из defaults, если оно есть).""" results = dict(defaults) if defaults else {} if not tasks: return results workers = max(1, min(max_workers, len(tasks))) ex = ThreadPoolExecutor(max_workers=workers) fut_map = {ex.submit(worker, *args): canonical for canonical, args in tasks} timeout = max(0.0, deadline - time.monotonic()) done, not_done = wait(list(fut_map.keys()), timeout=timeout) for fut in done: canonical = fut_map[fut] try: results[canonical] = fut.result() except Exception as exc: # noqa: BLE001 if canonical not in results: results[canonical] = (f"Ошибка при обработке {canonical}: {exc}", 0) for fut in not_done: canonical = fut_map[fut] fut.cancel() if canonical not in results: results[canonical] = (timeout_label, 0) # Не блокируемся на «зависших» запросах — отдаём управление сразу. try: ex.shutdown(wait=False, cancel_futures=True) except TypeError: # Python < 3.9 ex.shutdown(wait=False) return results def resolve_telegram(tg_tasks, deadline): # tg_tasks: список (canonical, username, message_id) tasks = [(c, (u, mid, c)) for (c, u, mid) in tg_tasks if u and mid] return _resolve_pool( tasks, process_telegram_link, TELEGRAM_CONCURRENCY, deadline, "Ошибка (TG): превышено время обработки", ) def resolve_max(max_tasks, deadline): # max_tasks: список (canonical, slug, post_id) defaults = {c: (slug, None) for (c, slug, _post) in max_tasks} tasks = [(c, (slug, post_id, c)) for (c, slug, post_id) in max_tasks] fetched = _resolve_pool( tasks, process_max_link, MAX_CONCURRENCY, deadline, None, defaults=defaults ) # просмотры из MAX недоступны всегда return {c: (t, None) for c, (t, _v) in fetched.items()} def resolve_vk(vk_tasks, deadline): # vk_tasks: список (canonical, owner_id, post_id) results = {} valid = [(c, o, p) for (c, o, p) in vk_tasks if o is not None and p is not None] batch_size = max(1, VK_BATCH_SIZE) for start in range(0, len(valid), batch_size): if time.monotonic() > deadline: for c, _, _ in valid[start:]: results[c] = ("**Ошибка (VK)**: превышено время обработки", 0) break chunk = valid[start:start + batch_size] results.update(process_vk_batch(chunk)) return results def normalize_links(links: Union[List[str], str]) -> List[str]: if isinstance(links, str): raw = links.splitlines() else: raw = [] for item in links: raw.extend(str(item).splitlines()) return [line.strip() for line in raw if line.strip()] def _escape(text: str) -> str: return html_lib.escape(text, quote=True) def _fmt_views(v) -> str: return "—" if v is None else human_format_views(v) def _render_groups(groups, has_views, default_title): """Сортируем группы и рендерим в HTML (
    ) и текст. Возвращаем (html_str, text_lines).""" if has_views: ordered = sorted( groups.items(), key=lambda kv: sum((v or 0) for _, v in kv[1]["items"]), reverse=True, ) else: ordered = sorted(groups.items(), key=lambda kv: (kv[1]["title"] or "").lower()) html_lines = ["
      "] text_lines: List[str] = [] for idx, (_, data) in enumerate(ordered, start=1): title = data["title"] or default_title items = data["items"] first_link, _first_views = items[0] line_html = f'
    1. {_escape(title)}' if len(items) > 1: for link2, _v in items[1:]: line_html += f' + ещё' if has_views: views_str = " + ".join(_fmt_views(v) for _, v in items) line_html += f" — {views_str}
    2. " else: line_html += " — " html_lines.append(line_html) line_text = f"{idx}. {title} ({first_link})" if len(items) > 1: line_text += " + ещё: " + ", ".join(link2 for link2, _v in items[1:]) if has_views: line_text += f" — {views_str}" else: line_text += " —" text_lines.append(line_text) html_lines.append("
    ") return "\n".join(html_lines), text_lines def build_output(lines, deadline): errors: List[str] = [] tg_lines = [ln for ln in lines if ln[0] == "telegram"] vk_lines = [ln for ln in lines if ln[0] == "vk"] max_lines = [ln for ln in lines if ln[0] == "max"] tg_fetch = [(c, a, b) for _, c, a, b in tg_lines if a and b] vk_fetch = [(c, a, b) for _, c, a, b in vk_lines if a is not None and b is not None] max_fetch = [(c, a, b) for _, c, a, b in max_lines if a and b] # Telegram, VK и MAX обрабатываем параллельно (раньше шли друг за другом). tg_results, vk_results, max_results = {}, {}, {} with ThreadPoolExecutor(max_workers=3) as top: f_tg = top.submit(resolve_telegram, tg_fetch, deadline) f_vk = top.submit(resolve_vk, vk_fetch, deadline) f_max = top.submit(resolve_max, max_fetch, deadline) tg_results = f_tg.result() vk_results = f_vk.result() max_results = f_max.result() tg_groups: dict = {} vk_groups: dict = {} max_groups: dict = {} for plat, canonical, a, b in lines: if plat == "telegram": username, mid = a, b if not username or not mid: key = canonical invalid_title = "Ошибка (TG): ссылка Telegram недоступна для публичного парсинга" if re.search(r"https?://(?:t(?:elegram)?\.me)/c/\d+/\d+", canonical, re.IGNORECASE): invalid_title = "Ошибка (TG): ссылки вида t.me/c/... не поддерживаются" tg_groups.setdefault(key, {"title": invalid_title, "items": []}) tg_groups[key]["items"].append((canonical, 0)) else: title, views = tg_results.get(canonical, (username, 0)) key = username if key not in tg_groups: tg_groups[key] = {"title": title, "items": []} if not tg_groups[key].get("title") or str(tg_groups[key]["title"]).startswith("Ошибка"): tg_groups[key]["title"] = title tg_groups[key]["items"].append((canonical, views)) elif plat == "vk": owner_id, post_id = a, b if owner_id is None or post_id is None: key = canonical vk_groups.setdefault(key, {"title": "VK пост", "items": []}) vk_groups[key]["items"].append((canonical, 0)) else: title, views = vk_results.get(canonical, (f"**Ошибка (VK)**: нет данных — {canonical}", 0)) key = str(owner_id) if key not in vk_groups: vk_groups[key] = {"title": title, "items": []} if not vk_groups[key].get("title") or str(vk_groups[key]["title"]).startswith("**Ошибка"): vk_groups[key]["title"] = title vk_groups[key]["items"].append((canonical, views)) elif plat == "max": slug, post_id = a, b if not slug or not post_id: key = canonical max_groups.setdefault(key, {"title": "MAX", "items": []}) max_groups[key]["items"].append((canonical, None)) else: title, _views = max_results.get(canonical, (slug, None)) key = slug if key not in max_groups: max_groups[key] = {"title": title, "items": []} if not max_groups[key].get("title"): max_groups[key]["title"] = title max_groups[key]["items"].append((canonical, None)) else: errors.append(f"Неизвестная платформа: {canonical}") tg_total_views = sum(v for g in tg_groups.values() for _, v in g["items"] if v) vk_total_views = sum(v for g in vk_groups.values() for _, v in g["items"] if v) max_count = sum(len(g["items"]) for g in max_groups.values()) html_lines: List[str] = [] text_lines: List[str] = [] # --- Telegram --- html_lines.append("

    Telegram

    ") html_lines.append(f'Суммарно посты собрали {human_format_views(tg_total_views)} просмотров.') text_lines.append("Telegram") text_lines.append(f"Суммарно посты собрали {human_format_views(tg_total_views)} просмотров.") if tg_groups: h, t = _render_groups(tg_groups, has_views=True, default_title="Telegram") html_lines.append(h) text_lines.extend(t) else: html_lines.append("Нет ссылок на Telegram") text_lines.append("Нет ссылок на Telegram") html_lines.append("
    ") text_lines.append("") # --- VK --- html_lines.append("

    ВКонтакте

    ") html_lines.append(f'Суммарно посты собрали {human_format_views(vk_total_views)} просмотров.') text_lines.append("ВКонтакте") text_lines.append(f"Суммарно посты собрали {human_format_views(vk_total_views)} просмотров.") if vk_groups: h, t = _render_groups(vk_groups, has_views=True, default_title="VK пост") html_lines.append(h) text_lines.extend(t) else: html_lines.append("Нет ссылок на ВКонтакте") text_lines.append("Нет ссылок на ВКонтакте") html_lines.append("
    ") text_lines.append("") # --- MAX (секцию показываем только если есть ссылки MAX) --- if max_groups: html_lines.append("

    MAX

    ") html_lines.append("Просмотры из MAX недоступны.") text_lines.append("MAX") text_lines.append("Просмотры из MAX недоступны.") h, t = _render_groups(max_groups, has_views=False, default_title="MAX") html_lines.append(h) text_lines.extend(t) html_lines.append("
    ") text_lines.append("") if errors: html_lines.append("

    Ошибки

    ") html_lines.append("
      ") for err in errors: html_lines.append(f"
    • {_escape(err)}
    • ") text_lines.append(f"- {err}") html_lines.append("
    ") html_lines.append("
    ") text_lines.append("") return ( "\n".join(html_lines), "\n".join(text_lines), tg_total_views, vk_total_views, errors, max_count, ) @app.get("/health") def health_check(): return {"status": "ok"} @app.post("/parse", response_model=ParseResponse) def parse_links(payload: ParseRequest): deadline = time.monotonic() + TOTAL_TIME_BUDGET raw_lines = normalize_links(payload.links) if not raw_lines: return ParseResponse(html="", text="", telegram_total=0, vk_total=0, errors=["Нет ссылок для обработки."]) truncated = False if len(raw_lines) > MAX_INPUT_LINKS: raw_lines = raw_lines[:MAX_INPUT_LINKS] truncated = True seen = set() lines: List[Tuple[str, str, object, object]] = [] for link in raw_lines: plat = detect_platform(link) if plat == "telegram": canonical, username, mid = canonicalize_tg_link(link) if not canonical: canonical, username, mid = link, None, None if canonical in seen: continue seen.add(canonical) lines.append(("telegram", canonical, username, mid)) elif plat == "vk": canonical, owner_id, post_id = canonicalize_vk_link(link) if not canonical: canonical = link if canonical in seen: continue seen.add(canonical) lines.append(("vk", canonical, owner_id, post_id)) elif plat == "max": canonical, slug, post_id = canonicalize_max_link(link) if not canonical: canonical, slug, post_id = link, None, None if canonical in seen: continue seen.add(canonical) lines.append(("max", canonical, slug, post_id)) else: lines.append(("unknown", link, None, None)) html, text, tg_total, vk_total, errors, max_count = build_output(lines, deadline) if truncated: note = f"Обработаны первые {MAX_INPUT_LINKS} ссылок (список был длиннее)." errors = errors + [note] text = text + ("\n" if text else "") + "- " + note return ParseResponse( html=html, text=text, telegram_total=tg_total, vk_total=vk_total, errors=errors, max_count=max_count, )