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

Delete app.py

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