Dink0 commited on
Commit
bd86148
·
verified ·
1 Parent(s): 63be2a8

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +706 -0
app.py ADDED
@@ -0,0 +1,706 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from concurrent.futures import ThreadPoolExecutor
4
+ import time
5
+ import os
6
+ import re
7
+ import html as html_lib
8
+ from typing import List, Tuple, Union
9
+
10
+ import requests
11
+ from fastapi import FastAPI
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+ from pydantic import BaseModel
14
+
15
+ APP_NAME = "pr-tool-backend"
16
+
17
+ VK_API_VERSION = os.getenv("VK_API_VERSION", "5.131")
18
+ VK_ACCESS_TOKEN = os.getenv("VK_ACCESS_TOKEN", "")
19
+ REQUEST_TIMEOUT = float(os.getenv("REQUEST_TIMEOUT", "15"))
20
+ MAX_WORKERS = int(os.getenv("MAX_WORKERS", "8"))
21
+ VK_MAX_RETRIES = int(os.getenv("VK_MAX_RETRIES", "5"))
22
+ VK_RETRY_DELAY = float(os.getenv("VK_RETRY_DELAY", "0.45"))
23
+ VK_BATCH_SIZE = int(os.getenv("VK_BATCH_SIZE", "100"))
24
+
25
+ app = FastAPI(title=APP_NAME)
26
+
27
+ # Для простоты разрешаем все источники. Можно сузить список доменов в проде.
28
+ app.add_middleware(
29
+ CORSMiddleware,
30
+ allow_origins=["*"],
31
+ allow_credentials=False,
32
+ allow_methods=["POST", "GET", "OPTIONS"],
33
+ allow_headers=["*"]
34
+ )
35
+
36
+
37
+ class ParseRequest(BaseModel):
38
+ links: Union[List[str], str]
39
+
40
+
41
+ class ParseResponse(BaseModel):
42
+ html: str
43
+ text: str
44
+ telegram_total: int
45
+ vk_total: int
46
+ errors: List[str]
47
+
48
+
49
+ # ---------- Вспомогательные функции ----------
50
+
51
+ def human_format_views(num: int) -> str:
52
+ if num >= 1_000_000:
53
+ value = num / 1_000_000
54
+ suffix = "M"
55
+ else:
56
+ value = num / 1000
57
+ suffix = "K"
58
+
59
+ rounded = round(value, 1)
60
+ if rounded.is_integer():
61
+ formatted = f"{int(rounded)}{suffix}"
62
+ else:
63
+ formatted = f"{rounded:.1f}{suffix}"
64
+
65
+ return formatted.replace(".", ",")
66
+
67
+
68
+ def detect_platform(link: str) -> str:
69
+ link = link.strip().lower()
70
+ if "t.me/" in link or "telegram.me/" in link:
71
+ return "telegram"
72
+ if "vk.com/wall" in link or "vk.ru/wall" in link:
73
+ return "vk"
74
+ return ""
75
+
76
+
77
+ def canonicalize_tg_link(link: str) -> Tuple[str | None, str | None, str | None]:
78
+ link = link.strip()
79
+ m = re.match(r"https?://(?:t(?:elegram)?\.me)/(?:s/)?([^/]+)/(?P<id>\d+)", link, re.IGNORECASE)
80
+ if not m:
81
+ return None, None, None
82
+ username = m.group(1)
83
+ message_id = m.group("id")
84
+ canonical = f"https://t.me/{username}/{message_id}"
85
+ return canonical, username, message_id
86
+
87
+
88
+ def canonicalize_vk_link(link: str) -> Tuple[str | None, int | None, str | None]:
89
+ link = link.strip()
90
+ m = re.search(r"(?:vk\.com|vk\.ru)/wall(-?\d+)_(\d+)", link)
91
+ if not m:
92
+ return None, None, None
93
+ owner_id = int(m.group(1))
94
+ post_id = m.group(2)
95
+ canonical = f"https://vk.com/wall{owner_id}_{post_id}"
96
+ return canonical, owner_id, post_id
97
+
98
+
99
+ def process_telegram_link(channel_username: str, message_id: str, canonical_link: str) -> Tuple[str, int]:
100
+ # Набор альтернативных адресов (если t.me не резолвится)
101
+ urls = [
102
+ f"https://t.me/{channel_username}/{message_id}?embed=1",
103
+ f"https://t.me/{channel_username}/{message_id}?embed=1&single=1",
104
+ f"https://t.me/{channel_username}/{message_id}?embed=1&mode=tme",
105
+ f"https://t.me/s/{channel_username}/{message_id}",
106
+ f"https://t.me/{channel_username}/{message_id}",
107
+ f"https://r.jina.ai/http://t.me/{channel_username}/{message_id}?embed=1",
108
+ f"https://r.jina.ai/http://t.me/{channel_username}/{message_id}?embed=1&single=1",
109
+ f"https://r.jina.ai/http://t.me/s/{channel_username}/{message_id}",
110
+ f"https://r.jina.ai/http://t.me/{channel_username}/{message_id}",
111
+ ]
112
+ headers = {"User-Agent": "Mozilla/5.0"}
113
+ post_headers = {
114
+ "User-Agent": "Mozilla/5.0",
115
+ "Content-Type": "application/x-www-form-urlencoded",
116
+ }
117
+
118
+ def _parse_views_number(s: str) -> int:
119
+ if not s:
120
+ return 0
121
+ s = s.strip().replace("\u00a0", "").replace(" ", "").replace(",", ".")
122
+ m2 = re.match(r"([\d\.]+)\s*([kKmM]?)", s)
123
+ if not m2:
124
+ return 0
125
+ val = float(m2.group(1))
126
+ suf = m2.group(2).lower()
127
+ if suf == "k":
128
+ val *= 1_000
129
+ elif suf == "m":
130
+ val *= 1_000_000
131
+ return int(val)
132
+
133
+ def _fetch_ok_responses(url_list):
134
+ texts: List[str] = []
135
+ last_err = None
136
+ for u in url_list:
137
+ try:
138
+ resp = requests.get(u, headers=headers, timeout=REQUEST_TIMEOUT)
139
+ if resp.status_code == 200 and resp.text:
140
+ texts.append(resp.text)
141
+
142
+ # Встроенный tg-виджет сам делает POST на тот же URL с _rl=1.
143
+ # Иногда именно этот ответ содержит уже дорисованный widget DOM с просмотрами.
144
+ if "tgme_widget_message_views" not in resp.text:
145
+ post_resp = requests.post(
146
+ u,
147
+ headers=post_headers,
148
+ data="_rl=1",
149
+ timeout=REQUEST_TIMEOUT,
150
+ )
151
+ if post_resp.status_code == 200 and post_resp.text:
152
+ texts.append(post_resp.text)
153
+ except Exception as exc:
154
+ last_err = exc
155
+ if texts:
156
+ return texts
157
+ raise last_err or Exception("Не удалось получить страницу Telegram")
158
+
159
+ def _strip_emoji(s: str) -> str:
160
+ # Убираем emoji/pictographs и variation selectors.
161
+ s = re.sub(
162
+ r"[\U0001F1E6-\U0001F1FF\U0001F300-\U0001FAFF\u2600-\u27BF\uFE0E\uFE0F]",
163
+ "",
164
+ s,
165
+ )
166
+ return re.sub(r"\s+", " ", s).strip()
167
+
168
+ def _clean_title(raw: str, fallback: str) -> str:
169
+ title = re.sub(r"<[^>]+>", "", (raw or "").strip())
170
+ title = html_lib.unescape(title)
171
+ title = re.sub(r"\s*[-|]\s*Telegram\s*$", "", title, flags=re.IGNORECASE)
172
+ title = _strip_emoji(title)
173
+ return title or fallback
174
+
175
+ def _is_placeholder_title(raw: str | None) -> bool:
176
+ if not raw:
177
+ return True
178
+ normalized = _clean_title(raw, "").strip().lower()
179
+ placeholders = {
180
+ "",
181
+ "telegram",
182
+ "telegram widget",
183
+ "telegram - a new era of messaging",
184
+ "telegram – a new era of messaging",
185
+ }
186
+ if normalized in placeholders:
187
+ return True
188
+ if normalized.startswith("telegram:"):
189
+ return True
190
+ if "a new era of messaging" in normalized:
191
+ return True
192
+ return False
193
+
194
+ def _extract_views(html: str) -> int:
195
+ patterns = [
196
+ # Виджет Telegram
197
+ r'class="tgme_widget_message_views[^"]*">([\d\s\.,kKmM]+)<',
198
+ # Резервный вариант из meta-description, но только если рядом явно "views"
199
+ r'content="[^"]*?([\d\s\.,kKmM]+)\s+views[^"]*"',
200
+ r'content="[^"]*?(?:Просмотры|просмотры|просмотров)[:\s]*([\d\s\.,kKmM]+)[^"]*"',
201
+ # Текстовый fallback (например, через jina)
202
+ r'(?:^|\n)\s*(?:Views|views|Просмотры|просмотры|Просмотров|просмотров)\s*[:\-]?\s*([\d\s\.,kKmM]+)\b',
203
+ r'([\d\s\.,kKmM]+)\s*(?:views|Views|просмотров|Просмотров)\b',
204
+ r'(?:👁|👀)\s*([\d\s\.,kKmM]+)\b',
205
+ ]
206
+ for pattern in patterns:
207
+ m = re.search(pattern, html, re.IGNORECASE)
208
+ if m:
209
+ parsed = _parse_views_number(m.group(1))
210
+ if parsed > 0:
211
+ return parsed
212
+ return 0
213
+
214
+ def _extract_text_title(raw_text: str) -> str | None:
215
+ looks_like_html = bool(re.search(r"<(?:!DOCTYPE|html|head|body|meta|script|div)\b", raw_text, re.IGNORECASE))
216
+ patterns = [
217
+ r'(?:^|\n)\s*Title:\s*(.+)',
218
+ r'(?:^|\n)\s*Channel:\s*(.+)',
219
+ r'(?:^|\n)\s*#\s+(.+)',
220
+ ]
221
+ for pattern in patterns:
222
+ m = re.search(pattern, raw_text, re.IGNORECASE)
223
+ if m:
224
+ return m.group(1).strip()
225
+
226
+ if looks_like_html:
227
+ return None
228
+
229
+ for line in raw_text.splitlines():
230
+ line = line.strip()
231
+ if not line:
232
+ continue
233
+ if line.startswith("http://") or line.startswith("https://"):
234
+ continue
235
+ if "views" in line.lower() or "просмотр" in line.lower():
236
+ continue
237
+ if len(line) <= 120:
238
+ return line
239
+ return None
240
+
241
+ def _extract_title(raw_text: str) -> str | None:
242
+ candidates: List[str] = []
243
+
244
+ m_title_meta = re.search(
245
+ r"<meta[^>]*property=[\"']og:title[\"'][^>]*content=[\"']([^\"']+)[\"']",
246
+ raw_text,
247
+ re.IGNORECASE,
248
+ )
249
+ if m_title_meta:
250
+ candidates.append(m_title_meta.group(1).strip())
251
+
252
+ m_twitter_title = re.search(
253
+ r"<meta[^>]*property=[\"']twitter:title[\"'][^>]*content=[\"']([^\"']+)[\"']",
254
+ raw_text,
255
+ re.IGNORECASE,
256
+ )
257
+ if m_twitter_title:
258
+ candidates.append(m_twitter_title.group(1).strip())
259
+
260
+ m_site_name = re.search(
261
+ r"<meta[^>]*property=[\"']og:site_name[\"'][^>]*content=[\"']([^\"']+)[\"']",
262
+ raw_text,
263
+ re.IGNORECASE,
264
+ )
265
+ if m_site_name:
266
+ candidates.append(m_site_name.group(1).strip())
267
+
268
+ m_author = re.search(
269
+ r'class="tgme_widget_message_author_name".*?<span[^>]*>(.*?)</span>',
270
+ raw_text,
271
+ re.IGNORECASE | re.DOTALL,
272
+ )
273
+ if m_author:
274
+ candidates.append(m_author.group(1).strip())
275
+
276
+ m_owner = re.search(
277
+ r'class="tgme_widget_message_owner_name".*?<span[^>]*>(.*?)</span>',
278
+ raw_text,
279
+ re.IGNORECASE | re.DOTALL,
280
+ )
281
+ if m_owner:
282
+ candidates.append(m_owner.group(1).strip())
283
+
284
+ text_title = _extract_text_title(raw_text)
285
+ if text_title:
286
+ candidates.append(text_title)
287
+
288
+ for candidate in candidates:
289
+ if not _is_placeholder_title(candidate):
290
+ return candidate
291
+ return None
292
+
293
+ try:
294
+ responses = _fetch_ok_responses(urls)
295
+
296
+ title_raw = None
297
+ views = 0
298
+ for response_text in responses:
299
+ candidate_title = _extract_title(response_text)
300
+ if candidate_title and not title_raw:
301
+ title_raw = candidate_title
302
+
303
+ candidate_views = _extract_views(response_text)
304
+ if candidate_views > views:
305
+ views = candidate_views
306
+
307
+ if title_raw and views > 0:
308
+ break
309
+
310
+ title = _clean_title(title_raw or "", channel_username)
311
+
312
+ return title, views
313
+
314
+ except Exception as exc:
315
+ return f"Ошибка (TG) при обработке {canonical_link}: {exc}", 0
316
+
317
+
318
+ def process_vk_link(owner_id: int, post_id: str, canonical_link: str) -> Tuple[str, int]:
319
+ posts_param = f"{owner_id}_{post_id}"
320
+ api_url = "https://api.vk.com/method/wall.getById"
321
+
322
+ for attempt in range(1, VK_MAX_RETRIES + 1):
323
+ params = {
324
+ "posts": posts_param,
325
+ "v": VK_API_VERSION,
326
+ "extended": 1,
327
+ }
328
+ if VK_ACCESS_TOKEN:
329
+ params["access_token"] = VK_ACCESS_TOKEN
330
+
331
+ try:
332
+ resp = requests.get(api_url, params=params, timeout=REQUEST_TIMEOUT)
333
+ except Exception as exc:
334
+ return f"**Ошибка (VK)**: {exc} — {canonical_link}", 0
335
+
336
+ if resp.status_code != 200:
337
+ return f"**Ошибка (VK)**: HTTP {resp.status_code} — {canonical_link}", 0
338
+
339
+ data = resp.json()
340
+ if "error" in data:
341
+ error = data["error"]
342
+ error_msg = error.get("error_msg", "")
343
+ error_code = error.get("error_code")
344
+ if error_code == 6 or "too many requests per second" in error_msg.lower():
345
+ time.sleep(VK_RETRY_DELAY * attempt)
346
+ continue
347
+ return f"**Ошибка (VK)**: {error_msg or 'API error'} — {canonical_link}", 0
348
+ break
349
+ else:
350
+ return f"**Ошибка (VK)**: Too many requests per second — {canonical_link}", 0
351
+
352
+ response_data = data.get("response", {})
353
+ items = response_data.get("items", [])
354
+ if not items:
355
+ return f"**Ошибка (VK)**: пост не найден — {canonical_link}", 0
356
+
357
+ post = items[0]
358
+ views = post.get("views", {}).get("count", 0)
359
+ post_owner_id = post.get("owner_id", owner_id)
360
+
361
+ title = None
362
+ if post_owner_id < 0:
363
+ group_id = -post_owner_id
364
+ for group in response_data.get("groups", []):
365
+ if group.get("id") == group_id:
366
+ title = group.get("name")
367
+ break
368
+ else:
369
+ user_id = post_owner_id
370
+ for profile in response_data.get("profiles", []):
371
+ if profile.get("id") == user_id:
372
+ first_name = profile.get("first_name", "")
373
+ last_name = profile.get("last_name", "")
374
+ title = (first_name + " " + last_name).strip()
375
+ break
376
+ if not title:
377
+ title = "VK пост"
378
+
379
+ return title, views
380
+
381
+
382
+ def process_vk_batch(
383
+ batch_items: List[Tuple[str, int, str]]
384
+ ) -> dict[str, Tuple[str, int]]:
385
+ api_url = "https://api.vk.com/method/wall.getById"
386
+ posts = ",".join(f"{owner_id}_{post_id}" for _, owner_id, post_id in batch_items)
387
+
388
+ for attempt in range(1, VK_MAX_RETRIES + 1):
389
+ params = {
390
+ "posts": posts,
391
+ "v": VK_API_VERSION,
392
+ "extended": 1,
393
+ }
394
+ if VK_ACCESS_TOKEN:
395
+ params["access_token"] = VK_ACCESS_TOKEN
396
+
397
+ try:
398
+ resp = requests.get(api_url, params=params, timeout=REQUEST_TIMEOUT)
399
+ except Exception as exc:
400
+ return {
401
+ canonical: (f"**Ошибка (VK)**: {exc} — {canonical}", 0)
402
+ for canonical, _, _ in batch_items
403
+ }
404
+
405
+ if resp.status_code != 200:
406
+ return {
407
+ canonical: (f"**Ошибка (VK)**: HTTP {resp.status_code} — {canonical}", 0)
408
+ for canonical, _, _ in batch_items
409
+ }
410
+
411
+ data = resp.json()
412
+ if "error" in data:
413
+ error = data["error"]
414
+ error_msg = error.get("error_msg", "")
415
+ error_code = error.get("error_code")
416
+ if error_code == 6 or "too many requests per second" in error_msg.lower():
417
+ time.sleep(VK_RETRY_DELAY * attempt)
418
+ continue
419
+ return {
420
+ canonical: (f"**Ошибка (VK)**: {error_msg or 'API error'} — {canonical}", 0)
421
+ for canonical, _, _ in batch_items
422
+ }
423
+ break
424
+ else:
425
+ return {
426
+ canonical: (f"**Ошибка (VK)**: Too many requests per second — {canonical}", 0)
427
+ for canonical, _, _ in batch_items
428
+ }
429
+
430
+ response_data = data.get("response", {})
431
+ items = response_data.get("items", [])
432
+ groups = {group.get("id"): group for group in response_data.get("groups", [])}
433
+ profiles = {profile.get("id"): profile for profile in response_data.get("profiles", [])}
434
+ item_map = {
435
+ f"{item.get('owner_id')}_{item.get('id')}": item
436
+ for item in items
437
+ if item.get("owner_id") is not None and item.get("id") is not None
438
+ }
439
+
440
+ result: dict[str, Tuple[str, int]] = {}
441
+ for canonical, owner_id, post_id in batch_items:
442
+ key = f"{owner_id}_{post_id}"
443
+ post = item_map.get(key)
444
+ if not post:
445
+ result[canonical] = (f"**Ошибка (VK)**: пост не найден — {canonical}", 0)
446
+ continue
447
+
448
+ views = post.get("views", {}).get("count", 0)
449
+ post_owner_id = post.get("owner_id", owner_id)
450
+ title = None
451
+
452
+ if post_owner_id < 0:
453
+ group = groups.get(-post_owner_id)
454
+ if group:
455
+ title = group.get("name")
456
+ else:
457
+ profile = profiles.get(post_owner_id)
458
+ if profile:
459
+ first_name = profile.get("first_name", "")
460
+ last_name = profile.get("last_name", "")
461
+ title = (first_name + " " + last_name).strip()
462
+
463
+ result[canonical] = (title or "VK пост", views)
464
+
465
+ return result
466
+
467
+
468
+ def normalize_links(links: Union[List[str], str]) -> List[str]:
469
+ if isinstance(links, str):
470
+ raw = links.splitlines()
471
+ else:
472
+ raw = []
473
+ for item in links:
474
+ raw.extend(str(item).splitlines())
475
+ return [line.strip() for line in raw if line.strip()]
476
+
477
+
478
+ def _escape(text: str) -> str:
479
+ return html_lib.escape(text, quote=True)
480
+
481
+
482
+ def resolve_line_item(line: Tuple[str, str, object, object]) -> Tuple[str, str, str | None, int]:
483
+ plat, canonical, a, b = line
484
+
485
+ if plat == "telegram":
486
+ username, mid = a, b
487
+ if not username or not mid:
488
+ return plat, canonical, "Telegram", 0
489
+ title, views = process_telegram_link(username, mid, canonical)
490
+ return plat, canonical, title, views
491
+
492
+ if plat == "vk":
493
+ owner_id, post_id = a, b
494
+ if owner_id is None or post_id is None:
495
+ return plat, canonical, "VK пост", 0
496
+ title, views = process_vk_link(owner_id, post_id, canonical)
497
+ return plat, canonical, title, views
498
+
499
+ return plat, canonical, None, 0
500
+
501
+
502
+ def build_output(lines: List[Tuple[str, str, object, object]]) -> Tuple[str, str, int, int, List[str]]:
503
+ errors: List[str] = []
504
+
505
+ tg_groups = {}
506
+ vk_groups = {}
507
+
508
+ telegram_lines = [line for line in lines if line[0] == "telegram"]
509
+ vk_lines = [line for line in lines if line[0] == "vk"]
510
+ telegram_results = {}
511
+ vk_results = {}
512
+
513
+ if telegram_lines:
514
+ worker_count = max(1, min(MAX_WORKERS, len(telegram_lines)))
515
+ with ThreadPoolExecutor(max_workers=worker_count) as executor:
516
+ for result in executor.map(resolve_line_item, telegram_lines):
517
+ telegram_results[result[1]] = result
518
+
519
+ valid_vk_batch: List[Tuple[str, int, str]] = []
520
+ for _, canonical, owner_id, post_id in vk_lines:
521
+ if owner_id is not None and post_id is not None:
522
+ valid_vk_batch.append((canonical, owner_id, post_id))
523
+
524
+ if valid_vk_batch:
525
+ batch_size = max(1, VK_BATCH_SIZE)
526
+ for start in range(0, len(valid_vk_batch), batch_size):
527
+ chunk = valid_vk_batch[start:start + batch_size]
528
+ vk_results.update(process_vk_batch(chunk))
529
+
530
+ for plat, canonical, a, b in lines:
531
+ if plat == "telegram":
532
+ username, mid = a, b
533
+ if not username or not mid:
534
+ key = canonical
535
+ tg_groups.setdefault(key, {"title": "Telegram", "items": []})
536
+ tg_groups[key]["items"].append((canonical, 0))
537
+ else:
538
+ _, _, title, views = telegram_results.get(canonical, (plat, canonical, username, 0))
539
+ key = username
540
+ if key not in tg_groups:
541
+ tg_groups[key] = {"title": title, "items": []}
542
+ if not tg_groups[key].get("title") or str(tg_groups[key]["title"]).startswith("**Ошибка"):
543
+ tg_groups[key]["title"] = title
544
+ tg_groups[key]["items"].append((canonical, views))
545
+
546
+ elif plat == "vk":
547
+ owner_id, post_id = a, b
548
+ if owner_id is None or post_id is None:
549
+ key = canonical
550
+ vk_groups.setdefault(key, {"title": "VK пост", "items": []})
551
+ vk_groups[key]["items"].append((canonical, 0))
552
+ else:
553
+ title, views = vk_results.get(canonical, process_vk_link(owner_id, post_id, canonical))
554
+ key = str(owner_id)
555
+ if key not in vk_groups:
556
+ vk_groups[key] = {"title": title, "items": []}
557
+ if not vk_groups[key].get("title") or str(vk_groups[key]["title"]).startswith("**Ошибка"):
558
+ vk_groups[key]["title"] = title
559
+ vk_groups[key]["items"].append((canonical, views))
560
+ else:
561
+ errors.append(f"Неизвестная платформа: {canonical}")
562
+
563
+ tg_total_views = sum(v for g in tg_groups.values() for _, v in g["items"])
564
+ vk_total_views = sum(v for g in vk_groups.values() for _, v in g["items"])
565
+
566
+ tg_sorted = sorted(
567
+ tg_groups.items(),
568
+ key=lambda kv: sum(v for _, v in kv[1]["items"]),
569
+ reverse=True,
570
+ )
571
+ vk_sorted = sorted(
572
+ vk_groups.items(),
573
+ key=lambda kv: sum(v for _, v in kv[1]["items"]),
574
+ reverse=True,
575
+ )
576
+
577
+ html_lines: List[str] = []
578
+ text_lines: List[str] = []
579
+
580
+ # --- Telegram ---
581
+ html_lines.append("<h2>Telegram</h2>")
582
+ html_lines.append(f'Суммарно посты собрали <b>{human_format_views(tg_total_views)}</b> просмотров.')
583
+ text_lines.append("Telegram")
584
+ text_lines.append(f"Суммарно посты собрали {human_format_views(tg_total_views)} просмотров.")
585
+ if tg_sorted:
586
+ html_lines.append("<ol>")
587
+ for idx, (_, data) in enumerate(tg_sorted, start=1):
588
+ title = data["title"] or "Telegram"
589
+ items = data["items"]
590
+ first_link, _first_views = items[0]
591
+
592
+ title_html = _escape(title)
593
+ first_link_html = _escape(first_link)
594
+ line_html = f'<li><a href="{first_link_html}">{title_html}</a>'
595
+ if len(items) > 1:
596
+ for link2, _v in items[1:]:
597
+ line_html += f' + <a href="{_escape(link2)}">ещё</a>'
598
+ views_str = " + ".join(human_format_views(v) for _, v in items)
599
+ line_html += f" — {views_str}</li>"
600
+ html_lines.append(line_html)
601
+
602
+ line_text = f"{idx}. {title} ({first_link})"
603
+ if len(items) > 1:
604
+ extra_links = ", ".join(link2 for link2, _v in items[1:])
605
+ line_text += f" + ещё: {extra_links}"
606
+ line_text += f" — {views_str}"
607
+ text_lines.append(line_text)
608
+ html_lines.append("</ol>")
609
+ else:
610
+ html_lines.append("<i>Нет ссылок на Telegram</i>")
611
+ text_lines.append("Нет ссылок на Telegram")
612
+ html_lines.append("<br/>")
613
+ text_lines.append("")
614
+
615
+ # --- VK ---
616
+ html_lines.append("<h2>ВКонтакте</h2>")
617
+ html_lines.append(f'Суммарно посты собрали <b>{human_format_views(vk_total_views)}</b> просмотров.')
618
+ text_lines.append("ВКонтакте")
619
+ text_lines.append(f"Суммарно посты собрали {human_format_views(vk_total_views)} просмотров.")
620
+ if vk_sorted:
621
+ html_lines.append("<ol>")
622
+ for idx, (_, data) in enumerate(vk_sorted, start=1):
623
+ title = data["title"] or "VK пост"
624
+ items = data["items"]
625
+ first_link, _first_views = items[0]
626
+
627
+ title_html = _escape(title)
628
+ first_link_html = _escape(first_link)
629
+ line_html = f'<li><a href="{first_link_html}">{title_html}</a>'
630
+ if len(items) > 1:
631
+ for link2, _v in items[1:]:
632
+ line_html += f' + <a href="{_escape(link2)}">ещё</a>'
633
+ views_str = " + ".join(human_format_views(v) for _, v in items)
634
+ line_html += f" — {views_str}</li>"
635
+ html_lines.append(line_html)
636
+
637
+ line_text = f"{idx}. {title} ({first_link})"
638
+ if len(items) > 1:
639
+ extra_links = ", ".join(link2 for link2, _v in items[1:])
640
+ line_text += f" + ещё: {extra_links}"
641
+ line_text += f" — {views_str}"
642
+ text_lines.append(line_text)
643
+ html_lines.append("</ol>")
644
+ else:
645
+ html_lines.append("<i>Нет ссылок на ВКонтакте</i>")
646
+ text_lines.append("Нет ссылок на ВКонтакте")
647
+ html_lines.append("<br/>")
648
+ text_lines.append("")
649
+
650
+ if errors:
651
+ html_lines.append("<h2>Ошибки</h2>")
652
+ html_lines.append("<ul>")
653
+ for err in errors:
654
+ html_lines.append(f"<li>{_escape(err)}</li>")
655
+ text_lines.append(f"- {err}")
656
+ html_lines.append("</ul>")
657
+ html_lines.append("<br/>")
658
+ text_lines.append("")
659
+
660
+ return "\n".join(html_lines), "\n".join(text_lines), tg_total_views, vk_total_views, errors
661
+
662
+
663
+ @app.get("/health")
664
+ def health_check():
665
+ return {"status": "ok"}
666
+
667
+
668
+ @app.post("/parse", response_model=ParseResponse)
669
+ def parse_links(payload: ParseRequest):
670
+ raw_lines = normalize_links(payload.links)
671
+ if not raw_lines:
672
+ return ParseResponse(html="", text="", telegram_total=0, vk_total=0, errors=["Нет ссылок для обработки."])
673
+
674
+ seen = set()
675
+ lines: List[Tuple[str, str, object, object]] = []
676
+ for link in raw_lines:
677
+ plat = detect_platform(link)
678
+ if plat == "telegram":
679
+ canonical, username, mid = canonicalize_tg_link(link)
680
+ if not canonical:
681
+ canonical = link
682
+ username = None
683
+ mid = None
684
+ if canonical in seen:
685
+ continue
686
+ seen.add(canonical)
687
+ lines.append(("telegram", canonical, username, mid))
688
+ elif plat == "vk":
689
+ canonical, owner_id, post_id = canonicalize_vk_link(link)
690
+ if not canonical:
691
+ canonical = link
692
+ if canonical in seen:
693
+ continue
694
+ seen.add(canonical)
695
+ lines.append(("vk", canonical, owner_id, post_id))
696
+ else:
697
+ lines.append(("unknown", link, None, None))
698
+
699
+ html, text, tg_total, vk_total, errors = build_output(lines)
700
+ return ParseResponse(
701
+ html=html,
702
+ text=text,
703
+ telegram_total=tg_total,
704
+ vk_total=vk_total,
705
+ errors=errors,
706
+ )