"""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('',body+'\n') if '' in html else html+body) # ===== FINAL6B: Vietnam hot hashtags + reliable VN RSS/source retrieval ===== VN_RSS_FEEDS = [ ('VnExpress Thời sự','https://vnexpress.net/rss/thoi-su.rss'), ('VnExpress Thế giới','https://vnexpress.net/rss/the-gioi.rss'), ('VnExpress Kinh doanh','https://vnexpress.net/rss/kinh-doanh.rss'), ('VnExpress Công nghệ','https://vnexpress.net/rss/so-hoa.rss'), ('VnExpress Thể thao','https://vnexpress.net/rss/the-thao.rss'), ('VnExpress Giải trí','https://vnexpress.net/rss/giai-tri.rss'), ('VnExpress Sức khỏe','https://vnexpress.net/rss/suc-khoe.rss'), ('VnExpress Giáo dục','https://vnexpress.net/rss/giao-duc.rss'), ('Dân trí Xã hội','https://dantri.com.vn/rss/xa-hoi.rss'), ('Dân trí Thế giới','https://dantri.com.vn/rss/the-gioi.rss'), ('Dân trí Kinh doanh','https://dantri.com.vn/rss/kinh-doanh.rss'), ('Dân trí Sức khỏe','https://dantri.com.vn/rss/suc-khoe.rss'), ('Dân trí Thể thao','https://dantri.com.vn/rss/the-thao.rss'), ('Dân trí Công nghệ','https://dantri.com.vn/rss/suc-manh-so.rss'), ('Vietnamnet Thời sự','https://vietnamnet.vn/thoi-su.rss'), ('Vietnamnet Kinh doanh','https://vietnamnet.vn/kinh-doanh.rss'), ('Vietnamnet Công nghệ','https://vietnamnet.vn/cong-nghe.rss'), ('Vietnamnet Thể thao','https://vietnamnet.vn/the-thao.rss'), ] def _fetch_rss_items(feed_name, feed_url, max_items=15): items=[] try: r=requests.get(feed_url,headers=UA,timeout=10);r.encoding='utf-8' soup=BeautifulSoup(r.text,'xml') for it in soup.find_all('item')[:max_items]: 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 '') desc=it.find('description').get_text(' ',strip=True) if it.find('description') else '' desc_txt=clean(BeautifulSoup(desc,'lxml').get_text(' ',strip=True)) if title and link: items.append({'title':title,'url':link,'source':feed_name,'snippet':desc_txt}) except Exception:pass return items def _vn_rss_pool(): now=time.time();key='vn_rss_pool' if key in _TOPIC_CACHE and now-_TOPIC_CACHE[key]['t']<600:return _TOPIC_CACHE[key]['d'] pool=[];seen=set() for name,url in VN_RSS_FEEDS: for it in _fetch_rss_items(name,url,12): if it['url'] not in seen: seen.add(it['url']);pool.append(it) _TOPIC_CACHE[key]={'t':now,'d':pool} return pool def _topic_tokens(topic): toks=[w.lower() for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',topic or '') if len(w)>1] return [t for t in toks if t not in STOP_WORDS] def _score_topic_item(topic,item): toks=_topic_tokens(topic) hay=(item.get('title','')+' '+item.get('snippet','')+' '+item.get('source','')).lower() if not toks:return 0 score=0 for t in toks: if t in hay:score+=2 if len(t)>3 else 1 phrase=topic.lower().strip() if phrase and phrase in hay:score+=8 return score # Override: hashtags must be Việt Nam-focused, using VN news RSS directly. def _hot_topics(): now=time.time() if _HOT_CACHE['d'] and now-_HOT_CACHE['t']<600:return _HOT_CACHE['d'] pool=_vn_rss_pool() freq={};display={} for it in pool[:180]: title=re.sub(r'\s+-\s+.*$','',it.get('title','')) # Extract compact Vietnamese hot phrases from current VN headlines. kws=[] # quoted/name phrases first for m in re.findall(r'([A-ZĐÀ-Ỹ][A-Za-zÀ-ỹ0-9]+(?:\s+[A-ZĐÀ-ỸA-Za-zÀ-ỹ0-9][A-Za-zÀ-ỹ0-9]+){1,4})',title): if len(m)>=6:kws.append(m) kws += _keywords_from_title(title) for kw in kws[:5]: kw=clean(kw) words=[w for w in kw.split() if w.lower() not in STOP_WORDS] if len(words)<2:continue kw=' '.join(words[:5]) if len(kw)<6 or len(kw)>55:continue key=kw.lower() freq[key]=freq.get(key,0)+1 display[key]=kw ranked=sorted(freq.items(),key=lambda x:x[1],reverse=True) topics=[];seen=set() for key,_ in ranked: kw=display[key] if key in seen:continue seen.add(key) label='#'+re.sub(r'\s+','',kw.title()) topics.append({'label':label,'topic':kw}) if len(topics)>=24:break # VN fallback, not generic global. for kw in ['Giá vàng trong nước','Bão và mưa lũ','Bóng đá Việt Nam','Kinh tế Việt Nam','AI tại Việt Nam','Giá xăng dầu','Thị trường chứng khoán Việt Nam','Tuyển Việt Nam','Sức khỏe cộng đồng','An ninh mạng Việt Nam']: 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'] def _candidate_urls(topic): seen=set();items=[] # 1) VN RSS pool relevance is most reliable and has direct URLs. scored=[] for it in _vn_rss_pool(): sc=_score_topic_item(topic,it) if sc>0:scored.append((sc,it)) for sc,it in sorted(scored,key=lambda x:x[0],reverse=True)[:12]: if it['url'] not in seen: seen.add(it['url']);items.append(it) # 2) Search trusted web if RSS not enough. queries=[topic+' Việt Nam tin tức',topic+' phân tích Việt Nam',topic+' mới nhất'] 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)>=14:break # 3) Google News as supplemental titles/direct links. for it in _google_news_items(topic,10): if it['url'] not in seen: seen.add(it['url']);items.append(it) return items[:24] def _web_research_context(topic): now=time.time();key='ctx2:'+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','')) or _score_topic_item(topic,it) # If RSS item has good snippet, keep it even when full text blocks. if text and len(text)>300 and rel>0: crawled.append({**it,'text':text,'rel':rel}) elif it.get('snippet') and len(it['snippet'])>120 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)[:7] blocks=[];sources=[] for it in crawled: label='ĐOẠN MÔ TẢ TỪ RSS/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 # ===== FINAL6C: FAST topic generation (RSS cache first, no slow full-page crawling) ===== import asyncio _FAST_TOPIC_CACHE={} FAST_RSS_FEEDS=[ ('VnExpress','https://vnexpress.net/rss/tin-moi-nhat.rss'), ('VnExpress Thời sự','https://vnexpress.net/rss/thoi-su.rss'), ('VnExpress Thế giới','https://vnexpress.net/rss/the-gioi.rss'), ('VnExpress Kinh doanh','https://vnexpress.net/rss/kinh-doanh.rss'), ('VnExpress Công nghệ','https://vnexpress.net/rss/so-hoa.rss'), ('VnExpress Thể thao','https://vnexpress.net/rss/the-thao.rss'), ('Dân trí','https://dantri.com.vn/rss/home.rss'), ('Dân trí Xã hội','https://dantri.com.vn/rss/xa-hoi.rss'), ('Dân trí Kinh doanh','https://dantri.com.vn/rss/kinh-doanh.rss'), ('Dân trí Thể thao','https://dantri.com.vn/rss/the-thao.rss'), ('Dân trí Công nghệ','https://dantri.com.vn/rss/suc-manh-so.rss'), ('Vietnamnet','https://vietnamnet.vn/rss/tin-moi-nhat.rss'), ('Vietnamnet Thời sự','https://vietnamnet.vn/thoi-su.rss'), ('Vietnamnet Kinh doanh','https://vietnamnet.vn/kinh-doanh.rss'), ('Vietnamnet Công nghệ','https://vietnamnet.vn/cong-nghe.rss'), ('Vietnamnet Thể thao','https://vietnamnet.vn/the-thao.rss'), ] def _fast_fetch_rss(feed_name, feed_url, max_items=20): items=[] try: r=requests.get(feed_url,headers=UA,timeout=6);r.encoding='utf-8' soup=BeautifulSoup(r.text,'xml') for it in soup.find_all('item')[:max_items]: 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 '') desc_raw=it.find('description').get_text(' ',strip=True) if it.find('description') else '' desc=clean(BeautifulSoup(desc_raw,'lxml').get_text(' ',strip=True)) if title and link: items.append({'title':title,'url':link,'source':feed_name,'snippet':desc}) except Exception:pass return items def _fast_rss_pool(): now=time.time();key='fast_rss_pool' if key in _FAST_TOPIC_CACHE and now-_FAST_TOPIC_CACHE[key]['t']<600:return _FAST_TOPIC_CACHE[key]['d'] pool=[];seen=set() # Sequential with short timeouts is predictable; RSS is small. for name,url in FAST_RSS_FEEDS: for it in _fast_fetch_rss(name,url,16): if it['url'] not in seen: seen.add(it['url']);pool.append(it) _FAST_TOPIC_CACHE[key]={'t':now,'d':pool} return pool def _fast_topic_tokens(topic): toks=[w.lower() for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',topic or '') if len(w)>1] return [t for t in toks if t not in STOP_WORDS] def _fast_score(topic,item): toks=_fast_topic_tokens(topic) hay=(item.get('title','')+' '+item.get('snippet','')+' '+item.get('source','')).lower() if not toks:return 0 score=0 for t in toks: if t in hay:score+=3 if len(t)>3 else 1 phrase=topic.lower().strip() if phrase and phrase in hay:score+=12 return score def _fast_sources(topic, limit=8): pool=_fast_rss_pool() scored=[] for it in pool: sc=_fast_score(topic,it) if sc>0:scored.append((sc,it)) scored=sorted(scored,key=lambda x:(x[0],len(x[1].get('snippet',''))),reverse=True) out=[];seen=set() for sc,it in scored: if it['url'] in seen:continue seen.add(it['url']);out.append({**it,'score':sc}) if len(out)>=limit:break # If topic too narrow and no match, use top latest from VN RSS as weak context instead of slow crawling. if not out: out=pool[:min(limit,8)] return out def _fast_context(topic): now=time.time();key='fast_ctx:'+topic.lower().strip() if key in _FAST_TOPIC_CACHE and now-_FAST_TOPIC_CACHE[key]['t']<600:return _FAST_TOPIC_CACHE[key]['d'] sources=_fast_sources(topic,8) blocks=[];src=[] for it in sources: text=(it.get('snippet') or '').strip() # Use title + RSS description only: fast and reliable. blocks.append(f"NGUỒN: {it.get('source','')}\nTIÊU ĐỀ: {it.get('title','')}\nTÓM TẮT RSS:\n{text}") src.append({'title':it.get('title',''),'url':it.get('url',''),'via':it.get('source','')}) data={'context':'\n\n---\n\n'.join(blocks),'sources':src,'count':len(blocks)} _FAST_TOPIC_CACHE[key]={'t':now,'d':data} return data def _fallback_fast_article(topic, sources): lines=[] for s in sources[:7]: title=s.get('title','') if title:lines.append(title) body='\n'.join('• '+x for x in lines[:7]) vias=', '.join(sorted({s.get('via','') for s in sources if s.get('via')})) return (f"{topic}: những điểm đáng chú ý\n\n" f"{topic} đang là chủ đề được quan tâm trong dòng tin tức hiện nay. Dựa trên các nguồn tin mới nhất, có thể tổng hợp nhanh một số điểm nổi bật để người đọc nắm bối cảnh và theo dõi tiếp diễn biến.\n\n" f"Các nguồn tin liên quan cho thấy chủ đề này gắn với những diễn biến sau:\n{body}\n\n" f"Nhìn chung, đây là vấn đề cần được theo dõi theo nhiều góc độ: bối cảnh, tác động thực tế, phản ứng của các bên liên quan và những thông tin cập nhật tiếp theo. Người đọc nên đối chiếu thêm các nguồn chính thống khi cần quyết định hoặc đánh giá chi tiết.\n\n" f"Nguồn tham khảo: {vias}") # Remove previous slow topic routes and register fast versions last. 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 {('/api/topic_post','POST'),('/api/topic_sources','GET')})] @app.get('/api/topic_sources') def api_topic_sources_fast(topic:str=Query(...)): data=_fast_context(clean(topic)) return JSONResponse({'count':data.get('count',0),'sources':data.get('sources',[]),'has_context':bool(data.get('context')),'mode':'fast_rss'}) @app.post('/api/topic_post') async def topic_post_fast(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=_fast_context(topic);context=research.get('context','');sources=research.get('sources',[]) prompt=f"""Bạn là biên tập viên VNEWS. Hãy viết MỘT BÀI VIẾT HOÀN CHỈNH bằng tiếng Việt về chủ đề: {topic} Dữ liệu nhanh từ RSS nguồn Việt Nam: {context[:12000]} Yêu cầu: - Không liệt kê tiêu đề nguồn thành bài viết. - Tổng hợp thành bài báo/tạp chí hoàn chỉnh. - Có tiêu đề mới, sapo 2-3 câu, 4-6 đoạn phân tích/bối cảnh/tác động. - Diễn đạt lại, không sao chép nguyên văn. - Nếu dữ liệu ít, viết thận trọng và nêu các điểm cần theo dõi. - Cuối bài có mục Nguồn tham khảo. """ text=None try: text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1300),timeout=28) except Exception: text=None if not text or len(text)<350: text=_fallback_fast_article(topic,sources) post=f5.base.make_post(topic,text,img,'','topic_fast_rss',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,'mode':'fast_rss','sources_count':len(sources)}) # ===== FINAL6D: FAST HOME LOAD ===== _FAST_HOME_CACHE={"t":0,"d":[]} _FAST_DT_CACHE={"t":0,"d":[]} _FAST_VNEGO_CACHE={"t":0,"d":[]} _FAST_HL_CACHE={"t":0,"d":[]} def _rss_articles_fast(feed_url, group, source='vne', limit=6): out=[] try: r=requests.get(feed_url,headers=UA,timeout=4);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 '') desc_raw=it.find('description').get_text(' ',strip=True) if it.find('description') else '' ds=BeautifulSoup(desc_raw,'lxml') im=ds.find('img'); img=im.get('src','') if im else '' desc=clean(ds.get_text(' ',strip=True))[:160] if title and link: out.append({'title':title,'link':link,'img':img,'summary':desc,'source':source,'group':group}) if len(out)>=limit:break except Exception:pass return out def _fast_homepage(): now=time.time() if _FAST_HOME_CACHE['d'] and now-_FAST_HOME_CACHE['t']<600:return _FAST_HOME_CACHE['d'] feeds=[('Thời Sự','https://vnexpress.net/rss/thoi-su.rss'),('Thế Giới','https://vnexpress.net/rss/the-gioi.rss'),('Kinh Doanh','https://vnexpress.net/rss/kinh-doanh.rss'),('Công Nghệ','https://vnexpress.net/rss/so-hoa.rss'),('Thể Thao','https://vnexpress.net/rss/the-thao.rss'),('Giải Trí','https://vnexpress.net/rss/giai-tri.rss'),('Sức Khỏe','https://vnexpress.net/rss/suc-khoe.rss'),('Giáo Dục','https://vnexpress.net/rss/giao-duc.rss'),('Pháp Luật','https://vnexpress.net/rss/phap-luat.rss'),('Du Lịch','https://vnexpress.net/rss/du-lich.rss')] arts=[] try: from concurrent.futures import ThreadPoolExecutor, as_completed with ThreadPoolExecutor(max_workers=6) as ex: futs=[ex.submit(_rss_articles_fast,u,g,'vne',6) for g,u in feeds] for f in as_completed(futs,timeout=7): try:arts.extend(f.result() or []) except Exception:pass except Exception: for g,u in feeds[:5]:arts.extend(_rss_articles_fast(u,g,'vne',4)) if arts:_FAST_HOME_CACHE.update({'t':now,'d':arts}) return _FAST_HOME_CACHE['d'] or arts def _fast_dantri_hot(): now=time.time() if _FAST_DT_CACHE['d'] and now-_FAST_DT_CACHE['t']<900:return _FAST_DT_CACHE['d'] data=_rss_articles_fast('https://dantri.com.vn/rss/home.rss','Tin Nổi Bật','dantri',12) if data:_FAST_DT_CACHE.update({'t':now,'d':data}) return data def _fast_vnego(): now=time.time() if _FAST_VNEGO_CACHE['d'] and now-_FAST_VNEGO_CACHE['t']<900:return _FAST_VNEGO_CACHE['d'] out=[] try: r=requests.get('https://vnexpress.net/vne-go',headers=UA,timeout=4);r.encoding='utf-8' soup=BeautifulSoup(r.text,'lxml');seen=set() for a in soup.find_all('a',href=True): href=a.get('href','');title=clean(a.get('title','') or a.get_text(' ',strip=True)) if not title or len(title)<8 or not href.startswith('http') or href in seen:continue if '/vne-go' not in href and '/video/' not in href:continue seen.add(href);img='';im=a.find('img') or (a.parent.find('img') if a.parent else None) if im:img=im.get('data-src') or im.get('src','') out.append({'title':title,'link':href,'img':img,'source':'vne-video'}) if len(out)>=10:break except Exception:pass _FAST_VNEGO_CACHE.update({'t':now,'d':out}) return out def _fast_highlights(): now=time.time() if _FAST_HL_CACHE['d'] and now-_FAST_HL_CACHE['t']<900:return _FAST_HL_CACHE['d'] _FAST_HL_CACHE.update({'t':now,'d':[]}) return [] for _p in ['/api/homepage','/api/dantri_hot','/api/vne_video','/api/highlights']: app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)==_p and 'GET' in getattr(r,'methods',set()))] @app.get('/api/homepage') def api_homepage_fast():return JSONResponse(_fast_homepage()) @app.get('/api/dantri_hot') def api_dantri_hot_fast():return JSONResponse(_fast_dantri_hot()) @app.get('/api/vne_video') def api_vne_video_fast():return JSONResponse(_fast_vnego()) @app.get('/api/highlights') def api_highlights_fast():return JSONResponse(_fast_highlights()) FINAL6_FAST_HOME_INJECT = """ """ app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))] @app.get('/') async def index_final6_fast_home(): 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+FINAL6_FAST_HOME_INJECT return HTMLResponse(html.replace('',body+'\n') if '' in html else html+body) # ===== FINAL6E: SHOW SOURCE CONTENTS IN TOPIC ARTICLE ===== def _extract_source_details_from_context(context, sources): details=[] # Map source urls by title for URL/via enrichment src_by_title={clean(s.get('title','')):s for s in (sources or [])} for block in (context or '').split('---'): block=block.strip() if not block:continue via='';title='';content='' m=re.search(r'NGUỒN:\s*(.*)',block) if m:via=clean(m.group(1)) m=re.search(r'TIÊU ĐỀ:\s*(.*)',block) if m:title=clean(m.group(1)) if 'NỘI DUNG BÀI VIẾT ĐÃ CRAWL:' in block: content=block.split('NỘI DUNG BÀI VIẾT ĐÃ CRAWL:',1)[1] elif 'TÓM TẮT RSS:' in block: content=block.split('TÓM TẮT RSS:',1)[1] elif 'ĐOẠN MÔ TẢ' in block: content=re.split(r'ĐOẠN MÔ TẢ[^:]*:',block,1)[-1] content=clean(content) if not title and not content:continue s=src_by_title.get(title,{}) details.append({'title':title or s.get('title','Nguồn tham khảo'),'url':s.get('url',''),'via':via or s.get('via',''),'content':content[:1800]}) if len(details)>=8:break return details # Remove prior topic endpoint and register one that stores source_details in post. app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/api/topic_post' and 'POST' in getattr(r,'methods',set()))] @app.post('/api/topic_post') async def topic_post_with_source_contents(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=_fast_context(topic) if '_fast_context' in globals() else _web_research_context(topic) context=research.get('context','');sources=research.get('sources',[]) details=_extract_source_details_from_context(context,sources) if not context or not details: 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) source_brief='\n\n'.join([f"[{i+1}] {d.get('title','')} ({d.get('via','')})\n{d.get('content','')[:1400]}" for i,d in enumerate(details)]) prompt=f"""Bạn là biên tập viên VNEWS. Hãy viết MỘT BÀI VIẾT HOÀN CHỈNH bằng tiếng Việt về chủ đề: {topic} Dưới đây là nội dung từng nguồn đã thu thập. Hãy tổng hợp ý chính, không sao chép nguyên văn, không biến các tiêu đề thành danh sách. NỘI DUNG NGUỒN: {source_brief[:18000]} Yêu cầu: - Tiêu đề mới, rõ, hấp dẫn. - Sapo 2-3 câu. - 5-8 đoạn phân tích/bối cảnh/tác động/điểm cần lưu ý. - Không dùng câu "Dưới đây là" hoặc "Tôi sẽ". - Cuối bài có mục "Nguồn tham khảo" nêu tên nguồn. """ text=None try: import asyncio text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1700),timeout=35) except Exception: text=None if not text or len(text)<350: bullets='\n'.join([f"• {d['title']}: {d.get('content','')[:320]}" for d in details[:6]]) vias=', '.join(sorted({d.get('via','') for d in details if d.get('via')})) text=(f"{topic}: tổng hợp những điểm đáng chú ý\n\n" f"{topic} đang được nhiều nguồn tin đề cập với các góc nhìn khác nhau. Dưới đây là phần tổng hợp nhanh từ những nội dung đã thu thập được.\n\n" f"{bullets}\n\n" f"Nhìn chung, chủ đề này cần được theo dõi thêm ở các khía cạnh: bối cảnh, tác động thực tế, phản ứng của các bên liên quan và các diễn biến mới trong thời gian tới.\n\n" f"Nguồn tham khảo: {vias}") post=f5.base.make_post(topic,text,img,'','topic_fast_rss_with_sources',sources=[s for s in sources if s.get('url')]) post['images']=[img] post['source_details']=details posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts) return JSONResponse({'post':post,'mode':'fast_rss_with_source_details','sources_count':len(details)}) FINAL6E_INJECT = """ '''