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

Delete app.py

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