Dink0 commited on
Commit
34ea1e3
·
verified ·
1 Parent(s): 7099dd2

Upload app.py

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