Dink0 commited on
Commit
89e8768
·
verified ·
1 Parent(s): 0162a2b

Delete app.py

Browse files
Files changed (1) hide show
  1. app.py +0 -964
app.py DELETED
@@ -1,964 +0,0 @@
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
- )