Dink0 commited on
Commit
ff3c435
·
verified ·
1 Parent(s): e2c307e

Repurpose PR_Tool as isolated ReportBridge backend; preserve MiniApp

Browse files
Dockerfile CHANGED
@@ -1,20 +1,8 @@
1
- FROM python:3.13.5-slim
2
-
3
  WORKDIR /app
4
-
5
- RUN apt-get update && apt-get install -y \
6
- build-essential \
7
- curl \
8
- git \
9
- && rm -rf /var/lib/apt/lists/*
10
-
11
  COPY requirements.txt ./
12
- COPY src/ ./src/
13
-
14
- RUN pip3 install -r requirements.txt
15
-
16
- EXPOSE 8501
17
-
18
- HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
19
-
20
- ENTRYPOINT ["streamlit", "run", "src/streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0"]
 
1
+ FROM python:3.11-slim
 
2
  WORKDIR /app
 
 
 
 
 
 
 
3
  COPY requirements.txt ./
4
+ RUN pip install --no-cache-dir -r requirements.txt
5
+ COPY *.py ./
6
+ COPY certs/ ./certs/
7
+ EXPOSE 7860
8
+ CMD ["uvicorn", "service:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
 
 
 
 
README.md CHANGED
@@ -1,19 +1,60 @@
1
  ---
2
- title: VKPRTool
3
- emoji: 🚀
4
- colorFrom: red
5
- colorTo: red
6
  sdk: docker
7
- app_port: 8501
8
- tags:
9
- - streamlit
10
  pinned: false
11
- short_description: Streamlit template space
12
  ---
13
 
14
- # Welcome to Streamlit!
15
 
16
- Edit `/src/streamlit_app.py` to customize this app to your heart's desire. :heart:
 
 
 
17
 
18
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
19
- forums](https://discuss.streamlit.io).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: ReportBridge
3
+ emoji: 📋
4
+ colorFrom: green
5
+ colorTo: blue
6
  sdk: docker
7
+ app_port: 7860
 
 
8
  pinned: false
 
9
  ---
10
 
11
+ # ReportBridge
12
 
13
+ Отдельный экспериментальный бэкенд расширения Медиалогии. Production Space
14
+ `PRAnalytics/MiniApp` не вызывается, не изменяется и не перезапускается.
15
+ `legacy_parser.py`, `max_api.py` и сертификат скопированы из локального снимка
16
+ MiniApp, commit `88344382f5b6a32564ef2546f0332f0562a30baf`. Секреты не копировались.
17
 
18
+ HF Settings -> Secrets:
19
+
20
+ - `BRIDGE_API_KEY`: отдельный длинный случайный пароль для расширения; НЕ токен HF.
21
+ - `VK_ACCESS_TOKEN`: токен VK, если нужны просмотры VK.
22
+ - `MAX_ACCESS_TOKEN`: токен бота Макс, если нужны просмотры Макс.
23
+ - `JINA_API_KEY`: необязательно, как в старом парсере.
24
+
25
+ `GET /health` сообщает только наличие настроек, не значения.
26
+ `POST /jobs` принимает `{request_id: UUID, links: [URL, ...]}`.
27
+ `GET /jobs/{id}` возвращает фактический прогресс и результат.
28
+ Для обоих `/jobs` нужен заголовок `X-Bridge-Key`.
29
+ Идентификатор запроса предотвращает дублирование запуска при сетевых повторах.
30
+ 500 ссылок на задание, максимум 5 активных заданий, 1 задание одновременно,
31
+ 3 параллельных запроса Telegram/Макс. Очередь и результаты хранятся только
32
+ в памяти до перезапуска; готовые задания удаляются при новом запуске через час.
33
+
34
+ Исходящие обращения только к публичным страницам Telegram/Макс, Jina и VK API.
35
+ Telegram с нулём из старого HTML-парсера считается неподтверждённым счётчиком,
36
+ а не успешным результатом. Ошибки исключаются из суммы; отчёт явно неполный.
37
+ Внешние API могут быть недоступны или менять разметку; очередь не отменяет
38
+ эти ограничения. Для защищённых материалов `/c/` нужен отдельный сценарий,
39
+ они не поддерживаются.
40
+
41
+ ## Статус запуска
42
+
43
+ 27 октября 2026 репозиторий создан отдельно, но HF остановил выделение CPU
44
+ для нового Space: `PAUSED`, `Quota exceeded for flavor cpu-basic
45
+ (requested=1): current=2, limit=0`. Платные ресурсы не подключались.
46
+ На этапе первоначального создания существующие Spaces не изменялись. Для работы облачного
47
+ API нужно разрешённое HF выделение CPU в этой организации.
48
+ Бэкенд отдельно проверен локально; реальный запрос Telegram получил просмотры.
49
+ Тесты сервиса и обработчиков не требуют доступа к рабочему MiniApp.
50
+
51
+
52
+ ## Перенос в PR_Tool
53
+
54
+ По явному разрешению владельца 27 октября 2026 бэкенд переносится в старый
55
+ `PRAnalytics/PR_Tool`, заменяя его Streamlit-интерфейс. `MiniApp` по-прежнему
56
+ не изменяется. Облачная сборка расширения использует
57
+ `https://pranalytics-pr-tool.hf.space`. Старые файлы и ревизия
58
+ `e2c307ec07d010e9b03a9c04ae2e65ed957e7c66` сохранены для отката.
59
+ В PR_Tool до переноса не было Secrets и Variables; токены VK и Макс
60
+ из другого Space автоматически не переносились.
certs/russian_trusted_root_ca.pem ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIFwjCCA6qgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx
3
+ PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu
4
+ ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg
5
+ Q0EwHhcNMjIwMzAxMjEwNDE1WhcNMzIwMjI3MjEwNDE1WjBwMQswCQYDVQQGEwJS
6
+ VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg
7
+ YW5kIENvbW11bmljYXRpb25zMSAwHgYDVQQDDBdSdXNzaWFuIFRydXN0ZWQgUm9v
8
+ dCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMfFOZ8pUAL3+r2n
9
+ qqE0Zp52selXsKGFYoG0GM5bwz1bSFtCt+AZQMhkWQheI3poZAToYJu69pHLKS6Q
10
+ XBiwBC1cvzYmUYKMYZC7jE5YhEU2bSL0mX7NaMxMDmH2/NwuOVRj8OImVa5s1F4U
11
+ zn4Kv3PFlDBjjSjXKVY9kmjUBsXQrIHeaqmUIsPIlNWUnimXS0I0abExqkbdrXbX
12
+ YwCOXhOO2pDUx3ckmJlCMUGacUTnylyQW2VsJIyIGA8V0xzdaeUXg0VZ6ZmNUr5Y
13
+ Ber/EAOLPb8NYpsAhJe2mXjMB/J9HNsoFMBFJ0lLOT/+dQvjbdRZoOT8eqJpWnVD
14
+ U+QL/qEZnz57N88OWM3rabJkRNdU/Z7x5SFIM9FrqtN8xewsiBWBI0K6XFuOBOTD
15
+ 4V08o4TzJ8+Ccq5XlCUW2L48pZNCYuBDfBh7FxkB7qDgGDiaftEkZZfApRg2E+M9
16
+ G8wkNKTPLDc4wH0FDTijhgxR3Y4PiS1HL2Zhw7bD3CbslmEGgfnnZojNkJtcLeBH
17
+ BLa52/dSwNU4WWLubaYSiAmA9IUMX1/RpfpxOxd4Ykmhz97oFbUaDJFipIggx5sX
18
+ ePAlkTdWnv+RWBxlJwMQ25oEHmRguNYf4Zr/Rxr9cS93Y+mdXIZaBEE0KS2iLRqa
19
+ OiWBki9IMQU4phqPOBAaG7A+eP8PAgMBAAGjZjBkMB0GA1UdDgQWBBTh0YHlzlpf
20
+ BKrS6badZrHF+qwshzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzAS
21
+ BgNVHRMBAf8ECDAGAQH/AgEEMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsF
22
+ AAOCAgEAALIY1wkilt/urfEVM5vKzr6utOeDWCUczmWX/RX4ljpRdgF+5fAIS4vH
23
+ tmXkqpSCOVeWUrJV9QvZn6L227ZwuE15cWi8DCDal3Ue90WgAJJZMfTshN4OI8cq
24
+ W9E4EG9wglbEtMnObHlms8F3CHmrw3k6KmUkWGoa+/ENmcVl68u/cMRl1JbW2bM+
25
+ /3A+SAg2c6iPDlehczKx2oa95QW0SkPPWGuNA/CE8CpyANIhu9XFrj3RQ3EqeRcS
26
+ AQQod1RNuHpfETLU/A2gMmvn/w/sx7TB3W5BPs6rprOA37tutPq9u6FTZOcG1Oqj
27
+ C/B7yTqgI7rbyvox7DEXoX7rIiEqyNNUguTk/u3SZ4VXE2kmxdmSh3TQvybfbnXV
28
+ 4JbCZVaqiZraqc7oZMnRoWrXRG3ztbnbes/9qhRGI7PqXqeKJBztxRTEVj8ONs1d
29
+ WN5szTwaPIvhkhO3CO5ErU2rVdUr89wKpNXbBODFKRtgxUT70YpmJ46VVaqdAhOZ
30
+ D9EUUn4YaeLaS8AjSF/h7UkjOibNc4qVDiPP+rkehFWM66PVnP1Msh93tc+taIfC
31
+ EYVMxjh8zNbFuoc7fzvvrFILLe7ifvEIUqSVIC/AzplM/Jxw7buXFeGP1qVCBEHq
32
+ 391d/9RAfaZ12zkwFsl+IKwE/OZxW8AHa9i1p4GO0YSNuczzEm4=
33
+ -----END CERTIFICATE-----
legacy_parser.py ADDED
@@ -0,0 +1,794 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from concurrent.futures import ThreadPoolExecutor, wait
4
+ import threading
5
+ import time
6
+ import os
7
+ import re
8
+ import html as html_lib
9
+ from typing import List, Optional, Tuple, Union
10
+
11
+ import requests
12
+ from fastapi import FastAPI
13
+ from fastapi.middleware.cors import CORSMiddleware
14
+ from pydantic import BaseModel
15
+
16
+ from max_api import get_public_post
17
+
18
+ APP_NAME = "pr-tool-backend"
19
+
20
+ VK_API_VERSION = os.getenv("VK_API_VERSION", "5.131")
21
+ VK_ACCESS_TOKEN = os.getenv("VK_ACCESS_TOKEN", "")
22
+
23
+ # --- Таймауты и параллелизм (всё настраивается через переменные окружения) ---
24
+ # Таймаут одного HTTP-запроса задаём кортежем (connect, read), чтобы зависший
25
+ # коннект не съедал всё время.
26
+ # С датацентр-IP (Hugging Face) Telegram блокирует/глушит прямые запросы к t.me,
27
+ # поэтому основной путь — прокси r.jina.ai (см. process_telegram_link). Прямой
28
+ # t.me остаётся резервом и потому держится «fail-fast».
29
+ # max(..., floor) — защита от слишком маленьких значений в env (например,
30
+ # случайно выставленного TELEGRAM_OP_TIMEOUT=3.05), которые рвут TG-запросы.
31
+ _TG_CONNECT_TIMEOUT = max(float(os.getenv("TELEGRAM_CONNECT_TIMEOUT", "8")), 4.0)
32
+ _TG_READ_TIMEOUT = max(float(os.getenv("TELEGRAM_OP_TIMEOUT", "8")), 5.0)
33
+ TG_HTTP_TIMEOUT = (_TG_CONNECT_TIMEOUT, _TG_READ_TIMEOUT)
34
+ # Прокси ходит к Telegram со своей стороны — даём ему больше времени.
35
+ _TG_PROXY_TIMEOUT = max(float(os.getenv("TELEGRAM_PROXY_TIMEOUT", "25")), 10.0)
36
+ TG_PROXY_HTTP_TIMEOUT = (10.0, _TG_PROXY_TIMEOUT)
37
+ _MAX_CONNECT_TIMEOUT = max(float(os.getenv("MAX_CONNECT_TIMEOUT", "10")), 8.0)
38
+ _MAX_READ_TIMEOUT = max(float(os.getenv("MAX_OP_TIMEOUT", "12")), 8.0)
39
+ MAX_HTTP_TIMEOUT = (_MAX_CONNECT_TIMEOUT, _MAX_READ_TIMEOUT)
40
+ REQUEST_TIMEOUT = float(os.getenv("REQUEST_TIMEOUT", "15")) # для VK API
41
+
42
+ TELEGRAM_CONCURRENCY = int(os.getenv("TELEGRAM_CONCURRENCY", "6"))
43
+ MAX_CONCURRENCY = int(os.getenv("MAX_CONCURRENCY", "8"))
44
+
45
+ VK_MAX_RETRIES = int(os.getenv("VK_MAX_RETRIES", "5"))
46
+ VK_RETRY_DELAY = float(os.getenv("VK_RETRY_DELAY", "0.45"))
47
+ VK_BATCH_SIZE = int(os.getenv("VK_BATCH_SIZE", "100"))
48
+
49
+ # Прокси r.jina.ai — основной путь для Telegram на HF (прямой t.me заблокирован).
50
+ # Включён по умолчанию; можно отключить через ENABLE_JINA_FALLBACK=0.
51
+ # X-Return-Format: html заставляет jina вернуть СЫРОЙ HTML виджета t.me
52
+ # (с tgme_widget_message_views и owner_name), который и парсит наш код.
53
+ ENABLE_JINA_FALLBACK = os.getenv("ENABLE_JINA_FALLBACK", "1") != "0"
54
+ _JINA_HEADERS = {"X-Return-Format": "html"}
55
+ _JINA_API_KEY = os.getenv("JINA_API_KEY", "").strip()
56
+ if _JINA_API_KEY:
57
+ # Ключ снимает строгие лимиты бесплатного тарифа r.jina.ai.
58
+ _JINA_HEADERS["Authorization"] = f"Bearer {_JINA_API_KEY}"
59
+
60
+ # Общий бюджет времени на весь запрос. По истечении возвращаем то, что успели
61
+ # обработать, а остальное помечаем как «превышено время» — вместо того, чтобы
62
+ # мини-приложение отвалилось по таймауту целиком.
63
+ TOTAL_TIME_BUDGET = float(os.getenv("TOTAL_TIME_BUDGET", "25"))
64
+
65
+ # Предохранитель от слишком больших списков. Лишнее отрезаем с пометкой.
66
+ MAX_INPUT_LINKS = int(os.getenv("MAX_INPUT_LINKS", "1200"))
67
+
68
+ app = FastAPI(title=APP_NAME)
69
+
70
+ # Для простоты разрешаем все источники. Можно сузить список доменов в проде.
71
+ app.add_middleware(
72
+ CORSMiddleware,
73
+ allow_origins=["*"],
74
+ allow_credentials=False,
75
+ allow_methods=["POST", "GET", "OPTIONS"],
76
+ allow_headers=["*"],
77
+ )
78
+
79
+
80
+ # ---------- HTTP-сессии с переиспользованием соединений ----------
81
+ # Отдельная сессия на поток (потокобезопасно) + пул соединений, чтобы не
82
+ # открывать TCP/TLS заново на каждую ссылку.
83
+ _thread_local = threading.local()
84
+
85
+
86
+ def _get_session() -> requests.Session:
87
+ sess = getattr(_thread_local, "session", None)
88
+ if sess is None:
89
+ sess = requests.Session()
90
+ adapter = requests.adapters.HTTPAdapter(
91
+ pool_connections=4, pool_maxsize=4, max_retries=0
92
+ )
93
+ sess.mount("http://", adapter)
94
+ sess.mount("https://", adapter)
95
+ sess.headers.update({"User-Agent": "Mozilla/5.0"})
96
+ _thread_local.session = sess
97
+ return sess
98
+
99
+
100
+ class ParseRequest(BaseModel):
101
+ links: Union[List[str], str]
102
+
103
+
104
+ class ParseResponse(BaseModel):
105
+ html: str
106
+ text: str
107
+ telegram_total: int
108
+ vk_total: int
109
+ errors: List[str]
110
+ max_count: int = 0
111
+
112
+
113
+ # ---------- Вспомогательные функции ----------
114
+
115
+ def clean_telegram_title(raw: str, fallback: str) -> str:
116
+ title = re.sub(
117
+ r"<i\b[^>]*class=[\"'][^\"']*emoji[^\"']*[\"'][^>]*>.*?</i>",
118
+ "",
119
+ (raw or "").strip(),
120
+ flags=re.IGNORECASE | re.DOTALL,
121
+ )
122
+ title = re.sub(r"<[^>]+>", "", title)
123
+ title = html_lib.unescape(title)
124
+ title = re.sub(r"\s*[-|]\s*Telegram\s*$", "", title, flags=re.IGNORECASE)
125
+ title = re.sub(
126
+ r"[\U0001F1E6-\U0001F1FF\U0001F300-\U0001FAFF\u2600-\u27BF\uFE0E\uFE0F]",
127
+ "",
128
+ title,
129
+ )
130
+ title = re.sub(r"\s+", " ", title).strip()
131
+ return title or fallback
132
+
133
+
134
+ def clean_max_title(raw: str, fallback: str) -> str:
135
+ title = re.sub(r"<[^>]+>", "", (raw or "").strip())
136
+ title = html_lib.unescape(title)
137
+ # убираем хвосты вида " — MAX", " | MAX", " - MAX"
138
+ title = re.sub(r"\s*[-—|·]\s*MAX\s*$", "", title, flags=re.IGNORECASE)
139
+ title = re.sub(r"\s+", " ", title).strip()
140
+ return title or fallback
141
+
142
+
143
+ def human_format_views(num: int) -> str:
144
+ if num >= 1_000_000:
145
+ value = num / 1_000_000
146
+ suffix = "M"
147
+ else:
148
+ value = num / 1000
149
+ suffix = "K"
150
+
151
+ rounded = round(value, 1)
152
+ if rounded.is_integer():
153
+ formatted = f"{int(rounded)}{suffix}"
154
+ else:
155
+ formatted = f"{rounded:.1f}{suffix}"
156
+
157
+ return formatted.replace(".", ",")
158
+
159
+
160
+ def _parse_views_number(raw: str) -> int:
161
+ if not raw:
162
+ return 0
163
+ normalized = raw.strip().replace("\u00a0", "").replace(" ", "").replace(",", ".")
164
+ match = re.match(r"([\d\.]+)\s*([kKmM]?)", normalized)
165
+ if not match:
166
+ return 0
167
+ value = float(match.group(1))
168
+ suffix = match.group(2).lower()
169
+ if suffix == "k":
170
+ value *= 1_000
171
+ elif suffix == "m":
172
+ value *= 1_000_000
173
+ return int(value)
174
+
175
+
176
+ def detect_platform(link: str) -> str:
177
+ link = link.strip().lower()
178
+ if "t.me/" in link or "telegram.me/" in link:
179
+ return "telegram"
180
+ if "vk.com/wall" in link or "vk.ru/wall" in link:
181
+ return "vk"
182
+ if "max.ru/" in link:
183
+ return "max"
184
+ return ""
185
+
186
+
187
+ def canonicalize_tg_link(link: str) -> Tuple[Optional[str], Optional[str], Optional[str]]:
188
+ link = link.strip()
189
+ private_match = re.match(r"https?://(?:t(?:elegram)?\.me)/c/(\d+)/(\d+)", link, re.IGNORECASE)
190
+ if private_match:
191
+ return link, None, None
192
+ m = re.match(r"https?://(?:t(?:elegram)?\.me)/(?:s/)?([^/]+)/(?P<id>\d+)", link, re.IGNORECASE)
193
+ if not m:
194
+ return None, None, None
195
+ username = m.group(1)
196
+ message_id = m.group("id")
197
+ canonical = f"https://t.me/{username}/{message_id}"
198
+ return canonical, username, message_id
199
+
200
+
201
+ def canonicalize_vk_link(link: str) -> Tuple[Optional[str], Optional[int], Optional[str]]:
202
+ link = link.strip()
203
+ m = re.search(r"(?:vk\.com|vk\.ru)/wall(-?\d+)_(\d+)", link)
204
+ if not m:
205
+ return None, None, None
206
+ owner_id = int(m.group(1))
207
+ post_id = m.group(2)
208
+ canonical = f"https://vk.com/wall{owner_id}_{post_id}"
209
+ return canonical, owner_id, post_id
210
+
211
+
212
+ def canonicalize_max_link(link: str) -> Tuple[Optional[str], Optional[str], Optional[str]]:
213
+ link = link.strip()
214
+ m = re.match(r"https?://(?:[\w-]+\.)?max\.ru/([^/?#]+)/([^/?#]+)", link, re.IGNORECASE)
215
+ if not m:
216
+ return None, None, None
217
+ slug = m.group(1)
218
+ post_id = m.group(2)
219
+ canonical = f"https://max.ru/{slug}/{post_id}"
220
+ return canonical, slug, post_id
221
+
222
+
223
+ # ---------- Обработчики одной ссылки ----------
224
+
225
+ def process_telegram_link(channel_username: str, message_id: str, canonical_link: str) -> Tuple[str, int]:
226
+ urls = []
227
+ if ENABLE_JINA_FALLBACK:
228
+ urls.extend([
229
+ (
230
+ f"https://r.jina.ai/https://t.me/{channel_username}/{message_id}?embed=1",
231
+ _JINA_HEADERS,
232
+ TG_PROXY_HTTP_TIMEOUT,
233
+ ),
234
+ (
235
+ f"https://r.jina.ai/http://t.me/{channel_username}/{message_id}?embed=1",
236
+ _JINA_HEADERS,
237
+ TG_PROXY_HTTP_TIMEOUT,
238
+ ),
239
+ (
240
+ f"https://r.jina.ai/http://t.me/s/{channel_username}/{message_id}",
241
+ _JINA_HEADERS,
242
+ TG_PROXY_HTTP_TIMEOUT,
243
+ ),
244
+ (
245
+ f"https://r.jina.ai/http://telegram.me/{channel_username}/{message_id}?embed=1",
246
+ _JINA_HEADERS,
247
+ TG_PROXY_HTTP_TIMEOUT,
248
+ ),
249
+ ])
250
+
251
+ urls.extend([
252
+ (f"https://t.me/{channel_username}/{message_id}?embed=1", None, TG_HTTP_TIMEOUT),
253
+ (f"https://t.me/s/{channel_username}/{message_id}", None, TG_HTTP_TIMEOUT),
254
+ (f"https://telegram.me/{channel_username}/{message_id}?embed=1", None, TG_HTTP_TIMEOUT),
255
+ ])
256
+
257
+ session = _get_session()
258
+ page_html = None
259
+ last_err: Optional[Exception] = None
260
+ for url, headers, timeout in urls:
261
+ try:
262
+ resp = session.get(url, headers=headers, timeout=timeout)
263
+ if resp.status_code == 200 and resp.text:
264
+ page_html = resp.text
265
+ break
266
+ except Exception as exc: # noqa: BLE001
267
+ last_err = exc
268
+
269
+ if page_html is None:
270
+ return f"Ошибка (TG) при обработке {canonical_link}: {last_err or 'нет ответа'}", 0
271
+
272
+ title = None
273
+ meta_title = re.search(
274
+ r"<meta[^>]*property=[\"']og:title[\"'][^>]*content=[\"']([^\"']+)[\"']",
275
+ page_html,
276
+ re.IGNORECASE,
277
+ )
278
+ if meta_title:
279
+ title = meta_title.group(1).strip()
280
+ if not title:
281
+ owner_title = re.search(
282
+ r'class="tgme_widget_message_owner_name".*?<span[^>]*>(.*?)</span>',
283
+ page_html,
284
+ re.IGNORECASE | re.DOTALL,
285
+ )
286
+ if owner_title:
287
+ title = owner_title.group(1).strip()
288
+ title = clean_telegram_title(title or "", channel_username)
289
+
290
+ views = 0
291
+ widget_views = re.search(
292
+ r'class="tgme_widget_message_views[^"]*">([\d\s\.,kKmM]+)<',
293
+ page_html,
294
+ re.IGNORECASE,
295
+ )
296
+ if widget_views:
297
+ views = _parse_views_number(widget_views.group(1))
298
+ else:
299
+ candidates = re.findall(
300
+ r"(\d[\d\s\.,]*)([kKmM]?)\s*(?:views|просмотр|просмотра|просмотров|переглядів|visualizações|visualizzazioni|ansichten|visninger|visitas)",
301
+ page_html,
302
+ re.IGNORECASE,
303
+ )
304
+ if candidates:
305
+ last_num, last_suffix = candidates[-1]
306
+ views = _parse_views_number(last_num + last_suffix)
307
+
308
+ return title, views
309
+
310
+
311
+ def process_max_link(slug: str, post_id: str, canonical_link: str) -> Tuple[str, Optional[int]]:
312
+ title, views = get_public_post(
313
+ _get_session(), slug, post_id, canonical_link, MAX_HTTP_TIMEOUT,
314
+ )
315
+ return clean_max_title(title, slug), views
316
+
317
+
318
+ def process_vk_link(owner_id: int, post_id: str, canonical_link: str) -> Tuple[str, int]:
319
+ """Одиночный запрос к VK (запасной путь; основной — батч process_vk_batch)."""
320
+ posts_param = f"{owner_id}_{post_id}"
321
+ api_url = "https://api.vk.com/method/wall.getById"
322
+
323
+ data = None
324
+ for attempt in range(1, VK_MAX_RETRIES + 1):
325
+ params = {"posts": posts_param, "v": VK_API_VERSION, "extended": 1}
326
+ if VK_ACCESS_TOKEN:
327
+ params["access_token"] = VK_ACCESS_TOKEN
328
+ try:
329
+ resp = requests.get(api_url, params=params, timeout=REQUEST_TIMEOUT)
330
+ except Exception as exc: # noqa: BLE001
331
+ return f"**Ошибка (VK)**: {exc} — {canonical_link}", 0
332
+ if resp.status_code != 200:
333
+ return f"**Ошибка (VK)**: HTTP {resp.status_code} — {canonical_link}", 0
334
+ data = resp.json()
335
+ if "error" in data:
336
+ error = data["error"]
337
+ error_msg = error.get("error_msg", "")
338
+ error_code = error.get("error_code")
339
+ if error_code == 6 or "too many requests per second" in error_msg.lower():
340
+ time.sleep(VK_RETRY_DELAY * attempt)
341
+ continue
342
+ return f"**Ошибка (VK)**: {error_msg or 'API error'} — {canonical_link}", 0
343
+ break
344
+ else:
345
+ return f"**Ошибка (VK)**: Too many requests per second — {canonical_link}", 0
346
+
347
+ response_data = (data or {}).get("response", {})
348
+ items = response_data.get("items", [])
349
+ if not items:
350
+ return f"**Ошибка (VK)**: пост не найден — {canonical_link}", 0
351
+
352
+ post = items[0]
353
+ views = post.get("views", {}).get("count", 0)
354
+ post_owner_id = post.get("owner_id", owner_id)
355
+
356
+ title = None
357
+ if post_owner_id < 0:
358
+ group_id = -post_owner_id
359
+ for group in response_data.get("groups", []):
360
+ if group.get("id") == group_id:
361
+ title = group.get("name")
362
+ break
363
+ else:
364
+ for profile in response_data.get("profiles", []):
365
+ if profile.get("id") == post_owner_id:
366
+ title = (profile.get("first_name", "") + " " + profile.get("last_name", "")).strip()
367
+ break
368
+ if not title:
369
+ title = "VK пост"
370
+ return title, views
371
+
372
+
373
+ def process_vk_batch(batch_items: List[Tuple[str, int, str]]) -> dict:
374
+ api_url = "https://api.vk.com/method/wall.getById"
375
+ posts = ",".join(f"{owner_id}_{post_id}" for _, owner_id, post_id in batch_items)
376
+
377
+ data = None
378
+ for attempt in range(1, VK_MAX_RETRIES + 1):
379
+ params = {"posts": posts, "v": VK_API_VERSION, "extended": 1}
380
+ if VK_ACCESS_TOKEN:
381
+ params["access_token"] = VK_ACCESS_TOKEN
382
+ try:
383
+ resp = requests.get(api_url, params=params, timeout=REQUEST_TIMEOUT)
384
+ except Exception as exc: # noqa: BLE001
385
+ return {c: (f"**Ошибка (VK)**: {exc} — {c}", 0) for c, _, _ in batch_items}
386
+
387
+ if resp.status_code != 200:
388
+ return {c: (f"**Ошибка (VK)**: HTTP {resp.status_code} — {c}", 0) for c, _, _ in batch_items}
389
+
390
+ data = resp.json()
391
+ if "error" in data:
392
+ error = data["error"]
393
+ error_msg = error.get("error_msg", "")
394
+ error_code = error.get("error_code")
395
+ if error_code == 6 or "too many requests per second" in error_msg.lower():
396
+ time.sleep(VK_RETRY_DELAY * attempt)
397
+ continue
398
+ return {c: (f"**Ошибка (VK)**: {error_msg or 'API error'} — {c}", 0) for c, _, _ in batch_items}
399
+ break
400
+ else:
401
+ return {c: (f"**Ошибка (VK)**: Too many requests per second — {c}", 0) for c, _, _ in batch_items}
402
+
403
+ response_data = (data or {}).get("response", {})
404
+ items = response_data.get("items", [])
405
+ groups = {group.get("id"): group for group in response_data.get("groups", [])}
406
+ profiles = {profile.get("id"): profile for profile in response_data.get("profiles", [])}
407
+ item_map = {
408
+ f"{item.get('owner_id')}_{item.get('id')}": item
409
+ for item in items
410
+ if item.get("owner_id") is not None and item.get("id") is not None
411
+ }
412
+
413
+ result: dict = {}
414
+ for canonical, owner_id, post_id in batch_items:
415
+ key = f"{owner_id}_{post_id}"
416
+ post = item_map.get(key)
417
+ if not post:
418
+ result[canonical] = (f"**Ошибка (VK)**: пост не найден — {canonical}", 0)
419
+ continue
420
+
421
+ views = post.get("views", {}).get("count", 0)
422
+ post_owner_id = post.get("owner_id", owner_id)
423
+ title = None
424
+ if post_owner_id < 0:
425
+ group = groups.get(-post_owner_id)
426
+ if group:
427
+ title = group.get("name")
428
+ else:
429
+ profile = profiles.get(post_owner_id)
430
+ if profile:
431
+ title = (profile.get("first_name", "") + " " + profile.get("last_name", "")).strip()
432
+
433
+ result[canonical] = (title or "VK пост", views)
434
+
435
+ return result
436
+
437
+
438
+ # ---------- Параллельное разрешение ссылок с бюджетом времени ----------
439
+
440
+ def _resolve_pool(tasks, worker, max_workers, deadline, timeout_label, defaults=None):
441
+ """tasks: список (canonical, args_tuple). worker(*args) -> (title, views).
442
+ Возвращаем dict canonical -> (title, views). Незавершённые к дедлайну
443
+ помечаем timeout_label (или оставляем значение из defaults, если оно есть)."""
444
+ results = dict(defaults) if defaults else {}
445
+ if not tasks:
446
+ return results
447
+
448
+ workers = max(1, min(max_workers, len(tasks)))
449
+ ex = ThreadPoolExecutor(max_workers=workers)
450
+ fut_map = {ex.submit(worker, *args): canonical for canonical, args in tasks}
451
+
452
+ timeout = max(0.0, deadline - time.monotonic())
453
+ done, not_done = wait(list(fut_map.keys()), timeout=timeout)
454
+
455
+ for fut in done:
456
+ canonical = fut_map[fut]
457
+ try:
458
+ results[canonical] = fut.result()
459
+ except Exception as exc: # noqa: BLE001
460
+ if canonical not in results:
461
+ results[canonical] = (f"Ошибка при обработке {canonical}: {exc}", 0)
462
+
463
+ for fut in not_done:
464
+ canonical = fut_map[fut]
465
+ fut.cancel()
466
+ if canonical not in results:
467
+ results[canonical] = (timeout_label, 0)
468
+
469
+ # Не блокируемся на «зависших» запросах — отдаём управление сразу.
470
+ try:
471
+ ex.shutdown(wait=False, cancel_futures=True)
472
+ except TypeError: # Python < 3.9
473
+ ex.shutdown(wait=False)
474
+
475
+ return results
476
+
477
+
478
+ def resolve_telegram(tg_tasks, deadline):
479
+ # tg_tasks: список (canonical, username, message_id)
480
+ tasks = [(c, (u, mid, c)) for (c, u, mid) in tg_tasks if u and mid]
481
+ return _resolve_pool(
482
+ tasks,
483
+ process_telegram_link,
484
+ TELEGRAM_CONCURRENCY,
485
+ deadline,
486
+ "Ошибка (TG): превышено время обработки",
487
+ )
488
+
489
+
490
+ def resolve_max(max_tasks, deadline):
491
+ # max_tasks: список (canonical, slug, post_id)
492
+ defaults = {c: (slug, None) for (c, slug, _post) in max_tasks}
493
+ tasks = [(c, (slug, post_id, c)) for (c, slug, post_id) in max_tasks]
494
+ fetched = _resolve_pool(
495
+ tasks, process_max_link, MAX_CONCURRENCY, deadline, None, defaults=defaults
496
+ )
497
+ return fetched
498
+
499
+
500
+ def resolve_vk(vk_tasks, deadline):
501
+ # vk_tasks: список (canonical, owner_id, post_id)
502
+ results = {}
503
+ valid = [(c, o, p) for (c, o, p) in vk_tasks if o is not None and p is not None]
504
+ batch_size = max(1, VK_BATCH_SIZE)
505
+ for start in range(0, len(valid), batch_size):
506
+ if time.monotonic() > deadline:
507
+ for c, _, _ in valid[start:]:
508
+ results[c] = ("**Ошибка (VK)**: превышено время обработки", 0)
509
+ break
510
+ chunk = valid[start:start + batch_size]
511
+ results.update(process_vk_batch(chunk))
512
+ return results
513
+
514
+
515
+ def normalize_links(links: Union[List[str], str]) -> List[str]:
516
+ if isinstance(links, str):
517
+ raw = links.splitlines()
518
+ else:
519
+ raw = []
520
+ for item in links:
521
+ raw.extend(str(item).splitlines())
522
+ return [line.strip() for line in raw if line.strip()]
523
+
524
+
525
+ def _escape(text: str) -> str:
526
+ return html_lib.escape(text, quote=True)
527
+
528
+
529
+ def _fmt_views(v) -> str:
530
+ return "—" if v is None else human_format_views(v)
531
+
532
+
533
+ def _render_groups(groups, has_views, default_title):
534
+ """Сортируем группы и рендерим в HTML (<ol>) и текст. Возвращаем (html_str, text_lines)."""
535
+ if has_views:
536
+ ordered = sorted(
537
+ groups.items(),
538
+ key=lambda kv: sum((v or 0) for _, v in kv[1]["items"]),
539
+ reverse=True,
540
+ )
541
+ else:
542
+ ordered = sorted(groups.items(), key=lambda kv: (kv[1]["title"] or "").lower())
543
+
544
+ html_lines = ["<ol>"]
545
+ text_lines: List[str] = []
546
+ for idx, (_, data) in enumerate(ordered, start=1):
547
+ title = data["title"] or default_title
548
+ items = data["items"]
549
+ first_link, _first_views = items[0]
550
+
551
+ line_html = f'<li><a href="{_escape(first_link)}">{_escape(title)}</a>'
552
+ if len(items) > 1:
553
+ for link2, _v in items[1:]:
554
+ line_html += f' + <a href="{_escape(link2)}">ещё</a>'
555
+
556
+ if has_views:
557
+ views_str = " + ".join(_fmt_views(v) for _, v in items)
558
+ line_html += f" — {views_str}</li>"
559
+ else:
560
+ line_html += " — </li>"
561
+ html_lines.append(line_html)
562
+
563
+ line_text = f"{idx}. {title} ({first_link})"
564
+ if len(items) > 1:
565
+ line_text += " + ещё: " + ", ".join(link2 for link2, _v in items[1:])
566
+ if has_views:
567
+ line_text += f" — {views_str}"
568
+ else:
569
+ line_text += " —"
570
+ text_lines.append(line_text)
571
+
572
+ html_lines.append("</ol>")
573
+ return "\n".join(html_lines), text_lines
574
+
575
+
576
+ def build_output(lines, deadline):
577
+ errors: List[str] = []
578
+
579
+ tg_lines = [ln for ln in lines if ln[0] == "telegram"]
580
+ vk_lines = [ln for ln in lines if ln[0] == "vk"]
581
+ max_lines = [ln for ln in lines if ln[0] == "max"]
582
+
583
+ tg_fetch = [(c, a, b) for _, c, a, b in tg_lines if a and b]
584
+ vk_fetch = [(c, a, b) for _, c, a, b in vk_lines if a is not None and b is not None]
585
+ max_fetch = [(c, a, b) for _, c, a, b in max_lines if a and b]
586
+
587
+ # Telegram, VK и MAX обрабатываем параллельно (раньше шли друг за другом).
588
+ tg_results, vk_results, max_results = {}, {}, {}
589
+ with ThreadPoolExecutor(max_workers=3) as top:
590
+ f_tg = top.submit(resolve_telegram, tg_fetch, deadline)
591
+ f_vk = top.submit(resolve_vk, vk_fetch, deadline)
592
+ f_max = top.submit(resolve_max, max_fetch, deadline)
593
+ tg_results = f_tg.result()
594
+ vk_results = f_vk.result()
595
+ max_results = f_max.result()
596
+
597
+ tg_groups: dict = {}
598
+ vk_groups: dict = {}
599
+ max_groups: dict = {}
600
+
601
+ for plat, canonical, a, b in lines:
602
+ if plat == "telegram":
603
+ username, mid = a, b
604
+ if not username or not mid:
605
+ key = canonical
606
+ invalid_title = "Ошибка (TG): ссылка Telegram недоступна для публичного парсинга"
607
+ if re.search(r"https?://(?:t(?:elegram)?\.me)/c/\d+/\d+", canonical, re.IGNORECASE):
608
+ invalid_title = "Ошибка (TG): ссылки вида t.me/c/... не поддерживаются"
609
+ tg_groups.setdefault(key, {"title": invalid_title, "items": []})
610
+ tg_groups[key]["items"].append((canonical, 0))
611
+ else:
612
+ title, views = tg_results.get(canonical, (username, 0))
613
+ key = username
614
+ if key not in tg_groups:
615
+ tg_groups[key] = {"title": title, "items": []}
616
+ if not tg_groups[key].get("title") or str(tg_groups[key]["title"]).startswith("Ошибка"):
617
+ tg_groups[key]["title"] = title
618
+ tg_groups[key]["items"].append((canonical, views))
619
+
620
+ elif plat == "vk":
621
+ owner_id, post_id = a, b
622
+ if owner_id is None or post_id is None:
623
+ key = canonical
624
+ vk_groups.setdefault(key, {"title": "VK пост", "items": []})
625
+ vk_groups[key]["items"].append((canonical, 0))
626
+ else:
627
+ title, views = vk_results.get(canonical, (f"**Ошибка (VK)**: нет данных — {canonical}", 0))
628
+ key = str(owner_id)
629
+ if key not in vk_groups:
630
+ vk_groups[key] = {"title": title, "items": []}
631
+ if not vk_groups[key].get("title") or str(vk_groups[key]["title"]).startswith("**Ошибка"):
632
+ vk_groups[key]["title"] = title
633
+ vk_groups[key]["items"].append((canonical, views))
634
+
635
+ elif plat == "max":
636
+ slug, post_id = a, b
637
+ if not slug or not post_id:
638
+ key = canonical
639
+ max_groups.setdefault(key, {"title": "MAX", "items": []})
640
+ max_groups[key]["items"].append((canonical, None))
641
+ else:
642
+ title, views = max_results.get(canonical, (slug, None))
643
+ key = slug
644
+ if key not in max_groups:
645
+ max_groups[key] = {"title": title, "items": []}
646
+ if not max_groups[key].get("title"):
647
+ max_groups[key]["title"] = title
648
+ max_groups[key]["items"].append((canonical, views))
649
+ if views is None:
650
+ errors.append(f"Не удалось получить просмотры MAX: {canonical}")
651
+
652
+ else:
653
+ errors.append(f"Неизвестная платформа: {canonical}")
654
+
655
+ tg_total_views = sum(v for g in tg_groups.values() for _, v in g["items"] if v)
656
+ vk_total_views = sum(v for g in vk_groups.values() for _, v in g["items"] if v)
657
+ max_count = sum(len(g["items"]) for g in max_groups.values())
658
+ max_total = sum(v for g in max_groups.values() for _, v in g["items"] if v is not None)
659
+ max_missing = sum(v is None for g in max_groups.values() for _, v in g["items"])
660
+
661
+ html_lines: List[str] = []
662
+ text_lines: List[str] = []
663
+
664
+ # --- Telegram ---
665
+ html_lines.append("<h2>Telegram</h2>")
666
+ html_lines.append(f'Суммарно посты собрали <b>{human_format_views(tg_total_views)}</b> просмотров.')
667
+ text_lines.append("Telegram")
668
+ text_lines.append(f"Суммарно посты собрали {human_format_views(tg_total_views)} просмотров.")
669
+ if tg_groups:
670
+ h, t = _render_groups(tg_groups, has_views=True, default_title="Telegram")
671
+ html_lines.append(h)
672
+ text_lines.extend(t)
673
+ else:
674
+ html_lines.append("<i>Нет ссылок на Telegram</i>")
675
+ text_lines.append("Нет ссылок на Telegram")
676
+ html_lines.append("<br/>")
677
+ text_lines.append("")
678
+
679
+ # --- VK ---
680
+ html_lines.append("<h2>ВКонтакте</h2>")
681
+ html_lines.append(f'Суммарно посты собрали <b>{human_format_views(vk_total_views)}</b> просмотров.')
682
+ text_lines.append("ВКонтакте")
683
+ text_lines.append(f"Суммарно посты собрали {human_format_views(vk_total_views)} просмотров.")
684
+ if vk_groups:
685
+ h, t = _render_groups(vk_groups, has_views=True, default_title="VK пост")
686
+ html_lines.append(h)
687
+ text_lines.extend(t)
688
+ else:
689
+ html_lines.append("<i>Нет ссылок на ВКонтакте</i>")
690
+ text_lines.append("Нет ссылок на ВКонтакте")
691
+ html_lines.append("<br/>")
692
+ text_lines.append("")
693
+
694
+ # --- MAX (секцию показываем только если есть ссылки MAX) ---
695
+ if max_groups:
696
+ html_lines.append("<h2>Макс</h2>")
697
+ text_lines.append("Макс")
698
+ if max_missing < max_count:
699
+ html_lines.append(f'Суммарно посты собрали <b>{human_format_views(max_total)}</b> просмотров.')
700
+ text_lines.append(f"Суммарно посты собрали {human_format_views(max_total)} просмотров.")
701
+ if max_missing:
702
+ note = f"Постов без данных о просмотрах: {max_missing}. Они не включены в сумму."
703
+ html_lines.append(f"<i>{note}</i>")
704
+ text_lines.append(note)
705
+ h, t = _render_groups(max_groups, has_views=True, default_title="MAX")
706
+ html_lines.append(h)
707
+ text_lines.extend(t)
708
+ html_lines.append("<br/>")
709
+ text_lines.append("")
710
+
711
+ if errors:
712
+ html_lines.append("<h2>Ошибки</h2>")
713
+ html_lines.append("<ul>")
714
+ for err in errors:
715
+ html_lines.append(f"<li>{_escape(err)}</li>")
716
+ text_lines.append(f"- {err}")
717
+ html_lines.append("</ul>")
718
+ html_lines.append("<br/>")
719
+ text_lines.append("")
720
+
721
+ return (
722
+ "\n".join(html_lines),
723
+ "\n".join(text_lines),
724
+ tg_total_views,
725
+ vk_total_views,
726
+ errors,
727
+ max_count,
728
+ )
729
+
730
+
731
+ @app.get("/health")
732
+ def health_check():
733
+ return {"status": "ok"}
734
+
735
+
736
+ @app.post("/parse", response_model=ParseResponse)
737
+ def parse_links(payload: ParseRequest):
738
+ deadline = time.monotonic() + TOTAL_TIME_BUDGET
739
+
740
+ raw_lines = normalize_links(payload.links)
741
+ if not raw_lines:
742
+ return ParseResponse(html="", text="", telegram_total=0, vk_total=0, errors=["Нет ссылок для обрабо��ки."])
743
+
744
+ truncated = False
745
+ if len(raw_lines) > MAX_INPUT_LINKS:
746
+ raw_lines = raw_lines[:MAX_INPUT_LINKS]
747
+ truncated = True
748
+
749
+ seen = set()
750
+ lines: List[Tuple[str, str, object, object]] = []
751
+ for link in raw_lines:
752
+ plat = detect_platform(link)
753
+ if plat == "telegram":
754
+ canonical, username, mid = canonicalize_tg_link(link)
755
+ if not canonical:
756
+ canonical, username, mid = link, None, None
757
+ if canonical in seen:
758
+ continue
759
+ seen.add(canonical)
760
+ lines.append(("telegram", canonical, username, mid))
761
+ elif plat == "vk":
762
+ canonical, owner_id, post_id = canonicalize_vk_link(link)
763
+ if not canonical:
764
+ canonical = link
765
+ if canonical in seen:
766
+ continue
767
+ seen.add(canonical)
768
+ lines.append(("vk", canonical, owner_id, post_id))
769
+ elif plat == "max":
770
+ canonical, slug, post_id = canonicalize_max_link(link)
771
+ if not canonical:
772
+ canonical, slug, post_id = link, None, None
773
+ if canonical in seen:
774
+ continue
775
+ seen.add(canonical)
776
+ lines.append(("max", canonical, slug, post_id))
777
+ else:
778
+ lines.append(("unknown", link, None, None))
779
+
780
+ html, text, tg_total, vk_total, errors, max_count = build_output(lines, deadline)
781
+
782
+ if truncated:
783
+ note = f"Обработаны первые {MAX_INPUT_LINKS} ссылок (список был длиннее)."
784
+ errors = errors + [note]
785
+ text = text + ("\n" if text else "") + "- " + note
786
+
787
+ return ParseResponse(
788
+ html=html,
789
+ text=text,
790
+ telegram_total=tg_total,
791
+ vk_total=vk_total,
792
+ errors=errors,
793
+ max_count=max_count,
794
+ )
max_api.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Read public MAX posts and their counters without joining channels."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import logging
7
+ import os
8
+ import re
9
+ from html.parser import HTMLParser
10
+ from pathlib import Path
11
+ from typing import Optional, Tuple
12
+ from urllib.parse import urlsplit
13
+
14
+ import json5
15
+ import requests
16
+
17
+
18
+ LOGGER = logging.getLogger(__name__)
19
+ API_BASE_URL = "https://platform-api2.max.ru"
20
+ CA_FILE = str(Path(__file__).parent / "certs" / "russian_trusted_root_ca.pem")
21
+
22
+
23
+ class PublicPostParser(HTMLParser):
24
+ def __init__(self):
25
+ super().__init__(convert_charrefs=True)
26
+ self.title = None
27
+ self.scripts = []
28
+ self._script = None
29
+
30
+ def handle_starttag(self, tag, attrs):
31
+ attrs = dict(attrs)
32
+ if tag == "meta" and attrs.get("property") == "og:title":
33
+ self.title = attrs.get("content")
34
+ if tag == "script":
35
+ self._script = []
36
+
37
+ def handle_data(self, data):
38
+ if self._script is not None:
39
+ self._script.append(data)
40
+
41
+ def handle_endtag(self, tag):
42
+ if tag == "script" and self._script is not None:
43
+ self.scripts.append("".join(self._script))
44
+ self._script = None
45
+
46
+
47
+ def parse_public_post(page_html: str, fallback: str) -> Tuple[str, Optional[int]]:
48
+ parser = PublicPostParser()
49
+ parser.feed(page_html)
50
+ title = parser.title or fallback
51
+
52
+ # Svelte serializes page data as JS literals, not strict JSON. JSON5 reads
53
+ # those literals without executing any code from the remote page.
54
+ for script in parser.scripts:
55
+ for match in re.finditer(r"\bdata\s*:\s*(\[)", script):
56
+ try:
57
+ nodes = json5.loads(
58
+ script, start=match.start(1), consume_trailing=False,
59
+ )
60
+ except ValueError:
61
+ continue
62
+ if not isinstance(nodes, list):
63
+ continue
64
+ for node in nodes:
65
+ if not isinstance(node, dict):
66
+ continue
67
+ data = node.get("data") or {}
68
+ if not isinstance(data, dict):
69
+ continue
70
+ link_info = data.get("linkInfo") or {}
71
+ if not isinstance(link_info, dict):
72
+ continue
73
+ channel = link_info.get("channel") or {}
74
+ if not isinstance(channel, dict):
75
+ continue
76
+ channel_id = channel.get("channelId")
77
+ if type(channel_id) is int and channel_id != 0:
78
+ return channel.get("title") or title, channel_id
79
+ return title, None
80
+
81
+
82
+ def message_id_from_link(channel_id: int, post_id: str) -> str:
83
+ if not re.fullmatch(r"[A-Za-z0-9_-]+", post_id):
84
+ raise ValueError("invalid_post_id")
85
+ message_bytes = base64.b64decode(
86
+ post_id + "=" * (-len(post_id) % 4), altchars=b"-_", validate=True,
87
+ )
88
+ if len(message_bytes) != 8:
89
+ raise ValueError("unsupported_post_id")
90
+ chat_bytes = (-abs(channel_id)).to_bytes(8, "big", signed=True)
91
+ return "mid." + chat_bytes.hex() + message_bytes.hex()
92
+
93
+
94
+ def get_public_post(session, slug, post_id, canonical_link, timeout):
95
+ title = slug
96
+ try:
97
+ page = session.get(canonical_link, timeout=timeout)
98
+ page.raise_for_status()
99
+ title, channel_id = parse_public_post(page.text, slug)
100
+ token = os.getenv("MAX_ACCESS_TOKEN", "").strip()
101
+ if not token:
102
+ LOGGER.warning("MAX views unavailable: token is not configured")
103
+ return title, None
104
+ if channel_id is None:
105
+ LOGGER.warning("MAX channel ID is absent for %s", canonical_link)
106
+ return title, None
107
+
108
+ mid = message_id_from_link(channel_id, post_id)
109
+ response = session.get(
110
+ f"{API_BASE_URL}/messages/{mid}",
111
+ headers={"Authorization": token},
112
+ timeout=timeout,
113
+ verify=CA_FILE,
114
+ allow_redirects=False,
115
+ )
116
+ if response.status_code != 200:
117
+ LOGGER.warning(
118
+ "MAX API returned HTTP %s for %s", response.status_code, canonical_link,
119
+ )
120
+ return title, None
121
+ message = response.json()
122
+ if not isinstance(message, dict):
123
+ return title, None
124
+ body = message.get("body") or {}
125
+ stat = message.get("stat") or {}
126
+ if not isinstance(body, dict) or not isinstance(stat, dict):
127
+ return title, None
128
+ if body.get("mid") != mid:
129
+ LOGGER.warning("MAX API returned a different message for %s", canonical_link)
130
+ return title, None
131
+ if message.get("url"):
132
+ returned_path = urlsplit(message["url"]).path.rstrip("/")
133
+ if returned_path != urlsplit(canonical_link).path.rstrip("/"):
134
+ LOGGER.warning("MAX API returned a different post URL for %s", canonical_link)
135
+ return title, None
136
+ views = stat.get("views")
137
+ if type(views) is int and views >= 0:
138
+ return title, views
139
+ LOGGER.warning("MAX API response has no views for %s", canonical_link)
140
+ except (requests.RequestException, ValueError, OverflowError) as exc:
141
+ # Log only the exception class: credential values must not enter logs.
142
+ LOGGER.warning("MAX lookup failed for %s (%s)", canonical_link, type(exc).__name__)
143
+ return title, None
reports.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Normalize public links and render structured parser results."""
2
+ import html
3
+ import re
4
+ from collections import OrderedDict
5
+ from urllib.parse import parse_qs, urlsplit
6
+
7
+
8
+ def normalize(raw):
9
+ try:
10
+ url = urlsplit(raw.strip())
11
+ if url.scheme not in ("https", "http") or url.username or url.password or url.port:
12
+ raise ValueError("Нужна обычная ссылка http/https")
13
+ host = url.hostname.lower().removeprefix("www.").removeprefix("m.")
14
+ path = url.path.rstrip("/")
15
+ if host in ("t.me", "telegram.me"):
16
+ if path.startswith("/c/"):
17
+ raise ValueError("Закрытая ссылка Telegram: нужен публичный адрес поста")
18
+ match = re.fullmatch(r"/(?:s/)?([A-Za-z][A-Za-z0-9_]{2,})/([1-9]\d*)", path)
19
+ if match:
20
+ slug, post = match.groups()
21
+ slug = slug.lower()
22
+ return {"url": f"https://t.me/{slug}/{post}", "platform": "telegram", "group": slug, "a": slug, "b": post}
23
+ elif host in ("vk.com", "vk.ru"):
24
+ wall = path
25
+ if "w" in parse_qs(url.query):
26
+ wall = "/" + parse_qs(url.query)["w"][0]
27
+ match = re.fullmatch(r"/wall(-?[1-9]\d*)_([1-9]\d*)", wall)
28
+ if match and "reply" not in parse_qs(url.query):
29
+ owner, post = match.groups()
30
+ return {"url": f"https://vk.com/wall{owner}_{post}", "platform": "vk", "group": owner, "a": int(owner), "b": post}
31
+ elif host == "max.ru":
32
+ match = re.fullmatch(r"/([A-Za-z0-9_]+)/([A-Za-z0-9_-]+)", path)
33
+ if match:
34
+ slug, post = match.groups()
35
+ return {"url": f"https://max.ru/{slug}/{post}", "platform": "max", "group": slug.lower(), "a": slug, "b": post}
36
+ raise ValueError("Площадка или формат ссылки пока не поддерживаются")
37
+ except (AttributeError, TypeError):
38
+ raise ValueError("Некорректная ссылка") from None
39
+
40
+
41
+ def human_views(value):
42
+ if value < 1000:
43
+ return str(value)
44
+ divisor, suffix = (1_000_000, "M") if value >= 1_000_000 else (1000, "K")
45
+ return f"{value / divisor:.1f}".rstrip("0").rstrip(".").replace(".", ",") + suffix
46
+
47
+
48
+ def clean_title(value):
49
+ # Strip legacy HTML emoji representations as well as unicode emoji clusters.
50
+ value = re.sub(r"<[^>]+>", "", html.unescape(str(value)))
51
+ value = re.sub(r"[\U0001F000-\U0001FAFF\u2600-\u27BF\uFE0E\uFE0F\u200D]", "", value)
52
+ value = re.sub(r"[#*0-9]\u20e3", "", value)
53
+ return re.sub(r"\s+", " ", value).strip()
54
+
55
+
56
+ def render(records):
57
+ chunks, plain, errors = [], [], []
58
+ for platform, label in (("telegram", "Telegram"), ("vk", "ВКонтакте"), ("max", "Макс")):
59
+ selected = [r for r in records if r["platform"] == platform]
60
+ if not selected:
61
+ continue
62
+ good = [r for r in selected if r.get("views") is not None and not r.get("error")]
63
+ groups = OrderedDict()
64
+ for record in good:
65
+ groups.setdefault(record["group"], []).append(record)
66
+ total = sum(r["views"] for r in good)
67
+ chunks.extend([f"<h2>{label}</h2>", f"<p>Суммарно посты собрали <b>{human_views(total)}</b> просмотров.</p>"])
68
+ plain.extend([label, f"Суммарно посты собрали {human_views(total)} просмотров."])
69
+ if len(good) != len(selected):
70
+ note = f"Неполный итог: получены просмотры {len(good)} из {len(selected)} постов."
71
+ chunks.append(f"<p><i>{note}</i></p>")
72
+ plain.append(note)
73
+ chunks.append("<ol>")
74
+ ordered = sorted(groups.values(), key=lambda items: sum(r["views"] for r in items), reverse=True)
75
+ for index, items in enumerate(ordered, 1):
76
+ title = clean_title(items[0]["title"]) or items[0]["group"]
77
+ links = [f'<a href="{html.escape(r["url"], quote=True)}">{html.escape(title if i == 0 else "ещё")}</a>' for i, r in enumerate(items)]
78
+ views = " + ".join(human_views(r["views"]) for r in items)
79
+ chunks.append(f'<li>{" + ".join(links)} — {views}</li>')
80
+ plain.append(f'{index}. {title}{" + ещё" * (len(items) - 1)} — {views}')
81
+ chunks.append("</ol>")
82
+ plain.append("")
83
+ for record in records:
84
+ if record.get("error"):
85
+ errors.append(f'{record["url"]}: {record["error"]}')
86
+ if errors:
87
+ chunks.append("<h2>Не удалось обработать</h2><ul>")
88
+ chunks.extend(f"<li>{html.escape(error)}</li>" for error in errors)
89
+ chunks.append("</ul>")
90
+ plain.extend(["Не удалось обработать", *errors])
91
+ return {"html": "\n".join(chunks), "text": "\n".join(plain), "errors": errors, "records": records}
requirements.txt CHANGED
@@ -1,4 +1,5 @@
1
- streamlit==1.36.0
2
- requests==2.31.0
3
- nest-asyncio==1.6.0
4
- pyaes==1.6.0
 
 
1
+ fastapi==0.162.0
2
+ uvicorn[standard]==0.54.0
3
+ requests==2.34.2
4
+ pydantic==2.14.1
5
+ json5==0.17.3
service.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Isolated report service. Never calls or modifies the production MiniApp Space."""
2
+ import hmac
3
+ import logging
4
+ import os
5
+ import threading
6
+ import time
7
+ import uuid
8
+ from concurrent.futures import ThreadPoolExecutor, as_completed
9
+ from typing import List
10
+
11
+ from fastapi import Depends, FastAPI, Header, HTTPException
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+ from pydantic import BaseModel, Field
14
+
15
+ import legacy_parser as engine
16
+ from reports import normalize, render
17
+
18
+ log = logging.getLogger(__name__)
19
+ API_KEY = os.getenv("BRIDGE_API_KEY", "")
20
+ app = FastAPI(title="ReportBridge", version="0.1.1")
21
+ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=False,
22
+ allow_methods=["GET", "POST", "DELETE"], allow_headers=["Content-Type", "X-Bridge-Key"])
23
+ jobs, lock = {}, threading.RLock()
24
+ queue = ThreadPoolExecutor(max_workers=1, thread_name_prefix="reports")
25
+
26
+
27
+ def authorize(x_bridge_key: str = Header(default="")):
28
+ if not API_KEY:
29
+ raise HTTPException(503, "В бэкенде расширения не настроен BRIDGE_API_KEY")
30
+ if not hmac.compare_digest(x_bridge_key.encode(), API_KEY.encode()):
31
+ raise HTTPException(401, "Неверный ключ ReportBridge")
32
+
33
+
34
+ class JobRequest(BaseModel):
35
+ request_id: str = Field(min_length=16, max_length=64, pattern=r"^[a-zA-Z0-9-]+$")
36
+ links: List[str] = Field(min_length=1, max_length=500)
37
+
38
+
39
+ def fetch_one(record):
40
+ result = dict(record)
41
+ try:
42
+ if record["platform"] == "telegram":
43
+ title, views = engine.process_telegram_link(record["a"], record["b"], record["url"])
44
+ else:
45
+ title, views = engine.process_max_link(record["a"], record["b"], record["url"])
46
+ if "ошибка" in title.lower() or views is None:
47
+ raise ValueError("Источник не вернул просмотры. Попробуйте повторить позже.")
48
+ # The legacy HTML parser returns 0 when it cannot find a counter. Avoid a false zero in new reports.
49
+ if record["platform"] == "telegram" and views == 0:
50
+ raise ValueError("Не найден счётчик просмотров Telegram; это не подтверждённый ноль")
51
+ result.update(title=title, views=views)
52
+ except Exception:
53
+ result.update(title=record["group"], views=None, error="Не удалось получить просмотры публичного поста")
54
+ return result
55
+
56
+
57
+ def run_job(job_id, records):
58
+ result_map = {}
59
+ with lock:
60
+ jobs[job_id]["status"] = "running"
61
+
62
+ def store_result(record):
63
+ with lock:
64
+ result_map[record["url"]] = record
65
+ jobs[job_id]["completed"] = len(result_map)
66
+
67
+ try:
68
+ with ThreadPoolExecutor(max_workers=3) as pool:
69
+ futures = {pool.submit(fetch_one, r): r for r in records if r["platform"] != "vk"}
70
+ vk = [r for r in records if r["platform"] == "vk"]
71
+ for start in range(0, len(vk), 100):
72
+ batch = vk[start:start + 100]
73
+ if not engine.VK_ACCESS_TOKEN:
74
+ data = {}
75
+ else:
76
+ data = engine.process_vk_batch([(r["url"], r["a"], r["b"]) for r in batch])
77
+ for record in batch:
78
+ title, views = data.get(record["url"], ("Ошибка: VK_ACCESS_TOKEN не настроен", None))
79
+ error = ("Не настроен VK_ACCESS_TOKEN в Secrets PR_Tool" if not engine.VK_ACCESS_TOKEN
80
+ else "Не удалось получить просмотры VK") if "ошибка" in title.lower() else None
81
+ store_result(dict(record, title=title if not error else record["group"], views=None if error else views, error=error))
82
+ if start + 100 < len(vk):
83
+ time.sleep(0.4)
84
+ for future in as_completed(futures):
85
+ store_result(future.result())
86
+ output = render([result_map[r["url"]] for r in records])
87
+ with lock:
88
+ jobs[job_id].update(status="complete", result=output)
89
+ except Exception:
90
+ log.exception("ReportBridge job failed")
91
+ with lock:
92
+ jobs[job_id].update(status="failed", error="Ошибка нового бэкенда. Рабочий MiniApp не затронут.")
93
+
94
+
95
+ @app.get("/")
96
+ def root():
97
+ return {"service": "ReportBridge", "production_miniapp": "not used", "health": "/health"}
98
+
99
+
100
+ @app.get("/health")
101
+ def health():
102
+ return {"status": "ok", "service": "ReportBridge", "configured": {
103
+ "bridge": bool(API_KEY), "vk": bool(engine.VK_ACCESS_TOKEN), "max": bool(os.getenv("MAX_ACCESS_TOKEN"))}}
104
+
105
+
106
+ @app.post("/jobs", dependencies=[Depends(authorize)])
107
+ def create_job(payload: JobRequest):
108
+ records, seen = [], set()
109
+ for raw in payload.links:
110
+ if len(raw) > 2048:
111
+ raise HTTPException(422, "Слишком длинная ссылка")
112
+ try:
113
+ record = normalize(raw)
114
+ except ValueError as exc:
115
+ raise HTTPException(422, str(exc)) from None
116
+ if record["url"] not in seen:
117
+ seen.add(record["url"])
118
+ records.append(record)
119
+ canonical = [r["url"] for r in records]
120
+ with lock:
121
+ now = time.time()
122
+ expired = [k for k, j in jobs.items() if j["status"] not in ("queued", "running") and now - j["created"] > 3600]
123
+ for key in expired:
124
+ del jobs[key]
125
+ for job in jobs.values():
126
+ if job["request_id"] == payload.request_id:
127
+ if job["links"] != canonical:
128
+ raise HTTPException(409, "Этот идентификатор уже используется для другого списка")
129
+ return {"id": job["id"], "status": job["status"]}
130
+ if sum(j["status"] in ("queued", "running") for j in jobs.values()) >= 5 or len(jobs) >= 100:
131
+ raise HTTPException(429, "Очередь нового Space заполнена. Попробуйте позже.")
132
+ job_id = str(uuid.uuid4())
133
+ jobs[job_id] = dict(id=job_id, request_id=payload.request_id, links=canonical,
134
+ status="queued", completed=0, total=len(records), created=now)
135
+ queue.submit(run_job, job_id, records)
136
+ return {"id": job_id, "status": "queued"}
137
+
138
+
139
+ @app.get("/jobs/{job_id}", dependencies=[Depends(authorize)])
140
+ def get_job(job_id: str):
141
+ with lock:
142
+ if job_id not in jobs:
143
+ raise HTTPException(404, "Задание не найдено. Возможно, новый Space перезапустился; запустите обработку заново.")
144
+ return {k: v for k, v in jobs[job_id].items() if k not in ("links", "request_id")}
src/streamlit_app.py DELETED
@@ -1,593 +0,0 @@
1
- import streamlit as st
2
- import asyncio
3
- import nest_asyncio
4
- import re
5
- import requests
6
- import urllib.parse
7
- import hashlib
8
- import base64
9
- import secrets
10
- import streamlit.components.v1 as components
11
-
12
- # Применяем nest_asyncio для работы с asyncio в Streamlit
13
- nest_asyncio.apply()
14
-
15
- # ----------- Настройки Telegram -----------
16
- TELEGRAM_API_ID = 23646321
17
- TELEGRAM_API_HASH = "102edf31d404cb72d17ee4831d4e8c7b"
18
- TELEGRAM_STRING_SESSION = "1ApWapzMBu3AR6vZZynrQwc59LD9XDRzbMtXVii-kfgNPeguoA2KOwyFqhsi1X_zDAsBh1DGvmVv46DmtZqk0KCd0jg9XXeS8T05E51D3Al5Ps8NNOF7fMWnpli2ROM2lou-t_8KpYbPoYnNXBAO2SzYkEhvyqQPSD7xGTWVkCLAVWm_s8Lqi-SCpXeOi341K5CR9nUCzaDkBmD3eG8SfqUp6GVO2QksGDZt3YMftWpXDwxsjuFPeeExoIawVD0Kxpjp20V3nS26SlR1-tm9lvOs9ATI_6QWPcCScT3UR5FikvrudyHNE55ZLpwbt3TRGuDMHv936DBtdHwGIQaNWSUf_5lEhIb0="
19
-
20
- # ----------- Настройки ВКонтакте -----------
21
- VK_CLIENT_ID = 53202706
22
- VK_CLIENT_SECRET = "piaMXHEDj0afa4QObJCD"
23
- VK_API_VERSION = "5.131"
24
- REDIRECT_URI = "https://huggingface.co/spaces/PRAnalytics/PR_Tool"
25
-
26
- # Инициализация токенов в session_state
27
- if "VK_ACCESS_TOKEN" not in st.session_state:
28
- st.session_state["VK_ACCESS_TOKEN"] = ""
29
- if "VK_REFRESH_TOKEN" not in st.session_state:
30
- st.session_state["VK_REFRESH_TOKEN"] = ""
31
- if "VK_DEVICE_ID" not in st.session_state:
32
- st.session_state["VK_DEVICE_ID"] = ""
33
-
34
- # Функции для генерации code_verifier и code_challenge
35
- def generate_code_verifier(length=128):
36
- return secrets.token_urlsafe(length)[:length]
37
-
38
- def generate_code_challenge(code_verifier):
39
- hashed = hashlib.sha256(code_verifier.encode('utf-8')).digest()
40
- return base64.urlsafe_b64encode(hashed).decode('utf-8').rstrip('=')
41
-
42
- # Функция для авторизации через VK ID
43
- def vk_auth():
44
- if 'code_verifier' not in st.session_state:
45
- st.session_state.code_verifier = generate_code_verifier()
46
-
47
- # Проверяем наличие токена сразу после перезагрузки
48
- if st.session_state.get("VK_ACCESS_TOKEN"):
49
- return # Прерываем авторизацию, если токен уже есть
50
-
51
- # Шаг 2: Извлечение параметров из URL
52
- query_params = st.experimental_get_query_params()
53
- user_code = query_params.get("code", [None])[0]
54
- user_device_id = query_params.get("device_id", [None])[0]
55
- user_state = query_params.get("state", [None])[0]
56
-
57
- if user_code and user_state:
58
- with st.expander("Технические подробности", expanded=False):
59
- st.write("Автоматически извлечены параметры из URL:")
60
- st.write(f"code: {user_code}")
61
- st.write(f"device_id: {user_device_id}")
62
- st.write(f"state: {user_state}")
63
-
64
- # Используем code_verifier из параметра state
65
- code_verifier = user_state
66
-
67
- url = "https://id.vk.com/oauth2/auth"
68
- data = {
69
- "grant_type": "authorization_code",
70
- "client_id": VK_CLIENT_ID,
71
- "client_secret": VK_CLIENT_SECRET,
72
- "redirect_uri": REDIRECT_URI,
73
- "code": user_code,
74
- "code_verifier": code_verifier
75
- }
76
- if user_device_id:
77
- data["device_id"] = user_device_id
78
-
79
- st.write("Отправляем запрос на:", url)
80
- st.write("Параметры запроса:", data)
81
-
82
- # Отправка запроса на обмен кода на токены
83
- resp = requests.post(url, data=data)
84
- st.write("Status code:", resp.status_code)
85
- st.write("Response text:", resp.text)
86
-
87
- try:
88
- tokens = resp.json()
89
- st.write("Ответ (JSON):")
90
- st.json(tokens)
91
-
92
- # Сохраняем токены
93
- st.session_state["VK_ACCESS_TOKEN"] = tokens["access_token"]
94
- st.session_state["VK_REFRESH_TOKEN"] = tokens["refresh_token"]
95
- st.session_state["VK_DEVICE_ID"] = user_device_id
96
- st.success("Токены успешно получены и сохранены!")
97
-
98
- # Закрываем технические подробности
99
- st.experimental_set_query_params()
100
-
101
- # Немедленный переход к парсеру без перезагрузки
102
- st.rerun()
103
- except Exception as e:
104
- st.error(f"Ошибка: {e}")
105
- return # Важно: прерываем выполнение после обработки кода
106
-
107
- # Если дошли сюда, показываем кнопку авторизации
108
- st.markdown("### Сначала пройдём авторизацию")
109
- code_challenge = generate_code_challenge(st.session_state.code_verifier)
110
- auth_params = {
111
- "client_id": VK_CLIENT_ID,
112
- "redirect_uri": REDIRECT_URI,
113
- "response_type": "code",
114
- "scope": "wall",
115
- "state": st.session_state.code_verifier,
116
- "code_challenge": code_challenge,
117
- "code_challenge_method": "S256",
118
- "response_mode": "query"
119
- }
120
- auth_url = "https://id.vk.com/authorize?" + urllib.parse.urlencode(auth_params, safe="-._~")
121
- st.markdown(
122
- f"""
123
- <a href="{auth_url}" style="
124
- display: inline-block;
125
- background-color: #4a76a8;
126
- color: white;
127
- text-decoration: none;
128
- padding: 10px 20px;
129
- border-radius: 4px;
130
- font-family: -apple-system,BlinkMacSystemFont,Roboto,Helvetica Neue,sans-serif;
131
- font-weight: 500;
132
- font-size: 16px;
133
- margin: 10px 0;
134
- text-align: center;
135
- ">
136
- Авторизоваться через VK ID
137
- </a>
138
- """,
139
- unsafe_allow_html=True
140
- )
141
-
142
- # Функция для обновления токена через refresh_token
143
- def refresh_vk_token() -> bool:
144
- st.write("Пытаемся обновить токен...")
145
-
146
- current_refresh = st.session_state["VK_REFRESH_TOKEN"]
147
- device_id = st.session_state.get("VK_DEVICE_ID", "")
148
-
149
- if not current_refresh or not VK_CLIENT_ID or not VK_CLIENT_SECRET or not device_id:
150
- st.error("Невозможно обновить токен: нет refresh_token, client_id, client_secret или device_id.")
151
- return False
152
-
153
- url = "https://id.vk.com/oauth2/auth"
154
- data = {
155
- "grant_type": "refresh_token",
156
- "client_id": VK_CLIENT_ID,
157
- "client_secret": VK_CLIENT_SECRET,
158
- "refresh_token": current_refresh,
159
- "device_id": device_id
160
- }
161
-
162
- headers = {"Content-Type": "application/x-www-form-urlencoded"}
163
-
164
- resp = requests.post(url, data=data, headers=headers)
165
- with st.expander("Посмотреть логи обновления токена"):
166
- st.write(f"Статус ответа: {resp.status_code}")
167
- st.write(f"Текст ответа: {resp.text}")
168
-
169
- if resp.status_code != 200:
170
- st.error(f"Ошибка обновления токена (HTTP {resp.status_code}): {resp.text}")
171
- return False
172
-
173
- try:
174
- result = resp.json()
175
- with st.expander("Посмотреть JSON-ответ обновления токена"):
176
- st.write(f"Ответ от VK API: {result}")
177
- except Exception as e:
178
- st.error(f"Не удалось распарсить ответ при обновлении токена: {e}")
179
- return False
180
-
181
- if "access_token" not in result:
182
- err_desc = result.get("error_description", result)
183
- st.error(f"Не удалось получить новый access_token: {err_desc}")
184
- return False
185
-
186
- # Сохраняем новые значения
187
- new_access = result["access_token"]
188
- st.session_state["VK_ACCESS_TOKEN"] = new_access
189
-
190
- if "refresh_token" in result:
191
- new_refresh = result["refresh_token"]
192
- st.session_state["VK_REFRESH_TOKEN"] = new_refresh
193
- st.write(f"Новый refresh_token: {new_refresh}")
194
- else:
195
- st.warning("Новый refresh_token не был получен. Убедитесь, что это ожидаемо.")
196
-
197
- st.success("VK-токен обновлён успешно!")
198
- st.write(f"Новый access_token: {new_access}")
199
-
200
- return True
201
-
202
- # Функция для форматирования числа просмотров
203
- def human_format_views(num: int) -> str:
204
- if num >= 1_000_000:
205
- formatted = f"{num / 1_000_000:.1f}M"
206
- else:
207
- formatted = f"{num / 1000:.1f}K"
208
- return formatted.replace('.', ',')
209
-
210
- # Функция для обработки ссылок Telegram
211
- def process_telegram_link(channel_username: str, message_id: str, canonical_link: str) -> tuple[str, int]:
212
- import re, requests
213
-
214
- # Набор альтернативных адресов (если t.me не резолвится)
215
- urls = [
216
- f"https://t.me/{channel_username}/{message_id}?embed=1",
217
- f"https://t.me/s/{channel_username}/{message_id}",
218
- f"https://telegram.me/{channel_username}/{message_id}?embed=1",
219
- # зеркала через r.jina.ai (обходит проблемы DNS/блокировок)
220
- f"https://r.jina.ai/http://t.me/{channel_username}/{message_id}?embed=1",
221
- f"https://r.jina.ai/http://t.me/s/{channel_username}/{message_id}",
222
- f"https://r.jina.ai/http://telegram.me/{channel_username}/{message_id}?embed=1",
223
- ]
224
- headers = {"User-Agent": "Mozilla/5.0"}
225
-
226
- def _parse_views_number(s: str) -> int:
227
- if not s:
228
- return 0
229
- s = s.strip().replace(" ", "").replace(",", ".")
230
- m2 = re.match(r'([\d\.]+)\s*([kKmM]?)', s)
231
- if not m2:
232
- return 0
233
- val = float(m2.group(1))
234
- suf = m2.group(2).lower()
235
- if suf == "k":
236
- val *= 1_000
237
- elif suf == "m":
238
- val *= 1_000_000
239
- return int(val)
240
-
241
- def _fetch_first_ok(url_list):
242
- last_err = None
243
- for u in url_list:
244
- try:
245
- resp = requests.get(u, headers=headers, timeout=15)
246
- if resp.status_code == 200 and resp.text:
247
- return resp.text
248
- except Exception as e:
249
- last_err = e
250
- raise last_err or Exception("Не удалось получить страницу Telegram")
251
-
252
- try:
253
- html = _fetch_first_ok(urls)
254
-
255
- # Title
256
- title = None
257
- m_title_meta = re.search(r'<meta[^>]*property=["\']og:title["\'][^>]*content=["\']([^"\']+)["\']', html, re.IGNORECASE)
258
- if m_title_meta:
259
- title = m_title_meta.group(1).strip()
260
- if not title:
261
- m_title = re.search(
262
- r'class="tgme_widget_message_owner_name".*?<span[^>]*>(.*?)</span>',
263
- html, re.IGNORECASE | re.DOTALL
264
- )
265
- if m_title:
266
- title = m_title.group(1).strip()
267
- if not title:
268
- title = channel_username
269
-
270
- # Views
271
- views = 0
272
- m_views = re.search(
273
- r'class="tgme_widget_message_views[^"]*">([\d\s\.,kKmM]+)<',
274
- html, re.IGNORECASE
275
- )
276
- if m_views:
277
- views = _parse_views_number(m_views.group(1))
278
- else:
279
- candidates = re.findall(
280
- r'(\d[\d\s\.,]*)([kKmM]?)\s*(?:views|просмотр|просмотра|просмотров|переглядів|visualizações|visualizzazioni|ansichten|visninger|visitas)?',
281
- html, re.IGNORECASE
282
- )
283
- if candidates:
284
- last_num, last_suf = candidates[-1]
285
- views = _parse_views_number(last_num + last_suf)
286
-
287
- return title, views
288
-
289
- except Exception as e:
290
- # Возвращаем «технический» title и 0, чтобы не ломать общий вывод
291
- return f"**Ошибка (TG)** при обработке [{canonical_link}]({canonical_link}): {e}", 0
292
-
293
- def process_vk_link(owner_id: int, post_id: str, canonical_link: str) -> tuple[str, int]:
294
- posts_param = f"{owner_id}_{post_id}"
295
- api_url = "https://api.vk.com/method/wall.getById"
296
-
297
- def do_request(token):
298
- params = {
299
- "posts": posts_param,
300
- "access_token": st.session_state["VK_ACCESS_TOKEN"],
301
- "v": VK_API_VERSION,
302
- "extended": 1
303
- }
304
- return requests.get(api_url, params=params)
305
-
306
- resp = do_request(st.session_state["VK_ACCESS_TOKEN"])
307
- if resp.status_code != 200:
308
- return f"**Ошибка (VK)**: HTTP {resp.status_code} — {canonical_link}", 0
309
-
310
- data = resp.json()
311
- if "error" in data:
312
- error_msg = data["error"].get("error_msg", "")
313
- if "access_token has expired" in error_msg.lower():
314
- if refresh_vk_token():
315
- resp = do_request(st.session_state["VK_ACCESS_TOKEN"])
316
- data = resp.json()
317
- if "error" in data:
318
- err_msg2 = data["error"].get("error_msg", "")
319
- return f"**Ошибка (VK)**: {err_msg2} — {canonical_link}", 0
320
- else:
321
- return f"**Ошибка (VK)**: не удалось обновить токен — {canonical_link}", 0
322
- else:
323
- return f"**Ошибка (VK)**: {error_msg} — {canonical_link}", 0
324
-
325
- response_data = data.get("response", {})
326
- items = response_data.get("items", [])
327
- if not items:
328
- return f"**Ошибка (VK)**: пост не найден — {canonical_link}", 0
329
-
330
- post = items[0]
331
- views = post.get("views", {}).get("count", 0)
332
- post_owner_id = post.get("owner_id", owner_id)
333
-
334
- title = None
335
- if post_owner_id < 0:
336
- group_id = -post_owner_id
337
- for g in response_data.get("groups", []):
338
- if g["id"] == group_id:
339
- title = g["name"]
340
- break
341
- else:
342
- user_id = post_owner_id
343
- for p in response_data.get("profiles", []):
344
- if p["id"] == user_id:
345
- first_name = p.get("first_name", "")
346
- last_name = p.get("last_name", "")
347
- title = (first_name + " " + last_name).strip()
348
- break
349
- if not title:
350
- title = "VK пост"
351
-
352
- return title, views
353
-
354
- # Функция для определения платформы
355
- def detect_platform(link: str) -> str:
356
- link = link.strip().lower()
357
- if "t.me/" in link or "telegram.me/" in link:
358
- return "telegram"
359
- elif "vk.com/wall" in link:
360
- return "vk"
361
- return ""
362
-
363
- def canonicalize_tg_link(link: str):
364
- """
365
- Возвращает (canonical_link, channel_username, message_id) или (None, None, None)
366
- Нормализуем и t.me, и telegram.me, и t.me/s/...
367
- """
368
- link = link.strip()
369
- m = re.match(r'https?://(?:t(?:elegram)?\.me)/(?:s/)?([^/]+)/(\d+)', link, re.IGNORECASE)
370
- if not m:
371
- return None, None, None
372
- username = m.group(1)
373
- message_id = m.group(2)
374
- canonical = f"https://t.me/{username}/{message_id}"
375
- return canonical, username, message_id
376
-
377
- def canonicalize_vk_link(link: str):
378
- """
379
- Возвращает (canonical_link, owner_id(int), post_id(str)) или (None, None, None)
380
- """
381
- link = link.strip()
382
- m = re.search(r'vk\.com/wall(-?\d+)_(\d+)', link)
383
- if not m:
384
- return None, None, None
385
- owner_id = int(m.group(1))
386
- post_id = m.group(2)
387
- canonical = f"https://vk.com/wall{owner_id}_{post_id}"
388
- return canonical, owner_id, post_id
389
-
390
- # Функция для отображения интерфейса парсера
391
- def show_parser_interface():
392
- st.write("Вставьте ссылки (каждая на новой строке) — как Telegram, так и ВКонтакте. Пример:")
393
- st.code("""
394
- https://t.me/meduzalive/130606
395
- https://t.me/meduzalive/130604
396
- https://vk.com/wall-12256840_3220312
397
- https://vk.com/wall-237426_1643234
398
- """)
399
-
400
- links_input = st.text_area("Ссылки:", height=200)
401
-
402
- if st.button("Обработать"):
403
- # Сырые ссылки
404
- raw_lines = [link.strip() for link in links_input.strip().split('\n') if link.strip()]
405
- if not raw_lines:
406
- st.warning("Нет ссылок для обработки.")
407
- return
408
-
409
- # Дедупликация по каноническим ссылкам
410
- seen = set()
411
- lines = []
412
- for link in raw_lines:
413
- plat = detect_platform(link)
414
- if plat == "telegram":
415
- canonical, username, mid = canonicalize_tg_link(link)
416
- if not canonical:
417
- # оставляем как есть, чтобы позже показать ошибку
418
- canonical = link
419
- username = None
420
- mid = None
421
- if canonical in seen:
422
- continue # дубликат — пропускаем
423
- seen.add(canonical)
424
- lines.append(("telegram", canonical, username, mid))
425
- elif plat == "vk":
426
- canonical, owner_id, post_id = canonicalize_vk_link(link)
427
- if not canonical:
428
- canonical = link
429
- if canonical in seen:
430
- continue
431
- seen.add(canonical)
432
- lines.append(("vk", canonical, owner_id, post_id))
433
- else:
434
- lines.append(("unknown", link, None, None))
435
-
436
- total_links = len(lines)
437
- progress_bar = st.progress(0)
438
- status_text = st.empty()
439
-
440
- # Группы: {group_key: {"title": str, "items": [(canonical_link, views), ...]}}
441
- tg_groups = {}
442
- vk_groups = {}
443
-
444
- for i, (plat, canonical, a, b) in enumerate(lines):
445
- try:
446
- status_text.text(f"Обработка {i+1}/{total_links}: {canonical}")
447
-
448
- if plat == "telegram":
449
- username, mid = a, b
450
- if not username or not mid:
451
- # неправильная ссылка — формируем "ошибочную" запись как отдельную группу по канону
452
- key = canonical
453
- tg_groups.setdefault(key, {"title": "Telegram", "items": []})
454
- tg_groups[key]["items"].append((canonical, 0))
455
- else:
456
- title, views = process_telegram_link(username, mid, canonical)
457
- key = username # группируем по username канала
458
- if key not in tg_groups:
459
- tg_groups[key] = {"title": title, "items": []}
460
- # title обновим только если он выглядит «нормально»
461
- if not tg_groups[key].get("title") or tg_groups[key]["title"].startswith("**Ошибка"):
462
- tg_groups[key]["title"] = title
463
- tg_groups[key]["items"].append((canonical, views))
464
-
465
- elif plat == "vk":
466
- owner_id, post_id = a, b
467
- if owner_id is None or post_id is None:
468
- key = canonical
469
- vk_groups.setdefault(key, {"title": "VK пост", "items": []})
470
- vk_groups[key]["items"].append((canonical, 0))
471
- else:
472
- title, views = process_vk_link(owner_id, post_id, canonical)
473
- key = str(owner_id) # группируем по owner_id
474
- if key not in vk_groups:
475
- vk_groups[key] = {"title": title, "items": []}
476
- if not vk_groups[key].get("title") or vk_groups[key]["title"].startswith("**Ошибка"):
477
- vk_groups[key]["title"] = title
478
- vk_groups[key]["items"].append((canonical, views))
479
-
480
- else:
481
- st.error(f"Неизвестная платформа: {canonical}")
482
-
483
- progress_bar.progress((i + 1) / total_links)
484
-
485
- except Exception as e:
486
- st.error(f"Ошибка при обработке {canonical}: {str(e)}")
487
-
488
- progress_bar.empty()
489
- status_text.empty()
490
-
491
- # Подсчитываем суммарные просмотры по платформам
492
- tg_total_views = sum(v for g in tg_groups.values() for _, v in g["items"])
493
- vk_total_views = sum(v for g in vk_groups.values() for _, v in g["items"])
494
-
495
- # Сортируем группы по суммарным просмотрам по убыванию
496
- tg_sorted = sorted(
497
- tg_groups.items(),
498
- key=lambda kv: sum(v for _, v in kv[1]["items"]),
499
- reverse=True
500
- )
501
- vk_sorted = sorted(
502
- vk_groups.items(),
503
- key=lambda kv: sum(v for _, v in kv[1]["items"]),
504
- reverse=True
505
- )
506
-
507
- md_lines = []
508
-
509
- # --- Telegram ---
510
- md_lines.append("## Telegram")
511
- md_lines.append(f"**Суммарный охват постов — {human_format_views(tg_total_views)} просмотров**")
512
- if tg_sorted:
513
- for idx, (_, data) in enumerate(tg_sorted, start=1):
514
- title = data["title"] or "Telegram"
515
- items = data["items"]
516
- # первая ссылка — та, что встретилась первой
517
- first_link, first_views = items[0]
518
- line = f"{idx}. [{title}]({first_link})"
519
- # остальные «ещё»
520
- if len(items) > 1:
521
- for link2, _v in items[1:]:
522
- line += f" + [ещё]({link2})"
523
- # просмотры как «A + B + C»
524
- views_str = " + ".join(human_format_views(v) for _, v in items)
525
- line += f" — {views_str}"
526
- md_lines.append(line)
527
- else:
528
- md_lines.append("_Нет ссылок на Telegram_")
529
- md_lines.append("")
530
-
531
- # --- ВКонтакте ---
532
- md_lines.append("## ВКонтакте")
533
- md_lines.append(f"**Суммарный охват постов — {human_format_views(vk_total_views)} просмотров**")
534
- if vk_sorted:
535
- for idx, (_, data) in enumerate(vk_sorted, start=1):
536
- title = data["title"] or "VK пост"
537
- items = data["items"]
538
- first_link, first_views = items[0]
539
- line = f"{idx}. [{title}]({first_link})"
540
- if len(items) > 1:
541
- for link2, _v in items[1:]:
542
- line += f" + [ещё]({link2})"
543
- views_str = " + ".join(human_format_views(v) for _, v in items)
544
- line += f" — {views_str}"
545
- md_lines.append(line)
546
- else:
547
- md_lines.append("_Нет ссылок на ВКонтакте_")
548
- md_lines.append("")
549
-
550
- # Вывод
551
- st.markdown("### Результат:")
552
- for line in md_lines:
553
- st.markdown(line, unsafe_allow_html=True)
554
-
555
- markdown_result = "\n".join(md_lines)
556
-
557
- # Кнопка копирования
558
- copy_button_html = f"""
559
- <html>
560
- <head>
561
- <script>
562
- function copyToClipboard() {{
563
- const btn = document.getElementById("copy-btn");
564
- const text = `{markdown_result}`;
565
- navigator.clipboard.writeText(text).then(function() {{
566
- btn.innerText = "Скопировано успешно";
567
- }}, function(err) {{
568
- btn.innerText = "Ошибка при копировании";
569
- }});
570
- }}
571
- </script>
572
- </head>
573
- <body>
574
- <button id="copy-btn" onclick="copyToClipboard()" style="padding: 8px 16px; font-size: 16px;">Скопировать код</button>
575
- </body>
576
- </html>
577
- """
578
- components.html(copy_button_html, height=80)
579
-
580
- # Интерфейс Streamlit
581
- def main():
582
- st.title("PR Tool made by @Dina")
583
-
584
- # Первым делом проверяем токен
585
- if not st.session_state.get("VK_ACCESS_TOKEN"):
586
- vk_auth()
587
- else:
588
- # Показываем интерфейс парсера
589
- show_parser_interface()
590
-
591
- # Запуск приложения
592
- if __name__ == "__main__":
593
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_max_api.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import time
3
+ import unittest
4
+ from unittest.mock import Mock, patch
5
+
6
+ import legacy_parser as app
7
+ import max_api
8
+
9
+
10
+ URL = "https://max.ru/vkontakte/AaE-viFyB3E"
11
+ MID = "mid.ffffc217ccd5157d01a13ebe21720771"
12
+ PAGE = '''<meta content="Public &amp; news" property="og:title">
13
+ <script>app.start(element, {
14
+ data: [null,{type:"data",data:{linkInfo:{channel:{
15
+ title:"Public & news",description:"a } and [ inside text",
16
+ participantsCount:5287,channelId:68067500157571},message:{text:"post"}}}}],
17
+ form:null});</script>'''
18
+
19
+
20
+ def page_response():
21
+ return Mock(status_code=200, text=PAGE)
22
+
23
+
24
+ class MaxPublicPostTests(unittest.TestCase):
25
+ def test_hydration_and_message_id(self):
26
+ title, channel_id = max_api.parse_public_post(PAGE, "vkontakte")
27
+ self.assertEqual(title, "Public & news")
28
+ self.assertEqual(channel_id, 68067500157571)
29
+ self.assertEqual(max_api.message_id_from_link(channel_id, "AaE-viFyB3E"), MID)
30
+
31
+ def test_missing_hydration_preserves_meta_title(self):
32
+ title, channel_id = max_api.parse_public_post(
33
+ '<meta property="og:title" content="Public &amp; news">', "vkontakte",
34
+ )
35
+ self.assertEqual(title, "Public & news")
36
+ self.assertIsNone(channel_id)
37
+
38
+ def test_bad_post_id_is_rejected(self):
39
+ with self.assertRaises(ValueError):
40
+ max_api.message_id_from_link(68067500157571, "bad")
41
+
42
+ @patch.dict(os.environ, {"MAX_ACCESS_TOKEN": "test-token"})
43
+ def test_public_post_views_and_credentials(self):
44
+ session = Mock()
45
+ session.get.side_effect = [page_response(), Mock(status_code=200, json=lambda: {
46
+ "body": {"mid": MID}, "stat": {"views": 1197}, "url": URL,
47
+ })]
48
+ self.assertEqual(max_api.get_public_post(
49
+ session, "vkontakte", "AaE-viFyB3E", URL, (5, 8),
50
+ ), ("Public & news", 1197))
51
+ public_request, api_request = session.get.call_args_list
52
+ self.assertNotIn("headers", public_request.kwargs)
53
+ self.assertEqual(api_request.args[0], max_api.API_BASE_URL + "/messages/" + MID)
54
+ self.assertEqual(api_request.kwargs["headers"], {"Authorization": "test-token"})
55
+ self.assertEqual(api_request.kwargs["verify"], max_api.CA_FILE)
56
+ self.assertFalse(api_request.kwargs["allow_redirects"])
57
+
58
+ @patch.dict(os.environ, {"MAX_ACCESS_TOKEN": "test-token"})
59
+ def test_denied_and_missing_counter_are_unknown_not_zero(self):
60
+ for response in [Mock(status_code=403), Mock(status_code=200, json=lambda: {
61
+ "body": {"mid": MID}, "stat": {},
62
+ }), Mock(status_code=200, json=lambda: {
63
+ "body": {"mid": "different"}, "stat": {"views": 9999},
64
+ })]:
65
+ with self.subTest(status=response.status_code):
66
+ session = Mock()
67
+ session.get.side_effect = [page_response(), response]
68
+ self.assertEqual(max_api.get_public_post(
69
+ session, "vkontakte", "AaE-viFyB3E", URL, (5, 8),
70
+ ), ("Public & news", None))
71
+
72
+ @patch.dict(os.environ, {"MAX_ACCESS_TOKEN": "test-token"})
73
+ def test_real_zero_is_preserved(self):
74
+ session = Mock()
75
+ session.get.side_effect = [page_response(), Mock(status_code=200, json=lambda: {
76
+ "body": {"mid": MID}, "stat": {"views": 0},
77
+ })]
78
+ self.assertEqual(max_api.get_public_post(
79
+ session, "vkontakte", "AaE-viFyB3E", URL, (5, 8),
80
+ )[1], 0)
81
+
82
+ def test_output_keeps_max_counters_and_partial_sum(self):
83
+ lines = [("max", URL, "vkontakte", "AaE-viFyB3E"),
84
+ ("max", "https://max.ru/other/AaE-viFyB3E", "other", "AaE-viFyB3E")]
85
+ with patch.object(app, "resolve_max", return_value={
86
+ URL: ("Public & news", 1197), lines[1][1]: ("Other", None),
87
+ }):
88
+ html, text, _, _, errors, max_count = app.build_output(lines, time.monotonic() + 10)
89
+ self.assertIn("<b>1,2K</b>", html)
90
+ self.assertIn("Public &amp; news", html)
91
+ self.assertIn("1,2K", text)
92
+ self.assertIn("Постов без данных о просмотрах: 1.", text)
93
+ self.assertEqual(len(errors), 1)
94
+ self.assertEqual(max_count, 2)
95
+
96
+
97
+ if __name__ == "__main__":
98
+ unittest.main()
tests/test_reports.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+ from reports import normalize, human_views, render
3
+
4
+
5
+ class ReportTests(unittest.TestCase):
6
+ def test_normalize(self):
7
+ self.assertEqual(normalize('http://vk.ru/wall-123_456')['url'], 'https://vk.com/wall-123_456')
8
+ self.assertEqual(normalize('https://telegram.me/s/Channel/123')['url'], 'https://t.me/channel/123')
9
+ for link in ('https://t.me/c/123/456', 'https://vk.ru.evil.test/wall-123_456', 'https://vk.com/club123'):
10
+ with self.assertRaises(ValueError):
11
+ normalize(link)
12
+
13
+ def test_format_without_zero(self):
14
+ for value, expected in ((2000, '2K'), (10600, '10,6K'), (1000000, '1M'), (35, '35'), (0, '0')):
15
+ self.assertEqual(human_views(value), expected)
16
+
17
+ def test_errors_are_not_zero_views(self):
18
+ first = dict(normalize('https://t.me/channel/1'), title='Имя 🗿', views=2000)
19
+ second = dict(normalize('https://t.me/channel/2'), title='Имя', views=None, error='Нет счётчика')
20
+ r = render([first, second])
21
+ self.assertIn('<b>2K</b>', r['html'])
22
+ self.assertNotIn('🗿', r['html'])
23
+ self.assertNotIn('0K', r['html'])
24
+ self.assertIn('Неполный итог', r['html'])
25
+ self.assertEqual(len(r['errors']), 1)
26
+
27
+ def test_grouping_and_html_escaping(self):
28
+ records = [dict(normalize('https://max.ru/channel/AaE-viFyB3E'), title='<script>alert(1)</script> Имя', views=1000),
29
+ dict(normalize('https://max.ru/channel/AaE-viFyB3F'), title='Имя', views=2000)]
30
+ r = render(records)
31
+ self.assertIn('<h2>Макс</h2>', r['html'])
32
+ self.assertIn('ещё</a>', r['html'])
33
+ self.assertNotIn('<script>', r['html'])
34
+ self.assertIn('<b>3K</b>', r['html'])
35
+
36
+
37
+ if __name__ == '__main__':
38
+ unittest.main()
tests/test_service.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import unittest
3
+ from unittest.mock import Mock, patch
4
+
5
+ from fastapi.testclient import TestClient
6
+ import service
7
+ from reports import normalize
8
+
9
+
10
+ class ServiceTests(unittest.TestCase):
11
+ def setUp(self):
12
+ self.client = TestClient(service.app)
13
+ self.key_patch = patch.object(service, 'API_KEY', 'test-bridge-key')
14
+ self.key_patch.start()
15
+ self.addCleanup(self.key_patch.stop)
16
+ self.queue_patch = patch.object(service, 'queue', Mock())
17
+ self.queue = self.queue_patch.start()
18
+ self.addCleanup(self.queue_patch.stop)
19
+ service.jobs.clear()
20
+ self.headers = {'X-Bridge-Key': 'test-bridge-key'}
21
+ self.payload = {'request_id': '12345678-abcd-efgh', 'links': ['http://vk.ru/wall-123_456']}
22
+
23
+ def test_authentication_and_production_route_absent(self):
24
+ self.assertEqual(self.client.post('/jobs', json=self.payload).status_code, 401)
25
+ self.assertEqual(self.client.post('/jobs', json=self.payload, headers={'X-Bridge-Key': 'wrong-key'}).status_code, 401)
26
+ self.assertEqual(self.client.get('/health').json()['service'], 'ReportBridge')
27
+ self.assertEqual(self.client.post('/parse', json={'links': []}).status_code, 404)
28
+
29
+ def test_idempotency_and_canonical_dedup(self):
30
+ self.payload['links'].append('https://vk.com/wall-123_456')
31
+ a = self.client.post('/jobs', json=self.payload, headers=self.headers)
32
+ b = self.client.post('/jobs', json=self.payload, headers=self.headers)
33
+ self.assertEqual(a.status_code, 200)
34
+ self.assertEqual(a.json()['id'], b.json()['id'])
35
+ self.queue.submit.assert_called_once()
36
+ job = self.client.get('/jobs/' + a.json()['id'], headers=self.headers).json()
37
+ self.assertEqual(job['total'], 1)
38
+ self.assertNotIn('links', job)
39
+ self.payload['links'] = ['https://t.me/channel/1']
40
+ self.assertEqual(self.client.post('/jobs', json=self.payload, headers=self.headers).status_code, 409)
41
+
42
+ def test_private_and_arbitrary_urls_never_enqueue(self):
43
+ for url in ['https://t.me/c/123/456', 'http://127.0.0.1/secret', 'https://vk.com.evil.com/wall-123_456']:
44
+ self.payload['links'] = [url]
45
+ self.assertEqual(self.client.post('/jobs', json=self.payload, headers=self.headers).status_code, 422)
46
+ self.queue.submit.assert_not_called()
47
+
48
+ def test_queue_limits(self):
49
+ for i in range(5):
50
+ self.payload['request_id'] = f'12345678-abcd-{i:06d}'
51
+ self.assertEqual(self.client.post('/jobs', json=self.payload, headers=self.headers).status_code, 200)
52
+ self.payload['request_id'] = '12345678-abcd-999999'
53
+ self.assertEqual(self.client.post('/jobs', json=self.payload, headers=self.headers).status_code, 429)
54
+
55
+ def test_real_progress_and_rendering(self):
56
+ records = [normalize('https://t.me/channel/1'), normalize('https://t.me/channel/2')]
57
+ service.jobs['test'] = dict(status='queued', completed=0, total=2, created=time.time())
58
+ with patch.object(service, 'fetch_one', side_effect=lambda r: dict(r, title='Имя', views=2000)):
59
+ service.run_job('test', records)
60
+ job = service.jobs['test']
61
+ self.assertEqual(job['status'], 'complete')
62
+ self.assertEqual(job['completed'], 2)
63
+ self.assertIn('<b>4K</b>', job['result']['html'])
64
+ self.assertIn('ещё</a>', job['result']['html'])
65
+
66
+
67
+ if __name__ == '__main__':
68
+ unittest.main()