VNEWS2 / ai_runtime_final4.py
bep40's picture
Fix topic button, shorts homepage, ask AI for videos and articles
c226238 verified
Raw
History Blame Contribute Delete
14.4 kB
"""Final4 runtime: fix topic button visibility, shorts home feed, AI asking for videos/articles."""
import re, time, json, os, requests
from urllib.parse import urlparse
import ai_runtime_final3 as f3
from ai_runtime_final3 import app, base, rt, HTMLResponse, JSONResponse, Request, Query
try:
import main as main_mod
except Exception:
main_mod=None
AI_INTERACTIONS_FILE=f3.AI_INTERACTIONS_FILE
_SHORTS_CACHE={"t":0,"d":[]}
SHORT_CHANNELS=f3.SHORT_CHANNELS
def clean(s):
import html as html_lib
return re.sub(r"\s+"," ",html_lib.unescape(s or "")).strip()
def _domain(u):
try:return urlparse(u or '').netloc.replace('www.','')
except Exception:return ''
def _load_json(path,default):
try:
if os.path.exists(path):
with open(path,'r',encoding='utf-8') as f:return json.load(f)
except Exception:pass
return default
def _save_json(path,data):
try:
os.makedirs(os.path.dirname(path),exist_ok=True);tmp=path+'.tmp'
with open(tmp,'w',encoding='utf-8') as f:json.dump(data,f,ensure_ascii=False)
os.replace(tmp,path)
except Exception:pass
def _fallback_shorts():
out=[];seen=set()
candidates=[]
try:candidates+=(getattr(main_mod,'SHORTS_FALLBACK',[]) or [])
except Exception:pass
try:candidates+=(getattr(rt,'SHORTS_FALLBACK',[]) or [])
except Exception:pass
# hard fallback if imports fail
hard=[('Lu_iCQ5YwNM','Công an lập hồ sơ xử lý người phụ nữ chửi bới, tát tài xế ô tô | Dân trí','baodantri7941'),('CwWvijF8BOA','Chú rể bật khóc nhận món quà bí mật người cha quá cố gửi 26 năm trước | Dân trí','baodantri7941'),('7Pd6vZ2Lz1M','Hành động ấm lòng trong tìm kiếm học sinh tử vong ở sông Lô | SKĐS','baosuckhoedoisongboyte'),('SlHLt_ZyPiE','Xử phạt người đàn ông xóa số điện thoại cứu hộ trên cao tốc Bắc - Nam | SKĐS','baosuckhoedoisongboyte')]
for vid,title,ch in hard:
candidates.append({'id':vid,'title':title,'channel':ch,'link':'https://www.youtube.com/watch?v='+vid,'img':'https://i.ytimg.com/vi/'+vid+'/hqdefault.jpg','source':'yt'})
for v in candidates:
vid=v.get('id') or ''
if vid and vid not in seen:
seen.add(vid)
if not v.get('link'):v['link']='https://www.youtube.com/watch?v='+vid
if not v.get('img'):v['img']='https://i.ytimg.com/vi/'+vid+'/hqdefault.jpg'
v['source']='yt';out.append(v)
return out
def _fresh_shorts():
items=[];seen=set()
for ch in SHORT_CHANNELS:
got=f3._youtube_shorts_ytdlp(ch,24) or f3._youtube_shorts_html(ch,24)
for v in got:
vid=v.get('id')
if vid and vid not in seen:
seen.add(vid);items.append(v)
for v in _fallback_shorts():
vid=v.get('id')
if vid and vid not in seen:
seen.add(vid);items.append(v)
return items[:60]
# Remove endpoints/root to override.
_PATCH={('/api/shorts','GET'),('/api/ai/interact','POST'),('/api/article/ask','POST'),('/','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)]
@app.get('/api/shorts')
def api_shorts_final4(refresh:int=Query(default=0)):
now=time.time()
if not refresh and _SHORTS_CACHE['d'] and now-_SHORTS_CACHE['t']<900:return JSONResponse(_SHORTS_CACHE['d'])
data=_fresh_shorts()
_SHORTS_CACHE.update({'t':now,'d':data})
return JSONResponse(data)
@app.post('/api/ai/interact')
async def ai_interact_final4(request:Request):
body=await request.json();pid=str(body.get('id','')).strip();kind=str(body.get('kind','wall')).strip();action=str(body.get('action','')).strip();text=clean(body.get('text',''));context=clean(body.get('context',''));title=clean(body.get('title',''))
if not pid:return JSONResponse({'error':'missing id'},status_code=400)
db=_load_json(AI_INTERACTIONS_FILE,{})
key=kind+':'+pid
st=db.get(key) or {'views':0,'likes':0,'comments':[],'asks':[]}
if action=='view':st['views']=int(st.get('views',0))+1
elif action=='like':st['likes']=int(st.get('likes',0))+1
elif action=='comment' and text:
st.setdefault('comments',[]).insert(0,{'text':text[:240],'ts':int(time.time())});st['comments']=st['comments'][:80]
elif action=='ask' and text:
if kind in ('ai','short','wall'):
posts=base._load_ai_wall();p=next((x for x in posts if str(x.get('id'))==pid),{})
title=title or p.get('title','');context=context or (p.get('text') or '')
# For YouTube shorts, frontend sends title/context because AI cannot watch video.
if not context:context=title or pid
prompt=f"""Bạn là trợ lý VNEWS. Trả lời chi tiết bằng tiếng Việt dựa trên thông tin có sẵn về video/bài viết.
Tiêu đề/ngữ cảnh: {title}
Nội dung mô tả: {context[:5000]}
Câu hỏi người dùng: {text}
Yêu cầu:
- Nếu là video YouTube/Shorts và chỉ có tiêu đề, hãy nói rõ rằng bạn suy luận từ tiêu đề/mô tả, không khẳng định đã xem video.
- Trả lời cụ thể, có giải thích, không quá ngắn.
"""
ans=await base.qwen_generate(prompt,max_tokens=900)
if not ans:ans='AI chưa trả lời được lúc này. Bạn thử hỏi lại cụ thể hơn.'
st.setdefault('asks',[]).insert(0,{'q':text[:240],'a':ans[:1500],'ts':int(time.time())});st['asks']=st['asks'][:50]
db[key]=st;_save_json(AI_INTERACTIONS_FILE,db)
return JSONResponse({'stats':st})
@app.post('/api/article/ask')
async def article_ask(request:Request):
body=await request.json();url=clean(body.get('url',''));question=clean(body.get('question',''))
if not question:return JSONResponse({'error':'missing question'},status_code=400)
title='';raw=''
try:
data=None
if url and hasattr(f3.f2.f1,'_scrape_url_article_only'):
data=f3.f2.f1._scrape_url_article_only(url)
if not data and url:data=base.scrape_any_url(url)
if data:
title=data.get('title','');raw=(data.get('summary','')+'\n'+data.get('text','')).strip()
except Exception:pass
context=raw[:12000] if raw else clean(body.get('context',''))[:12000]
prompt=f"""Bạn là trợ lý đọc hiểu bài viết của VNEWS. Hãy trả lời chi tiết câu hỏi của người dùng dựa trên bài viết.
Tiêu đề bài: {title}
Nội dung bài:
{context}
Câu hỏi: {question}
Yêu cầu:
- Trả lời bằng tiếng Việt.
- Dựa sát nội dung bài, nếu bài không có thông tin thì nói rõ.
- Giải thích chi tiết, có gạch đầu dòng khi hữu ích.
"""
ans=await base.qwen_generate(prompt,max_tokens=1200)
if not ans:ans='AI chưa trả lời được lúc này. Bạn thử hỏi lại hoặc rút gọn câu hỏi.'
return JSONResponse({'answer':ans,'title':title})
FINAL4_INJECT = r'''
<style>
/* Ensure topic Qwen button is visible; earlier patches hide any button containing “chủ đề”. */
.topic-final4{display:flex!important;flex-direction:column!important;gap:8px!important;width:100%!important;margin-top:6px}.topic-final4 input,.topic-final4 button{display:block!important;width:100%!important;box-sizing:border-box!important}.topic-final4 button{background:#2d8659!important;color:#fff!important;border:0!important;border-radius:18px!important;padding:9px 12px!important;font-size:11px!important;font-weight:700!important}.article-ai-ask{margin-top:12px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.article-ai-ask textarea{width:100%;min-height:70px;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px}.article-ai-ask button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin-top:6px}.article-ai-answer{white-space:pre-wrap;color:#ccc;font-size:13px;line-height:1.55;margin-top:8px}.ai-compose-row:has(#ai-url-input){display:flex!important;flex-direction:column!important}.ai-compose-row:has(#ai-url-input) input,.ai-compose-row:has(#ai-url-input) button{width:100%!important}
</style>
<script>
(function(){
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
let shortsMap={};
function ensureTopicButtonFinal4(){let comp=document.querySelector('.ai-compose');if(!comp)return;if(!document.getElementById('ai-topic-input-final4')){let row=document.createElement('div');row.className='topic-final4';row.innerHTML='<input id="ai-topic-input-final4" placeholder="Nhập chủ đề để Qwen2.5VL tạo bài lên Tường AI..."><button id="ai-topic-btn-final4" onclick="createTopicPostFinal4()">✨ Tạo bài bằng Qwen</button>';comp.insertBefore(row,comp.firstChild.nextSibling);}let b=document.getElementById('ai-topic-btn-final4');if(b){b.style.display='block';b.textContent='✨ Tạo bài bằng Qwen';}}
window.createTopicPostFinal4=async function(){let inp=document.getElementById('ai-topic-input-final4');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');let btn=document.getElementById('ai-topic-btn-final4');if(btn){btn.disabled=true;btn.textContent='Đang tạo...'}try{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');if(window.finalWall)window.finalWall.unshift(j.post);if(window.finalWall3)window.finalWall3.unshift(j.post);if(inp)inp.value='';if(window.renderWall)window.renderWall();alert('Đã tạo bài bằng Qwen và đăng lên Tường AI.');}catch(e){alert(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='✨ Tạo bài bằng Qwen'}}};
// Guarantee Shorts slide appears on home even if previous loadHome missed it.
async function ensureShortsHome(){let home=document.getElementById('view-home');if(!home||document.getElementById('shorts-final4'))return;let sh=await fetch('/api/shorts?refresh=1').then(r=>r.json()).catch(()=>[]);if(!sh.length)return;let wrap=document.createElement('div');wrap.id='shorts-final4';wrap.className='slider-wrap';let h='<div class="slider-header"><span class="slider-label">📱 Shorts Dân trí & SKĐS</span><span class="slider-note">Cập nhật YouTube</span></div><div class="slider-track">';sh.slice(0,30).forEach((a,i)=>{shortsMap[a.id]=a;h+=`<div class="slider-item shorts-item" onclick="openTikTok('shorts',${i})"><div class="slider-thumb shorts-thumb">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="slider-title">${esc(a.title)}</div></div>`});h+='</div>';wrap.innerHTML=h;let after=document.querySelector('.ai-compose')||home.firstChild;if(after)after.after(wrap);else home.prepend(wrap);}
// Patch ask for YouTube shorts: AI receives title/context.
let oldShortAct=window.shortAct;
window.shortAct=async function(kind,id,action,text=''){let meta=shortsMap[id]||{};let url='/api/ai/interact';let body={id,kind:kind==='yt'?'yt':kind,action,text,title:meta.title||'',context:meta.title?('Video Shorts YouTube từ kênh '+(meta.channel||'')+'. Tiêu đề: '+meta.title):''};let r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});let j=await r.json();let st=j.stats||j;let v=document.getElementById(`v-${kind}-${id}`),l=document.getElementById(`l-${kind}-${id}`);if(v&&st.views!=null)v.textContent=st.views;if(l&&st.likes!=null)l.textContent=st.likes;return st;};
window.submitShortAsk=async function(kind,id){let t=document.getElementById('short-ask-text').value.trim();if(!t)return;let st=await shortAct(kind,id,'ask',t);let a=(st.asks&&st.asks[0]&&st.asks[0].a)||'Chưa có trả lời';document.getElementById('short-answer').innerHTML='<p style="white-space:pre-wrap;color:#ccc">'+esc(a)+'</p>';};
// Patch openTikTok to populate shortsMap.
let oldOpenTikTok=window.openTikTok;
window.openTikTok=async function(type,startIdx){if(type==='shorts'){let arts=await fetch('/api/shorts?refresh=1').then(r=>r.json()).catch(()=>[]);arts.forEach(a=>{if(a.id)shortsMap[a.id]=a});}return oldOpenTikTok?oldOpenTikTok(type,startIdx):null;};
function addArticleAskBox(){let view=document.getElementById('view-article');if(!view||document.getElementById('article-ai-ask'))return;let art=view.querySelector('.article-view');if(!art)return;let box=document.createElement('div');box.id='article-ai-ask';box.className='article-ai-ask';box.innerHTML='<h3 style="font-size:14px;color:#5cb87a;margin-bottom:6px">🤖 Hỏi AI về bài viết</h3><textarea id="article-ai-question" placeholder="Nhập câu hỏi cần AI trả lời chi tiết về bài viết..."></textarea><button onclick="askArticleAI()">Hỏi AI</button><div id="article-ai-answer" class="article-ai-answer"></div>';art.appendChild(box);}
window.askArticleAI=async function(){let q=document.getElementById('article-ai-question')?.value.trim();if(!q)return alert('Nhập câu hỏi trước');let ans=document.getElementById('article-ai-answer');ans.textContent='Đang hỏi AI...';let url=(window._currentArticle&&window._currentArticle.url)||((typeof _currentArticle!=='undefined'&&_currentArticle.url)||'');let context=document.querySelector('.article-view')?.innerText||'';try{let r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,question:q,context})});let j=await r.json();ans.textContent=j.answer||j.error||'Không có trả lời';}catch(e){ans.textContent='Lỗi hỏi AI: '+e.message}}
let oldReadArticle=window.readArticle;if(oldReadArticle){window.readArticle=async function(){let ret=await oldReadArticle.apply(this,arguments);setTimeout(addArticleAskBox,700);return ret;}}
setTimeout(()=>{ensureTopicButtonFinal4();ensureShortsHome();},1000);setInterval(()=>{ensureTopicButtonFinal4();if(document.getElementById('view-home')?.classList.contains('active'))ensureShortsHome();addArticleAskBox();},2000);
})();
</script>
'''
@app.get('/')
async def index_final4():
html=f3.f2.f1._load_index_html();body=getattr(rt.old,'PATCH_INJECT','')+f3.f2.f1.FINAL_INJECT+f3.FINAL3_INJECT+FINAL4_INJECT
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)