Dink0 commited on
Commit
e9b6c2f
·
verified ·
1 Parent(s): dab18b6

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +527 -0
app.py ADDED
@@ -0,0 +1,527 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
24
+ app = FastAPI(title=APP_NAME)
25
+
26
+ # Для простоты разрешаем все источники. Можно сузить список доменов в проде.
27
+ app.add_middleware(
28
+ CORSMiddleware,
29
+ allow_origins=["*"],
30
+ allow_credentials=False,
31
+ allow_methods=["POST", "GET", "OPTIONS"],
32
+ allow_headers=["*"]
33
+ )
34
+
35
+
36
+ class ParseRequest(BaseModel):
37
+ links: Union[List[str], str]
38
+
39
+
40
+ class ParseResponse(BaseModel):
41
+ html: str
42
+ text: str
43
+ telegram_total: int
44
+ vk_total: int
45
+ errors: List[str]
46
+
47
+
48
+ # ---------- Вспомогательные функции ----------
49
+
50
+ def human_format_views(num: int) -> str:
51
+ if num >= 1_000_000:
52
+ value = num / 1_000_000
53
+ suffix = "M"
54
+ else:
55
+ value = num / 1000
56
+ suffix = "K"
57
+
58
+ rounded = round(value, 1)
59
+ if rounded.is_integer():
60
+ formatted = f"{int(rounded)}{suffix}"
61
+ else:
62
+ formatted = f"{rounded:.1f}{suffix}"
63
+
64
+ return formatted.replace(".", ",")
65
+
66
+
67
+ def detect_platform(link: str) -> str:
68
+ link = link.strip().lower()
69
+ if "t.me/" in link or "telegram.me/" in link:
70
+ return "telegram"
71
+ if "vk.com/wall" in link or "vk.ru/wall" in link:
72
+ return "vk"
73
+ return ""
74
+
75
+
76
+ def canonicalize_tg_link(link: str) -> Tuple[str | None, str | None, str | None]:
77
+ link = link.strip()
78
+ m = re.match(r"https?://(?:t(?:elegram)?\.me)/(?:s/)?([^/]+)/(?P<id>\d+)", link, re.IGNORECASE)
79
+ if not m:
80
+ return None, None, None
81
+ username = m.group(1)
82
+ message_id = m.group("id")
83
+ canonical = f"https://t.me/{username}/{message_id}"
84
+ return canonical, username, message_id
85
+
86
+
87
+ def canonicalize_vk_link(link: str) -> Tuple[str | None, int | None, str | None]:
88
+ link = link.strip()
89
+ m = re.search(r"(?:vk\.com|vk\.ru)/wall(-?\d+)_(\d+)", link)
90
+ if not m:
91
+ return None, None, None
92
+ owner_id = int(m.group(1))
93
+ post_id = m.group(2)
94
+ canonical = f"https://vk.com/wall{owner_id}_{post_id}"
95
+ return canonical, owner_id, post_id
96
+
97
+
98
+ def process_telegram_link(channel_username: str, message_id: str, canonical_link: str) -> Tuple[str, int]:
99
+ # Набор альтернативных адресов (если t.me не резолвится)
100
+ urls = [
101
+ f"https://t.me/{channel_username}/{message_id}?embed=1",
102
+ f"https://t.me/s/{channel_username}/{message_id}",
103
+ f"https://r.jina.ai/http://t.me/{channel_username}/{message_id}?embed=1",
104
+ f"https://r.jina.ai/http://t.me/s/{channel_username}/{message_id}",
105
+ ]
106
+ headers = {"User-Agent": "Mozilla/5.0"}
107
+
108
+ def _parse_views_number(s: str) -> int:
109
+ if not s:
110
+ return 0
111
+ s = s.strip().replace("\u00a0", "").replace(" ", "").replace(",", ".")
112
+ m2 = re.match(r"([\d\.]+)\s*([kKmM]?)", s)
113
+ if not m2:
114
+ return 0
115
+ val = float(m2.group(1))
116
+ suf = m2.group(2).lower()
117
+ if suf == "k":
118
+ val *= 1_000
119
+ elif suf == "m":
120
+ val *= 1_000_000
121
+ return int(val)
122
+
123
+ def _fetch_first_ok(url_list):
124
+ last_err = None
125
+ for u in url_list:
126
+ try:
127
+ resp = requests.get(u, headers=headers, timeout=REQUEST_TIMEOUT)
128
+ if resp.status_code == 200 and resp.text:
129
+ return resp.text
130
+ except Exception as exc:
131
+ last_err = exc
132
+ raise last_err or Exception("Не удалось получить страницу Telegram")
133
+
134
+ def _strip_emoji(s: str) -> str:
135
+ # Убираем emoji/pictographs и variation selectors.
136
+ s = re.sub(
137
+ r"[\U0001F1E6-\U0001F1FF\U0001F300-\U0001FAFF\u2600-\u27BF\uFE0E\uFE0F]",
138
+ "",
139
+ s,
140
+ )
141
+ return re.sub(r"\s+", " ", s).strip()
142
+
143
+ def _clean_title(raw: str, fallback: str) -> str:
144
+ title = re.sub(r"<[^>]+>", "", (raw or "").strip())
145
+ title = html_lib.unescape(title)
146
+ title = re.sub(r"\s*[-|]\s*Telegram\s*$", "", title, flags=re.IGNORECASE)
147
+ title = _strip_emoji(title)
148
+ return title or fallback
149
+
150
+ def _extract_views(html: str) -> int:
151
+ patterns = [
152
+ # Виджет Telegram
153
+ r'class="tgme_widget_message_views[^"]*">([\d\s\.,kKmM]+)<',
154
+ # Резервный вариант из meta-description, но только если рядом явно "views"
155
+ r'content="[^"]*?([\d\s\.,kKmM]+)\s+views[^"]*"',
156
+ r'content="[^"]*?(?:Просмотры|просмотры|просмотров)[:\s]*([\d\s\.,kKmM]+)[^"]*"',
157
+ # Текстовый fallback (например, через jina)
158
+ r'(?:^|\n)\s*(?:Views|views|Просмотры|просмотры|Просмотров|просмотров)\s*[:\-]?\s*([\d\s\.,kKmM]+)\b',
159
+ r'([\d\s\.,kKmM]+)\s*(?:views|Views|просмотров|Просмотров)\b',
160
+ ]
161
+ for pattern in patterns:
162
+ m = re.search(pattern, html, re.IGNORECASE)
163
+ if m:
164
+ parsed = _parse_views_number(m.group(1))
165
+ if parsed > 0:
166
+ return parsed
167
+ return 0
168
+
169
+ def _extract_text_title(raw_text: str) -> str | None:
170
+ patterns = [
171
+ r'(?:^|\n)\s*Title:\s*(.+)',
172
+ r'(?:^|\n)\s*Channel:\s*(.+)',
173
+ r'(?:^|\n)\s*#\s+(.+)',
174
+ ]
175
+ for pattern in patterns:
176
+ m = re.search(pattern, raw_text, re.IGNORECASE)
177
+ if m:
178
+ return m.group(1).strip()
179
+
180
+ for line in raw_text.splitlines():
181
+ line = line.strip()
182
+ if not line:
183
+ continue
184
+ if line.startswith("http://") or line.startswith("https://"):
185
+ continue
186
+ if "views" in line.lower() or "просмотр" in line.lower():
187
+ continue
188
+ if len(line) <= 120:
189
+ return line
190
+ return None
191
+
192
+ try:
193
+ html = _fetch_first_ok(urls)
194
+
195
+ title_raw = None
196
+ m_site_name = re.search(
197
+ r"<meta[^>]*property=[\"']og:site_name[\"'][^>]*content=[\"']([^\"']+)[\"']",
198
+ html,
199
+ re.IGNORECASE,
200
+ )
201
+ if m_site_name:
202
+ title_raw = m_site_name.group(1).strip()
203
+
204
+ m_title_meta = re.search(
205
+ r"<meta[^>]*property=[\"']og:title[\"'][^>]*content=[\"']([^\"']+)[\"']",
206
+ html,
207
+ re.IGNORECASE,
208
+ )
209
+ if m_title_meta and not title_raw:
210
+ title_raw = m_title_meta.group(1).strip()
211
+ if not title_raw:
212
+ m_title = re.search(
213
+ r'class="tgme_widget_message_author_name".*?<span[^>]*>(.*?)</span>',
214
+ html,
215
+ re.IGNORECASE | re.DOTALL,
216
+ )
217
+ if m_title:
218
+ title_raw = m_title.group(1).strip()
219
+ if not title_raw:
220
+ m_title = re.search(
221
+ r'class="tgme_widget_message_owner_name".*?<span[^>]*>(.*?)</span>',
222
+ html,
223
+ re.IGNORECASE | re.DOTALL,
224
+ )
225
+ if m_title:
226
+ title_raw = m_title.group(1).strip()
227
+ if not title_raw:
228
+ title_raw = _extract_text_title(html)
229
+ title = _clean_title(title_raw or "", channel_username)
230
+
231
+ views = _extract_views(html)
232
+
233
+ return title, views
234
+
235
+ except Exception as exc:
236
+ return f"Ошибка (TG) при обработке {canonical_link}: {exc}", 0
237
+
238
+
239
+ def process_vk_link(owner_id: int, post_id: str, canonical_link: str) -> Tuple[str, int]:
240
+ posts_param = f"{owner_id}_{post_id}"
241
+ api_url = "https://api.vk.com/method/wall.getById"
242
+
243
+ for attempt in range(1, VK_MAX_RETRIES + 1):
244
+ params = {
245
+ "posts": posts_param,
246
+ "v": VK_API_VERSION,
247
+ "extended": 1,
248
+ }
249
+ if VK_ACCESS_TOKEN:
250
+ params["access_token"] = VK_ACCESS_TOKEN
251
+
252
+ try:
253
+ resp = requests.get(api_url, params=params, timeout=REQUEST_TIMEOUT)
254
+ except Exception as exc:
255
+ return f"**Ошибка (VK)**: {exc} — {canonical_link}", 0
256
+
257
+ if resp.status_code != 200:
258
+ return f"**Ошибка (VK)**: HTTP {resp.status_code} — {canonical_link}", 0
259
+
260
+ data = resp.json()
261
+ if "error" in data:
262
+ error = data["error"]
263
+ error_msg = error.get("error_msg", "")
264
+ error_code = error.get("error_code")
265
+ if error_code == 6 or "too many requests per second" in error_msg.lower():
266
+ time.sleep(VK_RETRY_DELAY * attempt)
267
+ continue
268
+ return f"**Ошибка (VK)**: {error_msg or 'API error'} — {canonical_link}", 0
269
+ break
270
+ else:
271
+ return f"**Ошибка (VK)**: Too many requests per second — {canonical_link}", 0
272
+
273
+ response_data = data.get("response", {})
274
+ items = response_data.get("items", [])
275
+ if not items:
276
+ return f"**Ошибка (VK)**: пост не найден — {canonical_link}", 0
277
+
278
+ post = items[0]
279
+ views = post.get("views", {}).get("count", 0)
280
+ post_owner_id = post.get("owner_id", owner_id)
281
+
282
+ title = None
283
+ if post_owner_id < 0:
284
+ group_id = -post_owner_id
285
+ for group in response_data.get("groups", []):
286
+ if group.get("id") == group_id:
287
+ title = group.get("name")
288
+ break
289
+ else:
290
+ user_id = post_owner_id
291
+ for profile in response_data.get("profiles", []):
292
+ if profile.get("id") == user_id:
293
+ first_name = profile.get("first_name", "")
294
+ last_name = profile.get("last_name", "")
295
+ title = (first_name + " " + last_name).strip()
296
+ break
297
+ if not title:
298
+ title = "VK пост"
299
+
300
+ return title, views
301
+
302
+
303
+ def normalize_links(links: Union[List[str], str]) -> List[str]:
304
+ if isinstance(links, str):
305
+ raw = links.splitlines()
306
+ else:
307
+ raw = []
308
+ for item in links:
309
+ raw.extend(str(item).splitlines())
310
+ return [line.strip() for line in raw if line.strip()]
311
+
312
+
313
+ def _escape(text: str) -> str:
314
+ return html_lib.escape(text, quote=True)
315
+
316
+
317
+ def resolve_line_item(line: Tuple[str, str, object, object]) -> Tuple[str, str, str | None, int]:
318
+ plat, canonical, a, b = line
319
+
320
+ if plat == "telegram":
321
+ username, mid = a, b
322
+ if not username or not mid:
323
+ return plat, canonical, "Telegram", 0
324
+ title, views = process_telegram_link(username, mid, canonical)
325
+ return plat, canonical, title, views
326
+
327
+ if plat == "vk":
328
+ owner_id, post_id = a, b
329
+ if owner_id is None or post_id is None:
330
+ return plat, canonical, "VK пост", 0
331
+ title, views = process_vk_link(owner_id, post_id, canonical)
332
+ return plat, canonical, title, views
333
+
334
+ return plat, canonical, None, 0
335
+
336
+
337
+ def build_output(lines: List[Tuple[str, str, object, object]]) -> Tuple[str, str, int, int, List[str]]:
338
+ errors: List[str] = []
339
+
340
+ tg_groups = {}
341
+ vk_groups = {}
342
+
343
+ telegram_lines = [line for line in lines if line[0] == "telegram"]
344
+ telegram_results = {}
345
+ if telegram_lines:
346
+ worker_count = max(1, min(MAX_WORKERS, len(telegram_lines)))
347
+ with ThreadPoolExecutor(max_workers=worker_count) as executor:
348
+ for result in executor.map(resolve_line_item, telegram_lines):
349
+ telegram_results[result[1]] = result
350
+
351
+ for plat, canonical, a, b in lines:
352
+ if plat == "telegram":
353
+ username, mid = a, b
354
+ if not username or not mid:
355
+ key = canonical
356
+ tg_groups.setdefault(key, {"title": "Telegram", "items": []})
357
+ tg_groups[key]["items"].append((canonical, 0))
358
+ else:
359
+ _, _, title, views = telegram_results.get(canonical, (plat, canonical, username, 0))
360
+ key = username
361
+ if key not in tg_groups:
362
+ tg_groups[key] = {"title": title, "items": []}
363
+ if not tg_groups[key].get("title") or str(tg_groups[key]["title"]).startswith("**Ошибка"):
364
+ tg_groups[key]["title"] = title
365
+ tg_groups[key]["items"].append((canonical, views))
366
+
367
+ elif plat == "vk":
368
+ owner_id, post_id = a, b
369
+ if owner_id is None or post_id is None:
370
+ key = canonical
371
+ vk_groups.setdefault(key, {"title": "VK пост", "items": []})
372
+ vk_groups[key]["items"].append((canonical, 0))
373
+ else:
374
+ title, views = process_vk_link(owner_id, post_id, canonical)
375
+ key = str(owner_id)
376
+ if key not in vk_groups:
377
+ vk_groups[key] = {"title": title, "items": []}
378
+ if not vk_groups[key].get("title") or str(vk_groups[key]["title"]).startswith("**Ошибка"):
379
+ vk_groups[key]["title"] = title
380
+ vk_groups[key]["items"].append((canonical, views))
381
+ else:
382
+ errors.append(f"Неизвестная платформа: {canonical}")
383
+
384
+ tg_total_views = sum(v for g in tg_groups.values() for _, v in g["items"])
385
+ vk_total_views = sum(v for g in vk_groups.values() for _, v in g["items"])
386
+
387
+ tg_sorted = sorted(
388
+ tg_groups.items(),
389
+ key=lambda kv: sum(v for _, v in kv[1]["items"]),
390
+ reverse=True,
391
+ )
392
+ vk_sorted = sorted(
393
+ vk_groups.items(),
394
+ key=lambda kv: sum(v for _, v in kv[1]["items"]),
395
+ reverse=True,
396
+ )
397
+
398
+ html_lines: List[str] = []
399
+ text_lines: List[str] = []
400
+
401
+ # --- Telegram ---
402
+ html_lines.append("<h2>Telegram</h2>")
403
+ html_lines.append(f'Суммарно посты собрали <b>{human_format_views(tg_total_views)}</b> просмотров.')
404
+ text_lines.append("Telegram")
405
+ text_lines.append(f"Суммарно посты собрали {human_format_views(tg_total_views)} просмотров.")
406
+ if tg_sorted:
407
+ html_lines.append("<ol>")
408
+ for idx, (_, data) in enumerate(tg_sorted, start=1):
409
+ title = data["title"] or "Telegram"
410
+ items = data["items"]
411
+ first_link, _first_views = items[0]
412
+
413
+ title_html = _escape(title)
414
+ first_link_html = _escape(first_link)
415
+ line_html = f'<li><a href="{first_link_html}">{title_html}</a>'
416
+ if len(items) > 1:
417
+ for link2, _v in items[1:]:
418
+ line_html += f' + <a href="{_escape(link2)}">ещё</a>'
419
+ views_str = " + ".join(human_format_views(v) for _, v in items)
420
+ line_html += f" — {views_str}</li>"
421
+ html_lines.append(line_html)
422
+
423
+ line_text = f"{idx}. {title} ({first_link})"
424
+ if len(items) > 1:
425
+ extra_links = ", ".join(link2 for link2, _v in items[1:])
426
+ line_text += f" + ещё: {extra_links}"
427
+ line_text += f" — {views_str}"
428
+ text_lines.append(line_text)
429
+ html_lines.append("</ol>")
430
+ else:
431
+ html_lines.append("<i>Нет ссылок на Telegram</i>")
432
+ text_lines.append("Нет ссылок на Telegram")
433
+ html_lines.append("<br/>")
434
+ text_lines.append("")
435
+
436
+ # --- VK ---
437
+ html_lines.append("<h2>ВКонтакте</h2>")
438
+ html_lines.append(f'Суммарно посты собрали <b>{human_format_views(vk_total_views)}</b> просмотров.')
439
+ text_lines.append("ВКонтакте")
440
+ text_lines.append(f"Суммарно посты собрали {human_format_views(vk_total_views)} просмотров.")
441
+ if vk_sorted:
442
+ html_lines.append("<ol>")
443
+ for idx, (_, data) in enumerate(vk_sorted, start=1):
444
+ title = data["title"] or "VK пост"
445
+ items = data["items"]
446
+ first_link, _first_views = items[0]
447
+
448
+ title_html = _escape(title)
449
+ first_link_html = _escape(first_link)
450
+ line_html = f'<li><a href="{first_link_html}">{title_html}</a>'
451
+ if len(items) > 1:
452
+ for link2, _v in items[1:]:
453
+ line_html += f' + <a href="{_escape(link2)}">ещё</a>'
454
+ views_str = " + ".join(human_format_views(v) for _, v in items)
455
+ line_html += f" — {views_str}</li>"
456
+ html_lines.append(line_html)
457
+
458
+ line_text = f"{idx}. {title} ({first_link})"
459
+ if len(items) > 1:
460
+ extra_links = ", ".join(link2 for link2, _v in items[1:])
461
+ line_text += f" + ещё: {extra_links}"
462
+ line_text += f" — {views_str}"
463
+ text_lines.append(line_text)
464
+ html_lines.append("</ol>")
465
+ else:
466
+ html_lines.append("<i>Нет ссылок на ВКонтакте</i>")
467
+ text_lines.append("Нет ссылок на ВКонтакте")
468
+ html_lines.append("<br/>")
469
+ text_lines.append("")
470
+
471
+ if errors:
472
+ html_lines.append("<h2>Ошибки</h2>")
473
+ html_lines.append("<ul>")
474
+ for err in errors:
475
+ html_lines.append(f"<li>{_escape(err)}</li>")
476
+ text_lines.append(f"- {err}")
477
+ html_lines.append("</ul>")
478
+ html_lines.append("<br/>")
479
+ text_lines.append("")
480
+
481
+ return "\n".join(html_lines), "\n".join(text_lines), tg_total_views, vk_total_views, errors
482
+
483
+
484
+ @app.get("/health")
485
+ def health_check():
486
+ return {"status": "ok"}
487
+
488
+
489
+ @app.post("/parse", response_model=ParseResponse)
490
+ def parse_links(payload: ParseRequest):
491
+ raw_lines = normalize_links(payload.links)
492
+ if not raw_lines:
493
+ return ParseResponse(html="", text="", telegram_total=0, vk_total=0, errors=["Нет ссылок для обработки."])
494
+
495
+ seen = set()
496
+ lines: List[Tuple[str, str, object, object]] = []
497
+ for link in raw_lines:
498
+ plat = detect_platform(link)
499
+ if plat == "telegram":
500
+ canonical, username, mid = canonicalize_tg_link(link)
501
+ if not canonical:
502
+ canonical = link
503
+ username = None
504
+ mid = None
505
+ if canonical in seen:
506
+ continue
507
+ seen.add(canonical)
508
+ lines.append(("telegram", canonical, username, mid))
509
+ elif plat == "vk":
510
+ canonical, owner_id, post_id = canonicalize_vk_link(link)
511
+ if not canonical:
512
+ canonical = link
513
+ if canonical in seen:
514
+ continue
515
+ seen.add(canonical)
516
+ lines.append(("vk", canonical, owner_id, post_id))
517
+ else:
518
+ lines.append(("unknown", link, None, None))
519
+
520
+ html, text, tg_total, vk_total, errors = build_output(lines)
521
+ return ParseResponse(
522
+ html=html,
523
+ text=text,
524
+ telegram_total=tg_total,
525
+ vk_total=vk_total,
526
+ errors=errors,
527
+ )