VNEWS / ai_runtime_patch_fast.py
bep40's picture
Remove duplicate _draw_vn_text function, use wrap_text_vn/_draw_center instead
a703232 verified
Raw
History Blame
48.4 kB
"""Final patch v6: FIXED short video generation — Vietnamese fonts, robust text splitting, no silent errors."""
import re, threading, time, json, os, asyncio, hashlib, subprocess, requests, sys, logging
import ai_runtime_final6 as f6
from ai_runtime_final6 import app, rt, f5, HTMLResponse, JSONResponse, Request, Query
from fastapi.responses import FileResponse
import html as html_lib
from urllib.parse import urlparse
from datetime import datetime, timezone, timedelta
_log = logging.getLogger("patch_fast")
_log.setLevel(logging.INFO)
if not _log.handlers:
_log.addHandler(logging.StreamHandler(sys.stderr))
def clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or ""))).strip()
def _domain(u):
try:return urlparse(u or '').netloc.replace('www.','')
except:return ''
DATA_DIR="/data" if os.path.isdir('/data') else "/app/data"
os.makedirs(DATA_DIR,exist_ok=True)
SHORT_COMMENTS_FILE=os.path.join(DATA_DIR,'short_comments.json')
TTL_24H=86400;HAS_PERSISTENT=os.path.isdir('/data')
SHORTS_DIR = os.path.join(DATA_DIR, 'ai_shorts')
os.makedirs(SHORTS_DIR, exist_ok=True)
def _lj(p,d):
try:
if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8'))
except:pass
return d
def _sj(p,d):
try:os.makedirs(os.path.dirname(p),exist_ok=True);open(p+'.tmp','w',encoding='utf-8').write(json.dumps(d,ensure_ascii=False));os.replace(p+'.tmp',p)
except:pass
def _cleanup():
n=int(time.time());ps=f5.base._load_ai_wall();f=[p for p in ps if n-int(p.get('ts') or 0)<TTL_24H]
if len(f)<len(ps):f5.base._save_ai_wall(f)
def _scrape(url,mc=8000):
try:d=f5.base.scrape_any_url(url);return(d.get('title',''),((d.get('summary','')+'\n'+d.get('text','')).strip())[:mc],d.get('image') or d.get('og_image') or '')
except:return('','','')
# ======================================================================
# VIETNAMESE FONT DETECTION — find ANY font that supports Vietnamese
# ======================================================================
_FONT_REGULAR = None
_FONT_BOLD = None
_FONT_REGULAR_PATH = None
_FONT_BOLD_PATH = None
def _find_vn_fonts():
"""Find fonts that support Vietnamese diacritics (ấ, ắ, ỗ, ệ, ỷ, etc.)."""
global _FONT_REGULAR, _FONT_BOLD, _FONT_REGULAR_PATH, _FONT_BOLD_PATH
if _FONT_REGULAR is not None:
return True
from PIL import ImageFont
# Priority 1: Noto Sans (full Vietnamese support)
candidates_regular = [
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
"/usr/share/fonts/truetype/noto/NotoSans-Variable.ttf",
"/usr/share/fonts/truetype/vn/VNFont.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
]
candidates_bold = [
"/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf",
"/usr/share/fonts/truetype/noto/NotoSans-Variable.ttf", # Variable weight
"/usr/share/fonts/truetype/vn/VNFont-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
]
# Search for any ttf files in the system
try:
result = subprocess.run(
['find', '/usr/share/fonts', '-name', '*.ttf', '-type', 'f'],
capture_output=True, text=True, timeout=10
)
all_ttf = result.stdout.strip().split('\n')
# Prefer Noto/DejaVu/Liberation
for key in ['noto', 'dejavu', 'liberation', 'freefont', 'vn']:
for path in all_ttf:
if key in path.lower():
if 'bold' in path.lower() and _FONT_BOLD_PATH is None:
_FONT_BOLD_PATH = path
elif 'regular' in path.lower() and _FONT_REGULAR_PATH is None:
_FONT_REGULAR_PATH = path
except:
pass
# Try candidate paths
for path in candidates_regular:
if os.path.exists(path):
try:
_FONT_REGULAR = ImageFont.truetype(path, 40)
_FONT_REGULAR_PATH = path
_log.info(f"Font regular: {path}")
break
except:
continue
for path in candidates_bold:
if os.path.exists(path):
try:
_FONT_BOLD = ImageFont.truetype(path, 52)
_FONT_BOLD_PATH = path
_log.info(f"Font bold: {path}")
break
except:
continue
# Fallback: use whatever we found
if _FONT_REGULAR is None:
_FONT_REGULAR = ImageFont.load_default()
_FONT_BOLD = _FONT_REGULAR
_log.warning("No TrueType font found! Using PIL default (may not show Vietnamese)")
if _FONT_BOLD is None:
_FONT_BOLD = _FONT_REGULAR
return True
# ======================================================================
# ROBUST TEXT SEGMENTATION — split ANY text format into segments
# ======================================================================
def _split_into_segments(text, max_segments=10, min_len=30):
"""Split text into segments for short video, handling any format."""
text = clean(text)
if not text:
return []
# Strategy 1: Try bullet points (•, -, * numbered)
lines = text.split('\n')
segmented = []
for line in lines:
line = clean(line)
if not line:
continue
# Remove leading bullets
line_clean = re.sub(r'^[•\-\*\d\.\)\s]+', '', line).strip()
if len(line_clean) > min_len:
segmented.append(line_clean)
elif len(line) > min_len:
segmented.append(line)
# Strategy 2: If no bullet points found, split by sentences
if len(segmented) < 2:
# Split by sentences (Vietnamese period, exclamation, question)
sents = re.split(r'(?<=[.!?])\s+(?=[A-Z0-9À-ỸĐ])', text)
for s in sents:
s = clean(s)
if len(s) > min_len:
segmented.append(s)
# Strategy 3: If still no good segments, split by character chunks
if not segmented:
words = text.split()
chunk = []
char_count = 0
for w in words:
chunk.append(w)
char_count += len(w) + 1
if char_count > 150:
segmented.append(' '.join(chunk))
chunk = []
char_count = 0
if chunk:
segmented.append(' '.join(chunk))
# Strategy 4: Last resort — take whole text
if not segmented:
segmented = [text[:300]]
return segmented[:max_segments]
# ======================================================================
# FRAME CREATION — with Vietnamese font support
# ======================================================================
def _make_frame(post, seg, idx, total, img_path, downloaded, frame_path):
"""Create a single frame with Vietnamese text support - matching original layout."""
from PIL import Image, ImageDraw
_find_vn_fonts()
W, H = 1080, 1920
hero_h = 760
bg = Image.new('RGB', (W, H), (12, 12, 12))
draw = ImageDraw.Draw(bg)
# Background image (hero)
if downloaded:
try:
im = Image.open(img_path).convert('RGB')
ratio = im.width / max(1, im.height)
tr = W / hero_h
if ratio > tr:
nh = hero_h; nw = int(nh * ratio)
else:
nw = W; nh = int(nw / ratio)
im = im.resize((nw, nh))
left = (nw - W) // 2
top = (nh - hero_h) // 2
bg.paste(im.crop((left, top, left + W, top + hero_h)), (0, 0))
except Exception as e:
_log.warning(f"paste img: {e}")
try:
fb = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 58)
except:
fb = None
# Source badge on top image corner
badge = 'Nguồn: ' + _source_badge_url_first(post)
try:
b = draw.textbbox((0, 0), badge, font=fb); bw = b[2] - b[0]; bh = b[3] - b[1]
except:
bw = len(badge) * 16; bh = 34
bx = W - bw - 42; by = 24
draw.rounded_rectangle((bx - 16, by - 8, W - 24, by + bh + 14), radius=18, fill=(0, 0, 0, 170))
draw.text((bx, by), badge, fill=(255, 255, 255), font=fb)
# Bottom text area
draw.rectangle((0, hero_h - 20, W, H), fill=(12, 12, 12))
# Progress bars centered
total = max(1, total)
total_w = total * 38 - 14
start = (W - total_w) // 2
for i in range(total):
fill = (92, 184, 122) if i == idx else (70, 70, 70)
draw.rounded_rectangle((start + i * 38, 820, start + i * 38 + 24, 832), radius=6, fill=fill)
brand = 'VNEWS AI SHORT'
try:
bb = draw.textbbox((0, 0), brand, font=fb); tx = (W - (bb[2] - bb[0])) // 2
except:
tx = 360
draw.text((tx, 870), brand, fill=(110, 231, 143), font=fb)
seg_text = _strip_bullet_prefix(seg)
lines = wrap_text_vn(draw, seg_text, fb, W - 120, 8) if fb else [seg_text[:80]]
block_h = len(lines) * 74
y = max(980, 1250 - block_h // 2)
_draw_center(draw, lines, fb, y, (255, 255, 255), W, 74)
title_lines = wrap_text_vn(draw, _strip_bullet_prefix(post.get('title', '')), fb, W - 120, 3) if fb else []
y2 = 1640
draw.line((80, y2 - 26, W - 80, y2 - 26), fill=(70, 70, 70), width=2)
_draw_center(draw, title_lines, fb, y2, (220, 220, 220), W, 42)
bg.save(frame_path, quality=92)
return True
def _source_badge_url_first(post):
d = _domain(post.get('url', ''))
if d:
return d
for s in post.get('sources') or []:
d = _domain(s.get('url', ''))
if d:
return d
return 'VNEWS'
def wrap_text_vn(draw, text, font, maxw, max_lines):
words = _clean(text).split()
lines = []
cur = ''
for w in words:
test = (cur + ' ' + w).strip()
try:
width = draw.textbbox((0, 0), test, font=font)[2]
except:
width = len(test) * 22
if width <= maxw:
cur = test
else:
if cur:
lines.append(cur)
cur = w
if len(lines) >= max_lines:
break
if cur and len(lines) < max_lines:
lines.append(cur)
return lines
# ======================================================================
# SHORT VIDEO GENERATOR — FULLY ROBUST
# ======================================================================
def _gen_short_sync(post) -> str:
"""Generate short video NOW. Returns video URL or empty string on failure."""
post_id = post.get('id', '')
if not post_id:
_log.error("_gen_short_sync: no post_id")
return ''
text = post.get('text', '') or post.get('title', '')
if len(text) < 100:
_log.warning(f"_gen_short_sync: text too short ({len(text)})")
return ''
segments = _split_into_segments(text, max_segments=10, min_len=30)
if not segments:
_log.error("_gen_short_sync: no segments after splitting")
return ''
_log.info(f"Segments: {len(segments)} (first: '{segments[0][:50]}...')")
seg_hash = hashlib.md5(('|'.join(segments) + 'nu' + 'neutral' + '1.0').encode('utf-8')).hexdigest()[:8]
suffix = f"_nu_neutral_1p0_{seg_hash}_scenes_nosub"
out_name = f5.base._safe_name(post_id + suffix) + '.mp4'
out_mp4 = os.path.join(SHORTS_DIR, out_name)
video_url = '/api/ai/short-file/' + post_id + suffix
if os.path.exists(out_mp4):
_log.info(f"_gen_short_sync: already exists {out_mp4}")
return video_url
work = os.path.join(SHORTS_DIR, f5.base._safe_name(post_id + suffix))
os.makedirs(work, exist_ok=True)
# Download image
img_path = os.path.join(work, 'image.jpg')
downloaded = False
try:
img_url = post.get('img', '')
if img_url and img_url.startswith('http'):
r = requests.get(img_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=15)
if r.status_code == 200:
with open(img_path, 'wb') as f:
f.write(r.content)
downloaded = True
except Exception as e:
_log.warning(f"download image: {e}")
# Check PIL/ffmpeg
try:
from PIL import Image, ImageDraw, ImageFont
has_pil = True
except Exception as e:
_log.warning(f"PIL import: {e}")
has_pil = False
try:
subprocess.run(['ffmpeg', '-version'], capture_output=True, timeout=5)
except:
_log.error("ffmpeg not found!")
return ''
try:
from gtts import gTTS
has_tts = True
except Exception as e:
_log.warning(f"gTTS: {e}, using silent video")
has_tts = False
part_files = []
errors = []
for idx, seg in enumerate(segments[:10]):
frame_path = os.path.join(work, f'frame_{idx:02d}.jpg')
audio_path = os.path.join(work, f'voice_{idx:02d}.mp3')
audio_fast = os.path.join(work, f'voice_{idx:02d}_fast.mp3')
part_path = os.path.join(work, f'part_{idx:02d}.mp4')
# ----- Frame -----
frame_ok = False
if has_pil:
try:
_make_frame(post, seg, idx, len(segments), img_path, downloaded, frame_path)
if os.path.exists(frame_path) and os.path.getsize(frame_path) > 1000:
frame_ok = True
else:
_log.warning(f"frame {idx}: file too small or missing")
except Exception as e:
_log.error(f"frame {idx} creation: {e}")
errors.append(f"frame{idx}: {e}")
if not frame_ok:
try:
# Fallback: ffmpeg solid color frame
subprocess.run(['ffmpeg', '-y', '-f', 'lavfi', '-i', 'color=c=0x0f1726:s=1080x1920:d=1',
'-frames:v', '1', frame_path], capture_output=True, timeout=30)
frame_ok = os.path.exists(frame_path)
except:
continue
# ----- Audio -----
dur = 10.0
if has_tts:
try:
tts_text = re.sub(r'^[•\-\*\d\.\)\s]+', '', seg).strip()[:300]
if tts_text:
gTTS(tts_text, lang='vi', slow=False).save(audio_path)
# Copy audio directly (no atempo filter — it's a no-op at 1.0 and can fail)
subprocess.run(['ffmpeg', '-y', '-i', audio_path, '-vn', '-c:a', 'libmp3lame', audio_fast],
check=True, capture_output=True, timeout=90)
pr = subprocess.run(['ffprobe', '-v', 'error', '-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:no_key=1', audio_fast],
capture_output=True, timeout=20)
dur = max(6.0, float((pr.stdout or b'').decode().strip() or 10.0)) + 1.0
else:
dur = 8.0
except Exception as e:
_log.warning(f"TTS part {idx}: {e}")
errors.append(f"tts{idx}: {e}")
dur = 8.0
# ----- Combine frame + audio -----
try:
cmd = ['ffmpeg', '-y', '-loop', '1', '-t', str(dur), '-i', frame_path]
if has_tts and os.path.exists(audio_fast):
cmd += ['-i', audio_fast, '-shortest']
else:
cmd += ['-f', 'lavfi', '-i', 'anullsrc=r=44100:cl=mono', '-shortest']
cmd += ['-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p',
'-c:a', 'aac', '-b:a', '128k',
'-preset', 'fast', '-crf', '23',
part_path]
subprocess.run(cmd, check=True, capture_output=True, timeout=150)
if os.path.exists(part_path) and os.path.getsize(part_path) > 5000:
part_files.append(part_path)
_log.info(f"Part {idx}: {os.path.getsize(part_path)} bytes")
else:
_log.warning(f"Part {idx}: file too small or missing")
except Exception as e:
_log.warning(f"ffmpeg part {idx}: {e}")
errors.append(f"part{idx}: {e}")
if part_files:
try:
concat_file = os.path.join(work, 'concat.txt')
with open(concat_file, 'w', encoding='utf-8') as f:
for p in part_files:
f.write("file '" + p.replace("'", "'\\''") + "'\n")
subprocess.run(['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', concat_file, '-c', 'copy', out_mp4],
check=True, capture_output=True, timeout=180)
_log.info(f"Short video generated: {out_mp4} ({len(part_files)} parts, {os.path.getsize(out_mp4)} bytes)")
post['video'] = video_url
post['short_voice'] = 'nu'
post['short_emotion'] = 'neutral'
post['short_speed'] = 1.0
post['short_segments'] = segments
post['short_subtitles'] = False
wall = f5.base._load_ai_wall()
for i, p in enumerate(wall):
if p.get('id') == post_id:
wall[i] = post
break
f5.base._save_ai_wall(wall)
return video_url
except Exception as e:
_log.error(f"concat: {e}")
errors.append(f"concat: {e}")
else:
_log.error(f"No part files generated! Errors: {'; '.join(errors)}")
return ''
# ===== Auto Scheduler Trigger =====
_AUTO_SCHEDULER_RUNNING = False
_AUTO_SCHEDULER_LAST = 0
_AUTO_SCHEDULER_RESULTS = []
def _run_scheduler_now():
"""Trigger auto_scheduler._run_scheduled_posting() safely."""
global _AUTO_SCHEDULER_RUNNING, _AUTO_SCHEDULER_LAST, _AUTO_SCHEDULER_RESULTS
if _AUTO_SCHEDULER_RUNNING:
_log.warning("Scheduler already running!")
return {"status": "running", "message": "Scheduler đang chạy, vui lòng đợi."}
_AUTO_SCHEDULER_RUNNING = True
try:
import auto_scheduler as _as
_as.LOG.info("Triggered manually via API")
_as._run_scheduled_posting()
_AUTO_SCHEDULER_LAST = time.time()
_AUTO_SCHEDULER_RESULTS = [{'time': datetime.now().strftime('%H:%M %d/%m/%Y'), 'status': 'done'}]
_log.info("Auto scheduler completed successfully")
return {"status": "done", "message": "Đã đăng 3 bài tự động thành công!"}
except Exception as e:
_log.error(f"Auto scheduler failed: {e}", exc_info=True)
return {"status": "error", "message": f"Lỗi: {e}"}
finally:
_AUTO_SCHEDULER_RUNNING = False
@app.post('/api/auto/schedule')
async def _api_auto_schedule():
"""Trigger auto-scheduler to post 3 articles NOW."""
_log.info("POST /api/auto/schedule triggered")
result = await asyncio.get_event_loop().run_in_executor(None, _run_scheduler_now)
return JSONResponse(result)
@app.get('/api/auto/status')
async def _api_auto_status():
"""Check auto-scheduler status."""
return JSONResponse({
'running': _AUTO_SCHEDULER_RUNNING,
'last_run': _AUTO_SCHEDULER_LAST,
'last_run_str': datetime.fromtimestamp(_AUTO_SCHEDULER_LAST).strftime('%H:%M %d/%m/%Y') if _AUTO_SCHEDULER_LAST else 'never',
'results': _AUTO_SCHEDULER_RESULTS
})
_bg_home={"t":0,"d":[]};_bg_shorts={"t":0,"d":[]};_bg_lock=False
def _bg():
global _bg_lock
if _bg_lock:return
_bg_lock=True
try:
if hasattr(f6,'_fast_homepage'):d=f6._fast_homepage();(_bg_home.update({"t":time.time(),"d":d}) if d else None)
raw=[];[raw.extend(f6._yt_ytdlp(h,20) or f6._yt_html(h,20)) for h in f6.YOUTUBE_HANDLES];raw.extend(f6._fallback_shorts())
seen=set();out=[v for v in raw if v.get('id') and v['id'] not in seen and not seen.add(v['id'])]
if out:_bg_shorts.update({"t":time.time(),"d":out[:40]})
_cleanup()
except:pass
finally:_bg_lock=False
@app.on_event("startup")
async def _s():threading.Thread(target=_bg,daemon=True).start()
threading.Thread(target=lambda:[time.sleep(600) or _bg() for _ in iter(int,1)],daemon=True).start()
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None) in ('/api/homepage','/api/shorts','/api/ai_wall','/api/topic_post','/api/article/ask','/api/topic/rewrite','/api/rewrite_share','/api/url_wall','/api/short/comments','/api/short/comment','/api/storage_status','/api/ai/short/{post_id}','/api/ai/short-file/{file_id}','/api/auto/schedule','/api/auto/status','/') and any(m in getattr(r,'methods',set()) for m in ('GET','POST')))]
@app.get('/api/homepage')
def _h():
n=time.time()
if _bg_home['d']:(threading.Thread(target=_bg,daemon=True).start() if n-_bg_home['t']>300 else None);return JSONResponse(_bg_home['d'])
if hasattr(f6,'_fast_homepage'):d=f6._fast_homepage();_bg_home.update({"t":n,"d":d or []});return JSONResponse(d or [])
return JSONResponse([])
@app.get('/api/shorts')
def _sh(refresh:int=Query(default=0)):
n=time.time()
if _bg_shorts['d'] and (not refresh or n-_bg_shorts['t']<120):(threading.Thread(target=_bg,daemon=True).start() if n-_bg_shorts['t']>600 else None);return JSONResponse(_bg_shorts['d'])
return f6.api_shorts_final6(refresh=refresh) if hasattr(f6,'api_shorts_final6') else JSONResponse([])
@app.get('/api/ai_wall')
def _w():n=int(time.time());return JSONResponse({'posts':[p for p in f5.base._load_ai_wall() if n-int(p.get('ts') or 0)<TTL_24H],'persistent':HAS_PERSISTENT})
@app.get('/api/storage_status')
def _st():return JSONResponse({'persistent':HAS_PERSISTENT})
@app.get('/api/short/comments')
def _gc(id:str=Query(...)):return JSONResponse({'comments':_lj(SHORT_COMMENTS_FILE,{}).get(id,[])})
@app.post('/api/short/comment')
async def _pc(request:Request):
b=await request.json();v=str(b.get('id','')).strip();t=clean(b.get('text',''))
if not v or not t:return JSONResponse({'error':'missing'},status_code=400)
db=_lj(SHORT_COMMENTS_FILE,{});c=db.get(v,[]);c.insert(0,{'text':t[:300],'ts':int(time.time())});db[v]=c[:100];_sj(SHORT_COMMENTS_FILE,db);return JSONResponse({'comments':db[v]})
@app.post('/api/article/ask')
async def _ask(request:Request):
b=await request.json();q=clean(b.get('question',''));ctx=clean(b.get('context',''));url=clean(b.get('url',''))
if not q:return JSONResponse({'error':'missing question'},status_code=400)
title='';raw=''
if url:title,raw,_=_scrape(url,10000)
if not raw:raw=ctx[:12000]
ans=await f5.base.qwen_generate(f'Bạn là VNEWS AI. Nội dung: "{title}"\n{raw[:9000]}\n\nHỏi: "{q}"\n\nTrả lời tự nhiên bằng tiếng Việt.',max_tokens=1200)
return JSONResponse({'answer':ans or 'Chưa trả lời được.','title':title})
# ===== SHORT VIDEO ENDPOINT (SYNCHRONOUS) =====
@app.post('/api/ai/short/{post_id}')
async def api_ai_short_generate(post_id: str, request: Request):
wall = f5.base._load_ai_wall()
post = None
for p in wall:
if p.get('id') == post_id:
post = p
break
if not post:
return JSONResponse({'error': 'Post not found'}, status_code=404)
if post.get('video'):
return JSONResponse({'post': post, 'video': post['video'], 'status': 'done'})
_log.info(f"Generating short for post {post_id}...")
loop = asyncio.get_event_loop()
video_url = await loop.run_in_executor(None, _gen_short_sync, post)
if video_url:
_log.info(f"Short generated: {video_url}")
return JSONResponse({'post': post, 'video': video_url, 'status': 'done'})
else:
_log.error(f"Short generation FAILED for {post_id}")
return JSONResponse({'error': 'Không thể tạo video. Kiểm tra logs Space để biết chi tiết.'}, status_code=500)
@app.get('/api/ai/short-file/{file_id:path}')
async def api_ai_short_file(file_id: str):
safe_name = f5.base._safe_name(file_id)
fpath = os.path.join(SHORTS_DIR, safe_name + '.mp4')
if os.path.exists(fpath):
return FileResponse(fpath, media_type='video/mp4')
for fname in os.listdir(SHORTS_DIR):
if fname.endswith('.mp4') and (safe_name in fname or file_id in fname):
fpath = os.path.join(SHORTS_DIR, fname)
if os.path.exists(fpath):
return FileResponse(fpath, media_type='video/mp4')
base_id = f5.base._safe_name((file_id.split('_')[0] if '_' in file_id else file_id))
for fname in os.listdir(SHORTS_DIR):
if fname.endswith('.mp4') and fname.startswith(base_id):
return FileResponse(os.path.join(SHORTS_DIR, fname), media_type='video/mp4')
return JSONResponse({'error': 'File not found'}, status_code=404)
# ===== REWRITE =====
@app.post('/api/rewrite_share')
@app.post('/api/url_wall')
async def _rw(request:Request):
b=await request.json();url=clean(b.get('url',''));ctx=clean(b.get('context',''))
if not url.startswith('http'):return JSONResponse({'error':'URL không hợp lệ'},status_code=400)
title,raw,img=_scrape(url,14000)
if len(raw)<50:raw=ctx[:14000]
if len(raw)<50:return JSONResponse({'error':'Không đọc được bài'},status_code=422)
og_img = img if img and img.startswith('http') else ''
if not og_img:
try:
d = f5.base.scrape_any_url(url)
if d: og_img = d.get('og_image', '') or d.get('image', '') or ''
except: pass
if not og_img:
og_img = 'https://image.pollinations.ai/prompt/Vietnamese%20news%20update'
text=None
try:text=await asyncio.wait_for(f5.base.qwen_generate(f'Tóm tắt đăng Tường AI:\nTiêu đề: {title}\n{raw[:14000]}\n\n4-6 ý chính. Cuối ghi nguồn.',image_url=og_img or None,max_tokens=1000),timeout=30)
except:pass
if not text or len(text)<80:text=f"Tóm tắt: {title}\n\n{raw[:1200]}\n\nNguồn: {_domain(url)}"
paragraphs = [p.strip() for p in raw.split('\n') if len(p.strip()) > 60]
if not paragraphs:
paragraphs = [p.strip() for p in raw.split('\n') if len(p.strip()) > 20]
slides = []
for i, pt in enumerate(paragraphs[:8]):
slides.append({'text': pt, 'image': og_img, 'index': i + 1})
post=f5.base.make_post(title or 'Bài viết',text,og_img,url,'rewrite',sources=[{'title':title,'url':url,'via':_domain(url)}])
post['images'] = [og_img] if og_img else []
post['slides'] = slides
ps=f5.base._load_ai_wall();ps.insert(0,post);f5.base._save_ai_wall(ps)
if len(text) > 100:
t = threading.Thread(target=_gen_short_sync, args=(post,), daemon=True)
t.start()
return JSONResponse({'post':post})
@app.post('/api/topic/rewrite')
async def _tr(request:Request):
b=await request.json();pid=str(b.get('post_id','')).strip()
if not pid:return JSONResponse({'error':'missing post_id'},status_code=400)
ps=f5.base._load_ai_wall();p=next((x for x in ps if str(x.get('id'))==pid),None)
if not p:return JSONResponse({'error':'Bài không tồn tại'},status_code=404)
urls=list(dict.fromkeys([s['url'] for s in (p.get('source_details') or []) if s.get('url')]+[s['url'] for s in (p.get('sources') or []) if s.get('url')]))[:5]
parts=[]
for u in urls:t,r,_=_scrape(u,6000);(parts.append(f"[{_domain(u)}] {t}\n{r}") if r and len(r)>150 else None)
ac='\n---\n'.join(parts) if parts else (p.get('text') or '')
title=p.get('title','')
text=None
try:text=await asyncio.wait_for(f5.base.qwen_generate(f'Viết lại:\nChủ đề: {title}\n{ac[:16000]}\n\nTiêu đề mới + 4-6 ý + nguồn.',image_url=p.get('img'),max_tokens=1200),timeout=35)
except:pass
if not text or len(text)<100:text=f"Tóm tắt: {title}\n\n{ac[:1500]}\n\nNguồn: VNEWS AI"
np=f5.base.make_post('Rewrite: '+title,text,p.get('img',''),'','rewrite_topic',sources=p.get('sources',[]));np['images']=p.get('images',[])
all_p=f5.base._load_ai_wall();all_p.insert(0,np);f5.base._save_ai_wall(all_p);return JSONResponse({'post':np})
@app.post('/api/topic_post')
async def _tp(request:Request):
b=await request.json();topic=clean(b.get('topic',''))
if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
img=f6._topic_image(topic);research=f6._fast_context(topic) if hasattr(f6,'_fast_context') else f6._web_research_context(topic)
ctx=research.get('context','');src=research.get('sources',[]);det=f6._extract_source_details_from_context(ctx,src) if hasattr(f6,'_extract_source_details_from_context') else []
if not ctx or not src:return JSONResponse({'error':'Không tìm được nội dung.'},status_code=422)
sb='\n\n'.join([f"[{i+1}] {d.get('title','')} ({d.get('via','')})\n{d.get('content','')[:1400]}" for i,d in enumerate(det)]) if det else ctx[:18000]
text=None
try:text=await asyncio.wait_for(f5.base.qwen_generate(f'Viết bài tiếng Việt VỀ: "{topic}"\nNGUỒN:\n{sb[:18000]}\nCHỈ viết về "{topic}". 5-8 đoạn. Cuối có nguồn.',image_url=img,max_tokens=1700),timeout=35)
except:pass
if not text or len(text)<300:text=f"{topic}: tổng hợp\n\n"+'\n'.join([f"• {d['title']}: {d.get('content','')[:300]}" for d in (det or [])[:6]])+"\n\nNguồn: "+', '.join(sorted({d.get('via','') for d in (det or []) if d.get('via')}))
post=f5.base.make_post(topic,text,img,'','topic_focused',sources=[s for s in src if s.get('url')]);post['images']=[img];post['source_details']=det
ps=f5.base._load_ai_wall();ps.insert(0,post);f5.base._save_ai_wall(ps);return JSONResponse({'post':post})
PATCH_INJECT=r'''
<style>
.short-cmt-panel{position:fixed;bottom:0;left:0;right:0;max-height:55vh;background:#181818;border-radius:16px 16px 0 0;z-index:99999;padding:14px;display:none;overflow-y:auto}.short-cmt-panel.active{display:block}.short-cmt-panel textarea{width:100%;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px;margin:6px 0;min-height:60px}.short-cmt-panel button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin:4px}.cmt-item{background:#222;border-radius:8px;padding:7px;margin:5px 0;color:#ccc;font-size:12px}
.source-detail-box{margin-top:14px;background:#151515;border:1px solid #2b2b2b;border-radius:10px;padding:10px}.source-detail-item{background:#202020;border-radius:8px;padding:9px;margin:7px 0;cursor:pointer}.source-detail-title{font-size:12px;font-weight:700;color:#eee}.source-detail-content{font-size:12px;color:#bbb;line-height:1.5;white-space:pre-wrap;max-height:120px;overflow:hidden}.source-detail-item img{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:6px;margin-bottom:6px;background:#222}.source-vnews-btn{display:inline-block;margin-top:6px;background:#2d8659;color:#fff;padding:5px 10px;border-radius:12px;font-size:11px;font-weight:700}
.article-ai-ask{margin-top:12px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.article-ai-ask textarea{width:100%;min-height:60px;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px}.article-ai-ask button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin-top:6px}.article-ai-answer{white-space:pre-wrap;color:#ccc;font-size:13px;line-height:1.55;margin-top:8px}
.storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 4px}
button[onclick*="rewriteCurrentArticle"]{display:none!important}
#ai-short-home,.ai-short-home,.ai-short-card-final{display:none!important}
.source-detail-box a[target="_blank"]{display:none!important}
</style>
<div id="short-cmt-panel" class="short-cmt-panel"></div>
<script>
(function(){
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
fetch('/api/storage_status').then(r=>r.json()).then(j=>{if(!j.persistent){let h=document.getElementById('view-home');if(h){let w=document.createElement('div');w.className='storage-warn';w.innerHTML='⚠️ <b>Persistent Storage chưa bật.</b> Bật: Space Settings → Persistent Storage → Small.';h.prepend(w);}}});
async function renderShortAISlide(){let home=document.getElementById('view-home');if(!home)return;document.getElementById('short-ai-final-slide')?.remove();let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];let vids=wall.filter(p=>p.video);if(!vids.length)return;let wrap=document.createElement('div');wrap.id='short-ai-final-slide';wrap.className='slider-wrap';wrap.innerHTML='<div class="slider-header"><span class="slider-label">🎬 Short AI</span></div><div class="slider-track">'+vids.slice(0,30).map((p,i)=>`<div class="slider-item shorts-item" onclick="openAIShortFeed(${i})"><div class="slider-thumb shorts-thumb"><video src="${p.video}" muted preload="metadata" style="width:100%;height:100%;object-fit:cover"></video><div class="card-play">▶</div></div><div class="slider-title">${esc(p.title)}</div></div>`).join('')+'</div>';let comp=home.querySelector('.ai-compose');if(comp&&comp.nextSibling)comp.parentNode.insertBefore(wrap,comp.nextSibling);else home.prepend(wrap);}
setTimeout(renderShortAISlide,2500);setInterval(renderShortAISlide,15000);
function renderSourceDetails(post,container){let det=post.source_details||[];if(!det.length)return;container.querySelectorAll('.source-detail-box').forEach(e=>e.remove());let box=document.createElement('div');box.className='source-detail-box';box.innerHTML='<h3 style="font-size:14px;color:#5cb87a;margin-bottom:8px">📚 Bài nguồn</h3>'+det.map((s,i)=>`<div class="source-detail-item" data-url="${esc(s.url||'')}"><div class="source-detail-title">${i+1}. ${esc(s.title)}</div><div class="source-detail-content">${esc((s.content||'').slice(0,300))}</div><span class="source-vnews-btn">📖 Xem trên VNEWS</span></div>`).join('');container.appendChild(box);box.querySelectorAll('.source-detail-item').forEach(el=>{el.onclick=function(){let u=el.dataset.url;if(u&&typeof readArticle==='function')readArticle(u);}});det.forEach((s,i)=>{if(!s.url)return;fetch('/api/article?url='+encodeURIComponent(s.url)).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){let items=box.querySelectorAll('.source-detail-item');if(items[i]){let img=document.createElement('img');img.src=d.og_image||d.img;img.loading='lazy';img.onerror=function(){this.style.display='none'};items[i].prepend(img);}}}).catch(()=>{});});}
async function readAIWallPost(i){let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];let p=wall[i];if(!p)return;showView('view-article');let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${esc(p.title)}</h1>${p.img?`<img class="article-img" src="${p.img}">`:''}`;h+=`<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>`;h+=`<div class="article-actions"><button class="primary" onclick="doRewriteTopic(this,'${esc(p.id)}')">🤖 Rewrite AI đăng tường</button>${p.video?`<button onclick="openAIShortFeed(${i})">🎬 Xem Short</button>`:''}<button onclick="doShare('${esc(p.title)}','${location.origin}','${esc(p.img||'')}')">📤</button></div>`;h+=`<div class="article-ai-ask"><h3 style="font-size:14px;color:#5cb87a">🤖 Hỏi AI</h3><textarea id="article-ai-q" placeholder="Hỏi về nội dung..."></textarea><button onclick="askAIWall(${i})">Hỏi</button><div id="article-ai-ans" class="article-ai-answer"></div></div></div>`;document.getElementById('view-article').innerHTML=h;let art=document.querySelector('.article-view');if(art)renderSourceDetails(p,art);window.scrollTo(0,0);}
window.readAIWallPost=readAIWallPost;window.aiReadWallPatched=window.aiReadWall=window.readWallPost=function(i){readAIWallPost(i)};
window.openAIShortFeed=async function(startIdx){let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];let vids=wall.filter(p=>p.video);if(!vids.length)return alert('Chưa có Short AI');let ordered=startIdx>0?vids.slice(startIdx).concat(vids.slice(0,startIdx)):vids;showView('view-tiktok');let h='<button class="back-btn" onclick="switchCat(\'home\')">← Short AI</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">';ordered.forEach((p,i)=>{h+=`<div class="tiktok-slide" data-id="${p.id}"><video src="${p.video}" playsinline loop></video><div class="tiktok-bottom"><span class="badge badge-ai">AI Short</span><p class="tiktok-title">${esc(p.title)}</p></div><div class="tiktok-right"><button class="tiktok-right-btn" onclick="event.stopPropagation()"><div class="icon">👁</div><div class="count">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();likeShort('${p.id}',this)"><div class="icon">❤️</div><div class="count">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();openShortComments('${p.id}')"><div class="icon">💬</div><div class="count" id="cc-${p.id}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();shareShort('${esc(p.title)}')"><div class="icon">📤</div><div class="count">Share</div></button></div><span class="tiktok-counter">${i+1}/${ordered.length}</span></div>`});h+='</div></div>';document.getElementById('view-tiktok').innerHTML=h;initShortFeed();ordered.forEach(p=>{fetch('/api/short/comments?id='+encodeURIComponent(p.id)).then(r=>r.json()).then(j=>{let el=document.getElementById('cc-'+p.id);if(el)el.textContent=(j.comments||[]).length}).catch(()=>{});});}
window.likeShort=function(id,btn){let c=btn.querySelector('.count');c.textContent=parseInt(c.textContent||0)+1;}
window.shareShort=function(title){if(navigator.share)navigator.share({title,url:location.href}).catch(()=>{});else{navigator.clipboard.writeText(location.href);alert('Đã sao chép link!');}}
function initShortFeed(){let feed=document.getElementById('tiktok-feed');if(!feed)return;let slides=feed.querySelectorAll('.tiktok-slide');let cur=-1;function act(i){if(i===cur)return;slides.forEach((sl,idx)=>{let v=sl.querySelector('video');let fr=sl.querySelector('iframe');if(idx===i){if(v)v.play().catch(()=>{});if(fr&&!fr.src&&fr.dataset.ytSrc)fr.src=fr.dataset.ytSrc;}else{if(v)v.pause();if(fr&&fr.src)fr.src='';}});cur=i}let t;feed.addEventListener('scroll',()=>{clearTimeout(t);t=setTimeout(()=>{let rect=feed.getBoundingClientRect(),ctr=rect.top+rect.height/2,b=-1,d=1e9;slides.forEach((sl,i)=>{let dd=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(dd<d){d=dd;b=i}});if(b>=0)act(b)},130)});setTimeout(()=>act(0),300);slides.forEach(sl=>{let v=sl.querySelector('video');if(v)v.addEventListener('click',e=>{e.preventDefault();v.paused?v.play().catch(()=>{}):v.pause()})});}
window.doRewriteTopic=async function(btn,pid){btn.disabled=true;btn.textContent='Đang rewrite...';try{let r=await fetch('/api/topic/rewrite',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({post_id:pid})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');alert('Rewrite thành công!');showRewriteResult(j.post);}catch(e){alert(e.message)}finally{btn.disabled=false;btn.textContent='🤖 Rewrite AI đăng tường';}};
window.doRewriteArticle=async function(btn){let url=(window._currentArticle&&window._currentArticle.url)||'';if(!url){let a=document.querySelector('#view-article a[href*="://"]');if(a)url=a.href;}if(!url){let text=document.querySelector('.article-view')?.innerText?.slice(0,14000)||'';if(text.length<100){alert('Không tìm được nội dung để rewrite');return;}btn.disabled=true;btn.textContent='Đang rewrite...';try{let r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:'https://vnews.local/inline',context:text})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error);alert('Rewrite thành công!');showRewriteResult(j.post);}catch(e){alert(e.message)}finally{btn.disabled=false;btn.textContent='🤖 Rewrite AI đăng tường';}return;}btn.disabled=true;btn.textContent='Đang rewrite...';try{let ctx=document.querySelector('.article-view')?.innerText?.slice(0,14000)||'';let r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,context:ctx})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');alert('Rewrite thành công!');showRewriteResult(j.post);}catch(e){alert(e.message)}finally{btn.disabled=false;btn.textContent='🤖 Rewrite AI đăng tường';}};
function showRewriteResult(post){if(!post)return;showView('view-article');document.getElementById('view-article').innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">Rewrite</span><h1 class="article-title">${esc(post.title)}</h1>${post.img?`<img class="article-img" src="${post.img}">`:''}` +`<p class="article-p" style="white-space:pre-wrap">${esc(post.text)}</p><div class="article-actions"><button class="primary" onclick="makeShortFromPost('${esc(post.id)}',this)">🎬 Tạo Short AI</button><button onclick="doShare('${esc(post.title)}','${location.origin}','${esc(post.img||'')}')">📤</button></div></div>`;window.scrollTo(0,0);}
window.makeShortFromPost=async function(pid,btn){if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo video...';}try{let r=await fetch('/api/ai/short/'+pid,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:'nu',emotion:'neutral',speed:1.2})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');if(j.video){alert('✅ Đã tạo Short AI thành công!');renderShortAISlide();}else{alert(j.message||'Đã tạo!');renderShortAISlide();}}catch(e){alert('Lỗi: '+e.message)}finally{if(btn){btn.disabled=false;btn.textContent='🎬 Tạo Short AI';}}};
window.rewriteCurrentArticle=function(){let btn=document.querySelector('[data-rw-article]');if(btn)doRewriteArticle(btn);};
window.askAIWall=async function(i){let q=document.getElementById('article-ai-q')?.value.trim();if(!q)return alert('Nhập câu hỏi');document.getElementById('article-ai-ans').textContent='Đang hỏi...';let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];let p=wall[i]||{};let ctx=(p.text||'');for(let s of (p.source_details||[]))ctx+='\n'+(s.content||'');try{let r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({question:q,context:ctx.slice(0,12000)})});let j=await r.json();document.getElementById('article-ai-ans').textContent=j.answer||'Không trả lời được';}catch(e){document.getElementById('article-ai-ans').textContent='Lỗi: '+e.message}};
window.askArticleAI=async function(){let q=document.getElementById('article-ai-question')?.value.trim();if(!q)return alert('Nhập câu hỏi');let a=document.getElementById('article-ai-answer');a.textContent='Đang hỏi...';let url=(window._currentArticle&&window._currentArticle.url)||'';let ctx=document.querySelector('.article-view')?.innerText?.slice(0,12000)||'';try{let r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,question:q,context:ctx})});let j=await r.json();a.textContent=j.answer||'Không trả lời được';}catch(e){a.textContent='Lỗi: '+e.message}};
window.openShortComments=async function(id){let panel=document.getElementById('short-cmt-panel');let j=await fetch('/api/short/comments?id='+encodeURIComponent(id)).then(r=>r.json()).catch(()=>({comments:[]}));panel.innerHTML=`<h3 style="color:#5cb87a">💬 Bình luận</h3><div id="cmt-list">${(j.comments||[]).map(c=>`<div class="cmt-item">${esc(c.text)}</div>`).join('')||'<div class="cmt-item" style="color:#777">Chưa có</div>'}</div><textarea id="cmt-text" placeholder="Bình luận..."></textarea><button onclick="submitShortCmt('${esc(id)}')">Gửi</button><button onclick="document.getElementById('short-cmt-panel').classList.remove('active')">Đóng</button>`;panel.classList.add('active');}
window.submitShortCmt=async function(id){let t=document.getElementById('cmt-text')?.value.trim();if(!t)return;let j=await fetch('/api/short/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,text:t})}).then(r=>r.json()).catch(()=>({comments:[]}));document.getElementById('cmt-list').innerHTML=(j.comments||[]).map(c=>`<div class="cmt-item">${esc(c.text)}</div>`).join('');document.getElementById('cmt-text').value='';let el=document.getElementById('cc-'+id);if(el)el.textContent=(j.comments||[]).length;}
function patchArticle(){let art=document.querySelector('#view-article .article-view');if(!art)return;art.querySelectorAll('button[onclick*="rewriteCurrentArticle"],[data-rewrite],.rewrite-injected').forEach(e=>e.remove());art.querySelectorAll('.article-ai-ask').forEach((e,i)=>{if(i>0)e.remove();});if(!art.querySelector('[data-rw-article]')){let a=art.querySelector('.article-actions');if(a){let b=document.createElement('button');b.className='primary';b.setAttribute('data-rw-article','1');b.textContent='🤖 Rewrite AI đăng tường';b.onclick=function(){doRewriteArticle(b)};a.insertBefore(b,a.firstChild);}}if(!art.querySelector('.article-ai-ask')){let box=document.createElement('div');box.className='article-ai-ask';box.innerHTML='<h3 style="font-size:14px;color:#5cb87a">🤖 Hỏi AI</h3><textarea id="article-ai-question" placeholder="Hỏi..."></textarea><button onclick="askArticleAI()">Hỏi</button><div id="article-ai-answer" class="article-ai-answer"></div>';art.appendChild(box);}}
function patchShortBtns(){document.querySelectorAll('.tiktok-slide').forEach(sl=>{if(sl.dataset.cmtDone)return;sl.dataset.cmtDone='1';let id=sl.dataset.id||'';if(!id)return;let r=sl.querySelector('.tiktok-right');if(!r||r.querySelector('[data-cmt]'))return;let b=document.createElement('button');b.className='tiktok-right-btn';b.setAttribute('data-cmt','1');b.innerHTML='<div class="icon">💬</div><div class="count">0</div>';b.onclick=function(e){e.stopPropagation();openShortComments(id);};r.appendChild(b);fetch('/api/short/comments?id='+encodeURIComponent(id)).then(r=>r.json()).then(j=>{b.querySelector('.count').textContent=(j.comments||[]).length}).catch(()=>{});});}
function patchOldSourceLinks(){document.querySelectorAll('.source-detail-item a[target="_blank"],.source-detail-item a[href]').forEach(a=>{if(a.dataset.p7)return;a.dataset.p7='1';let url=a.href||'';a.removeAttribute('target');a.removeAttribute('href');a.textContent='📖 Xem trên VNEWS';a.className='source-vnews-btn';a.style.cursor='pointer';a.onclick=function(e){e.preventDefault();e.stopPropagation();if(url&&typeof readArticle==='function')readArticle(url);}});}
let oldRA=window.readArticle;if(oldRA){window.readArticle=async function(){let ret=await oldRA.apply(this,arguments);setTimeout(patchArticle,500);return ret;}}
let _hl=false;function dH(){if(_hl)return;_hl=true;setTimeout(()=>{if(typeof ensureHotTopics==='function')ensureHotTopics();if(typeof ensureNewsShortsHome==='function')ensureNewsShortsHome();},4000);}
if(document.readyState==='complete')dH();else window.addEventListener('load',dH);
setInterval(()=>{patchArticle();patchShortBtns();patchOldSourceLinks();},1500);
})();
</script>
'''
@app.get('/')
async def _index():
html=open('/app/static/index_v2.html','r',encoding='utf-8').read() if os.path.exists('/app/static/index_v2.html') else ('<html><body>VNEWS</body></html>' if os.path.exists('/app/static/index.html') else '')
body=getattr(rt.old,'PATCH_INJECT','')+f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT
body+=getattr(f6,'FINAL6_INJECT','');body+=getattr(f6,'FINAL6_FAST_HOME_INJECT','');body+=getattr(f6,'FINAL6E_INJECT','')
body+=PATCH_INJECT
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)