Dink0 commited on
Commit
4e528a1
·
verified ·
1 Parent(s): bd86148

Delete app.py

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