bep40 commited on
Commit
72c3aaa
·
verified ·
1 Parent(s): 32328f7

Restore full app_v2_entry.py with import rewrite_slide added

Browse files
Files changed (1) hide show
  1. app_v2_entry.py +1069 -1
app_v2_entry.py CHANGED
@@ -1,3 +1,7 @@
 
 
 
 
1
  try:
2
  import ai_ext
3
  except Exception as e:
@@ -11,4 +15,1068 @@ except Exception as e:
11
  try:
12
  import rewrite_slide
13
  except Exception as e:
14
- print(f"[WARN] rewrite_slide import failed: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """VNEWS v2 Entry Point - with fast bongda proxy + rewrite endpoints + multilingual TTS"""
2
+ import sys, os
3
+ from main import app, HEADERS, BONGDA_HEADERS, fetch_bongda_api, HL_LEAGUES
4
+
5
  try:
6
  import ai_ext
7
  except Exception as e:
 
15
  try:
16
  import rewrite_slide
17
  except Exception as e:
18
+ print(f"[WARN] rewrite_slide import failed: {e}")
19
+
20
+ from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response
21
+ from fastapi.staticfiles import StaticFiles
22
+ from starlette.routing import Mount
23
+ from fastapi import Query, Request, UploadFile, File, Form
24
+ import requests as req
25
+ from bs4 import BeautifulSoup
26
+ import re, html as html_lib, json, threading, time, uuid
27
+ from concurrent.futures import ThreadPoolExecutor, as_completed
28
+ from urllib.parse import quote
29
+
30
+ HL_LEAGUES['friendly'] = {"path": "giai-khac/friendly", "name": "Giao hữu", "emoji": "🤝"}
31
+
32
+ STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
33
+ app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)=='/' and hasattr(r,'methods') and 'GET' in getattr(r,'methods',set()))]
34
+ app.routes[:]=[r for r in app.routes if not isinstance(r, Mount)]
35
+ app.router.routes=[r for r in app.router.routes if not isinstance(r, Mount)]
36
+
37
+ def _clean(s): return re.sub(r"\s+"," ",html_lib.unescape(str(s or""))).strip()
38
+
39
+ # Cache for match details (5 min TTL)
40
+ _match_cache = {}
41
+
42
+ # === FAST BONGDA PROXY ENDPOINT ===
43
+ def _get_match_detail(event_id, slug=None):
44
+ headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "text/html", "Referer": "https://bongda.com.vn/"}
45
+ if slug:
46
+ url = f"https://bongda.com.vn/tran-dau/{event_id}/centre/{slug}"
47
+ else:
48
+ url = f"https://bongda.com.vn/tran-dau/{event_id}"
49
+ resp = req.get(url, headers=headers, timeout=15, allow_redirects=True)
50
+ if resp.status_code != 200:
51
+ return None
52
+ soup = BeautifulSoup(resp.text, 'html.parser')
53
+ result = {"event_id": event_id, "found": False, "sections": []}
54
+ info = {}
55
+ tel = soup.select_one('.teams')
56
+ if tel:
57
+ he = tel.select_one('.team.home')
58
+ if he:
59
+ p_tags = [p for p in he.select('p') if not p.get('class') or 'logo' not in p.get('class', [])]
60
+ if p_tags: info['home_team'] = _clean(p_tags[0].get_text())
61
+ lo = he.select_one('img')
62
+ if lo: info['home_logo'] = lo.get('src', '')
63
+ ae = tel.select_one('.team.away')
64
+ if ae:
65
+ p_tags = ae.select('p')
66
+ team_ps = [p for p in p_tags if not p.get('class') or 'logo' not in p.get('class', [])]
67
+ if team_ps: info['away_team'] = _clean(team_ps[-1].get_text())
68
+ lo = ae.select_one('img')
69
+ if lo: info['away_logo'] = lo.get('src', '')
70
+ sc = tel.select_one('.score')
71
+ if sc:
72
+ parts = [_clean(p.get_text()) for p in sc.select('p')]
73
+ if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}"
74
+ lb = sc.select_one('.label')
75
+ if lb: info['status_label'] = _clean(lb.get_text())
76
+ if info.get('home_team') and info.get('away_team'):
77
+ result['info'] = info
78
+ result['found'] = True
79
+ result['sections'].append('info')
80
+ events = []
81
+ for ev in soup.select('.events .period .event'):
82
+ ev_cls = ' '.join(ev.get('class', []))
83
+ ev_data = {'team': 'home' if 'home' in ev_cls else 'away', 'period': '', 'type': 'unknown', 'time': '', 'players': ''}
84
+ parent = ev.parent
85
+ if parent:
86
+ h2 = parent.find('h2')
87
+ if h2: ev_data['period'] = _clean(h2.get_text())
88
+ if ev.select_one('[class*="goal"]'): ev_data['type'] = 'goal'
89
+ elif ev.select_one('[class*="redcard"]'): ev_data['type'] = 'redcard'
90
+ elif ev.select_one('[class*="yellowcard"]'): ev_data['type'] = 'yellowcard'
91
+ elif ev.select_one('[class*="substitution"]'): ev_data['type'] = 'substitution'
92
+ players_el = ev.select_one('.players')
93
+ if players_el:
94
+ pl_text = _clean(players_el.get_text(' ', strip=True))
95
+ m = re.match(r"(\d+)'(.*)", pl_text)
96
+ if m:
97
+ ev_data['time'] = f"{m.group(1)}'"
98
+ ev_data['players'] = m.group(2)
99
+ else:
100
+ ev_data['players'] = pl_text
101
+ events.append(ev_data)
102
+ if events:
103
+ result['events'] = events
104
+ result['sections'].append('events')
105
+ pred = soup.select_one('.prediction-card')
106
+ if pred:
107
+ team_info = pred.select_one('.team-info')
108
+ if team_info:
109
+ teams = team_info.select('.team')
110
+ pred_data = {}
111
+ if len(teams) >= 2:
112
+ pred_data['home_name'] = _clean(teams[0].select_one('.team-name').get_text()) if teams[0].select_one('.team-name') else ''
113
+ pred_data['away_name'] = _clean(teams[1].select_one('.team-name').get_text()) if teams[1].select_one('.team-name') else ''
114
+ divider = team_info.select_one('.divider')
115
+ if divider: pred_data['result'] = _clean(divider.get_text())
116
+ vc = pred.select_one('.vote-count')
117
+ if vc: pred_data['vote_count'] = _clean(vc.get_text())
118
+ result['prediction'] = pred_data
119
+ recent = []
120
+ ml = soup.select_one('.matches-list')
121
+ if ml:
122
+ for item in ml.select('.match-detail, .match-item, li'):
123
+ de = item.select_one('.date, .time')
124
+ le = item.select_one('.league')
125
+ he_item = item.select_one('.home, .team-home')
126
+ ae_item = item.select_one('.away, .team-away')
127
+ se = item.select_one('.score, .result')
128
+ if he_item or ae_item:
129
+ recent.append({'date': _clean(de.get_text()) if de else '', 'league': _clean(le.get_text()) if le else '', 'home': _clean(he_item.get_text()) if he_item else '', 'away': _clean(ae_item.get_text()) if ae_item else '', 'score': _clean(se.get_text()) if se else 'vs'})
130
+ if recent:
131
+ result['recent_matches'] = recent
132
+ result['sections'].append('recent')
133
+ try:
134
+ api_h = {"User-Agent": "Mozilla/5.0", "Accept": "application/json", "X-Requested-With": "XMLHttpRequest", "Referer": "https://bongda.com.vn/"}
135
+ ar = req.get(f"https://bongda.com.vn/api/fixtures/h2h-stats?event_id={event_id}", headers=api_h, timeout=10)
136
+ if ar.status_code == 200:
137
+ ad = ar.json()
138
+ if ad.get('status') == 'success' and ad.get('html'):
139
+ asp = BeautifulSoup(ad['html'], 'html.parser')
140
+ ast = {}
141
+ for row in asp.select('li, tr'):
142
+ cells = row.select('td, span, p')
143
+ if len(cells) >= 3:
144
+ lb = _clean(cells[0].get_text())
145
+ if lb: ast[lb] = {'home': _clean(cells[1].get_text()), 'away': _clean(cells[2].get_text())}
146
+ if ast:
147
+ result['h2h_stats_parsed'] = ast
148
+ result['sections'].append('h2h_stats')
149
+ except: pass
150
+ return result
151
+
152
+ @app.get('/api/proxy/bongda')
153
+ def proxy_bongda(event_id: int = Query(default=None), slug: str = Query(default=None)):
154
+ if event_id is None:
155
+ return JSONResponse({'error': 'event_id required'}, status_code=400)
156
+ cache_key = f"{event_id}_{slug}"
157
+ now = time.time()
158
+ cached = _match_cache.get(cache_key)
159
+ if cached and now - cached.get('_ts', 0) < 300:
160
+ return JSONResponse(cached)
161
+ try:
162
+ result = _get_match_detail(event_id, slug)
163
+ if result:
164
+ result['_ts'] = now
165
+ _match_cache[cache_key] = result
166
+ return JSONResponse(result)
167
+ except Exception as e:
168
+ err = {"event_id": event_id, "found": False, "error": str(e), "_ts": now}
169
+ _match_cache[cache_key] = err
170
+ return JSONResponse(err)
171
+ return JSONResponse({"event_id": event_id, "found": False})
172
+
173
+ @app.get('/api/match/{event_id}/detail')
174
+ def api_match_detail(event_id: int, url: str = Query(default=None)):
175
+ slug = None
176
+ if url:
177
+ m = re.match(r'.+/tran-dau/\d+/(?:centre|preview)/(.+)', url)
178
+ if m:
179
+ slug = m.group(1)
180
+ cache_key = f"{event_id}_{slug or ''}"
181
+ now = time.time()
182
+ cached = _match_cache.get(cache_key)
183
+ if cached and now - cached.get('_ts', 0) < 300:
184
+ return JSONResponse(cached)
185
+ try:
186
+ if not slug:
187
+ try:
188
+ home_r = req.get("https://bongda.com.vn/", headers={"User-Agent": "Mozilla/5.0"}, timeout=10)
189
+ if home_r.status_code == 200:
190
+ home_soup = BeautifulSoup(home_r.text, 'html.parser')
191
+ for a in home_soup.select(f'a[href*="/tran-dau/{event_id}/"]'):
192
+ href = a.get('href', '')
193
+ m = re.match(r'/tran-dau/\d+/(?:centre|preview)/(.+)', href)
194
+ if m:
195
+ slug = m.group(1)
196
+ cache_key = f"{event_id}_{slug}"
197
+ break
198
+ except: pass
199
+ result = _get_match_detail(event_id, slug)
200
+ if result:
201
+ result['_ts'] = now
202
+ _match_cache[cache_key] = result
203
+ return JSONResponse(result)
204
+ except Exception as e:
205
+ err = {"event_id": event_id, "found": False, "error": str(e), "_ts": now}
206
+ _match_cache[cache_key] = err
207
+ return JSONResponse(err)
208
+ return JSONResponse({"event_id": event_id, "found": False})
209
+
210
+ _STOP=set('và của các những một được trong với cho tại sau trước khi không người việt nam hôm nay mới nhất nóng tin tức cập nhật theo từ đến là có thì này đã để'.split())
211
+
212
+ def _has_kw(topic,title):
213
+ tl=topic.lower();tt=(title or'').lower()
214
+ if tl in tt:return True
215
+ words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',tl) if len(w)>2 and w not in _STOP]
216
+ if not words:return True
217
+ return any(w in tt for w in words)
218
+
219
+ def _s_vnexpress(topic,limit=8):
220
+ items=[]
221
+ try:
222
+ r=req.get(f"https://timkiem.vnexpress.net/?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
223
+ for art in soup.select('article.item-news')[:limit]:
224
+ a=art.select_one('h2 a, h3 a')
225
+ if a and a.get('href'):
226
+ t=_clean(a.get('title','') or a.get_text(strip=True))
227
+ if _has_kw(topic,t):items.append({'title':t,'url':a['href'],'via':'VnExpress'})
228
+ except:pass
229
+ return items
230
+
231
+ def _s_dantri(topic,limit=8):
232
+ items=[]
233
+ try:
234
+ r=req.get(f"https://dantri.com.vn/tim-kiem/{quote(topic)}.htm",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
235
+ for a in soup.select('h3 a[href], .article-title a[href]')[:limit*2]:
236
+ t=_clean(a.get_text(strip=True));href=a.get('href','')
237
+ if t and len(t)>15 and _has_kw(topic,t):
238
+ if not href.startswith('http'):href='https://dantri.com.vn'+href
239
+ items.append({'title':t,'url':href,'via':'Dân Trí'})
240
+ if len(items)>=limit:break
241
+ except:pass
242
+ return items
243
+
244
+ def _s_vietnamnet(topic,limit=6):
245
+ items=[]
246
+ try:
247
+ r=req.get(f"https://vietnamnet.vn/tim-kiem?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
248
+ for a in soup.select('h3 a[href], .vnn-title a')[:limit*2]:
249
+ t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
250
+ if t and len(t)>15 and _has_kw(topic,t):
251
+ if not href.startswith('http'):href='https://vietnamnet.vn'+href
252
+ items.append({'title':t,'url':href,'via':'VietNamNet'})
253
+ if len(items)>=limit:break
254
+ except:pass
255
+ return items
256
+
257
+ def _s_bongda(topic,limit=5):
258
+ items=[]
259
+ try:
260
+ r=req.get(f"https://bongda.com.vn/tim-kiem.html?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=8);soup=BeautifulSoup(r.text,'lxml')
261
+ for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
262
+ t=_clean(a.get_text(strip=True));href=a.get('href','')
263
+ if t and len(t)>15 and _has_kw(topic,t):
264
+ if not href.startswith('http'):href='https://bongda.com.vn'+href
265
+ items.append({'title':t,'url':href,'via':'Bóng Đá'})
266
+ if len(items)>=limit:break
267
+ except:pass
268
+ return items
269
+
270
+ def _s_genk(topic,limit=5):
271
+ items=[]
272
+ try:
273
+ r=req.get(f"https://genk.vn/tim-kiem?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=8);soup=BeautifulSoup(r.text,'lxml')
274
+ for a in soup.select('a[href$=".chn"]')[:limit*3]:
275
+ t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
276
+ if t and len(t)>15 and _has_kw(topic,t):
277
+ if href.startswith('/'):href='https://genk.vn'+href
278
+ items.append({'title':t,'url':href,'via':'GenK'})
279
+ if len(items)>=limit:break
280
+ except:pass
281
+ return items
282
+
283
+ def _s_thanhnien(topic,limit=6):
284
+ items=[]
285
+ try:
286
+ r=req.get(f"https://thanhnien.vn/tim-kiem?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
287
+ for a in soup.select('h3 a[href], .box-title a')[:limit*2]:
288
+ t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
289
+ if t and len(t)>15 and _has_kw(topic,t):
290
+ if not href.startswith('http'):href='https://thanhnien.vn'+href
291
+ items.append({'title':t,'url':href,'via':'Thanh Niên'})
292
+ if len(items)>=limit:break
293
+ except:pass
294
+ return items
295
+
296
+ def _s_tuoitre(topic,limit=6):
297
+ items=[]
298
+ try:
299
+ r=req.get(f"https://tuoitre.vn/tim-kiem.htm?keywords={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
300
+ for a in soup.select('h3 a[href], .box-title-text a')[:limit*2]:
301
+ t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
302
+ if t and len(t)>15 and _has_kw(topic,t):
303
+ if not href.startswith('http'):href='https://tuoitre.vn'+href
304
+ items.append({'title':t,'url':href,'via':'Tuổi Trẻ'})
305
+ if len(items)>=limit:break
306
+ except:pass
307
+ return items
308
+
309
+ def _s_thethaovanhoa(topic,limit=5):
310
+ items=[]
311
+ try:
312
+ r=req.get(f"https://thethaovanhoa.vn/tim-kiem.htm?keyword={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=8);soup=BeautifulSoup(r.text,'lxml')
313
+ for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
314
+ t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
315
+ if t and len(t)>15 and _has_kw(topic,t):
316
+ if not href.startswith('http'):href='https://thethaovanhoa.vn'+href
317
+ items.append({'title':t,'url':href,'via':'TT&VH'})
318
+ if len(items)>=limit:break
319
+ except:pass
320
+ return items
321
+
322
+ def _search_all(topic,limit=36):
323
+ results={}
324
+ with ThreadPoolExecutor(8) as ex:
325
+ futs={ex.submit(_s_vnexpress,topic,8):'vne',ex.submit(_s_dantri,topic,8):'dt',ex.submit(_s_vietnamnet,topic,6):'vnn',ex.submit(_s_bongda,topic,5):'bd',ex.submit(_s_genk,topic,5):'gk',ex.submit(_s_thanhnien,topic,6):'tn',ex.submit(_s_tuoitre,topic,6):'tt',ex.submit(_s_thethaovanhoa,topic,5):'tvh'}
326
+ for f in as_completed(futs,timeout=14):
327
+ try:results[futs[f]]=f.result()
328
+ except:results[futs[f]]=[]
329
+ srcs=list(results.values());out=[];seen=set()
330
+ for i in range(max((len(s) for s in srcs),default=0)):
331
+ for s in srcs:
332
+ if i<len(s) and s[i].get('url') and s[i]['url'] not in seen:seen.add(s[i]['url']);out.append(s[i])
333
+ return out[:limit]
334
+
335
+ for _path in ['/api/article', '/api/hot_topics', '/api/categories', '/api/storage_status', '/s']:
336
+ app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)==_path and 'GET' in getattr(r,'methods',set()))]
337
+
338
+ _article_cache = {}
339
+ _article_cache_ttl = 1800
340
+
341
+ _art_session = None
342
+ _art_lock = threading.Lock()
343
+ def _get_art_session():
344
+ global _art_session
345
+ if _art_session is None:
346
+ with _art_lock:
347
+ if _art_session is None:
348
+ _art_session = req.Session()
349
+ _art_session.headers.update({
350
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
351
+ "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8",
352
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
353
+ })
354
+ return _art_session
355
+
356
+ def _scrape_article_fast(url):
357
+ from urllib.parse import urlparse
358
+ domain = urlparse(url).netloc
359
+ sess = _get_art_session()
360
+ uas = [
361
+ {"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"},
362
+ {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"},
363
+ ]
364
+ for ua in uas:
365
+ try:
366
+ r = sess.get(url, headers=ua, timeout=6, allow_redirects=True)
367
+ if not r or r.status_code != 200:
368
+ continue
369
+ r.encoding = 'utf-8'
370
+ soup = BeautifulSoup(r.text, 'lxml')
371
+ for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe','.ads','.ad','.banner-ads','.fb-comments','.fb-root','.social-share','.related-news','.tag','.breadcrumb']):
372
+ tag.decompose()
373
+ title = summary = og_img = ""
374
+ ogt = soup.find('meta', property='og:title')
375
+ if ogt: title = ogt.get('content', '')
376
+ ogd = soup.find('meta', property='og:description') or soup.find('meta', attrs={'name': 'description'})
377
+ if ogd: summary = ogd.get('content', '')[:500]
378
+ ogi = soup.find('meta', property='og:image')
379
+ if ogi:
380
+ og_img = ogi.get('content', '')
381
+ if og_img.startswith('//'): og_img = 'https:' + og_img
382
+ h1 = soup.find('h1')
383
+ if not title and h1: title = h1.get_text(strip=True)[:200]
384
+ body = []
385
+ selectors = [
386
+ '.fck_detail', '.sidebar-1',
387
+ '.singular-content', '.dt__content', '.article-content', '.content-detail', '#divNewsContent',
388
+ '.content-detail', '.main-content-detail', '.box-content',
389
+ '.knc-content', '.article-body', '.detail-body',
390
+ '.article-detail', '.detail-content',
391
+ 'article', 'main', '.cms-body', '.article__body', '.post-content',
392
+ '.entry-content', '#content', '.article-text', '.story-body',
393
+ ]
394
+ for sel in selectors:
395
+ el = soup.select_one(sel)
396
+ if el and len(el.find_all('p')) >= 2:
397
+ seen_imgs = set()
398
+ for child in el.find_all(['p','h2','h3','figure','img'], recursive=True):
399
+ if child.name == 'p':
400
+ t = child.get_text(strip=True)
401
+ if t and len(t) > 15:
402
+ body.append({'type': 'p', 'text': t})
403
+ elif child.name in ('h2','h3'):
404
+ t = child.get_text(strip=True)
405
+ if t:
406
+ body.append({'type': 'heading', 'text': t})
407
+ elif child.name in ('figure','img'):
408
+ im = child if child.name == 'img' else child.find('img')
409
+ if im:
410
+ src = im.get('data-src') or im.get('src') or im.get('data-lazy') or ''
411
+ if src and 'base64' not in src and src not in seen_imgs:
412
+ seen_imgs.add(src)
413
+ if src.startswith('//'): src = 'https:' + src
414
+ body.append({'type': 'img', 'src': src})
415
+ if child.name == 'figure':
416
+ cap = child.find('figcaption')
417
+ if cap:
418
+ ct = cap.get_text(strip=True)
419
+ if ct: body.append({'type': 'p', 'text': ct})
420
+ if len(body) >= 2:
421
+ return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img,
422
+ 'body': body[:50], 'source': domain, 'url': url}
423
+ if title and (summary or og_img):
424
+ fallback = []
425
+ if og_img: fallback.append({'type': 'img', 'src': og_img})
426
+ if summary: fallback.append({'type': 'p', 'text': summary})
427
+ if fallback:
428
+ return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img,
429
+ 'body': fallback, 'source': domain, 'url': url, 'fallback': True}
430
+ if title:
431
+ return {'title': _clean(title), 'summary': '', 'og_image': '',
432
+ 'body': [{'type': 'p', 'text': 'Nội dung đang được tải...'}],
433
+ 'source': domain, 'url': url, 'fallback': True}
434
+ break
435
+ except Exception:
436
+ continue
437
+ return None
438
+
439
+ @app.get('/api/article')
440
+ def api_article_v2(url: str = Query(...)):
441
+ from urllib.parse import unquote
442
+ safe_url = unquote(url)
443
+ try:
444
+ now = time.time()
445
+ cached = _article_cache.get(safe_url)
446
+ if cached and now - cached['t'] < _article_cache_ttl:
447
+ resp = JSONResponse(cached['d'])
448
+ resp.headers["Cache-Control"] = "public, max-age=1800"
449
+ return resp
450
+ data = _scrape_article_fast(safe_url)
451
+ if data and data.get('body'):
452
+ _article_cache[safe_url] = {'d': data, 't': now}
453
+ resp = JSONResponse(data)
454
+ resp.headers["Cache-Control"] = "public, max-age=1800"
455
+ return resp
456
+ result = {'error': 'Không đọc được', 'url': safe_url}
457
+ resp = JSONResponse(result)
458
+ resp.headers["Cache-Control"] = "public, max-age=60"
459
+ return resp
460
+ except Exception as e:
461
+ return JSONResponse({'error': f'Server error: {str(e)[:100]}', 'url': safe_url}, status_code=200)
462
+
463
+ _hot_cache={'t':0,'d':[]}
464
+ def _get_hot_topics():
465
+ now=time.time()
466
+ if _hot_cache['d'] and now-_hot_cache['t']<600:return _hot_cache['d']
467
+ freq={};display={}
468
+ feeds=['https://vnexpress.net/rss/tin-moi-nhat.rss','https://dantri.com.vn/rss/home.rss','https://vietnamnet.vn/rss/tin-moi-nhat.rss','https://thanhnien.vn/rss/home.rss','https://tuoitre.vn/rss/tin-moi-nhat.rss','https://genk.vn/rss','https://vnexpress.net/rss/the-thao.rss','https://thethaovanhoa.vn/rss/tin-nong.rss']
469
+ for feed_url in feeds:
470
+ try:
471
+ r=req.get(feed_url,headers={'User-Agent':'Mozilla/5.0'},timeout=6);r.encoding='utf-8';soup=BeautifulSoup(r.text,'xml')
472
+ for item in soup.find_all('item')[:12]:
473
+ title=_clean(item.find('title').get_text() if item.find('title') else '')
474
+ if not title:continue
475
+ title=re.sub(r'\s*[-|].*$','',title);words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',title) if len(w)>2 and w.lower() not in _STOP]
476
+ if len(words)<2:continue
477
+ for n in(3,4,2):
478
+ for i in range(max(0,len(words)-n+1)):
479
+ phrase=' '.join(words[i:i+n])
480
+ if 8<=len(phrase)<=45:key=phrase.lower();freq[key]=freq.get(key,0)+1;display[key]=phrase
481
+ except:continue
482
+ ranked=sorted(freq.items(),key=lambda x:x[1],reverse=True);topics=[];seen=set()
483
+ for key,count in ranked:
484
+ is_dup=any(len(set(e.split())&set(key.split()))/max(len(set(e.split())),len(set(key.split())),1)>0.6 for e in seen)
485
+ if is_dup:continue
486
+ seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',display[key].title()),'topic':display[key],'count':count})
487
+ if len(topics)>=20:break
488
+ for kw in['World Cup 2026','Kinh tế Việt Nam','Bóng đá châu Âu','Công nghệ AI','Giá vàng','Thời tiết']:
489
+ if len(topics)>=24:break
490
+ if not any(kw.lower() in s for s in seen):topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw,'count':0})
491
+ _hot_cache.update({'t':now,'d':topics[:24]});return topics[:24]
492
+
493
+ @app.get('/api/hot_topics')
494
+ def api_hot_topics():
495
+ resp = JSONResponse({'topics':_get_hot_topics()})
496
+ resp.headers["Cache-Control"] = "public, max-age=120"
497
+ return resp
498
+ @app.get('/')
499
+ async def serve_index():
500
+ p=os.path.join(STATIC_DIR,'index_v2.html')
501
+ if os.path.exists(p):return FileResponse(p,media_type='text/html')
502
+ return HTMLResponse('<h1>VNEWS</h1>')
503
+ @app.get('/api/hashtag/sources')
504
+ def _ht(topic:str=Query(...),page:int=Query(default=0)):
505
+ items=_search_all(topic,36);per_page=8;start=page*per_page;end=start+per_page
506
+ return JSONResponse({'sources':items[start:end],'topic':topic,'page':page,'has_more':end<len(items),'total':len(items)})
507
+ @app.get('/api/categories')
508
+ def _cat():return JSONResponse([])
509
+ @app.get('/api/storage_status')
510
+ def _st():return JSONResponse({'persistent':os.path.isdir('/data') and os.access('/data',os.W_OK)})
511
+ @app.get('/s')
512
+ async def _sh(url:str='',title:str='',img:str=''):return HTMLResponse(f'<!DOCTYPE html><html><head><meta property="og:title" content="{_clean(title)}"><meta property="og:image" content="{_clean(img)}"><meta http-equiv="refresh" content="0;url={_clean(url) or "/"}"></head><body></body></html>')
513
+
514
+ from wc2026_scraper import scrape_summary,scrape_fixtures,scrape_standings,scrape_stats,scrape_wc_news,scrape_road_to_wc,get_wc2026_all,scrape_history,scrape_h2h,scrape_lineups,scrape_match_detail
515
+
516
+ _xlb_cache = {}
517
+ _xlb_lock = threading.Lock()
518
+
519
+ def _xlb_scrape(path):
520
+ url = f"https://xemlaibongda.top/{path}"
521
+ r = req.get(url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}, timeout=15, allow_redirects=True)
522
+ if r.status_code != 200:
523
+ return []
524
+ soup = BeautifulSoup(r.text, 'lxml')
525
+ vids = []
526
+ seen = set()
527
+ for a in soup.select('a[href*="/video/"]'):
528
+ href = a.get('href', '')
529
+ if not href or href in seen:
530
+ continue
531
+ seen.add(href)
532
+ if not href.startswith('http'):
533
+ href = 'https://xemlaibongda.top' + href
534
+ img = a.select_one('img')
535
+ p = a.parent
536
+ for _ in range(4):
537
+ if img:
538
+ break
539
+ if p:
540
+ img = p.select_one('img')
541
+ p = p.parent
542
+ img_src = ''
543
+ if img:
544
+ img_src = img.get('data-src','') or img.get('src','') or img.get('data-lazy','') or img.get('data-original','')
545
+ if img_src.startswith('//'):
546
+ img_src = 'https:' + img_src
547
+ elif img_src.startswith('/'):
548
+ img_src = 'https://xemlaibongda.top' + img_src
549
+ title = ''
550
+ for sel in ['.title', 'h3', 'h2', '.name', '.post-title', '.entry-title', '.video-title']:
551
+ t = a.select_one(sel)
552
+ if t:
553
+ title = _clean(t.get_text())
554
+ break
555
+ if not title:
556
+ title = _clean(a.get('title',''))
557
+ if not title:
558
+ img_alt = a.select_one('img')
559
+ if img_alt:
560
+ title = _clean(img_alt.get('alt',''))
561
+ if not title:
562
+ parent = a.parent
563
+ if parent:
564
+ pt = _clean(parent.get_text(' ',strip=True))
565
+ if 5 < len(pt) < 120:
566
+ title = pt
567
+ if not title or len(title) < 3:
568
+ continue
569
+ vids.append({"link": href, "img": img_src, "title": title})
570
+ if len(vids) >= 30:
571
+ break
572
+ return vids
573
+
574
+ @app.get('/api/proxy/xlb')
575
+ def proxy_xlb(path: str = Query(default="")):
576
+ now = time.time()
577
+ cache_key = f"xlb:{path}"
578
+ with _xlb_lock:
579
+ cached = _xlb_cache.get(cache_key)
580
+ if cached and now - cached['t'] < 120:
581
+ return JSONResponse(cached['d'])
582
+ try:
583
+ vids = _xlb_scrape(path)
584
+ result = {"videos": vids, "count": len(vids)}
585
+ with _xlb_lock:
586
+ _xlb_cache[cache_key] = {'t': now, 'd': result}
587
+ return JSONResponse(result)
588
+ except Exception as e:
589
+ return JSONResponse({"videos": [], "count": 0, "error": str(e)}, status_code=500)
590
+
591
+ @app.get('/api/wc2026')
592
+ def _w():return JSONResponse(get_wc2026_all())
593
+ @app.get('/api/wc2026/fixtures')
594
+ def _wf():return JSONResponse(scrape_fixtures())
595
+ @app.get('/api/wc2026/standings')
596
+ def _ws():return JSONResponse(scrape_standings())
597
+ @app.get('/api/wc2026/stats')
598
+ def _wst():return JSONResponse(scrape_stats())
599
+ @app.get('/api/wc2026/history')
600
+ def _whi():return JSONResponse(scrape_history())
601
+ @app.get('/api/wc2026/news')
602
+ def _wn():return JSONResponse(scrape_wc_news())
603
+ @app.get('/api/wc2026/road')
604
+ def _wr():return JSONResponse(scrape_road_to_wc())
605
+ @app.get('/api/wc2026/h2h/{eid}')
606
+ def _wh2(eid:int):return JSONResponse(scrape_h2h(eid))
607
+ @app.get('/api/wc2026/lineups/{eid}')
608
+ def _wl(eid:int):return JSONResponse(scrape_lineups(eid))
609
+ @app.get('/api/wc2026/match/{eid}')
610
+ def _wm(eid:int):return JSONResponse(scrape_match_detail(eid))
611
+
612
+ DATA_DIR='/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)),'data')
613
+ os.makedirs(DATA_DIR,exist_ok=True)
614
+ IF=os.path.join(DATA_DIR,'interactions_v2.json')
615
+ CF=os.path.join(DATA_DIR,'comments_v2.json')
616
+ WALL_FILE=os.path.join(DATA_DIR,'wall_posts.json')
617
+ WALL_VIDEO_DIR=os.path.join(DATA_DIR,'wall_videos')
618
+ os.makedirs(WALL_VIDEO_DIR,exist_ok=True)
619
+
620
+ _il=threading.Lock();_cl=threading.Lock();_wl_lock=threading.Lock()
621
+ def _lj(p):
622
+ try:
623
+ if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8'))
624
+ except:pass
625
+ return{}
626
+ def _sj(p,d):
627
+ try:open(p+'.tmp','w',encoding='utf-8').write(json.dumps(d,ensure_ascii=False));os.replace(p+'.tmp',p)
628
+ except:pass
629
+
630
+ @app.post('/api/v2/interact')
631
+ async def _int(request:Request):
632
+ b=await request.json();v=str(b.get('id','')).strip();t=str(b.get('type','')).strip()
633
+ if not v or t not in('view','like'):return JSONResponse({'error':'x'},status_code=400)
634
+ with _il:db=_lj(IF);db.setdefault(v,{'views':0,'likes':0,'comments':0});db[v][t+'s']+=1;_sj(IF,db);return JSONResponse(db[v])
635
+
636
+ @app.get('/api/v2/interactions')
637
+ def _gi(id:str=Query(...)):
638
+ with _il:return JSONResponse(_lj(IF).get(id.strip(),{'views':0,'likes':0,'comments':0}))
639
+
640
+ @app.get('/api/v2/comments')
641
+ def _gc(id:str=Query(...)):
642
+ with _cl:return JSONResponse({'comments':_lj(CF).get(id.strip(),[])})
643
+
644
+ @app.post('/api/v2/comment')
645
+ async def _pc(request:Request):
646
+ b=await request.json();v=str(b.get('id','')).strip();tx=str(b.get('text','')).strip()[:500]
647
+ if not v or not tx:return JSONResponse({'error':'x'},status_code=400)
648
+ c={'text':tx,'time':time.strftime('%H:%M %d/%m',time.localtime()),'ts':int(time.time())}
649
+ with _cl:db=_lj(CF);db.setdefault(v,[]);db[v].append(c);db[v]=db[v][-200:];_sj(CF,db);cms=db[v]
650
+ with _il:idb=_lj(IF);idb.setdefault(v,{'views':0,'likes':0,'comments':0});idb[v]['comments']=len(cms);_sj(IF,idb)
651
+ return JSONResponse({'comments':cms})
652
+
653
+ def _load_wall_posts():
654
+ with _wl_lock:
655
+ return _lj(WALL_FILE)
656
+
657
+ def _save_wall_posts(posts):
658
+ with _wl_lock:
659
+ _sj(WALL_FILE, posts)
660
+
661
+ @app.get('/api/wall')
662
+ def api_wall():
663
+ posts = _load_wall_posts()
664
+ if not posts:
665
+ return JSONResponse({"posts": []})
666
+ return JSONResponse({"posts": posts})
667
+
668
+ @app.post('/api/wall')
669
+ async def api_wall_post(request: Request):
670
+ content_type = request.headers.get('content-type', '')
671
+ if 'multipart/form-data' in content_type:
672
+ try:
673
+ form = await request.form()
674
+ except Exception as e:
675
+ return JSONResponse({"error": f"Form parse error: {str(e)}"}, status_code=400)
676
+ title = form.get('title', 'Video mới') or 'Video mới'
677
+ text = form.get('text', '') or ''
678
+ source = form.get('source', 'vtv_recorder') or 'vtv_recorder'
679
+ video_file = form.get('video')
680
+ post_id = str(uuid.uuid4())[:12]
681
+ video_url = None
682
+ if video_file and hasattr(video_file, 'filename') and video_file.filename:
683
+ fname = video_file.filename.lower()
684
+ if fname.endswith('.mp4'):
685
+ ext = '.mp4'
686
+ elif fname.endswith('.webm'):
687
+ ext = '.webm'
688
+ else:
689
+ ext = '.webm'
690
+ video_filename = f"wall_{post_id}{ext}"
691
+ video_path = os.path.join(WALL_VIDEO_DIR, video_filename)
692
+ try:
693
+ content = await video_file.read()
694
+ if not content:
695
+ return JSONResponse({"error": "Empty video file"}, status_code=400)
696
+ with open(video_path, 'wb') as f:
697
+ f.write(content)
698
+ file_size_mb = len(content) / 1024 / 1024
699
+ if file_size_mb > 50:
700
+ os.remove(video_path)
701
+ return JSONResponse({"error": f"Video quá lớn ({file_size_mb:.1f}MB). Tối đa 50MB."}, status_code=400)
702
+ video_url = f"/api/wall/video/{video_filename}"
703
+ except Exception as e:
704
+ return JSONResponse({"error": f"Lỗi lưu video: {str(e)}"}, status_code=500)
705
+ post = {
706
+ "id": post_id,
707
+ "title": title[:200],
708
+ "text": text[:2000],
709
+ "source": source,
710
+ "video": video_url,
711
+ "img": None,
712
+ "images": [],
713
+ "created": int(time.time()),
714
+ "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
715
+ }
716
+ posts = _load_wall_posts()
717
+ if not isinstance(posts, list):
718
+ posts = []
719
+ posts.insert(0, post)
720
+ posts = posts[:200]
721
+ _save_wall_posts(posts)
722
+ return JSONResponse({"post": post, "ok": True})
723
+ try:
724
+ body = await request.json()
725
+ except:
726
+ body = {}
727
+ title = body.get('title', 'Bài mới') or 'Bài mới'
728
+ text = body.get('text', '') or ''
729
+ img = body.get('img', None)
730
+ source = body.get('source', 'user') or 'user'
731
+ post_id = str(uuid.uuid4())[:12]
732
+ post = {
733
+ "id": post_id,
734
+ "title": title[:200],
735
+ "text": text[:2000],
736
+ "source": source,
737
+ "video": None,
738
+ "img": img,
739
+ "images": [],
740
+ "created": int(time.time()),
741
+ "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
742
+ }
743
+ posts = _load_wall_posts()
744
+ if not isinstance(posts, list):
745
+ posts = []
746
+ posts.insert(0, post)
747
+ posts = posts[:200]
748
+ _save_wall_posts(posts)
749
+ return JSONResponse({"post": post, "ok": True})
750
+
751
+ @app.get('/api/wall/video/{filename}')
752
+ def api_wall_video(filename: str):
753
+ if '..' in filename or '/' in filename:
754
+ return Response(status_code=403)
755
+ video_path = os.path.join(WALL_VIDEO_DIR, filename)
756
+ if not os.path.exists(video_path):
757
+ return Response(status_code=404)
758
+ ext = os.path.splitext(filename)[1].lower()
759
+ media_type = 'video/mp4' if ext == '.mp4' else 'video/webm'
760
+ return FileResponse(video_path, media_type=media_type)
761
+
762
+ @app.delete('/api/wall/{post_id}')
763
+ def api_wall_delete(post_id: str):
764
+ posts = _load_wall_posts()
765
+ if not isinstance(posts, list):
766
+ return JSONResponse({"error": "No posts"}, status_code=404)
767
+ for i, p in enumerate(posts):
768
+ if p.get('id') == post_id:
769
+ if p.get('video'):
770
+ video_name = p['video'].split('/')[-1]
771
+ video_path = os.path.join(WALL_VIDEO_DIR, video_name)
772
+ if os.path.exists(video_path):
773
+ os.remove(video_path)
774
+ posts.pop(i)
775
+ _save_wall_posts(posts)
776
+ return JSONResponse({"ok": True})
777
+ return JSONResponse({"error": "Post not found"}, status_code=404)
778
+
779
+ # ===== LANGUAGE & EMOTION DETECTION =====
780
+ import random as _random2
781
+ from urllib.parse import quote as _quote2
782
+
783
+ _UA_RW = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept-Language': 'vi-VN,vi;q=0.9'}
784
+
785
+ # Unique character markers for language detection
786
+ _UNIQUE_CHARS = {
787
+ 'vietnamese': set('đăâêôơưàảãạáằẳẵặắầẩẫậấèẻẽẹéềễểệếìỉĩịíòỏõọóồổỗộốờởỡợớùủũụúừửữựứỳỷỹỵý'),
788
+ 'spanish': set('ñáéíóúü¿¡'),
789
+ 'portuguese': set('ãõçáéíóúâêôà'),
790
+ }
791
+
792
+ _STOPWORDS = {
793
+ 'english': {'the', 'is', 'at', 'which', 'on', 'a', 'an', 'and', 'or', 'but', 'in', 'with', 'to', 'for', 'of', 'not', 'no', 'can', 'had', 'have', 'has', 'was', 'were', 'are', 'be', 'been', 'this', 'that', 'it', 'he', 'she', 'they', 'his', 'her', 'my', 'your', 'our', 'we', 'you', 'i'},
794
+ 'vietnamese': {'là', 'của', 'và', 'có', 'được', 'cho', 'không', 'với', 'này', 'đó', 'từ', 'trong', 'đã', 'sẽ', 'một', 'các', 'những', 'về', 'tại', 'người', 'năm', 'đến', 'ra', 'lại', 'như', 'khi', 'để', 'rất', 'cũng', 'mà', 'nếu', 'sau', 'trên', 'theo', 'vì', 'do', 'nên', 'thì', 'mình', 'tôi', 'bạn', 'anh', 'chị', 'em'},
795
+ 'portuguese': {'de', 'um', 'que', 'e', 'do', 'da', 'em', 'para', 'com', 'não', 'uma', 'os', 'no', 'se', 'na', 'por', 'mais', 'as', 'dos', 'como', 'mas', 'ao', 'ele', 'das', 'tem', 'seu', 'sua', 'ou', 'quando', 'muito', 'nos', 'já', 'eu', 'também', 'só', 'pelo', 'pela', 'até', 'isso', 'ela', 'entre', 'depois', 'sem', 'mesmo', 'aos', 'são', 'está', 'ter', 'ser', 'foi', 'era', 'há', 'estão', 'você', 'nós', 'eles', 'elas'},
796
+ 'spanish': {'de', 'que', 'el', 'en', 'y', 'a', 'los', 'del', 'se', 'las', 'por', 'un', 'para', 'con', 'no', 'una', 'su', 'al', 'es', 'lo', 'como', 'más', 'pero', 'sus', 'le', 'ya', 'o', 'fue', 'este', 'ha', 'si', 'porque', 'esta', 'son', 'entre', 'está', 'cuando', 'muy', 'sin', 'sobre', 'ser', 'también', 'me', 'hasta', 'hay', 'donde', 'han', 'quien', 'están', 'desde', 'todo', 'nos', 'durante', 'todos', 'uno', 'les', 'ni', 'contra', 'otros', 'fueron', 'ese', 'eso', 'ante', 'ellos', 'yo', 'tú', 'él', 'ella', 'nosotros', 'usted', 'ustedes'},
797
+ }
798
+
799
+ def detect_language(text):
800
+ """Detect language from text content using stopword + character analysis."""
801
+ if not text:
802
+ return 'vietnamese'
803
+ text_lower = text.lower()
804
+ text_chars = set(text_lower)
805
+
806
+ # Strong signal: Vietnamese unique characters
807
+ vn_chars = len(text_chars & _UNIQUE_CHARS['vietnamese'])
808
+ if vn_chars >= 2:
809
+ return 'vietnamese'
810
+
811
+ # Spanish unique chars (ñ, ¿, ¡)
812
+ es_chars = len(text_chars & _UNIQUE_CHARS['spanish'])
813
+ pt_chars = len(text_chars & _UNIQUE_CHARS['portuguese'])
814
+
815
+ # Stopword scoring
816
+ words = set(re.findall(r'\b\w+\b', text_lower))
817
+ scores = {}
818
+ for lang, stops in _STOPWORDS.items():
819
+ scores[lang] = len(words & stops) / max(len(stops), 1)
820
+
821
+ # Disambiguate Portuguese vs Spanish
822
+ pt_markers = {'não', 'pelo', 'pela', 'isso', 'há', 'estão', 'num', 'numa', 'tenho', 'posso', 'você', 'nós', 'eles', 'elas', 'também', 'muito', 'já', 'só', 'até', 'entre', 'depois', 'sem', 'mesmo', 'aos', 'serão'}
823
+ es_markers = {'pero', 'está', 'están', 'porque', 'también', 'hasta', 'donde', 'quien', 'fue', 'son', 'fueron', 'ese', 'eso', 'ante', 'ellos', 'ella', 'nosotros', 'usted', 'ustedes', 'tú', 'él', 'desde', 'todo', 'durante', 'todos', 'uno', 'les', 'ni', 'contra', 'otros', 'fueron'}
824
+
825
+ pt_overlap = len(words & pt_markers)
826
+ es_overlap = len(words & es_markers)
827
+
828
+ if scores.get('portuguese', 0) > 0 and pt_overlap > es_overlap:
829
+ return 'portuguese'
830
+ if scores.get('spanish', 0) > 0 and es_overlap > pt_overlap:
831
+ return 'spanish'
832
+ if scores.get('english', 0) > 0.15:
833
+ return 'english'
834
+
835
+ best = max(scores, key=scores.get)
836
+ return best if scores[best] > 0.05 else 'vietnamese'
837
+
838
+ # Emotion keyword-based detection
839
+ _EMOTION_KEYWORDS = {
840
+ 'happy': {
841
+ 'en': ['happy', 'joy', 'wonderful', 'great', 'amazing', 'fantastic', 'love', 'excellent', 'beautiful', 'glad', 'delighted', 'pleased', 'cheerful', 'celebrate', 'victory', 'win', 'success'],
842
+ 'pt': ['feliz', 'alegria', 'maravilhoso', 'ótimo', 'incrível', 'fantástico', 'amor', 'excelente', 'lindo', 'contente', 'encantado', 'vitória', 'sucesso'],
843
+ 'es': ['feliz', 'alegria', 'maravilloso', 'genial', 'increíble', 'fantástico', 'amor', 'excelente', 'hermoso', 'contento', 'encantado', 'victoria', 'éxito'],
844
+ 'vi': ['vui', 'hạnh phúc', 'tuyệt vời', 'tuyệt', 'ý nghĩa', 'đẹp', 'thích', 'yêu', 'vui vẻ', 'hân hoan', 'phấn khích', 'chiến thắng', 'thành công'],
845
+ },
846
+ 'sad': {
847
+ 'en': ['sad', 'unhappy', 'terrible', 'awful', 'horrible', 'miserable', 'depressed', 'grief', 'sorrow', 'tragic', 'unfortunate', 'painful', 'death', 'die', 'kill'],
848
+ 'pt': ['triste', 'infeliz', 'terrível', 'horrível', 'miserável', 'deprimido', 'dor', 'trágico', 'infelizmente', 'penoso', 'morte', 'morrer'],
849
+ 'es': ['triste', 'infeliz', 'terrible', 'horrible', 'miserable', 'deprimido', 'dolor', 'trágico', 'desafortunado', 'penoso', 'muerte', 'morir'],
850
+ 'vi': ['buồn', 'không vui', 'tồi tệ', 'kinh khủng', 'đau khổ', 'đau buồn', 'bi thương', 'khốn nạn', 'đau đớn', 'thảm họa', 'chết', 'mất'],
851
+ },
852
+ 'excited': {
853
+ 'en': ['excited', 'thrilling', 'amazing', 'wow', 'incredible', 'unbelievable', 'awesome', 'exhilarating', 'electrifying', 'breathtaking', 'breakthrough', 'record'],
854
+ 'pt': ['animado', 'emocionante', 'incrível', 'impressionante', 'sensacional', 'eletrizante', 'empolgante', 'recorde'],
855
+ 'es': ['emocionante', 'increíble', 'impresionante', 'sensacional', 'electrizante', 'emocionado', 'entusiasmado', 'récord'],
856
+ 'vi': ['hào hứng', 'phấn khích', 'thú vị', 'tuyệt cú mèo', 'đỉnh cao', 'ngoạn mục', 'sục sôi', 'kỷ lục', 'đột phá'],
857
+ },
858
+ 'humorous': {
859
+ 'en': ['funny', 'hilarious', 'joke', 'laugh', 'comedy', 'humor', 'amusing', 'witty', 'sarcastic', 'ironic', 'ridiculous', 'absurd', 'lol', 'haha'],
860
+ 'pt': ['engraçado', 'hilário', 'piada', 'rir', 'comédia', 'humor', 'divertido', 'irônico', 'ridículo', 'absurdo', 'kkk'],
861
+ 'es': ['gracioso', 'hilarante', 'broma', 'risa', 'comedia', 'humor', 'divertido', 'irónico', 'ridículo', 'absurdo', 'jaja'],
862
+ 'vi': ['hài hước', 'buồn cười', 'đùa', 'cười', 'hài', 'vui nhộn', 'hóm hỉnh', 'mỉa mai', 'lố bịch', 'vô lý', 'haha'],
863
+ },
864
+ 'serious': {
865
+ 'en': ['serious', 'critical', 'important', 'urgent', 'severe', 'grave', 'significant', 'crucial', 'vital', 'essential', 'alarming', 'concerning', 'crisis', 'war', 'conflict'],
866
+ 'pt': ['sério', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'essencial', 'preocupante', 'crise', 'guerra', 'conflito'],
867
+ 'es': ['serio', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'esencial', 'preocupante', 'crisis', 'guerra', 'conflicto'],
868
+ 'vi': ['nghiêm trọng', 'quan trọng', 'khẩn cấp', 'nghiêm túc', 'đáng kể', 'thiết yếu', 'cần thiết', 'báo động', 'lo ngại', 'khủng hoảng', 'chiến tranh', 'xung đột'],
869
+ },
870
+ }
871
+
872
+ def detect_emotion(text, language='vietnamese'):
873
+ """Detect emotion from text using keyword matching."""
874
+ if not text:
875
+ return 'neutral'
876
+ text_lower = text.lower()
877
+
878
+ scores = {}
879
+ for emotion, lang_keywords in _EMOTION_KEYWORDS.items():
880
+ keywords = lang_keywords.get(language, lang_keywords.get('en', []))
881
+ score = sum(1 for kw in keywords if kw in text_lower)
882
+ scores[emotion] = score
883
+
884
+ if max(scores.values()) == 0:
885
+ return 'neutral'
886
+
887
+ return max(scores, key=scores.get)
888
+
889
+ def detect_language_and_emotion(title, text):
890
+ """Detect both language and emotion from article content."""
891
+ combined = f"{title} {text}"
892
+ lang = detect_language(combined)
893
+ emotion = detect_emotion(combined, lang)
894
+ return lang, emotion
895
+
896
+ # Voice selection based on language and emotion (using MultilingualNeural voices)
897
+ VOICE_BY_LANG_EMOTION = {
898
+ 'vietnamese': {
899
+ 'happy': ('vi-VN-HoaiMyNeural', 'vui'),
900
+ 'sad': ('vi-VN-NamMinhNeural', 'buồn'),
901
+ 'excited': ('vi-VN-HoaiMyNeural', 'hào hứng'),
902
+ 'humorous': ('vi-VN-HoaiMyNeural', 'vui'),
903
+ 'serious': ('vi-VN-NamMinhNeural', 'nghiêm túc'),
904
+ 'neutral': ('vi-VN-HoaiMyNeural', 'trung_tinh'),
905
+ },
906
+ 'portuguese': {
907
+ 'happy': ('pt-BR-ThalitaMultilingualNeural', 'feliz'),
908
+ 'sad': ('pt-BR-ThalitaMultilingualNeural', 'triste'),
909
+ 'excited': ('pt-BR-ThalitaMultilingualNeural', 'animado'),
910
+ 'humorous': ('pt-BR-ThalitaMultilingualNeural', 'engraçado'),
911
+ 'serious': ('pt-BR-ThalitaMultilingualNeural', 'sério'),
912
+ 'neutral': ('pt-BR-ThalitaMultilingualNeural', 'neutro'),
913
+ },
914
+ 'english': {
915
+ 'happy': ('en-US-AndrewMultilingualNeural', 'happy'),
916
+ 'sad': ('en-AU-WilliamMultilingualNeural', 'sad'),
917
+ 'excited': ('en-US-AndrewMultilingualNeural', 'excited'),
918
+ 'humorous': ('en-US-AndrewMultilingualNeural', 'funny'),
919
+ 'serious': ('en-AU-WilliamMultilingualNeural', 'serious'),
920
+ 'neutral': ('en-US-AndrewMultilingualNeural', 'neutral'),
921
+ },
922
+ 'french': {
923
+ 'happy': ('fr-FR-VivienneMultilingualNeural', 'heureux'),
924
+ 'sad': ('fr-FR-RemyMultilingualNeural', 'triste'),
925
+ 'excited': ('fr-FR-VivienneMultilingualNeural', 'excité'),
926
+ 'humorous': ('fr-FR-VivienneMultilingualNeural', 'drôle'),
927
+ 'serious': ('fr-FR-RemyMultilingualNeural', 'sérieux'),
928
+ 'neutral': ('fr-FR-VivienneMultilingualNeural', 'neutre'),
929
+ },
930
+ 'german': {
931
+ 'happy': ('de-DE-SeraphinaMultilingualNeural', 'glücklich'),
932
+ 'sad': ('de-DE-FlorianMultilingualNeural', 'traurig'),
933
+ 'excited': ('de-DE-SeraphinaMultilingualNeural', 'aufgeregt'),
934
+ 'humorous': ('de-DE-SeraphinaMultilingualNeural', 'lustig'),
935
+ 'serious': ('de-DE-FlorianMultilingualNeural', 'ernst'),
936
+ 'neutral': ('de-DE-SeraphinaMultilingualNeural', 'neutral'),
937
+ },
938
+ 'korean': {
939
+ 'happy': ('ko-KR-HyunsuMultilingualNeural', '행복'),
940
+ 'sad': ('ko-KR-HyunsuMultilingualNeural', '슬픔'),
941
+ 'excited': ('ko-KR-HyunsuMultilingualNeural', '흥분'),
942
+ 'humorous': ('ko-KR-HyunsuMultilingualNeural', '유쾌'),
943
+ 'serious': ('ko-KR-HyunsuMultilingualNeural', '진지'),
944
+ 'neutral': ('ko-KR-HyunsuMultilingualNeural', '중립'),
945
+ },
946
+ 'italian': {
947
+ 'happy': ('it-IT-GiuseppeMultilingualNeural', 'felice'),
948
+ 'sad': ('it-IT-GiuseppeMultilingualNeural', 'triste'),
949
+ 'excited': ('it-IT-GiuseppeMultilingualNeural', 'emozionato'),
950
+ 'humorous': ('it-IT-GiuseppeMultilingualNeural', 'divertente'),
951
+ 'serious': ('it-IT-GiuseppeMultilingualNeural', 'serio'),
952
+ 'neutral': ('it-IT-GiuseppeMultilingualNeural', 'neutro'),
953
+ },
954
+ }
955
+
956
+ # All valid voice IDs (new MultilingualNeural format)
957
+ VALID_VOICES = {
958
+ 'vi-VN-HoaiMyNeural', 'vi-VN-NamMinhNeural',
959
+ 'en-US-AndrewMultilingualNeural', 'en-AU-WilliamMultilingualNeural',
960
+ 'pt-BR-ThalitaMultilingualNeural',
961
+ 'fr-FR-VivienneMultilingualNeural', 'fr-FR-RemyMultilingualNeural',
962
+ 'de-DE-SeraphinaMultilingualNeural', 'de-DE-FlorianMultilingualNeural',
963
+ 'ko-KR-HyunsuMultilingualNeural',
964
+ 'it-IT-GiuseppeMultilingualNeural',
965
+ }
966
+
967
+ def get_voice_for_content(title, text, preferred_voice=None):
968
+ """Get appropriate voice based on content language and emotion."""
969
+ # Accept the new MultilingualNeural voices directly
970
+ if preferred_voice and preferred_voice in VALID_VOICES:
971
+ return preferred_voice
972
+
973
+ # Also accept old shorthand voice IDs and map them to new format
974
+ old_voice_map = {
975
+ 'hoaimy': 'vi-VN-HoaiMyNeural',
976
+ 'namminh': 'vi-VN-NamMinhNeural',
977
+ 'andrew': 'en-US-AndrewMultilingualNeural',
978
+ 'jenny': 'en-US-AndrewMultilingualNeural',
979
+ 'thalita': 'pt-BR-ThalitaMultilingualNeural',
980
+ 'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
981
+ 'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
982
+ 'ela': 'en-US-AndrewMultilingualNeural',
983
+ 'es_carlos': 'en-US-AndrewMultilingualNeural',
984
+ 'denise': 'fr-FR-VivienneMultilingualNeural',
985
+ 'katja': 'de-DE-SeraphinaMultilingualNeural',
986
+ 'nanami': 'en-US-AndrewMultilingualNeural',
987
+ 'sunhee': 'ko-KR-HyunsuMultilingualNeural',
988
+ 'xiaochen': 'en-US-AndrewMultilingualNeural',
989
+ }
990
+ if preferred_voice and preferred_voice in old_voice_map:
991
+ return old_voice_map[preferred_voice]
992
+
993
+ lang, emotion = detect_language_and_emotion(title, text)
994
+ lang_map = VOICE_BY_LANG_EMOTION.get(lang, VOICE_BY_LANG_EMOTION['vietnamese'])
995
+ voice, _ = lang_map.get(emotion, lang_map['neutral'])
996
+ return voice
997
+
998
+
999
+ def _is_relevant_image(img_url, title, text):
1000
+ """Check if an image is relevant to the article content."""
1001
+ if not img_url:
1002
+ return False
1003
+ skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif',
1004
+ 'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite',
1005
+ 'advertisement', 'ad-banner', 'sponsored', 'banner-ads']
1006
+ img_lower = img_url.lower()
1007
+ for p in skip_patterns:
1008
+ if p in img_lower:
1009
+ return False
1010
+ if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']):
1011
+ return False
1012
+ return True
1013
+
1014
+
1015
+ def _filter_relevant_images(images, title, text, max_images=8):
1016
+ """Filter and rank images by relevance to article content."""
1017
+ if not images:
1018
+ return []
1019
+ seen = set()
1020
+ relevant = []
1021
+ for img in images:
1022
+ if img in seen:
1023
+ continue
1024
+ seen.add(img)
1025
+ if _is_relevant_image(img, title, text):
1026
+ relevant.append(img)
1027
+ return relevant[:max_images]
1028
+
1029
+
1030
+ def _scrape_article_for_rewrite(url):
1031
+ """Scrape article: extract title, paragraphs, RELEVANT images, OG image."""
1032
+ try:
1033
+ r = req.get(url, headers=_UA_RW, timeout=15, allow_redirects=True)
1034
+ r.encoding = 'utf-8'
1035
+ soup = BeautifulSoup(r.text, 'lxml')
1036
+ for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
1037
+ tag.decompose()
1038
+ h1 = soup.find('h1')
1039
+ ogt = soup.find('meta', property='og:title')
1040
+ title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '')
1041
+ ogi = soup.find('meta', property='og:image')
1042
+ og_img = ogi.get('content', '') if ogi else ''
1043
+ if og_img and og_img.startswith('//'):
1044
+ og_img = 'https:' + og_img
1045
+ block = None
1046
+ for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
1047
+ el = soup.select_one(sel)
1048
+ if el and len(el.find_all('p')) >= 2:
1049
+ block = el
1050
+ break
1051
+ if not block:
1052
+ block = soup.body or soup
1053
+ paragraphs = []
1054
+ all_images = []
1055
+ seen_imgs = set()
1056
+ if og_img and og_img not in seen_imgs:
1057
+ all_images.append(og_img)
1058
+ seen_imgs.add(og_img)
1059
+ for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
1060
+ if el.name == 'p':
1061
+ t = _clean(el.get_text(strip=True))
1062
+ if t and len(t) > 40:
1063
+ paragraphs.append(t)
1064
+ elif el.name in ('figure', 'img'):
1065
+ im = el if el.name == 'img' else el.find('img')
1066
+ if im:
1067
+ src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
1068
+ if src and 'base64' not in src:
1069
+ if src.startswith('//'):
1070
+ src = 'https:' + src
1071
+ if src not in seen_imgs:
1072
+ all_images.append(src)
1073
+ seen_imgs.add(src)
1074
+ relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5]))
1075
+ paragraphs_text = '\n\n'.join(paragraphs)
1076
+ lang, emotion = detect_language_and_emotion(title, paragraphs_text)
1077
+ lang_name_map = {'vietnamese': 'vi', 'english': 'en', 'portuguese': 'pt', 'spanish': 'es',
1078
+ 'french': 'fr', 'german': 'de', 'korean': 'ko', 'italian': 'it'}
1079
+ return {'title': _clean(title), 'text': paragraphs_text, 'images': relevant_images,
1080
+ 'og_img': og_img, 'language': lang_name_map.get(lang, 'vi'), 'emotion': emotion}
1081
+ except Exception as e:
1082
+ return None