Dink0 commited on
Commit
3c76e6b
·
verified ·
1 Parent(s): 89e8768

Upload app.py

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