Spaces:
Sleeping
Sleeping
| 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"<i\b[^>]*class=[\"'][^\"']*emoji[^\"']*[\"'][^>]*>.*?</i>", | |
| "", | |
| (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<id>\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"<meta[^>]*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".*?<span[^>]*>(.*?)</span>', | |
| 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"<meta[^>]*property=[\"']og:title[\"'][^>]*content=[\"']([^\"']+)[\"']", | |
| page_html, | |
| re.IGNORECASE, | |
| ) | |
| if not m: | |
| m = re.search(r"<title[^>]*>(.*?)</title>", 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 (<ol>) и текст. Возвращаем (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 = ["<ol>"] | |
| 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'<li><a href="{_escape(first_link)}">{_escape(title)}</a>' | |
| if len(items) > 1: | |
| for link2, _v in items[1:]: | |
| line_html += f' + <a href="{_escape(link2)}">ещё</a>' | |
| if has_views: | |
| views_str = " + ".join(_fmt_views(v) for _, v in items) | |
| line_html += f" — {views_str}</li>" | |
| else: | |
| line_html += " — </li>" | |
| 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("</ol>") | |
| 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("<h2>Telegram</h2>") | |
| html_lines.append(f'Суммарно посты собрали <b>{human_format_views(tg_total_views)}</b> просмотров.') | |
| 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("<i>Нет ссылок на Telegram</i>") | |
| text_lines.append("Нет ссылок на Telegram") | |
| html_lines.append("<br/>") | |
| text_lines.append("") | |
| # --- VK --- | |
| html_lines.append("<h2>ВКонтакте</h2>") | |
| html_lines.append(f'Суммарно посты собрали <b>{human_format_views(vk_total_views)}</b> просмотров.') | |
| 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("<i>Нет ссылок на ВКонтакте</i>") | |
| text_lines.append("Нет ссылок на ВКонтакте") | |
| html_lines.append("<br/>") | |
| text_lines.append("") | |
| # --- MAX (секцию показываем только если есть ссылки MAX) --- | |
| if max_groups: | |
| html_lines.append("<h2>MAX</h2>") | |
| html_lines.append("<i>Просмотры из MAX недоступны.</i>") | |
| 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("<br/>") | |
| text_lines.append("") | |
| if errors: | |
| html_lines.append("<h2>Ошибки</h2>") | |
| html_lines.append("<ul>") | |
| for err in errors: | |
| html_lines.append(f"<li>{_escape(err)}</li>") | |
| text_lines.append(f"- {err}") | |
| html_lines.append("</ul>") | |
| html_lines.append("<br/>") | |
| text_lines.append("") | |
| return ( | |
| "\n".join(html_lines), | |
| "\n".join(text_lines), | |
| tg_total_views, | |
| vk_total_views, | |
| errors, | |
| max_count, | |
| ) | |
| def health_check(): | |
| return {"status": "ok"} | |
| 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, | |
| ) | |