bep40 commited on
Commit
ba0e89f
·
verified ·
1 Parent(s): 82dd049

Restore Space to revision 33c3dda

Browse files
Files changed (3) hide show
  1. ai_ext.py +4 -0
  2. ai_patch.py +565 -253
  3. 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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, re, html as html_lib, subprocess, requests
2
- from urllib.parse import quote_plus
3
- from bs4 import BeautifulSoup
4
- from fastapi import Request
5
- from fastapi.responses import JSONResponse, HTMLResponse, FileResponse
 
 
 
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
- return re.sub(r"\s+", " ", html_lib.unescape(s or "")).strip()
 
 
 
17
 
18
 
19
  def _norm(s):
20
  s = s.lower()
21
  s = re.sub(r"[^\wÀ-ỹ\s]", " ", s)
22
- return re.sub(r"\s+", " ", s).strip()
 
23
 
24
 
25
- def _similar(a,b):
26
- ta,tb=set(_norm(a).split()),set(_norm(b).split())
27
- if not ta or not tb:return False
28
- return len(ta&tb)/max(1,min(len(ta),len(tb)))>=0.72
 
 
29
 
30
 
31
- def _dedupe_units(units,max_units=7):
32
- out=[];seen=set()
33
  for u in units:
34
- u=_clean(re.sub(r"^[-•*\d\.\)\s]+","",u))
35
- if len(u)<18:continue
36
- nu=_norm(u)
37
- if nu in seen or any(_similar(u,o) for o in out):continue
38
- seen.add(nu);out.append(u)
39
- if len(out)>=max_units:break
 
 
 
 
 
 
40
  return out
41
 
42
 
43
- def _postprocess(text,max_units=7):
44
- text=_clean(text)
45
- if not text:return text
46
- lines=[]
47
- for line in re.split(r"\n+",text):
48
- line=_clean(line)
49
- if not line:continue
50
- low=line.lower()
51
- if any(low.startswith(p) and len(line)<80 for p in ("dưới đây","sau đây","tôi sẽ","tiêu đề:","sapo:","nội dung:")):continue
52
- lines.append(line)
53
- units=[]
54
- for line in lines:
55
- units.extend(re.split(r"(?<=[\.\!\?])\s+(?=[A-ZÀ-Ỹ0-9])",line) if len(line)>260 else [line])
56
- units=_dedupe_units(units,max_units)
57
- if not units:return text[:900]
58
- title=""
59
- if lines and len(lines[0])<=90 and not lines[0].startswith(("-","•","*")):
60
- title=lines[0];units=[u for u in units if not _similar(u,title)]
61
- body="\n".join("• "+u for u in units[:max_units])
62
- return (title+"\n\n"+body).strip() if title else body
63
-
64
-
65
- def _fallback_summary(prompt,max_units=6):
66
- text=prompt or ""
67
- for marker in ["Nội dung nguồn:","Nội dung gốc:","Nội dung:","Nguồn/bối cảnh internet:"]:
 
 
 
 
 
 
 
 
 
 
 
 
68
  if marker in text:
69
- text=text.split(marker,1)[1];break
70
- text=re.sub(r"https?://\S+","",text);text=_clean(text)
71
- sents=[_clean(s) for s in re.split(r"(?<=[\.\!\?])\s+(?=[A-ZÀ-Ỹ0-9])",text)]
72
- units=_dedupe_units([s for s in sents if 45<=len(s)<=260],max_units)
73
- return "\n".join("• "+u for u in units) if units else ("��� "+text[:700].rsplit(" ",1)[0] if text else " Không có đủ nội dung nguồn để tóm tắt.")
 
 
 
 
 
 
 
 
 
 
 
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:names.append(via)
81
- return "Nguồn tham khảo: "+", ".join(names[:5]) if names else "Nguồn tham khảo: tổng hợp internet"
 
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 _rich_web_context(topic,limit=5):
101
- try: ctx,sources=base.web_context(topic,limit=limit)
102
- except Exception: ctx,sources="",[]
103
- rich=[];rich_sources=[];seen=set()
104
- for s in (sources or [])[:limit*2]:
105
- url=s.get("url") or ""
106
- if not url.startswith("http") or url in seen:continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  seen.add(url)
108
  try:
109
- data=base.scrape_any_url(url)
110
- raw=(data.get("summary","")+"\n"+data.get("text","")).strip()
111
- if len(raw)<180:continue
112
- title=data.get("title") or s.get("title") or url
113
- via=data.get("via") or s.get("via") or base._domain(url)
114
- rich.append(f"### {title} ({via})\n{raw[:2400]}")
115
- rich_sources.append({"title":title,"url":url,"excerpt":raw[:700],"via":via})
116
- if len(rich)>=limit:break
117
- except Exception:continue
118
- if rich:return "\n\n".join(rich),rich_sources
119
- if ctx:return ctx,sources
120
- return f"Chủ đề cần tóm tắt: {topic}. Không truy xuất được nguồn đầy đủ, hãy tạo bản tóm tắt định hướng ngắn.",[]
121
-
122
-
123
- async def qwen_resilient(prompt,image_url=None,max_tokens=1200):
124
- token=base._hf_token();errors=[]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  try:
126
- orig=getattr(base,"_original_qwen_generate",None)
127
- if orig:
128
- txt=await orig(prompt,image_url=image_url,max_tokens=max_tokens)
129
- if txt:base.LAST_QWEN_ERROR="";return txt
130
- except Exception as e:errors.append(f"sdk:{str(e)[:160]}")
 
 
 
 
 
131
  if token:
132
- headers={"Authorization":"Bearer "+token,"Content-Type":"application/json"}
133
- for model in [os.getenv("QWEN_VL_MODEL",""),"Qwen/Qwen2.5-VL-7B-Instruct","Qwen/Qwen2.5-VL-3B-Instruct","Qwen/Qwen2.5-7B-Instruct","Qwen/Qwen2.5-3B-Instruct","Qwen/Qwen2.5-1.5B-Instruct"]:
134
- if not model:continue
 
 
 
 
 
 
 
 
 
 
135
  try:
136
- is_vl="VL" in model and bool(image_url)
137
- content=([{"type":"image_url","image_url":{"url":image_url}},{"type":"text","text":prompt}] if is_vl else prompt)
138
- payload={"model":model,"messages":[{"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 ý."},{"role":"user","content":content}],"max_tokens":min(int(max_tokens or 900),1400),"temperature":0.35,"top_p":0.85}
139
- r=requests.post("https://router.huggingface.co/v1/chat/completions",headers=headers,json=payload,timeout=95)
140
- if r.status_code>=300:errors.append(f"{model}:{r.status_code}");continue
141
- txt=(r.json().get("choices",[{}])[0].get("message",{}).get("content") or "").strip()
142
- if txt:base.LAST_QWEN_ERROR="";return txt
143
- except Exception as e:errors.append(f"{model}:{str(e)[:120]}")
144
- base.LAST_QWEN_ERROR=" | ".join(errors[-5:]) or "fallback"
145
- return _fallback_summary(prompt,6)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
 
147
 
148
- if not hasattr(base,"_original_qwen_generate"):
149
- base._original_qwen_generate=base.qwen_generate
150
- base.qwen_generate=qwen_resilient
151
 
152
 
153
- _PATCHED={('/api/topic_post','POST'),('/api/url_wall','POST'),('/api/rewrite_share','POST'),('/api/ai/short/{post_id}','POST'),('/api/ai/short-file/{file_id}','GET')}
154
- app.router.routes=[r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in _PATCHED)]
 
 
 
 
 
 
 
 
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 topic_post(request:Request):
161
- body=await request.json();topic=base._clean_text(body.get('topic',''))
162
- if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
163
- ctx,sources=_rich_web_context(topic,5)
164
- image=base.pollinations_image_url(topic)
165
- prompt=f"""Tóm tắt tổng hợp chủ đề để đăng Tường AI.
166
-
167
- Chủ đề: {topic}
168
-
169
- Yêu cầu:
170
- - Đọc nội dung các nguồn bên dưới; nếu có bài nguồn thì dựa vào nội dung bài, không chỉ tiêu đề.
171
- - Tạo 1 bản tóm tắt chung duy nhất.
172
- - Không tạo nhiều bài riêng.
 
 
 
 
 
 
 
 
173
  - Không lặp ý.
174
- - Tối đa 6 gạch đầu dòng.
175
- - Cuối cùng ghi nguồn tham khảo.
176
-
177
- Nguồn/bối cảnh internet:
178
- {ctx}"""
179
- text=await base.qwen_generate(prompt,image_url=image,max_tokens=950)
180
- text=_postprocess(text,7)
181
- if 'Nguồn tham khảo:' not in text:text+='\n\n'+_source_line(sources)
182
- post=base.make_post('Tổng hợp: '+topic,text,image,'','topic',sources=sources[:5])
183
- posts=base._load_ai_wall();posts.insert(0,post);base._save_ai_wall(posts)
184
- return JSONResponse({'post':post})
 
 
 
 
 
185
 
186
  @app.post('/api/url_wall')
187
- async def url_wall(request:Request):
188
- body=await request.json();url=base._clean_text(body.get('url',''))
189
- if not url.startswith('http'):return JSONResponse({'error':'missing url'},status_code=400)
190
- try:data=base.scrape_any_url(url)
191
- except Exception as e:return JSONResponse({'error':'Không scrape được URL: '+str(e)[:180]},status_code=422)
192
- raw=(data.get('summary','')+'\n'+data.get('text','')).strip()
193
- if len(raw)<120:return JSONResponse({'error':'URL không có đủ nội dung để tóm tắt'},status_code=422)
194
- prompt=_make_summary_prompt(data.get('title',''),raw,data.get('via','') or base._domain(url))
195
- text=await base.qwen_generate(prompt,image_url=data.get('image') or None,max_tokens=850)
196
- text=_postprocess(text,6)
197
- src=[{'title':data.get('title'),'url':url,'excerpt':raw[:500],'via':data.get('via') or base._domain(url)}]
198
- if 'Nguồn tham khảo:' not in text:text+='\n\n'+_source_line(src)
199
- post=base.make_post(data.get('title') or 'Bài viết',text,data.get('image') or '',url,'url',sources=src)
200
- posts=base._load_ai_wall();posts.insert(0,post);base._save_ai_wall(posts)
201
- return JSONResponse({'post':post})
 
 
 
 
 
 
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
- def wrap_text(draw,text,font,max_width,max_lines):
238
- words=_clean(text).split();lines=[];cur=''
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  for w in words:
240
- test=(cur+' '+w).strip()
241
- try:width=draw.textbbox((0,0),test,font=font)[2]
242
- except Exception:width=len(test)*20
243
- if width<=max_width:cur=test
 
 
 
244
  else:
245
- if cur:lines.append(cur)
246
- cur=w
247
- if len(lines)>=max_lines:break
248
- if cur and len(lines)<max_lines:lines.append(cur)
 
 
 
249
  return lines
250
 
251
- def make_segment_frame(post,segment,idx,total,img_path,out_path):
252
- if Image is None:return base._make_short_frame(post,img_path,out_path)
253
- W,H=1080,1920;bg=Image.new('RGB',(W,H),(14,14,14))
 
 
 
254
  try:
255
- im=Image.open(img_path).convert('RGB');target=(1080,760);ratio=im.width/im.height;tr=target[0]/target[1]
256
- if ratio>tr:nh=target[1];nw=int(nh*ratio)
257
- else:nw=target[0];nh=int(nw/ratio)
258
- im=im.resize((nw,nh));left=(nw-target[0])//2;top=(nh-target[1])//2;bg.paste(im.crop((left,top,left+target[0],top+target[1])),(0,0))
259
- except Exception:pass
260
- draw=ImageDraw.Draw(bg)
 
 
 
 
 
 
 
 
 
 
 
 
261
  try:
262
- fb=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',44);fl=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',28)
263
- except Exception:fb=fl=None
264
- draw.rectangle((0,720,W,H),fill=(14,14,14));margin=48;maxw=W-margin*2
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
- tld='com.vn' if voice in ('nu','female','mien-nam') else 'com'
277
- try:base.gTTS(text,lang='vi',tld=tld,slow=False).save(out_path)
278
- except TypeError:base.gTTS(text,lang='vi',slow=False).save(out_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
 
280
  @app.post('/api/ai/short/{post_id}')
281
- async def segmented_short(post_id:str,request:Request):
282
- try:body=await request.json()
283
- except Exception:body={}
284
- voice=str(body.get('voice','nu')).lower().strip();emotion=str(body.get('emotion','neutral')).lower().strip();speed=max(0.85,min(1.35,float(body.get('speed',1.2) or 1.2)))
285
- posts=base._load_ai_wall();post=next((p for p in posts if str(p.get('id'))==str(post_id)),None)
286
- if not post:return JSONResponse({'error':'post not found'},status_code=404)
287
- os.makedirs(base.SHORTS_DIR,exist_ok=True);suffix=f'_{voice}_{emotion}_{str(speed).replace(".","p")}_segments'
288
- out_mp4=os.path.join(base.SHORTS_DIR,base._safe_name(post_id+suffix)+'.mp4')
289
- if os.path.exists(out_mp4):post['video']='/api/ai/short-file/'+post_id+suffix;base._save_ai_wall(posts);return JSONResponse({'video':post['video']})
290
- work=os.path.join(base.SHORTS_DIR,base._safe_name(post_id+suffix));os.makedirs(work,exist_ok=True)
291
- img=os.path.join(work,'image.jpg');base._download_image(post.get('img'),post.get('title','AI news'),img)
292
- segs=split_segments(post)
293
- if not segs:return JSONResponse({'error':'Không có nội dung để tạo short'},status_code=422)
294
- clips=[]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
  try:
296
- for i,seg in enumerate(segs,1):
297
- frame=os.path.join(work,f'frame_{i:02d}.jpg');aud=os.path.join(work,f'aud_{i:02d}.mp3');audf=os.path.join(work,f'audf_{i:02d}.mp3');clip=os.path.join(work,f'clip_{i:02d}.mp4')
298
- prefix={'urgent':'Tin nhanh.','warm':'Câu chuyện đáng chú ý.','serious':'Bản tin nghiêm túc.','energetic':'Cập nhật nổi bật.'}.get(emotion,'')
299
- spoken=(prefix+' '+seg).strip() if i==1 and prefix else seg
300
- make_segment_frame(post,seg,i,len(segs),img,frame);make_tts(spoken,voice,aud)
301
- subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',audf],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=120)
302
- subprocess.run(['ffmpeg','-y','-loop','1','-i',frame,'-i',audf,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k','-vf','scale=1080:1920',clip],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=180)
303
- clips.append(clip)
304
- listfile=os.path.join(work,'list.txt')
305
- with open(listfile,'w',encoding='utf-8') as f:
306
- for c in clips:f.write("file '{}'\n".format(c.replace("'","'\\''")))
307
- subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',listfile,'-c','copy',out_mp4],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=240)
308
- post['video']='/api/ai/short-file/'+post_id+suffix;post['short_voice']=voice;post['short_emotion']=emotion;post['short_speed']=speed;post['short_subtitles']=False;post['short_segments']=len(segs);base._save_ai_wall(posts)
309
- return JSONResponse({'video':post['video'],'segments':len(segs),'speed':speed,'subtitles':False})
310
- except Exception as e:return JSONResponse({'error':'Không tạo được shorts: '+str(e)[:180]},status_code=500)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
311
 
312
  @app.get('/api/ai/short-file/{file_id}')
313
- def short_file(file_id:str):
314
- path=os.path.join(base.SHORTS_DIR,base._safe_name(file_id)+'.mp4')
315
- if not os.path.exists(path):return JSONResponse({'error':'not found'},status_code=404)
316
- return FileResponse(path,media_type='video/mp4',filename=f'vnews-ai-{file_id}.mp4')
317
-
318
- # Front-end patch
319
- app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
320
- PATCH_INJECT=r'''
 
 
 
 
 
 
 
 
 
 
321
  <script>
322
  (function(){
323
- if(window.aiMakeShortPatched){const old=window.aiMakeShortPatched;window.aiMakeShortPatched=function(i){return old(i)}}
324
- 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.post){window.location.reload();alert('Đã tổng hợp nội dung các nguồn thành 1 bản tóm tắt trê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'));};
 
 
 
 
 
 
 
 
 
 
 
 
 
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:html=f.read()
331
- # Preserve original ai_ext injection if present there via main import? This app now injects only needed override.
332
- extra=getattr(base,'AI_INJECT','') if hasattr(base,'AI_INJECT') else ''
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 đủ 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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"><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
5
- <title>VNEWS - Tin Tức Việt Nam</title><meta name="description" content="Tin tức tổng hợp, bóng đá trực tiếp, video highlight.">
6
- <meta property="og:title" content="VNEWS - Tin Tức Việt Nam"><meta property="og:image" content="https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg">
7
- <link rel="canonical" href="https://bep40-vnews.hf.space"><script src="https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js"></script>
 
 
 
 
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 · Shorts</p></div>
14
- <div class="cats" id="cat-bar"></div><div id="view-home" class="view active"><div class="loading">Đang tải...</div></div><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>
15
- <div class="match-overlay" id="match-overlay"><div class="mo-header"><h3>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>
 
 
16
  <script>
17
- const SPACE=location.origin;let _cats=[],_tikData=[],_currentEventId='',_hlLeagueData={},_vpHls=null,_shortsData=[],_shortsPlayer=null,_ytLoading=false,_lsTab='today';
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:"🇩🇪"},"world-cup":{name:"World Cup 2026",emoji:"🌍"}};
19
- 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));}
20
- 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(()=>{});}
21
- 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(()=>{});}
22
- async function init(){_cats=await fetchJsonTimeout('/api/categories',5000).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);}}
23
- 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=>{try{v.pause();if(v._hls){v._hls.destroy();v._hls=null;}}catch(e){}});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);}}
24
- function showView(id){document.querySelectorAll('.view').forEach(x=>x.classList.remove('active'));document.getElementById(id).classList.add('active');}
25
- async function loadHome(){const home=document.getElementById('view-home');home.innerHTML='<div id="home-fast"></div><div id="home-lazy"></div><div class="loading" id="home-loadnote">Đang tải...</div>';fetchJsonTimeout('/api/homepage',9000).then(news=>renderNews(news||[])).catch(()=>{let n=document.getElementById('home-loadnote');if(n)n.textContent='Không tải được tin chính';});setTimeout(loadLazySections,80);}
26
- function renderNews(news){let n=document.getElementById('home-loadnote');if(n)n.remove();let h='';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 loading="lazy" 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>';}document.getElementById('home-fast').innerHTML=h||'<div class="loading">Không có dữ liệu</div>';}
27
- async function loadLazySections(){const lazy=document.getElementById('home-lazy');let h='';try{let f=await fetchJsonTimeout('/api/livescore/featured',5000);if(f&&f.home){const sc=f.status==='live'?'':'upcoming';const st=f.status==='live'?`🔴 ${f.minute||'LIVE'}`:`⏰ ${f.time}`;h+=`<div class="featured-match" onclick="openMatch('${f.event_id}')"><div class="fm-league">${f.league}</div><div class="fm-teams"><div class="fm-team"><img src="${f.home_logo}" onerror="this.style.display='none'"><span>${f.home}</span></div><div class="fm-score">${f.score||'VS'}</div><div class="fm-team"><img src="${f.away_logo}" onerror="this.style.display='none'"><span>${f.away}</span></div></div><div class="fm-status ${sc}">${st}</div></div>`;}}catch(e){}
28
- h+=`<div class="ls-section"><div class="ls-header"><h3>⚽ Bóng Đá</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>`;lazy.innerHTML=h;loadLivescore('today');checkAndSwitchToLive();loadDantriHotAsync();loadShortsAsync();loadWorldCupRoadAsync();loadHighlightsLeaguesAsync();}
29
- function loadDantriHotAsync(){fetchJsonTimeout('/api/dantri_hot',7000).then(d=>{if(!d||!d.length)return;let w=document.createElement('div');w.className='slider-wrap';let h='<div class="slider-header"><span class="slider-label">🔥 Tin Nổi Bật</span></div><div class="slider-track">';d.forEach(a=>h+=`<div class="slider-item" onclick="readArticle('${a.link.replace(/'/g,"\\'")}','dantri')"><div class="slider-thumb">${a.img?`<img loading="lazy" src="${a.img}">`:''}</div><div class="slider-title">${a.title}</div></div>`);h+='</div>';w.innerHTML=h;document.getElementById('home-lazy').prepend(w);}).catch(()=>{});}
30
- function loadShortsAsync(){fetchJsonTimeout('/api/shorts',12000).then(sh=>{if(!sh||!sh.length)return;_shortsData=sh;let w=document.createElement('div');w.className='slider-wrap';let h='<div class="slider-header"><span class="slider-label">📱 Shorts · Dân Trí & Sức Khỏe</span></div><div class="slider-track">';sh.forEach((a,i)=>h+=`<div class="slider-item shorts-item" onclick="openShortsViewer(${i})"><div class="slider-thumb shorts-thumb">${a.img?`<img loading="lazy" src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="slider-title">${a.title}</div></div>`);h+='</div>';w.innerHTML=h;document.getElementById('home-lazy').appendChild(w);}).catch(()=>{});}
31
- function loadWorldCupRoadAsync(){fetchJsonTimeout('/api/worldcup2026',12000).then(wc=>{if(!wc||!wc.length)return;let w=document.createElement('div');w.className='slider-wrap';let h='<div class="slider-header"><span class="slider-label">🏆 Đường đến World Cup 2026</span></div><div class="slider-track">';wc.forEach(a=>h+=`<div class="slider-item" onclick="readArticle('${a.link.replace(/'/g,"\\'")}','ttvh')"><div class="slider-thumb">${a.img?`<img loading="lazy" src="${a.img}" onerror="this.parentElement.style.background='#222'">`:''}</div><div class="slider-title">${a.title}</div></div>`);h+='</div>';w.innerHTML=h;document.getElementById('home-lazy').appendChild(w);}).catch(()=>{});}
32
- function loadHighlightsLeaguesAsync(){fetchJsonTimeout('/api/highlights/leagues',12000).then(hl=>{_hlLeagueData=hl||{};for(const[key,cfg] of Object.entries(HL_CONFIG)){const vids=_hlLeagueData[key];if(!vids||!vids.length)continue;let w=document.createElement('div');w.className='slider-wrap';let h=`<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 loading="lazy" src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="slider-title">${a.title}</div></div>`);h+='</div>';w.innerHTML=h;document.getElementById('home-lazy').appendChild(w);}}).catch(()=>{});}
33
- 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');if(!el)return;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 fetchJsonTimeout(ep,8000);if(r.html&&r.html.length>50){el.innerHTML=r.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 tải dữ liệu</div>';}}
34
- 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()));}
35
- async function checkAndSwitchToLive(){try{const r=await fetchJsonTimeout('/api/livescore/live',5000);if(r.html&&r.html.length>100)loadLivescore('live');}catch(e){}}
36
- setInterval(()=>{if(_lsTab==='live')loadLivescore('live');},60000);
37
- async function openMatch(id){if(!id)return;_currentEventId=id;document.getElementById('match-overlay').classList.add('active');document.body.style.overflow='hidden';loadMatchTab('comm');}
38
- function closeMatch(){document.getElementById('match-overlay').classList.remove('active');document.body.style.overflow='';}
39
- 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 fetchJsonTimeout(tab==='stats'?`/api/match/${_currentEventId}/stats`:`/api/match/${_currentEventId}/commentaries`,8000);el.innerHTML=r.html||'<div class="loading">Không dữ liệu</div>';}catch(e){el.innerHTML='<div class="loading">Lỗi</div>';}}
40
- 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 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><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><div class="vp-title" id="vp-title"></div><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);}
41
- 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(()=>{});});}else{video.src=v.src;video.play().catch(()=>{});}video.scrollIntoView({behavior:'smooth',block:'start'});}
42
- 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';}}
43
- async function loadCat(id){const el=document.getElementById('view-cat');el.innerHTML='<div class="loading">Đang tải...</div>';const arts=await fetchJsonTimeout('/api/category/'+id,9000).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 loading="lazy" src="${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;}
44
- 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 fetchJsonTimeout('/api/article?url='+encodeURIComponent(url),12000).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;}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 lastImg='';data.body.forEach(b=>{if(b.type==='p')h+=`<p class="article-p">${b.text}</p>`;else if(b.type==='img'&&b.src&&b.src!==lastImg){lastImg=b.src;h+=`<img loading="lazy" class="article-img" src="${b.src}">`;}else if(b.type==='heading')h+=`<h2 class="article-h2">${b.text}</h2>`;});h+=`<div class="article-actions"><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>`;el.innerHTML=h;window.scrollTo(0,0);}
45
- function loadYT(cb){if(window.YT&&YT.Player){cb();return;}let prev=window.onYouTubeIframeAPIReady;window.onYouTubeIframeAPIReady=function(){if(typeof prev==='function')try{prev()}catch(e){}cb();};if(!_ytLoading){_ytLoading=true;let s=document.createElement('script');s.src='https://www.youtube.com/iframe_api';document.head.appendChild(s);}}
46
- function fmtNum(n){n=Number(n||0);return n>999?(Math.round(n/100)/10)+'k':String(n)}
47
- function openShortsViewer(i){const data=_shortsData||[];if(!data.length)return;let idx=i||0;document.documentElement.classList.add('ms-lock');document.body.classList.add('ms-lock');const v=document.createElement('div');v.className='msv';v.innerHTML='<div class="msv-bg"></div><div class="msv-stage"><div class="msv-phone"><div id="msv-player"></div></div></div><div class="msv-swipe"></div><button class="msv-close">×</button><div class="msv-info"><div class="msv-count"></div><div class="msv-title"></div></div><div class="msv-nav"><button class="msv-up">▲</button><button class="msv-down">▼</button></div>';document.body.appendChild(v);function current(){return data[idx]||{}}function currentId(){let it=current();return it.id||((it.link||'').match(/(?:v=|youtu\.be\/)([A-Za-z0-9_-]{11})/)||[])[1]||'';}function play(n){idx=(n+data.length)%data.length;const it=current();const id=currentId();v.querySelector('.msv-bg').style.backgroundImage='url('+(it.img||'')+')';v.querySelector('.msv-count').textContent=(idx+1)+'/'+data.length;v.querySelector('.msv-title').textContent=it.title||'';loadYT(()=>{if(_shortsPlayer&&_shortsPlayer.loadVideoById){_shortsPlayer.loadVideoById(id);}else{_shortsPlayer=new YT.Player('msv-player',{videoId:id,playerVars:{autoplay:1,playsinline:1,rel:0,controls:1},events:{onReady:e=>{try{e.target.playVideo()}catch(_){ }},onStateChange:e=>{if(e.data===YT.PlayerState.ENDED)play(idx+1);}}});}});}function close(){try{if(_shortsPlayer&&_shortsPlayer.destroy)_shortsPlayer.destroy()}catch(e){} _shortsPlayer=null;v.remove();document.documentElement.classList.remove('ms-lock');document.body.classList.remove('ms-lock');}v.querySelector('.msv-close').onclick=close;v.querySelector('.msv-up').onclick=()=>play(idx-1);v.querySelector('.msv-down').onclick=()=>play(idx+1);let sy=0;v.querySelector('.msv-swipe').addEventListener('touchstart',e=>{sy=e.touches[0].clientY},{passive:true});v.querySelector('.msv-swipe').addEventListener('touchend',e=>{let dy=(e.changedTouches[0].clientY-sy);if(Math.abs(dy)>45)play(idx+(dy<0?1:-1));},{passive:true});play(idx);}
48
- 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([fetchJsonTimeout('/api/highlights',9000).catch(()=>[]),fetchJsonTimeout('/api/bdp_videos',9000).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 loading="lazy" 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 loading="lazy" src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="card-body"><span class="badge badge-fpt">BDP</span><div class="card-title">${a.title}</div></div></div>`;});h+='</div>';}el.innerHTML=h;el.dataset.loaded='1';}
49
- 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'){_shortsData=await fetchJsonTimeout('/api/shorts',9000).catch(()=>[]);openShortsViewer(startIdx);return;}else if(type==='highlights')articles=await fetchJsonTimeout('/api/highlights',9000).catch(()=>[]);else articles=await fetchJsonTimeout('/api/bdp_videos',9000).catch(()=>[]);await buildTikTokPlayer(articles,startIdx,type);}
50
- async function openTikTokByUrl(targetUrl,type){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=await fetchJsonTimeout(type==='shorts'?'/api/shorts':type==='highlights'?'/api/highlights':'/api/bdp_videos',9000).catch(()=>[]);let startIdx=0;for(let i=0;i<articles.length;i++){if(articles[i].link===targetUrl){startIdx=i;break;}}await buildTikTokPlayer(articles,startIdx,type);}
51
- 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();}
52
- function shareVid(i){const v=_tikData[i];if(!v)return;doShareVideo(v.title,v.link||'',v.poster||v.img||'','highlights');}
53
- function initFeed(){const feed=document.getElementById('tiktok-feed');if(!feed)return;const slides=feed.querySelectorAll('.tiktok-slide');let cur=-1;function activate(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();hls.loadSource(v.dataset.hls);hls.attachMedia(v);hls.on(Hls.Events.MANIFEST_PARSED,()=>v.play().catch(()=>{}));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)activate(best);},150);});setTimeout(()=>activate(0),300);}
 
 
 
 
 
 
 
 
 
 
54
  init();
55
  </script>
56
- </body></html>
 
 
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 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 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>