"""Final6: robust topic synthesis, stable shorts, hot topic hashtags.
This runtime intentionally overrides only the topic/shorts/root endpoints from the restored app.
"""
import re, time, json, os, threading, html as html_lib
from urllib.parse import quote, urlparse, parse_qs, unquote
import requests
from bs4 import BeautifulSoup
import ai_runtime_final5 as f5
from ai_runtime_final5 import app, rt, HTMLResponse, JSONResponse, Request, Query
_PATCH={('/api/topic_post','POST'),('/api/shorts','GET'),('/api/hot_topics','GET'),('/api/topic_sources','GET'),('/','GET')}
app.router.routes=[r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in _PATCH)]
_TOPIC_CACHE={}
_HOT_CACHE={"t":0,"d":[]}
_SHORTS_CACHE_FINAL6={"t":0,"d":[]}
_TRANSLATE_CACHE_PATH="/data/title_vi_cache.json" if os.path.isdir('/data') else "/app/data/title_vi_cache.json"
_translate_lock=threading.Lock()
YOUTUBE_HANDLES=["baodantri7941","baosuckhoedoisongboyte"]
UA={"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","Accept-Language":"vi,en;q=0.8"}
STOP_WORDS=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'.split())
TRUSTED_SITES=['vnexpress.net','dantri.com.vn','vietnamnet.vn','tuoitre.vn','thanhnien.vn','laodong.vn','vov.vn','vtv.vn','genk.vn','cafef.vn','thethaovanhoa.vn']
def clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or ""))).strip()
def _domain(u):
try:return urlparse(u or '').netloc.replace('www.','')
except Exception:return ''
def _load_title_cache():
try:
if os.path.exists(_TRANSLATE_CACHE_PATH):
with open(_TRANSLATE_CACHE_PATH,'r',encoding='utf-8') as f:return json.load(f)
except Exception:pass
return {}
def _save_title_cache(db):
try:
os.makedirs(os.path.dirname(_TRANSLATE_CACHE_PATH),exist_ok=True);tmp=_TRANSLATE_CACHE_PATH+'.tmp'
with open(tmp,'w',encoding='utf-8') as f:json.dump(db,f,ensure_ascii=False)
os.replace(tmp,_TRANSLATE_CACHE_PATH)
except Exception:pass
def _looks_vietnamese(s):
s=s or ''
if re.search(r'[àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ]',s,re.I):return True
low=' '+s.lower()+' '
return any(w in low for w in [' và ',' của ',' người ',' tại ',' trong ',' với ',' không ',' được ',' công an ',' bệnh viện ',' học sinh ',' tài xế ',' bóng đá ',' tin tức ',' sức khỏe '])
def _translate_title_vi(title):
title=clean(title)
if not title or _looks_vietnamese(title):return title
with _translate_lock:
db=_load_title_cache()
if title in db:return db[title]
vi=title
try:
r=requests.get('https://translate.googleapis.com/translate_a/single',params={'client':'gtx','sl':'auto','tl':'vi','dt':'t','q':title},headers=UA,timeout=8)
if r.status_code==200:
data=r.json();vi=''.join(part[0] for part in data[0] if part and part[0]).strip() or title
except Exception:pass
vi=clean(vi)
with _translate_lock:
db=_load_title_cache();db[title]=vi;_save_title_cache(db)
return vi
# ===== Hot topics / hashtags =====
def _keywords_from_title(title):
title=clean(re.sub(r'\s+-\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_WORDS]
phrases=[]
for n in (4,3,2):
for i in range(0,max(0,len(words)-n+1)):
ph=' '.join(words[i:i+n]).strip()
if len(ph)>=8:phrases.append(ph)
if words:phrases.append(' '.join(words[:5]))
return phrases[:4]
def _hot_topics():
now=time.time()
if _HOT_CACHE['d'] and now-_HOT_CACHE['t']<900:return _HOT_CACHE['d']
topics=[];seen=set()
feeds=[
'https://news.google.com/rss?hl=vi&gl=VN&ceid=VN:vi',
'https://news.google.com/rss/headlines/section/topic/NATION?hl=vi&gl=VN&ceid=VN:vi',
'https://news.google.com/rss/headlines/section/topic/BUSINESS?hl=vi&gl=VN&ceid=VN:vi',
'https://news.google.com/rss/headlines/section/topic/SPORTS?hl=vi&gl=VN&ceid=VN:vi',
'https://news.google.com/rss/headlines/section/topic/TECHNOLOGY?hl=vi&gl=VN&ceid=VN:vi'
]
for feed in feeds:
try:
r=requests.get(feed,headers=UA,timeout=10);r.encoding='utf-8'
soup=BeautifulSoup(r.text,'xml')
for it in soup.find_all('item')[:15]:
title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
for kw in _keywords_from_title(title):
key=kw.lower()
if key not in seen and len(kw)<=60:
seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
if len(topics)>=24:break
if len(topics)>=24:break
except Exception:pass
if len(topics)>=24:break
for kw in ['AI trong giáo dục','World Cup 2026','kinh tế Việt Nam','biến đổi khí hậu','giá vàng','bóng đá Việt Nam','an ninh mạng','xe điện','sức khỏe tinh thần','thị trường chứng khoán']:
if kw.lower() not in seen:topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
_HOT_CACHE.update({'t':now,'d':topics[:24]})
return _HOT_CACHE['d']
@app.get('/api/hot_topics')
def api_hot_topics():return JSONResponse({'topics':_hot_topics()})
# ===== Topic web research =====
def _unwrap_ddg_href(href):
if not href:return ''
if href.startswith('//duckduckgo.com/l/?') or 'duckduckgo.com/l/?' in href:
qs=parse_qs(urlparse('https:'+href if href.startswith('//') else href).query)
return unquote(qs.get('uddg',[''])[0])
return href
def _ddg_search(query, limit=10):
items=[];seen=set()
try:
url='https://html.duckduckgo.com/html/?q='+quote(query)
r=requests.get(url,headers=UA,timeout=14);r.encoding='utf-8'
soup=BeautifulSoup(r.text,'lxml')
for res in soup.select('.result'):
a=res.select_one('.result__title a') or res.find('a',href=True)
if not a:continue
link=_unwrap_ddg_href(a.get('href',''));title=clean(a.get_text(' ',strip=True));snippet=clean((res.select_one('.result__snippet') or res).get_text(' ',strip=True))
if not link.startswith('http') or link in seen:continue
if any(bad in link for bad in ['duckduckgo.com','youtube.com','facebook.com','tiktok.com','twitter.com','x.com']):continue
seen.add(link);items.append({'title':title,'url':link,'source':_domain(link),'snippet':snippet})
if len(items)>=limit:break
except Exception:pass
return items
def _google_news_items(topic, limit=8):
items=[];seen=set()
try:
rss='https://news.google.com/rss/search?q='+quote(topic)+'&hl=vi&gl=VN&ceid=VN:vi'
r=requests.get(rss,headers=UA,timeout=12);r.encoding='utf-8'
soup=BeautifulSoup(r.text,'xml')
for it in soup.find_all('item')[:limit*2]:
title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
link=clean(it.find('link').get_text(strip=True) if it.find('link') else '')
src=clean(it.find('source').get_text(' ',strip=True) if it.find('source') else _domain(link))
if title and link and link not in seen:
seen.add(link);items.append({'title':title,'url':link,'source':src,'snippet':''})
if len(items)>=limit:break
except Exception:pass
return items
def _candidate_urls(topic):
seen=set();items=[]
queries=[topic+' tin tức Việt Nam', topic+' phân tích bối cảnh', topic+' site:vnexpress.net OR site:dantri.com.vn OR site:vietnamnet.vn']
for q in queries:
for it in _ddg_search(q,8):
if it['url'] not in seen:
seen.add(it['url']);items.append(it)
if len(items)>=12:break
for site in TRUSTED_SITES[:8]:
for it in _ddg_search(f'{topic} site:{site}',3):
if it['url'] not in seen:
seen.add(it['url']);items.append(it)
for it in _google_news_items(topic,8):
if it['url'] not in seen:
seen.add(it['url']);items.append(it)
return items[:24]
def _extract_article_text_bs(url, max_chars=9000):
try:
r=requests.get(url,headers=UA,timeout=16,allow_redirects=True)
if r.status_code>=400:return ''
r.encoding='utf-8';soup=BeautifulSoup(r.text,'lxml')
for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe','svg']):tag.decompose()
candidates=[]
for sel in ['article','main','.article-content','.detail-content','.singular-content','.fck_detail','.content-detail','.entry-content','.story-body','.knc-content']:
el=soup.select_one(sel)
if el:candidates.append(el)
if not candidates:candidates=[soup.body or soup]
best=max(candidates,key=lambda el:len(el.find_all('p')) if el else 0)
ps=[]
for el in best.find_all(['p','h2','h3'],recursive=True):
t=clean(el.get_text(' ',strip=True))
if len(t)>45 and not any(x in t.lower() for x in ['đăng ký nhận tin','theo dõi chúng tôi','chuyên mục','xem thêm','tin liên quan','advertisement']):ps.append(t)
if sum(len(x) for x in ps)>max_chars:break
return '\n'.join(ps)[:max_chars]
except Exception:return ''
def _jina_read_text(url, max_chars=9000):
try:
ju='https://r.jina.ai/http://'+url
r=requests.get(ju,headers=UA,timeout=28);r.encoding='utf-8'
if r.status_code!=200 or not r.text:return ''
lines=[]
for ln in r.text.splitlines():
t=clean(ln)
if not t or t.startswith(('Title:','URL Source:','Published Time:','Markdown Content:','Image:','Description:')):continue
if len(t)>45:lines.append(t)
if sum(len(x) for x in lines)>max_chars:break
return '\n'.join(lines)[:max_chars]
except Exception:return ''
def _scrape_article_text(url, max_chars=9000):
text=_extract_article_text_bs(url,max_chars)
if len(text)<350:text=_jina_read_text(url,max_chars)
return text
def _score_relevance(topic, title, text, snippet=''):
keys=[w.lower() for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',topic) if len(w)>2 and w.lower() not in STOP_WORDS]
hay=(title+' '+snippet+' '+text[:2500]).lower()
if not keys:return 1
return sum(1 for k in keys if k in hay)
def _web_research_context(topic):
now=time.time();key=topic.lower().strip()
if key in _TOPIC_CACHE and now-_TOPIC_CACHE[key]['t']<900:return _TOPIC_CACHE[key]['d']
items=_candidate_urls(topic)
crawled=[]
for it in items:
text=_scrape_article_text(it['url'],9000)
rel=_score_relevance(topic,it.get('title',''),text,it.get('snippet',''))
if text and len(text)>300 and rel>0:
crawled.append({**it,'text':text,'rel':rel})
elif it.get('snippet') and rel>0:
crawled.append({**it,'text':it['snippet'],'rel':rel,'snippet_only':True})
crawled=sorted(crawled,key=lambda x:(x.get('rel',0),len(x.get('text',''))),reverse=True)[:6]
blocks=[];sources=[]
for it in crawled:
label='ĐOẠN MÔ TẢ TỪ KẾT QUẢ TÌM KIẾM' if it.get('snippet_only') else 'NỘI DUNG BÀI VIẾT ĐÃ CRAWL'
blocks.append(f"NGUỒN: {it['source']}\nTIÊU ĐỀ: {it['title']}\n{label}:\n{it['text'][:8500]}")
sources.append({'title':it['title'],'url':it['url'],'via':it['source']})
data={'context':'\n\n---\n\n'.join(blocks),'sources':sources[:8],'count':len(blocks)}
_TOPIC_CACHE[key]={'t':now,'d':data}
return data
def _topic_image(topic):
try:return f5.base.pollinations_image_url(topic)
except Exception:return 'https://image.pollinations.ai/prompt/'+quote('Vietnamese editorial illustration, '+topic)+'?width=1024&height=576&nologo=true'
@app.get('/api/topic_sources')
def api_topic_sources(topic:str=Query(...)):
data=_web_research_context(clean(topic))
return JSONResponse({'count':data.get('count',0),'sources':data.get('sources',[]),'has_context':bool(data.get('context'))})
@app.post('/api/topic_post')
async def topic_post_synthesis(request:Request):
body=await request.json();topic=clean(body.get('topic',''))
if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
img=_topic_image(topic);research=_web_research_context(topic);context=research.get('context','');sources=research.get('sources',[])
if not context or research.get('count',0)==0:
return JSONResponse({'error':'Không tìm/crawl được đủ nội dung về chủ đề này. Hãy thử chủ đề cụ thể hơn hoặc dùng hashtag gợi ý.'},status_code=422)
prompt=f"""Bạn là biên tập viên VNEWS. Người dùng chọn chủ đề: "{topic}".
Dưới đây là NỘI DUNG các bài viết/đoạn mô tả đã crawl từ internet. Hãy đọc hiểu và TỔNG HỢP thành MỘT BÀI VIẾT HOÀN CHỈNH. Tuyệt đối không bê nguyên văn, không xếp danh sách tiêu đề thành bài viết, không viết kiểu trả lời chat.
DỮ LIỆU CRAWL:
{context[:30000]}
Yêu cầu bắt buộc:
- Viết bằng tiếng Việt, văn phong báo điện tử/tạp chí.
- Tiêu đề mới, rõ, hấp dẫn.
- Sapo 2-3 câu nêu vấn đề chính.
- 5-8 đoạn nội dung tổng hợp: bối cảnh, diễn biến/khái niệm, phân tích, tác động, điểm cần lưu ý.
- Dùng thông tin từ nội dung đã crawl để tổng hợp ý; nếu chỉ có mô tả tìm kiếm thì viết thận trọng.
- KHÔNG liệt kê các tiêu đề nguồn. KHÔNG mở đầu bằng "Dưới đây là" hay "Tôi sẽ".
- Cuối bài thêm mục "Nguồn tham khảo" gồm tên nguồn ngắn gọn.
"""
text=await f5.base.qwen_generate(prompt,image_url=img,max_tokens=2800)
if not text or len(text)<500:
parts=[]
for block in context.split('---'):
body=block.split('NỘI DUNG BÀI VIẾT ĐÃ CRAWL:')[-1].split('ĐOẠN MÔ TẢ TỪ KẾT QUẢ TÌM KIẾM:')[-1].strip()
if len(body)>120:parts.append(body)
joined='\n\n'.join(parts)[:8500]
text=(f"{topic}: những điểm chính cần biết\n\n{topic} đang thu hút sự chú ý vì liên quan đến nhiều khía cạnh thực tế. Tổng hợp từ các nội dung thu thập được, có thể nhìn vấn đề qua bối cảnh, tác động và những điểm cần theo dõi.\n\n"+joined+"\n\nNguồn tham khảo: "+', '.join(sorted({s.get('via','') for s in sources if s.get('via')})))
post=f5.base.make_post(topic,text,img,'','topic_web_synthesis',sources=[s for s in sources if s.get('url')]);post['images']=[img]
posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts)
return JSONResponse({'post':post})
# ===== Stable newest Dantri/SKDS Shorts =====
def _yt_ytdlp(handle,count=30):
try:
import yt_dlp
urls=[f'https://www.youtube.com/@{handle}/shorts',f'https://www.youtube.com/@{handle}/videos']
out=[];seen=set();opts={'quiet':True,'extract_flat':True,'skip_download':True,'playlistend':count,'ignoreerrors':True,'no_warnings':True,'extractor_args':{'youtube':{'player_client':['web']}}}
for url in urls:
with yt_dlp.YoutubeDL(opts) as ydl:info=ydl.extract_info(url,download=False)
for e in (info or {}).get('entries') or []:
vid=e.get('id') or ''
if not re.match(r'^[A-Za-z0-9_-]{11}$',vid) or vid in seen:continue
title=e.get('title') or 'YouTube Short'
if url.endswith('/videos') and '#short' not in title.lower() and 'shorts' not in title.lower():continue
seen.add(vid);out.append({'title':title,'link':'https://www.youtube.com/watch?v='+vid,'img':'https://i.ytimg.com/vi/'+vid+'/hqdefault.jpg','source':'yt','id':vid,'channel':handle})
if len(out)>=count:break
if len(out)>=count:break
return out
except Exception:return []
def _yt_html(handle,count=30):
out=[];seen=set()
for suffix in ['shorts','videos']:
try:
r=requests.get(f'https://www.youtube.com/@{handle}/{suffix}',headers=UA,timeout=15);html=r.text
for m in re.finditer(r'"videoId":"([A-Za-z0-9_-]{11})"',html):
vid=m.group(1)
if vid in seen:continue
snip=html[max(0,m.start()-1200):m.start()+2200];title='YouTube Short'
mt=re.search(r'"title":\{"runs":\[\{"text":"([^"]+)"',snip) or re.search(r'"accessibilityText":"([^"]+)"',snip)
if mt:title=clean(mt.group(1).replace('\\n',' '))
if suffix=='videos' and '#short' not in title.lower() and 'shorts' not in title.lower():continue
seen.add(vid);out.append({'title':title,'link':'https://www.youtube.com/watch?v='+vid,'img':'https://i.ytimg.com/vi/'+vid+'/hqdefault.jpg','source':'yt','id':vid,'channel':handle})
if len(out)>=count:break
except Exception:pass
if len(out)>=count:break
return out[:count]
def _fallback_shorts():
try:return f5._fallback_shorts()
except Exception:return []
@app.get('/api/shorts')
def api_shorts_final6(refresh:int=Query(default=0)):
now=time.time()
if not refresh and _SHORTS_CACHE_FINAL6['d'] and now-_SHORTS_CACHE_FINAL6['t']<600:return JSONResponse(_SHORTS_CACHE_FINAL6['d'])
raw=[]
for h in YOUTUBE_HANDLES:raw.extend(_yt_ytdlp(h,30) or _yt_html(h,30))
raw.extend(_fallback_shorts())
seen=set();out=[]
for v in raw:
vid=v.get('id') or ''
if not vid:
m=re.search(r'(?:v=|shorts/|youtu\.be/)([A-Za-z0-9_-]{11})',v.get('link',''));vid=m.group(1) if m else ''
title=_translate_title_vi(v.get('title') or 'YouTube Short');key=vid or re.sub(r'\W+','',title.lower())[:80]
if not key or key in seen:continue
seen.add(key);item=dict(v);item['id']=vid;item['title']=title
if vid:item['link']='https://www.youtube.com/watch?v='+vid;item['img']='https://i.ytimg.com/vi/'+vid+'/hqdefault.jpg'
item['source']='yt';out.append(item)
if len(out)>=40:break
_SHORTS_CACHE_FINAL6.update({'t':now,'d':out})
return JSONResponse(out)
FINAL6_INJECT=r'''
'''
@app.get('/')
async def index_final6():
html=f5.f4.f3.f2.f1._load_index_html()
body=getattr(rt.old,'PATCH_INJECT','')+f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT+FINAL6_INJECT
return HTMLResponse(html.replace('