Spaces:
Running
Running
| """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'] | |
| 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' | |
| 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'))}) | |
| 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 [] | |
| 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''' | |
| <style> | |
| #ai-topic-input-final3,.topic-final3,#ai-topic-input-final4,.topic-final4{display:none!important}.topic-final5{display:flex!important}.ai-wall-topic-live{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.hot-topic-row{display:flex;gap:6px;overflow-x:auto;padding:4px 0}.hot-chip{flex:0 0 auto;background:#222;border:1px solid #333;color:#ddd;border-radius:16px;padding:5px 10px;font-size:11px;cursor:pointer}.hot-chip:active{transform:scale(.96)}.topic-source-note{font-size:10px;color:#777;margin-top:4px;line-height:1.3} | |
| </style> | |
| <script> | |
| (function(){ | |
| function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));} | |
| let liveTopicWall=[]; | |
| async function ensureHotTopics(){let inp=document.getElementById('ai-topic-input-final5');if(!inp||document.getElementById('hot-topic-row-final6'))return;let row=document.createElement('div');row.id='hot-topic-row-final6';row.className='hot-topic-row';row.innerHTML='<span style="color:#777;font-size:11px;padding:5px 0">Đang tải từ khóa nóng...</span>';inp.insertAdjacentElement('afterend',row);let note=document.createElement('div');note.id='topic-source-note';note.className='topic-source-note';note.textContent='AI sẽ tìm nhiều nguồn, crawl nội dung bài viết rồi tổng hợp thành bài mới.';row.insertAdjacentElement('afterend',note);let j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));let topics=j.topics||[];row.innerHTML=topics.slice(0,18).map(t=>`<button class="hot-chip" onclick="document.getElementById('ai-topic-input-final5').value='${esc(t.topic).replace(/'/g,'\\\'')}';document.getElementById('ai-topic-input-final5').focus();">${esc(t.label)}</button>`).join('')||'';} | |
| async function ensureNewsShortsHome(){if(!document.getElementById('view-home')?.classList.contains('active'))return;let labels=[...document.querySelectorAll('.slider-wrap .slider-label')];let wraps=labels.filter(l=>/shorts|short /i.test(l.textContent||'')&&!/short ai/i.test(l.textContent||'')).map(l=>l.closest('.slider-wrap')).filter(Boolean);wraps.forEach((w,i)=>{if(i>0)w.remove();});let w=wraps[0];if(w){let seen=new Set();[...w.querySelectorAll('.slider-item')].forEach(it=>{let img=it.querySelector('img')?.src||'';let tt=(it.querySelector('.slider-title')?.textContent||'').trim().toLowerCase();let k=img||tt;if(k&&seen.has(k))it.remove();else if(k)seen.add(k);});if(w.querySelectorAll('.slider-item').length>=6)return;w.remove();}let sh=await fetch('/api/shorts?refresh=1').then(r=>r.json()).catch(()=>[]);if(!sh.length)return;let wrap=document.createElement('div');wrap.className='slider-wrap';wrap.id='shorts-final6-stable';let h='<div class="slider-header"><span class="slider-label">📱 Shorts Dân trí & SKĐS</span><span class="slider-note">Mới nhất</span></div><div class="slider-track">';sh.slice(0,30).forEach((a,i)=>{h+=`<div class="slider-item shorts-item" onclick="openTikTok('shorts',${i})"><div class="slider-thumb shorts-thumb">${a.img?`<img src="${esc(a.img)}">`:''}<div class="card-play">▶</div></div><div class="slider-title">${esc(a.title)}</div></div>`});h+='</div>';wrap.innerHTML=h;let comp=document.querySelector('.ai-compose')||document.getElementById('view-home').firstChild;if(comp)comp.after(wrap);else document.getElementById('view-home').prepend(wrap);} | |
| function renderLiveTopicWall(){let home=document.getElementById('view-home');if(!home||!liveTopicWall.length)return;document.getElementById('ai-wall-topic-live')?.remove();let wrap=document.createElement('div');wrap.id='ai-wall-topic-live';wrap.className='ai-wall-topic-live';let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI mới</span><span class="slider-note">Tổng hợp từ web</span></div><div class="slider-track">';liveTopicWall.slice(0,20).forEach((p,i)=>{h+=`<div class="wall-item"><div class="wall-thumb">${p.img?`<img src="${esc(p.img)}">`:''}</div><div class="wall-title">${esc(p.title)}</div><div class="wall-text">${esc(p.text)}</div><div class="wall-actions"><button class="primary" onclick="readLiveTopicWall(${i})">Xem</button></div></div>`});h+='</div>';wrap.innerHTML=h;let comp=document.querySelector('.ai-compose');if(comp)comp.after(wrap);else home.prepend(wrap);} | |
| window.readLiveTopicWall=function(i){let p=liveTopicWall[i];if(!p)return;showView('view-article');let imgs=(p.images||[]).filter(Boolean);let gal=imgs.length?'<div class="ai-wall-gallery">'+imgs.slice(0,12).map(u=>`<img src="${esc(u)}" loading="lazy">`).join('')+'</div>':(p.img?`<img class="article-img" src="${esc(p.img)}">`:'');document.getElementById('view-article').innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${esc(p.title)}</h1>${gal}<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p><div class="article-actions"><button onclick="shareAI?shareAI(${JSON.stringify(p).replace(/"/g,'"')},false):navigator.clipboard.writeText(location.href)">📤 Chia sẻ</button></div></div>`;window.scrollTo(0,0)}; | |
| window.createTopicPostFinal5=async function(){let inp=document.getElementById('ai-topic-input-final5');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');let btn=document.getElementById('ai-topic-btn-final5');if(btn){btn.disabled=true;btn.textContent='Đang tìm nguồn...'}try{let src=await fetch('/api/topic_sources?topic='+encodeURIComponent(topic)).then(r=>r.json()).catch(()=>null);if(btn&&src)btn.textContent='Đã tìm '+(src.count||0)+' nguồn, đang tổng hợp...';let r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');liveTopicWall.unshift(j.post);if(inp)inp.value='';renderLiveTopicWall();readLiveTopicWall(0);alert('Đã tạo bài tổng hợp từ nội dung web và đăng lên Tường AI.');}catch(e){alert(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='✨ Tạo bài tổng hợp từ web bằng Qwen'}}}; | |
| setInterval(()=>{document.querySelectorAll('#ai-topic-input-final3,.topic-final3,#ai-topic-input-final4,.topic-final4').forEach(e=>(e.closest('.topic-final3,.topic-final4,.ai-compose-row')||e).remove());let b=document.getElementById('ai-topic-btn-final5');if(b){b.style.display='block';b.textContent='✨ Tạo bài tổng hợp từ web bằng Qwen';}ensureHotTopics();ensureNewsShortsHome();},1200);setTimeout(()=>{ensureHotTopics();ensureNewsShortsHome();},1200); | |
| })(); | |
| </script> | |
| ''' | |
| 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>',body+'\n</body>') if '</body>' 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')})] | |
| 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'}) | |
| 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()))] | |
| def api_homepage_fast():return JSONResponse(_fast_homepage()) | |
| def api_dantri_hot_fast():return JSONResponse(_fast_dantri_hot()) | |
| def api_vne_video_fast():return JSONResponse(_fast_vnego()) | |
| def api_highlights_fast():return JSONResponse(_fast_highlights()) | |
| FINAL6_FAST_HOME_INJECT = """ | |
| <script> | |
| (function(){ | |
| const oldFetch=window.fetch; | |
| window.__allowShortRefresh=false; | |
| window.fetch=function(url,opts){try{let u=String(url||'');if(u.includes('/api/shorts?refresh=1')&&!window.__allowShortRefresh)url='/api/shorts';}catch(e){}return oldFetch.call(this,url,opts)}; | |
| setTimeout(()=>{window.__allowShortRefresh=true;},7000); | |
| })(); | |
| </script> | |
| """ | |
| app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))] | |
| 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>',body+'\n</body>') if '</body>' 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()))] | |
| 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 = """ | |
| <style> | |
| .source-detail-box{margin-top:14px;background:#151515;border:1px solid #2b2b2b;border-radius:10px;padding:10px}.source-detail-box h3{font-size:14px;color:#5cb87a;margin-bottom:8px}.source-detail-item{background:#202020;border-radius:8px;padding:9px;margin:7px 0}.source-detail-title{font-size:12px;font-weight:700;color:#eee;line-height:1.35}.source-detail-meta{font-size:10px;color:#888;margin:3px 0}.source-detail-content{font-size:12px;color:#bbb;line-height:1.5;white-space:pre-wrap;max-height:220px;overflow:auto}.source-detail-item a{color:#5cb87a;font-size:11px;text-decoration:none} | |
| </style> | |
| <script> | |
| (function(){ | |
| function escE(s){return String(s||'').replace(/[&<>\"']/g,m=>({'&':'&','<':'<','>':'>','\"':'"',"'":'''}[m]));} | |
| window.__topicWallE=[]; | |
| function sourceDetailsHtml(p){let arr=p.source_details||[];if(!arr.length)return '';let h='<div class="source-detail-box"><h3>📚 Nội dung từng nguồn đã dùng</h3>';arr.forEach((s,i)=>{h+=`<div class="source-detail-item"><div class="source-detail-title">${i+1}. ${escE(s.title)}</div><div class="source-detail-meta">${escE(s.via||'Nguồn')}</div><div class="source-detail-content">${escE(s.content||'')}</div>${s.url?`<a href="${escE(s.url)}" target="_blank">Mở nguồn gốc</a>`:''}</div>`});h+='</div>';return h;} | |
| function renderTopicWallE(){let home=document.getElementById('view-home');if(!home||!window.__topicWallE.length)return;document.getElementById('ai-wall-topic-live')?.remove();let wrap=document.createElement('div');wrap.id='ai-wall-topic-live';wrap.className='ai-wall-topic-live';let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI mới</span><span class="slider-note">Tổng hợp từ web</span></div><div class="slider-track">';window.__topicWallE.slice(0,20).forEach((p,i)=>{h+=`<div class="wall-item"><div class="wall-thumb">${p.img?`<img src="${escE(p.img)}">`:''}</div><div class="wall-title">${escE(p.title)}</div><div class="wall-text">${escE(p.text)}</div><div class="wall-actions"><button class="primary" onclick="readTopicWallE(${i})">Xem</button></div></div>`});h+='</div>';wrap.innerHTML=h;let comp=document.querySelector('.ai-compose');if(comp)comp.after(wrap);else home.prepend(wrap);} | |
| window.readTopicWallE=function(i){let p=window.__topicWallE[i];if(!p)return;showView('view-article');let imgs=(p.images||[]).filter(Boolean);let gal=imgs.length?'<div class="ai-wall-gallery">'+imgs.slice(0,12).map(u=>`<img src="${escE(u)}" loading="lazy">`).join('')+'</div>':(p.img?`<img class="article-img" src="${escE(p.img)}">`:'');let srcDetails=sourceDetailsHtml(p);document.getElementById('view-article').innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${escE(p.title)}</h1>${gal}<p class="article-p" style="white-space:pre-wrap">${escE(p.text)}</p>${srcDetails}<div class="article-actions"><button onclick="shareAI?shareAI(${JSON.stringify(p).replace(/"/g,'"')},false):navigator.clipboard.writeText(location.href)">📤 Chia sẻ</button></div></div>`;window.scrollTo(0,0)}; | |
| window.createTopicPostFinal5=async function(){let inp=document.getElementById('ai-topic-input-final5');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');let btn=document.getElementById('ai-topic-btn-final5');if(btn){btn.disabled=true;btn.textContent='Đang tìm nguồn...'}try{let src=await fetch('/api/topic_sources?topic='+encodeURIComponent(topic)).then(r=>r.json()).catch(()=>null);if(btn&&src)btn.textContent='Đã tìm '+(src.count||0)+' nguồn, đang tổng hợp...';let r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');window.__topicWallE.unshift(j.post);if(inp)inp.value='';renderTopicWallE();readTopicWallE(0);alert('Đã tạo bài tổng hợp từ nội dung web và đăng lên Tường AI.');}catch(e){alert(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='✨ Tạo bài tổng hợp từ web bằng Qwen'}}}; | |
| setInterval(()=>{document.querySelectorAll('#ai-topic-input-final3,.topic-final3,#ai-topic-input-final4,.topic-final4').forEach(e=>(e.closest('.topic-final3,.topic-final4,.ai-compose-row')||e).remove());let b=document.getElementById('ai-topic-btn-final5');if(b){b.style.display='block';b.textContent='✨ Tạo bài tổng hợp từ web bằng Qwen';}},1200); | |
| })(); | |
| </script> | |
| ''' | |