MiniApp / app.py
Dink0
Fix Telegram parsing via jina fallback
a70c3aa
Raw
History Blame Contribute Delete
32 kB
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,
)
@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,
)