bep40 commited on
Commit
1229376
·
verified ·
1 Parent(s): 48609cc

Restore full app_v2_entry.py with auto scheduler import

Browse files
Files changed (1) hide show
  1. app_v2_entry.py +1863 -1
app_v2_entry.py CHANGED
@@ -35,4 +35,1866 @@ STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
35
  SPACE = "https://bep40-vnews.hf.space" # SEO URL base for share links
36
  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()))]
37
  app.routes[:]=[r for r in app.routes if not isinstance(r, Mount)]
38
- app.router.routes=[r for r in app.router.routes if not isinstance(r, Mount)]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  SPACE = "https://bep40-vnews.hf.space" # SEO URL base for share links
36
  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()))]
37
  app.routes[:]=[r for r in app.routes if not isinstance(r, Mount)]
38
+ app.router.routes=[r for r in app.router.routes if not isinstance(r, Mount)]
39
+
40
+ def _clean(s): return re.sub(r"\s+"," ",html_lib.unescape(str(s or""))).strip()
41
+
42
+ # Cache for match details (5 min TTL)
43
+ _match_cache = {}
44
+
45
+ # === FAST BONGDA PROXY ENDPOINT ===
46
+ def _get_match_detail(event_id, slug=None):
47
+ headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "text/html", "Referer": "https://bongda.com.vn/"}
48
+ if slug:
49
+ url = f"https://bongda.com.vn/tran-dau/{event_id}/centre/{slug}"
50
+ else:
51
+ url = f"https://bongda.com.vn/tran-dau/{event_id}"
52
+ resp = req.get(url, headers=headers, timeout=15, allow_redirects=True)
53
+ if resp.status_code != 200:
54
+ return None
55
+ soup = BeautifulSoup(resp.text, 'html.parser')
56
+ result = {"event_id": event_id, "found": False, "sections": []}
57
+ info = {}
58
+ tel = soup.select_one('.teams')
59
+ if tel:
60
+ he = tel.select_one('.team.home')
61
+ if he:
62
+ p_tags = [p for p in he.select('p') if not p.get('class') or 'logo' not in p.get('class', [])]
63
+ if p_tags: info['home_team'] = _clean(p_tags[0].get_text())
64
+ lo = he.select_one('img')
65
+ if lo: info['home_logo'] = lo.get('src', '')
66
+ ae = tel.select_one('.team.away')
67
+ if ae:
68
+ p_tags = ae.select('p')
69
+ team_ps = [p for p in p_tags if not p.get('class') or 'logo' not in p.get('class', [])]
70
+ if team_ps: info['away_team'] = _clean(team_ps[-1].get_text())
71
+ lo = ae.select_one('img')
72
+ if lo: info['away_logo'] = lo.get('src', '')
73
+ sc = tel.select_one('.score')
74
+ if sc:
75
+ parts = [_clean(p.get_text()) for p in sc.select('p')]
76
+ if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}"
77
+ lb = sc.select_one('.label')
78
+ if lb: info['status_label'] = _clean(lb.get_text())
79
+ if info.get('home_team') and info.get('away_team'):
80
+ result['info'] = info
81
+ result['found'] = True
82
+ result['sections'].append('info')
83
+ events = []
84
+ for ev in soup.select('.events .period .event'):
85
+ ev_cls = ' '.join(ev.get('class', []))
86
+ ev_data = {'team': 'home' if 'home' in ev_cls else 'away', 'period': '', 'type': 'unknown', 'time': '', 'players': ''}
87
+ parent = ev.parent
88
+ if parent:
89
+ h2 = parent.find('h2')
90
+ if h2: ev_data['period'] = _clean(h2.get_text())
91
+ if ev.select_one('[class*="goal"]'): ev_data['type'] = 'goal'
92
+ elif ev.select_one('[class*="redcard"]'): ev_data['type'] = 'redcard'
93
+ elif ev.select_one('[class*="yellowcard"]'): ev_data['type'] = 'yellowcard'
94
+ elif ev.select_one('[class*="substitution"]'): ev_data['type'] = 'substitution'
95
+ players_el = ev.select_one('.players')
96
+ if players_el:
97
+ pl_text = _clean(players_el.get_text(' ', strip=True))
98
+ m = re.match(r"(\d+)'(.*)", pl_text)
99
+ if m:
100
+ ev_data['time'] = f"{m.group(1)}'"
101
+ ev_data['players'] = m.group(2)
102
+ else:
103
+ ev_data['players'] = pl_text
104
+ events.append(ev_data)
105
+ if events:
106
+ result['events'] = events
107
+ result['sections'].append('events')
108
+ pred = soup.select_one('.prediction-card')
109
+ if pred:
110
+ team_info = pred.select_one('.team-info')
111
+ if team_info:
112
+ teams = team_info.select('.team')
113
+ pred_data = {}
114
+ if len(teams) >= 2:
115
+ pred_data['home_name'] = _clean(teams[0].select_one('.team-name').get_text()) if teams[0].select_one('.team-name') else ''
116
+ pred_data['away_name'] = _clean(teams[1].select_one('.team-name').get_text()) if teams[1].select_one('.team-name') else ''
117
+ divider = team_info.select_one('.divider')
118
+ if divider: pred_data['result'] = _clean(divider.get_text())
119
+ vc = pred.select_one('.vote-count')
120
+ if vc: pred_data['vote_count'] = _clean(vc.get_text())
121
+ result['prediction'] = pred_data
122
+ recent = []
123
+ ml = soup.select_one('.matches-list')
124
+ if ml:
125
+ for item in ml.select('.match-detail, .match-item, li'):
126
+ de = item.select_one('.date, .time')
127
+ le = item.select_one('.league')
128
+ he_item = item.select_one('.home, .team-home')
129
+ ae_item = item.select_one('.away, .team-away')
130
+ se = item.select_one('.score, .result')
131
+ if he_item or ae_item:
132
+ 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'})
133
+ if recent:
134
+ result['recent_matches'] = recent
135
+ result['sections'].append('recent')
136
+ try:
137
+ api_h = {"User-Agent": "Mozilla/5.0", "Accept": "application/json", "X-Requested-With": "XMLHttpRequest", "Referer": "https://bongda.com.vn/"}
138
+ ar = req.get(f"https://bongda.com.vn/api/fixtures/h2h-stats?event_id={event_id}", headers=api_h, timeout=10)
139
+ if ar.status_code == 200:
140
+ ad = ar.json()
141
+ if ad.get('status') == 'success' and ad.get('html'):
142
+ asp = BeautifulSoup(ad['html'], 'html.parser')
143
+ ast = {}
144
+ for row in asp.select('li, tr'):
145
+ cells = row.select('td, span, p')
146
+ if len(cells) >= 3:
147
+ lb = _clean(cells[0].get_text())
148
+ if lb: ast[lb] = {'home': _clean(cells[1].get_text()), 'away': _clean(cells[2].get_text())}
149
+ if ast:
150
+ result['h2h_stats_parsed'] = ast
151
+ result['sections'].append('h2h_stats')
152
+ except: pass
153
+ return result
154
+
155
+ @app.get('/api/proxy/bongda')
156
+ def proxy_bongda(event_id: int = Query(default=None), slug: str = Query(default=None)):
157
+ if event_id is None:
158
+ return JSONResponse({'error': 'event_id required'}, status_code=400)
159
+ cache_key = f"{event_id}_{slug}"
160
+ now = time.time()
161
+ cached = _match_cache.get(cache_key)
162
+ if cached and now - cached.get('_ts', 0) < 300:
163
+ return JSONResponse(cached)
164
+ try:
165
+ result = _get_match_detail(event_id, slug)
166
+ if result:
167
+ result['_ts'] = now
168
+ _match_cache[cache_key] = result
169
+ return JSONResponse(result)
170
+ except Exception as e:
171
+ err = {"event_id": event_id, "found": False, "error": str(e), "_ts": now}
172
+ _match_cache[cache_key] = err
173
+ return JSONResponse(err)
174
+ return JSONResponse({"event_id": event_id, "found": False})
175
+
176
+ @app.get('/api/match/{event_id}/detail')
177
+ def api_match_detail(event_id: int, url: str = Query(default=None)):
178
+ slug = None
179
+ if url:
180
+ m = re.match(r'.+/tran-dau/\d+/(?:centre|preview)/(.+)', url)
181
+ if m:
182
+ slug = m.group(1)
183
+ cache_key = f"{event_id}_{slug or ''}"
184
+ now = time.time()
185
+ cached = _match_cache.get(cache_key)
186
+ if cached and now - cached.get('_ts', 0) < 300:
187
+ return JSONResponse(cached)
188
+ try:
189
+ if not slug:
190
+ try:
191
+ home_r = req.get("https://bongda.com.vn/", headers={"User-Agent": "Mozilla/5.0"}, timeout=10)
192
+ if home_r.status_code == 200:
193
+ home_soup = BeautifulSoup(home_r.text, 'html.parser')
194
+ for a in home_soup.select(f'a[href*="/tran-dau/{event_id}/"]'):
195
+ href = a.get('href', '')
196
+ m = re.match(r'/tran-dau/\d+/(?:centre|preview)/(.+)', href)
197
+ if m:
198
+ slug = m.group(1)
199
+ cache_key = f"{event_id}_{slug}"
200
+ break
201
+ except: pass
202
+ result = _get_match_detail(event_id, slug)
203
+ if result:
204
+ result['_ts'] = now
205
+ _match_cache[cache_key] = result
206
+ return JSONResponse(result)
207
+ except Exception as e:
208
+ err = {"event_id": event_id, "found": False, "error": str(e), "_ts": now}
209
+ _match_cache[cache_key] = err
210
+ return JSONResponse(err)
211
+ return JSONResponse({"event_id": event_id, "found": False})
212
+
213
+ _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())
214
+
215
+ def _has_kw(topic,title):
216
+ tl=topic.lower();tt=(title or'').lower()
217
+ if tl in tt:return True
218
+ words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',tl) if len(w)>2 and w not in _STOP]
219
+ if not words:return True
220
+ return any(w in tt for w in words)
221
+
222
+ def _s_vnexpress(topic,limit=8):
223
+ items=[]
224
+ try:
225
+ r=req.get(f"https://timkiem.vnexpress.net/?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
226
+ for art in soup.select('article.item-news')[:limit]:
227
+ a=art.select_one('h2 a, h3 a')
228
+ if a and a.get('href'):
229
+ t=_clean(a.get('title','') or a.get_text(strip=True))
230
+ if _has_kw(topic,t):items.append({'title':t,'url':a['href'],'via':'VnExpress'})
231
+ except:pass
232
+ return items
233
+
234
+ def _s_dantri(topic,limit=8):
235
+ items=[]
236
+ try:
237
+ 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')
238
+ for a in soup.select('h3 a[href], .article-title a[href]')[:limit*2]:
239
+ t=_clean(a.get_text(strip=True));href=a.get('href','')
240
+ if t and len(t)>15 and _has_kw(topic,t):
241
+ if not href.startswith('http'):href='https://dantri.com.vn'+href
242
+ items.append({'title':t,'url':href,'via':'Dân Trí'})
243
+ if len(items)>=limit:break
244
+ except:pass
245
+ return items
246
+
247
+ def _s_vietnamnet(topic,limit=6):
248
+ items=[]
249
+ try:
250
+ 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')
251
+ for a in soup.select('h3 a[href], .vnn-title a')[:limit*2]:
252
+ t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
253
+ if t and len(t)>15 and _has_kw(topic,t):
254
+ if not href.startswith('http'):href='https://vietnamnet.vn'+href
255
+ items.append({'title':t,'url':href,'via':'VietNamNet'})
256
+ if len(items)>=limit:break
257
+ except:pass
258
+ return items
259
+
260
+ def _s_bongda(topic,limit=5):
261
+ items=[]
262
+ try:
263
+ 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')
264
+ for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
265
+ t=_clean(a.get_text(strip=True));href=a.get('href','')
266
+ if t and len(t)>15 and _has_kw(topic,t):
267
+ if not href.startswith('http'):href='https://bongda.com.vn'+href
268
+ items.append({'title':t,'url':href,'via':'Bóng Đá'})
269
+ if len(items)>=limit:break
270
+ except:pass
271
+ return items
272
+
273
+ def _s_genk(topic,limit=5):
274
+ items=[]
275
+ try:
276
+ 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')
277
+ for a in soup.select('a[href$=".chn"]')[:limit*3]:
278
+ t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
279
+ if t and len(t)>15 and _has_kw(topic,t):
280
+ if href.startswith('/'):href='https://genk.vn'+href
281
+ items.append({'title':t,'url':href,'via':'GenK'})
282
+ if len(items)>=limit:break
283
+ except:pass
284
+ return items
285
+
286
+ def _s_thanhnien(topic,limit=6):
287
+ items=[]
288
+ try:
289
+ 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')
290
+ for a in soup.select('h3 a[href], .box-title a')[:limit*2]:
291
+ t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
292
+ if t and len(t)>15 and _has_kw(topic,t):
293
+ if not href.startswith('http'):href='https://thanhnien.vn'+href
294
+ items.append({'title':t,'url':href,'via':'Thanh Niên'})
295
+ if len(items)>=limit:break
296
+ except:pass
297
+ return items
298
+
299
+ def _s_tuoitre(topic,limit=6):
300
+ items=[]
301
+ try:
302
+ 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')
303
+ for a in soup.select('h3 a[href], .box-title-text a')[:limit*2]:
304
+ t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
305
+ if t and len(t)>15 and _has_kw(topic,t):
306
+ if not href.startswith('http'):href='https://tuoitre.vn'+href
307
+ items.append({'title':t,'url':href,'via':'Tuổi Trẻ'})
308
+ if len(items)>=limit:break
309
+ except:pass
310
+ return items
311
+
312
+ def _s_thethaovanhoa(topic,limit=5):
313
+ items=[]
314
+ try:
315
+ 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')
316
+ for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
317
+ t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
318
+ if t and len(t)>15 and _has_kw(topic,t):
319
+ if not href.startswith('http'):href='https://thethaovanhoa.vn'+href
320
+ items.append({'title':t,'url':href,'via':'TT&VH'})
321
+ if len(items)>=limit:break
322
+ except:pass
323
+ return items
324
+
325
+ def _search_all(topic,limit=36):
326
+ results={}
327
+ with ThreadPoolExecutor(8) as ex:
328
+ 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'}
329
+ for f in as_completed(futs,timeout=14):
330
+ try:results[futs[f]]=f.result()
331
+ except:results[futs[f]]=[]
332
+ srcs=list(results.values());out=[];seen=set()
333
+ for i in range(max((len(s) for s in srcs),default=0)):
334
+ for s in srcs:
335
+ 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])
336
+ return out[:limit]
337
+
338
+ for _path in ['/api/article', '/api/hot_topics', '/api/categories', '/api/storage_status']:
339
+ app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)==_path and 'GET' in getattr(r,'methods',set()))]
340
+
341
+ _article_cache = {}
342
+ _article_cache_ttl = 1800
343
+
344
+ _art_session = None
345
+ _art_lock = threading.Lock()
346
+ def _get_art_session():
347
+ global _art_session
348
+ if _art_session is None:
349
+ with _art_lock:
350
+ if _art_session is None:
351
+ _art_session = req.Session()
352
+ _art_session.headers.update({
353
+ "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",
354
+ "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8",
355
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
356
+ })
357
+ return _art_session
358
+
359
+ def _scrape_article_fast(url):
360
+ from urllib.parse import urlparse
361
+ domain = urlparse(url).netloc
362
+ sess = _get_art_session()
363
+ uas = [
364
+ {"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"},
365
+ {"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"},
366
+ ]
367
+ for ua in uas:
368
+ try:
369
+ r = sess.get(url, headers=ua, timeout=6, allow_redirects=True)
370
+ if not r or r.status_code != 200:
371
+ continue
372
+ r.encoding = 'utf-8'
373
+ soup = BeautifulSoup(r.text, 'lxml')
374
+ 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']):
375
+ tag.decompose()
376
+ title = summary = og_img = ""
377
+ ogt = soup.find('meta', property='og:title')
378
+ if ogt: title = ogt.get('content', '')
379
+ ogd = soup.find('meta', property='og:description') or soup.find('meta', attrs={'name': 'description'})
380
+ if ogd: summary = ogd.get('content', '')[:500]
381
+ ogi = soup.find('meta', property='og:image')
382
+ if ogi:
383
+ og_img = ogi.get('content', '')
384
+ if og_img.startswith('//'): og_img = 'https:' + og_img
385
+ h1 = soup.find('h1')
386
+ if not title and h1: title = h1.get_text(strip=True)[:200]
387
+ body = []
388
+ selectors = [
389
+ '.fck_detail', '.sidebar-1',
390
+ '.singular-content', '.dt__content', '.article-content', '.content-detail', '#divNewsContent',
391
+ '.content-detail', '.main-content-detail', '.box-content',
392
+ '.knc-content', '.article-body', '.detail-body',
393
+ '.article-detail', '.detail-content',
394
+ 'article', 'main', '.cms-body', '.article__body', '.post-content',
395
+ '.entry-content', '#content', '.article-text', '.story-body',
396
+ ]
397
+ for sel in selectors:
398
+ el = soup.select_one(sel)
399
+ if el and len(el.find_all('p')) >= 2:
400
+ seen_imgs = set()
401
+ for child in el.find_all(['p','h2','h3','figure','img'], recursive=True):
402
+ if child.name == 'p':
403
+ t = child.get_text(strip=True)
404
+ if t and len(t) > 15:
405
+ body.append({'type': 'p', 'text': t})
406
+ elif child.name in ('h2','h3'):
407
+ t = child.get_text(strip=True)
408
+ if t:
409
+ body.append({'type': 'heading', 'text': t})
410
+ elif child.name in ('figure','img'):
411
+ im = child if child.name == 'img' else child.find('img')
412
+ if im:
413
+ src = im.get('data-src') or im.get('src') or im.get('data-lazy') or ''
414
+ if src and 'base64' not in src and src not in seen_imgs:
415
+ seen_imgs.add(src)
416
+ if src.startswith('//'): src = 'https:' + src
417
+ body.append({'type': 'img', 'src': src})
418
+ if child.name == 'figure':
419
+ cap = child.find('figcaption')
420
+ if cap:
421
+ ct = cap.get_text(strip=True)
422
+ if ct: body.append({'type': 'p', 'text': ct})
423
+ if len(body) >= 2:
424
+ return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img,
425
+ 'body': body[:50], 'source': domain, 'url': url}
426
+ if title and (summary or og_img):
427
+ fallback = []
428
+ if og_img: fallback.append({'type': 'img', 'src': og_img})
429
+ if summary: fallback.append({'type': 'p', 'text': summary})
430
+ if fallback:
431
+ return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img,
432
+ 'body': fallback, 'source': domain, 'url': url, 'fallback': True}
433
+ if title:
434
+ return {'title': _clean(title), 'summary': '', 'og_image': '',
435
+ 'body': [{'type': 'p', 'text': 'Nội dung đang được tải...'}],
436
+ 'source': domain, 'url': url, 'fallback': True}
437
+ break
438
+ except Exception:
439
+ continue
440
+ return None
441
+
442
+ @app.get('/api/article')
443
+ def api_article_v2(url: str = Query(...)):
444
+ from urllib.parse import unquote
445
+ safe_url = unquote(url)
446
+ try:
447
+ now = time.time()
448
+ cached = _article_cache.get(safe_url)
449
+ if cached and now - cached['t'] < _article_cache_ttl:
450
+ resp = JSONResponse(cached['d'])
451
+ resp.headers["Cache-Control"] = "public, max-age=1800"
452
+ return resp
453
+ data = _scrape_article_fast(safe_url)
454
+ if data and data.get('body'):
455
+ _article_cache[safe_url] = {'d': data, 't': now}
456
+ resp = JSONResponse(data)
457
+ resp.headers["Cache-Control"] = "public, max-age=1800"
458
+ return resp
459
+ result = {'error': 'Không đọc được', 'url': safe_url}
460
+ resp = JSONResponse(result)
461
+ resp.headers["Cache-Control"] = "public, max-age=60"
462
+ return resp
463
+ except Exception as e:
464
+ return JSONResponse({'error': f'Server error: {str(e)[:100]}', 'url': safe_url}, status_code=200)
465
+
466
+ _hot_cache={'t':0,'d':[]}
467
+ def _get_hot_topics():
468
+ now=time.time()
469
+ if _hot_cache['d'] and now-_hot_cache['t']<600:return _hot_cache['d']
470
+ freq={};display={}
471
+ 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']
472
+ for feed_url in feeds:
473
+ try:
474
+ r=req.get(feed_url,headers={'User-Agent':'Mozilla/5.0'},timeout=6);r.encoding='utf-8';soup=BeautifulSoup(r.text,'xml')
475
+ for item in soup.find_all('item')[:12]:
476
+ title=_clean(item.find('title').get_text() if item.find('title') else '')
477
+ if not title:continue
478
+ 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]
479
+ if len(words)<2:continue
480
+ for n in(3,4,2):
481
+ for i in range(max(0,len(words)-n+1)):
482
+ phrase=' '.join(words[i:i+n])
483
+ if 8<=len(phrase)<=45:key=phrase.lower();freq[key]=freq.get(key,0)+1;display[key]=phrase
484
+ except:continue
485
+ ranked=sorted(freq.items(),key=lambda x:x[1],reverse=True);topics=[];seen=set()
486
+ for key,count in ranked:
487
+ 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)
488
+ if is_dup:continue
489
+ seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',display[key].title()),'topic':display[key],'count':count})
490
+ if len(topics)>=20:break
491
+ 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']:
492
+ if len(topics)>=24:break
493
+ if not any(kw.lower() in s for s in seen):topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw,'count':0})
494
+ _hot_cache.update({'t':now,'d':topics[:24]});return topics[:24]
495
+
496
+ @app.get('/api/hot_topics')
497
+ def api_hot_topics():
498
+ resp = JSONResponse({'topics':_get_hot_topics()})
499
+ resp.headers["Cache-Control"] = "public, max-age=120"
500
+ return resp
501
+ @app.get('/')
502
+ async def serve_index():
503
+ p=os.path.join(STATIC_DIR,'index_v2.html')
504
+ if os.path.exists(p):return FileResponse(p,media_type='text/html')
505
+ return HTMLResponse('<h1>VNEWS</h1>')
506
+ @app.get('/api/hashtag/sources')
507
+ def _ht(topic:str=Query(...),page:int=Query(default=0)):
508
+ items=_search_all(topic,36);per_page=8;start=page*per_page;end=start+per_page
509
+ return JSONResponse({'sources':items[start:end],'topic':topic,'page':page,'has_more':end<len(items),'total':len(items)})
510
+ @app.get('/api/categories')
511
+ def _cat():return JSONResponse([])
512
+ @app.get('/api/storage_status')
513
+ def _st():return JSONResponse({'persistent':os.path.isdir('/data') and os.access('/data',os.W_OK)})
514
+ # ===== SHARE HELPERS: render content pages for shared links =====
515
+ def _render_slides_page(post, safe_title, safe_img, safe_url):
516
+ slides = post.get('slides', [])
517
+ # Get image from post.img or first slide's image
518
+ if not safe_img and slides and slides[0].get('image'):
519
+ safe_img = slides[0].get('image', '')
520
+ # Use text for description if available
521
+ description = _clean((post.get('text') or '')[:200]) or "Tin tức tóm tắt, AI rewrite, World Cup 2026"
522
+
523
+ # Build canonical URL preserving original query format if url was provided
524
+ if safe_url and safe_url != '/':
525
+ canonical_url = f"{SPACE}/s?url={quote(safe_url)}&title={quote(safe_title[:100])}"
526
+ else:
527
+ canonical_url = f"{SPACE}/s?post_id={post.get('id') or ''}"
528
+
529
+ h = f'''<!DOCTYPE html>
530
+ <html lang="vi">
531
+ <head>
532
+ <meta charset="utf-8">
533
+ <meta name="viewport" content="width=device-width,initial-scale=1">
534
+ <title>{_clean(safe_title)}</title>
535
+ <meta property="og:title" content="{_clean(safe_title)}">
536
+ <meta property="og:image" content="{_clean(safe_img)}">
537
+ <meta property="og:description" content="{description}">
538
+ <meta property="og:url" content="{canonical_url}">
539
+ <link rel="canonical" href="{canonical_url}">
540
+ <style>
541
+ *{{box-sizing:border-box;margin:0;padding:0}}body{{background:#111;color:#eee;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:12px}}
542
+ .slide-card{{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:16px;margin-bottom:12px;max-width:600px;margin-left:auto;margin-right:auto}}
543
+ .slide-num{{color:#5cb87a;font-size:12px;font-weight:700;margin-bottom:6px}}
544
+ .slide-img{{width:100%;max-height:300px;object-fit:cover;border-radius:8px;margin-bottom:8px}}
545
+ .slide-text{{color:#ddd;font-size:14px;line-height:1.6;margin:0}}
546
+ </style>
547
+ </head>
548
+ <body>'''
549
+ for s in slides:
550
+ img_src = s.get('image', '')
551
+ if img_src and ('cdnphoto.dantri' in img_src or 'refooty' in img_src or 'vnexpress' in img_src or 'vcdn' in img_src):
552
+ img_tag = f'<img src="/api/proxy/img?url={quote(img_src, safe="")}" class="slide-img" loading="lazy" onerror="this.style.display=\'none\'">'
553
+ else:
554
+ img_tag = f'<img src="{_clean(img_src)}" class="slide-img" loading="lazy" onerror="this.style.display=\'none\'">' if img_src else ''
555
+ h += f'<div class="slide-card"><div class="slide-num">Slide {s.get("index",1)}/{len(slides)}</div>{img_tag}<p class="slide-text">{_clean(s.get("text",""))}</p></div>'
556
+ h += '</body></html>'
557
+ return HTMLResponse(h)
558
+
559
+ def _render_video_page(post, safe_title, safe_img, safe_url):
560
+ video_url = post.get('video', '')
561
+ # Use text for description if available
562
+ description = _clean((post.get('text') or '')[:200]) or "Tin tức tóm tắt, AI rewrite, World Cup 2026"
563
+
564
+ # Build canonical URL preserving original query format if url was provided
565
+ if safe_url and safe_url != '/':
566
+ canonical_url = f"{SPACE}/s?url={quote(safe_url)}&title={quote(safe_title[:100])}"
567
+ else:
568
+ canonical_url = f"{SPACE}/s?post_id={post.get('id') or ''}"
569
+
570
+ h = f'''<!DOCTYPE html>
571
+ <html lang="vi">
572
+ <head>
573
+ <meta charset="utf-8">
574
+ <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
575
+ <title>{_clean(safe_title)}</title>
576
+ <meta property="og:title" content="{_clean(safe_title)}">
577
+ <meta property="og:image" content="{_clean(safe_img)}">
578
+ <meta property="og:description" content="{description}">
579
+ <meta property="og:url" content="{canonical_url}">
580
+ <link rel="canonical" href="{canonical_url}">
581
+ <meta name="twitter:card" content="player">
582
+ <meta name="twitter:player" content="{video_url}">
583
+ <style>
584
+ *{{box-sizing:border-box;margin:0;padding:0}}body{{background:#111;color:#eee;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:0;overflow:hidden}}
585
+ .video-container{{width:100vw;height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#000}}
586
+ video{{width:100%;height:100%;max-height:100vh;object-fit:contain;background:#000}}
587
+ .title-bar{{position:fixed;bottom:0;left:0;right:0;background:linear-gradient(transparent,rgba(0,0,0,.8));padding:40px 16px 16px;text-align:center}}
588
+ .title-text{{color:#fff;font-size:13px;line-height:1.4;max-width:600px;margin:0 auto}}
589
+ </style>
590
+ </head>
591
+ <body>
592
+ <div class="video-container">
593
+ <video src="{_clean(video_url)}" controls autoplay playsinline loop></video>
594
+ <div class="title-bar"><div class="title-text">{_clean(safe_title)}</div></div>
595
+ </div>
596
+ </body></html>'''
597
+ return HTMLResponse(h)
598
+
599
+ @app.get('/s/{slug}')
600
+ async def _sh_slug(slug: str, request: Request, url: str = '', title: str = '', img: str = ''):
601
+ """SEO-friendly share endpoint with slug in URL path.
602
+ Shows slide content when url matches a wall post, otherwise redirects.
603
+ """
604
+ safe_title = _clean(title) if title else 'VNEWS - Tin tức'
605
+ safe_img = _clean(img) if img else ''
606
+ safe_url = _clean(url) if url else '/'
607
+
608
+ # Try to find post by URL first (most reliable)
609
+ post = None
610
+ try:
611
+ if url:
612
+ posts = _load_wall_posts()
613
+ for p in posts:
614
+ if p.get('url') == url and p.get('slides'):
615
+ post = p
616
+ safe_title = p.get('title', safe_title) or safe_title
617
+ safe_img = p.get('img', safe_img) or safe_img
618
+ safe_url = p.get('url', safe_url) or safe_url
619
+ break
620
+ # Fallback: any matching URL
621
+ if not post and url:
622
+ for p in posts:
623
+ if p.get('url') == url:
624
+ post = p
625
+ safe_title = p.get('title', safe_title) or safe_title
626
+ safe_img = p.get('img', safe_img) or safe_img
627
+ safe_url = p.get('url', safe_url) or safe_url
628
+ break
629
+ except:
630
+ pass
631
+
632
+ if post and post.get('slides'):
633
+ return _render_slides_page(post, safe_title, safe_img, safe_url)
634
+
635
+ if post and post.get('video'):
636
+ return _render_video_page(post, safe_title, safe_img, safe_url)
637
+
638
+ # Otherwise redirect
639
+ return HTMLResponse(f'''<!DOCTYPE html>
640
+ <html lang="vi">
641
+ <head>
642
+ <meta charset="utf-8">
643
+ <meta name="viewport" content="width=device-width,initial-scale=1">
644
+ <title>{_clean(safe_title)}</title>
645
+ <meta property="og:title" content="{_clean(safe_title)}">
646
+ <meta property="og:image" content="{_clean(safe_img)}">
647
+ <meta property="og:description" content="Tin tức tóm tắt, AI rewrite, World Cup 2026">
648
+ <meta property="og:url" content="{SPACE}/s/{slug}">
649
+ <link rel="canonical" href="{SPACE}/s/{slug}">
650
+ <meta http-equiv="refresh" content="0;url={safe_url}">
651
+ </head><body></body></html>''')
652
+
653
+
654
+
655
+ @app.get('/api/proxy/img')
656
+ def proxy_img(url: str = Query(default=""), max_size: int = Query(default=1200)):
657
+ """Proxy image from blocked CDN to public URL."""
658
+ from urllib.parse import unquote
659
+ safe_url = unquote(url)
660
+ import requests as _req
661
+ try:
662
+ r = _req.get(safe_url, timeout=10, headers={"User-Agent": "Mozilla/5.0"})
663
+ if r.status_code == 200 and r.content:
664
+ return Response(r.content, media_type=r.headers.get("content-type", "image/jpeg"))
665
+ except Exception as e:
666
+ pass
667
+ return Response(status_code=502)
668
+ @app.get('/s')
669
+ async def _sh(url:str='',title:str='',img:str='',post_id:str=''):
670
+ safe_title = _clean(title) if title else 'VNEWS - Tin tức'
671
+ safe_img = _clean(img) if img else ''
672
+ safe_url = _clean(url) if url else '/'
673
+
674
+ # Try to find wall post by post_id or URL (prioritize posts with slides/video)
675
+ post = None
676
+ try:
677
+ posts = _load_wall_posts()
678
+ if post_id:
679
+ for p in posts:
680
+ if p.get('id') == post_id:
681
+ post = p
682
+ safe_title = p.get('title', safe_title) or safe_title
683
+ safe_img = p.get('img', safe_img) or safe_img
684
+ safe_url = p.get('url', safe_url) or safe_url
685
+ break
686
+ elif url:
687
+ # Find matching URL - prioritize posts with slides or video
688
+ for p in posts:
689
+ if p.get('url') == url and p.get('slides'):
690
+ post = p
691
+ safe_title = p.get('title', safe_title) or safe_title
692
+ safe_img = p.get('img', safe_img) or safe_img
693
+ safe_url = p.get('url', safe_url) or safe_url
694
+ break
695
+ if not post:
696
+ # Fallback: find any matching URL
697
+ for p in posts:
698
+ if p.get('url') == url:
699
+ post = p
700
+ safe_title = p.get('title', safe_title) or safe_title
701
+ safe_img = p.get('img', safe_img) or safe_img
702
+ safe_url = p.get('url', safe_url) or safe_url
703
+ break
704
+ except:
705
+ pass
706
+
707
+ if post and post.get('slides'):
708
+ return _render_slides_page(post, safe_title, safe_img, safe_url)
709
+
710
+ if post and post.get('video'):
711
+ return _render_video_page(post, safe_title, safe_img, safe_url)
712
+
713
+ # Fallback: redirect to original URL
714
+ # Fetch og:image for better rich preview
715
+ if url and not safe_img:
716
+ try:
717
+ art = _scrape_article_fast(url)
718
+ if art and art.get('og_image'):
719
+ safe_img = art.get('og_image', '')
720
+ if art and art.get('title'):
721
+ safe_title = art.get('title', safe_title)
722
+ except:
723
+ pass
724
+
725
+ return HTMLResponse(f'''<!DOCTYPE html>
726
+ <html lang="vi">
727
+ <head>
728
+ <meta charset="utf-8">
729
+ <meta name="viewport" content="width=device-width,initial-scale=1">
730
+ <title>{safe_title}</title>
731
+ <meta property="og:title" content="{safe_title}">
732
+ <meta property="og:image" content="{safe_img}">
733
+ <meta property="og:description" content="Tin tức tóm tắt, AI rewrite, World Cup 2026">
734
+ <meta property="og:url" content="{SPACE}/s?url={quote(safe_url)}">
735
+ <link rel="canonical" href="{SPACE}/s?url={quote(safe_url)}">
736
+ <meta http-equiv="refresh" content="0;url={safe_url}">
737
+ </head><body></body></html>''')
738
+
739
+ 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
740
+
741
+ _xlb_cache = {}
742
+ _xlb_lock = threading.Lock()
743
+
744
+ def _xlb_scrape(path):
745
+ url = f"https://xemlaibongda.top/{path}"
746
+ r = req.get(url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}, timeout=15, allow_redirects=True)
747
+ if r.status_code != 200:
748
+ return []
749
+ soup = BeautifulSoup(r.text, 'lxml')
750
+ vids = []
751
+ seen = set()
752
+ for a in soup.select('a[href*="/video/"]'):
753
+ href = a.get('href', '')
754
+ if not href or href in seen:
755
+ continue
756
+ seen.add(href)
757
+ if not href.startswith('http'):
758
+ href = 'https://xemlaibongda.top' + href
759
+ img = a.select_one('img')
760
+ p = a.parent
761
+ for _ in range(4):
762
+ if img:
763
+ break
764
+ if p:
765
+ img = p.select_one('img')
766
+ p = p.parent
767
+ img_src = ''
768
+ if img:
769
+ img_src = img.get('data-src','') or img.get('src','') or img.get('data-lazy','') or img.get('data-original','')
770
+ if img_src.startswith('//'):
771
+ img_src = 'https:' + img_src
772
+ elif img_src.startswith('/'):
773
+ img_src = 'https://xemlaibongda.top' + img_src
774
+ title = ''
775
+ for sel in ['.title', 'h3', 'h2', '.name', '.post-title', '.entry-title', '.video-title']:
776
+ t = a.select_one(sel)
777
+ if t:
778
+ title = _clean(t.get_text())
779
+ break
780
+ if not title:
781
+ title = _clean(a.get('title',''))
782
+ if not title:
783
+ img_alt = a.select_one('img')
784
+ if img_alt:
785
+ title = _clean(img_alt.get('alt',''))
786
+ if not title:
787
+ parent = a.parent
788
+ if parent:
789
+ pt = _clean(parent.get_text(' ',strip=True))
790
+ if 5 < len(pt) < 120:
791
+ title = pt
792
+ if not title or len(title) < 3:
793
+ continue
794
+ vids.append({"link": href, "img": img_src, "title": title})
795
+ if len(vids) >= 30:
796
+ break
797
+ return vids
798
+
799
+ @app.get('/api/proxy/xlb')
800
+ def proxy_xlb(path: str = Query(default="")):
801
+ now = time.time()
802
+ cache_key = f"xlb:{path}"
803
+ with _xlb_lock:
804
+ cached = _xlb_cache.get(cache_key)
805
+ if cached and now - cached['t'] < 120:
806
+ return JSONResponse(cached['d'])
807
+ try:
808
+ vids = _xlb_scrape(path)
809
+ result = {"videos": vids, "count": len(vids)}
810
+ with _xlb_lock:
811
+ _xlb_cache[cache_key] = {'t': now, 'd': result}
812
+ return JSONResponse(result)
813
+ except Exception as e:
814
+ return JSONResponse({"videos": [], "count": 0, "error": str(e)}, status_code=500)
815
+
816
+ @app.get('/api/wc2026')
817
+ def _w():return JSONResponse(get_wc2026_all())
818
+ @app.get('/api/wc2026/fixtures')
819
+ def _wf():return JSONResponse(scrape_fixtures())
820
+ @app.get('/api/wc2026/standings')
821
+ def _ws():return JSONResponse(scrape_standings())
822
+ @app.get('/api/wc2026/stats')
823
+ def _wst():return JSONResponse(scrape_stats())
824
+ @app.get('/api/wc2026/history')
825
+ def _whi():return JSONResponse(scrape_history())
826
+ @app.get('/api/wc2026/news')
827
+ def _wn():return JSONResponse(scrape_wc_news())
828
+ @app.get('/api/wc2026/road')
829
+ def _wr():return JSONResponse(scrape_road_to_wc())
830
+ @app.get('/api/wc2026/h2h/{eid}')
831
+ def _wh2(eid:int):return JSONResponse(scrape_h2h(eid))
832
+ @app.get('/api/wc2026/lineups/{eid}')
833
+ def _wl(eid:int):return JSONResponse(scrape_lineups(eid))
834
+ @app.get('/api/wc2026/match/{eid}')
835
+ def _wm(eid:int):return JSONResponse(scrape_match_detail(eid))
836
+
837
+ DATA_DIR='/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)),'data')
838
+ os.makedirs(DATA_DIR,exist_ok=True)
839
+ IF=os.path.join(DATA_DIR,'interactions_v2.json')
840
+ CF=os.path.join(DATA_DIR,'comments_v2.json')
841
+ WALL_FILE=os.path.join(DATA_DIR,'wall_posts.json')
842
+ WALL_VIDEO_DIR=os.path.join(DATA_DIR,'wall_videos')
843
+ os.makedirs(WALL_VIDEO_DIR,exist_ok=True)
844
+
845
+ _il=threading.Lock();_cl=threading.Lock();_wl_lock=threading.Lock()
846
+ def _lj(p):
847
+ try:
848
+ if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8'))
849
+ except:pass
850
+ return [] # Return empty list instead of dict for wall posts
851
+ def _sj(p,d):
852
+ try:open(p+'.tmp','w',encoding='utf-8').write(json.dumps(d,ensure_ascii=False));os.replace(p+'.tmp',p)
853
+ except:pass
854
+
855
+ @app.post('/api/v2/interact')
856
+ async def _int(request:Request):
857
+ b=await request.json();v=str(b.get('id','')).strip();t=str(b.get('type','')).strip()
858
+ if not v or t not in('view','like'):return JSONResponse({'error':'x'},status_code=400)
859
+ 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])
860
+
861
+ @app.get('/api/v2/interactions')
862
+ def _gi(id:str=Query(...)):
863
+ with _il:return JSONResponse(_lj(IF).get(id.strip(),{'views':0,'likes':0,'comments':0}))
864
+
865
+ @app.get('/api/v2/comments')
866
+ def _gc(id:str=Query(...)):
867
+ with _cl:return JSONResponse({'comments':_lj(CF).get(id.strip(),[])})
868
+
869
+ @app.post('/api/v2/comment')
870
+ async def _pc(request:Request):
871
+ b=await request.json();v=str(b.get('id','')).strip();tx=str(b.get('text','')).strip()[:500]
872
+ if not v or not tx:return JSONResponse({'error':'x'},status_code=400)
873
+ c={'text':tx,'time':time.strftime('%H:%M %d/%m',time.localtime()),'ts':int(time.time())}
874
+ with _cl:db=_lj(CF);db.setdefault(v,[]);db[v].append(c);db[v]=db[v][-200:];_sj(CF,db);cms=db[v]
875
+ with _il:idb=_lj(IF);idb.setdefault(v,{'views':0,'likes':0,'comments':0});idb[v]['comments']=len(cms);_sj(IF,idb)
876
+ return JSONResponse({'comments':cms})
877
+
878
+ def _load_wall_posts():
879
+ with _wl_lock:
880
+ return _lj(WALL_FILE)
881
+
882
+ def _save_wall_posts(posts):
883
+ with _wl_lock:
884
+ _sj(WALL_FILE, posts)
885
+
886
+ @app.get('/api/wall')
887
+ def api_wall():
888
+ posts = _load_wall_posts()
889
+ if not posts:
890
+ return JSONResponse({"posts": []})
891
+ return JSONResponse({"posts": posts})
892
+
893
+ @app.post('/api/wall')
894
+ async def api_wall_post(request: Request):
895
+ content_type = request.headers.get('content-type', '')
896
+ if 'multipart/form-data' in content_type:
897
+ try:
898
+ form = await request.form()
899
+ except Exception as e:
900
+ return JSONResponse({"error": f"Form parse error: {str(e)}"}, status_code=400)
901
+ title = form.get('title', 'Video mới') or 'Video mới'
902
+ text = form.get('text', '') or ''
903
+ source = form.get('source', 'vtv_recorder') or 'vtv_recorder'
904
+ video_file = form.get('video')
905
+ post_id = str(uuid.uuid4())[:12]
906
+ video_url = None
907
+ if video_file and hasattr(video_file, 'filename') and video_file.filename:
908
+ fname = video_file.filename.lower()
909
+ if fname.endswith('.mp4'):
910
+ ext = '.mp4'
911
+ elif fname.endswith('.webm'):
912
+ ext = '.webm'
913
+ else:
914
+ ext = '.webm'
915
+ video_filename = f"wall_{post_id}{ext}"
916
+ video_path = os.path.join(WALL_VIDEO_DIR, video_filename)
917
+ try:
918
+ content = await video_file.read()
919
+ if not content:
920
+ return JSONResponse({"error": "Empty video file"}, status_code=400)
921
+ with open(video_path, 'wb') as f:
922
+ f.write(content)
923
+ file_size_mb = len(content) / 1024 / 1024
924
+ if file_size_mb > 50:
925
+ os.remove(video_path)
926
+ return JSONResponse({"error": f"Video quá lớn ({file_size_mb:.1f}MB). Tối đa 50MB."}, status_code=400)
927
+ video_url = f"/api/wall/video/{video_filename}"
928
+ except Exception as e:
929
+ return JSONResponse({"error": f"Lỗi lưu video: {str(e)}"}, status_code=500)
930
+ post = {
931
+ "id": post_id,
932
+ "title": title[:200],
933
+ "text": text[:2000],
934
+ "source": source,
935
+ "video": video_url,
936
+ "img": None,
937
+ "images": [],
938
+ "created": int(time.time()),
939
+ "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
940
+ }
941
+ posts = _load_wall_posts()
942
+ if not isinstance(posts, list):
943
+ posts = []
944
+ posts.insert(0, post)
945
+ posts = posts[:200]
946
+ _save_wall_posts(posts)
947
+ return JSONResponse({"post": post, "ok": True})
948
+ try:
949
+ body = await request.json()
950
+ except:
951
+ body = {}
952
+ title = body.get('title', 'Bài mới') or 'Bài mới'
953
+ text = body.get('text', '') or ''
954
+ img = body.get('img', None)
955
+ source = body.get('source', 'user') or 'user'
956
+ post_id = str(uuid.uuid4())[:12]
957
+ post = {
958
+ "id": post_id,
959
+ "title": title[:200],
960
+ "text": text[:2000],
961
+ "source": source,
962
+ "video": None,
963
+ "img": img,
964
+ "images": [],
965
+ "created": int(time.time()),
966
+ "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
967
+ }
968
+ posts = _load_wall_posts()
969
+ if not isinstance(posts, list):
970
+ posts = []
971
+ posts.insert(0, post)
972
+ posts = posts[:200]
973
+ _save_wall_posts(posts)
974
+ return JSONResponse({"post": post, "ok": True})
975
+
976
+ @app.get('/api/wall/video/{filename}')
977
+ def api_wall_video(filename: str):
978
+ if '..' in filename or '/' in filename:
979
+ return Response(status_code=403)
980
+ video_path = os.path.join(WALL_VIDEO_DIR, filename)
981
+ if not os.path.exists(video_path):
982
+ return Response(status_code=404)
983
+ ext = os.path.splitext(filename)[1].lower()
984
+ media_type = 'video/mp4' if ext == '.mp4' else 'video/webm'
985
+ return FileResponse(video_path, media_type=media_type)
986
+
987
+ @app.delete('/api/wall/{post_id}')
988
+ def api_wall_delete(post_id: str):
989
+ posts = _load_wall_posts()
990
+ if not isinstance(posts, list):
991
+ return JSONResponse({"error": "No posts"}, status_code=404)
992
+ for i, p in enumerate(posts):
993
+ if p.get('id') == post_id:
994
+ if p.get('video'):
995
+ video_name = p['video'].split('/')[-1]
996
+ video_path = os.path.join(WALL_VIDEO_DIR, video_name)
997
+ if os.path.exists(video_path):
998
+ os.remove(video_path)
999
+ posts.pop(i)
1000
+ _save_wall_posts(posts)
1001
+ return JSONResponse({"ok": True})
1002
+ return JSONResponse({"error": "Post not found"}, status_code=404)
1003
+
1004
+ # ===== LANGUAGE & EMOTION DETECTION =====
1005
+ import random as _random2
1006
+ from urllib.parse import quote as _quote2
1007
+
1008
+ _UA_RW = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept-Language': 'vi-VN,vi;q=0.9'}
1009
+
1010
+ # Unique character markers for language detection
1011
+ _UNIQUE_CHARS = {
1012
+ 'vietnamese': set('đăâêôơưàảãạáằẳẵặắầẩẫậấèẻẽẹéềễểệếìỉĩịíòỏõọóồổỗộốờởỡợớùủũụúừửữựứỳỷỹỵý'),
1013
+ 'spanish': set('ñáéíóúü¿¡'),
1014
+ 'portuguese': set('ãõçáéíóúâêôà'),
1015
+ }
1016
+
1017
+ _STOPWORDS = {
1018
+ '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'},
1019
+ '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'},
1020
+ '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'},
1021
+ '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'},
1022
+ }
1023
+
1024
+ def detect_language(text):
1025
+ """Detect language from text content using stopword + character analysis."""
1026
+ if not text:
1027
+ return 'vietnamese'
1028
+ text_lower = text.lower()
1029
+ text_chars = set(text_lower)
1030
+
1031
+ # Strong signal: Vietnamese unique characters
1032
+ vn_chars = len(text_chars & _UNIQUE_CHARS['vietnamese'])
1033
+ if vn_chars >= 2:
1034
+ return 'vietnamese'
1035
+
1036
+ # Spanish unique chars (ñ, ¿, ¡)
1037
+ es_chars = len(text_chars & _UNIQUE_CHARS['spanish'])
1038
+ pt_chars = len(text_chars & _UNIQUE_CHARS['portuguese'])
1039
+
1040
+ # Stopword scoring
1041
+ words = set(re.findall(r'\b\w+\b', text_lower))
1042
+ scores = {}
1043
+ for lang, stops in _STOPWORDS.items():
1044
+ scores[lang] = len(words & stops) / max(len(stops), 1)
1045
+
1046
+ # Disambiguate Portuguese vs Spanish
1047
+ 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'}
1048
+ 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'}
1049
+
1050
+ pt_overlap = len(words & pt_markers)
1051
+ es_overlap = len(words & es_markers)
1052
+
1053
+ if scores.get('portuguese', 0) > 0 and pt_overlap > es_overlap:
1054
+ return 'portuguese'
1055
+ if scores.get('spanish', 0) > 0 and es_overlap > pt_overlap:
1056
+ return 'spanish'
1057
+ if scores.get('english', 0) > 0.15:
1058
+ return 'english'
1059
+
1060
+ best = max(scores, key=scores.get)
1061
+ return best if scores[best] > 0.05 else 'vietnamese'
1062
+
1063
+ # Emotion keyword-based detection
1064
+ _EMOTION_KEYWORDS = {
1065
+ 'happy': {
1066
+ 'en': ['happy', 'joy', 'wonderful', 'great', 'amazing', 'fantastic', 'love', 'excellent', 'beautiful', 'glad', 'delighted', 'pleased', 'cheerful', 'celebrate', 'victory', 'win', 'success'],
1067
+ 'pt': ['feliz', 'alegria', 'maravilhoso', 'ótimo', 'incrível', 'fantástico', 'amor', 'excelente', 'lindo', 'contente', 'encantado', 'vitória', 'sucesso'],
1068
+ 'es': ['feliz', 'alegria', 'maravilloso', 'genial', 'increíble', 'fantástico', 'amor', 'excelente', 'hermoso', 'contento', 'encantado', 'victoria', 'éxito'],
1069
+ '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'],
1070
+ },
1071
+ 'sad': {
1072
+ 'en': ['sad', 'unhappy', 'terrible', 'awful', 'horrible', 'miserable', 'depressed', 'grief', 'sorrow', 'tragic', 'unfortunate', 'painful', 'death', 'die', 'kill'],
1073
+ 'pt': ['triste', 'infeliz', 'terrível', 'horrível', 'miserável', 'deprimido', 'dor', 'trágico', 'infelizmente', 'penoso', 'morte', 'morrer'],
1074
+ 'es': ['triste', 'infeliz', 'terrible', 'horrible', 'miserable', 'deprimido', 'dolor', 'trágico', 'desafortunado', 'penoso', 'muerte', 'morir'],
1075
+ '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'],
1076
+ },
1077
+ 'excited': {
1078
+ 'en': ['excited', 'thrilling', 'amazing', 'wow', 'incredible', 'unbelievable', 'awesome', 'exhilarating', 'electrifying', 'breathtaking', 'breakthrough', 'record'],
1079
+ 'pt': ['animado', 'emocionante', 'incrível', 'impressionante', 'sensacional', 'eletrizante', 'empolgante', 'recorde'],
1080
+ 'es': ['emocionante', 'increíble', 'impresionante', 'sensacional', 'electrizante', 'emocionado', 'entusiasmado', 'récord'],
1081
+ '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á'],
1082
+ },
1083
+ 'humorous': {
1084
+ 'en': ['funny', 'hilarious', 'joke', 'laugh', 'comedy', 'humor', 'amusing', 'witty', 'sarcastic', 'ironic', 'ridiculous', 'absurd', 'lol', 'haha'],
1085
+ 'pt': ['engraçado', 'hilário', 'piada', 'rir', 'comédia', 'humor', 'divertido', 'irônico', 'ridículo', 'absurdo', 'kkk'],
1086
+ 'es': ['gracioso', 'hilarante', 'broma', 'risa', 'comedia', 'humor', 'divertido', 'irónico', 'ridículo', 'absurdo', 'jaja'],
1087
+ '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'],
1088
+ },
1089
+ 'serious': {
1090
+ 'en': ['serious', 'critical', 'important', 'urgent', 'severe', 'grave', 'significant', 'crucial', 'vital', 'essential', 'alarming', 'concerning', 'crisis', 'war', 'conflict'],
1091
+ 'pt': ['sério', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'essencial', 'preocupante', 'crise', 'guerra', 'conflito'],
1092
+ 'es': ['serio', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'esencial', 'preocupante', 'crisis', 'guerra', 'conflicto'],
1093
+ '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'],
1094
+ },
1095
+ }
1096
+
1097
+ def detect_emotion(text, language='vietnamese'):
1098
+ """Detect emotion from text using keyword matching."""
1099
+ if not text:
1100
+ return 'neutral'
1101
+ text_lower = text.lower()
1102
+
1103
+ scores = {}
1104
+ for emotion, lang_keywords in _EMOTION_KEYWORDS.items():
1105
+ keywords = lang_keywords.get(language, lang_keywords.get('en', []))
1106
+ score = sum(1 for kw in keywords if kw in text_lower)
1107
+ scores[emotion] = score
1108
+
1109
+ if max(scores.values()) == 0:
1110
+ return 'neutral'
1111
+
1112
+ return max(scores, key=scores.get)
1113
+
1114
+ def detect_language_and_emotion(title, text):
1115
+ """Detect both language and emotion from article content."""
1116
+ combined = f"{title} {text}"
1117
+ lang = detect_language(combined)
1118
+ emotion = detect_emotion(combined, lang)
1119
+ return lang, emotion
1120
+
1121
+ # Voice selection based on language and emotion (using MultilingualNeural voices)
1122
+ VOICE_BY_LANG_EMOTION = {
1123
+ 'vietnamese': {
1124
+ 'happy': ('vi-VN-HoaiMyNeural', 'vui'),
1125
+ 'sad': ('vi-VN-NamMinhNeural', 'buồn'),
1126
+ 'excited': ('vi-VN-HoaiMyNeural', 'hào hứng'),
1127
+ 'humorous': ('vi-VN-HoaiMyNeural', 'vui'),
1128
+ 'serious': ('vi-VN-NamMinhNeural', 'nghiêm túc'),
1129
+ 'neutral': ('vi-VN-HoaiMyNeural', 'trung_tinh'),
1130
+ },
1131
+ 'portuguese': {
1132
+ 'happy': ('pt-BR-ThalitaMultilingualNeural', 'feliz'),
1133
+ 'sad': ('pt-BR-ThalitaMultilingualNeural', 'triste'),
1134
+ 'excited': ('pt-BR-ThalitaMultilingualNeural', 'animado'),
1135
+ 'humorous': ('pt-BR-ThalitaMultilingualNeural', 'engraçado'),
1136
+ 'serious': ('pt-BR-ThalitaMultilingualNeural', 'sério'),
1137
+ 'neutral': ('pt-BR-ThalitaMultilingualNeural', 'neutro'),
1138
+ },
1139
+ 'english': {
1140
+ 'happy': ('en-US-AndrewMultilingualNeural', 'happy'),
1141
+ 'sad': ('en-AU-WilliamMultilingualNeural', 'sad'),
1142
+ 'excited': ('en-US-AndrewMultilingualNeural', 'excited'),
1143
+ 'humorous': ('en-US-AndrewMultilingualNeural', 'funny'),
1144
+ 'serious': ('en-AU-WilliamMultilingualNeural', 'serious'),
1145
+ 'neutral': ('en-US-AndrewMultilingualNeural', 'neutral'),
1146
+ },
1147
+ 'french': {
1148
+ 'happy': ('fr-FR-VivienneMultilingualNeural', 'heureux'),
1149
+ 'sad': ('fr-FR-RemyMultilingualNeural', 'triste'),
1150
+ 'excited': ('fr-FR-VivienneMultilingualNeural', 'excité'),
1151
+ 'humorous': ('fr-FR-VivienneMultilingualNeural', 'drôle'),
1152
+ 'serious': ('fr-FR-RemyMultilingualNeural', 'sérieux'),
1153
+ 'neutral': ('fr-FR-VivienneMultilingualNeural', 'neutre'),
1154
+ },
1155
+ 'german': {
1156
+ 'happy': ('de-DE-SeraphinaMultilingualNeural', 'glücklich'),
1157
+ 'sad': ('de-DE-FlorianMultilingualNeural', 'traurig'),
1158
+ 'excited': ('de-DE-SeraphinaMultilingualNeural', 'aufgeregt'),
1159
+ 'humorous': ('de-DE-SeraphinaMultilingualNeural', 'lustig'),
1160
+ 'serious': ('de-DE-FlorianMultilingualNeural', 'ernst'),
1161
+ 'neutral': ('de-DE-SeraphinaMultilingualNeural', 'neutral'),
1162
+ },
1163
+ 'korean': {
1164
+ 'happy': ('ko-KR-HyunsuMultilingualNeural', '행복'),
1165
+ 'sad': ('ko-KR-HyunsuMultilingualNeural', '슬픔'),
1166
+ 'excited': ('ko-KR-HyunsuMultilingualNeural', '흥분'),
1167
+ 'humorous': ('ko-KR-HyunsuMultilingualNeural', '유쾌'),
1168
+ 'serious': ('ko-KR-HyunsuMultilingualNeural', '진지'),
1169
+ 'neutral': ('ko-KR-HyunsuMultilingualNeural', '중립'),
1170
+ },
1171
+ 'italian': {
1172
+ 'happy': ('it-IT-GiuseppeMultilingualNeural', 'felice'),
1173
+ 'sad': ('it-IT-GiuseppeMultilingualNeural', 'triste'),
1174
+ 'excited': ('it-IT-GiuseppeMultilingualNeural', 'emozionato'),
1175
+ 'humorous': ('it-IT-GiuseppeMultilingualNeural', 'divertente'),
1176
+ 'serious': ('it-IT-GiuseppeMultilingualNeural', 'serio'),
1177
+ 'neutral': ('it-IT-GiuseppeMultilingualNeural', 'neutro'),
1178
+ },
1179
+ }
1180
+
1181
+ # All valid voice IDs (new MultilingualNeural format)
1182
+ VALID_VOICES = {
1183
+ 'vi-VN-HoaiMyNeural', 'vi-VN-NamMinhNeural',
1184
+ 'en-US-AndrewMultilingualNeural', 'en-AU-WilliamMultilingualNeural',
1185
+ 'pt-BR-ThalitaMultilingualNeural',
1186
+ 'fr-FR-VivienneMultilingualNeural', 'fr-FR-RemyMultilingualNeural',
1187
+ 'de-DE-SeraphinaMultilingualNeural', 'de-DE-FlorianMultilingualNeural',
1188
+ 'ko-KR-HyunsuMultilingualNeural',
1189
+ 'it-IT-GiuseppeMultilingualNeural',
1190
+ }
1191
+
1192
+ def get_voice_for_content(title, text, preferred_voice=None):
1193
+ """Get appropriate voice based on content language and emotion."""
1194
+ # Accept the new MultilingualNeural voices directly
1195
+ if preferred_voice and preferred_voice in VALID_VOICES:
1196
+ return preferred_voice
1197
+
1198
+ # Also accept old shorthand voice IDs and map them to new format
1199
+ old_voice_map = {
1200
+ 'hoaimy': 'vi-VN-HoaiMyNeural',
1201
+ 'namminh': 'vi-VN-NamMinhNeural',
1202
+ 'andrew': 'en-US-AndrewMultilingualNeural',
1203
+ 'jenny': 'en-US-AndrewMultilingualNeural',
1204
+ 'thalita': 'pt-BR-ThalitaMultilingualNeural',
1205
+ 'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
1206
+ 'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
1207
+ 'ela': 'en-US-AndrewMultilingualNeural',
1208
+ 'es_carlos': 'en-US-AndrewMultilingualNeural',
1209
+ 'denise': 'fr-FR-VivienneMultilingualNeural',
1210
+ 'katja': 'de-DE-SeraphinaMultilingualNeural',
1211
+ 'nanami': 'en-US-AndrewMultilingualNeural',
1212
+ 'sunhee': 'ko-KR-HyunsuMultilingualNeural',
1213
+ 'xiaochen': 'en-US-AndrewMultilingualNeural',
1214
+ }
1215
+ if preferred_voice and preferred_voice in old_voice_map:
1216
+ return old_voice_map[preferred_voice]
1217
+
1218
+ lang, emotion = detect_language_and_emotion(title, text)
1219
+ lang_map = VOICE_BY_LANG_EMOTION.get(lang, VOICE_BY_LANG_EMOTION['vietnamese'])
1220
+ voice, _ = lang_map.get(emotion, lang_map['neutral'])
1221
+ return voice
1222
+
1223
+
1224
+ def _is_relevant_image(img_url, title, text):
1225
+ """Check if an image is relevant to the article content."""
1226
+ if not img_url:
1227
+ return False
1228
+ skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif',
1229
+ 'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite',
1230
+ 'advertisement', 'ad-banner', 'sponsored', 'banner-ads']
1231
+ img_lower = img_url.lower()
1232
+ for p in skip_patterns:
1233
+ if p in img_lower:
1234
+ return False
1235
+ if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']):
1236
+ return False
1237
+ return True
1238
+
1239
+
1240
+ def _filter_relevant_images(images, title, text, max_images=8):
1241
+ """Filter and rank images by relevance to article content."""
1242
+ if not images:
1243
+ return []
1244
+ seen = set()
1245
+ relevant = []
1246
+ for img in images:
1247
+ if img in seen:
1248
+ continue
1249
+ seen.add(img)
1250
+ if _is_relevant_image(img, title, text):
1251
+ relevant.append(img)
1252
+ return relevant[:max_images]
1253
+
1254
+
1255
+ def _scrape_article_for_rewrite(url):
1256
+ """Scrape article: extract title, paragraphs, RELEVANT images, OG image."""
1257
+ try:
1258
+ r = req.get(url, headers=_UA_RW, timeout=15, allow_redirects=True)
1259
+ r.encoding = 'utf-8'
1260
+ soup = BeautifulSoup(r.text, 'lxml')
1261
+ for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
1262
+ tag.decompose()
1263
+ h1 = soup.find('h1')
1264
+ ogt = soup.find('meta', property='og:title')
1265
+ title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '')
1266
+ ogi = soup.find('meta', property='og:image')
1267
+ og_img = ogi.get('content', '') if ogi else ''
1268
+ if og_img and og_img.startswith('//'):
1269
+ og_img = 'https:' + og_img
1270
+ block = None
1271
+ for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body', '#content', '.content', 'section', '[class*="content"]', '[class*="detail"]', '[class*="article"]']:
1272
+ el = soup.select_one(sel)
1273
+ if el and len(el.find_all('p')) >= 2:
1274
+ block = el
1275
+ break
1276
+ if not block:
1277
+ block = soup.body or soup
1278
+ paragraphs = []
1279
+ all_images = []
1280
+ seen_imgs = set()
1281
+ if og_img and og_img not in seen_imgs:
1282
+ all_images.append(og_img)
1283
+ seen_imgs.add(og_img)
1284
+ for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
1285
+ if el.name == 'p':
1286
+ t = _clean(el.get_text(strip=True))
1287
+ if t and len(t) > 40:
1288
+ paragraphs.append(t)
1289
+ elif el.name in ('figure', 'img'):
1290
+ im = el if el.name == 'img' else el.find('img')
1291
+ if im:
1292
+ src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
1293
+ if src and 'base64' not in src:
1294
+ if src.startswith('//'):
1295
+ src = 'https:' + src
1296
+ if src not in seen_imgs:
1297
+ all_images.append(src)
1298
+ seen_imgs.add(src)
1299
+ # FALLBACK: if no paragraph block found, grab all <p> in body with decent length
1300
+ if not paragraphs:
1301
+ for p in soup.find_all('p'):
1302
+ t = _clean(p.get_text(strip=True))
1303
+ if t and len(t) > 40:
1304
+ paragraphs.append(t)
1305
+ if len(paragraphs) >= 12:
1306
+ break
1307
+ # FALLBACK: if still empty, use og:description or title so we always have content
1308
+ if not paragraphs:
1309
+ og_desc = ''
1310
+ ogd = soup.find('meta', property='og:description')
1311
+ if ogd:
1312
+ og_desc = _clean(ogd.get('content', ''))
1313
+ if og_desc and len(og_desc) > 40:
1314
+ paragraphs = [og_desc]
1315
+ elif title and len(title) > 20:
1316
+ paragraphs = [title]
1317
+ # Filter to relevant images only
1318
+ relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5]))
1319
+ return {'title': _clean(title), 'paragraphs': paragraphs, 'images': relevant_images, 'og_img': og_img}
1320
+ except Exception:
1321
+ return None
1322
+
1323
+
1324
+ def _extract_key_points_rw(paragraphs, max_points=5):
1325
+ r"""Extract key points from paragraphs - extracts ALL sentences, not just first one.
1326
+
1327
+ Fixes: Original regex `^(.+?[.!?])\s` only captured first sentence per paragraph.
1328
+ Now splits on all sentence boundaries and takes valid sentences until max_points.
1329
+ """
1330
+ points = []
1331
+
1332
+ for p in paragraphs:
1333
+ if len(points) >= max_points:
1334
+ break
1335
+
1336
+ p = _clean(p)
1337
+ if not p:
1338
+ continue
1339
+
1340
+ # Split paragraph into sentences using Vietnamese + English punctuation
1341
+ sentences = re.split(r'(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])', p)
1342
+ sentences = [s.strip() for s in sentences if s.strip()]
1343
+
1344
+ for sentence in sentences:
1345
+ if len(points) >= max_points:
1346
+ break
1347
+
1348
+ # Clean sentence - remove extra whitespace
1349
+ sentence = _clean(sentence)
1350
+
1351
+ if len(sentence) < 30:
1352
+ continue
1353
+
1354
+ # Check for duplicates
1355
+ if any(sentence[:60] in existing for existing in points):
1356
+ continue
1357
+
1358
+ # Ensure sentence ends with punctuation
1359
+ if not sentence.endswith(('.', '!', '?')):
1360
+ sentence = sentence + '.'
1361
+
1362
+ points.append(sentence)
1363
+
1364
+ # If no valid sentences found, take chunks from raw text
1365
+ if not points:
1366
+ raw = '\n'.join(paragraphs)
1367
+ for i in range(0, min(len(raw), max_points * 300), 280):
1368
+ chunk = _clean(raw[i:i+280])
1369
+ if len(chunk) >= 30 and chunk not in points:
1370
+ points.append(chunk + ('.' if not chunk.endswith('.') else ''))
1371
+ if len(points) >= max_points:
1372
+ break
1373
+
1374
+ return points
1375
+
1376
+
1377
+ @app.post("/api/rewrite_slide")
1378
+ async def api_rewrite_slide(request: Request):
1379
+ """Fast rewrite as SLIDES - no AI needed, instant response."""
1380
+ body = await request.json()
1381
+ url = _clean(body.get("url", ""))
1382
+ context = body.get("context", "")
1383
+ preferred_voice = body.get("voice", "") # Accept custom voice selection
1384
+ if not url and not context:
1385
+ return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
1386
+ data = None
1387
+ if url and url.startswith("http"):
1388
+ data = _scrape_article_for_rewrite(url)
1389
+ if not data and context:
1390
+ paragraphs = [_clean(p) for p in context.split('\n') if len(_clean(p)) > 40]
1391
+ data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
1392
+ if not data or not data.get('paragraphs'):
1393
+ return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
1394
+ points = _extract_key_points_rw(data['paragraphs'], max_points=12)
1395
+ if not points:
1396
+ return JSONResponse({"error": "Không tìm được ý chính"}, status_code=422)
1397
+ images = data.get('images', [])
1398
+ slides = []
1399
+ for i, point in enumerate(points):
1400
+ img = images[i] if i < len(images) else (images[-1] if images else '')
1401
+ if img and 'cdnphoto.dantri' in img:
1402
+ img = '/api/proxy/img?url=' + _quote2(img, safe='')
1403
+ slides.append({'text': point, 'image': img, 'index': i + 1})
1404
+ summary_text = '\n\n'.join([f"• {s['text']}" for s in slides])
1405
+
1406
+ # Auto-detect language and emotion
1407
+ lang, emotion = detect_language_and_emotion(data['title'], summary_text)
1408
+ # Use preferred voice if provided, otherwise auto-detect
1409
+ voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], summary_text)
1410
+
1411
+ post = {
1412
+ "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
1413
+ "title": data['title'],
1414
+ "text": summary_text,
1415
+ "img": images[0] if images else '',
1416
+ "url": url,
1417
+ "kind": "slide_summary",
1418
+ "slides": slides,
1419
+ "images": images[:10],
1420
+ "video": "",
1421
+ "voice": voice,
1422
+ "emotion": emotion,
1423
+ "language": lang,
1424
+ "ts": int(time.time())
1425
+ }
1426
+ posts = _load_wall_posts()
1427
+ posts.insert(0, post)
1428
+ _save_wall_posts(posts)
1429
+ return JSONResponse({"post": post, "slides": slides})
1430
+
1431
+
1432
+ @app.post("/api/rewrite_share")
1433
+ async def api_rewrite_share(request: Request):
1434
+ """Rewrite article and post to Tường AI with SLIDES + AI text."""
1435
+ body = await request.json()
1436
+ url = _clean(body.get("url", ""))
1437
+ ctx = _clean(body.get("context", ""))
1438
+ preferred_voice = body.get("voice", "") # Accept custom voice selection
1439
+ if not url and not ctx:
1440
+ return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
1441
+ data = None
1442
+ if url and url.startswith("http"):
1443
+ data = _scrape_article_for_rewrite(url)
1444
+ if not data and ctx:
1445
+ paragraphs = [_clean(p) for p in ctx.split('\n') if len(_clean(p)) > 40]
1446
+ data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
1447
+ if not data or not data.get('paragraphs'):
1448
+ return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
1449
+ raw_text = '\n'.join(data['paragraphs'])
1450
+ if len(raw_text) < 50:
1451
+ raw_text = ctx[:14000]
1452
+ if len(raw_text) < 50:
1453
+ return JSONResponse({"error": "Bài viết quá ngắn"}, status_code=422)
1454
+ domain = ''
1455
+ try:
1456
+ from urllib.parse import urlparse
1457
+ domain = urlparse(url).netloc.replace('www.', '')
1458
+ except:
1459
+ pass
1460
+
1461
+ # Generate AI summary text
1462
+ ai_text = None
1463
+ try:
1464
+ import ai_ext
1465
+ if hasattr(ai_ext, 'qwen_generate'):
1466
+ prompt = f'Tóm tắt đăng Tường AI:\nTiêu đề: {data["title"]}\n{raw_text[:14000]}\n\n4-6 ý chính. Cuối ghi nguồn.'
1467
+ ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000)
1468
+ except Exception:
1469
+ pass
1470
+ if not ai_text or len(ai_text) < 80:
1471
+ key_pts = _extract_key_points_rw(data['paragraphs'], max_points=12)
1472
+ if key_pts:
1473
+ ai_text = '\n\n'.join([f"• {p}" for p in key_pts])
1474
+ else:
1475
+ ai_text = f"Tóm tắt: {data['title']}\n\n{raw_text[:1200]}\n\nNguồn: {domain}"
1476
+
1477
+ # Build slides from key points (FIX: include slides in rewrite_share too!)
1478
+ points = _extract_key_points_rw(data['paragraphs'], max_points=12)
1479
+ # FINAL FALLBACK: if still no points (e.g. Dantri edge cases), build from ai_text/title
1480
+ if not points:
1481
+ if ai_text and len(ai_text) > 40:
1482
+ # split ai_text into sentences/chunks
1483
+ chunks = [c.strip() for c in re.split(r'[.\n]+', ai_text) if len(c.strip()) > 30]
1484
+ points = chunks[:12] if chunks else [ai_text[:280]]
1485
+ elif data.get('title'):
1486
+ points = [data['title']]
1487
+ images = data.get('images', [])
1488
+ slides = []
1489
+ for i, point in enumerate(points):
1490
+ img = images[i] if i < len(images) else (images[-1] if images else '')
1491
+ if img and 'cdnphoto.dantri' in img:
1492
+ img = '/api/proxy/img?url=' + _quote2(img, safe='')
1493
+ slides.append({'text': point, 'image': img, 'index': i + 1})
1494
+
1495
+ # Auto-detect language and emotion
1496
+ lang, emotion = detect_language_and_emotion(data['title'], ai_text)
1497
+ # Use preferred voice if provided, otherwise auto-detect
1498
+ voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], ai_text)
1499
+
1500
+ post = {
1501
+ "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
1502
+ "title": data['title'],
1503
+ "text": ai_text,
1504
+ "img": images[0] if images else '',
1505
+ "url": url,
1506
+ "kind": "rewrite",
1507
+ "slides": slides,
1508
+ "images": images[:10],
1509
+ "video": "",
1510
+ "voice": voice,
1511
+ "emotion": emotion,
1512
+ "language": lang,
1513
+ "ts": int(time.time())
1514
+ }
1515
+ posts = _load_wall_posts()
1516
+ posts.insert(0, post)
1517
+ _save_wall_posts(posts)
1518
+ return JSONResponse({"post": post, "slides": slides})
1519
+
1520
+
1521
+ @app.post("/api/url_wall")
1522
+ async def api_url_wall(request: Request):
1523
+ """Submit URL to add to Tường AI."""
1524
+ body = await request.json()
1525
+ url = _clean(body.get("url", ""))
1526
+ if not url or not url.startswith('http'):
1527
+ return JSONResponse({"error": "URL không hợp lệ"}, status_code=400)
1528
+ # Reuse rewrite_share logic
1529
+ req._body = json.dumps({"url": url}).encode()
1530
+ return await api_rewrite_share(request)
1531
+
1532
+
1533
+ def _bg():
1534
+ time.sleep(15)
1535
+ while True:
1536
+ try:get_wc2026_all()
1537
+ except:pass
1538
+ time.sleep(90)
1539
+ threading.Thread(target=_bg,daemon=True).start()
1540
+
1541
+ # ===== AUTO SCHEDULER: rewrite AI + short at 7/13/19 VN time =====
1542
+ _AUTO_SCHEDULE_TIMES = [(7, '07:00'), (13, '13:00'), (19, '19:00')]
1543
+ _AUTO_LOG = os.path.join(DATA_DIR, 'auto_rewrite_log.json')
1544
+
1545
+ def _load_auto_log():
1546
+ try:
1547
+ if os.path.exists(_AUTO_LOG):
1548
+ with open(_AUTO_LOG, 'r') as f:
1549
+ return json.load(f)
1550
+ except: pass
1551
+ return {}
1552
+
1553
+ def _save_auto_log(log):
1554
+ try:
1555
+ tmp = _AUTO_LOG + '.tmp'
1556
+ with open(tmp, 'w') as f:
1557
+ json.dump(log, f)
1558
+ os.replace(tmp, _AUTO_LOG)
1559
+ except: pass
1560
+
1561
+ async def _auto_fetch_short(post_id):
1562
+ """Try to auto-generate a short for a post."""
1563
+ try:
1564
+ import httpx
1565
+ async with httpx.AsyncClient(timeout=180) as cl:
1566
+ r = await cl.post(
1567
+ f"http://localhost:7860/api/ai/short/{post_id}",
1568
+ json={"voice":"vi-VN-HoaiMyNeural","emotion":"neutral","speed":1.2},
1569
+ headers={"Content-Type":"application/json"}
1570
+ )
1571
+ if r.status_code < 300:
1572
+ sj = r.json()
1573
+ if sj.get('video'):
1574
+ posts = _load_wall_posts()
1575
+ for p in posts:
1576
+ if p.get('id') == post_id:
1577
+ p['video'] = sj['video']
1578
+ break
1579
+ _save_wall_posts(posts)
1580
+ return True
1581
+ except: pass
1582
+ return False
1583
+
1584
+ async def _auto_rewrite_one(topic, slot_label, used_urls=None, post_index=0):
1585
+ """Rewrite one topic: find articles, summarize, post to wall, trigger short.
1586
+ used_urls: shared set to avoid duplicate articles across topics.
1587
+ post_index: 0-based index to create multiple posts per topic (0,1,2 = up to 3 posts)."""
1588
+ from urllib.parse import quote as _q
1589
+ # Get MORE items to support 1-3 posts per topic
1590
+ items = _search_all(topic, limit=12)
1591
+ # Skip URLs already used by another topic
1592
+ if used_urls is not None:
1593
+ filtered = [it for it in items if it.get('url') not in used_urls]
1594
+ if filtered:
1595
+ items = filtered
1596
+ if not items or post_index >= len(items):
1597
+ return False
1598
+
1599
+ # Get article at post_index (0,1,2 for multiple posts)
1600
+ item = items[post_index] # post_index allows multiple articles per topic
1601
+ url = item.get('url', '')
1602
+ title = item.get('title', topic)
1603
+ if url and used_urls is not None:
1604
+ used_urls.add(url)
1605
+ if not url.startswith('http'):
1606
+ return False
1607
+
1608
+ data = _scrape_article_for_rewrite(url)
1609
+ if not data or not data.get('paragraphs'):
1610
+ return False
1611
+
1612
+ raw_text = '\n'.join(data['paragraphs'])
1613
+ ai_text = None
1614
+
1615
+ # Try AI generation
1616
+ try:
1617
+ import ai_ext
1618
+ prompt = f"Tóm tắt tin tức (tự động {slot_label}):\nTiêu đề: {data['title']}\n{raw_text[:10000]}\n\n4-6 ý chính dạng bullet. Cuối ghi nguồn."
1619
+ ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000)
1620
+ except: pass
1621
+
1622
+ if not ai_text or len(ai_text) < 80:
1623
+ pts = data['paragraphs'][:6]
1624
+ ai_text = '\n\n'.join([f"• {p[:300]}" for p in pts])
1625
+ via = item.get('via', '') or urlparse(url).netloc.replace('www.', '')
1626
+ ai_text += f"\n\nNguồn tham khảo: {via}"
1627
+
1628
+ # Build slides
1629
+ images = data.get('images', [])
1630
+ pts = data['paragraphs'][:10]
1631
+ slides = []
1632
+ for i, p in enumerate(pts[:8]):
1633
+ img = images[i] if i < len(images) else (images[-1] if images else data.get('og_img', ''))
1634
+ slides.append({'text': p[:300], 'image': img, 'index': i + 1})
1635
+
1636
+ post_id = str(int(time.time() * 1000)) + str(_random2.randint(100, 999))
1637
+ post = {
1638
+ "id": post_id, "title": data.get('title', title)[:200],
1639
+ "text": ai_text, "img": images[0] if images else data.get('og_img', ''),
1640
+ "url": url, "kind": "auto_rewrite", "slides": slides,
1641
+ "images": images[:10], "video": "",
1642
+ "voice": "vi-VN-HoaiMyNeural", "emotion": "neutral",
1643
+ "language": "vietnamese", "ts": int(time.time()),
1644
+ "auto_scheduled": True, "slot": slot_label,
1645
+ }
1646
+
1647
+ posts = _load_wall_posts()
1648
+ posts.insert(0, post)
1649
+ _save_wall_posts(posts)
1650
+
1651
+ # Trigger short generation async
1652
+ threading.Thread(target=lambda: asyncio.run(_auto_fetch_short(post_id)), daemon=True).start()
1653
+ return True
1654
+
1655
+ async def _do_scheduled_run(slot_label):
1656
+ """Main scheduled run: 1-3 posts from 3 different HOT topics (3-9 total), no duplicates."""
1657
+ print(f"[auto] Starting scheduled rewrite for {slot_label}")
1658
+
1659
+ # Get top hot topics, skip duplicates
1660
+ all_topics = _get_hot_topics()
1661
+ seen_topics = set()
1662
+ unique_topics = []
1663
+ for t in all_topics:
1664
+ kw = t.get('topic', '').lower().strip()
1665
+ if kw and len(kw) > 5 and kw not in seen_topics:
1666
+ is_dup = False
1667
+ for s in seen_topics:
1668
+ # Check if one topic is substring of another
1669
+ if kw in s or s in kw:
1670
+ is_dup = True
1671
+ break
1672
+ if not is_dup:
1673
+ seen_topics.add(kw)
1674
+ unique_topics.append(t)
1675
+ if len(unique_topics) >= 3:
1676
+ break
1677
+
1678
+ job_topics = [t['topic'] for t in unique_topics[:3] if t.get('topic')]
1679
+ if not job_topics:
1680
+ print(f"[auto] No hot topics found, skipping")
1681
+ return
1682
+
1683
+ print(f"[auto] Running 3 topics: {job_topics}")
1684
+
1685
+ # Track used URLs to avoid cross-topic duplicates
1686
+ _used_urls = set()
1687
+ results = []
1688
+
1689
+ # Process each topic, create 1-3 posts per topic
1690
+ for jt in job_topics:
1691
+ for post_idx in range(3): # Try up to 3 posts per topic
1692
+ try:
1693
+ ok = await asyncio.wait_for(_auto_rewrite_one(jt, slot_label, _used_urls, post_idx), timeout=120)
1694
+ if ok:
1695
+ results.append((jt, post_idx, True))
1696
+ print(f"[auto] Created post {post_idx+1} for '{jt}'")
1697
+ else:
1698
+ # No more articles for this topic
1699
+ break
1700
+ except Exception as e:
1701
+ print(f"[auto] Error on '{jt}' post {post_idx}: {e}")
1702
+ results.append((jt, post_idx, False))
1703
+ await asyncio.sleep(1) # Small delay between posts
1704
+
1705
+ # Ensure at least 3 posts total (fallback if needed)
1706
+ successful_posts = sum(1 for _, _, ok in results if ok)
1707
+ print(f"[auto] Done {slot_label}: {successful_posts} posts created")
1708
+
1709
+ # Log
1710
+ from datetime import datetime, timezone, timedelta
1711
+ VN_TZ_SCHED = timezone(timedelta(hours=7))
1712
+ today_str = datetime.now(VN_TZ_SCHED).strftime('%Y-%m-%d')
1713
+ log = _load_auto_log()
1714
+ if today_str not in log: log[today_str] = {}
1715
+ log[today_str][slot_label] = {
1716
+ 'time': datetime.now(VN_TZ_SCHED).strftime('%H:%M:%S'),
1717
+ 'count': successful_posts,
1718
+ 'total': len(job_topics),
1719
+ }
1720
+ _save_auto_log(log)
1721
+
1722
+ def _scheduler_loop():
1723
+ """Check every 60s; trigger at 7:00, 13:00, 19:00 VN time.
1724
+ On startup, check for any missed slots today and run them immediately."""
1725
+ time.sleep(35)
1726
+ from datetime import datetime, timezone, timedelta
1727
+ VN_TZ_SCHED = timezone(timedelta(hours=7))
1728
+
1729
+ _last_run_date = ""
1730
+ _last_run_slots = set()
1731
+
1732
+ # On startup: check log for missed slots today
1733
+ try:
1734
+ start_now = datetime.now(VN_TZ_SCHED)
1735
+ today_str = start_now.strftime('%Y-%m-%d')
1736
+ current_hour = start_now.hour
1737
+ current_minute = start_now.minute
1738
+ log = _load_auto_log()
1739
+ today_log = log.get(today_str, {})
1740
+ for h, label in _AUTO_SCHEDULE_TIMES:
1741
+ # Run if slot is past (either strictly earlier hour, or same hour but window has passed)
1742
+ should_run = False
1743
+ if h < current_hour:
1744
+ should_run = True
1745
+ elif h == current_hour and current_minute > 10:
1746
+ should_run = True
1747
+ if should_run and label not in today_log:
1748
+ print(f"[auto] Detected missed slot {label} (h={h} < now={current_hour}:{current_minute}), running catch-up now")
1749
+ _run_scheduled_sync(label)
1750
+ _last_run_slots.add(label)
1751
+ except Exception as e:
1752
+ print(f"[auto] Catch-up check error: {e}")
1753
+
1754
+ while True:
1755
+ try:
1756
+ now = datetime.now(VN_TZ_SCHED)
1757
+ today = now.strftime('%Y-%m-%d')
1758
+ hour = now.hour
1759
+ minute = now.minute
1760
+
1761
+ if today != _last_run_date:
1762
+ _last_run_date = today
1763
+ _last_run_slots = set()
1764
+
1765
+ slot = None
1766
+ for h, label in _AUTO_SCHEDULE_TIMES:
1767
+ if hour == h and 0 <= minute < 5:
1768
+ slot = label
1769
+ break
1770
+
1771
+ if slot and slot not in _last_run_slots:
1772
+ _last_run_slots.add(slot)
1773
+ _run_scheduled_sync(slot)
1774
+ except Exception as e:
1775
+ print(f"[auto] Loop error: {e}")
1776
+
1777
+ time.sleep(60)
1778
+
1779
+ threading.Thread(target=_scheduler_loop, daemon=True, name='auto-rewrite-scheduler').start()
1780
+
1781
+ @app.get('/api/debug/auto_schedule')
1782
+ async def debug_auto_schedule(slot: str = '07:00'):
1783
+ """Manually trigger auto scheduler for debugging."""
1784
+ try:
1785
+ # Check if we can access the data directory
1786
+ log = _load_auto_log()
1787
+ topics = _get_hot_topics()[:3]
1788
+ job_topics = [t['topic'] for t in topics if t.get('topic')]
1789
+ return JSONResponse({
1790
+ "slot": slot,
1791
+ "log": log,
1792
+ "hot_topics": job_topics,
1793
+ "wall_posts_count": len(_load_wall_posts()),
1794
+ "data_dir_writable": os.access(DATA_DIR, os.W_OK) if os.path.isdir(DATA_DIR) else False,
1795
+ "data_dir_exists": os.path.isdir(DATA_DIR),
1796
+ })
1797
+ except Exception as e:
1798
+ return JSONResponse({"error": str(e)}, status_code=500)
1799
+
1800
+ def _run_scheduled_sync(slot):
1801
+ """Run _do_scheduled_run in a separate event loop (for background thread)."""
1802
+ loop = asyncio.new_event_loop()
1803
+ asyncio.set_event_loop(loop)
1804
+ try:
1805
+ loop.run_until_complete(_do_scheduled_run(slot))
1806
+ except Exception as e:
1807
+ print(f"[auto] Background run error: {e}")
1808
+ finally:
1809
+ loop.close()
1810
+
1811
+ @app.get('/api/debug/trigger_auto')
1812
+ async def debug_trigger_auto(slot: str = '19:00'):
1813
+ """Trigger _do_scheduled_run in background thread (non-blocking)."""
1814
+ threading.Thread(target=_run_scheduled_sync, args=(slot,), daemon=True).start()
1815
+ return JSONResponse({"status": "started", "slot": slot})
1816
+
1817
+ # ===== SHORTS RSS PROXY ENDPOINT =====
1818
+ @app.get("/api/shorts/rss")
1819
+ def shorts_rss():
1820
+ """Get shorts from YouTube RSS feeds server-side"""
1821
+ import xml.etree.ElementTree as ET
1822
+ import html as html_lib2
1823
+ import re as re2
1824
+
1825
+ YOUTUBE_CHANNELS = {
1826
+ "baodantri7941": "UC_x5TKhOgd6GhYvv5z4I3jg",
1827
+ "baosuckhoedoisongboyte": "UCBsY5fXTQLkF_JnH9kLkL4g",
1828
+ }
1829
+
1830
+ shorts = []
1831
+ seen = set()
1832
+
1833
+ for handle, channel_id in YOUTUBE_CHANNELS.items():
1834
+ try:
1835
+ rss_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
1836
+ r = req.get(rss_url, headers=HEADERS, timeout=15)
1837
+ if r.status_code != 200:
1838
+ continue
1839
+
1840
+ root = ET.fromstring(r.text)
1841
+ ns = {
1842
+ 'atom': 'http://www.w3.org/2005/Atom',
1843
+ 'yt': 'http://www.youtube.com/xml/schemas/2015',
1844
+ 'media': 'http://search.yahoo.com/mrss/'
1845
+ }
1846
+
1847
+ for entry in root.findall('atom:entry', ns)[:30]:
1848
+ title_el = entry.find('atom:title', ns)
1849
+ title = html_lib2.unescape(title_el.text) if title_el is not None and title_el.text else ''
1850
+
1851
+ link_el = entry.find('atom:link', ns)
1852
+ link = link_el.get('href', '') if link_el is not None else ''
1853
+
1854
+ vid_el = entry.find('yt:videoId', ns)
1855
+ vid = vid_el.text if vid_el is not None else ''
1856
+
1857
+ if not vid or vid in seen:
1858
+ continue
1859
+
1860
+ # Check if it's a short
1861
+ is_short = '#shorts' in title.lower() or '#short' in title.lower() or '/shorts/' in link
1862
+
1863
+ if not is_short:
1864
+ desc_el = entry.find('media:description', ns)
1865
+ if desc_el is not None and desc_el.text:
1866
+ if '#shorts' in desc_el.text.lower():
1867
+ is_short = True
1868
+
1869
+ if not is_short:
1870
+ continue
1871
+
1872
+ seen.add(vid)
1873
+
1874
+ # Get thumbnail
1875
+ thumb = f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg"
1876
+ media_group = entry.find('media:group', ns)
1877
+ if media_group is not None:
1878
+ thumb_el = media_group.find('media:thumbnail', ns)
1879
+ if thumb_el is not None:
1880
+ thumb = thumb_el.get('url', thumb)
1881
+
1882
+ shorts.append({
1883
+ 'id': vid,
1884
+ 'title': title.replace('#shorts', '').replace('#short', '').strip()[:120],
1885
+ 'img': thumb,
1886
+ 'link': f'https://www.youtube.com/shorts/{vid}',
1887
+ 'channel': handle,
1888
+ 'source': 'yt'
1889
+ })
1890
+
1891
+ if len(shorts) >= 40:
1892
+ break
1893
+
1894
+ except Exception as e:
1895
+ print(f"RSS error for {handle}: {e}")
1896
+ continue
1897
+
1898
+ return {"shorts": shorts, "count": len(shorts)}
1899
+
1900
+ app.mount('/static',StaticFiles(directory=STATIC_DIR),name='vnews_static')