Dink0 commited on
Commit
c5a38d0
·
verified ·
1 Parent(s): 3cbfefc

Upload 3 files

Browse files
Files changed (3) hide show
  1. Dockerfile +12 -0
  2. app.py +372 -0
  3. requirements.txt +4 -0
Dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt ./
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY app.py ./
9
+
10
+ EXPOSE 7860
11
+
12
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
app.py ADDED
@@ -0,0 +1,372 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ from typing import List, Tuple, Union
6
+
7
+ import requests
8
+ from fastapi import FastAPI
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from pydantic import BaseModel
11
+
12
+ APP_NAME = "pr-tool-backend"
13
+
14
+ VK_API_VERSION = os.getenv("VK_API_VERSION", "5.131")
15
+ VK_ACCESS_TOKEN = os.getenv("VK_ACCESS_TOKEN", "")
16
+ REQUEST_TIMEOUT = float(os.getenv("REQUEST_TIMEOUT", "15"))
17
+
18
+ app = FastAPI(title=APP_NAME)
19
+
20
+ # Для простоты разрешаем все источники. Можно сузить список доменов в проде.
21
+ app.add_middleware(
22
+ CORSMiddleware,
23
+ allow_origins=["*"],
24
+ allow_credentials=False,
25
+ allow_methods=["POST", "GET", "OPTIONS"],
26
+ allow_headers=["*"]
27
+ )
28
+
29
+
30
+ class ParseRequest(BaseModel):
31
+ links: Union[List[str], str]
32
+
33
+
34
+ class ParseResponse(BaseModel):
35
+ markdown: str
36
+ telegram_total: int
37
+ vk_total: int
38
+ errors: List[str]
39
+
40
+
41
+ # ---------- Вспомогательные функции ----------
42
+
43
+ def human_format_views(num: int) -> str:
44
+ if num >= 1_000_000:
45
+ formatted = f"{num / 1_000_000:.1f}M"
46
+ else:
47
+ formatted = f"{num / 1000:.1f}K"
48
+ return formatted.replace(".", ",")
49
+
50
+
51
+ def detect_platform(link: str) -> str:
52
+ link = link.strip().lower()
53
+ if "t.me/" in link or "telegram.me/" in link:
54
+ return "telegram"
55
+ if "vk.com/wall" in link:
56
+ return "vk"
57
+ return ""
58
+
59
+
60
+ def canonicalize_tg_link(link: str) -> Tuple[str | None, str | None, str | None]:
61
+ link = link.strip()
62
+ m = re.match(r"https?://(?:t(?:elegram)?\.me)/(?:s/)?([^/]+)/(?P<id>\d+)", link, re.IGNORECASE)
63
+ if not m:
64
+ return None, None, None
65
+ username = m.group(1)
66
+ message_id = m.group("id")
67
+ canonical = f"https://t.me/{username}/{message_id}"
68
+ return canonical, username, message_id
69
+
70
+
71
+ def canonicalize_vk_link(link: str) -> Tuple[str | None, int | None, str | None]:
72
+ link = link.strip()
73
+ m = re.search(r"vk\.com/wall(-?\d+)_(\d+)", link)
74
+ if not m:
75
+ return None, None, None
76
+ owner_id = int(m.group(1))
77
+ post_id = m.group(2)
78
+ canonical = f"https://vk.com/wall{owner_id}_{post_id}"
79
+ return canonical, owner_id, post_id
80
+
81
+
82
+ def process_telegram_link(channel_username: str, message_id: str, canonical_link: str) -> Tuple[str, int]:
83
+ # Набор альтернативных адресов (если t.me не резолвится)
84
+ urls = [
85
+ f"https://t.me/{channel_username}/{message_id}?embed=1",
86
+ f"https://t.me/s/{channel_username}/{message_id}",
87
+ f"https://telegram.me/{channel_username}/{message_id}?embed=1",
88
+ f"https://r.jina.ai/http://t.me/{channel_username}/{message_id}?embed=1",
89
+ f"https://r.jina.ai/http://t.me/s/{channel_username}/{message_id}",
90
+ f"https://r.jina.ai/http://telegram.me/{channel_username}/{message_id}?embed=1",
91
+ ]
92
+ headers = {"User-Agent": "Mozilla/5.0"}
93
+
94
+ def _parse_views_number(s: str) -> int:
95
+ if not s:
96
+ return 0
97
+ s = s.strip().replace(" ", "").replace(",", ".")
98
+ m2 = re.match(r"([\d\.]+)\s*([kKmM]?)", s)
99
+ if not m2:
100
+ return 0
101
+ val = float(m2.group(1))
102
+ suf = m2.group(2).lower()
103
+ if suf == "k":
104
+ val *= 1_000
105
+ elif suf == "m":
106
+ val *= 1_000_000
107
+ return int(val)
108
+
109
+ def _fetch_first_ok(url_list):
110
+ last_err = None
111
+ for u in url_list:
112
+ try:
113
+ resp = requests.get(u, headers=headers, timeout=REQUEST_TIMEOUT)
114
+ if resp.status_code == 200 and resp.text:
115
+ return resp.text
116
+ except Exception as exc:
117
+ last_err = exc
118
+ raise last_err or Exception("Не удалось получить страницу Telegram")
119
+
120
+ try:
121
+ html = _fetch_first_ok(urls)
122
+
123
+ title = None
124
+ m_title_meta = re.search(
125
+ r"<meta[^>]*property=[\"']og:title[\"'][^>]*content=[\"']([^\"']+)[\"']",
126
+ html,
127
+ re.IGNORECASE,
128
+ )
129
+ if m_title_meta:
130
+ title = m_title_meta.group(1).strip()
131
+ if not title:
132
+ m_title = re.search(
133
+ r'class="tgme_widget_message_owner_name".*?<span[^>]*>(.*?)</span>',
134
+ html,
135
+ re.IGNORECASE | re.DOTALL,
136
+ )
137
+ if m_title:
138
+ title = m_title.group(1).strip()
139
+ if not title:
140
+ title = channel_username
141
+
142
+ views = 0
143
+ m_views = re.search(
144
+ r'class="tgme_widget_message_views[^"]*">([\d\s\.,kKmM]+)<',
145
+ html,
146
+ re.IGNORECASE,
147
+ )
148
+ if m_views:
149
+ views = _parse_views_number(m_views.group(1))
150
+ else:
151
+ candidates = re.findall(
152
+ r'(\d[\d\s\.,]*)([kKmM]?)\s*(?:views|просмотр|просмотра|просмотров|переглядів|visualizações|visualizzazioni|ansichten|visninger|visitas)?',
153
+ html,
154
+ re.IGNORECASE,
155
+ )
156
+ if candidates:
157
+ last_num, last_suf = candidates[-1]
158
+ views = _parse_views_number(last_num + last_suf)
159
+
160
+ return title, views
161
+
162
+ except Exception as exc:
163
+ return f"**Ошибка (TG)** при обработке [{canonical_link}]({canonical_link}): {exc}", 0
164
+
165
+
166
+ def process_vk_link(owner_id: int, post_id: str, canonical_link: str) -> Tuple[str, int]:
167
+ posts_param = f"{owner_id}_{post_id}"
168
+ api_url = "https://api.vk.com/method/wall.getById"
169
+
170
+ params = {
171
+ "posts": posts_param,
172
+ "v": VK_API_VERSION,
173
+ "extended": 1,
174
+ }
175
+ if VK_ACCESS_TOKEN:
176
+ params["access_token"] = VK_ACCESS_TOKEN
177
+
178
+ try:
179
+ resp = requests.get(api_url, params=params, timeout=REQUEST_TIMEOUT)
180
+ except Exception as exc:
181
+ return f"**Ошибка (VK)**: {exc} — {canonical_link}", 0
182
+
183
+ if resp.status_code != 200:
184
+ return f"**Ошибка (VK)**: HTTP {resp.status_code} — {canonical_link}", 0
185
+
186
+ data = resp.json()
187
+ if "error" in data:
188
+ error_msg = data["error"].get("error_msg", "")
189
+ return f"**Ошибка (VK)**: {error_msg or 'API error'} — {canonical_link}", 0
190
+
191
+ response_data = data.get("response", {})
192
+ items = response_data.get("items", [])
193
+ if not items:
194
+ return f"**Ошибка (VK)**: пост не найден — {canonical_link}", 0
195
+
196
+ post = items[0]
197
+ views = post.get("views", {}).get("count", 0)
198
+ post_owner_id = post.get("owner_id", owner_id)
199
+
200
+ title = None
201
+ if post_owner_id < 0:
202
+ group_id = -post_owner_id
203
+ for group in response_data.get("groups", []):
204
+ if group.get("id") == group_id:
205
+ title = group.get("name")
206
+ break
207
+ else:
208
+ user_id = post_owner_id
209
+ for profile in response_data.get("profiles", []):
210
+ if profile.get("id") == user_id:
211
+ first_name = profile.get("first_name", "")
212
+ last_name = profile.get("last_name", "")
213
+ title = (first_name + " " + last_name).strip()
214
+ break
215
+ if not title:
216
+ title = "VK пост"
217
+
218
+ return title, views
219
+
220
+
221
+ def normalize_links(links: Union[List[str], str]) -> List[str]:
222
+ if isinstance(links, str):
223
+ raw = links.splitlines()
224
+ else:
225
+ raw = []
226
+ for item in links:
227
+ raw.extend(str(item).splitlines())
228
+ return [line.strip() for line in raw if line.strip()]
229
+
230
+
231
+ def build_markdown(lines: List[Tuple[str, str, object, object]]) -> Tuple[str, int, int, List[str]]:
232
+ errors: List[str] = []
233
+
234
+ tg_groups = {}
235
+ vk_groups = {}
236
+
237
+ for plat, canonical, a, b in lines:
238
+ if plat == "telegram":
239
+ username, mid = a, b
240
+ if not username or not mid:
241
+ key = canonical
242
+ tg_groups.setdefault(key, {"title": "Telegram", "items": []})
243
+ tg_groups[key]["items"].append((canonical, 0))
244
+ else:
245
+ title, views = process_telegram_link(username, mid, canonical)
246
+ key = username
247
+ if key not in tg_groups:
248
+ tg_groups[key] = {"title": title, "items": []}
249
+ if not tg_groups[key].get("title") or str(tg_groups[key]["title"]).startswith("**Ошибка"):
250
+ tg_groups[key]["title"] = title
251
+ tg_groups[key]["items"].append((canonical, views))
252
+
253
+ elif plat == "vk":
254
+ owner_id, post_id = a, b
255
+ if owner_id is None or post_id is None:
256
+ key = canonical
257
+ vk_groups.setdefault(key, {"title": "VK пост", "items": []})
258
+ vk_groups[key]["items"].append((canonical, 0))
259
+ else:
260
+ title, views = process_vk_link(owner_id, post_id, canonical)
261
+ key = str(owner_id)
262
+ if key not in vk_groups:
263
+ vk_groups[key] = {"title": title, "items": []}
264
+ if not vk_groups[key].get("title") or str(vk_groups[key]["title"]).startswith("**Ошибка"):
265
+ vk_groups[key]["title"] = title
266
+ vk_groups[key]["items"].append((canonical, views))
267
+ else:
268
+ errors.append(f"Неизвестная платформа: {canonical}")
269
+
270
+ tg_total_views = sum(v for g in tg_groups.values() for _, v in g["items"])
271
+ vk_total_views = sum(v for g in vk_groups.values() for _, v in g["items"])
272
+
273
+ tg_sorted = sorted(
274
+ tg_groups.items(),
275
+ key=lambda kv: sum(v for _, v in kv[1]["items"]),
276
+ reverse=True,
277
+ )
278
+ vk_sorted = sorted(
279
+ vk_groups.items(),
280
+ key=lambda kv: sum(v for _, v in kv[1]["items"]),
281
+ reverse=True,
282
+ )
283
+
284
+ md_lines: List[str] = []
285
+
286
+ md_lines.append("## Telegram")
287
+ md_lines.append(f"**Суммарный охват постов — {human_format_views(tg_total_views)} просмотров**")
288
+ if tg_sorted:
289
+ for idx, (_, data) in enumerate(tg_sorted, start=1):
290
+ title = data["title"] or "Telegram"
291
+ items = data["items"]
292
+ first_link, _first_views = items[0]
293
+ line = f"{idx}. [{title}]({first_link})"
294
+ if len(items) > 1:
295
+ for link2, _v in items[1:]:
296
+ line += f" + [ещё]({link2})"
297
+ views_str = " + ".join(human_format_views(v) for _, v in items)
298
+ line += f" — {views_str}"
299
+ md_lines.append(line)
300
+ else:
301
+ md_lines.append("_Нет ссылок на Telegram_")
302
+ md_lines.append("")
303
+
304
+ md_lines.append("## ВКонтакте")
305
+ md_lines.append(f"**Суммарный охват постов — {human_format_views(vk_total_views)} просмотров**")
306
+ if vk_sorted:
307
+ for idx, (_, data) in enumerate(vk_sorted, start=1):
308
+ title = data["title"] or "VK пост"
309
+ items = data["items"]
310
+ first_link, _first_views = items[0]
311
+ line = f"{idx}. [{title}]({first_link})"
312
+ if len(items) > 1:
313
+ for link2, _v in items[1:]:
314
+ line += f" + [ещё]({link2})"
315
+ views_str = " + ".join(human_format_views(v) for _, v in items)
316
+ line += f" — {views_str}"
317
+ md_lines.append(line)
318
+ else:
319
+ md_lines.append("_Нет ссылок на ВКонтакте_")
320
+ md_lines.append("")
321
+
322
+ if errors:
323
+ md_lines.append("## Ошибки")
324
+ md_lines.extend(f"- {err}" for err in errors)
325
+ md_lines.append("")
326
+
327
+ return "\n".join(md_lines), tg_total_views, vk_total_views, errors
328
+
329
+
330
+ @app.get("/health")
331
+ def health_check():
332
+ return {"status": "ok"}
333
+
334
+
335
+ @app.post("/parse", response_model=ParseResponse)
336
+ def parse_links(payload: ParseRequest):
337
+ raw_lines = normalize_links(payload.links)
338
+ if not raw_lines:
339
+ return ParseResponse(markdown="", telegram_total=0, vk_total=0, errors=["Нет ссылок для обработки."])
340
+
341
+ seen = set()
342
+ lines: List[Tuple[str, str, object, object]] = []
343
+ for link in raw_lines:
344
+ plat = detect_platform(link)
345
+ if plat == "telegram":
346
+ canonical, username, mid = canonicalize_tg_link(link)
347
+ if not canonical:
348
+ canonical = link
349
+ username = None
350
+ mid = None
351
+ if canonical in seen:
352
+ continue
353
+ seen.add(canonical)
354
+ lines.append(("telegram", canonical, username, mid))
355
+ elif plat == "vk":
356
+ canonical, owner_id, post_id = canonicalize_vk_link(link)
357
+ if not canonical:
358
+ canonical = link
359
+ if canonical in seen:
360
+ continue
361
+ seen.add(canonical)
362
+ lines.append(("vk", canonical, owner_id, post_id))
363
+ else:
364
+ lines.append(("unknown", link, None, None))
365
+
366
+ markdown, tg_total, vk_total, errors = build_markdown(lines)
367
+ return ParseResponse(
368
+ markdown=markdown,
369
+ telegram_total=tg_total,
370
+ vk_total=vk_total,
371
+ errors=errors,
372
+ )
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ fastapi==0.110.0
2
+ uvicorn[standard]==0.27.1
3
+ requests==2.31.0
4
+ pydantic==2.6.1