diff --git a/.docker_rebuild b/.docker_rebuild
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/.dockerignore b/.dockerignore
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/.huggingface/main.json b/.huggingface/main.json
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/.huggingface/rebuild b/.huggingface/rebuild
deleted file mode 100644
index 5b52fc9f825e7d7997f32173b23d1b1f0aed0156..0000000000000000000000000000000000000000
--- a/.huggingface/rebuild
+++ /dev/null
@@ -1 +0,0 @@
-rebuild
\ No newline at end of file
diff --git a/.rebuild b/.rebuild
deleted file mode 100644
index 6ef330ce036685b32334eb4404583bb3a75e66d2..0000000000000000000000000000000000000000
--- a/.rebuild
+++ /dev/null
@@ -1 +0,0 @@
-1783786956
\ No newline at end of file
diff --git a/.rebuild_flag b/.rebuild_flag
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/.rebuild_v2 b/.rebuild_v2
deleted file mode 100644
index cff4b32511b1198c956b98034d83cf2b28936c8e..0000000000000000000000000000000000000000
--- a/.rebuild_v2
+++ /dev/null
@@ -1 +0,0 @@
-1783785895
\ No newline at end of file
diff --git a/.restart_trigger b/.restart_trigger
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/CHANGELOG.md b/CHANGELOG.md
deleted file mode 100644
index d7e8f05818627317960375f70db3aec4f04d7968..0000000000000000000000000000000000000000
--- a/CHANGELOG.md
+++ /dev/null
@@ -1,74 +0,0 @@
-# VNEWS v2.8 - Icon Change
-- Changed Short AI feed video share button icon from 📤 (upload/share) to 📥 (download) to distinguish from article share
-
-# VNEWS v6.5 - Resilient Shorts Auto-Updater
-
-## Changes
-
-### Critical Fix: Shorts timeout and homepage load stability
-**Root cause**: YouTube shorts fetching in `main.py` using `scrape_shorts()` and `_yt_channel_shorts_requests()` could hang indefinitely when YouTube blocks requests or yt-dlp times out, causing:
-- Homepage `/api/shorts` endpoint to time out (30s limit)
-- Space to appear unresponsive on first load
-- No fallback when sources fail
-
-**Fix applied**:
-1. **shorts_updater.py** (NEW) — Resilient background updater:
- - Hard timeout (25s) per channel using subprocess isolation
- - Stale-while-revalidate pattern: returns cached data immediately, updates in background
- - Automatic fallback to hardcoded short URLs when all sources fail
- - Persistent storage in `/data/shorts_cache.json` for cache across restarts
- - Background scheduler runs every 10 minutes automatically
- - No blocking on first homepage load
-
-2. **_run.py** — Integrated resilient shorts endpoint:
- - Overrides `/api/shorts` with non-blocking version
- - Returns cached/fallback data in <100ms guaranteed
- - Triggers background update if cache is stale or empty
- - Never hangs - always returns valid JSON response
-
-3. **FALLBACK_SHORTS** — 6 hardcoded viral shorts as emergency fallback:
- - baodantri7941 (Dân trí) headlines
- - baosuckhoedoisongboyte (Sức khỏe & đời sống) stories
- - vtvnambo (VTV Nam Bộ) news
-
-### Benefits
-- Homepage loads in <2 seconds always
-- Shorts data auto-updates every 10 minutes
-- Never times out - graceful degradation to fallback
-- Persistent cache survives Space restarts
-- Uses bucket `bep40/VNEWS-storage` for cache storage
-
-### Channels monitored
-- baodantri7941 (Dân trí)
-- baosuckhoedoisongboyte (Sức khỏe & đời sống)
-- vtvnambo (VTV Nam Bộ)
-
----
-
-# VNEWS v5.1 - Rewrite Fix
-
-## Changes
-
-### Critical Fix: Rewrite button not creating posts on Tường AI
-**Root cause**: `_run.py` imports from `app_v2_entry.py`, but the `/api/rewrite_share` endpoint was only defined in `ai_runtime_patch_fast.py` (loaded through `app_entry.py` which is NOT used). The frontend called a non-existent endpoint → 404 → silent failure.
-
-**Fix applied**:
-1. **app_v2_entry.py** — Added 3 new endpoints:
- - `POST /api/rewrite_slide` — Fast extractive summary (no AI needed), creates slides from article key points + images, saves to wall
- - `POST /api/rewrite_share` — AI-powered rewrite with extractive fallback, saves to wall
- - `POST /api/url_wall` — URL submission endpoint (alias for rewrite_share)
- - All endpoints use the same `_load_wall_posts()` / `_save_wall_posts()` and `WALL_FILE` path as the existing `/api/wall` endpoint
-
-2. **static/index_v2.html** — Added `` to load the rewrite fix
-
-3. **static/rewrite_fix_v2.js** — New file that overrides `rewriteArticle()` to:
- - Call `/api/rewrite_slide` first (fast, no AI needed)
- - Fallback to `/api/rewrite_share` if slide fails
- - Show slide preview overlay after successful post
- - Use `prependWallPost()` to add the new post to Tường AI
-
-### Previous changes (v5)
-- Rewrote match_detail_v2.py with correct event parsing
-- 2-tab layout for match detail (stats + timeline)
-- Fixed _run.py import
-- Dockerfile cache busting
diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644
index 30c7b1042bc73b39ab1d7cacb5e37fc414ac9433..0000000000000000000000000000000000000000
--- a/Dockerfile
+++ /dev/null
@@ -1,16 +0,0 @@
-FROM python:3.12-slim
-
-WORKDIR /app
-
-RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg fonts-dejavu-core && rm -rf /var/lib/apt/lists/*
-RUN pip install --no-cache-dir "beautifulsoup4>=4.12" lxml
-RUN pip install --no-cache-dir fastapi uvicorn requests beautifulsoup4 jinja2 yt-dlp huggingface_hub gTTS pillow edge-tts python-dateutil httpx
-
-COPY requirements.txt .
-RUN pip install --no-cache-dir -r requirements.txt || true
-
-COPY . .
-EXPOSE 7860
-
-CMD ["uvicorn", "_run:app", "--host", "0.0.0.0", "--port", "7860"]
-# v2.7-rebuild
diff --git a/README.md b/README.md
index 8f9526538a80ace404f12e119c3bd2d0e940b957..ebcd71c672947f56bc568d773f5762789310a420 100644
--- a/README.md
+++ b/README.md
@@ -9,25 +9,5 @@ tags:
- ml-intern
---
-# VNEWS - Tin Tức Việt Nam
-
-**v18 - FIXED VTV2/VTV3/VTV6/VTV9 stream hanging**
-
-## 🔧 Changes in v18 (2026-07-06)
-- **VTV2, VTV3, VTV6, VTV9**: Skip expired ssaimh CDN token → immediately fall through to sv2.xemtivitop.com
-- **15+ extraction patterns** for m3u8 URL (up from 5), including: file:, src=, source:, player.src(), hls.loadSource(), href=, ``, url:, window.location, iframe follow (3 levels deep), base64 decode
-- **Backup CDN** `tv.mediacdn.vn` for VTV2/VTV3/VTV6/VTV9
-- **Fast timeout** 5s for CDN, 12s for PHP endpoints (was 15s each = 60s+ total)
-- **sv2.xemtivitop.com** re-prioritized to check BEFORE xemtv.us
-- **Iframe chain following**: if a PHP page returns an iframe → follow it up to 3 levels to find the m3u8
-
-## Features:
-- 📰 News from VnExpress (10 categories) + GenK AI
-- ⚽ Livescore from bongda.com.vn (live, today, upcoming, results, standings)
-- 🎬 Football highlights from xemlaibongda.top (8 leagues)
-- 📺 VTV live channels (VTV1→VTV10, VTV Prime)
- - Priority: ssaimh CDN → sv2.xemtivitop.com → xemtv.us → xemtivitop blogspot → FPTPlay → VTVGo → mediacdn → xemtv.net
-- 🏆 World Cup 2026 (news, fixtures, standings, stats, highlights)
-- 🤖 AI article writing + TTS (multilingual, emotion-aware)
-- 🔍 Topic search (8 news sources)
-- 🎤 TTS: voice selector + emotion selector + speed control
\ No newline at end of file
+# bep40/vnews
+
diff --git a/_run.py b/_run.py
deleted file mode 100644
index de72380c3006dba490ec49c1fe421c87723a0490..0000000000000000000000000000000000000000
--- a/_run.py
+++ /dev/null
@@ -1 +0,0 @@
-from app_v2_entry import app # v5-stable inline bongda proxy
\ No newline at end of file
diff --git a/ai_ext.py b/ai_ext.py
deleted file mode 100644
index cf4b1d0ff3215baa8ed3f05887a252c47d56bc90..0000000000000000000000000000000000000000
--- a/ai_ext.py
+++ /dev/null
@@ -1,574 +0,0 @@
-"""VNEWS AI Extension - rewrite + auto short video generation.
-Imported by app_v2_entry.py to register /api/rewrite_share, /api/topic_post,
-/api/ai_wall, /api/wall, /api/ai/short endpoints on the main FastAPI app.
-
-Uses main.py's WALL_FILE (wall_posts.json) for unified data store.
-TTS: edge-tts (HoaiMy female, NamMinh male) with speed control + gTTS fallback.
-"""
-import os, re, json, time, random, html as html_lib, subprocess, asyncio
-from urllib.parse import quote_plus, quote, urlparse, urljoin
-from typing import Optional, List, Dict
-import requests
-from bs4 import BeautifulSoup
-from fastapi import Request, Query
-from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
-
-# Try to import main app, but don't fail if it doesn't exist
-try:
- from main import app
-except ImportError:
- # Create a minimal FastAPI app for standalone testing
- try:
- from fastapi import FastAPI
- app = FastAPI()
- except Exception:
- app = None
-
-# Import wall store from main.py so we read/write the SAME file
-try:
- from main import _load_wall, _save_wall, _web_context # noqa: F401
-except ImportError:
- _data_dir = "/data" if os.path.isdir("/data") else "/app/data"
- _wall_file = os.path.join(_data_dir, "wall_posts.json")
- def _load_wall():
- try:
- if os.path.exists(_wall_file):
- with open(_wall_file, "r", encoding="utf-8") as f:
- return json.load(f)
- except Exception:
- pass
- return []
- def _save_wall(posts):
- try:
- os.makedirs(os.path.dirname(_wall_file), exist_ok=True)
- tmp = _wall_file + ".tmp"
- with open(tmp, "w", encoding="utf-8") as f:
- json.dump(posts[:100], f, ensure_ascii=False)
- os.replace(tmp, _wall_file)
- except Exception:
- pass
- def _web_context(topic):
- return ""
-
-# ai_ext alias for backward compatibility
-_load_ai_wall = _load_wall
-_save_ai_wall = _save_wall
-
-try:
- from huggingface_hub import AsyncInferenceClient
-except Exception:
- AsyncInferenceClient = None
-try:
- from gtts import gTTS
-except Exception:
- gTTS = None
-try:
- from PIL import Image, ImageDraw, ImageFont
-except Exception:
- Image = ImageDraw = ImageFont = None
-try:
- import edge_tts
-except Exception:
- edge_tts = None
-
-
-def _hf_token():
- for k in ("HF_TOKEN", "HUGGINGFACE_HUB_API_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HF_API_TOKEN"):
- v = os.getenv(k, "").strip()
- if v:
- return v
- return ""
-
-
-def _clean_text(s: str) -> str:
- """Clean text for processing."""
- s = html_lib.unescape(s or "")
- s = re.sub(r"\s+", " ", s)
- return s.strip()
-
-
-def _domain(url: str) -> str:
- """Extract domain from URL."""
- try:
- return urlparse(url or "").netloc.replace("www.", "")
- except Exception:
- return ""
-
-
-async def qwen_generate(prompt: str, image_url: str = None, max_tokens: int = 1200) -> str:
- """Generate text using Qwen models via Hugging Face Inference API.
-
- This function provides a resilient implementation that:
- 1. First tries the SDK-based inference client if available
- 2. Falls back to REST API calls to HF router endpoint
- 3. Returns a fallback summary if all else fails
- """
- token = _hf_token()
- errors = []
-
- # Try HF router API with multiple models
- if token:
- models = [
- 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",
- "Qwen/Qwen2.5-72B-Instruct",
- "meta-llama/Llama-3.3-70B-Instruct",
- ]
- # Deduplicate while preserving order
- seen = set()
- models = [m for m in models if m and m not in seen and not seen.add(m)]
-
- headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
-
- for model in models:
- try:
- is_vl = "VL" in model and image_url
- if is_vl:
- user_content = [
- {"type": "image_url", "image_url": {"url": image_url}},
- {"type": "text", "text": prompt}
- ]
- else:
- user_content = prompt
-
- payload = {
- "model": model,
- "messages": [
- {"role": "system", "content": "Bạn là trợ lý AI tiếng Việt. Trả lời tự nhiên, ngắn gọn, chính xác."},
- {"role": "user", "content": user_content},
- ],
- "max_tokens": min(int(max_tokens or 900), 1400),
- "temperature": 0.35,
- "top_p": 0.85,
- }
-
- r = requests.post(
- "https://router.huggingface.co/v1/chat/completions",
- headers=headers,
- json=payload,
- timeout=95
- )
-
- if r.status_code >= 300:
- errors.append(f"{model}: HTTP {r.status_code}")
- continue
-
- j = r.json()
- txt = (j.get("choices", [{}])[0].get("message", {}).get("content") or "").strip()
-
- if txt:
- return txt
-
- errors.append(f"{model}: empty response")
-
- except Exception as e:
- errors.append(f"{model}: {type(e).__name__}")
-
- # Fallback: extractive summary from prompt
- LAST_QWEN_ERROR = errors[-3:] if errors else "unknown error"
- return _fallback_summary_from_prompt(prompt, max_units=6)
-
-
-def _fallback_summary_from_prompt(prompt: str, max_units: int = 6) -> str:
- """Generate a simple fallback summary when AI is unavailable."""
- text = prompt or ""
- 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:"]:
- if marker in text:
- text = text.split(marker, 1)[1]
- break
- text = re.sub(r"https?://\S+", "", text)
- text = re.sub(r"\s+", " ", text).strip()
-
- # Split into sentences - extract ALL valid sentences, not just first few
- sentences = re.split(r"(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])", text)
- units = []
- for s in sentences:
- s = _clean_text(s)
- if len(s) >= 30: # Lower threshold to capture more content
- units.append(s)
-
- if units:
- # Take up to max_units valid sentences
- result_units = units[:max_units]
- return "\n".join("• " + u for u in result_units)
- if text:
- # Fallback: take chunks if no sentence boundaries found
- chunks = []
- for i in range(0, min(len(text), max_units * 300), 280):
- chunk = _clean_text(text[i:i+300])
- if chunk and chunk not in chunks:
- chunks.append(chunk)
- if len(chunks) >= max_units:
- break
- if chunks:
- return "\n".join("• " + c for c in chunks)
- return "• Không có đủ nội dung để tóm tắt."
-
-
-HF_TOKEN = _hf_token()
-QWEN_VL_MODEL = os.getenv("QWEN_VL_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct")
-QWEN_TEXT_MODELS = [m.strip() for m in os.getenv(
- "QWEN_TEXT_MODELS",
- "Qwen/Qwen2.5-72B-Instruct,meta-llama/Llama-3.3-70B-Instruct,Qwen/Qwen2.5-7B-Instruct"
-).split(",") if m.strip()]
-_WORKING_MODEL_TEXT = None
-_WORKING_MODEL_VL = None
-DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data"
-SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts")
-HEADERS = {
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
- "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"
-}
-LAST_QWEN_ERROR = ""
-
-
-# ===== MULTILINGUAL VOICES FOR TTS =====
-# Maps voice IDs to edge-tts voice names (only MultilingualNeural voices)
-MULTILINGUAL_VOICES = {
- # Vietnamese - Native voices
- "vi-vn-hoaimyneural": "vi-VN-HoaiMyNeural",
- "vi-vn-namminhneural": "vi-VN-NamMinhNeural",
- "hoaimy": "vi-VN-HoaiMyNeural",
- "namminh": "vi-VN-NamMinhNeural",
- "vi_female": "vi-VN-HoaiMyNeural",
- "vi_male": "vi-VN-NamMinhNeural",
- "nu": "vi-VN-HoaiMyNeural",
- "male": "vi-VN-NamMinhNeural",
- "female": "vi-VN-HoaiMyNeural",
- "mien-nam": "vi-VN-HoaiMyNeural",
- # English - Multilingual
- "en-us-andrewmultilingualneural": "en-US-AndrewMultilingualNeural",
- "en-au-williammultilingualneural": "en-AU-WilliamMultilingualNeural",
- "en_andrew": "en-US-AndrewMultilingualNeural",
- "andrew": "en-US-AndrewMultilingualNeural",
- "en_jenny": "en-US-AndrewMultilingualNeural",
- "jenny": "en-US-AndrewMultilingualNeural",
- # Portuguese - Thalita Multilingual ONLY
- "pt-br-thalitamultilingualneural": "pt-BR-ThalitaMultilingualNeural",
- "pt_thalita": "pt-BR-ThalitaMultilingualNeural",
- "thalita": "pt-BR-ThalitaMultilingualNeural",
- "pt_francisco": "pt-BR-ThalitaMultilingualNeural",
- "pt": "pt-BR-ThalitaMultilingualNeural",
- # French - Multilingual
- "fr-fr-viviennemultilingualneural": "fr-FR-VivienneMultilingualNeural",
- "fr-fr-remymultilingualneural": "fr-FR-RemyMultilingualNeural",
- "fr_denise": "fr-FR-VivienneMultilingualNeural",
- "denise": "fr-FR-VivienneMultilingualNeural",
- "fr": "fr-FR-VivienneMultilingualNeural",
- # German - Multilingual
- "de-de-seraphinamultilingualneural": "de-DE-SeraphinaMultilingualNeural",
- "de-de-florianmultilingualneural": "de-DE-FlorianMultilingualNeural",
- "de_katja": "de-DE-SeraphinaMultilingualNeural",
- "katja": "de-DE-SeraphinaMultilingualNeural",
- "de": "de-DE-SeraphinaMultilingualNeural",
- # Korean - Hyunsu Multilingual (NOT SunHee)
- "ko-kr-hyunsumultilingualneural": "ko-KR-HyunsuMultilingualNeural",
- "ko_sunhee": "ko-KR-HyunsuMultilingualNeural",
- "sunhee": "ko-KR-HyunsuMultilingualNeural",
- "ko": "ko-KR-HyunsuMultilingualNeural",
- # Italian - Multilingual
- "it-it-giuseppemultilingualneural": "it-IT-GiuseppeMultilingualNeural",
- # Spanish (fallback to English multilingual)
- "es_ela": "en-US-AndrewMultilingualNeural",
- "ela": "en-US-AndrewMultilingualNeural",
- "es_carlos": "en-US-AndrewMultilingualNeural",
- "es": "en-US-AndrewMultilingualNeural",
- # Japanese (fallback to English multilingual)
- "ja_nanami": "en-US-AndrewMultilingualNeural",
- "nanami": "en-US-AndrewMultilingualNeural",
- "ja": "en-US-AndrewMultilingualNeural",
- # Chinese (fallback to English multilingual)
- "zh_xiaochen": "en-US-AndrewMultilingualNeural",
- "xiaochen": "en-US-AndrewMultilingualNeural",
- "zh": "en-US-AndrewMultilingualNeural",
-}
-
-
-def _detect_voice_emotion(title, text):
- """Detect appropriate voice and emotion based on content for multilingual TTS."""
- content = ((title or "") + " " + (text or "")).lower()
-
- # World Cup / Football content - use Andrew multilingual
- if any(kw in content for kw in ["world cup", "wc 2026", "fifa", "bóng đá", "trận đấu", "bóng bóng", "đội tuyển", "cầu thủ"]):
- return ("andrew", "excited")
-
- # News categories - choose appropriate voice
- if any(kw in content for kw in ["kinh tế", "tài chính", "thị trường", "economics", "finance"]):
- return ("jenny", "calm")
- if any(kw in content for kw in ["thiên tai", "bão", "lũ lụt", "cháy nổ", "tai nạn", "disaster", "accident"]):
- return ("thalita", "serious")
- if any(kw in content for kw in ["giải trí", "showbiz", "entertainment", "hài hước"]):
- return ("ela", "happy")
- if any(kw in content for kw in ["công nghệ", "tech", "technology", "ai", "trí tuệ nhân tạo"]):
- return ("katja", "excited")
-
- # Default Vietnamese
- return ("hoaimy", "trung_tinh")
-
-
-def _safe_name(s: str) -> str:
- """Create safe filename from string."""
- s = re.sub(r"[^\w\-.]", "_", s)
- return s[:100] if len(s) > 100 else s
-
-
-def _download_image(url: str, fallback_title: str, out_path: str) -> bool:
- """Download image from URL to path."""
- if not url:
- return False
- try:
- r = requests.get(url, headers=HEADERS, timeout=15)
- if r.status_code == 200:
- os.makedirs(os.path.dirname(out_path), exist_ok=True)
- with open(out_path, "wb") as f:
- f.write(r.content)
- return True
- except Exception:
- pass
- return False
-
-
-def pollination_image_url(topic: str) -> str:
- """Generate image URL from Pollinations.ai."""
- return f"https://image.pollinations.ai/prompt/{quote(topic)}?width=1024&height=768&nologo=true&model=flux"
-
-
-# Use the same wall file as app_v2_entry.py for consistency
-WALL_FILE = os.path.join(DATA_DIR, "wall_posts.json")
-
-def _load_ai_wall():
- """Load AI wall posts from JSON file (uses wall_posts.json for consistency with app_v2_entry)."""
- try:
- if os.path.exists(WALL_FILE):
- with open(WALL_FILE, "r", encoding="utf-8") as f:
- return json.load(f)
- except Exception:
- pass
- return []
-
-
-def _save_ai_wall(posts):
- """Save AI wall posts to JSON file (uses wall_posts.json for consistency with app_v2_entry)."""
- try:
- os.makedirs(os.path.dirname(WALL_FILE), exist_ok=True)
- tmp = WALL_FILE + ".tmp"
- with open(tmp, "w", encoding="utf-8") as f:
- json.dump(posts[:100], f, ensure_ascii=False)
- os.replace(tmp, WALL_FILE)
- except Exception:
- pass
-
-
-# Helper functions for wall operations
-def _load_wall_posts():
- """Alias for _load_ai_wall for consistency with app_v2_entry.py."""
- return _load_ai_wall()
-
-
-def _save_wall_posts(posts):
- """Alias for _save_ai_wall for consistency with app_v2_entry.py."""
- return _save_ai_wall(posts)
-
-
-def make_post(title: str, text: str, img: str, url: str, kind: str, sources=None):
- """Create a post dict with standard fields."""
- return {
- "id": str(int(time.time() * 1000)),
- "title": title,
- "text": text,
- "img": img,
- "url": url,
- "kind": kind,
- "sources": sources or [],
- "ts": int(time.time())
- }
-
-
-def _short_script(post) -> str:
- """Extract clean text for TTS from post."""
- text = post.get("text", "") or post.get("title", "")
- text = re.sub(r"^[•\-\*]\s*", "", text, flags=re.M)
- text = re.sub(r"\s*\n\s*", ". ", text)
- return _clean_text(text)[:2000] # Increased from 1000 to 2000 for full content
-
-
-# ===== SCRAPER FUNCTIONS (required by ai_patch.py) =====
-def scrape_any_url(url: str) -> dict:
- """Scrape any URL and extract article content.
-
- Returns dict with: title, summary, text, image, og_image, via (domain)
- """
- try:
- r = requests.get(url, headers=HEADERS, timeout=15, allow_redirects=True)
- r.encoding = 'utf-8'
- soup = BeautifulSoup(r.text, 'lxml')
-
- # Remove scripts, styles, nav, footer
- for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
- tag.decompose()
-
- # Extract title
- h1 = soup.find('h1')
- ogt = soup.find('meta', property='og:title')
- title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else url)
-
- # Extract OG image
- ogi = soup.find('meta', property='og:image')
- og_image = ogi.get('content', '') if ogi else ''
-
- # Extract article body
- block = None
- for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
- el = soup.select_one(sel)
- if el and len(el.find_all('p')) >= 2:
- block = el
- break
- if not block:
- block = soup.body or soup
-
- # Extract text from paragraphs
- paragraphs = []
- for el in block.find_all(['p', 'h2', 'h3'], recursive=True):
- t = _clean_text(el.get_text(strip=True))
- if t and len(t) > 40:
- paragraphs.append(t)
-
- # Extract images
- images = []
- for el in block.find_all(['figure', 'img'], recursive=True):
- im = el if el.name == 'img' else el.find('img')
- if im:
- src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
- if src and 'base64' not in src:
- if src.startswith('//'):
- src = 'https:' + src
- images.append(src)
-
- # Prefer OG image as main image
- image = og_image or (images[0] if images else '')
-
- return {
- 'title': title,
- 'summary': paragraphs[0] if paragraphs else '',
- 'text': '\n'.join(paragraphs),
- 'image': image,
- 'og_image': og_image,
- 'via': _domain(url),
- 'images': images
- }
- except Exception as e:
- return {'title': url, 'summary': '', 'text': '', 'image': '', 'og_image': '', 'via': _domain(url), 'error': str(e)}
-
-
-def web_context(topic: str, limit: int = 5) -> tuple:
- """Get web context for a topic. Returns (context_text, sources_list)."""
- sources = []
- try:
- # Try Google News RSS
- rss_url = f"https://news.google.com/rss/search?q={quote_plus(topic)}&hl=vi&gl=VN&ceid=VN:vi"
- r = requests.get(rss_url, headers=HEADERS, timeout=15)
- r.encoding = 'utf-8'
- soup = BeautifulSoup(r.text, 'xml')
- for it in soup.find_all('item')[:limit]:
- title = it.find('title').get_text(' ', strip=True) if it.find('title') else ''
- link = it.find('link').get_text(strip=True) if it.find('link') else ''
- if title and link:
- sources.append({'title': title, 'url': link, 'via': _domain(link)})
- except Exception:
- pass
-
- context = f'Trên mạng có nhiều bài viết về "{topic}". Một số nguồn: ' + ', '.join([s.get('title', '') for s in sources[:3]])
- return context, sources
-
-
-# ===== SHORT FRAME FUNCTION (required by ai_patch.py) =====
-def _make_short_frame(post, img_path, out_path):
- """Create a short video frame from post and image.
-
- Called by ai_patch.py _make_short_frame_full when Image is available.
- """
- if Image is None:
- # Create a minimal frame without PIL - just return success
- # The caller should handle this case
- return False
-
- W, H = 1080, 1920
- bg = Image.new("RGB", (W, H), (14, 14, 14))
-
- try:
- im = Image.open(img_path).convert("RGB")
- target = (1080, 760)
- im_ratio = im.width / max(1, im.height)
- target_ratio = target[0] / target[1]
-
- if im_ratio > target_ratio:
- new_h = target[1]
- new_w = int(new_h * im_ratio)
- else:
- new_w = target[0]
- new_h = int(new_w / im_ratio)
-
- im = im.resize((new_w, new_h))
- left = (new_w - target[0]) // 2
- top = (new_h - target[1]) // 2
- im = im.crop((left, top, left + target[0], top + target[1]))
- bg.paste(im, (0, 0))
- except Exception:
- pass
-
- draw = ImageDraw.Draw(bg)
-
- try:
- font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 54)
- font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 38)
- except Exception:
- font_title = font_body = None
-
- draw.rectangle((0, 720, W, H), fill=(14, 14, 14))
- margin = 48
- maxw = W - margin * 2
-
- y = 830
- for ln in _wrap_text(draw, post.get("title", ""), font_title, maxw, 4):
- draw.text((margin, y), ln, fill=(255, 255, 255), font=font_title)
- y += 66
-
- y += 18
- text = post.get("text", "")
- text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip()
- body_lines = _wrap_text(draw, text, font_body, maxw, 14)
- for ln in body_lines:
- draw.text((margin, y), ln, fill=(220, 220, 220), font=font_body)
- y += 50
- if y > 1640:
- break
-
- bg.save(out_path, quality=92)
- return True
-
-
-def _wrap_text(draw, text, font, max_width, max_lines):
- """Helper for wrapping text in frames."""
- words = _clean_text(text).split()
- lines, cur = [], ""
- for w in words:
- test = (cur + " " + w).strip()
- try:
- width = draw.textbbox((0, 0), test, font=font)[2]
- except Exception:
- width = len(test) * 20
- if width <= max_width:
- 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
\ No newline at end of file
diff --git a/ai_fix2.py b/ai_fix2.py
deleted file mode 100644
index 895be1d7505ed5fedaec5b8f023a52434101989f..0000000000000000000000000000000000000000
--- a/ai_fix2.py
+++ /dev/null
@@ -1,366 +0,0 @@
-import os, re, subprocess, html as html_lib, json
-from urllib.parse import quote_plus, urlparse, parse_qs, unquote
-import requests
-import ai_patch as prev
-from ai_patch import app
-from fastapi import Request
-from fastapi.responses import JSONResponse, HTMLResponse, FileResponse
-
-base = prev.base
-
-
-def clean(s):
- return re.sub(r"\s+", " ", html_lib.unescape(s or "")).strip()
-
-
-def _is_real_article_text(raw):
- raw = clean(raw)
- if len(raw) < 500:
- return False
- # Reject search-result/title-only pages: need several real sentences.
- sentences = re.split(r"(?<=[\.\!\?])\s+", raw)
- long_sentences = [s for s in sentences if len(s) > 45]
- return len(long_sentences) >= 5
-
-
-def _extract_ddg_url(href):
- if not href:
- return ""
- if href.startswith("//"):
- href = "https:" + href
- if "duckduckgo.com/l/" in href:
- try:
- qs = parse_qs(urlparse(href).query)
- if qs.get("uddg"):
- return unquote(qs["uddg"][0])
- except Exception:
- pass
- return href
-
-
-def _ddg_article_urls(topic, limit=12):
- urls = []
- try:
- q = quote_plus(topic + " tin tức bài viết phân tích")
- r = requests.get("https://html.duckduckgo.com/html/?q=" + q, headers=base.HEADERS, timeout=18)
- r.encoding = "utf-8"
- from bs4 import BeautifulSoup
- soup = BeautifulSoup(r.text, "lxml")
- for a in soup.select("a.result__a"):
- u = _extract_ddg_url(a.get("href", ""))
- if not u.startswith("http"):
- continue
- if any(bad in u for bad in ["google.com", "youtube.com", "facebook.com", "x.com", "twitter.com"]):
- continue
- if u not in urls:
- urls.append(u)
- if len(urls) >= limit:
- break
- except Exception:
- pass
- return urls
-
-
-def _rss_article_urls(topic, limit=10):
- out = []
- try:
- url = "https://news.google.com/rss/search?q=" + quote_plus(topic) + "&hl=vi&gl=VN&ceid=VN:vi"
- r = requests.get(url, headers=base.HEADERS, timeout=15)
- r.encoding = "utf-8"
- from bs4 import BeautifulSoup
- soup = BeautifulSoup(r.text, "xml")
- for it in soup.find_all("item")[:limit]:
- title = it.find("title").get_text(" ", strip=True) if it.find("title") else ""
- link = it.find("link").get_text(strip=True) if it.find("link") else ""
- src = it.find("source").get_text(" ", strip=True) if it.find("source") else base._domain(link)
- if title and link:
- out.append({"title": title, "url": link, "via": src, "excerpt": title})
- except Exception:
- pass
- return out
-
-
-def _topic_source_articles(topic, limit=5):
- """Scrape actual article bodies. Do not accept title-only sources."""
- candidates = []
- seen = set()
-
- # 1) DuckDuckGo actual result URLs are usually more directly scrapable.
- for u in _ddg_article_urls(topic, limit=14):
- if u not in seen:
- seen.add(u)
- candidates.append({"url": u, "title": "", "via": base._domain(u)})
-
- # 2) Add base web_context sources.
- try:
- _ctx, srcs = base.web_context(topic, limit=8)
- for s in srcs or []:
- u = s.get("url") or ""
- if u.startswith("http") and u not in seen:
- seen.add(u)
- candidates.append(s)
- except Exception:
- pass
-
- # 3) Google News RSS fallback last.
- for s in _rss_article_urls(topic, limit=10):
- u = s.get("url") or ""
- if u.startswith("http") and u not in seen:
- seen.add(u)
- candidates.append(s)
-
- out = []
- for s in candidates[:24]:
- url = s.get("url") or ""
- try:
- page = base.scrape_any_url(url)
- raw = (page.get("summary", "") + "\n" + page.get("text", "")).strip()
- if not _is_real_article_text(raw):
- continue
- title = page.get("title") or s.get("title") or url
- via = page.get("via") or s.get("via") or base._domain(url)
- out.append({
- "title": title,
- "url": url,
- "raw": raw,
- "image": page.get("image") or "",
- "via": via,
- "source": {"title": title, "url": url, "excerpt": raw[:700], "via": via}
- })
- if len(out) >= limit:
- break
- except Exception:
- continue
- return out[:limit]
-
-
-def sentence_split(text):
- text = re.sub(r"^[•\-\*]\s*", "", text or "", flags=re.M)
- text = re.sub(r"\n+", ". ", text)
- parts = []
- for s in re.split(r"(?<=[\.\!\?])\s+", text):
- s = clean(s)
- if len(s) >= 8:
- parts.append(s)
- return parts
-
-
-def srt_time(sec):
- ms = int((sec - int(sec)) * 1000)
- sec = int(sec)
- return f"{sec//3600:02d}:{(sec%3600)//60:02d}:{sec%60:02d},{ms:03d}"
-
-
-def parse_timecode(t):
- # 00:00:01.234 or 00:00:01,234
- t = t.replace(',', '.')
- parts = t.split(':')
- if len(parts) == 3:
- return int(parts[0])*3600 + int(parts[1])*60 + float(parts[2])
- if len(parts) == 2:
- return int(parts[0])*60 + float(parts[1])
- return float(parts[0])
-
-
-def convert_vtt_to_scaled_srt(vtt_path, srt_path, speed=1.2):
- try:
- txt = open(vtt_path, 'r', encoding='utf-8').read().splitlines()
- cues = []
- i = 0
- while i < len(txt):
- line = txt[i].strip()
- if '-->' in line:
- a, b = [x.strip().split()[0] for x in line.split('-->')[:2]]
- start = parse_timecode(a) / speed
- end = parse_timecode(b) / speed
- i += 1
- texts = []
- while i < len(txt) and txt[i].strip():
- texts.append(txt[i].strip())
- i += 1
- s = clean(' '.join(texts))
- if s:
- cues.append((start, end, s))
- i += 1
- if not cues:
- return False
- with open(srt_path, 'w', encoding='utf-8') as f:
- for idx, (st, en, s) in enumerate(cues, 1):
- if en <= st:
- en = st + 1.2
- f.write(f"{idx}\n{srt_time(st)} --> {srt_time(en)}\n{s}\n\n")
- return True
- except Exception:
- return False
-
-
-def write_weighted_srt(script, path, total_duration):
- subs = sentence_split(script)
- if not subs:
- subs = [clean(script)[:140] or "VNEWS"]
- total_chars = max(1, sum(len(x) for x in subs))
- usable = max(2.0, float(total_duration) - 1.0)
- cur = 0.5
- with open(path, "w", encoding="utf-8") as f:
- for i, s in enumerate(subs, 1):
- dur = max(1.8, min(7.0, usable * len(s) / total_chars))
- start = cur
- end = min(total_duration - 0.15, cur + dur)
- cur = end + 0.18
- f.write(f"{i}\n{srt_time(start)} --> {srt_time(end)}\n{s}\n\n")
- if cur >= total_duration - 0.2:
- break
-
-
-def tts_script_full(post, emotion):
- title = clean(post.get("title", ""))
- text = clean(post.get("text", ""))
- text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip()
- 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, "")
- script = f"{prefix} {title}. {text}".strip()
- # Keep complete wall summary. Only trim pathological payloads, on sentence boundary.
- if len(script) > 3600:
- tmp = script[:3600]
- cut = max(tmp.rfind("."), tmp.rfind("!"), tmp.rfind("?"))
- script = tmp[:cut + 1] if cut > 1600 else tmp
- script = re.sub(r"([\.\!\?])\s*", r"\1\n", script)
- script = re.sub(r"\n{2,}", "\n", script).strip()
- return script
-
-
-_PATCH = {('/api/topic_post','POST'),('/api/ai/short/{post_id}','POST'),('/api/ai/short-file/{file_id}','GET'),('/','GET')}
-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 _PATCH)]
-
-
-@app.post('/api/topic_post')
-async def topic_post_aggregate(request: Request):
- body = await request.json()
- topic = base._clean_text(body.get('topic',''))
- if not topic:
- return JSONResponse({'error':'missing topic'}, status_code=400)
- articles = _topic_source_articles(topic, limit=5)
- if not articles:
- return JSONResponse({'error':'Không scrape được nội dung bài viết thật cho chủ đề này. Hãy thử chủ đề cụ thể hơn hoặc dán URL trực tiếp.'}, status_code=422)
- source_blocks = []
- sources = []
- image = ""
- for i, art in enumerate(articles, 1):
- raw = art.get('raw','')
- source_blocks.append(f"[Nguồn {i}] {art.get('title','')} ({art.get('via','')})\n{raw[:3000]}")
- sources.append(art.get('source') or {'title': art.get('title'), 'url': art.get('url'), 'via': art.get('via'), 'excerpt': raw[:600]})
- if not image and art.get('image'):
- image = art.get('image')
- ctx = "\n\n".join(source_blocks)
- prompt = f"""Bạn là biên tập viên tổng hợp tin tức tiếng Việt.
-
-Chủ đề: {topic}
-
-NHIỆM VỤ:
-- Đọc nội dung của TẤT CẢ các bài nguồn bên dưới.
-- Tổng hợp thành 1 bản tóm tắt chung duy nhất, giống cách tóm tắt qua URL.
-- Không tạo mỗi tiêu đề thành một bài riêng.
-- Không chỉ liệt kê tiêu đề; phải dựa vào nội dung trong từng bài.
-- Không lặp ý giữa các nguồn.
-- Tối đa 6 gạch đầu dòng, mỗi dòng 1 câu rõ ràng.
-- Nếu các nguồn có góc nhìn khác nhau, gộp lại thành ý tổng hợp.
-- Cuối cùng thêm dòng: Nguồn tham khảo: tên website.
-
-Nội dung nguồn:
-{ctx[:16000]}"""
- text = await prev.base.qwen_generate(prompt, image_url=image or None, max_tokens=1100)
- text = prev._postprocess_ai_text(text, max_units=7)
- if 'Nguồn tham khảo:' not in text:
- text += '\n\n' + prev._source_line(sources)
- post = base.make_post('Tổng hợp: ' + topic, text, image or base.pollinations_image_url(topic), '', 'topic_aggregate', sources=sources[:5])
- posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
- return JSONResponse({'post': post, 'count_sources': len(sources)})
-
-
-@app.post('/api/ai/short/{post_id}')
-async def ai_short_full(post_id: str, request: Request):
- try:
- body = await request.json()
- except Exception:
- body = {}
- 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)))
- posts = base._load_ai_wall()
- post = next((p for p in posts if str(p.get('id')) == str(post_id)), None)
- if not post:
- return JSONResponse({'error':'post not found'}, status_code=404)
- os.makedirs(base.SHORTS_DIR, exist_ok=True)
- suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_fullv2"
- out_mp4 = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix) + '.mp4')
- 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'], 'speed': speed, 'subtitles': True})
- work = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix)); os.makedirs(work, exist_ok=True)
- img = os.path.join(work,'image.jpg'); frame = os.path.join(work,'frame.jpg'); audio = os.path.join(work,'voice.mp3'); audio_fast=os.path.join(work,'voice_fast.mp3'); srt=os.path.join(work,'subtitles.srt'); vtt=os.path.join(work,'subtitles.vtt')
- try:
- base._download_image(post.get('img'), post.get('title','AI news'), img)
- prev._make_short_frame_full(post, img, frame)
- script = tts_script_full(post, emotion)
- 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')
- used_edge = False
- try:
- subprocess.run(['python','-m','edge_tts','--voice',edge_voice,'--text',script,'--write-media',audio,'--write-subtitles',vtt], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=260)
- used_edge = True
- except Exception:
- tld = 'com.vn' if voice in ('nu','female','mien-nam') else 'com'
- try:
- base.gTTS(script, lang='vi', tld=tld, slow=False).save(audio)
- except TypeError:
- base.gTTS(script, lang='vi', slow=False).save(audio)
- subprocess.run(['ffmpeg','-y','-i',audio,'-filter:a',f'atempo={speed}','-vn',audio_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=220)
- duration = 45.0
- try:
- pr = subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:no_key=1',audio_fast], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
- duration = float((pr.stdout or b'45').decode().strip() or 45)
- except Exception:
- pass
- if used_edge and os.path.exists(vtt):
- ok = convert_vtt_to_scaled_srt(vtt, srt, speed=speed)
- if not ok:
- write_weighted_srt(script, srt, duration)
- else:
- write_weighted_srt(script, srt, duration)
- vf = "scale=1080:1920,subtitles='{}':force_style='FontName=DejaVu Sans,FontSize=16,PrimaryColour=&H00FFFFFF,OutlineColour=&HAA000000,BorderStyle=1,Outline=1.5,Shadow=0,Alignment=2,MarginV=42'".format(srt.replace("'", "\\'"))
- 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]
- subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=420)
- post['video'] = '/api/ai/short-file/' + post_id + suffix
- post['short_voice'] = voice; post['short_emotion'] = emotion; post['short_speed'] = speed; post['short_subtitles'] = True
- base._save_ai_wall(posts)
- return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': True, 'duration': duration})
- except Exception as e:
- return JSONResponse({'error':'Không tạo được shorts: '+str(e)[:180]}, status_code=500)
-
-
-@app.get('/api/ai/short-file/{file_id}')
-def ai_short_file_full(file_id: str):
- path = os.path.join(base.SHORTS_DIR, base._safe_name(file_id) + '.mp4')
- if not os.path.exists(path):
- return JSONResponse({'error':'not found'}, status_code=404)
- return FileResponse(path, media_type='video/mp4', filename=f'vnews-ai-{file_id}.mp4')
-
-
-app.router.routes = [r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
-
-@app.get('/')
-async def index_fix2():
- with open('/app/static/index.html','r',encoding='utf-8') as f:
- html = f.read()
- inject = prev.PATCH_INJECT + r'''
-
-'''
- return HTMLResponse(html.replace('