bep40 commited on
Commit
1dbafff
·
verified ·
1 Parent(s): 5fc1fbb

fix: upload fresh app_v2_entry with team stats

Browse files
Files changed (1) hide show
  1. app_v2_entry_fresh.py +736 -0
app_v2_entry_fresh.py ADDED
@@ -0,0 +1,736 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """VNEWS v2 Entry Point - with fast bongda proxy"""
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:
8
+ print(f"[WARN] ai_ext import failed: {e}")
9
+
10
+ from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response
11
+ from fastapi.staticfiles import StaticFiles
12
+ from starlette.routing import Mount
13
+ from fastapi import Query, Request
14
+ import requests as req
15
+ from bs4 import BeautifulSoup
16
+ import re, html as html_lib, json, threading, time
17
+ from concurrent.futures import ThreadPoolExecutor, as_completed
18
+ from urllib.parse import quote
19
+
20
+ HL_LEAGUES['friendly'] = {"path": "giai-khac/friendly", "name": "Giao hữu", "emoji": "🤝"}
21
+
22
+ STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
23
+ 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()))]
24
+ app.routes[:]=[r for r in app.routes if not isinstance(r, Mount)]
25
+ app.router.routes=[r for r in app.router.routes if not isinstance(r, Mount)]
26
+
27
+ def _clean(s): return re.sub(r"\s+"," ",html_lib.unescape(str(s or""))).strip()
28
+
29
+ # Cache for match details (5 min TTL)
30
+ _match_cache = {}
31
+
32
+ # === FAST BONGDA PROXY ENDPOINT ===
33
+ def _get_match_detail(event_id, slug=None):
34
+ """Internal function to scrape match detail from bongda.com.vn"""
35
+ headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "text/html", "Referer": "https://bongda.com.vn/"}
36
+
37
+ if slug:
38
+ url = f"https://bongda.com.vn/tran-dau/{event_id}/centre/{slug}"
39
+ else:
40
+ url = f"https://bongda.com.vn/tran-dau/{event_id}"
41
+
42
+ resp = req.get(url, headers=headers, timeout=15, allow_redirects=True)
43
+ if resp.status_code != 200:
44
+ return None
45
+
46
+ soup = BeautifulSoup(resp.text, 'html.parser')
47
+ result = {"event_id": event_id, "found": False, "sections": []}
48
+ info = {}
49
+
50
+ tel = soup.select_one('.teams')
51
+ if tel:
52
+ he = tel.select_one('.team.home')
53
+ if he:
54
+ p_tags = [p for p in he.select('p') if not p.get('class') or 'logo' not in p.get('class', [])]
55
+ if p_tags: info['home_team'] = _clean(p_tags[0].get_text())
56
+ lo = he.select_one('img')
57
+ if lo: info['home_logo'] = lo.get('src', '')
58
+ ae = tel.select_one('.team.away')
59
+ if ae:
60
+ p_tags = ae.select('p')
61
+ team_ps = [p for p in p_tags if not p.get('class') or 'logo' not in p.get('class', [])]
62
+ if team_ps: info['away_team'] = _clean(team_ps[-1].get_text())
63
+ lo = ae.select_one('img')
64
+ if lo: info['away_logo'] = lo.get('src', '')
65
+ sc = tel.select_one('.score')
66
+ if sc:
67
+ parts = [_clean(p.get_text()) for p in sc.select('p')]
68
+ if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}"
69
+ lb = sc.select_one('.label')
70
+ if lb: info['status_label'] = _clean(lb.get_text())
71
+
72
+ if info.get('home_team') and info.get('away_team'):
73
+ result['info'] = info
74
+ result['found'] = True
75
+ result['sections'].append('info')
76
+
77
+ events = []
78
+ for ev in soup.select('.events .period .event'):
79
+ ev_cls = ' '.join(ev.get('class', []))
80
+ ev_data = {'team': 'home' if 'home' in ev_cls else 'away', 'period': '', 'type': 'unknown', 'time': '', 'players': ''}
81
+
82
+ parent = ev.parent
83
+ if parent:
84
+ h2 = parent.find('h2')
85
+ if h2: ev_data['period'] = _clean(h2.get_text())
86
+
87
+ if ev.select_one('[class*="goal"]'): ev_data['type'] = 'goal'
88
+ elif ev.select_one('[class*="redcard"]'): ev_data['type'] = 'redcard'
89
+ elif ev.select_one('[class*="yellowcard"]'): ev_data['type'] = 'yellowcard'
90
+ elif ev.select_one('[class*="substitution"]'): ev_data['type'] = 'substitution'
91
+
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
+
103
+ if events:
104
+ result['events'] = events
105
+ result['sections'].append('events')
106
+
107
+ pred = soup.select_one('.prediction-card')
108
+ if pred:
109
+ team_info = pred.select_one('.team-info')
110
+ if team_info:
111
+ teams = team_info.select('.team')
112
+ pred_data = {}
113
+ if len(teams) >= 2:
114
+ pred_data['home_name'] = _clean(teams[0].select_one('.team-name').get_text()) if teams[0].select_one('.team-name') else ''
115
+ pred_data['away_name'] = _clean(teams[1].select_one('.team-name').get_text()) if teams[1].select_one('.team-name') else ''
116
+ divider = team_info.select_one('.divider')
117
+ if divider: pred_data['result'] = _clean(divider.get_text())
118
+ vc = pred.select_one('.vote-count')
119
+ if vc: pred_data['vote_count'] = _clean(vc.get_text())
120
+ result['prediction'] = pred_data
121
+
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
+
137
+ try:
138
+ api_h = {"User-Agent": "Mozilla/5.0", "Accept": "application/json", "X-Requested-With": "XMLHttpRequest", "Referer": "https://bongda.com.vn/"}
139
+ ar = req.get(f"https://bongda.com.vn/api/fixtures/h2h-stats?event_id={event_id}", headers=api_h, timeout=10)
140
+ if ar.status_code == 200:
141
+ ad = ar.json()
142
+ if ad.get('status') == 'success' and ad.get('html'):
143
+ asp = BeautifulSoup(ad['html'], 'html.parser')
144
+ ast = {}
145
+ for row in asp.select('li, tr'):
146
+ cells = row.select('td, span, p')
147
+ if len(cells) >= 3:
148
+ lb = _clean(cells[0].get_text())
149
+ if lb: ast[lb] = {'home': _clean(cells[1].get_text()), 'away': _clean(cells[2].get_text())}
150
+ if ast:
151
+ result['h2h_stats_parsed'] = ast
152
+ result['sections'].append('h2h_stats')
153
+ except: pass
154
+
155
+ return result
156
+
157
+ @app.get('/api/proxy/bongda')
158
+ def proxy_bongda(event_id: int = Query(default=None), slug: str = Query(default=None)):
159
+ if event_id is None:
160
+ return JSONResponse({'error': 'event_id required'}, status_code=400)
161
+
162
+ cache_key = f"{event_id}_{slug}"
163
+ now = time.time()
164
+ cached = _match_cache.get(cache_key)
165
+ if cached and now - cached.get('_ts', 0) < 300:
166
+ return JSONResponse(cached)
167
+
168
+ try:
169
+ result = _get_match_detail(event_id, slug)
170
+ if result:
171
+ result['_ts'] = now
172
+ _match_cache[cache_key] = result
173
+ return JSONResponse(result)
174
+ except Exception as e:
175
+ err = {"event_id": event_id, "found": False, "error": str(e), "_ts": now}
176
+ _match_cache[cache_key] = err
177
+ return JSONResponse(err)
178
+
179
+ return JSONResponse({"event_id": event_id, "found": False})
180
+
181
+ @app.get('/api/match/{event_id}/detail')
182
+ def api_match_detail(event_id: int, url: str = Query(default=None)):
183
+ # Try to extract slug from url if provided
184
+ slug = None
185
+ if url:
186
+ m = re.match(r'.+/tran-dau/\d+/(?:centre|preview)/(.+)', url)
187
+ if m:
188
+ slug = m.group(1)
189
+
190
+ cache_key = f"{event_id}_{slug or ''}"
191
+ now = time.time()
192
+ cached = _match_cache.get(cache_key)
193
+ if cached and now - cached.get('_ts', 0) < 300:
194
+ return JSONResponse(cached)
195
+
196
+ try:
197
+ # If no slug, try to find it from homepage
198
+ if not slug:
199
+ try:
200
+ home_r = req.get("https://bongda.com.vn/", headers={"User-Agent": "Mozilla/5.0"}, timeout=10)
201
+ if home_r.status_code == 200:
202
+ home_soup = BeautifulSoup(home_r.text, 'html.parser')
203
+ for a in home_soup.select(f'a[href*="/tran-dau/{event_id}/"]'):
204
+ href = a.get('href', '')
205
+ m = re.match(r'/tran-dau/\d+/(?:centre|preview)/(.+)', href)
206
+ if m:
207
+ slug = m.group(1)
208
+ cache_key = f"{event_id}_{slug}"
209
+ break
210
+ except: pass
211
+
212
+ result = _get_match_detail(event_id, slug)
213
+ if result:
214
+ result['_ts'] = now
215
+ _match_cache[cache_key] = result
216
+ return JSONResponse(result)
217
+ except Exception as e:
218
+ err = {"event_id": event_id, "found": False, "error": str(e), "_ts": now}
219
+ _match_cache[cache_key] = err
220
+ return JSONResponse(err)
221
+
222
+ return JSONResponse({"event_id": event_id, "found": False})
223
+
224
+ # === Rest of endpoints (existing) ===
225
+ _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())
226
+
227
+ def _has_kw(topic,title):
228
+ tl=topic.lower();tt=(title or'').lower()
229
+ if tl in tt:return True
230
+ words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',tl) if len(w)>2 and w not in _STOP]
231
+ if not words:return True
232
+ return any(w in tt for w in words)
233
+
234
+ def _s_vnexpress(topic,limit=8):
235
+ items=[]
236
+ try:
237
+ r=req.get(f"https://timkiem.vnexpress.net/?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
238
+ for art in soup.select('article.item-news')[:limit]:
239
+ a=art.select_one('h2 a, h3 a')
240
+ if a and a.get('href'):
241
+ t=_clean(a.get('title','') or a.get_text(strip=True))
242
+ if _has_kw(topic,t):items.append({'title':t,'url':a['href'],'via':'VnExpress'})
243
+ except:pass
244
+ return items
245
+
246
+ def _s_dantri(topic,limit=8):
247
+ items=[]
248
+ try:
249
+ 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')
250
+ for a in soup.select('h3 a[href], .article-title a[href]')[:limit*2]:
251
+ t=_clean(a.get_text(strip=True));href=a.get('href','')
252
+ if t and len(t)>15 and _has_kw(topic,t):
253
+ if not href.startswith('http'):href='https://dantri.com.vn'+href
254
+ items.append({'title':t,'url':href,'via':'Dân Trí'})
255
+ if len(items)>=limit:break
256
+ except:pass
257
+ return items
258
+
259
+ def _s_vietnamnet(topic,limit=6):
260
+ items=[]
261
+ try:
262
+ 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')
263
+ for a in soup.select('h3 a[href], .vnn-title a')[:limit*2]:
264
+ t=_clean(a.get_text(strip=True));href=a.get('href','')
265
+ if t and len(t)>15 and _has_kw(topic,t):
266
+ if not href.startswith('http'):href='https://vietnamnet.vn'+href
267
+ items.append({'title':t,'url':href,'via':'VietNamNet'})
268
+ if len(items)>=limit:break
269
+ except:pass
270
+ return items
271
+
272
+ def _s_bongda(topic,limit=5):
273
+ items=[]
274
+ try:
275
+ 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')
276
+ for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
277
+ t=_clean(a.get_text(strip=True));href=a.get('href','')
278
+ if t and len(t)>15 and _has_kw(topic,t):
279
+ if not href.startswith('http'):href='https://bongda.com.vn'+href
280
+ items.append({'title':t,'url':href,'via':'Bóng Đá'})
281
+ if len(items)>=limit:break
282
+ except:pass
283
+ return items
284
+
285
+ def _s_genk(topic,limit=5):
286
+ items=[]
287
+ try:
288
+ 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')
289
+ for a in soup.select('a[href$=".chn"]')[:limit*3]:
290
+ t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
291
+ if t and len(t)>15 and _has_kw(topic,t):
292
+ if href.startswith('/'):href='https://genk.vn'+href
293
+ items.append({'title':t,'url':href,'via':'GenK'})
294
+ if len(items)>=limit:break
295
+ except:pass
296
+ return items
297
+
298
+ def _s_thanhnien(topic,limit=6):
299
+ items=[]
300
+ try:
301
+ 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')
302
+ for a in soup.select('h3 a[href], .box-title a')[:limit*2]:
303
+ t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
304
+ if t and len(t)>15 and _has_kw(topic,t):
305
+ if not href.startswith('http'):href='https://thanhnien.vn'+href
306
+ items.append({'title':t,'url':href,'via':'Thanh Niên'})
307
+ if len(items)>=limit:break
308
+ except:pass
309
+ return items
310
+
311
+ def _s_tuoitre(topic,limit=6):
312
+ items=[]
313
+ try:
314
+ 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')
315
+ for a in soup.select('h3 a[href], .box-title-text a')[:limit*2]:
316
+ t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
317
+ if t and len(t)>15 and _has_kw(topic,t):
318
+ if not href.startswith('http'):href='https://tuoitre.vn'+href
319
+ items.append({'title':t,'url':href,'via':'Tuổi Trẻ'})
320
+ if len(items)>=limit:break
321
+ except:pass
322
+ return items
323
+
324
+ def _s_thethaovanhoa(topic,limit=5):
325
+ items=[]
326
+ try:
327
+ 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')
328
+ for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
329
+ t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
330
+ if t and len(t)>15 and _has_kw(topic,t):
331
+ if not href.startswith('http'):href='https://thethaovanhoa.vn'+href
332
+ items.append({'title':t,'url':href,'via':'TT&VH'})
333
+ if len(items)>=limit:break
334
+ except:pass
335
+ return items
336
+
337
+ def _search_all(topic,limit=36):
338
+ results={}
339
+ with ThreadPoolExecutor(8) as ex:
340
+ 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'}
341
+ for f in as_completed(futs,timeout=14):
342
+ try:results[futs[f]]=f.result()
343
+ except:results[futs[f]]=[]
344
+ srcs=list(results.values());out=[];seen=set()
345
+ for i in range(max((len(s) for s in srcs),default=0)):
346
+ for s in srcs:
347
+ 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])
348
+ return out[:limit]
349
+
350
+ app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)=='/api/article' and 'GET' in getattr(r,'methods',set()))]
351
+
352
+ def _scrape_generic(url):
353
+ try:
354
+ r=req.get(url,headers={'User-Agent':'Mozilla/5.0','Accept-Language':'vi-VN,vi;q=0.9'},timeout=15,allow_redirects=True);r.encoding='utf-8';soup=BeautifulSoup(r.text,'lxml')
355
+ for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript']):tag.decompose()
356
+ h1=soup.find('h1');ogt=soup.find('meta',property='og:title');title=(h1.get_text(strip=True) if h1 else '')or(ogt.get('content','') if ogt else '')
357
+ ogd=soup.find('meta',property='og:description');summary=ogd.get('content','') if ogd else ''
358
+ ogi=soup.find('meta',property='og:image');og_img=ogi.get('content','') if ogi else ''
359
+ if og_img and og_img.startswith('//'):og_img='https:'+og_img
360
+ block=None
361
+ for sel in['article','.singular-content','.detail-content','.fck_detail','.content-detail','.knc-content','main','.cms-body','.article__body']:
362
+ el=soup.select_one(sel)
363
+ if el and len(el.find_all('p'))>=2:block=el;break
364
+ if not block:block=soup.body or soup
365
+ body=[]
366
+ for el in block.find_all(['p','h2','h3','figure','img'],recursive=True):
367
+ if el.name=='p':t=el.get_text(strip=True);(body.append({'type':'p','text':t}) if t and len(t)>30 else None)
368
+ elif el.name in('h2','h3'):t=el.get_text(strip=True);(body.append({'type':'heading','text':t}) if t else None)
369
+ elif el.name in('figure','img'):
370
+ im=el if el.name=='img' else el.find('img')
371
+ if im:src=im.get('data-src') or im.get('src') or'';(body.append({'type':'img','src':'https:'+src if src.startswith('//') else src}) if src and'base64' not in src else None)
372
+ if not body and summary:body=[{'type':'p','text':summary}]
373
+ return{'title':_clean(title),'summary':_clean(summary),'og_image':og_img,'body':body[:50],'source':'generic','url':url}
374
+ except:return None
375
+
376
+ @app.get('/api/article')
377
+ def api_article_v2(url:str=Query(...)):
378
+ from main import scrape_vne_article,scrape_bbc_article,scrape_dantri_article,scrape_genk_article,scrape_ttvh_article
379
+ if 'vnexpress.net' in url:data=scrape_vne_article(url)
380
+ elif 'bbc.com' in url:data=scrape_bbc_article(url)
381
+ elif 'dantri.com.vn' in url:data=scrape_dantri_article(url)
382
+ elif 'genk.vn' in url:data=scrape_genk_article(url)
383
+ elif 'thethaovanhoa.vn' in url:data=scrape_ttvh_article(url)
384
+ else:data=_scrape_generic(url)
385
+ if data and data.get('body'):return JSONResponse(data)
386
+ return JSONResponse(data if data else{'error':'Không đọc được','url':url})
387
+
388
+ _hot_cache={'t':0,'d':[]}
389
+ def _get_hot_topics():
390
+ now=time.time()
391
+ if _hot_cache['d'] and now-_hot_cache['t']<600:return _hot_cache['d']
392
+ freq={};display={}
393
+ 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']
394
+ for feed_url in feeds:
395
+ try:
396
+ r=req.get(feed_url,headers={'User-Agent':'Mozilla/5.0'},timeout=6);r.encoding='utf-8';soup=BeautifulSoup(r.text,'xml')
397
+ for item in soup.find_all('item')[:12]:
398
+ title=_clean(item.find('title').get_text() if item.find('title') else '')
399
+ if not title:continue
400
+ 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]
401
+ if len(words)<2:continue
402
+ for n in(3,4,2):
403
+ for i in range(max(0,len(words)-n+1)):
404
+ phrase=' '.join(words[i:i+n])
405
+ if 8<=len(phrase)<=45:key=phrase.lower();freq[key]=freq.get(key,0)+1;display[key]=phrase
406
+ except:continue
407
+ ranked=sorted(freq.items(),key=lambda x:x[1],reverse=True);topics=[];seen=set()
408
+ for key,count in ranked:
409
+ 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)
410
+ if is_dup:continue
411
+ seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',display[key].title()),'topic':display[key],'count':count})
412
+ if len(topics)>=20:break
413
+ 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']:
414
+ if len(topics)>=24:break
415
+ if not any(kw.lower() in s for s in seen):topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw,'count':0})
416
+ _hot_cache.update({'t':now,'d':topics[:24]});return topics[:24]
417
+
418
+ @app.get('/api/hot_topics')
419
+ def api_hot_topics():return JSONResponse({'topics':_get_hot_topics()})
420
+ @app.get('/')
421
+ async def serve_index():
422
+ p=os.path.join(STATIC_DIR,'index_v3.html')
423
+ if os.path.exists(p):return FileResponse(p,media_type='text/html')
424
+ p2=os.path.join(STATIC_DIR,'index_v2.html')
425
+ if os.path.exists(p2):return FileResponse(p2,media_type='text/html')
426
+ return HTMLResponse('<h1>VNEWS</h1>')
427
+ @app.get('/api/hashtag/sources')
428
+ def _ht(topic:str=Query(...),page:int=Query(default=0)):
429
+ items=_search_all(topic,36);per_page=8;start=page*per_page;end=start+per_page
430
+ return JSONResponse({'sources':items[start:end],'topic':topic,'page':page,'has_more':end<len(items),'total':len(items)})
431
+ @app.get('/api/categories')
432
+ def _cat():return JSONResponse([])
433
+ @app.get('/api/storage_status')
434
+ def _st():return JSONResponse({'persistent':os.path.isdir('/data') and os.access('/data',os.W_OK)})
435
+ @app.get('/s')
436
+ 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>')
437
+
438
+ 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)
439
+ @app.get('/api/wc2026')
440
+ def _w():return JSONResponse(get_wc2026_all())
441
+ @app.get('/api/wc2026/fixtures')
442
+ def _wf():return JSONResponse(scrape_fixtures())
443
+ @app.get('/api/wc2026/standings')
444
+ def _ws():return JSONResponse(scrape_standings())
445
+ @app.get('/api/wc2026/stats')
446
+ def _wst():return JSONResponse(scrape_stats())
447
+ @app.get('/api/wc2026/history')
448
+ def _whi():return JSONResponse(scrape_history())
449
+ @app.get('/api/wc2026/news')
450
+ def _wn():return JSONResponse(scrape_wc_news())
451
+ @app.get('/api/wc2026/road')
452
+ def _wr():return JSONResponse(scrape_road_to_wc())
453
+ @app.get('/api/wc2026/h2h/{eid}')
454
+ def _wh2(eid:int):return JSONResponse(scrape_h2h(eid))
455
+ @app.get('/api/wc2026/lineups/{eid}')
456
+ def _wl(eid:int):return JSONResponse(scrape_lineups(eid))
457
+ @app.get('/api/wc2026/match/{eid}')
458
+ def _wm(eid:int):return JSONResponse(scrape_match_detail(eid))
459
+
460
+ DATA_DIR='/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)),'data')
461
+ os.makedirs(DATA_DIR,exist_ok=True);IF=os.path.join(DATA_DIR,'interactions_v2.json');CF=os.path.join(DATA_DIR,'comments_v2.json')
462
+ _il=threading.Lock();_cl=threading.Lock()
463
+ def _lj(p):
464
+ try:
465
+ if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8'))
466
+ except:pass
467
+ return{}
468
+ def _sj(p,d):
469
+ try:open(p+'.tmp','w',encoding='utf-8').write(json.dumps(d,ensure_ascii=False));os.replace(p+'.tmp',p)
470
+ except:pass
471
+
472
+ @app.post('/api/v2/interact')
473
+ async def _int(request:Request):
474
+ b=await request.json();v=str(b.get('id','')).strip();t=str(b.get('type','')).strip()
475
+ if not v or t not in('view','like'):return JSONResponse({'error':'x'},status_code=400)
476
+ 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])
477
+
478
+ @app.get('/api/v2/interactions')
479
+ def _gi(id:str=Query(...)):
480
+ with _il:return JSONResponse(_lj(IF).get(id.strip(),{'views':0,'likes':0,'comments':0}))
481
+
482
+ @app.get('/api/v2/comments')
483
+ def _gc(id:str=Query(...)):
484
+ with _cl:return JSONResponse({'comments':_lj(CF).get(id.strip(),[])})
485
+
486
+ @app.post('/api/v2/comment')
487
+ async def _pc(request:Request):
488
+ b=await request.json();v=str(b.get('id','')).strip();tx=str(b.get('text','')).strip()[:500]
489
+ if not v or not tx:return JSONResponse({'error':'x'},status_code=400)
490
+ c={'text':tx,'time':time.strftime('%H:%M %d/%m',time.localtime()),'ts':int(time.time())}
491
+ with _cl:db=_lj(CF);db.setdefault(v,[]);db[v].append(c);db[v]=db[v][-200:];_sj(CF,db);cms=db[v]
492
+ with _il:idb=_lj(IF);idb.setdefault(v,{'views':0,'likes':0,'comments':0});idb[v]['comments']=len(cms);_sj(IF,idb)
493
+ return JSONResponse({'comments':cms})
494
+
495
+ # ============================================================
496
+ # TEAM STATS — scrape bongda.com.vn /doi-bong/ page, render inline
497
+ # ============================================================
498
+ def _scrape_team_page(team_path):
499
+ """Scrape team stats from bongda.com.vn team page."""
500
+ url = f"https://bongda.com.vn/doi-bong/{team_path}"
501
+ headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "text/html", "Referer": "https://bongda.com.vn/"}
502
+ try:
503
+ r = req.get(url, headers=headers, timeout=15, allow_redirects=True)
504
+ if r.status_code != 200:
505
+ return None
506
+ soup = BeautifulSoup(r.text, 'html.parser')
507
+ except:
508
+ return None
509
+
510
+ team = {"name": "", "logo": "", "league": "", "country": "", "stats": {}, "recent": [], "squad": [], "found": False}
511
+
512
+ h1 = soup.select_one('h1')
513
+ if h1:
514
+ team['name'] = _clean(h1.get_text())
515
+ team['found'] = True
516
+
517
+ logo_el = soup.select_one('.team-logo img, .club-logo img, img.team-logo, .team-header img')
518
+ if logo_el:
519
+ team['logo'] = logo_el.get('src', '')
520
+
521
+ info_el = soup.select_one('.team-info, .club-info, .team-header')
522
+ if info_el:
523
+ txt = info_el.get_text()
524
+ if 'việt nam' in txt.lower() or 'vietnam' in txt.lower():
525
+ team['country'] = 'Việt Nam'
526
+
527
+ for li in soup.select('li.match-detail, .match-list .match-item, .recent-matches li'):
528
+ home_el = li.select_one('.home-team .name, .team-home')
529
+ away_el = li.select_one('.away-team .name, .team-away')
530
+ if not home_el or not away_el:
531
+ continue
532
+ home = _clean(home_el.get_text())
533
+ away = _clean(away_el.get_text())
534
+ status_el = li.select_one('.status a, .score')
535
+ score = ""
536
+ event_id = ""
537
+ match_url = ""
538
+ if status_el:
539
+ href = status_el.get('href', '')
540
+ if href:
541
+ match_url = 'https://bongda.com.vn' + href if href.startswith('/') else href
542
+ m = re.search(r'/tran-dau/(\d+)/', href)
543
+ if m:
544
+ event_id = m.group(1)
545
+ spans = status_el.find_all('span')
546
+ if len(spans) >= 3:
547
+ score = f"{spans[0].get_text(strip=True)} - {spans[2].get_text(strip=True)}"
548
+ elif status_el.select_one('.vs'):
549
+ score = 'VS'
550
+ time_el = li.select_one('.match-time, .time, .date')
551
+ mt = _clean(time_el.get_text()) if time_el else ''
552
+ team['recent'].append({
553
+ 'home': home, 'away': away, 'score': score, 'time': mt,
554
+ 'event_id': event_id, 'url': match_url
555
+ })
556
+ if len(team['recent']) >= 10:
557
+ break
558
+
559
+ standings_rows = []
560
+ for tr in soup.select('.standings-table tr, .league-table tr, table tbody tr'):
561
+ cells = tr.select('td')
562
+ if len(cells) >= 5:
563
+ pos = _clean(cells[0].get_text())
564
+ tname = _clean(cells[1].get_text()) if len(cells) > 1 else ''
565
+ played = _clean(cells[2].get_text()) if len(cells) > 2 else ''
566
+ gd = _clean(cells[3].get_text()) if len(cells) > 3 else ''
567
+ pts = _clean(cells[4].get_text()) if len(cells) > 4 else ''
568
+ if pos and tname and pos.isdigit():
569
+ standings_rows.append({'pos': pos, 'team': tname, 'played': played, 'gd': gd, 'pts': pts})
570
+ if standings_rows:
571
+ team['standings'] = standings_rows
572
+
573
+ for p in soup.select('.player-item, .squad-item, .player-card'):
574
+ pname_el = p.select_one('.player-name, .name, h3, h4')
575
+ pname = _clean(pname_el.get_text()) if pname_el else ''
576
+ if not pname:
577
+ continue
578
+ ppos_el = p.select_one('.position, .pos, .player-pos')
579
+ ppos = _clean(ppos_el.get_text()) if ppos_el else ''
580
+ team['squad'].append({'name': pname, 'position': ppos})
581
+
582
+ breadcrumb = soup.select_one('.breadcrumb, .breadcrumbs')
583
+ if breadcrumb:
584
+ for a in breadcrumb.select('a'):
585
+ lt = _clean(a.get_text())
586
+ if lt and lt.lower() != 'trang chủ':
587
+ team['league'] = lt
588
+ break
589
+
590
+ return team
591
+
592
+
593
+ @app.get("/api/team/{path:path}")
594
+ async def api_team_stats(path: str):
595
+ data = _scrape_team_page(path)
596
+ if data and data.get('found'):
597
+ return JSONResponse(data)
598
+ return JSONResponse({"found": False, "error": "Không tìm thấy đội bóng"})
599
+
600
+
601
+ def _render_team_page(path: str):
602
+ """Render full HTML team stats page."""
603
+ data = _scrape_team_page(path)
604
+ if not data or not data.get('found'):
605
+ from fastapi.responses import RedirectResponse
606
+ return RedirectResponse(f"https://bongda.com.vn/doi-bong/{path}", status_code=302)
607
+
608
+ team = data
609
+ name = _clean(team.get('name') or 'Đội bóng')
610
+ logo = _clean(team.get('logo') or '')
611
+ league = _clean(team.get('league') or '')
612
+ country = _clean(team.get('country') or '')
613
+
614
+ recent_html = ''
615
+ if team.get('recent'):
616
+ for m in team['recent'][:10]:
617
+ eid = _clean(m.get('event_id', ''))
618
+ murl = _clean(m.get('url', ''))
619
+ sc = _clean(m.get('score', 'VS'))
620
+ mt = _clean(m.get('time', ''))
621
+ home = _clean(m.get('home', ''))
622
+ away = _clean(m.get('away', ''))
623
+ onclick = f'onclick="openMatch(\'{eid}\',\'{murl}\')"' if eid else ''
624
+ recent_html += f'<div class="tm-match" {onclick} style="cursor:pointer">'
625
+ recent_html += f'<span class="tm-match-time">{mt}</span>'
626
+ recent_html += f'<span class="tm-match-teams">{home} <span class="tm-score">{sc}</span> {away}</span>'
627
+ recent_html += '</div>'
628
+
629
+ standings_html = ''
630
+ if team.get('standings'):
631
+ standings_html = '<table class="tm-standings"><thead><tr><th>#</th><th>Đội</th><th>Trận</th><th>HS</th><th>Điểm</th></tr></thead><tbody>'
632
+ for row in team['standings']:
633
+ pos = _clean(row.get('pos', ''))
634
+ tname = _clean(row.get('team', ''))
635
+ played = _clean(row.get('played', ''))
636
+ gd = _clean(row.get('gd', ''))
637
+ pts = _clean(row.get('pts', ''))
638
+ highlight = ' class="tm-highlight"' if name and tname and name.lower()[:6] in tname.lower()[:6] else ''
639
+ standings_html += f'<tr{highlight}><td>{pos}</td><td>{tname}</td><td>{played}</td><td>{gd}</td><td>{pts}</td></tr>'
640
+ standings_html += '</tbody></table>'
641
+
642
+ squad_html = ''
643
+ if team.get('squad'):
644
+ for p in team['squad'][:30]:
645
+ pnm = _clean(p.get('name', ''))
646
+ pps = _clean(p.get('position', ''))
647
+ if not pnm:
648
+ continue
649
+ squad_html += f'<div class="tm-player"><span class="tm-player-name">{pnm}</span><span class="tm-player-pos">{pps}</span></div>'
650
+
651
+ logo_html = f'<img src="{logo}" class="tm-logo" alt="{name}" onerror="this.style.display=\'none\'">' if logo else ''
652
+
653
+ sections_html = ''
654
+ sections_html += '<div class="tm-section"><div class="tm-section-title">📋 Trận đấu gần nhất</div>'
655
+ sections_html += recent_html or '<div class="tm-nodata">Không có dữ liệu trận đấu</div>'
656
+ sections_html += '</div>'
657
+
658
+ if standings_html:
659
+ sections_html += '<div class="tm-section"><div class="tm-section-title">🏆 Bảng xếp hạng</div>' + standings_html + '</div>'
660
+
661
+ if squad_html:
662
+ sections_html += '<div class="tm-section"><div class="tm-section-title">👥 Đội hình</div><div>' + squad_html + '</div></div>'
663
+
664
+ html = f'''<!DOCTYPE html>
665
+ <html lang="vi">
666
+ <head>
667
+ <meta charset="utf-8">
668
+ <meta name="viewport" content="width=device-width,initial-scale=1">
669
+ <title>⚽ {name} — Thống kê | VNEWS</title>
670
+ <meta property="og:title" content="⚽ {name} — Thống kê | VNEWS">
671
+ <style>
672
+ *{{margin:0;padding:0;box-sizing:border-box}}body{{background:#0d1117;color:#e0e0e0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif}}
673
+ .tm-header{{background:linear-gradient(135deg,#1a2a1f,#0d1117);border-bottom:2px solid #2d8659;padding:16px;text-align:center}}
674
+ .tm-top{{display:flex;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap}}
675
+ .tm-logo{{width:64px;height:64px;object-fit:contain;border-radius:50%;border:3px solid #2d8659;background:#111}}
676
+ .tm-name{{font-size:24px;font-weight:900;color:#fff}}
677
+ .tm-badge{{font-size:12px;color:#888;margin-top:4px}}
678
+ .tm-badge span{{background:#1a2a1f;color:#5cb87a;padding:2px 8px;border-radius:4px;margin:0 2px}}
679
+ .tm-nav{{background:#161b22;padding:8px 16px;display:flex;gap:8px;align-items:center;border-bottom:1px solid #21262d}}
680
+ .tm-nav a{{color:#5cb87a;text-decoration:none;font-size:13px}}
681
+ .tm-nav a:hover{{text-decoration:underline}}
682
+ .tm-container{{max-width:900px;margin:0 auto;padding:16px}}
683
+ .tm-section{{margin-bottom:20px}}
684
+ .tm-section-title{{font-size:14px;font-weight:800;color:#5cb87a;margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid #21262d}}
685
+ .tm-match{{display:flex;align-items:center;gap:10px;padding:10px 12px;background:#161b22;border-radius:6px;margin-bottom:4px;font-size:13px;border:1px solid #21262d;transition:background .2s}}
686
+ .tm-match:hover{{background:#1c2128;border-color:#2d8659}}
687
+ .tm-match-time{{font-size:11px;color:#888;min-width:60px}}
688
+ .tm-match-teams{{flex:1;color:#e0e0e0}}
689
+ .tm-score{{color:#f0c040;font-weight:800}}
690
+ .tm-standings{{width:100%;border-collapse:collapse;font-size:13px}}
691
+ .tm-standings th{{background:#1a2a1f;color:#5cb87a;padding:8px;text-align:left;font-size:11px;border-bottom:1px solid #2d8659}}
692
+ .tm-standings td{{padding:8px;border-bottom:1px solid #21262d}}
693
+ .tm-standings tr:hover td{{background:#161b22}}
694
+ .tm-highlight td{{background:#1a2a1f;border-left:3px solid #5cb87a}}
695
+ .tm-player{{display:inline-flex;align-items:center;gap:6px;background:#161b22;padding:5px 10px;border-radius:5px;margin:3px;font-size:12px;border:1px solid #21262d}}
696
+ .tm-player-name{{color:#e0e0e0}}
697
+ .tm-player-pos{{color:#888;font-size:10px}}
698
+ .tm-back{{display:inline-block;margin-bottom:12px;color:#5cb87a;text-decoration:none;font-size:14px}}
699
+ .tm-back:hover{{text-decoration:underline}}
700
+ .tm-nodata{{text-align:center;color:#666;padding:20px;font-size:13px}}
701
+ </style>
702
+ </head>
703
+ <body>
704
+ <div class="tm-header"><div class="tm-top">{logo_html}<div><div class="tm-name">⚽ {name}</div><div class="tm-badge">{league and f"<span>🏆 {league}</span>"}{country and f"<span>📍 {country}</span>"}<span>📡 VNEWS</span></div></div></div></div>
705
+ <div class="tm-nav"><a href="/" class="tm-back">← Quay về VNEWS</a> <a href="https://bongda.com.vn/doi-bong/{path}" target="_blank">Xem trên Bongda.com.vn ↗</a></div>
706
+ <div class="tm-container">{sections_html}</div>
707
+ <script>function openMatch(id,url){{if(!id)return;window.location.href='/?match='+id+(url?'&url='+encodeURIComponent(url):'');}}</script>
708
+ </body></html>'''
709
+ return HTMLResponse(html)
710
+
711
+
712
+ @app.get("/doi-bong/{path:path}")
713
+ async def page_team_stats(path: str):
714
+ return _render_team_page(path)
715
+
716
+
717
+ @app.get("/giai-dau/{path:path}")
718
+ async def page_league_internal(path: str):
719
+ from fastapi.responses import RedirectResponse
720
+ return RedirectResponse(f"https://bongda.com.vn/giai-dau/{path}", status_code=302)
721
+
722
+
723
+ @app.get("/cau-thu/{path:path}")
724
+ async def redirect_cau_thu(path: str):
725
+ from fastapi.responses import RedirectResponse
726
+ return RedirectResponse(f"https://bongda.com.vn/cau-thu/{path}", status_code=302)
727
+
728
+ def _bg():
729
+ time.sleep(15)
730
+ while True:
731
+ try:get_wc2026_all()
732
+ except:pass
733
+ time.sleep(90)
734
+ threading.Thread(target=_bg,daemon=True).start()
735
+
736
+ app.mount('/static',StaticFiles(directory=STATIC_DIR),name='vnews_static')