"""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 = """ """ # Override root one last time to append source-details UI. 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_source_details(): 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+globals().get('FINAL6_FAST_HOME_INJECT','')+FINAL6E_INJECT return HTMLResponse(html.replace('',body+'\n') if '' in html else html+body) # ===== FINAL6F: CLEAN TOPIC OUTPUT + IN-APP SOURCE READER ===== def _clean_generated_article(text, topic=''): """Remove prompt/instruction leakage from generated topic articles.""" text=str(text or '').strip() bad_patterns=[ r'^\s*[•\-]*\s*Hãy viết .*', r'^\s*[•\-]*\s*Dưới đây là .*', r'^\s*[•\-]*\s*Dữ liệu .*', r'^\s*[•\-]*\s*NỘI DUNG NGUỒN.*', r'^\s*[•\-]*\s*Yêu cầu\s*:.*', r'^\s*[•\-]*\s*Tiêu đề mới.*', r'^\s*[•\-]*\s*Sapo\s*2.*', r'^\s*[•\-]*\s*5\s*[-–]\s*8\s*đoạn.*', r'^\s*[•\-]*\s*Không dùng câu.*', r'^\s*[•\-]*\s*Cuối bài.*', r'^\s*[•\-]*\s*Không liệt kê.*', r'^\s*[•\-]*\s*Tổng hợp thành.*', r'^\s*[•\-]*\s*Diễn đạt lại.*', r'^\s*[•\-]*\s*Tuyệt đối.*', ] out=[] for ln in text.splitlines(): s=ln.strip() if not s: out.append(ln);continue if any(re.search(p,s,re.I) for p in bad_patterns): continue out.append(ln) cleaned='\n'.join(out).strip() # If model returned a markdown code/prompt-like block, keep content after first plausible title line. cleaned=re.sub(r'^(?:Bài viết|Nội dung bài viết)\s*[::]\s*','',cleaned,flags=re.I).strip() # Remove duplicated leading topic instruction if it appears inline. cleaned=re.sub(r'Hãy viết MỘT BÀI VIẾT HOÀN CHỈNH[^\n\.]*[\.\n]*','',cleaned,flags=re.I).strip() return cleaned or text def _source_article_data(url): try: r=requests.get(url,headers=UA,timeout=14);r.encoding='utf-8' soup=BeautifulSoup(r.text,'lxml') h1=soup.find('h1') ogt=soup.find('meta',property='og:title') ogd=soup.find('meta',property='og:description') ogi=soup.find('meta',property='og:image') title=clean(h1.get_text(' ',strip=True) if h1 else (ogt.get('content','') if ogt else '')) summary=clean(ogd.get('content','') if ogd else '') img=ogi.get('content','') if ogi else '' except Exception: title='';summary='';img='' text=_scrape_article_text(url,12000) body=[] for para in re.split(r'\n+',text or ''): para=clean(para) if len(para)>35: body.append({'type':'p','text':para}) if len(body)>=80:break if not title:title=url if not body and summary:body=[{'type':'p','text':summary}] return {'title':title,'summary':summary,'og_image':img,'body':body,'source':'topic-source','url':url} @app.get('/api/topic_source_article') def api_topic_source_article(url:str=Query(...)): if not url.startswith('http'): return JSONResponse({'error':'bad url'},status_code=400) return JSONResponse(_source_article_data(url)) # Override topic generation one last time with output cleaning and source details. 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_clean_final(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 '_extract_source_details_from_context' in globals() else [] if not details: # Build details directly from sources/snippets if helper unavailable or empty. for s in sources[:8]: details.append({'title':s.get('title',''),'url':s.get('url',''),'via':s.get('via',''),'content':s.get('excerpt','') or s.get('snippet','') or ''}) if not context and 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[:8])]) prompt=f"""Vai trò: biên tập viên VNEWS. Nhiệm vụ: viết một bài báo tiếng Việt hoàn chỉnh về chủ đề "{topic}" dựa trên các nguồn bên dưới. Nguồn thu thập: {source_brief[:18000]} Quy tắc biên tập: 1. Chỉ xuất bản bài viết cuối cùng, không nhắc lại yêu cầu, không liệt kê chỉ dẫn. 2. Không sao chép nguyên văn; hãy tổng hợp và diễn đạt lại. 3. Bài có tiêu đề, sapo, các đoạn phân tích/bối cảnh/tác động, và mục Nguồn tham khảo ngắn. 4. Không dùng các câu như "Dưới đây là", "Tôi sẽ", "Yêu cầu". """ 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.get('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ựa trên nội dung đã thu thập, có thể rút ra một số điểm chính để người đọc nắm nhanh bối cảnh.\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}") text=_clean_generated_article(text,topic) post=f5.base.make_post(topic,text,img,'','topic_clean_with_sources',sources=[s for s in sources if s.get('url')]) post['images']=[img] post['source_details']=details[:8] posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts) return JSONResponse({'post':post,'mode':'clean_with_source_details','sources_count':len(details)}) FINAL6F_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_clean_links(): 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+globals().get('FINAL6_FAST_HOME_INJECT','')+globals().get('FINAL6E_INJECT','')+FINAL6F_INJECT return HTMLResponse(html.replace('',body+'\n') if '' in html else html+body) # ===== FINAL6G: SELECTED FAST SOURCES ===== # Restrict hot hashtags + topic articles to the requested sources only: # Thethaovanhoa, Dantri, VTV, VnExpress, Vatvostudio, GenK, VNReview. SELECTED_SOURCE_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'), ('VTV','https://vtv.vn/rss/trang-chu.rss'), ('VTV Thời sự','https://vtv.vn/rss/thoi-su.rss'), ('VTV Công nghệ','https://vtv.vn/rss/cong-nghe.rss'), ('Thể thao văn hóa','https://thethaovanhoa.vn/rss/home.rss'), ('Thể thao văn hóa Bóng đá','https://thethaovanhoa.vn/rss/bong-da.rss'), ('GenK','https://genk.vn/home.rss'), ('GenK AI','https://genk.vn/ai.rss'), ('VNReview','https://vnreview.vn/rss/home.rss'), ('VNReview Công nghệ','https://vnreview.vn/rss/cong-nghe.rss'), ] SELECTED_HOMEPAGES=[ ('VTV','https://vtv.vn/'), ('Thể thao văn hóa','https://thethaovanhoa.vn/'), ('GenK','https://genk.vn/'), ('VNReview','https://vnreview.vn/'), ('Vatvostudio','https://vatvostudio.vn/'), ] SELECTED_DOMAINS=['vnexpress.net','dantri.com.vn','vtv.vn','thethaovanhoa.vn','genk.vn','vnreview.vn','vatvostudio.vn'] def _selected_fetch_rss(feed_name, feed_url, max_items=10): items=[] try: r=requests.get(feed_url,headers=UA,timeout=4);r.encoding='utf-8' if r.status_code>=400:return [] 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 '' ds=BeautifulSoup(desc_raw,'lxml') desc=clean(ds.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 _selected_scrape_homepage(name, url, max_items=10): items=[];seen=set() try: r=requests.get(url,headers=UA,timeout=4);r.encoding='utf-8' if r.status_code>=400:return [] soup=BeautifulSoup(r.text,'lxml') base=url.rstrip('/') for a in soup.find_all('a',href=True): href=a.get('href','').strip();title=clean(a.get('title','') or a.get_text(' ',strip=True)) if not href or not title or len(title)<18:continue if href.startswith('/'): p=urlparse(url); href=f'{p.scheme}://{p.netloc}{href}' if not href.startswith('http') or href in seen:continue dom=_domain(href) if not any(d in dom for d in SELECTED_DOMAINS):continue if any(x in href.lower() for x in ['#','javascript:','facebook','youtube','tiktok']):continue seen.add(href) items.append({'title':title,'url':href,'source':name,'snippet':''}) if len(items)>=max_items:break except Exception:pass return items def _fast_rss_pool(): now=time.time();key='selected_fast_pool' if key in _FAST_TOPIC_CACHE and now-_FAST_TOPIC_CACHE[key]['t']<600:return _FAST_TOPIC_CACHE[key]['d'] pool=[];seen=set() # RSS first: fast and reliable. for name,url in SELECTED_SOURCE_FEEDS: for it in _selected_fetch_rss(name,url,10): if it['url'] not in seen: seen.add(it['url']);pool.append(it) # Homepage fallback for sources with weak/no RSS, especially Vatvostudio. for name,url in SELECTED_HOMEPAGES: for it in _selected_scrape_homepage(name,url,10): if it['url'] not in seen: seen.add(it['url']);pool.append(it) _FAST_TOPIC_CACHE[key]={'t':now,'d':pool} return pool def _hot_topics(): now=time.time() if _HOT_CACHE['d'] and now-_HOT_CACHE['t']<600:return _HOT_CACHE['d'] pool=_fast_rss_pool() freq={};display={} for it in pool[:220]: title=re.sub(r'\s+-\s+.*$','',it.get('title','')) kws=[] 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]: words=[w for w in clean(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 topics=[];seen=set() for key,_ in sorted(freq.items(),key=lambda x:x[1],reverse=True): kw=display[key] if key in seen:continue seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw}) if len(topics)>=24:break for kw in ['AI tại Việt Nam','Công nghệ Việt Nam','VTV thời sự','VnExpress kinh doanh','Dân trí xã hội','GenK AI','VNReview công nghệ','Vatvostudio smartphone','Thể thao văn hóa World Cup']: 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=[] scored=[] for it in _fast_rss_pool(): sc=_fast_score(topic,it) if sc>0:scored.append((sc,it)) for sc,it in sorted(scored,key=lambda x:(x[0],len(x[1].get('snippet',''))),reverse=True)[:14]: if it['url'] not in seen: seen.add(it['url']);items.append(it) # Search only selected sources when RSS lacks matches. if len(items)<6: for dom in SELECTED_DOMAINS: for it in _ddg_search(f'{topic} site:{dom}',4): if it['url'] not in seen: seen.add(it['url']);items.append(it) if len(items)>=12:break return items[:20] # ===== FINAL6G: SOURCE-LIMITED FAST TOPICS AND FAST HOME ===== # Limit hot hashtags/topic context to requested sources and make homepage APIs return quickly. _SOURCE_FEEDS = [ ('VnExpress','https://vnexpress.net/rss/tin-moi-nhat.rss','vnexpress.net'), ('VnExpress Thời sự','https://vnexpress.net/rss/thoi-su.rss','vnexpress.net'), ('VnExpress Thế giới','https://vnexpress.net/rss/the-gioi.rss','vnexpress.net'), ('VnExpress Kinh doanh','https://vnexpress.net/rss/kinh-doanh.rss','vnexpress.net'), ('VnExpress Công nghệ','https://vnexpress.net/rss/so-hoa.rss','vnexpress.net'), ('VnExpress Thể thao','https://vnexpress.net/rss/the-thao.rss','vnexpress.net'), ('Dân trí','https://dantri.com.vn/rss/home.rss','dantri.com.vn'), ('Dân trí Xã hội','https://dantri.com.vn/rss/xa-hoi.rss','dantri.com.vn'), ('Dân trí Kinh doanh','https://dantri.com.vn/rss/kinh-doanh.rss','dantri.com.vn'), ('Dân trí Thể thao','https://dantri.com.vn/rss/the-thao.rss','dantri.com.vn'), ('Dân trí Công nghệ','https://dantri.com.vn/rss/suc-manh-so.rss','dantri.com.vn'), ('VTV','https://vtv.vn/rss/trang-chu.rss','vtv.vn'), ('VTV Thời sự','https://vtv.vn/rss/thoi-su.rss','vtv.vn'), ('GenK','https://genk.vn/rss/home.rss','genk.vn'), ('GenK AI','https://genk.vn/ai.rss','genk.vn'), ('VnReview','https://vnreview.vn/rss/tin-moi-nhat.rss','vnreview.vn'), ('VnReview Công nghệ','https://vnreview.vn/rss/cong-nghe.rss','vnreview.vn'), ('Vật Vờ Studio','https://vatvostudio.vn/feed/','vatvostudio.vn'), ('Thể thao văn hóa','https://thethaovanhoa.vn/rss/home.rss','thethaovanhoa.vn'), ('Thể thao văn hóa World Cup','https://thethaovanhoa.vn/rss/world-cup-2026.rss','thethaovanhoa.vn'), ] _SOURCE_CACHE={'t':0,'items':[]} _FAST_ROUTE_CACHE={} def _feed_items_source_limited(max_per_feed=10): now=time.time() if _SOURCE_CACHE['items'] and now-_SOURCE_CACHE['t']<600:return _SOURCE_CACHE['items'] items=[];seen=set() def one(feed): name,url,dom=feed;out=[] try: r=requests.get(url,headers=UA,timeout=3.5);r.encoding='utf-8' soup=BeautifulSoup(r.text,'xml') for it in soup.find_all('item')[:max_per_feed*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') img='';im=ds.find('img') if im:img=im.get('src','') or im.get('data-src','') desc=clean(ds.get_text(' ',strip=True))[:700] if title and link: out.append({'title':title,'url':link,'link':link,'source':name,'via':name,'domain':dom,'snippet':desc,'img':img}) if len(out)>=max_per_feed:break except Exception:pass return out try: from concurrent.futures import ThreadPoolExecutor, as_completed with ThreadPoolExecutor(max_workers=8) as ex: futs=[ex.submit(one,f) for f in _SOURCE_FEEDS] for f in as_completed(futs,timeout=5.5): try: for it in f.result() or []: if it['url'] not in seen: seen.add(it['url']);items.append(it) except Exception:pass except Exception: for f in _SOURCE_FEEDS[:8]: for it in one(f): if it['url'] not in seen: seen.add(it['url']);items.append(it) _SOURCE_CACHE.update({'t':now,'items':items}) return items def _score_topic_source(topic,it): toks=[w.lower() for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',topic or '') if len(w)>1 and w.lower() not in STOP_WORDS] hay=(it.get('title','')+' '+it.get('snippet','')+' '+it.get('source','')).lower() if not toks:return 0 score=sum((3 if len(t)>3 else 1) for t in toks if t in hay) if topic.lower().strip() and topic.lower().strip() in hay:score+=12 return score def _hot_topics(): now=time.time() if _HOT_CACHE['d'] and now-_HOT_CACHE['t']<600:return _HOT_CACHE['d'] freq={};display={} for it in _feed_items_source_limited(8)[:180]: title=re.sub(r'\s+-\s+.*$','',it.get('title','')) kws=[] 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[:4]: words=[w for w in clean(kw).split() if w.lower() not in STOP_WORDS] if len(words)<2:continue kw=' '.join(words[:5]) if 6<=len(kw)<=55: key=kw.lower();freq[key]=freq.get(key,0)+1;display[key]=kw topics=[] for key,_ in sorted(freq.items(),key=lambda x:x[1],reverse=True): kw=display[key];topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw}) if len(topics)>=24:break for kw in ['Giá vàng trong nước','AI tại Việt Nam','Bóng đá Việt Nam','Kinh tế Việt Nam','Công nghệ AI','Vật Vờ Studio','World Cup 2026','Sức khỏe cộng đồng']: if not any(t['topic'].lower()==kw.lower() for t in topics):topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw}) _HOT_CACHE.update({'t':now,'d':topics[:24]}) return _HOT_CACHE['d'] def _fast_context(topic): now=time.time();key='source_limited_ctx:'+topic.lower().strip() if key in _FAST_TOPIC_CACHE and now-_FAST_TOPIC_CACHE[key]['t']<600:return _FAST_TOPIC_CACHE[key]['d'] pool=_feed_items_source_limited(12) scored=[] for it in pool: sc=_score_topic_source(topic,it) if sc>0:scored.append((sc,it)) if not scored: # Try broader matching by first token only before giving up. first=(_fast_topic_tokens(topic) or [''])[0] if first: for it in pool: if first in (it.get('title','')+' '+it.get('snippet','')).lower():scored.append((1,it)) picked=[it for sc,it in sorted(scored,key=lambda x:(x[0],len(x[1].get('snippet',''))),reverse=True)[:8]] if not picked:picked=pool[:6] blocks=[];src=[] for it in picked: content=it.get('snippet','') or it.get('title','') blocks.append(f"NGUỒN: {it.get('source','')}\nTIÊU ĐỀ: {it.get('title','')}\nTÓM TẮT RSS:\n{content}") src.append({'title':it.get('title',''),'url':it.get('url',''),'via':it.get('source',''),'snippet':content}) data={'context':'\n\n---\n\n'.join(blocks),'sources':src,'count':len(blocks)} _FAST_TOPIC_CACHE[key]={'t':now,'d':data} return data # Override slow search functions to never crawl open web during topic generation. def _web_research_context(topic): return _fast_context(topic) def _candidate_urls(topic): return _fast_context(topic).get('sources',[]) # Fast homepage endpoints from requested source RSS; no slow HTML scrapers. def _fast_homepage_sources(): now=time.time();key='home_sources' if key in _FAST_ROUTE_CACHE and now-_FAST_ROUTE_CACHE[key]['t']<600:return _FAST_ROUTE_CACHE[key]['d'] groups=[];seen=set() group_map=[('Tin mới','https://vnexpress.net/rss/tin-moi-nhat.rss','vne'),('Thời Sự','https://vnexpress.net/rss/thoi-su.rss','vne'),('Kinh Doanh','https://vnexpress.net/rss/kinh-doanh.rss','vne'),('Công Nghệ','https://vnexpress.net/rss/so-hoa.rss','vne'),('Dân Trí','https://dantri.com.vn/rss/home.rss','dantri'),('GenK','https://genk.vn/rss/home.rss','genk'),('VnReview','https://vnreview.vn/rss/tin-moi-nhat.rss','vnreview')] for g,u,s in group_map: for it in _rss_articles_fast(u,g,s,6) if '_rss_articles_fast' in globals() else []: if it['link'] not in seen: seen.add(it['link']);groups.append(it) _FAST_ROUTE_CACHE[key]={'t':now,'d':groups} return groups for _p in ['/api/homepage','/api/dantri_hot','/api/vne_video','/api/highlights','/api/hot_topics','/api/topic_sources']: 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_source_fast():return JSONResponse(_fast_homepage_sources()) @app.get('/api/dantri_hot') def api_dantri_hot_source_fast(): data=[{**it,'source':'dantri','link':it.get('url') or it.get('link')} for it in _feed_items_source_limited(8) if it.get('domain')=='dantri.com.vn'][:12] return JSONResponse(data) @app.get('/api/vne_video') def api_vne_video_source_fast(): return JSONResponse([]) # do not block homepage if VnEgo is slow @app.get('/api/highlights') def api_highlights_source_fast():return JSONResponse([]) @app.get('/api/hot_topics') def api_hot_topics_source_fast():return JSONResponse({'topics':_hot_topics(),'sources':'vn_only'}) @app.get('/api/topic_sources') def api_topic_sources_source_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':'source_limited_rss'}) # Override root: include all existing UI but add a script that prevents forced shorts refresh on initial load. ROOT_FAST_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_final_fast_sources(): 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+globals().get('FINAL6_FAST_HOME_INJECT','')+globals().get('FINAL6E_INJECT','')+globals().get('FINAL6F_INJECT','')+ROOT_FAST_INJECT return HTMLResponse(html.replace('',body+'\n') if '' in html else html+body)