Spaces:
Running
Running
Fix _get_hot_topics: count frequency across feeds, sort by hotness (most mentioned first)"
Browse files- app_v2_entry.py +42 -60
app_v2_entry.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""
|
| 2 |
VNEWS v2 Entry Point
|
| 3 |
-
Hashtag: 8 sources
|
| 4 |
-
Hot topics:
|
| 5 |
"""
|
| 6 |
import sys, os
|
| 7 |
from main import app, HEADERS, BONGDA_HEADERS, fetch_bongda_api
|
|
@@ -30,7 +30,6 @@ def _has_kw(topic,title):
|
|
| 30 |
if not words:return True
|
| 31 |
return any(w in tt for w in words)
|
| 32 |
|
| 33 |
-
# === 8 SEARCH SOURCES ===
|
| 34 |
def _s_vnexpress(topic,limit=8):
|
| 35 |
items=[]
|
| 36 |
try:
|
|
@@ -42,7 +41,6 @@ def _s_vnexpress(topic,limit=8):
|
|
| 42 |
if _has_kw(topic,t):items.append({'title':t,'url':a['href'],'via':'VnExpress'})
|
| 43 |
except:pass
|
| 44 |
return items
|
| 45 |
-
|
| 46 |
def _s_dantri(topic,limit=8):
|
| 47 |
items=[]
|
| 48 |
try:
|
|
@@ -55,7 +53,6 @@ def _s_dantri(topic,limit=8):
|
|
| 55 |
if len(items)>=limit:break
|
| 56 |
except:pass
|
| 57 |
return items
|
| 58 |
-
|
| 59 |
def _s_vietnamnet(topic,limit=6):
|
| 60 |
items=[]
|
| 61 |
try:
|
|
@@ -68,7 +65,6 @@ def _s_vietnamnet(topic,limit=6):
|
|
| 68 |
if len(items)>=limit:break
|
| 69 |
except:pass
|
| 70 |
return items
|
| 71 |
-
|
| 72 |
def _s_bongda(topic,limit=5):
|
| 73 |
items=[]
|
| 74 |
try:
|
|
@@ -81,7 +77,6 @@ def _s_bongda(topic,limit=5):
|
|
| 81 |
if len(items)>=limit:break
|
| 82 |
except:pass
|
| 83 |
return items
|
| 84 |
-
|
| 85 |
def _s_genk(topic,limit=5):
|
| 86 |
items=[]
|
| 87 |
try:
|
|
@@ -94,7 +89,6 @@ def _s_genk(topic,limit=5):
|
|
| 94 |
if len(items)>=limit:break
|
| 95 |
except:pass
|
| 96 |
return items
|
| 97 |
-
|
| 98 |
def _s_thanhnien(topic,limit=6):
|
| 99 |
items=[]
|
| 100 |
try:
|
|
@@ -107,7 +101,6 @@ def _s_thanhnien(topic,limit=6):
|
|
| 107 |
if len(items)>=limit:break
|
| 108 |
except:pass
|
| 109 |
return items
|
| 110 |
-
|
| 111 |
def _s_tuoitre(topic,limit=6):
|
| 112 |
items=[]
|
| 113 |
try:
|
|
@@ -120,7 +113,6 @@ def _s_tuoitre(topic,limit=6):
|
|
| 120 |
if len(items)>=limit:break
|
| 121 |
except:pass
|
| 122 |
return items
|
| 123 |
-
|
| 124 |
def _s_thethaovanhoa(topic,limit=5):
|
| 125 |
items=[]
|
| 126 |
try:
|
|
@@ -135,23 +127,12 @@ def _s_thethaovanhoa(topic,limit=5):
|
|
| 135 |
return items
|
| 136 |
|
| 137 |
def _search_all(topic, limit=36):
|
| 138 |
-
"""Search 8 sources in parallel, interleave results."""
|
| 139 |
results={}
|
| 140 |
with ThreadPoolExecutor(8) as ex:
|
| 141 |
-
futs={
|
| 142 |
-
ex.submit(_s_vnexpress,topic,8):'vne',
|
| 143 |
-
ex.submit(_s_dantri,topic,8):'dt',
|
| 144 |
-
ex.submit(_s_vietnamnet,topic,6):'vnn',
|
| 145 |
-
ex.submit(_s_bongda,topic,5):'bd',
|
| 146 |
-
ex.submit(_s_genk,topic,5):'gk',
|
| 147 |
-
ex.submit(_s_thanhnien,topic,6):'tn',
|
| 148 |
-
ex.submit(_s_tuoitre,topic,6):'tt',
|
| 149 |
-
ex.submit(_s_thethaovanhoa,topic,5):'tvh',
|
| 150 |
-
}
|
| 151 |
for f in as_completed(futs,timeout=14):
|
| 152 |
try:results[futs[f]]=f.result()
|
| 153 |
except:results[futs[f]]=[]
|
| 154 |
-
# Interleave: take 1 from each source in round-robin
|
| 155 |
srcs=list(results.values());interleaved=[];seen=set()
|
| 156 |
mx=max((len(s) for s in srcs),default=0)
|
| 157 |
for i in range(mx):
|
|
@@ -161,40 +142,32 @@ def _search_all(topic, limit=36):
|
|
| 161 |
if url and url not in seen:seen.add(url);interleaved.append(s[i])
|
| 162 |
return interleaved[:limit]
|
| 163 |
|
| 164 |
-
# === ARTICLE
|
| 165 |
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()))]
|
| 166 |
def _scrape_generic(url):
|
| 167 |
try:
|
| 168 |
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)
|
| 169 |
r.encoding='utf-8';soup=BeautifulSoup(r.text,'lxml')
|
| 170 |
for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript']):tag.decompose()
|
| 171 |
-
h1=soup.find('h1');ogt=soup.find('meta',property='og:title')
|
| 172 |
-
title=(h1.get_text(strip=True) if h1 else '')or(ogt.get('content','') if ogt else '')
|
| 173 |
ogd=soup.find('meta',property='og:description');summary=ogd.get('content','') if ogd else ''
|
| 174 |
ogi=soup.find('meta',property='og:image');og_img=ogi.get('content','') if ogi else ''
|
| 175 |
if og_img and og_img.startswith('//'):og_img='https:'+og_img
|
| 176 |
if og_img and 'cdnphoto.dantri' in og_img:og_img='/api/proxy/img?url='+quote(og_img,safe='')
|
| 177 |
-
selectors=['article','.singular-content','.detail-content','.fck_detail','.content-detail','.knc-content','main','.cms-body','.article__body','.detail__content']
|
| 178 |
block=None
|
| 179 |
-
for sel in
|
| 180 |
el=soup.select_one(sel)
|
| 181 |
if el and len(el.find_all('p'))>=2:block=el;break
|
| 182 |
if not block:block=soup.body or soup
|
| 183 |
body=[]
|
| 184 |
for el in block.find_all(['p','h2','h3','figure','img'],recursive=True):
|
| 185 |
-
if el.name=='p':
|
| 186 |
-
|
| 187 |
-
if t and len(t)>30:body.append({'type':'p','text':t})
|
| 188 |
-
elif el.name in('h2','h3'):
|
| 189 |
-
t=el.get_text(strip=True)
|
| 190 |
-
if t:body.append({'type':'heading','text':t})
|
| 191 |
elif el.name in('figure','img'):
|
| 192 |
im=el if el.name=='img' else el.find('img')
|
| 193 |
if im:
|
| 194 |
src=im.get('data-src') or im.get('src') or im.get('data-original') or''
|
| 195 |
-
if src and'base64' not in src:
|
| 196 |
-
if src.startswith('//'):src='https:'+src
|
| 197 |
-
body.append({'type':'img','src':src})
|
| 198 |
if not body and summary:body=[{'type':'p','text':summary}]
|
| 199 |
return{'title':_clean(title),'summary':_clean(summary),'og_image':og_img,'body':body[:50],'source':'generic','url':url}
|
| 200 |
except:return None
|
|
@@ -212,44 +185,53 @@ def api_article_v2(url:str=Query(...)):
|
|
| 212 |
data=_scrape_generic(url)
|
| 213 |
return JSONResponse(data if data else{'error':'Không đọc được','url':url})
|
| 214 |
|
| 215 |
-
# === HOT TOPICS (
|
| 216 |
_hot_cache={'t':0,'d':[]}
|
| 217 |
def _get_hot_topics():
|
| 218 |
now=time.time()
|
| 219 |
if _hot_cache['d'] and now-_hot_cache['t']<600:return _hot_cache['d']
|
| 220 |
-
|
| 221 |
-
feeds
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
'https://thanhnien.vn/rss/home.rss',
|
| 226 |
-
'https://tuoitre.vn/rss/tin-moi-nhat.rss',
|
| 227 |
-
'https://genk.vn/rss',
|
| 228 |
-
'https://vnexpress.net/rss/the-thao.rss',
|
| 229 |
-
'https://thethaovanhoa.vn/rss/tin-nong.rss',
|
| 230 |
-
]
|
| 231 |
for feed_url in feeds:
|
| 232 |
try:
|
| 233 |
r=req.get(feed_url,headers={'User-Agent':'Mozilla/5.0'},timeout=6);r.encoding='utf-8';soup=BeautifulSoup(r.text,'xml')
|
| 234 |
-
for item in soup.find_all('item')[:
|
| 235 |
title=_clean(item.find('title').get_text() if item.find('title') else '')
|
| 236 |
if not title:continue
|
| 237 |
title=re.sub(r'\s*[-|].*$','',title)
|
| 238 |
words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',title) if len(w)>2 and w.lower() not in _STOP]
|
| 239 |
if len(words)<2:continue
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
except:continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
if len(topics)>=20:break
|
| 250 |
-
|
|
|
|
|
|
|
| 251 |
if len(topics)>=24:break
|
| 252 |
-
if not any(kw.lower() in s for s in seen):topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
|
|
|
|
| 253 |
_hot_cache.update({'t':now,'d':topics[:24]});return topics[:24]
|
| 254 |
|
| 255 |
@app.get('/api/hot_topics')
|
|
@@ -262,7 +244,7 @@ async def serve_index():
|
|
| 262 |
@app.get('/api/hashtag/sources')
|
| 263 |
def _ht(topic:str=Query(...),page:int=Query(default=0)):
|
| 264 |
items=_search_all(topic,36)
|
| 265 |
-
per_page=
|
| 266 |
return JSONResponse({'sources':items[start:end],'topic':topic,'page':page,'has_more':end<len(items),'total':len(items)})
|
| 267 |
@app.get('/api/categories')
|
| 268 |
def _cat():return JSONResponse([])
|
|
|
|
| 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
|
|
|
|
| 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:
|
|
|
|
| 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:
|
|
|
|
| 53 |
if len(items)>=limit:break
|
| 54 |
except:pass
|
| 55 |
return items
|
|
|
|
| 56 |
def _s_vietnamnet(topic,limit=6):
|
| 57 |
items=[]
|
| 58 |
try:
|
|
|
|
| 65 |
if len(items)>=limit:break
|
| 66 |
except:pass
|
| 67 |
return items
|
|
|
|
| 68 |
def _s_bongda(topic,limit=5):
|
| 69 |
items=[]
|
| 70 |
try:
|
|
|
|
| 77 |
if len(items)>=limit:break
|
| 78 |
except:pass
|
| 79 |
return items
|
|
|
|
| 80 |
def _s_genk(topic,limit=5):
|
| 81 |
items=[]
|
| 82 |
try:
|
|
|
|
| 89 |
if len(items)>=limit:break
|
| 90 |
except:pass
|
| 91 |
return items
|
|
|
|
| 92 |
def _s_thanhnien(topic,limit=6):
|
| 93 |
items=[]
|
| 94 |
try:
|
|
|
|
| 101 |
if len(items)>=limit:break
|
| 102 |
except:pass
|
| 103 |
return items
|
|
|
|
| 104 |
def _s_tuoitre(topic,limit=6):
|
| 105 |
items=[]
|
| 106 |
try:
|
|
|
|
| 113 |
if len(items)>=limit:break
|
| 114 |
except:pass
|
| 115 |
return items
|
|
|
|
| 116 |
def _s_thethaovanhoa(topic,limit=5):
|
| 117 |
items=[]
|
| 118 |
try:
|
|
|
|
| 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):
|
|
|
|
| 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)
|
| 160 |
if el and len(el.find_all('p'))>=2:block=el;break
|
| 161 |
if not block:block=soup.body or soup
|
| 162 |
body=[]
|
| 163 |
for el in block.find_all(['p','h2','h3','figure','img'],recursive=True):
|
| 164 |
+
if el.name=='p':t=el.get_text(strip=True);(body.append({'type':'p','text':t}) if t and len(t)>30 else None)
|
| 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
|
|
|
|
| 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')
|
|
|
|
| 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([])
|