Spaces:
Running
Running
| """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 | |
| 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) | |
| 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 | |
| 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')))] | |
| 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([]) | |
| 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([]) | |
| 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}) | |
| def _st():return JSONResponse({'persistent':HAS_PERSISTENT}) | |
| def _gc(id:str=Query(...)):return JSONResponse({'comments':_lj(SHORT_COMMENTS_FILE,{}).get(id,[])}) | |
| 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]}) | |
| 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) ===== | |
| 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) | |
| 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 ===== | |
| 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}) | |
| 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}) | |
| 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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> | |
| ''' | |
| 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) |