bep40 commited on
Commit
f0bf332
·
verified ·
1 Parent(s): d12db28

Fix: import HL_LEAGUES + add friendly path for highlights"

Browse files
Files changed (1) hide show
  1. app_v2_entry.py +33 -78
app_v2_entry.py CHANGED
@@ -1,10 +1,6 @@
1
- """
2
- VNEWS v2 Entry Point
3
- Hashtag: 8 sources
4
- Hot topics: SORTED by frequency (most mentioned across feeds = hottest = auto-loaded first)
5
- """
6
  import sys, os
7
- from main import app, HEADERS, BONGDA_HEADERS, fetch_bongda_api
8
  from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
9
  from fastapi.staticfiles import StaticFiles
10
  from starlette.routing import Mount
@@ -15,34 +11,32 @@ from bs4 import BeautifulSoup
15
  import re, html as html_lib, json, threading, time
16
  from concurrent.futures import ThreadPoolExecutor, as_completed
17
 
 
 
 
18
  STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
19
  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()))]
20
  app.routes[:]=[r for r in app.routes if not isinstance(r, Mount)]
21
  app.router.routes=[r for r in app.router.routes if not isinstance(r, Mount)]
22
-
23
  def _clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or""))).strip()
24
  _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())
25
-
26
  def _has_kw(topic,title):
27
  tl=topic.lower();tt=(title or'').lower()
28
  if tl in tt:return True
29
  words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',tl) if len(w)>2 and w not in _STOP]
30
  if not words:return True
31
  return any(w in tt for w in words)
32
-
33
  def _s_vnexpress(topic,limit=8):
34
- items=[]
35
  try:
36
  r=req.get(f"https://timkiem.vnexpress.net/?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
37
  for art in soup.select('article.item-news')[:limit]:
38
  a=art.select_one('h2 a, h3 a')
39
- if a and a.get('href'):
40
- t=_clean(a.get('title','') or a.get_text(strip=True))
41
- if _has_kw(topic,t):items.append({'title':t,'url':a['href'],'via':'VnExpress'})
42
  except:pass
43
  return items
44
  def _s_dantri(topic,limit=8):
45
- items=[]
46
  try:
47
  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')
48
  for a in soup.select('h3 a[href], .article-title a[href]')[:limit*2]:
@@ -54,10 +48,10 @@ def _s_dantri(topic,limit=8):
54
  except:pass
55
  return items
56
  def _s_vietnamnet(topic,limit=6):
57
- items=[]
58
  try:
59
  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')
60
- for a in soup.select('h3 a[href], .vnn-title a, .horizontalPost__main-title a')[:limit*2]:
61
  t=_clean(a.get_text(strip=True));href=a.get('href','')
62
  if t and len(t)>15 and _has_kw(topic,t):
63
  if not href.startswith('http'):href='https://vietnamnet.vn'+href
@@ -66,10 +60,10 @@ def _s_vietnamnet(topic,limit=6):
66
  except:pass
67
  return items
68
  def _s_bongda(topic,limit=5):
69
- items=[]
70
  try:
71
  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')
72
- for a in soup.select('h3 a[href], .title a[href], article a[href]')[:limit*2]:
73
  t=_clean(a.get_text(strip=True));href=a.get('href','')
74
  if t and len(t)>15 and _has_kw(topic,t):
75
  if not href.startswith('http'):href='https://bongda.com.vn'+href
@@ -78,7 +72,7 @@ def _s_bongda(topic,limit=5):
78
  except:pass
79
  return items
80
  def _s_genk(topic,limit=5):
81
- items=[]
82
  try:
83
  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')
84
  for a in soup.select('a[href$=".chn"]')[:limit*3]:
@@ -90,10 +84,10 @@ def _s_genk(topic,limit=5):
90
  except:pass
91
  return items
92
  def _s_thanhnien(topic,limit=6):
93
- items=[]
94
  try:
95
  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')
96
- for a in soup.select('h3 a[href], .box-title a, .story__title a')[:limit*2]:
97
  t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
98
  if t and len(t)>15 and _has_kw(topic,t):
99
  if not href.startswith('http'):href='https://thanhnien.vn'+href
@@ -102,10 +96,10 @@ def _s_thanhnien(topic,limit=6):
102
  except:pass
103
  return items
104
  def _s_tuoitre(topic,limit=6):
105
- items=[]
106
  try:
107
  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')
108
- for a in soup.select('h3 a[href], .box-title-text a, .news-item a')[:limit*2]:
109
  t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
110
  if t and len(t)>15 and _has_kw(topic,t):
111
  if not href.startswith('http'):href='https://tuoitre.vn'+href
@@ -114,10 +108,10 @@ def _s_tuoitre(topic,limit=6):
114
  except:pass
115
  return items
116
  def _s_thethaovanhoa(topic,limit=5):
117
- items=[]
118
  try:
119
  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')
120
- for a in soup.select('h3 a[href], .title a[href], article a[href]')[:limit*2]:
121
  t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
122
  if t and len(t)>15 and _has_kw(topic,t):
123
  if not href.startswith('http'):href='https://thethaovanhoa.vn'+href
@@ -125,35 +119,27 @@ def _s_thethaovanhoa(topic,limit=5):
125
  if len(items)>=limit:break
126
  except:pass
127
  return items
128
-
129
- def _search_all(topic, limit=36):
130
  results={}
131
  with ThreadPoolExecutor(8) as ex:
132
  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'}
133
  for f in as_completed(futs,timeout=14):
134
  try:results[futs[f]]=f.result()
135
  except:results[futs[f]]=[]
136
- srcs=list(results.values());interleaved=[];seen=set()
137
- mx=max((len(s) for s in srcs),default=0)
138
  for i in range(mx):
139
  for s in srcs:
140
- if i<len(s):
141
- url=s[i].get('url','')
142
- if url and url not in seen:seen.add(url);interleaved.append(s[i])
143
- return interleaved[:limit]
144
-
145
- # === ARTICLE ===
146
  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()))]
147
  def _scrape_generic(url):
148
  try:
149
- r=req.get(url,headers={'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36','Accept-Language':'vi-VN,vi;q=0.9'},timeout=15,allow_redirects=True)
150
- r.encoding='utf-8';soup=BeautifulSoup(r.text,'lxml')
151
  for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript']):tag.decompose()
152
  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 '')
153
  ogd=soup.find('meta',property='og:description');summary=ogd.get('content','') if ogd else ''
154
  ogi=soup.find('meta',property='og:image');og_img=ogi.get('content','') if ogi else ''
155
  if og_img and og_img.startswith('//'):og_img='https:'+og_img
156
- if og_img and 'cdnphoto.dantri' in og_img:og_img='/api/proxy/img?url='+quote(og_img,safe='')
157
  block=None
158
  for sel in['article','.singular-content','.detail-content','.fck_detail','.content-detail','.knc-content','main','.cms-body','.article__body']:
159
  el=soup.select_one(sel)
@@ -165,13 +151,10 @@ def _scrape_generic(url):
165
  elif el.name in('h2','h3'):t=el.get_text(strip=True);(body.append({'type':'heading','text':t}) if t else None)
166
  elif el.name in('figure','img'):
167
  im=el if el.name=='img' else el.find('img')
168
- if im:
169
- src=im.get('data-src') or im.get('src') or im.get('data-original') or''
170
- if src and'base64' not in src:body.append({'type':'img','src':'https:'+src if src.startswith('//') else src})
171
  if not body and summary:body=[{'type':'p','text':summary}]
172
  return{'title':_clean(title),'summary':_clean(summary),'og_image':og_img,'body':body[:50],'source':'generic','url':url}
173
  except:return None
174
-
175
  @app.get('/api/article')
176
  def api_article_v2(url:str=Query(...)):
177
  from main import scrape_vne_article,scrape_bbc_article,scrape_dantri_article,scrape_genk_article,scrape_ttvh_article
@@ -184,56 +167,35 @@ def api_article_v2(url:str=Query(...)):
184
  if data and data.get('body'):return JSONResponse(data)
185
  data=_scrape_generic(url)
186
  return JSONResponse(data if data else{'error':'Không đọc được','url':url})
187
-
188
- # === HOT TOPICS — SORTED BY FREQUENCY (most mentioned = hottest = first) ===
189
  _hot_cache={'t':0,'d':[]}
190
  def _get_hot_topics():
191
  now=time.time()
192
  if _hot_cache['d'] and now-_hot_cache['t']<600:return _hot_cache['d']
193
-
194
- # Count how many times each phrase appears across ALL feeds
195
  freq={};display={}
196
  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']
197
-
198
  for feed_url in feeds:
199
  try:
200
  r=req.get(feed_url,headers={'User-Agent':'Mozilla/5.0'},timeout=6);r.encoding='utf-8';soup=BeautifulSoup(r.text,'xml')
201
  for item in soup.find_all('item')[:12]:
202
  title=_clean(item.find('title').get_text() if item.find('title') else '')
203
  if not title:continue
204
- title=re.sub(r'\s*[-|].*$','',title)
205
- words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',title) if len(w)>2 and w.lower() not in _STOP]
206
  if len(words)<2:continue
207
- # Generate phrases and count
208
  for n in(3,4,2):
209
  for i in range(max(0,len(words)-n+1)):
210
  phrase=' '.join(words[i:i+n])
211
- if len(phrase)<8 or len(phrase)>45:continue
212
- key=phrase.lower()
213
- freq[key]=freq.get(key,0)+1
214
- display[key]=phrase
215
  except:continue
216
-
217
- # Sort by frequency (most mentioned = hottest)
218
- ranked=sorted(freq.items(),key=lambda x:x[1],reverse=True)
219
-
220
- topics=[];seen=set()
221
  for key,count in ranked:
222
- # Skip duplicates (overlapping phrases)
223
  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)
224
  if is_dup:continue
225
- seen.add(key)
226
- phrase=display[key]
227
- topics.append({'label':'#'+re.sub(r'\s+','',phrase.title()),'topic':phrase,'count':count})
228
  if len(topics)>=20:break
229
-
230
- # Add fallbacks
231
- 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','An ninh mạng']:
232
  if len(topics)>=24:break
233
  if not any(kw.lower() in s for s in seen):topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw,'count':0})
234
-
235
  _hot_cache.update({'t':now,'d':topics[:24]});return topics[:24]
236
-
237
  @app.get('/api/hot_topics')
238
  def api_hot_topics():return JSONResponse({'topics':_get_hot_topics()})
239
  @app.get('/')
@@ -243,21 +205,16 @@ async def serve_index():
243
  return HTMLResponse('<h1>VNEWS</h1>')
244
  @app.get('/api/hashtag/sources')
245
  def _ht(topic:str=Query(...),page:int=Query(default=0)):
246
- items=_search_all(topic,36)
247
- per_page=8;start=page*per_page;end=start+per_page
248
  return JSONResponse({'sources':items[start:end],'topic':topic,'page':page,'has_more':end<len(items),'total':len(items)})
249
  @app.get('/api/categories')
250
  def _cat():return JSONResponse([])
251
  @app.get('/api/storage_status')
252
  def _st():return JSONResponse({'persistent':os.path.isdir('/data') and os.access('/data',os.W_OK)})
253
  @app.get('/s')
254
- async def _sh(url:str='',title:str='',img:str=''):
255
- 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>Redirecting...</body></html>')
256
-
257
- # Interactions
258
  DATA_DIR='/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)),'data')
259
- os.makedirs(DATA_DIR,exist_ok=True)
260
- IF=os.path.join(DATA_DIR,'interactions_v2.json');CF=os.path.join(DATA_DIR,'comments_v2.json')
261
  _il=threading.Lock();_cl=threading.Lock()
262
  def _lj(p):
263
  try:
@@ -286,8 +243,6 @@ async def _pc(request:Request):
286
  with _cl:db=_lj(CF);db.setdefault(v,[]);db[v].append(c);db[v]=db[v][-200:];_sj(CF,db);cms=db[v]
287
  with _il:idb=_lj(IF);idb.setdefault(v,{'views':0,'likes':0,'comments':0});idb[v]['comments']=len(cms);_sj(IF,idb)
288
  return JSONResponse({'comments':cms})
289
-
290
- # WC2026
291
  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)
292
  @app.get('/api/wc2026')
293
  def _w():return JSONResponse(get_wc2026_all())
 
1
+ """VNEWS v2 Entry Point - with friendly highlights"""
 
 
 
 
2
  import sys, os
3
+ from main import app, HEADERS, BONGDA_HEADERS, fetch_bongda_api, HL_LEAGUES
4
  from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
5
  from fastapi.staticfiles import StaticFiles
6
  from starlette.routing import Mount
 
11
  import re, html as html_lib, json, threading, time
12
  from concurrent.futures import ThreadPoolExecutor, as_completed
13
 
14
+ # Add friendly league for xemlaibongda.top highlights
15
+ HL_LEAGUES['friendly'] = {"path": "giao-huu/friendly", "name": "Giao hữu", "emoji": "🤝"}
16
+
17
  STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
18
  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()))]
19
  app.routes[:]=[r for r in app.routes if not isinstance(r, Mount)]
20
  app.router.routes=[r for r in app.router.routes if not isinstance(r, Mount)]
 
21
  def _clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or""))).strip()
22
  _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())
 
23
  def _has_kw(topic,title):
24
  tl=topic.lower();tt=(title or'').lower()
25
  if tl in tt:return True
26
  words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',tl) if len(w)>2 and w not in _STOP]
27
  if not words:return True
28
  return any(w in tt for w in words)
 
29
  def _s_vnexpress(topic,limit=8):
30
+ items=[];
31
  try:
32
  r=req.get(f"https://timkiem.vnexpress.net/?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
33
  for art in soup.select('article.item-news')[:limit]:
34
  a=art.select_one('h2 a, h3 a')
35
+ if a and a.get('href'):t=_clean(a.get('title','') or a.get_text(strip=True));items.append({'title':t,'url':a['href'],'via':'VnExpress'}) if _has_kw(topic,t) else None
 
 
36
  except:pass
37
  return items
38
  def _s_dantri(topic,limit=8):
39
+ items=[];
40
  try:
41
  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')
42
  for a in soup.select('h3 a[href], .article-title a[href]')[:limit*2]:
 
48
  except:pass
49
  return items
50
  def _s_vietnamnet(topic,limit=6):
51
+ items=[];
52
  try:
53
  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')
54
+ for a in soup.select('h3 a[href], .vnn-title a')[:limit*2]:
55
  t=_clean(a.get_text(strip=True));href=a.get('href','')
56
  if t and len(t)>15 and _has_kw(topic,t):
57
  if not href.startswith('http'):href='https://vietnamnet.vn'+href
 
60
  except:pass
61
  return items
62
  def _s_bongda(topic,limit=5):
63
+ items=[];
64
  try:
65
  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')
66
+ for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
67
  t=_clean(a.get_text(strip=True));href=a.get('href','')
68
  if t and len(t)>15 and _has_kw(topic,t):
69
  if not href.startswith('http'):href='https://bongda.com.vn'+href
 
72
  except:pass
73
  return items
74
  def _s_genk(topic,limit=5):
75
+ items=[];
76
  try:
77
  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')
78
  for a in soup.select('a[href$=".chn"]')[:limit*3]:
 
84
  except:pass
85
  return items
86
  def _s_thanhnien(topic,limit=6):
87
+ items=[];
88
  try:
89
  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')
90
+ for a in soup.select('h3 a[href], .box-title a')[:limit*2]:
91
  t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
92
  if t and len(t)>15 and _has_kw(topic,t):
93
  if not href.startswith('http'):href='https://thanhnien.vn'+href
 
96
  except:pass
97
  return items
98
  def _s_tuoitre(topic,limit=6):
99
+ items=[];
100
  try:
101
  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')
102
+ for a in soup.select('h3 a[href], .box-title-text a')[:limit*2]:
103
  t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
104
  if t and len(t)>15 and _has_kw(topic,t):
105
  if not href.startswith('http'):href='https://tuoitre.vn'+href
 
108
  except:pass
109
  return items
110
  def _s_thethaovanhoa(topic,limit=5):
111
+ items=[];
112
  try:
113
  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')
114
+ for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
115
  t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
116
  if t and len(t)>15 and _has_kw(topic,t):
117
  if not href.startswith('http'):href='https://thethaovanhoa.vn'+href
 
119
  if len(items)>=limit:break
120
  except:pass
121
  return items
122
+ def _search_all(topic,limit=36):
 
123
  results={}
124
  with ThreadPoolExecutor(8) as ex:
125
  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'}
126
  for f in as_completed(futs,timeout=14):
127
  try:results[futs[f]]=f.result()
128
  except:results[futs[f]]=[]
129
+ srcs=list(results.values());out=[];seen=set();mx=max((len(s) for s in srcs),default=0)
 
130
  for i in range(mx):
131
  for s in srcs:
132
+ 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])
133
+ return out[:limit]
 
 
 
 
134
  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()))]
135
  def _scrape_generic(url):
136
  try:
137
+ r=req.get(url,headers={'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36','Accept-Language':'vi-VN,vi;q=0.9'},timeout=15,allow_redirects=True);r.encoding='utf-8';soup=BeautifulSoup(r.text,'lxml')
 
138
  for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript']):tag.decompose()
139
  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 '')
140
  ogd=soup.find('meta',property='og:description');summary=ogd.get('content','') if ogd else ''
141
  ogi=soup.find('meta',property='og:image');og_img=ogi.get('content','') if ogi else ''
142
  if og_img and og_img.startswith('//'):og_img='https:'+og_img
 
143
  block=None
144
  for sel in['article','.singular-content','.detail-content','.fck_detail','.content-detail','.knc-content','main','.cms-body','.article__body']:
145
  el=soup.select_one(sel)
 
151
  elif el.name in('h2','h3'):t=el.get_text(strip=True);(body.append({'type':'heading','text':t}) if t else None)
152
  elif el.name in('figure','img'):
153
  im=el if el.name=='img' else el.find('img')
154
+ 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)
 
 
155
  if not body and summary:body=[{'type':'p','text':summary}]
156
  return{'title':_clean(title),'summary':_clean(summary),'og_image':og_img,'body':body[:50],'source':'generic','url':url}
157
  except:return None
 
158
  @app.get('/api/article')
159
  def api_article_v2(url:str=Query(...)):
160
  from main import scrape_vne_article,scrape_bbc_article,scrape_dantri_article,scrape_genk_article,scrape_ttvh_article
 
167
  if data and data.get('body'):return JSONResponse(data)
168
  data=_scrape_generic(url)
169
  return JSONResponse(data if data else{'error':'Không đọc được','url':url})
 
 
170
  _hot_cache={'t':0,'d':[]}
171
  def _get_hot_topics():
172
  now=time.time()
173
  if _hot_cache['d'] and now-_hot_cache['t']<600:return _hot_cache['d']
 
 
174
  freq={};display={}
175
  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']
 
176
  for feed_url in feeds:
177
  try:
178
  r=req.get(feed_url,headers={'User-Agent':'Mozilla/5.0'},timeout=6);r.encoding='utf-8';soup=BeautifulSoup(r.text,'xml')
179
  for item in soup.find_all('item')[:12]:
180
  title=_clean(item.find('title').get_text() if item.find('title') else '')
181
  if not title:continue
182
+ 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]
 
183
  if len(words)<2:continue
 
184
  for n in(3,4,2):
185
  for i in range(max(0,len(words)-n+1)):
186
  phrase=' '.join(words[i:i+n])
187
+ if 8<=len(phrase)<=45:key=phrase.lower();freq[key]=freq.get(key,0)+1;display[key]=phrase
 
 
 
188
  except:continue
189
+ ranked=sorted(freq.items(),key=lambda x:x[1],reverse=True);topics=[];seen=set()
 
 
 
 
190
  for key,count in ranked:
 
191
  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)
192
  if is_dup:continue
193
+ seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',display[key].title()),'topic':display[key],'count':count})
 
 
194
  if len(topics)>=20:break
195
+ 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']:
 
 
196
  if len(topics)>=24:break
197
  if not any(kw.lower() in s for s in seen):topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw,'count':0})
 
198
  _hot_cache.update({'t':now,'d':topics[:24]});return topics[:24]
 
199
  @app.get('/api/hot_topics')
200
  def api_hot_topics():return JSONResponse({'topics':_get_hot_topics()})
201
  @app.get('/')
 
205
  return HTMLResponse('<h1>VNEWS</h1>')
206
  @app.get('/api/hashtag/sources')
207
  def _ht(topic:str=Query(...),page:int=Query(default=0)):
208
+ items=_search_all(topic,36);per_page=8;start=page*per_page;end=start+per_page
 
209
  return JSONResponse({'sources':items[start:end],'topic':topic,'page':page,'has_more':end<len(items),'total':len(items)})
210
  @app.get('/api/categories')
211
  def _cat():return JSONResponse([])
212
  @app.get('/api/storage_status')
213
  def _st():return JSONResponse({'persistent':os.path.isdir('/data') and os.access('/data',os.W_OK)})
214
  @app.get('/s')
215
+ 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>')
 
 
 
216
  DATA_DIR='/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)),'data')
217
+ 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')
 
218
  _il=threading.Lock();_cl=threading.Lock()
219
  def _lj(p):
220
  try:
 
243
  with _cl:db=_lj(CF);db.setdefault(v,[]);db[v].append(c);db[v]=db[v][-200:];_sj(CF,db);cms=db[v]
244
  with _il:idb=_lj(IF);idb.setdefault(v,{'views':0,'likes':0,'comments':0});idb[v]['comments']=len(cms);_sj(IF,idb)
245
  return JSONResponse({'comments':cms})
 
 
246
  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)
247
  @app.get('/api/wc2026')
248
  def _w():return JSONResponse(get_wc2026_all())