bep40 commited on
Commit
26f8fe9
·
verified ·
1 Parent(s): 79a2cca

Upload ai_runtime_patch_fast.py

Browse files
Files changed (1) hide show
  1. ai_runtime_patch_fast.py +489 -24
ai_runtime_patch_fast.py CHANGED
@@ -1,9 +1,16 @@
1
- """Final patch v2: fix topic rewrite, remove duplicate short slide, full short interaction buttons."""
2
- import re, threading, time, json, os, asyncio
3
  import ai_runtime_final6 as f6
4
  from ai_runtime_final6 import app, rt, f5, HTMLResponse, JSONResponse, Request, Query
 
5
  import html as html_lib
6
  from urllib.parse import urlparse
 
 
 
 
 
 
7
 
8
  def clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or ""))).strip()
9
  def _domain(u):
@@ -13,6 +20,8 @@ DATA_DIR="/data" if os.path.isdir('/data') else "/app/data"
13
  os.makedirs(DATA_DIR,exist_ok=True)
14
  SHORT_COMMENTS_FILE=os.path.join(DATA_DIR,'short_comments.json')
15
  TTL_24H=86400;HAS_PERSISTENT=os.path.isdir('/data')
 
 
16
  def _lj(p,d):
17
  try:
18
  if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8'))
@@ -27,6 +36,413 @@ def _cleanup():
27
  def _scrape(url,mc=8000):
28
  try:d=f5.base.scrape_any_url(url);return(d.get('title',''),((d.get('summary','')+'\n'+d.get('text','')).strip())[:mc],d.get('image') or d.get('og_image') or '')
29
  except:return('','','')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  _bg_home={"t":0,"d":[]};_bg_shorts={"t":0,"d":[]};_bg_lock=False
31
  def _bg():
32
  global _bg_lock
@@ -43,7 +459,7 @@ def _bg():
43
  @app.on_event("startup")
44
  async def _s():threading.Thread(target=_bg,daemon=True).start()
45
  threading.Thread(target=lambda:[time.sleep(600) or _bg() for _ in iter(int,1)],daemon=True).start()
46
- app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None) in ('/api/homepage','/api/shorts','/api/ai_wall','/api/topic_post','/api/article/ask','/api/topic/rewrite','/api/rewrite_share','/api/url_wall','/api/short/comments','/api/short/comment','/api/storage_status','/') and any(m in getattr(r,'methods',set()) for m in ('GET','POST')))]
47
  @app.get('/api/homepage')
48
  def _h():
49
  n=time.time()
@@ -75,6 +491,48 @@ async def _ask(request:Request):
75
  if not raw:raw=ctx[:12000]
76
  ans=await f5.base.qwen_generate(f'Bạn là VNEWS AI. Nội dung: "{title}"\n{raw[:9000]}\n\nHỏi: "{q}"\n\nTrả lời tự nhiên bằng tiếng Việt.',max_tokens=1200)
77
  return JSONResponse({'answer':ans or 'Chưa trả lời được.','title':title})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  @app.post('/api/rewrite_share')
79
  @app.post('/api/url_wall')
80
  async def _rw(request:Request):
@@ -83,12 +541,33 @@ async def _rw(request:Request):
83
  title,raw,img=_scrape(url,14000)
84
  if len(raw)<50:raw=ctx[:14000]
85
  if len(raw)<50:return JSONResponse({'error':'Không đọc được bài'},status_code=422)
 
 
 
 
 
 
 
 
86
  text=None
87
- try:text=await asyncio.wait_for(f5.base.qwen_generate(f'Tóm tắt đăng Tường AI:\nTiêu đề: {title}\n{raw[:14000]}\n\n4-6 ý chính. Cuối ghi nguồn.',image_url=img or None,max_tokens=1000),timeout=30)
88
  except:pass
89
  if not text or len(text)<80:text=f"Tóm tắt: {title}\n\n{raw[:1200]}\n\nNguồn: {_domain(url)}"
90
- post=f5.base.make_post(title or 'Bài viết',text,img,url,'rewrite',sources=[{'title':title,'url':url,'via':_domain(url)}])
91
- ps=f5.base._load_ai_wall();ps.insert(0,post);f5.base._save_ai_wall(ps);return JSONResponse({'post':post})
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  @app.post('/api/topic/rewrite')
93
  async def _tr(request:Request):
94
  b=await request.json();pid=str(b.get('post_id','')).strip()
@@ -128,7 +607,6 @@ PATCH_INJECT=r'''
128
  .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:60px;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}
129
  .storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 4px}
130
  button[onclick*="rewriteCurrentArticle"]{display:none!important}
131
- /* Hide ALL old Short AI slides from previous layers */
132
  #ai-short-home,.ai-short-home,.ai-short-card-final{display:none!important}
133
  .source-detail-box a[target="_blank"]{display:none!important}
134
  </style>
@@ -137,40 +615,27 @@ button[onclick*="rewriteCurrentArticle"]{display:none!important}
137
  (function(){
138
  function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
139
  fetch('/api/storage_status').then(r=>r.json()).then(j=>{if(!j.persistent){let h=document.getElementById('view-home');if(h){let w=document.createElement('div');w.className='storage-warn';w.innerHTML='⚠️ <b>Persistent Storage chưa bật.</b> Bật: Space Settings → Persistent Storage → Small.';h.prepend(w);}}});
140
-
141
- // === Short AI Slide on homepage (same as Dantri shorts) ===
142
  async function renderShortAISlide(){let home=document.getElementById('view-home');if(!home)return;document.getElementById('short-ai-final-slide')?.remove();let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];let vids=wall.filter(p=>p.video);if(!vids.length)return;let wrap=document.createElement('div');wrap.id='short-ai-final-slide';wrap.className='slider-wrap';wrap.innerHTML='<div class="slider-header"><span class="slider-label">🎬 Short AI</span></div><div class="slider-track">'+vids.slice(0,30).map((p,i)=>`<div class="slider-item shorts-item" onclick="openAIShortFeed(${i})"><div class="slider-thumb shorts-thumb"><video src="${p.video}" muted preload="metadata" style="width:100%;height:100%;object-fit:cover"></video><div class="card-play">▶</div></div><div class="slider-title">${esc(p.title)}</div></div>`).join('')+'</div>';let comp=home.querySelector('.ai-compose');if(comp&&comp.nextSibling)comp.parentNode.insertBefore(wrap,comp.nextSibling);else home.prepend(wrap);}
143
- setTimeout(renderShortAISlide,2500);
144
-
145
- // === Source Details ===
146
  function renderSourceDetails(post,container){let det=post.source_details||[];if(!det.length)return;container.querySelectorAll('.source-detail-box').forEach(e=>e.remove());let box=document.createElement('div');box.className='source-detail-box';box.innerHTML='<h3 style="font-size:14px;color:#5cb87a;margin-bottom:8px">📚 Bài nguồn</h3>'+det.map((s,i)=>`<div class="source-detail-item" data-url="${esc(s.url||'')}"><div class="source-detail-title">${i+1}. ${esc(s.title)}</div><div class="source-detail-content">${esc((s.content||'').slice(0,300))}</div><span class="source-vnews-btn">📖 Xem trên VNEWS</span></div>`).join('');container.appendChild(box);box.querySelectorAll('.source-detail-item').forEach(el=>{el.onclick=function(){let u=el.dataset.url;if(u&&typeof readArticle==='function')readArticle(u);}});det.forEach((s,i)=>{if(!s.url)return;fetch('/api/article?url='+encodeURIComponent(s.url)).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){let items=box.querySelectorAll('.source-detail-item');if(items[i]){let img=document.createElement('img');img.src=d.og_image||d.img;img.loading='lazy';img.onerror=function(){this.style.display='none'};items[i].prepend(img);}}}).catch(()=>{});});}
147
-
148
- // === AI Wall Post View ===
149
  async function readAIWallPost(i){let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];let p=wall[i];if(!p)return;showView('view-article');let h=`<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>${p.img?`<img class="article-img" src="${p.img}">`:''}`;h+=`<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>`;h+=`<div class="article-actions"><button class="primary" onclick="doRewriteTopic(this,'${esc(p.id)}')">🤖 Rewrite AI đăng tường</button>${p.video?`<button onclick="openAIShortFeed(${i})">🎬 Xem Short</button>`:''}<button onclick="doShare('${esc(p.title)}','${location.origin}','${esc(p.img||'')}')">📤</button></div>`;h+=`<div class="article-ai-ask"><h3 style="font-size:14px;color:#5cb87a">🤖 Hỏi AI</h3><textarea id="article-ai-q" placeholder="Hỏi về nội dung..."></textarea><button onclick="askAIWall(${i})">Hỏi</button><div id="article-ai-ans" class="article-ai-answer"></div></div></div>`;document.getElementById('view-article').innerHTML=h;let art=document.querySelector('.article-view');if(art)renderSourceDetails(p,art);window.scrollTo(0,0);}
150
  window.readAIWallPost=readAIWallPost;window.aiReadWallPatched=window.aiReadWall=window.readWallPost=function(i){readAIWallPost(i)};
151
-
152
- // === Short AI Feed: FULL interaction buttons like Dantri Shorts ===
153
  window.openAIShortFeed=async function(startIdx){let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];let vids=wall.filter(p=>p.video);if(!vids.length)return alert('Chưa có Short AI');let ordered=startIdx>0?vids.slice(startIdx).concat(vids.slice(0,startIdx)):vids;showView('view-tiktok');let h='<button class="back-btn" onclick="switchCat(\'home\')">← Short AI</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">';ordered.forEach((p,i)=>{h+=`<div class="tiktok-slide" data-id="${p.id}"><video src="${p.video}" playsinline loop></video><div class="tiktok-bottom"><span class="badge badge-ai">AI Short</span><p class="tiktok-title">${esc(p.title)}</p></div><div class="tiktok-right"><button class="tiktok-right-btn" onclick="event.stopPropagation()"><div class="icon">👁</div><div class="count">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();likeShort('${p.id}',this)"><div class="icon">❤️</div><div class="count">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();openShortComments('${p.id}')"><div class="icon">💬</div><div class="count" id="cc-${p.id}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();shareShort('${esc(p.title)}')"><div class="icon">📤</div><div class="count">Share</div></button></div><span class="tiktok-counter">${i+1}/${ordered.length}</span></div>`});h+='</div></div>';document.getElementById('view-tiktok').innerHTML=h;initShortFeed();ordered.forEach(p=>{fetch('/api/short/comments?id='+encodeURIComponent(p.id)).then(r=>r.json()).then(j=>{let el=document.getElementById('cc-'+p.id);if(el)el.textContent=(j.comments||[]).length}).catch(()=>{});});}
154
  window.likeShort=function(id,btn){let c=btn.querySelector('.count');c.textContent=parseInt(c.textContent||0)+1;}
155
  window.shareShort=function(title){if(navigator.share)navigator.share({title,url:location.href}).catch(()=>{});else{navigator.clipboard.writeText(location.href);alert('Đã sao chép link!');}}
156
  function initShortFeed(){let feed=document.getElementById('tiktok-feed');if(!feed)return;let slides=feed.querySelectorAll('.tiktok-slide');let cur=-1;function act(i){if(i===cur)return;slides.forEach((sl,idx)=>{let v=sl.querySelector('video');let fr=sl.querySelector('iframe');if(idx===i){if(v)v.play().catch(()=>{});if(fr&&!fr.src&&fr.dataset.ytSrc)fr.src=fr.dataset.ytSrc;}else{if(v)v.pause();if(fr&&fr.src)fr.src='';}});cur=i}let t;feed.addEventListener('scroll',()=>{clearTimeout(t);t=setTimeout(()=>{let rect=feed.getBoundingClientRect(),ctr=rect.top+rect.height/2,b=-1,d=1e9;slides.forEach((sl,i)=>{let dd=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(dd<d){d=dd;b=i}});if(b>=0)act(b)},130)});setTimeout(()=>act(0),300);slides.forEach(sl=>{let v=sl.querySelector('video');if(v)v.addEventListener('click',e=>{e.preventDefault();v.paused?v.play().catch(()=>{}):v.pause()})});}
157
-
158
- // === Handlers ===
159
  window.doRewriteTopic=async function(btn,pid){btn.disabled=true;btn.textContent='Đang rewrite...';try{let r=await fetch('/api/topic/rewrite',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({post_id:pid})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');alert('Rewrite thành công!');showRewriteResult(j.post);}catch(e){alert(e.message)}finally{btn.disabled=false;btn.textContent='🤖 Rewrite AI đăng tường';}};
160
  window.doRewriteArticle=async function(btn){let url=(window._currentArticle&&window._currentArticle.url)||'';if(!url){let a=document.querySelector('#view-article a[href*="://"]');if(a)url=a.href;}if(!url){let text=document.querySelector('.article-view')?.innerText?.slice(0,14000)||'';if(text.length<100){alert('Không tìm được nội dung để rewrite');return;}btn.disabled=true;btn.textContent='Đang rewrite...';try{let r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:'https://vnews.local/inline',context:text})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error);alert('Rewrite thành công!');showRewriteResult(j.post);}catch(e){alert(e.message)}finally{btn.disabled=false;btn.textContent='🤖 Rewrite AI đăng tường';}return;}btn.disabled=true;btn.textContent='Đang rewrite...';try{let ctx=document.querySelector('.article-view')?.innerText?.slice(0,14000)||'';let r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,context:ctx})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');alert('Rewrite thành công!');showRewriteResult(j.post);}catch(e){alert(e.message)}finally{btn.disabled=false;btn.textContent='🤖 Rewrite AI đăng tường';}};
161
  function showRewriteResult(post){if(!post)return;showView('view-article');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">Rewrite</span><h1 class="article-title">${esc(post.title)}</h1>${post.img?`<img class="article-img" src="${post.img}">`:''}` +`<p class="article-p" style="white-space:pre-wrap">${esc(post.text)}</p><div class="article-actions"><button class="primary" onclick="makeShortFromPost('${esc(post.id)}',this)">🎬 Tạo Short AI</button><button onclick="doShare('${esc(post.title)}','${location.origin}','${esc(post.img||'')}')">📤</button></div></div>`;window.scrollTo(0,0);}
162
- window.makeShortFromPost=async function(pid,btn){if(btn){btn.disabled=true;btn.textContent='Đang tạo...';}try{let r=await fetch('/api/ai/short/'+pid,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:'nu',emotion:'neutral',speed:1.2})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');alert('Đã tạo Short AI!');renderShortAISlide();}catch(e){alert(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='🎬 Tạo Short AI';}}};
163
  window.rewriteCurrentArticle=function(){let btn=document.querySelector('[data-rw-article]');if(btn)doRewriteArticle(btn);};
164
  window.askAIWall=async function(i){let q=document.getElementById('article-ai-q')?.value.trim();if(!q)return alert('Nhập câu hỏi');document.getElementById('article-ai-ans').textContent='Đang hỏi...';let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];let p=wall[i]||{};let ctx=(p.text||'');for(let s of (p.source_details||[]))ctx+='\n'+(s.content||'');try{let r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({question:q,context:ctx.slice(0,12000)})});let j=await r.json();document.getElementById('article-ai-ans').textContent=j.answer||'Không trả lời được';}catch(e){document.getElementById('article-ai-ans').textContent='Lỗi: '+e.message}};
165
  window.askArticleAI=async function(){let q=document.getElementById('article-ai-question')?.value.trim();if(!q)return alert('Nhập câu hỏi');let a=document.getElementById('article-ai-answer');a.textContent='Đang hỏi...';let url=(window._currentArticle&&window._currentArticle.url)||'';let ctx=document.querySelector('.article-view')?.innerText?.slice(0,12000)||'';try{let r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,question:q,context:ctx})});let j=await r.json();a.textContent=j.answer||'Không trả lời được';}catch(e){a.textContent='Lỗi: '+e.message}};
166
  window.openShortComments=async function(id){let panel=document.getElementById('short-cmt-panel');let j=await fetch('/api/short/comments?id='+encodeURIComponent(id)).then(r=>r.json()).catch(()=>({comments:[]}));panel.innerHTML=`<h3 style="color:#5cb87a">💬 Bình luận</h3><div id="cmt-list">${(j.comments||[]).map(c=>`<div class="cmt-item">${esc(c.text)}</div>`).join('')||'<div class="cmt-item" style="color:#777">Chưa có</div>'}</div><textarea id="cmt-text" placeholder="Bình luận..."></textarea><button onclick="submitShortCmt('${esc(id)}')">Gửi</button><button onclick="document.getElementById('short-cmt-panel').classList.remove('active')">Đóng</button>`;panel.classList.add('active');}
167
  window.submitShortCmt=async function(id){let t=document.getElementById('cmt-text')?.value.trim();if(!t)return;let j=await fetch('/api/short/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,text:t})}).then(r=>r.json()).catch(()=>({comments:[]}));document.getElementById('cmt-list').innerHTML=(j.comments||[]).map(c=>`<div class="cmt-item">${esc(c.text)}</div>`).join('');document.getElementById('cmt-text').value='';let el=document.getElementById('cc-'+id);if(el)el.textContent=(j.comments||[]).length;}
168
-
169
- // === Patch regular articles ===
170
  function patchArticle(){let art=document.querySelector('#view-article .article-view');if(!art)return;art.querySelectorAll('button[onclick*="rewriteCurrentArticle"],[data-rewrite],.rewrite-injected').forEach(e=>e.remove());art.querySelectorAll('.article-ai-ask').forEach((e,i)=>{if(i>0)e.remove();});if(!art.querySelector('[data-rw-article]')){let a=art.querySelector('.article-actions');if(a){let b=document.createElement('button');b.className='primary';b.setAttribute('data-rw-article','1');b.textContent='🤖 Rewrite AI đăng tường';b.onclick=function(){doRewriteArticle(b)};a.insertBefore(b,a.firstChild);}}if(!art.querySelector('.article-ai-ask')){let box=document.createElement('div');box.className='article-ai-ask';box.innerHTML='<h3 style="font-size:14px;color:#5cb87a">🤖 Hỏi AI</h3><textarea id="article-ai-question" placeholder="Hỏi..."></textarea><button onclick="askArticleAI()">Hỏi</button><div id="article-ai-answer" class="article-ai-answer"></div>';art.appendChild(box);}}
171
  function patchShortBtns(){document.querySelectorAll('.tiktok-slide').forEach(sl=>{if(sl.dataset.cmtDone)return;sl.dataset.cmtDone='1';let id=sl.dataset.id||'';if(!id)return;let r=sl.querySelector('.tiktok-right');if(!r||r.querySelector('[data-cmt]'))return;let b=document.createElement('button');b.className='tiktok-right-btn';b.setAttribute('data-cmt','1');b.innerHTML='<div class="icon">💬</div><div class="count">0</div>';b.onclick=function(e){e.stopPropagation();openShortComments(id);};r.appendChild(b);fetch('/api/short/comments?id='+encodeURIComponent(id)).then(r=>r.json()).then(j=>{b.querySelector('.count').textContent=(j.comments||[]).length}).catch(()=>{});});}
172
  function patchOldSourceLinks(){document.querySelectorAll('.source-detail-item a[target="_blank"],.source-detail-item a[href]').forEach(a=>{if(a.dataset.p7)return;a.dataset.p7='1';let url=a.href||'';a.removeAttribute('target');a.removeAttribute('href');a.textContent='📖 Xem trên VNEWS';a.className='source-vnews-btn';a.style.cursor='pointer';a.onclick=function(e){e.preventDefault();e.stopPropagation();if(url&&typeof readArticle==='function')readArticle(url);}});}
173
-
174
  let oldRA=window.readArticle;if(oldRA){window.readArticle=async function(){let ret=await oldRA.apply(this,arguments);setTimeout(patchArticle,500);return ret;}}
175
  let _hl=false;function dH(){if(_hl)return;_hl=true;setTimeout(()=>{if(typeof ensureHotTopics==='function')ensureHotTopics();if(typeof ensureNewsShortsHome==='function')ensureNewsShortsHome();},4000);}
176
  if(document.readyState==='complete')dH();else window.addEventListener('load',dH);
@@ -181,8 +646,8 @@ setInterval(()=>{patchArticle();patchShortBtns();patchOldSourceLinks();},1500);
181
 
182
  @app.get('/')
183
  async def _index():
184
- html=f5.f4.f3.f2.f1._load_index_html()
185
  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
186
  body+=getattr(f6,'FINAL6_INJECT','');body+=getattr(f6,'FINAL6_FAST_HOME_INJECT','');body+=getattr(f6,'FINAL6E_INJECT','')
187
  body+=PATCH_INJECT
188
- return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
 
1
+ """Final patch v6: FIXED short video generation Vietnamese fonts, robust text splitting, no silent errors."""
2
+ import re, threading, time, json, os, asyncio, hashlib, subprocess, requests, sys, logging
3
  import ai_runtime_final6 as f6
4
  from ai_runtime_final6 import app, rt, f5, HTMLResponse, JSONResponse, Request, Query
5
+ from fastapi.responses import FileResponse
6
  import html as html_lib
7
  from urllib.parse import urlparse
8
+ from datetime import datetime, timezone, timedelta
9
+
10
+ _log = logging.getLogger("patch_fast")
11
+ _log.setLevel(logging.INFO)
12
+ if not _log.handlers:
13
+ _log.addHandler(logging.StreamHandler(sys.stderr))
14
 
15
  def clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or ""))).strip()
16
  def _domain(u):
 
20
  os.makedirs(DATA_DIR,exist_ok=True)
21
  SHORT_COMMENTS_FILE=os.path.join(DATA_DIR,'short_comments.json')
22
  TTL_24H=86400;HAS_PERSISTENT=os.path.isdir('/data')
23
+ SHORTS_DIR = os.path.join(DATA_DIR, 'ai_shorts')
24
+ os.makedirs(SHORTS_DIR, exist_ok=True)
25
  def _lj(p,d):
26
  try:
27
  if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8'))
 
36
  def _scrape(url,mc=8000):
37
  try:d=f5.base.scrape_any_url(url);return(d.get('title',''),((d.get('summary','')+'\n'+d.get('text','')).strip())[:mc],d.get('image') or d.get('og_image') or '')
38
  except:return('','','')
39
+
40
+ # ======================================================================
41
+ # VIETNAMESE FONT DETECTION — find ANY font that supports Vietnamese
42
+ # ======================================================================
43
+ _FONT_REGULAR = None
44
+ _FONT_BOLD = None
45
+ _FONT_REGULAR_PATH = None
46
+ _FONT_BOLD_PATH = None
47
+
48
+ def _find_vn_fonts():
49
+ """Find fonts that support Vietnamese diacritics (ấ, ắ, ỗ, ệ, ỷ, etc.)."""
50
+ global _FONT_REGULAR, _FONT_BOLD, _FONT_REGULAR_PATH, _FONT_BOLD_PATH
51
+ if _FONT_REGULAR is not None:
52
+ return True
53
+
54
+ from PIL import ImageFont
55
+
56
+ # Priority 1: Noto Sans (full Vietnamese support)
57
+ candidates_regular = [
58
+ "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
59
+ "/usr/share/fonts/truetype/noto/NotoSans-Variable.ttf",
60
+ "/usr/share/fonts/truetype/vn/VNFont.ttf",
61
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
62
+ "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
63
+ "/usr/share/fonts/truetype/freefont/FreeSans.ttf",
64
+ ]
65
+ candidates_bold = [
66
+ "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf",
67
+ "/usr/share/fonts/truetype/noto/NotoSans-Variable.ttf",
68
+ "/usr/share/fonts/truetype/vn/VNFont-Bold.ttf",
69
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
70
+ "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
71
+ "/usr/share/fonts/truetype/freefont/FreeSans.ttf",
72
+ ]
73
+
74
+ try:
75
+ result = subprocess.run(
76
+ ['find', '/usr/share/fonts', '-name', '*.ttf', '-type', 'f'],
77
+ capture_output=True, text=True, timeout=10
78
+ )
79
+ all_ttf = result.stdout.strip().split('\n')
80
+ for key in ['noto', 'dejavu', 'liberation', 'freefont', 'vn']:
81
+ for path in all_ttf:
82
+ if key in path.lower():
83
+ if 'bold' in path.lower() and _FONT_BOLD_PATH is None:
84
+ _FONT_BOLD_PATH = path
85
+ elif 'regular' in path.lower() and _FONT_REGULAR_PATH is None:
86
+ _FONT_REGULAR_PATH = path
87
+ except:
88
+ pass
89
+
90
+ for path in candidates_regular:
91
+ if os.path.exists(path):
92
+ try:
93
+ _FONT_REGULAR = ImageFont.truetype(path, 40)
94
+ _FONT_REGULAR_PATH = path
95
+ _log.info(f"Font regular: {path}")
96
+ break
97
+ except:
98
+ continue
99
+
100
+ for path in candidates_bold:
101
+ if os.path.exists(path):
102
+ try:
103
+ _FONT_BOLD = ImageFont.truetype(path, 52)
104
+ _FONT_BOLD_PATH = path
105
+ _log.info(f"Font bold: {path}")
106
+ break
107
+ except:
108
+ continue
109
+
110
+ if _FONT_REGULAR is None:
111
+ _FONT_REGULAR = ImageFont.load_default()
112
+ _FONT_BOLD = _FONT_REGULAR
113
+ _log.warning("No TrueType font found! Using PIL default (may not show Vietnamese)")
114
+ if _FONT_BOLD is None:
115
+ _FONT_BOLD = _FONT_REGULAR
116
+
117
+ return True
118
+
119
+ def _split_into_segments(text, max_segments=10, min_len=30):
120
+ """Split text into segments for short video, handling any format."""
121
+ text = clean(text)
122
+ if not text:
123
+ return []
124
+
125
+ # Strategy 1: Try bullet points
126
+ lines = text.split('\n')
127
+ segmented = []
128
+ for line in lines:
129
+ line = clean(line)
130
+ if not line:
131
+ continue
132
+ line_clean = re.sub(r'^[•\-\*\d\.\)\s]+', '', line).strip()
133
+ if len(line_clean) > min_len:
134
+ segmented.append(line_clean)
135
+ elif len(line) > min_len:
136
+ segmented.append(line)
137
+
138
+ # Strategy 2: If no bullet points found, split by sentences
139
+ if len(segmented) < 2:
140
+ sents = re.split(r'(?<=[.!?])\s+(?=[A-Z0-9À-ỸĐ])', text)
141
+ for s in sents:
142
+ s = clean(s)
143
+ if len(s) > min_len:
144
+ segmented.append(s)
145
+
146
+ # Strategy 3: If still no good segments, split by character chunks
147
+ if not segmented:
148
+ words = text.split()
149
+ chunk = []
150
+ char_count = 0
151
+ for w in words:
152
+ chunk.append(w)
153
+ char_count += len(w) + 1
154
+ if char_count > 150:
155
+ segmented.append(' '.join(chunk))
156
+ chunk = []
157
+ char_count = 0
158
+ if chunk:
159
+ segmented.append(' '.join(chunk))
160
+
161
+ # Strategy 4: Last resort
162
+ if not segmented:
163
+ segmented = [text[:300]]
164
+
165
+ return segmented[:max_segments]
166
+
167
+ def _draw_vn_text(draw, text, font, x, y, max_width, max_lines=6, fill=(255,255,255)):
168
+ """Draw text with word wrapping, handling Vietnamese characters."""
169
+ if font is None:
170
+ return y
171
+
172
+ words = text.split()
173
+ lines = []
174
+ current_line = []
175
+
176
+ for w in words:
177
+ test_line = ' '.join(current_line + [w])
178
+ try:
179
+ w_px = font.getlength(test_line)
180
+ except:
181
+ try:
182
+ w_px = draw.textbbox((0,0), test_line, font=font)[2]
183
+ except:
184
+ w_px = len(test_line) * 22
185
+
186
+ if w_px <= max_width:
187
+ current_line.append(w)
188
+ else:
189
+ if current_line:
190
+ lines.append(' '.join(current_line))
191
+ current_line = [w]
192
+ if len(lines) >= max_lines:
193
+ break
194
+
195
+ if current_line and len(lines) < max_lines:
196
+ lines.append(' '.join(current_line))
197
+
198
+ for ln in lines[:max_lines]:
199
+ draw.text((x, y), ln, fill=fill, font=font)
200
+ y += 55
201
+
202
+ return y
203
+
204
+ def _make_frame(post, seg, idx, img_path, downloaded, frame_path):
205
+ """Create a single frame with Vietnamese text support."""
206
+ from PIL import Image, ImageDraw
207
+ _find_vn_fonts()
208
+
209
+ sz = (1080, 1920)
210
+ bg = Image.new('RGB', sz, (15, 23, 38))
211
+ draw = ImageDraw.Draw(bg)
212
+
213
+ # Background image
214
+ if downloaded:
215
+ try:
216
+ im = Image.open(img_path).convert('RGB')
217
+ im = im.resize((1080, 760))
218
+ bg.paste(im, (0, 0))
219
+ overlay = Image.new('RGBA', (1080, 400), (0, 0, 0, 180))
220
+ bg.paste(overlay, (0, 720), overlay)
221
+ except Exception as e:
222
+ _log.warning(f"paste img: {e}")
223
+
224
+ # Title bar
225
+ draw.rectangle([0, 0, sz[0], 100], fill=(25, 118, 210))
226
+ ttl = post.get('title', '')[:50]
227
+ if _FONT_BOLD:
228
+ draw.text((sz[0]//2, 50), ttl, fill='white', font=_FONT_BOLD, anchor='mm')
229
+
230
+ # Segment text
231
+ seg_text = seg[:200]
232
+ _draw_vn_text(draw, seg_text, _FONT_REGULAR, 80, 820, 920, max_lines=8)
233
+
234
+ bg.save(frame_path, quality=88)
235
+ return True
236
+
237
+ def _gen_short_sync(post) -> str:
238
+ """Generate short video NOW. Returns video URL or empty string on failure."""
239
+ post_id = post.get('id', '')
240
+ if not post_id:
241
+ _log.error("_gen_short_sync: no post_id")
242
+ return ''
243
+
244
+ text = post.get('text', '') or post.get('title', '')
245
+ if len(text) < 100:
246
+ _log.warning(f"_gen_short_sync: text too short ({len(text)})")
247
+ return ''
248
+
249
+ segments = _split_into_segments(text, max_segments=10, min_len=30)
250
+ if not segments:
251
+ _log.error("_gen_short_sync: no segments after splitting")
252
+ return ''
253
+
254
+ _log.info(f"Segments: {len(segments)} (first: '{segments[0][:50]}...')")
255
+
256
+ seg_hash = hashlib.md5(('|'.join(segments) + 'nu' + 'neutral' + '1.0').encode('utf-8')).hexdigest()[:8]
257
+ suffix = f"_nu_neutral_1p0_{seg_hash}_scenes_nosub"
258
+ out_name = f5.base._safe_name(post_id + suffix) + '.mp4'
259
+ out_mp4 = os.path.join(SHORTS_DIR, out_name)
260
+ video_url = '/api/ai/short-file/' + post_id + suffix
261
+
262
+ if os.path.exists(out_mp4):
263
+ _log.info(f"_gen_short_sync: already exists {out_mp4}")
264
+ return video_url
265
+
266
+ work = os.path.join(SHORTS_DIR, f5.base._safe_name(post_id + suffix))
267
+ os.makedirs(work, exist_ok=True)
268
+
269
+ # Download image
270
+ img_path = os.path.join(work, 'image.jpg')
271
+ downloaded = False
272
+ try:
273
+ img_url = post.get('img', '')
274
+ if img_url and img_url.startswith('http'):
275
+ r = requests.get(img_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=15)
276
+ if r.status_code == 200:
277
+ with open(img_path, 'wb') as f:
278
+ f.write(r.content)
279
+ downloaded = True
280
+ except Exception as e:
281
+ _log.warning(f"download image: {e}")
282
+
283
+ try:
284
+ from PIL import Image, ImageDraw, ImageFont
285
+ has_pil = True
286
+ except Exception as e:
287
+ _log.warning(f"PIL import: {e}")
288
+ has_pil = False
289
+
290
+ try:
291
+ subprocess.run(['ffmpeg', '-version'], capture_output=True, timeout=5)
292
+ except:
293
+ _log.error("ffmpeg not found!")
294
+ return ''
295
+
296
+ try:
297
+ from gtts import gTTS
298
+ has_tts = True
299
+ except Exception as e:
300
+ _log.warning(f"gTTS: {e}, using silent video")
301
+ has_tts = False
302
+
303
+ part_files = []
304
+ errors = []
305
+
306
+ for idx, seg in enumerate(segments[:10]):
307
+ frame_path = os.path.join(work, f'frame_{idx:02d}.jpg')
308
+ audio_path = os.path.join(work, f'voice_{idx:02d}.mp3')
309
+ audio_fast = os.path.join(work, f'voice_{idx:02d}_fast.mp3')
310
+ part_path = os.path.join(work, f'part_{idx:02d}.mp4')
311
+
312
+ # ----- Frame -----
313
+ frame_ok = False
314
+ if has_pil:
315
+ try:
316
+ _make_frame(post, seg, idx, img_path, downloaded, frame_path)
317
+ if os.path.exists(frame_path) and os.path.getsize(frame_path) > 1000:
318
+ frame_ok = True
319
+ else:
320
+ _log.warning(f"frame {idx}: file too small or missing")
321
+ except Exception as e:
322
+ _log.error(f"frame {idx} creation: {e}")
323
+ errors.append(f"frame{idx}: {e}")
324
+
325
+ if not frame_ok:
326
+ try:
327
+ subprocess.run(['ffmpeg', '-y', '-f', 'lavfi', '-i', 'color=c=0x0f1726:s=1080x1920:d=1',
328
+ '-frames:v', '1', frame_path], capture_output=True, timeout=30)
329
+ frame_ok = os.path.exists(frame_path)
330
+ except:
331
+ continue
332
+
333
+ # ----- Audio -----
334
+ dur = 10.0
335
+ if has_tts:
336
+ try:
337
+ tts_text = re.sub(r'^[•\-\*\d\.\)\s]+', '', seg).strip()[:300]
338
+ if tts_text:
339
+ gTTS(tts_text, lang='vi', slow=False).save(audio_path)
340
+ subprocess.run(['ffmpeg', '-y', '-i', audio_path, '-vn', '-c:a', 'libmp3lame', audio_fast],
341
+ check=True, capture_output=True, timeout=90)
342
+ pr = subprocess.run(['ffprobe', '-v', 'error', '-show_entries', 'format=duration',
343
+ '-of', 'default=noprint_wrappers=1:no_key=1', audio_fast],
344
+ capture_output=True, timeout=20)
345
+ dur = max(6.0, float((pr.stdout or b'').decode().strip() or 10.0)) + 1.0
346
+ else:
347
+ dur = 8.0
348
+ except Exception as e:
349
+ _log.warning(f"TTS part {idx}: {e}")
350
+ errors.append(f"tts{idx}: {e}")
351
+ dur = 8.0
352
+
353
+ # ----- Combine frame + audio -----
354
+ try:
355
+ cmd = ['ffmpeg', '-y', '-loop', '1', '-t', str(dur), '-i', frame_path]
356
+ if has_tts and os.path.exists(audio_fast):
357
+ cmd += ['-i', audio_fast, '-shortest']
358
+ else:
359
+ cmd += ['-f', 'lavfi', '-i', 'anullsrc=r=44100:cl=mono', '-shortest']
360
+ cmd += ['-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p',
361
+ '-c:a', 'aac', '-b:a', '128k',
362
+ '-preset', 'fast', '-crf', '23',
363
+ part_path]
364
+ subprocess.run(cmd, check=True, capture_output=True, timeout=150)
365
+ if os.path.exists(part_path) and os.path.getsize(part_path) > 5000:
366
+ part_files.append(part_path)
367
+ _log.info(f"Part {idx}: {os.path.getsize(part_path)} bytes")
368
+ else:
369
+ _log.warning(f"Part {idx}: file too small or missing")
370
+ except Exception as e:
371
+ _log.warning(f"ffmpeg part {idx}: {e}")
372
+ errors.append(f"part{idx}: {e}")
373
+
374
+ if part_files:
375
+ try:
376
+ concat_file = os.path.join(work, 'concat.txt')
377
+ with open(concat_file, 'w', encoding='utf-8') as f:
378
+ for p in part_files:
379
+ f.write("file '" + p.replace("'", "'\\''") + "'\n")
380
+ subprocess.run(['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', concat_file, '-c', 'copy', out_mp4],
381
+ check=True, capture_output=True, timeout=180)
382
+ _log.info(f"Short video generated: {out_mp4} ({len(part_files)} parts, {os.path.getsize(out_mp4)} bytes)")
383
+
384
+ post['video'] = video_url
385
+ post['short_voice'] = 'nu'
386
+ post['short_emotion'] = 'neutral'
387
+ post['short_speed'] = 1.0
388
+ post['short_segments'] = segments
389
+ post['short_subtitles'] = False
390
+
391
+ wall = f5.base._load_ai_wall()
392
+ for i, p in enumerate(wall):
393
+ if p.get('id') == post_id:
394
+ wall[i] = post
395
+ break
396
+ f5.base._save_ai_wall(wall)
397
+ return video_url
398
+ except Exception as e:
399
+ _log.error(f"concat: {e}")
400
+ errors.append(f"concat: {e}")
401
+ else:
402
+ _log.error(f"No part files generated! Errors: {'; '.join(errors)}")
403
+ return ''
404
+
405
+ # ===== Auto Scheduler Trigger =====
406
+ _AUTO_SCHEDULER_RUNNING = False
407
+ _AUTO_SCHEDULER_LAST = 0
408
+ _AUTO_SCHEDULER_RESULTS = []
409
+
410
+ def _run_scheduler_now():
411
+ """Trigger auto_scheduler._run_scheduled_posting() safely."""
412
+ global _AUTO_SCHEDULER_RUNNING, _AUTO_SCHEDULER_LAST, _AUTO_SCHEDULER_RESULTS
413
+ if _AUTO_SCHEDULER_RUNNING:
414
+ _log.warning("Scheduler already running!")
415
+ return {"status": "running", "message": "Scheduler đang chạy, vui lòng đợi."}
416
+ _AUTO_SCHEDULER_RUNNING = True
417
+ try:
418
+ import auto_scheduler as _as
419
+ _as.LOG.info("Triggered manually via API")
420
+ _as._run_scheduled_posting()
421
+ _AUTO_SCHEDULER_LAST = time.time()
422
+ _AUTO_SCHEDULER_RESULTS = [{'time': datetime.now().strftime('%H:%M %d/%m/%Y'), 'status': 'done'}]
423
+ _log.info("Auto scheduler completed successfully")
424
+ return {"status": "done", "message": "Đã đăng 3 bài tự động thành công!"}
425
+ except Exception as e:
426
+ _log.error(f"Auto scheduler failed: {e}", exc_info=True)
427
+ return {"status": "error", "message": f"Lỗi: {e}"}
428
+ finally:
429
+ _AUTO_SCHEDULER_RUNNING = False
430
+
431
+ @app.post('/api/auto/schedule')
432
+ async def _api_auto_schedule():
433
+ _log.info("POST /api/auto/schedule triggered")
434
+ result = await asyncio.get_event_loop().run_in_executor(None, _run_scheduler_now)
435
+ return JSONResponse(result)
436
+
437
+ @app.get('/api/auto/status')
438
+ async def _api_auto_status():
439
+ return JSONResponse({
440
+ 'running': _AUTO_SCHEDULER_RUNNING,
441
+ 'last_run': _AUTO_SCHEDULER_LAST,
442
+ 'last_run_str': datetime.fromtimestamp(_AUTO_SCHEDULER_LAST).strftime('%H:%M %d/%m/%Y') if _AUTO_SCHEDULER_LAST else 'never',
443
+ 'results': _AUTO_SCHEDULER_RESULTS
444
+ })
445
+
446
  _bg_home={"t":0,"d":[]};_bg_shorts={"t":0,"d":[]};_bg_lock=False
447
  def _bg():
448
  global _bg_lock
 
459
  @app.on_event("startup")
460
  async def _s():threading.Thread(target=_bg,daemon=True).start()
461
  threading.Thread(target=lambda:[time.sleep(600) or _bg() for _ in iter(int,1)],daemon=True).start()
462
+ app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None) in ('/api/homepage','/api/shorts','/api/ai_wall','/api/topic_post','/api/article/ask','/api/topic/rewrite','/api/rewrite_share','/api/url_wall','/api/short/comments','/api/short/comment','/api/storage_status','/api/ai/short/{post_id}','/api/ai/short-file/{file_id}','/api/auto/schedule','/api/auto/status','/') and any(m in getattr(r,'methods',set()) for m in ('GET','POST')))]
463
  @app.get('/api/homepage')
464
  def _h():
465
  n=time.time()
 
491
  if not raw:raw=ctx[:12000]
492
  ans=await f5.base.qwen_generate(f'Bạn là VNEWS AI. Nội dung: "{title}"\n{raw[:9000]}\n\nHỏi: "{q}"\n\nTrả lời tự nhiên bằng tiếng Việt.',max_tokens=1200)
493
  return JSONResponse({'answer':ans or 'Chưa trả lời được.','title':title})
494
+
495
+ # ===== SHORT VIDEO ENDPOINT (SYNCHRONOUS) =====
496
+ @app.post('/api/ai/short/{post_id}')
497
+ async def api_ai_short_generate(post_id: str, request: Request):
498
+ wall = f5.base._load_ai_wall()
499
+ post = None
500
+ for p in wall:
501
+ if p.get('id') == post_id:
502
+ post = p
503
+ break
504
+ if not post:
505
+ return JSONResponse({'error': 'Post not found'}, status_code=404)
506
+ if post.get('video'):
507
+ return JSONResponse({'post': post, 'video': post['video'], 'status': 'done'})
508
+ _log.info(f"Generating short for post {post_id}...")
509
+ loop = asyncio.get_event_loop()
510
+ video_url = await loop.run_in_executor(None, _gen_short_sync, post)
511
+ if video_url:
512
+ _log.info(f"Short generated: {video_url}")
513
+ return JSONResponse({'post': post, 'video': video_url, 'status': 'done'})
514
+ else:
515
+ _log.error(f"Short generation FAILED for {post_id}")
516
+ return JSONResponse({'error': 'Không thể tạo video. Kiểm tra logs Space để biết chi tiết.'}, status_code=500)
517
+
518
+ @app.get('/api/ai/short-file/{file_id:path}')
519
+ async def api_ai_short_file(file_id: str):
520
+ safe_name = f5.base._safe_name(file_id)
521
+ fpath = os.path.join(SHORTS_DIR, safe_name + '.mp4')
522
+ if os.path.exists(fpath):
523
+ return FileResponse(fpath, media_type='video/mp4')
524
+ for fname in os.listdir(SHORTS_DIR):
525
+ if fname.endswith('.mp4') and (safe_name in fname or file_id in fname):
526
+ fpath = os.path.join(SHORTS_DIR, fname)
527
+ if os.path.exists(fpath):
528
+ return FileResponse(fpath, media_type='video/mp4')
529
+ base_id = f5.base._safe_name((file_id.split('_')[0] if '_' in file_id else file_id))
530
+ for fname in os.listdir(SHORTS_DIR):
531
+ if fname.endswith('.mp4') and fname.startswith(base_id):
532
+ return FileResponse(os.path.join(SHORTS_DIR, fname), media_type='video/mp4')
533
+ return JSONResponse({'error': 'File not found'}, status_code=404)
534
+
535
+ # ===== REWRITE =====
536
  @app.post('/api/rewrite_share')
537
  @app.post('/api/url_wall')
538
  async def _rw(request:Request):
 
541
  title,raw,img=_scrape(url,14000)
542
  if len(raw)<50:raw=ctx[:14000]
543
  if len(raw)<50:return JSONResponse({'error':'Không đọc được bài'},status_code=422)
544
+ og_img = img if img and img.startswith('http') else ''
545
+ if not og_img:
546
+ try:
547
+ d = f5.base.scrape_any_url(url)
548
+ if d: og_img = d.get('og_image', '') or d.get('image', '') or ''
549
+ except: pass
550
+ if not og_img:
551
+ og_img = 'https://image.pollinations.ai/prompt/Vietnamese%20news%20update'
552
  text=None
553
+ try:text=await asyncio.wait_for(f5.base.qwen_generate(f'Tóm tắt đăng Tường AI:\nTiêu đề: {title}\n{raw[:14000]}\n\n4-6 ý chính. Cuối ghi nguồn.',image_url=og_img or None,max_tokens=1000),timeout=30)
554
  except:pass
555
  if not text or len(text)<80:text=f"Tóm tắt: {title}\n\n{raw[:1200]}\n\nNguồn: {_domain(url)}"
556
+ paragraphs = [p.strip() for p in raw.split('\n') if len(p.strip()) > 60]
557
+ if not paragraphs:
558
+ paragraphs = [p.strip() for p in raw.split('\n') if len(p.strip()) > 20]
559
+ slides = []
560
+ for i, pt in enumerate(paragraphs[:8]):
561
+ slides.append({'text': pt, 'image': og_img, 'index': i + 1})
562
+ post=f5.base.make_post(title or 'Bài viết',text,og_img,url,'rewrite',sources=[{'title':title,'url':url,'via':_domain(url)}])
563
+ post['images'] = [og_img] if og_img else []
564
+ post['slides'] = slides
565
+ ps=f5.base._load_ai_wall();ps.insert(0,post);f5.base._save_ai_wall(ps)
566
+ if len(text) > 100:
567
+ t = threading.Thread(target=_gen_short_sync, args=(post,), daemon=True)
568
+ t.start()
569
+ return JSONResponse({'post':post})
570
+
571
  @app.post('/api/topic/rewrite')
572
  async def _tr(request:Request):
573
  b=await request.json();pid=str(b.get('post_id','')).strip()
 
607
  .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:60px;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}
608
  .storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 4px}
609
  button[onclick*="rewriteCurrentArticle"]{display:none!important}
 
610
  #ai-short-home,.ai-short-home,.ai-short-card-final{display:none!important}
611
  .source-detail-box a[target="_blank"]{display:none!important}
612
  </style>
 
615
  (function(){
616
  function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
617
  fetch('/api/storage_status').then(r=>r.json()).then(j=>{if(!j.persistent){let h=document.getElementById('view-home');if(h){let w=document.createElement('div');w.className='storage-warn';w.innerHTML='⚠️ <b>Persistent Storage chưa bật.</b> Bật: Space Settings → Persistent Storage → Small.';h.prepend(w);}}});
 
 
618
  async function renderShortAISlide(){let home=document.getElementById('view-home');if(!home)return;document.getElementById('short-ai-final-slide')?.remove();let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];let vids=wall.filter(p=>p.video);if(!vids.length)return;let wrap=document.createElement('div');wrap.id='short-ai-final-slide';wrap.className='slider-wrap';wrap.innerHTML='<div class="slider-header"><span class="slider-label">🎬 Short AI</span></div><div class="slider-track">'+vids.slice(0,30).map((p,i)=>`<div class="slider-item shorts-item" onclick="openAIShortFeed(${i})"><div class="slider-thumb shorts-thumb"><video src="${p.video}" muted preload="metadata" style="width:100%;height:100%;object-fit:cover"></video><div class="card-play">▶</div></div><div class="slider-title">${esc(p.title)}</div></div>`).join('')+'</div>';let comp=home.querySelector('.ai-compose');if(comp&&comp.nextSibling)comp.parentNode.insertBefore(wrap,comp.nextSibling);else home.prepend(wrap);}
619
+ setTimeout(renderShortAISlide,2500);setInterval(renderShortAISlide,15000);
 
 
620
  function renderSourceDetails(post,container){let det=post.source_details||[];if(!det.length)return;container.querySelectorAll('.source-detail-box').forEach(e=>e.remove());let box=document.createElement('div');box.className='source-detail-box';box.innerHTML='<h3 style="font-size:14px;color:#5cb87a;margin-bottom:8px">📚 Bài nguồn</h3>'+det.map((s,i)=>`<div class="source-detail-item" data-url="${esc(s.url||'')}"><div class="source-detail-title">${i+1}. ${esc(s.title)}</div><div class="source-detail-content">${esc((s.content||'').slice(0,300))}</div><span class="source-vnews-btn">📖 Xem trên VNEWS</span></div>`).join('');container.appendChild(box);box.querySelectorAll('.source-detail-item').forEach(el=>{el.onclick=function(){let u=el.dataset.url;if(u&&typeof readArticle==='function')readArticle(u);}});det.forEach((s,i)=>{if(!s.url)return;fetch('/api/article?url='+encodeURIComponent(s.url)).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){let items=box.querySelectorAll('.source-detail-item');if(items[i]){let img=document.createElement('img');img.src=d.og_image||d.img;img.loading='lazy';img.onerror=function(){this.style.display='none'};items[i].prepend(img);}}}).catch(()=>{});});}
 
 
621
  async function readAIWallPost(i){let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];let p=wall[i];if(!p)return;showView('view-article');let h=`<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>${p.img?`<img class="article-img" src="${p.img}">`:''}`;h+=`<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>`;h+=`<div class="article-actions"><button class="primary" onclick="doRewriteTopic(this,'${esc(p.id)}')">🤖 Rewrite AI đăng tường</button>${p.video?`<button onclick="openAIShortFeed(${i})">🎬 Xem Short</button>`:''}<button onclick="doShare('${esc(p.title)}','${location.origin}','${esc(p.img||'')}')">📤</button></div>`;h+=`<div class="article-ai-ask"><h3 style="font-size:14px;color:#5cb87a">🤖 Hỏi AI</h3><textarea id="article-ai-q" placeholder="Hỏi về nội dung..."></textarea><button onclick="askAIWall(${i})">Hỏi</button><div id="article-ai-ans" class="article-ai-answer"></div></div></div>`;document.getElementById('view-article').innerHTML=h;let art=document.querySelector('.article-view');if(art)renderSourceDetails(p,art);window.scrollTo(0,0);}
622
  window.readAIWallPost=readAIWallPost;window.aiReadWallPatched=window.aiReadWall=window.readWallPost=function(i){readAIWallPost(i)};
 
 
623
  window.openAIShortFeed=async function(startIdx){let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];let vids=wall.filter(p=>p.video);if(!vids.length)return alert('Chưa có Short AI');let ordered=startIdx>0?vids.slice(startIdx).concat(vids.slice(0,startIdx)):vids;showView('view-tiktok');let h='<button class="back-btn" onclick="switchCat(\'home\')">← Short AI</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">';ordered.forEach((p,i)=>{h+=`<div class="tiktok-slide" data-id="${p.id}"><video src="${p.video}" playsinline loop></video><div class="tiktok-bottom"><span class="badge badge-ai">AI Short</span><p class="tiktok-title">${esc(p.title)}</p></div><div class="tiktok-right"><button class="tiktok-right-btn" onclick="event.stopPropagation()"><div class="icon">👁</div><div class="count">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();likeShort('${p.id}',this)"><div class="icon">❤️</div><div class="count">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();openShortComments('${p.id}')"><div class="icon">💬</div><div class="count" id="cc-${p.id}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();shareShort('${esc(p.title)}')"><div class="icon">📤</div><div class="count">Share</div></button></div><span class="tiktok-counter">${i+1}/${ordered.length}</span></div>`});h+='</div></div>';document.getElementById('view-tiktok').innerHTML=h;initShortFeed();ordered.forEach(p=>{fetch('/api/short/comments?id='+encodeURIComponent(p.id)).then(r=>r.json()).then(j=>{let el=document.getElementById('cc-'+p.id);if(el)el.textContent=(j.comments||[]).length}).catch(()=>{});});}
624
  window.likeShort=function(id,btn){let c=btn.querySelector('.count');c.textContent=parseInt(c.textContent||0)+1;}
625
  window.shareShort=function(title){if(navigator.share)navigator.share({title,url:location.href}).catch(()=>{});else{navigator.clipboard.writeText(location.href);alert('Đã sao chép link!');}}
626
  function initShortFeed(){let feed=document.getElementById('tiktok-feed');if(!feed)return;let slides=feed.querySelectorAll('.tiktok-slide');let cur=-1;function act(i){if(i===cur)return;slides.forEach((sl,idx)=>{let v=sl.querySelector('video');let fr=sl.querySelector('iframe');if(idx===i){if(v)v.play().catch(()=>{});if(fr&&!fr.src&&fr.dataset.ytSrc)fr.src=fr.dataset.ytSrc;}else{if(v)v.pause();if(fr&&fr.src)fr.src='';}});cur=i}let t;feed.addEventListener('scroll',()=>{clearTimeout(t);t=setTimeout(()=>{let rect=feed.getBoundingClientRect(),ctr=rect.top+rect.height/2,b=-1,d=1e9;slides.forEach((sl,i)=>{let dd=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(dd<d){d=dd;b=i}});if(b>=0)act(b)},130)});setTimeout(()=>act(0),300);slides.forEach(sl=>{let v=sl.querySelector('video');if(v)v.addEventListener('click',e=>{e.preventDefault();v.paused?v.play().catch(()=>{}):v.pause()})});}
 
 
627
  window.doRewriteTopic=async function(btn,pid){btn.disabled=true;btn.textContent='Đang rewrite...';try{let r=await fetch('/api/topic/rewrite',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({post_id:pid})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');alert('Rewrite thành công!');showRewriteResult(j.post);}catch(e){alert(e.message)}finally{btn.disabled=false;btn.textContent='🤖 Rewrite AI đăng tường';}};
628
  window.doRewriteArticle=async function(btn){let url=(window._currentArticle&&window._currentArticle.url)||'';if(!url){let a=document.querySelector('#view-article a[href*="://"]');if(a)url=a.href;}if(!url){let text=document.querySelector('.article-view')?.innerText?.slice(0,14000)||'';if(text.length<100){alert('Không tìm được nội dung để rewrite');return;}btn.disabled=true;btn.textContent='Đang rewrite...';try{let r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:'https://vnews.local/inline',context:text})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error);alert('Rewrite thành công!');showRewriteResult(j.post);}catch(e){alert(e.message)}finally{btn.disabled=false;btn.textContent='🤖 Rewrite AI đăng tường';}return;}btn.disabled=true;btn.textContent='Đang rewrite...';try{let ctx=document.querySelector('.article-view')?.innerText?.slice(0,14000)||'';let r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,context:ctx})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');alert('Rewrite thành công!');showRewriteResult(j.post);}catch(e){alert(e.message)}finally{btn.disabled=false;btn.textContent='🤖 Rewrite AI đăng tường';}};
629
  function showRewriteResult(post){if(!post)return;showView('view-article');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">Rewrite</span><h1 class="article-title">${esc(post.title)}</h1>${post.img?`<img class="article-img" src="${post.img}">`:''}` +`<p class="article-p" style="white-space:pre-wrap">${esc(post.text)}</p><div class="article-actions"><button class="primary" onclick="makeShortFromPost('${esc(post.id)}',this)">🎬 Tạo Short AI</button><button onclick="doShare('${esc(post.title)}','${location.origin}','${esc(post.img||'')}')">📤</button></div></div>`;window.scrollTo(0,0);}
630
+ window.makeShortFromPost=async function(pid,btn){if(btn){btn.disabled=true;btn.textContent='Đang tạo video...';}try{let r=await fetch('/api/ai/short/'+pid,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:'nu',emotion:'neutral',speed:1.2})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');if(j.video){alert('Đã tạo Short AI thành công!');renderShortAISlide();}else{alert(j.message||'Đã tạo!');renderShortAISlide();}}catch(e){alert('Lỗi: '+e.message)}finally{if(btn){btn.disabled=false;btn.textContent='🎬 Tạo Short AI';}}};
631
  window.rewriteCurrentArticle=function(){let btn=document.querySelector('[data-rw-article]');if(btn)doRewriteArticle(btn);};
632
  window.askAIWall=async function(i){let q=document.getElementById('article-ai-q')?.value.trim();if(!q)return alert('Nhập câu hỏi');document.getElementById('article-ai-ans').textContent='Đang hỏi...';let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];let p=wall[i]||{};let ctx=(p.text||'');for(let s of (p.source_details||[]))ctx+='\n'+(s.content||'');try{let r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({question:q,context:ctx.slice(0,12000)})});let j=await r.json();document.getElementById('article-ai-ans').textContent=j.answer||'Không trả lời được';}catch(e){document.getElementById('article-ai-ans').textContent='Lỗi: '+e.message}};
633
  window.askArticleAI=async function(){let q=document.getElementById('article-ai-question')?.value.trim();if(!q)return alert('Nhập câu hỏi');let a=document.getElementById('article-ai-answer');a.textContent='Đang hỏi...';let url=(window._currentArticle&&window._currentArticle.url)||'';let ctx=document.querySelector('.article-view')?.innerText?.slice(0,12000)||'';try{let r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,question:q,context:ctx})});let j=await r.json();a.textContent=j.answer||'Không trả lời được';}catch(e){a.textContent='Lỗi: '+e.message}};
634
  window.openShortComments=async function(id){let panel=document.getElementById('short-cmt-panel');let j=await fetch('/api/short/comments?id='+encodeURIComponent(id)).then(r=>r.json()).catch(()=>({comments:[]}));panel.innerHTML=`<h3 style="color:#5cb87a">💬 Bình luận</h3><div id="cmt-list">${(j.comments||[]).map(c=>`<div class="cmt-item">${esc(c.text)}</div>`).join('')||'<div class="cmt-item" style="color:#777">Chưa có</div>'}</div><textarea id="cmt-text" placeholder="Bình luận..."></textarea><button onclick="submitShortCmt('${esc(id)}')">Gửi</button><button onclick="document.getElementById('short-cmt-panel').classList.remove('active')">Đóng</button>`;panel.classList.add('active');}
635
  window.submitShortCmt=async function(id){let t=document.getElementById('cmt-text')?.value.trim();if(!t)return;let j=await fetch('/api/short/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,text:t})}).then(r=>r.json()).catch(()=>({comments:[]}));document.getElementById('cmt-list').innerHTML=(j.comments||[]).map(c=>`<div class="cmt-item">${esc(c.text)}</div>`).join('');document.getElementById('cmt-text').value='';let el=document.getElementById('cc-'+id);if(el)el.textContent=(j.comments||[]).length;}
 
 
636
  function patchArticle(){let art=document.querySelector('#view-article .article-view');if(!art)return;art.querySelectorAll('button[onclick*="rewriteCurrentArticle"],[data-rewrite],.rewrite-injected').forEach(e=>e.remove());art.querySelectorAll('.article-ai-ask').forEach((e,i)=>{if(i>0)e.remove();});if(!art.querySelector('[data-rw-article]')){let a=art.querySelector('.article-actions');if(a){let b=document.createElement('button');b.className='primary';b.setAttribute('data-rw-article','1');b.textContent='🤖 Rewrite AI đăng tường';b.onclick=function(){doRewriteArticle(b)};a.insertBefore(b,a.firstChild);}}if(!art.querySelector('.article-ai-ask')){let box=document.createElement('div');box.className='article-ai-ask';box.innerHTML='<h3 style="font-size:14px;color:#5cb87a">🤖 Hỏi AI</h3><textarea id="article-ai-question" placeholder="Hỏi..."></textarea><button onclick="askArticleAI()">Hỏi</button><div id="article-ai-answer" class="article-ai-answer"></div>';art.appendChild(box);}}
637
  function patchShortBtns(){document.querySelectorAll('.tiktok-slide').forEach(sl=>{if(sl.dataset.cmtDone)return;sl.dataset.cmtDone='1';let id=sl.dataset.id||'';if(!id)return;let r=sl.querySelector('.tiktok-right');if(!r||r.querySelector('[data-cmt]'))return;let b=document.createElement('button');b.className='tiktok-right-btn';b.setAttribute('data-cmt','1');b.innerHTML='<div class="icon">💬</div><div class="count">0</div>';b.onclick=function(e){e.stopPropagation();openShortComments(id);};r.appendChild(b);fetch('/api/short/comments?id='+encodeURIComponent(id)).then(r=>r.json()).then(j=>{b.querySelector('.count').textContent=(j.comments||[]).length}).catch(()=>{});});}
638
  function patchOldSourceLinks(){document.querySelectorAll('.source-detail-item a[target="_blank"],.source-detail-item a[href]').forEach(a=>{if(a.dataset.p7)return;a.dataset.p7='1';let url=a.href||'';a.removeAttribute('target');a.removeAttribute('href');a.textContent='📖 Xem trên VNEWS';a.className='source-vnews-btn';a.style.cursor='pointer';a.onclick=function(e){e.preventDefault();e.stopPropagation();if(url&&typeof readArticle==='function')readArticle(url);}});}
 
639
  let oldRA=window.readArticle;if(oldRA){window.readArticle=async function(){let ret=await oldRA.apply(this,arguments);setTimeout(patchArticle,500);return ret;}}
640
  let _hl=false;function dH(){if(_hl)return;_hl=true;setTimeout(()=>{if(typeof ensureHotTopics==='function')ensureHotTopics();if(typeof ensureNewsShortsHome==='function')ensureNewsShortsHome();},4000);}
641
  if(document.readyState==='complete')dH();else window.addEventListener('load',dH);
 
646
 
647
  @app.get('/')
648
  async def _index():
649
+ html=open('/app/static/index_v2.html','r',encoding='utf-8').read() if os.path.exists('/app/static/index_v2.html') else ('<html><body>VNEWS</body></html>' if os.path.exists('/app/static/index.html') else '')
650
  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
651
  body+=getattr(f6,'FINAL6_INJECT','');body+=getattr(f6,'FINAL6_FAST_HOME_INJECT','');body+=getattr(f6,'FINAL6E_INJECT','')
652
  body+=PATCH_INJECT
653
+ return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)