Spaces:
Running
Running
Restore Space to revision 33c3dda
Browse files- ai_ext.py +4 -0
- ai_patch.py +565 -253
- static/index.html +63 -46
ai_ext.py
CHANGED
|
@@ -376,6 +376,7 @@ def _make_short_frame(post, img_path, out_path):
|
|
| 376 |
bg=Image.new("RGB",(W,H),(14,14,14))
|
| 377 |
try:
|
| 378 |
im=Image.open(img_path).convert("RGB")
|
|
|
|
| 379 |
target=(1080,860)
|
| 380 |
im_ratio=im.width/im.height; target_ratio=target[0]/target[1]
|
| 381 |
if im_ratio>target_ratio:
|
|
@@ -412,6 +413,7 @@ def _download_image(url, fallback_topic, out_path):
|
|
| 412 |
with open(out_path,"wb") as f:f.write(r.content)
|
| 413 |
return out_path
|
| 414 |
except Exception: pass
|
|
|
|
| 415 |
gen=pollinations_image_url(fallback_topic)
|
| 416 |
try:
|
| 417 |
r=requests.get(gen,headers=HEADERS,timeout=25)
|
|
@@ -427,6 +429,7 @@ def _download_image(url, fallback_topic, out_path):
|
|
| 427 |
|
| 428 |
def _short_script(post):
|
| 429 |
txt=_clean_text(post.get("text",""))
|
|
|
|
| 430 |
if len(txt)>900: txt=txt[:900].rsplit(" ",1)[0]+"."
|
| 431 |
return f"{post.get('title','')}. {txt}"
|
| 432 |
|
|
@@ -466,6 +469,7 @@ def api_ai_short_file(post_id:str):
|
|
| 466 |
return FileResponse(path,media_type="video/mp4",filename=f"vnews-ai-{post_id}.mp4")
|
| 467 |
|
| 468 |
|
|
|
|
| 469 |
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
|
| 470 |
AI_INJECT=r'''
|
| 471 |
<style>.ai-wall-extra{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.ai-wall-card{flex:0 0 250px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:8px}.ai-wall-img{width:100%;aspect-ratio:16/9;background:#222;border-radius:8px;overflow:hidden;margin-bottom:6px}.ai-wall-img img{width:100%;height:100%;object-fit:cover}.ai-wall-title{font-size:12px;color:#5cb87a;font-weight:800;line-height:1.3;margin-bottom:4px}.ai-wall-text{font-size:11px;color:#bbb;line-height:1.45;white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden}.ai-wall-actions{display:flex;gap:6px;margin-top:8px}.ai-wall-actions button{flex:1;border:1px solid #333;background:#222;color:#ddd;border-radius:14px;padding:6px 8px;font-size:10px}.ai-wall-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}</style><script>(function(){function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}let aiWall=[];async function refreshAiWall(){try{const r=await fetch('/api/ai_wall');const j=await r.json();aiWall=j.posts||[];renderAiWall();}catch(e){}}function renderAiWall(){const home=document.getElementById('view-home');if(!home)return;let old=document.getElementById('ai-wall-block');if(old)old.remove();if(!aiWall.length)return;let wrap=document.createElement('div');wrap.id='ai-wall-block';wrap.className='ai-wall-extra';let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track">';aiWall.slice(0,30).forEach((p,i)=>{h+=`<div class="ai-wall-card"><div class="ai-wall-img">${p.img?`<img src="${p.img}">`:''}</div><div class="ai-wall-title">${esc(p.title)}</div><div class="ai-wall-text">${esc(p.text)}</div><div class="ai-wall-actions"><button onclick="aiReadWall(${i})">Xem</button><button class="primary" onclick="aiMakeShort(${i})">Shorts</button></div></div>`});h+='</div>';wrap.innerHTML=h;home.prepend(wrap)}window.aiReadWall=function(i){const p=aiWall[i];if(!p)return;showView('view-article');let sources='';if(p.sources&&p.sources.length){sources='<div class="article-summary"><b>Nguồn tham khảo:</b><br>'+p.sources.slice(0,5).map(s=>`• ${esc(s.title||s.url||'Nguồn')} ${s.url?`(${esc(new URL(s.url).hostname.replace('www.',''))})`:''}`).join('<br>')+'</div>'}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}">`:''}${sources}<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>${p.video?`<video class="article-img" src="${p.video}" controls playsinline></video>`:''}<div class="article-actions">${p.url?`<button onclick="window.open('${p.url}','_blank')">🔗 Nguồn</button>`:''}<button onclick="aiMakeShort(${i})">🎬 Tạo video shorts</button></div></div>`;document.getElementById('view-article').innerHTML=h;window.scrollTo(0,0)};window.aiMakeShort=async function(i){const p=aiWall[i];if(!p)return;try{alert('Đang tạo shorts AI, vui lòng chờ...');const r=await fetch('/api/ai/short/'+p.id,{method:'POST'});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');p.video=j.video;aiReadWall(i);refreshAiWall();}catch(e){alert(e.message)}};const oldLoad=window.loadHome;if(typeof oldLoad==='function'){window.loadHome=async function(){await oldLoad.apply(this,arguments);setTimeout(refreshAiWall,50);};}setTimeout(refreshAiWall,1200);})();</script>'''
|
|
|
|
| 376 |
bg=Image.new("RGB",(W,H),(14,14,14))
|
| 377 |
try:
|
| 378 |
im=Image.open(img_path).convert("RGB")
|
| 379 |
+
# cover top area 1080x860
|
| 380 |
target=(1080,860)
|
| 381 |
im_ratio=im.width/im.height; target_ratio=target[0]/target[1]
|
| 382 |
if im_ratio>target_ratio:
|
|
|
|
| 413 |
with open(out_path,"wb") as f:f.write(r.content)
|
| 414 |
return out_path
|
| 415 |
except Exception: pass
|
| 416 |
+
# fallback generated image
|
| 417 |
gen=pollinations_image_url(fallback_topic)
|
| 418 |
try:
|
| 419 |
r=requests.get(gen,headers=HEADERS,timeout=25)
|
|
|
|
| 429 |
|
| 430 |
def _short_script(post):
|
| 431 |
txt=_clean_text(post.get("text",""))
|
| 432 |
+
# TTS should read concise summary, not entire huge post.
|
| 433 |
if len(txt)>900: txt=txt[:900].rsplit(" ",1)[0]+"."
|
| 434 |
return f"{post.get('title','')}. {txt}"
|
| 435 |
|
|
|
|
| 469 |
return FileResponse(path,media_type="video/mp4",filename=f"vnews-ai-{post_id}.mp4")
|
| 470 |
|
| 471 |
|
| 472 |
+
# Override index route to show Tường AI slide + shorts generation.
|
| 473 |
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
|
| 474 |
AI_INJECT=r'''
|
| 475 |
<style>.ai-wall-extra{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.ai-wall-card{flex:0 0 250px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:8px}.ai-wall-img{width:100%;aspect-ratio:16/9;background:#222;border-radius:8px;overflow:hidden;margin-bottom:6px}.ai-wall-img img{width:100%;height:100%;object-fit:cover}.ai-wall-title{font-size:12px;color:#5cb87a;font-weight:800;line-height:1.3;margin-bottom:4px}.ai-wall-text{font-size:11px;color:#bbb;line-height:1.45;white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden}.ai-wall-actions{display:flex;gap:6px;margin-top:8px}.ai-wall-actions button{flex:1;border:1px solid #333;background:#222;color:#ddd;border-radius:14px;padding:6px 8px;font-size:10px}.ai-wall-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}</style><script>(function(){function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}let aiWall=[];async function refreshAiWall(){try{const r=await fetch('/api/ai_wall');const j=await r.json();aiWall=j.posts||[];renderAiWall();}catch(e){}}function renderAiWall(){const home=document.getElementById('view-home');if(!home)return;let old=document.getElementById('ai-wall-block');if(old)old.remove();if(!aiWall.length)return;let wrap=document.createElement('div');wrap.id='ai-wall-block';wrap.className='ai-wall-extra';let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track">';aiWall.slice(0,30).forEach((p,i)=>{h+=`<div class="ai-wall-card"><div class="ai-wall-img">${p.img?`<img src="${p.img}">`:''}</div><div class="ai-wall-title">${esc(p.title)}</div><div class="ai-wall-text">${esc(p.text)}</div><div class="ai-wall-actions"><button onclick="aiReadWall(${i})">Xem</button><button class="primary" onclick="aiMakeShort(${i})">Shorts</button></div></div>`});h+='</div>';wrap.innerHTML=h;home.prepend(wrap)}window.aiReadWall=function(i){const p=aiWall[i];if(!p)return;showView('view-article');let sources='';if(p.sources&&p.sources.length){sources='<div class="article-summary"><b>Nguồn tham khảo:</b><br>'+p.sources.slice(0,5).map(s=>`• ${esc(s.title||s.url||'Nguồn')} ${s.url?`(${esc(new URL(s.url).hostname.replace('www.',''))})`:''}`).join('<br>')+'</div>'}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}">`:''}${sources}<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>${p.video?`<video class="article-img" src="${p.video}" controls playsinline></video>`:''}<div class="article-actions">${p.url?`<button onclick="window.open('${p.url}','_blank')">🔗 Nguồn</button>`:''}<button onclick="aiMakeShort(${i})">🎬 Tạo video shorts</button></div></div>`;document.getElementById('view-article').innerHTML=h;window.scrollTo(0,0)};window.aiMakeShort=async function(i){const p=aiWall[i];if(!p)return;try{alert('Đang tạo shorts AI, vui lòng chờ...');const r=await fetch('/api/ai/short/'+p.id,{method:'POST'});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');p.video=j.video;aiReadWall(i);refreshAiWall();}catch(e){alert(e.message)}};const oldLoad=window.loadHome;if(typeof oldLoad==='function'){window.loadHome=async function(){await oldLoad.apply(this,arguments);setTimeout(refreshAiWall,50);};}setTimeout(refreshAiWall,1200);})();</script>'''
|
ai_patch.py
CHANGED
|
@@ -1,10 +1,17 @@
|
|
| 1 |
-
import os
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
| 6 |
import ai_ext as base
|
| 7 |
from ai_ext import app
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
try:
|
| 10 |
from PIL import Image, ImageDraw, ImageFont
|
|
@@ -13,75 +20,111 @@ except Exception:
|
|
| 13 |
|
| 14 |
|
| 15 |
def _clean(s):
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
def _norm(s):
|
| 20 |
s = s.lower()
|
| 21 |
s = re.sub(r"[^\wÀ-ỹ\s]", " ", s)
|
| 22 |
-
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
-
def _similar(a,b):
|
| 26 |
-
ta
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
-
def _dedupe_units(units,max_units=7):
|
| 32 |
-
out=[]
|
| 33 |
for u in units:
|
| 34 |
-
u=_clean(re.sub(r"^[-•*\d\.\)\s]+","",u))
|
| 35 |
-
if len(u)<18:
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
return out
|
| 41 |
|
| 42 |
|
| 43 |
-
def
|
| 44 |
-
text=_clean(text)
|
| 45 |
-
if not text:
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
if marker in text:
|
| 69 |
-
text=text.split(marker,1)[1]
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
|
| 76 |
def _source_line(sources):
|
| 77 |
-
names=[]
|
| 78 |
for s in (sources or [])[:5]:
|
| 79 |
-
via=s.get("via") or base._domain(s.get("url","")) or s.get("title","")
|
| 80 |
-
if via and via not in names:
|
| 81 |
-
|
|
|
|
| 82 |
|
| 83 |
|
| 84 |
-
def _make_summary_prompt(title,raw,source_hint=""):
|
| 85 |
return f"""Bạn là biên tập viên tóm tắt tin tức tiếng Việt.
|
| 86 |
|
| 87 |
NHIỆM VỤ BẮT BUỘC:
|
|
@@ -89,245 +132,514 @@ NHIỆM VỤ BẮT BUỘC:
|
|
| 89 |
- Không lặp lại cùng một ý, cùng một câu, cùng một chi tiết.
|
| 90 |
- Không thêm thông tin ngoài nguồn.
|
| 91 |
- Tối đa 5 gạch đầu dòng, mỗi gạch đầu dòng 1 câu ngắn.
|
|
|
|
|
|
|
| 92 |
|
| 93 |
Tiêu đề nguồn: {title}
|
| 94 |
Nguồn: {source_hint}
|
| 95 |
|
| 96 |
Nội dung nguồn:
|
| 97 |
-
{raw[:14000]}
|
|
|
|
| 98 |
|
| 99 |
|
| 100 |
-
def
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
seen.add(url)
|
| 108 |
try:
|
| 109 |
-
|
| 110 |
-
raw=(
|
| 111 |
-
if len(raw)<180:
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
try:
|
| 126 |
-
|
| 127 |
-
if
|
| 128 |
-
txt=await
|
| 129 |
-
if txt:
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
if token:
|
| 132 |
-
|
| 133 |
-
for
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
try:
|
| 136 |
-
is_vl="VL" in model and bool(image_url)
|
| 137 |
-
|
| 138 |
-
payload=
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
|
| 147 |
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
base.
|
| 151 |
|
| 152 |
|
| 153 |
-
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
-
@app.get('/api/wall')
|
| 157 |
-
def compat_wall():return JSONResponse({'posts':base._load_ai_wall()[:80]})
|
| 158 |
|
| 159 |
@app.post('/api/topic_post')
|
| 160 |
-
async def
|
| 161 |
-
body=await request.json()
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
- Không lặp ý.
|
| 174 |
-
-
|
| 175 |
-
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
{
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
|
| 186 |
@app.post('/api/url_wall')
|
| 187 |
-
async def
|
| 188 |
-
body=await request.json()
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
|
| 203 |
-
@app.post('/api/rewrite_share')
|
| 204 |
-
async def rewrite_share(request:Request):
|
| 205 |
-
body=await request.json();url=base._clean_text(body.get('url',''))
|
| 206 |
-
if not url.startswith('http'):return JSONResponse({'error':'missing url'},status_code=400)
|
| 207 |
-
try:data=base.scrape_any_url(url)
|
| 208 |
-
except Exception as e:return JSONResponse({'error':'Không đọc được bài viết: '+str(e)[:180]},status_code=422)
|
| 209 |
-
raw=(data.get('summary','')+'\n'+data.get('text','')).strip()
|
| 210 |
-
if len(raw)<120:return JSONResponse({'error':'Bài viết không đủ nội dung để tóm tắt'},status_code=422)
|
| 211 |
-
prompt=_make_summary_prompt(data.get('title',''),raw,data.get('via','') or base._domain(url))
|
| 212 |
-
text=await base.qwen_generate(prompt,image_url=data.get('image') or None,max_tokens=850)
|
| 213 |
-
text=_postprocess(text,6)
|
| 214 |
-
src=[{'title':data.get('title'),'url':url,'excerpt':raw[:500],'via':data.get('via') or base._domain(url)}]
|
| 215 |
-
if 'Nguồn tham khảo:' not in text:text+='\n\n'+_source_line(src)
|
| 216 |
-
post=base.make_post(data.get('title') or 'Bài viết',text,data.get('image') or '',url,'summary',sources=src)
|
| 217 |
-
posts=base._load_ai_wall();posts.insert(0,post);base._save_ai_wall(posts)
|
| 218 |
-
return JSONResponse({'post':post})
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
def split_segments(post):
|
| 222 |
-
title=_clean(post.get('title',''));text=re.sub(r'Nguồn tham khảo:.*','',post.get('text',''),flags=re.S).strip()
|
| 223 |
-
lines=[]
|
| 224 |
-
if title:lines.append(title)
|
| 225 |
-
for line in re.split(r'\n+',text):
|
| 226 |
-
line=_clean(re.sub(r'^[•\-*]\s*','',line))
|
| 227 |
-
if len(line)>8:lines.append(line)
|
| 228 |
-
segs=[];cur=''
|
| 229 |
-
for line in lines:
|
| 230 |
-
if len(cur)+len(line)<190:cur=(cur+' '+line).strip()
|
| 231 |
-
else:
|
| 232 |
-
if cur:segs.append(cur)
|
| 233 |
-
cur=line
|
| 234 |
-
if cur:segs.append(cur)
|
| 235 |
-
return segs[:14]
|
| 236 |
|
| 237 |
-
|
| 238 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
for w in words:
|
| 240 |
-
test=(cur+
|
| 241 |
-
try:
|
| 242 |
-
|
| 243 |
-
|
|
|
|
|
|
|
|
|
|
| 244 |
else:
|
| 245 |
-
if cur:
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
|
|
|
|
|
|
|
|
|
| 249 |
return lines
|
| 250 |
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
|
|
|
|
|
|
|
|
|
| 254 |
try:
|
| 255 |
-
im=Image.open(img_path).convert(
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
try:
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
draw.text((margin,770),f'VNEWS · Short AI {idx}/{total}',fill=(92,184,122),font=fl)
|
| 266 |
-
y=840
|
| 267 |
-
for ln in wrap_text(draw,segment,fb,maxw,16):
|
| 268 |
-
draw.text((margin,y),ln,fill=(242,242,242),font=fb);y+=58
|
| 269 |
-
if y>1650:break
|
| 270 |
-
bg.save(out_path,quality=92)
|
| 271 |
-
|
| 272 |
-
def make_tts(text,voice,out_path):
|
| 273 |
-
edge_voice={'nam':'vi-VN-NamMinhNeural','male':'vi-VN-NamMinhNeural','nu':'vi-VN-HoaiMyNeural','female':'vi-VN-HoaiMyNeural','mien-nam':'vi-VN-HoaiMyNeural'}.get(voice,'vi-VN-HoaiMyNeural')
|
| 274 |
-
try:subprocess.run(['python','-m','edge_tts','--voice',edge_voice,'--text',text,'--write-media',out_path],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=160)
|
| 275 |
except Exception:
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
|
| 280 |
@app.post('/api/ai/short/{post_id}')
|
| 281 |
-
async def
|
| 282 |
-
try:
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
if not
|
| 294 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
try:
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
|
| 312 |
@app.get('/api/ai/short-file/{file_id}')
|
| 313 |
-
def
|
| 314 |
-
path=os.path.join(base.SHORTS_DIR,base._safe_name(file_id)+'.mp4')
|
| 315 |
-
if not os.path.exists(path):
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
<script>
|
| 322 |
(function(){
|
| 323 |
-
|
| 324 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
})();
|
| 326 |
</script>
|
| 327 |
'''
|
|
|
|
| 328 |
@app.get('/')
|
| 329 |
async def index_patched():
|
| 330 |
-
with open('/app/static/index.html','r',encoding='utf-8') as f:
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
return HTMLResponse(html.replace('</body>',extra+PATCH_INJECT+'\n</body>'))
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import re
|
| 3 |
+
import time
|
| 4 |
+
import random
|
| 5 |
+
import json
|
| 6 |
+
import html as html_lib
|
| 7 |
+
import subprocess
|
| 8 |
+
import requests
|
| 9 |
import ai_ext as base
|
| 10 |
from ai_ext import app
|
| 11 |
+
from fastapi import Request
|
| 12 |
+
from fastapi.responses import JSONResponse, HTMLResponse, FileResponse
|
| 13 |
+
from bs4 import BeautifulSoup
|
| 14 |
+
from urllib.parse import quote_plus
|
| 15 |
|
| 16 |
try:
|
| 17 |
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
| 20 |
|
| 21 |
|
| 22 |
def _clean(s):
|
| 23 |
+
s = html_lib.unescape(s or "")
|
| 24 |
+
s = re.sub(r"[ \t]+", " ", s)
|
| 25 |
+
s = re.sub(r"\n{3,}", "\n\n", s)
|
| 26 |
+
return s.strip()
|
| 27 |
|
| 28 |
|
| 29 |
def _norm(s):
|
| 30 |
s = s.lower()
|
| 31 |
s = re.sub(r"[^\wÀ-ỹ\s]", " ", s)
|
| 32 |
+
s = re.sub(r"\s+", " ", s).strip()
|
| 33 |
+
return s
|
| 34 |
|
| 35 |
|
| 36 |
+
def _similar(a, b):
|
| 37 |
+
ta = set(_norm(a).split())
|
| 38 |
+
tb = set(_norm(b).split())
|
| 39 |
+
if not ta or not tb:
|
| 40 |
+
return False
|
| 41 |
+
return len(ta & tb) / max(1, min(len(ta), len(tb))) >= 0.72
|
| 42 |
|
| 43 |
|
| 44 |
+
def _dedupe_units(units, max_units=7):
|
| 45 |
+
out, seen = [], set()
|
| 46 |
for u in units:
|
| 47 |
+
u = _clean(re.sub(r"^[-•*\d\.\)\s]+", "", u))
|
| 48 |
+
if len(u) < 18:
|
| 49 |
+
continue
|
| 50 |
+
nu = _norm(u)
|
| 51 |
+
if nu in seen:
|
| 52 |
+
continue
|
| 53 |
+
if any(_similar(u, old) for old in out):
|
| 54 |
+
continue
|
| 55 |
+
seen.add(nu)
|
| 56 |
+
out.append(u)
|
| 57 |
+
if len(out) >= max_units:
|
| 58 |
+
break
|
| 59 |
return out
|
| 60 |
|
| 61 |
|
| 62 |
+
def _postprocess_ai_text(text, max_units=7):
|
| 63 |
+
text = _clean(text)
|
| 64 |
+
if not text:
|
| 65 |
+
return text
|
| 66 |
+
drop_prefixes = (
|
| 67 |
+
"dưới đây", "sau đây", "bài viết", "tôi sẽ", "mình sẽ",
|
| 68 |
+
"tóm tắt bài", "tiêu đề:", "sapo:", "nội dung:", "kết luận:"
|
| 69 |
+
)
|
| 70 |
+
raw_lines = []
|
| 71 |
+
for line in re.split(r"\n+", text):
|
| 72 |
+
line = _clean(line)
|
| 73 |
+
if not line:
|
| 74 |
+
continue
|
| 75 |
+
low = line.lower().strip()
|
| 76 |
+
if any(low.startswith(p) and len(line) < 80 for p in drop_prefixes):
|
| 77 |
+
continue
|
| 78 |
+
raw_lines.append(line)
|
| 79 |
+
units = []
|
| 80 |
+
for line in raw_lines:
|
| 81 |
+
if len(line) > 260:
|
| 82 |
+
units.extend(re.split(r"(?<=[\.\!\?])\s+(?=[A-ZÀ-Ỹ0-9])", line))
|
| 83 |
+
else:
|
| 84 |
+
units.append(line)
|
| 85 |
+
units = _dedupe_units(units, max_units=max_units)
|
| 86 |
+
if not units:
|
| 87 |
+
return text[:900]
|
| 88 |
+
title = ""
|
| 89 |
+
if raw_lines and len(raw_lines[0]) <= 90 and not raw_lines[0].startswith(("-", "•", "*")):
|
| 90 |
+
title = raw_lines[0]
|
| 91 |
+
units = [u for u in units if not _similar(u, title)]
|
| 92 |
+
body = "\n".join("• " + u for u in units[:max_units])
|
| 93 |
+
return (title + "\n\n" + body).strip() if title else body
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _fallback_summary_from_prompt(prompt, max_units=6):
|
| 97 |
+
text = prompt or ""
|
| 98 |
+
for marker in ["Nội dung nguồn:", "Nội dung bài:", "Nội dung gốc:", "Nội dung:", "Nguồn/bối cảnh internet:"]:
|
| 99 |
if marker in text:
|
| 100 |
+
text = text.split(marker, 1)[1]
|
| 101 |
+
break
|
| 102 |
+
text = re.sub(r"https?://\S+", "", text)
|
| 103 |
+
text = re.sub(r"\s+", " ", text).strip()
|
| 104 |
+
sentences = re.split(r"(?<=[\.\!\?])\s+(?=[A-ZÀ-Ỹ0-9])", text)
|
| 105 |
+
candidates = []
|
| 106 |
+
for s in sentences:
|
| 107 |
+
s = _clean(s)
|
| 108 |
+
if 45 <= len(s) <= 260:
|
| 109 |
+
candidates.append(s)
|
| 110 |
+
units = _dedupe_units(candidates, max_units=max_units)
|
| 111 |
+
if units:
|
| 112 |
+
return "\n".join("• " + u for u in units)
|
| 113 |
+
if text:
|
| 114 |
+
return "• " + text[:700].rsplit(" ", 1)[0]
|
| 115 |
+
return "• Không có đủ nội dung nguồn để tóm tắt."
|
| 116 |
|
| 117 |
|
| 118 |
def _source_line(sources):
|
| 119 |
+
names = []
|
| 120 |
for s in (sources or [])[:5]:
|
| 121 |
+
via = s.get("via") or base._domain(s.get("url", "")) or s.get("title", "")
|
| 122 |
+
if via and via not in names:
|
| 123 |
+
names.append(via)
|
| 124 |
+
return "Nguồn tham khảo: " + ", ".join(names[:5]) if names else "Nguồn tham khảo: tổng hợp internet"
|
| 125 |
|
| 126 |
|
| 127 |
+
def _make_summary_prompt(title, raw, source_hint=""):
|
| 128 |
return f"""Bạn là biên tập viên tóm tắt tin tức tiếng Việt.
|
| 129 |
|
| 130 |
NHIỆM VỤ BẮT BUỘC:
|
|
|
|
| 132 |
- Không lặp lại cùng một ý, cùng một câu, cùng một chi tiết.
|
| 133 |
- Không thêm thông tin ngoài nguồn.
|
| 134 |
- Tối đa 5 gạch đầu dòng, mỗi gạch đầu dòng 1 câu ngắn.
|
| 135 |
+
- Nếu bài có số liệu/nhân vật/thời điểm quan trọng thì giữ lại.
|
| 136 |
+
- Không viết phần mở bài dài, không viết văn kể lại.
|
| 137 |
|
| 138 |
Tiêu đề nguồn: {title}
|
| 139 |
Nguồn: {source_hint}
|
| 140 |
|
| 141 |
Nội dung nguồn:
|
| 142 |
+
{raw[:14000]}
|
| 143 |
+
"""
|
| 144 |
|
| 145 |
|
| 146 |
+
def _direct_news_rss(topic, limit=10):
|
| 147 |
+
out = []
|
| 148 |
+
try:
|
| 149 |
+
url = "https://news.google.com/rss/search?q=" + quote_plus(topic) + "&hl=vi&gl=VN&ceid=VN:vi"
|
| 150 |
+
r = requests.get(url, headers=base.HEADERS, timeout=15)
|
| 151 |
+
r.encoding = "utf-8"
|
| 152 |
+
soup = BeautifulSoup(r.text, "xml")
|
| 153 |
+
for it in soup.find_all("item")[:limit]:
|
| 154 |
+
title = it.find("title").get_text(" ", strip=True) if it.find("title") else ""
|
| 155 |
+
link = it.find("link").get_text(strip=True) if it.find("link") else ""
|
| 156 |
+
src = it.find("source").get_text(" ", strip=True) if it.find("source") else base._domain(link)
|
| 157 |
+
if title and link:
|
| 158 |
+
out.append({"title": title, "url": link, "via": src, "excerpt": title})
|
| 159 |
+
except Exception:
|
| 160 |
+
pass
|
| 161 |
+
return out
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _topic_source_articles(topic, limit=5):
|
| 165 |
+
"""Return actual scraped article bodies for a topic. Each source becomes one Wall AI post."""
|
| 166 |
+
try:
|
| 167 |
+
_ctx, sources = base.web_context(topic, limit=limit)
|
| 168 |
+
except Exception:
|
| 169 |
+
sources = []
|
| 170 |
+
if not sources:
|
| 171 |
+
sources = _direct_news_rss(topic, limit=10)
|
| 172 |
+
out, seen = [], set()
|
| 173 |
+
for s in (sources or [])[:limit * 3]:
|
| 174 |
+
url = s.get("url") or ""
|
| 175 |
+
if not url.startswith("http") or url in seen:
|
| 176 |
+
continue
|
| 177 |
seen.add(url)
|
| 178 |
try:
|
| 179 |
+
page = base.scrape_any_url(url)
|
| 180 |
+
raw = (page.get("summary", "") + "\n" + page.get("text", "")).strip()
|
| 181 |
+
if len(raw) < 180:
|
| 182 |
+
continue
|
| 183 |
+
title = page.get("title") or s.get("title") or url
|
| 184 |
+
via = page.get("via") or s.get("via") or base._domain(url)
|
| 185 |
+
out.append({
|
| 186 |
+
"title": title,
|
| 187 |
+
"url": url,
|
| 188 |
+
"raw": raw,
|
| 189 |
+
"image": page.get("image") or "",
|
| 190 |
+
"via": via,
|
| 191 |
+
"source": {"title": title, "url": url, "excerpt": raw[:700], "via": via}
|
| 192 |
+
})
|
| 193 |
+
if len(out) >= limit:
|
| 194 |
+
break
|
| 195 |
+
except Exception:
|
| 196 |
+
continue
|
| 197 |
+
# Fallback to headlines/snippets only if no full body works.
|
| 198 |
+
if not out:
|
| 199 |
+
for s in (sources or _direct_news_rss(topic, 6))[:limit]:
|
| 200 |
+
title = s.get("title") or topic
|
| 201 |
+
excerpt = s.get("excerpt") or s.get("description") or s.get("content") or title
|
| 202 |
+
url = s.get("url", "")
|
| 203 |
+
via = s.get("via") or base._domain(url)
|
| 204 |
+
out.append({
|
| 205 |
+
"title": title,
|
| 206 |
+
"url": url,
|
| 207 |
+
"raw": excerpt,
|
| 208 |
+
"image": base.pollinations_image_url(title),
|
| 209 |
+
"via": via,
|
| 210 |
+
"source": {"title": title, "url": url, "excerpt": excerpt[:700], "via": via}
|
| 211 |
+
})
|
| 212 |
+
return out[:limit]
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
async def qwen_generate_resilient(prompt: str, image_url=None, max_tokens: int = 1200):
|
| 216 |
+
errors = []
|
| 217 |
+
token = base._hf_token()
|
| 218 |
try:
|
| 219 |
+
original = getattr(base, "_original_qwen_generate", None)
|
| 220 |
+
if original:
|
| 221 |
+
txt = await original(prompt, image_url=image_url, max_tokens=max_tokens)
|
| 222 |
+
if txt:
|
| 223 |
+
base.LAST_QWEN_ERROR = ""
|
| 224 |
+
return txt
|
| 225 |
+
if getattr(base, "LAST_QWEN_ERROR", ""):
|
| 226 |
+
errors.append("sdk: " + str(base.LAST_QWEN_ERROR)[:260])
|
| 227 |
+
except Exception as e:
|
| 228 |
+
errors.append(f"sdk: {type(e).__name__}: {str(e)[:260]}")
|
| 229 |
if token:
|
| 230 |
+
models = []
|
| 231 |
+
for m in [
|
| 232 |
+
os.getenv("QWEN_VL_MODEL", ""),
|
| 233 |
+
"Qwen/Qwen2.5-VL-7B-Instruct",
|
| 234 |
+
"Qwen/Qwen2.5-VL-3B-Instruct",
|
| 235 |
+
"Qwen/Qwen2.5-7B-Instruct",
|
| 236 |
+
"Qwen/Qwen2.5-3B-Instruct",
|
| 237 |
+
"Qwen/Qwen2.5-1.5B-Instruct",
|
| 238 |
+
]:
|
| 239 |
+
if m and m not in models:
|
| 240 |
+
models.append(m)
|
| 241 |
+
headers = {"Authorization": "Bearer " + token, "Content-Type": "application/json"}
|
| 242 |
+
for model in models:
|
| 243 |
try:
|
| 244 |
+
is_vl = "VL" in model and bool(image_url)
|
| 245 |
+
user_content = ([{"type": "image_url", "image_url": {"url": image_url}}, {"type": "text", "text": prompt}] if is_vl else prompt)
|
| 246 |
+
payload = {
|
| 247 |
+
"model": model,
|
| 248 |
+
"messages": [
|
| 249 |
+
{"role": "system", "content": "Bạn là biên tập viên AI tiếng Việt. Chỉ tóm tắt súc tích nội dung nguồn, không viết lại toàn bài, không lặp ý, không bịa chi tiết."},
|
| 250 |
+
{"role": "user", "content": user_content},
|
| 251 |
+
],
|
| 252 |
+
"max_tokens": min(int(max_tokens or 900), 1400),
|
| 253 |
+
"temperature": 0.35,
|
| 254 |
+
"top_p": 0.85,
|
| 255 |
+
}
|
| 256 |
+
r = requests.post("https://router.huggingface.co/v1/chat/completions", headers=headers, json=payload, timeout=95)
|
| 257 |
+
if r.status_code >= 300:
|
| 258 |
+
errors.append(f"{model}: HTTP {r.status_code} {r.text[:180]}")
|
| 259 |
+
continue
|
| 260 |
+
j = r.json()
|
| 261 |
+
txt = (j.get("choices", [{}])[0].get("message", {}).get("content") or "").strip()
|
| 262 |
+
if txt:
|
| 263 |
+
base.LAST_QWEN_ERROR = ""
|
| 264 |
+
return txt
|
| 265 |
+
errors.append(f"{model}: empty response")
|
| 266 |
+
except Exception as e:
|
| 267 |
+
errors.append(f"{model}: {type(e).__name__}: {str(e)[:220]}")
|
| 268 |
+
else:
|
| 269 |
+
errors.append("missing HF_TOKEN")
|
| 270 |
+
base.LAST_QWEN_ERROR = " | ".join(errors[-6:]) or "Qwen unavailable; used extractive fallback"
|
| 271 |
+
print("[qwen resilient fallback]", base.LAST_QWEN_ERROR)
|
| 272 |
+
return _fallback_summary_from_prompt(prompt, max_units=6)
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
if not hasattr(base, "_original_qwen_generate"):
|
| 276 |
+
base._original_qwen_generate = base.qwen_generate
|
| 277 |
+
base.qwen_generate = qwen_generate_resilient
|
| 278 |
|
| 279 |
|
| 280 |
+
@app.get('/api/wall')
|
| 281 |
+
def compat_wall():
|
| 282 |
+
return JSONResponse({'posts': base._load_ai_wall()[:80]})
|
| 283 |
|
| 284 |
|
| 285 |
+
_PATCHED_PATHS = {
|
| 286 |
+
('/api/topic_post', 'POST'),
|
| 287 |
+
('/api/url_wall', 'POST'),
|
| 288 |
+
('/api/rewrite_share', 'POST'),
|
| 289 |
+
('/api/ai/short/{post_id}', 'POST'),
|
| 290 |
+
}
|
| 291 |
+
app.router.routes = [
|
| 292 |
+
r for r in app.router.routes
|
| 293 |
+
if not any(getattr(r, 'path', None) == p and m in getattr(r, 'methods', set()) for p, m in _PATCHED_PATHS)
|
| 294 |
+
]
|
| 295 |
|
|
|
|
|
|
|
| 296 |
|
| 297 |
@app.post('/api/topic_post')
|
| 298 |
+
async def compat_topic_post(request: Request):
|
| 299 |
+
body = await request.json()
|
| 300 |
+
topic = base._clean_text(body.get('topic', ''))
|
| 301 |
+
if not topic:
|
| 302 |
+
return JSONResponse({'error': 'missing topic'}, status_code=400)
|
| 303 |
+
articles = _topic_source_articles(topic, limit=4)
|
| 304 |
+
if not articles:
|
| 305 |
+
return JSONResponse({'error': 'Không lấy được bài viết nguồn cho chủ đề này.'}, status_code=422)
|
| 306 |
+
new_posts = []
|
| 307 |
+
posts = base._load_ai_wall()
|
| 308 |
+
for art in articles:
|
| 309 |
+
prompt = f"""Tóm tắt RIÊNG bài viết nguồn sau để đăng Tường AI.
|
| 310 |
+
|
| 311 |
+
Chủ đề lọc: {topic}
|
| 312 |
+
Tiêu đề bài nguồn: {art['title']}
|
| 313 |
+
Nguồn: {art['via']}
|
| 314 |
+
|
| 315 |
+
Yêu cầu bắt buộc:
|
| 316 |
+
- Tóm tắt nội dung trong BÀI VIẾT này, không chỉ tiêu đề.
|
| 317 |
+
- Không trộn với bài khác.
|
| 318 |
+
- Không viết lại toàn bộ bài.
|
| 319 |
- Không lặp ý.
|
| 320 |
+
- 4-6 gạch đầu dòng, mỗi dòng 1 câu rõ ràng.
|
| 321 |
+
- Giữ số liệu/nhân vật/thời điểm quan trọng nếu có.
|
| 322 |
+
|
| 323 |
+
Nội dung bài:
|
| 324 |
+
{art['raw'][:14000]}"""
|
| 325 |
+
text = await base.qwen_generate(prompt, image_url=art.get('image') or None, max_tokens=900)
|
| 326 |
+
text = _postprocess_ai_text(text, max_units=6)
|
| 327 |
+
src = [art['source']]
|
| 328 |
+
if 'Nguồn tham khảo:' not in text:
|
| 329 |
+
text += "\n\n" + _source_line(src)
|
| 330 |
+
post = base.make_post(art['title'], text, art.get('image') or base.pollinations_image_url(art['title']), art.get('url') or '', 'topic_article', sources=src)
|
| 331 |
+
new_posts.append(post)
|
| 332 |
+
posts = new_posts + posts
|
| 333 |
+
base._save_ai_wall(posts)
|
| 334 |
+
return JSONResponse({'post': new_posts[0], 'posts': new_posts, 'count': len(new_posts)})
|
| 335 |
+
|
| 336 |
|
| 337 |
@app.post('/api/url_wall')
|
| 338 |
+
async def compat_url_wall(request: Request):
|
| 339 |
+
body = await request.json()
|
| 340 |
+
url = base._clean_text(body.get('url', ''))
|
| 341 |
+
if not url.startswith('http'):
|
| 342 |
+
return JSONResponse({'error': 'missing url'}, status_code=400)
|
| 343 |
+
try:
|
| 344 |
+
data = base.scrape_any_url(url)
|
| 345 |
+
except Exception as e:
|
| 346 |
+
return JSONResponse({'error': 'Không scrape được URL: ' + str(e)[:180]}, status_code=422)
|
| 347 |
+
raw = (data.get('summary', '') + '\n' + data.get('text', '')).strip()
|
| 348 |
+
if len(raw) < 120:
|
| 349 |
+
return JSONResponse({'error': 'URL không có đủ nội dung để tóm tắt'}, status_code=422)
|
| 350 |
+
prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
|
| 351 |
+
text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=850)
|
| 352 |
+
text = _postprocess_ai_text(text, max_units=6)
|
| 353 |
+
src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
|
| 354 |
+
if 'Nguồn tham khảo:' not in text:
|
| 355 |
+
text += "\n\n" + _source_line(src)
|
| 356 |
+
post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'url', sources=src)
|
| 357 |
+
posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
|
| 358 |
+
return JSONResponse({'post': post})
|
| 359 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
|
| 361 |
+
@app.post('/api/rewrite_share')
|
| 362 |
+
async def compat_rewrite_share(request: Request):
|
| 363 |
+
body = await request.json()
|
| 364 |
+
url = base._clean_text(body.get('url', ''))
|
| 365 |
+
if not url.startswith('http'):
|
| 366 |
+
return JSONResponse({'error': 'missing url'}, status_code=400)
|
| 367 |
+
try:
|
| 368 |
+
data = base.scrape_any_url(url)
|
| 369 |
+
except Exception as e:
|
| 370 |
+
return JSONResponse({'error': 'Không đọc được bài viết: ' + str(e)[:180]}, status_code=422)
|
| 371 |
+
raw = (data.get('summary', '') + '\n' + data.get('text', '')).strip()
|
| 372 |
+
if len(raw) < 120:
|
| 373 |
+
return JSONResponse({'error': 'Bài viết không đủ nội dung để tóm tắt'}, status_code=422)
|
| 374 |
+
prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
|
| 375 |
+
text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=850)
|
| 376 |
+
text = _postprocess_ai_text(text, max_units=6)
|
| 377 |
+
src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
|
| 378 |
+
if 'Nguồn tham khảo:' not in text:
|
| 379 |
+
text += "\n\n" + _source_line(src)
|
| 380 |
+
post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'summary', sources=src)
|
| 381 |
+
posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
|
| 382 |
+
return JSONResponse({'post': post})
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
def _emotion_script(text, emotion):
|
| 386 |
+
text = _clean(text)
|
| 387 |
+
if emotion == 'urgent':
|
| 388 |
+
return 'Tin nhanh. ' + text
|
| 389 |
+
if emotion == 'warm':
|
| 390 |
+
return 'Câu chuyện đáng chú ý. ' + text
|
| 391 |
+
if emotion == 'serious':
|
| 392 |
+
return 'Bản tin nghiêm túc. ' + text
|
| 393 |
+
if emotion == 'energetic':
|
| 394 |
+
return 'Cập nhật nổi bật. ' + text
|
| 395 |
+
return text
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
def _tts_script_smart(post, emotion):
|
| 399 |
+
raw = base._short_script(post)
|
| 400 |
+
raw = re.sub(r"^[•\-\*]\s*", "", raw, flags=re.M)
|
| 401 |
+
raw = re.sub(r"\s*\n\s*", ". ", raw)
|
| 402 |
+
raw = re.sub(r"([\.\!\?])\s*", r"\1\n", raw)
|
| 403 |
+
raw = re.sub(r"\n{2,}", "\n", raw).strip()
|
| 404 |
+
raw = _emotion_script(raw, emotion)
|
| 405 |
+
if len(raw) > 1000:
|
| 406 |
+
raw = raw[:1000]
|
| 407 |
+
cut = max(raw.rfind("."), raw.rfind("!"), raw.rfind("?"))
|
| 408 |
+
if cut > 350:
|
| 409 |
+
raw = raw[:cut + 1]
|
| 410 |
+
return raw
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
def _split_subtitle_sentences(script):
|
| 414 |
+
parts = []
|
| 415 |
+
for line in script.splitlines():
|
| 416 |
+
line = _clean(line)
|
| 417 |
+
if not line:
|
| 418 |
+
continue
|
| 419 |
+
for s in re.split(r"(?<=[\.\!\?])\s+", line):
|
| 420 |
+
s = _clean(s)
|
| 421 |
+
if 8 <= len(s) <= 140:
|
| 422 |
+
parts.append(s)
|
| 423 |
+
return parts[:12]
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
def _srt_time(sec):
|
| 427 |
+
ms = int((sec - int(sec)) * 1000)
|
| 428 |
+
sec = int(sec)
|
| 429 |
+
h = sec // 3600
|
| 430 |
+
m = (sec % 3600) // 60
|
| 431 |
+
s = sec % 60
|
| 432 |
+
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
def _write_srt(script, path, total_duration=30):
|
| 436 |
+
subs = _split_subtitle_sentences(script)
|
| 437 |
+
if not subs:
|
| 438 |
+
subs = [script[:120]]
|
| 439 |
+
dur = max(2.2, min(5.0, total_duration / max(1, len(subs))))
|
| 440 |
+
cur = 0.3
|
| 441 |
+
with open(path, 'w', encoding='utf-8') as f:
|
| 442 |
+
for i, s in enumerate(subs, 1):
|
| 443 |
+
start = cur
|
| 444 |
+
end = cur + dur
|
| 445 |
+
cur = end + 0.15
|
| 446 |
+
f.write(f"{i}\n{_srt_time(start)} --> {_srt_time(end)}\n{s}\n\n")
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
def _wrap_text_px(draw, text, font, max_width, max_lines):
|
| 450 |
+
words = _clean(text).split()
|
| 451 |
+
lines, cur = [], ""
|
| 452 |
for w in words:
|
| 453 |
+
test = (cur + " " + w).strip()
|
| 454 |
+
try:
|
| 455 |
+
width = draw.textbbox((0, 0), test, font=font)[2]
|
| 456 |
+
except Exception:
|
| 457 |
+
width = len(test) * 20
|
| 458 |
+
if width <= max_width:
|
| 459 |
+
cur = test
|
| 460 |
else:
|
| 461 |
+
if cur:
|
| 462 |
+
lines.append(cur)
|
| 463 |
+
cur = w
|
| 464 |
+
if len(lines) >= max_lines:
|
| 465 |
+
break
|
| 466 |
+
if cur and len(lines) < max_lines:
|
| 467 |
+
lines.append(cur)
|
| 468 |
return lines
|
| 469 |
|
| 470 |
+
|
| 471 |
+
def _make_short_frame_full(post, img_path, out_path):
|
| 472 |
+
if Image is None:
|
| 473 |
+
return base._make_short_frame(post, img_path, out_path)
|
| 474 |
+
W, H = 1080, 1920
|
| 475 |
+
bg = Image.new("RGB", (W, H), (14, 14, 14))
|
| 476 |
try:
|
| 477 |
+
im = Image.open(img_path).convert("RGB")
|
| 478 |
+
target = (1080, 760)
|
| 479 |
+
im_ratio = im.width / im.height
|
| 480 |
+
target_ratio = target[0] / target[1]
|
| 481 |
+
if im_ratio > target_ratio:
|
| 482 |
+
new_h = target[1]
|
| 483 |
+
new_w = int(new_h * im_ratio)
|
| 484 |
+
else:
|
| 485 |
+
new_w = target[0]
|
| 486 |
+
new_h = int(new_w / im_ratio)
|
| 487 |
+
im = im.resize((new_w, new_h))
|
| 488 |
+
left = (new_w - target[0]) // 2
|
| 489 |
+
top = (new_h - target[1]) // 2
|
| 490 |
+
im = im.crop((left, top, left + target[0], top + target[1]))
|
| 491 |
+
bg.paste(im, (0, 0))
|
| 492 |
+
except Exception:
|
| 493 |
+
pass
|
| 494 |
+
draw = ImageDraw.Draw(bg)
|
| 495 |
try:
|
| 496 |
+
font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 54)
|
| 497 |
+
font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 38)
|
| 498 |
+
font_label = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 30)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 499 |
except Exception:
|
| 500 |
+
font_title = font_body = font_label = None
|
| 501 |
+
draw.rectangle((0, 720, W, H), fill=(14, 14, 14))
|
| 502 |
+
margin = 48
|
| 503 |
+
maxw = W - margin * 2
|
| 504 |
+
draw.text((margin, 770), "VNEWS · Tường AI", fill=(92, 184, 122), font=font_label)
|
| 505 |
+
y = 830
|
| 506 |
+
for ln in _wrap_text_px(draw, post.get("title", ""), font_title, maxw, 4):
|
| 507 |
+
draw.text((margin, y), ln, fill=(255, 255, 255), font=font_title)
|
| 508 |
+
y += 66
|
| 509 |
+
y += 18
|
| 510 |
+
text = post.get("text", "")
|
| 511 |
+
text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip()
|
| 512 |
+
body_lines = _wrap_text_px(draw, text, font_body, maxw, 14)
|
| 513 |
+
for ln in body_lines:
|
| 514 |
+
draw.text((margin, y), ln, fill=(220, 220, 220), font=font_body)
|
| 515 |
+
y += 50
|
| 516 |
+
if y > 1640:
|
| 517 |
+
break
|
| 518 |
+
bg.save(out_path, quality=92)
|
| 519 |
+
|
| 520 |
|
| 521 |
@app.post('/api/ai/short/{post_id}')
|
| 522 |
+
async def patched_ai_short(post_id: str, request: Request):
|
| 523 |
+
try:
|
| 524 |
+
body = await request.json()
|
| 525 |
+
except Exception:
|
| 526 |
+
body = {}
|
| 527 |
+
voice = str(body.get('voice', 'nu')).strip().lower()
|
| 528 |
+
emotion = str(body.get('emotion', 'neutral')).strip().lower()
|
| 529 |
+
speed = float(body.get('speed', 1.2) or 1.2)
|
| 530 |
+
speed = max(0.85, min(1.35, speed))
|
| 531 |
+
|
| 532 |
+
posts = base._load_ai_wall()
|
| 533 |
+
post = next((p for p in posts if str(p.get('id')) == str(post_id)), None)
|
| 534 |
+
if not post:
|
| 535 |
+
return JSONResponse({'error': 'post not found'}, status_code=404)
|
| 536 |
+
|
| 537 |
+
os.makedirs(base.SHORTS_DIR, exist_ok=True)
|
| 538 |
+
suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}"
|
| 539 |
+
out_mp4 = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix) + '.mp4')
|
| 540 |
+
if os.path.exists(out_mp4):
|
| 541 |
+
post['video'] = '/api/ai/short-file/' + post_id + suffix
|
| 542 |
+
post['short_voice'] = voice
|
| 543 |
+
post['short_emotion'] = emotion
|
| 544 |
+
post['short_speed'] = speed
|
| 545 |
+
base._save_ai_wall(posts)
|
| 546 |
+
return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed})
|
| 547 |
+
if base.gTTS is None:
|
| 548 |
+
return JSONResponse({'error': 'gTTS chưa sẵn sàng'}, status_code=503)
|
| 549 |
+
|
| 550 |
+
work = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix))
|
| 551 |
+
os.makedirs(work, exist_ok=True)
|
| 552 |
+
img = os.path.join(work, 'image.jpg')
|
| 553 |
+
frame = os.path.join(work, 'frame.jpg')
|
| 554 |
+
audio = os.path.join(work, 'voice.mp3')
|
| 555 |
+
audio_fast = os.path.join(work, 'voice_fast.mp3')
|
| 556 |
+
srt = os.path.join(work, 'subtitles.srt')
|
| 557 |
try:
|
| 558 |
+
base._download_image(post.get('img'), post.get('title', 'AI news'), img)
|
| 559 |
+
_make_short_frame_full(post, img, frame)
|
| 560 |
+
script = _tts_script_smart(post, emotion)
|
| 561 |
+
# True selectable voice via edge-tts if installed. Fallback to gTTS.
|
| 562 |
+
edge_voice = {
|
| 563 |
+
'nam': 'vi-VN-NamMinhNeural',
|
| 564 |
+
'male': 'vi-VN-NamMinhNeural',
|
| 565 |
+
'nu': 'vi-VN-HoaiMyNeural',
|
| 566 |
+
'female': 'vi-VN-HoaiMyNeural',
|
| 567 |
+
'mien-nam': 'vi-VN-HoaiMyNeural',
|
| 568 |
+
}.get(voice, 'vi-VN-HoaiMyNeural')
|
| 569 |
+
try:
|
| 570 |
+
subprocess.run(['python', '-m', 'edge_tts', '--voice', edge_voice, '--text', script, '--write-media', audio], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=160)
|
| 571 |
+
except Exception:
|
| 572 |
+
tld = 'com.vn' if voice in ('nu', 'female', 'mien-nam') else 'com'
|
| 573 |
+
try:
|
| 574 |
+
base.gTTS(script, lang='vi', tld=tld, slow=False).save(audio)
|
| 575 |
+
except TypeError:
|
| 576 |
+
base.gTTS(script, lang='vi', slow=False).save(audio)
|
| 577 |
+
subprocess.run(['ffmpeg', '-y', '-i', audio, '-filter:a', f'atempo={speed}', '-vn', audio_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
| 578 |
+
# Estimate duration for subtitle timing.
|
| 579 |
+
duration = 30.0
|
| 580 |
+
try:
|
| 581 |
+
pr = subprocess.run(['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', audio_fast], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
|
| 582 |
+
duration = float((pr.stdout or b'30').decode().strip() or 30)
|
| 583 |
+
except Exception:
|
| 584 |
+
pass
|
| 585 |
+
_write_srt(script, srt, duration)
|
| 586 |
+
# Burn subtitles at bottom, with readable style.
|
| 587 |
+
vf = "scale=1080:1920,subtitles='{}':force_style='FontName=DejaVu Sans,FontSize=34,PrimaryColour=&H00FFFFFF,OutlineColour=&HAA000000,BorderStyle=1,Outline=3,Shadow=1,Alignment=2,MarginV=90'".format(srt.replace("'", "\\'"))
|
| 588 |
+
cmd = ['ffmpeg', '-y', '-loop', '1', '-i', frame, '-i', audio_fast, '-shortest', '-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '128k', '-vf', vf, out_mp4]
|
| 589 |
+
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=220)
|
| 590 |
+
post['video'] = '/api/ai/short-file/' + post_id + suffix
|
| 591 |
+
post['short_voice'] = voice
|
| 592 |
+
post['short_emotion'] = emotion
|
| 593 |
+
post['short_speed'] = speed
|
| 594 |
+
post['short_subtitles'] = True
|
| 595 |
+
base._save_ai_wall(posts)
|
| 596 |
+
return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': True})
|
| 597 |
+
except Exception as e:
|
| 598 |
+
return JSONResponse({'error': 'Không tạo được shorts: ' + str(e)[:180]}, status_code=500)
|
| 599 |
+
|
| 600 |
|
| 601 |
@app.get('/api/ai/short-file/{file_id}')
|
| 602 |
+
def patched_ai_short_file(file_id: str):
|
| 603 |
+
path = os.path.join(base.SHORTS_DIR, base._safe_name(file_id) + '.mp4')
|
| 604 |
+
if not os.path.exists(path):
|
| 605 |
+
return JSONResponse({'error': 'not found'}, status_code=404)
|
| 606 |
+
return FileResponse(path, media_type='video/mp4', filename=f'vnews-ai-{file_id}.mp4')
|
| 607 |
+
|
| 608 |
+
|
| 609 |
+
@app.get('/api/ai_shorts')
|
| 610 |
+
def api_ai_shorts():
|
| 611 |
+
posts = [p for p in base._load_ai_wall() if p.get('video')]
|
| 612 |
+
return JSONResponse({'posts': posts[:80]})
|
| 613 |
+
|
| 614 |
+
|
| 615 |
+
app.router.routes = [r for r in app.router.routes if not (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set()))]
|
| 616 |
+
|
| 617 |
+
PATCH_INJECT = r'''
|
| 618 |
+
<style>.ai-wall-patched{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.ai-wall-card{flex:0 0 250px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:8px}.ai-wall-img{width:100%;aspect-ratio:16/9;background:#222;border-radius:8px;overflow:hidden;margin-bottom:6px}.ai-wall-img img{width:100%;height:100%;object-fit:cover}.ai-wall-title{font-size:12px;color:#5cb87a;font-weight:800;line-height:1.3;margin-bottom:4px}.ai-wall-text{font-size:11px;color:#bbb;line-height:1.45;white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden}.ai-wall-actions{display:flex;gap:6px;margin-top:8px}.ai-wall-actions button,.ai-wall-actions select{flex:1;border:1px solid #333;background:#222;color:#ddd;border-radius:14px;padding:6px 8px;font-size:10px;min-width:0}.ai-wall-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}.ai-short-card{flex:0 0 145px}.ai-short-video{width:100%;aspect-ratio:9/16;background:#000;border-radius:8px;overflow:hidden}.ai-short-video video{width:100%;height:100%;object-fit:cover}.ai-short-progress{position:fixed;inset:0;background:rgba(0,0,0,.78);z-index:99999;display:none;align-items:center;justify-content:center;padding:20px}.ai-short-progress.active{display:flex}.ai-short-box{max-width:420px;width:100%;background:#141414;border:2px solid #2d8659;border-radius:14px;padding:18px;color:#eee;box-shadow:0 0 30px rgba(45,134,89,.35)}.ai-short-box h3{color:#5cb87a;margin-bottom:10px}.ai-short-step{font-size:13px;line-height:1.55;color:#ccc}.ai-short-spinner{width:34px;height:34px;border:4px solid #333;border-top-color:#5cb87a;border-radius:50%;animation:spin 1s linear infinite;margin:10px auto}@keyframes spin{to{transform:rotate(360deg)}}</style>
|
| 619 |
+
<div id="ai-short-progress" class="ai-short-progress"><div class="ai-short-box"><h3>🎬 Đang tạo Short AI</h3><div class="ai-short-spinner"></div><div class="ai-short-step" id="ai-short-step">Đang chuẩn bị...</div></div></div>
|
| 620 |
<script>
|
| 621 |
(function(){
|
| 622 |
+
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}
|
| 623 |
+
let patchedWall=[];let aiShorts=[];
|
| 624 |
+
function showProgress(msg){let box=document.getElementById('ai-short-progress');let st=document.getElementById('ai-short-step');if(st)st.innerHTML=msg;if(box)box.classList.add('active');}
|
| 625 |
+
function hideProgress(){document.getElementById('ai-short-progress')?.classList.remove('active');}
|
| 626 |
+
function updateAiLabels(){document.querySelectorAll('.ai-compose-title').forEach(e=>e.textContent='🤖 Tường AI: lọc từng bài theo chủ đề, tóm tắt nội dung bài');document.querySelectorAll('button').forEach(b=>{if((b.textContent||'').includes('AI viết lại'))b.textContent='🤖 Tóm tắt AI & đăng tường';});}
|
| 627 |
+
async function loadPatchedWall(){try{const r=await fetch('/api/ai_wall');const j=await r.json();patchedWall=j.posts||[];renderPatchedWall();updateAiLabels();}catch(e){}try{const r2=await fetch('/api/ai_shorts');const j2=await r2.json();aiShorts=j2.posts||[];renderAiShorts();}catch(e){}}
|
| 628 |
+
function renderAiShorts(){const home=document.getElementById('view-home');if(!home)return;document.getElementById('ai-shorts-patched')?.remove();if(!aiShorts.length)return;let wrap=document.createElement('div');wrap.id='ai-shorts-patched';wrap.className='ai-wall-patched';let h='<div class="slider-header"><span class="slider-label">🎬 Short AI</span><span class="slider-note">Video đã tạo</span></div><div class="slider-track">';aiShorts.slice(0,30).forEach((p,i)=>{h+=`<div class="ai-short-card" onclick="aiReadShortPatched(${i})"><div class="ai-short-video"><video src="${p.video}" muted playsinline preload="metadata"></video></div><div class="slider-title">${esc(p.title)}</div></div>`});h+='</div>';wrap.innerHTML=h;let wall=document.getElementById('ai-wall-patched');if(wall)wall.after(wrap);else home.prepend(wrap);}
|
| 629 |
+
function renderPatchedWall(){const home=document.getElementById('view-home');if(!home)return;document.getElementById('ai-wall-patched')?.remove();if(!patchedWall.length)return;let wrap=document.createElement('div');wrap.id='ai-wall-patched';wrap.className='ai-wall-patched';let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI</span><span class="slider-note">Mỗi nguồn = một bài tóm tắt</span></div><div class="slider-track">';patchedWall.slice(0,30).forEach((p,i)=>{h+=`<div class="ai-wall-card"><div class="ai-wall-img">${p.img?`<img src="${p.img}">`:''}</div><div class="ai-wall-title">${esc(p.title)}</div><div class="ai-wall-text">${esc(p.text)}</div><div class="ai-wall-actions"><button onclick="aiReadWallPatched(${i})">Xem</button><button class="primary" onclick="aiMakeShortPatched(${i})">Shorts</button></div></div>`});h+='</div>';wrap.innerHTML=h;let after=document.querySelector('.ai-compose');if(after)after.after(wrap);else home.prepend(wrap);}
|
| 630 |
+
window.aiReadShortPatched=function(i){const p=aiShorts[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">Short AI</span><h1 class="article-title">${esc(p.title)}</h1><video class="article-img" src="${p.video}" controls playsinline autoplay></video><p class="article-p" style="white-space:pre-wrap">${esc(p.text||'')}</p><div class="article-actions"><button onclick="window.open('${p.video}','_blank')">⬇ Mở video</button>${p.url?`<button onclick="window.open('${p.url}','_blank')">🔗 Nguồn</button>`:''}</div></div>`;document.getElementById('view-article').innerHTML=h;window.scrollTo(0,0)};
|
| 631 |
+
window.aiReadWallPatched=function(i){const p=patchedWall[i];if(!p)return;showView('view-article');let sources='';if(p.sources&&p.sources.length){sources='<div class="article-summary"><b>Nguồn tham khảo:</b><br>'+p.sources.slice(0,5).map(s=>`• ${esc(s.title||s.url||'Nguồn')} ${s.url?`(${esc(new URL(s.url).hostname.replace('www.',''))})`:''}`).join('<br>')+'</div>'}let voiceBox=`<div class="article-actions"><select id="ai-short-voice"><option value="nu">Giọng nữ Việt</option><option value="nam">Giọng nam Việt</option><option value="mien-nam">Giọng miền Nam</option></select><select id="ai-short-emotion"><option value="neutral">Trung tính</option><option value="urgent">Tin nhanh</option><option value="warm">Ấm áp</option><option value="serious">Nghiêm túc</option><option value="energetic">Sôi nổi</option></select><button onclick="aiMakeShortPatched(${i})">🎬 Tạo video shorts</button></div>`;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}">`:''}${sources}<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>${p.video?`<video class="article-img" src="${p.video}" controls playsinline></video>`:''}<div class="article-actions">${p.url?`<button onclick="window.open('${p.url}','_blank')">🔗 Nguồn</button>`:''}</div>${voiceBox}</div>`;document.getElementById('view-article').innerHTML=h;window.scrollTo(0,0)};
|
| 632 |
+
window.aiMakeShortPatched=async function(i){const p=patchedWall[i];if(!p)return;let voice=document.getElementById('ai-short-voice')?.value||'nu';let emotion=document.getElementById('ai-short-emotion')?.value||'neutral';let voiceName={nu:'Giọng nữ Việt',nam:'Giọng nam Việt','mien-nam':'Giọng miền Nam'}[voice]||voice;let emotionName={neutral:'Trung tính',urgent:'Tin nhanh',warm:'Ấm áp',serious:'Nghiêm túc',energetic:'Sôi nổi'}[emotion]||emotion;let ok=confirm(`Quy trình tạo short AI:\n\n1) Dùng ảnh đại diện của bài hoặc tạo ảnh minh họa nếu thiếu.\n2) Rút gọn nội dung tóm tắt thành kịch bản đọc ngắn.\n3) Tự ngắt câu theo dấu câu và xuống dòng hợp lý.\n4) Tạo giọng đọc tiếng Việt: ${voiceName}.\n5) Áp dụng cảm xúc/kịch bản: ${emotionName}.\n6) Tăng tốc giọng đọc 1.2 lần.\n7) Căn chữ full width khung 9:16 và thêm phụ đề theo lời đọc.\n8) Sau khi xong, video xuất hiện ở slide “Short AI”.\n\nQuá trình có thể mất 1-3 phút. Bạn muốn bắt đầu?`);if(!ok)return;try{showProgress(`Bước 1/5: Chuẩn bị ảnh và căn chữ full width...<br>Bước 2/5: Tạo kịch bản, tự ngắt câu/xuống dòng...<br>Bước 3/5: Tạo giọng đọc ${voiceName}, cảm xúc ${emotionName}.<br>Bước 4/5: Tăng tốc 1.2x và burn phụ đề.<br>Bước 5/5: Lưu vào slide “Short AI”.`);const r=await fetch('/api/ai/short/'+p.id,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice,emotion,speed:1.2})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');p.video=j.video;hideProgress();alert('Hoàn tất: video shorts đã được tạo và thêm vào slide “Short AI”.');aiReadWallPatched(i);loadPatchedWall();}catch(e){hideProgress();alert('Không tạo được shorts: '+e.message)}};
|
| 633 |
+
window.createTopicPost=function(){let inp=document.getElementById('ai-topic-input');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&(j.posts||j.post)){let arr=j.posts||[j.post];patchedWall=arr.concat(patchedWall.filter(x=>!arr.find(y=>y.id===x.id)));renderPatchedWall();if(inp)inp.value='';alert(`Đã lọc và tóm tắt ${arr.length} bài viết theo chủ đề lên Tường AI`);}else alert(j.error||'Lỗi tạo bài')}).catch(e=>alert(e.message||'Lỗi tạo bài'));};
|
| 634 |
+
window.createUrlPost=function(){let inp=document.getElementById('ai-url-input');let url=(inp&&inp.value||'').trim();if(!url)return alert('Dán URL trước');if(!/^https?:\/\//i.test(url))return alert('URL cần bắt đầu bằng http:// hoặc https://');fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&j.post){patchedWall=[j.post].concat(patchedWall.filter(x=>x.id!==j.post.id));renderPatchedWall();if(inp)inp.value='';alert('Đã tóm tắt URL và đăng lên Tường AI');}else alert(j.error||'Lỗi URL')}).catch(e=>alert(e.message||'Lỗi URL'));};
|
| 635 |
+
window.rewriteCurrentArticle=function(){if(!window._currentArticle&&typeof _currentArticle!=='undefined')window._currentArticle=_currentArticle;let cur=window._currentArticle||_currentArticle;if(!cur)return;let btn=document.querySelector('.article-actions button.primary');if(btn){btn.textContent='Đang tóm tắt...';btn.disabled=true}fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:cur.url})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&j.post){document.getElementById('rewrite-result').innerHTML=`<div class="rewrite-box"><div class="rewrite-title">Đã tóm tắt và đăng Tường AI</div><div class="rewrite-text">${esc(j.post.text||'')}</div></div>`;patchedWall=[j.post].concat(patchedWall.filter(x=>x.id!==j.post.id));renderPatchedWall();alert('Đã tóm tắt lên Tường AI');}else alert(j.error||'Không tóm tắt được')}).catch(e=>alert(e.message||'Lỗi tóm tắt')).finally(()=>{if(btn){btn.textContent='🤖 Tóm tắt AI & đăng tường';btn.disabled=false}})};
|
| 636 |
+
setTimeout(loadPatchedWall,1500);setInterval(updateAiLabels,2000);
|
| 637 |
})();
|
| 638 |
</script>
|
| 639 |
'''
|
| 640 |
+
|
| 641 |
@app.get('/')
|
| 642 |
async def index_patched():
|
| 643 |
+
with open('/app/static/index.html','r',encoding='utf-8') as f:
|
| 644 |
+
html=f.read()
|
| 645 |
+
return HTMLResponse(html.replace('</body>', PATCH_INJECT+'\n</body>'))
|
|
|
static/index.html
CHANGED
|
@@ -1,56 +1,73 @@
|
|
| 1 |
<!DOCTYPE html>
|
| 2 |
<html lang="vi">
|
| 3 |
<head>
|
| 4 |
-
<meta charset="utf-8">
|
| 5 |
-
<
|
| 6 |
-
<
|
| 7 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
<style>
|
| 9 |
-
*{box-sizing:border-box;margin:0;padding:0}body{background:#111;color:#eee;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;overflow-x:hidden}.header{background:linear-gradient(135deg,#0d1117,#1a3a2a 50%,#8b7500);padding:12px;text-align:center}.header h1{font-size:18px}.header p{font-size:10px;color:#aaa}.cats{display:flex;overflow-x:auto;background:#1a1a1a;border-bottom:1px solid #333;padding:0 4px;position:sticky;top:0;z-index:50;scrollbar-width:none}.cat{padding:9px 11px;color:#888;font-size:11px;white-space:nowrap;border-bottom:2px solid transparent;cursor:pointer}.cat.active{color:#5cb87a;border-bottom-color:#5cb87a;font-weight:700}.view{display:none}.view.active{display:block}.loading{text-align:center;padding:30px;color:#777;font-size:12px}.slider-wrap,.ls-section{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.slider-header,.ls-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.slider-label,.ls-header h3{color:#f0c040;font-size:13px;font-weight:800}.slider-note{font-size:10px;color:#777}.slider-track{display:flex;overflow-x:auto;gap:8px;padding:4px 10px 10px;scrollbar-width:none}.slider-item{flex:0 0 160px;cursor:pointer}.shorts-item{flex:0 0 110px!important}.slider-thumb{position:relative;width:100%;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#333}.shorts-thumb{aspect-ratio:3/4!important;border-radius:8px!important}.slider-thumb img,.card-img img{width:100%;height:100%;object-fit:cover}.slider-title{font-size:10px;color:#ccc;margin-top:3px;line-height:1.2;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.card-play{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:30px;height:30px;border-radius:50%;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;color:#fff;font-size:12px}.grid{display:grid;grid-template-columns:repeat(2,1fr);gap:6px;padding:6px 4px}@media(min-width:650px){.grid{grid-template-columns:repeat(3,1fr)}}.card{background:#1a1a1a;border:1px solid #222;border-radius:8px;overflow:hidden;cursor:pointer}.card-img{position:relative;aspect-ratio:16/9;background:#333}.card-body{padding:6px 8px}.card-title{font-size:11px;line-height:1.35;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.badge{font-size:8px;padding:1px 5px;border-radius:3px;font-weight:700;display:inline-block;margin-bottom:2px;color:#fff}.badge-vne{background:#c0392b}.badge-bbc{background:#b80000}.badge-dt{background:#1565c0}.badge-genk{background:#6a1b9a}.badge-fpt{background:#f26522}.section-title{font-size:13px;font-weight:800;color:#5cb87a;margin:8px 0 4px;padding-left:8px;border-left:3px solid #5cb87a}.back-btn{background:#111;color:#fff;border:none;padding:10px;font-size:12px;width:100%;position:sticky;top:0;z-index:60}.article-view{padding:12px 8px 40px;max-width:760px;margin:0 auto}.article-title{font-size:18px;font-weight:800;line-height:1.3;margin-bottom:8px}.article-summary{background:#1a2a1f;border-left:3px solid #2d8659;padding:10px;margin-bottom:14px;color:#ccc;font-size:13px}.article-p{font-size:14px;line-height:1.7;color:#ccc;margin-bottom:10px}.article-img{width:100%;border-radius:6px;margin:10px 0}.article-h2{font-size:16px;margin:16px 0 8px}.article-actions{display:flex;gap:8px;flex-wrap:wrap;border-top:1px solid #333;margin-top:16px;padding-top:10px}.article-actions button{background:#1a1a1a;border:1px solid #333;color:#ccc;padding:7px 12px;border-radius:14px;font-size:11px}.featured-match{margin:6px 4px;background:linear-gradient(135deg,#1a2a1f,#0d1117);border:1px solid #2d8659;border-radius:10px;padding:12px;cursor:pointer}.fm-league{text-align:center;color:#5cb87a;font-size:9px;font-weight:700;text-transform:uppercase}.fm-teams{display:flex;align-items:center;justify-content:center;gap:10px;margin-top:6px}.fm-team{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px}.fm-team img{width:32px;height:32px;object-fit:contain}.fm-team span{font-size:10px;color:#ccc;text-align:center}.fm-score{font-size:22px;font-weight:900;min-width:60px;text-align:center}.fm-status{text-align:center;margin-top:6px;font-size:9px;color:#e74c3c;font-weight:700}.fm-status.upcoming{color:#f0c040}.ls-tabs{display:flex;gap:4px;padding:0 10px 8px;overflow-x:auto;scrollbar-width:none}.ls-tab{padding:4px 10px;background:#222;border:1px solid #333;border-radius:12px;color:#999;font-size:10px;white-space:nowrap;cursor:pointer}.ls-tab.active{background:#2d8659;border-color:#2d8659;color:#fff;font-weight:700}.ls-content{max-height:420px;overflow-y:auto;padding:0 6px 8px}.ls-content .matchs-league{list-style:none}.ls-content .title-content{display:flex;gap:6px;align-items:center;background:#222;border-radius:4px;margin:4px 0;padding:4px 6px}.ls-content .title-content img{width:16px;height:16px}.ls-content .title-content strong{font-size:11px}.ls-content .match-detail{padding:5px 6px;border-bottom:1px solid #222;cursor:pointer}.ls-content .match{display:flex;flex-wrap:wrap;align-items:center;gap:4px}.ls-content .datetime{width:100%;font-size:9px;color:#888}.ls-content .teams{display:flex;width:100%;align-items:center;gap:4px}.ls-content .team{flex:1;display:flex;align-items:center;gap:4px;text-decoration:none;min-width:0}.ls-content .team .name{font-size:11px;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ls-content .team .logo img{width:18px;height:18px}.ls-content .home-team{justify-content:flex-end;text-align:right}.ls-content .status{flex:0 0 50px;text-align:center}.ls-content .status a{color:#fff;text-decoration:none;font-weight:800;font-size:12px}.ls-content .status .label{font-size:8px;color:#888;display:block}.ls-content .status .label.live{color:#e74c3c}.ls-content .info,.ls-content .btns{display:none}.match-overlay{position:fixed;inset:0;background:#111;z-index:9999;display:none;flex-direction:column;overflow:auto}.match-overlay.active{display:flex}.mo-header{padding:10px;background:#1a1a1a;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:1}.mo-header h3{font-size:13px}.mo-close{background:none;border:0;color:#fff;font-size:22px}.mo-tabs{display:flex;gap:4px;padding:8px 10px;background:#1a1a1a}.mo-tab{padding:5px 12px;background:#222;border:1px solid #333;border-radius:10px;color:#999;font-size:10px}.mo-tab.active{background:#2d8659;color:#fff}.mo-body{padding:8px;overflow-x:auto}.vp-wrap{background:#000;width:100%;aspect-ratio:1/1;position:relative}.vp-wrap.wide{aspect-ratio:16/9}.vp-wrap video,.vp-wrap iframe{width:100%;height:100%;object-fit:contain;border:none}.vp-ratio-btn{position:absolute;right:8px;top:8px;z-index:5;background:rgba(0,0,0,.6);border:1px solid #777;color:#fff;border-radius:12px;padding:3px 8px;font-size:9px}.vp-title{padding:8px 10px;background:#1a1a1a;font-size:13px;font-weight:700}.pl-list{padding:4px}.pl-item{display:flex;gap:8px;padding:8px 6px;border-bottom:1px solid #1a1a1a;border-radius:6px}.pl-item.active{background:#1a2a1f;border:1px solid #2d8659}.pl-item-thumb{flex:0 0 100px;aspect-ratio:16/9;border-radius:4px;overflow:hidden;background:#333;position:relative}.pl-item-thumb img{width:100%;height:100%;object-fit:cover}.pl-item-title{font-size:11px;color:#ccc;line-height:1.3}.tiktok-container{width:100%;height:80vh;max-height:680px;min-height:400px;background:#000}.tiktok-feed{height:100%;overflow-y:scroll;scroll-snap-type:y mandatory;scrollbar-width:none}.tiktok-slide{height:80vh;max-height:680px;min-height:400px;scroll-snap-align:start;position:relative;background:#000;display:flex;align-items:center;justify-content:center}.tiktok-slide video,.tiktok-slide iframe{width:100%;height:100%;object-fit:contain;border:none}.tiktok-bottom{position:absolute;bottom:0;left:0;right:60px;padding:12px 10px 16px;background:linear-gradient(transparent,rgba(0,0,0,.85))}.tiktok-title{font-size:12px;color:#fff}.tiktok-counter{position:absolute;top:8px;left:8px;background:rgba(0,0,0,.5);font-size:9px;padding:2px 7px;border-radius:8px}.tiktok-right{position:absolute;right:8px;bottom:100px}.tiktok-right-btn{background:none;border:0;color:#fff}.tiktok-right-btn .icon{width:42px;height:42px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:20px}.msv{position:fixed;inset:0;background:#000;z-index:99999;overflow:hidden;color:#fff}.msv-stage{position:absolute;inset:0;display:flex;align-items:center;justify-content:center}.msv-bg{position:absolute;inset:-30px;background:center/cover no-repeat;filter:blur(28px);transform:scale(1.08);opacity:.28}.msv-phone{position:relative;z-index:1;width:max(100vw,56.25dvh);height:max(100dvh,177.7778vw);aspect-ratio:9/16;background:#000;overflow:hidden}.msv-phone iframe{position:absolute;inset:0;width:100%!important;height:100%!important;border:0}.msv-close{position:fixed;top:10px;right:10px;z-index:8;width:38px;height:38px;border-radius:50%;border:1px solid rgba(255,255,255,.16);background:rgba(0,0,0,.55);color:#fff;font-size:22px}.msv-info{position:fixed;left:12px;right:58px;bottom:18px;z-index:6;text-shadow:0 1px 7px #000}.msv-count{font-size:11px;color:#6ee78f;font-weight:700}.msv-title{font-size:13px;line-height:1.35;font-weight:700}.msv-nav{position:fixed;right:10px;top:50%;transform:translateY(-50%);z-index:7;display:flex;flex-direction:column;gap:10px}.msv-nav button{width:38px;height:38px;border:0;border-radius:50%;background:rgba(255,255,255,.16);color:#fff}.ms-lock{overflow:hidden!important}
|
| 10 |
</style>
|
| 11 |
</head>
|
| 12 |
<body>
|
| 13 |
-
<div class="header"><h1>📰 VNEWS</h1><p>Tin tức · Bóng đá trực tiếp ·
|
| 14 |
-
<div class="cats" id="cat-bar"></div>
|
| 15 |
-
<div
|
|
|
|
|
|
|
| 16 |
<script>
|
| 17 |
-
const SPACE=location.origin;let _cats=[],_tikData=[],_currentEventId='',_hlLeagueData={},_vpHls=null,
|
| 18 |
-
const HL_CONFIG={"premier-league":{name:"Premier League",emoji:"🏴"},"fa-cup":{name:"FA Cup",emoji:"🏆"},"champions-league":{name:"Champions League",emoji:"⭐"},"europa-league":{name:"Europa League",emoji:"🟠"},"la-liga":{name:"La Liga",emoji:"🇪🇸"},"serie-a":{name:"Serie A",emoji:"🇮🇹"},"bundesliga":{name:"Bundesliga",emoji:"🇩🇪"}
|
| 19 |
-
function
|
| 20 |
-
function
|
| 21 |
-
function
|
| 22 |
-
|
| 23 |
-
function
|
| 24 |
-
function
|
| 25 |
-
async function
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
function
|
| 30 |
-
|
| 31 |
-
function
|
| 32 |
-
|
| 33 |
-
async function
|
| 34 |
-
function
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
async function
|
| 38 |
-
function
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
function
|
| 53 |
-
function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
init();
|
| 55 |
</script>
|
| 56 |
-
</body>
|
|
|
|
|
|
| 1 |
<!DOCTYPE html>
|
| 2 |
<html lang="vi">
|
| 3 |
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
|
| 6 |
+
<title>VNEWS - Tin Tức Việt Nam</title>
|
| 7 |
+
<meta name="description" content="Tin tức tổng hợp, bóng đá trực tiếp, video highlight, rewrite AI.">
|
| 8 |
+
<meta property="og:title" content="VNEWS - Tin Tức Việt Nam">
|
| 9 |
+
<meta property="og:image" content="https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg">
|
| 10 |
+
<link rel="canonical" href="https://bep40-vnews.hf.space">
|
| 11 |
+
<script src="https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js"></script>
|
| 12 |
<style>
|
| 13 |
+
*{box-sizing:border-box;margin:0;padding:0}body{background:#111;color:#eee;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;overflow-x:hidden}.header{background:linear-gradient(135deg,#0d1117,#1a3a2a 50%,#8b7500);padding:12px;text-align:center}.header h1{font-size:18px}.header p{font-size:10px;color:#aaa}.cats{display:flex;overflow-x:auto;background:#1a1a1a;border-bottom:1px solid #333;padding:0 4px;position:sticky;top:0;z-index:50;scrollbar-width:none}.cat{padding:9px 11px;color:#888;font-size:11px;white-space:nowrap;border-bottom:2px solid transparent;cursor:pointer}.cat.active{color:#5cb87a;border-bottom-color:#5cb87a;font-weight:700}.view{display:none}.view.active{display:block}.loading{text-align:center;padding:30px;color:#777;font-size:12px}.slider-wrap,.ls-section{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.slider-header,.ls-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.slider-label,.ls-header h3{color:#f0c040;font-size:13px;font-weight:800}.slider-note{font-size:10px;color:#777}.slider-track{display:flex;overflow-x:auto;gap:8px;padding:4px 10px 10px;scrollbar-width:none}.slider-item{flex:0 0 160px;cursor:pointer}.shorts-item{flex:0 0 110px!important}.slider-thumb{position:relative;width:100%;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#333}.shorts-thumb{aspect-ratio:3/4!important;border-radius:8px!important}.slider-thumb img,.card-img img{width:100%;height:100%;object-fit:cover}.slider-title{font-size:10px;color:#ccc;margin-top:3px;line-height:1.2;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.card-play{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:30px;height:30px;border-radius:50%;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;color:#fff;font-size:12px}.grid{display:grid;grid-template-columns:repeat(2,1fr);gap:6px;padding:6px 4px}@media(min-width:650px){.grid{grid-template-columns:repeat(3,1fr)}}.card{background:#1a1a1a;border:1px solid #222;border-radius:8px;overflow:hidden;cursor:pointer}.card-img{position:relative;aspect-ratio:16/9;background:#333}.card-body{padding:6px 8px}.card-title{font-size:11px;line-height:1.35;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.badge{font-size:8px;padding:1px 5px;border-radius:3px;font-weight:700;display:inline-block;margin-bottom:2px;color:#fff}.badge-vne{background:#c0392b}.badge-bbc{background:#b80000}.badge-dt{background:#1565c0}.badge-genk{background:#6a1b9a}.badge-fpt{background:#f26522}.badge-ai{background:#2d8659}.badge-wc{background:#0b6bcb}.section-title{font-size:13px;font-weight:800;color:#5cb87a;margin:8px 0 4px;padding-left:8px;border-left:3px solid #5cb87a}.back-btn{background:#111;color:#fff;border:none;padding:10px;font-size:12px;width:100%;position:sticky;top:0;z-index:60}.article-view{padding:12px 8px 40px;max-width:760px;margin:0 auto}.article-title{font-size:18px;font-weight:800;line-height:1.3;margin-bottom:8px}.article-summary{background:#1a2a1f;border-left:3px solid #2d8659;padding:10px;margin-bottom:14px;color:#ccc;font-size:13px}.article-p{font-size:14px;line-height:1.7;color:#ccc;margin-bottom:10px}.article-img{width:100%;border-radius:6px;margin:10px 0}.article-h2{font-size:16px;margin:16px 0 8px}.article-actions{display:flex;gap:8px;flex-wrap:wrap;border-top:1px solid #333;margin-top:16px;padding-top:10px}.article-actions button,.article-actions select{background:#1a1a1a;border:1px solid #333;color:#ccc;padding:7px 12px;border-radius:14px;font-size:11px}.article-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}.featured-match{margin:6px 4px;background:linear-gradient(135deg,#1a2a1f,#0d1117);border:1px solid #2d8659;border-radius:10px;padding:12px;cursor:pointer}.fm-league{text-align:center;color:#5cb87a;font-size:9px;font-weight:700;text-transform:uppercase}.fm-teams{display:flex;align-items:center;justify-content:center;gap:10px;margin-top:6px}.fm-team{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px}.fm-team img{width:32px;height:32px;object-fit:contain}.fm-team span{font-size:10px;color:#ccc;text-align:center}.fm-score{font-size:22px;font-weight:900;min-width:60px;text-align:center}.fm-status{text-align:center;margin-top:6px;font-size:9px;color:#e74c3c;font-weight:700}.fm-status.upcoming{color:#f0c040}.ls-tabs{display:flex;gap:4px;padding:0 10px 8px;overflow-x:auto;scrollbar-width:none}.ls-tab{padding:4px 10px;background:#222;border:1px solid #333;border-radius:12px;color:#999;font-size:10px;white-space:nowrap;cursor:pointer}.ls-tab.active{background:#2d8659;border-color:#2d8659;color:#fff;font-weight:700}.ls-content{max-height:420px;overflow-y:auto;padding:0 6px 8px}.ls-content .matchs-league{list-style:none}.ls-content .title-content{display:flex;gap:6px;align-items:center;background:#222;border-radius:4px;margin:4px 0;padding:4px 6px}.ls-content .title-content img{width:16px;height:16px}.ls-content .title-content strong{font-size:11px}.ls-content .match-detail{padding:5px 6px;border-bottom:1px solid #222;cursor:pointer}.ls-content .match{display:flex;flex-wrap:wrap;align-items:center;gap:4px}.ls-content .datetime{width:100%;font-size:9px;color:#888}.ls-content .teams{display:flex;width:100%;align-items:center;gap:4px}.ls-content .team{flex:1;display:flex;align-items:center;gap:4px;text-decoration:none;min-width:0}.ls-content .team .name{font-size:11px;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ls-content .team .logo img{width:18px;height:18px}.ls-content .home-team{justify-content:flex-end;text-align:right}.ls-content .status{flex:0 0 50px;text-align:center}.ls-content .status a{color:#fff;text-decoration:none;font-weight:800;font-size:12px}.ls-content .status .label{font-size:8px;color:#888;display:block}.ls-content .status .label.live{color:#e74c3c}.ls-content .info,.ls-content .btns{display:none}.match-overlay{position:fixed;inset:0;background:#111;z-index:9999;display:none;flex-direction:column;overflow:auto}.match-overlay.active{display:flex}.mo-header{padding:10px;background:#1a1a1a;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:1}.mo-header h3{font-size:13px}.mo-close{background:none;border:0;color:#fff;font-size:22px}.mo-tabs{display:flex;gap:4px;padding:8px 10px;background:#1a1a1a}.mo-tab{padding:5px 12px;background:#222;border:1px solid #333;border-radius:10px;color:#999;font-size:10px}.mo-tab.active{background:#2d8659;color:#fff}.mo-body{padding:8px;overflow-x:auto}.ai-compose{margin:6px 4px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.ai-compose-title{font-size:13px;font-weight:800;color:#5cb87a;margin-bottom:8px}.ai-compose-row{display:flex;gap:6px;margin-top:6px}.ai-compose input{flex:1;background:#222;border:1px solid #333;color:#eee;border-radius:18px;padding:9px 12px;font-size:12px;min-width:0}.ai-compose button{background:#2d8659;border:0;color:#fff;border-radius:18px;padding:9px 12px;font-size:11px;font-weight:700}.ai-compose button.secondary{background:#333}.wall-item{flex:0 0 260px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:8px}.wall-thumb{width:100%;aspect-ratio:16/9;border-radius:8px;background:#222;overflow:hidden;margin-bottom:6px}.wall-thumb img{width:100%;height:100%;object-fit:cover}.wall-title{font-size:12px;color:#5cb87a;font-weight:800;line-height:1.3;margin-bottom:4px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wall-tone{font-size:9px;color:#f0c040;margin-bottom:4px}.wall-text{font-size:11px;color:#bbb;line-height:1.4;white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden}.wall-actions{display:flex;gap:6px;margin-top:8px}.wall-actions button{flex:1;border:1px solid #333;background:#222;color:#ddd;border-radius:14px;padding:6px 8px;font-size:10px}.wall-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}.rewrite-box{margin-top:12px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:10px}.rewrite-title{font-size:12px;font-weight:800;color:#5cb87a;margin-bottom:6px}.rewrite-text{font-size:12px;line-height:1.55;color:#ccc;white-space:pre-wrap}.vp-wrap{background:#000;width:100%;aspect-ratio:1/1;position:relative}.vp-wrap.wide{aspect-ratio:16/9}.vp-wrap video,.vp-wrap iframe{width:100%;height:100%;object-fit:contain;border:none}.vp-ratio-btn{position:absolute;right:8px;top:8px;z-index:5;background:rgba(0,0,0,.6);border:1px solid #777;color:#fff;border-radius:12px;padding:3px 8px;font-size:9px}.vp-title{padding:8px 10px;background:#1a1a1a;font-size:13px;font-weight:700}.pl-list{padding:4px}.pl-item{display:flex;gap:8px;padding:8px 6px;border-bottom:1px solid #1a1a1a;border-radius:6px}.pl-item.active{background:#1a2a1f;border:1px solid #2d8659}.pl-item-thumb{flex:0 0 100px;aspect-ratio:16/9;border-radius:4px;overflow:hidden;background:#333;position:relative}.pl-item-thumb img{width:100%;height:100%;object-fit:cover}.pl-item-title{font-size:11px;color:#ccc;line-height:1.3}.tiktok-container{width:100%;height:80vh;max-height:680px;min-height:400px;background:#000}.tiktok-feed{height:100%;overflow-y:scroll;scroll-snap-type:y mandatory;scrollbar-width:none}.tiktok-slide{height:80vh;max-height:680px;min-height:400px;scroll-snap-align:start;position:relative;background:#000;display:flex;align-items:center;justify-content:center}.tiktok-slide video,.tiktok-slide iframe{width:100%;height:100%;object-fit:contain;border:none}.tiktok-bottom{position:absolute;bottom:0;left:0;right:60px;padding:12px 10px 16px;background:linear-gradient(transparent,rgba(0,0,0,.85))}.tiktok-title{font-size:12px;color:#fff}.tiktok-counter{position:absolute;top:8px;left:8px;background:rgba(0,0,0,.5);font-size:9px;padding:2px 7px;border-radius:8px}.tiktok-right{position:absolute;right:8px;bottom:100px}.tiktok-right-btn{background:none;border:0;color:#fff}.tiktok-right-btn .icon{width:42px;height:42px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:20px}
|
| 14 |
</style>
|
| 15 |
</head>
|
| 16 |
<body>
|
| 17 |
+
<div class="header"><h1>📰 VNEWS</h1><p>Tin tức · Video · Bóng đá trực tiếp · Rewrite AI</p></div>
|
| 18 |
+
<div class="cats" id="cat-bar"></div>
|
| 19 |
+
<div id="view-home" class="view active"><div class="loading">Đang tải...</div></div>
|
| 20 |
+
<div id="view-cat" class="view"></div><div id="view-video" class="view"></div><div id="view-tiktok" class="view"></div><div id="view-article" class="view"></div>
|
| 21 |
+
<div class="match-overlay" id="match-overlay"><div class="mo-header"><h3 id="mo-title">Chi tiết trận đấu</h3><button class="mo-close" onclick="closeMatch()">✕</button></div><div class="mo-tabs"><span class="mo-tab active" onclick="loadMatchTab('comm')">Diễn biến</span><span class="mo-tab" onclick="loadMatchTab('stats')">Thống kê</span></div><div class="mo-body" id="mo-body"><div class="loading">Đang tải...</div></div></div>
|
| 22 |
<script>
|
| 23 |
+
const SPACE=location.origin;let _cats=[],_tikData=[],_currentEventId='',_featuredEventId='',_hlLeagueData={},_vpHls=null,_serverWall=[],_currentArticle=null;
|
| 24 |
+
const HL_CONFIG={"premier-league":{name:"Premier League",emoji:"🏴"},"fa-cup":{name:"FA Cup",emoji:"🏆"},"champions-league":{name:"Champions League",emoji:"⭐"},"europa-league":{name:"Europa League",emoji:"🟠"},"la-liga":{name:"La Liga",emoji:"🇪🇸"},"serie-a":{name:"Serie A",emoji:"🇮🇹"},"bundesliga":{name:"Bundesliga",emoji:"🇩🇪"}};
|
| 25 |
+
function escapeHtml(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}
|
| 26 |
+
function proxyImg(u){if(!u)return '';if(u.startsWith('/api/proxy/img'))return u;return '/api/proxy/img?url='+encodeURIComponent(u)}
|
| 27 |
+
function doShareVideo(title,url,img,type){const shareUrl=SPACE+'/v?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||'')+'&type='+encodeURIComponent(type||'highlights');if(navigator.share)navigator.share({title:'🎬 '+title,url:shareUrl}).catch(()=>{});else navigator.clipboard.writeText(shareUrl).then(()=>alert('Đã sao chép link!')).catch(()=>{})}
|
| 28 |
+
function doShare(title,url,img){const shareUrl=SPACE+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||'');if(navigator.share)navigator.share({title,url:shareUrl}).catch(()=>{});else navigator.clipboard.writeText(shareUrl).then(()=>alert('Đã sao chép!')).catch(()=>{})}
|
| 29 |
+
async function openMatch(id){if(!id)return;_currentEventId=id;document.getElementById('match-overlay').classList.add('active');document.body.style.overflow='hidden';loadMatchTab('comm')}
|
| 30 |
+
function closeMatch(){document.getElementById('match-overlay').classList.remove('active');document.body.style.overflow=''}
|
| 31 |
+
async function loadMatchTab(tab){document.querySelectorAll('.mo-tab').forEach(t=>t.classList.remove('active'));document.querySelectorAll('.mo-tab').forEach(t=>{if((tab==='comm'&&t.textContent==='Diễn biến')||(tab==='stats'&&t.textContent==='Thống kê'))t.classList.add('active')});const el=document.getElementById('mo-body');el.innerHTML='<div class="loading">Đang tải...</div>';try{const r=await fetch(tab==='stats'?`/api/match/${_currentEventId}/stats`:`/api/match/${_currentEventId}/commentaries`);const d=await r.json();el.innerHTML=d.html||'<div class="loading">Không có dữ liệu</div>'}catch(e){el.innerHTML='<div class="loading">Lỗi</div>'}}
|
| 32 |
+
let _lsTab='today';
|
| 33 |
+
function bindMatchClicks(container){container.querySelectorAll('.match-detail').forEach(md=>{md.addEventListener('click',function(e){e.preventDefault();e.stopPropagation();const a=this.querySelector('.status a');if(a){const m=(a.getAttribute('href')||'').match(/\/tran-dau\/(\d+)\//);if(m)openMatch(m[1])}})});container.querySelectorAll('a').forEach(a=>a.addEventListener('click',e=>e.preventDefault()))}
|
| 34 |
+
async function loadLivescore(tab){_lsTab=tab;document.querySelectorAll('.ls-tab').forEach(t=>t.classList.remove('active'));document.querySelector(`.ls-tab[data-tab="${tab}"]`)?.classList.add('active');const el=document.getElementById('ls-content');el.innerHTML='<div class="loading" style="padding:20px">Đang tải...</div>';let ep='/api/livescore/'+tab;if(tab.startsWith('bxh_'))ep='/api/livescore/standings/'+tab.replace('bxh_','');try{const r=await fetch(ep);const d=await r.json();if(d.html&&d.html.length>50){el.innerHTML=d.html;bindMatchClicks(el)}else el.innerHTML='<div class="loading" style="padding:20px">Không có dữ liệu</div>'}catch(e){el.innerHTML='<div class="loading" style="padding:20px">Lỗi</div>'}}
|
| 35 |
+
async function autoSwitchLive(){try{const r=await fetch('/api/livescore/live');const d=await r.json();if(d.html&&d.html.length>50){const el=document.getElementById('ls-content');if(el){_lsTab='live';document.querySelectorAll('.ls-tab').forEach(t=>t.classList.remove('active'));document.querySelector('.ls-tab[data-tab="live"]')?.classList.add('active');el.innerHTML=d.html;bindMatchClicks(el)}}else if(_lsTab==='live'){loadLivescore('today')}}catch(e){}}
|
| 36 |
+
setInterval(()=>{if(_lsTab==='live')loadLivescore('live');else autoSwitchLive()},60000);
|
| 37 |
+
async function refreshFeatured(){try{const r=await fetch('/api/livescore/featured');const f=await r.json();if(!f||!f.home)return;_featuredEventId=f.event_id;const el=document.querySelector('.featured-match');if(!el)return;const sc=f.status==='live'?'':'upcoming';const st=f.status==='live'?`🔴 ${f.minute||'LIVE'}`:`⏰ ${f.time}`;el.querySelector('.fm-league').textContent=f.league;el.querySelector('.fm-score').textContent=f.score||'VS';el.querySelector('.fm-status').className='fm-status '+sc;el.querySelector('.fm-status').textContent=st;el.setAttribute('onclick',`openMatch('${f.event_id}')`)}catch(e){}}
|
| 38 |
+
setInterval(refreshFeatured,30000);
|
| 39 |
+
async function init(){_cats=await fetch('/api/categories').then(r=>r.json()).catch(()=>[]);let bar='<div class="cat active" data-cat="home">🏠</div><div class="cat" data-cat="video">🎬 Video</div>';_cats.forEach(c=>bar+=`<div class="cat" data-cat="${c.id}">${c.name}</div>`);document.getElementById('cat-bar').innerHTML=bar;document.querySelectorAll('.cat').forEach(t=>t.onclick=()=>switchCat(t.dataset.cat));await loadHome();const pending=localStorage.getItem('pending_article');if(pending){localStorage.removeItem('pending_article');readArticle(pending)}}
|
| 40 |
+
function switchCat(id){document.querySelectorAll('.cat').forEach(x=>x.classList.remove('active'));document.querySelector(`[data-cat="${id}"]`)?.classList.add('active');document.querySelectorAll('.view').forEach(x=>x.classList.remove('active'));document.querySelectorAll('video').forEach(v=>{v.pause();if(v._hls){v._hls.destroy();v._hls=null}});if(_vpHls){_vpHls.destroy();_vpHls=null}document.querySelectorAll('iframe[data-yt-src]').forEach(f=>{f.src=''});if(id==='home'){document.getElementById('view-home').classList.add('active')}else if(id==='video'){document.getElementById('view-video').classList.add('active');loadVideos()}else{document.getElementById('view-cat').classList.add('active');loadCat(id)}}
|
| 41 |
+
function showView(id){document.querySelectorAll('.view').forEach(x=>x.classList.remove('active'));document.getElementById(id).classList.add('active')}
|
| 42 |
+
async function openLeaguePlayer(league,idx){showView('view-tiktok');document.querySelectorAll('.cat').forEach(x=>x.classList.remove('active'));const el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải video...</div>';const articles=_hlLeagueData[league]||[];if(!articles.length){el.innerHTML='<div class="loading">Không có video</div>';return}const cfg=HL_CONFIG[league]||{name:league,emoji:'🎬'};let h=`<button class="back-btn" onclick="switchCat('home')">← ${cfg.emoji} ${cfg.name}</button>`;h+=`<div class="vp-wrap" id="vp-wrap"><video id="vp-video" playsinline controls loop></video><button class="vp-ratio-btn" onclick="toggleRatio()">16:9</button></div>`;h+=`<div class="vp-title" id="vp-title"></div>`;h+=`<div class="pl-list" id="pl-list">`;articles.forEach((a,i)=>{h+=`<div class="pl-item${i===idx?' active':''}" data-idx="${i}" onclick="playFromList('${league}',${i})"><div class="pl-item-thumb">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="pl-item-body"><div class="pl-item-title">${a.title}</div></div></div>`});h+=`</div>`;el.innerHTML=h;playFromList(league,idx)}
|
| 43 |
+
async function playFromList(league,idx){const articles=_hlLeagueData[league]||[];const a=articles[idx];if(!a)return;document.querySelectorAll('.pl-item').forEach(x=>x.classList.remove('active'));document.querySelector(`.pl-item[data-idx="${idx}"]`)?.classList.add('active');const titleEl=document.getElementById('vp-title');if(titleEl)titleEl.textContent=a.title;const video=document.getElementById('vp-video');if(!video)return;if(_vpHls){_vpHls.destroy();_vpHls=null}video.removeAttribute('src');video.innerHTML='';const r=await fetch('/api/video_url?url='+encodeURIComponent(a.link));const v=await r.json();if(!v||!v.src){titleEl.textContent=a.title+' (lỗi)';return}if(v.type==='youtube'){const wrap=video.parentElement;wrap.innerHTML=`<iframe src="${v.src}" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture" style="width:100%;height:100%;border:none"></iframe>`;return}if(v.poster)video.poster=v.poster;if(v.type==='hls'&&Hls.isSupported()){_vpHls=new Hls({maxBufferLength:60,maxMaxBufferLength:120,maxBufferSize:30*1000*1000,startLevel:-1});_vpHls.loadSource(v.src);_vpHls.attachMedia(video);_vpHls.on(Hls.Events.MANIFEST_PARSED,()=>{video.play().catch(()=>{})});_vpHls.on(Hls.Events.ERROR,(e,d)=>{if(d.fatal&&d.type===Hls.ErrorTypes.NETWORK_ERROR)_vpHls.startLoad()})}else{video.src=v.src;video.play().catch(()=>{})}video.scrollIntoView({behavior:'smooth',block:'start'})}
|
| 44 |
+
async function loadHome(){const[news,sh,ai,wall,featured,hlLeagues,wc]=await Promise.all([fetchJsonTimeout('/api/homepage').catch(()=>[]),fetchJsonTimeout('/api/shorts').catch(()=>[]),fetchJsonTimeout('/api/genk_ai').catch(()=>[]),fetchJsonTimeout('/api/wall').catch(()=>({posts:[]})),fetchJsonTimeout('/api/livescore/featured').catch(()=>null),fetchJsonTimeout('/api/highlights/leagues').catch(()=>({})),fetchJsonTimeout('/api/worldcup2026').catch(()=>[])]);_hlLeagueData=hlLeagues;_serverWall=(wall&&wall.posts)||[];let h='';
|
| 45 |
+
if(featured&&featured.home){_featuredEventId=featured.event_id;const sc=featured.status==='live'?'':'upcoming';const st=featured.status==='live'?`🔴 ${featured.minute||'LIVE'}`:`⏰ ${featured.time}`;h+=`<div class="featured-match" onclick="openMatch('${featured.event_id}')"><div class="fm-league">${featured.league}</div><div class="fm-teams"><div class="fm-team"><img src="${featured.home_logo}" onerror="this.style.display='none'"><span>${featured.home}</span></div><div class="fm-score">${featured.score||'VS'}</div><div class="fm-team"><img src="${featured.away_logo}" onerror="this.style.display='none'"><span>${featured.away}</span></div></div><div class="fm-status ${sc}">${st}</div></div>`}
|
| 46 |
+
h+=`<div class="ai-compose"><div class="ai-compose-title">🤖 AI viết bài lên tường</div><div class="ai-compose-row"><input id="ai-topic-input" placeholder="Nhập gợi ý chủ đề bài viết..."><button onclick="createTopicPost()">Tạo bài + ảnh</button></div><div class="ai-compose-row"><input id="ai-url-input" placeholder="Dán URL bài viết để AI tóm tắt lên tường..."><button class="secondary" onclick="createUrlPost()">Chèn URL</button></div></div>`;h+=`<div class="ls-section"><div class="ls-header"><h3>⚽ Livescore</h3></div><div class="ls-tabs"><span class="ls-tab active" data-tab="today" onclick="loadLivescore('today')">📅 Hôm nay</span><span class="ls-tab" data-tab="live" onclick="loadLivescore('live')">🔴 Live</span><span class="ls-tab" data-tab="incoming" onclick="loadLivescore('incoming')">⏰ Sắp tới</span><span class="ls-tab" data-tab="results" onclick="loadLivescore('results')">✅ Kết quả</span><span class="ls-tab" data-tab="bxh_nha" onclick="loadLivescore('bxh_nha')">🏆 NHA</span><span class="ls-tab" data-tab="bxh_laliga" onclick="loadLivescore('bxh_laliga')">🏆 La Liga</span><span class="ls-tab" data-tab="bxh_seriea" onclick="loadLivescore('bxh_seriea')">🏆 Serie A</span><span class="ls-tab" data-tab="bxh_bundesliga" onclick="loadLivescore('bxh_bundesliga')">🏆 Bundesliga</span><span class="ls-tab" data-tab="bxh_ligue1" onclick="loadLivescore('bxh_ligue1')">🏆 Ligue 1</span></div><div class="ls-content" id="ls-content"><div class="loading" style="padding:20px">Đang tải...</div></div></div>`;
|
| 47 |
+
if(_serverWall.length)h+=renderWallSlide(_serverWall);
|
| 48 |
+
if(sh&&sh.length){h+='<div class="slider-wrap"><div class="slider-header"><span class="slider-label">📱 Shorts</span></div><div class="slider-track">';sh.forEach((a,i)=>{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">${a.title}</div></div>`});h+='</div></div>'}
|
| 49 |
+
for(const[key,cfg] of Object.entries(HL_CONFIG)){const vids=hlLeagues[key];if(!vids||!vids.length)continue;h+=`<div class="slider-wrap"><div class="slider-header"><span class="slider-label">${cfg.emoji} ${cfg.name}</span></div><div class="slider-track">`;vids.slice(0,8).forEach((a,i)=>{h+=`<div class="slider-item" onclick="openLeaguePlayer('${key}',${i})"><div class="slider-thumb">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="slider-title">${a.title}</div></div>`});h+='</div></div>'}
|
| 50 |
+
if(wc&&wc.length){h+='<div class="slider-wrap"><div class="slider-header"><span class="slider-label">🌍 Đường đến World Cup 2026</span><span class="slider-note">Thethaovanhoa.vn</span></div><div class="slider-track">';wc.slice(0,18).forEach(a=>{h+=`<div class="slider-item" onclick="readArticle('${a.link.replace(/'/g,"\\'")}','ttvh')"><div class="slider-thumb">${a.img?`<img src="${a.img}">`:''}</div><div class="slider-title">${a.title}</div></div>`});h+='</div></div>'}
|
| 51 |
+
if(ai&&ai.length){h+='<div class="slider-wrap"><div class="slider-header"><span class="slider-label">🤖 Ứng dụng AI</span></div><div class="slider-track">';ai.forEach(a=>{h+=`<div class="slider-item" onclick="readArticle('${a.link.replace(/'/g,"\\'")}','genk')"><div class="slider-thumb">${a.img?`<img src="${proxyImg(a.img)}">`:''}</div><div class="slider-title">${a.title}</div></div>`});h+='</div></div>'}
|
| 52 |
+
const groups={};news.forEach(a=>{if(!groups[a.group])groups[a.group]=[];groups[a.group].push(a)});for(const[g,arts] of Object.entries(groups)){h+=`<div class="section-title">${g}</div><div class="grid">`;arts.slice(0,6).forEach(a=>{const bg=a.source==='bbc'?'badge-bbc':a.source==='dantri'?'badge-dt':a.source==='genk'?'badge-genk':'badge-vne';const lb=a.source==='bbc'?'BBC':a.source==='dantri'?'DT':a.source==='genk'?'GenK':'VnE';h+=`<div class="card" onclick="readArticle('${a.link.replace(/'/g,"\\'")}','${a.source}')"><div class="card-img">${a.img?`<img src="${a.img}">`:''}</div><div class="card-body"><span class="badge ${bg}">${lb}</span><div class="card-title">${a.title}</div></div></div>`});h+='</div>'}
|
| 53 |
+
document.getElementById('view-home').innerHTML=h;loadLivescore('today');autoSwitchLive()}
|
| 54 |
+
function fetchJsonTimeout(url,ms=9000){const c=new AbortController();const t=setTimeout(()=>c.abort(),ms);return fetch(url,{signal:c.signal}).then(r=>r.json()).finally(()=>clearTimeout(t))}
|
| 55 |
+
function renderWallSlide(posts){let h='<div class="slider-wrap" data-wall-live="1"><div class="slider-header"><span class="slider-label">🧱 Tường AI</span><span class="slider-note">Rewrite AI</span></div><div class="slider-track">';posts.slice(0,30).forEach(p=>{h+=`<div class="wall-item"><div class="wall-thumb">${p.img?`<img src="${p.img}">`:''}</div><div class="wall-title">${escapeHtml(p.title)}</div><div class="wall-tone">Rewrite AI</div><div class="wall-text">${escapeHtml(p.text)}</div><div class="wall-actions"><button class="primary" onclick="readWallPost('${p.id}')">Xem</button>${p.url?`<button onclick="readArticle('${p.url.replace(/'/g,"\\'")}')">Gốc</button>`:''}</div></div>`});h+='</div></div>';return h}
|
| 56 |
+
function prependWallPost(p){if(!p)return;_serverWall=[p].concat((_serverWall||[]).filter(x=>x.id!==p.id));let existing=document.querySelector('[data-wall-live="1"]')||document.querySelector('.slider-label')?.closest('.slider-wrap');let html=renderWallSlide(_serverWall);let tmp=document.createElement('div');tmp.innerHTML=html;let nw=tmp.firstChild;nw.dataset.wallLive='1';let home=document.getElementById('view-home');let old=document.querySelector('[data-wall-live="1"]');if(old)old.replaceWith(nw);else if(home)home.insertBefore(nw,home.children[1]||null);}
|
| 57 |
+
function readWallPost(id){let p=_serverWall.find(x=>x.id===id);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 Rewrite</span><h1 class="article-title">${escapeHtml(p.title)}</h1>${p.img?`<img class="article-img" src="${p.img}">`:''}<div class="article-summary">Bài viết AI tóm tắt và viết lại</div><p class="article-p" style="white-space:pre-wrap">${escapeHtml(p.text)}</p><div class="article-actions"><button onclick="doShare('${escapeHtml(p.title)}','${p.url||location.href}','${p.img||''}')">📤 Chia sẻ</button>${p.url?`<button onclick="readArticle('${p.url.replace(/'/g,"\\'")}')">Đọc bài gốc</button>`:''}</div></div>`;document.getElementById('view-article').innerHTML=h;window.scrollTo(0,0)}
|
| 58 |
+
async function loadCat(id){const el=document.getElementById('view-cat');el.innerHTML='<div class="loading">Đang tải...</div>';const arts=await fetch('/api/category/'+id).then(r=>r.json()).catch(()=>[]);if(!arts.length){el.innerHTML='<div class="loading">Không có tin</div>';return}let h='<div class="grid">';arts.forEach(a=>{const bg=a.source==='bbc'?'badge-bbc':a.source==='dantri'?'badge-dt':a.source==='genk'?'badge-genk':'badge-vne';h+=`<div class="card" onclick="readArticle('${a.link.replace(/'/g,"\\'")}','${a.source}')"><div class="card-img">${a.img?`<img src="${a.source==='genk'?proxyImg(a.img):a.img}">`:''}</div><div class="card-body"><span class="badge ${bg}">${a.source}</span><div class="card-title">${a.title}</div></div></div>`});h+='</div>';el.innerHTML=h}
|
| 59 |
+
async function readArticle(url,source){const supported=url.includes('vnexpress.net')||url.includes('bbc.com')||url.includes('dantri.com.vn')||url.includes('genk.vn')||url.includes('thethaovanhoa.vn');if(!supported){window.open(url,'_blank');return}showView('view-article');const el=document.getElementById('view-article');el.innerHTML='<div class="loading">Đang tải...</div>';const data=await fetch('/api/article?url='+encodeURIComponent(url)).then(r=>r.json()).catch(()=>null);if(!data||data.error||!data.body||!data.body.length){el.innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="loading"><a href="${url}" target="_blank" style="color:#5cb87a">Mở link gốc</a></div>`;return}_currentArticle={url,data};let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><h1 class="article-title">${data.title}</h1>`;if(data.summary)h+=`<div class="article-summary">${data.summary}</div>`;let seenImgs=new Set();data.body.forEach(b=>{if(b.type==='p')h+=`<p class="article-p">${b.text}</p>`;else if(b.type==='img'&&b.src&&!seenImgs.has(b.src)){seenImgs.add(b.src);h+=`<img class="article-img" src="${b.src}">`}else if(b.type==='heading')h+=`<h2 class="article-h2">${b.text}</h2>`});h+=`<div class="article-actions"><select id="rewrite-tone"><option value="vui-ve">Vui vẻ</option><option value="nghiem-tuc">Nghiêm túc</option><option value="nghi-luan">Nghị luận</option><option value="hoi-dap">Hỏi đáp</option><option value="soi-noi">Sôi nổi</option><option value="thu-hut">Thu hút</option><option value="phan-tich">Phân tích</option><option value="chuyen-gia">Chuyên gia</option></select><button class="primary" onclick="rewriteCurrentArticle()">🤖 AI viết lại & đăng tường</button><button onclick="doShare('${(data.title||'').replace(/'/g,"\\'")}','${url.replace(/'/g,"\\'")}','${(data.og_image||'').replace(/'/g,"\\'")}')">📤 Chia sẻ</button><button onclick="window.open('${url}','_blank')">🔗 Gốc</button></div><div id="rewrite-result"></div></div>`;el.innerHTML=h;window.scrollTo(0,0)}
|
| 60 |
+
function rewriteCurrentArticle(){if(!_currentArticle)return;let tone=document.getElementById('rewrite-tone')?.value||'nghiem-tuc';let btn=document.querySelector('.article-actions button.primary');if(btn){btn.textContent='Đang rewrite...';btn.disabled=true}fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:_currentArticle.url,tone})}).then(r=>r.json()).then(j=>{if(j&&j.post){document.getElementById('rewrite-result').innerHTML=`<div class="rewrite-box"><div class="rewrite-title">Đã rewrite và đăng lên Tường AI</div><div class="rewrite-text">${escapeHtml(j.post.text||'')}</div></div>`;prependWallPost(j.post);alert('Đã đăng lên Tường AI');}else alert(j.error||'Không tạo được bài AI')}).catch(()=>alert('Lỗi tạo bài AI')).finally(()=>{if(btn){btn.textContent='🤖 AI viết lại & đăng tường';btn.disabled=false}})}
|
| 61 |
+
function refreshWallAfterPost(){fetch('/api/wall').then(r=>r.json()).then(j=>{_serverWall=(j&&j.posts)||[];let old=document.querySelector('.slider-label');switchCat('home');}).catch(()=>{})}
|
| 62 |
+
function createTopicPost(){let inp=document.getElementById('ai-topic-input');let topic=(inp&&inp.value||'').trim();if(!topic){alert('Nhập chủ đề trước');return;}fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})}).then(r=>r.json()).then(j=>{if(j&&j.post){prependWallPost(j.post);alert('Đã tạo bài và đăng lên tường');if(inp)inp.value='';}else alert(j.error||'Lỗi tạo bài');}).catch(()=>alert('Lỗi tạo bài'));}
|
| 63 |
+
function createUrlPost(){let inp=document.getElementById('ai-url-input');let url=(inp&&inp.value||'').trim();if(!url){alert('Dán URL trước');return;}fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})}).then(r=>r.json()).then(j=>{if(j&&j.post){prependWallPost(j.post);alert('Đã tóm tắt URL và đăng lên tường');if(inp)inp.value='';}else alert(j.error||'Lỗi URL');}).catch(()=>alert('Lỗi URL'));}
|
| 64 |
+
async function loadVideos(){const el=document.getElementById('view-video');if(el.dataset.loaded)return;el.innerHTML='<div class="loading">Đang tải...</div>';const[hl,bdp]=await Promise.all([fetch('/api/highlights').then(r=>r.json()).catch(()=>[]),fetch('/api/bdp_videos').then(r=>r.json()).catch(()=>[])]);let h='<div class="section-title">🎬 Highlight</div><div class="grid">';hl.forEach((a,i)=>{h+=`<div class="card" onclick="openTikTok('highlights',${i})"><div class="card-img">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="card-body"><span class="badge badge-fpt">HL</span><div class="card-title">${a.title}</div></div></div>`});h+='</div>';if(bdp.length){h+='<div class="section-title">⚽ BDP</div><div class="grid">';bdp.forEach((a,i)=>{h+=`<div class="card" onclick="openTikTok('bdp',${i})"><div class="card-img">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="card-body"><span class="badge badge-bdp">BDP</span><div class="card-title">${a.title}</div></div></div>`});h+='</div>'}el.innerHTML=h;el.dataset.loaded='1'}
|
| 65 |
+
async function openTikTok(type,startIdx){showView('view-tiktok');document.querySelectorAll('.cat').forEach(x=>x.classList.remove('active'));const el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải video...</div>';let articles;if(type==='shorts')articles=await fetch('/api/shorts').then(r=>r.json()).catch(()=>[]);else if(type==='highlights')articles=await fetch('/api/highlights').then(r=>r.json()).catch(()=>[]);else articles=await fetch('/api/bdp_videos').then(r=>r.json()).catch(()=>[]);await buildTikTokPlayer(articles,startIdx,type)}
|
| 66 |
+
async function buildTikTokPlayer(articles,startIdx,type){const el=document.getElementById('view-tiktok');const vids=[];const results=await Promise.all(articles.map(async(a,i)=>{try{const r=await fetch('/api/video_url?url='+encodeURIComponent(a.link));const v=await r.json();if(v&&v.src)return{article:a,video:v,idx:i}}catch(e){}return null}));results.forEach(r=>{if(!r)return;const{article:a,video:v,idx:i}=r;vids.push({...a,...v,_idx:i})});vids.sort((a,b)=>a._idx-b._idx);if(!vids.length){el.innerHTML='<div class="loading">Không tìm thấy video</div>';return}let ti=vids.findIndex(v=>v._idx===startIdx);if(ti<0)ti=0;const ordered=ti>0?[...vids.slice(ti),...vids.slice(0,ti)]:vids;_tikData=ordered;let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">`;ordered.forEach((v,i)=>{const isYT=v.type==='youtube';const isHLS=!isYT&&v.src&&v.src.includes('.m3u8');const poster=v.poster?` poster="${v.poster}"`:'';const vtag=isYT?`<iframe data-yt-src="${v.src}" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture"></iframe>`:isHLS?`<video playsinline preload="none"${poster} data-hls="${v.src}" loop controls></video>`:`<video playsinline preload="none"${poster} loop controls><source src="${v.src}" type="video/mp4"></video>`;h+=`<div class="tiktok-slide" id="tslide-${i}">${vtag}<div class="tiktok-bottom"><span class="badge badge-fpt">VIDEO</span><p class="tiktok-title">${v.title}</p></div><div class="tiktok-right"><button class="tiktok-right-btn" onclick="event.stopPropagation();shareVid(${i})"><div class="icon">📤</div></button></div><span class="tiktok-counter">${i+1}/${ordered.length}</span></div>`});h+='</div></div>';el.innerHTML=h;initFeed()}
|
| 67 |
+
function shareVid(i){const v=_tikData[i];if(!v)return;doShareVideo(v.title,v.link||'',v.poster||v.img||'','highlights')}
|
| 68 |
+
function initFeed(){const feed=document.getElementById('tiktok-feed');if(!feed)return;const slides=feed.querySelectorAll('.tiktok-slide');let cur=-1;function activateSlide(idx){if(idx===cur)return;slides.forEach((sl,i)=>{const v=sl.querySelector('video');const f=sl.querySelector('iframe');if(i===idx){if(v&&v.dataset.hls){if(!v._hls){const hls=new Hls({maxBufferLength:60,maxMaxBufferLength:120,maxBufferSize:30*1000*1000,startLevel:-1});hls.loadSource(v.dataset.hls);hls.attachMedia(v);hls.on(Hls.Events.MANIFEST_PARSED,()=>v.play().catch(()=>{}));hls.on(Hls.Events.ERROR,(e,d)=>{if(d.fatal&&d.type===Hls.ErrorTypes.NETWORK_ERROR)hls.startLoad()});v._hls=hls}else v.play().catch(()=>{})}else if(v)v.play().catch(()=>{});if(f&&!f.src&&f.dataset.ytSrc)f.src=f.dataset.ytSrc}else{if(v){v.pause();if(v._hls){v._hls.destroy();v._hls=null}}if(f&&f.src)f.src=''}});cur=idx}let sT;feed.addEventListener('scroll',()=>{clearTimeout(sT);sT=setTimeout(()=>{const rect=feed.getBoundingClientRect();const ctr=rect.top+rect.height/2;let best=-1,bestD=1e9;slides.forEach((sl,i)=>{const d=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(d<bestD){bestD=d;best=i}});if(best>=0)activateSlide(best)},150)});setTimeout(()=>activateSlide(0),300);slides.forEach(sl=>{const v=sl.querySelector('video');if(v)v.addEventListener('click',e=>{e.preventDefault();v.paused?v.play().catch(()=>{}):v.pause()})})}
|
| 69 |
+
function toggleRatio(){const w=document.getElementById('vp-wrap');const btn=w?.querySelector('.vp-ratio-btn');if(!w||!btn)return;if(w.classList.contains('wide')){w.classList.remove('wide');btn.textContent='16:9'}else{w.classList.add('wide');btn.textContent='1:1'}}
|
| 70 |
init();
|
| 71 |
</script>
|
| 72 |
+
</body>
|
| 73 |
+
</html>
|