Dink0 commited on
Commit
b988df0
·
verified ·
1 Parent(s): 79b0542

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +591 -38
src/streamlit_app.py CHANGED
@@ -1,40 +1,593 @@
1
- import altair as alt
2
- import numpy as np
3
- import pandas as pd
4
  import streamlit as st
 
 
 
 
 
 
 
 
 
5
 
6
- """
7
- # Welcome to Streamlit!
8
-
9
- Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
10
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
11
- forums](https://discuss.streamlit.io).
12
-
13
- In the meantime, below is an example of what you can do with just a few lines of code:
14
- """
15
-
16
- num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
17
- num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
18
-
19
- indices = np.linspace(0, 1, num_points)
20
- theta = 2 * np.pi * num_turns * indices
21
- radius = indices
22
-
23
- x = radius * np.cos(theta)
24
- y = radius * np.sin(theta)
25
-
26
- df = pd.DataFrame({
27
- "x": x,
28
- "y": y,
29
- "idx": indices,
30
- "rand": np.random.randn(num_points),
31
- })
32
-
33
- st.altair_chart(alt.Chart(df, height=700, width=700)
34
- .mark_point(filled=True)
35
- .encode(
36
- x=alt.X("x", axis=None),
37
- y=alt.Y("y", axis=None),
38
- color=alt.Color("idx", legend=None, scale=alt.Scale()),
39
- size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
40
- ))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import streamlit as st
2
+ import asyncio
3
+ import nest_asyncio
4
+ import re
5
+ import requests
6
+ import urllib.parse
7
+ import hashlib
8
+ import base64
9
+ import secrets
10
+ import streamlit.components.v1 as components
11
 
12
+ # Применяем nest_asyncio для работы с asyncio в Streamlit
13
+ nest_asyncio.apply()
14
+
15
+ # ----------- Настройки Telegram -----------
16
+ TELEGRAM_API_ID = 23646321
17
+ TELEGRAM_API_HASH = "102edf31d404cb72d17ee4831d4e8c7b"
18
+ TELEGRAM_STRING_SESSION = "1ApWapzMBu3AR6vZZynrQwc59LD9XDRzbMtXVii-kfgNPeguoA2KOwyFqhsi1X_zDAsBh1DGvmVv46DmtZqk0KCd0jg9XXeS8T05E51D3Al5Ps8NNOF7fMWnpli2ROM2lou-t_8KpYbPoYnNXBAO2SzYkEhvyqQPSD7xGTWVkCLAVWm_s8Lqi-SCpXeOi341K5CR9nUCzaDkBmD3eG8SfqUp6GVO2QksGDZt3YMftWpXDwxsjuFPeeExoIawVD0Kxpjp20V3nS26SlR1-tm9lvOs9ATI_6QWPcCScT3UR5FikvrudyHNE55ZLpwbt3TRGuDMHv936DBtdHwGIQaNWSUf_5lEhIb0="
19
+
20
+ # ----------- Настройки ВКонтакте -----------
21
+ VK_CLIENT_ID = 53202706
22
+ VK_CLIENT_SECRET = "piaMXHEDj0afa4QObJCD"
23
+ VK_API_VERSION = "5.131"
24
+ REDIRECT_URI = "https://huggingface.co/spaces/PRAnalytics/PR_Tool"
25
+
26
+ # Инициализация токенов в session_state
27
+ if "VK_ACCESS_TOKEN" not in st.session_state:
28
+ st.session_state["VK_ACCESS_TOKEN"] = ""
29
+ if "VK_REFRESH_TOKEN" not in st.session_state:
30
+ st.session_state["VK_REFRESH_TOKEN"] = ""
31
+ if "VK_DEVICE_ID" not in st.session_state:
32
+ st.session_state["VK_DEVICE_ID"] = ""
33
+
34
+ # Функции для генерации code_verifier и code_challenge
35
+ def generate_code_verifier(length=128):
36
+ return secrets.token_urlsafe(length)[:length]
37
+
38
+ def generate_code_challenge(code_verifier):
39
+ hashed = hashlib.sha256(code_verifier.encode('utf-8')).digest()
40
+ return base64.urlsafe_b64encode(hashed).decode('utf-8').rstrip('=')
41
+
42
+ # Функция для авторизации через VK ID
43
+ def vk_auth():
44
+ if 'code_verifier' not in st.session_state:
45
+ st.session_state.code_verifier = generate_code_verifier()
46
+
47
+ # Проверяем наличие токена сразу после перезагрузки
48
+ if st.session_state.get("VK_ACCESS_TOKEN"):
49
+ return # Прерываем авторизацию, если токен уже есть
50
+
51
+ # Шаг 2: Извлечение параметров из URL
52
+ query_params = st.experimental_get_query_params()
53
+ user_code = query_params.get("code", [None])[0]
54
+ user_device_id = query_params.get("device_id", [None])[0]
55
+ user_state = query_params.get("state", [None])[0]
56
+
57
+ if user_code and user_state:
58
+ with st.expander("Технические подробности", expanded=False):
59
+ st.write("Автоматически извлечены параметры из URL:")
60
+ st.write(f"code: {user_code}")
61
+ st.write(f"device_id: {user_device_id}")
62
+ st.write(f"state: {user_state}")
63
+
64
+ # Используем code_verifier из параметра state
65
+ code_verifier = user_state
66
+
67
+ url = "https://id.vk.com/oauth2/auth"
68
+ data = {
69
+ "grant_type": "authorization_code",
70
+ "client_id": VK_CLIENT_ID,
71
+ "client_secret": VK_CLIENT_SECRET,
72
+ "redirect_uri": REDIRECT_URI,
73
+ "code": user_code,
74
+ "code_verifier": code_verifier
75
+ }
76
+ if user_device_id:
77
+ data["device_id"] = user_device_id
78
+
79
+ st.write("Отправляем запрос на:", url)
80
+ st.write("Параметры запроса:", data)
81
+
82
+ # Отправка запроса на обмен кода на токены
83
+ resp = requests.post(url, data=data)
84
+ st.write("Status code:", resp.status_code)
85
+ st.write("Response text:", resp.text)
86
+
87
+ try:
88
+ tokens = resp.json()
89
+ st.write("Ответ (JSON):")
90
+ st.json(tokens)
91
+
92
+ # Сохраняем токены
93
+ st.session_state["VK_ACCESS_TOKEN"] = tokens["access_token"]
94
+ st.session_state["VK_REFRESH_TOKEN"] = tokens["refresh_token"]
95
+ st.session_state["VK_DEVICE_ID"] = user_device_id
96
+ st.success("Токены успешно получены и сохранены!")
97
+
98
+ # Закрываем технические подробности
99
+ st.experimental_set_query_params()
100
+
101
+ # Немедленный переход к парсеру без перезагрузки
102
+ st.rerun()
103
+ except Exception as e:
104
+ st.error(f"Ошибка: {e}")
105
+ return # Важно: прерываем выполнение после обработки кода
106
+
107
+ # Если дошли сюда, показываем кнопку авторизации
108
+ st.markdown("### Сначала пройдём авторизацию")
109
+ code_challenge = generate_code_challenge(st.session_state.code_verifier)
110
+ auth_params = {
111
+ "client_id": VK_CLIENT_ID,
112
+ "redirect_uri": REDIRECT_URI,
113
+ "response_type": "code",
114
+ "scope": "wall",
115
+ "state": st.session_state.code_verifier,
116
+ "code_challenge": code_challenge,
117
+ "code_challenge_method": "S256",
118
+ "response_mode": "query"
119
+ }
120
+ auth_url = "https://id.vk.com/authorize?" + urllib.parse.urlencode(auth_params, safe="-._~")
121
+ st.markdown(
122
+ f"""
123
+ <a href="{auth_url}" style="
124
+ display: inline-block;
125
+ background-color: #4a76a8;
126
+ color: white;
127
+ text-decoration: none;
128
+ padding: 10px 20px;
129
+ border-radius: 4px;
130
+ font-family: -apple-system,BlinkMacSystemFont,Roboto,Helvetica Neue,sans-serif;
131
+ font-weight: 500;
132
+ font-size: 16px;
133
+ margin: 10px 0;
134
+ text-align: center;
135
+ ">
136
+ Авторизоваться через VK ID
137
+ </a>
138
+ """,
139
+ unsafe_allow_html=True
140
+ )
141
+
142
+ # Функция для обновления токена через refresh_token
143
+ def refresh_vk_token() -> bool:
144
+ st.write("Пытаемся обновить токен...")
145
+
146
+ current_refresh = st.session_state["VK_REFRESH_TOKEN"]
147
+ device_id = st.session_state.get("VK_DEVICE_ID", "")
148
+
149
+ if not current_refresh or not VK_CLIENT_ID or not VK_CLIENT_SECRET or not device_id:
150
+ st.error("Невозможно обновить токен: нет refresh_token, client_id, client_secret или device_id.")
151
+ return False
152
+
153
+ url = "https://id.vk.com/oauth2/auth"
154
+ data = {
155
+ "grant_type": "refresh_token",
156
+ "client_id": VK_CLIENT_ID,
157
+ "client_secret": VK_CLIENT_SECRET,
158
+ "refresh_token": current_refresh,
159
+ "device_id": device_id
160
+ }
161
+
162
+ headers = {"Content-Type": "application/x-www-form-urlencoded"}
163
+
164
+ resp = requests.post(url, data=data, headers=headers)
165
+ with st.expander("Посмотреть логи обновления токена"):
166
+ st.write(f"Статус ответа: {resp.status_code}")
167
+ st.write(f"Текст ответа: {resp.text}")
168
+
169
+ if resp.status_code != 200:
170
+ st.error(f"Ошибка обновления токена (HTTP {resp.status_code}): {resp.text}")
171
+ return False
172
+
173
+ try:
174
+ result = resp.json()
175
+ with st.expander("Посмотреть JSON-ответ обновления токена"):
176
+ st.write(f"Ответ от VK API: {result}")
177
+ except Exception as e:
178
+ st.error(f"Не удалось распарсить ответ при обновлении токена: {e}")
179
+ return False
180
+
181
+ if "access_token" not in result:
182
+ err_desc = result.get("error_description", result)
183
+ st.error(f"Не удалось получить новый access_token: {err_desc}")
184
+ return False
185
+
186
+ # Сохраняем новые значения
187
+ new_access = result["access_token"]
188
+ st.session_state["VK_ACCESS_TOKEN"] = new_access
189
+
190
+ if "refresh_token" in result:
191
+ new_refresh = result["refresh_token"]
192
+ st.session_state["VK_REFRESH_TOKEN"] = new_refresh
193
+ st.write(f"Новый refresh_token: {new_refresh}")
194
+ else:
195
+ st.warning("Новый refresh_token не был получен. Убедитесь, что это ожидаемо.")
196
+
197
+ st.success("VK-токен обновлён успешно!")
198
+ st.write(f"Новый access_token: {new_access}")
199
+
200
+ return True
201
+
202
+ # Функция для форматирования числа просмотров
203
+ def human_format_views(num: int) -> str:
204
+ if num >= 1_000_000:
205
+ formatted = f"{num / 1_000_000:.1f}M"
206
+ else:
207
+ formatted = f"{num / 1000:.1f}K"
208
+ return formatted.replace('.', ',')
209
+
210
+ # Функция для обработки ссылок Telegram
211
+ def process_telegram_link(channel_username: str, message_id: str, canonical_link: str) -> tuple[str, int]:
212
+ import re, requests
213
+
214
+ # Набор альтернативных адресов (если t.me не резолвится)
215
+ urls = [
216
+ f"https://t.me/{channel_username}/{message_id}?embed=1",
217
+ f"https://t.me/s/{channel_username}/{message_id}",
218
+ f"https://telegram.me/{channel_username}/{message_id}?embed=1",
219
+ # зеркала через r.jina.ai (обходит проблемы DNS/блокировок)
220
+ f"https://r.jina.ai/http://t.me/{channel_username}/{message_id}?embed=1",
221
+ f"https://r.jina.ai/http://t.me/s/{channel_username}/{message_id}",
222
+ f"https://r.jina.ai/http://telegram.me/{channel_username}/{message_id}?embed=1",
223
+ ]
224
+ headers = {"User-Agent": "Mozilla/5.0"}
225
+
226
+ def _parse_views_number(s: str) -> int:
227
+ if not s:
228
+ return 0
229
+ s = s.strip().replace(" ", "").replace(",", ".")
230
+ m2 = re.match(r'([\d\.]+)\s*([kKmM]?)', s)
231
+ if not m2:
232
+ return 0
233
+ val = float(m2.group(1))
234
+ suf = m2.group(2).lower()
235
+ if suf == "k":
236
+ val *= 1_000
237
+ elif suf == "m":
238
+ val *= 1_000_000
239
+ return int(val)
240
+
241
+ def _fetch_first_ok(url_list):
242
+ last_err = None
243
+ for u in url_list:
244
+ try:
245
+ resp = requests.get(u, headers=headers, timeout=15)
246
+ if resp.status_code == 200 and resp.text:
247
+ return resp.text
248
+ except Exception as e:
249
+ last_err = e
250
+ raise last_err or Exception("Не удалось получить страницу Telegram")
251
+
252
+ try:
253
+ html = _fetch_first_ok(urls)
254
+
255
+ # Title
256
+ title = None
257
+ m_title_meta = re.search(r'<meta[^>]*property=["\']og:title["\'][^>]*content=["\']([^"\']+)["\']', html, re.IGNORECASE)
258
+ if m_title_meta:
259
+ title = m_title_meta.group(1).strip()
260
+ if not title:
261
+ m_title = re.search(
262
+ r'class="tgme_widget_message_owner_name".*?<span[^>]*>(.*?)</span>',
263
+ html, re.IGNORECASE | re.DOTALL
264
+ )
265
+ if m_title:
266
+ title = m_title.group(1).strip()
267
+ if not title:
268
+ title = channel_username
269
+
270
+ # Views
271
+ views = 0
272
+ m_views = re.search(
273
+ r'class="tgme_widget_message_views[^"]*">([\d\s\.,kKmM]+)<',
274
+ html, re.IGNORECASE
275
+ )
276
+ if m_views:
277
+ views = _parse_views_number(m_views.group(1))
278
+ else:
279
+ candidates = re.findall(
280
+ r'(\d[\d\s\.,]*)([kKmM]?)\s*(?:views|просмотр|просмотра|просмотров|переглядів|visualizações|visualizzazioni|ansichten|visninger|visitas)?',
281
+ html, re.IGNORECASE
282
+ )
283
+ if candidates:
284
+ last_num, last_suf = candidates[-1]
285
+ views = _parse_views_number(last_num + last_suf)
286
+
287
+ return title, views
288
+
289
+ except Exception as e:
290
+ # Возвращаем «технический» title и 0, чтобы не ломать общий вывод
291
+ return f"**Ошибка (TG)** при обработке [{canonical_link}]({canonical_link}): {e}", 0
292
+
293
+ def process_vk_link(owner_id: int, post_id: str, canonical_link: str) -> tuple[str, int]:
294
+ posts_param = f"{owner_id}_{post_id}"
295
+ api_url = "https://api.vk.com/method/wall.getById"
296
+
297
+ def do_request(token):
298
+ params = {
299
+ "posts": posts_param,
300
+ "access_token": st.session_state["VK_ACCESS_TOKEN"],
301
+ "v": VK_API_VERSION,
302
+ "extended": 1
303
+ }
304
+ return requests.get(api_url, params=params)
305
+
306
+ resp = do_request(st.session_state["VK_ACCESS_TOKEN"])
307
+ if resp.status_code != 200:
308
+ return f"**Ошибка (VK)**: HTTP {resp.status_code} — {canonical_link}", 0
309
+
310
+ data = resp.json()
311
+ if "error" in data:
312
+ error_msg = data["error"].get("error_msg", "")
313
+ if "access_token has expired" in error_msg.lower():
314
+ if refresh_vk_token():
315
+ resp = do_request(st.session_state["VK_ACCESS_TOKEN"])
316
+ data = resp.json()
317
+ if "error" in data:
318
+ err_msg2 = data["error"].get("error_msg", "")
319
+ return f"**Ошибка (VK)**: {err_msg2} — {canonical_link}", 0
320
+ else:
321
+ return f"**Ошибка (VK)**: не удалось обновить токен — {canonical_link}", 0
322
+ else:
323
+ return f"**Ошибка (VK)**: {error_msg} — {canonical_link}", 0
324
+
325
+ response_data = data.get("response", {})
326
+ items = response_data.get("items", [])
327
+ if not items:
328
+ return f"**Ошибка (VK)**: пост не найден — {canonical_link}", 0
329
+
330
+ post = items[0]
331
+ views = post.get("views", {}).get("count", 0)
332
+ post_owner_id = post.get("owner_id", owner_id)
333
+
334
+ title = None
335
+ if post_owner_id < 0:
336
+ group_id = -post_owner_id
337
+ for g in response_data.get("groups", []):
338
+ if g["id"] == group_id:
339
+ title = g["name"]
340
+ break
341
+ else:
342
+ user_id = post_owner_id
343
+ for p in response_data.get("profiles", []):
344
+ if p["id"] == user_id:
345
+ first_name = p.get("first_name", "")
346
+ last_name = p.get("last_name", "")
347
+ title = (first_name + " " + last_name).strip()
348
+ break
349
+ if not title:
350
+ title = "VK пост"
351
+
352
+ return title, views
353
+
354
+ # Функция для определения платформы
355
+ def detect_platform(link: str) -> str:
356
+ link = link.strip().lower()
357
+ if "t.me/" in link or "telegram.me/" in link:
358
+ return "telegram"
359
+ elif "vk.com/wall" in link:
360
+ return "vk"
361
+ return ""
362
+
363
+ def canonicalize_tg_link(link: str):
364
+ """
365
+ Возвращает (canonical_link, channel_username, message_id) или (None, None, None)
366
+ Нормализуем и t.me, и telegram.me, и t.me/s/...
367
+ """
368
+ link = link.strip()
369
+ m = re.match(r'https?://(?:t(?:elegram)?\.me)/(?:s/)?([^/]+)/(\d+)', link, re.IGNORECASE)
370
+ if not m:
371
+ return None, None, None
372
+ username = m.group(1)
373
+ message_id = m.group(2)
374
+ canonical = f"https://t.me/{username}/{message_id}"
375
+ return canonical, username, message_id
376
+
377
+ def canonicalize_vk_link(link: str):
378
+ """
379
+ Возвращает (canonical_link, owner_id(int), post_id(str)) или (None, None, None)
380
+ """
381
+ link = link.strip()
382
+ m = re.search(r'vk\.com/wall(-?\d+)_(\d+)', link)
383
+ if not m:
384
+ return None, None, None
385
+ owner_id = int(m.group(1))
386
+ post_id = m.group(2)
387
+ canonical = f"https://vk.com/wall{owner_id}_{post_id}"
388
+ return canonical, owner_id, post_id
389
+
390
+ # Функция для отображения интерфейса парсера
391
+ def show_parser_interface():
392
+ st.write("Вставьте ссылки (каждая на новой строке) — как Telegram, так и ВКонтакте. Пример:")
393
+ st.code("""
394
+ https://t.me/meduzalive/130606
395
+ https://t.me/meduzalive/130604
396
+ https://vk.com/wall-12256840_3220312
397
+ https://vk.com/wall-237426_1643234
398
+ """)
399
+
400
+ links_input = st.text_area("Ссылки:", height=200)
401
+
402
+ if st.button("Обработать"):
403
+ # Сырые ссылки
404
+ raw_lines = [link.strip() for link in links_input.strip().split('\n') if link.strip()]
405
+ if not raw_lines:
406
+ st.warning("Нет ссылок для обработки.")
407
+ return
408
+
409
+ # Дедупликация по каноническим ссылкам
410
+ seen = set()
411
+ lines = []
412
+ for link in raw_lines:
413
+ plat = detect_platform(link)
414
+ if plat == "telegram":
415
+ canonical, username, mid = canonicalize_tg_link(link)
416
+ if not canonical:
417
+ # оставляем как есть, чтобы позже показать ошибку
418
+ canonical = link
419
+ username = None
420
+ mid = None
421
+ if canonical in seen:
422
+ continue # дубликат — пропускаем
423
+ seen.add(canonical)
424
+ lines.append(("telegram", canonical, username, mid))
425
+ elif plat == "vk":
426
+ canonical, owner_id, post_id = canonicalize_vk_link(link)
427
+ if not canonical:
428
+ canonical = link
429
+ if canonical in seen:
430
+ continue
431
+ seen.add(canonical)
432
+ lines.append(("vk", canonical, owner_id, post_id))
433
+ else:
434
+ lines.append(("unknown", link, None, None))
435
+
436
+ total_links = len(lines)
437
+ progress_bar = st.progress(0)
438
+ status_text = st.empty()
439
+
440
+ # Группы: {group_key: {"title": str, "items": [(canonical_link, views), ...]}}
441
+ tg_groups = {}
442
+ vk_groups = {}
443
+
444
+ for i, (plat, canonical, a, b) in enumerate(lines):
445
+ try:
446
+ status_text.text(f"Обработка {i+1}/{total_links}: {canonical}")
447
+
448
+ if plat == "telegram":
449
+ username, mid = a, b
450
+ if not username or not mid:
451
+ # неправильная ссылка — формируем "ошибочную" запись как отдельную группу по канону
452
+ key = canonical
453
+ tg_groups.setdefault(key, {"title": "Telegram", "items": []})
454
+ tg_groups[key]["items"].append((canonical, 0))
455
+ else:
456
+ title, views = process_telegram_link(username, mid, canonical)
457
+ key = username # группируем по username канала
458
+ if key not in tg_groups:
459
+ tg_groups[key] = {"title": title, "items": []}
460
+ # title обновим только если он выглядит «нормально»
461
+ if not tg_groups[key].get("title") or tg_groups[key]["title"].startswith("**Ошибка"):
462
+ tg_groups[key]["title"] = title
463
+ tg_groups[key]["items"].append((canonical, views))
464
+
465
+ elif plat == "vk":
466
+ owner_id, post_id = a, b
467
+ if owner_id is None or post_id is None:
468
+ key = canonical
469
+ vk_groups.setdefault(key, {"title": "VK пост", "items": []})
470
+ vk_groups[key]["items"].append((canonical, 0))
471
+ else:
472
+ title, views = process_vk_link(owner_id, post_id, canonical)
473
+ key = str(owner_id) # группируем по owner_id
474
+ if key not in vk_groups:
475
+ vk_groups[key] = {"title": title, "items": []}
476
+ if not vk_groups[key].get("title") or vk_groups[key]["title"].startswith("**Ошибка"):
477
+ vk_groups[key]["title"] = title
478
+ vk_groups[key]["items"].append((canonical, views))
479
+
480
+ else:
481
+ st.error(f"Неизвестная платформа: {canonical}")
482
+
483
+ progress_bar.progress((i + 1) / total_links)
484
+
485
+ except Exception as e:
486
+ st.error(f"Ошибка при обработке {canonical}: {str(e)}")
487
+
488
+ progress_bar.empty()
489
+ status_text.empty()
490
+
491
+ # Подсчитываем суммарные просмотры по платформам
492
+ tg_total_views = sum(v for g in tg_groups.values() for _, v in g["items"])
493
+ vk_total_views = sum(v for g in vk_groups.values() for _, v in g["items"])
494
+
495
+ # Сортируем группы по суммарным просмотрам по убыванию
496
+ tg_sorted = sorted(
497
+ tg_groups.items(),
498
+ key=lambda kv: sum(v for _, v in kv[1]["items"]),
499
+ reverse=True
500
+ )
501
+ vk_sorted = sorted(
502
+ vk_groups.items(),
503
+ key=lambda kv: sum(v for _, v in kv[1]["items"]),
504
+ reverse=True
505
+ )
506
+
507
+ md_lines = []
508
+
509
+ # --- Telegram ---
510
+ md_lines.append("## Telegram")
511
+ md_lines.append(f"**Суммарный охват постов — {human_format_views(tg_total_views)} просмотров**")
512
+ if tg_sorted:
513
+ for idx, (_, data) in enumerate(tg_sorted, start=1):
514
+ title = data["title"] or "Telegram"
515
+ items = data["items"]
516
+ # первая ссылка — та, что встретилась первой
517
+ first_link, first_views = items[0]
518
+ line = f"{idx}. [{title}]({first_link})"
519
+ # остальные «ещё»
520
+ if len(items) > 1:
521
+ for link2, _v in items[1:]:
522
+ line += f" + [ещё]({link2})"
523
+ # просмотры как «A + B + C»
524
+ views_str = " + ".join(human_format_views(v) for _, v in items)
525
+ line += f" — {views_str}"
526
+ md_lines.append(line)
527
+ else:
528
+ md_lines.append("_Нет ссылок на Telegram_")
529
+ md_lines.append("")
530
+
531
+ # --- ВКонтакте ---
532
+ md_lines.append("## ВКонтакте")
533
+ md_lines.append(f"**Суммарный охват постов — {human_format_views(vk_total_views)} просмотров**")
534
+ if vk_sorted:
535
+ for idx, (_, data) in enumerate(vk_sorted, start=1):
536
+ title = data["title"] or "VK пост"
537
+ items = data["items"]
538
+ first_link, first_views = items[0]
539
+ line = f"{idx}. [{title}]({first_link})"
540
+ if len(items) > 1:
541
+ for link2, _v in items[1:]:
542
+ line += f" + [ещё]({link2})"
543
+ views_str = " + ".join(human_format_views(v) for _, v in items)
544
+ line += f" — {views_str}"
545
+ md_lines.append(line)
546
+ else:
547
+ md_lines.append("_Нет ссылок на ВКонтакте_")
548
+ md_lines.append("")
549
+
550
+ # Вывод
551
+ st.markdown("### Результат:")
552
+ for line in md_lines:
553
+ st.markdown(line, unsafe_allow_html=True)
554
+
555
+ markdown_result = "\n".join(md_lines)
556
+
557
+ # Кнопка копирования
558
+ copy_button_html = f"""
559
+ <html>
560
+ <head>
561
+ <script>
562
+ function copyToClipboard() {{
563
+ const btn = document.getElementById("copy-btn");
564
+ const text = `{markdown_result}`;
565
+ navigator.clipboard.writeText(text).then(function() {{
566
+ btn.innerText = "Скопировано успешно";
567
+ }}, function(err) {{
568
+ btn.innerText = "Ошибка при копировании";
569
+ }});
570
+ }}
571
+ </script>
572
+ </head>
573
+ <body>
574
+ <button id="copy-btn" onclick="copyToClipboard()" style="padding: 8px 16px; font-size: 16px;">Скопировать код</button>
575
+ </body>
576
+ </html>
577
+ """
578
+ components.html(copy_button_html, height=80)
579
+
580
+ # Интерфейс Streamlit
581
+ def main():
582
+ st.title("PR Tool made by @Dina")
583
+
584
+ # Первым делом проверяем токен
585
+ if not st.session_state.get("VK_ACCESS_TOKEN"):
586
+ vk_auth()
587
+ else:
588
+ # Показываем интерфейс парсера
589
+ show_parser_interface()
590
+
591
+ # Запуск приложения
592
+ if __name__ == "__main__":
593
+ main()