Dink0 commited on
Commit
0162a2b
·
verified ·
1 Parent(s): 62becde

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +964 -0
app.py ADDED
@@ -0,0 +1,964 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from threading import Lock
5
+ import time
6
+ import os
7
+ import re
8
+ import html as html_lib
9
+ from typing import List, 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
+ try:
17
+ from telethon import TelegramClient
18
+ from telethon.sessions import StringSession
19
+ except Exception:
20
+ TelegramClient = None
21
+ StringSession = None
22
+
23
+ try:
24
+ from playwright.sync_api import TimeoutError as PlaywrightTimeoutError, sync_playwright
25
+ except Exception:
26
+ PlaywrightTimeoutError = Exception
27
+ sync_playwright = None
28
+
29
+ APP_NAME = "pr-tool-backend"
30
+
31
+ VK_API_VERSION = os.getenv("VK_API_VERSION", "5.131")
32
+ VK_ACCESS_TOKEN = os.getenv("VK_ACCESS_TOKEN", "")
33
+ TELEGRAM_API_ID = int(os.getenv("TELEGRAM_API_ID", "0") or "0")
34
+ TELEGRAM_API_HASH = os.getenv("TELEGRAM_API_HASH", "")
35
+ TELEGRAM_STRING_SESSION = os.getenv("TELEGRAM_STRING_SESSION", "")
36
+ REQUEST_TIMEOUT = float(os.getenv("REQUEST_TIMEOUT", "15"))
37
+ TELEGRAM_OP_TIMEOUT = float(os.getenv("TELEGRAM_OP_TIMEOUT", "8"))
38
+ TELEGRAM_CONCURRENCY = int(os.getenv("TELEGRAM_CONCURRENCY", "6"))
39
+ VK_MAX_RETRIES = int(os.getenv("VK_MAX_RETRIES", "5"))
40
+ VK_RETRY_DELAY = float(os.getenv("VK_RETRY_DELAY", "0.45"))
41
+ VK_BATCH_SIZE = int(os.getenv("VK_BATCH_SIZE", "100"))
42
+ PLAYWRIGHT_GOTO_TIMEOUT_MS = int(os.getenv("PLAYWRIGHT_GOTO_TIMEOUT_MS", "20000"))
43
+ ENABLE_TELEGRAM_BROWSER_FALLBACK = os.getenv("ENABLE_TELEGRAM_BROWSER_FALLBACK", "1") != "0"
44
+
45
+ _TELEGRAM_BROWSER_LOCK = Lock()
46
+
47
+ app = FastAPI(title=APP_NAME)
48
+
49
+ # Для простоты разрешаем все источники. Можно сузить список доменов в проде.
50
+ app.add_middleware(
51
+ CORSMiddleware,
52
+ allow_origins=["*"],
53
+ allow_credentials=False,
54
+ allow_methods=["POST", "GET", "OPTIONS"],
55
+ allow_headers=["*"]
56
+ )
57
+
58
+
59
+ class ParseRequest(BaseModel):
60
+ links: Union[List[str], str]
61
+
62
+
63
+ class ParseResponse(BaseModel):
64
+ html: str
65
+ text: str
66
+ telegram_total: int
67
+ vk_total: int
68
+ errors: List[str]
69
+
70
+
71
+ # ---------- Вспомогательные функции ----------
72
+
73
+ def clean_telegram_title(raw: str, fallback: str) -> str:
74
+ title = re.sub(
75
+ r"<i\b[^>]*class=[\"'][^\"']*emoji[^\"']*[\"'][^>]*>.*?</i>",
76
+ "",
77
+ (raw or "").strip(),
78
+ flags=re.IGNORECASE | re.DOTALL,
79
+ )
80
+ title = re.sub(r"<[^>]+>", "", title)
81
+ title = html_lib.unescape(title)
82
+ title = re.sub(r"\s*[-|]\s*Telegram\s*$", "", title, flags=re.IGNORECASE)
83
+ title = re.sub(
84
+ r"[\U0001F1E6-\U0001F1FF\U0001F300-\U0001FAFF\u2600-\u27BF\uFE0E\uFE0F]",
85
+ "",
86
+ title,
87
+ )
88
+ title = re.sub(r"\s+", " ", title).strip()
89
+ return title or fallback
90
+
91
+ def human_format_views(num: int) -> str:
92
+ if num >= 1_000_000:
93
+ value = num / 1_000_000
94
+ suffix = "M"
95
+ else:
96
+ value = num / 1000
97
+ suffix = "K"
98
+
99
+ rounded = round(value, 1)
100
+ if rounded.is_integer():
101
+ formatted = f"{int(rounded)}{suffix}"
102
+ else:
103
+ formatted = f"{rounded:.1f}{suffix}"
104
+
105
+ return formatted.replace(".", ",")
106
+
107
+
108
+ def detect_platform(link: str) -> str:
109
+ link = link.strip().lower()
110
+ if "t.me/" in link or "telegram.me/" in link:
111
+ return "telegram"
112
+ if "vk.com/wall" in link or "vk.ru/wall" in link:
113
+ return "vk"
114
+ return ""
115
+
116
+
117
+ def canonicalize_tg_link(link: str) -> Tuple[str | None, str | None, str | None]:
118
+ link = link.strip()
119
+ m = re.match(r"https?://(?:t(?:elegram)?\.me)/(?:s/)?([^/]+)/(?P<id>\d+)", link, re.IGNORECASE)
120
+ if not m:
121
+ return None, None, None
122
+ username = m.group(1)
123
+ message_id = m.group("id")
124
+ canonical = f"https://t.me/{username}/{message_id}"
125
+ return canonical, username, message_id
126
+
127
+
128
+ def canonicalize_vk_link(link: str) -> Tuple[str | None, int | None, str | None]:
129
+ link = link.strip()
130
+ m = re.search(r"(?:vk\.com|vk\.ru)/wall(-?\d+)_(\d+)", link)
131
+ if not m:
132
+ return None, None, None
133
+ owner_id = int(m.group(1))
134
+ post_id = m.group(2)
135
+ canonical = f"https://vk.com/wall{owner_id}_{post_id}"
136
+ return canonical, owner_id, post_id
137
+
138
+
139
+ def _telethon_ready() -> bool:
140
+ return bool(
141
+ TelegramClient
142
+ and StringSession
143
+ and TELEGRAM_API_ID
144
+ and TELEGRAM_API_HASH
145
+ and TELEGRAM_STRING_SESSION
146
+ )
147
+
148
+
149
+ def process_telegram_batch(batch_items: List[Tuple[str, str, str]]) -> dict[str, Tuple[str, int]]:
150
+ if not batch_items:
151
+ return {}
152
+
153
+ if not _telethon_ready():
154
+ return {
155
+ canonical: process_telegram_link(username, message_id, canonical)
156
+ for canonical, username, message_id in batch_items
157
+ }
158
+
159
+ async def _run_batch() -> dict[str, Tuple[str, int]]:
160
+ results: dict[str, Tuple[str, int]] = {}
161
+ grouped: dict[str, List[Tuple[str, int]]] = {}
162
+
163
+ for canonical, username, message_id in batch_items:
164
+ grouped.setdefault(username, []).append((canonical, int(message_id)))
165
+
166
+ client = TelegramClient(
167
+ StringSession(TELEGRAM_STRING_SESSION),
168
+ TELEGRAM_API_ID,
169
+ TELEGRAM_API_HASH,
170
+ request_retries=2,
171
+ connection_retries=2,
172
+ timeout=REQUEST_TIMEOUT,
173
+ )
174
+
175
+ async with client:
176
+ semaphore = asyncio.Semaphore(max(1, TELEGRAM_CONCURRENCY))
177
+
178
+ async def _process_username(username: str, items: List[Tuple[str, int]]) -> None:
179
+ async with semaphore:
180
+ try:
181
+ entity = await asyncio.wait_for(
182
+ client.get_entity(username),
183
+ timeout=TELEGRAM_OP_TIMEOUT,
184
+ )
185
+ except Exception as exc:
186
+ for canonical, _ in items:
187
+ results[canonical] = (f"Ошибка (TG) при обработке {canonical}: {exc}", 0)
188
+ return
189
+
190
+ entity_title = getattr(entity, "title", None)
191
+ if not entity_title:
192
+ first_name = getattr(entity, "first_name", "")
193
+ last_name = getattr(entity, "last_name", "")
194
+ entity_title = (f"{first_name} {last_name}").strip()
195
+ title = clean_telegram_title(entity_title or "", username)
196
+
197
+ ids = [message_id for _, message_id in items]
198
+ try:
199
+ messages = await asyncio.wait_for(
200
+ client.get_messages(entity, ids=ids),
201
+ timeout=TELEGRAM_OP_TIMEOUT,
202
+ )
203
+ except Exception as exc:
204
+ for canonical, _ in items:
205
+ results[canonical] = (f"Ошибка (TG) при обработке {canonical}: {exc}", 0)
206
+ return
207
+
208
+ if not isinstance(messages, list):
209
+ messages = [messages]
210
+
211
+ message_map = {}
212
+ for message in messages:
213
+ if message is None:
214
+ continue
215
+ message_id = getattr(message, "id", None)
216
+ if message_id is None:
217
+ continue
218
+ message_map[int(message_id)] = message
219
+
220
+ for canonical, message_id in items:
221
+ message = message_map.get(message_id)
222
+ if message is None:
223
+ results[canonical] = (f"Ошибка (TG): пост не найден — {canonical}", 0)
224
+ continue
225
+ views = int(getattr(message, "views", 0) or 0)
226
+ results[canonical] = (title, views)
227
+
228
+ tasks = [
229
+ asyncio.create_task(_process_username(username, items))
230
+ for username, items in grouped.items()
231
+ ]
232
+ if tasks:
233
+ total_timeout = max(
234
+ TELEGRAM_OP_TIMEOUT,
235
+ (((len(tasks) + max(1, TELEGRAM_CONCURRENCY) - 1) // max(1, TELEGRAM_CONCURRENCY)) * TELEGRAM_OP_TIMEOUT * 2),
236
+ )
237
+ try:
238
+ await asyncio.wait_for(
239
+ asyncio.gather(*tasks),
240
+ timeout=total_timeout,
241
+ )
242
+ except asyncio.TimeoutError:
243
+ for username, items in grouped.items():
244
+ for canonical, _ in items:
245
+ results.setdefault(
246
+ canonical,
247
+ (f"Ошибка (TG) при обработке {canonical}: timeout", 0),
248
+ )
249
+
250
+ return results
251
+
252
+ try:
253
+ return asyncio.run(_run_batch())
254
+ except Exception:
255
+ return {
256
+ canonical: process_telegram_link(username, message_id, canonical)
257
+ for canonical, username, message_id in batch_items
258
+ }
259
+
260
+
261
+ def process_telegram_link(channel_username: str, message_id: str, canonical_link: str) -> Tuple[str, int]:
262
+ # Набор альтернативных адресов (если t.me не резолвится)
263
+ urls = [
264
+ f"https://t.me/{channel_username}/{message_id}?embed=1",
265
+ f"https://t.me/{channel_username}/{message_id}?embed=1&single=1",
266
+ f"https://t.me/{channel_username}/{message_id}?embed=1&mode=tme",
267
+ f"https://t.me/s/{channel_username}/{message_id}",
268
+ f"https://t.me/{channel_username}/{message_id}",
269
+ f"https://r.jina.ai/http://t.me/{channel_username}/{message_id}?embed=1",
270
+ f"https://r.jina.ai/http://t.me/{channel_username}/{message_id}?embed=1&single=1",
271
+ f"https://r.jina.ai/http://t.me/s/{channel_username}/{message_id}",
272
+ f"https://r.jina.ai/http://t.me/{channel_username}/{message_id}",
273
+ ]
274
+ headers = {"User-Agent": "Mozilla/5.0"}
275
+ post_headers = {
276
+ "User-Agent": "Mozilla/5.0",
277
+ "Content-Type": "application/x-www-form-urlencoded",
278
+ }
279
+
280
+ def _parse_views_number(s: str) -> int:
281
+ if not s:
282
+ return 0
283
+ s = s.strip().replace("\u00a0", "").replace(" ", "").replace(",", ".")
284
+ m2 = re.match(r"([\d\.]+)\s*([kKmM]?)", s)
285
+ if not m2:
286
+ return 0
287
+ val = float(m2.group(1))
288
+ suf = m2.group(2).lower()
289
+ if suf == "k":
290
+ val *= 1_000
291
+ elif suf == "m":
292
+ val *= 1_000_000
293
+ return int(val)
294
+
295
+ def _fetch_ok_responses(url_list):
296
+ texts: List[str] = []
297
+ last_err = None
298
+ for u in url_list:
299
+ try:
300
+ resp = requests.get(u, headers=headers, timeout=REQUEST_TIMEOUT)
301
+ if resp.status_code == 200 and resp.text:
302
+ texts.append(resp.text)
303
+
304
+ # Встроенный tg-виджет сам делает POST на тот же URL с _rl=1.
305
+ # Иногда именно этот ответ содержит уже дорисованный widget DOM с просмотрами.
306
+ if "tgme_widget_message_views" not in resp.text:
307
+ post_resp = requests.post(
308
+ u,
309
+ headers=post_headers,
310
+ data="_rl=1",
311
+ timeout=REQUEST_TIMEOUT,
312
+ )
313
+ if post_resp.status_code == 200 and post_resp.text:
314
+ texts.append(post_resp.text)
315
+ except Exception as exc:
316
+ last_err = exc
317
+ if texts:
318
+ return texts
319
+ raise last_err or Exception("Не удалось получить страницу Telegram")
320
+
321
+ def _strip_emoji(s: str) -> str:
322
+ # Убираем emoji/pictographs и variation selectors.
323
+ s = re.sub(
324
+ r"[\U0001F1E6-\U0001F1FF\U0001F300-\U0001FAFF\u2600-\u27BF\uFE0E\uFE0F]",
325
+ "",
326
+ s,
327
+ )
328
+ return re.sub(r"\s+", " ", s).strip()
329
+
330
+ def _clean_title(raw: str, fallback: str) -> str:
331
+ title = re.sub(
332
+ r"<i\b[^>]*class=[\"'][^\"']*emoji[^\"']*[\"'][^>]*>.*?</i>",
333
+ "",
334
+ (raw or "").strip(),
335
+ flags=re.IGNORECASE | re.DOTALL,
336
+ )
337
+ title = re.sub(r"<[^>]+>", "", title)
338
+ title = html_lib.unescape(title)
339
+ title = re.sub(r"\s*[-|]\s*Telegram\s*$", "", title, flags=re.IGNORECASE)
340
+ title = _strip_emoji(title)
341
+ return title or fallback
342
+
343
+ def _is_placeholder_title(raw: str | None) -> bool:
344
+ if not raw:
345
+ return True
346
+ normalized = _clean_title(raw, "").strip().lower()
347
+ placeholders = {
348
+ "",
349
+ "telegram",
350
+ "telegram widget",
351
+ "telegram - a new era of messaging",
352
+ "telegram – a new era of messaging",
353
+ }
354
+ if normalized in placeholders:
355
+ return True
356
+ if normalized.startswith("telegram:"):
357
+ return True
358
+ if "a new era of messaging" in normalized:
359
+ return True
360
+ return False
361
+
362
+ def _is_username_like_title(raw: str | None) -> bool:
363
+ if not raw:
364
+ return False
365
+ normalized = _clean_title(raw, "").strip().lower()
366
+ username_variants = {
367
+ channel_username.lower(),
368
+ f"@{channel_username.lower()}",
369
+ f"t.me/{channel_username.lower()}",
370
+ }
371
+ return normalized in username_variants
372
+
373
+ def _extract_views(html: str) -> int:
374
+ patterns = [
375
+ # Виджет Telegram
376
+ r'class="tgme_widget_message_views[^"]*">([\d\s\.,kKmM]+)<',
377
+ # Резервный вариант из meta-description, но только если рядом явно "views"
378
+ r'content="[^"]*?([\d\s\.,kKmM]+)\s+views[^"]*"',
379
+ r'content="[^"]*?(?:Просмотры|просмотры|просмотров)[:\s]*([\d\s\.,kKmM]+)[^"]*"',
380
+ # Текстовый fallback (например, через jina)
381
+ r'(?:^|\n)\s*(?:Views|views|Просмотры|просмотры|Просмотров|просмотров)\s*[:\-]?\s*([\d\s\.,kKmM]+)\b',
382
+ r'([\d\s\.,kKmM]+)\s*(?:views|Views|просмотров|Просмотров)\b',
383
+ r'(?:👁|👀)\s*([\d\s\.,kKmM]+)\b',
384
+ ]
385
+ for pattern in patterns:
386
+ m = re.search(pattern, html, re.IGNORECASE)
387
+ if m:
388
+ parsed = _parse_views_number(m.group(1))
389
+ if parsed > 0:
390
+ return parsed
391
+ return 0
392
+
393
+ def _extract_text_title(raw_text: str) -> str | None:
394
+ looks_like_html = bool(re.search(r"<(?:!DOCTYPE|html|head|body|meta|script|div)\b", raw_text, re.IGNORECASE))
395
+ patterns = [
396
+ r'(?:^|\n)\s*Title:\s*(.+)',
397
+ r'(?:^|\n)\s*Channel:\s*(.+)',
398
+ r'(?:^|\n)\s*#\s+(.+)',
399
+ ]
400
+ for pattern in patterns:
401
+ m = re.search(pattern, raw_text, re.IGNORECASE)
402
+ if m:
403
+ return m.group(1).strip()
404
+
405
+ if looks_like_html:
406
+ return None
407
+
408
+ for line in raw_text.splitlines():
409
+ line = line.strip()
410
+ if not line:
411
+ continue
412
+ if line.startswith("http://") or line.startswith("https://"):
413
+ continue
414
+ if "views" in line.lower() or "просмотр" in line.lower():
415
+ continue
416
+ if _is_username_like_title(line):
417
+ continue
418
+ if len(line) <= 120:
419
+ return line
420
+ return None
421
+
422
+ def _extract_title(raw_text: str) -> str | None:
423
+ candidates: List[str] = []
424
+
425
+ m_title_meta = re.search(
426
+ r"<meta[^>]*property=[\"']og:title[\"'][^>]*content=[\"']([^\"']+)[\"']",
427
+ raw_text,
428
+ re.IGNORECASE,
429
+ )
430
+ if m_title_meta:
431
+ candidates.append(m_title_meta.group(1).strip())
432
+
433
+ m_twitter_title = re.search(
434
+ r"<meta[^>]*property=[\"']twitter:title[\"'][^>]*content=[\"']([^\"']+)[\"']",
435
+ raw_text,
436
+ re.IGNORECASE,
437
+ )
438
+ if m_twitter_title:
439
+ candidates.append(m_twitter_title.group(1).strip())
440
+
441
+ m_site_name = re.search(
442
+ r"<meta[^>]*property=[\"']og:site_name[\"'][^>]*content=[\"']([^\"']+)[\"']",
443
+ raw_text,
444
+ re.IGNORECASE,
445
+ )
446
+ if m_site_name:
447
+ candidates.append(m_site_name.group(1).strip())
448
+
449
+ m_author = re.search(
450
+ r'class="tgme_widget_message_author_name".*?<span[^>]*>(.*?)</span>',
451
+ raw_text,
452
+ re.IGNORECASE | re.DOTALL,
453
+ )
454
+ if m_author:
455
+ candidates.append(m_author.group(1).strip())
456
+
457
+ m_owner = re.search(
458
+ r'class="tgme_widget_message_owner_name".*?<span[^>]*>(.*?)</span>',
459
+ raw_text,
460
+ re.IGNORECASE | re.DOTALL,
461
+ )
462
+ if m_owner:
463
+ candidates.append(m_owner.group(1).strip())
464
+
465
+ text_title = _extract_text_title(raw_text)
466
+ if text_title:
467
+ candidates.append(text_title)
468
+
469
+ for candidate in candidates:
470
+ if not _is_placeholder_title(candidate):
471
+ return candidate
472
+ return None
473
+
474
+ def _pick_widget_html(raw_text: str) -> str | None:
475
+ candidates = [raw_text]
476
+ iframe_matches = re.findall(r"<iframe\b[^>]*>(.*?)</iframe>", raw_text, re.IGNORECASE | re.DOTALL)
477
+ candidates.extend(iframe_matches)
478
+
479
+ for candidate in candidates:
480
+ if (
481
+ "tgme_widget_message_views" in candidate
482
+ or "tgme_widget_message_owner_name" in candidate
483
+ or "tgme_widget_message_author_name" in candidate
484
+ ):
485
+ return candidate
486
+ return None
487
+
488
+ def _fetch_browser_response() -> str | None:
489
+ if not ENABLE_TELEGRAM_BROWSER_FALLBACK or sync_playwright is None:
490
+ return None
491
+
492
+ browser_urls = [
493
+ f"https://t.me/{channel_username}/{message_id}?embed=1&single=1",
494
+ f"https://t.me/{channel_username}/{message_id}?embed=1",
495
+ f"https://t.me/{channel_username}/{message_id}",
496
+ ]
497
+
498
+ with _TELEGRAM_BROWSER_LOCK:
499
+ try:
500
+ with sync_playwright() as playwright:
501
+ browser = playwright.chromium.launch(
502
+ headless=True,
503
+ args=["--no-sandbox", "--disable-dev-shm-usage"],
504
+ )
505
+ context = browser.new_context(
506
+ user_agent=headers["User-Agent"],
507
+ viewport={"width": 1280, "height": 900},
508
+ )
509
+ page = context.new_page()
510
+
511
+ try:
512
+ for url in browser_urls:
513
+ try:
514
+ page.goto(url, wait_until="domcontentloaded", timeout=PLAYWRIGHT_GOTO_TIMEOUT_MS)
515
+ except PlaywrightTimeoutError:
516
+ pass
517
+ except Exception:
518
+ continue
519
+
520
+ try:
521
+ page.wait_for_timeout(700)
522
+ page.wait_for_selector(
523
+ ".tgme_widget_message_views, .tgme_widget_message_owner_name, .tgme_widget_message_author_name",
524
+ timeout=4000,
525
+ )
526
+ except PlaywrightTimeoutError:
527
+ pass
528
+ except Exception:
529
+ pass
530
+
531
+ frame_htmls: List[str] = []
532
+ try:
533
+ frame_htmls.append(page.content())
534
+ except Exception:
535
+ pass
536
+
537
+ for frame in page.frames:
538
+ if frame == page.main_frame:
539
+ continue
540
+ try:
541
+ frame_htmls.append(frame.content())
542
+ except Exception:
543
+ continue
544
+
545
+ for frame_html in frame_htmls:
546
+ widget_html = _pick_widget_html(frame_html)
547
+ if widget_html:
548
+ return widget_html
549
+ finally:
550
+ context.close()
551
+ browser.close()
552
+ except Exception:
553
+ return None
554
+
555
+ return None
556
+
557
+ try:
558
+ responses = _fetch_ok_responses(urls)
559
+
560
+ title_raw = None
561
+ views = 0
562
+ for response_text in responses:
563
+ candidate_title = _extract_title(response_text)
564
+ if candidate_title:
565
+ if not title_raw:
566
+ title_raw = candidate_title
567
+ elif _is_username_like_title(title_raw) and not _is_username_like_title(candidate_title):
568
+ title_raw = candidate_title
569
+
570
+ candidate_views = _extract_views(response_text)
571
+ if candidate_views > views:
572
+ views = candidate_views
573
+
574
+ if title_raw and not _is_username_like_title(title_raw) and views > 0:
575
+ break
576
+
577
+ if not title_raw or _is_username_like_title(title_raw) or views <= 0:
578
+ browser_response = _fetch_browser_response()
579
+ if browser_response:
580
+ browser_title = _extract_title(browser_response)
581
+ browser_views = _extract_views(browser_response)
582
+ if browser_title and (not title_raw or _is_username_like_title(title_raw)):
583
+ title_raw = browser_title
584
+ if browser_views > views:
585
+ views = browser_views
586
+
587
+ title = _clean_title(title_raw or "", channel_username)
588
+
589
+ return title, views
590
+
591
+ except Exception as exc:
592
+ return f"Ошибка (TG) при обработке {canonical_link}: {exc}", 0
593
+
594
+
595
+ def process_vk_link(owner_id: int, post_id: str, canonical_link: str) -> Tuple[str, int]:
596
+ posts_param = f"{owner_id}_{post_id}"
597
+ api_url = "https://api.vk.com/method/wall.getById"
598
+
599
+ for attempt in range(1, VK_MAX_RETRIES + 1):
600
+ params = {
601
+ "posts": posts_param,
602
+ "v": VK_API_VERSION,
603
+ "extended": 1,
604
+ }
605
+ if VK_ACCESS_TOKEN:
606
+ params["access_token"] = VK_ACCESS_TOKEN
607
+
608
+ try:
609
+ resp = requests.get(api_url, params=params, timeout=REQUEST_TIMEOUT)
610
+ except Exception as exc:
611
+ return f"**Ошибка (VK)**: {exc} — {canonical_link}", 0
612
+
613
+ if resp.status_code != 200:
614
+ return f"**Ошибка (VK)**: HTTP {resp.status_code} — {canonical_link}", 0
615
+
616
+ data = resp.json()
617
+ if "error" in data:
618
+ error = data["error"]
619
+ error_msg = error.get("error_msg", "")
620
+ error_code = error.get("error_code")
621
+ if error_code == 6 or "too many requests per second" in error_msg.lower():
622
+ time.sleep(VK_RETRY_DELAY * attempt)
623
+ continue
624
+ return f"**Ошибка (VK)**: {error_msg or 'API error'} — {canonical_link}", 0
625
+ break
626
+ else:
627
+ return f"**Ошибка (VK)**: Too many requests per second — {canonical_link}", 0
628
+
629
+ response_data = data.get("response", {})
630
+ items = response_data.get("items", [])
631
+ if not items:
632
+ return f"**Ошибка (VK)**: пост не найден — {canonical_link}", 0
633
+
634
+ post = items[0]
635
+ views = post.get("views", {}).get("count", 0)
636
+ post_owner_id = post.get("owner_id", owner_id)
637
+
638
+ title = None
639
+ if post_owner_id < 0:
640
+ group_id = -post_owner_id
641
+ for group in response_data.get("groups", []):
642
+ if group.get("id") == group_id:
643
+ title = group.get("name")
644
+ break
645
+ else:
646
+ user_id = post_owner_id
647
+ for profile in response_data.get("profiles", []):
648
+ if profile.get("id") == user_id:
649
+ first_name = profile.get("first_name", "")
650
+ last_name = profile.get("last_name", "")
651
+ title = (first_name + " " + last_name).strip()
652
+ break
653
+ if not title:
654
+ title = "VK пост"
655
+
656
+ return title, views
657
+
658
+
659
+ def process_vk_batch(
660
+ batch_items: List[Tuple[str, int, str]]
661
+ ) -> dict[str, Tuple[str, int]]:
662
+ api_url = "https://api.vk.com/method/wall.getById"
663
+ posts = ",".join(f"{owner_id}_{post_id}" for _, owner_id, post_id in batch_items)
664
+
665
+ for attempt in range(1, VK_MAX_RETRIES + 1):
666
+ params = {
667
+ "posts": posts,
668
+ "v": VK_API_VERSION,
669
+ "extended": 1,
670
+ }
671
+ if VK_ACCESS_TOKEN:
672
+ params["access_token"] = VK_ACCESS_TOKEN
673
+
674
+ try:
675
+ resp = requests.get(api_url, params=params, timeout=REQUEST_TIMEOUT)
676
+ except Exception as exc:
677
+ return {
678
+ canonical: (f"**Ошибка (VK)**: {exc} — {canonical}", 0)
679
+ for canonical, _, _ in batch_items
680
+ }
681
+
682
+ if resp.status_code != 200:
683
+ return {
684
+ canonical: (f"**Ошибка (VK)**: HTTP {resp.status_code} — {canonical}", 0)
685
+ for canonical, _, _ in batch_items
686
+ }
687
+
688
+ data = resp.json()
689
+ if "error" in data:
690
+ error = data["error"]
691
+ error_msg = error.get("error_msg", "")
692
+ error_code = error.get("error_code")
693
+ if error_code == 6 or "too many requests per second" in error_msg.lower():
694
+ time.sleep(VK_RETRY_DELAY * attempt)
695
+ continue
696
+ return {
697
+ canonical: (f"**Ошибка (VK)**: {error_msg or 'API error'} — {canonical}", 0)
698
+ for canonical, _, _ in batch_items
699
+ }
700
+ break
701
+ else:
702
+ return {
703
+ canonical: (f"**Ошибка (VK)**: Too many requests per second — {canonical}", 0)
704
+ for canonical, _, _ in batch_items
705
+ }
706
+
707
+ response_data = data.get("response", {})
708
+ items = response_data.get("items", [])
709
+ groups = {group.get("id"): group for group in response_data.get("groups", [])}
710
+ profiles = {profile.get("id"): profile for profile in response_data.get("profiles", [])}
711
+ item_map = {
712
+ f"{item.get('owner_id')}_{item.get('id')}": item
713
+ for item in items
714
+ if item.get("owner_id") is not None and item.get("id") is not None
715
+ }
716
+
717
+ result: dict[str, Tuple[str, int]] = {}
718
+ for canonical, owner_id, post_id in batch_items:
719
+ key = f"{owner_id}_{post_id}"
720
+ post = item_map.get(key)
721
+ if not post:
722
+ result[canonical] = (f"**Ошибка (VK)**: пост не найден — {canonical}", 0)
723
+ continue
724
+
725
+ views = post.get("views", {}).get("count", 0)
726
+ post_owner_id = post.get("owner_id", owner_id)
727
+ title = None
728
+
729
+ if post_owner_id < 0:
730
+ group = groups.get(-post_owner_id)
731
+ if group:
732
+ title = group.get("name")
733
+ else:
734
+ profile = profiles.get(post_owner_id)
735
+ if profile:
736
+ first_name = profile.get("first_name", "")
737
+ last_name = profile.get("last_name", "")
738
+ title = (first_name + " " + last_name).strip()
739
+
740
+ result[canonical] = (title or "VK пост", views)
741
+
742
+ return result
743
+
744
+
745
+ def normalize_links(links: Union[List[str], str]) -> List[str]:
746
+ if isinstance(links, str):
747
+ raw = links.splitlines()
748
+ else:
749
+ raw = []
750
+ for item in links:
751
+ raw.extend(str(item).splitlines())
752
+ return [line.strip() for line in raw if line.strip()]
753
+
754
+
755
+ def _escape(text: str) -> str:
756
+ return html_lib.escape(text, quote=True)
757
+
758
+ def build_output(lines: List[Tuple[str, str, object, object]]) -> Tuple[str, str, int, int, List[str]]:
759
+ errors: List[str] = []
760
+
761
+ tg_groups = {}
762
+ vk_groups = {}
763
+
764
+ telegram_lines = [line for line in lines if line[0] == "telegram"]
765
+ vk_lines = [line for line in lines if line[0] == "vk"]
766
+ telegram_results = {}
767
+ vk_results = {}
768
+
769
+ if telegram_lines:
770
+ valid_telegram_batch: List[Tuple[str, str, str]] = []
771
+ for _, canonical, username, mid in telegram_lines:
772
+ if username and mid:
773
+ valid_telegram_batch.append((canonical, username, mid))
774
+ if valid_telegram_batch:
775
+ telegram_results = process_telegram_batch(valid_telegram_batch)
776
+
777
+ valid_vk_batch: List[Tuple[str, int, str]] = []
778
+ for _, canonical, owner_id, post_id in vk_lines:
779
+ if owner_id is not None and post_id is not None:
780
+ valid_vk_batch.append((canonical, owner_id, post_id))
781
+
782
+ if valid_vk_batch:
783
+ batch_size = max(1, VK_BATCH_SIZE)
784
+ for start in range(0, len(valid_vk_batch), batch_size):
785
+ chunk = valid_vk_batch[start:start + batch_size]
786
+ vk_results.update(process_vk_batch(chunk))
787
+
788
+ for plat, canonical, a, b in lines:
789
+ if plat == "telegram":
790
+ username, mid = a, b
791
+ if not username or not mid:
792
+ key = canonical
793
+ tg_groups.setdefault(key, {"title": "Telegram", "items": []})
794
+ tg_groups[key]["items"].append((canonical, 0))
795
+ else:
796
+ title, views = telegram_results.get(canonical, (username, 0))
797
+ key = username
798
+ if key not in tg_groups:
799
+ tg_groups[key] = {"title": title, "items": []}
800
+ if not tg_groups[key].get("title") or str(tg_groups[key]["title"]).startswith("**Ошибка"):
801
+ tg_groups[key]["title"] = title
802
+ tg_groups[key]["items"].append((canonical, views))
803
+
804
+ elif plat == "vk":
805
+ owner_id, post_id = a, b
806
+ if owner_id is None or post_id is None:
807
+ key = canonical
808
+ vk_groups.setdefault(key, {"title": "VK пост", "items": []})
809
+ vk_groups[key]["items"].append((canonical, 0))
810
+ else:
811
+ title, views = vk_results.get(canonical, process_vk_link(owner_id, post_id, canonical))
812
+ key = str(owner_id)
813
+ if key not in vk_groups:
814
+ vk_groups[key] = {"title": title, "items": []}
815
+ if not vk_groups[key].get("title") or str(vk_groups[key]["title"]).startswith("**Ошибка"):
816
+ vk_groups[key]["title"] = title
817
+ vk_groups[key]["items"].append((canonical, views))
818
+ else:
819
+ errors.append(f"Неизвестная платформа: {canonical}")
820
+
821
+ tg_total_views = sum(v for g in tg_groups.values() for _, v in g["items"])
822
+ vk_total_views = sum(v for g in vk_groups.values() for _, v in g["items"])
823
+
824
+ tg_sorted = sorted(
825
+ tg_groups.items(),
826
+ key=lambda kv: sum(v for _, v in kv[1]["items"]),
827
+ reverse=True,
828
+ )
829
+ vk_sorted = sorted(
830
+ vk_groups.items(),
831
+ key=lambda kv: sum(v for _, v in kv[1]["items"]),
832
+ reverse=True,
833
+ )
834
+
835
+ html_lines: List[str] = []
836
+ text_lines: List[str] = []
837
+
838
+ # --- Telegram ---
839
+ html_lines.append("<h2>Telegram</h2>")
840
+ html_lines.append(f'Суммарно посты собрали <b>{human_format_views(tg_total_views)}</b> просмотров.')
841
+ text_lines.append("Telegram")
842
+ text_lines.append(f"Суммарно посты собрали {human_format_views(tg_total_views)} просмотров.")
843
+ if tg_sorted:
844
+ html_lines.append("<ol>")
845
+ for idx, (_, data) in enumerate(tg_sorted, start=1):
846
+ title = data["title"] or "Telegram"
847
+ items = data["items"]
848
+ first_link, _first_views = items[0]
849
+
850
+ title_html = _escape(title)
851
+ first_link_html = _escape(first_link)
852
+ line_html = f'<li><a href="{first_link_html}">{title_html}</a>'
853
+ if len(items) > 1:
854
+ for link2, _v in items[1:]:
855
+ line_html += f' + <a href="{_escape(link2)}">ещё</a>'
856
+ views_str = " + ".join(human_format_views(v) for _, v in items)
857
+ line_html += f" — {views_str}</li>"
858
+ html_lines.append(line_html)
859
+
860
+ line_text = f"{idx}. {title} ({first_link})"
861
+ if len(items) > 1:
862
+ extra_links = ", ".join(link2 for link2, _v in items[1:])
863
+ line_text += f" + ещё: {extra_links}"
864
+ line_text += f" — {views_str}"
865
+ text_lines.append(line_text)
866
+ html_lines.append("</ol>")
867
+ else:
868
+ html_lines.append("<i>Нет ссылок на Telegram</i>")
869
+ text_lines.append("Нет ссылок на Telegram")
870
+ html_lines.append("<br/>")
871
+ text_lines.append("")
872
+
873
+ # --- VK ---
874
+ html_lines.append("<h2>ВКонтакте</h2>")
875
+ html_lines.append(f'Суммарно посты собрали <b>{human_format_views(vk_total_views)}</b> просмотров.')
876
+ text_lines.append("ВКонтакте")
877
+ text_lines.append(f"Суммарно посты собрали {human_format_views(vk_total_views)} просмотров.")
878
+ if vk_sorted:
879
+ html_lines.append("<ol>")
880
+ for idx, (_, data) in enumerate(vk_sorted, start=1):
881
+ title = data["title"] or "VK пост"
882
+ items = data["items"]
883
+ first_link, _first_views = items[0]
884
+
885
+ title_html = _escape(title)
886
+ first_link_html = _escape(first_link)
887
+ line_html = f'<li><a href="{first_link_html}">{title_html}</a>'
888
+ if len(items) > 1:
889
+ for link2, _v in items[1:]:
890
+ line_html += f' + <a href="{_escape(link2)}">ещё</a>'
891
+ views_str = " + ".join(human_format_views(v) for _, v in items)
892
+ line_html += f" — {views_str}</li>"
893
+ html_lines.append(line_html)
894
+
895
+ line_text = f"{idx}. {title} ({first_link})"
896
+ if len(items) > 1:
897
+ extra_links = ", ".join(link2 for link2, _v in items[1:])
898
+ line_text += f" + ещё: {extra_links}"
899
+ line_text += f" — {views_str}"
900
+ text_lines.append(line_text)
901
+ html_lines.append("</ol>")
902
+ else:
903
+ html_lines.append("<i>Нет ссылок на ВКонтакте</i>")
904
+ text_lines.append("Нет ссылок на ВКонтакте")
905
+ html_lines.append("<br/>")
906
+ text_lines.append("")
907
+
908
+ if errors:
909
+ html_lines.append("<h2>Ошибки</h2>")
910
+ html_lines.append("<ul>")
911
+ for err in errors:
912
+ html_lines.append(f"<li>{_escape(err)}</li>")
913
+ text_lines.append(f"- {err}")
914
+ html_lines.append("</ul>")
915
+ html_lines.append("<br/>")
916
+ text_lines.append("")
917
+
918
+ return "\n".join(html_lines), "\n".join(text_lines), tg_total_views, vk_total_views, errors
919
+
920
+
921
+ @app.get("/health")
922
+ def health_check():
923
+ return {"status": "ok"}
924
+
925
+
926
+ @app.post("/parse", response_model=ParseResponse)
927
+ def parse_links(payload: ParseRequest):
928
+ raw_lines = normalize_links(payload.links)
929
+ if not raw_lines:
930
+ return ParseResponse(html="", text="", telegram_total=0, vk_total=0, errors=["Нет ссылок для обработки."])
931
+
932
+ seen = set()
933
+ lines: List[Tuple[str, str, object, object]] = []
934
+ for link in raw_lines:
935
+ plat = detect_platform(link)
936
+ if plat == "telegram":
937
+ canonical, username, mid = canonicalize_tg_link(link)
938
+ if not canonical:
939
+ canonical = link
940
+ username = None
941
+ mid = None
942
+ if canonical in seen:
943
+ continue
944
+ seen.add(canonical)
945
+ lines.append(("telegram", canonical, username, mid))
946
+ elif plat == "vk":
947
+ canonical, owner_id, post_id = canonicalize_vk_link(link)
948
+ if not canonical:
949
+ canonical = link
950
+ if canonical in seen:
951
+ continue
952
+ seen.add(canonical)
953
+ lines.append(("vk", canonical, owner_id, post_id))
954
+ else:
955
+ lines.append(("unknown", link, None, None))
956
+
957
+ html, text, tg_total, vk_total, errors = build_output(lines)
958
+ return ParseResponse(
959
+ html=html,
960
+ text=text,
961
+ telegram_total=tg_total,
962
+ vk_total=vk_total,
963
+ errors=errors,
964
+ )