Dink0 commited on
Commit
f6b2354
·
verified ·
1 Parent(s): 6341223

Upload app.py

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