diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 40f60e1de89e0f904c11d302058de8c039f587a9..0000000000000000000000000000000000000000 --- a/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -__pycache__/ -*.pyc -data/ -.data -.huggingface/ -.restart_trigger diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index bed2a391f57aae4c106228b98824a5a07c71913b..0000000000000000000000000000000000000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,16 +0,0 @@ -# FPT Play Stream Selector Update - -Added FPT Play channel with stream selector UI similar to VTV6: - -- New tab "FPT" (orange themed) in the channel tabs -- Stream selector with 4 sources: - 1. 🌐 Web FPT Play (iframe) - 2. 📡 HLS Proxy (via /api/proxy/m3u8) - 3. 🔗 HLS Direct - 4. 📺 HD1.xemtv.net (iframe from LINK 1) -- Backend vtv_api.py now returns stream_selectors for fpt-the-thao channel -- Frontend handles stream switching automatically when FPT tab is active - -## Changes -- `static/vtv_init.js`: Added FPT tab + stream selector UI logic -- `vtv_api.py`: Added FPT Play endpoint responses with stream_selectors data diff --git a/Dockerfile b/Dockerfile index fd799e0b9eca048c628530076b0a14544944a46b..bbedafd73d632f813392e62974ca6a580ab4d7bf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,48 +1,15 @@ -FROM python:3.12-slim +FROM python:3.11-slim WORKDIR /app -RUN echo "[BUILD] step1: apt-get update+install ffmpeg + Vietnamese fonts" && \ - apt-get update && apt-get install -y --no-install-recommends \ - ffmpeg \ - fonts-dejavu-core \ - fonts-noto \ - fonts-noto-cjk \ - fonts-noto-color-emoji \ - fonts-liberation \ - fonts-freefont-ttf \ - libfreetype6 \ - && rm -rf /var/lib/apt/lists/* && \ - echo "[BUILD] step1 done" - -RUN echo "[BUILD] step2: pip base pkgs (bs4/lxml)" && \ - pip install --no-cache-dir "beautifulsoup4>=4.12" lxml && \ - echo "[BUILD] step2 done" - -RUN echo "[BUILD] step3: pip main pkgs" && \ - pip install --no-cache-dir fastapi uvicorn requests beautifulsoup4 jinja2 yt-dlp huggingface_hub gTTS pillow edge-tts python-dateutil httpx pycryptodome && \ - echo "[BUILD] step3 done" - -COPY requirements.txt . -RUN echo "[BUILD] step4: pip requirements.txt" && \ - pip install --no-cache-dir -r requirements.txt || true && \ - echo "[BUILD] step4 done" +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 fastapi uvicorn requests beautifulsoup4 lxml jinja2 yt-dlp huggingface_hub gTTS pillow edge-tts COPY . . -EXPOSE 7860 -RUN echo "[BUILD] step5: setup Vietnamese font symlink" && \ - mkdir -p /usr/share/fonts/truetype/vn && \ - # Prefer Noto Sans for Vietnamese - it has full diacritic support - if [ -f /usr/share/fonts/truetype/noto/NotoSans-Regular.ttf ]; then \ - ln -sf /usr/share/fonts/truetype/noto/NotoSans-Regular.ttf /usr/share/fonts/truetype/vn/VNFont.ttf; \ - elif [ -f /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf ]; then \ - ln -sf /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf /usr/share/fonts/truetype/vn/VNFont.ttf; \ - ln -sf /usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf /usr/share/fonts/truetype/vn/VNFont-Bold.ttf; \ - fi; \ - fc-cache -f -v || true; \ - date > /app/.build_done && \ - echo "[BUILD] step5 done" +EXPOSE 7860 -CMD ["uvicorn", "_run:app", "--host", "0.0.0.0", "--port", "7860"] -# v3.0-vn-font-fix-short-video-2026-07-19 +# CRITICAL: Use app_v2_entry.py which imports ONLY main.py (core APIs) +# This bypasses the entire injection chain (app_run → app_final → ai_runtime_final6) +# Guarantees index_v2.html is served clean without old JS patches +CMD ["uvicorn", "app_v2_entry:app", "--host", "0.0.0.0", "--port", "7860"] diff --git a/README.md b/README.md index f65ec063b0184f66ec5c41d94307c4f38f4c0a38..ebcd71c672947f56bc568d773f5762789310a420 100644 --- a/README.md +++ b/README.md @@ -9,50 +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 - -## 🎬 Short AI — Video từ link (scrap YouTube / TikTok / tin tức) - -Short creator có chế độ **"🔗 Video từ link"**: dán link video YouTube / TikTok / -VnExpress / Dân trí / Znews / 24h... → bấm "Lấy video" để xem trước → tạo short -chạy video + ảnh đã chọn bù phần còn thiếu nếu video ngắn hơn giọng đọc. - -### 🔑 Cài cookies cho YouTube (bỏ chặn "Sign in to confirm you're not a bot") -YouTube đôi khi chặn IP datacenter. Cách khắc phục bằng cookies: - -1. Cài extension trình duyệt **"Get cookies.txt LOCALLY"** (Chrome/Edge) hoặc - **"cookies.txt"** (Firefox). -2. Mở `https://www.youtube.com` (đã đăng nhập) → bấm extension → **Export** → ra file `cookies.txt` (định dạng Netscape). -3. Đưa cookies vào Space bằng **một trong hai cách**: - - **Cách A (khuyến nghị):** Vào Settings của Space - `huggingface.co/spaces/bep40/VNEWS/settings` → **Variables and secrets** → - tạo secret tên **`YT_COOKIES`**, giá trị = toàn bộ nội dung file `cookies.txt`. - - **Cách B:** đặt file `cookies.txt` vào thư mục gốc repo `VNEWS/` và commit - (chú ý: cookies sẽ công khai nếu repo public — ưu tiên Cách A). -4. Rebuild Space (mỗi lần đổi secret phải **Restart** Space). - -Backend tự đọc `YT_COOKIES` (secret) hoặc `/app/cookies.txt`, ghi thành file tạm -và truyền cho yt-dlp qua `cookiefile`. Không cần sửa code. - -> Lưu ý: cookies có hạn (thường vài tuần). Khi hết hạn, export lại và cập nhật secret. \ No newline at end of file +# bep40/vnews + diff --git a/RESTART_TRIGGER.md b/RESTART_TRIGGER.md deleted file mode 100644 index e6c79c75d2068b3dc20f52912afac9b21944b2d0..0000000000000000000000000000000000000000 --- a/RESTART_TRIGGER.md +++ /dev/null @@ -1,7 +0,0 @@ -trigger rebuild v20260827fptwall - -- FPT wall cards now use .wall-thumb 100% width/height (16:9), identical to Short AI cards -- FPT player opens a scrollable vertical feed interleaving FPT Play + Short AI videos, newest-first -- 16:9 <-> 9:16 ratio toggle (🖥️/📺) works on football highlight slides and Tường AI slide player -- Cache-busting: app_v2.js?v=20260827fptwall -- FORCE REBUILD: this commit retriggers the Space container with the latest static/app_v2.js \ No newline at end of file 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 index ab7540db0765711cf34947d481888084e0f87c65..9ddd41c6072ff2a9e13fd503618277a7753f569e 100644 --- a/ai_ext.py +++ b/ai_ext.py @@ -13,16 +13,7 @@ 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 +from main import app # Import wall store from main.py so we read/write the SAME file try: @@ -50,10 +41,6 @@ except ImportError: 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: @@ -73,260 +60,994 @@ except Exception: def _hf_token(): - for k in ("HF_TOKEN", "HUGGINGFACE_HUB_API_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HF_API_TOKEN"): + for k in ("HF_TOKEN", "HUGGINGFACEHUB_API_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HF_API_TOKEN"): v = os.getenv(k, "").strip() if v: return v return "" +HF_TOKEN = _hf_token() +QWEN_VL_MODEL = os.getenv("QWEN_VL_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct") +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 = "" + +# ===== TTS VOICE CONFIG ===== +TTS_VOICES = { + "hoaimy": {"id": "vi-VN-HoaiMyNeural", "gender": "female", "name": "Hoài My", "country": "Việt Nam", "lang": "vi", "flag": "🇻🇳"}, + "namminh": {"id": "vi-VN-NamMinhNeural", "gender": "male", "name": "Nam Minh", "country": "Việt Nam", "lang": "vi", "flag": "🇻🇳"}, + "en_us_f": {"id": "en-US-AvaNeural", "gender": "female", "name": "Ava", "country": "Mỹ", "lang": "en", "flag": "🇺🇸"}, + "en_us_m": {"id": "en-US-BrianNeural", "gender": "male", "name": "Brian", "country": "Mỹ", "lang": "en", "flag": "🇺🇸"}, + "en_gb_f": {"id": "en-GB-SoniaNeural", "gender": "female", "name": "Sonia", "country": "Anh", "lang": "en", "flag": "🇬🇧"}, + "en_gb_m": {"id": "en-GB-RyanNeural", "gender": "male", "name": "Ryan", "country": "Anh", "lang": "en", "flag": "🇬🇧"}, + "zh_cn_f": {"id": "zh-CN-XiaoxiaoNeural", "gender": "female", "name": "Xiaoxiao", "country": "Trung Quốc", "lang": "zh", "flag": "🇨🇳"}, + "zh_cn_m": {"id": "zh-CN-YunyangNeural", "gender": "male", "name": "Yunyang", "country": "Trung Quốc", "lang": "zh", "flag": "🇨🇳"}, + "zh_hk_f": {"id": "zh-HK-HiuGaaiNeural", "gender": "female", "name": "HiuGaai", "country": "Hồng Kông", "lang": "zh", "flag": "🇭🇰"}, + "ja_jp_f": {"id": "ja-JP-NanamiNeural", "gender": "female", "name": "Nanami", "country": "Nhật Bản", "lang": "ja", "flag": "🇯🇵"}, + "ja_jp_m": {"id": "ja-JP-KeitaNeural", "gender": "male", "name": "Keita", "country": "Nhật Bản", "lang": "ja", "flag": "🇯🇵"}, + "ko_kr_f": {"id": "ko-KR-SunHiNeural", "gender": "female", "name": "Sun-Hi", "country": "Hàn Quốc", "lang": "ko", "flag": "🇰🇷"}, + "ko_kr_m": {"id": "ko-KR-InJoonNeural", "gender": "male", "name": "InJoon", "country": "Hàn Quốc", "lang": "ko", "flag": "🇰🇷"}, + "fr_fr_f": {"id": "fr-FR-DeniseNeural", "gender": "female", "name": "Denise", "country": "Pháp", "lang": "fr", "flag": "🇫🇷"}, + "fr_fr_m": {"id": "fr-FR-HenriNeural", "gender": "male", "name": "Henri", "country": "Pháp", "lang": "fr", "flag": "🇫🇷"}, + "de_de_f": {"id": "de-DE-KatjaNeural", "gender": "female", "name": "Katja", "country": "Đức", "lang": "de", "flag": "🇩🇪"}, + "de_de_m": {"id": "de-DE-KillianNeural", "gender": "male", "name": "Killian", "country": "Đức", "lang": "de", "flag": "🇩🇪"}, + "es_es_f": {"id": "es-ES-XimenaNeural", "gender": "female", "name": "Ximena", "country": "Tây Ban Nha", "lang": "es", "flag": "🇪🇸"}, + "es_mx_m": {"id": "es-MX-JorgeNeural", "gender": "male", "name": "Jorge", "country": "Mexico", "lang": "es", "flag": "🇲🇽"}, + "th_th_f": {"id": "th-TH-PremwadeeNeural", "gender": "female", "name": "Premwadee", "country": "Thái Lan", "lang": "th", "flag": "🇹🇭"}, + "th_th_m": {"id": "th-TH-NiwatNeural", "gender": "male", "name": "Niwat", "country": "Thái Lan", "lang": "th", "flag": "🇹🇭"}, + "hi_in_f": {"id": "hi-IN-SwaraNeural", "gender": "female", "name": "Swara", "country": "Ấn Độ", "lang": "hi", "flag": "🇮🇳"}, + "hi_in_m": {"id": "hi-IN-MadhurNeural", "gender": "male", "name": "Madhur", "country": "Ấn Độ", "lang": "hi", "flag": "🇮🇳"}, +} +TTS_DEFAULT_VOICE = "hoaimy" +TTS_DEFAULT_SPEED = 1.2 + +TOPIC_VOICE_MAP = { + "bóng đá": "namminh", "thể thao": "namminh", "world cup": "namminh", + "premier league": "namminh", "champions league": "namminh", "la liga": "namminh", + "serie a": "namminh", "bundesliga": "namminh", "v-league": "namminh", + "tennis": "namminh", "olympic": "namminh", "f1": "namminh", "moto": "namminh", + "sức khỏe": "hoaimy", "làm đẹp": "hoaimy", "giải trí": "hoaimy", + "âm nhạc": "hoaimy", "phim": "hoaimy", "thời trang": "hoaimy", + "ẩm thực": "hoaimy", "du lịch": "hoaimy", "gia đình": "hoaimy", + "tình yêu": "hoaimy", "hôn nhân": "hoaimy", "mẹ và bé": "hoaimy", + "công nghệ": "namminh", "ai": "namminh", "robot": "namminh", + "khoa học": "namminh", "vũ trụ": "namminh", "điện thoại": "namminh", + "laptop": "namminh", "game": "namminh", + "chính trị": "namminh", "kinh tế": "namminh", "tài chính": "namminh", + "chứng khoán": "namminh", "ngân hàng": "namminh", "thị trường": "namminh", + "xã hội": "namminh", "pháp luật": "namminh", "giáo dục": "namminh", +} + + +def _detect_voice_for_topic(title: str, text: str) -> str: + combined = (title + " " + text[:500]).lower() + for keyword, voice_id in TOPIC_VOICE_MAP.items(): + if keyword in combined: + return voice_id + return TTS_DEFAULT_VOICE + +# ===== TEXT HELPERS ===== 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() + return re.sub(r"\s+", " ", s).strip() - -def _domain(url: str) -> str: - """Extract domain from URL.""" +def _domain(u): try: - return urlparse(url or "").netloc.replace("www.", "") + return urlparse(u).netloc.replace("www.", "") except Exception: return "" +def _safe_name(s): + return re.sub(r"[^a-zA-Z0-9_-]+", "_", str(s))[:80] -async def qwen_generate(prompt: str, image_url: str = None, max_tokens: int = 1200) -> str: - """Generate text using Llama/Qwen models via Hugging Face Inference API. - - Prioritizes Llama-3.3-70B for better creative/opinion writing. + +# ===== CLEAN AI OUTPUT — COMPLETE REWRITE ===== +def _clean_ai_output(text: str) -> str: """ - token = _hf_token() - errors = [] - - # Try HF router API with multiple models - Llama FIRST for opinion writing - if token: - models = [ - os.getenv("QWEN_VL_MODEL", ""), - "meta-llama/Llama-3.3-70B-Instruct", # FIRST - best for opinion/analysis - "Qwen/Qwen2.5-VL-7B-Instruct", - "Qwen/Qwen2.5-72B-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à nhà báo phản biện chuyên nghiệp. Luôn viết theo quan điểm cá nhân, phân tích sâu, không sao chép nguyên văn nguồn tin."}, - {"role": "user", "content": user_content}, - ], - "max_tokens": min(int(max_tokens or 2000), 2500), - "temperature": 0.75, - "top_p": 0.9, - } - - 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) + Aggressively clean AI output: + 1. Remove markdown artifacts + 2. Remove instruction leakage / meta-commentary + 3. Split into sentences, deduplicate (exact + near-match) + 4. Trim at complete sentence boundary (no mid-sentence cuts) + 5. Remove consecutive duplicate lines + """ + if not text: + return "" + # --- Step 1: Remove markdown --- + text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE) + text = re.sub(r'\*\*([^*]+)\*\*', r'\1', text) + text = re.sub(r'\*([^*]+)\*', r'\1', text) + text = re.sub(r'^---+\s*$', '', text, flags=re.MULTILINE) + text = re.sub(r'^[-*_]{3,}\s*$', '', text, flags=re.MULTILINE) -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 - sentences = re.split(r"(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])", text) - units = [] - for s in sentences: - s = _clean_text(s) - if len(s) >= 30: - units.append(s) - - if units: - result_units = units[:max_units] - return "\n".join("• " + u for u in result_units) - if text: - 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: + # --- Step 2: Remove instruction leakage (entire lines) --- + leakage_patterns = [ + r'Dưới đây là', r'Theo yêu cầu', r'Tôi sẽ viết', r'Tôi sẽ tóm tắt', + r'Đây là bài', r'Đây là nội dung', r'Bài viết sau đây', + r'Nội dung (tóm tắt|chính)', r'Nhiệm vụ', r'Vai trò', r'Tôi là', + r'Dựa trên.*tôi sẽ', r'Hãy', r'Bạn cần', r'Đọc bài viết', + r'Tôi xin', r'Xin chào', r'Trân trọng', r'Kính thưa', + r'Dựa trên.*dưới đây', r'Sau đây là', r'Dưới đây là bài', + r'Dựa trên.*tôi đã', r'Theo nội dung', r'Tóm tắt như sau', + r'Dưới đây là bài viết', r'Đây là phần', r'Bài tóm tắt', + r'Nội dung tóm tắt', r'Đoạn văn sau', r'Viết lại.*sau', + ] + for phrase in leakage_patterns: + text = re.sub(r'^[^\n]*' + phrase + r'[^\n]*\n?', '', text, flags=re.MULTILINE | re.IGNORECASE) + + # Remove lines that are just meta-instructions + text = re.sub(r'^[^\n]*(?:QUY TẮC|bắt buộc|tối đa|không dùng)[^\n]*\n?', '', text, flags=re.MULTILINE | re.IGNORECASE) + + text = re.sub(r'\n{3,}', '\n\n', text) + text = text.strip() + + if not text: + return "" + + # --- Step 3: Split into sentences & deduplicate --- + # Vietnamese sentences end with . ! ? or end of string + # We split on sentence-ending punctuation followed by space or end + raw_parts = re.split(r'(?<=[.!?])\s+', text.strip()) + + def _norm(s): + """Normalize for comparison: lowercase, collapse whitespace, remove punctuation.""" + s = re.sub(r'\s+', ' ', s.strip().lower()) + return re.sub(r'[^\w\s]', '', s) + + seen_sentences = set() + unique_parts = [] + + for part in raw_parts: + n = _norm(part) + if not n or len(n) < 5: + # Very short fragments — keep them if they're not empty + if part.strip(): + unique_parts.append(part) + continue + + # Check exact match + if n in seen_sentences: + continue + + # Check near-duplicate: substring match + is_dup = False + n_clean = re.sub(r'\s+', '', n) + for seen in list(seen_sentences): + seen_clean = re.sub(r'\s+', '', seen) + if not n_clean or not seen_clean: + continue + # One is substring of the other + if n_clean in seen_clean or seen_clean in n_clean: + shorter = min(len(n_clean), len(seen_clean)) + longer = max(len(n_clean), len(seen_clean)) + if shorter > 15 and shorter / longer > 0.7: + is_dup = True break - if chunks: - return "\n".join("• " + c for c in chunks) - return "• Không có đủ nội dung để tóm tắt." + # High word overlap + else: + words_n = set(n.split()) + words_s = set(seen.split()) + if len(words_n) >= 3 and len(words_s) >= 3: + overlap = len(words_n & words_s) + ratio = overlap / min(len(words_n), len(words_s)) + if ratio > 0.75: + is_dup = True + break -# ===== URL scraping & article processing ===== -HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"} + if is_dup: + continue -try: - _shorts_base = "/data" if os.path.isdir("/data") else os.path.join(os.path.dirname(os.path.abspath(__file__)), "data") -except Exception: - _shorts_base = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data") -SHORTS_DIR = os.path.join(_shorts_base, "ai_shorts") -os.makedirs(SHORTS_DIR, exist_ok=True) + seen_sentences.add(n) + unique_parts.append(part) -import random as _random2 -from datetime import datetime, timezone, timedelta -_VN_TZ = timezone(timedelta(hours=7)) + result = ' '.join(unique_parts).strip() + # --- Step 4: Remove consecutive duplicate lines --- + lines = result.split('\n') + final_lines = [] + prev_line = "" + for line in lines: + stripped = line.strip() + if stripped and stripped == prev_line: + continue + final_lines.append(line) + prev_line = stripped + result = '\n'.join(final_lines).strip() -def _safe_name(filename: str) -> str: - """Sanitize filename.""" - return re.sub(r"[^a-zA-Z0-9_.-]", "_", filename)[:120] + # --- Step 5: Trim at complete sentence boundary --- + # Find the last sentence-ending punctuation and cut there + # This prevents mid-sentence truncation + if result: + # Find all sentence-ending positions + end_matches = list(re.finditer(r'[.!?]\s*$', result)) + if not end_matches: + # No ending punctuation — try to find the last complete sentence + last_end = -1 + for m in re.finditer(r'[.!?](?=\s|$)', result): + last_end = m.end() + if last_end > 0: + result = result[:last_end].strip() + # else: already ends with punctuation, good + # --- Step 6: Final cleanup --- + # Remove any trailing incomplete sentence fragments after last punctuation + # e.g. "Đây là câu 1. Đây là câu 2. Và một" → cut "Và một" + last_period = result.rfind('.') + last_exclaim = result.rfind('!') + last_question = result.rfind('?') + last_end = max(last_period, last_exclaim, last_question) + if last_end > 0: + after = result[last_end + 1:].strip() + # If what follows the last sentence end is a short fragment (< 20 chars, no ending punct), cut it + if after and len(after) < 20 and not re.search(r'[.!?]$', after): + result = result[:last_end + 1].strip() -def pollinations_image_url(topic: str) -> str: - """Generate a placeholder image URL via Pollinations.""" + return result + + +# ===== EXTRACT ALL IMAGES FROM ARTICLE — ENHANCED ===== +def _extract_all_images(soup, base_url: str) -> List[Dict]: + """Extract ALL content images from an article page using multi-strategy approach.""" + images = [] + seen_urls = set() + skip_patterns = [ + "avatar", "icon", "logo", "button", "banner-ad", "tracking", + "beacon", "pixel", "1x1", "spacer", "emoji", "sprite", "placeholder", + "advertisement", "ads", "widget", "sidebar", "footer-logo", + "social", "share", "comment-avatar", "user-avatar", + ] + + def _add_image(src: str, alt: str = "", source_tag: str = "img"): + if not src or src.startswith("data:"): + return + # Normalize URL + src = src.strip() + if src.startswith("//"): + src = "https:" + src + abs_url = urljoin(base_url, src) + if abs_url in seen_urls: + return + # Skip non-content images by URL pattern + if any(p in abs_url.lower() for p in skip_patterns): + return + # Skip very small images (likely icons) + try: + parsed = urlparse(abs_url) + path = parsed.path.lower() + if any(path.endswith(ext) for ext in ['.svg', '.ico']): + return + # Skip if URL contains dimension patterns like 16x16, 32x32 + if re.search(r'/\d{1,3}x\d{1,3}/', path) or re.search(r'-\d{1,3}x\d{1,3}\.', path): + return + except Exception: + pass + seen_urls.add(abs_url) + images.append({"url": abs_url, "alt": alt, "source": source_tag}) + + # Strategy 1: Standard tags with all lazy-load attributes + for img in soup.find_all("img"): + src = (img.get("src") or img.get("data-src") or img.get("data-lazy-src") or + img.get("data-original") or img.get("data-srcset", "").split(",")[0].strip().split(" ")[0]) + _add_image(src, alt=img.get("alt", ""), source_tag="img") + + # Strategy 2: srcset on + for img in soup.find_all("img", srcset=True): + for part in img["srcset"].split(","): + part = part.strip() + if part: + _add_image(part.split(" ")[0], alt=img.get("alt", ""), source_tag="srcset") + + # Strategy 3: with + for picture in soup.find_all("picture"): + for source in picture.find_all("source"): + srcset = source.get("srcset", "") + for part in srcset.split(","): + part = part.strip() + if part: + _add_image(part.split(" ")[0], source_tag="picture/srcset") + fallback_img = picture.find("img") + if fallback_img: + _add_image( + fallback_img.get("src") or fallback_img.get("data-src"), + alt=fallback_img.get("alt", ""), + source_tag="picture/img" + ) + + # Strategy 4: WordPress CMS patterns + for img in soup.find_all("img", class_=re.compile(r"wp-image|size-large|size-full|aligncenter")): + _add_image(img.get("data-src") or img.get("src"), + alt=img.get("alt", ""), source_tag="wp-image") + + # Strategy 5: Background images in style attributes + for tag in soup.find_all(style=re.compile(r"background-image")): + for m in re.findall(r'url\(["\']?(.*?)["\']?\)', tag.get("style", "")): + _add_image(m, source_tag="background-style") + + # Strategy 6: og:image (featured/hero image) + og_image = soup.find("meta", property="og:image") + if og_image and og_image.get("content"): + _add_image(og_image["content"], source_tag="og:image") + + # Strategy 7: twitter:image + tw_image = soup.find("meta", attrs={"name": "twitter:image"}) + if tw_image and tw_image.get("content"): + _add_image(tw_image["content"], source_tag="twitter:image") + + # Strategy 8:
with
+ for figure in soup.find_all("figure"): + img = figure.find("img") + if img: + src = img.get("data-src") or img.get("src") + figcaption = figure.find("figcaption") + alt = figcaption.get_text(strip=True) if figcaption else img.get("alt", "") + _add_image(src, alt=alt, source_tag="figure") + + # Strategy 9: tags linking to images + for a in soup.find_all("a", href=True): + href = a["href"] + if any(href.lower().endswith(ext) for ext in [".jpg", ".jpeg", ".png", ".webp"]): + _add_image(href, alt=a.get_text(strip=True)[:80], source_tag="link") + + # Strategy 10: VnExpress-specific — fig-parent / fig-image containers + for fig_parent in soup.find_all(class_=re.compile(r"fig-parent|fig-image|image|thumb")): + img = fig_parent.find("img") + if img: + src = img.get("data-src") or img.get("src") or img.get("data-original") + _add_image(src, alt=img.get("alt", ""), source_tag="fig-parent") + # Also check for background-image in fig-parent + style = fig_parent.get("style", "") + for m in re.findall(r'url\(["\']?(.*?)["\']?\)', style): + _add_image(m, source_tag="fig-parent-bg") + + # Strategy 11: Dân Trí / VietnamNet — image in .content-detail, .article-body + for container in soup.find_all(class_=re.compile(r"content-detail|article-body|article_content|detail-content|singular-content|fck_detail|knc-content|cms-body|article__body")): + for img in container.find_all("img"): + src = img.get("data-src") or img.get("src") or img.get("data-original") + _add_image(src, alt=img.get("alt", ""), source_tag="content-block-img") + + # Strategy 12: Generic — any inside
or
+ for scope in soup.find_all(["article", "main"]): + for img in scope.find_all("img"): + src = img.get("data-src") or img.get("src") or img.get("data-original") + _add_image(src, alt=img.get("alt", ""), source_tag="article-scope") + + return images + + +# ===== JINA READER ===== +def _reader_url(target_url: str) -> str: + safe = quote(target_url, safe=":/?#[]@!$&'()*+,;=%") + return "https://r.jina.ai/http://" + safe + +def jina_reader_markdown(url: str) -> str: + jr = _reader_url(url) + r = requests.get(jr, headers={"Accept": "text/markdown,text/plain,*/*", "X-Return-Format": "markdown", "User-Agent": "Mozilla/5.0"}, timeout=35) + r.raise_for_status() + return r.text or "" + +def _parse_jina_markdown(md: str, url: str): + lines = [x.rstrip() for x in (md or "").splitlines()] + title = ""; first_image = ""; all_images = []; content_lines = []; in_content = False + for ln in lines: + if ln.startswith("Title:") and not title: + title = _clean_text(ln.replace("Title:", "", 1)); continue + if ln.startswith("URL Source:"): + continue + if ln.startswith("Markdown Content:"): + in_content = True; continue + # Extract ALL images from markdown ![alt](url) + for mimg in re.finditer(r'!\[[^\]]*\]\((https?://[^)]+)\)', ln): + img_url = mimg.group(1) + if img_url not in all_images: + all_images.append(img_url) + if not first_image: + first_image = img_url + if in_content or (title and not ln.startswith("Title:")): + if ln.strip(): + content_lines.append(ln) + text = "\n".join(content_lines) + text = re.sub(r'!\[[^\]]*\]\([^)]+\)', '', text) + paras = [] + for part in re.split(r'\n{2,}|\n(?=#{1,3}\s)', text): + t = _clean_text(re.sub(r'^#{1,6}\s*', '', part)) + if len(t) >= 40: + paras.append(t) + if len(paras) >= 35: + break + if not title and paras: + title = paras[0][:90] + return {"url": url, "title": title or url, "summary": paras[0] if paras else "", + "text": "\n".join(paras), "image": first_image, + "images": all_images, "via": "jina"} + + +# ===== WEB SCRAPE (with full image extraction) ===== +def _best_content_block(soup): + best, best_score = None, 0 + for el in soup.find_all(["article", "main", "section", "div"]): + ps = el.find_all("p") + txt = " ".join(p.get_text(" ", strip=True) for p in ps) + score = len(ps) * 100 + len(txt) + cls = " ".join(el.get("class", [])) + if any(k in cls.lower() for k in ["content", "article", "detail", "body", "post", "entry"]): + score += 800 + if score > best_score: + best, best_score = el, score + return best + +def scrape_any_url_direct(url: str): + r = requests.get(url, headers=HEADERS, timeout=18) + if r.status_code in {401, 403, 406, 409, 429, 451, 503}: + raise RuntimeError(f"blocked status {r.status_code}") + r.encoding = "utf-8" + soup = BeautifulSoup(r.text, "lxml") + for tag in soup.find_all(["script", "style", "nav", "footer", "aside", "form", "noscript"]): + tag.decompose() + + # Title + title = soup.find("h1").get_text(" ", strip=True) if soup.find("h1") else "" + if not title: + ogt = soup.find("meta", property="og:title") or soup.find("meta", attrs={"name": "title"}) + title = ogt.get("content", "") if ogt else (soup.title.get_text(strip=True) if soup.title else "") + + # Summary + desc_tag = soup.find("meta", property="og:description") or soup.find("meta", attrs={"name": "description"}) + summary = desc_tag.get("content", "") if desc_tag else "" + + # Featured image (og:image) + img_tag = soup.find("meta", property="og:image") or soup.find("meta", attrs={"name": "twitter:image"}) + image = img_tag.get("content", "") if img_tag else "" + if image and image.startswith("//"): + image = "https:" + image + + # Extract ALL images from the article + all_images = _extract_all_images(soup, url) + image_urls = [img["url"] for img in all_images] + + # Ensure featured image is first + if image and image not in image_urls: + image_urls.insert(0, image) + elif image in image_urls: + image_urls.remove(image) + image_urls.insert(0, image) + + # Content paragraphs + block = _best_content_block(soup) or soup + paras, seen_p = [], set() + for p in block.find_all("p"): + t = _clean_text(p.get_text(" ", strip=True)) + if len(t) >= 40 and t not in seen_p: + seen_p.add(t) + paras.append(t) + if len(paras) >= 35: + break + + if not title and paras: + title = paras[0][:90] + + return { + "url": url, "title": title or url, "summary": paras[0] if paras else "", + "text": "\n".join(paras), "image": image_urls[0] if image_urls else "", + "images": image_urls, "via": _domain(url) + } + +def scrape_any_url(url: str): + """Try direct scrape first, fall back to Jina Reader. + + KEY FIX: When falling back to Jina, MERGE images from both sources + instead of overwriting. Direct scrape often finds more inline images. + """ + data = scrape_any_url_direct(url) + raw_text = (data.get("summary", "") + "\n" + data.get("text", "")).strip() + direct_images = data.get("images", []) + + if len(raw_text) >= 120: + # Direct scrape got enough content — but still try Jina for more images + # Only use Jina if direct scrape found very few images + if len(direct_images) < 3: + try: + md = jina_reader_markdown(url) + if md: + jr = _parse_jina_markdown(md, url) + if jr.get("images"): + # MERGE: add Jina images that aren't already in direct scrape + existing = set(direct_images) + for img_url in jr["images"]: + if img_url not in existing: + direct_images.append(img_url) + existing.add(img_url) + data["images"] = direct_images + except Exception: + pass + return data + + # Direct scrape didn't get enough content — fall back to Jina try: - return "https://image.pollinations.ai/prompt/" + quote("Vietnamese editorial illustration, " + topic, safe="") + "?width=1024&height=576&nologo=true" + md = jina_reader_markdown(url) + if md: + jr = _parse_jina_markdown(md, url) + if jr.get("text"): + # Keep direct scrape's title/images if Jina's are worse + if data.get("title") and data["title"] != url: + jr["title"] = data["title"] + # MERGE images: direct scrape images + Jina images + merged_images = list(direct_images) # start with direct scrape images + existing = set(merged_images) + for img_url in jr.get("images", []): + if img_url not in existing: + merged_images.append(img_url) + existing.add(img_url) + jr["images"] = merged_images + # Use direct scrape's image as first if available + if data.get("image"): + jr["image"] = data["image"] + jr["via"] = data.get("via", _domain(url)) + " + jina" + return jr except Exception: - return "" + pass + return data + + +# ===== POLLINATIONS IMAGE ===== +def pollinations_image_url(topic: str) -> str: + prompt = "editorial illustration, Vietnamese news, " + topic + return "https://image.pollinations.ai/prompt/" + quote(prompt, safe="") + "?width=1024&height=576&nologo=true" + + +# ===== QWEN AI (strict, concise) ===== +async def qwen_generate(prompt: str, image_url: Optional[str] = None, max_tokens: int = 700, image_urls: Optional[List[str]] = None): + global LAST_QWEN_ERROR, HF_TOKEN + HF_TOKEN = _hf_token() + if not HF_TOKEN: + LAST_QWEN_ERROR = "Không tìm thấy token" + return None + if not AsyncInferenceClient: + LAST_QWEN_ERROR = "Thiếu huggingface_hub" + return None + errors = []; models = [] + for m in [QWEN_VL_MODEL, "Qwen/Qwen2.5-VL-7B-Instruct", "Qwen/Qwen2.5-VL-3B-Instruct"]: + if m and m not in models: + models.append(m) + for model in models: + try: + client = AsyncInferenceClient(provider="auto", api_key=HF_TOKEN, timeout=90) + content = [] + # Collect all images: image_urls list takes priority, fall back to single image_url + all_img_urls = [] + if image_urls: + all_img_urls = image_urls[:6] # max 6 images to avoid context overflow + elif image_url: + all_img_urls = [image_url] + for img_u in all_img_urls: + if img_u and img_u.startswith("http"): + content.append({"type": "image_url", "image_url": {"url": img_u}}) + content.append({"type": "text", "text": prompt}) + messages = [ + {"role": "system", "content": ( + "Bạn là biên tập viên báo điện tử tiếng Việt. " + "NHIỆM VỤ: Chỉ TÓM TẮT nội dung, KHÔNG viết lại bài đầy đủ. " + "QUY TẮC CỨNG: " + "(1) KHÔNG lặp lại bất kỳ nội dung nào — mỗi ý chỉ xuất hiện ĐÚNG 1 LẦN. " + "(2) Nếu 2 câu diễn đạt cùng 1 ý → bỏ cây thứ 2. " + "(3) KHÔNG dùng Markdown (##, **, ---, *). " + "(4) KHÔNG viết 'Dưới đây là', 'Tôi sẽ', 'Theo yêu cầu', 'Nhiệm vụ', 'Vai trò', 'Đây là bài tóm tắt'. " + "(5) KHÔNG bịa thông tin ngoài nguồn. " + "(6) Chỉ viết ĐOẠN VĂN THUẦN, không bullet points. " + "(7) Tối đa 200 từ. Ngắn gọn, súc tích. " + "(8) KHÔNG sao chép nguyên văn từ bài gốc. Viết lại bằng cách diễn đạt khác hoàn toàn." + )}, + {"role": "user", "content": content} + ] + resp = await client.chat_completion(model=model, messages=messages, max_tokens=max_tokens, temperature=0.3, top_p=0.8) + txt = (resp.choices[0].message.content or "").strip() + if txt: + LAST_QWEN_ERROR = "" + return txt + except Exception as e: + errors.append(f"{model}: {type(e).__name__}: {str(e)[:220]}") + LAST_QWEN_ERROR = " | ".join(errors) or "Qwen không trả nội dung." + print("[qwen errors]", LAST_QWEN_ERROR) + return None + + +# ===== TTS GENERATION ===== +async def _generate_tts_edge(text: str, voice_id: str, speed: float, out_path: str): + if edge_tts is None: + raise RuntimeError("edge-tts chưa cài đặt") + voice = TTS_VOICES.get(voice_id, TTS_VOICES[TTS_DEFAULT_VOICE])["id"] + pct = int((speed - 1.0) * 100) + rate = f"+{pct}%" if pct >= 0 else f"{pct}%" + communicate = edge_tts.Communicate(text, voice, rate=rate) + await communicate.save(out_path) + +def _generate_tts_gtts(text: str, out_path: str): + if gTTS is None: + raise RuntimeError("gTTS chưa cài đặt") + gTTS(text, lang="vi").save(out_path) -def _download_image(url: str, fallback_title: str, out_path: str) -> str: - """Download an image from URL or create a placeholder.""" +# ===== SHORT VIDEO GENERATION ===== +def _download_image(url, fallback_topic, out_path): if url: try: r = requests.get(url, headers=HEADERS, timeout=15) - if r.status_code == 200 and len(r.content) > 1200: - os.makedirs(os.path.dirname(out_path), exist_ok=True) + if r.status_code == 200 and len(r.content) > 1000: with open(out_path, "wb") as f: f.write(r.content) return out_path except Exception: pass - # Fallback: create a placeholder image + gen = pollinations_image_url(fallback_topic) try: - from PIL import Image, ImageDraw, ImageFont - img = Image.new("RGB", (1080, 760), (24, 24, 24)) - draw = ImageDraw.Draw(img) - try: - font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 48) - except Exception: - font = None - text = (fallback_title or "VNEWS")[:40] - try: - bbox = draw.textbbox((0, 0), text, font=font) - tw = bbox[2] - bbox[0] - except Exception: - tw = len(text) * 24 - draw.text(((1080 - tw) // 2, 330), text, fill=(255, 255, 255), font=font) - os.makedirs(os.path.dirname(out_path), exist_ok=True) - img.save(out_path, quality=90) - return out_path + r = requests.get(gen, headers=HEADERS, timeout=25) + if r.status_code == 200 and len(r.content) > 1000: + with open(out_path, "wb") as f: + f.write(r.content) + return out_path except Exception: + pass + if Image: + Image.new("RGB", (1080, 860), (30, 55, 42)).save(out_path) return out_path + raise RuntimeError("Không tạo được ảnh") +def _make_short_frame(post, img_path, out_path): + if Image is None: + raise RuntimeError("Pillow chưa sẵn sàng") + W, H = 1080, 1920 + bg = Image.new("RGB", (W, H), (14, 14, 14)) + try: + im = Image.open(img_path).convert("RGB") + target = (1080, 860) + im_ratio = im.width / 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", 52) + font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 40) + font_label = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 32) + except Exception: + font_title = font_body = font_label = None + draw.rectangle((0, 780, W, H), fill=(14, 14, 14)) + draw.text((54, 830), "VNEWS · Tường AI", fill=(92, 184, 122), font=font_label) + title = post.get("title", "") + words = title.split(); lines_t = []; cur = "" + for w in words: + if len(cur) + len(w) + 1 <= 24: + cur = (cur + " " + w).strip() + else: + if cur: lines_t.append(cur) + cur = w + if cur: lines_t.append(cur) + draw.multiline_text((54, 900), "\n".join(lines_t[:3]), fill=(255, 255, 255), font=font_title, spacing=10) + body_text = post.get("text", "") + words_b = body_text.split(); lines_b = []; cur_b = "" + for w in words_b: + if len(cur_b) + len(w) + 1 <= 34: + cur_b = (cur_b + " " + w).strip() + else: + if cur_b: lines_b.append(cur_b) + cur_b = w + if len(lines_b) >= 10: + break + if cur_b and len(lines_b) < 10: lines_b.append(cur_b) + draw.multiline_text((54, 1120), "\n".join(lines_b), fill=(220, 220, 220), font=font_body, spacing=12) + bg.save(out_path, quality=92) -def scrape_any_url(url: str) -> dict: - """Scrape article content from any URL.""" - if not url or not url.startswith("http"): - return {"title": "", "text": "", "summary": "", "image": "", "og_image": "", "via": ""} +def _short_script(post, max_chars=700): + txt = _clean_text(post.get("text", "")) + if len(txt) > max_chars: + # Trim at complete sentence + trimmed = txt[:max_chars] + last_end = -1 + for m in re.finditer(r'[.!?](?=\s|$)', trimmed): + last_end = m.end() + if last_end > 0: + txt = trimmed[:last_end] + else: + txt = trimmed.rsplit(" ", 1)[0] + "." + title = _clean_text(post.get("title", "")) + return f"{title}. {txt}" + +async def _generate_short_video(post, post_id: str, voice_id: str = None, speed: float = None) -> str: try: - r = requests.get(url, headers=HEADERS, timeout=15, allow_redirects=True) - if r.status_code != 200 or not r.text: - return {"title": "", "text": "", "summary": "", "image": "", "og_image": "", "via": _domain(url)} - r.encoding = "utf-8" - soup = BeautifulSoup(r.text, "lxml") - for tag in soup.find_all(["script", "style", "nav", "footer", "aside", "form", "noscript", "iframe", ".ads", ".ad", ".banner-ads", ".fb-comments", ".fb-root", ".social-share", ".related-news", ".breadcrumb"]): - tag.decompose() - title = "" - ogt = soup.find("meta", property="og:title") - if ogt: - title = ogt.get("content", "") - h1 = soup.find("h1") - if not title and h1: - title = h1.get_text(strip=True) - if not title: - t = soup.find("title") - if t: - title = t.get_text(strip=True) - og_image = "" - ogi = soup.find("meta", property="og:image") - if ogi: - og_image = ogi.get("content", "") - if og_image.startswith("//"): - og_image = "https:" + og_image - summary = "" - ogd = soup.find("meta", property="og:description") or soup.find("meta", attrs={"name": "description"}) - if ogd: - summary = ogd.get("content", "")[:500] - body_text = [] - for sel in ["article", ".singular-content", ".detail-content", ".fck_detail", ".content-detail", ".knc-content", "main", ".cms-body", ".article__body", ".post-content", ".entry-content"]: - el = soup.select_one(sel) - if el and len(el.find_all("p")) >= 2: - for p in el.find_all("p"): - t = _clean_text(p.get_text(strip=True)) - if t and len(t) > 30: - body_text.append(t) - break - if not body_text and soup.body: - for p in soup.body.find_all("p"): - t = _clean_text(p.get_text(strip=True)) - if t and len(t) > 30: - body_text.append(t) - text = "\n".join(body_text) - return {"title": _clean_text(title), "text": text, "summary": _clean_text(summary), "image": og_image, "og_image": og_image, "via": _domain(url), "url": url} + os.makedirs(SHORTS_DIR, exist_ok=True) + out_mp4 = os.path.join(SHORTS_DIR, _safe_name(post_id) + ".mp4") + if os.path.exists(out_mp4): + return "/api/ai/short-file/" + post_id + + work = os.path.join(SHORTS_DIR, _safe_name(post_id)) + os.makedirs(work, exist_ok=True) + img_path = os.path.join(work, "image.jpg") + frame_path = os.path.join(work, "frame.jpg") + audio_path = os.path.join(work, "voice.mp3") + + _download_image(post.get("img"), post.get("title", "AI news"), img_path) + _make_short_frame(post, img_path, frame_path) + script = _short_script(post) + + if voice_id is None: + voice_id = _detect_voice_for_topic(post.get("title", ""), post.get("text", "")) + if speed is None: + speed = TTS_DEFAULT_SPEED + + try: + await _generate_tts_edge(script, voice_id, speed, audio_path) + except Exception as e: + print(f"[TTS edge-tts error] {e}, falling back to gTTS") + if gTTS: + _generate_tts_gtts(script, audio_path) + else: + return "" + + cmd = ["ffmpeg", "-y", "-loop", "1", "-i", frame_path, "-i", audio_path, + "-shortest", "-c:v", "libx264", "-tune", "stillimage", "-pix_fmt", "yuv420p", + "-c:a", "aac", "-b:a", "128k", "-vf", "scale=1080:1920", out_mp4] + subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180) + return "/api/ai/short-file/" + post_id except Exception as e: - return {"title": "", "text": "", "summary": "", "image": "", "og_image": "", "via": _domain(url)} + print(f"[short video error] {e}") + return "" -def make_post(title: str, text: str, img: str, url: str, kind: str = "auto", sources: list = None) -> dict: - """Create a wall post dict.""" - import random as _r2 - now = int(time.time() * 1000) +# ===== MAKE POST ===== +def make_post(title, text, image, source_url, kind, sources=None, images=None): return { - "id": str(now) + str(_r2.randint(100, 999)), - "title": (title or "Bài viết")[:200], - "text": (text or "")[:5000], - "img": img or "", - "url": url or "", - "kind": kind or "auto", - "sources": sources or [], - "created": now, - "created_str": datetime.now(_VN_TZ).strftime("%H:%M %d/%m/%Y"), + "id": str(int(time.time() * 1000)) + str(random.randint(100, 999)), + "title": title, "text": text, "img": image, "url": source_url, + "kind": kind, "sources": sources or [], "video": "", + "images": images or [], "ts": int(time.time()) } + + +# ===== SHARED PROMPT BUILDER — STRICT REWRITE ===== +def _build_rewrite_prompt(title: str, raw: str, images: List[str] = None) -> str: + image_info = "" + if images: + num = len(images) + if num == 1: + image_info = "\n\nBài viết có 1 ảnh minh họa. Hãy tham khảo ảnh để hiểu ngữ cảnh (nếu phù hợp)." + else: + image_info = f"\n\nBài viết có {num} ảnh minh họa. Hãy tham khảo tất cả ảnh để hiểu ngữ cảnh và bổ sung thông tin cho bài viết (nếu phù hợp)." + + return f"""Đọc bài viết dưới đây và viết một bài TÓM TẮT NGẮN trên Tường AI. + +⚠️ QUY TẮC CỨNG — VI PHẠM = BỎ QUA: +1. KHÔNG sao chép nguyên văn bất kỳ câu nào từ bài gốc. Phải diễn đạt LẠI hoàn toàn bằng ngôn ngữ riêng. +2. KHÔNG lặp lại bất kỳ thông tin nào. Mỗi ý chỉ xuất hiện ĐÚNG 1 LẦN. +3. Nếu 2 câu diễn đạt cùng 1 ý → chỉ giữ 1 câu, bỏ cây còn lại. +4. KHÔNG dùng Markdown (##, **, ---, *). +5. KHÔNG viết "Dưới đây là", "Tôi sẽ", "Theo yêu cầu", "Nhiệm vụ", "Vai trò", "Đây là bài tóm tắt", "Sau đây là", "Dựa trên". +6. Viết thành ĐOẠN VĂN THUẦN, mạch lạc, dễ đọc. Không dùng bullet points. +7. Giữ sự thật, KHÔNG bịa thông tin. +8. Tối đa 200 từ. Ngắn gọn, đủ ý. +9. Mỗi câu phải trọn vẹn, không bị ngắt giữa chừng.{image_info} + +Tiêu đề gốc: {title} + +Nội dung gốc: +{raw[:14000]} + +Chỉ viết phần tóm tắt, không thêm gì khác:""" + + +def _build_topic_prompt(topic: str, ctx: str) -> str: + return f"""Viết bài TÓM TẮT NGẮN GỌN về chủ đề: "{topic}". + +⚠️ QUY TẮC CỨNG: +1. KHÔNG sao chép nguyên văn từ nguồn. Phải diễn đạt LẠI hoàn toàn. +2. KHÔNG lặp lại bất kỳ nội dung nào. Mỗi thông tin chỉ xuất hiện ĐÚNG 1 LẦN. +3. Nếu 2 câu nói cùng 1 ý → chỉ giữ 1 câu. +4. KHÔNG dùng Markdown (##, **, ---, *). +5. KHÔNG viết "Dưới đây là", "Tôi sẽ", "Theo yêu cầu", "Nhiệm vụ", "Vai trò". +6. Viết thành ĐOẠN VĂN THUẦN, mạch lạc. Không dùng bullet points. +7. Giữ sự thật, KHÔNG bịa. +8. Tối đa 200 từ. Ngắn gọn, đủ ý. +9. Mỗi câu phải trọn vẹn. + +Nguồn thực tế: +{ctx[:12000]} + +Chỉ viết phần tóm tắt, không thêm gì khác:""" + + +# ===== WRITE ENDPOINTS ===== +@app.post("/api/rewrite_share") +async def api_rewrite_share(request: Request): + body = await request.json() + url = _clean_text(body.get("url", "")) + if not url.startswith("http"): + return JSONResponse({"error": "missing url"}, status_code=400) + try: + data = scrape_any_url(url) + except Exception as e: + return JSONResponse({"error": "Không đọc được bài viết: " + str(e)[:180]}, status_code=422) + raw = (data.get("summary", "") + "\n" + data.get("text", "")).strip() + if len(raw) < 60: + return JSONResponse({"error": "Bài viết quá ngắn để tóm tắt"}, status_code=422) + + images = data.get("images", []) + prompt = _build_rewrite_prompt(data.get("title", ""), raw, images) + text = await qwen_generate(prompt, image_urls=images if images else None, max_tokens=700) + if not text: + return JSONResponse({"error": "Qwen2.5-VL chưa sẵn sàng: " + LAST_QWEN_ERROR}, status_code=503) + text = _clean_ai_output(text) + post = make_post(data.get("title") or "Bài viết", text, + images[0] if images else data.get("image", ""), + url, "rewrite", images=images) + + # Auto-generate short video + video_url = await _generate_short_video(post, post["id"]) + if video_url: + post["video"] = video_url + + posts = _load_wall() + posts.insert(0, post) + _save_wall(posts) + return JSONResponse({"post": post}) + + +@app.post("/api/url_wall") +async def api_url_wall(request: Request): + body = await request.json() + url = _clean_text(body.get("url", "")) + if not url.startswith("http"): + return JSONResponse({"error": "missing url"}, status_code=400) + try: + data = scrape_any_url(url) + except Exception as e: + return JSONResponse({"error": "Không scrape được URL: " + str(e)[:180]}, status_code=422) + raw = (data.get("summary", "") + "\n" + data.get("text", "")).strip() + if len(raw) < 60: + return JSONResponse({"error": "URL không có đủ nội dung"}, status_code=422) + + images = data.get("images", []) + prompt = _build_rewrite_prompt(data.get("title", ""), raw, images) + text = await qwen_generate(prompt, image_urls=images if images else None, max_tokens=700) + if not text: + return JSONResponse({"error": "Qwen2.5-VL chưa sẵn sàng: " + LAST_QWEN_ERROR}, status_code=503) + text = _clean_ai_output(text) + post = make_post(data.get("title") or "Bài viết", text, + images[0] if images else data.get("image", ""), + url, "url", images=images) + + video_url = await _generate_short_video(post, post["id"]) + if video_url: + post["video"] = video_url + + posts = _load_wall() + posts.insert(0, post) + _save_wall(posts) + return JSONResponse({"post": post}) + + +@app.post("/api/topic_post") +async def api_topic_post(request: Request): + body = await request.json() + topic = _clean_text(body.get("topic", "")) + if not topic: + return JSONResponse({"error": "missing topic"}, status_code=400) + + ctx = _web_context(topic) + if not ctx: + return JSONResponse({"error": "Không lấy được dữ liệu cho chủ đề này"}, status_code=422) + + image = pollinations_image_url(topic) + prompt = _build_topic_prompt(topic, ctx) + text = await qwen_generate(prompt, image_url=image, max_tokens=700) + if not text: + return JSONResponse({"error": "Qwen2.5-VL chưa sẵn sàng: " + LAST_QWEN_ERROR}, status_code=503) + text = _clean_ai_output(text) + post = make_post(topic, text, image, "", "topic") + + video_url = await _generate_short_video(post, post["id"]) + if video_url: + post["video"] = video_url + + posts = _load_wall() + posts.insert(0, post) + _save_wall(posts) + return JSONResponse({"post": post}) + + +# ===== WALL ENDPOINTS ===== +@app.get("/api/ai_wall") +def api_ai_wall(): + return JSONResponse({"posts": _load_wall()[:80]}) + +@app.get("/api/wall") +def api_wall(): + return JSONResponse({"posts": _load_wall()[:80]}) + + +# ===== SHORT VIDEO ENDPOINT ===== +@app.post("/api/ai/short/{post_id}") +async def api_ai_short(post_id: str, voice: str = Query(default=None), speed: float = Query(default=None)): + posts = _load_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(SHORTS_DIR, exist_ok=True) + out_mp4 = os.path.join(SHORTS_DIR, _safe_name(post_id) + ".mp4") + + if os.path.exists(out_mp4) and voice is None and speed is None: + video_url = "/api/ai/short-file/" + post_id + for i, p in enumerate(posts): + if str(p.get("id")) == str(post_id): + posts[i]["video"] = video_url + break + _save_wall(posts) + return JSONResponse({"video": video_url}) + + if voice is not None and voice not in TTS_VOICES: + return JSONResponse({"error": f"voice không hợp lệ. Chọn: {list(TTS_VOICES.keys())}"}, status_code=400) + + video_url = await _generate_short_video(post, post_id, voice_id=voice, speed=speed) + if video_url: + for i, p in enumerate(posts): + if str(p.get("id")) == str(post_id): + posts[i]["video"] = video_url + break + _save_wall(posts) + return JSONResponse({"video": video_url}) + return JSONResponse({"error": "Không tạo được shorts"}, status_code=500) + + +@app.get("/api/ai/short-file/{post_id}") +def api_ai_short_file(post_id: str): + path = os.path.join(SHORTS_DIR, _safe_name(post_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-{post_id}.mp4") + + +@app.get("/api/ai/status") +def api_ai_status(): + return JSONResponse({ + "has_token": bool(_hf_token()), + "client_imported": AsyncInferenceClient is not None, + "model": QWEN_VL_MODEL, + "last_error": LAST_QWEN_ERROR, + "tts_ready": gTTS is not None or edge_tts is not None, + "tts_engine": "edge-tts" if edge_tts else ("gtts" if gTTS else "none"), + "tts_voices": {k: v["flag"] + " " + v["name"] for k, v in TTS_VOICES.items()}, + "tts_voice_count": len(TTS_VOICES), + "tts_default_speed": TTS_DEFAULT_SPEED, + }) + + +@app.get("/api/ai/voices") +def api_ai_voices(): + voices_out = {} + for k, v in TTS_VOICES.items(): + voices_out[k] = { + "name": v["name"], + "gender": v["gender"], + "country": v["country"], + "lang": v["lang"], + "flag": v["flag"], + "label": f"{v['flag']} {v['name']} ({v['gender']})", + } + return JSONResponse({ + "voices": voices_out, + "default_voice": TTS_DEFAULT_VOICE, + "default_speed": TTS_DEFAULT_SPEED, + "topic_voice_map": TOPIC_VOICE_MAP, + }) diff --git a/ai_fix2.py b/ai_fix2.py index 895be1d7505ed5fedaec5b8f023a52434101989f..b5a201354fe9e23c2294108b52d0ac2e3b06ca8d 100644 --- a/ai_fix2.py +++ b/ai_fix2.py @@ -321,7 +321,7 @@ async def ai_short_full(post_id: str, request: Request): 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) + pr = subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:nokey=1',audio_fast], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20) duration = float((pr.stdout or b'45').decode().strip() or 45) except Exception: pass diff --git a/ai_patch.py b/ai_patch.py index 41aeba3d810744429c14e473c32c3a9fb8e3a605..54e371e6a5d5ede04884ed0b4742ef289ef250ad 100644 --- a/ai_patch.py +++ b/ai_patch.py @@ -6,7 +6,6 @@ import json import html as html_lib import subprocess import requests -import hashlib import ai_ext as base from ai_ext import app from fastapi import Request @@ -42,17 +41,17 @@ def _similar(a, b): return len(ta & tb) / max(1, min(len(ta), len(tb))) >= 0.72 -def _dedupe_units(units, max_units=25): - """Deduplicate units - only skip exact matches to ensure all bullet points are read.""" +def _dedupe_units(units, max_units=7): out, seen = [], set() for u in units: u = _clean(re.sub(r"^[-•*\d\.\)\s]+", "", u)) if len(u) < 18: continue nu = _norm(u) - # Only skip exact matches, NOT similar content (to avoid skipping valid bullet points) if nu in seen: continue + if any(_similar(u, old) for old in out): + continue seen.add(nu) out.append(u) if len(out) >= max_units: @@ -60,7 +59,7 @@ def _dedupe_units(units, max_units=25): return out -def _postprocess_ai_text(text, max_units=20): +def _postprocess_ai_text(text, max_units=7): text = _clean(text) if not text: return text @@ -79,9 +78,10 @@ def _postprocess_ai_text(text, max_units=20): raw_lines.append(line) units = [] for line in raw_lines: - # KEEP FULL bullet point - don't truncate or split into segments - if len(line) >= 18: - units.append(_clean(re.sub(r"^[-•*\d\.\)\s]+", "", line))) + if len(line) > 260: + units.extend(re.split(r"(?<=[\.\!\?])\s+(?=[A-ZÀ-Ỹ0-9])", line)) + else: + units.append(line) units = _dedupe_units(units, max_units=max_units) if not units: return text[:900] @@ -194,6 +194,7 @@ def _topic_source_articles(topic, limit=5): break except Exception: continue + # Fallback to headlines/snippets only if no full body works. if not out: for s in (sources or _direct_news_rss(topic, 6))[:limit]: title = s.get("title") or topic @@ -268,7 +269,7 @@ async def qwen_generate_resilient(prompt: str, image_url=None, max_tokens: int = errors.append("missing HF_TOKEN") base.LAST_QWEN_ERROR = " | ".join(errors[-6:]) or "Qwen unavailable; used extractive fallback" print("[qwen resilient fallback]", base.LAST_QWEN_ERROR) - return _fallback_summary_from_prompt(prompt, max_units=12) + return _fallback_summary_from_prompt(prompt, max_units=6) if not hasattr(base, "_original_qwen_generate"): @@ -321,30 +322,12 @@ Yêu cầu bắt buộc: Nội dung bài: {art['raw'][:14000]}""" - text = await base.qwen_generate(prompt, image_url=art.get('image') or None, max_tokens=1500) - text = _postprocess_ai_text(text, max_units=20) + text = await base.qwen_generate(prompt, image_url=art.get('image') or None, max_tokens=900) + text = _postprocess_ai_text(text, max_units=6) src = [art['source']] if 'Nguồn tham khảo:' not in text: text += "\n\n" + _source_line(src) post = base.make_post(art['title'], text, art.get('image') or base.pollinations_image_url(art['title']), art.get('url') or '', 'topic_article', sources=src) - - # Generate slides for this post so they persist after page reload - try: - page_data = _scrape_article_images(art.get('url', '')) - if page_data and page_data.get('paragraphs'): - key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12) - if key_points: - relevant_imgs = page_data.get('images', []) - if not relevant_imgs and page_data.get('og_img'): - relevant_imgs = [page_data['og_img']] - slides = [] - for i, point in enumerate(key_points): - img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '') - slides.append({'text': point, 'image': img, 'index': i + 1}) - post['slides'] = slides - except Exception: - pass - new_posts.append(post) posts = new_posts + posts base._save_ai_wall(posts) @@ -365,142 +348,14 @@ async def compat_url_wall(request: Request): if len(raw) < 120: return JSONResponse({'error': 'URL không có đủ nội dung để tóm tắt'}, status_code=422) prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url)) - text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=1500) - text = _postprocess_ai_text(text, max_units=20) + text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=850) + text = _postprocess_ai_text(text, max_units=6) src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}] if 'Nguồn tham khảo:' not in text: text += "\n\n" + _source_line(src) post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'url', sources=src) - - # Generate slides so they persist after page reload - slides = [] - try: - page_data = _scrape_article_images(url) - if page_data and page_data.get('paragraphs'): - key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12) - if key_points: - relevant_imgs = page_data.get('images', []) - if not relevant_imgs and page_data.get('og_img'): - relevant_imgs = [page_data['og_img']] - for i, point in enumerate(key_points): - img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '') - slides.append({'text': point, 'image': img, 'index': i + 1}) - except Exception: - pass - post['slides'] = slides - posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts) - return JSONResponse({'post': post, 'slides': slides}) - - -def _is_relevant_image(img_url, title, text): - """Check if an image is relevant to the article content.""" - if not img_url: - return False - skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif', - 'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite', - 'advertisement', 'ad-banner', 'sponsored', 'banner-ads'] - img_lower = img_url.lower() - for p in skip_patterns: - if p in img_lower: - return False - if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']): - return False - return True - - -def _filter_relevant_images(images, title, text, max_images=8): - """Filter and rank images by relevance to article content.""" - if not images: - return [] - seen = set() - relevant = [] - for img in images: - if img in seen: - continue - seen.add(img) - if _is_relevant_image(img, title, text): - relevant.append(img) - return relevant[:max_images] - - -def _extract_key_points_for_slides(paragraphs, max_points=12): - """Extract key points from paragraphs for slides - extracts ALL sentences, not just first one.""" - points = [] - for p in paragraphs: - if len(points) >= max_points: - break - p = _clean(p) - if not p: - continue - # Split paragraph into sentences using Vietnamese + English punctuation - GET ALL SENTENCES - sentences = re.split(r'(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])', p) - sentences = [s.strip() for s in sentences if s.strip()] - - for sentence in sentences: - if len(points) >= max_points: - break - sentence = _clean(sentence) - if len(sentence) < 30: - continue - if any(sentence[:60] in existing for existing in points): - continue - if not sentence.endswith(('.', '!', '?')): - sentence = sentence + '.' - points.append(sentence) - return points - - -def _scrape_article_images(url): - """Scrape article page and return only relevant images.""" - try: - headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", - "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"} - r = requests.get(url, headers=headers, timeout=15, allow_redirects=True) - r.encoding = 'utf-8' - soup = BeautifulSoup(r.text, 'lxml') - for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']): - tag.decompose() - 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 '') - ogi = soup.find('meta', property='og:image') - og_img = ogi.get('content', '') if ogi else '' - if og_img and og_img.startswith('//'): - og_img = 'https:' + og_img - 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 - paragraphs = [] - all_images = [] - seen_imgs = set() - if og_img and og_img not in seen_imgs: - all_images.append(og_img) - seen_imgs.add(og_img) - for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True): - if el.name == 'p': - t = _clean(el.get_text(strip=True)) - if t and len(t) > 40: - paragraphs.append(t) - elif el.name in ('figure', 'img'): - 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 - if src not in seen_imgs: - all_images.append(src) - seen_imgs.add(src) - relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5])) - return {'title': _clean(title), 'paragraphs': paragraphs, 'images': relevant_images, 'og_img': og_img} - except Exception: - return None + return JSONResponse({'post': post}) @app.post('/api/rewrite_share') @@ -517,56 +372,40 @@ async def compat_rewrite_share(request: Request): if len(raw) < 120: return JSONResponse({'error': 'Bài viết không đủ nội dung để tóm tắt'}, status_code=422) prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url)) - text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=1500) - text = _postprocess_ai_text(text, max_units=20) + text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=850) + text = _postprocess_ai_text(text, max_units=6) src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}] if 'Nguồn tham khảo:' not in text: text += "\n\n" + _source_line(src) post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'summary', sources=src) - - # Generate slides with relevant images only - slides = [] - page_data = _scrape_article_images(url) - if page_data and page_data.get('paragraphs'): - key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12) - if key_points: - relevant_imgs = page_data.get('images', []) - if not relevant_imgs and page_data.get('og_img'): - relevant_imgs = [page_data['og_img']] - for i, point in enumerate(key_points): - img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '') - slides.append({'text': point, 'image': img, 'index': i + 1}) - - # FIX: Save slides into post so they persist after page reload - post['slides'] = slides posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts) - - return JSONResponse({'post': post, 'slides': slides}) + return JSONResponse({'post': post}) def _emotion_script(text, emotion): - """Prepend emotion-appropriate prefix to text based on emotion type. - - NOTE: Prefix is NOT added to avoid cluttering Short AI speech. - The emotion is still used for voice selection but content is read cleanly. - """ text = _clean(text) - # REMOVED: No prefix added to keep content clean and natural + if emotion == 'urgent': + return 'Tin nhanh. ' + text + if emotion == 'warm': + return 'Câu chuyện đáng chú ý. ' + text + if emotion == 'serious': + return 'Bản tin nghiêm túc. ' + text + if emotion == 'energetic': + return 'Cập nhật nổi bật. ' + text return text def _tts_script_smart(post, emotion): - raw = base._short_script(post) if hasattr(base, '_short_script') else _clean(post.get('text', '') or post.get('title', '')) + raw = base._short_script(post) raw = re.sub(r"^[•\-\*]\s*", "", raw, flags=re.M) raw = re.sub(r"\s*\n\s*", ". ", raw) raw = re.sub(r"([\.\!\?])\s*", r"\1\n", raw) raw = re.sub(r"\n{2,}", "\n", raw).strip() - # REMOVED: _emotion_script call - read content cleanly without prefix - # INCREASED to 3000 to read full content of all bullet points - if len(raw) > 3000: - raw = raw[:3000] + raw = _emotion_script(raw, emotion) + if len(raw) > 1000: + raw = raw[:1000] cut = max(raw.rfind("."), raw.rfind("!"), raw.rfind("?")) - if cut > 700: + if cut > 350: raw = raw[:cut + 1] return raw @@ -681,7 +520,8 @@ def _make_short_frame_full(post, img_path, out_path): -def _summary_segments_from_post(post, max_segments=25): +def _summary_segments_from_post(post, max_segments=7): + """Only use current post summary. Each bullet/paragraph becomes one short scene.""" raw = _clean(post.get('text') or post.get('title') or '') raw = re.sub(r'^Bản tin AI viết lại:\s*', '', raw, flags=re.I) raw = re.sub(r'Nguồn tham khảo:.*$', '', raw, flags=re.I|re.S).strip() @@ -692,7 +532,7 @@ def _summary_segments_from_post(post, max_segments=25): low=ln.lower() if low.startswith(('điểm chính','tiêu đề','sapo','nguồn tham khảo')): continue if len(ln)>=18: lines.append(ln) - if len(lines)<3: + if len(lines)<2: lines=[] for s in re.split(r'(?<=[\.\!\?])\s+', raw): s=_clean(s) @@ -702,12 +542,14 @@ def _summary_segments_from_post(post, max_segments=25): def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='neutral'): + """Create one 9:16 frame for one summary segment. No subtitle layer.""" if Image is None: return _make_short_frame_full(post, img_path, out_path) W,H=1080,1920 bg=Image.new('RGB',(W,H),(10,10,10)) try: im=Image.open(img_path).convert('RGB') + # background cover ratio=im.width/max(1,im.height); target=W/H if ratio>target: nh=H; nw=int(nh*ratio) @@ -717,6 +559,7 @@ def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='ne cover=cover.crop((left,top,left+W,top+H)) bg.paste(cover,(0,0)) bg=Image.blend(bg, Image.new('RGB',(W,H),(0,0,0)), 0.50) + # top hero image hero_h=720; target=W/hero_h if ratio>target: nh=hero_h; nw=int(nh*ratio) @@ -736,6 +579,7 @@ def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='ne except Exception: font_brand=font_small=font_seg=font_title=None draw.rectangle((0,680,W,H), fill=(12,12,12)) + # progress bars dot_x=48; dot_y=742 for i in range(total): fill=(92,184,122) if i==idx else (70,70,70) @@ -744,8 +588,7 @@ def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='ne draw.rounded_rectangle((48,834,260,880), radius=20, fill=(28,70,45)) draw.text((66,842),f'Đoạn {idx+1}/{total}',fill=(235,235,235),font=font_small) y=940; maxw=W-96 - # INCREASED from 12 to 18 for full content display - each key point can span multiple lines - for ln in _wrap_text_px(draw, segment, font_seg, maxw, 18): + for ln in _wrap_text_px(draw, segment, font_seg, maxw, 8): draw.text((48,y),ln,fill=(255,255,255),font=font_seg) y+=74 if y>1500: break @@ -757,24 +600,24 @@ def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='ne bg.save(out_path, quality=92) -def _estimate_audio_duration(path, fallback=15.0): - """Estimate audio duration with 15s minimum per segment for complete bullet reading.""" +def _estimate_audio_duration(path, fallback=4.0): try: - pr=subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:no_key=1',path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20) - return max(12.0, float((pr.stdout or b'').decode().strip() or fallback)) + pr=subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:nokey=1',path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20) + return max(1.5, float((pr.stdout or b'').decode().strip() or fallback)) except Exception: return fallback @app.post('/api/ai/short/{post_id}') async def patched_ai_short(post_id: str, request: Request): + """Create 9:16 short from separate timed summary segments; no subtitles.""" try: body = await request.json() except Exception: body = {} voice = str(body.get('voice', 'nu')).strip().lower() emotion = str(body.get('emotion', 'neutral')).strip().lower() - speed = float(body.get('speed', 1.0) or 1.0) + speed = float(body.get('speed', 1.2) or 1.2) speed = max(0.85, min(1.35, speed)) posts = base._load_ai_wall() @@ -782,7 +625,7 @@ async def patched_ai_short(post_id: str, request: Request): if not post: return JSONResponse({'error': 'post not found'}, status_code=404) - segments = _summary_segments_from_post(post, max_segments=25) + segments = _summary_segments_from_post(post, max_segments=7) seg_hash = hashlib.md5(('|'.join(segments)+voice+emotion+str(speed)).encode('utf-8')).hexdigest()[:8] os.makedirs(base.SHORTS_DIR, exist_ok=True) suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_{seg_hash}_scenes_nosub" @@ -805,63 +648,11 @@ async def patched_ai_short(post_id: str, request: Request): try: base._download_image(post.get('img'), post.get('title', 'AI news'), img) edge_voice = { - # Vietnamese - 'vi-vn-hoaimyneural': 'vi-VN-HoaiMyNeural', - 'vi-vn-namminhneural': 'vi-VN-NamMinhNeural', - 'hoaimy': 'vi-VN-HoaiMyNeural', - 'namminh': 'vi-VN-NamMinhNeural', 'nam': 'vi-VN-NamMinhNeural', 'male': 'vi-VN-NamMinhNeural', 'nu': 'vi-VN-HoaiMyNeural', 'female': 'vi-VN-HoaiMyNeural', 'mien-nam': 'vi-VN-HoaiMyNeural', - # English - Multilingual - 'en-us-andrewmultilingualneural': 'en-US-AndrewMultilingualNeural', - 'en-au-williammultilingualneural': 'en-AU-WilliamMultilingualNeural', - 'andrew': 'en-US-AndrewMultilingualNeural', - 'en_andrew': 'en-US-AndrewMultilingualNeural', - 'jenny': 'en-US-AndrewMultilingualNeural', - 'en_jenny': 'en-US-AndrewMultilingualNeural', - # Portuguese - Multilingual (ONLY Thalita) - 'pt-br-thalitamultilingualneural': 'pt-BR-ThalitaMultilingualNeural', - 'thalita': 'pt-BR-ThalitaMultilingualNeural', - 'pt_thalita': 'pt-BR-ThalitaMultilingualNeural', - 'pt_br_thalita': 'pt-BR-ThalitaMultilingualNeural', - 'pt': 'pt-BR-ThalitaMultilingualNeural', - 'pt_francisco': 'pt-BR-ThalitaMultilingualNeural', - # French - Multilingual - 'fr-fr-viviennemultilingualneural': 'fr-FR-VivienneMultilingualNeural', - 'fr-fr-remymultilingualneural': 'fr-FR-RemyMultilingualNeural', - 'denise': 'fr-FR-VivienneMultilingualNeural', - 'fr': 'fr-FR-VivienneMultilingualNeural', - 'fr_denise': 'fr-FR-VivienneMultilingualNeural', - # German - Multilingual - 'de-de-seraphinamultilingualneural': 'de-DE-SeraphinaMultilingualNeural', - 'de-de-florianmultilingualneural': 'de-DE-FlorianMultilingualNeural', - 'katja': 'de-DE-SeraphinaMultilingualNeural', - 'de': 'de-DE-SeraphinaMultilingualNeural', - 'de_katja': 'de-DE-SeraphinaMultilingualNeural', - # Korean - Multilingual (Hyunsu, NOT SunHee) - 'ko-kr-hyusumultilingualneural': 'ko-KR-HyunsuMultilingualNeural', - 'ko-kr-hyunsuneural': 'ko-KR-HyunsuMultilingualNeural', - 'sunhee': 'ko-KR-HyunsuMultilingualNeural', - 'ko': 'ko-KR-HyunsuMultilingualNeural', - 'ko_sunhee': 'ko-KR-HyunsuMultilingualNeural', - # Italian - Multilingual - 'it-it-giuseppemultilingualneural': 'it-IT-GiuseppeMultilingualNeural', - # Spanish (keep for backward compat) - 'ela': 'en-US-AndrewMultilingualNeural', - 'es_ela': 'en-US-AndrewMultilingualNeural', - 'es': 'en-US-AndrewMultilingualNeural', - 'es_carlos': 'en-US-AndrewMultilingualNeural', - # Japanese (keep for backward compat) - 'nanami': 'en-US-AndrewMultilingualNeural', - 'ja': 'en-US-AndrewMultilingualNeural', - 'ja_nanami': 'en-US-AndrewMultilingualNeural', - # Chinese (keep for backward compat) - 'xiaochen': 'en-US-AndrewMultilingualNeural', - 'zh': 'en-US-AndrewMultilingualNeural', - 'zh_xiaochen': 'en-US-AndrewMultilingualNeural', }.get(voice, 'vi-VN-HoaiMyNeural') part_files=[] for idx, seg in enumerate(segments): @@ -874,13 +665,14 @@ async def patched_ai_short(post_id: str, request: Request): try: subprocess.run(['python','-m','edge_tts','--voice',edge_voice,'--text',spoken,'--write-media',aud], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120) except Exception: - tld='com.vn' if voice in ('nu','female','mien-nam','hoaimy') else 'com' + tld='com.vn' if voice in ('nu','female','mien-nam') else 'com' try: base.gTTS(spoken, lang='vi', tld=tld, slow=False).save(aud) except TypeError: base.gTTS(spoken, lang='vi', slow=False).save(aud) subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90) - dur=_estimate_audio_duration(aud_fast, fallback=15.0)+0.35 + dur=_estimate_audio_duration(aud_fast, fallback=4.0)+0.35 + # No subtitles: scene text is part of frame only. subprocess.run(['ffmpeg','-y','-loop','1','-t',str(dur),'-i',frame,'-i',aud_fast,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k',part], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=150) part_files.append(part) concat=os.path.join(work,'concat.txt') @@ -915,3 +707,33 @@ def api_ai_shorts(): app.router.routes = [r for r in app.router.routes if not (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set()))] + +PATCH_INJECT = r''' + +

🎬 Đang tạo Short AI

Đang chuẩn bị...
+ +''' + +@app.get('/') +async def index_patched(): + with open('/app/static/index.html','r',encoding='utf-8') as f: + html=f.read() + return HTMLResponse(html.replace('', PATCH_INJECT+'\n')) diff --git a/ai_runtime_final.py b/ai_runtime_final.py index 8dce363f4414b76f00d41b1e4cccc2c2c3809d78..1e23ae3e8836bc9090b91a1bd1899314fdf291b1 100644 --- a/ai_runtime_final.py +++ b/ai_runtime_final.py @@ -297,7 +297,7 @@ async def final_short(post_id:str,request:Request): clips.append(clip) lf=os.path.join(work,'list.txt') with open(lf,'w',encoding='utf-8') as f: - for c in clips:f.write("file '"+c.replace("","'\\''"))+"'\n") + for c in clips:f.write("file '"+c.replace("'","'\\''")+"'\n") subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',lf,'-c','copy',out],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=240) post['video']='/api/ai/short-file/'+post_id+suffix;post['short_subtitles']=False;post['short_segments']=segs;post['short_speed']=speed;base._save_ai_wall(posts) return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False}) @@ -309,7 +309,48 @@ def ai_wall_share(post:str=Query(default=''), short:int=Query(default=0)): if not p:return HTMLResponse(f'') title=p.get('title') or 'VNEWS AI';img=p.get('img') or DEFAULT_IMG desc=(p.get('text') or '')[:220] - return HTMLResponse(f'{title}') + return HTMLResponse(f'''{title}''') FINAL_INJECT = r''' + +''' + +def _load_index_html(): + try: + with open('/app/static/index.html','r',encoding='utf-8') as f:html=f.read() + except Exception:html='' + if '' not in html or '
' in r.text:html=r.text + except Exception:pass + if '' not in html:html='VNEWS
VNEWS
' + return html + +@app.get('/') +async def index_final(): + html=_load_index_html();body=getattr(rt.old,'PATCH_INJECT','') + FINAL_INJECT + return HTMLResponse(html.replace('',body+'\n') if '' in html else html+body) diff --git a/ai_runtime_final6.py b/ai_runtime_final6.py index 4efc9c899924f859f2e644ac3e18c98919615c20..d6dea1ffa21fbbe7346af3e3205cffe20c446a2c 100644 --- a/ai_runtime_final6.py +++ b/ai_runtime_final6.py @@ -840,10 +840,486 @@ FINAL6E_INJECT = """ function escE(s){return String(s||'').replace(/[&<>\"']/g,m=>({'&':'&','<':'<','>':'>','\"':'"',"'":'''}[m]));} window.__topicWallE=[]; function sourceDetailsHtml(p){let arr=p.source_details||[];if(!arr.length)return '';let h='
';return h;} -function renderTopicWallE(){let home=document.getElementById('view-home');if(!home||!window.__topicWallE.length)return;document.getElementById('ai-wall-topic-live')?.remove();let wrap=document.createElement('div');wrap.id='ai-wall-topic-live';wrap.className='ai-wall-topic-live';let h='
🧱 Tường AI mớiTổng hợp từ web
';window.__topicWallE.slice(0,20).forEach((p,i)=>{h+=`
${p.img?``:''}
${escE(p.title)}
${escE(p.text)}
`});h+='
';wrap.innerHTML=h;let comp=document.querySelector('.ai-compose');if(comp)comp.after(wrap);else home.prepend(wrap);} -window.readTopicWallE=function(i){let p=window.__topicWallE[i];if(!p)return;showView('view-article');let imgs=(p.images||[]).filter(Boolean);let gal=imgs.length?'':(p.img?``:'');let srcDetails=sourceDetailsHtml(p);document.getElementById('view-article').innerHTML=`
AI

${escE(p.title)}

${gal}

${escE(p.text)}

${srcDetails}
`;window.scrollTo(0,0)}; -window.createTopicPostFinal5=async function(){let inp=document.getElementById('ai-topic-input-final5');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');let btn=document.getElementById('ai-topic-btn-final5');if(btn){btn.disabled=true;btn.textContent='Đang tìm nguồn...'}try{let src=await fetch('/api/topic_sources?topic='+encodeURIComponent(topic)).then(r=>r.json()).catch(()=>null);if(btn&&src)btn.textContent='Đã tìm '+(src.count||0)+' nguồn, đang tổng hợp...';let r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');window.__topicWallE.unshift(j.post);if(inp)inp.value='';renderTopicWallE();readTopicWallE(0);alert('Đã tạo bài tổng hợp từ nội dung web và đăng lên Tường AI.');}catch(e){alert(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='✨ Tạo bài tổng hợp từ web bằng Qwen'}}}; -setInterval(()=>{document.querySelectorAll('#ai-topic-input-final3,.topic-final3,#ai-topic-input-final4,.topic-final4').forEach(e=>(e.closest('.topic-final3,.topic-final4,.ai-compose-row')||e).remove());let b=document.getElementById('ai-topic-btn-final5');if(b){b.style.display='block';b.textContent='✨ Tạo bài tổng hợp từ web bằng Qwen';}},1200); +function renderTopicWallE(){let home=document.getElementById('view-home');if(!home||!window.__topicWallE.length)return;document.getElementById('ai-wall-topic-live')?.remove();let wrap=document.createElement('div');wrap.id='ai-wall-topic-live';wrap.className='ai-wall-topic-live';let h='
🧱 Tường AI mớiCó nội dung nguồn
';window.__topicWallE.slice(0,20).forEach((p,i)=>{h+=`
${p.img?``:''}
${escE(p.title)}
${escE(p.text)}
`});h+='
';wrap.innerHTML=h;let comp=document.querySelector('.ai-compose');if(comp)comp.after(wrap);else home.prepend(wrap);} +window.readTopicWallE=function(i){let p=window.__topicWallE[i];if(!p)return;showView('view-article');let imgs=(p.images||[]).filter(Boolean);let gal=imgs.length?'':(p.img?``:'');document.getElementById('view-article').innerHTML=`
AI

${escE(p.title)}

${gal}

${escE(p.text)}

${sourceDetailsHtml(p)}
`;window.scrollTo(0,0)}; +window.createTopicPostFinal5=async function(){let inp=document.getElementById('ai-topic-input-final5');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');let btn=document.getElementById('ai-topic-btn-final5');if(btn){btn.disabled=true;btn.textContent='Đang lấy nguồn...'}try{let src=await fetch('/api/topic_sources?topic='+encodeURIComponent(topic)).then(r=>r.json()).catch(()=>null);if(btn&&src)btn.textContent='Đã tìm '+(src.count||0)+' nguồn, đang viết...';let r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');window.__topicWallE.unshift(j.post);if(inp)inp.value='';renderTopicWallE();readTopicWallE(0);}catch(e){alert(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='✨ Tạo bài tổng hợp từ web bằng Qwen'}}}; })(); -''' +""" + +# Override root one last time to append source-details UI. +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_final6_source_details(): + html=f5.f4.f3.f2.f1._load_index_html() + 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+FINAL6_INJECT+globals().get('FINAL6_FAST_HOME_INJECT','')+FINAL6E_INJECT + return HTMLResponse(html.replace('',body+'\n') if '' in html else html+body) + + +# ===== FINAL6F: CLEAN TOPIC OUTPUT + IN-APP SOURCE READER ===== +def _clean_generated_article(text, topic=''): + """Remove prompt/instruction leakage from generated topic articles.""" + text=str(text or '').strip() + bad_patterns=[ + r'^\s*[•\-]*\s*Hãy viết .*', + r'^\s*[•\-]*\s*Dưới đây là .*', + r'^\s*[•\-]*\s*Dữ liệu .*', + r'^\s*[•\-]*\s*NỘI DUNG NGUỒN.*', + r'^\s*[•\-]*\s*Yêu cầu\s*:.*', + r'^\s*[•\-]*\s*Tiêu đề mới.*', + r'^\s*[•\-]*\s*Sapo\s*2.*', + r'^\s*[•\-]*\s*5\s*[-–]\s*8\s*đoạn.*', + r'^\s*[•\-]*\s*Không dùng câu.*', + r'^\s*[•\-]*\s*Cuối bài.*', + r'^\s*[•\-]*\s*Không liệt kê.*', + r'^\s*[•\-]*\s*Tổng hợp thành.*', + r'^\s*[•\-]*\s*Diễn đạt lại.*', + r'^\s*[•\-]*\s*Tuyệt đối.*', + ] + out=[] + for ln in text.splitlines(): + s=ln.strip() + if not s: + out.append(ln);continue + if any(re.search(p,s,re.I) for p in bad_patterns): + continue + out.append(ln) + cleaned='\n'.join(out).strip() + # If model returned a markdown code/prompt-like block, keep content after first plausible title line. + cleaned=re.sub(r'^(?:Bài viết|Nội dung bài viết)\s*[::]\s*','',cleaned,flags=re.I).strip() + # Remove duplicated leading topic instruction if it appears inline. + cleaned=re.sub(r'Hãy viết MỘT BÀI VIẾT HOÀN CHỈNH[^\n\.]*[\.\n]*','',cleaned,flags=re.I).strip() + return cleaned or text + +def _source_article_data(url): + try: + r=requests.get(url,headers=UA,timeout=14);r.encoding='utf-8' + soup=BeautifulSoup(r.text,'lxml') + h1=soup.find('h1') + ogt=soup.find('meta',property='og:title') + ogd=soup.find('meta',property='og:description') + ogi=soup.find('meta',property='og:image') + title=clean(h1.get_text(' ',strip=True) if h1 else (ogt.get('content','') if ogt else '')) + summary=clean(ogd.get('content','') if ogd else '') + img=ogi.get('content','') if ogi else '' + except Exception: + title='';summary='';img='' + text=_scrape_article_text(url,12000) + body=[] + for para in re.split(r'\n+',text or ''): + para=clean(para) + if len(para)>35: + body.append({'type':'p','text':para}) + if len(body)>=80:break + if not title:title=url + if not body and summary:body=[{'type':'p','text':summary}] + return {'title':title,'summary':summary,'og_image':img,'body':body,'source':'topic-source','url':url} + +@app.get('/api/topic_source_article') +def api_topic_source_article(url:str=Query(...)): + if not url.startswith('http'): + return JSONResponse({'error':'bad url'},status_code=400) + return JSONResponse(_source_article_data(url)) + +# Override topic generation one last time with output cleaning and source details. +app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/api/topic_post' and 'POST' in getattr(r,'methods',set()))] + +@app.post('/api/topic_post') +async def topic_post_clean_final(request:Request): + body=await request.json();topic=clean(body.get('topic','')) + if not topic:return JSONResponse({'error':'missing topic'},status_code=400) + img=_topic_image(topic) + research=_fast_context(topic) if '_fast_context' in globals() else _web_research_context(topic) + context=research.get('context','');sources=research.get('sources',[]) + details=_extract_source_details_from_context(context,sources) if '_extract_source_details_from_context' in globals() else [] + if not details: + # Build details directly from sources/snippets if helper unavailable or empty. + for s in sources[:8]: + details.append({'title':s.get('title',''),'url':s.get('url',''),'via':s.get('via',''),'content':s.get('excerpt','') or s.get('snippet','') or ''}) + if not context and not details: + return JSONResponse({'error':'Không tìm/crawl được đủ nội dung về chủ đề này. Hãy thử chủ đề cụ thể hơn hoặc dùng hashtag gợi ý.'},status_code=422) + source_brief='\n\n'.join([f"[{i+1}] {d.get('title','')} ({d.get('via','')})\n{d.get('content','')[:1400]}" for i,d in enumerate(details[:8])]) + prompt=f"""Vai trò: biên tập viên VNEWS. +Nhiệm vụ: viết một bài báo tiếng Việt hoàn chỉnh về chủ đề "{topic}" dựa trên các nguồn bên dưới. + +Nguồn thu thập: +{source_brief[:18000]} + +Quy tắc biên tập: +1. Chỉ xuất bản bài viết cuối cùng, không nhắc lại yêu cầu, không liệt kê chỉ dẫn. +2. Không sao chép nguyên văn; hãy tổng hợp và diễn đạt lại. +3. Bài có tiêu đề, sapo, các đoạn phân tích/bối cảnh/tác động, và mục Nguồn tham khảo ngắn. +4. Không dùng các câu như "Dưới đây là", "Tôi sẽ", "Yêu cầu". +""" + text=None + try: + import asyncio + text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1700),timeout=35) + except Exception: + text=None + if not text or len(text)<350: + bullets='\n'.join([f"• {d.get('title','')}: {d.get('content','')[:320]}" for d in details[:6]]) + vias=', '.join(sorted({d.get('via','') for d in details if d.get('via')})) + text=(f"{topic}: tổng hợp những điểm đáng chú ý\n\n" + f"{topic} đang được nhiều nguồn tin đề cập với các góc nhìn khác nhau. Dựa trên nội dung đã thu thập, có thể rút ra một số điểm chính để người đọc nắm nhanh bối cảnh.\n\n" + f"{bullets}\n\n" + f"Nhìn chung, chủ đề này cần được theo dõi thêm ở các khía cạnh: bối cảnh, tác động thực tế, phản ứng của các bên liên quan và các diễn biến mới trong thời gian tới.\n\n" + f"Nguồn tham khảo: {vias}") + text=_clean_generated_article(text,topic) + post=f5.base.make_post(topic,text,img,'','topic_clean_with_sources',sources=[s for s in sources if s.get('url')]) + post['images']=[img] + post['source_details']=details[:8] + posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts) + return JSONResponse({'post':post,'mode':'clean_with_source_details','sources_count':len(details)}) + +FINAL6F_INJECT = """ + +""" + +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_final6_clean_links(): + html=f5.f4.f3.f2.f1._load_index_html() + 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+FINAL6_INJECT+globals().get('FINAL6_FAST_HOME_INJECT','')+globals().get('FINAL6E_INJECT','')+FINAL6F_INJECT + return HTMLResponse(html.replace('',body+'\n') if '' in html else html+body) + + +# ===== FINAL6G: SELECTED FAST SOURCES ===== +# Restrict hot hashtags + topic articles to the requested sources only: +# Thethaovanhoa, Dantri, VTV, VnExpress, Vatvostudio, GenK, VNReview. +SELECTED_SOURCE_FEEDS=[ + ('VnExpress','https://vnexpress.net/rss/tin-moi-nhat.rss'), + ('VnExpress Thời sự','https://vnexpress.net/rss/thoi-su.rss'), + ('VnExpress Thế giới','https://vnexpress.net/rss/the-gioi.rss'), + ('VnExpress Kinh doanh','https://vnexpress.net/rss/kinh-doanh.rss'), + ('VnExpress Công nghệ','https://vnexpress.net/rss/so-hoa.rss'), + ('VnExpress Thể thao','https://vnexpress.net/rss/the-thao.rss'), + ('Dân trí','https://dantri.com.vn/rss/home.rss'), + ('Dân trí Xã hội','https://dantri.com.vn/rss/xa-hoi.rss'), + ('Dân trí Kinh doanh','https://dantri.com.vn/rss/kinh-doanh.rss'), + ('Dân trí Thể thao','https://dantri.com.vn/rss/the-thao.rss'), + ('Dân trí Công nghệ','https://dantri.com.vn/rss/suc-manh-so.rss'), + ('VTV','https://vtv.vn/rss/trang-chu.rss'), + ('VTV Thời sự','https://vtv.vn/rss/thoi-su.rss'), + ('VTV Công nghệ','https://vtv.vn/rss/cong-nghe.rss'), + ('Thể thao văn hóa','https://thethaovanhoa.vn/rss/home.rss'), + ('Thể thao văn hóa Bóng đá','https://thethaovanhoa.vn/rss/bong-da.rss'), + ('GenK','https://genk.vn/home.rss'), + ('GenK AI','https://genk.vn/ai.rss'), + ('VNReview','https://vnreview.vn/rss/home.rss'), + ('VNReview Công nghệ','https://vnreview.vn/rss/cong-nghe.rss'), +] +SELECTED_HOMEPAGES=[ + ('VTV','https://vtv.vn/'), + ('Thể thao văn hóa','https://thethaovanhoa.vn/'), + ('GenK','https://genk.vn/'), + ('VNReview','https://vnreview.vn/'), + ('Vatvostudio','https://vatvostudio.vn/'), +] +SELECTED_DOMAINS=['vnexpress.net','dantri.com.vn','vtv.vn','thethaovanhoa.vn','genk.vn','vnreview.vn','vatvostudio.vn'] + +def _selected_fetch_rss(feed_name, feed_url, max_items=10): + items=[] + try: + r=requests.get(feed_url,headers=UA,timeout=4);r.encoding='utf-8' + if r.status_code>=400:return [] + soup=BeautifulSoup(r.text,'xml') + for it in soup.find_all('item')[:max_items]: + title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '') + link=clean(it.find('link').get_text(strip=True) if it.find('link') else '') + desc_raw=it.find('description').get_text(' ',strip=True) if it.find('description') else '' + ds=BeautifulSoup(desc_raw,'lxml') + desc=clean(ds.get_text(' ',strip=True)) + if title and link: + items.append({'title':title,'url':link,'source':feed_name,'snippet':desc}) + except Exception:pass + return items + +def _selected_scrape_homepage(name, url, max_items=10): + items=[];seen=set() + try: + r=requests.get(url,headers=UA,timeout=4);r.encoding='utf-8' + if r.status_code>=400:return [] + soup=BeautifulSoup(r.text,'lxml') + base=url.rstrip('/') + for a in soup.find_all('a',href=True): + href=a.get('href','').strip();title=clean(a.get('title','') or a.get_text(' ',strip=True)) + if not href or not title or len(title)<18:continue + if href.startswith('/'): + p=urlparse(url); href=f'{p.scheme}://{p.netloc}{href}' + if not href.startswith('http') or href in seen:continue + dom=_domain(href) + if not any(d in dom for d in SELECTED_DOMAINS):continue + if any(x in href.lower() for x in ['#','javascript:','facebook','youtube','tiktok']):continue + seen.add(href) + items.append({'title':title,'url':href,'source':name,'snippet':''}) + if len(items)>=max_items:break + except Exception:pass + return items + +def _fast_rss_pool(): + now=time.time();key='selected_fast_pool' + if key in _FAST_TOPIC_CACHE and now-_FAST_TOPIC_CACHE[key]['t']<600:return _FAST_TOPIC_CACHE[key]['d'] + pool=[];seen=set() + # RSS first: fast and reliable. + for name,url in SELECTED_SOURCE_FEEDS: + for it in _selected_fetch_rss(name,url,10): + if it['url'] not in seen: + seen.add(it['url']);pool.append(it) + # Homepage fallback for sources with weak/no RSS, especially Vatvostudio. + for name,url in SELECTED_HOMEPAGES: + for it in _selected_scrape_homepage(name,url,10): + if it['url'] not in seen: + seen.add(it['url']);pool.append(it) + _FAST_TOPIC_CACHE[key]={'t':now,'d':pool} + return pool + +def _hot_topics(): + now=time.time() + if _HOT_CACHE['d'] and now-_HOT_CACHE['t']<600:return _HOT_CACHE['d'] + pool=_fast_rss_pool() + freq={};display={} + for it in pool[:220]: + title=re.sub(r'\s+-\s+.*$','',it.get('title','')) + kws=[] + for m in re.findall(r'([A-ZĐÀ-Ỹ][A-Za-zÀ-ỹ0-9]+(?:\s+[A-ZĐÀ-ỸA-Za-zÀ-ỹ0-9][A-Za-zÀ-ỹ0-9]+){1,4})',title): + if len(m)>=6:kws.append(m) + kws+=_keywords_from_title(title) + for kw in kws[:5]: + words=[w for w in clean(kw).split() if w.lower() not in STOP_WORDS] + if len(words)<2:continue + kw=' '.join(words[:5]) + if len(kw)<6 or len(kw)>55:continue + key=kw.lower();freq[key]=freq.get(key,0)+1;display[key]=kw + topics=[];seen=set() + for key,_ in sorted(freq.items(),key=lambda x:x[1],reverse=True): + kw=display[key] + if key in seen:continue + seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw}) + if len(topics)>=24:break + for kw in ['AI tại Việt Nam','Công nghệ Việt Nam','VTV thời sự','VnExpress kinh doanh','Dân trí xã hội','GenK AI','VNReview công nghệ','Vatvostudio smartphone','Thể thao văn hóa World Cup']: + if kw.lower() not in seen:topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw}) + _HOT_CACHE.update({'t':now,'d':topics[:24]}) + return _HOT_CACHE['d'] + +def _candidate_urls(topic): + seen=set();items=[] + scored=[] + for it in _fast_rss_pool(): + sc=_fast_score(topic,it) + if sc>0:scored.append((sc,it)) + for sc,it in sorted(scored,key=lambda x:(x[0],len(x[1].get('snippet',''))),reverse=True)[:14]: + if it['url'] not in seen: + seen.add(it['url']);items.append(it) + # Search only selected sources when RSS lacks matches. + if len(items)<6: + for dom in SELECTED_DOMAINS: + for it in _ddg_search(f'{topic} site:{dom}',4): + if it['url'] not in seen: + seen.add(it['url']);items.append(it) + if len(items)>=12:break + return items[:20] + + +# ===== FINAL6G: SOURCE-LIMITED FAST TOPICS AND FAST HOME ===== +# Limit hot hashtags/topic context to requested sources and make homepage APIs return quickly. +_SOURCE_FEEDS = [ + ('VnExpress','https://vnexpress.net/rss/tin-moi-nhat.rss','vnexpress.net'), + ('VnExpress Thời sự','https://vnexpress.net/rss/thoi-su.rss','vnexpress.net'), + ('VnExpress Thế giới','https://vnexpress.net/rss/the-gioi.rss','vnexpress.net'), + ('VnExpress Kinh doanh','https://vnexpress.net/rss/kinh-doanh.rss','vnexpress.net'), + ('VnExpress Công nghệ','https://vnexpress.net/rss/so-hoa.rss','vnexpress.net'), + ('VnExpress Thể thao','https://vnexpress.net/rss/the-thao.rss','vnexpress.net'), + ('Dân trí','https://dantri.com.vn/rss/home.rss','dantri.com.vn'), + ('Dân trí Xã hội','https://dantri.com.vn/rss/xa-hoi.rss','dantri.com.vn'), + ('Dân trí Kinh doanh','https://dantri.com.vn/rss/kinh-doanh.rss','dantri.com.vn'), + ('Dân trí Thể thao','https://dantri.com.vn/rss/the-thao.rss','dantri.com.vn'), + ('Dân trí Công nghệ','https://dantri.com.vn/rss/suc-manh-so.rss','dantri.com.vn'), + ('VTV','https://vtv.vn/rss/trang-chu.rss','vtv.vn'), + ('VTV Thời sự','https://vtv.vn/rss/thoi-su.rss','vtv.vn'), + ('GenK','https://genk.vn/rss/home.rss','genk.vn'), + ('GenK AI','https://genk.vn/ai.rss','genk.vn'), + ('VnReview','https://vnreview.vn/rss/tin-moi-nhat.rss','vnreview.vn'), + ('VnReview Công nghệ','https://vnreview.vn/rss/cong-nghe.rss','vnreview.vn'), + ('Vật Vờ Studio','https://vatvostudio.vn/feed/','vatvostudio.vn'), + ('Thể thao văn hóa','https://thethaovanhoa.vn/rss/home.rss','thethaovanhoa.vn'), + ('Thể thao văn hóa World Cup','https://thethaovanhoa.vn/rss/world-cup-2026.rss','thethaovanhoa.vn'), +] +_SOURCE_CACHE={'t':0,'items':[]} +_FAST_ROUTE_CACHE={} + +def _feed_items_source_limited(max_per_feed=10): + now=time.time() + if _SOURCE_CACHE['items'] and now-_SOURCE_CACHE['t']<600:return _SOURCE_CACHE['items'] + items=[];seen=set() + def one(feed): + name,url,dom=feed;out=[] + try: + r=requests.get(url,headers=UA,timeout=3.5);r.encoding='utf-8' + soup=BeautifulSoup(r.text,'xml') + for it in soup.find_all('item')[:max_per_feed*2]: + title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '') + link=clean(it.find('link').get_text(strip=True) if it.find('link') else '') + desc_raw=it.find('description').get_text(' ',strip=True) if it.find('description') else '' + ds=BeautifulSoup(desc_raw,'lxml') + img='';im=ds.find('img') + if im:img=im.get('src','') or im.get('data-src','') + desc=clean(ds.get_text(' ',strip=True))[:700] + if title and link: + out.append({'title':title,'url':link,'link':link,'source':name,'via':name,'domain':dom,'snippet':desc,'img':img}) + if len(out)>=max_per_feed:break + except Exception:pass + return out + try: + from concurrent.futures import ThreadPoolExecutor, as_completed + with ThreadPoolExecutor(max_workers=8) as ex: + futs=[ex.submit(one,f) for f in _SOURCE_FEEDS] + for f in as_completed(futs,timeout=5.5): + try: + for it in f.result() or []: + if it['url'] not in seen: + seen.add(it['url']);items.append(it) + except Exception:pass + except Exception: + for f in _SOURCE_FEEDS[:8]: + for it in one(f): + if it['url'] not in seen: + seen.add(it['url']);items.append(it) + _SOURCE_CACHE.update({'t':now,'items':items}) + return items + +def _score_topic_source(topic,it): + toks=[w.lower() for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',topic or '') if len(w)>1 and w.lower() not in STOP_WORDS] + hay=(it.get('title','')+' '+it.get('snippet','')+' '+it.get('source','')).lower() + if not toks:return 0 + score=sum((3 if len(t)>3 else 1) for t in toks if t in hay) + if topic.lower().strip() and topic.lower().strip() in hay:score+=12 + return score + +def _hot_topics(): + now=time.time() + if _HOT_CACHE['d'] and now-_HOT_CACHE['t']<600:return _HOT_CACHE['d'] + freq={};display={} + for it in _feed_items_source_limited(8)[:180]: + title=re.sub(r'\s+-\s+.*$','',it.get('title','')) + kws=[] + for m in re.findall(r'([A-ZĐÀ-Ỹ][A-Za-zÀ-ỹ0-9]+(?:\s+[A-ZĐÀ-ỸA-Za-zÀ-ỹ0-9][A-Za-zÀ-ỹ0-9]+){1,4})',title): + if len(m)>=6:kws.append(m) + kws += _keywords_from_title(title) + for kw in kws[:4]: + words=[w for w in clean(kw).split() if w.lower() not in STOP_WORDS] + if len(words)<2:continue + kw=' '.join(words[:5]) + if 6<=len(kw)<=55: + key=kw.lower();freq[key]=freq.get(key,0)+1;display[key]=kw + topics=[] + for key,_ in sorted(freq.items(),key=lambda x:x[1],reverse=True): + kw=display[key];topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw}) + if len(topics)>=24:break + for kw in ['Giá vàng trong nước','AI tại Việt Nam','Bóng đá Việt Nam','Kinh tế Việt Nam','Công nghệ AI','Vật Vờ Studio','World Cup 2026','Sức khỏe cộng đồng']: + if not any(t['topic'].lower()==kw.lower() for t in topics):topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw}) + _HOT_CACHE.update({'t':now,'d':topics[:24]}) + return _HOT_CACHE['d'] + +def _fast_context(topic): + now=time.time();key='source_limited_ctx:'+topic.lower().strip() + if key in _FAST_TOPIC_CACHE and now-_FAST_TOPIC_CACHE[key]['t']<600:return _FAST_TOPIC_CACHE[key]['d'] + pool=_feed_items_source_limited(12) + scored=[] + for it in pool: + sc=_score_topic_source(topic,it) + if sc>0:scored.append((sc,it)) + if not scored: + # Try broader matching by first token only before giving up. + first=(_fast_topic_tokens(topic) or [''])[0] + if first: + for it in pool: + if first in (it.get('title','')+' '+it.get('snippet','')).lower():scored.append((1,it)) + picked=[it for sc,it in sorted(scored,key=lambda x:(x[0],len(x[1].get('snippet',''))),reverse=True)[:8]] + if not picked:picked=pool[:6] + blocks=[];src=[] + for it in picked: + content=it.get('snippet','') or it.get('title','') + blocks.append(f"NGUỒN: {it.get('source','')}\nTIÊU ĐỀ: {it.get('title','')}\nTÓM TẮT RSS:\n{content}") + src.append({'title':it.get('title',''),'url':it.get('url',''),'via':it.get('source',''),'snippet':content}) + data={'context':'\n\n---\n\n'.join(blocks),'sources':src,'count':len(blocks)} + _FAST_TOPIC_CACHE[key]={'t':now,'d':data} + return data + +# Override slow search functions to never crawl open web during topic generation. +def _web_research_context(topic): + return _fast_context(topic) + +def _candidate_urls(topic): + return _fast_context(topic).get('sources',[]) + +# Fast homepage endpoints from requested source RSS; no slow HTML scrapers. +def _fast_homepage_sources(): + now=time.time();key='home_sources' + if key in _FAST_ROUTE_CACHE and now-_FAST_ROUTE_CACHE[key]['t']<600:return _FAST_ROUTE_CACHE[key]['d'] + groups=[];seen=set() + group_map=[('Tin mới','https://vnexpress.net/rss/tin-moi-nhat.rss','vne'),('Thời Sự','https://vnexpress.net/rss/thoi-su.rss','vne'),('Kinh Doanh','https://vnexpress.net/rss/kinh-doanh.rss','vne'),('Công Nghệ','https://vnexpress.net/rss/so-hoa.rss','vne'),('Dân Trí','https://dantri.com.vn/rss/home.rss','dantri'),('GenK','https://genk.vn/rss/home.rss','genk'),('VnReview','https://vnreview.vn/rss/tin-moi-nhat.rss','vnreview')] + for g,u,s in group_map: + for it in _rss_articles_fast(u,g,s,6) if '_rss_articles_fast' in globals() else []: + if it['link'] not in seen: + seen.add(it['link']);groups.append(it) + _FAST_ROUTE_CACHE[key]={'t':now,'d':groups} + return groups + +for _p in ['/api/homepage','/api/dantri_hot','/api/vne_video','/api/highlights','/api/hot_topics','/api/topic_sources']: + app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)==_p and 'GET' in getattr(r,'methods',set()))] +@app.get('/api/homepage') +def api_homepage_source_fast():return JSONResponse(_fast_homepage_sources()) +@app.get('/api/dantri_hot') +def api_dantri_hot_source_fast(): + data=[{**it,'source':'dantri','link':it.get('url') or it.get('link')} for it in _feed_items_source_limited(8) if it.get('domain')=='dantri.com.vn'][:12] + return JSONResponse(data) +@app.get('/api/vne_video') +def api_vne_video_source_fast(): + return JSONResponse([]) # do not block homepage if VnEgo is slow +@app.get('/api/highlights') +def api_highlights_source_fast():return JSONResponse([]) +@app.get('/api/hot_topics') +def api_hot_topics_source_fast():return JSONResponse({'topics':_hot_topics(),'sources':'vn_only'}) +@app.get('/api/topic_sources') +def api_topic_sources_source_fast(topic:str=Query(...)): + data=_fast_context(clean(topic));return JSONResponse({'count':data.get('count',0),'sources':data.get('sources',[]),'has_context':bool(data.get('context')),'mode':'source_limited_rss'}) + +# Override root: include all existing UI but add a script that prevents forced shorts refresh on initial load. +ROOT_FAST_INJECT=""" + +""" +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_final_fast_sources(): + html=f5.f4.f3.f2.f1._load_index_html() + 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+FINAL6_INJECT+globals().get('FINAL6_FAST_HOME_INJECT','')+globals().get('FINAL6E_INJECT','')+globals().get('FINAL6F_INJECT','')+ROOT_FAST_INJECT + return HTMLResponse(html.replace('',body+'\n') if '' in html else html+body) diff --git a/ai_runtime_fix.py b/ai_runtime_fix.py deleted file mode 100644 index 0ec96f7c3c57d95b682765689d6ed0964058cd85..0000000000000000000000000000000000000000 --- a/ai_runtime_fix.py +++ /dev/null @@ -1,394 +0,0 @@ -"""VNEWS Short Video Fix - standalone module with clean registration. -This module MUST be imported LAST to register /api/ai/short endpoints. -FIX v1: No route filtering issues - registers endpoints unconditionally. -FIX v2: SSE inline endpoint for auto homepage updates -""" -import os -import re -import time -import json -import sys -import logging -import asyncio -import hashlib -import subprocess -import requests -from datetime import datetime, timezone, timedelta -from urllib.parse import urlparse -from fastapi import Request, Query -from fastapi.responses import JSONResponse, FileResponse - -# Import dependencies -try: - import ai_ext as base -except ImportError: - import ai_runtime_final6 as base - -# Try to import app from various sources -try: - from app_v2_entry import app -except ImportError: - try: - from main import app - except ImportError: - from ai_runtime_final6 import app - -_log = logging.getLogger("short_fix") -_log.setLevel(logging.INFO) -if not _log.handlers: - _log.addHandler(logging.StreamHandler(sys.stderr)) - -DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data" -os.makedirs(DATA_DIR, exist_ok=True) -SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts") -os.makedirs(SHORTS_DIR, exist_ok=True) - -# ===== VIETNAMESE FONT DETECTION ===== -_VN_FONT_REG = None -_VN_FONT_BOLD = None - -def _get_vn_fonts(): - """Find Vietnamese-supporting fonts.""" - global _VN_FONT_REG, _VN_FONT_BOLD - if _VN_FONT_REG is not None: - return _VN_FONT_REG, _VN_FONT_BOLD - - try: - from PIL import ImageFont - except Exception: - _log.error("PIL not available!") - return None, None - - # Priority: Noto > DejaVu > Liberation - reg_paths = [ - "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", - "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", - "/usr/share/fonts/truetype/freefont/FreeSans.ttf", - ] - bold_paths = [ - "/usr/share/fonts/truetype/noto/NotoSans-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", - ] - - for path in reg_paths: - if os.path.exists(path): - try: - _VN_FONT_REG = ImageFont.truetype(path, 40) - _log.info(f"Found regular font: {path}") - break - except: - continue - - for path in bold_paths: - if os.path.exists(path): - try: - _VN_FONT_BOLD = ImageFont.truetype(path, 52) - _log.info(f"Found bold font: {path}") - break - except: - continue - - if _VN_FONT_REG is None: - _VN_FONT_REG = ImageFont.load_default() - if _VN_FONT_BOLD is None: - _VN_FONT_BOLD = _VN_FONT_REG - - return _VN_FONT_REG, _VN_FONT_BOLD - - -def _clean(s): - import html as html_lib - return re.sub(r"\s+", " ", html_lib.unescape(str(s or ""))).strip() - - -# ===== ROBUST TEXT SEGMENTATION ===== -def _split_into_segments(text, max_segments=10, min_len=30): - """Split text into segments - multi strategy.""" - text = _clean(text) - if not text: - return [] - - # Strategy 1: bullet points - lines = text.split('\n') - segmented = [] - for line in lines: - line = _clean(line) - line_bare = re.sub(r'^[•\-\*\d\.\)\s]+', '', line).strip() - if len(line_bare) > min_len: - segmented.append(line_bare) - elif len(line) > min_len: - segmented.append(line) - - # Strategy 2: sentences (Vietnamese) - if len(segmented) < 2: - sents = re.split(r'(?<=[.!?])\s+(?=[A-Z0-9À-ỸĐ])', text) - segmented = [s for s in sents if len(_clean(s)) > min_len] - - # Strategy 3: character chunks - if not segmented: - words = text.split() - for i in range(0, min(len(words), max_segments * 20), 20): - chunk = ' '.join(words[i:i+20]) - if len(chunk) > min_len: - segmented.append(chunk) - - # Strategy 4: fallback - if not segmented: - segmented = [text[:300]] - - return segmented[:max_segments] - - -# ===== SHORT VIDEO GENERATOR ===== -def _gen_short_core(post, work_dir): - """Core short generation - returns video path or None.""" - post_id = post.get('id', '') - text = post.get('text', '') or post.get('title', '') - - if not post_id or len(text) < 100: - _log.error(f"Invalid post: id={post_id}, text_len={len(text)}") - return None - - segments = _split_into_segments(text, max_segments=10, min_len=30) - if not segments: - _log.error("No segments generated") - return None - - _log.info(f"Generating short: {len(segments)} segments") - - seg_hash = hashlib.md5(('|'.join(segments) + 'nu').encode()).hexdigest()[:8] - suffix = f"_nu_{seg_hash}" - out_mp4 = os.path.join(work_dir, f"{post_id}{suffix}.mp4") - - if os.path.exists(out_mp4): - _log.info(f"Already exists: {out_mp4}") - return out_mp4 - - # Check dependencies - try: - subprocess.run(['ffmpeg', '-version'], capture_output=True, timeout=5) - except Exception as e: - _log.error(f"ffmpeg missing: {e}") - return None - - # Download image - img_path = os.path.join(work_dir, 'bg.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=12) - 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"Image download: {e}") - - try: - from PIL import Image, ImageDraw - has_pil = True - except: - has_pil = False - _log.warning("PIL not available") - - try: - from gtts import gTTS - has_tts = True - except: - has_tts = False - _log.warning("gTTS not available") - - parts = [] - - for i, seg in enumerate(segments[:10]): - frame = os.path.join(work_dir, f'frame_{i}.jpg') - audio = os.path.join(work_dir, f'audio_{i}.mp3') - part = os.path.join(work_dir, f'part_{i}.mp4') - - # Create frame - try: - if has_pil: - _make_frame(post, seg, img_path, downloaded, frame) - else: - subprocess.run(['ffmpeg', '-y', '-f', 'lavfi', '-i', - 'color=c=black:s=1080x1920:d=1', '-frames:v', '1', frame], - capture_output=True, timeout=20) - except Exception as e: - _log.error(f"Frame error: {e}") - continue - - # Create audio - if has_tts: - try: - tts = _clean(seg)[:300] - gTTS(tts, lang='vi', slow=False).save(audio) - except Exception as e: - _log.warning(f"TTS error: {e}") - audio = None - - # Combine - dur = 10 - try: - cmd = ['ffmpeg', '-y', '-loop', '1', '-t', str(dur), '-i', frame] - if has_tts and os.path.exists(audio): - cmd += ['-i', audio, '-shortest'] - else: - cmd += ['-f', 'lavfi', '-i', 'anullsrc', '-shortest'] - cmd += ['-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p', - '-c:a', 'aac', '-b:a', '128k', part] - subprocess.run(cmd, capture_output=True, timeout=120) - if os.path.exists(part) and os.path.getsize(part) > 5000: - parts.append(part) - except Exception as e: - _log.error(f"Part combine error: {e}") - - if not parts: - _log.error("No video parts created!") - return None - - # Concatenate - try: - concat = os.path.join(work_dir, 'list.txt') - with open(concat, 'w') as f: - for p in parts: - f.write(f"file '{p}'\n") - subprocess.run(['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', concat, '-c', 'copy', out_mp4], - capture_output=True, timeout=180) - _log.info(f"Short created: {out_mp4}") - return out_mp4 - except Exception as e: - _log.error(f"Concat error: {e}") - return None - - -def _make_frame(post, text, img_path, downloaded, out_path): - """Create video frame with Vietnamese font.""" - from PIL import Image, ImageDraw - _get_vn_fonts() - - W, H = 1080, 1920 - bg = Image.new('RGB', (W, H), (15, 23, 38)) - d = ImageDraw.Draw(bg) - - # Background image - if downloaded and os.path.exists(img_path): - try: - im = Image.open(img_path).convert('RGB') - im = im.resize((W, 760)) - bg.paste(im, (0, 0)) - except: - pass - - # Title - d.rectangle([0, 0, W, 100], fill=(25, 118, 210)) - ttl = post.get('title', '')[:50] - if _VN_FONT_BOLD: - d.text((W//2, 50), ttl, fill='white', font=_VN_FONT_BOLD, anchor='mm') - - # Content - y = 150 - for ln in _wrap_text(d, text[:200], _VN_FONT_REG, 80, 920, 10): - d.text((80, y), ln, fill='white', font=_VN_FONT_REG) - y += 55 - - bg.save(out_path, quality=85) - - -def _wrap_text(draw, text, font, x, max_w, max_lines): - """Word wrap text.""" - words = text.split() - lines = [] - cur = [] - for w in words: - test = ' '.join(cur + [w]) - try: - w_px = draw.textbbox((0, 0), test, font=font)[2] - except: - w_px = len(test) * 22 - if w_px <= max_w: - cur.append(w) - else: - if cur: - lines.append(' '.join(cur)) - cur = [w] - if len(lines) >= max_lines: - break - if cur and len(lines) < max_lines: - lines.append(' '.join(cur)) - return lines - - -def _gen_short_sync(post) -> str: - """Sync wrapper - returns video URL.""" - work = os.path.join(SHORTS_DIR, f"work_{post.get('id', int(time.time()))}") - os.makedirs(work, exist_ok=True) - result = _gen_short_core(post, work) - if result: - # Update wall - try: - wall = base._load_ai_wall() - for i, p in enumerate(wall): - if str(p.get('id')) == str(post.get('id')): - p['video'] = f'/api/ai/short-file/{post.get("id")}_nu_{hashlib.md5(str(post).encode()).hexdigest()[:8]}' - wall[i] = p - break - base._save_ai_wall(wall) - # Notify SSE for auto-update - try: - from auto_update_sse import notify_new_short - notify_new_short(post) - except: - pass - except Exception as e: - _log.warning(f"Wall update: {e}") - return result - return '' - - -# ===== REGISTER ENDPOINTS - MUST BE AT MODULE LEVEL ===== -@app.post('/api/ai/short/{post_id}') -async def api_short_generate(post_id: str, request: Request): - _log.info(f"POST /api/ai/short/{post_id}") - wall = base._load_ai_wall() - post = next((p for p in wall if str(p.get('id')) == str(post_id)), None) - if not post: - return JSONResponse({'error': 'Post not found in wall'}, status_code=404) - - if post.get('video'): - return JSONResponse({'post': post, 'video': post['video'], 'status': 'done'}) - - loop = asyncio.get_event_loop() - result = await loop.run_in_executor(None, _gen_short_sync, post) - - if result: - # Get the video URL from wall (updated in _gen_short_sync) - wall = base._load_ai_wall() - post = next((p for p in wall if str(p.get('id')) == str(post_id)), post) - return JSONResponse({'post': post, 'video': post.get('video'), 'status': 'done'}) - return JSONResponse({'error': 'Video generation failed'}, status_code=500) - - -@app.get('/api/ai/short-file/{file_id:path}') -async def api_short_file(file_id: str): - safe = re.sub(r'[^\w\-.]', '_', file_id)[:100] - for fname in os.listdir(SHORTS_DIR) if os.path.isdir(SHORTS_DIR) else []: - if fname.endswith('.mp4') and safe in fname: - return FileResponse(os.path.join(SHORTS_DIR, fname), media_type='video/mp4') - return JSONResponse({'error': 'Not found'}, status_code=404) - - -# ===== SSE ENDPOINT FOR AUTO-UPDATE ===== -try: - from auto_update_sse import sse_events as _sse_handler - app.add_api_route('/api/events', _sse_handler, methods=['GET']) - _log.info("SSE endpoint registered at /api/events") -except Exception as e: - _log.warning(f"SSE route not loaded: {e}") - - -# Log startup -_log.info("Short video endpoints registered") \ No newline at end of file diff --git a/ai_runtime_patch_fast.py b/ai_runtime_patch_fast.py index e08d425c45bba58bd73cb1ed98f7a4dbb7828b43..126cc9a14012fabb8b4f72064810a56a1c663802 100644 --- a/ai_runtime_patch_fast.py +++ b/ai_runtime_patch_fast.py @@ -1,5 +1,5 @@ """Final patch v2: fix topic rewrite, remove duplicate short slide, full short interaction buttons.""" -import re, threading, time, json, os, asyncio, requests +import re, threading, time, json, os, asyncio import ai_runtime_final6 as f6 from ai_runtime_final6 import app, rt, f5, HTMLResponse, JSONResponse, Request, Query import html as html_lib @@ -121,69 +121,6 @@ async def _tp(request:Request): 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}) -# ===== M3U IPTV Channels (FPT Play Bóng Đá live streams) ===== -_M3U_URL = "https://raw.githubusercontent.com/Love4vn/Stalker2M3U/refs/heads/1/live_schedule_Optimize.m3u" -_m3u_cache = {"t": 0, "d": []} - -def _parse_m3u(): - """Fetch & parse the M3U playlist. Returns list of channel dicts.""" - r = requests.get(_M3U_URL, headers={"User-Agent": "Mozilla/5.0"}, timeout=20) - if r.status_code != 200: - return [] - lines = r.text.splitlines() - out = [] - cur = {} - for line in lines: - line = line.strip() - if line.startswith("#EXTINF"): - t_match = re.search(r',(.+)$', line) - cur = { - "title": t_match.group(1).strip() if t_match else "Channel", - "id": "", - "logo": "", - "group": "", - "url": "", - "vlcopts": {}, - } - for attr_match in re.finditer(r'(\w+)="([^"]*)"', line): - k, v = attr_match.group(1), attr_match.group(2) - if k == "tvg-id": - cur["id"] = v - elif k == "tvg-logo": - cur["logo"] = v - elif k == "group-title": - cur["group"] = v - elif line.startswith("#EXTVLCOPT:"): - key_part = line[len("#EXTVLCOPT:"):] - if "=" in key_part: - k, v = key_part.split("=", 1) - cur.setdefault("vlcopts", {})[k.strip()] = v.strip() - elif line and not line.startswith("#") and cur: - cur["url"] = line - out.append(cur) - cur = {} - # Deduplicate by URL (same channel may have multiple stalker entries) - seen = set() - deduped = [] - for c in out: - if c["url"] not in seen: - seen.add(c["url"]) - deduped.append(c) - return deduped - -@app.get('/api/m3u/channels') -def api_m3u_channels(refresh: int = Query(default=0)): - n = int(time.time()) - if not refresh and _m3u_cache["d"] and n - _m3u_cache["t"] < 300: - return JSONResponse({"channels": _m3u_cache["d"], "updated": _m3u_cache["t"]}) - try: - channels = _parse_m3u() - _m3u_cache["t"] = n - _m3u_cache["d"] = channels - return JSONResponse({"channels": channels, "updated": n}) - except Exception as e: - return JSONResponse({"channels": _m3u_cache["d"], "updated": _m3u_cache["t"], "error": str(e)}) - PATCH_INJECT=r'''
''' diff --git a/ai_runtime_patch_final.py b/ai_runtime_patch_final.py deleted file mode 100644 index 0512ad0ef58446a2f791f8f85e0457950cdd54d3..0000000000000000000000000000000000000000 --- a/ai_runtime_patch_final.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Final patch: homepage fix + AI topics at top + SSE auto-update""" -import re, json, time -from fastapi.responses import HTMLResponse, JSONResponse -from fastapi import Query - -# Import chain - must be after ai_runtime_final6 -try: - import ai_runtime_final6 as f6 - from ai_runtime_final6 import app, f5 - from main import rt -except Exception as e: - print(f"[ERROR] f6 import: {e}") - f6 = None - f5 = None - rt = None - -PATCH_CSS_JS = r''' - -
- -''' - -# Register route if possible -if f6 and app: - try: - # Remove duplicate / route to avoid conflict - original_routes = [r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))] - app.router.routes = original_routes - - @app.get('/') - async def patch_homepage(): - html = f5.f4.f3.f2.f1._load_index_html() if f5 else "" - body = "" - if hasattr(rt,'old') and hasattr(rt.old,'PATCH_INJECT'): - body += getattr(rt.old,'PATCH_INJECT','') - if f5: - body += getattr(f5.f4.f3.f2.f1,'FINAL_INJECT','') if hasattr(f5,'f4') else '' - body += getattr(f5.f4.f3,'FINAL3_INJECT','') if hasattr(f5,'f4') else '' - body += getattr(f5.f4,'FINAL4_INJECT','') if hasattr(f5,'f4') else '' - body += getattr(f5,'FINAL5_INJECT','') if hasattr(f5,'f4') else '' - body += getattr(f6,'FINAL6_INJECT','') if f6 else '' - body += getattr(f6,'FINAL6_FAST_HOME_INJECT','') if f6 else '' - body += getattr(f6,'FINAL6E_INJECT','') if f6 else '' - body += PATCH_CSS_JS - if '' in html: - html = html.replace('', body + '\n') - else: - html = html + body - return HTMLResponse(html) - except Exception as e: - print(f"[ERROR] register route: {e}") \ No newline at end of file diff --git a/ai_short_v2.py b/ai_short_v2.py deleted file mode 100644 index 675704f10853a311f3d9ec2f2761012140901c6a..0000000000000000000000000000000000000000 --- a/ai_short_v2.py +++ /dev/null @@ -1,2083 +0,0 @@ -"""VNEWS Short AI v2 — Short creator with TikTok background music, uploaded audio, -uploaded video/image background, and 'recreate from designed slides' (image-only, -reusing previous short audio, NO text overlays) mode. - -Registered by app_v2_entry.py (import ai_short_v2). Uses the SAME wall store -(WALL_FILE) as the wall endpoints so posts created by the designer survive and -can be re-shorted purely from designed images. -""" -import os -import re -import json -import time -import uuid -import hashlib -import subprocess -import threading - -import requests -from urllib.parse import quote as urllib_quote -from fastapi import Request, UploadFile, File, Query -from fastapi.responses import JSONResponse, FileResponse, Response, StreamingResponse - -try: - import yt_dlp -except Exception: # pragma: no cover - yt_dlp = None - -try: - from PIL import Image, ImageDraw, ImageFont -except Exception: # pragma: no cover - Image = ImageDraw = ImageFont = None - -try: - from main import app -except Exception: # pragma: no cover - from fastapi import FastAPI - app = FastAPI() - -DATA_DIR = "/data" if os.path.isdir("/data") else os.path.join(os.path.dirname(os.path.abspath(__file__)), "data") -WALL_FILE = os.path.join(DATA_DIR, "wall_posts.json") -SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts") -UPLOAD_DIR = os.path.join(DATA_DIR, "short_uploads") -WALL_IMG_DIR = os.path.join(DATA_DIR, "wall_imgs") -os.makedirs(SHORTS_DIR, exist_ok=True) -os.makedirs(UPLOAD_DIR, exist_ok=True) - -_wl_lock = threading.Lock() - -UA_HEADERS = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " - "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8", -} - -# --------------------------------------------------------------------------- -# Video scraping (YouTube / TikTok / news sites) via yt-dlp + oEmbed fallback -# --------------------------------------------------------------------------- -_SCRAPE_CACHE = {} # url -> {ok, ...} (in-memory, TTL 30 min) -_SCRAPE_TTL = 30 * 60 - -YTDLP_OPTS = { - "format": "best[height<=720][ext=mp4]/best[height<=720]/best", - "quiet": True, - "no_warnings": True, - "noplaylist": True, - "socket_timeout": 30, - "retries": 5, - "fragment_retries": 5, - "ignoreerrors": False, - "extractor_args": {"youtube": {"player_client": ["tv", "ios", "mweb"]}}, - "http_headers": UA_HEADERS, -} - - -# --------------------------------------------------------------------------- -# YouTube cookies (bypasses the "Sign in to confirm you're not a bot" block). -# Sources, in priority order: -# 1. YT_COOKIES env var (HF Space secret — raw Netscape-format cookies.txt) -# 2. /app/cookies.txt (a cookies.txt file baked into the repo/runtime) -# yt-dlp reads a Netscape cookies.txt via the 'cookiefile' option. -# --------------------------------------------------------------------------- -_cookie_file = None - - -def _ensure_cookies(): - """Materialise a cookies.txt (Netscape format) for yt-dlp if any cookie - source is available. Returns the cookiefile path or None.""" - global _cookie_file - if _cookie_file and os.path.exists(_cookie_file): - return _cookie_file - content = None - # 1. HF Space secret YT_COOKIES (raw cookies.txt content) - secret = os.environ.get("YT_COOKIES", "").strip() - if secret and ("# Netscape" in secret or "#HTTP" in secret or "youtube.com" in secret or "\tTRUE" in secret): - content = secret - # 2. baked-in file - if not content: - for p in ("/app/cookies.txt", "cookies.txt"): - if os.path.exists(p): - content = open(p, encoding="utf-8", errors="ignore").read() - break - if not content: - _cookie_file = None - return None - try: - path = os.path.join(DATA_DIR, "yt_cookies.txt") - os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, "w", encoding="utf-8") as f: - f.write(content) - _cookie_file = path - return path - except Exception: - return None - - -def _ytdlp_opts(): - """YTDLP opts + cookiefile (only when cookies are available).""" - opts = dict(YTDLP_OPTS) - cf = _ensure_cookies() - if cf: - opts["cookiefile"] = cf - # also add "cookiesfrombrowser" fallback? No — datacenter IPs can't read - # a local browser. cookiefile is the reliable path. - return opts - - -def _oembed_probe(url): - """Cheap metadata fallback for TikTok/Facebook/Instagram/YouTube (no direct URL).""" - try: - host = (re.sub(r"^https?://", "", url).split("/")[0] or "").lower() - api = None - if "tiktok" in host: - api = "https://www.tiktok.com/oembed?url=" + urllib_quote(url, safe="") - elif "facebook" in host or "fb.watch" in host: - api = "https://www.facebook.com/plugins/video/oembed.json?url=" + urllib_quote(url, safe="") - elif "youtube.com" in host or "youtu.be" in host: - api = "https://www.youtube.com/oembed?url=" + urllib_quote(url, safe="") + "&format=json" - if not api: - return None - # Facebook oEmbed often times out from datacenter IPs; use a shorter timeout - _timeout = 8 if ("facebook" in host or "fb.watch" in host) else 12 - j = {} - try: - r = requests.get(api, headers=UA_HEADERS, timeout=_timeout) - if r.status_code == 200: - j = r.json() or {} - except Exception: - j = {} - # YouTube: youtube.com/oembed is often SNI-blocked from datacenter IPs. - # Fall back to _yt_meta (noembed.com cascade) which reliably returns - # title+thumbnail+description from restricted networks. - yt_meta = None - if "youtube.com" in host or "youtu.be" in host: - yt_meta = _yt_meta(url) - title = ((j.get("title") or "") or (yt_meta or {}).get("title") or "").strip()[:200] - thumb = ((j.get("thumbnail_url") or "") or (yt_meta or {}).get("thumbnail") or "").strip() - desc = (yt_meta or {}).get("description") or "" - if "youtube.com" in host or "youtu.be" in host: - if not desc: - desc = (yt_meta or {}).get("description") or "" - embed_url = _oembed_embed_url(url) - # oEmbed providers (notably TikTok short links like vm.tiktok.com) don't - # expose the video ID in the URL, but the `html` field contains the - # canonical `:isHLS?``:``;const videoId='hl-'+league+'-'+(v.id||v._idx);h+=buildTikTokSlide({vtag,title:v.title,badge:'HL',badgeClass:'badge-fpt',videoId,idx:i,total:ordered.length,shareUrl:v.link||'',extraBtn:``});});h+='
';el.innerHTML=h;initTikTokFeed();} +async function openYTShortsFeed(startIdx){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='
Đang tải...
';const arts=_shortsData.length?_shortsData:await fetch('/api/shorts').then(r=>r.json()).catch(()=>[]);if(!arts.length){el.innerHTML='
Không có shorts
';return}const ordered=startIdx>0?[...arts.slice(startIdx),...arts.slice(0,startIdx)]:arts;let h=`
`;ordered.forEach((v,i)=>{const id=v.id||'';const src=`https://www.youtube.com/embed/${id}?autoplay=1&rel=0&playsinline=1`;const vtag=``;const badge=v.channel==='baosuckhoedoisongboyte'?'SKĐS':'Dân trí';const videoId='yt-'+id;h+=buildTikTokSlide({vtag,title:v.title,badge,badgeClass:'badge-fpt',videoId,idx:i,total:ordered.length,shareUrl:'https://youtube.com/watch?v='+id});});h+='
';el.innerHTML=h;initTikTokFeed();} +async function openShortAIFeed(startIdx){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='
Đang tải...
';const wall=(await fetch('/api/wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];const vids=wall.filter(p=>p.video);if(!vids.length){el.innerHTML='
Chưa có Short AI
';return}const ordered=startIdx>0?[...vids.slice(startIdx),...vids.slice(0,startIdx)]:vids;let h=`
`;ordered.forEach((p,i)=>{const vtag=``;const videoId='ai-'+(p.id||i);h+=buildTikTokSlide({vtag,title:p.title,badge:'AI',badgeClass:'badge-ai',videoId,idx:i,total:ordered.length,shareUrl:SPACE});});h+='
';el.innerHTML=h;initTikTokFeed();} +async function readArticle(url){showView('view-article');const el=document.getElementById('view-article');el.innerHTML='
Đang tải...
';try{const r=await fetch('/api/article?url='+encodeURIComponent(url));const data=await r.json();if(data&&!data.error&&data.body&&data.body.length){_currentArticle={url,data};let h=`

${esc(data.title)}

`;if(data.summary)h+=`
${esc(data.summary)}
`;const seen={};data.body.forEach(b=>{if(b.type==='p')h+=`

${b.text}

`;else if(b.type==='img'&&b.src&&!seen[b.src]){seen[b.src]=1;h+=``}else if(b.type==='heading')h+=`

${esc(b.text)}

`});h+=`

🤖 Hỏi AI

`;el.innerHTML=h;window.scrollTo(0,0);return;}}catch(e){}el.innerHTML=`

Không đọc được.

Mở gốc →
`;} +async function rewriteArticle(){const url=_currentArticle?.url;if(!url)return;toast('⏳ Đang rewrite...');try{const r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,context:document.querySelector('.article-view')?.innerText?.slice(0,14000)||''})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error);toast('✅ Đã đăng Tường AI!');if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message)}} +async function rewriteUrl(){const url=document.getElementById('url-input')?.value.trim();if(!url)return alert('Dán URL');toast('⏳ Đang rewrite...');try{const r=await fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error);toast('✅ Đã đăng!');document.getElementById('url-input').value='';if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message)}} +async function askAI(){const q=document.getElementById('ask-q')?.value.trim();if(!q)return alert('Nhập câu hỏi');const a=document.getElementById('ask-a');a.textContent='Đang hỏi...';try{const r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:_currentArticle?.url||'',question:q,context:document.querySelector('.article-view')?.innerText?.slice(0,12000)||''})});const j=await r.json();a.textContent=j.answer||'Không trả lời được';}catch(e){a.textContent='Lỗi: '+e.message}} +async function readWallPost(i){const p=_wallPosts[i];if(!p)return;showView('view-article'); + // Build image gallery HTML + const images = p.images || []; + let imgGallery = ''; + if(images.length > 0){ + imgGallery = '
'; + images.forEach((imgUrl, idx) => { + if(idx === 0){ + imgGallery += ``; + } else { + if(idx === 1) imgGallery += ''; + imgGallery += '
'; + } + const hasVideo = p.video && p.video.length > 0; + const voiceOptions = [ + {id:'hoaimy', label:'🎙️ Nữ — Hoài My'}, + {id:'namminh', label:'🎙️ Nam — Nam Minh'}, + ]; + let voiceSelector = ''; + if(!hasVideo){ + voiceSelector = `
🎙️ Chọn giọng đọc:
`; + voiceOptions.forEach(v=>{ + voiceSelector += ``; + }); + voiceSelector += `
Tốc độ:
`; + voiceSelector += `
`; + } + document.getElementById('view-article').innerHTML=`
AI

${esc(p.title)}

${imgGallery}

${esc(p.text)}

${hasVideo?``:''}
${hasVideo?`${voiceSelector}`:`${voiceSelector}`}
`; + const firstVoiceBtn = document.querySelector('.tts-voice-btn'); + if(firstVoiceBtn) firstVoiceBtn.classList.add('active'); + window.scrollTo(0,0)} +async function loadNewsTab(){const el=document.getElementById('view-cat');el.innerHTML='
Đang tải...
';try{const r=await fetch('/api/homepage');const news=await r.json();if(!news.length){el.innerHTML='
Không có tin
';return}const groups={};news.forEach(a=>{if(!groups[a.group])groups[a.group]=[];groups[a.group].push(a)});let h='';for(const[g,arts] of Object.entries(groups)){h+=`
${g}
`;arts.slice(0,6).forEach(a=>{h+=`
${a.img?``:''}
${esc(a.source||'VnE')}
${esc(a.title)}
`});h+='
'}el.innerHTML=h}catch(e){el.innerHTML='
Lỗi
'}} +async function loadCat(id){const el=document.getElementById('view-cat');el.innerHTML='
Đang tải...
';const arts=await fetch('/api/category/'+id).then(r=>r.json()).catch(()=>[]);if(!arts.length){el.innerHTML='
Không có tin
';return}let h='
';arts.forEach(a=>{h+=`
${a.img?``:''}
${esc(a.source||'')}
${esc(a.title)}
`});h+='
';el.innerHTML=h} +fetch('/api/storage_status').then(r=>r.json()).then(j=>{if(!j.persistent){const home=document.getElementById('view-home');if(home){const w=document.createElement('div');w.className='storage-warn';w.innerHTML='⚠️ Persistent Storage chưa bật.';home.prepend(w)}}}).catch(()=>{}); \ No newline at end of file diff --git a/app_main.py b/app_main.py index e6e3ac68305763476ac9a11084015e1f7a4878e9..fcd6c1d1adec9c97f078f2180388d89367be02a2 100644 --- a/app_main.py +++ b/app_main.py @@ -1,7 +1,7 @@ """VNEWS v2 - Clean frontend. CRITICAL: removes ALL old routes before registering new ones.""" from app_run import * from app_run import app, f5, f6, rt, PATCH_INJECT, UNIFIED_INJECT_FIXED, HIGHLIGHT_FULL_OVERRIDE, EXTRA_WALL_FIX, FAST_HASHTAG_JS -from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response +from fastapi.responses import HTMLResponse, JSONResponse, FileResponse from fastapi.staticfiles import StaticFiles from fastapi import Query, Request import requests as req @@ -67,17 +67,25 @@ def _search_all(topic, limit=40): if i.get('url') and i['url'] not in seen:seen.add(i['url']);unique.append(i) return unique[:limit] -# Remove old routes +# ============================================================ +# AGGRESSIVELY remove ALL old '/' routes from entire chain +# This ensures our new '/' route is the ONLY one serving +# ============================================================ +# Remove from app.router.routes (APIRoute objects) app.router.routes = [r for r in app.router.routes if not ( (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set())) or (getattr(r, 'path', None) == '/api/hashtag/sources' and 'GET' in getattr(r, 'methods', set())) or (getattr(r, 'path', None) in ('/api/short/comments', '/api/short/comment')) )] +# Also remove from app.routes (includes mounts) app.routes[:] = [r for r in app.routes if not ( - hasattr(r, 'path') and getattr(r, 'path', None) == '/' and + hasattr(r, 'path') and getattr(r, 'path', None) == '/' and hasattr(r, 'methods') and 'GET' in getattr(r, 'methods', set()) )] +# ============================================================ +# DEFINE ALL NEW ROUTES +# ============================================================ STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static') @app.get('/api/hashtag/sources') @@ -98,56 +106,9 @@ def _storage():return JSONResponse({'persistent':os.path.isdir('/data') and os.a async def _share(url:str='',title:str='',img:str=''): return HTMLResponse(f'Redirecting...') -@app.get('/api/proxy/page') -def proxy_page(url: str = Query(...)): - try: - r = req.get(url, headers={'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36','Accept-Language':'vi-VN,vi;q=0.9','Referer':'https://hd.xemtv.net/'}, timeout=15) - return HTMLResponse(content=r.text) - except: - return HTMLResponse(content='', status_code=502) - -@app.get('/api/proxy/hls') -def proxy_hls(url: str = Query(...)): - try: - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Accept': '*/*', - 'Accept-Language': 'vi-VN,vi;q=0.9', - 'Referer': 'https://fptplay.vn/', - 'Origin': 'https://fptplay.vn', - } - r = req.get(url, headers=headers, timeout=15) - content_type = r.headers.get('Content-Type', 'application/vnd.apple.mpegurl') - text = r.text - base_url = url.rsplit('/', 1)[0] + '/' - def _rewrite_url(m): - seg_url = m.group(0) - if seg_url.startswith('http'): - return '/api/proxy/seg?url=' + quote(seg_url, safe='') - elif seg_url.startswith('/'): - return '/api/proxy/seg?url=' + quote(base_url.rsplit('/', 2)[0] + seg_url, safe='') - else: - return '/api/proxy/seg?url=' + quote(base_url + seg_url, safe='') - text = re.sub(r'https?://[^\s"\'<>]+\.(ts|m3u8)[^\s"\'<>]*', _rewrite_url, text) - return HTMLResponse(content=text, media_type=content_type) - except: - return HTMLResponse(content='', status_code=502) - -@app.get('/api/proxy/seg') -def proxy_seg(url: str = Query(...)): - try: - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - 'Referer': 'https://fptplay.vn/', - 'Origin': 'https://fptplay.vn', - } - r = req.get(url, headers=headers, timeout=15) - content_type = r.headers.get('Content-Type', 'video/MP2T') - return Response(content=r.content, media_type=content_type) - except: - return Response(content=b'', status_code=502) - -# Interactions +# ============================================================ +# INTERACTIONS + COMMENTS +# ============================================================ DATA_DIR = '/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data') os.makedirs(DATA_DIR, exist_ok=True) INTERACTIONS_FILE = os.path.join(DATA_DIR, 'interactions_v2.json') @@ -199,7 +160,9 @@ async def api_post_comment(request:Request): idb[vid]['comments']=len(comments);_save_json(INTERACTIONS_FILE,idb) return JSONResponse({'comments':comments}) -# World Cup 2026 API +# ============================================================ +# WORLD CUP 2026 API +# ============================================================ from wc2026_scraper import ( scrape_summary, scrape_fixtures, scrape_standings, scrape_stats, scrape_wc_news, scrape_road_to_wc, get_wc2026_all, @@ -229,41 +192,6 @@ def api_wc2026_lineups(event_id:int):return JSONResponse(scrape_lineups(event_id @app.get('/api/wc2026/match/{event_id}') def api_wc2026_match(event_id:int):return JSONResponse(scrape_match_detail(event_id)) -# Match Detail API (for any match from bongda.com.vn) -from match_detail import fetch_match_detail, fetch_match_detail_by_url, _bongda_api - -@app.get('/api/match/{event_id}/detail') -def api_match_detail(event_id: int, url: str = Query(default=None)): - """Get complete match detail. Optional 'url' param with full bongda URL (with slug) for HTML scraping.""" - if url: - return JSONResponse(fetch_match_detail_by_url(url)) - return JSONResponse(fetch_match_detail(event_id)) - -@app.get('/api/match/{event_id}/commentaries') -def api_match_commentaries(event_id: int): - """Get match commentaries from bongda API.""" - comm = _bongda_api("/api/fixtures/commentaries", {"event_id": event_id}) - if comm and comm.get("status") == "success": - html = comm.get("html", "") - if html and len(html.strip()) > 10: - return JSONResponse({"html": html}) - return JSONResponse({"html": ""}) - -@app.get('/api/match/{event_id}/stats') -def api_match_stats(event_id: int): - """Get match player performance stats from bongda API.""" - perf = _bongda_api("/api/event-standing/player-performance", {"event_id": event_id}) - if perf and perf.get("status") == "success": - html = perf.get("html", "") - if html and len(html.strip()) > 10: - return JSONResponse({"html": html}) - return JSONResponse({"html": ""}) - -@app.get('/api/match/detail') -def api_match_detail_by_url(url: str = Query(...)): - """Get match detail by full bongda.com.vn URL.""" - return JSONResponse(fetch_match_detail_by_url(url)) - def _wc2026_bg_refresh(): time.sleep(10) while True: @@ -272,12 +200,18 @@ def _wc2026_bg_refresh(): time.sleep(90) threading.Thread(target=_wc2026_bg_refresh,daemon=True).start() -# Serve frontend +# ============================================================ +# SERVE V2 FRONTEND - THIS MUST BE THE ONLY '/' ROUTE +# ============================================================ @app.get('/') async def _index_v2(): + """Serve clean v2 frontend from static/index_v2.html""" index_path = os.path.join(STATIC_DIR, 'index_v2.html') if os.path.exists(index_path): return FileResponse(index_path, media_type='text/html') return HTMLResponse('

VNEWS v2

index_v2.html not found

') +# ============================================================ +# MOUNT STATIC FILES LAST (must be after all route definitions) +# ============================================================ app.mount('/static', StaticFiles(directory=STATIC_DIR), name='vnews_static') diff --git a/app_v2.js b/app_v2.js deleted file mode 100644 index 9bef70162aec2611a1d80653fc752c0cafd93a83..0000000000000000000000000000000000000000 --- a/app_v2.js +++ /dev/null @@ -1,1669 +0,0 @@ -/** - * VNEWS Frontend v2 - Shorts Dantri/SKDS removed, VTV Digital CDN - * v2.8 - Changed short AI feed video share button icon (📥) to distinguish from article share (📤) - * v2.6 - Fixed share links: doShare now includes post_id for wall posts, - * readSlidePost has share button, /s + /s/{slug} render slides/video inline - * v2.5 - Added 'Tạo lại' button on wall cards with video - * v2.4 - Fixed: prependWallPost detached-element bug, makeShortVideo UI update, slide viewer for rewrite posts - */ -// ---- World Cup 2026 tab switcher (was missing -> loadHome() crashed) ---- -function switchWCTab(tab){ - try { - var el = document.getElementById('wc-content'); - if(!el) return; - var d = (typeof _wc2026Data !== 'undefined') ? _wc2026Data : null; - if(!d){ el.innerHTML = '
Không có dữ liệu World Cup 2026
'; return; } - if(tab === 'fixtures'){ - var m = (d.fixtures && d.fixtures.matches) || []; - var h = '
'; - m.slice(0, 40).forEach(function(x){ - h += '
'+esc(x.group||'')+''+esc(x.home||'?')+' '+esc(x.score||'VS')+' '+esc(x.away||'?')+''+esc(x.date_vn||'')+'
'; - }); - h += '
'; - el.innerHTML = m.length ? h : '
Chưa có lịch thi đấu
'; - } else if(tab === 'standings'){ - el.innerHTML = '
'+esc((d.standings && (typeof d.standings==='string'?d.standings:JSON.stringify(d.standings)))||'Bảng xếp hạng đang cập nhật')+'
'; - } else if(tab === 'highlights'){ - el.innerHTML = '
'+esc((d.highlights && (typeof d.highlights==='string'?d.highlights:JSON.stringify(d.highlights)))||'Highlight đang cập nhật')+'
'; - } else if(tab === 'stats'){ - el.innerHTML = '
'+esc((d.stats && (typeof d.stats==='string'?d.stats:JSON.stringify(d.stats)))||'Thống kê đang cập nhật')+'
'; - } else { - var news = (d.news) || []; - var nh = '
'; - (Array.isArray(news)?news:[]).slice(0,15).forEach(function(n){ - nh += ''; - }); - nh += '
'; - el.innerHTML = Array.isArray(news)&&news.length ? nh : '
Đang tải tin World Cup 2026...
'; - } - // update active tab styling - try { - document.querySelectorAll('.wc-tab').forEach(function(t){ - var want = (tab==='news') ? 'news' : tab; - t.classList.toggle('active', t.getAttribute('onclick') && t.getAttribute('onclick').indexOf("'"+want+"'")>=0); - }); - } catch(e){} - } catch(e){ - var el2 = document.getElementById('wc-content'); - if(el2) el2.innerHTML = '
Lỗi tải World Cup 2026
'; - } -} -function _proxyImg(url){ - if(!url || typeof url !== 'string') return ''; - if(url.startsWith('http') && !url.includes(location.host)){ - return '/api/proxy/img?url='+encodeURIComponent(url); - } - return url; -} - -var _ttsSelections = {}; - -function _fetchWithTimeout(url, ms){ - return new Promise((resolve,reject)=>{ - const ctrl=new AbortController(); - const tid=setTimeout(()=>ctrl.abort(),ms); - fetch(url,{signal:ctrl.signal}).then(r=>{ - clearTimeout(tid); - if(!r.ok) return reject(new Error('HTTP '+r.status)); - return r.json(); - }).then(d=>resolve(d)).catch(e=>{clearTimeout(tid);reject(e);}); - }); -} - -// ===== rewriteUrl: tạo bài rewrite slide AI từ URL bài viết ===== -// Gọi /api/url_wall (backend thực tế), lấy post rồi đẩy lên Tường AI và mở slide viewer. -async function rewriteUrl(){ - const inp = document.getElementById('url-input'); - const url = (inp && inp.value || '').trim(); - if(!url){ alert('Vui lòng dán URL bài viết'); return; } - if(!/^https?:\/\//i.test(url)){ alert('URL cần bắt đầu bằng http:// hoặc https://'); return; } - let btn = null; - try { btn = inp && inp.parentElement ? inp.parentElement.querySelector('button') : null; } catch(e){} - const orig = btn ? btn.textContent : 'Rewrite'; - if(btn){ btn.disabled = true; btn.textContent = '⏳ Đang tạo...'; } - toast('⏳ Đang tạo bài rewrite slide AI...'); - try { - const r = await fetch('/api/url_wall', { - method:'POST', - headers:{'Content-Type':'application/json'}, - body: JSON.stringify({url: url}) - }); - const j = await r.json(); - if(!r.ok || j.error) throw new Error(j.error || 'Lỗi tạo bài'); - if(!j.post) throw new Error('Không tạo được bài'); - if(typeof prependWallPost === 'function'){ prependWallPost(j.post); } - else if(Array.isArray(_wallPosts)){ _wallPosts.unshift(j.post); } - if(inp) inp.value = ''; - toast('✅ Đã tạo bài rewrite slide AI!'); - const idx = (_wallPosts || []).indexOf(j.post); - if(j.post.slides && j.post.slides.length){ readSlidePost(idx >= 0 ? idx : 0); } - else if(typeof readWallPost === 'function'){ readWallPost(idx >= 0 ? idx : 0); } - } catch(e){ - toast('❌ ' + e.message); - } finally { - if(btn){ btn.disabled = false; btn.textContent = orig; } - } -} - -// ===== shareVideoToApps: gửi file MP4 qua app (chia sẻ video) ===== -async function shareVideoToApps(videoUrl, title){ - if(!videoUrl){ toast('Không có video để chia sẻ'); return; } - const safeTitle = (title || 'VNEWS Short AI').toString().slice(0, 80); - if(navigator.canShare && navigator.canShare({files:[]}) !== undefined){ - try { - const resp = await fetch(videoUrl, {mode:'cors'}); - if(resp && resp.ok){ - const blob = await resp.blob(); - let fname = (videoUrl.split('?')[0].split('/').pop() || 'vnews-short.mp4'); - if(!fname.toLowerCase().endsWith('.mp4')) fname = 'vnews-short.mp4'; - const file = new File([blob], fname, {type: (blob.type && blob.type.indexOf('video')>=0) ? blob.type : 'video/mp4'}); - if(navigator.canShare && navigator.canShare({files:[file]})){ - await navigator.share({files:[file], title: safeTitle, text: safeTitle}); - return; - } - } - } catch(e){ /* fall through to link share */ } - } - const _base = (typeof SPACE !== 'undefined' && SPACE) ? SPACE : location.origin; - const shareUrl = _base + '/s?url=' + encodeURIComponent(videoUrl) + '&title=' + encodeURIComponent(safeTitle); - if(navigator.share){ - try { await navigator.share({title: safeTitle, text: safeTitle, url: shareUrl}); return; } catch(e){ if(e && e.name === 'AbortError') return; } - } - if(navigator.clipboard && navigator.clipboard.writeText){ - try { await navigator.clipboard.writeText(shareUrl); toast('📋 Đã sao chép link video!'); return; } catch(e){} - } - try { - const ta = document.createElement('textarea'); - ta.value = shareUrl; ta.style.position='fixed'; ta.style.left='-9999px'; ta.style.top='-9999px'; ta.style.opacity='0'; - document.body.appendChild(ta); ta.select(); ta.setSelectionRange(0, 99999); - if(document.execCommand('copy')){ toast('📋 Đã sao chép link video!'); } - else { prompt('📋 Sao chép link video:', shareUrl); } - document.body.removeChild(ta); - } catch(e){ prompt('📋 Sao chép link video:', shareUrl); } -} - -async function loadHome(){ - const homeEl = document.getElementById('view-home'); - if(!homeEl) return; - - homeEl.innerHTML = - '' - +'
🤖 AI viết bài
' - +'
' - +'
' - +'
' - +'

⚽ Livescore

📋 Cập nhật🔴 Live📅 Hôm nay⏰ Sắp tới✅ Kết quả🏆 NHA🏆 La Liga
Đang tải...
' - +'

🏆 World Cup 2026

● LIVE
📰 Tin tức📅 Lịch thi đấu🏆 BXH🎬 Highlight📊 Thống kê
Đang tải World Cup 2026...
' - +'
'; - - const afterEl = homeEl.querySelector('#home-after-wc'); - - loadLivescore('recent'); - loadHotTopics(); - - const [featuredData, wallData, hlLeagues, wcData] = await Promise.allSettled([ - _fetchWithTimeout('/api/livescore/featured', 5000), - _fetchWithTimeout('/api/wall', 5000), - _fetchWithTimeout('/api/highlights/leagues', 10000), - _fetchWithTimeout('/api/wc2026', 20000), - ]).then(results => results.map(r => r.status === 'fulfilled' ? r.value : null)); - - if(featuredData && featuredData.home){ - const sc=featuredData.status==='live'?'':'upcoming'; - const st=featuredData.status==='live'?`🔴 ${featuredData.minute||'LIVE'}`:`⏰ ${featuredData.time}`; - const area=document.getElementById('home-featured-area'); - if(area) area.innerHTML=``; - } - - _wallPosts = (wallData && wallData.posts) || []; - _hlLeagueData = hlLeagues || {}; - _wc2026Data = wcData; - - if(wcData) switchWCTab('news'); - - _renderHLSection(); - - _renderWallIn(afterEl); -} - -function _renderSlidesIn(key, label, emoji, vids, afterEl){ - if(!vids||!vids.length||!afterEl) return; - const wrap=document.createElement('div'); - wrap.className='slider-wrap'; - let h=`
${emoji} ${label}
`; - const isHL = key==='world-cup'||key==='premier-league'||key==='champions-league'||key==='la-liga'||key==='serie-a'||key==='bundesliga'||key==='friendly'; - vids.slice(0,isHL?8:12).forEach((a,i)=>{ - if(isHL){ - h+=`
${a.img?``:''}
${esc(a.title)}
`; - } else { - h+=`
${a.img?``:''}
${esc(a.title)}
`; - } - }); - h+='
'; - wrap.innerHTML=h; - afterEl.parentNode.insertBefore(wrap, afterEl); -} - -function _renderWallIn(afterEl){ - if(!_wallPosts||!_wallPosts.length) return; - const posts=_wallPosts; - const wrap=document.createElement('div'); - wrap.className='slider-wrap';wrap.id='ai-wall-wrap'; - let h='
🧱 Tường AI
'; - posts.slice(0,20).forEach((p,i)=>{h+=makeWallItem(p,i);}); - h+='
';wrap.innerHTML=h; - const target=document.getElementById('ai-wall-under-compose'); - if(target) target.appendChild(wrap); - else if(afterEl) afterEl.parentNode.insertBefore(wrap,afterEl); -} - -function _renderHLIn(afterEl){ - if(!_hlLeagueData||Object.keys(_hlLeagueData).length===0||!afterEl) return; - const HL_CONFIG={"world-cup":{name:"World Cup 2026",emoji:"🌍"},"premier-league":{name:"Premier League",emoji:"🏴"},"champions-league":{name:"Champions League",emoji:"⭐"},"la-liga":{name:"La Liga",emoji:"🇪🇸"},"serie-a":{name:"Serie A",emoji:"🇮🇹"},"bundesliga":{name:"Bundesliga",emoji:"🇩🇪"},"friendly":{name:"Giao hữu",emoji:"🤝"}}; - for(const[key,cfg] of Object.entries(HL_CONFIG)){ - const vids=_hlLeagueData[key]; - if(!vids||!vids.length) continue; - _renderSlidesIn(key,cfg.name,cfg.emoji,vids,afterEl); - } -} - -// === RENDER HIGHLIGHT (Video bóng đá) SECTION — above Livescore, with "Xem thêm" per league === -var _hlPage = {}; // {leagueKey: page} -var _hlHasMore = {}; // {leagueKey: bool} - -function _renderHLSection(){ - const hlEl = document.getElementById('home-hl-section'); - if(!hlEl) return; - if(!_hlLeagueData || Object.keys(_hlLeagueData).length===0){ - return; - } - const HL_CONFIG={"premier-league":{name:"Ngoại Hạng Anh",emoji:"🏴"},"champions-league":{name:"Champions League",emoji:"⭐"},"la-liga":{name:"La Liga",emoji:"🇪🇸"},"serie-a":{name:"Serie A",emoji:"🇮🇹"},"bundesliga":{name:"Bundesliga",emoji:"🇩🇪"},"world-cup":{name:"World Cup 2026",emoji:"🌍"},"fa-cup":{name:"FA Cup",emoji:"🏆"},"friendly":{name:"Giao hữu",emoji:"🤝"}}; - - // Collect all leagues that have videos (premier-league first) - const orderedLeagues = []; - const plVids = _hlLeagueData['premier-league']; - if(plVids && plVids.length) orderedLeagues.push('premier-league'); - for(const [key, cfg] of Object.entries(HL_CONFIG)){ - if(key === 'premier-league') continue; - const vids = _hlLeagueData[key]; - if(vids && vids.length) orderedLeagues.push(key); - } - - if(orderedLeagues.length === 0){ - return; // nothing to show - } - - let html = '
🎬 Video bóng đá
'; - // Main track: premier-league (Ngoại Hạng Anh) - html += '
'; - html += '
'; - hlEl.innerHTML = html; - _renderHLSlides('premier-league', HL_CONFIG['premier-league'].name, HL_CONFIG['premier-league'].emoji, plVids, 'hl-premier-league'); - - // Temporarily hide per-league blocks until slide formatting is verified - // (Remaining leagues rendering deferred to avoid broken slider layout) -} - -function _renderHLSlides(key, label, emoji, vids, containerId){ - _hlPage[key] = 0; - _hlHasMore[key] = vids && vids.length >= 8; - const container = document.getElementById(containerId); - if(!container) return; - if(!vids || !vids.length){ - container.innerHTML = '
Chưa có video
'; - return; - } - let h = ''; - const limit = 8; - const batch = vids.slice(0, limit); - batch.forEach((a, i) => { - h += '
' + (a.img ? '' : '') + '
' + esc(a.title) + '
'; - }); - container.innerHTML = h; -} - -async function _loadMoreHL(key){ - const page = (_hlPage[key] || 0) + 1; - const btn = event && event.target; - if(btn){ btn.disabled = true; btn.textContent = '⏳ Đang tải...'; } - try{ - const r = await fetch('/api/highlights/' + key + '/page?page=' + page + '&limit=15'); - const j = await r.json(); - if(!j || !j.videos || !j.videos.length){ - if(btn) btn.style.display = 'none'; - return; - } - // Append new videos to _hlLeagueData so openHighlightFeed picks them up - if(!_hlLeagueData[key]) _hlLeagueData[key] = []; - _hlLeagueData[key] = _hlLeagueData[key].concat(j.videos); - // Re-index existing items (their _idx was assigned at creation time in openHighlightFeed - // from articles order, so appending is sufficient — new items go to the end) - const container = document.getElementById('hl-track-' + key) || document.getElementById('hl-premier-league'); - if(!container || !container.querySelector){ if(btn) btn.style.display = 'none'; return; } - let h = ''; - const startIdx = (_hlLeagueData[key].length - j.videos.length); - const offset = startIdx; - j.videos.forEach((a, i) => { - h += '
' + (a.img ? '' : '') + '
' + esc(a.title) + '
'; - }); - container.insertAdjacentHTML('beforeend', h); - _hlPage[key] = page; - _hlHasMore[key] = j.has_more; - if(!j.has_more && btn) btn.style.display = 'none'; - }catch(e){ - if(btn) btn.textContent = '❌ Lỗi'; - } -} - -// === WALL POST HELPERS === -function makeWallItem(p,i){ - var hasVideo = p.video && p.video.length > 0; - var isSlide = (p.slides && p.slides.length) || p.kind==='slide_summary'; - // Slide/design posts: keep the image's natural aspect ratio (no 16:9 crop) - // so portrait-designed slides aren't cut — this fixes the "saved image ratio - // and background position look wrong vs the preview" bug on the wall thumb. - var thumbStyle = isSlide - ? 'aspect-ratio:auto;max-height:340px;display:flex;align-items:flex-start;justify-content:center;background:#000' - : ''; - var thumbContent = p.img - ? (isSlide - ? '' - : '') - : (hasVideo ? '' : ''); - var videoBadge = hasVideo ? '
🎬
' : ''; - var vid = p.id||i; - var lang = p.language || detectLanguage(p.title + ' ' + (p.text||'')); - var curVoice = p.voice || getAutoVoice(lang); - var curEmotion = p.emotion || detectEmotion(p.title + ' ' + (p.text||'')); - var selKey = 'inline-' + vid; - if(!_ttsSelections[selKey]) _ttsSelections[selKey] = {voice: curVoice, emotion: curEmotion}; - var voiceOpts = ''; - VOICE_LIST.forEach(function(v){ - voiceOpts += ''; - }); - var emotOpts = ''; - EMOTION_LIST.forEach(function(e){ - emotOpts += ''; - }); - var spd = p.short_speed || '1.2'; - var voiceBar = '
' - +'' - +'' - +'' - +'
'; -var makeBtn = hasVideo - ? '' - : ''; - var designBtn = (p.slides && p.slides.length) ? '' : ''; - return '
'+thumbContent+videoBadge+'
'+esc(p.title)+'
'+esc((p.text||'').slice(0,180))+'
'+voiceBar+'
'+designBtn+makeBtn+'
'; -} - -/* ===================================================================== - * Short AI Creator v2 - * - TikTok background-music list (server-provided) or uploaded audio - * - uploaded image OR uploaded video as the short background - * - "Tạo lại" (recreate): use the DESIGNED slide images + the previous - * short's audio (NO text ever enters the video again) - * ===================================================================== */ -var _shortMusicList = []; -async function loadShortMusicList(){ - if(_shortMusicList.length) return _shortMusicList; - try{ - const r = await fetch('/api/ai-short/music'); - const j = await r.json(); - _shortMusicList = (j.music || []); - }catch(e){ _shortMusicList = []; } - return _shortMusicList; -} - -function openShortCreator(postId){ - if(!postId) return; - const p = _wallPosts.find(x => String(x.id) === String(postId)); - if(!p) return; - // if the post has designed slides, recreate defaults to slides mode - const hasSlides = p.slides && p.slides.length > 0; - _buildShortCreatorModal(p, hasSlides, false); -} - -/* 🔥 Thêm Short HOT — homepage button. Opens the SAME short-creator modal with - * full scrap capability (image / uploaded video / video-from-link) but creates - * a BRAND-NEW wall post (no source article) when the short is generated. */ -function openShortHotCreator(){ - const p = { - id: 'hot-' + Date.now(), - title: '', - text: '', - img: null, - images: [], - slides: null, - video: null, - language: 'vi', - }; - _buildShortCreatorModal(p, false, true); -} - -function _buildShortCreatorModal(p, defaultUseSlides, hotMode){ - loadShortMusicList().then(music => { - const hasPrev = !!(p.short_audio_url || p.video); - const hasSlides = p.slides && p.slides.length > 0; - const useSlideDef = defaultUseSlides !== undefined ? defaultUseSlides : (hasSlides && hasPrev); - const overlay = document.createElement('div'); - overlay.id = 'short-creator-overlay'; - overlay.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.94);z-index:99999;display:flex;align-items:center;justify-content:center;padding:12px;overflow-y:auto'; - - // voice/emotion from stored selection - const selKey = 'inline-' + p.id; - if(!_ttsSelections[selKey]) _ttsSelections[selKey] = {voice: p.voice || getAutoVoice(p.language || detectLanguage(p.title+' '+(p.text||''))), emotion: p.emotion || detectEmotion(p.title+' '+(p.text||''))}; - const curVoice = _ttsSelections[selKey].voice || 'vi-VN-HoaiMyNeural'; - const curEmotion = _ttsSelections[selKey].emotion || 'neutral'; - - let h = '
'; - h += '

'+(hotMode?'🔥 Thêm Short HOT':'🎬 Tạo Short AI')+(hasPrev?' (Tạo lại)':'')+'

'; - if(hotMode){ - h += '
'; - h += '
'; - } - - /* 0. Recreate mode (designed slides only, no text) */ - if(hasSlides){ - h += '
'; - h += ''; - h += '
Dùng ảnh slide đã thiết kế làm nền + giữ nguyên audio của Short trước đó. Chữ KHÔNG xuất hiện trong video.
'; - h += '
'; - h += '
Chọn slide muốn đưa vào Short (tích = dùng):
'; - p.slides.forEach((s,i)=>{ - const simg = s.image || p.img || ''; - h += ''; - }); - h += '
'; - } - - /* 1. Background: image (default) / uploaded video / scraped video link */ - h += '
'; - h += '
'; - h += ''; - h += ''; - h += ''; - h += '
'; - h += ''; - h += ''; - h += ''; - h += '
'; - - /* 2. Audio + 3. Voice — SKIPPED in HOT mode (video keeps its own audio) */ - if(!hotMode){ - /* 2. Audio: previous short audio / TikTok music / uploaded audio */ - h += '
'; - h += ''; - /* Music chooser — luôn hiển thị để người dùng NGHE THỬ trước khi chọn */ - h += '
'; - h += '
'; - h += '
'; - h += ''; - h += '
▶ Nghe thử nhạc nền TikTok trước khi chọn. Nhạc sẽ được trộn vào video (nền) khi tạo.
'; - h += '
'; - h += ''; - h += '
'; - - /* 3. Voice + emotion + speed (TTS) */ - h += '
'; - h += '
'; - h += '
'; - h += '
'; - h += '
'; - } - - h += '
'; - h += ''; - h += ''; - h += '
'; - h += '
'; - overlay.innerHTML=h; document.body.appendChild(overlay); - // Cleanup HLS preview instance whenever the modal is removed (✕ / Hủy / success) - (function(){ - const obs = new MutationObserver(function(muts){ - if(document.getElementById('sc-scrape-video')){ - const p = document.getElementById('sc-scrape-video'); - if(p._hls){ try{ p._hls.destroy(); }catch(e){} p._hls = null; } - } - }); - obs.observe(document.body, {childList:true, subtree:false}); - // stop after this overlay is gone - setTimeout(()=>obs.disconnect(), 60*1000); - })(); - - // ---- mode toggles ---- - let bgMode = hotMode ? 'link' : 'img'; - function setBgMode(m){ - bgMode = m; - document.getElementById('sc-bg-mode-img').style.background = m==='img' ? '#2d8659' : '#333'; - document.getElementById('sc-bg-mode-img').style.color = m==='img' ? '#fff' : '#ccc'; - document.getElementById('sc-bg-mode-video').style.background = m==='video' ? '#2d8659' : '#333'; - document.getElementById('sc-bg-mode-video').style.color = m==='video' ? '#fff' : '#ccc'; - document.getElementById('sc-bg-mode-link').style.background = m==='link' ? '#2d8659' : '#333'; - document.getElementById('sc-bg-mode-link').style.color = m==='link' ? '#fff' : '#ccc'; - document.getElementById('sc-bg-img-wrap').style.display = (m==='img' || m==='link') ? 'block' : 'none'; - document.getElementById('sc-bg-video-wrap').style.display = m==='video' ? 'block' : 'none'; - document.getElementById('sc-bg-link-wrap').style.display = m==='link' ? 'block' : 'none'; - } - if(hotMode){ - // HOT mode: only video-from-link is offered; hide ảnh/upload-video buttons - document.getElementById('sc-bg-mode-img').style.display = 'none'; - document.getElementById('sc-bg-mode-video').style.display = 'none'; - document.getElementById('sc-bg-mode-link').style.background = '#2d8659'; - document.getElementById('sc-bg-mode-link').style.color = '#fff'; - } else { - document.getElementById('sc-bg-mode-img').addEventListener('click', ()=>setBgMode('img')); - document.getElementById('sc-bg-mode-video').addEventListener('click', ()=>setBgMode('video')); - document.getElementById('sc-bg-mode-link').addEventListener('click', ()=>setBgMode('link')); - } - - // ---- scraped video link: fetch preview ---- - const scLinkInput = document.getElementById('sc-video-link'); - const scScrapeBtn = document.getElementById('sc-video-scrape'); - if(scScrapeBtn && scLinkInput){ - scScrapeBtn.addEventListener('click', async ()=>{ - const url = scLinkInput.value.trim(); - const st = document.getElementById('sc-video-scrape-status'); - const preview = document.getElementById('sc-video-preview'); - const meta = document.getElementById('sc-video-meta'); - if(!url){ - st.textContent = '⚠️ Dán link video trước.'; - st.style.color = '#e0a030'; - return; - } - st.textContent = '⏳ Đang lấy thông tin video...'; - st.style.color = '#888'; - try{ - const r = await fetch('/api/ai-short/scrape?url=' + encodeURIComponent(url)); - const j = await r.json(); - if(!r.ok || j.error) throw new Error(j.error || 'Không lấy được video'); - // metadata - let metaHtml = ''; - if(j.title) metaHtml += '
' + esc(j.title) + '
'; - if(j.duration) metaHtml += '
⏱️ ' + Math.round(j.duration) + 's
'; - if(j.extractor) metaHtml += '
Nguồn: ' + esc(j.extractor) + '
'; - if(j.note) metaHtml += '
⚠️ ' + esc(j.note) + '
'; - meta.innerHTML = metaHtml; - meta.style.display = 'block'; - // direct URL for the short - const scDirect = j.direct_url || ''; - meta.setAttribute('data-title', j.title || ''); - meta.setAttribute('data-direct', scDirect || ''); - document.getElementById('sc-scrape-direct').value = scDirect; - document.getElementById('sc-scrape-duration').value = j.duration ? String(j.duration) : ''; - if(j.previewable && scDirect){ - const isHls = !!j.is_hls || /\.m3u8/i.test(scDirect); - const streamUrl = isHls - ? '/api/ai-short/scrape/hls?url=' + encodeURIComponent(scDirect) - : '/api/ai-short/scrape/preview?url=' + encodeURIComponent(scDirect); - preview.style.display = 'block'; - preview.setAttribute('data-ishls', isHls ? '1' : '0'); - preview.innerHTML = ''; - const v = preview.querySelector('#sc-scrape-video'); - if(isHls && v && typeof Hls !== 'undefined' && Hls.isSupported()){ - const hls = new Hls(); - hls.loadSource(streamUrl); - hls.attachMedia(v); - hls.on(Hls.Events.MANIFEST_PARSED, ()=>{ v.play().catch(()=>{}); }); - v._hls = hls; - } else if(isHls && v && v.canPlayType('application/vnd.apple.mpegurl')){ - v.src = streamUrl; // native Safari HLS - } - st.textContent = '✅ Đã lấy video. Bấm play để xem trước.'; - st.style.color = '#8f8'; - } else { - preview.style.display = 'none'; - if(j.note){ - st.textContent = '⚠️ ' + j.note; - st.style.color = '#e0a030'; - } else { - st.textContent = '❌ Không phát trước được (có thể do nguồn chặn). Bạn vẫn có thể thử tạo short.'; - st.style.color = '#e05555'; - } - } - }catch(e){ - st.textContent = '❌ ' + e.message; - st.style.color = '#e05555'; - preview.style.display = 'none'; - document.getElementById('sc-scrape-direct').value = ''; - document.getElementById('sc-scrape-duration').value = ''; - } - }); - } - - // ---- rewrite image picker (chọn 1 ảnh cho nhiều slide) ---- - let _scChosenImg = ''; - document.getElementById('sc-rewrite-list').addEventListener('click', e=>{ - const btn = e.target.closest('.sc-rw-img'); - if(!btn) return; - document.querySelectorAll('.sc-rw-img').forEach(b=>{ b.style.borderColor='#333'; b.dataset.sel='0'; }); - btn.style.borderColor='#ffd700'; btn.dataset.sel='1'; - _scChosenImg = btn.dataset.img || ''; - const hidden = document.getElementById('sc-chosen-img'); - if(hidden) hidden.value = _scChosenImg; - }); - - // ---- audio source toggles (skipped in HOT mode) ---- - if(!hotMode){ - function setAudioSrc(v){ - // nhạc nền TikTok luôn hiển thị để nghe thử; ẩn khi chọn "Không âm thanh" - document.getElementById('sc-music-wrap').style.display = v==='none' ? 'none' : 'block'; - document.getElementById('sc-upload-wrap').style.display = v==='upload' ? 'block' : 'none'; - } - document.getElementById('sc-audio-src').addEventListener('change', e=>setAudioSrc(e.target.value)); - if(hasPrev && p.video) setAudioSrc('prev'); else setAudioSrc('tts'); - } - - // ---- recreate slides: show/hide slide picker ---- - const scReuse = document.getElementById('sc-reuse-slides'); - if(scReuse){ - scReuse.addEventListener('change', function(){ - const pk = document.getElementById('sc-slide-picker'); - if(pk) pk.style.display = this.checked ? 'block' : 'none'; - }); - } - - // ---- music preview player (skipped in HOT mode) ---- - let _scAudio = null; - const listenBtn = hotMode ? null : document.getElementById('sc-music-listen'); - const musicSel = hotMode ? null : document.getElementById('sc-music'); - const playerBox = hotMode ? null : document.getElementById('sc-music-playerbox'); - if(listenBtn && musicSel){ - listenBtn.addEventListener('click', function(){ - const opt = musicSel.options[musicSel.selectedIndex]; - const aurl = opt ? (opt.dataset.aurl || '') : ''; - if(!aurl){ - alert('Không có link nhạc cho lựa chọn này.'); - return; - } - // proxy để tránh CORS khi phát thử trong trình duyệt - const streamUrl = '/api/ai-short/music/stream?url=' + encodeURIComponent(aurl); - if(_scAudio && _scAudio.dataset.src === aurl && !_scAudio.paused){ - _scAudio.pause(); listenBtn.textContent = '▶ Nghe thử'; - return; - } - if(_scAudio) _scAudio.pause(); - if(playerBox) playerBox.style.display = 'block'; - if(playerBox) playerBox.innerHTML = '
🎧 '+esc(opt.text)+'
'; - _scAudio = playerBox ? playerBox.querySelector('audio') : null; - if(_scAudio){ - _scAudio.dataset.src = aurl; - _scAudio.addEventListener('ended', function(){ listenBtn.textContent = '▶ Nghe thử'; }); - _scAudio.addEventListener('error', function(){ - listenBtn.textContent = '▶ Nghe thử'; - toast('❌ Không phát được nhạc thử (mạng/vùng chặn). Bạn vẫn có thể chọn để trộn vào video.'); - }); - listenBtn.textContent = '⏹ Dừng'; - } - }); - } - - // ---- create ---- - document.getElementById('sc-create-btn').addEventListener('click', async ()=>{ - const btn = document.getElementById('sc-create-btn'); - const st = document.getElementById('sc-status'); - btn.disabled = true; btn.textContent = '⏳ Đang tạo...'; - st.textContent = 'Đang chuẩn bị...'; - - // upload files first - try{ - // image file (not in HOT mode) - let imgUrl = ''; - const imgUrlInput = document.getElementById('sc-img-url'); - if(imgUrlInput) imgUrl = imgUrlInput.value.trim() || ''; - const imgFile = hotMode ? null : document.getElementById('sc-img-file').files[0]; - if(imgFile){ - st.textContent = '⏳ Đang tải ảnh lên...'; - const fd = new FormData(); fd.append('file', imgFile, imgFile.name); - const r = await fetch('/api/ai-short/upload', {method:'POST', body:fd}); - const j = await r.json(); - if(!r.ok || j.error) throw new Error(j.error || 'Lỗi upload ảnh'); - imgUrl = j.url; - } - // video file (not in HOT mode) - let videoUrl = ''; - const videoFile = hotMode ? null : document.getElementById('sc-video-file').files[0]; - if(videoFile){ - st.textContent = '⏳ Đang tải video lên...'; - const fd = new FormData(); fd.append('file', videoFile, videoFile.name); - const r = await fetch('/api/ai-short/upload', {method:'POST', body:fd}); - const j = await r.json(); - if(!r.ok || j.error) throw new Error(j.error || 'Lỗi upload video'); - videoUrl = j.url; - } - // audio file (not in HOT mode) - let uploadAudioUrl = ''; - const audioFile = hotMode ? null : document.getElementById('sc-audio-file').files[0]; - if(audioFile){ - st.textContent = '⏳ Đang tải audio lên...'; - const fd = new FormData(); fd.append('file', audioFile, audioFile.name); - const r = await fetch('/api/ai-short/upload', {method:'POST', body:fd}); - const j = await r.json(); - if(!r.ok || j.error) throw new Error(j.error || 'Lỗi upload audio'); - uploadAudioUrl = j.url; - } - - const audioSrc = hotMode ? 'video' : (document.getElementById('sc-audio-src').value); - const useSlides = hotMode ? false : (document.getElementById('sc-reuse-slides') ? document.getElementById('sc-reuse-slides').checked : false); - // nhạc nền TikTok: giá trị != none được trộn vào video (kể cả tạo lại) - let musicId = ''; - const musicSel = hotMode ? null : document.getElementById('sc-music'); - if(musicSel && musicSel.value && musicSel.value !== 'none' && audioSrc !== 'none'){ - musicId = musicSel.value; - } - if(audioSrc === 'music') musicId = musicSel ? musicSel.value : ''; - let audioUrl = ''; - if(hotMode){ audioUrl = ''; } - else if(audioSrc==='upload') audioUrl = uploadAudioUrl; - else if(audioSrc==='prev' && p.video){ audioUrl = p.short_audio_url || ''; } - // reuse_audio explicitly: only when user chose "giữ nguyên audio short trước" - const reuseAudio = hotMode ? false : (audioSrc === 'prev'); - // collect selected slide indices (recreate mode) - let slideIndices = []; - if(useSlides){ - document.querySelectorAll('#sc-slide-picker .sc-slide-chk:checked').forEach(chk=>{ - slideIndices.push(parseInt(chk.dataset.idx)); - }); - if(!slideIndices.length){ - st.textContent = '⚠️ Chọn ít nhất 1 slide để tạo lại.'; - throw new Error('Chọn ít nhất 1 slide'); - } - } - // reuse_audio is sent explicitly above; music (audio_music) is overlaid by the backend - const body = { - post_id: p.id, - use_slides: useSlides, - slide_indices: slideIndices, - reuse_audio: reuseAudio, - voice: hotMode ? 'vi-VN-HoaiMyNeural' : document.getElementById('sc-voice').value, - emotion: hotMode ? 'neutral' : document.getElementById('sc-emotion').value, - speed: hotMode ? 1.0 : (parseFloat(document.getElementById('sc-speed').value) || 1.2), - audio_music: musicId, - audio_url: audioUrl, - }; - if(hotMode){ - // create a brand-new wall post on the AI wall - body.create_new = true; - const hotTitleEl = document.getElementById('sc-hot-title'); - body.title = hotTitleEl ? hotTitleEl.value.trim() : ''; - // scraped video title as fallback wall title - if(!body.title){ - const meta = document.getElementById('sc-video-meta'); - body.title = (meta && meta.dataset.title) ? meta.dataset.title : '🔥 Short HOT'; - } - body.text = ''; - body.language = 'vi'; - const srcEl = document.getElementById('sc-video-link'); - if(srcEl && srcEl.value.trim()) body.url = srcEl.value.trim(); - } - // if uploaded video selected -> use it as background via images? No: - if(bgMode==='video' && videoUrl){ - body.video_url = videoUrl; // backend handles - const vmute = document.getElementById('sc-video-mute'); - if(vmute && vmute.checked) body.video_muted = true; - } else if(bgMode==='link'){ - // scraped video from link: video-first + image tail completion - const scDirect = document.getElementById('sc-scrape-direct') ? document.getElementById('sc-scrape-direct').value : ''; - if(scDirect){ - body.video_url = scDirect; - body.scrape_mode = true; // video-first assembly - const vdur = document.getElementById('sc-scrape-duration') ? document.getElementById('sc-scrape-duration').value : ''; - if(vdur) body.video_dur = parseFloat(vdur) || undefined; - const lmute = document.getElementById('sc-link-mute'); - if(lmute && lmute.checked) body.video_muted = true; - // still send the chosen images (they complete the short if the - // video is shorter than the voice) - const chosen = document.getElementById('sc-chosen-img') ? document.getElementById('sc-chosen-img').value : ''; - const imgs = []; - if(chosen) imgs.push(chosen); - if(imgUrl) imgs.push(imgUrl); - if(imgs.length) body.images = imgs; - if(chosen) body.fixed_image = chosen; - } else { - st.textContent = '⚠️ Bấm "📥 Lấy video" để lấy video từ link trước khi tạo.'; - throw new Error('Chưa lấy được video từ link'); - } - } else { - // 1. ảnh chọn từ danh sách rewrite (áp dụng cho nhiều slide, fix nền cho cả short) - // 2. ảnh URL nhập tay / tải lên - const chosen = document.getElementById('sc-chosen-img') ? document.getElementById('sc-chosen-img').value : ''; - const imgs = []; - if(chosen) imgs.push(chosen); - if(imgUrl) imgs.push(imgUrl); - if(imgs.length) body.images = imgs; // backend: first = fixed bg (normal mode) - if(chosen) body.fixed_image = chosen; // applies to slides mode too (1 ảnh cho nhiều slide) - } - st.textContent = '⏳ Đang tạo video short (có thể mất 1-2 phút)...'; - const r2 = await fetch('/api/ai-short', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)}); - const j2 = await r2.json(); - if(!r2.ok || j2.error) throw new Error(j2.error || 'Lỗi tạo video'); - toast('✅ Đã tạo Short AI!'); - const pp = _wallPosts.find(x => String(x.id) === String(p.id)); - if(pp){ - pp.video = j2.video; - pp.voice = j2.voice; pp.emotion = j2.emotion; pp.short_speed = j2.speed; - pp.short_music = j2.music; pp.short_audio_url = audioUrl; pp.short_use_slides = j2.use_slides; - pp.short_images = j2.images; - } - if(hotMode && j2.post){ - // 🆕 brand-new wall post created by the backend -> prepend on the AI wall - prependWallPost(j2.post); - } else { - const itemId = 'wall-item-'+p.id; - const el = document.getElementById(itemId); - if(el){ - const idx = _wallPosts.indexOf(pp); - el.insertAdjacentHTML('afterend', makeWallItem(pp, idx)); - el.remove(); - } - } - document.getElementById('short-creator-overlay').remove(); - }catch(e){ - st.textContent = '❌ ' + e.message; - btn.disabled = false; btn.textContent = hotMode ? '🔥 Thêm Short HOT' : '🎬 Tạo Short AI'; - toast('❌ ' + e.message); - } - }); - }); -} - -/* legacy fast path — used by any remaining tts-create-btn handlers */ -async function makeShortVideo(postId, btn, voice, speed, emotion){ - openShortCreator(postId); - return; -} - -var VOICE_LIST = [ - {id:'vi-VN-HoaiMyNeural', label:'🎙️ Hoài My (VI)', lang:'vi'}, - {id:'vi-VN-NamMinhNeural', label:'🎙️ Nam Minh (VI)', lang:'vi'}, - {id:'en-US-AndrewMultilingualNeural', label:'🎙️ Andrew (EN)', lang:'en'}, - {id:'en-AU-WilliamMultilingualNeural', label:'🎙️ William (EN)', lang:'en'}, - {id:'pt-BR-ThalitaMultilingualNeural', label:'🎙️ Thalita (PT)', lang:'pt'}, - {id:'fr-FR-VivienneMultilingualNeural', label:'🎙️ Vivienne (FR)', lang:'fr'}, - {id:'fr-FR-RemyMultilingualNeural', label:'🎙️ Rémy (FR)', lang:'fr'}, - {id:'de-DE-SeraphinaMultilingualNeural', label:'🎙️ Seraphina (DE)', lang:'de'}, - {id:'de-DE-FlorianMultilingualNeural', label:'🎙️ Florian (DE)', lang:'de'}, - {id:'ko-KR-HyunsuMultilingualNeural', label:'🎙️ Hyunsu (KO)', lang:'ko'}, - {id:'it-IT-GiuseppeMultilingualNeural', label:'🎙️ Giuseppe (IT)', lang:'it'}, -]; -var EMOTION_LIST = [ - {id:'neutral', label:'😐 Trung tính'}, - {id:'happy', label:'😊 Vui vẻ'}, - {id:'excited', label:'🔥 Hào hứng'}, - {id:'sad', label:'😢 Buồn'}, - {id:'humorous', label:'😂 Hài hước'}, - {id:'serious', label:'⚠️ Nghiêm túc'}, - {id:'urgent', label:'🚨 Khẩn cấp'}, - {id:'warm', label:'💖 Ấm áp'}, -]; - -document.addEventListener('click',function(e){ - var btn = e.target.closest('.tts-voice-btn'); - if(btn){ - var container = btn.closest('.tts-selector'); - if(container){ - var selKey = 'inline-'+container.dataset.postId; - if(!_ttsSelections[selKey]) _ttsSelections[selKey]={voice:btn.dataset.voice,emotion:'neutral'}; - var allBtns = container.querySelectorAll('.tts-voice-btn'); - for(var i=0;i=2) return 'vi'; - if(chars.has('ñ')||chars.has('¿')||chars.has('¡')) return 'es'; - if(chars.has('ã')||chars.has('õ')) return 'pt'; - var words=t.split(/\s+/); - var enWords=['the','is','at','which','on','and','or','but','this','that','with','from','have','been']; - var enCount=words.filter(function(w){return enWords.indexOf(w)>=0;}).length; - if(enCount>=2) return 'en'; - return 'vi'; -} -function detectEmotion(text){ - if(!text) return 'neutral'; - var t=text.toLowerCase(); - var kws={ - happy:['vui','hạnh phúc','tuyệt','thành công','chiến thắng','feliz','maravilloso','happy','joy','wonderful','great','amazing','love','excellent'], - excited:['hào hứng','phấn khích','đột phá','kỷ lục','đỉnh cao','emocionante','increíble','excited','thrilling','unbelievable','awesome','breakthrough'], - sad:['buồn','đau','mất','thảm họa','khủng hoảng','triste','terrible','sad','unhappy','tragic','painful','death'], - humorous:['hài hước','buồn cười','haha','đùa','engraçado','gracioso','funny','hilarious','joke','lol'], - serious:['nghiêm trọng','khẩn cấp','quan trọng','lo ngại','sério','crítico','serious','critical','urgent','severe','crisis'], - urgent:['khẩn cấp','báo động','ngay lập tức','urgent','breaking','alert','emergency'], - warm:['ấm áp','tình cảm','yêu thương','warm','love','heart','touching'] - }; - var bestScore=0, bestEmotion='neutral'; - for(var em in kws){var score=0; for(var kw of kws[em]){if(t.indexOf(kw)>=0) score++;} if(score>bestScore){bestScore=score;bestEmotion=em;}} - return bestEmotion; -} -function getAutoVoice(lang){var map={vi:'vi-VN-HoaiMyNeural',pt:'pt-BR-ThalitaMultilingualNeural',en:'en-US-AndrewMultilingualNeural',fr:'fr-FR-VivienneMultilingualNeural',de:'de-DE-SeraphinaMultilingualNeural',ko:'ko-KR-HyunsuMultilingualNeural',it:'it-IT-GiuseppeMultilingualNeural'};return map[lang]||'vi-VN-HoaiMyNeural';} -function buildVoiceEmotionSelector(post){ - var lang=post.language||detectLanguage(post.title+' '+(post.text||'')); - var _oldVoiceMap = {'hoaimy':'vi-VN-HoaiMyNeural','namminh':'vi-VN-NamMinhNeural','andrew':'en-US-AndrewMultilingualNeural','jenny':'en-US-AndrewMultilingualNeural','thalita':'pt-BR-ThalitaMultilingualNeural','pt_thalita':'pt-BR-ThalitaMultilingualNeural','vivienne':'fr-FR-VivienneMultilingualNeural','remy':'fr-FR-RemyMultilingualNeural','seraphina':'de-DE-SeraphinaMultilingualNeural','florian':'de-DE-FlorianMultilingualNeural','sunhee':'ko-KR-HyunsuMultilingualNeural','hyunsu':'ko-KR-HyunsuMultilingualNeural','giuseppe':'it-IT-GiuseppeMultilingualNeural','ela':'en-US-AndrewMultilingualNeural','denise':'fr-FR-VivienneMultilingualNeural','katja':'de-DE-SeraphinaMultilingualNeural','nanami':'en-US-AndrewMultilingualNeural','xiaochen':'en-US-AndrewMultilingualNeural','es_carlos':'en-US-AndrewMultilingualNeural','pt_francisco':'pt-BR-ThalitaMultilingualNeural'}; - var _postVoice = post.voice ? (_oldVoiceMap[post.voice] || post.voice) : ''; - var autoVoice= _postVoice || getAutoVoice(lang); - var autoEmotion=post.emotion||detectEmotion(post.title+' '+(post.text||'')); - var selKey = 'inline-'+post.id; - if(!_ttsSelections[selKey]){_ttsSelections[selKey] = {voice: autoVoice, emotion: autoEmotion};} - var h='
'; - h+='
🎙️ Giọng đọc (ngôn ngữ: '+lang.toUpperCase()+'):
'; - VOICE_LIST.forEach(function(v){var sel=v.id===_ttsSelections[selKey].voice?'border-color:#5cb87a;background:#1a2a1f':'border-color:#333;background:#222';h+='';}); - h+='
😊 Cảm xúc:
'; - EMOTION_LIST.forEach(function(e){var sel=e.id===_ttsSelections[selKey].emotion?'border-color:#5cb87a;background:#1a2a1f':'border-color:#333;background:#222';h+='';}); - h+='
⚡ Tốc độ:'; - h+='
'; - h+='
'; - return h; -} -window.showVoiceEmotionSelector=function(postId,title,text){ - var overlay=document.createElement('div'); - overlay.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:99999;display:flex;align-items:center;justify-content:center;padding:16px'; - var box=document.createElement('div');box.style.cssText='background:#1a1a1a;border:2px solid #2d8659;border-radius:16px;padding:20px;max-width:400px;width:100%;max-height:80vh;overflow-y:auto'; - var lang=detectLanguage(title+' '+text);var autoEmotion=detectEmotion(title+' '+text); - var h='

🎬 Tạo Short AI (ngôn ngữ: '+lang.toUpperCase()+')

'; - h+='
🎙️ Chọn giọng đọc:
'; - VOICE_LIST.forEach(function(v){var sel=v.id===getAutoVoice(lang)?'border-color:#5cb87a;background:#1a2a1f':'border-color:#333;background:#222';h+='';}); - h+='
😊 Chọn cảm xúc:
'; - EMOTION_LIST.forEach(function(e){var sel=e.id===autoEmotion?'border-color:#5cb87a;background:#1a2a1f':'border-color:#333;background:#222';h+='';}); - h+='
⚡ Tốc độ:
'; - h+='
'; - h+='
'; - h+='
'; - h+=''; - box.innerHTML=h;overlay.appendChild(box);document.body.appendChild(overlay); - var selectedVoice=getAutoVoice(lang),selectedEmotion=autoEmotion; - box.querySelectorAll('.ve-voice-btn').forEach(function(btn){btn.addEventListener('click',function(){box.querySelectorAll('.ve-voice-btn').forEach(function(b){b.style.borderColor='#333';b.style.background='#222';});this.style.borderColor='#5cb87a';this.style.background='#1a2a1f';selectedVoice=this.dataset.voice;});}); - box.querySelectorAll('.ve-emotion-btn').forEach(function(btn){btn.addEventListener('click',function(){box.querySelectorAll('.ve-emotion-btn').forEach(function(b){b.style.borderColor='#333';b.style.background='#222';});this.style.borderColor='#5cb87a';this.style.background='#1a2a1f';selectedEmotion=this.dataset.emotion;});}); - box.querySelector('#ve-cancel-btn').addEventListener('click',function(){overlay.remove();}); - box.querySelector('#ve-create-btn').addEventListener('click',async function(){ - this.disabled=true;this.textContent='⏳ Đang tạo...'; - box.querySelector('#ve-status').style.display='block';box.querySelector('#ve-status').textContent='Đang tạo video shorts...'; - try{ - var speed=parseFloat(box.querySelector('#ve-speed').value)||1.2; - if(!_ttsSelections["inline-"+postId]) _ttsSelections["inline-"+postId]={voice:"vi-VN-HoaiMyNeural",emotion:"neutral"}; - _ttsSelections["inline-"+postId].voice=selectedVoice;_ttsSelections["inline-"+postId].emotion=selectedEmotion; - var r=await fetch('/api/ai/short/'+encodeURIComponent(postId),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:selectedVoice,emotion:selectedEmotion,speed:speed})}); - var j=await r.json(); - if(!r.ok||j.error) throw new Error(j.error||'Lỗi tạo video'); - toast('✅ Đã tạo Short AI!');overlay.remove(); - var p=_wallPosts.find(function(x){return String(x.id)===String(postId);}); - if(p){p.video=j.video;p.voice=j.voice;p.emotion=j.emotion;} - }catch(e){this.disabled=false;this.textContent='🎬 Tạo Short';box.querySelector('#ve-status').textContent='❌ '+e.message;} - }); -}; - -function prependWallPost(post){ - _wallPosts.unshift(post); - const track=document.getElementById('ai-wall-track'); - const wrap=document.getElementById('ai-wall-wrap'); - const target=document.getElementById('ai-wall-under-compose'); - if(target && (!track||!wrap)){ - const newWrap=document.createElement('div'); - newWrap.className='slider-wrap';newWrap.id='ai-wall-wrap'; - newWrap.innerHTML=`
🧱 Tường AI
${makeWallItem(post,0)}
`; - target.appendChild(newWrap); - const firstItem=newWrap.querySelector('.wall-item'); - if(firstItem)firstItem.className='wall-item wall-item-new'; - return; - } - if(track){ - track.insertAdjacentHTML('afterbegin', makeWallItem(post, 0)); - track.scrollTo({left:0,behavior:'smooth'}); - } -} - -let _wallPosts=[]; -let _currentView='home'; -let _currentEventId=null; -let _currentMatchUrl=null; -let _htPage=0,_htTopic=''; -async function loadHotTopics(){let j=null;try{j=await _fetchWithTimeout('/api/hot_topics',8000);}catch(e){j={topics:[]};}const el=document.getElementById('hot-topics');if(!el)return;el.innerHTML=(j.topics||[]).slice(0,18).map(t=>{const topicText=t.topic||t.label.replace(/^#/,'');return``;}).join('');} -function searchTopic(topic){if(!topic){topic=document.getElementById('topic-input')?.value.trim();if(!topic){alert('Nhập chủ đề');return;}}document.getElementById('topic-input').value='';_htTopic=topic;_htPage=0;showHashtagSources(topic,0);} -async function showHashtagSources(topic,page){const box=document.getElementById('hashtag-box');if(!box)return;if(page===0)box.innerHTML=`

🔍 ${esc(topic)}

Đang tìm...
`;try{const r=await fetch(`/api/hashtag/sources?topic=${encodeURIComponent(topic)}&page=${page}`);const j=await r.json();const sources=j.sources||[];if(!sources.length&&page===0){box.innerHTML=`

🔍 ${esc(topic)}

Không tìm được bài viết liên quan
`;return;}let h='';if(page===0)h=`

🔍 ${esc(topic)} (${j.total} bài từ 8 nguồn)

`;sources.forEach((s,i)=>{const idx=page*8+i;h+=`
${esc(s.title)}
${esc(s.via||'')}
`;});if(page===0){h+=`
`;if(j.has_more)h+=``;h+=`
`;box.innerHTML=h;}else{document.getElementById('ht-list')?.insertAdjacentHTML('beforeend',h);const btn=document.getElementById('ht-more');if(btn){if(!j.has_more)btn.remove();else{btn.disabled=false;btn.textContent='Tải thêm ▼';}}}sources.forEach((s,i)=>{const idx=page*8+i;if(!s.url)return;const ctrl=new AbortController();setTimeout(()=>ctrl.abort(),4000);fetch('/api/article?url='+encodeURIComponent(s.url),{signal:ctrl.signal}).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){const el=document.getElementById('ht-img-'+idx);if(el)el.innerHTML=``;}}).catch(()=>{});});}catch(e){box.innerHTML=`

🔍 ${esc(topic)}

Lỗi: ${esc(e.message)}
`;}} -function loadMoreHashtag(){_htPage++;const btn=document.getElementById('ht-more');if(btn){btn.disabled=true;btn.textContent='Đang tải...';}showHashtagSources(_htTopic,_htPage);} -async function rewriteHashtag(topic){const btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang tổng hợp...';}try{const r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');toast('✅ Đã đăng Tường AI!');if(btn)btn.textContent='✅ Đăng thành công!';if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message);if(btn){btn.disabled=false;btn.textContent='🤖 Rewrite AI';}}} -async function loadLivescore(tab){document.querySelectorAll('.ls-tab').forEach(t=>t.classList.remove('active'));document.querySelector(`.ls-tab[data-tab="${tab}"]`)?.classList.add('active');const el=document.getElementById('ls-content');if(!el)return;el.innerHTML='
Đang tải...
';let ep='/api/livescore/'+tab;if(tab.startsWith('bxh_'))ep='/api/livescore/standings/'+tab.replace('bxh_','');try{const d=await _fetchWithTimeout(ep,12000);el.innerHTML=d.html&&d.html.length>50?d.html:'
Không có dữ liệu
';bindMatchClicks(el);}catch(e){el.innerHTML='
Lỗi tải hoặc hết thời gian
';}} -function bindMatchClicks(el){el.querySelectorAll('.match-detail').forEach(md=>{md.style.cursor='pointer';md.addEventListener('click',function(e){const statusA=this.querySelector('.status a');const teamA=this.querySelector('.teams a[href*="/tran-dau/"]');const a = statusA || teamA;if(a){e.preventDefault();e.stopPropagation();const href=a.getAttribute('href')||'';const m=href.match(/\/tran-dau\/(\d+)\//);if(m){const fullUrl=href.startsWith('http')?href:'https://bongda.com.vn'+href;openMatch(m[1],fullUrl);}}});});el.querySelectorAll('a').forEach(a=>{a.addEventListener('click',e=>{e.preventDefault();e.stopPropagation()});});} -function openMatch(id,url){if(!id)return;_currentEventId=id;if(url)_currentMatchUrl=url;document.getElementById('match-overlay').classList.add('active');document.body.style.overflow='hidden';loadMatchTab('detail')} -function closeMatch(){document.getElementById('match-overlay').classList.remove('active');document.body.style.overflow=''} -async function loadMatchTab(tab){document.querySelectorAll('.mo-tab').forEach(t=>t.classList.remove('active'));document.querySelectorAll('.mo-tab').forEach(t=>{if((tab==='comm'&&t.textContent==='Diễn biến')||(tab==='stats'&&t.textContent==='Thống kê')||(tab==='detail'&&t.textContent.includes('Chi tiết')))t.classList.add('active')});const el=document.getElementById('mo-body');if(!el)return;el.innerHTML='
Đang tải...
';try{let apiUrl;if(tab==='stats')apiUrl=`/api/match/${_currentEventId}/stats`;else if(tab==='comm')apiUrl=`/api/match/${_currentEventId}/commentaries`;else{apiUrl=`/api/match/${_currentEventId}/detail`;if(_currentMatchUrl)apiUrl+='?url='+encodeURIComponent(_currentMatchUrl)}const r=await fetch(apiUrl);if(!r.ok){el.innerHTML='
Lỗi máy chủ ('+r.status+')
';return}const d=await r.json();if(d.error){el.innerHTML='
'+esc(d.error)+'
';return}if(tab==='detail'&&typeof renderMatchDetail==='function'){renderMatchDetail(el,d);return}el.innerHTML=d.html||'
Không có dữ liệu
'}catch(e){el.innerHTML='
Lỗi
'}} -function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]))} -function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')} -function switchCat(id){document.querySelectorAll('.cat').forEach(c=>c.classList.remove('active'));document.querySelector(`[data-cat="${id}"]`)?.classList.add('active');document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.querySelectorAll('video').forEach(v=>{v.pause();if(v._hls){v._hls.destroy();v._hls=null}});document.querySelectorAll('iframe[data-yt-src]').forEach(f=>{f.src=''});if(id==='home')document.getElementById('view-home').classList.add('active');else if(id==='news-all'){document.getElementById('view-cat').classList.add('active');loadNewsTab()}else{document.getElementById('view-cat').classList.add('active');loadCat(id)}} -function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}} - -// ===== doShare: COPY link to clipboard, then try native share as bonus ===== - function doShare(title,url,img,postId){ - var shareUrl; - var _base = (typeof SPACE!=='undefined' && SPACE) ? SPACE : location.origin; - if(postId){ - shareUrl = _base+'/s?post_id='+encodeURIComponent(postId)+'&title='+encodeURIComponent(title); - } else { - shareUrl = _base+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||''); - } - // Try clipboard API first (modern, works on HTTPS) - if(navigator.clipboard && navigator.clipboard.writeText){ - navigator.clipboard.writeText(shareUrl).then(function(){ - toast('📋 Đã sao chép link!'); - try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){} - }).catch(function(){ - // Fallback: execCommand (deprecated but works on some browsers) - try{ - var ta=document.createElement('textarea'); - ta.value=shareUrl;ta.style.position='fixed';ta.style.left='-9999px';ta.style.top='-9999px';ta.style.opacity='0'; - document.body.appendChild(ta);ta.select();ta.setSelectionRange(0,99999); - if(document.execCommand('copy')){toast('📋 Đã sao chép link!');} - else{prompt('📋 Sao chép link:', shareUrl);} - document.body.removeChild(ta); - }catch(e){prompt('📋 Sao chép link:', shareUrl);} - try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){} - }); - } else { - // execCommand approach - try{ - var ta=document.createElement('textarea'); - ta.value=shareUrl;ta.style.position='fixed';ta.style.left='-9999px';ta.style.top='-9999px';ta.style.opacity='0'; - document.body.appendChild(ta);ta.select();ta.setSelectionRange(0,99999); - if(document.execCommand('copy')){toast('📋 Đã sao chép link!');} - else{prompt('📋 Sao chép link:', shareUrl);} - document.body.removeChild(ta); - }catch(e){prompt('📋 Sao chép link:', shareUrl);} - try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){} - } -} - -async function doInteract(videoId,type){try{const r=await fetch('/api/v2/interact',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:videoId,type})});return await r.json();}catch(e){return null;}} -async function getInteractions(videoId){try{return await fetch('/api/v2/interactions?id='+encodeURIComponent(videoId)).then(r=>r.json());}catch(e){return{views:0,likes:0,comments:0};}} -async function getComments(videoId){try{const j=await fetch('/api/v2/comments?id='+encodeURIComponent(videoId)).then(r=>r.json());return j.comments||[];}catch(e){return[];}} -async function postComment(videoId,text){try{const j=await fetch('/api/v2/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:videoId,text})}).then(r=>r.json());return j.comments||[];}catch(e){return[];}} -function buildTikTokSlide(opts){return`
${opts.vtag}
${opts.badge||''}

${esc(opts.title)}

${opts.extraBtn||''}
${opts.idx+1}/${opts.total}
`;} -async function doView(videoId,btn){const j=await doInteract(videoId,'view');if(j){const c=btn.querySelector('.count');if(c)c.textContent=fmtNum(j.views);}} -async function doLike(videoId,btn){const j=await doInteract(videoId,'like');if(j){const c=btn.querySelector('.count');if(c)c.textContent=fmtNum(j.likes);}} -function fmtNum(n){if(!n)return'0';if(n>=1000000)return(n/1000000).toFixed(1)+'M';if(n>=1000)return(n/1000).toFixed(1)+'K';return String(n);} -async function loadCounters(videoIds){for(let i=0;iĐang tải...
';const cmts=await getComments(videoId);renderInlineComments(panel,videoId,idx,cmts);/* Remove snap so feed can scroll to show comment input */var sl=panel.closest('.tiktok-slide');if(sl)sl.style.scrollSnapAlign='';setTimeout(function(){var feed=document.getElementById('tiktok-feed');if(feed){var slidePos=sl?sl.offsetTop:0;feed.scrollTop=slidePos+200;}/* Focus input */var inp=document.getElementById('cmt-input-'+idx);if(inp)inp.focus();},300);} -function renderInlineComments(panel,videoId,idx,cmts){let h='
💬 Bình luận
';if(cmts.length){cmts.slice(-30).forEach(c=>{h+=`
${c.time||''}${esc(c.text)}
`;});}else{h+='
Chưa có bình luận
';}h+=`
`;panel.innerHTML=h;const list=panel.querySelector('.inline-cmt-list');if(list)list.scrollTop=list.scrollHeight;} -async function submitInlineCmt(videoId,idx){const inp=document.getElementById('cmt-input-'+idx);if(!inp)return;const text=inp.value.trim();if(!text)return;inp.value='';inp.disabled=true;const cmts=await postComment(videoId,text);inp.disabled=false;const panel=document.getElementById('cmt-inline-'+idx);if(panel)renderInlineComments(panel,videoId,idx,cmts);const cc=document.getElementById('cc-'+idx);if(cc)cc.textContent=fmtNum(cmts.length);/* Keep focus on input after submit */var inp2=document.getElementById('cmt-input-'+idx);if(inp2)inp2.focus();} -function toggle169View(videoId,iconId){const slide=videoId?document.querySelector(`.tiktok-slide[data-vid="${videoId}"]`):null;if(slide){slide.classList.toggle('ratio-wide');const iconEl=iconId?document.getElementById(iconId):null;if(iconEl)iconEl.textContent=slide.classList.contains('ratio-wide')?'📺':'🖥️';else{const btn=slide.querySelector('.tiktok-right-btn .icon');if(btn)btn.textContent=slide.classList.contains('ratio-wide')?'📺':'🖥️';}return}document.querySelectorAll('.tiktok-slide.ratio-wide').forEach(s=>s.classList.remove('ratio-wide'));document.querySelectorAll('.tiktok-slide').forEach(s=>s.classList.add('ratio-wide'));document.querySelectorAll('.tiktok-right-btn .icon').forEach(b=>{if(b.textContent==='🖥️')b.textContent='📺';})} -function initTikTokFeed(){const feed=document.getElementById('tiktok-feed');if(!feed)return;const slides=feed.querySelectorAll('.tiktok-slide');let cur=-1;function act(i){if(i===cur)return;slides.forEach((sl,idx)=>{const v=sl.querySelector('video');const fr=sl.querySelector('iframe');if(idx===i){if(v&&v.dataset.hls&&!v._hls&&typeof Hls!=='undefined'&&Hls.isSupported()){const hls=new Hls();hls.loadSource(v.dataset.hls);hls.attachMedia(v);hls.on(Hls.Events.MANIFEST_PARSED,()=>v.play().catch(()=>{}));v._hls=hls}else if(v)v.play().catch(()=>{});if(fr&&!fr.src&&fr.dataset.ytSrc)fr.src=fr.dataset.ytSrc;const vid=sl.dataset.vid;if(vid&&!sl._viewed){sl._viewed=true;doInteract(vid,'view').then(j=>{if(j){const vc=document.getElementById('vc-'+idx);if(vc)vc.textContent=fmtNum(j.views);}});}}else{if(v){v.pause();if(v._hls){v._hls.destroy();v._hls=null}}if(fr&&fr.src)fr.src=''}});cur=i}let sT;feed.addEventListener('scroll',()=>{clearTimeout(sT);sT=setTimeout(()=>{const rect=feed.getBoundingClientRect(),ctr=rect.top+rect.height/2;let best=-1,bestD=1e9;slides.forEach((sl,i)=>{const d=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(d=0)act(best)},150)});setTimeout(()=>act(0),400);slides.forEach(sl=>{const v=sl.querySelector('video');if(v)v.addEventListener('click',e=>{e.preventDefault();v.paused?v.play().catch(()=>{}):v.pause()})});const ids=[...slides].map(sl=>sl.dataset.vid||'');loadCounters(ids)} -async function openHighlightFeed(league,idx,forceUrl){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='
Đang tải...
';let articles=(_hlLeagueData||{})[league]||[];if(!articles.length){try{articles=await fetch('/api/highlights/'+league).then(r=>r.json())}catch(e){articles=[]}}if(!articles.length){el.innerHTML='
Không có video
';return}const vids=[];const results=await Promise.all(articles.map(async(a,i)=>{try{const r=await fetch('/api/video_url?url='+encodeURIComponent(a.link)+'&img='+encodeURIComponent(a.img||''));const v=await r.json();if(v&&v.src){return{_idx:i,title:a.title||v.title||'',link:a.link||'',img:a.img||v.poster||'',src:v.src,type:v.type||'',poster:v.poster||a.img||''}}return null}catch(e){}return null}));results.forEach(r=>{if(r)vids.push(r)});vids.sort((a,b)=>a._idx-b._idx);if(!vids.length){el.innerHTML='
Không tìm thấy video
';return}let ti=vids.findIndex(v=>v._idx===idx);if(ti<0)ti=0;const ordered=ti>0?[...vids.slice(ti),...vids.slice(0,ti)]:vids;let h=`
`;ordered.forEach((v,i)=>{const isYT=v.type==='youtube',isHLS=!isYT&&v.src?.includes('.m3u8'),poster=v.poster?` poster="${esc(v.poster)}"`:'';const vtag=isYT?``:isHLS?``:``;const videoId='hl-'+league+'-'+v._idx;h+=buildTikTokSlide({vtag,title:v.title,badge:'HL',badgeClass:'badge-fpt',videoId,idx:i,total:ordered.length,shareUrl:v.link||'',postId:'',extraBtn:``})});h+='
';el.innerHTML=h;setTimeout(()=>initTikTokFeed(),200);} -async function openYTShortsFeed(idx){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='
Đã xóa Shorts Dân trí/SKĐS
';} -async function openShortAIFeed(idx){showView('view-tiktok');const el=document.getElementById('view-tiktok');if(!_wallPosts||!_wallPosts.length){el.innerHTML='
Không có Short AI
';return}const aiPosts=_wallPosts.filter(p=>p.video);if(!aiPosts.length||idx>=aiPosts.length){el.innerHTML='
Không có Short AI
';return}const ordered=aiPosts.slice(idx).concat(aiPosts.slice(0,idx));let h=`
`;ordered.forEach((p,i)=>{const baseIdx=_wallPosts.indexOf(p);const vtag=p.video?``:'';h+=buildTikTokSlide({vtag,title:p.title,badge:'Short AI',badgeClass:'badge-ai',videoId:p.id||'ai-'+i,idx:i,total:ordered.length,shareUrl:p.video||'',postId:p.id||'',extraBtn:``})});h+='
';el.innerHTML=h;setTimeout(()=>initTikTokFeed(),200);} -function readWallPost(idx){const p=_wallPosts&&_wallPosts[idx];if(!p)return;if(p.slides&&p.slides.length){readSlidePost(idx);return}readArticle(p.url||'','','',p.title,p.text);} -/** Show rewrite slide viewer - vertical slides with text+image */ -function readSlidePost(idx){const p=_wallPosts[idx];if(!p||!p.slides)return;showView('view-article');const el=document.getElementById('view-article');let h=`
`;p.slides.forEach((s,i)=>{h+=`
Slide ${s.index||i+1}/${p.slides.length}
${s.image?``:''}

${esc(s.text)}

`;});h+=`
`;el.innerHTML=h;} - -/** Slide Designer Modal - design a slide image with high-contrast text on background */ -function designerGetRatio(){const v=document.getElementById('designer-ratio')?.value||'3:4';const m=v.split(':');return m.length===2?{w:parseInt(m[0]),h:parseInt(m[1])}:1;} -function designerGetLayout(){return document.getElementById('designer-layout')?.value||'solid';} -function designerDebounce(fn,d){clearTimeout(fn._t);fn._t=setTimeout(fn,d);} -function openSlideDesigner(idx){ - designerSlideIdx=idx; - const p=_wallPosts[idx];if(!p||!p.slides)return; - const overlay=document.createElement('div');overlay.id='slide-designer-overlay';overlay.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:99999;display:flex;align-items:center;justify-content:center;padding:16px;overflow-y:auto'; - let h='
'; - h+='

🎨 Thiết kế ảnh slide

'; - h+='
'; - h+='
'; - h+='
'; - h+='
'; - h+='
'; - ['#ffffff','#000000','#ff4444','#44ff44','#4444ff','#ffff00','#ff00ff','#00ffff','#ff8800','#88ff00'].forEach(c=>{h+=``;}); - h+='
'; - h+='
'; - h+='
'; - h+=''; - h+='
'; - overlay.innerHTML=h;document.body.appendChild(overlay); - document.getElementById('designer-slide-select').addEventListener('change',function(){updateDesignerText(this.value);previewDesignerSlide();}); - document.getElementById('designer-preview-btn').addEventListener('click',function(){previewDesignerSlide();}); - document.getElementById('designer-save-btn').addEventListener('click',function(){saveDesignerSlide(p.title,(p.id||''),idx);}); - ['change','input'].forEach(function(evt){ - const txt=document.getElementById('designer-text');if(txt)txt.addEventListener(evt,function(){designerDebounce(previewDesignerSlide,150);}); - const bg=document.getElementById('designer-bg-url');if(bg)bg.addEventListener(evt,function(){designerDebounce(previewDesignerSlide,150);}); - const ratio=document.getElementById('designer-ratio');if(ratio)ratio.addEventListener(evt,function(){previewDesignerSlide();}); - const layout=document.getElementById('designer-layout');if(layout)layout.addEventListener(evt,function(){previewDesignerSlide();}); - }); - previewDesignerSlide(); -} -function updateDesignerText(slideIdx){const p=_wallPosts[designerSlideIdx];if(!p||!p.slides)return;const s=p.slides[parseInt(slideIdx)];if(s)document.getElementById('designer-text').value=s.text||'';document.getElementById('designer-bg-url').value=s.image||p.img||'';designerBgDataUrl=null;} -function handleDesignerBgFile(input){ - const file=input.files&&input.files[0];if(!file)return; - const reader=new FileReader(); - reader.onload=function(e){designerBgDataUrl=e.target.result;document.getElementById('designer-bg-url').value='[uploaded]';}; - reader.readAsDataURL(file); -} -function previewDesignerSlide(){ - const slideIdx=parseInt(document.getElementById('designer-slide-select')?.value||'0'); - const p=_wallPosts[designerSlideIdx];if(!p||!p.slides)return; - const s=p.slides[slideIdx];if(!s)return; - const text=document.getElementById('designer-text')?.value||s.text||''; - const bgUrlInput=document.getElementById('designer-bg-url')?.value||''; - const bgUrl=designerBgDataUrl||bgUrlInput||s.image||p.img||''; - const textColor=document.getElementById('designer-text')?.style.color||'#ffffff'; - const ratio=designerGetRatio(); - const layout=designerGetLayout(); - const previewArea=document.getElementById('designer-preview-area');if(!previewArea)return; - previewArea.style.display='block';previewArea.innerHTML='
⏳ Đang tạo xem trưởng...
'; - const canvas=document.createElement('canvas'); - const vw=Math.max(document.documentElement.clientWidth||540, document.body.clientWidth||540); - const maxW=Math.min(540, Math.floor(vw*0.9)); - canvas.width=maxW;canvas.height=Math.round(maxW*ratio.h/ratio.w); - canvas.style.maxWidth='100%';canvas.style.width='100%';canvas.style.height='auto'; - canvas.style.borderRadius='12px';canvas.style.boxShadow='0 8px 24px rgba(0,0,0,.5)'; - canvas.style.display='block';canvas.style.margin='0 auto'; - const ctx=canvas.getContext('2d');const img=new Image();img.crossOrigin='anonymous'; - function drawLayout(){ - if(layout==='vignette'){const g=ctx.createRadialGradient(canvas.width/2,canvas.height/2,0,canvas.width/2,canvas.height/2,Math.max(canvas.width,canvas.height)*0.6);g.addColorStop(0,'rgba(0,0,0,0)');g.addColorStop(1,'rgba(0,0,0,0.7)');ctx.fillStyle=g;ctx.fillRect(0,0,canvas.width,canvas.height);} - else if(layout==='split'){ctx.fillStyle='rgba(0,0,0,0)';ctx.fillRect(0,0,canvas.width,canvas.height/2);ctx.fillStyle='rgba(0,0,0,0.7)';ctx.fillRect(0,canvas.height/2,canvas.width,canvas.height/2);} - else if(layout==='spotlight'){const cx=canvas.width/2,cy=canvas.height/2,r=Math.max(canvas.width,canvas.height)*0.35;ctx.save();ctx.fillStyle='rgba(0,0,0,0.7)';ctx.fillRect(0,0,canvas.width,canvas.height);ctx.globalCompositeOperation='destination-out';ctx.fillStyle='white';ctx.beginPath();ctx.arc(cx,cy,r,0,Math.PI*2);ctx.fill();ctx.globalCompositeOperation='source-over';} - else if(layout==='diagonal'){ctx.fillStyle='rgba(0,0,0,0.55)';ctx.fillRect(0,0,canvas.width,canvas.height);ctx.fillStyle='rgba(0,0,0,0.75)';ctx.beginPath();ctx.moveTo(0,0);ctx.lineTo(canvas.width,0);ctx.lineTo(0,canvas.height);ctx.closePath();ctx.fill();} - else {ctx.fillStyle='rgba(0,0,0,0.55)';ctx.fillRect(0,0,canvas.width,canvas.height);} - } - function drawText(){ - ctx.fillStyle=textColor;ctx.font='bold '+Math.round(canvas.width*28/540)+'px sans-serif'; - ctx.textAlign='center';ctx.textBaseline='middle'; - const maxW=Math.round(canvas.width*0.82); - const words=text.split(' ');let line='';let y=Math.round(canvas.height/2); - const fontSize=Math.round(canvas.width*28/540);const lineHeight=Math.round(fontSize*1.3); - for(let i=0;imaxW&&i>0){ctx.fillText(line.trim(),canvas.width/2,y);y+=lineHeight;line=words[i];}else{line=test;}} - ctx.fillText(line.trim(),canvas.width/2,y); - } - function renderAll(){ - ctx.fillStyle='#1a1a1a';ctx.fillRect(0,0,canvas.width,canvas.height); - drawLayout(); - drawText(); - previewArea.innerHTML='';previewArea.appendChild(canvas); - } - img.onload=function(){ - const iw=img.naturalWidth||1,ih=img.naturalHeight||1; - const scale=Math.max(canvas.width/iw,canvas.height/ih); - const dw=iw*scale,dh=ih*scale; - const dx=(canvas.width-dw)/2,dy=(canvas.height-dh)/2; - ctx.drawImage(img,0,0,iw,ih,dx,dy,dw,dh); - drawLayout();drawText(); - previewArea.innerHTML='';previewArea.appendChild(canvas); - }; - img.onerror=function(){renderAll();}; - if(bgUrl){img.src=bgUrl.startsWith('/api/')?bgUrl:bgUrl.startsWith('data:')?bgUrl:'/api/proxy/img?url='+encodeURIComponent(bgUrl);}else{renderAll();} -} -async function saveDesignerSlide(title,postId,idx){ - const slideIdx=parseInt(document.getElementById('designer-slide-select')?.value||'0'); - const p=_wallPosts[idx];if(!p||!p.slides)return; - const s=p.slides[slideIdx];if(!s)return; - const text=document.getElementById('designer-text')?.value||s.text||''; - const bgUrlInput=document.getElementById('designer-bg-url')?.value||''; - const bgUrl=designerBgDataUrl||bgUrlInput||s.image||p.img||''; - const textColor=document.getElementById('designer-text')?.style.color||'#ffffff'; - const ratio=designerGetRatio(); - const layout=designerGetLayout(); - const statusEl=document.getElementById('designer-status');if(statusEl)statusEl.textContent='⏳ Đang tạo ảnh...'; - const FINAL_W=ratio.w>=ratio.h?1800:1350; - const FINAL_H=Math.round(FINAL_W*ratio.h/ratio.w); - const canvas=document.createElement('canvas');canvas.width=FINAL_W;canvas.height=FINAL_H; - const ctx=canvas.getContext('2d'); - const img=new Image();img.crossOrigin='anonymous'; - function drawLayoutF(){ - if(layout==='vignette'){const g=ctx.createRadialGradient(FINAL_W/2,FINAL_H/2,0,FINAL_W/2,FINAL_H/2,Math.max(FINAL_W,FINAL_H)*0.6);g.addColorStop(0,'rgba(0,0,0,0)');g.addColorStop(1,'rgba(0,0,0,0.7)');ctx.fillStyle=g;ctx.fillRect(0,0,FINAL_W,FINAL_H);} - else if(layout==='split'){ctx.fillStyle='rgba(0,0,0,0)';ctx.fillRect(0,0,FINAL_W,FINAL_H/2);ctx.fillStyle='rgba(0,0,0,0.7)';ctx.fillRect(0,FINAL_H/2,FINAL_W,FINAL_H/2);} - else if(layout==='spotlight'){const cx=FINAL_W/2,cy=FINAL_H/2,r=Math.max(FINAL_W,FINAL_H)*0.35;ctx.save();ctx.fillStyle='rgba(0,0,0,0.7)';ctx.fillRect(0,0,FINAL_W,FINAL_H);ctx.globalCompositeOperation='destination-out';ctx.fillStyle='white';ctx.beginPath();ctx.arc(cx,cy,r,0,Math.PI*2);ctx.fill();ctx.globalCompositeOperation='source-over';} - else if(layout==='diagonal'){ctx.fillStyle='rgba(0,0,0,0.55)';ctx.fillRect(0,0,FINAL_W,FINAL_H);ctx.fillStyle='rgba(0,0,0,0.75)';ctx.beginPath();ctx.moveTo(0,0);ctx.lineTo(FINAL_W,0);ctx.lineTo(0,FINAL_H);ctx.closePath();ctx.fill();} - else {ctx.fillStyle='rgba(0,0,0,0.55)';ctx.fillRect(0,0,FINAL_W,FINAL_H);} - } - function drawTextF(){ - ctx.fillStyle=textColor;ctx.font='bold '+Math.round(FINAL_W*48/1080)+'px sans-serif'; - ctx.textAlign='center';ctx.textBaseline='middle'; - const maxW=Math.round(FINAL_W*0.82);const words=text.split(' ');let line='';let y=Math.round(FINAL_H/2); - const fontSize=Math.round(FINAL_W*48/1080);const lineHeight=Math.round(fontSize*1.3); - for(let i=0;imaxW&&i>0){ctx.fillText(line.trim(),FINAL_W/2,y);y+=lineHeight;line=words[i];}else{line=test;}}ctx.fillText(line.trim(),FINAL_W/2,y); - } - function render(){ - ctx.fillStyle='#1a1a1a';ctx.fillRect(0,0,FINAL_W,FINAL_H); - drawLayoutF();drawTextF(); - canvas.toBlob(async function(blob){ - const formData=new FormData();formData.append('file',blob,'slide_'+slideIdx+'.png');formData.append('post_id',postId||p.id||''); - try{const r=await fetch('/api/wall/img',{method:'POST',body:formData});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi upload'); - s.image=j.url; - const post={id:p.id,title:title||p.title,text:p.text,img:j.url,url:p.url,slides:p.slides,images:p.images,voice:p.voice,emotion:p.emotion,language:p.language,ts:p.ts,kind:p.kind||'slide_summary'}; - const wr=await fetch('/api/wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(post)}); - const wj=await wr.json();if(wr.ok&&wj.post){prependWallPost(wj.post);toast('✅ Đã lưu ảnh đăng lên Tường AI!');}else{toast('✅ Đã lưu ảnh slide!');} - document.getElementById('slide-designer-overlay').remove(); - }catch(e){if(statusEl)statusEl.textContent='❌ '+e.message;toast('❌ '+e.message);} - },'image/png'); - } - img.onload=function(){ - const iw=img.naturalWidth||1,ih=img.naturalHeight||1; - const scale=Math.max(FINAL_W/iw,FINAL_H/ih); - const dw=iw*scale,dh=ih*scale; - const dx=(FINAL_W-dw)/2,dy=(FINAL_H-dh)/2; - ctx.drawImage(img,0,0,iw,ih,dx,dy,dw,dh); - drawLayoutF();drawTextF(); - canvas.toBlob(async function(blob){ - const formData=new FormData();formData.append('file',blob,'slide_'+slideIdx+'.png');formData.append('post_id',postId||p.id||''); - try{const r=await fetch('/api/wall/img',{method:'POST',body:formData});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi upload'); - s.image=j.url; - const post={id:p.id,title:title||p.title,text:p.text,img:j.url,url:p.url,slides:p.slides,images:p.images,voice:p.voice,emotion:p.emotion,language:p.language,ts:p.ts,kind:p.kind||'slide_summary'}; - const wr=await fetch('/api/wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(post)}); - const wj=await wr.json();if(wr.ok&&wj.post){prependWallPost(wj.post);toast('✅ Đã lưu ảnh đăng lên Tường AI!');}else{toast('✅ Đã lưu ảnh slide!');} - document.getElementById('slide-designer-overlay').remove(); - }catch(e){if(statusEl)statusEl.textContent='❌ '+e.message;toast('❌ '+e.message);} - },'image/png'); - }; - img.onerror=function(){if(statusEl)statusEl.textContent='⚠️ Không tải được ảnh nền, dùng nền mặc định';ctx.fillStyle='#1a1a1a';ctx.fillRect(0,0,FINAL_W,FINAL_H);drawLayoutF();drawTextF();canvas.toBlob(function(blob){ - const formData=new FormData();formData.append('file',blob,'slide_'+slideIdx+'.png');formData.append('post_id',postId||p.id||''); - fetch('/api/wall/img',{method:'POST',body:formData}).then(r=>r.json()).then(j=>{ - if(j.ok){s.image=j.url;const post={id:p.id,title:title||p.title,text:p.text,img:j.url,url:p.url,slides:p.slides,images:p.images,voice:p.voice,emotion:p.emotion,language:p.language,ts:p.ts,kind:p.kind||'slide_summary'}; - fetch('/api/wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(post)}).then(r=>r.json()).then(wj=>{if(wj.post)prependWallPost(wj.post);toast('✅ Đã lưu ảnh đăng lên Tường AI!');document.getElementById('slide-designer-overlay').remove();});} - }).catch(e=>{if(statusEl)statusEl.textContent='❌ '+e.message;toast('❌ '+e.message);}); - },'image/png');}; - if(bgUrl){img.src=bgUrl.startsWith('/api/')?bgUrl:bgUrl.startsWith('data:')?bgUrl:'/api/proxy/img?url='+encodeURIComponent(bgUrl);}else{render();} -} - -function readNewsTab(tab){loadNewsTab();} -function loadNewsTab(){const el=document.getElementById('view-cat');if(!el)return;el.innerHTML='
Đang tải tin tức...
';fetch('/api/homepage').then(r=>r.json()).then(articles=>{if(!articles||!articles.length){el.innerHTML='
Không có tin
';return}let h='
';articles.forEach(a=>{const src=a.source||'vne';const badge=a.group||a.source||'';h+=`
${a.img?``:''}
${esc(badge)}
${esc(a.title)}
`;});h+='
';el.innerHTML=h;}).catch(()=>{el.innerHTML='
Lỗi tải
';});} - -function readArticle(url,title,img,presetTitle,presetText){showView('view-article');const el=document.getElementById('view-article');el.innerHTML='
Đang tải...
';if(presetTitle){el.innerHTML=`

${esc(presetTitle)}

${presetText?`
${esc(presetText)}
`:''}
`;return;}if(!url)return;fetch('/api/article?url='+encodeURIComponent(url)).then(r=>r.json()).then(d=>{let h=`
`;if(d.title)h+=`

${esc(d.title)}

`;if(d.summary)h+=`
${esc(d.summary)}
`;if(d.body)d.body.forEach(b=>{if(b.type==='p')h+=`

${esc(b.text)}

`;else if(b.type==='heading')h+=`

${esc(b.text)}

`;else if(b.type==='img'&&b.src)h+=``;});h+=`
`;el.innerHTML=h;}).catch(()=>{el.innerHTML=`

Không thể tải bài viết

`;});} -async function rewriteSlide(url){if(!url)return;const btn=document.querySelector('.article-actions .primary')||event?.target;if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo slides...';}toast('⏳ Đang tạo slide rewrite...');try{const r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');toast('✅ Đã tạo slide! Đang tạo Short AI...');if(btn)btn.textContent='✅ Đang tạo Short AI...';if(j.post){j.post.slides=j.slides||[];prependWallPost(j.post);}// Show slides immediately if available -if(j.slides && j.slides.length){showView('view-article');const el=document.getElementById('view-article');let h=`
`;j.slides.forEach(s=>{h+=`
Slide ${s.index}/${j.slides.length}
${s.image?``:''}

${esc(s.text)}

`;});h+=`
⏳ Đang tạo video Short AI...
`;el.innerHTML=h;} -const postId=j.post&&j.post.id;if(postId){setTimeout(async()=>{const sr=await fetch('/api/ai/short/'+encodeURIComponent(postId),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:'vi-VN-HoaiMyNeural',emotion:'neutral',speed:1.2})});const sj=await sr.json();if(sr.ok&&sj.video&&typeof _wallPosts!=='undefined'){const p=_wallPosts.find(x=>String(x.id)===String(postId));if(p){p.video=sj.video;const itemId='wall-item-'+postId;const el2=document.getElementById(itemId);if(el2){const idx=_wallPosts.indexOf(p);el2.insertAdjacentHTML('afterend',makeWallItem(p,idx));el2.remove();}}toast('✅ Short AI đã sẵn sàng!');const statusEl=document.getElementById('short-ai-status');if(statusEl)statusEl.innerHTML='✅ Short AI đẵn sàng! ';}},500);}if(btn)btn.textContent='✅ Hoàn tất';}catch(e){toast('❌ '+e.message);if(btn){btn.disabled=false;btn.textContent='🤖 Slide Rewrite AI';}}} - -function downloadVideo(url, title){ - var a = document.createElement('a'); - a.href = url; - a.download = (title||'video').toString().replace(/[^a-zA-Z0-9_\-\p{L}]/gu,'_').substring(0,60)+'.mp4'; - a.target = '_blank'; - a.rel = 'noopener'; - a.style.display = 'none'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - toast('📥 Đang tải video xuống...'); -} - -// ===== PERSONAL OPINION POST: Viết bài dựa trên quan điểm cá nhân + tin HOT ===== -async function openPersonalPostPreview() { - const opinion = document.getElementById('opinion-input')?.value.trim(); - if (!opinion || opinion.length < 10) { - alert('Vui lòng nhập quan điểm cá nhân (ít nhất 10 ký tự)'); - return; - } - - // Get selected hot topics from UI (if any) - const selectedTopics = []; - if (window._htTopic) { - selectedTopics.push(window._htTopic); - } - - // Get selected sources from hashtag view - const selectedSources = []; - document.querySelectorAll('.hashtag-src-item.selected').forEach(el => { - const idx = parseInt(el.dataset.idx || '0'); - // Would need source tracking - }); - - const btn = event?.target; - const origText = btn ? btn.textContent : ''; - if (btn) { btn.disabled = true; btn.textContent = '⏳ Đang tạo preview...'; } - - try { - const resp = await fetch('/api/personal_post/preview', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - opinion: opinion, - selected_topics: selectedTopics, - selected_sources: selectedSources - }) - }); - const data = await resp.json(); - if (!resp.ok || data.error) throw new Error(data.error || 'Lỗi tạo preview'); - - showPersonalPostModal(data.preview, opinion, selectedTopics, selectedSources); - } catch (e) { - toast('❌ ' + e.message); - } finally { - if (btn) { btn.disabled = false; btn.textContent = origText; } - } -} - -function showPersonalPostModal(preview, originalOpinion, selectedTopics, selectedSources) { - // Remove existing modal - const existing = document.getElementById('personal-post-modal'); - if (existing) existing.remove(); - - const modal = document.createElement('div'); - modal.id = 'personal-post-modal'; - modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.9);z-index:99999;display:flex;align-items:center;justify-content:center;padding:16px;overflow-y:auto'; - - // Each slide renders EXACTLY like the designer's final PNG (same ratio, - // same bg draw, same text draw) — preview = pixel-identical to wall post image. - const slidesHtml = (preview.slides || []).map((s, i) => { - const ratio = {w:3, h:4}; // preview always 3:4 (same as designer final) - const slideStyle = 'width:100%;max-width:280px;margin:0 auto;display:block;border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,.5)'; - return '
' - + '
Slide ' + (i+1) + '
' - + '' - + '' - + '
'; - }).join(''); - - // Background image source: uploaded data-URL (set later) or slide.image / first source image. - // We keep a small state object so text edits re-render live. - window._pvState = { slides: (preview.slides||[]).map(function(s,i){ - var bg = s.image || (preview.images && preview.images[0]) || ''; - return { text: s.text||'', bg: bg }; - }), ratio: 3/4, layout: 'solid', filter: 'none', glow: 'white', pos: 'bottom', color: '#ffffff' }; - - const sourcesHtml = (preview.sources || []).map((s, i) => { - const imgSrc = (preview.images && preview.images[i+1]) ? preview.images[i+1] : ''; - return '
' - + '
' - + (imgSrc ? '' : '') - + '
' - + '
' + (s.title || '') + '
' - + '' - + '
'; - }).join(''); - - modal.innerHTML = '
' - + '
' - + '

📝 Xem trước bài quan điểm cá nhân

' - + '' - + '
' - + '
' - + '' - + '' - + '
' - + '
' - + '' - + '' - + '
' - + '
' - + '' - + '
' - + '' - + '' - + '
' - + '
' + slidesHtml + '
' - + '
' - + '
' - + '' - + '
' - + (sourcesHtml || '
Không có nguồn tin
') - + '
' - + '
' - + '' - + '' - + '
'; - - document.body.appendChild(modal); - - // draw every slide preview canvas - previewRedrawAllSlides(); - - // text edits re-render live - modal.querySelectorAll('.preview-slide-text').forEach(ta => { - ta.addEventListener('input', function(){ - const idx = parseInt(this.dataset.idx || '0', 10); - if(window._pvState && window._pvState.slides[idx]) window._pvState.slides[idx].text = this.value; - previewDrawSlide(idx); - }); - }); - - // Store preview data for publishing - window._personalPostPreview = preview; - window._personalPostOpinion = originalOpinion; -} - -/* Live-edit slide preview canvas (exact same rendering style as designer output) */ -function previewDrawSlide(idx){ - const canvas = document.querySelector('.preview-slide-canvas[data-idx="'+idx+'"]'); - if(!canvas || !window._pvState) return; - const st = window._pvState.slides[idx]; - if(!st) return; - const W = 600, H = Math.round(W * window._pvState.ratio); // 3:4 -> 800 - canvas.width = W; canvas.height = H; - const ctx = canvas.getContext('2d'); - const opts = { text: st.text, pos: window._pvState.pos, glow: window._pvState.glow, - color: window._pvState.color, layout: window._pvState.layout, - filter: window._pvState.filter, bgUrl: st.bg }; - function draw(img){ - // base dark - ctx.fillStyle='#141414'; ctx.fillRect(0,0,W,H); - if(img){ - ctx.save(); - let f=''; - if(opts.filter==='grayscale') f='grayscale(1)'; - else if(opts.filter==='sepia') f='sepia(0.85)'; - else if(opts.filter==='saturate') f='saturate(2.4)'; - else if(opts.filter==='warm') f='sepia(0.45) saturate(1.5) hue-rotate(-15deg)'; - else if(opts.filter==='cool') f='saturate(1.2) hue-rotate(15deg) brightness(1.05)'; - else if(opts.filter==='invert') f='invert(1)'; - else if(opts.filter==='noir') f='grayscale(1) contrast(1.6) brightness(0.9)'; - const iw=img.naturalWidth||1, ih=img.naturalHeight||1; - const scale=Math.max(W/iw, H/ih); - const dw=iw*scale, dh=ih*scale; - ctx.drawImage(img,0,0,iw,ih,(W-dw)/2,(H-dh)/2,dw,dh); - ctx.filter='none'; - ctx.restore(); - } - // solid overlay like designer - ctx.fillStyle='rgba(0,0,0,0.6)'; ctx.fillRect(0,0,W,H); - drawPreviewText(ctx,W,H,opts); - } - const bgSrc = (typeof _proxyImg==='function') ? _proxyImg(st.bg||'') : (st.bg || ''); - if(bgSrc){ - const im = new Image(); im.crossOrigin='anonymous'; - im.onload = function(){ draw(im); }; - im.onerror = function(){ draw(null); }; - im.src = bgSrc; - } else draw(null); -} -function drawPreviewText(ctx,W,H,opts){ - const text = opts.text || ''; - if(!text.trim()) return; - const fs = Math.min(52, Math.round(W*0.09)); - ctx.font = 'bold '+fs+'px "Segoe UI","Arial","Noto Sans",sans-serif'; - ctx.textAlign='center'; - const lines=[]; let line=''; - const maxW=Math.round(W*0.82); - text.split(' ').forEach(w=>{ - const test = line? line+' '+w : w; - if(ctx.measureText(test).width>maxW && line){ lines.push(line.trim()); line=w; } else { line=test; } - }); - if(line) lines.push(line.trim()); - const lh=Math.round(fs*1.25); - const blockH=lines.length*lh; - let y; - if(opts.pos==='top') y=Math.round(H*0.14)+lh/2; - else if(opts.pos==='bottom') y=Math.round(H*0.88)-blockH+lh/2; - else y=Math.round(H/2)-blockH/2+lh/2; - if(opts.glow==='white'){ ctx.shadowColor='rgba(255,255,255,0.95)'; ctx.shadowBlur=Math.max(8,fs*0.5); } - else if(opts.glow==='color'){ ctx.shadowColor=opts.color; ctx.shadowBlur=Math.max(10,fs*0.6); } - else if(opts.glow==='neon'){ ctx.shadowColor='#00ffff'; ctx.shadowBlur=Math.max(16,fs*0.9); } - ctx.fillStyle=opts.color; - lines.forEach((l,li)=>{ ctx.fillText(l, W/2, y+li*lh); }); - ctx.shadowBlur=0; -} -function previewRedrawAllSlides(){ - if(!window._pvState) return; - window._pvState.slides.forEach(function(_, i){ previewDrawSlide(i); }); -} -function openPreviewDesigner(){ - // opens the real slide designer on the FIRST preview slide as a background. - // The designer modal already renders canvas + uploads PNGs; we reuse it by - // temporarily creating a wall-post-like object from the preview slides. - if(!window._personalPostPreview || !window._pvState) return; - const slides = window._pvState.slides.map(function(s,i){ - return { text: s.text, image: s.bg, index: i+1 }; - }); - const fakePost = { - id: 'preview-fake', title: document.getElementById('preview-title')?.value || 'Bài quan điểm', - slides: slides, kind: 'personal_opinion', img: slides[0]?.image||'', url: '', video: '' - }; - if(typeof openSlideDesigner === 'function'){ - // designer expects index into _wallPosts; temporarily append so designer works - const existed = _wallPosts && _wallPosts.some(p => p.id === 'preview-fake'); - if(!existed && Array.isArray(_wallPosts)) _wallPosts.unshift(fakePost); - const idx = _wallPosts.findIndex(p => p.id === 'preview-fake'); - openSlideDesigner(idx >= 0 ? idx : 0); - // the fake post is only a designer context; the designer's own save posts - // the designed slides directly to the wall. Remove the fake entry on close. - setTimeout(function(){ - const ov = document.getElementById('slide-designer-overlay'); - if(!ov && Array.isArray(_wallPosts)){ - _wallPosts = _wallPosts.filter(p => p.id !== 'preview-fake'); - } - }, 30000); - } else { - toast('Không có công cụ thiết kế!'); - } -} - -function toggleSourceSelection(el, idx) { - const checkbox = el.querySelector('.source-checkbox'); - checkbox.checked = !checkbox.checked; - el.style.border = checkbox.checked ? '1px solid #2d8659' : '1px solid transparent'; - el.style.background = checkbox.checked ? '#1a2a1f' : '#202020'; -} - -async function publishPersonalPostFromModal() { - const title = document.getElementById('preview-title')?.value.trim(); - if (!title) { - alert('Vui lòng nhập tiêu đề'); - return; - } - - // Collect edited slides (text + designed/bg image from _pvState) - const slides = []; - if (window._pvState && window._pvState.slides) { - window._pvState.slides.forEach((s, i) => { - const text = (s.text || '').trim(); - if (text) slides.push({ text, image: s.bg || '', index: slides.length + 1 }); - }); - } else { - document.querySelectorAll('.preview-slide-text').forEach(ta => { - const text = ta.value.trim(); - if (text) slides.push({ text, image: '', index: slides.length + 1 }); - }); - } - - // Collect selected sources - const sources = []; - document.querySelectorAll('[data-source-idx]').forEach((el, i) => { - const checkbox = el.querySelector('.source-checkbox'); - if (checkbox.checked && window._personalPostPreview?.sources?.[i]) { - sources.push(window._personalPostPreview.sources[i]); - } - }); - - const btn = event.target; - const origText = btn.textContent; - btn.disabled = true; - btn.textContent = '⏳ Đang đăng...'; - - try { - const resp = await fetch('/api/personal_post', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - opinion: window._personalPostOpinion, - selected_topics: [], - selected_sources: sources, - custom_title: title, - custom_slides: slides - }) - }); - const data = await resp.json(); - if (!resp.ok || data.error) throw new Error(data.error || 'Lỗi đăng bài'); - - toast('✅ Đã đăng bài quan điểm lên Tường AI!'); - - // Prepend to wall - if (data.post && typeof prependWallPost === 'function') { - prependWallPost(data.post); - } - - // Close modal - document.getElementById('personal-post-modal')?.remove(); - - // Clear opinion input - document.getElementById('opinion-input').value = ''; - } catch (e) { - toast('❌ ' + e.message); - } finally { - btn.disabled = false; - btn.textContent = origText; - } -} - diff --git a/app_v2_entry.py b/app_v2_entry.py index ed4c2085dc802fd6ef51acbbdee94b80f2bab891..17354c2fc4186b8c4f05c5699594755f3495a511 100644 --- a/app_v2_entry.py +++ b/app_v2_entry.py @@ -1,240 +1,41 @@ -"""VNEWS v2 Entry Point - with fast bongda proxy + rewrite endpoints + multilingual TTS""" +"""VNEWS v2 Entry Point - with AI endpoints + friendly highlights""" import sys, os from main import app, HEADERS, BONGDA_HEADERS, fetch_bongda_api, HL_LEAGUES +# Import AI extension to get /api/rewrite_share, /api/topic_post, /api/ai_wall endpoints try: import ai_ext except Exception as e: print(f"[WARN] ai_ext import failed: {e}") -try: - import ai_patch -except Exception as e: - print(f"[WARN] ai_patch import failed: {e}") - -try: - import ai_short_v2 # Short AI v2: TikTok music, uploaded audio/video/img, recreate from slides -except Exception as e: - print(f"[WARN] ai_short_v2 import failed: {e}") - -from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response, StreamingResponse +from fastapi.responses import HTMLResponse, JSONResponse, FileResponse from fastapi.staticfiles import StaticFiles from starlette.routing import Mount -from fastapi import Query, Request, UploadFile, File, Form +from fastapi import Query, Request import requests as req +from urllib.parse import quote from bs4 import BeautifulSoup -import re, html as html_lib, json, threading, time, uuid +import re, html as html_lib, json, threading, time from concurrent.futures import ThreadPoolExecutor, as_completed -from urllib.parse import quote -import asyncio HL_LEAGUES['friendly'] = {"path": "giai-khac/friendly", "name": "Giao hữu", "emoji": "🤝"} STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static') -SPACE = "https://bep40-vnews.hf.space" # SEO URL base for share links +# Only remove '/' route, keep all other routes (including AI endpoints) app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)=='/' and hasattr(r,'methods') and 'GET' in getattr(r,'methods',set()))] +# Remove only Mount objects (old static mounts), NOT API routes app.routes[:]=[r for r in app.routes if not isinstance(r, Mount)] +# Clean mounts from router too app.router.routes=[r for r in app.router.routes if not isinstance(r, Mount)] -def _clean(s): return re.sub(r"\s+"," ",html_lib.unescape(str(s or""))).strip() - -def _ensure_sentence_complete(text): - """Ensure text ends with complete sentence (ends with . ! or ?). Trim if cut mid-sentence.""" - text = _clean(text) - if not text: - return text - # Find last sentence ending - for end_char in ['.', '!', '?']: - last_pos = text.rfind(end_char) - if last_pos > len(text) * 0.5: # Keep if ending is in latter half - return text[:last_pos + 1].strip() - # If no ending found, try to find last complete sentence - sentences = re.split(r'(?<=[.!?])\s+', text) - complete = [s.strip() for s in sentences if s.strip() and len(s.strip()) > 20] - if complete[:-1]: # Return all but last incomplete - return ' '.join(complete[:-1]) - return text[:150] + '.' if len(text) > 150 else text - -# Cache for match details (5 min TTL) -_match_cache = {} - -# === FAST BONGDA PROXY ENDPOINT === -def _get_match_detail(event_id, slug=None): - headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "text/html", "Referer": "https://bongda.com.vn/"} - if slug: - url = f"https://bongda.com.vn/tran-dau/{event_id}/centre/{slug}" - else: - url = f"https://bongda.com.vn/tran-dau/{event_id}" - resp = req.get(url, headers=headers, timeout=15, allow_redirects=True) - if resp.status_code != 200: - return None - soup = BeautifulSoup(resp.text, 'html.parser') - result = {"event_id": event_id, "found": False, "sections": []} - info = {} - tel = soup.select_one('.teams') - if tel: - he = tel.select_one('.team.home') - if he: - p_tags = [p for p in he.select('p') if not p.get('class') or 'logo' not in p.get('class', [])] - if p_tags: info['home_team'] = _clean(p_tags[0].get_text()) - lo = he.select_one('img') - if lo: info['home_logo'] = lo.get('src', '') - ae = tel.select_one('.team.away') - if ae: - p_tags = ae.select('p') - team_ps = [p for p in p_tags if not p.get('class') or 'logo' not in p.get('class', [])] - if team_ps: info['away_team'] = _clean(team_ps[-1].get_text()) - lo = ae.select_one('img') - if lo: info['away_logo'] = lo.get('src', '') - sc = tel.select_one('.score') - if sc: - parts = [_clean(p.get_text()) for p in sc.select('p')] - if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}" - lb = sc.select_one('.label') - if lb: info['status_label'] = _clean(lb.get_text()) - if info.get('home_team') and info.get('away_team'): - result['info'] = info - result['found'] = True - result['sections'].append('info') - events = [] - for ev in soup.select('.events .period .event'): - ev_cls = ' '.join(ev.get('class', [])) - ev_data = {'team': 'home' if 'home' in ev_cls else 'away', 'period': '', 'type': 'unknown', 'time': '', 'players': ''} - parent = ev.parent - if parent: - h2 = parent.find('h2') - if h2: ev_data['period'] = _clean(h2.get_text()) - if ev.select_one('[class*="goal"]'): ev_data['type'] = 'goal' - elif ev.select_one('[class*="redcard"]'): ev_data['type'] = 'redcard' - elif ev.select_one('[class*="yellowcard"]'): ev_data['type'] = 'yellowcard' - elif ev.select_one('[class*="substitution"]'): ev_data['type'] = 'substitution' - players_el = ev.select_one('.players') - if players_el: - pl_text = _clean(players_el.get_text(' ', strip=True)) - m = re.match(r"(\d+)'(.*)", pl_text) - if m: - ev_data['time'] = f"{m.group(1)}'" - ev_data['players'] = m.group(2) - else: - ev_data['players'] = pl_text - events.append(ev_data) - if events: - result['events'] = events - result['sections'].append('events') - pred = soup.select_one('.prediction-card') - if pred: - team_info = pred.select_one('.team-info') - if team_info: - teams = team_info.select('.team') - pred_data = {} - if len(teams) >= 2: - pred_data['home_name'] = _clean(teams[0].select_one('.team-name').get_text()) if teams[0].select_one('.team-name') else '' - pred_data['away_name'] = _clean(teams[1].select_one('.team-name').get_text()) if teams[1].select_one('.team-name') else '' - divider = team_info.select_one('.divider') - if divider: pred_data['result'] = _clean(divider.get_text()) - vc = pred.select_one('.vote-count') - if vc: pred_data['vote_count'] = _clean(vc.get_text()) - result['prediction'] = pred_data - recent = [] - ml = soup.select_one('.matches-list') - if ml: - for item in ml.select('.match-detail, .match-item, li'): - de = item.select_one('.date, .time') - le = item.select_one('.league') - he_item = item.select_one('.home, .team-home') - ae_item = item.select_one('.away, .team-away') - se = item.select_one('.score, .result') - if he_item or ae_item: - recent.append({'date': _clean(de.get_text()) if de else '', 'league': _clean(le.get_text()) if le else '', 'home': _clean(he_item.get_text()) if he_item else '', 'away': _clean(ae_item.get_text()) if ae_item else '', 'score': _clean(se.get_text()) if se else 'vs'}) - if recent: - result['recent_matches'] = recent - result['sections'].append('recent') - try: - api_h = {"User-Agent": "Mozilla/5.0", "Accept": "application/json", "X-Requested-With": "XMLHttpRequest", "Referer": "https://bongda.com.vn/"} - ar = req.get(f"https://bongda.com.vn/api/fixtures/h2h-stats?event_id={event_id}", headers=api_h, timeout=10) - if ar.status_code == 200: - ad = ar.json() - if ad.get('status') == 'success' and ad.get('html'): - asp = BeautifulSoup(ad['html'], 'html.parser') - ast = {} - for row in asp.select('li, tr'): - cells = row.select('td, span, p') - if len(cells) >= 3: - lb = _clean(cells[0].get_text()) - if lb: ast[lb] = {'home': _clean(cells[1].get_text()), 'away': _clean(cells[2].get_text())} - if ast: - result['h2h_stats_parsed'] = ast - result['sections'].append('h2h_stats') - except: pass - return result - -@app.get('/api/proxy/bongda') -def proxy_bongda(event_id: int = Query(default=None), slug: str = Query(default=None)): - if event_id is None: - return JSONResponse({'error': 'event_id required'}, status_code=400) - cache_key = f"{event_id}_{slug}" - now = time.time() - cached = _match_cache.get(cache_key) - if cached and now - cached.get('_ts', 0) < 300: - return JSONResponse(cached) - try: - result = _get_match_detail(event_id, slug) - if result: - result['_ts'] = now - _match_cache[cache_key] = result - return JSONResponse(result) - except Exception as e: - err = {"event_id": event_id, "found": False, "error": str(e), "_ts": now} - _match_cache[cache_key] = err - return JSONResponse(err) - return JSONResponse({"event_id": event_id, "found": False}) - -@app.get('/api/match/{event_id}/detail') -def api_match_detail(event_id: int, url: str = Query(default=None)): - slug = None - if url: - m = re.match(r'.+/tran-dau/\d+/(?:centre|preview)/(.+)', url) - if m: - slug = m.group(1) - cache_key = f"{event_id}_{slug or ''}" - now = time.time() - cached = _match_cache.get(cache_key) - if cached and now - cached.get('_ts', 0) < 300: - return JSONResponse(cached) - try: - if not slug: - try: - home_r = req.get("https://bongda.com.vn/", headers={"User-Agent": "Mozilla/5.0"}, timeout=10) - if home_r.status_code == 200: - home_soup = BeautifulSoup(home_r.text, 'html.parser') - for a in home_soup.select(f'a[href*="/tran-dau/{event_id}/"]'): - href = a.get('href', '') - m = re.match(r'/tran-dau/\d+/(?:centre|preview)/(.+)', href) - if m: - slug = m.group(1) - cache_key = f"{event_id}_{slug}" - break - except: pass - result = _get_match_detail(event_id, slug) - if result: - result['_ts'] = now - _match_cache[cache_key] = result - return JSONResponse(result) - except Exception as e: - err = {"event_id": event_id, "found": False, "error": str(e), "_ts": now} - _match_cache[cache_key] = err - return JSONResponse(err) - return JSONResponse({"event_id": event_id, "found": False}) - +def _clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or""))).strip() _STOP=set('và của các những một được trong với cho tại sau trước khi không người việt nam hôm nay mới nhất nóng tin tức cập nhật theo từ đến là có thì này đã để'.split()) - def _has_kw(topic,title): tl=topic.lower();tt=(title or'').lower() if tl in tt:return True - words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',tl) if len(w)>2 and w.lower() not in _STOP] + words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',tl) if len(w)>2 and w not in _STOP] if not words:return True return any(w in tt for w in words) - def _s_vnexpress(topic,limit=8): items=[] try: @@ -246,7 +47,6 @@ def _s_vnexpress(topic,limit=8): if _has_kw(topic,t):items.append({'title':t,'url':a['href'],'via':'VnExpress'}) except:pass return items - def _s_dantri(topic,limit=8): items=[] try: @@ -259,20 +59,18 @@ def _s_dantri(topic,limit=8): if len(items)>=limit:break except:pass return items - def _s_vietnamnet(topic,limit=6): items=[] try: r=req.get(f"https://vietnamnet.vn/tim-kiem?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml') for a in soup.select('h3 a[href], .vnn-title a')[:limit*2]: - t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','') + t=_clean(a.get_text(strip=True));href=a.get('href','') if t and len(t)>15 and _has_kw(topic,t): if not href.startswith('http'):href='https://vietnamnet.vn'+href items.append({'title':t,'url':href,'via':'VietNamNet'}) if len(items)>=limit:break except:pass return items - def _s_bongda(topic,limit=5): items=[] try: @@ -285,7 +83,6 @@ def _s_bongda(topic,limit=5): if len(items)>=limit:break except:pass return items - def _s_genk(topic,limit=5): items=[] try: @@ -298,7 +95,6 @@ def _s_genk(topic,limit=5): if len(items)>=limit:break except:pass return items - def _s_thanhnien(topic,limit=6): items=[] try: @@ -311,7 +107,6 @@ def _s_thanhnien(topic,limit=6): if len(items)>=limit:break except:pass return items - def _s_tuoitre(topic,limit=6): items=[] try: @@ -324,7 +119,6 @@ def _s_tuoitre(topic,limit=6): if len(items)>=limit:break except:pass return items - def _s_thethaovanhoa(topic,limit=5): items=[] try: @@ -337,7 +131,6 @@ def _s_thethaovanhoa(topic,limit=5): if len(items)>=limit:break except:pass return items - def _search_all(topic,limit=36): results={} with ThreadPoolExecutor(8) as ex: @@ -345,174 +138,66 @@ def _search_all(topic,limit=36): for f in as_completed(futs,timeout=14): try:results[futs[f]]=f.result() except:results[futs[f]]=[] - srcs=list(results.values());out=[];seen=set() - for i in range(max((len(s) for s in srcs),default=0)): + srcs=list(results.values());out=[];seen=set();mx=max((len(s) for s in srcs),default=0) + for i in range(mx): for s in srcs: if i= 2: - seen_imgs = set() - for child in el.find_all(['p','h2','h3','figure','img'], recursive=True): - if child.name == 'p': - t = child.get_text(strip=True) - if t and len(t) > 15: - body.append({'type': 'p', 'text': t}) - elif child.name in ('h2','h3'): - t = child.get_text(strip=True) - if t: - body.append({'type': 'heading', 'text': t}) - elif child.name in ('figure','img'): - im = child if child.name == 'img' else child.find('img') - if im: - src = im.get('data-src') or im.get('src') or im.get('data-lazy') or '' - if src and 'base64' not in src and src not in seen_imgs: - seen_imgs.add(src) - if src.startswith('//'): src = 'https:' + src - body.append({'type': 'img', 'src': src}) - if child.name == 'figure': - cap = child.find('figcaption') - if cap: - ct = cap.get_text(strip=True) - if ct: body.append({'type': 'p', 'text': ct}) - if len(body) >= 2: - return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img, - 'body': body[:50], 'source': domain, 'url': url} - if title and (summary or og_img): - fallback = [] - if og_img: fallback.append({'type': 'img', 'src': og_img}) - if summary: fallback.append({'type': 'p', 'text': summary}) - if fallback: - return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img, - 'body': fallback, 'source': domain, 'url': url, 'fallback': True} - if title: - return {'title': _clean(title), 'summary': '', 'og_image': '', - 'body': [{'type': 'p', 'text': 'Nội dung đang được tải...'}], - 'source': domain, 'url': url, 'fallback': True} - break - except Exception: - continue - return None - +# Override article endpoint +app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)=='/api/article' and 'GET' in getattr(r,'methods',set()))] +def _scrape_generic(url): + try: + r=req.get(url,headers={'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36','Accept-Language':'vi-VN,vi;q=0.9'},timeout=15,allow_redirects=True);r.encoding='utf-8';soup=BeautifulSoup(r.text,'lxml') + for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript']):tag.decompose() + 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 '') + ogd=soup.find('meta',property='og:description');summary=ogd.get('content','') if ogd else '' + ogi=soup.find('meta',property='og:image');og_img=ogi.get('content','') if ogi else '' + if og_img and og_img.startswith('//'):og_img='https:'+og_img + 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 + body=[] + for el in block.find_all(['p','h2','h3','figure','img'],recursive=True): + if el.name=='p':t=el.get_text(strip=True);(body.append({'type':'p','text':t}) if t and len(t)>30 else None) + elif el.name in('h2','h3'):t=el.get_text(strip=True);(body.append({'type':'heading','text':t}) if t else None) + elif el.name in('figure','img'): + im=el if el.name=='img' else el.find('img') + if im:src=im.get('data-src') or im.get('src') or'';(body.append({'type':'img','src':'https:'+src if src.startswith('//') else src}) if src and'base64' not in src else None) + if not body and summary:body=[{'type':'p','text':summary}] + return{'title':_clean(title),'summary':_clean(summary),'og_image':og_img,'body':body[:50],'source':'generic','url':url} + except:return None @app.get('/api/article') -def api_article_v2(url: str = Query(...)): - from urllib.parse import unquote - safe_url = unquote(url) - try: - now = time.time() - cached = _article_cache.get(safe_url) - if cached and now - cached['t'] < _article_cache_ttl: - resp = JSONResponse(cached['d']) - resp.headers["Cache-Control"] = "public, max-age=1800" - return resp - data = _scrape_article_fast(safe_url) - if data and data.get('body'): - _article_cache[safe_url] = {'d': data, 't': now} - resp = JSONResponse(data) - resp.headers["Cache-Control"] = "public, max-age=1800" - return resp - result = {'error': 'Không đọc được', 'url': safe_url} - resp = JSONResponse(result) - resp.headers["Cache-Control"] = "public, max-age=60" - return resp - except Exception as e: - return JSONResponse({'error': f'Server error: {str(e)[:100]}', 'url': safe_url}, status_code=200) - +def api_article_v2(url:str=Query(...)): + from main import scrape_vne_article,scrape_bbc_article,scrape_dantri_article,scrape_genk_article,scrape_ttvh_article + if 'vnexpress.net' in url:data=scrape_vne_article(url) + elif 'bbc.com' in url:data=scrape_bbc_article(url) + elif 'dantri.com.vn' in url:data=scrape_dantri_article(url) + elif 'genk.vn' in url:data=scrape_genk_article(url) + elif 'thethaovanhoa.vn' in url:data=scrape_ttvh_article(url) + else:data=_scrape_generic(url) + if data and data.get('body'):return JSONResponse(data) + data=_scrape_generic(url);return JSONResponse(data if data else{'error':'Không đọc được','url':url}) _hot_cache={'t':0,'d':[]} -def _parse_feed(feed_url): - try: - r=req.get(feed_url,headers={'User-Agent':'Mozilla/5.0'},timeout=4) - r.encoding='utf-8';soup=BeautifulSoup(r.text,'xml') - out=[] - for item in soup.find_all('item')[:12]: - t=item.find('title') - title=_clean(t.get_text() if t else '') - if title: out.append(title) - return out - except Exception: - return [] def _get_hot_topics(): now=time.time() if _hot_cache['d'] and now-_hot_cache['t']<600:return _hot_cache['d'] - feeds=['https://vnexpress.net/rss/tin-moi-nhat.rss','https://dantri.com.vn/rss/home.rss','https://vietnamnet.vn/rss/tin-moi-nhat.rss','https://thanhnien.vn/rss/home.rss','https://tuoitre.vn/rss/tin-moi-nhat.rss','https://genk.vn/rss','https://vnexpress.net/rss/the-thao.rss','https://thethaovanhoa.vn/rss/tin-nong.rss'] - titles=[] - with ThreadPoolExecutor(8) as ex: - futs=[ex.submit(_parse_feed,f) for f in feeds] - for f in as_completed(futs,timeout=5): - try: - for t in f.result(): titles.append(t) - except Exception: pass freq={};display={} - for title in titles: - if not title: continue - title=re.sub(r'\s*[-|].*$','',title) - words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',title) if len(w)>2 and w.lower() not in _STOP] - if len(words)<2:continue - for n in(3,4,2): - for i in range(max(0,len(words)-n+1)): - phrase=' '.join(words[i:i+n]) - if 8<=len(phrase)<=45:key=phrase.lower();freq[key]=freq.get(key,0)+1;display[key]=phrase + feeds=['https://vnexpress.net/rss/tin-moi-nhat.rss','https://dantri.com.vn/rss/home.rss','https://vietnamnet.vn/rss/tin-moi-nhat.rss','https://thanhnien.vn/rss/home.rss','https://tuoitre.vn/rss/tin-moi-nhat.rss','https://genk.vn/rss','https://vnexpress.net/rss/the-thao.rss','https://thethaovanhoa.vn/rss/tin-nong.rss'] + for feed_url in feeds: + try: + r=req.get(feed_url,headers={'User-Agent':'Mozilla/5.0'},timeout=6);r.encoding='utf-8';soup=BeautifulSoup(r.text,'xml') + for item in soup.find_all('item')[:12]: + title=_clean(item.find('title').get_text() if item.find('title') else '') + if not title:continue + title=re.sub(r'\s*[-|].*$','',title);words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',title) if len(w)>2 and w.lower() not in _STOP] + if len(words)<2:continue + for n in(3,4,2): + for i in range(max(0,len(words)-n+1)): + phrase=' '.join(words[i:i+n]) + if 8<=len(phrase)<=45:key=phrase.lower();freq[key]=freq.get(key,0)+1;display[key]=phrase + except:continue ranked=sorted(freq.items(),key=lambda x:x[1],reverse=True);topics=[];seen=set() for key,count in ranked: is_dup=any(len(set(e.split())&set(key.split()))/max(len(set(e.split())),len(set(key.split())),1)>0.6 for e in seen) @@ -523,12 +208,8 @@ def _get_hot_topics(): if len(topics)>=24:break if not any(kw.lower() in s for s in seen):topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw,'count':0}) _hot_cache.update({'t':now,'d':topics[:24]});return topics[:24] - @app.get('/api/hot_topics') -def api_hot_topics(): - resp = JSONResponse({'topics':_get_hot_topics()}) - resp.headers["Cache-Control"] = "public, max-age=120" - return resp +def api_hot_topics():return JSONResponse({'topics':_get_hot_topics()}) @app.get('/') async def serve_index(): p=os.path.join(STATIC_DIR,'index_v2.html') @@ -539,314 +220,14 @@ def _ht(topic:str=Query(...),page:int=Query(default=0)): items=_search_all(topic,36);per_page=8;start=page*per_page;end=start+per_page return JSONResponse({'sources':items[start:end],'topic':topic,'page':page,'has_more':end - - - - -{_clean(safe_title)} - - - - - - - -''' - for s in slides: - img_src = s.get('image', '') - if img_src and ('cdnphoto.dantri' in img_src or 'refooty' in img_src or 'vnexpress' in img_src or 'vcdn' in img_src): - img_tag = f'' - else: - img_tag = f'' if img_src else '' - h += f'
Slide {s.get("index",1)}/{len(slides)}
{img_tag}

{_clean(s.get("text",""))}

' - h += '' - return HTMLResponse(h) - -def _render_video_page(post, safe_title, safe_img, safe_url): - video_url = post.get('video', '') - # Use text for description if available - description = _clean((post.get('text') or '')[:200]) or "Tin tức tóm tắt, AI rewrite, World Cup 2026" - - # Build canonical URL preserving original query format if url was provided - if safe_url and safe_url != '/': - canonical_url = f"{SPACE}/s?url={quote(safe_url)}&title={quote(safe_title[:100])}" - else: - canonical_url = f"{SPACE}/s?post_id={post.get('id') or ''}" - - h = f''' - - - - -{_clean(safe_title)} - - - - - - - - - - -
- -
{_clean(safe_title)}
-
-''' - return HTMLResponse(h) - -@app.get('/s/{slug}') -async def _sh_slug(slug: str, request: Request, url: str = '', title: str = '', img: str = ''): - """SEO-friendly share endpoint with slug in URL path. - Shows slide content when slug matches a wall post ID, otherwise redirects. - """ - safe_title = _clean(title) if title else 'VNEWS - Tin tức' - safe_img = _clean(img) if img else '' - safe_url = _clean(url) if url else '/' - - # Try to find post by slug (post ID) - post = None - try: - if slug and len(slug) > 5: # Likely a post ID - posts = _load_wall_posts() - for p in posts: - if p.get('id') == slug: - post = p - safe_title = p.get('title', safe_title) or safe_title - safe_img = p.get('img', safe_img) or safe_img - safe_url = p.get('url', safe_url) or safe_url - break - except: - pass - - if post and post.get('slides'): - return _render_slides_page(post, safe_title, safe_img, safe_url) - - if post and post.get('video'): - return _render_video_page(post, safe_title, safe_img, safe_url) - - # Otherwise redirect - return HTMLResponse(f''' - - - - -{_clean(safe_title)} - - - - - - -''') - @app.get('/s') -async def _sh(url:str='',title:str='',img:str='',post_id:str=''): - safe_title = _clean(title) if title else 'VNEWS - Tin tức' - safe_img = _clean(img) if img else '' - safe_url = _clean(url) if url else '/' - - # Try to find wall post by post_id or URL (prioritize posts with slides/video) - post = None - try: - posts = _load_wall_posts() - if post_id: - for p in posts: - if p.get('id') == post_id: - post = p - safe_title = p.get('title', safe_title) or safe_title - safe_img = p.get('img', safe_img) or safe_img - safe_url = p.get('url', safe_url) or safe_url - break - elif url: - # Find matching URL - prioritize posts with slides or video - for p in posts: - if p.get('url') == url and p.get('slides'): - post = p - safe_title = p.get('title', safe_title) or safe_title - safe_img = p.get('img', safe_img) or safe_img - safe_url = p.get('url', safe_url) or safe_url - break - if not post: - # Fallback: find any matching URL - for p in posts: - if p.get('url') == url: - post = p - safe_title = p.get('title', safe_title) or safe_title - safe_img = p.get('img', safe_img) or safe_img - safe_url = p.get('url', safe_url) or safe_url - break - except: - pass - - if post and post.get('slides'): - return _render_slides_page(post, safe_title, safe_img, safe_url) - - if post and post.get('video'): - return _render_video_page(post, safe_title, safe_img, safe_url) - - # Fallback: redirect to original URL - return HTMLResponse(f''' - - - - -{safe_title} - - - - - - -''') - -from wc2026_scraper import scrape_summary,scrape_fixtures,scrape_standings,scrape_stats,scrape_wc_news,scrape_road_to_wc,get_wc2026_all,scrape_history,scrape_h2h,scrape_lineups,scrape_match_detail - -_xlb_cache = {} -_xlb_lock = threading.Lock() - -def _xlb_scrape(path): - url = f"https://xemlaibongda.top/{path}" - r = req.get(url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}, timeout=15, allow_redirects=True) - if r.status_code != 200: - return [] - soup = BeautifulSoup(r.text, 'lxml') - vids = [] - seen = set() - for a in soup.select('a[href*="/video/"]'): - href = a.get('href', '') - if not href or href in seen: - continue - seen.add(href) - if not href.startswith('http'): - href = 'https://xemlaibongda.top' + href - img = a.select_one('img') - p = a.parent - for _ in range(4): - if img: - break - if p: - img = p.select_one('img') - p = p.parent - img_src = '' - if img: - img_src = img.get('data-src','') or img.get('src','') or img.get('data-lazy','') or img.get('data-original','') - if img_src.startswith('//'): - img_src = 'https:' + img_src - elif img_src.startswith('/'): - img_src = 'https://xemlaibongda.top' + img_src - title = '' - for sel in ['.title', 'h3', 'h2', '.name', '.post-title', '.entry-title', '.video-title']: - t = a.select_one(sel) - if t: - title = _clean(t.get_text()) - break - if not title: - title = _clean(a.get('title','')) - if not title: - img_alt = a.select_one('img') - if img_alt: - title = _clean(img_alt.get('alt','')) - if not title: - parent = a.parent - if parent: - pt = _clean(parent.get_text(' ',strip=True)) - if 5 < len(pt) < 120: - title = pt - if not title or len(title) < 3: - continue - vids.append({"link": href, "img": img_src, "title": title}) - if len(vids) >= 30: - break - return vids - -@app.get('/api/proxy/xlb') -def proxy_xlb(path: str = Query(default="")): - now = time.time() - cache_key = f"xlb:{path}" - with _xlb_lock: - cached = _xlb_cache.get(cache_key) - if cached and now - cached['t'] < 120: - return JSONResponse(cached['d']) - try: - vids = _xlb_scrape(path) - result = {"videos": vids, "count": len(vids)} - with _xlb_lock: - _xlb_cache[cache_key] = {'t': now, 'd': result} - return JSONResponse(result) - except Exception as e: - return JSONResponse({"videos": [], "count": 0, "error": str(e)}, status_code=500) - -@app.get('/api/wc2026') -def _w():return JSONResponse(get_wc2026_all()) -@app.get('/api/wc2026/fixtures') -def _wf():return JSONResponse(scrape_fixtures()) -@app.get('/api/wc2026/standings') -def _ws():return JSONResponse(scrape_standings()) -@app.get('/api/wc2026/stats') -def _wst():return JSONResponse(scrape_stats()) -@app.get('/api/wc2026/history') -def _whi():return JSONResponse(scrape_history()) -@app.get('/api/wc2026/news') -def _wn():return JSONResponse(scrape_wc_news()) -@app.get('/api/wc2026/road') -def _wr():return JSONResponse(scrape_road_to_wc()) -@app.get('/api/wc2026/h2h/{eid}') -def _wh2(eid:int):return JSONResponse(scrape_h2h(eid)) -@app.get('/api/wc2026/lineups/{eid}') -def _wl(eid:int):return JSONResponse(scrape_lineups(eid)) -@app.get('/api/wc2026/match/{eid}') -def _wm(eid:int):return JSONResponse(scrape_match_detail(eid)) - +async def _sh(url:str='',title:str='',img:str=''):return HTMLResponse(f'') DATA_DIR='/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)),'data') -os.makedirs(DATA_DIR,exist_ok=True) -IF=os.path.join(DATA_DIR,'interactions_v2.json') -CF=os.path.join(DATA_DIR,'comments_v2.json') -WALL_FILE=os.path.join(DATA_DIR,'wall_posts.json') -WALL_VIDEO_DIR=os.path.join(DATA_DIR,'wall_videos') -os.makedirs(WALL_VIDEO_DIR,exist_ok=True) - -_il=threading.Lock();_cl=threading.Lock();_wl_lock=threading.Lock() +os.makedirs(DATA_DIR,exist_ok=True);IF=os.path.join(DATA_DIR,'interactions_v2.json');CF=os.path.join(DATA_DIR,'comments_v2.json') +_il=threading.Lock();_cl=threading.Lock() def _lj(p): try: if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8')) @@ -855,21 +236,17 @@ def _lj(p): def _sj(p,d): try:open(p+'.tmp','w',encoding='utf-8').write(json.dumps(d,ensure_ascii=False));os.replace(p+'.tmp',p) except:pass - @app.post('/api/v2/interact') async def _int(request:Request): b=await request.json();v=str(b.get('id','')).strip();t=str(b.get('type','')).strip() if not v or t not in('view','like'):return JSONResponse({'error':'x'},status_code=400) with _il:db=_lj(IF);db.setdefault(v,{'views':0,'likes':0,'comments':0});db[v][t+'s']+=1;_sj(IF,db);return JSONResponse(db[v]) - @app.get('/api/v2/interactions') def _gi(id:str=Query(...)): with _il:return JSONResponse(_lj(IF).get(id.strip(),{'views':0,'likes':0,'comments':0})) - @app.get('/api/v2/comments') def _gc(id:str=Query(...)): with _cl:return JSONResponse({'comments':_lj(CF).get(id.strip(),[])}) - @app.post('/api/v2/comment') async def _pc(request:Request): b=await request.json();v=str(b.get('id','')).strip();tx=str(b.get('text','')).strip()[:500] @@ -878,1531 +255,27 @@ async def _pc(request:Request): with _cl:db=_lj(CF);db.setdefault(v,[]);db[v].append(c);db[v]=db[v][-200:];_sj(CF,db);cms=db[v] with _il:idb=_lj(IF);idb.setdefault(v,{'views':0,'likes':0,'comments':0});idb[v]['comments']=len(cms);_sj(IF,idb) return JSONResponse({'comments':cms}) - -def _load_wall_posts(): - with _wl_lock: - posts = _lj(WALL_FILE) - if not isinstance(posts, list): - posts = [] - return posts - -def _save_wall_posts(posts): - with _wl_lock: - _sj(WALL_FILE, posts) - -def _post_has_valid_image(p): - """A wall post is considered to have a 'valid' (non-placeholder) image if - at least one of the following is a real, non-placeholder URL: - - p.img (main thumbnail) - - p.short_thumb (generated by Short AI / oEmbed fallback) - - p.images[] (fallback image list) - - p.slides[].image (designed slide images) - - Auto posts that failed metadata enrichment — e.g. pollinations.ai - AI-generated placeholder images, vnexpress logo placeholders, or empty/None - fields — are flagged as having NO valid image so they can be filtered out - of the wall (they show the wrong picture vs the original article).""" - PLACEHOLDER_MARKERS = ( - "pollinations.ai", # AI-generated editorial illustration placeholder - "logo_default.jpg", # vnexpress placeholder logo - "logo.png", - "/logo", - "placeholder", - "no-image", - "blank", - ) - - def _is_placeholder(url): - if not url or not isinstance(url, str): - return True - u = url.strip().lower() - if not u or u.startswith(("about:blank", "data:")): - return True - return any(m in u for m in PLACEHOLDER_MARKERS) - - def _is_real(url): - return bool(url) and not _is_placeholder(url) - - img = p.get("img") - if _is_real(img): - return True - thumb = p.get("short_thumb") - if _is_real(thumb): - return True - images = p.get("images") or [] - if any(_is_real(x) for x in images): - return True - slides = p.get("slides") or [] - if any(_is_real(s.get("image")) for s in slides if isinstance(s, dict)): - return True - return False - -def _post_has_slide_info(p): - """True if the post has usable slide/rewrite AI content: - - designed slides with text+image, OR - - a generated short video (/api/ai/short-file/) with a real thumbnail, OR - - an oEmbed iframe (YouTube/TikTok/Facebook embed) with a real thumbnail - (Short HOT fallback case — embeddable, viewable as a slide).""" - slides = p.get("slides") or [] - if any((s.get("image") or "").strip() and (s.get("text") or "").strip() for s in slides if isinstance(s, dict)): - return True - # Generated short video with a real thumbnail - vid = (p.get("video") or "").strip() - if vid and "api/ai/short-file/" in vid and (p.get("short_thumb") or "").strip(): - return True - # oEmbed iframe (YouTube/TikTok/FB) with a valid image -> viewable embed slide - if vid and ("youtube.com/embed" in vid or "tiktok.com/embed" in vid or - "facebook.com/plugins" in vid or "instagram.com" in vid or - "twitter.com" in vid or "x.com" in vid or "player.vimeo" in vid): - if _post_has_valid_image(p): - return True - return False - -def _is_auto_post(p): - """Auto-generated wall posts: Short HOT, oEmbed fallback, FPT auto, - slide-rewrite AI (auto_rewrite), topic auto-summary (topic_article), or - posts with no meaningful source / created by the AI pipeline.""" - kind = (p.get("kind") or "").strip().lower() - source = (p.get("source") or "").strip().lower() - return kind in ("hot_short", "slide_summary", "auto_rewrite", "topic_article", "summary") or \ - source in ("hot_short", "fptplay", "ai", "auto_rewrite", "topic_article") - -def _created_ts(p): - """Best-effort creation timestamp. Posts created by the AI pipeline often - store `created` as a millisecond epoch; some older posts (auto_rewrite) - omit it and instead embed the timestamp in the `id` (Unix ms). Fall back - to 0 so sorting never crashes.""" - for field in ("created", "ts"): - v = p.get(field) - if v is not None: - try: - return int(v) - except (ValueError, TypeError): - pass - # try to derive from id (numeric prefix = Unix ms timestamp) - pid = str(p.get("id", "")).strip() - if pid.isdigit() and len(pid) >= 12: - try: - return int(pid) - except (ValueError, TypeError): - pass - return 0 - -def _wall_posts_for_view(): - """Apply the user-facing wall filtering + sorting: - 1) Hide auto-generated posts that have a wrong/placeholder image (no - valid image at all — pollinations.ai, vnexpress logo, empty) or have - no slide-AI content (no slides / no short-video / no embed). These - are the 'bài đăng tự động bị sai ảnh / ko có thông tin slide AI' that - don't match the original article. This filter is VIEW-ONLY — it does - NOT delete from the persistent store. - 2) Sort newest-first by creation timestamp, mixing all sources (FPT, - slide rewrite AI, etc.) without source separation.""" - posts = _load_wall_posts() - if not posts: - return [] - filtered = [] - for p in posts: - if not isinstance(p, dict): - continue - if _is_auto_post(p) and not (_post_has_valid_image(p) and _post_has_slide_info(p)): - # auto post with a wrong/placeholder/missing image or no slide AI - # content -> hide from the wall (non-destructive) - continue - filtered.append(p) - filtered.sort(key=_created_ts, reverse=True) - return filtered - -@app.get("/api/ai/wall") -def api_ai_wall(): - return JSONResponse({"posts": _wall_posts_for_view()}) - - -# ===== YOUTUBE RSS FEED PROXY (avoids browser CORS) ===== -_YT_FEED_CACHE = {} -_YT_FEED_TTL = 600 # 10 minutes - - -def _fetch_youtube_rss_feed(channel_id: str = "UC4LvrpNXujjbGOS4RDvr41g"): - """Fetch & parse the YouTube RSS feed server-side. - - The browser cannot reach youtube.com directly (CORS / network block). - We try two sources in order: - 1) Direct feed XML (works if the server has outbound YouTube access) - 2) rss2json.com proxy (always works — it fetches the feed server-side) - Cached for _YT_FEED_TTL seconds.""" - now = time.time() - cached = _YT_FEED_CACHE.get(channel_id) - if cached and now - cached.get('_ts', 0) < _YT_FEED_TTL: - return cached.get('videos', []) - out = [] - try: - # Attempt 1: direct YouTube RSS - feed_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}" - try: - r = req.get(feed_url, headers=HEADERS, timeout=10) - if r.status_code == 200 and r.text.strip(): - soup = BeautifulSoup(r.text, 'xml') - for e in soup.find_all('entry'): - out.extend(_parse_yt_entry(e)) - except Exception as ex2: - print(f"[yt-feed] direct fetch failed, trying rss2json: {ex2}") - # Attempt 2: rss2json proxy (fallback / always) - if not out: - rss2json_url = f"https://api.rss2json.com/v1/api.json?rss_url={feed_url}" - r2 = req.get(rss2json_url, headers=HEADERS, timeout=15) - if r2.status_code == 200: - data = r2.json() - if data.get('status') == 'ok': - for item in data.get('items', []): - vid_m = re.search(r'([a-zA-Z0-9_-]{11})', item.get('id', '')) - if not vid_m: - continue - vid = vid_m.group(1) - title = item.get('title', 'Video') or 'Video' - # prefer the youtube watch URL, fallback to item link - link = item.get('link', '') - if '/watch' not in link: - link = f'https://www.youtube.com/watch?v={vid}' - # thumbnail: prefer media:thumbnail, fallback to youtube hqdefault - thumb = item.get('thumbnail', f'https://i.ytimg.com/vi/{vid}/hqdefault.jpg') - published = item.get('pubDate', '') - out.append({ - 'id': 'yt-' + vid, - 'videoId': vid, - 'title': title.strip()[:200], - 'link': link, - 'img': thumb, - 'published': published, - 'source': 'yt-feed' - }) - if out: - _YT_FEED_CACHE[channel_id] = {'_ts': now, 'videos': out} - return out - except Exception as ex: - print(f"[yt-feed] server error: {ex}") - return [] - - -def _parse_yt_entry(e): - """Parse a single YouTube RSS element into a video dict.""" - out = [] - try: - id_el = e.find('id') - if not id_el: - return out - vid_m = re.search(r'([a-zA-Z0-9_-]{11})', id_el.get_text('')) - if not vid_m: - return out - vid = vid_m.group(1) - title_el = e.find('title') - title = title_el.get_text('') if title_el else 'Video' - link_el = e.find('link', {'rel': 'alternate'}) - link = link_el.get('href') if link_el else f'https://www.youtube.com/watch?v={vid}' - pub_el = e.find('published') - published = pub_el.get_text('') if pub_el else '' - ns = {'media': 'http://search.yahoo.com/mrss/'} - thumb_el = e.find('media:thumbnail', ns) - thumb_url = thumb_el.get('url') if thumb_el else f'https://i.ytimg.com/vi/{vid}/hqdefault.jpg' - out.append({ - 'id': 'yt-' + vid, - 'videoId': vid, - 'title': title.strip()[:200], - 'link': link, - 'img': thumb_url, - 'published': published, - 'source': 'yt-feed' - }) - except Exception as ex: - print(f"[yt-feed] parse entry error: {ex}") - return out - - -@app.get("/api/yt/feed") -def api_yt_feed(channel_id: str = Query(default="UC4LvrpNXujjbGOS4RDvr41g")): - """Server-side YouTube RSS feed fetcher — avoids browser CORS. - Returns parsed feed videos as JSON for the Tường AI wall.""" - videos = _fetch_youtube_rss_feed(channel_id) - return JSONResponse({"videos": videos, "count": len(videos)}) -@app.post("/api/ai/short/{post_id}") -def api_ai_short(post_id: str): - """Generate a short video for a wall post. - Returns clear JSON error when video not available yet. - """ - post_id_s = str(post_id) - posts = _load_wall_posts() - if not isinstance(posts, list): - posts = [] - post = None - for p in posts: - pid = str(p.get("id", "")) - if pid == post_id_s or pid.startswith(post_id_s): - post = p - break - if not post: - return JSONResponse({"error": "Không tìm thấy bài viết", "post_id": post_id}, status_code=404) - if post.get("video"): - return JSONResponse({"video": post["video"], "post": post}) - return JSONResponse({"error": "Chưa có video cho bài này. Vui lòng upload video trước."}, status_code=409) - -@app.get('/api/wall') -def api_wall(): - posts = _wall_posts_for_view() - if not posts: - return JSONResponse({"posts": []}) - return JSONResponse({"posts": posts}) - -@app.post('/api/wall') -async def api_wall_post(request: Request): - content_type = request.headers.get('content-type', '') - if 'multipart/form-data' in content_type: - try: - form = await request.form() - except Exception as e: - return JSONResponse({"error": f"Form parse error: {str(e)}"}, status_code=400) - title = form.get('title', 'Video mới') or 'Video mới' - text = form.get('text', '') or '' - source = form.get('source', 'vtv_recorder') or 'vtv_recorder' - video_file = form.get('video') - post_id = str(uuid.uuid4())[:12] - video_url = None - if video_file and hasattr(video_file, 'filename') and video_file.filename: - fname = video_file.filename.lower() - if fname.endswith('.mp4'): - ext = '.mp4' - elif fname.endswith('.webm'): - ext = '.webm' - else: - ext = '.webm' - video_filename = f"wall_{post_id}{ext}" - video_path = os.path.join(WALL_VIDEO_DIR, video_filename) - try: - content = await video_file.read() - if not content: - return JSONResponse({"error": "Empty video file"}, status_code=400) - with open(video_path, 'wb') as f: - f.write(content) - file_size_mb = len(content) / 1024 / 1024 - if file_size_mb > 50: - os.remove(video_path) - return JSONResponse({"error": f"Video quá lớn ({file_size_mb:.1f}MB). Tối đa 50MB."}, status_code=400) - video_url = f"/api/wall/video/{video_filename}" - except Exception as e: - return JSONResponse({"error": f"Lỗi lưu video: {str(e)}"}, status_code=500) - post = { - "id": post_id, - "title": title[:200], - "text": text[:2000], - "source": source, - "video": video_url, - "img": None, - "images": [], - "created": int(time.time()), - "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()), - } - posts = _load_wall_posts() - if not isinstance(posts, list): - posts = [] - posts.insert(0, post) - posts = posts[:200] - _save_wall_posts(posts) - return JSONResponse({"post": post, "ok": True}) - try: - body = await request.json() - except: - body = {} - title = body.get('title', 'Bài mới') or 'Bài mới' - text = body.get('text', '') or '' - img = body.get('img', None) - source = body.get('source', 'user') or 'user' - incoming_id = body.get('id') or '' # allow client to (re)publish an existing post by id - post_id = str(uuid.uuid4())[:12] - # Preserve slide-design post fields (slides, kind, url, images, video, voice, etc.) - # so that "Thiết kế ảnh" -> "Đăng lên Tường AI" keeps the selected slides. - slides = body.get('slides') - kind = body.get('kind') or 'user' - post_url = body.get('url') or '' - images = body.get('images') or [] - video = body.get('video') - voice = body.get('voice') or '' - emotion = body.get('emotion') or '' - language = body.get('language') or '' - post = { - "id": post_id, - "title": title[:200], - "text": text[:2000], - "source": source, - "video": video, - "img": img, - "images": images[:10], - "url": post_url, - "kind": kind, - "slides": slides if slides is not None else None, - "voice": voice, - "emotion": emotion, - "language": language, - "created": int(time.time()), - "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()), - } - # Remove keys with None so the JSON stays lean (but keep structure) - post = {k: v for k, v in post.items() if v is not None} - posts = _load_wall_posts() - if not isinstance(posts, list): - posts = [] - # If the client re-publishes (e.g. designer "Lưu & Đăng lên Tường AI" sends - # the original post id), update that post in place instead of creating a - # duplicate. This keeps the homepage wall clean (no duplicate slide posts). - updated = False - if incoming_id: - for _p in posts: - if str(_p.get('id')) == str(incoming_id): - # Merge new fields into the existing post, but PRESERVE the - # original id (and created timestamp) so the post identity is - # stable and no duplicate is created. - preserve_id = _p.get('id') - preserve_created = _p.get('created') - preserve_created_str = _p.get('created_str') - _p.update(post) - _p['id'] = preserve_id - if preserve_created is not None: _p['created'] = preserve_created - if preserve_created_str is not None: _p['created_str'] = preserve_created_str - updated = True - post = _p # return the merged post (with stable id) to the client - break - if not updated: - posts.insert(0, post) - else: - # Move the updated post to the top so it re-appears at the top of the wall. - posts = [p for p in posts if str(p.get('id')) != str(incoming_id)] - posts.insert(0, post) - posts = posts[:200] - _save_wall_posts(posts) - return JSONResponse({"post": post, "ok": True}) - -@app.get('/api/wall/video/{filename}') -def api_wall_video(filename: str): - if '..' in filename or '/' in filename: - return Response(status_code=403) - video_path = os.path.join(WALL_VIDEO_DIR, filename) - if not os.path.exists(video_path): - return Response(status_code=404) - ext = os.path.splitext(filename)[1].lower() - media_type = 'video/mp4' if ext == '.mp4' else 'video/webm' - return FileResponse(video_path, media_type=media_type) - -WALL_IMG_DIR = os.path.join(DATA_DIR, 'wall_imgs') -os.makedirs(WALL_IMG_DIR, exist_ok=True) - -@app.post('/api/wall/img') -async def api_wall_img(request: Request): - """Upload a designed slide image (PNG) and return a served URL.""" - global WALL_IMG_DIR - try: - form = await request.form() - f = form.get('file') - if not f or not hasattr(f, 'filename') or not f.filename: - return JSONResponse({"error": "Thiếu file ảnh"}, status_code=400) - ext = os.path.splitext(f.filename)[1].lower() - if ext not in ('.png', '.jpg', '.jpeg', '.webp'): - ext = '.png' - img_id = str(uuid.uuid4())[:12] - fname = f"wallimg_{img_id}{ext}" - fpath = os.path.join(WALL_IMG_DIR, fname) - content = await f.read() - if not content: - return JSONResponse({"error": "File rỗng"}, status_code=400) - if len(content) > 15 * 1024 * 1024: - return JSONResponse({"error": "Ảnh quá lớn (>15MB)"}, status_code=400) - with open(fpath, 'wb') as fh: - fh.write(content) - post_id = (form.get('post_id') or '').strip() - if post_id: - posts = _load_wall_posts() - if isinstance(posts, list): - for p in posts: - if str(p.get('id')) == str(post_id): - p['img'] = f"/api/wall/img/{fname}" - break - _save_wall_posts(posts) - url = f"/api/wall/img/{fname}" - return JSONResponse({"ok": True, "url": url, "img_id": img_id}) - except Exception as e: - return JSONResponse({"error": f"Lỗi upload ảnh: {str(e)[:150]}"}, status_code=500) - - -@app.get('/api/wall/img/{fname}') -def api_wall_img_file(fname: str): - if '..' in fname or '/' in fname: - return Response(status_code=403) - img_path = os.path.join(WALL_IMG_DIR, fname) - if not os.path.exists(img_path): - return Response(status_code=404) - ext = os.path.splitext(fname)[1].lower() - media_type = 'image/png' if ext == '.png' else ('image/jpeg' if ext in ('.jpg', '.jpeg') else 'image/webp') - return FileResponse(img_path, media_type=media_type) - -@app.delete('/api/wall/{post_id}') -def api_wall_delete(post_id: str): - posts = _load_wall_posts() - if not isinstance(posts, list): - return JSONResponse({"error": "No posts"}, status_code=404) - for i, p in enumerate(posts): - if p.get('id') == post_id: - if p.get('video'): - video_name = p['video'].split('/')[-1] - video_path = os.path.join(WALL_VIDEO_DIR, video_name) - if os.path.exists(video_path): - os.remove(video_path) - posts.pop(i) - _save_wall_posts(posts) - return JSONResponse({"ok": True}) - return JSONResponse({"error": "Post not found"}, status_code=404) - -@app.post('/api/wall/cleanup') -def api_wall_cleanup(): - """Permanently remove auto-generated wall posts that are completely - unrecoverable (no valid image, no video, no slides, no embed — i.e. a - post that can never be displayed), and sort the remaining posts - newest-first. Returns the number of removed posts and the new count. - - NOTE: Conservative — auto posts that HAVE a valid image or a video/embed - are preserved (they remain visible at view time via - _wall_posts_for_view). Only use this to purge genuine garbage.""" - posts = _load_wall_posts() - if not isinstance(posts, list): - return JSONResponse({"error": "No posts"}, status_code=404) - before = len(posts) - kept = [] - removed = [] - for p in posts: - if not isinstance(p, dict): - removed.append(str(p)) - continue - if _is_auto_post(p) and not _post_has_valid_image(p) and not _post_has_slide_info(p) and not (p.get("video") or "").strip(): - removed.append(p.get('id', '?')) - else: - kept.append(p) - # sort newest-first - kept.sort(key=_created_ts, reverse=True) - _save_wall_posts(kept) - return JSONResponse({ - "ok": True, - "before": before, - "removed_count": before - len(kept), - "removed_ids": removed, - "after": len(kept), - }) - -# ===== LANGUAGE & EMOTION DETECTION ===== -import random as _random2 -from urllib.parse import quote as _quote2 - -_UA_RW = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept-Language': 'vi-VN,vi;q=0.9'} - -# Unique character markers for language detection -_UNIQUE_CHARS = { - 'vietnamese': set('đăâêôơưàảãạáằẳẵặắầẩẫậấèẻẽẹéềễểệếìỉĩịíòỏõọóồổỗộốờởỡợớùủũụúừửữựứỳỷỹỵý'), - 'spanish': set('ñáéíóúü¿¡'), - 'portuguese': set('ãõçáéíóúâêôà'), -} - -_STOPWORDS = { - 'english': {'the', 'is', 'at', 'which', 'on', 'a', 'an', 'and', 'or', 'but', 'in', 'with', 'to', 'for', 'of', 'not', 'no', 'can', 'had', 'have', 'has', 'was', 'were', 'are', 'be', 'been', 'this', 'that', 'it', 'he', 'she', 'they', 'his', 'her', 'my', 'your', 'our', 'we', 'you', 'i'}, - 'vietnamese': {'là', 'của', 'và', 'có', 'được', 'cho', 'không', 'với', 'này', 'đó', 'từ', 'trong', 'đã', 'sẽ', 'một', 'các', 'những', 'về', 'tại', 'người', 'năm', 'đến', 'ra', 'lại', 'như', 'khi', 'để', 'rất', 'cũng', 'mà', 'nếu', 'sau', 'trên', 'theo', 'vì', 'do', 'nên', 'thì', 'mình', 'tôi', 'bạn', 'anh', 'chị', 'em'}, - 'portuguese': {'de', 'um', 'que', 'e', 'do', 'da', 'em', 'para', 'com', 'não', 'uma', 'os', 'no', 'se', 'na', 'por', 'mais', 'as', 'dos', 'como', 'mas', 'ao', 'ele', 'das', 'tem', 'seu', 'sua', 'ou', 'quando', 'muito', 'nos', 'já', 'eu', 'também', 'só', 'pelo', 'pela', 'até', 'isso', 'ela', 'entre', 'depois', 'sem', 'mesmo', 'aos', 'são', 'está', 'ter', 'ser', 'foi', 'era', 'há', 'estão', 'você', 'nós', 'eles', 'elas'}, - 'spanish': {'de', 'que', 'el', 'en', 'y', 'a', 'los', 'del', 'se', 'las', 'por', 'un', 'para', 'con', 'no', 'una', 'su', 'al', 'es', 'lo', 'como', 'más', 'pero', 'sus', 'le', 'ya', 'o', 'fue', 'este', 'ha', 'si', 'porque', 'esta', 'son', 'entre', 'está', 'cuando', 'muy', 'sin', 'sobre', 'ser', 'también', 'me', 'hasta', 'hay', 'donde', 'han', 'quien', 'están', 'desde', 'todo', 'nos', 'durante', 'todos', 'uno', 'les', 'ni', 'contra', 'otros', 'fueron', 'ese', 'eso', 'ante', 'ellos', 'yo', 'tú', 'él', 'ella', 'nosotros', 'usted', 'ustedes'}, -} - -def detect_language(text): - """Detect language from text content using stopword + character analysis.""" - if not text: - return 'vietnamese' - text_lower = text.lower() - text_chars = set(text_lower) - - # Strong signal: Vietnamese unique characters - vn_chars = len(text_chars & _UNIQUE_CHARS['vietnamese']) - if vn_chars >= 2: - return 'vietnamese' - - # Spanish unique chars (ñ, ¿, ¡) - es_chars = len(text_chars & _UNIQUE_CHARS['spanish']) - pt_chars = len(text_chars & _UNIQUE_CHARS['portuguese']) - - # Stopword scoring - words = set(re.findall(r'\b\w+\b', text_lower)) - scores = {} - for lang, stops in _STOPWORDS.items(): - scores[lang] = len(words & stops) / max(len(stops), 1) - - # Disambiguate Portuguese vs Spanish - pt_markers = {'não', 'pelo', 'pela', 'isso', 'há', 'estão', 'num', 'numa', 'tenho', 'posso', 'você', 'nós', 'eles', 'elas', 'também', 'muito', 'já', 'só', 'até', 'entre', 'depois', 'sem', 'mesmo', 'aos', 'serão'} - es_markers = {'pero', 'está', 'están', 'porque', 'también', 'hasta', 'donde', 'quien', 'fue', 'son', 'fueron', 'ese', 'eso', 'ante', 'ellos', 'ella', 'nosotros', 'usted', 'ustedes', 'tú', 'él', 'desde', 'todo', 'durante', 'todos', 'uno', 'les', 'ni', 'contra', 'otros', 'fueron'} - - pt_overlap = len(words & pt_markers) - es_overlap = len(words & es_markers) - - if scores.get('portuguese', 0) > 0 and pt_overlap > es_overlap: - return 'portuguese' - if scores.get('spanish', 0) > 0 and es_overlap > pt_overlap: - return 'spanish' - if scores.get('english', 0) > 0.15: - return 'english' - - best = max(scores, key=scores.get) - return best if scores[best] > 0.05 else 'vietnamese' - -# Emotion keyword-based detection -_EMOTION_KEYWORDS = { - 'happy': { - 'en': ['happy', 'joy', 'wonderful', 'great', 'amazing', 'fantastic', 'love', 'excellent', 'beautiful', 'glad', 'delighted', 'pleased', 'cheerful', 'celebrate', 'victory', 'win', 'success'], - 'pt': ['feliz', 'alegria', 'maravilhoso', 'ótimo', 'incrível', 'fantástico', 'amor', 'excelente', 'lindo', 'contente', 'encantado', 'vitória', 'sucesso'], - 'es': ['feliz', 'alegria', 'maravilloso', 'genial', 'increíble', 'fantástico', 'amor', 'excelente', 'hermoso', 'contento', 'encantado', 'victoria', 'éxito'], - 'vi': ['vui', 'hạnh phúc', 'tuyệt vời', 'tuyệt', 'ý nghĩa', 'đẹp', 'thích', 'yêu', 'vui vẻ', 'hân hoan', 'phấn khích', 'chiến thắng', 'thành công'], - }, - 'sad': { - 'en': ['sad', 'unhappy', 'terrible', 'awful', 'horrible', 'miserable', 'depressed', 'grief', 'sorrow', 'tragic', 'unfortunate', 'painful', 'death', 'die', 'kill'], - 'pt': ['triste', 'infeliz', 'terrível', 'horrível', 'miserável', 'deprimido', 'dor', 'trágico', 'infelizmente', 'penoso', 'morte', 'morrer'], - 'es': ['triste', 'infeliz', 'terrible', 'horrible', 'miserable', 'deprimido', 'dolor', 'trágico', 'desafortunado', 'penoso', 'muerte', 'morir'], - 'vi': ['buồn', 'không vui', 'tồi tệ', 'kinh khủng', 'đau khổ', 'đau buồn', 'bi thương', 'khốn nạn', 'đau đớn', 'thảm họa', 'chết', 'mất'], - }, - 'excited': { - 'en': ['excited', 'thrilling', 'amazing', 'wow', 'incredible', 'unbelievable', 'awesome', 'exhilarating', 'electrifying', 'breathtaking', 'breakthrough', 'record'], - 'pt': ['animado', 'emocionante', 'incrível', 'impressionante', 'sensacional', 'eletrizante', 'empolgante', 'recorde'], - 'es': ['emocionante', 'increíble', 'impresionante', 'sensacional', 'electrizante', 'emocionado', 'entusiasmado', 'récord'], - 'vi': ['hào hứng', 'phấn khích', 'thú vị', 'tuyệt cú mèo', 'đỉnh cao', 'ngoạn mục', 'sục sôi', 'kỷ lục', 'đột phá'], - }, - 'humorous': { - 'en': ['funny', 'hilarious', 'joke', 'laugh', 'comedy', 'humor', 'amusing', 'witty', 'sarcastic', 'ironic', 'ridiculous', 'absurd', 'lol', 'haha'], - 'pt': ['engraçado', 'hilário', 'piada', 'rir', 'comédia', 'humor', 'divertido', 'irônico', 'ridículo', 'absurdo', 'kkk'], - 'es': ['gracioso', 'hilarante', 'broma', 'risa', 'comedia', 'humor', 'divertido', 'irónico', 'ridículo', 'absurdo', 'jaja'], - 'vi': ['hài hước', 'buồn cười', 'đùa', 'cười', 'hài', 'vui nhộn', 'hóm hỉnh', 'mỉa mai', 'lố bịch', 'vô lý', 'haha'], - }, - 'serious': { - 'en': ['serious', 'critical', 'important', 'urgent', 'severe', 'grave', 'significant', 'crucial', 'vital', 'essential', 'alarming', 'concerning', 'crisis', 'war', 'conflict'], - 'pt': ['sério', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'essencial', 'preocupante', 'crise', 'guerra', 'conflito'], - 'es': ['serio', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'esencial', 'preocupante', 'crisis', 'guerra', 'conflicto'], - 'vi': ['nghiêm trọng', 'quan trọng', 'khẩn cấp', 'nghiêm túc', 'đáng kể', 'thiết yếu', 'cần thiết', 'báo động', 'lo ngại', 'khủng hoảng', 'chiến tranh', 'xung đột'], - }, -} - -def detect_emotion(text, language='vietnamese'): - """Detect emotion from text using keyword matching.""" - if not text: - return 'neutral' - text_lower = text.lower() - - scores = {} - for emotion, lang_keywords in _EMOTION_KEYWORDS.items(): - keywords = lang_keywords.get(language, lang_keywords.get('en', [])) - score = sum(1 for kw in keywords if kw in text_lower) - scores[emotion] = score - - if max(scores.values()) == 0: - return 'neutral' - - return max(scores, key=scores.get) - -def detect_language_and_emotion(title, text): - """Detect both language and emotion from article content.""" - combined = f"{title} {text}" - lang = detect_language(combined) - emotion = detect_emotion(combined, lang) - return lang, emotion - -# Voice selection based on language and emotion (using MultilingualNeural voices) -VOICE_BY_LANG_EMOTION = { - 'vietnamese': { - 'happy': ('vi-VN-HoaiMyNeural', 'vui'), - 'sad': ('vi-VN-NamMinhNeural', 'buồn'), - 'excited': ('vi-VN-HoaiMyNeural', 'hào hứng'), - 'humorous': ('vi-VN-HoaiMyNeural', 'vui'), - 'serious': ('vi-VN-NamMinhNeural', 'nghiêm túc'), - 'neutral': ('vi-VN-HoaiMyNeural', 'trung_tinh'), - }, - 'portuguese': { - 'happy': ('pt-BR-ThalitaMultilingualNeural', 'feliz'), - 'sad': ('pt-BR-ThalitaMultilingualNeural', 'triste'), - 'excited': ('pt-BR-ThalitaMultilingualNeural', 'animado'), - 'humorous': ('pt-BR-ThalitaMultilingualNeural', 'engraçado'), - 'serious': ('pt-BR-ThalitaMultilingualNeural', 'sério'), - 'neutral': ('pt-BR-ThalitaMultilingualNeural', 'neutro'), - }, - 'english': { - 'happy': ('en-US-AndrewMultilingualNeural', 'happy'), - 'sad': ('en-AU-WilliamMultilingualNeural', 'sad'), - 'excited': ('en-US-AndrewMultilingualNeural', 'excited'), - 'humorous': ('en-US-AndrewMultilingualNeural', 'funny'), - 'serious': ('en-AU-WilliamMultilingualNeural', 'serious'), - 'neutral': ('en-US-AndrewMultilingualNeural', 'neutral'), - }, - 'french': { - 'happy': ('fr-FR-VivienneMultilingualNeural', 'heureux'), - 'sad': ('fr-FR-RemyMultilingualNeural', 'triste'), - 'excited': ('fr-FR-VivienneMultilingualNeural', 'excité'), - 'humorous': ('fr-FR-VivienneMultilingualNeural', 'drôle'), - 'serious': ('fr-FR-RemyMultilingualNeural', 'sérieux'), - 'neutral': ('fr-FR-VivienneMultilingualNeural', 'neutre'), - }, - 'german': { - 'happy': ('de-DE-SeraphinaMultilingualNeural', 'glücklich'), - 'sad': ('de-DE-FlorianMultilingualNeural', 'traurig'), - 'excited': ('de-DE-SeraphinaMultilingualNeural', 'aufgeregt'), - 'humorous': ('de-DE-SeraphinaMultilingualNeural', 'lustig'), - 'serious': ('de-DE-FlorianMultilingualNeural', 'ernst'), - 'neutral': ('de-DE-SeraphinaMultilingualNeural', 'neutral'), - }, - 'korean': { - 'happy': ('ko-KR-HyunsuMultilingualNeural', '행복'), - 'sad': ('ko-KR-HyunsuMultilingualNeural', '슬픔'), - 'excited': ('ko-KR-HyunsuMultilingualNeural', '흥분'), - 'humorous': ('ko-KR-HyunsuMultilingualNeural', '유쾌'), - 'serious': ('ko-KR-HyunsuMultilingualNeural', '진지'), - 'neutral': ('ko-KR-HyunsuMultilingualNeural', '중립'), - }, - 'italian': { - 'happy': ('it-IT-GiuseppeMultilingualNeural', 'felice'), - 'sad': ('it-IT-GiuseppeMultilingualNeural', 'triste'), - 'excited': ('it-IT-GiuseppeMultilingualNeural', 'emozionato'), - 'humorous': ('it-IT-GiuseppeMultilingualNeural', 'divertente'), - 'serious': ('it-IT-GiuseppeMultilingualNeural', 'serio'), - 'neutral': ('it-IT-GiuseppeMultilingualNeural', 'neutro'), - }, -} - -# All valid voice IDs (new MultilingualNeural format) -VALID_VOICES = { - 'vi-VN-HoaiMyNeural', 'vi-VN-NamMinhNeural', - 'en-US-AndrewMultilingualNeural', 'en-AU-WilliamMultilingualNeural', - 'pt-BR-ThalitaMultilingualNeural', - 'fr-FR-VivienneMultilingualNeural', 'fr-FR-RemyMultilingualNeural', - 'de-DE-SeraphinaMultilingualNeural', 'de-DE-FlorianMultilingualNeural', - 'ko-KR-HyunsuMultilingualNeural', - 'it-IT-GiuseppeMultilingualNeural', -} - -def get_voice_for_content(title, text, preferred_voice=None): - """Get appropriate voice based on content language and emotion.""" - # Accept the new MultilingualNeural voices directly - if preferred_voice and preferred_voice in VALID_VOICES: - return preferred_voice - - # Also accept old shorthand voice IDs and map them to new format - old_voice_map = { - 'hoaimy': 'vi-VN-HoaiMyNeural', - 'namminh': 'vi-VN-NamMinhNeural', - 'andrew': 'en-US-AndrewMultilingualNeural', - 'jenny': 'en-US-AndrewMultilingualNeural', - 'thalita': 'pt-BR-ThalitaMultilingualNeural', - 'pt_thalita': 'pt-BR-ThalitaMultilingualNeural', - 'pt_francisco': 'pt-BR-ThalitaMultilingualNeural', - 'ela': 'en-US-AndrewMultilingualNeural', - 'es_carlos': 'en-US-AndrewMultilingualNeural', - 'denise': 'fr-FR-VivienneMultilingualNeural', - 'katja': 'de-DE-SeraphinaMultilingualNeural', - 'nanami': 'en-US-AndrewMultilingualNeural', - 'sunhee': 'ko-KR-HyunsuMultilingualNeural', - 'xiaochen': 'en-US-AndrewMultilingualNeural', - } - if preferred_voice and preferred_voice in old_voice_map: - return old_voice_map[preferred_voice] - - lang, emotion = detect_language_and_emotion(title, text) - lang_map = VOICE_BY_LANG_EMOTION.get(lang, VOICE_BY_LANG_EMOTION['vietnamese']) - voice, _ = lang_map.get(emotion, lang_map['neutral']) - return voice - - -def _is_relevant_image(img_url, title, text): - """Check if an image is relevant to the article content.""" - if not img_url: - return False - skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif', - 'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite', - 'advertisement', 'ad-banner', 'sponsored', 'banner-ads'] - img_lower = img_url.lower() - for p in skip_patterns: - if p in img_lower: - return False - if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']): - return False - return True - - -def _filter_relevant_images(images, title, text, max_images=8): - """Filter and rank images by relevance to article content.""" - if not images: - return [] - seen = set() - relevant = [] - for img in images: - if img in seen: - continue - seen.add(img) - if _is_relevant_image(img, title, text): - relevant.append(img) - return relevant[:max_images] - - -def _scrape_article_for_rewrite(url): - """Scrape article: extract title, paragraphs, RELEVANT images, OG image.""" - try: - r = req.get(url, headers=_UA_RW, timeout=15, allow_redirects=True) - r.encoding = 'utf-8' - soup = BeautifulSoup(r.text, 'lxml') - for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']): - tag.decompose() - 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 '') - ogi = soup.find('meta', property='og:image') - og_img = ogi.get('content', '') if ogi else '' - if og_img and og_img.startswith('//'): - og_img = 'https:' + og_img - 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 - paragraphs = [] - all_images = [] - seen_imgs = set() - if og_img and og_img not in seen_imgs: - all_images.append(og_img) - seen_imgs.add(og_img) - for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True): - if el.name == 'p': - t = _clean(el.get_text(strip=True)) - if t and len(t) > 40: - paragraphs.append(t) - elif el.name in ('figure', 'img'): - 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 - if src not in seen_imgs: - all_images.append(src) - seen_imgs.add(src) - # Filter to relevant images only - relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5])) - return {'title': _clean(title), 'paragraphs': paragraphs, 'images': relevant_images, 'og_img': og_img} - except Exception: - return None - - -def _extract_key_points_rw(paragraphs, max_points=5): - r"""Extract key points from paragraphs - extracts ALL sentences, not just first one. - - Fixes: Original regex `^(.+?[.!?])\s` only captured first sentence per paragraph. - Now splits on all sentence boundaries and takes valid sentences until max_points. - """ - points = [] - - for p in paragraphs: - if len(points) >= max_points: - break - - p = _clean(p) - if not p: - continue - - # Split paragraph into sentences using Vietnamese + English punctuation - sentences = re.split(r'(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])', p) - sentences = [s.strip() for s in sentences if s.strip()] - - for sentence in sentences: - if len(points) >= max_points: - break - - # Clean sentence - remove extra whitespace - sentence = _clean(sentence) - - if len(sentence) < 30: - continue - - # Check for duplicates - if any(sentence[:60] in existing for existing in points): - continue - - # Ensure sentence ends with punctuation - if not sentence.endswith(('.', '!', '?')): - sentence = sentence + '.' - - points.append(sentence) - - # If no valid sentences found, take chunks from raw text - if not points: - raw = '\n'.join(paragraphs) - for i in range(0, min(len(raw), max_points * 300), 280): - chunk = _clean(raw[i:i+280]) - if len(chunk) >= 30 and chunk not in points: - points.append(chunk + ('.' if not chunk.endswith('.') else '')) - if len(points) >= max_points: - break - - return points - - -@app.post("/api/rewrite_slide") -async def api_rewrite_slide(request: Request): - """Fast rewrite as SLIDES - no AI needed, instant response.""" - body = await request.json() - url = _clean(body.get("url", "")) - context = body.get("context", "") - preferred_voice = body.get("voice", "") # Accept custom voice selection - if not url and not context: - return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400) - data = None - if url and url.startswith("http"): - data = _scrape_article_for_rewrite(url) - if not data and context: - paragraphs = [_clean(p) for p in context.split('\n') if len(_clean(p)) > 40] - data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''} - if not data or not data.get('paragraphs'): - return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422) - points = _extract_key_points_rw(data['paragraphs'], max_points=12) - if not points: - return JSONResponse({"error": "Không tìm được ý chính"}, status_code=422) - images = data.get('images', []) - slides = [] - for i, point in enumerate(points): - img = images[i] if i < len(images) else (images[-1] if images else '') - if img and 'cdnphoto.dantri' in img: - img = '/api/proxy/img?url=' + _quote2(img, safe='') - slides.append({'text': point, 'image': img, 'index': i + 1}) - summary_text = '\n\n'.join([f"• {s['text']}" for s in slides]) - - # Auto-detect language and emotion - lang, emotion = detect_language_and_emotion(data['title'], summary_text) - # Use preferred voice if provided, otherwise auto-detect - voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], summary_text) - - post = { - "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)), - "title": data['title'], - "text": summary_text, - "img": images[0] if images else '', - "url": url, - "kind": "slide_summary", - "slides": slides, - "images": images[:10], - "video": "", - "voice": voice, - "emotion": emotion, - "language": lang, - "ts": int(time.time()) - } - posts = _load_wall_posts() - posts.insert(0, post) - _save_wall_posts(posts) - return JSONResponse({"post": post, "slides": slides}) - - -@app.post("/api/rewrite_share") -async def api_rewrite_share(request: Request): - """Rewrite article and post to Tường AI with SLIDES + AI text.""" - body = await request.json() - url = _clean(body.get("url", "")) - ctx = _clean(body.get("context", "")) - preferred_voice = body.get("voice", "") # Accept custom voice selection - if not url and not ctx: - return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400) - data = None - if url and url.startswith("http"): - data = _scrape_article_for_rewrite(url) - if not data and ctx: - paragraphs = [_clean(p) for p in ctx.split('\n') if len(_clean(p)) > 40] - data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''} - if not data or not data.get('paragraphs'): - return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422) - raw_text = '\n'.join(data['paragraphs']) - if len(raw_text) < 50: - raw_text = ctx[:14000] - if len(raw_text) < 50: - return JSONResponse({"error": "Bài viết quá ngắn"}, status_code=422) - domain = '' - try: - from urllib.parse import urlparse - domain = urlparse(url).netloc.replace('www.', '') - except: - pass - - # Generate AI summary text - ai_text = None - try: - import ai_ext - if hasattr(ai_ext, 'qwen_generate'): - prompt = f'Tóm tắt đăng Tường AI:\nTiêu đề: {data["title"]}\n{raw_text[:14000]}\n\n4-6 ý chính. Cuối ghi nguồn.' - ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000) - except Exception: - pass - if not ai_text or len(ai_text) < 80: - key_pts = _extract_key_points_rw(data['paragraphs'], max_points=12) - if key_pts: - ai_text = '\n\n'.join([f"• {p}" for p in key_pts]) - else: - ai_text = f"Tóm tắt: {data['title']}\n\n{raw_text[:1200]}\n\nNguồn: {domain}" - - # Build slides from key points (FIX: include slides in rewrite_share too!) - points = _extract_key_points_rw(data['paragraphs'], max_points=12) - images = data.get('images', []) - slides = [] - for i, point in enumerate(points): - img = images[i] if i < len(images) else (images[-1] if images else '') - if img and 'cdnphoto.dantri' in img: - img = '/api/proxy/img?url=' + _quote2(img, safe='') - slides.append({'text': point, 'image': img, 'index': i + 1}) - - # Auto-detect language and emotion - lang, emotion = detect_language_and_emotion(data['title'], ai_text) - # Use preferred voice if provided, otherwise auto-detect - voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], ai_text) - - post = { - "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)), - "title": data['title'], - "text": ai_text, - "img": images[0] if images else '', - "url": url, - "kind": "rewrite", - "slides": slides, - "images": images[:10], - "video": "", - "voice": voice, - "emotion": emotion, - "language": lang, - "ts": int(time.time()) - } - posts = _load_wall_posts() - posts.insert(0, post) - _save_wall_posts(posts) - return JSONResponse({"post": post, "slides": slides}) - - -@app.post("/api/url_wall") -async def api_url_wall(request: Request): - """Submit URL to add to Tường AI.""" - body = await request.json() - url = _clean(body.get("url", "")) - if not url or not url.startswith('http'): - return JSONResponse({"error": "URL không hợp lệ"}, status_code=400) - # Reuse rewrite_share logic - req._body = json.dumps({"url": url}).encode() - return await api_rewrite_share(request) - - -# ===== PERSONAL OPINION POST v2: AI tổng hợp bài viết từ quan điểm + nguồn tin HOT ===== - -# ===== KEYWORD EXTRACTION FROM OPINION ===== -_STOP_WORDS_EX = set(""" -và của các những một được trong với cho tại sau trước khi không người -việt nam hôm nay mới nhất nóng tin tức cập nhật theo từ đến là có thì -này đã để về lại nên cũng rất như vì do nếu sẽ nếu thế nhưng mà vẫn -đang vào ra hơn đây đó nào cả cùng đã từng hãy còn chỉ cũng đều khiến -được đã bị bởi qua những lúc cái gì cô chú bác anh chị em bạn tôi mình -ông bà thầy vì vậy chính phải ấy đấy đâu đó thôi nhé đấy ạ nhỉ -ngày tháng năm giờ phút giây tuần tháng quý -""".strip().split()) - -def _extract_keywords_from_opinion(text, max_keywords=5): - """Extract meaningful keywords from user's opinion for news search.""" - if not text: - return [] - text = text.lower() - text = re.sub(r'https?://\S+', '', text) - text = re.sub(r'[^\w\sÀ-ỹ]', ' ', text) - text = re.sub(r'\s+', ' ', text).strip() - words = [w for w in text.split() if len(w) > 2 and w not in _STOP_WORDS_EX] - word_scores = {} - for w in words: - word_scores[w] = word_scores.get(w, 0) + 1 - sorted_words = sorted(word_scores.items(), key=lambda x: -x[1]) - top_words = [w for w, s in sorted_words[:max_keywords]] - phrases = [] - for i in range(len(words) - 1): - phrase = words[i] + ' ' + words[i + 1] - if len(phrase) > 5: - phrases.append(phrase) - phrase_scores = {} - for p in phrases: - phrase_scores[p] = phrase_scores.get(p, 0) + 1 - sorted_phrases = sorted(phrase_scores.items(), key=lambda x: -x[1]) - top_phrases = [p for p, s in sorted_phrases[:3]] - result = [] - for p in top_phrases: - if p not in result: - result.append(p) - for w in top_words: - if w not in result: - result.append(w) - return result[:max_keywords] - - -@app.post("/api/personal_post/preview") -async def api_personal_post_preview(request: Request): - """Preview personal post: fetch full articles, let AI compose logical article with images.""" - body = await request.json() - opinion = _clean(body.get("opinion", "")) - selected_topics = body.get("selected_topics", []) or [] - selected_sources = body.get("selected_sources", []) or [] - - if not opinion or len(opinion) < 10: - return JSONResponse({"error": "Quan điểm cá nhân quá ngắn (cần ít nhất 10 ký tự)"}, status_code=400) - - # Lấy keywords từ QUAN ĐIỂM CÁ NHÂN để tìm nguồn tin chính xác - keywords = _extract_keywords_from_opinion(opinion, max_keywords=5) - if keywords: - selected_topics = keywords[:3] - else: - # Fallback: hot topics - hot = _get_hot_topics() - selected_topics = [t.get("topic", "") for t in hot[:3] if t.get("topic")] - - # Tìm nguồn tin - all_sources = [] - seen_urls = set() - for topic in selected_topics[:3]: - sources = _search_all(topic, limit=5) - for s in sources: - if s.get("url") and s["url"] not in seen_urls: - seen_urls.add(s["url"]) - all_sources.append(s) - if len(all_sources) >= 6: - break - if len(all_sources) >= 6: - break - - for src in selected_sources: - if src.get("url") and src["url"] not in seen_urls: - all_sources.insert(0, src) - - # Scrape nội dung đầy đủ từng nguồn (paragraphs + images) - source_details = [] - source_images = [] - for src in all_sources[:5]: - url = src.get("url", "") - if not url: - continue - try: - art = _scrape_article_for_rewrite(url) - if art: - src_detail = { - "title": art.get("title", src.get("title", "")), - "url": url, - "via": src.get("via", ""), - "paragraphs": art.get("paragraphs", [])[:8], - "images": art.get("images", [])[:3], - "og_image": art.get("og_img", "") - } - source_details.append(src_detail) - # Collect images for proxy - for img in art.get("images", [])[:2]: - if any(x in img for x in ["cdnphoto.dantri", "vnexpress", "vcdn", "refooty"]): - img = "/api/proxy/img?url=" + _quote2(img, safe="") - source_images.append(img) - except: - pass - if len(source_details) >= 5: - break - - # Tạo title từ opinion - opinion_words = re.findall(r"[A-Za-zÀ-ỹ0-9]+", opinion) - title_words = opinion_words[:8] if len(opinion_words) >= 8 else opinion_words[:4] - title = " ".join([w[0].upper() + w[1:] for w in title_words]) if title_words else "Quan điểm cá nhân" - title = title[:80] - - # AI sinh bài viết hoàn chỉnh - ai_text = None - try: - import ai_ext - if hasattr(ai_ext, 'qwen_generate'): - # Build detailed context from source articles - source_context = "" - for i, sd in enumerate(source_details[:5]): - src_title = sd.get("title", "") - src_via = sd.get("via", "") - src_paras = sd.get("paragraphs", []) - source_context += f"\n=== Nguồn {i+1}: {src_title} ({src_via}) ===\n" - for j, p in enumerate(src_paras[:4]): - source_context += f" - {p[:300]}\n" - - prompt = ( - "QUAN ĐIỂM: " + opinion[:500] + "\nNGUỒN: " + source_context[:1000] + "\n\n" - "=== NGUỒN TIN THAM KHẢO ===\n" + source_context + "\n\n" - "=== YÊU CẦU VIẾT BÀI THEO SLIDE ===\n" - "Viết bài thành 5-6 ĐOẠN VĂN NGẮN, mỗi đoạn là 1 SLIDE.\n" - "\n" - "QUAN TRỌNG NHẤT: MỗI SLIDE PHẢI KẾT HỢP QUAN ĐIỂM CÁ NHÂN + NỘI DUNG NGUỒN TIN, KHÔNG PHẢI CHỈ NÓI VỀ NGUỒN TIN.\n" - "\n" - "SLIDE 1 - MỞ ĐẦU:\n" - "- NHIỆN HỮU QUAN ĐIỂM CÁ NHÂN LÊN ĐẦU\n" - "- Giới thiệu chủ đề, nêu rõ quan điểm của bạn (dựa vào QUAN ĐIỂM CÁ NHÂN ở trên)\n" - "- 2-4 câu hoàn chỉnh\n" - "\n" - "SLIDE 2-3-4-5 - PHÂN TÍCH:\n" - "- Mỗi slide: B�Commencer bằng QUAN ĐIỂM CÁ NHÂN, sau đó dẫn chứng từ 1 nguồn tin\n" - "- Ví dụ: \"Theo quan điểm của tôi, đây là vấn đề cần lưu ý. Theo VnExpress...\"\n" - "- Dẫn chứng từ nguồn (ghi rõ tên báo: Theo VnExpress, Theo Thanh Niên...)\n" - "- 2-4 câu hoàn chỉnh mỗi slide\n" - "\n" - "SLIDE 6 - KẾT LUẬN:\n" - "- Tổng kết quan điểm cá nhân, đưa ra nhận định cuối cùng\n" - "- 2-3 câu hoàn chỉnh\n" - "\n" - "Định dạng đầu ra:\n" - "---SLIDE 1---\n" - "[nội dung đoạn văn slide 1]\n" - "---SLIDE 2---\n" - "[nội dung đoạn văn slide 2]\n" - "...v.v...\n" - "\n" - "QUAN TRỌNG:\n" - "- Mỗi slide là 1 đoạn văn HOÀN CHỈNH, 2-4 câu\n" - "- PHẢI KẾT THÚC BẰNG DẤU CHẤM (.) HOẢN TOÀN\n" - "- Kết hợp QUAN ĐIỂM CÁ NHÂN với NỘI DUNG NGUỒN TIN\n" - "- Không gạch đầu dòng, không bullet points\n" - "- Viết liền mạch tự nhiên, giọng văn báo chí\n" - "- Mỗi slide phải khác nhau, không lặp ý\n" - "- Độ dài: 300-600 từ" - ) - ai_text = None # Không dùng AI, để code tự kết hợp opinion + source - except: - pass - - if not ai_text or len(ai_text) < 100: - # Fallback: build article manually - ai_text = "## " + title + "\n\n" - ai_text += opinion + "\n\n" - for i, sd in enumerate(source_details[:5]): - ai_text += "### " + sd.get("title", f"Nguồn {i+1}") + "\n" - for p in sd.get("paragraphs", [])[:3]: - ai_text += p[:250] + "\n" - ai_text += "*Nguồn: " + sd.get("via", "") + "*\n\n" - ai_text += "\n---\n*Bài viết tổng hợp từ quan điểm cá nhân và các nguồn tin liên quan*" - - # Parse slides từ AI output (format: ---SLIDE N--- content) - slides = [] - if ai_text: - # Try to parse the ---SLIDE--- format - pattern = r'---SLIDE\s*(\d+)---\s*\n(.*?)(?=---SLIDE|\Z)' - matches = re.findall(pattern, ai_text, re.DOTALL) - - if matches: - for idx, (num, content) in enumerate(matches): - # Normalize: ensure complete sentences - text = _ensure_sentence_complete(content) - if len(text) > 40: - img = source_images[idx] if idx < len(source_images) else "" - slides.append({"text": text, "image": img, "index": idx + 1}) - - # If we have parsed slides, ensure minimum 3 - if len(slides) < 3: - # Use parsed slides as base, fill remaining from AI text - used_indices = set() - for s in slides: - used_indices.add(s['index'] - 1) - - # Split remaining AI text into more slides - sentences = re.split(r'(?<=[.!?])\s+', ai_text) - current_chunk = "" - next_idx = len(slides) - - for sent in sentences: - sent = _ensure_sentence_complete(sent) - if len(sent) < 20: - continue - - # Skip if this sentence is already in parsed slides - found = False - for slide in slides: - if sent[:50] in slide['text']: - found = True - break - - if found: - continue - - if current_chunk and len(current_chunk + " " + sent) <= 380: - current_chunk += " " + sent - else: - if len(current_chunk) > 50: - img = source_images[next_idx] if next_idx < len(source_images) else "" - slides.append({"text": current_chunk, "image": img, "index": next_idx + 1}) - current_chunk = sent - next_idx += 1 - - # Add final chunk - if len(current_chunk) > 50 and next_idx < 6: - img = source_images[next_idx] if next_idx < len(source_images) else "" - slides.append({"text": current_chunk, "image": img, "index": next_idx + 1}) - - # Ultimate fallback: create slides from opinion + source - if len(slides) < 2: - slides = [] - # Slide 1: opinion - if opinion and len(opinion) > 20: - slides.append({"text": opinion[:450], "image": source_images[0] if source_images else "", "index": 1}) - - # Slide 2-6: from AI text or sources - if ai_text: - sentences = re.split(r'(?<=[.!?])\s+', ai_text) - for i, sent in enumerate(sentences[:5]): - text = _ensure_sentence_complete(_clean(sent)) - if len(text) > 60: - if len(slides) < 6: - img = source_images[len(slides)] if len(slides) < len(source_images) else "" - slides.append({"text": text, "image": img, "index": len(slides) + 1}) - - # Fill remaining with key points from sources - KẾT HỢP VỚI QUAN ĐIỂM CÁ NHÂN - src_idx = len(slides) - while len(slides) < 4 and src_idx < len(source_details): - paragraphs = source_details[src_idx].get("paragraphs", []) - src_title = source_details[src_idx].get("title", "") - src_via = source_details[src_idx].get("via", "") - for p in paragraphs[:2]: - if len(p) > 60 and len(slides) < 6: - # Kết hợp opinion với nội dung source - combined = f"Theo góc nhìn của tôi, {opinion[:100]}... Theo {src_via}: {p[:250]}" - img = source_images[len(slides)] if len(slides) < len(source_images) else "" - slides.append({"text": _ensure_sentence_complete(combined), "image": img, "index": len(slides) + 1}) - break # Mỗi nguồn 1 slide - src_idx += 1 - - # Final fallback: ensure at least 2-3 slides - while len(slides) < 3: - idx = len(slides) - if idx == 0 and opinion: - slides.append({"text": opinion[:400], "image": "", "index": 1}) - elif ai_text: - slides.append({"text": ai_text[idx*300:(idx+1)*300], "image": "", "index": idx + 1}) - else: - slides.append({"text": f"Nguồn tham khảo {idx + 1}", "image": "", "index": idx + 1}) - - preview = { - "title": title, - "text": ai_text, - "opinion": opinion, - "images": source_images[:10], - "sources": source_details[:5], - "slides": slides[:6] # Max 6 slides - } - - return JSONResponse({"preview": preview}) - - -@app.post("/api/personal_post") -async def api_personal_post(request: Request): - """Create and save personal opinion post.""" - body = await request.json() - opinion = _clean(body.get("opinion", "")) - selected_topics = body.get("selected_topics", []) or [] - selected_sources = body.get("selected_sources", []) or [] - custom_title = body.get("custom_title", "") - custom_slides = body.get("custom_slides", []) - - if not opinion or len(opinion) < 10: - return JSONResponse({"error": "Quan điểm cá nhân quá ngắn (cần ít nhất 10 ký tự)"}, status_code=400) - - if not selected_topics: - # Lấy keywords từ QUAN ĐIỂM CÁ NHÂN để tìm nguồn tin chính xác - keywords = _extract_keywords_from_opinion(opinion, max_keywords=5) - if keywords: - selected_topics = keywords[:3] - else: - hot = _get_hot_topics() - selected_topics = [t.get("topic", "") for t in hot[:3] if t.get("topic")] - - all_sources = [] - seen_urls = set() - for topic in selected_topics[:3]: - sources = _search_all(topic, limit=5) - for s in sources: - if s.get("url") and s["url"] not in seen_urls: - seen_urls.add(s["url"]) - all_sources.append(s) - if len(all_sources) >= 6: - break - if len(all_sources) >= 6: - break - - for src in selected_sources: - if src.get("url") and src["url"] not in seen_urls: - all_sources.insert(0, src) - - source_details = [] - source_images = [] - for src in all_sources[:5]: - url = src.get("url", "") - if not url: - continue - try: - art = _scrape_article_for_rewrite(url) - if art: - src_detail = { - "title": art.get("title", src.get("title", "")), - "url": url, - "via": src.get("via", ""), - "paragraphs": art.get("paragraphs", [])[:6], - "images": art.get("images", [])[:2], - "og_image": art.get("og_img", "") - } - source_details.append(src_detail) - for img in art.get("images", [])[:2]: - if any(x in img for x in ["cdnphoto.dantri", "vnexpress", "vcdn", "refooty"]): - img = "/api/proxy/img?url=" + _quote2(img, safe="") - source_images.append(img) - except: - pass - - # Title - if custom_title: - title = custom_title[:80] - else: - opinion_words = re.findall(r"[A-Za-zÀ-ỹ0-9]+", opinion) - title_words = opinion_words[:8] if len(opinion_words) >= 8 else opinion_words[:4] - title = " ".join([w[0].upper() + w[1:] for w in title_words]) if title_words else "Quan điểm cá nhân" - title = title[:80] - - # AI sinh bài - ai_text = None - try: - import ai_ext - if hasattr(ai_ext, 'qwen_generate'): - source_context = "" - for i, sd in enumerate(source_details[:5]): - src_title = sd.get("title", "") - src_via = sd.get("via", "") - src_paras = sd.get("paragraphs", []) - source_context += f"\nNguồn {i+1}: {src_title} ({src_via})\n" - for j, p in enumerate(src_paras[:3]): - source_context += f" - {p[:300]}\n" - prompt = ( - "QUAN ĐIỂM: " + opinion[:500] + "\nNGUỒN: " + source_context[:1000] + "\n\n" - "=== NGUỒN TIN ===\n" + source_context + "\n\n" - "=== YÊU CẦU VIẾT BÀI THEO SLIDE ===\n" - "Viết bài thành 5-6 ĐOẠN VĂN NGẮN, mỗi đoạn là 1 SLIDE.\n" - "\n" - "SLIDE 1 - MỞ ĐẦU: Giới thiệu chủ đề, nêu quan điểm cá nhân (2-4 câu hoàn chỉnh)\n" - "SLIDE 2-3-4-5 - PHÂN TÍCH: Mỗi slide dùng 1 nguồn tin cụ thể, kết hợp quan điểm cá nhân, ghi rõ nguồn (Theo VnExpress...), 2-4 câu hoàn chỉnh, thành 1 đoạn văn hoàn chỉnh\n" - "SLIDE 6 - KẾT LUẬN: Tổng kết quan điểm, nhận định cuối cùng (2-3 câu hoàn chỉnh)\n" - "\n" - "Định dạng:\n" - "---SLIDE 1---\n[đoạn văn hoàn chỉnh kết thúc bằng dấu chấm]\n---SLIDE 2---\n[đoạn văn hoàn chỉnh kết thúc bằng dấu chấm]\n...\n" - "\n" - "QUAN TRỌNG: Mỗi slide là 1 đoạn văn HOÀN CHỈNH, 2-4 câu, PHẢI KẾT THÚC BẰNG DẤU CHẤM (.). Kết hợp QUAN ĐIỂM + NGUỒN TIN. Không gạch đầu dòng. Viết liền mạch. 300-600 từ." - ) - ai_text = None # Không dùng AI, để code tự kết hợp opinion + source - except: - pass - - if not ai_text or len(ai_text) < 100: - ai_text = "## " + title + "\n\n" + opinion + "\n\n" - for i, sd in enumerate(source_details[:5]): - ai_text += "### " + sd.get("title", "") + "\n" - for p in sd.get("paragraphs", [])[:2]: - ai_text += p[:250] + "\n" - ai_text += "\n---\n*Nguồn: " + sd.get("via", "") + "*\n\n" - - # Tạo slides - if custom_slides and len(custom_slides) > 0: - slides = [] - for i, slide in enumerate(custom_slides): - slides.append({ - "text": slide.get("text", ""), - "image": slide.get("image", ""), - "index": i + 1 - }) - else: - slides = [] - # Parse từ AI output (format: ---SLIDE N---) - if ai_text: - pattern = r'---SLIDE\s*(\d+)---\s*\n(.*?)(?=---SLIDE|\Z)' - matches = re.findall(pattern, ai_text, re.DOTALL) - if matches: - for idx, (num, content) in enumerate(matches): - # Normalize: ensure complete sentences - text = _ensure_sentence_complete(content) - if len(text) > 30: - img = source_images[idx] if idx < len(source_images) else "" - slides.append({"text": text, "image": img, "index": idx + 1}) - - # Fallback: split by paragraphs - if len(slides) < 3: - paragraphs = [p.strip() for p in re.split(r'\n\n+', ai_text) if p.strip()] - slides = [] - para_count = 0 - for p in paragraphs: - p = p.strip() - if p.startswith('#') or p.startswith('---') or p.startswith('*Nguồn'): - continue - # Normalize: ensure complete sentences - p_normalized = _ensure_sentence_complete(p) - if len(p_normalized) > 50: - img = source_images[para_count] if para_count < len(source_images) else "" - slides.append({"text": p_normalized, "image": img, "index": para_count + 1}) - para_count += 1 - if para_count >= 6: - break - - if len(slides) < 2: - slides = [] - # Slide 1: QUAN ĐIỂM CÁ NHÂN (BẮT BUỘC) - slides.append({"text": f"Theo quan điểm cá nhân: {opinion[:400]}", "image": source_images[0] if source_images else "", "index": 1}) - - # Slide 2-6: KẾT HỢP QUAN ĐIỂM + SOURCE - for i in range(min(5, len(source_details))): - if len(slides) >= 6: - break - src = source_details[i] - src_via = src.get("via", "") - src_paras = src.get("paragraphs", []) - - src_text = "" - for p in src_paras[:2]: - p = p.strip()[:280] - if len(p) > 50: - src_text = p - break - - if src_text: - combined = f"Theo góc nhìn cá nhân, {opinion[:60]}. Theo {src_via}: {src_text}" - img = source_images[len(slides)] if len(slides) < len(source_images) else (source_images[-1] if source_images else "") - slides.append({"text": _ensure_sentence_complete(combined), "image": img, "index": len(slides) + 1}) - - lang, emotion = detect_language_and_emotion(title, ai_text) - voice = get_voice_for_content(title, ai_text) - - post = { - "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)), - "title": title, - "text": ai_text, - "img": source_images[0] if source_images else "", - "url": "", - "kind": "personal_opinion", - "slides": slides, - "images": source_images[:10], - "video": "", - "voice": voice, - "emotion": emotion, - "language": lang, - "ts": int(time.time()), - "sources": source_details[:5] - } - - posts = _load_wall_posts() - posts.insert(0, post) - _save_wall_posts(posts) - - return JSONResponse({"post": post, "slides": slides}) - - -# ===== END PERSONAL OPINION POST v2 ===== - +from wc2026_scraper import(scrape_summary,scrape_fixtures,scrape_standings,scrape_stats,scrape_wc_news,scrape_road_to_wc,get_wc2026_all,scrape_history,scrape_h2h,scrape_lineups,scrape_match_detail) +@app.get('/api/wc2026') +def _w():return JSONResponse(get_wc2026_all()) +@app.get('/api/wc2026/fixtures') +def _wf():return JSONResponse(scrape_fixtures()) +@app.get('/api/wc2026/standings') +def _ws():return JSONResponse(scrape_standings()) +@app.get('/api/wc2026/stats') +def _wst():return JSONResponse(scrape_stats()) +@app.get('/api/wc2026/history') +def _whi():return JSONResponse(scrape_history()) +@app.get('/api/wc2026/news') +def _wn():return JSONResponse(scrape_wc_news()) +@app.get('/api/wc2026/road') +def _wr():return JSONResponse(scrape_road_to_wc()) +@app.get('/api/wc2026/h2h/{eid}') +def _wh2(eid:int):return JSONResponse(scrape_h2h(eid)) +@app.get('/api/wc2026/lineups/{eid}') +def _wl(eid:int):return JSONResponse(scrape_lineups(eid)) +@app.get('/api/wc2026/match/{eid}') +def _wm(eid:int):return JSONResponse(scrape_match_detail(eid)) def _bg(): time.sleep(15) while True: @@ -2410,506 +283,4 @@ def _bg(): except:pass time.sleep(90) threading.Thread(target=_bg,daemon=True).start() - -# ===== AUTO SCHEDULER: rewrite AI + short at 7/13/19 VN time ===== -_AUTO_SCHEDULE_TIMES = [(7, '07:00'), (13, '13:00'), (19, '19:00')] -_AUTO_LOG = os.path.join(DATA_DIR, 'auto_rewrite_log.json') - -def _load_auto_log(): - try: - if os.path.exists(_AUTO_LOG): - with open(_AUTO_LOG, 'r') as f: - return json.load(f) - except: pass - return {} - -def _save_auto_log(log): - try: - tmp = _AUTO_LOG + '.tmp' - with open(tmp, 'w') as f: - json.dump(log, f) - os.replace(tmp, _AUTO_LOG) - except: pass - -async def _auto_fetch_short(post_id): - """Try to auto-generate a short for a post.""" - try: - import httpx - async with httpx.AsyncClient(timeout=180) as cl: - r = await cl.post( - f"http://localhost:7860/api/ai/short/{post_id}", - json={"voice":"vi-VN-HoaiMyNeural","emotion":"neutral","speed":1.2}, - headers={"Content-Type":"application/json"} - ) - if r.status_code < 300: - sj = r.json() - if sj.get('video'): - posts = _load_wall_posts() - for p in posts: - if p.get('id') == post_id: - p['video'] = sj['video'] - break - _save_wall_posts(posts) - return True - except: pass - return False - -async def _auto_rewrite_one(topic, slot_label, used_urls=None, post_index=0): - """Rewrite one topic: find articles, summarize, post to wall, trigger short. - used_urls: shared set to avoid duplicate articles across topics. - post_index: 0-based index to create multiple posts per topic (0,1,2 = up to 3 posts).""" - from urllib.parse import quote as _q - # Get MORE items to support 1-3 posts per topic - items = _search_all(topic, limit=12) - # Skip URLs already used by another topic - if used_urls is not None: - filtered = [it for it in items if it.get('url') not in used_urls] - if filtered: - items = filtered - if not items or post_index >= len(items): - return False - - # Get article at post_index (0,1,2 for multiple posts) - item = items[post_index] # post_index allows multiple articles per topic - url = item.get('url', '') - title = item.get('title', topic) - if url and used_urls is not None: - used_urls.add(url) - if not url.startswith('http'): - return False - - data = _scrape_article_for_rewrite(url) - if not data or not data.get('paragraphs'): - return False - - raw_text = '\n'.join(data['paragraphs']) - ai_text = None - - # Try AI generation - try: - import ai_ext - prompt = f"Tóm tắt tin tức (tự động {slot_label}):\nTiêu đề: {data['title']}\n{raw_text[:10000]}\n\n4-6 ý chính dạng bullet. Cuối ghi nguồn." - ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000) - except: pass - - if not ai_text or len(ai_text) < 80: - pts = data['paragraphs'][:6] - ai_text = '\n\n'.join([f"• {p[:300]}" for p in pts]) - via = item.get('via', '') or urlparse(url).netloc.replace('www.', '') - ai_text += f"\n\nNguồn tham khảo: {via}" - - # Build slides - images = data.get('images', []) - pts = data['paragraphs'][:10] - slides = [] - for i, p in enumerate(pts[:8]): - img = images[i] if i < len(images) else (images[-1] if images else data.get('og_img', '')) - slides.append({'text': p[:300], 'image': img, 'index': i + 1}) - - post_id = str(int(time.time() * 1000)) + str(_random2.randint(100, 999)) - post = { - "id": post_id, "title": data.get('title', title)[:200], - "text": ai_text, "img": images[0] if images else data.get('og_img', ''), - "url": url, "kind": "auto_rewrite", "slides": slides, - "images": images[:10], "video": "", - "voice": "vi-VN-HoaiMyNeural", "emotion": "neutral", - "language": "vietnamese", "ts": int(time.time()), - "auto_scheduled": True, "slot": slot_label, - } - - posts = _load_wall_posts() - posts.insert(0, post) - _save_wall_posts(posts) - - # Trigger short generation async - threading.Thread(target=lambda: asyncio.run(_auto_fetch_short(post_id)), daemon=True).start() - return True - -async def _do_scheduled_run(slot_label): - """Main scheduled run: 1-3 posts from 3 different HOT topics (3-9 total), no duplicates.""" - print(f"[auto] Starting scheduled rewrite for {slot_label}") - - # Get top hot topics, skip duplicates - all_topics = _get_hot_topics() - seen_topics = set() - unique_topics = [] - for t in all_topics: - kw = t.get('topic', '').lower().strip() - if kw and len(kw) > 5 and kw not in seen_topics: - is_dup = False - for s in seen_topics: - # Check if one topic is substring of another - if kw in s or s in kw: - is_dup = True - break - if not is_dup: - seen_topics.add(kw) - unique_topics.append(t) - if len(unique_topics) >= 3: - break - - job_topics = [t['topic'] for t in unique_topics[:3] if t.get('topic')] - if not job_topics: - print(f"[auto] No hot topics found, skipping") - return - - print(f"[auto] Running 3 topics: {job_topics}") - - # Track used URLs to avoid cross-topic duplicates - _used_urls = set() - results = [] - - # Process each topic, create 1-3 posts per topic - for jt in job_topics: - for post_idx in range(3): # Try up to 3 posts per topic - try: - ok = await asyncio.wait_for(_auto_rewrite_one(jt, slot_label, _used_urls, post_idx), timeout=120) - if ok: - results.append((jt, post_idx, True)) - print(f"[auto] Created post {post_idx+1} for '{jt}'") - else: - # No more articles for this topic - break - except Exception as e: - print(f"[auto] Error on '{jt}' post {post_idx}: {e}") - results.append((jt, post_idx, False)) - await asyncio.sleep(1) # Small delay between posts - - # Ensure at least 3 posts total (fallback if needed) - successful_posts = sum(1 for _, _, ok in results if ok) - print(f"[auto] Done {slot_label}: {successful_posts} posts created") - - # Log - from datetime import datetime, timezone, timedelta - VN_TZ_SCHED = timezone(timedelta(hours=7)) - today_str = datetime.now(VN_TZ_SCHED).strftime('%Y-%m-%d') - log = _load_auto_log() - if today_str not in log: log[today_str] = {} - log[today_str][slot_label] = { - 'time': datetime.now(VN_TZ_SCHED).strftime('%H:%M:%S'), - 'count': successful_posts, - 'total': len(job_topics), - } - _save_auto_log(log) - -def _scheduler_loop(): - """Check every 60s; trigger at 7:00, 13:00, 19:00 VN time. - On startup, check for any missed slots today and run them immediately.""" - time.sleep(35) - from datetime import datetime, timezone, timedelta - VN_TZ_SCHED = timezone(timedelta(hours=7)) - - _last_run_date = "" - _last_run_slots = set() - - # On startup: check log for missed slots today - try: - start_now = datetime.now(VN_TZ_SCHED) - today_str = start_now.strftime('%Y-%m-%d') - current_hour = start_now.hour - current_minute = start_now.minute - log = _load_auto_log() - today_log = log.get(today_str, {}) - for h, label in _AUTO_SCHEDULE_TIMES: - # Run if slot is past (either strictly earlier hour, or same hour but window has passed) - should_run = False - if h < current_hour: - should_run = True - elif h == current_hour and current_minute > 10: - should_run = True - if should_run and label not in today_log: - print(f"[auto] Detected missed slot {label} (h={h} < now={current_hour}:{current_minute}), running catch-up now") - _run_scheduled_sync(label) - _last_run_slots.add(label) - except Exception as e: - print(f"[auto] Catch-up check error: {e}") - - while True: - try: - now = datetime.now(VN_TZ_SCHED) - today = now.strftime('%Y-%m-%d') - hour = now.hour - minute = now.minute - - if today != _last_run_date: - _last_run_date = today - _last_run_slots = set() - - slot = None - for h, label in _AUTO_SCHEDULE_TIMES: - if hour == h and 0 <= minute < 5: - slot = label - break - - if slot and slot not in _last_run_slots: - _last_run_slots.add(slot) - _run_scheduled_sync(slot) - except Exception as e: - print(f"[auto] Loop error: {e}") - - time.sleep(60) - -threading.Thread(target=_scheduler_loop, daemon=True, name='auto-rewrite-scheduler').start() - -def _wall_cleanup_once(): - """One-time startup cleanup: sort the persistent wall store newest-first - and remove truly-garbage auto posts (no valid image at ALL, no video, no - slides, no embed URL — i.e. a post that can never be displayed). - - NOTE: This is intentionally conservative. Posts with a valid image or a - valid embed/video are NEVER deleted here — they are only hidden at view - time by _wall_posts_for_view() when the image is a placeholder. This - prevents accidental data loss of FPT Short AI posts whose image is valid - but may temporarily lack a short_thumb.""" - try: - time.sleep(8) - posts = _load_wall_posts() - if not isinstance(posts, list): - return - kept = [] - removed = 0 - removed_ids = [] - needs_sort = False - for p in posts: - if not isinstance(p, dict): - removed += 1 - continue - if _is_auto_post(p): - has_img = _post_has_valid_image(p) - has_content = _post_has_slide_info(p) - vid = (p.get("video") or "").strip() - # Only delete auto posts that are completely unrecoverable: - # no valid image AND no video/slides/embed at all. - if not has_img and not has_content and not vid: - removed += 1 - removed_ids.append(p.get('id', '?')) - continue - kept.append(p) - # Sort if any post lacks proper ordering - prev_ts = None - is_sorted = True - for p in kept: - ts = _created_ts(p) - if prev_ts is not None and ts > prev_ts: - is_sorted = False - break - prev_ts = ts - if removed > 0 or not is_sorted: - kept.sort(key=_created_ts, reverse=True) - _save_wall_posts(kept) - print(f"[wall] startup cleanup: removed {removed} garbage posts ({removed_ids[:10]}), kept {len(kept)}, sorted newest-first") - except Exception as e: - print(f"[wall] startup cleanup error: {e}") - -threading.Thread(target=_wall_cleanup_once, daemon=True, name='wall-cleanup').start() - -@app.get('/api/debug/auto_schedule') -async def debug_auto_schedule(slot: str = '07:00'): - """Manually trigger auto scheduler for debugging.""" - try: - # Check if we can access the data directory - log = _load_auto_log() - topics = _get_hot_topics()[:3] - job_topics = [t['topic'] for t in topics if t.get('topic')] - return JSONResponse({ - "slot": slot, - "log": log, - "hot_topics": job_topics, - "wall_posts_count": len(_load_wall_posts()), - "data_dir_writable": os.access(DATA_DIR, os.W_OK) if os.path.isdir(DATA_DIR) else False, - "data_dir_exists": os.path.isdir(DATA_DIR), - }) - except Exception as e: - return JSONResponse({"error": str(e)}, status_code=500) - -def _run_scheduled_sync(slot): - """Run _do_scheduled_run in a separate event loop (for background thread).""" - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - loop.run_until_complete(_do_scheduled_run(slot)) - except Exception as e: - print(f"[auto] Background run error: {e}") - finally: - loop.close() - -@app.get('/api/debug/trigger_auto') -async def debug_trigger_auto(slot: str = '19:00'): - """Trigger _do_scheduled_run in background thread (non-blocking).""" - threading.Thread(target=_run_scheduled_sync, args=(slot,), daemon=True).start() - return JSONResponse({"status": "started", "slot": slot}) - -# ===== SHORTS RSS PROXY ENDPOINT ===== -@app.get("/api/shorts/rss") -def shorts_rss(): - """Get shorts from YouTube RSS feeds server-side""" - import xml.etree.ElementTree as ET - import html as html_lib2 - import re as re2 - - YOUTUBE_CHANNELS = { - "baodantri7941": "UC_x5TKhOgd6GhYvv5z4I3jg", - "baosuckhoedoisongboyte": "UCBsY5fXTQLkF_JnH9kLkL4g", - } - - shorts = [] - seen = set() - - for handle, channel_id in YOUTUBE_CHANNELS.items(): - try: - rss_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}" - r = req.get(rss_url, headers=HEADERS, timeout=15) - if r.status_code != 200: - continue - - root = ET.fromstring(r.text) - ns = { - 'atom': 'http://www.w3.org/2005/Atom', - 'yt': 'http://www.youtube.com/xml/schemas/2015', - 'media': 'http://search.yahoo.com/mrss/' - } - - for entry in root.findall('atom:entry', ns)[:30]: - title_el = entry.find('atom:title', ns) - title = html_lib2.unescape(title_el.text) if title_el is not None and title_el.text else '' - - link_el = entry.find('atom:link', ns) - link = link_el.get('href', '') if link_el is not None else '' - - vid_el = entry.find('yt:videoId', ns) - vid = vid_el.text if vid_el is not None else '' - - if not vid or vid in seen: - continue - - # Check if it's a short - is_short = '#shorts' in title.lower() or '#short' in title.lower() or '/shorts/' in link - - if not is_short: - desc_el = entry.find('media:description', ns) - if desc_el is not None and desc_el.text: - if '#shorts' in desc_el.text.lower(): - is_short = True - - if not is_short: - continue - - seen.add(vid) - - # Get thumbnail - thumb = f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg" - media_group = entry.find('media:group', ns) - if media_group is not None: - thumb_el = media_group.find('media:thumbnail', ns) - if thumb_el is not None: - thumb = thumb_el.get('url', thumb) - - shorts.append({ - 'id': vid, - 'title': title.replace('#shorts', '').replace('#short', '').strip()[:120], - 'img': thumb, - 'link': f'https://www.youtube.com/shorts/{vid}', - 'channel': handle, - 'source': 'yt' - }) - - if len(shorts) >= 40: - break - - except Exception as e: - print(f"RSS error for {handle}: {e}") - continue - - return {"shorts": shorts, "count": len(shorts)} - -# ===== M3U IPTV Channels ===== -_M3U_URL = "https://raw.githubusercontent.com/Love4vn/Stalker2M3U/refs/heads/1/live_schedule_Optimize.m3u" -_m3u_cache = {"t": 0, "d": []} - -def _parse_m3u(): - """Fetch & parse the M3U playlist from GitHub.""" - r = req.get(_M3U_URL, headers={"User-Agent": "Mozilla/5.0"}, timeout=20) - if r.status_code != 200: - return [] - lines = r.text.splitlines() - out = [] - cur = {} - for line in lines: - line = line.strip() - if line.startswith("#EXTINF"): - t_match = re.search(r',(.+)$', line) - cur = { - "title": t_match.group(1).strip() if t_match else "Channel", - "id": "", - "logo": "", - "group": "", - "url": "", - "vlcopts": {}, - } - for attr_match in re.finditer(r'(\w+)="([^"]*)"', line): - k, v = attr_match.group(1), attr_match.group(2) - if k == "tvg-id": - cur["id"] = v - elif k == "tvg-logo": - cur["logo"] = v - elif k == "group-title": - cur["group"] = v - elif line.startswith("#EXTVLCOPT:"): - key_part = line[len("#EXTVLCOPT:"):] - if "=" in key_part: - k, v = key_part.split("=", 1) - cur.setdefault("vlcopts", {})[k.strip()] = v.strip() - elif line and not line.startswith("#") and cur: - cur["url"] = line - out.append(cur) - cur = {} - # Deduplicate by URL - seen = set() - deduped = [] - for c in out: - if c["url"] not in seen: - seen.add(c["url"]) - deduped.append(c) - return deduped - -@app.get('/api/m3u/channels') -def api_m3u_channels(refresh: int = Query(default=0)): - n = int(time.time()) - if not refresh and _m3u_cache["d"] and n - _m3u_cache["t"] < 300: - return JSONResponse({"channels": _m3u_cache["d"], "updated": _m3u_cache["t"]}) - try: - channels = _parse_m3u() - _m3u_cache["t"] = n - _m3u_cache["d"] = channels - return JSONResponse({"channels": channels, "updated": n}) - except Exception as e: - return JSONResponse({"channels": _m3u_cache["d"], "updated": _m3u_cache["t"], "error": str(e)}) - -@app.get('/api/proxy/m3u') -def proxy_m3u_stream(url: str = Query(...), ua: str = Query(default=None), cookie: str = Query(default=None), auth: str = Query(default=None)): - """Proxy MPEG-TS / HLS streams from stalker portals with custom headers.""" - if not url or not url.startswith('http'): - return Response(status_code=400, content="Invalid URL") - headers = dict(HEADERS) - if ua: - headers["User-Agent"] = ua - if cookie: - headers["Cookie"] = cookie - if auth: - headers["Authorization"] = auth - try: - r = req.get(url, headers=headers, timeout=15, stream=True) - if r.status_code != 200: - return Response(status_code=502, content="upstream error") - # Determine media type - ct = r.headers.get("Content-Type", "") - media_type = "video/mp2t" if "mp2t" in ct or "mpeg" in ct.lower() or ".ts" in url else (ct or "video/mp2t") - return StreamingResponse( - (chunk for chunk in r.iter_content(chunk_size=65536)), - media_type=media_type, - headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "no-cache", "Accept-Ranges": "bytes"} - ) - except Exception as e: - return Response(status_code=502, content=str(e)[:200]) - -app.mount('/static',StaticFiles(directory=STATIC_DIR),name='vnews_static') \ No newline at end of file +app.mount('/static',StaticFiles(directory=STATIC_DIR),name='vnews_static') diff --git a/app_v2_entry.py.gitigignore b/app_v2_entry.py.gitigignore deleted file mode 100644 index b0ce4bb73e0138d7e48114fc6bb45471e71096cd..0000000000000000000000000000000000000000 --- a/app_v2_entry.py.gitigignore +++ /dev/null @@ -1,3 +0,0 @@ -.pyc -__pycache__/ -*.pyc diff --git a/app_v2_entry_hot.py b/app_v2_entry_hot.py deleted file mode 100644 index 18b903b8b3b409d6a16a38cb8c6723515a58f296..0000000000000000000000000000000000000000 --- a/app_v2_entry_hot.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Hot topics patch - makes AI topics always visible at top of HOT list.""" -# This file is imported by app_v2_entry.py - -# AI topics to prepend to hot topics -AI_HOT_TOPICS = [ - {'label': '#Công nghệ AI', 'topic': 'Công nghệ AI', 'count': 0}, - {'label': '#World Cup 2026', 'topic': 'World Cup 2026', 'count': 0}, - {'label': '#Kinh tế Việt Nam', 'topic': 'Kinh tế Việt Nam', 'count': 0}, - {'label': '#Bóng đá châu Âu', 'topic': 'Bóng đá châu Âu', 'count': 0}, - {'label': '#Giá vàng', 'topic': 'Giá vàng', 'count': 0}, - {'label': '#Thời tiết', 'topic': 'Thời tiết', 'count': 0}, -] - -def prepend_ai_hot_topics(topics): - """Prepend AI topics to hot topics list, ensuring they're always visible.""" - if not topics: - return AI_HOT_TOPICS[:] - # Remove duplicates that already exist - existing_topics = [t.get('topic', '').lower() for t in topics] - result = [] - for ai_topic in AI_HOT_TOPICS: - if ai_topic.get('topic', '').lower() not in existing_topics: - result.append(ai_topic) - return result + topics \ No newline at end of file diff --git a/app_v2_entry_test.py b/app_v2_entry_test.py deleted file mode 100644 index b06732f0aef340c8a62a87762b8d738b225c1f45..0000000000000000000000000000000000000000 --- a/app_v2_entry_test.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -VNEWS App v2 - Main application with match detail API -""" -import os, json, re, time, asyncio, hashlib, logging, threading, importlib, sys -from datetime import datetime, timezone, timedelta -from pathlib import Path -from typing import Optional - -import httpx -import requests -from fastapi import FastAPI, HTTPException, Query -from fastapi.responses import JSONResponse, FileResponse, HTMLResponse -from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates - -# ... (rest of app_v2_entry.py content) diff --git a/app_v2_entry_v2.py b/app_v2_entry_v2.py deleted file mode 100644 index f8093e26f974eb852f11f2ec736c0483934ac353..0000000000000000000000000000000000000000 --- a/app_v2_entry_v2.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -VNEWS App v2 - Main application with match detail API -""" -import os, json, re, time, asyncio, hashlib, logging, threading, importlib -from datetime import datetime, timezone, timedelta -from pathlib import Path -from typing import Optional - -import httpx -import requests -from fastapi import FastAPI, HTTPException, Query -from fastapi.responses import JSONResponse, FileResponse, HTMLResponse -from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates - -# ... (rest of app_v2_entry.py content) diff --git a/app_v2_patch.py b/app_v2_patch.py deleted file mode 100644 index 8813192d6506e09ea86871b4e95bb1121810bb3e..0000000000000000000000000000000000000000 --- a/app_v2_patch.py +++ /dev/null @@ -1,111 +0,0 @@ -"""VNEWS v2 Patch - auto scheduler + status endpoints + keep-alive. -This is imported by app_v2_entry.py to add auto posting functionality. -FIX v2: Catch-up scheduler + keep-alive to prevent Space sleep -""" -import sys, os, threading, json, time, logging -from datetime import datetime, timezone, timedelta -from fastapi import Request -from fastapi.responses import JSONResponse -import requests as _req - -VN_TZ = timezone(timedelta(hours=7)) -LOG = logging.getLogger("app_v2_patch") -LOG.setLevel(logging.INFO) -if not LOG.handlers: - ch = logging.StreamHandler() - ch.setFormatter(logging.Formatter('%(asctime)s [app_v2_patch] %(levelname)s: %(message)s')) - LOG.addHandler(ch) - -# ===== Keep-alive: prevent Space from sleeping ===== -# HF Spaces sleep after ~30 min of inactivity on free tier -# This thread pings the Space every 10 minutes to keep it alive -SPACE_URL = "https://bep40-vnews.hf.space" - -def _keep_alive_loop(): - """Ping the Space every 10 minutes to prevent sleep.""" - LOG.info(f"🔄 Keep-alive thread started - ping {SPACE_URL} every 10 min") - while True: - try: - time.sleep(600) # 10 minutes - _req.get(f"{SPACE_URL}/api/scheduler/status", - headers={"User-Agent": "VNEWS-KeepAlive/1.0"}, - timeout=15) - LOG.debug("Keep-alive ping OK") - except Exception as e: - LOG.warning(f"Keep-alive ping failed (Space may be sleeping): {e}") - -# Start keep-alive in background -try: - _ka_thread = threading.Thread(target=_keep_alive_loop, daemon=True, name="keep-alive") - _ka_thread.start() - LOG.info("🔄 Keep-alive started - Space will stay awake") -except Exception as e: - LOG.warning(f"Keep-alive start failed: {e}") - -# ===== Start auto scheduler ===== -try: - import auto_scheduler as _as - _as.start_auto_scheduler() - LOG.info("[auto_scheduler] Started successfully - will post at 7:00, 13:00, 19:00 VN time (with catch-up)") -except Exception as e: - LOG.error(f"[auto_scheduler] Start failed: {e}") - -def register_scheduler_endpoints(app): - """Register scheduler status/trigger endpoints on the FastAPI app.""" - - @app.get('/api/scheduler/status') - def scheduler_status(): - running = any(t.name == 'auto-scheduler' and t.is_alive() for t in threading.enumerate()) - keep_alive = any(t.name == 'keep-alive' and t.is_alive() for t in threading.enumerate()) - - # Load state to show which slots ran today - today_str = datetime.now(VN_TZ).strftime('%Y-%m-%d') - state = {} - try: - state_file = '/data/scheduler_state.json' if os.path.isdir('/data') else None - if state_file and os.path.exists(state_file): - state = json.load(open(state_file, 'r')) - except: - pass - - ran_today = state.get(today_str, {}) if state else {} - - return JSONResponse({ - "running": running, - "keep_alive": keep_alive, - "schedule": "7:00, 13:00, 19:00 VN time", - "today": today_str, - "slots_ran_today": ran_today, - "catch_up_enabled": True, - "next_run": "7:00, 13:00, or 19:00 VN time (whichever is next)" - }) - - @app.post('/api/scheduler/trigger') - async def scheduler_trigger(): - try: - import auto_scheduler as _as2 - _as2._run_scheduled_posting() - return JSONResponse({"ok": True, "message": "Scheduled posting triggered manually"}) - except Exception as e: - return JSONResponse({"ok": False, "error": str(e)}, status_code=500) - - @app.get('/api/scheduler/force') - def scheduler_force(): - """Force-run all missed slots immediately. Useful after deploy.""" - try: - import auto_scheduler as _as2 - _as2._check_missed_slots() - return JSONResponse({"ok": True, "message": "Missed slots check triggered"}) - except Exception as e: - return JSONResponse({"ok": False, "error": str(e)}, status_code=500) - - return app - - -# Auto-register on the main app from app_v2_entry -try: - from main import app - register_scheduler_endpoints(app) - LOG.info("[app_v2_patch] Scheduler endpoints registered: /api/scheduler/status, /api/scheduler/trigger, /api/scheduler/force") -except Exception as e: - LOG.error(f"[app_v2_patch] Could not register endpoints: {e}") diff --git a/auto_scheduler.py b/auto_scheduler.py deleted file mode 100644 index 0681dc5b881e7a7a254a55b095f05ded955c8159..0000000000000000000000000000000000000000 --- a/auto_scheduler.py +++ /dev/null @@ -1,396 +0,0 @@ -"""VNEWS Auto Scheduler - tự động đăng 3 bài rewrite AI + shorts từ 3 chủ đề HOT -Vào các khung giờ: 7:00, 13:00, 19:00 (giờ Việt Nam) -Mỗi bài: Rewrite AI từ nguồn báo + short video tự động -FIX v7: Giữ nguyên tiêu đề gốc từng bài viết + thêm "Tin tóm tắt VNEWS 7h sáng/13h trưa/19h tối" ở đầu text -""" -import os, re, json, time, threading, asyncio, logging, random, hashlib, html as html_lib -from datetime import datetime, timezone, timedelta, date -from urllib.parse import quote -import requests -from bs4 import BeautifulSoup - -# Import storage for persistent data -from storage import load_wall_posts, save_wall_posts, DATA_DIR - -VN_TZ = timezone(timedelta(hours=7)) -LOG = logging.getLogger("auto_scheduler") -LOG.setLevel(logging.INFO) -if not LOG.handlers: - ch = logging.StreamHandler() - ch.setFormatter(logging.Formatter('%(asctime)s [%(name)s] %(levelname)s: %(message)s')) - LOG.addHandler(ch) - -SCHEDULE_TIMES = [(7, 0), (13, 0), (19, 0)] -SCHEDULE_LABELS = {t: f"{t[0]:02d}:{t[1]:02d}" for t in SCHEDULE_TIMES} - -os.makedirs(DATA_DIR, exist_ok=True) -SCHEDULE_STATE_FILE = os.path.join(DATA_DIR, 'scheduler_state.json') - -def _load_state(): - try: - if os.path.exists(SCHEDULE_STATE_FILE): - with open(SCHEDULE_STATE_FILE, 'r') as f: return json.load(f) - except: pass - return {} - -def _save_state(state): - try: - tmp = SCHEDULE_STATE_FILE + '.tmp' - with open(tmp, 'w') as f: json.dump(state, f, ensure_ascii=False) - os.replace(tmp, SCHEDULE_STATE_FILE) - except Exception as e: LOG.warning(f"Cannot save state: {e}") - -_STOP = set('và của các những một được trong với cho tại sau trước khi không người vietnam hôm nay mới nhất nóng tin tức cập nhật theo từ đến là có thì này đã để'.split()) - -def _clean(s): - s = html_lib.unescape(s or "") - # FIX: Remove malformed HTML artifacts (truncated tags without closing >) - s = s.replace(']+>', '', s) # Remove all HTML tags - return re.sub(r"\s+", " ", s).strip() - -def _get_hot_topics(): - freq = {}; display = {} - feeds = [ - 'https://vnexpress.net/rss/tin-moi-nhat.rss', - 'https://dantri.com.vn/rss/home.rss', - 'https://vietnamnet.vn/rss/tin-moi-nhat.rss', - 'https://thanhnien.vn/rss/home.rss', - 'https://tuoitre.vn/rss/tin-moi-nhat.rss', - 'https://genk.vn/rss', - 'https://vnexpress.net/rss/the-thao.rss', - 'https://thethaovanhoa.vn/rss/tin-nong.rss', - 'https://vnexpress.net/rss/kinh-doanh.rss', - 'https://dantri.com.vn/rss/the-gioi.rss', - ] - for feed_url in feeds: - try: - r = requests.get(feed_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=6) - r.encoding = 'utf-8' - soup = BeautifulSoup(r.text, 'xml') - for item in soup.find_all('item')[:12]: - title = _clean(item.find('title').get_text() if item.find('title') else '') - if not title: continue - title = re.sub(r'\s*[-|].*$', '', title) - words = [w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+', title) if len(w) > 2 and w.lower() not in _STOP] - if len(words) < 2: continue - for n in (3, 4, 2): - for i in range(max(0, len(words) - n + 1)): - phrase = ' '.join(words[i:i + n]) - if 8 <= len(phrase) <= 45: - key = phrase.lower() - freq[key] = freq.get(key, 0) + 1 - display[key] = phrase - except: continue - ranked = sorted(freq.items(), key=lambda x: x[1], reverse=True) - topics = []; seen = set() - for key, count in ranked: - kw = display[key] - is_dup = any(len(set(e.split()) & set(key.split())) / max(len(set(e.split())), len(set(key.split())), 1) > 0.6 for e in seen) - if is_dup: continue - seen.add(key) - topics.append({'label': '#' + re.sub(r'\s+', '', kw.title()), 'topic': kw, 'count': count}) - if len(topics) >= 20: break - for kw in ['World Cup 2026', 'Kinh tế Việt Nam', 'Bóng đá châu Âu', 'Công nghệ AI', 'Giá vàng', 'Thời tiết']: - if len(topics) >= 24: break - if not any(kw.lower() in s for s in seen): - topics.append({'label': '#' + re.sub(r'\s+', '', kw.title()), 'topic': kw, 'count': 0}) - return topics[:24] - -_ai_ext = None; _ai_patch = None -def _get_ai_ext(): - global _ai_ext - if _ai_ext is None: import ai_ext as m; _ai_ext = m - return _ai_ext -def _get_ai_patch(): - global _ai_patch - if _ai_patch is None: import ai_patch as m; _ai_patch = m - return _ai_patch - -_RSS_FEEDS = [ - ('https://vnexpress.net/rss/tin-moi-nhat.rss', 'VnExpress'), - ('https://dantri.com.vn/rss/home.rss', 'Dân Trí'), - ('https://vietnamnet.vn/rss/tin-moi-nhat.rss', 'VietNamNet'), - ('https://thanhnien.vn/rss/home.rss', 'Thanh Niên'), - ('https://tuoitre.vn/rss/tin-moi-nhat.rss', 'Tuổi Trẻ'), - ('https://genk.vn/rss', 'GenK'), - ('https://vnexpress.net/rss/the-thao.rss', 'VnExpress'), - ('https://thethaovanhoa.vn/rss/tin-nong.rss', 'TT&VH'), - ('https://vnexpress.net/rss/kinh-doanh.rss', 'VnExpress'), - ('https://dantri.com.vn/rss/the-gioi.rss', 'Dân Trí'), -] - -def _search_articles_by_topic(topic, limit=4): - all_articles = []; seen_urls = set() - topic_lower = topic.lower() - topic_words = set(re.findall(r'[A-Za-zÀ-ỹ0-9]+', topic_lower)) - for feed_url, source in _RSS_FEEDS: - try: - r = requests.get(feed_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=6) - r.encoding = 'utf-8' - soup = BeautifulSoup(r.text, 'xml') - for item in soup.find_all('item')[:8]: - title = _clean(item.find('title').get_text() if item.find('title') else '') - link = _clean(item.find('link').get_text() if item.find('link') else '') - desc = _clean(item.find('description').get_text() if item.find('description') else '') - if not title or not link or link in seen_urls: continue - seen_urls.add(link) - title_words = set(re.findall(r'[A-Za-zÀ-ỹ0-9]+', title.lower())) - overlap = len(topic_words & title_words) if topic_words else 0 - exact_match = topic_lower in title.lower() or topic_lower in desc.lower() - if exact_match or overlap >= 2: - img = '' - encl = item.find('enclosure') - if encl: img = encl.get('url', '') - if not img: - try: - art_r = requests.get(link, headers={'User-Agent': 'Mozilla/5.0'}, timeout=4) - art_r.encoding = 'utf-8' - art_soup = BeautifulSoup(art_r.text, 'lxml') - ogi = art_soup.find('meta', property='og:image') - if ogi: img = ogi.get('content', '') - except: pass - all_articles.append({'title': title, 'url': link, 'raw': desc or title, 'image': img, 'via': source, 'source': {'title': title, 'url': link, 'excerpt': (desc or title)[:700], 'via': source}}) - if len(all_articles) >= limit: break - except: continue - return all_articles[:limit] - -async def _create_ai_post(topic): - ai_ext = _get_ai_ext(); ai_patch = _get_ai_patch() - articles = _search_articles_by_topic(topic, limit=4) - if not articles: - LOG.warning(f"No articles for topic: {topic}. Fallback.") - return await _create_fallback_post(topic, ai_ext, ai_patch) - posts = [] - # Get schedule time label for text intro (7h sáng, 13h trưa, 19h tối) - now = datetime.now(VN_TZ) - hour = now.hour - time_label = "7h sáng" if hour == 7 else ("13h trưa" if hour == 13 else "19h tối") - text_intro = f"Tin tóm tắt VNEWS {time_label}" - wall = ai_ext._load_ai_wall() - if not isinstance(wall, list): wall = [] - for art in articles: - try: - prompt = ai_patch._make_summary_prompt(art.get('title', topic), art.get('raw', ''), art.get('via', '')) - text = await ai_ext.qwen_generate(prompt, image_url=art.get('image'), max_tokens=1500) - text = ai_patch._postprocess_ai_text(text, max_units=20) - src = [art.get('source', {'title': art.get('title', topic), 'url': art.get('url', ''), 'via': art.get('via', '')})] - # Prepend time label intro to text (giữ nguyên title là tiêu đề gốc của bài báo) - if text and not text.startswith(text_intro): - text = f"{text_intro}\n\n{text}" - if 'Nguồn tham khảo:' not in (text or ''): - text = (text or '') + "\n\n" + ai_patch._source_line(src) - img = art.get('image') or ai_ext.pollination_image_url(art.get('title', topic)) - # Dùng art.get('title') GIỮ NGUYÊN tiêu đề gốc từ bài báo - post = ai_ext.make_post(art.get('title', topic), text, img, art.get('url', ''), 'auto_scheduled', sources=src) - try: - page_data = ai_patch._scrape_article_images(art.get('url', '')) - if page_data and page_data.get('paragraphs'): - kp = ai_patch._extract_key_points_for_slides(page_data['paragraphs'], max_points=8) - if kp: - imgs = page_data.get('images', []) - if not imgs and page_data.get('og_img'): imgs = [page_data['og_img']] - slides = [] - for i, pt in enumerate(kp): - slides.append({'text': pt, 'image': imgs[i] if i < len(imgs) else (imgs[-1] if imgs else ''), 'index': i + 1}) - post['slides'] = slides - except: pass - posts.append(post) - except Exception as e: - LOG.error(f"Error post: {e}") - if not posts: return await _create_fallback_post(topic, ai_ext, ai_patch) - wall = posts + wall - ai_ext._save_ai_wall(wall) - for post in posts: - try: _try_generate_short(post) - except: pass - return posts - -async def _create_fallback_post(topic, ai_ext, ai_patch): - LOG.info(f"Fallback: {topic}") - try: - # Still add time label to fallback posts - now = datetime.now(VN_TZ) - hour = now.hour - time_label = "7h sáng" if hour == 7 else ("13h trưa" if hour == 13 else "19h tối") - text_intro = f"Tin tóm tắt VNEWS {time_label}" - text = f"{text_intro}\n\n• {topic} đang là chủ đề nóng hôm nay.\n• Theo dõi VNEWS để cập nhật tin tức mới nhất." - img = ai_ext.pollination_image_url(topic) - post = ai_ext.make_post(topic, text, img, '', 'auto_scheduled', sources=[]) - wall = ai_ext._load_ai_wall() - if not isinstance(wall, list): wall = [] - wall = [post] + wall - ai_ext._save_ai_wall(wall) - LOG.info(f"Fallback saved: {topic}") - return [post] - except Exception as e: - LOG.error(f"Fallback failed: {e}") - return [] - -def _try_generate_short(post): - post_id = post.get('id', '') - if not post_id: return - try: - ai_ext = _get_ai_ext(); ai_patch = _get_ai_patch() - if ai_ext.gTTS is None: return - segments = ai_patch._summary_segments_from_post(post, max_segments=15) - if not segments: return - 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_mp4 = os.path.join(ai_ext.SHORTS_DIR, ai_ext._safe_name(post_id + suffix) + '.mp4') - if os.path.exists(out_mp4): - post['video'] = '/api/ai/short-file/' + post_id + suffix - wall = ai_ext._load_ai_wall() - for i, p in enumerate(wall): - if p.get('id') == post_id: wall[i] = post; break - ai_ext._save_ai_wall(wall); return - threading.Thread(target=lambda: _generate_short_worker(post, segments, post_id, suffix, out_mp4), daemon=True).start() - except Exception as e: LOG.warning(f"Short init: {e}") - -def _generate_short_worker(post, segments, post_id, suffix, out_mp4): - import subprocess - try: - ai_ext = _get_ai_ext(); ai_patch = _get_ai_patch() - work = os.path.join(ai_ext.SHORTS_DIR, ai_ext._safe_name(post_id + suffix)) - os.makedirs(work, exist_ok=True) - img = os.path.join(work, 'image.jpg') - ai_ext._download_image(post.get('img'), post.get('title', 'AI news'), img) - part_files = [] - for idx, seg in enumerate(segments[:10]): - frame = os.path.join(work, f'frame_{idx:02d}.jpg') - aud = os.path.join(work, f'voice_{idx:02d}.mp3') - aud_fast = os.path.join(work, f'voice_{idx:02d}_fast.mp3') - part = os.path.join(work, f'part_{idx:02d}.mp4') - try: ai_patch._make_scene_frame(post, seg, idx, min(len(segments), 10), img, frame, emotion='neutral') - except: - if not os.path.exists(img): continue - from PIL import Image - Image.new('RGB', (1080, 1920), (14, 14, 14)).save(frame, quality=85) - tts_text = re.sub(r'^[•\-\*\d\.\)\s]+', '', seg).strip() - try: ai_ext.gTTS(tts_text, lang='vi', slow=False).save(aud) - except: - try: ai_ext.gTTS(tts_text, lang='vi', tld='com.vn', slow=False).save(aud) - except: continue - subprocess.run(['ffmpeg', '-y', '-i', aud, '-filter:a', 'atempo=1.0', '-vn', aud_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90) - dur = 12.0 - try: - pr = subprocess.run(['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:no_key=1', aud_fast], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20) - dur = max(8.0, float((pr.stdout or b'').decode().strip() or 12.0)) + 0.5 - except: pass - subprocess.run(['ffmpeg', '-y', '-loop', '1', '-t', str(dur), '-i', frame, '-i', aud_fast, '-shortest', '-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '128k', part], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=150) - part_files.append(part) - if part_files: - concat = os.path.join(work, 'concat.txt') - with open(concat, '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, '-c', 'copy', out_mp4], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180) - post['video'] = '/api/ai/short-file/' + post_id + suffix - post['short_voice'] = 'nu'; post['short_emotion'] = 'neutral'; post['short_speed'] = 1.0 - post['short_segments'] = segments; post['short_subtitles'] = False - wall = ai_ext._load_ai_wall() - for i, p in enumerate(wall): - if p.get('id') == post_id: wall[i] = post; break - ai_ext._save_ai_wall(wall) - LOG.info(f"Short: {post_id}") - except Exception as e: LOG.warning(f"Short fail: {e}") - -def _run_async(coro): - """Run async coroutine safely regardless of current event loop state.""" - try: - loop = asyncio.get_running_loop() - except RuntimeError: - return asyncio.run(coro) - import concurrent.futures - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: - return pool.submit(asyncio.run, coro).result(timeout=300) - -def _run_scheduled_posting(): - LOG.info("=" * 50) - LOG.info("Scheduler triggered at %s", datetime.now(VN_TZ).strftime('%H:%M %d/%m/%Y')) - LOG.info("=" * 50) - try: - hot_topics = _get_hot_topics() - if not hot_topics: - LOG.warning("No hot topics"); return - selected = []; seen_labels = set() - for t in hot_topics: - label = t.get('label', '') - if label and label not in seen_labels: - seen_labels.add(label); selected.append(t['topic']) - if len(selected) >= 3: break - if len(selected) < 3: - selected = ['Thời sự Việt Nam', 'Kinh tế Việt Nam', 'Thể thao'] - LOG.info(f"Topics: {selected}") - async def _do_all(): - results = [] - for topic in selected: - try: - posts = await _create_ai_post(topic) - results.append({'topic': topic, 'posts': len(posts) if posts else 0}) - LOG.info(f"{'✓' if posts else '✗'} {topic}: {len(posts) if posts else 0} posts") - except Exception as e: - LOG.error(f"Error {topic}: {e}") - results.append({'topic': topic, 'posts': 0}) - return results - results = _run_async(_do_all()) - LOG.info(f"Done: {len(results)} topics") - for r in results: LOG.info(f" • {r['topic']}: {r['posts']} bài") - except Exception as e: - LOG.error(f"Scheduler error: {e}", exc_info=True) - -def _check_missed_slots(): - try: - state = _load_state() - today_str = datetime.now(VN_TZ).strftime('%Y-%m-%d') - now = datetime.now(VN_TZ); cur_mins = now.hour * 60 + now.minute - ran = state.get(today_str, {}) - for s in SCHEDULE_TIMES: - lbl = SCHEDULE_LABELS[s]; sm = s[0] * 60 + s[1] - if ran.get(lbl): continue - if cur_mins >= sm: - LOG.info(f"Catch-up: {lbl}") - _run_scheduled_posting() - if today_str not in state: state[today_str] = {} - state[today_str][lbl] = True; _save_state(state) - except Exception as e: LOG.error(f"Catch-up: {e}") - -def _scheduler_loop(): - LOG.info("Scheduler started") - LOG.info(f"Schedule: {', '.join(f'{h:02d}:{m:02d}' for h,m in SCHEDULE_TIMES)} VN") - state = _load_state(); today_str = datetime.now(VN_TZ).strftime('%Y-%m-%d') - ran = state.get(today_str, {}) - now = datetime.now(VN_TZ); cur_mins = now.hour * 60 + now.minute - for s in SCHEDULE_TIMES: - lbl = SCHEDULE_LABELS[s]; sm = s[0] * 60 + s[1] - if ran.get(lbl): LOG.info(f" ✓ {lbl} done"); continue - if cur_mins >= sm: - LOG.info(f" → {lbl} missed! Catch-up") - _run_scheduled_posting() - if today_str not in state: state[today_str] = {} - state[today_str][lbl] = True; _save_state(state) - else: LOG.info(f" ⏩ {lbl} upcoming") - while True: - try: - now = datetime.now(VN_TZ) - ck = (now.hour, now.minute) - state = _load_state(); today_str = now.strftime('%Y-%m-%d') - ran = state.get(today_str, {}) - for s in SCHEDULE_TIMES: - lbl = SCHEDULE_LABELS[s] - if ck == s and not ran.get(lbl): - LOG.info(f"On-time: {lbl}") - _run_scheduled_posting() - if today_str not in state: state[today_str] = {} - state[today_str][lbl] = True; _save_state(state) - break - time.sleep(60) - except Exception as e: - LOG.error(f"Loop: {e}") - time.sleep(60) - -def start_auto_scheduler(): - t = threading.Thread(target=_scheduler_loop, daemon=True, name="auto-scheduler") - t.start() - LOG.info("Auto scheduler started") - return t diff --git a/auto_update_sse.py b/auto_update_sse.py deleted file mode 100644 index 965d8911b230df411dffb1bd4aa992adeb6a5e90..0000000000000000000000000000000000000000 --- a/auto_update_sse.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Auto-update SSE endpoint for VNEWS - pushes updates when new posts/shorts published.""" -import asyncio -import json -import time -from fastapi import Request -from fastapi.responses import StreamingResponse - -# Connected clients queue -_clients = [] -_lock = asyncio.Lock() - -async def _notify_clients(event_type: str, data: dict): - """Send notification to all SSE clients.""" - if not _clients: - return - msg = f"data: {json.dumps({'type': event_type, 'data': data, 'ts': int(time.time())})}\n\n" - async with _lock: - dead = [] - for q in _clients: - try: - await q.put_nowait(msg) - except asyncio.QueueFull: - pass - except: - dead.append(q) - for q in dead: - if q in _clients: - _clients.remove(q) - -# Public functions to call from other modules -notify_new_post = lambda post: asyncio.create_task(_notify_clients("new_post", post)) if post else None -notify_new_short = lambda post: asyncio.create_task(_notify_clients("new_short", post)) if post else None - -async def sse_events(request: Request): - """SSE endpoint for real-time updates on homepage.""" - q = asyncio.Queue(maxsize=10) - _clients.append(q) - - async def event_generator(): - try: - # Send initial connection message - yield "data: {\"type\":\"connected\",\"ts\":null}\n\n" - while not await request.is_disconnected(): - try: - msg = await asyncio.wait_for(q.get(), timeout=25.0) - yield msg - except asyncio.TimeoutError: - yield ":keepalive\n\n" - except: - pass - finally: - if q in _clients: - _clients.remove(q) - - return StreamingResponse(event_generator(), media_type="text/event-stream") \ No newline at end of file diff --git a/bdp_full.html b/bdp_full.html deleted file mode 100644 index a9beedf1acb6d0beac8485aaf0704a3798cc5ea8..0000000000000000000000000000000000000000 --- a/bdp_full.html +++ /dev/null @@ -1,1250 +0,0 @@ - - - - Hull City vs MU: Quỷ Đỏ vào hang bắt hổ - Bongdaplus.vn - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- -
-
- - - - - - - -
- -
-
- - -
-
- - -
-
- -
-
-

Theo dõi thông tin

- -
-
-
-
- - - - - - - -
-
- - -
-
- - - - -
-
- -
- - -
-
-
-
- Multimedia» -
-
-
- -
-
-
-
-
-
- -
-
- -
-
-
-

Hull City vs MU: Quỷ Đỏ vào hang bắt hổ

-

- Mùa giải Premier League 2026/27 của MU sẽ mở màn bằng chuyến làm khách đầy thử thách trước tân binh Hull City tại sân MKM. Cuộc đối đầu Hull City vs MU là màn chạm trán đáng chú ý khi “Đàn Hổ” đang tràn đầy khí thế, trong khi Quỷ đỏ không được phép sảy chân nếu muốn sớm tạo đà cho cuộc đua vô địch. -

- -
08:07 - 22/08/2026
-
- - -
-
- - - - -
-
-
-
-
-
- -
-
- - - - - -
- -
-
Bình luận
-
-
-
- -
- -
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
- - - - - -
-
-
- - -
-
-
-
Video mới nhất
- -
-
- -
-
- Hull City vs MU: Quỷ Đỏ vào hang bắt hổ - Hull City vs MU: Quỷ Đỏ vào hang bắt hổ - - 44 phút trước - - - -
-
- -
-
- Brentford vs Tottenham: De Zerbi vượt ải Gtech - Brentford vs Tottenham: De Zerbi vượt ải Gtech - - 45 phút trước - - - -
-
- - - - - -
- -
- - - - - - - - - - -
-
-
-
- - - -
-
-
- - -
-
- - -
- - - - - - - - -
-
-
-
-
-
Tạp chí điện tử Bóng đá
-
-
-

Giấy phép số 48/GP-BTTTT cấp ngày 05/02/2020

-

Tổng biên tập:           Vũ Khắc Sơn

-

Phó tổng biên tập:    Nguyễn Hà Thanh

-
-

Địa chỉ:  Tầng 6, Tòa nhà Licogi 13 Tower, 164 đường Khuất Duy Tiến, phường Thanh Xuân, Thành phố Hà Nội

-

Văn phòng giao dịch: Tầng 5-6-7, toà nhà LĐBĐVN, số 18 Lý Văn Phức, phường Ô Chợ Dừa, Hà Nội

-

Điện thoại: (84.24) 3554 1188 - (84.24) 3554 1199

-

- Email:     toasoan@bongdaplus.vn | - vanphong@bongdaplus.vn -

-
-

Liên hệ quảng cáo

-

Hotline:  0903 203 412

-

Email:    tien.nguyen@giaminhmedia.vn

-
-
-
- -
-
-
- -
- Bản quyền ©2011 Bongdaplus.vn. Chỉ được phát hành lại thông tin khi - có sự đồng ý bằng văn bản của Tạp chí điện tử Bóng đá -
-
-
-
-
- -
-
-
-
-
Thông tin Toà soạn
- -
-
-
Tạp chí Điện tử Bóng Đá
-
-
Tổng biên tập:
-
Vũ Khắc Sơn
-
-
Phó Tổng biên tập:
-
- Nguyễn Hà Thanh -
-
-
-
-
Địa chỉ:
-
Tầng 6, Tòa nhà Licogi 13 Tower, 164 đường Khuất Duy Tiến, phường Thanh Xuân, Thành phố Hà Nội
-
Văn phòng giao dịch:
-
Tầng 5-6-7, toà nhà LĐBĐVN, số 18 Lý Văn Phức, phường Ô Chợ Dừa, Hà Nội
-
Tel:
-
(84.24) 3554 1188 - (84.24) 3554 1199
-
Fax:
-
(84.24) 3553 9898
-
Email:
- -
-
-
- -
-
-
Thông tin Liên hệ
- -
-
-
Tạp chí Điện tử Bóng Đá
-
-
Hotline:
-
0903 203 412
-
Email:
- -
-

Địa chỉ liên hệ:

- Tầng 6, Tòa nhà Licogi 13 Tower, 164 đường Khuất Duy Tiến, phường Thanh Xuân, Thành phố Hà Nội -

Văn phòng giao dịch:

- Tầng 5-6-7, toà nhà LĐBĐVN, số 18 Lý Văn Phức, phường Ô Chợ Dừa, Hà Nội -
-
- - - -
- - - - - - - - - - - diff --git a/bdp_page.html b/bdp_page.html deleted file mode 100644 index 8eee4b5139966796d60d0714c8ae2da75ccc2c1e..0000000000000000000000000000000000000000 --- a/bdp_page.html +++ /dev/null @@ -1,1250 +0,0 @@ - - - - Hull City vs MU: Quỷ Đỏ vào hang bắt hổ - Bongdaplus.vn - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- -
-
- - - -
- - - - -
-
- - - - - - - -
- -
-
- - -
-
- - -
-
- -
-
-

Theo dõi thông tin

- -
-
-
-
- - - - - - - -
-
- - -
-
- - - - -
-
- -
- - -
-
-
-
- Multimedia» -
-
-
- -
-
-
-
-
-
- -
-
- -
-
-
-

Hull City vs MU: Quỷ Đỏ vào hang bắt hổ

-

- Mùa giải Premier League 2026/27 của MU sẽ mở màn bằng chuyến làm khách đầy thử thách trước tân binh Hull City tại sân MKM. Cuộc đối đầu Hull City vs MU là màn chạm trán đáng chú ý khi “Đàn Hổ” đang tràn đầy khí thế, trong khi Quỷ đỏ không được phép sảy chân nếu muốn sớm tạo đà cho cuộc đua vô địch. -

- -
08:07 - 22/08/2026
-
- - -
-
- - - - -
-
-
-
-
-
- -
-
- - - - - -
- -
-
Bình luận
-
-
-
- -
- -
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
- - - - - -
-
-
- - -
-
-
-
Video mới nhất
- -
-
- -
-
- Hull City vs MU: Quỷ Đỏ vào hang bắt hổ - Hull City vs MU: Quỷ Đỏ vào hang bắt hổ - - 43 phút trước - - - -
-
- -
-
- Brentford vs Tottenham: De Zerbi vượt ải Gtech - Brentford vs Tottenham: De Zerbi vượt ải Gtech - - 43 phút trước - - - -
-
- - - - - -
- -
- - - - - - - - - - -
-
-
-
- - - -
-
-
- - -
-
- - -
- - - - - - - - -
-
-
-
-
-
Tạp chí điện tử Bóng đá
-
-
-

Giấy phép số 48/GP-BTTTT cấp ngày 05/02/2020

-

Tổng biên tập:           Vũ Khắc Sơn

-

Phó tổng biên tập:    Nguyễn Hà Thanh

-
-

Địa chỉ:  Tầng 6, Tòa nhà Licogi 13 Tower, 164 đường Khuất Duy Tiến, phường Thanh Xuân, Thành phố Hà Nội

-

Văn phòng giao dịch: Tầng 5-6-7, toà nhà LĐBĐVN, số 18 Lý Văn Phức, phường Ô Chợ Dừa, Hà Nội

-

Điện thoại: (84.24) 3554 1188 - (84.24) 3554 1199

-

- Email:     toasoan@bongdaplus.vn | - vanphong@bongdaplus.vn -

-
-

Liên hệ quảng cáo

-

Hotline:  0903 203 412

-

Email:    tien.nguyen@giaminhmedia.vn

-
-
-
- -
-
-
- -
- Bản quyền ©2011 Bongdaplus.vn. Chỉ được phát hành lại thông tin khi - có sự đồng ý bằng văn bản của Tạp chí điện tử Bóng đá -
-
-
-
-
- -
-
-
-
-
Thông tin Toà soạn
- -
-
-
Tạp chí Điện tử Bóng Đá
-
-
Tổng biên tập:
-
Vũ Khắc Sơn
-
-
Phó Tổng biên tập:
-
- Nguyễn Hà Thanh -
-
-
-
-
Địa chỉ:
-
Tầng 6, Tòa nhà Licogi 13 Tower, 164 đường Khuất Duy Tiến, phường Thanh Xuân, Thành phố Hà Nội
-
Văn phòng giao dịch:
-
Tầng 5-6-7, toà nhà LĐBĐVN, số 18 Lý Văn Phức, phường Ô Chợ Dừa, Hà Nội
-
Tel:
-
(84.24) 3554 1188 - (84.24) 3554 1199
-
Fax:
-
(84.24) 3553 9898
-
Email:
- -
-
-
- -
-
-
Thông tin Liên hệ
- -
-
-
Tạp chí Điện tử Bóng Đá
-
-
Hotline:
-
0903 203 412
-
Email:
- -
-

Địa chỉ liên hệ:

- Tầng 6, Tòa nhà Licogi 13 Tower, 164 đường Khuất Duy Tiến, phường Thanh Xuân, Thành phố Hà Nội -

Văn phòng giao dịch:

- Tầng 5-6-7, toà nhà LĐBĐVN, số 18 Lý Văn Phức, phường Ô Chợ Dừa, Hà Nội -
-
- - - -
- - - - - - - - - - - diff --git a/bongda_proxy.py b/bongda_proxy.py deleted file mode 100644 index 826427d4fc358fbf512c7230cf218fd794d180cd..0000000000000000000000000000000000000000 --- a/bongda_proxy.py +++ /dev/null @@ -1,113 +0,0 @@ -"""VNEWS — Bongda Proxy Endpoint (for fast match detail loading)""" -import requests -from bs4 import BeautifulSoup -import re -import json - -def _cl(s): - return re.sub(r'\s+', ' ', str(s or '')).strip() - -def _normalize_time(raw): - t = _cl(raw) - t = re.sub(r"(\d+)'\s*\+(\d+)", r"\1+\2'", t) - t = t.replace("''", "'") - return t - -def scrape_match_html(event_id, url=None): - result = {"event_id": event_id, "found": False, "sections": []} - headers = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "Referer": "https://bongda.com.vn/", - } - html = None - urls_to_try = [url] if url else [] - urls_to_try += [ - f"https://bongda.com.vn/tran-dau/{event_id}/centre/", - f"https://bongda.com.vn/tran-dau/{event_id}/preview/", - ] - for u in urls_to_try: - if not u: - continue - try: - resp = requests.get(u, headers=headers, timeout=15, allow_redirects=True) - if resp.status_code == 200 and len(resp.text) > 1000: - html = resp.text - break - except Exception: - continue - if not html: - return result - try: - soup = BeautifulSoup(html, 'html.parser') - info = {} - tel = soup.select_one('.teams') - if tel: - he = tel.select_one('.team.home') - if he: - ne = he.select_one('p:not(.logo)') or he.find('p') - if ne: info['home_team'] = _cl(ne.get_text()) - lo = he.select_one('img') - if lo: info['home_logo'] = lo.get('src', '') - ae = tel.select_one('.team.away') - if ae: - ne = ae.select_one('p:not(.logo)') or ae.find('p') - if ne: info['away_team'] = _cl(ne.get_text()) - lo = ae.select_one('img') - if lo: info['away_logo'] = lo.get('src', '') - sc = tel.select_one('.score') - if sc: - parts = [_cl(p.get_text()) for p in sc.select('p')] - if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}" - lb = sc.select_one('.label') - if lb: info['status_label'] = _cl(lb.get_text()) - if info.get('home_team') and info.get('away_team'): - result['info'] = info - result['found'] = True - result['sections'].append('info') - else: - return result - events = [] - events_div = soup.select_one('.events') - if events_div: - period = '' - for child in events_div.children: - if not hasattr(child, 'name') or not child.name: continue - cls = ' '.join(child.get('class', [])) - if 'period' in cls: - h2 = child.find('h2') - if h2: period = _cl(h2.get_text()) - for ev in child.children: - if not hasattr(ev, 'name') or not ev.name: continue - ev_cls = ' '.join(ev.get('class', [])) - if 'event' not in ev_cls: continue - ev_data = {'team': 'home' if 'home' in ev_cls else 'away', 'period': period, 'type': 'unknown', 'time': ''} - type_el = ev.select_one('.event-type') - if type_el: - if type_el.select_one('[class*="redcard"]'): ev_data['type'] = 'redcard' - elif type_el.select_one('[class*="yellowcard"]'): ev_data['type'] = 'yellowcard' - elif type_el.select_one('[class*="goal"]'): ev_data['type'] = 'goal' - elif type_el.select_one('[class*="substitution"]'): ev_data['type'] = 'substitution' - players_el = ev.select_one('.players') - if players_el: - time_el = players_el.select_one('.event-time') - if time_el: ev_data['time'] = _normalize_time(time_el.get_text()) - text = _cl(players_el.get_text(' ', strip=True).replace(ev_data['time'], '').strip()) - ev_data['players'] = text - events.append(ev_data) - if events: - result['events'] = events - result['sections'].append('events') - except Exception as e: - result['error'] = str(e) - return result - -from fastapi import Query -from fastapi.responses import JSONResponse - -def add_bongda_proxy_endpoint(app): - @app.get('/api/proxy/bongda') - def proxy_bongda(event_id: int = Query(default=None), url: str = Query(default=None)): - if event_id is None: - return JSONResponse({'error': 'event_id required'}, status_code=400) - return JSONResponse(scrape_match_html(event_id, url)) diff --git a/embed.html b/embed.html deleted file mode 100644 index 7db6b7b500ee9821970bcdd93322abc69bd97e31..0000000000000000000000000000000000000000 --- a/embed.html +++ /dev/null @@ -1,56 +0,0 @@ - - - - - EmbedVideo - Bongdaplus.vn - - - - - - - - - - - - -
- - - - -
- - diff --git a/headers.txt b/headers.txt deleted file mode 100644 index 3ff90d3225bb6378ac856e3f22ce162ddbb0cbdf..0000000000000000000000000000000000000000 --- a/headers.txt +++ /dev/null @@ -1,9 +0,0 @@ -HTTP/2 200 -strict-transport-security: max-age=31536000; includeSubDomains; preload -set-cookie: BongdaplusView_98267_1=1; expires=Sat, 22 Aug 2026 02:21:27 GMT; path=/ -x-frame-options: SAMEORIGIN -x-xss-protection: 1; mode=block -x-content-type-options: nosniff -x-powered-by: ASP.NET -date: Sat, 22 Aug 2026 01:51:27 GMT - diff --git a/index_v2.html b/index_v2.html deleted file mode 100644 index ebf879c251104d2a9d0cc62cd46c92da85cff9b4..0000000000000000000000000000000000000000 --- a/index_v2.html +++ /dev/null @@ -1,98 +0,0 @@ - - - - - -VNEWS - Tin Tức Việt Nam - - - - - - - - - -

📰 VNEWS

Tin tức · Bóng đá LIVE · Highlight · AI · World Cup 2026

-
-
-
Đang tải...
-
-
-
-
-
-

Chi tiết trận đấu

-
📋 Chi tiếtDiễn biếnThống kê
-
Đang tải...
-
-
- - - - - - - - - - - - diff --git a/logs_route.py b/logs_route.py deleted file mode 100644 index f703d40d12d08f49b5dbc361c0d7ef70da5a6a67..0000000000000000000000000000000000000000 --- a/logs_route.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Independent logs page for VNEWS Space. -Serves /logs (HTML) and /logs.txt (raw) so build/runtime errors are visible -even when the Hugging Face build-logs tab is stuck/unavailable. -Mounted from _run.py. -""" -import os -import time -import json -import subprocess -from fastapi import Request -from fastapi.responses import HTMLResponse, PlainTextResponse - -try: - from app_v2_entry import app -except Exception: - from main import app - -BUILD_DONE = "/app/.build_done" -DATA_DIR = '/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data') - - -def _collect(): - lines = [] - lines.append("=== VNEWS LOGS ===") - lines.append("generated: " + time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())) - lines.append("") - # Build marker - if os.path.exists(BUILD_DONE): - lines.append("[BUILD] .build_done exists -> container started OK") - try: - lines.append("[BUILD] built at: " + open(BUILD_DONE).read().strip()) - except Exception: - pass - else: - lines.append("[BUILD] WARNING: .build_done MISSING -> uvicorn started before build finished?") - lines.append("") - - # Space status from HF runtime file - try: - import json as _j - mj = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.huggingface', 'main.json') - if os.path.exists(mj): - lines.append("[RUNTIME] .huggingface/main.json present") - else: - lines.append("[RUNTIME] .huggingface/main.json NOT found") - except Exception as e: - lines.append("[RUNTIME] error: " + str(e)) - lines.append("") - - # Data dir contents - lines.append("[DATA] dir=" + DATA_DIR) - try: - if os.path.isdir(DATA_DIR): - for f in sorted(os.listdir(DATA_DIR)): - p = os.path.join(DATA_DIR, f) - lines.append(" - %s (%d bytes)" % (f, os.path.getsize(p))) - else: - lines.append(" (data dir missing)") - except Exception as e: - lines.append(" error: " + str(e)) - lines.append("") - - # Recent container logs (stdout) if captured - log_paths = ["/tmp/vnews_stdout.log", os.path.join(DATA_DIR, "app.log")] - for lp in log_paths: - if os.path.exists(lp): - lines.append("[STDOUT] tail of " + lp + ":") - try: - with open(lp, "r", errors="replace") as fh: - tail = fh.read().splitlines()[-50:] - for l in tail: - lines.append(" " + l) - except Exception as e: - lines.append(" read error: " + str(e)) - lines.append("") - - # Environment hints - lines.append("[ENV] HF_SPACE: " + os.environ.get("HF_SPACE", "?")) - lines.append("[ENV] SPACE_ID: " + os.environ.get("SPACE_ID", "?")) - lines.append("[ENV] CUDA/CPU: " + ("gpu" if os.environ.get("CUDA_VISIBLE_DEVICES") else "cpu")) - lines.append("") - lines.append("=== END ===") - return "\n".join(lines) - - -@app.get("/logs") -def logs_page(request: Request): - txt = _collect() - html = ( - "" - "" - "VNEWS Logs" - "" - "

VNEWS — Build & Runtime Logs

" - "

📄 raw text · refresh để cập nhật

" - "
" + txt.replace("&", "&").replace("<", "<").replace(">", ">") + "
" - "" - ) - return HTMLResponse(html) - - -@app.get("/logs.txt") -def logs_raw(request: Request): - return PlainTextResponse(_collect()) diff --git a/main.py b/main.py index 332eafbf268666da3406de201a6c73aa76438a87..20c39a0c9946abaac5bddfe3f1bd67d1bd84d62b 100644 --- a/main.py +++ b/main.py @@ -1,722 +1,298 @@ -"""VNEWS - FastAPI backend with livescore + xemlaibongda highlights + VTV channels""" -import re, time, subprocess, json, os, threading +"""VNEWS - FastAPI backend with livescore + xemlaibongda highlights + YouTube FPT shorts""" +import hashlib, re, time, subprocess, json, os, threading import html as html_lib -from datetime import datetime, timezone, timedelta, date -from collections import defaultdict - -VN_TZ = timezone(timedelta(hours=7)) +from datetime import datetime from concurrent.futures import ThreadPoolExecutor, as_completed from fastapi import FastAPI, Query, Request from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, Response -from urllib.parse import quote +from fastapi.staticfiles import StaticFiles +from urllib.parse import unquote, quote, urlencode import requests from bs4 import BeautifulSoup app = FastAPI() - -# ===== WORLD CUP 2026 SCRAPER ===== -from wc2026_scraper import get_wc2026_all, scrape_fixtures, scrape_standings, scrape_stats, scrape_wc_news - -# ===== RATE LIMITING ===== -_rate_limit_data = defaultdict(list) -_rate_limit_lock = threading.Lock() -RATE_LIMIT_MAX = 60 -RATE_LIMIT_WINDOW = 60 - -def _check_rate_limit(ip: str) -> bool: - with _rate_limit_lock: - now = time.time() - _rate_limit_data[ip] = [t for t in _rate_limit_data[ip] if now - t < RATE_LIMIT_WINDOW] - if len(_rate_limit_data[ip]) >= RATE_LIMIT_MAX: return False - _rate_limit_data[ip].append(now) - return True - -@app.middleware("http") -async def rate_limit_middleware(request: Request, call_next): - if request.url.path.startswith("/api/"): - ip = request.client.host - if not _check_rate_limit(ip): return JSONResponse({"error": "rate limit exceeded"}, status_code=429) - return await call_next(request) - -# ===== VTV CHANNELS API ===== -from vtv_api import router as vtv_router -app.include_router(vtv_router) - HEADERS = {"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36","Accept-Language":"vi-VN,vi;q=0.9,en;q=0.8"} BONGDA_HEADERS = {"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36","Accept-Language":"vi-VN,vi;q=0.9","Referer":"https://bongda.com.vn/lich-thi-dau","X-Requested-With":"XMLHttpRequest"} BASE_BDP = "https://bongdaplus.vn" +SPACE_URL = "https://bep40-vnews.hf.space" _cache = {} _cache_ttl = 300 _cache_ttl_live = 60 _cache_ttl_yt = 1800 - +SHORTS_FALLBACK = [ +{"id":"Lu_iCQ5YwNM","title":"Công an lập hồ sơ xử lý người phụ nữ chửi bới, tát nam tài xế ô tô ở Hà Nội | #shorts","channel":"baodantri7941"}, +{"id":"CwWvijF8BOA","title":"Chú rể Ninh Bình bật khóc nhận món quà bí mật người cha quá cố gửi 26 năm trước | #shorts","channel":"baodantri7941"}, +{"id":"tvPewsc2ph4","title":"Tính năng ẩn trên iPhone giúp giảm mỏi mắt | #shorts","channel":"baodantri7941"}, +{"id":"b1Nxzv9ixlU","title":"Y án 3 năm tù với nữ tài xế uống 8 lon bia lái xe tông chủ tịch xã tử vong | #shorts","channel":"baodantri7941"}, +{"id":"Xp5eTwAZAis","title":"Người đánh hàng xóm tại chung cư ở Hà Nội bị tuyên hơn 4 tháng tù | #shorts","channel":"baodantri7941"}, +{"id":"Htzvwg6iOBM","title":"Xe điện Audi S6 Sportback e-tron có gì đặc biệt? | #shorts","channel":"baodantri7941"}, +{"id":"iMdFmWvYdlo","title":"Cô gái người Nga yêu thời trang và đất nước Việt Nam | #shorts","channel":"baodantri7941"}, +{"id":"IVaRc6moEv8","title":"Người nông dân Trung Quốc đột quỵ, bệnh viện giúp bán sạch 4 tấn táo | #shorts","channel":"baodantri7941"}, +{"id":"uVxqPxToItU","title":"Công an vào cuộc vụ người phụ nữ chửi bới, hành hung tài xế ô tô ở Hà Nội | #shorts","channel":"baodantri7941"}, +{"id":"VAfgNNgZDRs","title":"Khởi tố 4 đối tượng ném bom xăng vào nhà dân ở Đồng Nai | #shorts","channel":"baodantri7941"}, +{"id":"sBH_-zGh0Xw","title":"Vì sao Times New Roman vẫn nổi tiếng sau hàng chục năm? | #shorts","channel":"baodantri7941"}, +{"id":"woKn5f2bLHM","title":"Quảng Ninh ngập sâu diện rộng sau đợt mưa lớn | #shorts","channel":"baodantri7941"}, +{"id":"bcpgRoxbLPw","title":"Giông lốc quật bay mái tôn ở TP.HCM | #shorts","channel":"baodantri7941"}, +{"id":"ZIIC5osy544","title":"Bé trai Trung Quốc rơi từ tầng 11 vẫn sống sót kỳ diệu | #shorts","channel":"baodantri7941"}, +{"id":"uTMJ49NQpyc","title":"Sau lớp mascot 40kg: Câu chuyện mưu sinh của người trẻ ở TPHCM | #shorts","channel":"baodantri7941"}, +{"id":"7Pd6vZ2Lz1M","title":"Hành động ấm lòng của người đàn ông tham gia tìm kiếm 5 học sinh tử vong ở sông Lô | SKĐS","channel":"baosuckhoedoisongboyte"}, +{"id":"SlHLt_ZyPiE","title":"Xử phạt người đàn ông xóa số điện thoại cứu hộ trên cao tốc Bắc - Nam | SKĐS","channel":"baosuckhoedoisongboyte"}, +{"id":"IUOprcJyYr4","title":"Phụ nữ táo bón có phải do lười ăn rau? | SKĐS #shorts","channel":"baosuckhoedoisongboyte"}, +{"id":"YY8ojFNE-AU","title":"Quái xế tự quay clip nẹt pô, đánh võng đăng TikTok bị xử lý | SKĐS","channel":"baosuckhoedoisongboyte"}, +{"id":"OV7_oGdQGII","title":"Bố cô dâu khóc sụt sùi rồi quẩy cực sung gây bão mạng | SKĐS","channel":"baosuckhoedoisongboyte"}, +{"id":"FoxhFyz2skY","title":"Người đàn ông nước ngoài đập phá ô tô, bẻ cần gạt nước ở Đà Nẵng | SKĐS","channel":"baosuckhoedoisongboyte"}, +{"id":"R1oC_I8dFPU","title":"Thanh niên buông tay lái, đứng trên xe máy khi đổ đèo ở Đắk Lắk | SKĐS","channel":"baosuckhoedoisongboyte"}, +{"id":"U0Ft6ChWAIo","title":"Cô giáo kể phút tháo chạy khỏi xe khách trước khi bị lũ vò nát ở Cao Bằng | SKĐS","channel":"baosuckhoedoisongboyte"}, +{"id":"hH0ANeze_4E","title":"Liên tiếp hàng chục con bò bị sét đánh chết trong ngày mưa dông | SKĐS","channel":"baosuckhoedoisongboyte"}, +{"id":"pXWt0QbAzRQ","title":"Va chạm giao thông, người phụ nữ lăng mạ tài xế ô tô | SKĐS","channel":"baosuckhoedoisongboyte"}, +{"id":"UWWLPY1OYt4","title":"CSGT chặn xe khách khống chế đối tượng cướp dây chuyền tại Gia Lai | SKĐS","channel":"baosuckhoedoisongboyte"}, +{"id":"AxhVTQutsuo","title":"Xuất tinh sớm và những hiểu lầm thường gặp | SKĐS #shorts","channel":"baosuckhoedoisongboyte"}, +{"id":"cNy6FgaNxYM","title":"Cô dâu khóc sưng mắt vì 6 chỉ vàng không cánh mà bay trong ngày cưới | SKĐS","channel":"baosuckhoedoisongboyte"}, +{"id":"IDt_S6q59Ro","title":"Chở bạn gái không đội mũ bảo hiểm, thanh niên đấm CSGT | SKĐS","channel":"baosuckhoedoisongboyte"}, +{"id":"LFxJ9Ik6W0A","title":"Mệnh lệnh từ trái tim: CSGT Hà Nội mở đường đưa bé 5 tháng tuổi đi cấp cứu | SKĐS","channel":"baosuckhoedoisongboyte"} +] +for _v in SHORTS_FALLBACK: + _v["link"]="https://www.youtube.com/watch?v="+_v["id"] + _v["img"]="https://i.ytimg.com/vi/"+_v["id"]+"/hqdefault.jpg" + _v["source"]="yt" +SHORT_STATS_FILE = "/data/short_stats.json" if os.path.isdir("/data") else "/app/short_stats.json" +_short_lock = threading.Lock() +def _load_short_db(): + try: + if os.path.exists(SHORT_STATS_FILE): + with open(SHORT_STATS_FILE,"r",encoding="utf-8") as f:return json.load(f) + except:pass + return {} +def _save_short_db(db): + try: + os.makedirs(os.path.dirname(SHORT_STATS_FILE),exist_ok=True) + tmp=SHORT_STATS_FILE+".tmp" + with open(tmp,"w",encoding="utf-8") as f:json.dump(db,f,ensure_ascii=False) + os.replace(tmp,SHORT_STATS_FILE) + except:pass + +def _short_default():return {"views":0,"likes":0,"shares":0,"comments":[]} +WALL_FILE = "/data/wall_posts.json" if os.path.isdir("/data") else "/app/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: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:pass PRIORITY_LEAGUES = ["Ngoại Hạng Anh","FA Cup","Champions League","LaLiga","Copa del Rey","Serie A","Bundesliga","Ligue 1","V-League"] -LEAGUE_IDS = {"nha":36781,"laliga":38843,"seriea":36072,"bundesliga":40040,"ligue1":37298} -HL_LEAGUES = { - "premier-league":{"path":"anh/premier-league","name":"Premier League","emoji":"🏴󠁧󠁢󠁥󠁮󠁧󠁿"}, - "fa-cup":{"path":"anh/fa-cup","name":"FA Cup","emoji":"🏆"}, - "bundesliga":{"path":"duc/bundesliga","name":"Bundesliga","emoji":"🇩🇪"}, - "serie-a":{"path":"italy/serie-a","name":"Serie A","emoji":"🇮🇹"}, - "la-liga":{"path":"tay-ban-nha/la-liga","name":"La Liga","emoji":"🇪🇸"}, - "champions-league":{"path":"cup-chau-au/uefa-champions-league","name":"Champions League","emoji":"⭐"}, - "europa-league":{"path":"cup-chau-au/uefa-europa-league","name":"Europa League","emoji":"🟠"}, - "world-cup":{"path":"the-gioi/world-cup","name":"World Cup 2026","emoji":"🌍"}, -} +LEAGUE_IDS = {"nha":27110,"laliga":27233,"seriea":27044,"bundesliga":26891,"ligue1":27212} +HL_LEAGUES = {"premier-league":{"path":"anh/premier-league","name":"Premier League","emoji":"🏴󠁧󠁢󠁥󠁮󠁧󠁿"},"fa-cup":{"path":"anh/fa-cup","name":"FA Cup","emoji":"🏆"},"bundesliga":{"path":"duc/bundesliga","name":"Bundesliga","emoji":"🇩🇪"},"serie-a":{"path":"italy/serie-a","name":"Serie A","emoji":"🇮🇹"},"la-liga":{"path":"tay-ban-nha/la-liga","name":"La Liga","emoji":"🇪🇸"},"champions-league":{"path":"cup-chau-au/uefa-champions-league","name":"Champions League","emoji":"⭐"},"europa-league":{"path":"cup-chau-au/uefa-europa-league","name":"Europa League","emoji":"🟠"},"world-cup":{"path":"the-gioi/world-cup-qualifiers","name":"World Cup 2026","emoji":"🌍"}} def _cached(key, fn, ttl=None): - now=time.time(); t=ttl or _cache_ttl - if key in _cache and now-_cache[key]["t"]=3: score=f"{spans[0].get_text(strip=True)} - {spans[2].get_text(strip=True)}" - if len(spans)>=4: minute=spans[3].get_text(strip=True) - if not score and status_el and status_el.select_one(".vs"): score="VS" + href=status_el.get("href","");m=re.search(r'/tran-dau/(\d+)/',href) + if m:event_id=m.group(1) + spans=status_el.find_all("span") if status_el else [];score="";minute="" + if len(spans)>=3:score=f"{spans[0].get_text(strip=True)} - {spans[2].get_text(strip=True)}" + if len(spans)>=4:minute=spans[3].get_text(strip=True) + if not score and status_el and status_el.select_one(".vs"):score="VS" league=league_el.get_text(strip=True) if league_el else "" - return {"home":home_el.get_text(strip=True),"away":away_el.get_text(strip=True),"score":score or"VS","minute":minute,"league":league,"time":time_el.get_text(strip=True) if time_el else "","event_id":event_id,"home_logo":home_logo.get("src","") if home_logo else "","away_logo":away_logo.get("src","") if away_logo else "","status":status_type} + return{"home":home_el.get_text(strip=True),"away":away_el.get_text(strip=True),"score":score or"VS","minute":minute,"league":league,"time":time_el.get_text(strip=True) if time_el else "","event_id":event_id,"home_logo":home_logo.get("src","") if home_logo else "","away_logo":away_logo.get("src","") if away_logo else "","status":status_type} # ===== VIDEO PROXY ===== @app.get("/api/proxy/m3u8") def proxy_m3u8(url: str = Query(...)): try: r = requests.get(url, headers=HEADERS, timeout=15) - if r.status_code != 200: return Response(status_code=502, content="upstream error") - lines = r.text.strip().split('\n'); rewritten = [] + if r.status_code != 200:return Response(status_code=502, content="upstream error") + lines = r.text.strip().split('\n');rewritten = [] for line in lines: - if line.startswith('#') or not line.strip(): rewritten.append(line) - else: rewritten.append("/api/proxy/seg?url=" + quote(line.strip(), safe="")) - return Response(content='\n'.join(rewritten).encode('utf-8'), media_type="application/vnd.apple.mpegurl", headers={"Access-Control-Allow-Origin":"*","Cache-Control":"public, max-age=300"}) - except: return Response(status_code=502, content="proxy error") + if line.startswith('#') or not line.strip():rewritten.append(line) + else:rewritten.append("/api/proxy/seg?url=" + quote(line.strip(), safe="")) + return Response(content='\n'.join(rewritten).encode('utf-8'),media_type="application/vnd.apple.mpegurl",headers={"Access-Control-Allow-Origin":"*","Cache-Control":"public, max-age=300"}) + except:return Response(status_code=502, content="proxy error") @app.get("/api/proxy/seg") def proxy_segment(url: str = Query(...)): try: r = requests.get(url, headers=HEADERS, timeout=30) - if r.status_code != 200: return Response(status_code=502, content="upstream error") + if r.status_code != 200:return Response(status_code=502, content="upstream error") data = r.content - if len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47: data = data[188:] - return Response(content=data, media_type="video/mp2t", headers={"Access-Control-Allow-Origin":"*","Cache-Control":"public, max-age=3600"}) - except: return Response(status_code=502, content="proxy error") + if len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47:data = data[188:] + return Response(content=data,media_type="video/mp2t",headers={"Access-Control-Allow-Origin":"*","Cache-Control":"public, max-age=3600"}) + except:return Response(status_code=502, content="proxy error") @app.get("/api/proxy/video") def proxy_video(url: str = Query(...), request: Request = None): try: req_headers = dict(HEADERS) - if request and request.headers.get("range"): req_headers["Range"] = request.headers["range"] + if request and request.headers.get("range"):req_headers["Range"] = request.headers["range"] r = requests.get(url, headers=req_headers, timeout=30, stream=True) resp_headers = {"Access-Control-Allow-Origin":"*","Accept-Ranges":"bytes","Content-Type":r.headers.get("Content-Type","video/mp4")} - if "Content-Range" in r.headers: resp_headers["Content-Range"] = r.headers["Content-Range"] - if "Content-Length" in r.headers: resp_headers["Content-Length"] = r.headers["Content-Length"] - return StreamingResponse(r.iter_content(chunk_size=256*1024), status_code=r.status_code, headers=resp_headers) - except: return Response(status_code=502, content="proxy error") + if "Content-Range" in r.headers:resp_headers["Content-Range"] = r.headers["Content-Range"] + if "Content-Length" in r.headers:resp_headers["Content-Length"] = r.headers["Content-Length"] + return StreamingResponse(r.iter_content(chunk_size=256*1024),status_code=r.status_code,headers=resp_headers) + except:return Response(status_code=502, content="proxy error") @app.get("/api/proxy/img") def proxy_img(url: str = Query(...)): + """Proxy images from sources that block hotlinking (DanTri CDN).""" try: - from urllib.parse import urlparse - _u = urlparse(url); _host = _u.netloc.lower() - _referer = "https://dantri.com.vn/" - if "refooty" in _host or "xemlaibongda" in _host: _referer = "https://xemlaibongda.top/" - elif "ytimg" in _host or "youtube" in _host: _referer = "https://www.youtube.com/" - elif "vncecdn" in _host or "vnexpress" in _host: _referer = "https://vnexpress.net/" - r = requests.get(url, headers={**HEADERS, "Referer": _referer}, timeout=10) - if r.status_code != 200: return Response(status_code=502) - return Response(content=r.content, media_type=r.headers.get("Content-Type", "image/jpeg"), headers={"Cache-Control": "public, max-age=86400", "Access-Control-Allow-Origin": "*"}) - except: return Response(status_code=502) + r = requests.get(url, headers={**HEADERS, "Referer": "https://dantri.com.vn/"}, timeout=10) + if r.status_code != 200:return Response(status_code=502) + ct = r.headers.get("Content-Type", "image/jpeg") + return Response(content=r.content, media_type=ct, headers={"Cache-Control": "public, max-age=86400", "Access-Control-Allow-Origin": "*"}) + except:return Response(status_code=502) # ===== XEMLAIBONGDA HIGHLIGHTS ===== def _scrape_xemlaibongda_page(page_path, limit=20): try: url = f"https://xemlaibongda.top/{page_path}" if page_path else "https://xemlaibongda.top/" - r = requests.get(url, headers=HEADERS, timeout=8) - if r.status_code != 200: return [] - r.encoding = "utf-8" - soup = BeautifulSoup(r.text, "lxml") - videos = []; seen = set() - for a in soup.find_all("a", href=True): - href = a.get("href", "") - if "/video/" not in href and "/xem-lai/" not in href: continue - if not href.startswith("http"): href = "https://xemlaibongda.top" + href - clean_href = href.split("?")[0].split("#")[0] - if clean_href in seen: continue - seen.add(clean_href) - img_src = "" - img = a.find("img") - if not img and a.parent: img = a.parent.find("img") - if not img: - p = a.parent - for _ in range(4): - if p and p.find("img"): img = p.find("img"); break - p = p.parent if p else None - if img: - img_src = (img.get("data-src", "") or img.get("src", "") or img.get("data-lazy", "") or img.get("data-original", "") or img.get("data-thumb", "") or img.get("data-image", "")) - if img_src.startswith("//"): img_src = "https:" + img_src - elif img_src.startswith("/"): img_src = "https://xemlaibongda.top" + img_src - if not img_src: - p = a.parent - for _ in range(5): - if p is None: break - style = p.get("style", "") - bg_match = re.search(r'url\(["\']?(.*?)["\']?\)', style) - if bg_match: - img_src = bg_match.group(1) - if img_src.startswith("//"): img_src = "https:" + img_src - elif img_src.startswith("/"): img_src = "https://xemlaibongda.top" + img_src - break - p = p.parent if p else None - title = "" - for attr in ["title", "aria-label"]: - val = a.get(attr, "") - if val and len(val) >= 5: title = val; break - if not title: - for selector in ["h3", "h2", "h4", ".title", ".video-title", "strong"]: - try: - el = a.select_one(selector) - if el: t = el.get_text(strip=True) - if t and len(t) >= 5: title = t; break - except: pass - if not title: - text = a.get_text(strip=True) - if text and len(text) >= 5: title = text[:100] - if not title or len(title) < 3: - slug = clean_href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/") - title = slug.replace("-", " ").replace("_", " ").title() - title = re.sub(r'\d{4}-\d{2}-\d{2}', '', title).strip() - if not title or len(title) < 3: continue - if not img_src: - slug = clean_href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/") - img_src = f"https://xemlaibongda.top/uploads/thumb/{slug}.jpg" - videos.append({"title": title[:100], "link": clean_href, "img": img_src, "source": "xemlaibongda", "published": _video_published(img_src, clean_href)}) - if len(videos) >= limit: break + r=requests.get(url,headers=HEADERS,timeout=15) + if r.status_code!=200:return[] + r.encoding="utf-8";soup=BeautifulSoup(r.text,"lxml");videos=[];seen=set() + for a in soup.find_all("a",href=True): + href=a.get("href","") + if"/video/" not in href:continue + if not href.startswith("http"):href="https://xemlaibongda.top"+href + if href in seen:continue + seen.add(href);slug=href.split("/video/")[-1].rstrip("/") + title=slug.replace("-"," ").title() + title=re.sub(r'\d{4}\s*\d{2}\s*\d{2}$','',title).strip() + title=re.sub(r'\s+V\s+',' vs ',title);title=re.sub(r'\s+Vs\s+',' vs ',title) + img=a.find("img") or (a.parent.find("img") if a.parent else None) + img_src="" + if img:img_src=img.get("data-src","") or img.get("src","") or img.get("data-lazy","") + if not img_src:img_src=f"https://img.refooty.com/thumbnail/{slug}.webp" + videos.append({"title":title,"link":href,"img":img_src,"source":"xemlaibongda"}) + if len(videos)>=limit:break return videos - except Exception as e: - print(f"[xemlaibongda] Error: {e}"); return [] + except:return[] -def scrape_xemlaibongda(): return _scrape_xemlaibongda_page("", 20) +def scrape_xemlaibongda():return _scrape_xemlaibongda_page("",20) def scrape_highlights_by_league(league_key): - if league_key not in HL_LEAGUES: return [] - # Try bongdaplus.vn first (reliable from HF Spaces), fallback from there - bp_vids = scrape_bongdaplus_by_league(league_key, 20) - if bp_vids: return bp_vids - # Fallback: xemlaibongda.top (may be slow/unreachable) - vids = _scrape_xemlaibongda_page(HL_LEAGUES[league_key]["path"], 20) - if vids: return vids - # Ultimate fallback: bongdaplus.vn general video feed, only for primary home league - if league_key == "premier-league": - return scrape_bongdaplus_videos(20) - return [] + if league_key not in HL_LEAGUES:return[] + return _scrape_xemlaibongda_page(HL_LEAGUES[league_key]["path"],20) + def scrape_all_league_highlights(): results = {} - def _fetch(key): return key, scrape_highlights_by_league(key) + def _fetch(key):return key, scrape_highlights_by_league(key) with ThreadPoolExecutor(8) as ex: futs = [ex.submit(_fetch, k) for k in HL_LEAGUES] - for f in as_completed(futs, timeout=20): - try: key, vids = f.result() - except: continue - if vids: results[key] = vids - if not results: - fb = scrape_bongdaplus_videos(20) - if fb: - results["premier-league"] = fb - # The league sub-pages resolve to the SAME content from this server (the - # football sites serve one generic list regardless of league path). Return - # the unique videos once per league (deduped against the global set) and - # enrich with the general bongdaplus feed so the homepage slider gets a - # genuinely bigger, duplicate-free list instead of 8x the same 10 clips. - global_seen = set() - seen_per_league = {} - enriched = {} - try: - extra = scrape_bongdaplus_videos(40) - except Exception: - extra = [] - for key, vids in list(results.items()): - uniq = [] - fresh = [] - for v in vids: - link = (v or {}).get("link", "") - if not link or link in seen_per_league.get(key, set()): continue - seen_per_league.setdefault(key, set()).add(link) - if link in global_seen: - fresh.append(v) # duplicate across leagues — will dedupe - continue - global_seen.add(link) - uniq.append(v) - # league-local unique list - if uniq: - results[key] = uniq - # still add across-league dupes to each league so per-league feeds - # are non-empty, but ONLY if the league has < 10 unique videos - if len(uniq) < 10: - for v in fresh: - link = (v or {}).get("link", "") - if link in seen_per_league.get(key, set()): continue - seen_per_league[key].add(link) - uniq.append(v) - results[key] = uniq - # enrich league with global feed leftovers when it's still thin - if len(uniq) < 10: - for v in extra: - link = (v or {}).get("link", "") - if not link or link in seen_per_league.get(key, set()): continue - seen_per_league[key].add(link) - uniq.append(v) - results[key] = uniq - return results - -BDP_HL = "https://bongdaplus.vn/video" -def _video_published(img_src, link=""): - """Best-effort publish date from image URL (bongdaplus stores dates as - /Media/YYYY/MM/DD/) or from a dated slug. Returns 'YYYY-MM-DD' or ''.""" - try: - if img_src: - m = re.search(r"/Media/(\d{4})/(\d{2})/(\d{2})/", img_src) - if m: - return "%s-%s-%s" % (m.group(1), m.group(2), m.group(3)) - if link: - m2 = re.search(r"/(\d{4})/(\d{2})/(\d{2})/", link) - if m2: - return "%s-%s-%s" % (m2.group(1), m2.group(2), m2.group(3)) - except Exception: - pass - return "" - -def scrape_bongdaplus_videos(limit=20): - """Scrape football highlight/clip videos from bongdaplus.vn/video (reachable from HF Spaces).""" - try: - r = requests.get(BDP_HL, headers=HEADERS, timeout=15) - if r.status_code != 200: return [] - r.encoding = "utf-8" - soup = BeautifulSoup(r.text, "lxml") - videos = []; seen = set() - for a in soup.find_all("a", href=True): - href = a.get("href","") - if "/video/" not in href or not href.endswith(".html"): continue - if not href.startswith("http"): href = "https://bongdaplus.vn" + href - link = href.split("?")[0].split("#")[0] - if link in seen: continue - seen.add(link) - img_src = "" - img = a.find("img") - if img: - img_src = img.get("data-src","") or img.get("src","") or "" - title = "" - t = a.get("title","") or "" - if t and len(t)>=5: title = t - if not title: - img_alt = img.get("alt","") if img else "" - if img_alt and len(img_alt)>=5: title = img_alt - if not title: - txt = a.get_text(strip=True) - if txt and len(txt)>=5: title = txt - title = re.sub(r'^(VIDEO\s*:\s*|VIDEO\s+)', '', title, flags=re.I).strip() - if not title or len(title)<5: continue - if img_src and img_src.startswith("//"): img_src = "https:" + img_src - videos.append({"title": title[:100], "link": link, "img": img_src, "source": "bongdaplus", "published": _video_published(img_src, link)}) - if len(videos) >= limit: break - return videos - except Exception as e: - print(f"[bongdaplus] Error: {e}"); return [] - -# Bongdaplus.vn league-specific video URLs (used as fallback when xemlaibongda.top fails) -BDP_LEAGUE_URLS = { - "premier-league": "https://bongdaplus.vn/video/premier-league", - "la-liga": "https://bongdaplus.vn/video/la-liga", - "serie-a": "https://bongdaplus.vn/video/serie-a", - "bundesliga": "https://bongdaplus.vn/video/bundesliga", - "champions-league": "https://bongdaplus.vn/video/champions-league", - "fa-cup": "https://bongdaplus.vn/video/fa-cup", - "europa-league": "https://bongdaplus.vn/video/europa-league", - "world-cup": "https://bongdaplus.vn/video/world-cup", -} -def scrape_bongdaplus_by_league(league_key, limit=20): - """Scrape bongdaplus.vn video page for a specific league.""" - url = BDP_LEAGUE_URLS.get(league_key) - if not url: return [] - try: - r = requests.get(url, headers=HEADERS, timeout=15) - if r.status_code != 200: return [] - r.encoding = "utf-8" - soup = BeautifulSoup(r.text, "lxml") - videos = []; seen = set() - for a in soup.find_all("a", href=True): - href = a.get("href","") - if "/video/" not in href or not href.endswith(".html"): continue - if not href.startswith("http"): href = "https://bongdaplus.vn" + href - link = href.split("?")[0].split("#")[0] - if link in seen: continue - seen.add(link) - img_src = "" - img = a.find("img") - if img: - img_src = img.get("data-src","") or img.get("src","") or "" - title = "" - t = a.get("title","") or "" - if t and len(t)>=5: title = t - if not title: - img_alt = img.get("alt","") if img else "" - if img_alt and len(img_alt)>=5: title = img_alt - if not title: - txt = a.get_text(strip=True) - if txt and len(txt)>=5: title = txt - title = re.sub(r'^(VIDEO\s*:\s*|VIDEO\s+)', '', title, flags=re.I).strip() - if not title or len(title)<5: continue - if img_src and img_src.startswith("//"): img_src = "https:" + img_src - videos.append({"title": title[:100], "link": link, "img": img_src, "source": "bongdaplus", "published": _video_published(img_src, link)}) - if len(videos) >= limit: break - return videos - except Exception as e: - print(f"[bongdaplus:{league_key}] Error: {e}"); return [] - -def extract_bongdaplus_video(url): - """Extract direct MP4 from bongdaplus.vn video detail via its embed page. - - The embed page (/video-embed/{id}.html) sometimes only has a JPG placeholder - as . In that case, fall back to the main video page - (/video/{slug}.html) which contains a YouTube iframe with the actual - highlight. - """ - try: - m = re.search(r'/video/(?:[^/]+-)?(\d+)\.html', url) - if not m: - return None - vid = m.group(1) - embed = f"https://bongdaplus.vn/video-embed/{vid}.html" - r = requests.get(embed, headers=HEADERS, timeout=15) - if r.status_code != 200: return None - r.encoding = "utf-8" - soup = BeautifulSoup(r.text, "lxml") - video = soup.find("video") - src = "" - poster = "" - if video: - src = video.get("src","") - poster = video.get("poster","") - if not src: - src_el = soup.find("source") - if src_el: src = src_el.get("src","") - if not src: return None - src = src.split("?")[0].split("#")[0] - # Only accept real media; placeholder .jpg/jpeg/png means video not available - if not re.search(r'\.(mp4|m3u8|webm)$', src, re.I): - # The embed source is a JPG placeholder — fall back to the main video - # page and look for a YouTube iframe (actual highlight video). + for f in as_completed(futs): try: - r2 = requests.get(url, headers=HEADERS, timeout=15) - if r2.status_code == 200: - soup2 = BeautifulSoup(r2.text, "lxml") - yt_iframe = soup2.find("iframe", src=re.compile(r"youtube\.com/embed|youtube-nocookie\.com/embed")) - if yt_iframe: - yt_src = yt_iframe.get("src","").strip() - if yt_src.startswith("//"): yt_src = "https:" + yt_src - yt_id = re.search(r'/(?:embed|v)/([a-zA-Z0-9_-]{11})', yt_src) - if yt_id: - poster = poster or "" - if not poster: - og = soup2.find("meta", property="og:image") - if og: poster = og.get("content","") - if not poster: - og = soup.find("meta", property="og:image") - if og: poster = og.get("content","") - return {"src": f"https://www.youtube.com/embed/{yt_id.group(1)}?autoplay=1&rel=0&enablejsapi=1", "poster": poster or yt_src, "type": "youtube"} - except Exception: - pass - return None - if not poster: - og = soup.find("meta",property="og:image") - if og: poster = og.get("content","") - return {"src": src, "poster": poster, "type": "video"} - except Exception: - return None + key, vids = f.result() + if vids:results[key] = vids + except:pass + return results def extract_xemlaibongda_video(url): try: - r=requests.get(url, headers=HEADERS, timeout=15) - if r.status_code!=200: return None - r.encoding="utf-8"; soup=BeautifulSoup(r.text,"lxml") - og=soup.find("meta",property="og:image") - og_poster=og.get("content","") if og else "" - if og_poster.startswith("//"): og_poster="https:"+og_poster - video=soup.find("video") + r=requests.get(url,headers=HEADERS,timeout=15) + if r.status_code!=200:return None + r.encoding="utf-8";soup=BeautifulSoup(r.text,"lxml");video=soup.find("video") if video: - src=video.get("src",""); poster=video.get("poster","") + src=video.get("src","");poster=video.get("poster","") if not src: source=video.find("source") - if source: src=source.get("src","") - if not poster: poster=og_poster - if src: return{"src":src,"poster":poster,"type":"hls" if".m3u8" in src else"video"} + if source:src=source.get("src","") + if src:return{"src":src,"poster":poster,"type":"hls" if".m3u8" in src else"video"} m3u8s=re.findall(r'(https?://[^\s"\'<>]+\.m3u8)',r.text) - if m3u8s: return{"src":m3u8s[0],"poster":og_poster,"type":"hls"} - yt_iframe = soup.find("iframe", src=re.compile(r"youtube\.com/embed|youtube-nocookie\.com/embed")) - if yt_iframe: return{"src":yt_iframe.get("src",""),"poster":og_poster,"type":"youtube"} + if m3u8s: + og=soup.find("meta",property="og:image");poster=og.get("content","") if og else "" + return{"src":m3u8s[0],"poster":poster,"type":"hls"} return None - except: return None + except:return None + +# ===== YOUTUBE SHORTS ===== +def _yt_channel_shorts(channel, count=15): + """Fast scrape YouTube shorts tab without yt-dlp. Returns newest-first IDs/titles.""" + try: + url=f"https://www.youtube.com/@{channel}/shorts" + r=requests.get(url,headers={**HEADERS,"Accept-Language":"vi,en;q=0.8"},timeout=15) + if r.status_code!=200:return[] + html=r.text + ids=[];items=[] + for m in re.finditer(r'"videoId":"([A-Za-z0-9_-]{11})"',html): + vid=m.group(1) + if vid in ids:continue + ids.append(vid) + snip=html[max(0,m.start()-900):m.start()+1600] + title="" + mt=re.search(r'"title":\{"runs":\[\{"text":"([^"]+)"',snip) + if not mt:mt=re.search(r'"accessibilityText":"([^"]+)"',snip) + if mt:title=html_lib.unescape(mt.group(1)).replace('\n',' ').strip() + if not title:title="YouTube Short" + items.append({"title":title,"link":f"https://www.youtube.com/watch?v={vid}","img":f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg","source":"yt","id":vid,"channel":channel}) + if len(items)>=count:break + return items + except:return[] +def scrape_shorts(): + """Stable shorts feed: fast HTML scrape + static fallback so slide never disappears.""" + vids=[] + with ThreadPoolExecutor(2) as ex: + futs=[ex.submit(_yt_channel_shorts,ch,24) for ch in ["baodantri7941","baosuckhoedoisongboyte"]] + for f in as_completed(futs): + try: + r=f.result() + if r:vids.extend(r) + except:pass + merged=[];seen=set() + for v in vids+SHORTS_FALLBACK: + vid=v.get("id") + if not vid or vid in seen:continue + seen.add(vid);merged.append(v) + return merged[:40] # ===== LIVESCORE ===== @app.get("/api/livescore/live") -def api_livescore_live(): return JSONResponse({"html":_cached("ls_live",lambda:fetch_bongda_api("/api/fixtures/live"),ttl=_cache_ttl_live)}) +def api_livescore_live():return JSONResponse({"html":_cached("ls_live",lambda:fetch_bongda_api("/api/fixtures/live"),ttl=_cache_ttl_live)}) @app.get("/api/livescore/incoming") -def api_livescore_incoming(): return JSONResponse({"html":_cached("ls_incoming",lambda:fetch_bongda_api("/api/fixtures/incoming"),ttl=_cache_ttl_live)}) +def api_livescore_incoming():return JSONResponse({"html":_cached("ls_incoming",lambda:fetch_bongda_api("/api/fixtures/incoming"),ttl=_cache_ttl_live)}) @app.get("/api/livescore/today") def api_livescore_today(): - today=datetime.now(VN_TZ).strftime("%Y-%m-%d");return JSONResponse({"html":_cached("ls_today",lambda:fetch_bongda_api(f"/api/fixtures/get-by-date?date={today}"),ttl=_cache_ttl)}) + today=datetime.now().strftime("%Y-%m-%d");return JSONResponse({"html":_cached("ls_today",lambda:fetch_bongda_api(f"/api/fixtures/get-by-date?date={today}"),ttl=_cache_ttl)}) @app.get("/api/livescore/results") def api_livescore_results(): - today=datetime.now(VN_TZ).strftime("%Y-%m-%d");return JSONResponse({"html":_cached("ls_results",lambda:fetch_bongda_api(f"/api/fixtures/get-by-date?date={today}&status=finished"),ttl=_cache_ttl)}) + today=datetime.now().strftime("%Y-%m-%d");return JSONResponse({"html":_cached("ls_results",lambda:fetch_bongda_api(f"/api/fixtures/get-by-date?date={today}&status=finished"),ttl=_cache_ttl)}) @app.get("/api/livescore/standings/{league}") def api_livescore_standings(league:str): - tid=LEAGUE_IDS.get(league,36781);return JSONResponse({"html":_cached(f"ls_bxh_{league}",lambda:fetch_bongda_api(f"/api/league-table/home?tournament_id={tid}&is_detail=True"),ttl=_cache_ttl)}) + tid=LEAGUE_IDS.get(league,27110);return JSONResponse({"html":_cached(f"ls_bxh_{league}",lambda:fetch_bongda_api(f"/api/league-table/home?tournament_id={tid}&is_detail=True"),ttl=_cache_ttl)}) @app.get("/api/livescore/date/{date}") def api_livescore_date(date:str):return JSONResponse({"html":fetch_bongda_api(f"/api/fixtures/get-by-date?date={date}")}) - -def _compute_updates7d(): - from datetime import date as _date - today = _date.today() - all_html = [] - # Past 7 days (results) - for i in range(7, 0, -1): - d = (today - timedelta(days=i)).strftime("%Y-%m-%d") - html = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}&status=finished") - if html and len(html) > 50: - soup = BeautifulSoup(html, "lxml") - day_label = (today - timedelta(days=i)).strftime("%d/%m") - for match in soup.select(".match-detail"): - dt = soup.new_tag("div", **{"class": "datetime"}) - dt.string = f"📅 {day_label}" - match.insert(0, dt) - all_html.append(str(soup)) - # Next 7 days (upcoming) - for i in range(7): - d = (today + timedelta(days=i)).strftime("%Y-%m-%d") - html = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}") - if html and len(html) > 50: - soup = BeautifulSoup(html, "lxml") - day_label = (today + timedelta(days=i)).strftime("%d/%m") - for match in soup.select(".match-detail"): - dt = soup.new_tag("div", **{"class": "datetime"}) - dt.string = f"📅 {day_label}" - match.insert(0, dt) - all_html.append(str(soup)) - combined = "
" + "".join(all_html) + "
" - return combined if all_html else "" - -_updates7d_cache = {"t": 0, "d": "", "busy": False} -_updates7d_lock = threading.Lock() -def _start_updates7d_refresh(): - def _run(): - try: - with _updates7d_lock: - if _updates7d_cache["busy"]: return - _updates7d_cache["busy"] = True - data = _compute_updates7d() - with _updates7d_lock: - if data: - _updates7d_cache["t"] = time.time() - _updates7d_cache["d"] = data - _updates7d_cache["busy"] = False - except Exception: - with _updates7d_lock: - _updates7d_cache["busy"] = False - th = threading.Thread(target=_run, daemon=True) - th.start() - -@app.get("/api/livescore/updates7d") -def api_livescore_updates7d(): - """Aggregate matches. Never blocks synchronously — returns cached value instantly - and refreshes in a background thread. Pre-warmed at startup.""" - now = time.time() - with _updates7d_lock: - fresh = _updates7d_cache["d"] and (now - _updates7d_cache["t"] < _cache_ttl) - stale = _updates7d_cache["d"] - if not fresh: - _start_updates7d_refresh() - return JSONResponse({"html": stale or "", "cached": bool(stale)}) - -# ===== LIVESCORE RECENT (prioritized: upcoming → just-finished today → yesterday → older) ===== -def _compute_recent(): - """Compute recent livescore with priority: upcoming → just-finished today → yesterday → older.""" - from datetime import date as _date - today = _date.today() - all_html = [] - - # Helper to extract match details with date label - def _extract_matches(date_str, day_label, status_param=""): - url_path = f"/api/fixtures/get-by-date?date={date_str}" - if status_param: - url_path += f"&status={status_param}" - html = fetch_bongda_api(url_path) - if not html or len(html) <= 50: - return - soup = BeautifulSoup(html, "lxml") - for match in soup.select(".match-detail"): - dt = soup.new_tag("div", **{"class": "datetime section-date"}) - dt.string = f"📅 {day_label}" - match.insert(0, dt) - all_html.append(str(soup)) - - # 1) Trận sắp tới (hôm nay + ngày mai) - upcoming_html = [] - for offset in [0, 1]: - d = (today + timedelta(days=offset)).strftime("%Y-%m-%d") - day_label = (today + timedelta(days=offset)).strftime("%d/%m") - html = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}") - if html and len(html) > 50: - soup = BeautifulSoup(html, "lxml") - for match in soup.select(".match-detail"): - dt = soup.new_tag("div", **{"class": "datetime section-date"}) - dt.string = f"📅 {day_label}" - match.insert(0, dt) - upcoming_html.append(str(soup)) - if upcoming_html: - all_html.append("
⏰ Trận sắp tới
" + "".join(upcoming_html) + "
") - - # 2) Vừa kết thúc hôm nay - today_str = today.strftime("%Y-%m-%d") - finished_today = fetch_bongda_api(f"/api/fixtures/get-by-date?date={today_str}&status=finished") - if finished_today and len(finished_today) > 50: - soup = BeautifulSoup(finished_today, "lxml") - for match in soup.select(".match-detail"): - dt = soup.new_tag("div", **{"class": "datetime section-date"}) - dt.string = f"📅 {today.strftime('%d/%m')}" - match.insert(0, dt) - all_html.append("
✅ Vừa kết thúc
" + str(soup) + "
") - - # 3) Hôm qua - yesterday = today - timedelta(days=1) - d = yesterday.strftime("%Y-%m-%d") - html_yesterday = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}&status=finished") - if html_yesterday and len(html_yesterday) > 50: - soup = BeautifulSoup(html_yesterday, "lxml") - for match in soup.select(".match-detail"): - dt = soup.new_tag("div", **{"class": "datetime section-date"}) - dt.string = f"📅 {yesterday.strftime('%d/%m')}" - match.insert(0, dt) - all_html.append("
📅 Hôm qua
" + str(soup) + "
") - - # 4) Các ngày cũ hơn (2 ngày trước → 6 ngày trước) - older_html = [] - for i in range(2, 7): - d = (today - timedelta(days=i)).strftime("%Y-%m-%d") - day_label = (today - timedelta(days=i)).strftime("%d/%m") - html = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}&status=finished") - if html and len(html) > 50: - soup = BeautifulSoup(html, "lxml") - for match in soup.select(".match-detail"): - dt = soup.new_tag("div", **{"class": "datetime section-date"}) - dt.string = f"📅 {day_label}" - match.insert(0, dt) - older_html.append(str(soup)) - if older_html: - all_html.append("
🗓️ Kết quả trước
" + "".join(older_html) + "
") - - combined = "
" + "".join(all_html) + "
" - return combined if all_html else "" - -_recent_cache = {"t": 0, "d": "", "busy": False} -_recent_lock = threading.Lock() -def _start_recent_refresh(): - def _run(): - try: - with _recent_lock: - if _recent_cache["busy"]: return - _recent_cache["busy"] = True - data = _compute_recent() - with _recent_lock: - if data: - _recent_cache["t"] = time.time() - _recent_cache["d"] = data - _recent_cache["busy"] = False - except Exception: - with _recent_lock: - _recent_cache["busy"] = False - th = threading.Thread(target=_run, daemon=True) - th.start() - -@app.get("/api/livescore/recent") -def api_livescore_recent(): - """Recent matches with new priority: upcoming today/tomorrow → finished today → yesterday → older.""" - now = time.time() - with _recent_lock: - fresh = _recent_cache["d"] and (now - _recent_cache["t"] < _cache_ttl) - stale = _recent_cache["d"] - if not fresh: - _start_recent_refresh() - return JSONResponse({"html": stale or "", "cached": bool(stale)}) - -def _warm_recent(): - def _warm(): - try: - time.sleep(3) - _start_recent_refresh() - except Exception: - pass - th = threading.Thread(target=_warm, daemon=True) - th.start() -_warm_recent() - -def _warm_updates7d(): - # Lightweight startup pre-warm so the first user request is instant. - def _warm(): - try: - time.sleep(3) - _start_updates7d_refresh() - except Exception: - pass - th = threading.Thread(target=_warm, daemon=True) - th.start() -_warm_updates7d() - @app.get("/api/match/{event_id}/commentaries") def api_match_commentaries(event_id:int):return JSONResponse({"html":fetch_bongda_api(f"/api/fixtures/commentaries?event_id={event_id}")}) @app.get("/api/match/{event_id}/stats") def api_match_stats(event_id:int):return JSONResponse({"html":fetch_bongda_api(f"/api/event-standing/player-performance?event_id={event_id}")}) - -from match_detail_v2 import fetch_match_detail, fetch_match_detail_by_url - -@app.get("/api/match/{event_id}/detail") -def api_match_detail(event_id: int, url: str = Query(default="")): - try: - if url: data = fetch_match_detail_by_url(url) - else: data = fetch_match_detail(event_id) - return JSONResponse(data) - except Exception as e: return JSONResponse({"event_id": event_id, "found": False, "error": str(e)}) - @app.get("/api/livescore/featured") def api_livescore_featured(): def _f(): - sources=[("/api/fixtures/live","live"),("/api/fixtures/get-by-date?date="+datetime.now(VN_TZ).strftime("%Y-%m-%d"),"today"),("/api/fixtures/incoming","upcoming")] + sources=[("/api/fixtures/live","live"),("/api/fixtures/get-by-date?date="+datetime.now().strftime("%Y-%m-%d"),"today"),("/api/fixtures/incoming","upcoming")] for endpoint, stype in sources: html=fetch_bongda_api(endpoint) if not html or len(html)<100:continue @@ -734,382 +310,616 @@ def api_livescore_featured(): return None return JSONResponse(_cached("ls_featured",_f,ttl=30)) +# ===== VIDEO APIs ===== +@app.get("/api/shorts") +def api_shorts():return JSONResponse(_cached("yt_shorts_v3",scrape_shorts,ttl=_cache_ttl_yt)) +@app.get("/api/short-stats") +def api_short_stats(ids:str=Query(default="")): + arr=[x for x in ids.split(",") if x] + with _short_lock: + db=_load_short_db();out={} + for vid in arr: + st=db.get(vid) or _short_default() + out[vid]={"views":int(st.get("views",0)),"likes":int(st.get("likes",0)),"shares":int(st.get("shares",0)),"comments":st.get("comments",[])[:80]} + return JSONResponse({"stats":out}) + +@app.post("/api/short-action") +async def api_short_action(request:Request): + try:body=await request.json() + except:body={} + vid=str(body.get("id","")).strip();action=str(body.get("action","")).strip();txt=str(body.get("text","")).strip() + if not vid:return JSONResponse({"error":"missing id"},status_code=400) + with _short_lock: + db=_load_short_db();st=db.get(vid) or _short_default() + if action=="view":st["views"]=int(st.get("views",0))+1 + elif action=="like":st["likes"]=int(st.get("likes",0))+1 + elif action=="share":st["shares"]=int(st.get("shares",0))+1 + elif action=="comment" and txt: + comments=st.get("comments",[]) + comments.insert(0,{"text":txt[:180],"ts":int(time.time())}) + st["comments"]=comments[:80] + st["updated"]=int(time.time());db[vid]=st;_save_short_db(db) + out={"views":int(st.get("views",0)),"likes":int(st.get("likes",0)),"shares":int(st.get("shares",0)),"comments":st.get("comments",[])[:80]} + return JSONResponse({"stats":out}) + @app.get("/api/highlights") -def api_highlights(): return JSONResponse(_cached("xemlaibongda_hl",lambda: scrape_bongdaplus_videos(20) or scrape_xemlaibongda(),ttl=_cache_ttl)) +def api_highlights():return JSONResponse(_cached("xemlaibongda_hl",scrape_xemlaibongda,ttl=_cache_ttl)) @app.get("/api/highlights/leagues") -def api_highlights_leagues(): return JSONResponse(_cached("hl_leagues",scrape_all_league_highlights,ttl=_cache_ttl)) +def api_highlights_leagues():return JSONResponse(_cached("hl_leagues",scrape_all_league_highlights,ttl=_cache_ttl)) @app.get("/api/highlights/{league}") def api_highlights_league(league:str): - if league not in HL_LEAGUES: return JSONResponse({"error":"league not found"}) + if league not in HL_LEAGUES:return JSONResponse({"error":"league not found"}) return JSONResponse(_cached(f"hl_{league}",lambda:scrape_highlights_by_league(league),ttl=_cache_ttl)) - -@app.get("/api/highlights/{league}/page") -def api_highlights_league_page(league:str, page:int=Query(default=0, ge=0), limit:int=Query(default=15, ge=5, le=40)): - """Paginated league highlights for 'Xem thêm' load-more. Supports 'all' to fetch every league aggregated.""" - if league == "all": - all_vids = [] - with ThreadPoolExecutor(8) as ex: - futs = {ex.submit(scrape_highlights_by_league, k): k for k in HL_LEAGUES} - for f in as_completed(futs, timeout=20): - try: - all_vids.extend(f.result()) - except: pass - # The league sub-pages resolve to the same content from this server, - # so dedupe by link — the slider/feed must not show 8x the same 10 - # videos. If dedup leaves few unique items, enrich from the general - # bongdaplus video feed to add genuinely different videos. - seen = set(); uniq = [] - for v in all_vids: - link = (v or {}).get("link", "") - if not link or link in seen: continue - seen.add(link); uniq.append(v) - if len(uniq) < 20: - try: - extra = scrape_bongdaplus_videos(40) - for v in extra: - link = (v or {}).get("link", "") - if not link or link in seen: continue - seen.add(link); uniq.append(v) - except Exception: - pass - all_vids = uniq - start = page * limit - end = start + limit - paged = all_vids[start:end] - return JSONResponse({"videos": paged, "league": "all", "page": page, "has_more": end < len(all_vids), "total": len(all_vids)}) - if league not in HL_LEAGUES: return JSONResponse({"error":"league not found"}) - all_vids = scrape_highlights_by_league(league) - start = page * limit - end = start + limit - paged = all_vids[start:end] - return JSONResponse({"videos": paged, "league": league, "page": page, "has_more": end < len(all_vids), "total": len(all_vids)}) - +@app.get("/api/highlights_config") +def api_highlights_config():return JSONResponse(HL_LEAGUES) @app.get("/api/video_url") -def api_video_url(url:str=Query(...), img:str=Query(default="")): +def api_video_url(url:str=Query(...)): if "youtube.com" in url or "youtu.be" in url: m=re.search(r'(?:v=|shorts/|youtu\.be/)([a-zA-Z0-9_-]{11})',url) - if m: vid=m.group(1); return JSONResponse({"src":f"https://www.youtube.com/embed/{vid}?autoplay=1&rel=0&enablejsapi=1","poster":f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg","type":"youtube"}) + if m:vid=m.group(1);return JSONResponse({"src":f"https://www.youtube.com/embed/{vid}?autoplay=1&rel=0&enablejsapi=1","poster":f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg","type":"youtube"}) if "xemlaibongda.top" in url: v=extract_xemlaibongda_video(url) if v: - if v["type"]=="hls": v["src"]="/api/proxy/m3u8?url="+quote(v["src"],safe="") - if not v.get("poster") and img: v["poster"] = img - return JSONResponse(v) - if "bongdaplus.vn" in url and "/video/" in url: - v=extract_bongdaplus_video(url) - if v: - if not v.get("poster") and img: v["poster"] = img + if v["type"]=="hls":v["src"]="/api/proxy/m3u8?url="+quote(v["src"],safe="") return JSONResponse(v) + if "bongdaplus.vn" in url: + try: + m=re.search(r'-(\d{6,})\.html',url) + if m: + r=requests.get(f"{BASE_BDP}/video-embed/{m.group(1)}.html",headers=HEADERS,timeout=10);r.encoding="utf-8" + soup=BeautifulSoup(r.text,"lxml");video=soup.select_one("video#videoPlayer") + if video: + source=video.find("source");src=source.get("src","") if source else "";poster=video.get("poster","") + if src:return JSONResponse({"src":"/api/proxy/video?url="+quote(src,safe=""),"poster":poster,"type":"video"}) + except:pass return JSONResponse({"error":"not found"}) - -# ===== FPT PLAY (YouTube channel) API ===== -# The datacenter cannot reach youtube.com directly, so this endpoint keeps a -# persisted list of the channel's latest videos. The frontend (running in the -# user's browser, which CAN reach YouTube) periodically fetches the channel via -# r.jina.ai, then POSTs the result here so the slide auto-updates when the -# channel uploads — exactly like "Thêm Short HOT" but fully automatic. -FPT_FILE = "/data/fpt_play_videos.json" -FPT_CHANNEL_URL = "https://www.youtube.com/@fptbongdaofficial" -FPT_CACHE_TTL = 600 # 10 min - -def _fpt_load(): - try: - if os.path.exists(FPT_FILE): - with open(FPT_FILE, encoding="utf-8") as f: - j = json.load(f) - if isinstance(j, list): - return j - except Exception: - pass - return [] - -def _fpt_save(videos): - try: - with open(FPT_FILE, "w", encoding="utf-8") as f: - json.dump(videos, f, ensure_ascii=False) - except Exception: - pass - -def _fpt_try_server_fetch(): - """Server-side attempt (jina proxy with our Origin). Usually blocked from - the datacenter, but harmless to try once so the slide works even without - a client push.""" - try: - _hdrs = dict(HEADERS) - _hdrs["Origin"] = "https://bep40-vnews.hf.space" - r = requests.get("https://r.jina.ai/" + FPT_CHANNEL_URL + "/videos", - headers=_hdrs, timeout=40) - if r.status_code != 200: - return [] - txt = r.text - out = [] - seen = set() - # jina markdown lists entries as "1. Title - url" or link lines with yt ids - for m in re.finditer(r"https?://(?:www\.)?youtube\.com/watch\?v=([a-zA-Z0-9_-]{11})", txt): - vid = m.group(1) - if vid in seen: continue - seen.add(vid) - out.append({ - "id": vid, - "title": "", - "link": "https://youtu.be/" + vid, - "img": "https://i.ytimg.com/vi/%s/hqdefault.jpg" % vid, - "source": "fptplay", - "published": "", - }) - return out - except Exception: - return [] - -@app.get("/api/fptplay/videos") -def api_fptplay_videos(): - vids = _fpt_load() - if not vids: - vids = _fpt_try_server_fetch() - if vids: _fpt_save(vids) - return JSONResponse({"channel": FPT_CHANNEL_URL, "videos": vids, "updated": int(time.time())}) - -@app.post("/api/fptplay/update") -async def api_fptplay_update(request: Request): - try: - body = await request.json() - except Exception: - body = {} - incoming = body.get("videos") or [] - if not isinstance(incoming, list) or not incoming: - return JSONResponse({"ok": True, "videos": _fpt_load()}) - seen = set() - merged = [] - for v in _fpt_load(): - vid = (v or {}).get("id", "") - if vid and vid in seen: continue - if vid: seen.add(vid) - merged.append(v) - added = 0 - for v in incoming: - vid = (v or {}).get("id", "") or "" - if not vid or vid in seen: continue - seen.add(vid) - # keep user-friendly fields, always normalize the watch link - merged.append({ - "id": vid, - "title": (v.get("title") or "").strip()[:200], - "link": (v.get("link") or "https://youtu.be/" + vid), - "img": (v.get("img") or "https://i.ytimg.com/vi/%s/hqdefault.jpg" % vid), - "source": "fptplay", - "published": (v.get("published") or "")[:10], - }) - added += 1 - # newest first - merged.sort(key=lambda x: (x.get("published") or ""), reverse=True) - _fpt_save(merged[:50]) - return JSONResponse({"ok": True, "added": added, "videos": merged[:50]}) - -# ===== WORLD CUP 2026 API ===== -_wc_request_times = []; _wc_rate_limit_lock = threading.Lock() -_WC_RATE_LIMIT = 10 -def _wc_rate_limit(): - global _wc_request_times - with _wc_rate_limit_lock: - now = time.time() - _wc_request_times = [t for t in _wc_request_times if now - t < 60] - if len(_wc_request_times) >= _WC_RATE_LIMIT: return False - _wc_request_times.append(now) - return True - -@app.get("/api/wc2026") -def api_wc2026(): - return JSONResponse(_cached("wc2026", get_wc2026_all, ttl=_cache_ttl)) - -@app.get("/api/wc2026/{tab}") -def api_wc2026_tab(tab: str): - valid_tabs = ["news", "fixtures", "standings", "stats", "highlights"] - if tab not in valid_tabs: return JSONResponse({"error": "invalid tab"}, status_code=400) - def _fetch_tab(): - if tab == "highlights": return scrape_highlights_by_league("world-cup") - elif tab == "news": return scrape_wc_news() - elif tab == "fixtures": return scrape_fixtures() - elif tab == "standings": return scrape_standings() - elif tab == "stats": return scrape_stats() - return [] - return JSONResponse(_cached(f"wc2026_{tab}", _fetch_tab, ttl=_cache_ttl)) - @app.get("/api/bdp_videos") def api_bdp_videos(): def _f(): try: - soup=_get(f"{BASE_BDP}/video"); arts=[]; seen=set() + soup=_get(f"{BASE_BDP}/video");arts=[];seen=set() for a in soup.find_all("a",href=True): href=a.get("href","") if"/video/" not in href or href in("/video/","/video/ban-thang-dep","/video/highlight"):continue - if not href.startswith("http"): href=BASE_BDP+href - if href in seen: continue + if not href.startswith("http"):href=BASE_BDP+href + if href in seen:continue title=re.sub(r'^\d{2}:\d{2}','',a.get_text(strip=True)).strip() - if not title or len(title)<5: continue + if not title or len(title)<5:continue img_tag=a.find("img") or(a.parent.find("img") if a.parent else None) img=(img_tag.get("data-src") or img_tag.get("src","")) if img_tag else "" - seen.add(href); arts.append({"title":title,"link":href,"img":img,"source":"bdp"}) + seen.add(href);arts.append({"title":title,"link":href,"img":img,"source":"bdp"}) return arts[:20] - except: return [] + except:return[] return JSONResponse(_cached("bdp_videos",_f)) - # ===== NEWS ===== -VNE_CATS={"thoi-su":("https://vnexpress.net/thoi-su","Thời Sự"),"the-gioi":("https://vnexpress.net/the-gioi","Thế Giới"),"kinh-doanh":("https://vnexpress.net/kinh-doanh","Kinh Doanh"),"the-thao":("https://vnexpress.net/the-thao","Thể Thao"),"giai-tri":("https://vnexpress.net/giai-tri","Giải Trí"),"suc-khoe":("https://vnexpress.net/suc-khoe","Sức Khỏe"),"phap-luat":("https://vnexpress.net/phap-luat","Pháp Luật"),"giao-duc":("https://vnexpress.net/giao-duc","Giáo Dục"),"du-lich":("https://vnexpress.net/du-lich","Du Lịch"),"doi-song":("https://vnexpress.net/doi-song","Đời Sống")} - def scrape_vne(cat_url): try: - soup=_get(cat_url); arts=[] + soup=_get(cat_url);arts=[] for it in soup.select("article.item-news")[:15]: a=it.select_one("h2.title-news a") or it.select_one("h3.title-news a") - if not a: continue - t=a.get("title","") or a.get_text(strip=True); lk=a.get("href","") - if not t or not lk: continue - im=it.find("img"); img=(im.get("data-src") or im.get("src","")) if im else "" - if img and 'blank' in img: + if not a:continue + t=a.get("title","") or a.get_text(strip=True);lk=a.get("href","") + if not t or not lk:continue + im=it.find("img");img=(im.get("data-src") or im.get("src","")) if im else "" + if img and'blank'in img: src=it.find("source") - if src: img=src.get("srcset","").split(",")[0].strip().split(" ")[0] + if src:img=src.get("srcset","").split(",")[0].strip().split(" ")[0] arts.append({"title":t,"link":lk,"img":img,"source":"vne"}) return arts - except Exception: - return [] - + except:return[] +def scrape_vne_article(url): + try: + soup=_get(url);h1=soup.select_one("h1.title-detail");desc=soup.select_one("p.description") + og=soup.find("meta",property="og:image");og_img=og.get("content","") if og else "" + cd=soup.select_one("article.fck_detail");body=[] + if cd: + for ch in cd.children: + if not hasattr(ch,'name') or not ch.name:continue + if ch.name=="p":t=ch.get_text(strip=True);(body.append({"type":"p","text":t}) if t else None) + elif ch.name=="figure": + im=ch.find("img") + if im:s=im.get("data-src") or im.get("src","");body.append({"type":"img","src":s}) + elif ch.name in("h2","h3"):body.append({"type":"heading","text":ch.get_text(strip=True)}) + return{"title":h1.get_text(strip=True) if h1 else "","summary":desc.get_text(strip=True) if desc else "","og_image":og_img,"body":body,"source":"vne","url":url} + except:return None +def _scrape_dantri_homepage(cat_filter=None): + try: + soup=_get("https://dantri.com.vn/");arts=[];seen=set() + for a in soup.find_all("a",href=True): + href=a.get("href","");title=a.get("title","") or a.get_text(strip=True) + if not title or len(title)<15 or"javascript:" in href:continue + if not href.startswith("http"):href="https://dantri.com.vn"+href + if href in seen or not href.endswith(".htm"):continue + if cat_filter and f"/{cat_filter}/" not in href:continue + img_tag=a.find("img") + if not img_tag and a.parent:img_tag=a.parent.find("img") + img_src="" + if img_tag:img_src=img_tag.get("data-src","") or img_tag.get("src","") + if not img_src or "cdn" not in img_src:continue + proxied_img="/api/proxy/img?url="+quote(img_src,safe="") + seen.add(href);arts.append({"title":title,"link":href,"img":proxied_img,"source":"dantri"}) + if len(arts)>=15:break + return arts + except:return[] +def scrape_dantri_hot():return _scrape_dantri_homepage() +def scrape_dantri_congnghe(): + try: + soup=_get("https://dantri.com.vn/");arts=[];seen=set() + for a in soup.find_all("a",href=True): + href=a.get("href","");title=a.get("title","") or a.get_text(strip=True) + if not title or len(title)<15 or"javascript:" in href:continue + if not href.startswith("http"):href="https://dantri.com.vn"+href + if href in seen or not href.endswith(".htm"):continue + if"/cong-nghe/" not in href:continue + img_tag=a.find("img") + if not img_tag and a.parent:img_tag=a.parent.find("img") + img_src="" + if img_tag:img_src=img_tag.get("data-src","") or img_tag.get("src","") + if img_src and "cdn" in img_src:img_src="/api/proxy/img?url="+quote(img_src,safe="") + else:img_src="" + seen.add(href);arts.append({"title":title,"link":href,"img":img_src,"source":"dantri"}) + if len(arts)>=15:break + return arts + except:return[] def scrape_genk_ai(): + """Scrape AI articles from genk.vn - readable in-app""" try: r=requests.get("https://genk.vn/ai.chn",headers=HEADERS,timeout=15) - if r.status_code!=200: return [] - r.encoding="utf-8"; soup=BeautifulSoup(r.text,"lxml"); articles=[]; seen=set() + if r.status_code!=200:return[] + r.encoding="utf-8";soup=BeautifulSoup(r.text,"lxml") + articles=[];seen=set() for a in soup.find_all("a",href=True): href=a.get("href","") - if not href.endswith(".chn") or href=="/ai.chn": continue - if href.startswith("/"): href="https://genk.vn"+href - if href in seen or "genk.vn" not in href: continue + if not href.endswith(".chn") or href=="/ai.chn":continue + if href.startswith("/"):href="https://genk.vn"+href + if href in seen or "genk.vn" not in href:continue title=a.get("title","") or a.get_text(strip=True) - if not title or len(title)<20: continue - container=a.parent; img_src="" + if not title or len(title)<20:continue + container=a.parent;img_src="" for _ in range(6): - if container is None: break + if container is None:break for img in container.find_all("img"): s=img.get("data-src","") or img.get("src","") - if s and "mediacdn" in s and "avatar" not in s and "logo" not in s: img_src=s; break - if img_src: break; container=container.parent + if s and "mediacdn" in s and "avatar" not in s and "logo" not in s: + img_src=s;break + if img_src:break + container=container.parent seen.add(href) if not img_src: try: - og_r=requests.get(href,headers=HEADERS,timeout=8); og_r.encoding="utf-8" - og_soup=BeautifulSoup(og_r.text,"lxml"); og_tag=og_soup.find("meta",property="og:image") - if og_tag: img_src=og_tag.get("content","") - except: pass + og_r=requests.get(href,headers=HEADERS,timeout=8);og_r.encoding="utf-8" + og_soup=BeautifulSoup(og_r.text,"lxml");og_tag=og_soup.find("meta",property="og:image") + if og_tag:img_src=og_tag.get("content","") + except:pass articles.append({"title":title,"link":href,"img":img_src,"source":"genk"}) - if len(articles)>=30: break + if len(articles)>=30:break return articles - except: return [] + except:return[] +def scrape_dantri_article(url): + try: + r=requests.get(url,headers=HEADERS,timeout=15);r.encoding="utf-8";soup=BeautifulSoup(r.text,"lxml") + for tag in soup.find_all(["script","style","nav","footer","aside"]):tag.decompose() + h1=soup.find("h1");og=soup.find("meta",property="og:image");og_img=og.get("content","") if og else "" + if og_img and "cdnphoto.dantri" in og_img:og_img="/api/proxy/img?url="+quote(og_img,safe="") + content=soup.select_one("main") or soup.select_one("div.singular-content") or soup.select_one("article");body=[] + if content: + for el in content.find_all(["p","h2","h3","figure","img"],recursive=True): + if el.name=="p":t=el.get_text(strip=True);(body.append({"type":"p","text":t}) if t and len(t)>15 else None) + elif el.name in("h2","h3"):t=el.get_text(strip=True);(body.append({"type":"heading","text":t}) if t else None) + elif el.name in("figure","img"): + im=el if el.name=="img" else el.find("img") + if im: + s=im.get("data-src") or im.get("src","") + if s and"base64" not in s: + if "cdnphoto.dantri" in s:s="/api/proxy/img?url="+quote(s,safe="") + body.append({"type":"img","src":s}) + desc="";sapo=soup.select_one("h2.singular-sapo") or soup.select_one("h2[class*=sapo]") + if not sapo: + og_desc=soup.find("meta",property="og:description") + if og_desc:desc=og_desc.get("content","") + else:desc=sapo.get_text(strip=True) + return{"title":h1.get_text(strip=True) if h1 else "","summary":desc,"og_image":og_img,"body":body,"source":"dantri","url":url} + except:return None +def scrape_bbc_vietnamese(): + try: + r=requests.get("https://www.bbc.com/vietnamese",headers={"User-Agent":"Mozilla/5.0","Accept-Language":"en-GB"},timeout=15);r.encoding="utf-8" + soup=BeautifulSoup(r.text,"lxml");arts=[];seen=set() + for a in soup.select("a[href*='/vietnamese/']"): + href=a.get("href","") + if not href or href=="/vietnamese" or href.count("/")<3:continue + if not href.startswith("http"):href="https://www.bbc.com"+href + if href in seen:continue + title=a.get_text(strip=True) + if not title or len(title)<15 or any(x in title.lower() for x in["đăng nhập","trang chủ","bbc news"]):continue + img="";container=a.parent + for _ in range(3): + if container: + im=container.find("img") + if im:img=im.get("src","") or im.get("data-src","");break + container=container.parent + seen.add(href);arts.append({"title":title,"link":href,"img":img,"source":"bbc"}) + if len(arts)>=15:break + return arts + except:return[] +def scrape_bbc_article(url): + try: + r=requests.get(url,headers={"User-Agent":"Mozilla/5.0","Accept-Language":"en-GB"},timeout=15);r.encoding="utf-8" + soup=BeautifulSoup(r.text,"lxml");h1=soup.find("h1") + og=soup.find("meta",property="og:image");og_img=og.get("content","") if og else "" + body=[] + for p in soup.select("[data-component='text-block'] p, article p, main p"): + t=p.get_text(strip=True) + if t and len(t)>20:body.append({"type":"p","text":t}) + return{"title":h1.get_text(strip=True) if h1 else "","summary":"","og_image":og_img,"body":body,"source":"bbc","url":url} + except:return None + +def scrape_ttvh_worldcup(): + """Scrape all World Cup 2026 articles from The Thao Van Hoa RSS.""" + try: + r=requests.get("https://thethaovanhoa.vn/rss/world-cup-2026.rss",headers=HEADERS,timeout=15);r.encoding="utf-8" + soup=BeautifulSoup(r.text,"xml");arts=[];seen=set() + for it in soup.find_all("item"): + 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 "") + desc=(it.find("description").get_text(" ",strip=True) if it.find("description") else "") + img="";ds=BeautifulSoup(desc,"lxml");im=ds.find("img") + if im:img=im.get("src","") or im.get("data-src","") + if title and link and link not in seen: + seen.add(link);arts.append({"title":title,"link":link,"img":img,"source":"ttvh"}) + if arts:return arts + except:pass + try: + soup=_get("https://thethaovanhoa.vn/world-cup-2026.htm");arts=[];seen=set() + for a in soup.find_all("a",href=True): + href=a.get("href","") + if not href.startswith("http"):href="https://thethaovanhoa.vn"+href + if href in seen or "thethaovanhoa.vn" not in href:continue + if not re.search(r"/[^/]+-\d{8,}\.htm",href):continue + title=a.get("title","") or a.get_text(" ",strip=True) + img=None;p=a + for _ in range(5): + if p is None:break + img=p.find("img") + if img:break + p=p.parent + img_src="" + if img: + img_src=img.get("data-src","") or img.get("src","") or img.get("data-original","") or img.get("data-thumb","") + if len(title)<15:title=img.get("alt","") or img.get("title","") or title + if not title or len(title)<15:continue + seen.add(href);arts.append({"title":title,"link":href,"img":img_src,"source":"ttvh"}) + if len(arts)>=24:break + return arts + except:return[] + +def scrape_ttvh_article(url): + try: + soup=_get(url);h1=soup.find("h1");og=soup.find("meta",property="og:image");og_img=og.get("content","") if og else "" + og_title=soup.find("meta",property="og:title");fallback_title=og_title.get("content","") if og_title else "" + desc_el=soup.find("meta",property="og:description");desc=desc_el.get("content","") if desc_el else "" + cd=soup.select_one(".detail-content") or soup.select_one(".content-detail") or soup.select_one("article") or soup.select_one("main") + body=[] + if cd: + for el in cd.find_all(["p","h2","h3","figure","img"],recursive=True): + if el.name=="p": + t=el.get_text(strip=True) + if t and len(t)>20 and "Theo dõi" not in t:body.append({"type":"p","text":t}) + elif el.name in ("h2","h3"): + t=el.get_text(strip=True) + if t:body.append({"type":"heading","text":t}) + elif el.name in ("figure","img"): + 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","") + if src and "base64" not in src:body.append({"type":"img","src":src}) + if not body and desc:body=[{"type":"p","text":desc}] + return {"title":h1.get_text(strip=True) if h1 else fallback_title,"summary":desc,"og_image":og_img,"body":body,"source":"ttvh","url":url} + except:return None + +VNE_CATS={"thoi-su":("https://vnexpress.net/thoi-su","Thời Sự"),"the-gioi":("https://vnexpress.net/the-gioi","Thế Giới"),"kinh-doanh":("https://vnexpress.net/kinh-doanh","Kinh Doanh"),"the-thao":("https://vnexpress.net/the-thao","Thể Thao"),"giai-tri":("https://vnexpress.net/giai-tri","Giải Trí"),"suc-khoe":("https://vnexpress.net/suc-khoe","Sức Khỏe"),"phap-luat":("https://vnexpress.net/phap-luat","Pháp Luật"),"giao-duc":("https://vnexpress.net/giao-duc","Giáo Dục"),"du-lich":("https://vnexpress.net/du-lich","Du Lịch"),"doi-song":("https://vnexpress.net/doi-song","Đời Sống")} @app.get("/api/homepage") def api_homepage(): def _f(): articles=[] with ThreadPoolExecutor(12) as ex: futs={ex.submit(scrape_vne,VNE_CATS[k][0]):VNE_CATS[k][1] for k in["thoi-su","the-gioi","kinh-doanh","the-thao","giai-tri","phap-luat","giao-duc","du-lich","doi-song"]} + futs[ex.submit(scrape_bbc_vietnamese)]="BBC" for f in as_completed(futs): try: - for a in f.result(): a["group"]=futs[f]; articles.append(a) - except: pass + for a in f.result():a["group"]=futs[f];articles.append(a) + except:pass return articles return JSONResponse(_cached("homepage",_f)) - @app.get("/api/category/{cat_id}") def api_category(cat_id:str): def _f(): - if cat_id=="cong-nghe": return scrape_genk_ai() - if cat_id in VNE_CATS: - arts=scrape_vne(VNE_CATS[cat_id][0]) - [a.update({"group":VNE_CATS[cat_id][1]}) for a in arts] - return arts - return [] + if cat_id=="bbc":return scrape_bbc_vietnamese() + if cat_id=="cong-nghe":return scrape_genk_ai() + if cat_id in VNE_CATS:arts=scrape_vne(VNE_CATS[cat_id][0]);[a.update({"group":VNE_CATS[cat_id][1]}) for a in arts];return arts + return[] return JSONResponse(_cached(f"cat_{cat_id}",_f)) - @app.get("/api/categories") def api_categories(): - cats=[{"id":"cong-nghe","name":"Công Nghệ","source":"genk"}] - for k,(u,n) in VNE_CATS.items(): cats.append({"id":k,"name":n,"source":"vne"}) + cats=[{"id":"bbc","name":"BBC Tiếng Việt","source":"bbc"},{"id":"cong-nghe","name":"Công Nghệ","source":"genk"}] + for k,(u,n) in VNE_CATS.items():cats.append({"id":k,"name":n,"source":"vne"}) return JSONResponse(cats) - -@app.get("/api/proxy/xlb") -def api_xlb(path: str = Query(default=""), limit: int = Query(default=20)): - # Try xemlaibongda.top first (fast timeout), fallback to bongdaplus.vn - videos = _do_xlb_scrape_xlb(path, limit) - if not videos: - videos = _do_xlb_scrape_bdp(limit) - if not videos: - return JSONResponse({"videos": [], "error": "No videos available"}) - return JSONResponse({"videos": videos}) - -def _do_xlb_scrape_xlb(path, limit): - try: - url = f"https://xemlaibongda.top/{path}" if path else "https://xemlaibongda.top/" - r = requests.get(url, headers=HEADERS, timeout=8) - if r.status_code != 200: return [] - r.encoding = "utf-8" - soup = BeautifulSoup(r.text, "lxml") - videos, seen = [], set() - for a in soup.find_all("a", href=True): - href = a.get("href", "") - if "/video/" not in href and "/xem-lai/" not in href: continue - if not href.startswith("http"): href = "https://xemlaibongda.top" + href - clean = href.split("?")[0].split("#")[0] - if clean in seen: continue - seen.add(clean) - img_src = "" - img = a.find("img") or (a.parent.find("img") if a.parent else None) - if not img: - p = a.parent - for _ in range(5): - if p and p.find("img"): img = p.find("img"); break - p = p.parent if p else None - if img: - img_src = (img.get("data-src", "") or img.get("src", "") or img.get("data-lazy", "") or img.get("data-original", "")) - if img_src.startswith("//"): img_src = "https:" + img_src - elif img_src.startswith("/"): img_src = "https://xemlaibongda.top" + img_src - title = a.find("h3") - if not title: title = a.find("h2") - if not title: title = a.find("strong") - t = title.get_text(strip=True) if title else "" - if not t: - slug = clean.split("/video/")[-1].rstrip("/") - t = slug.replace("-", " ").title() - videos.append({"title": t[:100], "link": clean, "img": img_src, "source": "xemlaibongda", "published": _video_published(img_src, clean)}) - if len(videos) >= limit: break - return videos - except Exception: - return [] - -def _do_xlb_scrape_bdp(limit): - """Fallback: scrape bongdaplus.vn videos for the xlb endpoint.""" +@app.get("/api/dantri_hot") +def api_dantri_hot():return JSONResponse(_cached("dantri_hot",scrape_dantri_hot)) +@app.get("/api/genk_ai") +def api_genk_ai():return JSONResponse(_cached("genk_ai",scrape_genk_ai,ttl=_cache_ttl)) +@app.get("/api/worldcup2026") +def api_worldcup2026():return JSONResponse(_cached("ttvh_worldcup",scrape_ttvh_worldcup,ttl=_cache_ttl)) +def scrape_genk_article(url): try: - return scrape_bongdaplus_videos(limit) - except Exception: - return [] + r=requests.get(url,headers=HEADERS,timeout=15);r.encoding="utf-8";soup=BeautifulSoup(r.text,"lxml") + h1=soup.find("h1");og=soup.find("meta",property="og:image");og_img=og.get("content","") if og else "" + og_title=soup.find("meta",property="og:title");fallback_title=og_title.get("content","") if og_title else "" + desc_el=soup.find("meta",property="og:description");desc=desc_el.get("content","") if desc_el else "" + cd=soup.select_one(".knc-content");body=[] + if cd: + for el in cd.find_all(["p","h2","h3","figure","img"],recursive=True): + if el.name=="p":t=el.get_text(strip=True);(body.append({"type":"p","text":t}) if t and len(t)>15 else None) + elif el.name in("h2","h3"):t=el.get_text(strip=True);(body.append({"type":"heading","text":t}) if t else None) + elif el.name in("figure","img"): + im=el if el.name=="img" else el.find("img") + if im:s=im.get("data-src") or im.get("src","");(body.append({"type":"img","src":s}) if s and"base64" not in s else None) + return{"title":h1.get_text(strip=True) if h1 else "","summary":desc,"og_image":og_img,"body":body,"source":"genk","url":url} + except:return None @app.get("/api/article") def api_article(url:str=Query(...)): + if"vnexpress.net" in url:data=scrape_vne_article(url) + elif"bbc.com" in url:data=scrape_bbc_article(url) + elif"dantri.com.vn" in url:data=scrape_dantri_article(url) + elif"genk.vn" in url:data=scrape_genk_article(url) + elif"thethaovanhoa.vn" in url:data=scrape_ttvh_article(url) + else:data=None + return JSONResponse(data if data else{"error":"not supported"}) +def _web_context(topic): + """Collect real web/news context for a topic.""" + bits=[] try: - r2 = requests.get(url, headers=HEADERS, timeout=10) - if r2.status_code == 200: - r2.encoding = "utf-8" - soup = BeautifulSoup(r2.text, "lxml") - og = soup.find("meta", property="og:image") - return JSONResponse({"og_image": og.get("content", "") if og else ""}) - except: pass - return JSONResponse({"og_image": ""}) - -@app.get("/api/storage_status") -def api_storage_status(): - return JSONResponse({"persistent":os.path.isdir("/data")}) - -@app.get("/api/hot_topics") -def api_hot_topics(): - return JSONResponse({"topics":[]}) - -@app.get("/", response_class=HTMLResponse) -async def root(): - return HTMLResponse("

VNEWS v17

VTV Digital CDN ssaimh · No shorts Dantri/SKDS · Homepage full content

") \ No newline at end of file + rss="https://news.google.com/rss/search?q="+quote(topic)+"&hl=vi&gl=VN&ceid=VN:vi" + r=requests.get(rss,headers=HEADERS,timeout=12);r.encoding="utf-8" + soup=BeautifulSoup(r.text,"xml") + for it in soup.find_all("item")[:8]: + title=it.find("title").get_text(" ",strip=True) if it.find("title") else "" + src=it.find("source").get_text(" ",strip=True) if it.find("source") else "" + if title:bits.append((title+(" — "+src if src else ""))[:280]) + except:pass + if bits:return "\n".join(bits) + try: + r=requests.get("https://html.duckduckgo.com/html/?q="+quote(topic),headers=HEADERS,timeout=12);r.encoding="utf-8" + soup=BeautifulSoup(r.text,"lxml") + for res in soup.select(".result")[:6]: + t=res.select_one(".result__title");sn=res.select_one(".result__snippet") + line=((t.get_text(" ",strip=True) if t else "")+" — "+(sn.get_text(" ",strip=True) if sn else "")).strip(" —") + if line:bits.append(line[:280]) + except:pass + return "\n".join(bits) + +def _jina_read(url): + try: + ju="https://r.jina.ai/http://"+url + r=requests.get(ju,headers=HEADERS,timeout=25);r.encoding="utf-8" + if r.status_code!=200 or not r.text:return None + lines=[x.rstrip() for x in r.text.splitlines()] + title="";img="";body=[];summary="" + for ln in lines[:40]: + if ln.startswith("Title:"):title=ln.replace("Title:","",1).strip() + elif ln.startswith("Image:"):img=ln.replace("Image:","",1).strip() + elif ln.startswith("Description:"):summary=ln.replace("Description:","",1).strip() + for ln in lines: + t=ln.strip() + if not t or t.startswith(("Title:","URL Source:","Published Time:","Markdown Content:","Image:","Description:")):continue + if len(t)>40:body.append({"type":"p","text":t}) + if not body and summary:body=[{"type":"p","text":summary}] + return {"title":title or url,"summary":summary,"og_image":img,"body":body[:80],"source":"jina","url":url} + except:return None + +def _scrape_generic_article(url): + try: + hdr={**HEADERS,"Accept":"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"} + r=requests.get(url,headers=hdr,timeout=15);r.encoding="utf-8" + ct=r.headers.get("content-type","").lower() + if r.status_code>=400 or "text/html" not in ct: + jr=_jina_read(url) + if jr:return jr + soup=BeautifulSoup(r.text,"lxml") + for tag in soup.find_all(["script","style","nav","footer","aside","form"]):tag.decompose() + h1=soup.find("h1") + ogt=soup.find("meta",property="og:title");title=h1.get_text(strip=True) if h1 else (ogt.get("content","") if ogt else "") + ogd=soup.find("meta",property="og:description");desc=ogd.get("content","") if ogd else "" + ogi=soup.find("meta",property="og:image");img=ogi.get("content","") if ogi else "" + main=soup.find("article") or soup.find("main") or soup.body + body=[] + if main: + for el in main.find_all(["p","h2","h3","figure","img"],recursive=True): + if el.name=="p": + t=el.get_text(" ",strip=True) + if t and len(t)>35:body.append({"type":"p","text":t}) + elif el.name in ("h2","h3"): + t=el.get_text(" ",strip=True) + if t:body.append({"type":"heading","text":t}) + elif el.name in ("figure","img"): + 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","") + if src and "base64" not in src:body.append({"type":"img","src":src}) + if not body: + jr=_jina_read(url) + if jr and jr.get("body"):return jr + if not body and desc:body=[{"type":"p","text":desc}] + return {"title":title or url,"summary":desc,"og_image":img,"body":body,"source":"generic","url":url} + except: + return _jina_read(url) + +def _article_by_url(url): + if "vnexpress.net" in url:return scrape_vne_article(url) + if "bbc.com" in url:return scrape_bbc_article(url) + if "dantri.com.vn" in url:return scrape_dantri_article(url) + if "genk.vn" in url:return scrape_genk_article(url) + if "thethaovanhoa.vn" in url:return scrape_ttvh_article(url) + return _scrape_generic_article(url) + +def _call_qwen(prompt, max_tokens=1800): + """Try Qwen2.5-VL via HF router; return None if unavailable.""" + try: + token=os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACEHUB_API_TOKEN") or os.environ.get("VAISTUDIO") + if not token:return None + headers={"Authorization":"Bearer "+token,"Content-Type":"application/json"} + payload={"model":"Qwen/Qwen2.5-VL-7B-Instruct","messages":[{"role":"user","content":prompt}],"max_tokens":max_tokens,"temperature":0.7} + r=requests.post("https://router.huggingface.co/v1/chat/completions",headers=headers,json=payload,timeout=75) + if r.status_code>=300:return None + j=r.json();return j.get("choices",[{}])[0].get("message",{}).get("content") + except:return None + +def _collect_article_text(data, limit=28000): + title=(data or {}).get("title","");summary=(data or {}).get("summary","") + parts=[] + if summary:parts.append(summary) + for b in (data or {}).get("body",[]): + if b.get("type")=="heading":parts.append("## "+b.get("text","") ) + elif b.get("type")=="p":parts.append(b.get("text","") ) + text="\n".join([p.strip() for p in parts if p and p.strip()]) + return title,text[:limit] + +def _ai_rewrite_article(data,tone="tu-nhien"): + title,text=_collect_article_text(data) + prompt=("Bạn là biên tập viên báo điện tử tiếng Việt. Hãy viết lại bài dưới đây bằng ngôn ngữ tự nhiên, mạch lạc, không cắt khúc, không bỏ ý quan trọng. " + "Giữ đúng sự thật, không bịa, không thêm thông tin ngoài bài. Văn phong: "+tone+". " + "Đầu ra gồm: tiêu đề hấp dẫn, đoạn sapo 2-3 câu, các đoạn nội dung ngắn dễ đọc, và 3 gạch đầu dòng điểm chính.\n\n" + "TIÊU ĐỀ GỐC: "+title+"\n\nNỘI DUNG GỐC:\n"+text) + out=_call_qwen(prompt,2200) + if out and len(out)>300:return out.strip() + # Fallback: complete non-truncated rewrite using full collected text chunks + paras=[p.strip() for p in text.split("\n") if len(p.strip())>30] + body="\n\n".join(paras[:18]) + bullets="\n".join(["• "+p[:220]+("..." if len(p)>220 else "") for p in paras[:5]]) + return ("Bản tin AI viết lại: "+title+"\n\n"+ + (paras[0] if paras else "")+"\n\n"+body+"\n\nĐiểm chính:\n"+bullets).strip() + +def _image_for_topic(topic): + return "https://image.pollinations.ai/prompt/"+quote("editorial illustration, Vietnamese news, "+topic,safe="")+"?width=1024&height=576&nologo=true" + +def _topic_articles(topic,limit=5): + items=[];seen=set() + try: + rss="https://news.google.com/rss/search?q="+quote(topic)+"&hl=vi&gl=VN&ceid=VN:vi" + r=requests.get(rss,headers=HEADERS,timeout=12);r.encoding="utf-8" + soup=BeautifulSoup(r.text,"xml") + for it in soup.find_all("item")[:limit*3]: + 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 "" + if not title or not link or link in seen:continue + seen.add(link);items.append({"title":title,"link":link,"source":src}) + if len(items)>=limit:break + except:pass + return items + +def _topic_article_context(topic): + """Filter readable article sources by topic, then summarize actual article bodies.""" + raw_keys=[k.lower() for k in re.findall(r"[\wÀ-ỹ]+",topic) if len(k)>2] + # Drop ultra-generic tokens; keep domain words such as giáo/dục, bóng/đá, world/cup. + stop={"trong","năm","the","and","của","cho","với","một","các","những","hiện","nay"} + keys=[k for k in raw_keys if k not in stop] + candidates=[];seen=set() + def add_items(items): + for a in items or []: + link=a.get("link","");title=a.get("title","") + if not link or link in seen:continue + seen.add(link);candidates.append(a) + try:add_items(scrape_genk_ai()) + except:pass + try:add_items(scrape_dantri_congnghe()) + except:pass + try:add_items(scrape_ttvh_worldcup()) + except:pass + scored=[];img="" + for a in candidates[:40]: + data=_article_by_url(a.get("link","")) + if not data or not data.get("body"):continue + title=data.get("title") or a.get("title","") + ps=[b.get("text","") for b in data.get("body",[]) if b.get("type")=="p" and len(b.get("text",""))>40] + excerpt=" ".join(ps)[:1800] or data.get("summary","") + hay=(title+" "+excerpt).lower() + score=sum(1 for k in keys if k in hay) + # Require topic relevance when we have meaningful keys. + if keys and score==0:continue + if len(keys)>=2 and score<2 and not any(" ".join(keys[i:i+2]) in hay for i in range(len(keys)-1)):continue + scored.append((score,title,a.get("link",""),excerpt,data.get("og_image") or a.get("img","") or "")) + scored=sorted(scored,key=lambda x:x[0],reverse=True)[:5] + chunks=[] + for score,title,link,excerpt,im in scored: + if not img and im:img=im + chunks.append("BÀI: "+title+"\nURL: "+link+"\nNỘI DUNG LỌC: "+excerpt) + if chunks:return "\n\n".join(chunks),img + return _web_context(topic),"" + +def _topic_post_text(topic): + ctx,img=_topic_article_context(topic) + prompt=("Bạn là cây bút báo điện tử tiếng Việt. Hãy lọc các thông tin thực tế trong những nguồn dưới đây để viết một bài tóm tắt theo chủ đề: "+topic+ + ". Không viết chung chung. Chỉ dùng dữ kiện có trong nguồn; nếu nguồn khác nhau thì tổng hợp khách quan. " + "Đầu ra gồm: tiêu đề, sapo, các ý chính theo bullet, phần phân tích ngắn và kết luận.\n\nNGUỒN THỰC TẾ:\n"+ctx) + out=_call_qwen(prompt,1800) + if out and len(out)>300:return out.strip() + if ctx: + return "Bài tóm tắt theo chủ đề: "+topic+"\n\nDữ liệu thực tế đã lọc:\n"+ctx[:3500]+"\n\nTóm tắt: Các nguồn trên cho thấy chủ đề này đang có nhiều diễn biến đáng chú ý. Khi viết bài, nên nêu rõ bối cảnh, các điểm mới, tác động thực tế và những điều còn cần kiểm chứng." + return "Chưa thu thập được dữ liệu đủ rõ cho chủ đề: "+topic + +@app.get("/api/wall") +def api_wall():return JSONResponse({"posts":_load_wall()[:50]}) + +@app.post("/api/rewrite_share") +async def api_rewrite_share(request:Request): + try:body=await request.json() + except:body={} + url=str(body.get("url","")).strip();tone=str(body.get("tone","tu-nhien")).strip() + if not url:return JSONResponse({"error":"missing url"},status_code=400) + data=_article_by_url(url) + if not data or not data.get("title") or (not data.get("body") and not data.get("summary")): + return JSONResponse({"error":"Không đọc được bài viết"},status_code=422) + post={"id":hashlib.md5((url+str(time.time())).encode()).hexdigest()[:12],"url":url,"title":data.get("title",""),"img":data.get("og_image","") or "","text":_ai_rewrite_article(data,tone),"ts":int(time.time()),"source":data.get("source","")} + posts=_load_wall();posts.insert(0,post);_save_wall(posts) + return JSONResponse({"post":post}) + +@app.post("/api/topic_post") +async def api_topic_post(request:Request): + try:body=await request.json() + except:body={} + topic=str(body.get("topic","")).strip() + if not topic:return JSONResponse({"error":"missing topic"},status_code=400) + ctx_img=_topic_article_context(topic)[1] + post={"id":hashlib.md5((topic+str(time.time())).encode()).hexdigest()[:12],"url":"","title":topic,"img":ctx_img or _image_for_topic(topic),"text":_topic_post_text(topic),"ts":int(time.time()),"source":"ai-topic"} + posts=_load_wall();posts.insert(0,post);_save_wall(posts) + return JSONResponse({"post":post}) + +@app.post("/api/url_wall") +async def api_url_wall(request:Request): + try:body=await request.json() + except:body={} + url=str(body.get("url","")).strip() + if not url:return JSONResponse({"error":"missing url"},status_code=400) + data=_article_by_url(url) + if not data or not data.get("title"): + return JSONResponse({"error":"Không đọc được URL"},status_code=422) + post={"id":hashlib.md5((url+str(time.time())).encode()).hexdigest()[:12],"url":url,"title":data.get("title",""),"img":data.get("og_image","") or "","text":_ai_rewrite_article(data,"ngan-gon-tu-nhien"),"ts":int(time.time()),"source":data.get("source","")} + posts=_load_wall();posts.insert(0,post);_save_wall(posts) + return JSONResponse({"post":post}) + +@app.get("/v") +async def video_share(url:str=Query(default=""),title:str=Query(default="VNEWS Video"),img:str=Query(default=""),type:str=Query(default="highlights")): + decoded_url=unquote(url);decoded_title=unquote(title) + redirect_script=f'' if decoded_url else f'' + return HTMLResponse(f'{decoded_title}

{redirect_script}') +@app.get("/s") +async def share_redirect(url:str=Query(default=""),title:str=Query(default="VNEWS"),img:str=Query(default="")): + decoded_url=unquote(url) + redirect_script=f'' if decoded_url else f'' + return HTMLResponse(f'{unquote(title)}{redirect_script}') +@app.get("/") +async def index(): + with open("/app/static/index.html","r",encoding="utf-8") as f:return HTMLResponse(content=f.read()) +app.mount("/static",StaticFiles(directory="/app/static"),name="static") diff --git a/main_patch.py b/main_patch.py deleted file mode 100644 index ac0921a0db85a125a039effe4380b4394fa2d16d..0000000000000000000000000000000000000000 --- a/main_patch.py +++ /dev/null @@ -1,8 +0,0 @@ -# PATCH: Add these 2 lines to main.py right after "app = FastAPI()" -# Line 1: from vtv_api import router as vtv_router -# Line 2: app.include_router(vtv_router) -# -# This enables the VTV1-VTV10 + VTVPrime stream endpoints: -# GET /api/vtv/streams - Get all channel streams -# GET /api/vtv/stream/{id} - Get specific channel stream -# GET /api/proxy/page?url=... - Proxy web pages (for xemtv PHP scraping) diff --git a/match_detail.py b/match_detail.py deleted file mode 100644 index 32d439d20eca4a7dfe643364acdfe4eb1f283d32..0000000000000000000000000000000000000000 --- a/match_detail.py +++ /dev/null @@ -1,309 +0,0 @@ -""" -Match Detail Scraper for bongda.com.vn -""" -import requests, re, json, time, threading -from bs4 import BeautifulSoup - -def _sp(html): - try: - return BeautifulSoup(html, 'lxml') - except: - return BeautifulSoup(html, 'html.parser') - -BH = { - "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": "application/json, text/javascript, */*; q=0.01", - "Referer": "https://bongda.com.vn/", - "X-Requested-With": "XMLHttpRequest", -} -HH = { - "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": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "Referer": "https://bongda.com.vn/", -} - -def _cl(s): - return re.sub(r'\s+', ' ', str(s or '')).strip() - -def _api(ep, params=None): - try: - url = f"https://bongda.com.vn{ep}" - if params: - url += "?" + "&".join(f"{k}={v}" for k, v in params.items()) - r = requests.get(url, headers=BH, timeout=15) - if r.status_code == 200: - try: return r.json() - except: pass - except: pass - return None - -def _get_teams(soup): - info = {} - tel = soup.select_one('.teams') - if not tel: - return info - he = tel.select_one('.team.home, .home-team') - if he: - ne = he.select_one('p:not(.logo)') or he.find('p') - if ne: info['home_team'] = _cl(ne.get_text()) - lo = he.select_one('img') - if lo: info['home_logo'] = lo.get('src', '') - le = he if he.name == 'a' else he.find('a') - if le and le.get('href'): - m = re.search(r'/doi-bong/(\d+)/', le['href']) - if m: info['home_team_id'] = m.group(1) - ae = tel.select_one('.team.away, .away-team') - if ae: - ne = ae.select_one('p:not(.logo)') or ae.find('p') - if ne: info['away_team'] = _cl(ne.get_text()) - lo = ae.select_one('img') - if lo: info['away_logo'] = lo.get('src', '') - le = ae if ae.name == 'a' else ae.find('a') - if le and le.get('href'): - m = re.search(r'/doi-bong/(\d+)/', le['href']) - if m: info['away_team_id'] = m.group(1) - sc = tel.select_one('.score') - if sc: - parts = [_cl(p.get_text()) for p in sc.select('p')] - if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}" - lb = sc.select_one('.label') - if lb: info['status_label'] = _cl(lb.get_text()) - return info - -def _get_timeline(soup): - tl = [] - el = soup.select_one('.timeline') - if not el: return tl - half = '' - for c in el.children: - if not hasattr(c, 'name') or not c.name: continue - t = _cl(c.get_text()) - if not t: continue - if t in ['H1','H2','Hiệp 1','Hiệp 2']: - half = t; continue - m = re.match(r"(\d+'\+?\d*)", t) - if m: - tl.append({'time': m.group(1), 'text': t[m.end():].strip(), 'half': half}) - elif len(t) > 5: - tl.append({'time': '', 'text': t, 'half': half}) - return tl - -def _get_events(soup): - evts = [] - for el in soup.select('.event'): - e = {} - cl = ' '.join(el.get('class', [])) - e['team'] = 'home' if 'home' in cl else ('away' if 'away' in cl else '') - ps = [_cl(p.get_text()) for p in el.select('p')] - ps = [p for p in ps if p] - if ps: e['players'] = ps - tl = el.select_one('.time, .minute, span') - if tl: e['time'] = _cl(tl.get_text()) - evts.append(e) - return evts - -def _get_stats(soup): - st = {} - for sel in ['.match-stats','[class*="stats"]']: - el = soup.select_one(sel) - if el and len(str(el)) > 50: - for row in el.select('li,tr,.stat-row'): - cells = row.select('td,span,p') - if len(cells) >= 3: - lb = _cl(cells[0].get_text()) - if lb: st[lb] = {'home': _cl(cells[1].get_text()), 'away': _cl(cells[2].get_text())} - if st: break - return st - -def _get_h2h(soup): - h2h = {'matches': [], 'stats': {}} - for sel in ['.head-to-head','[class*="h2h"]']: - el = soup.select_one(sel) - if el and len(str(el)) > 50: - for it in el.select('li,tr,.match-item'): - m = {} - cells = it.select('td,span,p') - if len(cells) >= 3: - m['date'] = _cl(cells[0].get_text()) - m['home'] = _cl(cells[1].get_text()) - m['score'] = _cl(cells[2].get_text()) - if m.get('home'): - if len(cells) > 3: m['away'] = _cl(cells[3].get_text()) - h2h['matches'].append(m) - if h2h['matches']: break - return h2h - -def _get_form(soup): - f = {'home': [], 'away': []} - for sel in ['.form-guide','[class*="form"]']: - el = soup.select_one(sel) - if el and len(str(el)) > 50: - items = el.select('li,.form-item,tr') - for it in items[:10]: - t = _cl(it.get_text()) - if t: f['home'].append({'text': t}) - for it in items[10:20]: - t = _cl(it.get_text()) - if t: f['away'].append({'text': t}) - break - return f - -def _get_info(soup): - info = {} - mi = soup.select_one('.match-info') - if mi: - te = mi.select_one('.times,li') - if te: info['datetime'] = _cl(te.get_text()) - le = soup.select_one('.league,.tournament,[class*="league"]') - if le: info['league'] = _cl(le.get_text()) - return info - -def _scrape(url): - print(f"[DEBUG] _scrape: {url[:80]}", flush=True) - try: - r = requests.get(url, headers=HH, timeout=15, allow_redirects=True) - print(f"[DEBUG] HTTP={r.status_code}", flush=True) - if r.status_code != 200: - return False, {} - sp = _sp(r.text) - d = {} - - teams = _get_teams(sp) - print(f"[DEBUG] teams={teams}", flush=True) - if teams: d['info'] = teams - - mi = _get_info(sp) - if mi: - d.setdefault('info', {}).update(mi) - - tl = _get_timeline(sp) - if tl: - d['timeline'] = tl - d['commentaries_html'] = '\n'.join([f"{t.get('time','')} {t.get('text','')}" for t in tl]) - - ev = _get_events(sp) - if ev: d['events'] = ev - - st = _get_stats(sp) - if st: - d['stats_parsed'] = st - d['stats_html'] = str(st) - - h2h = _get_h2h(sp) - if h2h.get('matches'): d['h2h_matches'] = h2h['matches'] - if h2h.get('stats'): d['h2h_stats'] = h2h['stats'] - - if '/preview/' in url: - fm = _get_form(sp) - if fm.get('home'): d['home_form'] = fm['home'] - if fm.get('away'): d['away_form'] = fm['away'] - - print(f"[DEBUG] success keys={list(d.keys())}", flush=True) - return True, d - except Exception as e: - import traceback - print(f"[DEBUG] error: {e}", flush=True) - traceback.print_exc() - return False, {} - -def fetch_match_detail_by_url(url): - m = re.search(r'/tran-dau/(\d+)/', url) - if not m: return {"error": "Could not extract event_id", "found": False} - event_id = int(m.group(1)) - res = {"event_id": event_id, "found": False, "sections": []} - _fetch_api(event_id, res) - ok, d = _scrape(url) - print(f"[DEBUG] by_url: ok={ok} d_keys={list(d.keys())}", flush=True) - if ok: _merge(res, d) - return res - -def fetch_match_detail(event_id): - print(f"[DEBUG] fetch_match_detail({event_id})", flush=True) - res = {"event_id": event_id, "found": False, "sections": []} - _fetch_api(event_id, res) - - for pt in ["centre", "preview"]: - url = f"https://bongda.com.vn/tran-dau/{event_id}/{pt}/" - ok, d = _scrape(url) - print(f"[DEBUG] {pt}: ok={ok}", flush=True) - if ok: - _merge(res, d) - if res.get("found"): break - - print(f"[DEBUG] final: found={res['found']} sections={res['sections']}", flush=True) - return res - -def _fetch_api(eid, res): - pm = _api("/api/event-standing/pre-match", {"event_id": eid}) - res["pre_match"] = pm - res["pre_match_html"] = pm.get("html","") if pm and pm.get("status")=="success" and len(pm.get("html","").strip())>10 else "" - - hm = _api("/api/fixtures/h2h-match", {"event_id": eid}) - res["h2h_match"] = hm - if hm and hm.get("status")=="success": - h = hm.get("html","") - if len(h.strip())>10: - res["h2h_html"] = h - res["sections"].append("h2h") - else: res["h2h_html"] = "" - - hs = _api("/api/fixtures/h2h-stats", {"event_id": eid}) - res["h2h_stats"] = hs - if hs and hs.get("status")=="success": - h = hs.get("html","") - if len(h.strip())>10: - res["h2h_stats_html"] = h - res["sections"].append("h2h_stats") - try: - sp = _sp(h) - stats = {} - for row in sp.select('li,tr,.stat-row'): - cells = row.select('td,span,p') - if len(cells)>=3: - lb = _cl(cells[0].get_text()) - if lb: stats[lb] = {'home': _cl(cells[1].get_text()), 'away': _cl(cells[2].get_text())} - if stats: res["h2h_stats_parsed"] = stats - except: pass - else: res["h2h_stats_html"] = "" - - pf = _api("/api/event-standing/player-performance", {"event_id": eid}) - res["performance"] = pf - if pf and pf.get("status")=="success" and len(pf.get("html","").strip())>10: - res["stats_html"] = pf["html"] - res["sections"].append("stats") - else: res["stats_html"] = "" - - cm = _api("/api/fixtures/commentaries", {"event_id": eid}) - if cm and cm.get("status")=="success" and len(cm.get("html","").strip())>10: - res["commentaries_html"] = cm["html"] - res["sections"].append("commentaries") - elif not res.get("commentaries_html"): res["commentaries_html"] = "" - -def _merge(res, d): - if d.get("info"): - res.setdefault("info", {}).update(d["info"]) - res["found"] = True - if "info" not in res["sections"]: res["sections"].append("info") - if d.get("timeline"): - res["timeline"] = d["timeline"] - if not res.get("commentaries_html"): res["commentaries_html"] = d.get("commentaries_html","") - res["sections"].append("commentaries") - if d.get("events"): - res["events"] = d["events"] - res["sections"].append("events") - if d.get("stats_parsed"): - res["stats_parsed"] = d["stats_parsed"] - if not res.get("stats_html"): res["stats_html"] = d.get("stats_html","") - res["sections"].append("stats") - if d.get("h2h_matches"): - res["h2h"] = d["h2h_matches"] - res["sections"].append("h2h") - if d.get("h2h_stats"): - res["h2h_stats_parsed"] = d["h2h_stats"] - res["sections"].append("h2h_stats") - if d.get("home_form"): - res["home_form"] = d["home_form"] - res["sections"].append("home_form") - if d.get("away_form"): - res["away_form"] = d["away_form"] - res["sections"].append("away_form") diff --git a/match_detail_v2.py b/match_detail_v2.py deleted file mode 100644 index 825a2ab3c4841ef46ef8714467a787e7a4fecc2b..0000000000000000000000000000000000000000 --- a/match_detail_v2.py +++ /dev/null @@ -1,418 +0,0 @@ -"""VNEWS — Match Detail Parser v2 (html.parser only, no lxml dependency)""" -import re -import requests -from bs4 import BeautifulSoup - -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": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "Accept-Language": "vi-VN,vi;q=0.9", - "Referer": "https://bongda.com.vn/", -} - -API_HEADERS = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", - "Accept": "application/json, text/javascript, */*; q=0.01", - "X-Requested-With": "XMLHttpRequest", - "Referer": "https://bongda.com.vn/", -} - - -def _cl(s): - return re.sub(r'\s+', ' ', str(s or '')).strip() - - -def _normalize_time(raw): - t = _cl(raw) - if not t: - return t - t = re.sub(r"(\d+)'\s*\+(\d+)", r"\1+\2'", t) - t = t.replace("''", "'") - return t - - -def _mk(html): - """Parse HTML using html.parser (lxml may not be available).""" - return BeautifulSoup(html, 'html.parser') - - -def fetch_html(url, timeout=8): - resp = requests.get(url, headers=HEADERS, timeout=timeout, allow_redirects=True) - resp.raise_for_status() - return resp.text - - -def parse_events(sp): - """Parse .events > .period > .event structure.""" - events = [] - events_div = sp.select_one('.events') - if not events_div: - return events - - current_period = '' - for child in events_div.children: - if not hasattr(child, 'name') or not child.name: - continue - cls_str = ' '.join(child.get('class', []) if child.get('class') else []) - - if 'period' in cls_str: - h2 = child.find('h2') - if h2: - current_period = _cl(h2.get_text()) - - for ev in child.children: - if not hasattr(ev, 'name') or not ev.name: - continue - ev_cls_str = ' '.join(ev.get('class', []) if ev.get('class') else []) - if 'event' not in ev_cls_str: - continue - - team = 'home' if 'home' in ev_cls_str else 'away' - ev_data = { - 'team': team, 'period': current_period, 'type': 'unknown', - 'time': '', 'players': '', 'player_in': '', 'player_out': '', - 'scorer': '', 'assist': '', 'card_type': '', 'player': '', - } - - type_el = ev.select_one('.event-type') - if type_el: - if type_el.select_one('[class*="redcard"]'): - ev_data['type'] = 'redcard'; ev_data['card_type'] = 'red' - elif type_el.select_one('[class*="yellowcard"]'): - ev_data['type'] = 'yellowcard'; ev_data['card_type'] = 'yellow' - elif type_el.select_one('[class*="goal"]'): - ev_data['type'] = 'goal' - elif type_el.select_one('[class*="substitution"]'): - ev_data['type'] = 'substitution' - else: - for rect in type_el.select('svg rect'): - if rect.get('fill') == '#E20007': - ev_data['type'] = 'redcard'; ev_data['card_type'] = 'red'; break - if ev_data['type'] == 'unknown': - for circle in type_el.select('svg circle'): - if circle.get('fill') == 'white' and circle.get('r') == '8': - ev_data['type'] = 'goal'; break - if ev_data['type'] == 'unknown' and ev.select_one('.players.subst'): - ev_data['type'] = 'substitution' - - players_el = ev.select_one('.players') - if players_el and ev_data['type'] == 'unknown': - pcls = ' '.join(players_el.get('class', []) if players_el.get('class') else []) - if 'goal' in pcls: ev_data['type'] = 'goal' - elif 'card' in pcls: ev_data['type'] = 'redcard'; ev_data['card_type'] = 'red' - elif 'subst' in pcls: ev_data['type'] = 'substitution' - - if players_el: - time_el = players_el.select_one('.event-time') - if time_el: - ev_data['time'] = _normalize_time(time_el.get_text()) - ev_data['players'] = _cl(players_el.get_text(' ', strip=True)) - - texts = [] - for d in players_el.find_all('div', recursive=False): - t = _cl(d.get_text()) - if t and t != ev_data['time']: - texts.append(t) - for p in players_el.find_all('p', recursive=False): - t = _cl(p.get_text()) - if t and t not in texts: - texts.append(t) - - if ev_data['type'] == 'substitution': - if len(texts) >= 2: - ev_data['player_out'] = texts[0]; ev_data['player_in'] = texts[1] - elif len(texts) == 1: - ev_data['player_in'] = texts[0] - elif ev_data['type'] == 'goal': - if len(texts) >= 1: ev_data['scorer'] = texts[0] - if len(texts) >= 2: ev_data['assist'] = texts[1] - elif ev_data['type'] in ('redcard', 'yellowcard'): - if texts: ev_data['player'] = ' '.join(texts) - - events.append(ev_data) - return events - - -def fetch_match_detail(event_id: int) -> dict: - import concurrent.futures - result = {"event_id": event_id, "found": False, "sections": []} - - html = None - base = f"https://bongda.com.vn/tran-dau/{event_id}" - urls = [base + suffix for suffix in ['/centre/', '/preview/', '/bao-cao-nhanh/']] - - # Try all URLs in parallel, take first success - with concurrent.futures.ThreadPoolExecutor(max_workers=3) as ex: - futures = {ex.submit(requests.get, url, headers=HEADERS, timeout=8, allow_redirects=True): url for url in urls} - for future in concurrent.futures.as_completed(futures, timeout=12): - try: - resp = future.result() - if resp.status_code == 200 and len(resp.text) > 1000: - html = resp.text - for f in futures: - f.cancel() - break - except Exception: - continue - - if not html: - return result - - sp = _mk(html) - info = {} - - tel = sp.select_one('.teams') - if tel: - he = tel.select_one('.team.home') or tel.select_one('[class*="home"]') - if he: - ne = he.select_one('p:not(.logo)') or he.find('p') - if ne: info['home_team'] = _cl(ne.get_text()) - lo = he.select_one('img') - if lo: info['home_logo'] = lo.get('src', '') - - ae = tel.select_one('.team.away') or tel.select_one('[class*="away"]') - if ae: - ne = ae.select_one('p:not(.logo)') or ae.find('p') - if ne: info['away_team'] = _cl(ne.get_text()) - lo = ae.select_one('img') - if lo: info['away_logo'] = lo.get('src', '') - - sc = tel.select_one('.score') - if sc: - parts = [_cl(p.get_text()) for p in sc.select('p')] - if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}" - lb = sc.select_one('.label') - if lb: info['status_label'] = _cl(lb.get_text()) - - if info.get('home_team') and info.get('away_team'): - result['info'] = info - result['found'] = True - result['sections'].append('info') - else: - return result - - mi = sp.select_one('.match-info') - if mi: - for sel in ['.times', 'li']: - el = mi.select_one(sel) - if el: - t = _cl(el.get_text()) - if t: info.setdefault('datetime', t); break - - events = parse_events(sp) - if events: - result['events'] = events - result['sections'].append('events') - - pred = sp.select_one('.prediction-card') - if pred: - pred_data = {} - team_info = pred.select_one('.team-info') - if team_info: - teams = team_info.select('.team') - if len(teams) >= 2: - pred_data['home_name'] = _cl(teams[0].select_one('.team-name').get_text()) if teams[0].select_one('.team-name') else '' - pred_data['away_name'] = _cl(teams[1].select_one('.team-name').get_text()) if teams[1].select_one('.team-name') else '' - divider = team_info.select_one('.divider') - if divider: pred_data['result'] = _cl(divider.get_text()) - vote_count = pred.select_one('.vote-count') - if vote_count: pred_data['vote_count'] = _cl(vote_count.get_text()) - result['prediction'] = pred_data - - try: - ar = requests.get( - f"https://bongda.com.vn/api/fixtures/h2h-stats?event_id={event_id}", - headers=API_HEADERS, timeout=6 - ) - if ar.status_code == 200: - ad = ar.json() - if ad.get('status') == 'success' and ad.get('html'): - asp = _mk(ad['html']) - ast = {} - for row in asp.select('li, tr, .stat-row'): - cells = row.select('td, span, p') - if len(cells) >= 3: - lb = _cl(cells[0].get_text()) - if lb: ast[lb] = {'home': _cl(cells[1].get_text()), 'away': _cl(cells[2].get_text())} - if ast: result['h2h_stats_parsed'] = ast; result['sections'].append('h2h_stats') - except Exception: - pass - - h2h_data = [] - h2h_el = sp.select_one('.h2h-standings') - if h2h_el: - rows = h2h_el.select('.ranking-table tbody tr, .leaderboard tr') - for row in rows: - cells = row.select('td') - if len(cells) >= 4: - logo = row.select_one('img') - name_el = row.select_one('.team-name, p.link, .name') - h2h_data.append({ - 'pos': _cl(cells[0].get_text()), - 'logo': logo.get('src', '') if logo else '', - 'name': _cl(name_el.get_text()) if name_el else '', - 'played': _cl(cells[1].get_text()) if len(cells) > 1 else '', - 'wins': _cl(cells[2].get_text()) if len(cells) > 2 else '', - 'draws': _cl(cells[3].get_text()) if len(cells) > 3 else '', - 'losses': _cl(cells[4].get_text()) if len(cells) > 4 else '', - 'gf': _cl(cells[5].get_text()) if len(cells) > 5 else '', - 'ga': _cl(cells[6].get_text()) if len(cells) > 6 else '', - 'points': _cl(cells[8].get_text()) if len(cells) > 8 else '', - }) - if h2h_data: result['h2h_standings'] = h2h_data; result['sections'].append('h2h_standings') - - recent_matches = [] - matches_list = sp.select_one('.matches-list') - if matches_list: - for item in matches_list.select('.match-detail, .match-item, li'): - date_el = item.select_one('.date, .time, .match-time') - league_el = item.select_one('.league') - home_el = item.select_one('.home, .team-home') - away_el = item.select_one('.away, .team-away') - score_el = item.select_one('.score, .result') - if home_el or away_el: - recent_matches.append({ - 'date': _cl(date_el.get_text()) if date_el else '', - 'league': _cl(league_el.get_text()) if league_el else '', - 'home': _cl(home_el.get_text()) if home_el else '', - 'away': _cl(away_el.get_text()) if away_el else '', - 'score': _cl(score_el.get_text()) if score_el else 'vs', - }) - if recent_matches: result['recent_matches'] = recent_matches; result['sections'].append('recent') - - return result - - -def fetch_match_detail_by_url(url: str) -> dict: - import concurrent.futures - eid_match = re.search(r'/tran-dau/(\d+)/', url) - if not eid_match: - return {"event_id": 0, "found": False, "error": "Cannot extract event_id from URL"} - event_id = int(eid_match.group(1)) - result = {"event_id": event_id, "found": False, "sections": []} - - html = None - try: - resp = requests.get(url, headers=HEADERS, timeout=8, allow_redirects=True) - if resp.status_code == 200 and len(resp.text) > 1000: - html = resp.text - except Exception: - pass - - if not html: - return fetch_match_detail(event_id) - - sp = _mk(html) - info = {} - - tel = sp.select_one('.teams') - if tel: - he = tel.select_one('.team.home') or tel.select_one('[class*="home"]') - if he: - ne = he.select_one('p:not(.logo)') or he.find('p') - if ne: info['home_team'] = _cl(ne.get_text()) - lo = he.select_one('img') - if lo: info['home_logo'] = lo.get('src', '') - ae = tel.select_one('.team.away') or tel.select_one('[class*="away"]') - if ae: - ne = ae.select_one('p:not(.logo)') or ae.find('p') - if ne: info['away_team'] = _cl(ne.get_text()) - lo = ae.select_one('img') - if lo: info['away_logo'] = lo.get('src', '') - sc = tel.select_one('.score') - if sc: - parts = [_cl(p.get_text()) for p in sc.select('p')] - if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}" - lb = sc.select_one('.label') - if lb: info['status_label'] = _cl(lb.get_text()) - - if info.get('home_team') and info.get('away_team'): - result['info'] = info; result['found'] = True; result['sections'].append('info') - else: - return fetch_match_detail(event_id) - - mi = sp.select_one('.match-info') - if mi: - te = mi.select_one('.times, li') - if te: info.setdefault('datetime', _cl(te.get_text())) - - events = parse_events(sp) - if events: result['events'] = events; result['sections'].append('events') - - pred = sp.select_one('.prediction-card') - if pred: - pred_data = {} - team_info = pred.select_one('.team-info') - if team_info: - teams = team_info.select('.team') - if len(teams) >= 2: - pred_data['home_name'] = _cl(teams[0].select_one('.team-name').get_text()) if teams[0].select_one('.team-name') else '' - pred_data['away_name'] = _cl(teams[1].select_one('.team-name').get_text()) if teams[1].select_one('.team-name') else '' - divider = team_info.select_one('.divider') - if divider: pred_data['result'] = _cl(divider.get_text()) - vote_count = pred.select_one('.vote-count') - if vote_count: pred_data['vote_count'] = _cl(vote_count.get_text()) - result['prediction'] = pred_data - - try: - ar = requests.get( - f"https://bongda.com.vn/api/fixtures/h2h-stats?event_id={event_id}", - headers=API_HEADERS, timeout=6 - ) - if ar.status_code == 200: - ad = ar.json() - if ad.get('status') == 'success' and ad.get('html'): - asp = _mk(ad['html']) - ast = {} - for row in asp.select('li, tr, .stat-row'): - cells = row.select('td, span, p') - if len(cells) >= 3: - lb = _cl(cells[0].get_text()) - if lb: ast[lb] = {'home': _cl(cells[1].get_text()), 'away': _cl(cells[2].get_text())} - if ast: result['h2h_stats_parsed'] = ast; result['sections'].append('h2h_stats') - except Exception: - pass - - h2h_data = [] - h2h_el = sp.select_one('.h2h-standings') - if h2h_el: - rows = h2h_el.select('.ranking-table tbody tr, .leaderboard tr') - for row in rows: - cells = row.select('td') - if len(cells) >= 4: - logo = row.select_one('img') - name_el = row.select_one('.team-name, p.link, .name') - h2h_data.append({ - 'pos': _cl(cells[0].get_text()), - 'logo': logo.get('src', '') if logo else '', - 'name': _cl(name_el.get_text()) if name_el else '', - 'played': _cl(cells[1].get_text()) if len(cells) > 1 else '', - 'wins': _cl(cells[2].get_text()) if len(cells) > 2 else '', - 'draws': _cl(cells[3].get_text()) if len(cells) > 3 else '', - 'losses': _cl(cells[4].get_text()) if len(cells) > 4 else '', - 'gf': _cl(cells[5].get_text()) if len(cells) > 5 else '', - 'ga': _cl(cells[6].get_text()) if len(cells) > 6 else '', - 'points': _cl(cells[8].get_text()) if len(cells) > 8 else '', - }) - if h2h_data: result['h2h_standings'] = h2h_data; result['sections'].append('h2h_standings') - - recent_matches = [] - matches_list = sp.select_one('.matches-list') - if matches_list: - for item in matches_list.select('.match-detail, .match-item, li'): - date_el = item.select_one('.date, .time, .match-time') - league_el = item.select_one('.league') - home_el = item.select_one('.home, .team-home') - away_el = item.select_one('.away, .team-away') - score_el = item.select_one('.score, .result') - if home_el or away_el: - recent_matches.append({ - 'date': _cl(date_el.get_text()) if date_el else '', - 'league': _cl(league_el.get_text()) if league_el else '', - 'home': _cl(home_el.get_text()) if home_el else '', - 'away': _cl(away_el.get_text()) if away_el else '', - 'score': _cl(score_el.get_text()) if score_el else 'vs', - }) - if recent_matches: result['recent_matches'] = recent_matches; result['sections'].append('recent') - - return result diff --git a/patch_ai_hot.py b/patch_ai_hot.py deleted file mode 100644 index b5e7e0b6d8708e76b41075e33ebdd206afa72773..0000000000000000000000000000000000000000 --- a/patch_ai_hot.py +++ /dev/null @@ -1,55 +0,0 @@ -"""PATCH AI: prepend AI topics to hot list + homepage route fix""" -import re, json, time -from fastapi.responses import HTMLResponse - -# Import at runtime to avoid circular -try: - from main import app, rt - import ai_runtime_final6 as f6 - from ai_runtime_final6 import f5 -except: - f6, f5, rt = None, None, None - -# Patch hot_topics to prepend AI topics -if f6 and hasattr(f6, '_HOT_CACHE') and hasattr(f6, '_hot_topics'): - _orig_hot = f6._hot_topics - def _hot_topics_patched(): - topics = _orig_hot() - # Prepend AI topics to front - for ai in ['Công nghệ AI', 'World Cup 2026', 'Kinh tế Việt Nam']: - if not any(ai.lower() == t.get('topic','').lower() for t in topics): - topics.insert(0, {'label': f'#{ai.replace(" ", "")}', 'topic': ai, 'count': 0}) - return topics[:24] - f6._hot_topics = f6._HOT_CACHE['d'] = _hot_topics_patched() - f6._HOT_CACHE['t'] = time.time() - -PATCH_INJECT = r''' - -''' - -# Register homepage route -if app and f5 and f6: - # Remove old / route - 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 patch_homepage(): - html = f5.f4.f3.f2.f1._load_index_html() if f5 else "" - body = (getattr(rt.old,'PATCH_INJECT','') if hasattr(rt,'old') else '') + \ - (getattr(f5.f4.f3.f2.f1,'FINAL_INJECT','') if f5 else '') + \ - (getattr(f5.f4.f3,'FINAL3_INJECT','') if f5 else '') + \ - (getattr(f5.f4,'FINAL4_INJECT','') if f5 else '') + \ - (getattr(f5,'FINAL5_INJECT','') if f5 else '') + \ - (getattr(f6,'FINAL6_INJECT','') or '') + \ - (getattr(f6,'FINAL6_FAST_HOME_INJECT','') or '') + \ - (getattr(f6,'FINAL6E_INJECT','') or '') + \ - PATCH_INJECT - if '' in html: - html = html.replace('', body + '\n') - else: - html += body - return HTMLResponse(html) \ No newline at end of file diff --git a/patch_extra.py b/patch_extra.py index c3e248bccababe223379d74821445217275dc3db..7e536b6358e6e9c1720f33d2605a750a069dc3e1 100644 --- a/patch_extra.py +++ b/patch_extra.py @@ -35,7 +35,7 @@ window.makeShortFromPost=async function(pid,btn){ var r=await fetch('/api/ai/short/'+pid,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:'nu',emotion:'neutral',speed:1.2})}); var j=await r.json(); if(!r.ok||j.error)throw new Error(j.error||'Lỗi'); - showShortProgress('✅ Đ�ã tạo Short AI!'); + showShortProgress('✅ Đã tạo Short AI!'); setTimeout(hideShortProgress,3000); if(typeof renderShortAISlide==='function')renderShortAISlide(); }catch(e){ diff --git a/piped_client.py b/piped_client.py deleted file mode 100644 index 4ca3b72dcf611e08c0fe4da0ee55ea0f4a1e0a10..0000000000000000000000000000000000000000 --- a/piped_client.py +++ /dev/null @@ -1,258 +0,0 @@ -""" -YouTube Shorts Scraper using Piped API -Piped is a privacy-friendly YouTube proxy that works without JS -""" -import requests -import json -import time -import threading - -_cache = {} -_lock = threading.Lock() -CACHE_TTL = 900 # 15 min - -# Piped API instances (public) -PIPED_INSTANCES = [ - "https://pipedapi.kavin.rocks", - "https://pipedapi.adminforge.de", - "https://api.piped.projectsegfau.lt", -] - -UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"} - -def _cached(key): - with _lock: - if key in _cache and time.time() - _cache[key]['t'] < CACHE_TTL: - return _cache[key]['d'] - return None - -def _set_cache(key, data): - with _lock: - _cache[key] = {'t': time.time(), 'd': data} - -def _piped_request(path, params=None): - """Try multiple Piped instances""" - last_err = None - for base in PIPED_INSTANCES: - try: - url = f"{base}{path}" - r = requests.get(url, params=params, headers=UA, timeout=15) - if r.status_code == 200: - return r.json() - except Exception as e: - last_err = e - continue - raise Exception(f"All Piped instances failed: {last_err}") - -def get_channel_videos(channel_id, max_videos=200): - """Get all videos from a channel using Piped API with pagination""" - cached = _cached(f'ch_vids_{channel_id}') - if cached is not None: - return cached - - all_videos = [] - page = None - - while len(all_videos) < max_videos: - try: - if page: - data = _piped_request(f"/channels/{channel_id}/videos", {"nextpage": page}) - else: - data = _piped_request(f"/channels/{channel_id}/videos") - - videos = data.get('relatedStreams', []) - if not videos: - break - - all_videos.extend(videos) - - # Check for next page - next_page = data.get('nextpage') - if not next_page or next_page == page: - break - page = next_page - - # Small delay to be polite - time.sleep(0.3) - - if len(all_videos) >= max_videos: - break - - except Exception as e: - print(f"Piped pagination error: {e}") - break - - result = all_videos[:max_videos] - _set_cache(f'ch_vids_{channel_id}', result) - return result - -def get_vtvnambo_shorts_piped(max_count=50): - """Get shorts from VTV Nam Bộ using Piped API""" - # VTV Nam Bộ channel ID - channel_id = "UCJ0btJV8qh7J7R2aXb9GmGA" - - try: - videos = get_channel_videos(channel_id, 200) - - shorts = [] - for v in videos: - title = v.get('title', '') - vid = v.get('url', '').replace('/watch?v=', '') - if not vid: - continue - - # Filter for shorts: title has #shorts, or duration <= 60s - duration = v.get('duration', 0) - is_short = ( - '#shorts' in title.lower() or - '#short' in title.lower() or - (duration > 0 and duration <= 60) - ) - - if is_short: - shorts.append({ - 'id': vid, - 'title': title, - 'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg", - 'channel': 'vtvnambo', - }) - - if shorts: - return shorts[:max_count] - except Exception as e: - print(f"Piped shorts error: {e}") - - return [] - -def get_vtvnambo_shorts_rss(max_count=50): - """Get shorts from YouTube RSS feed""" - cached = _cached('vtvnambo_rss') - if cached is not None: - return cached - - from xml.etree import ElementTree as ET - - # First get channel ID from page - channel_id = None - try: - r = requests.get("https://www.youtube.com/@vtvnambo", headers=UA, timeout=15) - if r.status_code == 200: - m = re.search(r'"channelId":"(UC[^"]+)"', r.text) - if m: - channel_id = m.group(1) - except: - pass - - if not channel_id: - channel_id = "UCJ0btJV8qh7J7R2aXb9GmGA" # fallback - - try: - url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}" - r = requests.get(url, headers=UA, timeout=15) - if r.status_code != 200: - return [] - - root = ET.fromstring(r.text) - ns = {'atom': 'http://www.w3.org/2005/Atom', 'yt': 'http://www.youtube.com/xml/schemas/2015'} - - shorts = [] - for entry in root.findall('atom:entry', ns)[:max_count * 2]: - title_el = entry.find('atom:title', ns) - title = title_el.text if title_el is not None and title_el.text else '' - - vid_el = entry.find('yt:videoId', ns) - vid = vid_el.text if vid_el is not None else '' - if not vid: - continue - - is_short = '#shorts' in title.lower() or '#short' in title.lower() - link_el = entry.find('atom:link', ns) - link = link_el.get('href', '') if link_el is not None else '' - if '/shorts/' in link: - is_short = True - - if is_short: - shorts.append({ - 'id': vid, - 'title': title, - 'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg", - 'channel': 'vtvnambo', - }) - - _set_cache('vtvnambo_rss', shorts[:max_count]) - return shorts[:max_count] - except Exception as e: - print(f"RSS error: {e}") - - return [] - -def get_vtvnambo_shorts(max_count=50): - """Get all shorts from VTV Nam Bộ. Tries Piped API first, then RSS.""" - cached = _cached('vtvnambo_shorts_v3') - if cached is not None: - return cached - - all_shorts = [] - seen_ids = set() - - # Method 1: Piped API (most reliable) - try: - piped_shorts = get_vtvnambo_shorts_piped(max_count) - for s in piped_shorts: - if s['id'] not in seen_ids: - seen_ids.add(s['id']) - all_shorts.append(s) - print(f"Piped API found {len(piped_shorts)} shorts") - except Exception as e: - print(f"Piped method failed: {e}") - - # Method 2: RSS feed - if len(all_shorts) < 3: - try: - rss_shorts = get_vtvnambo_shorts_rss(max_count) - for s in rss_shorts: - if s['id'] not in seen_ids: - seen_ids.add(s['id']) - all_shorts.append(s) - print(f"RSS found {len(rss_shorts)} shorts") - except Exception as e: - print(f"RSS method failed: {e}") - - result = all_shorts[:max_count] - _set_cache('vtvnambo_shorts_v3', result) - return result - -def get_wc_related_shorts(max_count=30): - """Get World Cup / football related shorts.""" - all_shorts = get_vtvnambo_shorts(max_count * 3) - - wc_kws = [ - 'world cup', 'wc 2026', 'worldcup', 'fifa', 'bóng đá', - 'trận đấu', 'đội tuyển', 'tuyển', 'vòng loại', - 'khoảnh khắc', 'highlights', 'bàn thắng', 'goal', - 'kết quả', 'tỉ số', 'việt nam', 'vn', - 'ngoại hạng', 'premier league', 'champions league', - 'laliga', 'serie a', 'bundesliga', 'ligue 1', - 'copa', 'europa', 'c1', 'c2', - 'messi', 'ronaldo', 'neymar', 'mbappe', 'haaland', - 'v-league', 'vleague', 'bóng đá việt', - 'đội bóng', 'hlv', 'huấn luyện viên', - 'chuyển nhượng', 'transfer', - 'asian cup', 'aff cup', 'sea games', - 'olympic', 'u23', 'u20', 'u17', - ] - - wc_shorts = [] - for s in all_shorts: - tl = s.get('title', '').lower() - if any(k in tl for k in wc_kws): - wc_shorts.append(s) - - if not wc_shorts: - wc_shorts = all_shorts - - return wc_shorts[:max_count] - -import re -# Alias for backward compatibility -get_vtvnamo_shorts = get_vtvnambo_shorts diff --git a/rebuild3.md b/rebuild3.md deleted file mode 100644 index 5b52fc9f825e7d7997f32173b23d1b1f0aed0156..0000000000000000000000000000000000000000 --- a/rebuild3.md +++ /dev/null @@ -1 +0,0 @@ -rebuild \ No newline at end of file diff --git a/rebuild_trigger.txt b/rebuild_trigger.txt deleted file mode 100644 index 104d0767f8deab294a133206b057ba95ecaa43d7..0000000000000000000000000000000000000000 --- a/rebuild_trigger.txt +++ /dev/null @@ -1 +0,0 @@ -M3U CSS deploy: Fri Aug 28 23:38:32 UTC 2026 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 5a7dc8fa59e562b34baac2b7323dc7cb06ae18a8..0000000000000000000000000000000000000000 --- a/requirements.txt +++ /dev/null @@ -1,16 +0,0 @@ -fastapi -uvicorn -requests -beautifulsoup4>=4.12.0 -lxml -jinja2 -yt-dlp -huggingface_hub -gTTS -pillow -edge-tts -python-dateutil -httpx -python-multipart -pycryptodome -# trigger rebuild 1784359130 diff --git a/restart.txt b/restart.txt deleted file mode 100644 index 31776977662f5a5d1c2483dbe85d673686a539b3..0000000000000000000000000000000000000000 --- a/restart.txt +++ /dev/null @@ -1 +0,0 @@ -rebuild 1787553359 diff --git a/restart2.md b/restart2.md deleted file mode 100644 index 1ad28c637f7615a09045aa79531232266712cc28..0000000000000000000000000000000000000000 --- a/restart2.md +++ /dev/null @@ -1 +0,0 @@ -restart with _run.py fix \ No newline at end of file diff --git a/restart_trigger.md b/restart_trigger.md deleted file mode 100644 index 7c200e23b9f8292a8309bc73fd0a5980e138b73a..0000000000000000000000000000000000000000 --- a/restart_trigger.md +++ /dev/null @@ -1,14 +0,0 @@ -# VNEWS Space Restart Trigger - -## Version: v20260822d - -### All fixes applied: -1. **Scrolling fix**: `-webkit-overflow-scrolling: touch` + `scroll-behavior: smooth` on `.tiktok-feed` -2. **Thumbnail fix**: `.yt-thumb-wrap` with background image for YouTube iframes, YouTube hqdefault fallback -3. **Lazy loading**: IntersectionObserver to unload slides far from viewport -4. **SHORT HOT video looping**: Video plays at full duration instead of being trimmed to narration length -5. **CSS consistency**: Same poster/thumbnail styling for all TikTok slides -6. **Homepage loading**: Retry logic for `scrape_vne`, bongdaplus.vn as primary source, faster xemlaibongda timeout -7. **YouTube SSL error**: Increased yt-dlp retries (5), socket_timeout 30s, YouTube embed fallback - -Last restart: 20260822d diff --git a/rewrite_fix_v2.js b/rewrite_fix_v2.js deleted file mode 100644 index 488520ab2edc21927c2ef5b49f0839b01c13ff63..0000000000000000000000000000000000000000 --- a/rewrite_fix_v2.js +++ /dev/null @@ -1,2 +0,0 @@ -// No-op - all functionality built into app_v2.js -(function(){})(); diff --git a/rewrite_slide.py b/rewrite_slide.py deleted file mode 100644 index b86340c3e987415f5d233fccbb761c14b2223285..0000000000000000000000000000000000000000 --- a/rewrite_slide.py +++ /dev/null @@ -1,204 +0,0 @@ -"""Fast rewrite as slides - no AI needed, extracts key points + images from article.""" -from main import app -from fastapi import Request -from fastapi.responses import JSONResponse -import requests, re, time, random, json, os -from bs4 import BeautifulSoup -from urllib.parse import quote - -UA = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept-Language': 'vi-VN,vi;q=0.9'} - -try: - from main import _load_wall, _save_wall -except: - _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: pass - return [] - def _save_wall(posts): - try: - os.makedirs(os.path.dirname(_wall_file), exist_ok=True) - with open(_wall_file+'.tmp', 'w', encoding='utf-8') as f: json.dump(posts[:100], f, ensure_ascii=False) - os.replace(_wall_file+'.tmp', _wall_file) - except: pass - - -def _clean(s): return re.sub(r'\s+', ' ', str(s or '')).strip() - - -def _scrape_article_full(url): - """Scrape article: extract paragraphs + ALL images.""" - try: - r = requests.get(url, headers=UA, timeout=15, allow_redirects=True) - r.encoding = 'utf-8' - soup = BeautifulSoup(r.text, 'lxml') - for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']): tag.decompose() - - # 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 '') - - # OG image - ogi = soup.find('meta', property='og:image') - og_img = ogi.get('content', '') if ogi else '' - if og_img and og_img.startswith('//'): og_img = 'https:' + og_img - - # Find content block - 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 paragraphs and images IN ORDER - paragraphs = [] - images = [] - seen_imgs = set() - - if og_img and og_img not in seen_imgs: - images.append(og_img) - seen_imgs.add(og_img) - - for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True): - if el.name == 'p': - t = _clean(el.get_text(strip=True)) - if t and len(t) > 40: - paragraphs.append(t) - elif el.name in ('figure', 'img'): - 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 - if src not in seen_imgs: - images.append(src) - seen_imgs.add(src) - - return {'title': _clean(title), 'paragraphs': paragraphs, 'images': images, 'og_img': og_img} - except Exception as e: - return None - - -def _ensure_complete_sentence(text): - """Ensure text ends with complete sentence ending.""" - text = _clean(text) - if not text: - return text - # Find last complete sentence ending - for end_char in ['.', '!', '?']: - last_pos = text.rfind(end_char) - if last_pos > len(text) * 0.5: # Keep if ending is in latter half - return text[:last_pos + 1].strip() - return text - -def _extract_key_points(paragraphs, max_points=5): - """Extract key points: take first COMPLETE sentence of each significant paragraph.""" - points = [] - for p in paragraphs: - if len(points) >= max_points: break - # Find ALL sentence endings and take the first complete sentence - # that ends with . ! or ? - sentences = re.split(r'(?<=[.!?])\s+', p) - sentence = "" - for s in sentences: - s = _clean(s) - if len(s) >= 30 and re.search(r'[.!?]$', s): - sentence = s - break - - # If no complete sentence found, take first sentence and ensure it ends - if not sentence and sentences: - sentence = _ensure_complete_sentence(sentences[0]) - - # Skip if too short or duplicate - if len(sentence) < 30: continue - if any(sentence[:50] in existing for existing in points): continue - - points.append(sentence) - - return points - - -@app.post("/api/rewrite_slide") -async def api_rewrite_slide(request: Request): - """ - Fast rewrite as SLIDES: - - Extract key points from article (1 sentence each, full and complete) - - Pair each point with an image from the article - - Return as slides array for frontend to display - - Save to Tường AI - NO AI NEEDED - instant response. - """ - body = await request.json() - url = _clean(body.get("url", "")) - context = body.get("context", "") - - if not url and not context: - return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400) - - # Scrape article - data = None - if url and url.startswith("http"): - data = _scrape_article_full(url) - - if not data and context: - # Use context passed from frontend - paragraphs = [_clean(p) for p in context.split('\n') if len(_clean(p)) > 40] - data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''} - - if not data or not data.get('paragraphs'): - return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422) - - # Extract key points - points = _extract_key_points(data['paragraphs'], max_points=6) - if not points: - return JSONResponse({"error": "Không tìm được ý chính"}, status_code=422) - - # Build slides: pair each point with an image - images = data.get('images', []) - slides = [] - for i, point in enumerate(points): - img = images[i] if i < len(images) else (images[-1] if images else '') - # Proxy dantri images - if img and 'cdnphoto.dantri' in img: - img = '/api/proxy/img?url=' + quote(img, safe='') - slides.append({ - 'text': point, - 'image': img, - 'index': i + 1 - }) - - # Create post for Tường AI - summary_text = '\n\n'.join([f"• {s['text']}" for s in slides]) - # Auto voice + emotion based on topic (reuse ai_ext detector if available) - try: - from ai_ext import _detect_voice_emotion - _voice, _emotion = _detect_voice_emotion(data['title'], summary_text) - except Exception: - _voice, _emotion = "hoaimy", "trung_tinh" - post = { - "id": str(int(time.time() * 1000)) + str(random.randint(100, 999)), - "title": data['title'], - "text": summary_text, - "img": images[0] if images else '', - "url": url, - "kind": "slide_summary", - "slides": slides, - "images": images[:10], - "video": "", - "voice": _voice, - "emotion": _emotion, - "ts": int(time.time()) - } - - # Save to wall - posts = _load_wall() - posts.insert(0, post) - _save_wall(posts) - - return JSONResponse({"post": post, "slides": slides}) diff --git a/runtime.txt b/runtime.txt deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/shorts_cache.py b/shorts_cache.py deleted file mode 100644 index 6ed6bd22884712258a650fe68133d40d74628875..0000000000000000000000000000000000000000 --- a/shorts_cache.py +++ /dev/null @@ -1,86 +0,0 @@ -""" -VNEWS Shorts Runtime Cache - External Updater Module -GitHub Actions fetches YouTube shorts via yt-dlp -> POST to /api/shorts/update -Space saves to RAM cache + persistent file if /data available -""" -import os -import json -import time -import threading - -# Runtime cache (RAM) -_shorts_runtime_cache = None -_shorts_cache_ts = 0 -_shorts_cache_lock = threading.Lock() - -# Secret for authenticating update requests -SHORTS_UPDATE_SECRET = os.environ.get("SHORTS_UPDATE_SECRET", "vnews-shorts-2026") - -# Paths -SHORTS_CACHE_FILE = "/data/shorts_runtime_cache.json" if os.path.isdir("/data") else "/app/shorts_runtime_cache.json" - - -def get_runtime_cache(): - """Get cached shorts (from RAM or file fallback)""" - global _shorts_runtime_cache, _shorts_cache_ts - with _shorts_cache_lock: - if _shorts_runtime_cache is not None: - age = time.time() - _shorts_cache_ts - if age < 7200: # 2h fresh - return _shorts_runtime_cache - - # Try file fallback - try: - if os.path.exists(SHORTS_CACHE_FILE): - with open(SHORTS_CACHE_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - age = time.time() - data.get("ts", 0) - if age < 86400: # 24h stale limit - items = data.get("items", []) - with _shorts_cache_lock: - _shorts_runtime_cache = items - _shorts_cache_ts = data.get("ts", time.time()) - return items - except Exception as e: - print(f"[cache] read error: {e}") - - return None - - -def set_runtime_cache(items): - """Update runtime cache from external data""" - global _shorts_runtime_cache, _shorts_cache_ts - ts = time.time() - with _shorts_cache_lock: - _shorts_runtime_cache = items - _shorts_cache_ts = ts - - # Also write to file (persistent if /data mounted) - try: - os.makedirs(os.path.dirname(SHORTS_CACHE_FILE), exist_ok=True) - payload = {"items": items, "ts": ts, "count": len(items)} - with open(SHORTS_CACHE_FILE, "w", encoding="utf-8") as f: - json.dump(payload, f, ensure_ascii=False, indent=2) - print(f"[cache] saved {len(items)} shorts to {SHORTS_CACHE_FILE}") - except Exception as e: - print(f"[cache] write skipped: {e}") - - return len(items) - - -def get_cache_status(): - """Return status dict for the cache""" - cache = None - with _shorts_cache_lock: - if _shorts_runtime_cache is not None: - cache = _shorts_runtime_cache - age = int(time.time() - _shorts_cache_ts) - else: - age = -1 - return { - "cached": cache is not None, - "count": len(cache) if cache else 0, - "age_seconds": age, - "has_persistent": os.path.isdir("/data"), - "cache_file_exists": os.path.exists(SHORTS_CACHE_FILE), - } diff --git a/shorts_rss_proxy.py b/shorts_rss_proxy.py deleted file mode 100644 index 657d7c8a1c4a792448440632e132d3691f3dabea..0000000000000000000000000000000000000000 --- a/shorts_rss_proxy.py +++ /dev/null @@ -1,114 +0,0 @@ -""" -YouTube RSS Proxy - Fetches YouTube channel RSS feeds server-side -Avoids CORS issues when client tries to fetch YouTube directly -""" -import requests as req -from fastapi import Query -from fastapi.responses import Response - -HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"} - -YOUTUBE_CHANNELS = { - "baodantri7941": "UC_x5TKhOgd6GhYvv5z4I3jg", - "baosuckhoedoisongboyte": "UCBsY5fXTQLkF_JnH9kLkL4g", -} - -def setup_rss_proxy(app): - """Add RSS proxy endpoints to the FastAPI app""" - - @app.get("/api/proxy/rss") - def proxy_rss(url: str = Query(...)): - """Proxy YouTube RSS feed to avoid CORS""" - try: - r = req.get(url, headers=HEADERS, timeout=15) - if r.status_code == 200: - return Response( - content=r.content, - media_type="application/xml", - headers={"Access-Control-Allow-Origin": "*"} - ) - return Response(status_code=r.status_code) - except Exception as e: - return Response(status_code=502, content=str(e)) - - @app.get("/api/shorts/rss") - def shorts_via_rss(): - """Get shorts from YouTube RSS feeds server-side""" - import xml.etree.ElementTree as ET - import html as html_lib - import re - - shorts = [] - seen = set() - - for handle, channel_id in YOUTUBE_CHANNELS.items(): - try: - rss_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}" - r = req.get(rss_url, headers=HEADERS, timeout=15) - if r.status_code != 200: - continue - - root = ET.fromstring(r.text) - ns = { - 'atom': 'http://www.w3.org/2005/Atom', - 'yt': 'http://www.youtube.com/xml/schemas/2015', - 'media': 'http://search.yahoo.com/mrss/' - } - - for entry in root.findall('atom:entry', ns)[:30]: - title_el = entry.find('atom:title', ns) - title = html_lib.unescape(title_el.text) if title_el is not None and title_el.text else '' - - link_el = entry.find('atom:link', ns) - link = link_el.get('href', '') if link_el is not None else '' - - vid_el = entry.find('yt:videoId', ns) - vid = vid_el.text if vid_el is not None else '' - - if not vid: - m = re.search(r'(?:v=|shorts/)([A-Za-z0-9_-]{11})', link) - if m: - vid = m.group(1) - - if not vid or vid in seen: - continue - - # Check if it's a short - is_short = '#shorts' in title.lower() or '#short' in title.lower() or '/shorts/' in link - - if not is_short: - desc_el = entry.find('media:description', ns) - if desc_el is not None and desc_el.text: - if '#shorts' in desc_el.text.lower(): - is_short = True - - if not is_short: - continue - - seen.add(vid) - - # Get thumbnail - thumb = f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg" - media_group = entry.find('media:group', ns) - if media_group is not None: - thumb_el = media_group.find('media:thumbnail', ns) - if thumb_el is not None: - thumb = thumb_el.get('url', thumb) - - shorts.append({ - 'id': vid, - 'title': title.replace('#shorts', '').replace('#short', '').strip()[:120], - 'img': thumb, - 'link': f'https://www.youtube.com/shorts/{vid}', - 'channel': handle, - 'source': 'yt' - }) - - if len(shorts) >= 40: - break - - except Exception as e: - print(f"RSS error for {handle}: {e}") - continue - - return {"shorts": shorts, "count": len(shorts)} diff --git a/static/app_v2.js b/static/app_v2.js index 6785fdab52f27c5e41952d1a68c6b2334ba2627c..1fe4d4ea54ee49a604dd00d3c7dc3cb3265ae37b 100644 --- a/static/app_v2.js +++ b/static/app_v2.js @@ -1,2401 +1,316 @@ -// VNEWS v17.1 - FPT Play slider removed from homepage -/** - * VNEWS Frontend v2 - Shorts Dantri/SKDS removed, VTV Digital CDN - * v2.8 - Changed short AI feed video share button icon (📥) to distinguish from article share (📤) - * v2.6 - Fixed share links: doShare now includes post_id for wall posts, - * readSlidePost has share button, /s + /s/{slug} render slides/video inline - * v2.5 - Added 'Tạo lại' button on wall cards with video - * v2.4 - Fixed: prependWallPost detached-element bug, makeShortVideo UI update, slide viewer for rewrite posts - */ -// ---- World Cup 2026 tab switcher (was missing -> loadHome() crashed) ---- -function switchWCTab(tab){ - try { - var el = document.getElementById('wc-content'); - if(!el) return; - var d = (typeof _wc2026Data !== 'undefined') ? _wc2026Data : null; - if(!d){ el.innerHTML = '
Không có dữ liệu World Cup 2026
'; return; } - if(tab === 'fixtures'){ - var m = (d.fixtures && d.fixtures.matches) || []; - var h = '
'; - m.slice(0, 40).forEach(function(x){ - h += '
'+esc(x.group||'')+''+esc(x.home||'?')+' '+esc(x.score||'VS')+' '+esc(x.away||'?')+''+esc(x.date_vn||'')+'
'; - }); - h += '
'; - el.innerHTML = m.length ? h : '
Chưa có lịch thi đấu
'; - } else if(tab === 'standings'){ - el.innerHTML = '
'+esc((d.standings && (typeof d.standings==='string'?d.standings:JSON.stringify(d.standings)))||'Bảng xếp hạng đang cập nhật')+'
'; - } else if(tab === 'highlights'){ - el.innerHTML = '
'+esc((d.highlights && (typeof d.highlights==='string'?d.highlights:JSON.stringify(d.highlights)))||'Highlight đang cập nhật')+'
'; - } else if(tab === 'stats'){ - el.innerHTML = '
'+esc((d.stats && (typeof d.stats==='string'?d.stats:JSON.stringify(d.stats)))||'Thống kê đang cập nhật')+'
'; - } else { - var news = (d.news) || []; - var nh = '
'; - (Array.isArray(news)?news:[]).slice(0,15).forEach(function(n){ - nh += ''; - }); - nh += '
'; - el.innerHTML = Array.isArray(news)&&news.length ? nh : '
Đang tải tin World Cup 2026...
'; - } - // update active tab styling - try { - document.querySelectorAll('.wc-tab').forEach(function(t){ - var want = (tab==='news') ? 'news' : tab; - t.classList.toggle('active', t.getAttribute('onclick') && t.getAttribute('onclick').indexOf("'"+want+"'")>=0); - }); - } catch(e){} - } catch(e){ - var el2 = document.getElementById('wc-content'); - if(el2) el2.innerHTML = '
Lỗi tải World Cup 2026
'; - } -} -function _proxyImg(url){ - if(!url || typeof url !== 'string') return ''; - if(url.startsWith('http') && !url.includes(location.host)){ - return '/api/proxy/img?url='+encodeURIComponent(url); - } - return url; -} - -var _ttsSelections = {}; - -function _fetchWithTimeout(url, ms){ - return new Promise((resolve,reject)=>{ - const ctrl=new AbortController(); - const tid=setTimeout(()=>ctrl.abort(),ms); - fetch(url,{signal:ctrl.signal}).then(r=>{ - clearTimeout(tid); - if(!r.ok) return reject(new Error('HTTP '+r.status)); - return r.json(); - }).then(d=>resolve(d)).catch(e=>{clearTimeout(tid);reject(e);}); - }); -} - -// ===== rewriteUrl: tạo bài rewrite slide AI từ URL bài viết ===== -// Gọi /api/url_wall (backend thực tế), lấy post rồi đẩy lên Tường AI và mở slide viewer. -async function rewriteUrl(){ - const inp = document.getElementById('url-input'); - const url = (inp && inp.value || '').trim(); - if(!url){ alert('Vui lòng dán URL bài viết'); return; } - if(!/^https?:\/\//i.test(url)){ alert('URL cần bắt đầu bằng http:// hoặc https://'); return; } - let btn = null; - try { btn = inp && inp.parentElement ? inp.parentElement.querySelector('button') : null; } catch(e){} - const orig = btn ? btn.textContent : 'Rewrite'; - if(btn){ btn.disabled = true; btn.textContent = '⏳ Đang tạo...'; } - toast('⏳ Đang tạo bài rewrite slide AI...'); - try { - const r = await fetch('/api/url_wall', { - method:'POST', - headers:{'Content-Type':'application/json'}, - body: JSON.stringify({url: url}) - }); - const j = await r.json(); - if(!r.ok || j.error) throw new Error(j.error || 'Lỗi tạo bài'); - if(!j.post) throw new Error('Không tạo được bài'); - if(typeof prependWallPost === 'function'){ prependWallPost(j.post); } - else if(Array.isArray(_wallPosts)){ _wallPosts.unshift(j.post); } - if(inp) inp.value = ''; - toast('✅ Đã tạo bài rewrite slide AI!'); - const idx = (_wallPosts || []).indexOf(j.post); - if(j.post.slides && j.post.slides.length){ readSlidePost(idx >= 0 ? idx : 0); } - else if(typeof readWallPost === 'function'){ readWallPost(idx >= 0 ? idx : 0); } - } catch(e){ - toast('❌ ' + e.message); - } finally { - if(btn){ btn.disabled = false; btn.textContent = orig; } - } -} - -// ===== shareVideoToApps: gửi file MP4 qua app (chia sẻ video) ===== -async function shareVideoToApps(videoUrl, title){ - if(!videoUrl){ toast('Không có video để chia sẻ'); return; } - const safeTitle = (title || 'VNEWS Short AI').toString().slice(0, 80); - if(navigator.canShare && navigator.canShare({files:[]}) !== undefined){ - try { - const resp = await fetch(videoUrl, {mode:'cors'}); - if(resp && resp.ok){ - const blob = await resp.blob(); - let fname = (videoUrl.split('?')[0].split('/').pop() || 'vnews-short.mp4'); - if(!fname.toLowerCase().endsWith('.mp4')) fname = 'vnews-short.mp4'; - const file = new File([blob], fname, {type: (blob.type && blob.type.indexOf('video')>=0) ? blob.type : 'video/mp4'}); - if(navigator.canShare && navigator.canShare({files:[file]})){ - await navigator.share({files:[file], title: safeTitle, text: safeTitle}); - return; - } - } - } catch(e){ /* fall through to link share */ } - } - const _base = (typeof SPACE !== 'undefined' && SPACE) ? SPACE : location.origin; - const shareUrl = _base + '/s?url=' + encodeURIComponent(videoUrl) + '&title=' + encodeURIComponent(safeTitle); - if(navigator.share){ - try { await navigator.share({title: safeTitle, text: safeTitle, url: shareUrl}); return; } catch(e){ if(e && e.name === 'AbortError') return; } - } - if(navigator.clipboard && navigator.clipboard.writeText){ - try { await navigator.clipboard.writeText(shareUrl); toast('📋 Đã sao chép link video!'); return; } catch(e){} - } - try { - const ta = document.createElement('textarea'); - ta.value = shareUrl; ta.style.position='fixed'; ta.style.left='-9999px'; ta.style.top='-9999px'; ta.style.opacity='0'; - document.body.appendChild(ta); ta.select(); ta.setSelectionRange(0, 99999); - if(document.execCommand('copy')){ toast('📋 Đã sao chép link video!'); } - else { prompt('📋 Sao chép link video:', shareUrl); } - document.body.removeChild(ta); - } catch(e){ prompt('📋 Sao chép link video:', shareUrl); } -} +// === VNEWS Frontend v2 - Full Functions === +// Updated: Rewrite fixes — better gallery, voice selector always shown, no-reload wall update +// === LOAD HOME === async function loadHome(){ - const homeEl = document.getElementById('view-home'); - if(!homeEl) return; - - homeEl.innerHTML = - '' - +'
🤖 AI viết bài
' - +'
' - +'
' - +'
' - +'

⚽ Livescore

📋 Cập nhật🔴 Live📅 Hôm nay⏰ Sắp tới✅ Kết quả🏆 NHA🏆 La Liga🏆 Serie A🏆 Bundesliga🏆 League 1
Đang tải...
' - +'

🏆 World Cup 2026

● LIVE
📰 Tin tức📅 Lịch thi đấu🏆 BXH🎬 Highlight📊 Thống kê
Đang tải World Cup 2026...
' - +'
' - +'
'; - - const afterEl = homeEl.querySelector('#home-after-wc'); - - loadLivescore('recent'); - loadHotTopics(); - - const [featuredData, wallData, hlLeagues, wcData] = await Promise.allSettled([ - _fetchWithTimeout('/api/livescore/featured', 5000), - _fetchWithTimeout('/api/wall', 5000), - _fetchWithTimeout('/api/highlights/leagues', 10000), - _fetchWithTimeout('/api/wc2026', 20000), - ]).then(results => results.map(r => r.status === 'fulfilled' ? r.value : null)); - - if(featuredData && featuredData.home){ - const sc=featuredData.status==='live'?'':'upcoming'; - const st=featuredData.status==='live'?`🔴 ${featuredData.minute||'LIVE'}`:`⏰ ${featuredData.time}`; - const area=document.getElementById('home-featured-area'); - if(area) area.innerHTML=``; - } - - _wallPosts = (wallData && wallData.posts) || []; - _hlLeagueData = hlLeagues || {}; - _wc2026Data = wcData; - - if(wcData) switchWCTab('news'); - - _renderHLSection(); - _fptStartAuto(); - - // === YOUTUBE RSS FEED (FPT Bóng Đá) — interleave latest videos onto Tường AI === - _ytFeedStartAuto(); - - _renderWallIn(afterEl); -} - -function _renderSlidesIn(key, label, emoji, vids, afterEl){ - if(!vids||!vids.length||!afterEl) return; - const wrap=document.createElement('div'); - wrap.className='slider-wrap'; - let h=`
${emoji} ${label}
`; - const isHL = key==='world-cup'||key==='premier-league'||key==='champions-league'||key==='la-liga'||key==='serie-a'||key==='bundesliga'||key==='friendly'; - vids.slice(0,isHL?8:12).forEach((a,i)=>{ - if(isHL){ - h+=`
${a.img?``:''}
${esc(a.title)}
`; - } else { - h+=`
${a.img?``:''}
${esc(a.title)}
`; - } - }); - h+='
'; - wrap.innerHTML=h; - afterEl.parentNode.insertBefore(wrap, afterEl); -} - -function _renderWallIn(afterEl){ - if(!_wallPosts||!_wallPosts.length) return; - const posts=_wallPosts; - const wrap=document.createElement('div'); - wrap.className='slider-wrap';wrap.id='ai-wall-wrap'; - let h='
🧱 Tường AI
'; - posts.slice(0,20).forEach((p,i)=>{h+=makeWallItem(p,i);}); - h+='
';wrap.innerHTML=h; - const target=document.getElementById('ai-wall-under-compose'); - if(target) target.appendChild(wrap); - else if(afterEl) afterEl.parentNode.insertBefore(wrap,afterEl); - // Interleave YouTube RSS feed videos into the wall with wall CSS - if(wrap && typeof _renderYTFeedInWall === 'function') _renderYTFeedInWall(); -} - -function _renderHLIn(afterEl){ - if(!_hlLeagueData||Object.keys(_hlLeagueData).length===0||!afterEl) return; + const[featured,sh,wall,hlLeagues,ai,wcData]=await Promise.all([ + fetch('/api/livescore/featured').then(r=>r.json()).catch(()=>null), + fetch('/api/shorts').then(r=>r.json()).catch(()=>[]), + fetch('/api/wall').then(r=>r.json()).catch(()=>({posts:[]})), + fetch('/api/highlights/leagues').then(r=>r.json()).catch(()=>({})), + fetch('/api/genk_ai').then(r=>r.json()).catch(()=>[]), + fetch('/api/wc2026').then(r=>r.json()).catch(()=>null) + ]); + _hlLeagueData=hlLeagues; + _wc2026Data=wcData; + _shortsData=interleaveShorts(sh||[]); + _wallPosts=(wall&&wall.posts)||[]; + let h=''; + if(featured&&featured.home){const sc=featured.status==='live'?'':'upcoming';const st=featured.status==='live'?`🔴 ${featured.minute||'LIVE'}`:`⏰ ${featured.time}`;h+=``;} + h+=`
🤖 AI viết bài
`; + h+='
'; + h+=`

⚽ Livescore

📅 Hôm nay🔴 Live⏰ Sắp tới✅ Kết quả🏆 NHA🏆 La Liga
Đang tải...
`; + h+=`

🏆 World Cup 2026

● LIVE
📰 Tin tức📅 Lịch thi đấu🏆 BXH🎬 Highlight📊 Thống kê
Đang tải World Cup 2026...
`; + const wallPosts=_wallPosts; + const aiShorts=wallPosts.filter(p=>p.video); + if(aiShorts.length){h+='
🎬 Short AI
';aiShorts.slice(0,20).forEach((p,i)=>{h+=`
${esc(p.title)}
`});h+='
';} + if(_shortsData.length){h+='
📱 Shorts Dân trí & SKĐSMới nhất · xen kẽ
';_shortsData.slice(0,30).forEach((a,i)=>{const badge=a.channel==='baosuckhoedoisongboyte'?'SKĐS':'Dân trí';h+=`
${a.img?``:''}
${badge} ${esc(a.title)}
`;});h+='
';} + if(wallPosts.length){h+=`
🧱 Tường AI
`;wallPosts.slice(0,20).forEach((p,i)=>{h+=makeWallItem(p,i)});h+='
';} const HL_CONFIG={"world-cup":{name:"World Cup 2026",emoji:"🌍"},"premier-league":{name:"Premier League",emoji:"🏴"},"champions-league":{name:"Champions League",emoji:"⭐"},"la-liga":{name:"La Liga",emoji:"🇪🇸"},"serie-a":{name:"Serie A",emoji:"🇮🇹"},"bundesliga":{name:"Bundesliga",emoji:"🇩🇪"},"friendly":{name:"Giao hữu",emoji:"🤝"}}; - for(const[key,cfg] of Object.entries(HL_CONFIG)){ - const vids=_hlLeagueData[key]; - if(!vids||!vids.length) continue; - _renderSlidesIn(key,cfg.name,cfg.emoji,vids,afterEl); - } -} - -// === RENDER HIGHLIGHT (Video bóng đá) SECTION — above Livescore, with "Xem thêm" per league === -var _hlPage = {}; // {leagueKey: page} -var _hlHasMore = {}; // {leagueKey: bool} - -function _renderHLSection(){ - const hlEl = document.getElementById('home-hl-section'); - if(!hlEl) return; - if(!_hlLeagueData || Object.keys(_hlLeagueData).length===0){ - return; - } - const HL_CONFIG={"premier-league":{name:"Ngoại Hạng Anh",emoji:"🏴"},"champions-league":{name:"Champions League",emoji:"⭐"},"la-liga":{name:"La Liga",emoji:"🇪🇸"},"serie-a":{name:"Serie A",emoji:"🇮🇹"},"bundesliga":{name:"Bundesliga",emoji:"🇩🇪"},"world-cup":{name:"World Cup 2026",emoji:"🌍"},"fa-cup":{name:"FA Cup",emoji:"🏆"},"friendly":{name:"Giao hữu",emoji:"🤝"}}; - - // Ordered list of leagues that have videos (premier-league first) - const orderedLeagues = []; - const plVids = _hlLeagueData['premier-league']; - if(plVids && plVids.length) orderedLeagues.push('premier-league'); - for(const [key, cfg] of Object.entries(HL_CONFIG)){ - if(key === 'premier-league') continue; - const vids = _hlLeagueData[key]; - if(vids && vids.length) orderedLeagues.push(key); - } - if(orderedLeagues.length === 0){ - return; // nothing to show - } - _hlLeagueKey = orderedLeagues; - - // Build ONE combined video list from ALL leagues (premier league items first). - // The scraped sources return the SAME videos for every league (the league - // sub-pages all resolve to the same content from the server), so combine - // then DEDUPE by link — otherwise the slider shows the same ~10 videos - // repeated N times and "loops back to video #1" after the unique ones. - const seenLinks = new Set(); - let combined = []; - orderedLeagues.forEach(key => { - const vids = _hlLeagueData[key] || []; - vids.forEach(a => { - if(!a || !a.link || seenLinks.has(a.link)) return; - seenLinks.add(a.link); - combined.push(Object.assign({}, a, {_league: key, _pos: combined.length})); - }); - }); - // Newest first: sort by publish date when known (the scrapers now return - // `published` parsed from the image/URL dates). Videos without a date keep - // their relative order and go last, so the slider always starts with the - // freshest clips. - combined.sort(function(x, y){ - const xd = x.published || ''; - const yd = y.published || ''; - if(xd && yd) return xd < yd ? 1 : (xd > yd ? -1 : 0); - if(xd && !yd) return -1; // dated first - if(!xd && yd) return 1; - return (x._pos||0) - (y._pos||0); // stable for undated - }); - _hlLeagueData['all'] = combined; - - // ONE unified "🎬 Video bóng đá" slider (per-league sliders removed). - // All videos render at once — no "Xem thêm" button needed (the paged API - // re-scrapes the same sources, so it adds nothing but duplicates). - let html = '
🎬 Video bóng đá' + combined.length + ' video
'; - html += '
'; - hlEl.innerHTML = html; - _renderHLSlides('all', 'Video bóng đá', '🎬', combined, 'hl-main-track'); + for(const[key,cfg] of Object.entries(HL_CONFIG)){const vids=hlLeagues[key];if(!vids||!vids.length)continue;h+=`
${cfg.emoji} ${cfg.name}
`;vids.slice(0,8).forEach((a,i)=>{h+=`
${a.img?``:''}
${esc(a.title)}
`});h+='
';} + if(ai&&ai.length){h+='
🤖 Ứng dụng AI
';ai.slice(0,12).forEach(a=>{h+=`
${a.img?``:''}
${esc(a.title)}
`});h+='
';} + document.getElementById('view-home').innerHTML=h; + loadLivescore('today');loadHotTopics(); + if(_wc2026Data)switchWCTab('news'); } -function _renderHLSlides(key, label, emoji, vids, containerId){ - const container = document.getElementById(containerId); - if(!container) return; - if(!vids || !vids.length){ - container.innerHTML = '
Chưa có video
'; - return; - } - // Render ALL football videos immediately (the dataset is small: ~70-80 - // items). No 8-item cap, no chunked lazy-load, no server paging on the - // homepage — every item gets its exact index into the vids array, so - // openHighlightFeed can never mismatch order and the slider can never - // "loop back to video #1". Images use native lazy-loading so only the - // visible thumbnails are fetched. - let h = ''; - vids.forEach((a, i) => { - h += _hlItemHtml(key, a, i); - }); - container.innerHTML = h; -} - -// All football videos are rendered at once from the full vids array, so the -// chunked lazy-load machinery is no longer needed and is removed to keep the -// track's indices simple and duplicate-free. - -function _hlItemHtml(key, a, i){ - return '
' + (a.img ? '' : '') + '
' + esc(a.title) + '
'; -} - -async function _loadMoreHL(key, auto){ - // Legacy: kept as a no-op safety net (the old inline button HTML may still - // be cached in some browsers; it simply appends nothing and hides itself). - const btn = typeof document !== 'undefined' ? document.querySelector('.hl-load-more') : null; - if(btn) btn.style.display = 'none'; - _hlHasMore[key] = false; -} - -// === FPT PLAY SLIDER (auto-updating channel) === -// Shows the latest videos from https://youtube.com/@fptbongdaofficial like the -// Short HOT oEmbed slides. The server can't reach youtube.com from its -// datacenter, so the browser (home IP) fetches the channel via r.jina.ai and -// pushes the result to /api/fptplay/update — the slide refreshes automatically -// when the channel uploads something new. -var _fptVideos = []; -var _fptTimer = null; -var _fptSeen = {}; - -function _fptParseJina(txt){ - // jina markdown of the /videos tab: each entry has a title line and a watch - // URL. Collect (title, videoId) in page order. - const out = []; - const titles = []; - const lines = String(txt||'').split('\n'); - for(let i=0;io.id===id)) title = titles.pop() || ''; - if(!title){ - // fall back to the line itself (sans url) - title = line.replace(/https?:\/\/\S+/g,'').replace(/[|\[\]]/g,'').trim(); - } - out.push({id:id, title:(title||'Video').slice(0,200), link:'https://youtu.be/'+id}); - } else if(/^\s*\d+\./.test(line)){ - const t = line.replace(/^\s*\d+\.\s+/,'').trim(); - if(t && t.length>4 && !/youtube\.com|youtu\.be/i.test(t)) titles.push(t); - } - } - return out; -} - -async function _fptFetchClient(){ - // Browser-side: user's home IP can reach r.jina.ai (same trick as Short HOT - // description). Returns array of {id,title,link}. - try{ - const ctrl = new AbortController(); - const tid = setTimeout(()=>ctrl.abort(), 60000); - const resp = await fetch('https://r.jina.ai/https://www.youtube.com/@fptbongdaofficial/videos', { - headers:{'x-no-cache':'1','x-respond-with':'markdown'}, - signal: ctrl.signal - }); - clearTimeout(tid); - if(!resp.ok) throw new Error('jina '+resp.status); - const txt = await resp.text(); - const parsed = _fptParseJina(txt); - if(!parsed.length) throw new Error('empty parse'); - return parsed; - }catch(e){ - return null; - } -} - -function _fptRender(){ - const el = document.getElementById('home-fpt-section'); - if(!el) return; - if(!_fptVideos || !_fptVideos.length){ - el.innerHTML = ''; - return; - } - const vids = _fptVideos.slice(0, 30); - // Render FPT Play videos using the SAME wall card CSS as Short AI cards - // (via _makeYTFeedWallItem) — 100% match with card video Short AI. - let h = '
📺 FPT Play Bóng Đá' + vids.length + ' video · tự cập nhật
'; - vids.forEach((a,i)=>{ - h += _makeYTFeedWallItem(a, i); - }); - h += '
'; - el.innerHTML = h; - // lazy thumbs - el.querySelectorAll('img[loading]').forEach(function(im){ im.loading='lazy'; }); -} - -async function _fptSync(){ - // 1) read server cache first (fast) +// === WALL POST HELPERS === +function makeWallItem(p,i){ + const hasVideo = p.video && p.video.length > 0; + const thumbContent = p.img + ? `` + : (hasVideo ? `` : ''); + const videoBadge = hasVideo + ? `
🎬
` + : ''; + const videoBtn = hasVideo + ? `` + : ``; + + return `
+
+ ${thumbContent} + ${videoBadge} +
+
${esc(p.title)}
+
${esc((p.text||'').slice(0,180))}
+
+ + ${videoBtn} +
+
`; +} + +// === GENERATE SHORT VIDEO FOR A WALL POST === +async function makeShortVideo(postId, btn, voice, speed){ + if(!postId)return; + const origText = btn ? btn.textContent : '🎬 Tạo Video'; + if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo...';} + toast('⏳ Đang tạo video shorts...'); try{ - const r = await fetch('/api/fptplay/videos'); + let url = '/api/ai/short/'+encodeURIComponent(postId); + const params = []; + if(voice) params.push('voice='+encodeURIComponent(voice)); + if(speed) params.push('speed='+encodeURIComponent(speed)); + if(params.length) url += '?' + params.join('&'); + const r = await fetch(url, {method:'POST'}); const j = await r.json(); - if(j && j.videos && j.videos.length){ - _fptVideos = j.videos; - _fptRender(); - } - }catch(e){} - // 2) refresh from the channel (client-side jina) — replaces stale cache - const fresh = await _fptFetchClient(); - if(fresh && fresh.length){ - // push to server so the slide stays updated and persisted - try{ - const body = JSON.stringify({videos: fresh}); - const r2 = await fetch('/api/fptplay/update', {method:'POST', headers:{'Content-Type':'application/json'}, body}); - const j2 = await r2.json(); - if(j2 && j2.videos && j2.videos.length) _fptVideos = j2.videos; - else _fptVideos = fresh; - }catch(e2){ - _fptVideos = fresh; - } - _fptRender(); - } -} - -function _fptStartAuto(){ - if(_fptTimer) return; - _fptSync(); // immediate first pass - _fptTimer = setInterval(_fptSync, 12 * 60 * 1000); // auto-refresh every 12 min -} - -// === YOUTUBE RSS FEED — auto-updating FPT Bóng Đá videos on Tường AI === -// Channel ID UC4LvrpNXujjbGOS4RDvr41g = FPT Bóng Đá. -// The browser fetches the YouTube RSS feed (feeds/videos.xml) directly since -// the datacenter can't reach youtube.com. Parsed entries are interleaved into -// the AI wall (Tường AI) using the same wall CSS classes as makeWallItem so -// they look like native wall posts. Auto-refresh every 15 minutes. -var _ytFeedVideos = []; -var _ytFeedTimer = null; -var _ytFeedUrl = 'https://www.youtube.com/feeds/videos.xml?channel_id=UC4LvrpNXujjbGOS4RDvr41g'; -// The browser cannot fetch this URL directly (YouTube RSS lacks CORS headers). -// The server-side /api/yt/feed endpoint fetches + parses it and returns JSON. - -function _ytParseRssXml(xmlText){ - // Parse YouTube RSS XML. The browser can fetch the raw XML directly. - // Returns [{id, title, link, img, published}] in newest-first order. - var out = []; - try{ - var parser = new DOMParser(); - var doc = parser.parseFromString(xmlText, 'application/xml'); - // Check for parse errors - var err = doc.querySelector('parsererror'); - if(err){ return null; } - var entries = doc.getElementsByTagName('entry'); - for(var i=0; i String(x.id) === String(postId)); + if(p){ + p.video = j.video; + const itemId = 'wall-item-'+postId; + const el = document.getElementById(itemId); + if(el){ + const idx = _wallPosts.indexOf(p); + el.outerHTML = makeWallItem(p, idx); + const newEl = document.getElementById(itemId); + if(newEl) newEl.className = 'wall-item wall-item-new'; } - // Fallback: YouTube default thumbnail - if(!thumb){ thumb = 'https://i.ytimg.com/vi/' + vid + '/hqdefault.jpg'; } - out.push({ - id: 'yt-' + vid, - videoId: vid, - title: (title || 'Video').slice(0,200), - link: link, - img: thumb, - published: published - }); - } - return out.length ? out : null; - }catch(e){ - console.error('[yt-feed] parse error', e); - return null; - } -} - -function _normalizeYoutubeTs(p){ - // Sort key: use published date or a numeric id prefix, newest first - if(p.published){ - var d = new Date(p.published); - if(!isNaN(d.getTime())){ return d.getTime(); } - } - // fallback: parse videoId is not chronological; use 0 - return 0; -} - -async function _ytFetchFeed(){ - // Fetch the YouTube RSS feed via r.jina.ai reader proxy (works from the browser - // — YouTube blocks direct RSS access due to CORS). r.jina.ai proxies the - // rss2json.com JSON output as plain text inside a markdown blob. - try{ - var ctrl = new AbortController(); - var tid = setTimeout(function(){ ctrl.abort(); }, 20000); - var rssUrl = 'https://www.youtube.com/feeds/videos.xml?channel_id=UC4LvrpNXujjbGOS4RDvr41g'; - var rjUrl = 'https://r.jina.ai/https://api.rss2json.com/v1/api.json?rss_url=' + encodeURIComponent(rssUrl); - var resp = await fetch(rjUrl, { - headers:{'Accept':'text/plain','x-jina-reader':'1','x-no-cache':'1'}, - signal: ctrl.signal - }); - clearTimeout(tid); - if(!resp.ok){ throw new Error('HTTP ' + resp.status); } - var txt = await resp.text(); - // r.jina.ai wraps the JSON in a markdown block — extract the JSON - var jsonMatch = txt.match(/\{[\s\S]*"status"\s*:\s*"ok"[\s\S]*\}/); - if(!jsonMatch){ - console.error('[yt-feed] no JSON in r.jina.ai response'); - return null; } - var data = JSON.parse(jsonMatch[0]); - if(!data.items || !data.items.length){ return null; } - var out = []; - for(var i=0;i' - : '
'; - var videoBadge = '
🎬
'; - var vid = v.videoId || v.id; - var callOpen = "openYTEmbedFeed('"+esc(vid)+"','"+esc(v.title)+"')"; - return '
' + - '
' + - thumbContent + videoBadge + - '
' + - '
' + esc(v.title) + '
' + - '
' + esc((v.published||'').replace(/T.*/,'') ) + '
' + - '
' + - '' + - '
' + - '
'; -} -function _renderYTFeedInWall(){ - // Interleave YouTube feed videos into the wall track, using wall CSS. - // Called after _renderWallIn and after each feed refresh. - var track = document.getElementById('ai-wall-track'); - if(!track){ return; } - // Remove any previous feed items (they get re-appended to stay "latest") - var existing = track.querySelectorAll('.wall-yt-feed-item'); - existing.forEach(function(el){ el.remove(); }); - if(!_ytFeedVideos || !_ytFeedVideos.length){ return; } - // Interleave: insert feed items at regular intervals among wall posts - var wallItems = Array.prototype.slice.call(track.children); - var feedItems = _ytFeedVideos.slice(0, 12); // cap to keep wall tidy - var step = Math.max(1, Math.ceil(wallItems.length / feedItems.length)); - var insertIdx = step; - feedItems.forEach(function(v, i){ - var wrapper = document.createElement('div'); - wrapper.className = 'wall-yt-feed-item'; - wrapper.innerHTML = _makeYTFeedWallItem(v, i); - if(insertIdx < wallItems.length){ - track.insertBefore(wrapper, wallItems[insertIdx]); - wallItems.splice(insertIdx, 0, wrapper); - insertIdx += step + 1; - }else{ - track.appendChild(wrapper); - wallItems.push(wrapper); - } +// ===== VOICE SELECTOR ===== +function selectVoice(voiceKey){ + document.querySelectorAll('.tts-voice-btn').forEach(b=>{ + b.classList.toggle('active', b.dataset.voice === voiceKey); }); + const inp = document.getElementById('selected-voice'); + if(inp) inp.value = voiceKey; } -function openYTEmbedFeed(videoId, title){ - // Open a YouTube embed in the SAME TikTok-style viewer used by Short AI - // (buildTikTokSlide + initTikTokFeed) — 100% match with Short AI player. - // Builds a SCROLLABLE feed that starts at this FPT video and lets the user - // swipe UP/DOWN to browse older FPT videos interleaved with Short AI content, - // in newest-first order. - showView('view-tiktok'); - var el = document.getElementById('view-tiktok'); - if(!el){ return; } - - function _fptTs(v){ - if(v && v.published){ - var d = new Date(v.published); - if(!isNaN(d.getTime())) return d.getTime(); - } - return 0; - } - - // 1) Gather all playable items: FPT wall videos + Short AI posts (newest first) - var items = []; - var seenFpt = {}; - function pushFpt(v){ - if(!v) return; - var vid = v.videoId || v.id; - if(!vid || seenFpt[vid]) return; - seenFpt[vid] = 1; - items.push({ - kind:'fpt', - videoId: vid, - title: v.title || 'Video', - img: v.img || 'https://i.ytimg.com/vi/' + vid + '/hqdefault.jpg', - ts: _fptTs(v) - }); - } - (_ytFeedVideos||[]).forEach(pushFpt); - (_fptVideos||[]).forEach(pushFpt); - (_wallPosts||[]).forEach(function(p){ - if(!p || !p.video) return; - var ts = parseInt(p.created||'0',10); - if(isNaN(ts) || !ts){ var d = new Date(p.created_str||''); ts = isNaN(d.getTime()) ? 0 : d.getTime(); } - items.push({ - kind:'ai', - videoId: p.id || ('ai-' + items.length), - postId: p.id || '', - title: p.title || 'Video', - img: p.short_thumb || p.img || '', - text: (p.text && p.text !== p.title ? p.text : '').slice(0,400), - video: p.video, - ts: ts - }); - }); - - // 2) Sort newest-first, then rotate so the clicked FPT video is first - items.sort(function(a,b){ return (b.ts||0) - (a.ts||0); }); - var start = 0; - for(var i=0;i' - + '
'; - } - function aiVtag(it){ - var isYT = /youtube\.com\/embed|youtu\.be\/|youtube-nocookie|tiktok\.com\/embed|facebook\.com\/plugins|instagram\.com\/p\/.*\/embed|platform\.twitter\.com|player\.vimeo\.com/.test(it.video||''); - if(!it.video) return ''; - return isYT - ? '
' - : ''; - } - - var h = '' - + '
'; - ordered.forEach(function(it, idx){ - var vtag, badge, badgeClass, shareUrl, postId, desc, extraBtn; - if(it.kind === 'fpt'){ - vtag = fptVtag(it); - badge = 'FPT Play'; badgeClass = 'badge-fpt'; - shareUrl = 'https://www.youtube.com/watch?v=' + it.videoId; - postId = ''; desc = ''; extraBtn = ''; - } else { - vtag = aiVtag(it); - badge = 'Short AI'; badgeClass = 'badge-ai'; - shareUrl = it.video || ''; - postId = it.postId || ''; - desc = it.text || ''; - extraBtn = ''; - } - h += buildTikTokSlide({ - vtag: vtag, - title: it.title, - badge: badge, - badgeClass: badgeClass, - videoId: it.videoId + '-feed-' + String(idx), - idx: idx, - total: ordered.length, - shareUrl: shareUrl, - postId: postId, - desc: desc, - extraBtn: extraBtn || '' - }); - }); - h += '
'; - el.innerHTML = h; - setTimeout(function(){ initTikTokFeed(); }, 200); -} - -async function _ytFeedSync(){ - // 1) render from cache (immediate) - if(_ytFeedVideos.length){ - _renderYTFeedInWall(); - } - // 2) refresh from the live channel feed - var fresh = await _ytFetchFeed(); - if(fresh && fresh.length){ - _ytFeedVideos = fresh.slice(0, 15); - _renderYTFeedInWall(); - } -} - -function _ytFeedStartAuto(){ - if(_ytFeedTimer) return; - _ytFeedSync(); - _ytFeedTimer = setInterval(_ytFeedSync, 15 * 60 * 1000); // every 15 min -} - -// === WALL POST HELPERS === -function makeWallItem(p,i){ - var hasVideo = p.video && p.video.length > 0;var isEmbed = !!(p.embed_oembed || /tiktok\.com\/embed|facebook\.com\/plugins|instagram\.com\/p\/.*\/embed|youtube\.com\/embed|platform\.twitter\.com|player\.vimeo\.com/.test(p.video||'')); - var isSlide = (p.slides && p.slides.length) || p.kind==='slide_summary'; - // Slide/design posts: keep the image's natural aspect ratio (no 16:9 crop) - // so portrait-designed slides aren't cut — this fixes the "saved image ratio - // and background position look wrong vs the preview" bug on the wall thumb. - var thumbStyle = isSlide - ? 'aspect-ratio:auto;max-height:340px;display:flex;align-items:flex-start;justify-content:center;background:#000' - : ''; - var thumbContent = isEmbed - ? (p.img - ? '' - : '
') - : (isSlide - ? (p.img ? '' : (hasVideo ? '' : '')) - : (p.img ? '' : (hasVideo ? '' : ''))); - var videoBadge = hasVideo ? '
🎬
' : ''; - var vid = p.id||i; - var lang = p.language || detectLanguage(p.title + ' ' + (p.text||'')); - var curVoice = p.voice || getAutoVoice(lang); - var curEmotion = p.emotion || detectEmotion(p.title + ' ' + (p.text||'')); - var selKey = 'inline-' + vid; - if(!_ttsSelections[selKey]) _ttsSelections[selKey] = {voice: curVoice, emotion: curEmotion}; - var voiceOpts = ''; - VOICE_LIST.forEach(function(v){ - voiceOpts += ''; - }); - var emotOpts = ''; - EMOTION_LIST.forEach(function(e){ - emotOpts += ''; - }); - var spd = p.short_speed || '1.2'; - var voiceBar = '
' - +'' - +'' - +'' - +'
'; -var makeBtn = hasVideo - ? '' - : ''; - var designBtn = (p.slides && p.slides.length) ? '' : ''; - return '
'+thumbContent+videoBadge+'
'+esc(p.title)+'
'+esc((p.text||'').slice(0,180))+'
'+voiceBar+'
'+designBtn+makeBtn+'
'; -} - -/* ===================================================================== - * Short AI Creator v2 - * - TikTok background-music list (server-provided) or uploaded audio - * - uploaded image OR uploaded video as the short background - * - "Tạo lại" (recreate): use the DESIGNED slide images + the previous - * short's audio (NO text ever enters the video again) - * ===================================================================== */ -var _shortMusicList = []; -async function loadShortMusicList(){ - if(_shortMusicList.length) return _shortMusicList; - try{ - const r = await fetch('/api/ai-short/music'); - const j = await r.json(); - _shortMusicList = (j.music || []); - }catch(e){ _shortMusicList = []; } - return _shortMusicList; -} - -function openShortCreator(postId){ - if(!postId) return; - const p = _wallPosts.find(x => String(x.id) === String(postId)); - if(!p) return; - // if the post has designed slides, recreate defaults to slides mode - const hasSlides = p.slides && p.slides.length > 0; - _buildShortCreatorModal(p, hasSlides, false); -} - -/* 🔥 Thêm Short HOT — homepage button. Opens the SAME short-creator modal with - * full scrap capability (image / uploaded video / video-from-link) but creates - * a BRAND-NEW wall post (no source article) when the short is generated. */ -function openShortHotCreator(){ - const p = { - id: 'hot-' + Date.now(), - title: '', - text: '', - img: null, - images: [], - slides: null, - video: null, - language: 'vi', - }; - _buildShortCreatorModal(p, false, true); -} - -function _buildShortCreatorModal(p, defaultUseSlides, hotMode){ - loadShortMusicList().then(music => { - const hasPrev = !!(p.short_audio_url || p.video); - const hasSlides = p.slides && p.slides.length > 0; - const useSlideDef = defaultUseSlides !== undefined ? defaultUseSlides : (hasSlides && hasPrev); - const overlay = document.createElement('div'); - overlay.id = 'short-creator-overlay'; - overlay.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.94);z-index:99999;display:flex;align-items:center;justify-content:center;padding:12px;overflow-y:auto'; - - // voice/emotion from stored selection - const selKey = 'inline-' + p.id; - if(!_ttsSelections[selKey]) _ttsSelections[selKey] = {voice: p.voice || getAutoVoice(p.language || detectLanguage(p.title+' '+(p.text||''))), emotion: p.emotion || detectEmotion(p.title+' '+(p.text||''))}; - const curVoice = _ttsSelections[selKey].voice || 'vi-VN-HoaiMyNeural'; - const curEmotion = _ttsSelections[selKey].emotion || 'neutral'; - - let h = '
'; - h += '

'+(hotMode?'🔥 Thêm Short HOT':'🎬 Tạo Short AI')+(hasPrev?' (Tạo lại)':'')+'

'; - if(hotMode){ - h += '
'; - h += '
'; - } - - /* 0. Recreate mode (designed slides only, no text) */ - if(hasSlides){ - h += '
'; - h += ''; - h += '
Dùng ảnh slide đã thiết kế làm nền + giữ nguyên audio của Short trước đó. Chữ KHÔNG xuất hiện trong video.
'; - h += '
'; - h += '
Chọn slide muốn đưa vào Short (tích = dùng):
'; - p.slides.forEach((s,i)=>{ - const simg = s.image || p.img || ''; - h += ''; - }); - h += '
'; - } - - /* 1. Background: image (default) / uploaded video / scraped video link */ - h += '
'; - h += '
'; - h += ''; - h += ''; - h += ''; - h += '
'; - h += ''; - h += ''; - h += ''; - h += '
'; - - /* 2. Audio + 3. Voice — SKIPPED in HOT mode (video keeps its own audio) */ - if(!hotMode){ - /* 2. Audio: previous short audio / TikTok music / uploaded audio */ - h += '
'; - h += ''; - /* Music chooser — luôn hiển thị để người dùng NGHE THỬ trước khi chọn */ - h += '
'; - h += '
'; - h += '
'; - h += ''; - h += '
▶ Nghe thử nhạc nền TikTok trước khi chọn. Nhạc sẽ được trộn vào video (nền) khi tạo.
'; - h += '
'; - h += ''; - h += '
'; - - /* 3. Voice + emotion + speed (TTS) */ - h += '
'; - h += '
'; - h += '
'; - h += '
'; - h += '
'; - } - - h += '
'; - h += ''; - h += ''; - h += '
'; - h += '
'; - overlay.innerHTML=h; document.body.appendChild(overlay); - // Cleanup HLS preview instance whenever the modal is removed (✕ / Hủy / success) - (function(){ - const obs = new MutationObserver(function(muts){ - if(document.getElementById('sc-scrape-video')){ - const p = document.getElementById('sc-scrape-video'); - if(p._hls){ try{ p._hls.destroy(); }catch(e){} p._hls = null; } - } - }); - obs.observe(document.body, {childList:true, subtree:false}); - // stop after this overlay is gone - setTimeout(()=>obs.disconnect(), 60*1000); - })(); - - // ---- mode toggles ---- - let bgMode = hotMode ? 'link' : 'img'; - function setBgMode(m){ - bgMode = m; - document.getElementById('sc-bg-mode-img').style.background = m==='img' ? '#2d8659' : '#333'; - document.getElementById('sc-bg-mode-img').style.color = m==='img' ? '#fff' : '#ccc'; - document.getElementById('sc-bg-mode-video').style.background = m==='video' ? '#2d8659' : '#333'; - document.getElementById('sc-bg-mode-video').style.color = m==='video' ? '#fff' : '#ccc'; - document.getElementById('sc-bg-mode-link').style.background = m==='link' ? '#2d8659' : '#333'; - document.getElementById('sc-bg-mode-link').style.color = m==='link' ? '#fff' : '#ccc'; - document.getElementById('sc-bg-img-wrap').style.display = (m==='img' || m==='link') ? 'block' : 'none'; - document.getElementById('sc-bg-video-wrap').style.display = m==='video' ? 'block' : 'none'; - document.getElementById('sc-bg-link-wrap').style.display = m==='link' ? 'block' : 'none'; - } - if(hotMode){ - // HOT mode: only video-from-link is offered; hide ảnh/upload-video buttons - document.getElementById('sc-bg-mode-img').style.display = 'none'; - document.getElementById('sc-bg-mode-video').style.display = 'none'; - document.getElementById('sc-bg-mode-link').style.background = '#2d8659'; - document.getElementById('sc-bg-mode-link').style.color = '#fff'; - } else { - document.getElementById('sc-bg-mode-img').addEventListener('click', ()=>setBgMode('img')); - document.getElementById('sc-bg-mode-video').addEventListener('click', ()=>setBgMode('video')); - document.getElementById('sc-bg-mode-link').addEventListener('click', ()=>setBgMode('link')); - } - - // ---- scraped video link: fetch preview ---- - const scLinkInput = document.getElementById('sc-video-link'); - const scScrapeBtn = document.getElementById('sc-video-scrape'); - if(scScrapeBtn && scLinkInput){ - scScrapeBtn.addEventListener('click', async ()=>{ - const url = scLinkInput.value.trim(); - const st = document.getElementById('sc-video-scrape-status'); - const preview = document.getElementById('sc-video-preview'); - const meta = document.getElementById('sc-video-meta'); - if(!url){ - st.textContent = '⚠️ Dán link video trước.'; - st.style.color = '#e0a030'; - return; - } - st.textContent = '⏳ Đang lấy thông tin video...'; - st.style.color = '#888'; - try{ - const r = await fetch('/api/ai-short/scrape?url=' + encodeURIComponent(url)); - const j = await r.json(); - if(!r.ok || j.error) throw new Error(j.error || 'Không lấy được video'); - // metadata - let metaHtml = ''; - if(j.title) metaHtml += '
' + esc(j.title) + '
'; - if(j.duration) metaHtml += '
⏱️ ' + Math.round(j.duration) + 's
'; - if(j.extractor) metaHtml += '
Nguồn: ' + esc(j.extractor) + '
'; - if(j.note) metaHtml += '
⚠️ ' + esc(j.note) + '
'; - meta.innerHTML = metaHtml; - meta.style.display = 'block'; - // direct URL for the short; keep oEmbed fallback metadata too - const scDirect = j.direct_url || ''; - let scEmbed = j.embed_url || ''; - // YouTube/other scrape often returns the oEmbed-style iframe URL via - // direct_url (embed pages can't be downloaded as media). Normalize: - // treat direct embed URLs as oEmbed sources so the submit path saves - // an embeddable slide with auto title/thumb instead of erroring. - if(!scEmbed && /youtube\.com\/embed|youtu\.be\/|tiktok\.com\/embed|facebook\.com\/plugins|instagram\.com\/p\/.*\/embed|player\.vimeo\.com|platform\.twitter\.com/.test(scDirect)){ - scEmbed = scDirect; - } - const scThumb = j.thumbnail || j.thumb || ''; - const scDesc = j.description || ''; - meta.setAttribute('data-title', j.title || ''); - meta.setAttribute('data-direct', scDirect || ''); - meta.setAttribute('data-embed', scEmbed || ''); - meta.setAttribute('data-thumb', scThumb || ''); - meta.setAttribute('data-desc', scDesc || ''); - // Show description (YouTube/other) under the title in the meta box - if(scDesc){ - metaHtml += '
' + esc(scDesc) + '
'; - meta.innerHTML = metaHtml; - } - // Auto-fill the Short HOT title field with the real scraped title - const hotTitle = document.getElementById('sc-hot-title'); - if(hotTitle){ hotTitle.setAttribute('data-autofilled', j.title ? '1' : '0'); } - if(hotTitle && j.title && !hotTitle.value.trim()){ - hotTitle.value = j.title; - } - document.getElementById('sc-scrape-direct').value = scDirect; - document.getElementById('sc-scrape-duration').value = j.duration ? String(j.duration) : ''; - // YouTube description client-side fallback: the user's browser (home - // IP) can reach r.jina.ai even when the server cannot; fill the - // description if the server didn't return one. - if(!scDesc && /youtube\.com|youtu\.be/.test(url)){ - try{ - fetch('https://r.jina.ai/' + url, {headers:{'x-no-cache':'1'}}) - .then(resp => resp.text()) - .then(txt => { - const m = txt.match(/(?:^|\n)Description:\s*([\s\S]*?)(?=\n[A-Z][a-z]+:|$)/); - const t2 = txt.match(/(?:^|\n)Title:\s*(.+)/); - if(m && m[1] && m[1].trim()){ - const d = m[1].trim().slice(0,2000); - meta.setAttribute('data-desc', d); - metaHtml += '
' + esc(d) + '
'; - meta.innerHTML = metaHtml; - } - if(t2 && t2[1] && hotTitle && !hotTitle.value.trim()){ - hotTitle.value = t2[1].trim().slice(0,250); - } - }).catch(()=>{}); - }catch(e2){} - } - // fallback note when only oEmbed metadata is available (no direct media) - if(!scDirect && scEmbed){ - st.textContent = 'ℹ️ Video không tải trực tiếp được, sẽ chèn dạng oEmbed (tự lấy tiêu đề/mô tả).'; - st.style.color = '#e0a030'; - meta.style.display = 'block'; - const ebtn = document.getElementById('sc-video-scrape'); - if(ebtn) ebtn.style.display = 'none'; - } - if(j.previewable && scDirect){ - const isYouTube = /youtube\.com\/embed|youtu\.be\/|youtube-nocookie/.test(scDirect); - const isHls = !!j.is_hls || /\.m3u8/i.test(scDirect); - const streamUrl = isHls - ? '/api/ai-short/scrape/hls?url=' + encodeURIComponent(scDirect) - : '/api/ai-short/scrape/preview?url=' + encodeURIComponent(scDirect); - preview.style.display = 'block'; - preview.setAttribute('data-ishls', isHls ? '1' : '0'); - if(isYouTube){ - // YouTube embed — render as iframe (direct embed URL already has autoplay) - preview.setAttribute('data-ishls', '0'); - preview.innerHTML = ''; - st.textContent = '✅ Đã lấy video YouTube. Bấm play để xem trước.'; - st.style.color = '#8f8'; - } else { - preview.innerHTML = ''; - const v = preview.querySelector('#sc-scrape-video'); - if(isHls && v && typeof Hls !== 'undefined' && Hls.isSupported()){ - const hls = new Hls(); - hls.loadSource(streamUrl); - hls.attachMedia(v); - hls.on(Hls.Events.MANIFEST_PARSED, ()=>{ v.play().catch(()=>{}); }); - v._hls = hls; - } else if(isHls && v && v.canPlayType('application/vnd.apple.mpegurl')){ - v.src = streamUrl; // native Safari HLS - } - st.textContent = '✅ Đã lấy video. Bấm play để xem trước.'; - st.style.color = '#8f8'; - } - } else { - preview.style.display = 'none'; - if(j.note){ - st.textContent = '⚠️ ' + j.note; - st.style.color = '#e0a030'; - } else { - st.textContent = '❌ Không phát trước được (có thể do nguồn chặn). Bạn vẫn có thể thử tạo short.'; - st.style.color = '#e05555'; - } - } - }catch(e){ - st.textContent = '❌ ' + e.message; - st.style.color = '#e05555'; - preview.style.display = 'none'; - document.getElementById('sc-scrape-direct').value = ''; - document.getElementById('sc-scrape-duration').value = ''; - } - }); - } - - // ---- rewrite image picker (chọn 1 ảnh cho nhiều slide) ---- - let _scChosenImg = ''; - document.getElementById('sc-rewrite-list').addEventListener('click', e=>{ - const btn = e.target.closest('.sc-rw-img'); - if(!btn) return; - document.querySelectorAll('.sc-rw-img').forEach(b=>{ b.style.borderColor='#333'; b.dataset.sel='0'; }); - btn.style.borderColor='#ffd700'; btn.dataset.sel='1'; - _scChosenImg = btn.dataset.img || ''; - const hidden = document.getElementById('sc-chosen-img'); - if(hidden) hidden.value = _scChosenImg; - }); - - // ---- audio source toggles (skipped in HOT mode) ---- - if(!hotMode){ - function setAudioSrc(v){ - // nhạc nền TikTok luôn hiển thị để nghe thử; ẩn khi chọn "Không âm thanh" - document.getElementById('sc-music-wrap').style.display = v==='none' ? 'none' : 'block'; - document.getElementById('sc-upload-wrap').style.display = v==='upload' ? 'block' : 'none'; - } - document.getElementById('sc-audio-src').addEventListener('change', e=>setAudioSrc(e.target.value)); - if(hasPrev && p.video) setAudioSrc('prev'); else setAudioSrc('tts'); - } - - // ---- recreate slides: show/hide slide picker ---- - const scReuse = document.getElementById('sc-reuse-slides'); - if(scReuse){ - scReuse.addEventListener('change', function(){ - const pk = document.getElementById('sc-slide-picker'); - if(pk) pk.style.display = this.checked ? 'block' : 'none'; - }); - } - - // ---- music preview player (skipped in HOT mode) ---- - let _scAudio = null; - const listenBtn = hotMode ? null : document.getElementById('sc-music-listen'); - const musicSel = hotMode ? null : document.getElementById('sc-music'); - const playerBox = hotMode ? null : document.getElementById('sc-music-playerbox'); - if(listenBtn && musicSel){ - listenBtn.addEventListener('click', function(){ - const opt = musicSel.options[musicSel.selectedIndex]; - const aurl = opt ? (opt.dataset.aurl || '') : ''; - if(!aurl){ - alert('Không có link nhạc cho lựa chọn này.'); - return; - } - // proxy để tránh CORS khi phát thử trong trình duyệt - const streamUrl = '/api/ai-short/music/stream?url=' + encodeURIComponent(aurl); - if(_scAudio && _scAudio.dataset.src === aurl && !_scAudio.paused){ - _scAudio.pause(); listenBtn.textContent = '▶ Nghe thử'; - return; - } - if(_scAudio) _scAudio.pause(); - if(playerBox) playerBox.style.display = 'block'; - if(playerBox) playerBox.innerHTML = '
🎧 '+esc(opt.text)+'
'; - _scAudio = playerBox ? playerBox.querySelector('audio') : null; - if(_scAudio){ - _scAudio.dataset.src = aurl; - _scAudio.addEventListener('ended', function(){ listenBtn.textContent = '▶ Nghe thử'; }); - _scAudio.addEventListener('error', function(){ - listenBtn.textContent = '▶ Nghe thử'; - toast('❌ Không phát được nhạc thử (mạng/vùng chặn). Bạn vẫn có thể chọn để trộn vào video.'); - }); - listenBtn.textContent = '⏹ Dừng'; - } - }); - } - - // ---- create ---- - document.getElementById('sc-create-btn').addEventListener('click', async ()=>{ - const btn = document.getElementById('sc-create-btn'); - const st = document.getElementById('sc-status'); - btn.disabled = true; btn.textContent = '⏳ Đang tạo...'; - st.textContent = 'Đang chuẩn bị...'; - - // upload files first - try{ - // image file (not in HOT mode) - let imgUrl = ''; - const imgUrlInput = document.getElementById('sc-img-url'); - if(imgUrlInput) imgUrl = imgUrlInput.value.trim() || ''; - const imgFile = hotMode ? null : document.getElementById('sc-img-file').files[0]; - if(imgFile){ - st.textContent = '⏳ Đang tải ảnh lên...'; - const fd = new FormData(); fd.append('file', imgFile, imgFile.name); - const r = await fetch('/api/ai-short/upload', {method:'POST', body:fd}); - const j = await r.json(); - if(!r.ok || j.error) throw new Error(j.error || 'Lỗi upload ảnh'); - imgUrl = j.url; - } - // video file (not in HOT mode) - let videoUrl = ''; - const videoFile = hotMode ? null : document.getElementById('sc-video-file').files[0]; - if(videoFile){ - st.textContent = '⏳ Đang tải video lên...'; - const fd = new FormData(); fd.append('file', videoFile, videoFile.name); - const r = await fetch('/api/ai-short/upload', {method:'POST', body:fd}); - const j = await r.json(); - if(!r.ok || j.error) throw new Error(j.error || 'Lỗi upload video'); - videoUrl = j.url; - } - // audio file (not in HOT mode) - let uploadAudioUrl = ''; - const audioFile = hotMode ? null : document.getElementById('sc-audio-file').files[0]; - if(audioFile){ - st.textContent = '⏳ Đang tải audio lên...'; - const fd = new FormData(); fd.append('file', audioFile, audioFile.name); - const r = await fetch('/api/ai-short/upload', {method:'POST', body:fd}); - const j = await r.json(); - if(!r.ok || j.error) throw new Error(j.error || 'Lỗi upload audio'); - uploadAudioUrl = j.url; - } - - const audioSrc = hotMode ? 'video' : (document.getElementById('sc-audio-src').value); - const useSlides = hotMode ? false : (document.getElementById('sc-reuse-slides') ? document.getElementById('sc-reuse-slides').checked : false); - // nhạc nền TikTok: giá trị != none được trộn vào video (kể cả tạo lại) - let musicId = ''; - const musicSel = hotMode ? null : document.getElementById('sc-music'); - if(musicSel && musicSel.value && musicSel.value !== 'none' && audioSrc !== 'none'){ - musicId = musicSel.value; - } - if(audioSrc === 'music') musicId = musicSel ? musicSel.value : ''; - let audioUrl = ''; - if(hotMode){ audioUrl = ''; } - else if(audioSrc==='upload') audioUrl = uploadAudioUrl; - else if(audioSrc==='prev' && p.video){ audioUrl = p.short_audio_url || ''; } - // reuse_audio explicitly: only when user chose "giữ nguyên audio short trước" - const reuseAudio = hotMode ? false : (audioSrc === 'prev'); - // collect selected slide indices (recreate mode) - let slideIndices = []; - if(useSlides){ - document.querySelectorAll('#sc-slide-picker .sc-slide-chk:checked').forEach(chk=>{ - slideIndices.push(parseInt(chk.dataset.idx)); - }); - if(!slideIndices.length){ - st.textContent = '⚠️ Chọn ít nhất 1 slide để tạo lại.'; - throw new Error('Chọn ít nhất 1 slide'); - } - } - // reuse_audio is sent explicitly above; music (audio_music) is overlaid by the backend - const body = { - post_id: p.id, - use_slides: useSlides, - slide_indices: slideIndices, - reuse_audio: reuseAudio, - voice: hotMode ? 'vi-VN-HoaiMyNeural' : document.getElementById('sc-voice').value, - emotion: hotMode ? 'neutral' : document.getElementById('sc-emotion').value, - speed: hotMode ? 1.0 : (parseFloat(document.getElementById('sc-speed').value) || 1.2), - audio_music: musicId, - audio_url: audioUrl, - }; - if(hotMode){ - // create a brand-new wall post on the AI wall - body.create_new = true; - const hotTitleEl = document.getElementById('sc-hot-title'); - body.title = hotTitleEl ? hotTitleEl.value.trim() : ''; - // scraped video title as fallback wall title - if(!body.title){ - const meta = document.getElementById('sc-video-meta'); - body.title = (meta && meta.dataset.title) ? meta.dataset.title : '🔥 Short HOT'; - } - body.text = ''; - body.language = 'vi'; - const srcEl = document.getElementById('sc-video-link'); - if(srcEl && srcEl.value.trim()) body.url = srcEl.value.trim(); - } - // if uploaded video selected -> use it as background via images? No: - if(bgMode==='video' && videoUrl){ - body.video_url = videoUrl; // backend handles - const vmute = document.getElementById('sc-video-mute'); - if(vmute && vmute.checked) body.video_muted = true; - } else if(bgMode==='link'){ - // scraped video from link: video-first + image tail completion - const scDirect = document.getElementById('sc-scrape-direct') ? document.getElementById('sc-scrape-direct').value : ''; - const meta = document.getElementById('sc-video-meta'); - const scEmbed = meta ? (meta.getAttribute('data-embed') || '') : ''; - const scThumb = meta ? (meta.getAttribute('data-thumb') || '') : ''; - const scTitle = meta ? (meta.getAttribute('data-title') || '') : ''; - const scDesc = meta ? (meta.getAttribute('data-desc') || '') : ''; - // if the modal title was auto-filled from the scrape, prefer it for - // the wall post (user can still edit it before submitting) - const hotTitleVal = document.getElementById('sc-hot-title') ? document.getElementById('sc-hot-title').value.trim() : ''; - if(scDirect && !/youtube\.com\/embed|youtu\.be\/|tiktok\.com\/embed|facebook\.com\/plugins|instagram\.com\/p\/.*\/embed|player\.vimeo\.com|platform\.twitter\.com/.test(scDirect)){ - body.video_url = scDirect; - body.scrape_mode = true; // video-first assembly - const vdur = document.getElementById('sc-scrape-duration') ? document.getElementById('sc-scrape-duration').value : ''; - if(vdur) body.video_dur = parseFloat(vdur) || undefined; - const lmute = document.getElementById('sc-link-mute'); - if(lmute && lmute.checked) body.video_muted = true; - // still send the chosen images (they complete the short if the - // video is shorter than the voice) - const chosen = document.getElementById('sc-chosen-img') ? document.getElementById('sc-chosen-img').value : ''; - const imgs = []; - if(chosen) imgs.push(chosen); - if(imgUrl) imgs.push(imgUrl); - if(imgs.length) body.images = imgs; - if(chosen) body.fixed_image = chosen; - } else if(scEmbed){ - // oEmbed fallback: media can't be downloaded (TikTok/FB/IG/YT block - // datacenter IPs) -> save the short as an embeddable slide with - // auto title/desc/thumb from the scrape metadata. - body.embed_url = scEmbed; - body.embed_title = hotTitleVal || scTitle || ''; - body.embed_thumb = scThumb || ''; - body.embed_desc = scDesc || ''; - const chosenImg = imgUrl || (document.getElementById('sc-chosen-img') ? document.getElementById('sc-chosen-img').value : ''); - if(chosenImg) body.images = [chosenImg]; - } else { - // No direct media and no embed metadata from the scrape. If the - // user pasted a social/link URL, still post it — the backend - // auto-probes oEmbed and saves the short as an embed slide with - // auto title/desc/thumb (no more "chưa lấy được video" error). - const srcUrlEl = document.getElementById('sc-video-link'); - const srcUrl = srcUrlEl ? srcUrlEl.value.trim() : ''; - if(srcUrl && /^https?:\/\//i.test(srcUrl)){ - body.url = srcUrl; - body.allow_oembed_fallback = true; - st.textContent = 'ℹ️ Không lấy được video trực tiếp — sẽ đăng dạng oEmbed (backend tự lấy tiêu đề/mô tả/ảnh).'; - st.style.color = '#e0a030'; - } else { - st.textContent = '⚠️ Bấm "📥 Lấy video" để lấy video từ link trước khi tạo.'; - throw new Error('Chưa lấy được video từ link'); - } - } - } else { - // 1. ảnh chọn từ danh sách rewrite (áp dụng cho nhiều slide, fix nền cho cả short) - // 2. ảnh URL nhập tay / tải lên - const chosen = document.getElementById('sc-chosen-img') ? document.getElementById('sc-chosen-img').value : ''; - const imgs = []; - if(chosen) imgs.push(chosen); - if(imgUrl) imgs.push(imgUrl); - if(imgs.length) body.images = imgs; // backend: first = fixed bg (normal mode) - if(chosen) body.fixed_image = chosen; // applies to slides mode too (1 ảnh cho nhiều slide) - } - st.textContent = '⏳ Đang tạo video short (có thể mất 1-2 phút)...'; - const r2 = await fetch('/api/ai-short', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)}); - const j2 = await r2.json(); - if(!r2.ok || j2.error) throw new Error(j2.error || 'Lỗi tạo video'); - toast('✅ Đã tạo Short AI!'); - const pp = _wallPosts.find(x => String(x.id) === String(p.id)); - if(pp){ - pp.video = j2.video; - pp.voice = j2.voice; pp.emotion = j2.emotion; pp.short_speed = j2.speed; - pp.short_music = j2.music; pp.short_audio_url = audioUrl; pp.short_use_slides = j2.use_slides; - pp.short_images = j2.images; - } - if(hotMode && j2.post){ - // 🆕 brand-new wall post created by the backend -> prepend on the AI wall - prependWallPost(j2.post); - } else { - const itemId = 'wall-item-'+p.id; - const el = document.getElementById(itemId); - if(el){ - const idx = _wallPosts.indexOf(pp); - el.insertAdjacentHTML('afterend', makeWallItem(pp, idx)); - el.remove(); - } - } - document.getElementById('short-creator-overlay').remove(); - }catch(e){ - st.textContent = '❌ ' + e.message; - btn.disabled = false; btn.textContent = hotMode ? '🔥 Thêm Short HOT' : '🎬 Tạo Short AI'; - toast('❌ ' + e.message); - } - }); - }); -} - -/* legacy fast path — used by any remaining tts-create-btn handlers */ -async function makeShortVideo(postId, btn, voice, speed, emotion){ - openShortCreator(postId); - return; -} - -var VOICE_LIST = [ - {id:'vi-VN-HoaiMyNeural', label:'🎙️ Hoài My (VI)', lang:'vi'}, - {id:'vi-VN-NamMinhNeural', label:'🎙️ Nam Minh (VI)', lang:'vi'}, - {id:'en-US-AndrewMultilingualNeural', label:'🎙️ Andrew (EN)', lang:'en'}, - {id:'en-AU-WilliamMultilingualNeural', label:'🎙️ William (EN)', lang:'en'}, - {id:'pt-BR-ThalitaMultilingualNeural', label:'🎙️ Thalita (PT)', lang:'pt'}, - {id:'fr-FR-VivienneMultilingualNeural', label:'🎙️ Vivienne (FR)', lang:'fr'}, - {id:'fr-FR-RemyMultilingualNeural', label:'🎙️ Rémy (FR)', lang:'fr'}, - {id:'de-DE-SeraphinaMultilingualNeural', label:'🎙️ Seraphina (DE)', lang:'de'}, - {id:'de-DE-FlorianMultilingualNeural', label:'🎙️ Florian (DE)', lang:'de'}, - {id:'ko-KR-HyunsuMultilingualNeural', label:'🎙️ Hyunsu (KO)', lang:'ko'}, - {id:'it-IT-GiuseppeMultilingualNeural', label:'🎙️ Giuseppe (IT)', lang:'it'}, -]; -var EMOTION_LIST = [ - {id:'neutral', label:'😐 Trung tính'}, - {id:'happy', label:'😊 Vui vẻ'}, - {id:'excited', label:'🔥 Hào hứng'}, - {id:'sad', label:'😢 Buồn'}, - {id:'humorous', label:'😂 Hài hước'}, - {id:'serious', label:'⚠️ Nghiêm túc'}, - {id:'urgent', label:'🚨 Khẩn cấp'}, - {id:'warm', label:'💖 Ấm áp'}, -]; - -document.addEventListener('click',function(e){ - var btn = e.target.closest('.tts-voice-btn'); - if(btn){ - var container = btn.closest('.tts-selector'); - if(container){ - var selKey = 'inline-'+container.dataset.postId; - if(!_ttsSelections[selKey]) _ttsSelections[selKey]={voice:btn.dataset.voice,emotion:'neutral'}; - var allBtns = container.querySelectorAll('.tts-voice-btn'); - for(var i=0;ip.video); + let shortAISection = document.getElementById('short-ai-section'); + if(aiShorts.length === 0){ + if(shortAISection) shortAISection.remove(); return; } - var cbtn = e.target.closest('.tts-create-btn'); - if(cbtn){ - var container = cbtn.closest('.tts-selector'); - if(container){ - var selKey = 'inline-'+container.dataset.postId; - var selVoice = _ttsSelections[selKey] ? _ttsSelections[selKey].voice : 'vi-VN-HoaiMyNeural'; - var selEmotion = _ttsSelections[selKey] ? _ttsSelections[selKey].emotion : 'neutral'; - var speedSel = container.querySelector('.tts-speed'); - var speed = speedSel ? parseFloat(speedSel.value)||1.2 : 1.2; - window.makeShortVideo(container.dataset.postId, cbtn, selVoice, speed, selEmotion); + const existing = document.querySelectorAll('#view-home .slider-wrap'); + if(shortAISection){ + const track = shortAISection.querySelector('.slider-track'); + if(track){ + let h = ''; + aiShorts.slice(0,20).forEach((p,i)=>{ + h+=`
${esc(p.title)}
`; + }); + track.innerHTML = h; } - return; } -}); -function detectLanguage(text){ - if(!text) return 'vi'; - var t=text.toLowerCase(), chars=new Set(t); - var vnChars='đăâêôơưàảãạáằẳẵặắầẩẫậấèẻẽẹéềễểệếìỉĩịíòỏõọóồổỗộốờởỡợớùủũụúừửữựứỳỷỹỵý'; - var vnCount=0; for(var c of vnChars){if(chars.has(c)) vnCount++;} - if(vnCount>=2) return 'vi'; - if(chars.has('ñ')||chars.has('¿')||chars.has('¡')) return 'es'; - if(chars.has('ã')||chars.has('õ')) return 'pt'; - var words=t.split(/\s+/); - var enWords=['the','is','at','which','on','and','or','but','this','that','with','from','have','been']; - var enCount=words.filter(function(w){return enWords.indexOf(w)>=0;}).length; - if(enCount>=2) return 'en'; - return 'vi'; -} -function detectEmotion(text){ - if(!text) return 'neutral'; - var t=text.toLowerCase(); - var kws={ - happy:['vui','hạnh phúc','tuyệt','thành công','chiến thắng','feliz','maravilloso','happy','joy','wonderful','great','amazing','love','excellent'], - excited:['hào hứng','phấn khích','đột phá','kỷ lục','đỉnh cao','emocionante','increíble','excited','thrilling','unbelievable','awesome','breakthrough'], - sad:['buồn','đau','mất','thảm họa','khủng hoảng','triste','terrible','sad','unhappy','tragic','painful','death'], - humorous:['hài hước','buồn cười','haha','đùa','engraçado','gracioso','funny','hilarious','joke','lol'], - serious:['nghiêm trọng','khẩn cấp','quan trọng','lo ngại','sério','crítico','serious','critical','urgent','severe','crisis'], - urgent:['khẩn cấp','báo động','ngay lập tức','urgent','breaking','alert','emergency'], - warm:['ấm áp','tình cảm','yêu thương','warm','love','heart','touching'] - }; - var bestScore=0, bestEmotion='neutral'; - for(var em in kws){var score=0; for(var kw of kws[em]){if(t.indexOf(kw)>=0) score++;} if(score>bestScore){bestScore=score;bestEmotion=em;}} - return bestEmotion; -} -function getAutoVoice(lang){var map={vi:'vi-VN-HoaiMyNeural',pt:'pt-BR-ThalitaMultilingualNeural',en:'en-US-AndrewMultilingualNeural',fr:'fr-FR-VivienneMultilingualNeural',de:'de-DE-SeraphinaMultilingualNeural',ko:'ko-KR-HyunsuMultilingualNeural',it:'it-IT-GiuseppeMultilingualNeural'};return map[lang]||'vi-VN-HoaiMyNeural';} -function buildVoiceEmotionSelector(post){ - var lang=post.language||detectLanguage(post.title+' '+(post.text||'')); - var _oldVoiceMap = {'hoaimy':'vi-VN-HoaiMyNeural','namminh':'vi-VN-NamMinhNeural','andrew':'en-US-AndrewMultilingualNeural','jenny':'en-US-AndrewMultilingualNeural','thalita':'pt-BR-ThalitaMultilingualNeural','pt_thalita':'pt-BR-ThalitaMultilingualNeural','vivienne':'fr-FR-VivienneMultilingualNeural','remy':'fr-FR-RemyMultilingualNeural','seraphina':'de-DE-SeraphinaMultilingualNeural','florian':'de-DE-FlorianMultilingualNeural','sunhee':'ko-KR-HyunsuMultilingualNeural','hyunsu':'ko-KR-HyunsuMultilingualNeural','giuseppe':'it-IT-GiuseppeMultilingualNeural','ela':'en-US-AndrewMultilingualNeural','denise':'fr-FR-VivienneMultilingualNeural','katja':'de-DE-SeraphinaMultilingualNeural','nanami':'en-US-AndrewMultilingualNeural','xiaochen':'en-US-AndrewMultilingualNeural','es_carlos':'en-US-AndrewMultilingualNeural','pt_francisco':'pt-BR-ThalitaMultilingualNeural'}; - var _postVoice = post.voice ? (_oldVoiceMap[post.voice] || post.voice) : ''; - var autoVoice= _postVoice || getAutoVoice(lang); - var autoEmotion=post.emotion||detectEmotion(post.title+' '+(post.text||'')); - var selKey = 'inline-'+post.id; - if(!_ttsSelections[selKey]){_ttsSelections[selKey] = {voice: autoVoice, emotion: autoEmotion};} - var h='
'; - h+='
🎙️ Giọng đọc (ngôn ngữ: '+lang.toUpperCase()+'):
'; - VOICE_LIST.forEach(function(v){var sel=v.id===_ttsSelections[selKey].voice?'border-color:#5cb87a;background:#1a2a1f':'border-color:#333;background:#222';h+='';}); - h+='
😊 Cảm xúc:
'; - EMOTION_LIST.forEach(function(e){var sel=e.id===_ttsSelections[selKey].emotion?'border-color:#5cb87a;background:#1a2a1f':'border-color:#333;background:#222';h+='';}); - h+='
⚡ Tốc độ:'; - h+='
'; - h+='
'; - return h; } -window.showVoiceEmotionSelector=function(postId,title,text){ - var overlay=document.createElement('div'); - overlay.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:99999;display:flex;align-items:center;justify-content:center;padding:16px'; - var box=document.createElement('div');box.style.cssText='background:#1a1a1a;border:2px solid #2d8659;border-radius:16px;padding:20px;max-width:400px;width:100%;max-height:80vh;overflow-y:auto'; - var lang=detectLanguage(title+' '+text);var autoEmotion=detectEmotion(title+' '+text); - var h='

🎬 Tạo Short AI (ngôn ngữ: '+lang.toUpperCase()+')

'; - h+='
🎙️ Chọn giọng đọc:
'; - VOICE_LIST.forEach(function(v){var sel=v.id===getAutoVoice(lang)?'border-color:#5cb87a;background:#1a2a1f':'border-color:#333;background:#222';h+='';}); - h+='
😊 Chọn cảm xúc:
'; - EMOTION_LIST.forEach(function(e){var sel=e.id===autoEmotion?'border-color:#5cb87a;background:#1a2a1f':'border-color:#333;background:#222';h+='';}); - h+='
⚡ Tốc độ:
'; - h+='
'; - h+='
'; - h+='
'; - h+=''; - box.innerHTML=h;overlay.appendChild(box);document.body.appendChild(overlay); - var selectedVoice=getAutoVoice(lang),selectedEmotion=autoEmotion; - box.querySelectorAll('.ve-voice-btn').forEach(function(btn){btn.addEventListener('click',function(){box.querySelectorAll('.ve-voice-btn').forEach(function(b){b.style.borderColor='#333';b.style.background='#222';});this.style.borderColor='#5cb87a';this.style.background='#1a2a1f';selectedVoice=this.dataset.voice;});}); - box.querySelectorAll('.ve-emotion-btn').forEach(function(btn){btn.addEventListener('click',function(){box.querySelectorAll('.ve-emotion-btn').forEach(function(b){b.style.borderColor='#333';b.style.background='#222';});this.style.borderColor='#5cb87a';this.style.background='#1a2a1f';selectedEmotion=this.dataset.emotion;});}); - box.querySelector('#ve-cancel-btn').addEventListener('click',function(){overlay.remove();}); - box.querySelector('#ve-create-btn').addEventListener('click',async function(){ - this.disabled=true;this.textContent='⏳ Đang tạo...'; - box.querySelector('#ve-status').style.display='block';box.querySelector('#ve-status').textContent='Đang tạo video shorts...'; - try{ - var speed=parseFloat(box.querySelector('#ve-speed').value)||1.2; - if(!_ttsSelections["inline-"+postId]) _ttsSelections["inline-"+postId]={voice:"vi-VN-HoaiMyNeural",emotion:"neutral"}; - _ttsSelections["inline-"+postId].voice=selectedVoice;_ttsSelections["inline-"+postId].emotion=selectedEmotion; - var r=await fetch('/api/ai/short/'+encodeURIComponent(postId),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:selectedVoice,emotion:selectedEmotion,speed:speed})}); - var j=await r.json(); - if(!r.ok||j.error) throw new Error(j.error||'Lỗi tạo video'); - toast('✅ Đã tạo Short AI!');overlay.remove(); - var p=_wallPosts.find(function(x){return String(x.id)===String(postId);}); - if(p){p.video=j.video;p.voice=j.voice;p.emotion=j.emotion;} - }catch(e){this.disabled=false;this.textContent='🎬 Tạo Short';box.querySelector('#ve-status').textContent='❌ '+e.message;} - }); -}; function prependWallPost(post){ _wallPosts.unshift(post); const track=document.getElementById('ai-wall-track'); const wrap=document.getElementById('ai-wall-wrap'); - const target=document.getElementById('ai-wall-under-compose'); - if(target && (!track||!wrap)){ - const newWrap=document.createElement('div'); - newWrap.className='slider-wrap';newWrap.id='ai-wall-wrap'; - newWrap.innerHTML=`
🧱 Tường AI
${makeWallItem(post,0)}
`; - target.appendChild(newWrap); - const firstItem=newWrap.querySelector('.wall-item'); - if(firstItem)firstItem.className='wall-item wall-item-new'; - // re-interleave YouTube feed items after new wall content - if(typeof _ytFeedVideos !== 'undefined') _renderYTFeedInWall(); + const homeEl=document.getElementById('view-home'); + if(!track||!wrap){ + if(homeEl){ + let insertBefore=homeEl.querySelector('.slider-wrap'); + const newWrap=document.createElement('div'); + newWrap.className='slider-wrap'; + newWrap.id='ai-wall-wrap'; + newWrap.innerHTML=`
🧱 Tường AI
${makeWallItem(post,0)}
`; + if(insertBefore){ + homeEl.insertBefore(newWrap,insertBefore); + }else{ + homeEl.appendChild(newWrap); + } + const firstItem=newWrap.querySelector('.wall-item'); + if(firstItem)firstItem.className='wall-item wall-item-new'; + } return; } - if(track){ - track.insertAdjacentHTML('afterbegin', makeWallItem(post, 0)); - track.scrollTo({left:0,behavior:'smooth'}); - // re-interleave YouTube feed items after new wall content - if(typeof _renderYTFeedInWall === 'function') _renderYTFeedInWall(); - } -} - + // Track exists — prepend with animation + const div=document.createElement('div'); + div.className='wall-item wall-item-new'; + div.id='wall-item-'+(post.id||'new-'+Date.now()); + const hasVideo = post.video && post.video.length > 0; + const thumbContent = post.img + ? `` + : (hasVideo ? `` : ''); + const videoBadge = hasVideo ? `
🎬
` : ''; + const videoBtn = hasVideo + ? `` + : ``; + div.innerHTML=`
${thumbContent}${videoBadge}
${esc(post.title)}
${esc((post.text||'').slice(0,180))}
${videoBtn}
`; + track.prepend(div); + track.scrollTo({left:0,behavior:'smooth'}); + if(hasVideo) refreshShortAISlider(); +} + +// === REST OF FUNCTIONS === +let _shortsData=[]; let _wallPosts=[]; let _currentView='home'; -let _currentEventId=null; -let _currentMatchUrl=null; +function interleaveShorts(shorts){const dt=shorts.filter(s=>s.channel==='baodantri7941');const sk=shorts.filter(s=>s.channel==='baosuckhoedoisongboyte');const result=[];let i=0,j=0;while(i{const topicText=t.topic||t.label.replace(/^#/,'');return``;}).join('');} +async function loadHotTopics(){const j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));const el=document.getElementById('hot-topics');if(!el)return;el.innerHTML=(j.topics||[]).slice(0,18).map(t=>{const topicText=t.topic||t.label.replace(/^#/,'');return``;}).join('');if(j.topics&&j.topics[0]){const firstTopic=j.topics[0].topic||j.topics[0].label.replace(/^#/,'');setTimeout(()=>searchTopic(firstTopic),800);}} function searchTopic(topic){if(!topic){topic=document.getElementById('topic-input')?.value.trim();if(!topic){alert('Nhập chủ đề');return;}}document.getElementById('topic-input').value='';_htTopic=topic;_htPage=0;showHashtagSources(topic,0);} -async function showHashtagSources(topic,page){const box=document.getElementById('hashtag-box');if(!box)return;if(page===0)box.innerHTML=`

🔍 ${esc(topic)}

Đang tìm...
`;try{const r=await fetch(`/api/hashtag/sources?topic=${encodeURIComponent(topic)}&page=${page}`);const j=await r.json();const sources=j.sources||[];if(!sources.length&&page===0){box.innerHTML=`

🔍 ${esc(topic)}

Không tìm được bài viết liên quan
`;return;}let h='';if(page===0)h=`

🔍 ${esc(topic)} (${j.total} bài từ 8 nguồn)

`;sources.forEach((s,i)=>{const idx=page*8+i;h+=`
${esc(s.title)}
${esc(s.via||'')}
`;});if(page===0){h+=`
`;if(j.has_more)h+=``;h+=`
`;box.innerHTML=h;}else{document.getElementById('ht-list')?.insertAdjacentHTML('beforeend',h);const btn=document.getElementById('ht-more');if(btn){if(!j.has_more)btn.remove();else{btn.disabled=false;btn.textContent='Tải thêm ▼';}}}sources.forEach((s,i)=>{const idx=page*8+i;if(!s.url)return;const ctrl=new AbortController();setTimeout(()=>ctrl.abort(),4000);fetch('/api/article?url='+encodeURIComponent(s.url),{signal:ctrl.signal}).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){const el=document.getElementById('ht-img-'+idx);if(el)el.innerHTML=``;}}).catch(()=>{});});}catch(e){box.innerHTML=`

🔍 ${esc(topic)}

Lỗi: ${esc(e.message)}
`;}} +async function showHashtagSources(topic,page){const box=document.getElementById('hashtag-box');if(!box)return;if(page===0)box.innerHTML=`

🔍 ${esc(topic)}

Đang tìm...
`;try{const r=await fetch(`/api/hashtag/sources?topic=${encodeURIComponent(topic)}&page=${page}`);const j=await r.json();const sources=j.sources||[];if(!sources.length&&page===0){box.innerHTML=`

🔍 ${esc(topic)}

Không tìm được bài viết liên quan
`;return;}let h='';if(page===0)h=`

🔍 ${esc(topic)} (${j.total} bài từ 8 nguồn)

`;sources.forEach((s,i)=>{const idx=page*8+i;h+=`
${esc(s.title)}
${esc(s.via||'')}
`;});if(page===0){h+=`
`;if(j.has_more)h+=``;h+=`
`;box.innerHTML=h;}else{document.getElementById('ht-list')?.insertAdjacentHTML('beforeend',h);const btn=document.getElementById('ht-more');if(btn){if(!j.has_more)btn.remove();else{btn.disabled=false;btn.textContent='Tải thêm ▼';}}}sources.forEach((s,i)=>{const idx=page*8+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)){const el=document.getElementById('ht-img-'+idx);if(el)el.innerHTML=``;}}).catch(()=>{});});}catch(e){box.innerHTML=`

🔍 ${esc(topic)}

Lỗi: ${esc(e.message)}
`;}} function loadMoreHashtag(){_htPage++;const btn=document.getElementById('ht-more');if(btn){btn.disabled=true;btn.textContent='Đang tải...';}showHashtagSources(_htTopic,_htPage);} async function rewriteHashtag(topic){const btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang tổng hợp...';}try{const r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');toast('✅ Đã đăng Tường AI!');if(btn)btn.textContent='✅ Đăng thành công!';if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message);if(btn){btn.disabled=false;btn.textContent='🤖 Rewrite AI';}}} -async function loadLivescore(tab){document.querySelectorAll('.ls-tab').forEach(t=>t.classList.remove('active'));document.querySelector(`.ls-tab[data-tab="${tab}"]`)?.classList.add('active');const el=document.getElementById('ls-content');if(!el)return;el.innerHTML='
Đang tải...
';let ep='/api/livescore/'+tab;if(tab.startsWith('bxh_'))ep='/api/livescore/standings/'+tab.replace('bxh_','');try{const d=await _fetchWithTimeout(ep,12000);el.innerHTML=d.html&&d.html.length>50?d.html:'
Không có dữ liệu
';bindMatchClicks(el);if(tab.startsWith('bxh_'))_decorateStandings(el);}catch(e){el.innerHTML='
Lỗi tải hoặc hết thời gian
';}} -function _decorateStandings(el){ - try{ - const items=el.querySelectorAll('.leaderboard-item'); - if(!items.length)return; - // Build header row: # | Đội bóng | Tr T H B BT BB +/- Đ - const head=document.createElement('div'); - head.className='ls-bxh-head'; - head.innerHTML='

Tr

T

H

B

BT

BB

+/-

Đ

'; - el.insertBefore(head, items[0]); - // Make each row use flex layout with the copy cells as a fixed-width grid - items.forEach(function(it){ - it.classList.add('ls-bxh-row'); - }); - }catch(e){} -} -function bindMatchClicks(el){el.querySelectorAll('.match-detail').forEach(md=>{md.style.cursor='pointer';md.addEventListener('click',function(e){const statusA=this.querySelector('.status a');const teamA=this.querySelector('.teams a[href*="/tran-dau/"]');const a = statusA || teamA;if(a){e.preventDefault();e.stopPropagation();const href=a.getAttribute('href')||'';const m=href.match(/\/tran-dau\/(\d+)\//);if(m){const fullUrl=href.startsWith('http')?href:'https://bongda.com.vn'+href;openMatch(m[1],fullUrl);}}});});el.querySelectorAll('a').forEach(a=>{a.addEventListener('click',e=>{e.preventDefault();e.stopPropagation()});});} -function openMatch(id,url){if(!id)return;_currentEventId=id;if(url)_currentMatchUrl=url;document.getElementById('match-overlay').classList.add('active');document.body.style.overflow='hidden';loadMatchTab('detail')} +async function loadLivescore(tab){document.querySelectorAll('.ls-tab').forEach(t=>t.classList.remove('active'));document.querySelector(`.ls-tab[data-tab="${tab}"]`)?.classList.add('active');const el=document.getElementById('ls-content');if(!el)return;el.innerHTML='
Đang tải...
';let ep='/api/livescore/'+tab;if(tab.startsWith('bxh_'))ep='/api/livescore/standings/'+tab.replace('bxh_','');try{const r=await fetch(ep);const d=await r.json();el.innerHTML=d.html&&d.html.length>50?d.html:'
Không có dữ liệu
';bindMatchClicks(el);}catch(e){el.innerHTML='
Lỗi
';}} +function bindMatchClicks(el){el.querySelectorAll('.match-detail').forEach(md=>{md.addEventListener('click',function(e){e.preventDefault();const a=this.querySelector('.status a');if(a){const m=(a.getAttribute('href')||'').match(/\/tran-dau\/(\d+)\//);if(m)openMatch(m[1])}})});el.querySelectorAll('a').forEach(a=>a.addEventListener('click',e=>e.preventDefault()))} +function openMatch(id){if(!id)return;_currentEventId=id;document.getElementById('match-overlay').classList.add('active');document.body.style.overflow='hidden';loadMatchTab('comm')} function closeMatch(){document.getElementById('match-overlay').classList.remove('active');document.body.style.overflow=''} -async function loadMatchTab(tab){document.querySelectorAll('.mo-tab').forEach(t=>t.classList.remove('active'));document.querySelectorAll('.mo-tab').forEach(t=>{if((tab==='comm'&&t.textContent==='Diễn biến')||(tab==='stats'&&t.textContent==='Thống kê')||(tab==='detail'&&t.textContent.includes('Chi tiết')))t.classList.add('active')});const el=document.getElementById('mo-body');if(!el)return;el.innerHTML='
Đang tải...
';try{let apiUrl;if(tab==='stats')apiUrl=`/api/match/${_currentEventId}/stats`;else if(tab==='comm')apiUrl=`/api/match/${_currentEventId}/commentaries`;else{apiUrl=`/api/match/${_currentEventId}/detail`;if(_currentMatchUrl)apiUrl+='?url='+encodeURIComponent(_currentMatchUrl)}const r=await fetch(apiUrl);if(!r.ok){el.innerHTML='
Lỗi máy chủ ('+r.status+')
';return}const d=await r.json();if(d.error){el.innerHTML='
'+esc(d.error)+'
';return}if(tab==='detail'&&typeof renderMatchDetail==='function'){renderMatchDetail(el,d);return}el.innerHTML=d.html||'
Không có dữ liệu
'}catch(e){el.innerHTML='
Lỗi
'}} -function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]))} -function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')} -function switchCat(id){document.querySelectorAll('.cat').forEach(c=>c.classList.remove('active'));document.querySelector(`[data-cat="${id}"]`)?.classList.add('active');document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.querySelectorAll('video').forEach(v=>{v.pause();if(v._hls){v._hls.destroy();v._hls=null}});document.querySelectorAll('iframe[data-yt-src]').forEach(f=>{f.src='';const tw=f.closest('.yt-thumb-wrap');if(tw)tw.style.backgroundImage='none'});if(id==='home')document.getElementById('view-home').classList.add('active');else if(id==='news-all'){document.getElementById('view-cat').classList.add('active');loadNewsTab()}else{document.getElementById('view-cat').classList.add('active');loadCat(id)}} -function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}} - -// ===== doShare: COPY link to clipboard, then try native share as bonus ===== - function doShare(title,url,img,postId){ - var shareUrl; - var _base = (typeof SPACE!=='undefined' && SPACE) ? SPACE : location.origin; - if(postId){ - shareUrl = _base+'/s?post_id='+encodeURIComponent(postId)+'&title='+encodeURIComponent(title); - } else { - shareUrl = _base+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||''); - } - // Try clipboard API first (modern, works on HTTPS) - if(navigator.clipboard && navigator.clipboard.writeText){ - navigator.clipboard.writeText(shareUrl).then(function(){ - toast('📋 Đã sao chép link!'); - try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){} - }).catch(function(){ - // Fallback: execCommand (deprecated but works on some browsers) - try{ - var ta=document.createElement('textarea'); - ta.value=shareUrl;ta.style.position='fixed';ta.style.left='-9999px';ta.style.top='-9999px';ta.style.opacity='0'; - document.body.appendChild(ta);ta.select();ta.setSelectionRange(0,99999); - if(document.execCommand('copy')){toast('📋 Đã sao chép link!');} - else{prompt('📋 Sao chép link:', shareUrl);} - document.body.removeChild(ta); - }catch(e){prompt('📋 Sao chép link:', shareUrl);} - try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){} - }); - } else { - // execCommand approach - try{ - var ta=document.createElement('textarea'); - ta.value=shareUrl;ta.style.position='fixed';ta.style.left='-9999px';ta.style.top='-9999px';ta.style.opacity='0'; - document.body.appendChild(ta);ta.select();ta.setSelectionRange(0,99999); - if(document.execCommand('copy')){toast('📋 Đã sao chép link!');} - else{prompt('📋 Sao chép link:', shareUrl);} - document.body.removeChild(ta); - }catch(e){prompt('📋 Sao chép link:', shareUrl);} - try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){} - } -} - +async function loadMatchTab(tab){document.querySelectorAll('.mo-tab').forEach(t=>t.classList.remove('active'));document.querySelectorAll('.mo-tab').forEach(t=>{if((tab==='comm'&&t.textContent==='Diễn biến')||(tab==='stats'&&t.textContent==='Thống kê'))t.classList.add('active')});const el=document.getElementById('mo-body');el.innerHTML='
Đang tải...
';try{const r=await fetch(tab==='stats'?`/api/match/${_currentEventId}/stats`:`/api/match/${_currentEventId}/commentaries`);const d=await r.json();el.innerHTML=d.html||'
Không có dữ liệu
'}catch(e){el.innerHTML='
Lỗi
'}} async function doInteract(videoId,type){try{const r=await fetch('/api/v2/interact',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:videoId,type})});return await r.json();}catch(e){return null;}} async function getInteractions(videoId){try{return await fetch('/api/v2/interactions?id='+encodeURIComponent(videoId)).then(r=>r.json());}catch(e){return{views:0,likes:0,comments:0};}} async function getComments(videoId){try{const j=await fetch('/api/v2/comments?id='+encodeURIComponent(videoId)).then(r=>r.json());return j.comments||[];}catch(e){return[];}} async function postComment(videoId,text){try{const j=await fetch('/api/v2/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:videoId,text})}).then(r=>r.json());return j.comments||[];}catch(e){return[];}} -function buildTikTokSlide(opts){return`
${opts.vtag}
${opts.badge||''}

${esc(opts.title)}

${opts.desc?`

${esc(opts.desc)}

`:''}
${opts.extraBtn||''}
${opts.idx+1}/${opts.total}
`;} -function toggleFeedFullscreen(videoId){ - const slide = videoId ? document.querySelector('.tiktok-slide[data-vid="'+videoId+'"]') : null; - const feedEl = slide ? slide.closest('.tiktok-container') : document.querySelector('.tiktok-container'); - if(!feedEl) return; - const on = feedEl.classList.toggle('feed-fullscreen'); - document.body.style.overflow = on ? 'hidden' : ''; - const icons = feedEl.querySelectorAll('[id^="fs-toggle-"]'); - icons.forEach(ic => { ic.textContent = on ? '✕' : '⛶'; }); - if(on){ - // keep the active slide centered & playable in full height - setTimeout(()=>{ slide && slide.scrollIntoView({block:'nearest'}); }, 60); - } else if(slide){ - slide.scrollIntoView({block:'nearest'}); - } -} +function buildTikTokSlide(opts){return`
${opts.vtag}
${opts.badge||''}

${esc(opts.title)}

${opts.extraBtn||''}
${opts.idx+1}/${opts.total}
`;} async function doView(videoId,btn){const j=await doInteract(videoId,'view');if(j){const c=btn.querySelector('.count');if(c)c.textContent=fmtNum(j.views);}} async function doLike(videoId,btn){const j=await doInteract(videoId,'like');if(j){const c=btn.querySelector('.count');if(c)c.textContent=fmtNum(j.likes);}} function fmtNum(n){if(!n)return'0';if(n>=1000000)return(n/1000000).toFixed(1)+'M';if(n>=1000)return(n/1000).toFixed(1)+'K';return String(n);} async function loadCounters(videoIds){for(let i=0;iĐang tải...
';const cmts=await getComments(videoId);renderInlineComments(panel,videoId,idx,cmts);/* Remove snap so feed can scroll to show comment input */var sl=panel.closest('.tiktok-slide');if(sl)sl.style.scrollSnapAlign='';setTimeout(function(){var feed=document.getElementById('tiktok-feed');if(feed){var slidePos=sl?sl.offsetTop:0;feed.scrollTop=slidePos+200;}/* Focus input */var inp=document.getElementById('cmt-input-'+idx);if(inp)inp.focus();},300);} -function renderInlineComments(panel,videoId,idx,cmts){let h='
💬 Bình luận
';if(cmts.length){cmts.slice(-30).forEach(c=>{h+=`
${c.time||''}${esc(c.text)}
`;});}else{h+='
Chưa có bình luận
';}h+=`
`;panel.innerHTML=h;const list=panel.querySelector('.inline-cmt-list');if(list)list.scrollTop=list.scrollHeight;} -async function submitInlineCmt(videoId,idx){const inp=document.getElementById('cmt-input-'+idx);if(!inp)return;const text=inp.value.trim();if(!text)return;inp.value='';inp.disabled=true;const cmts=await postComment(videoId,text);inp.disabled=false;const panel=document.getElementById('cmt-inline-'+idx);if(panel)renderInlineComments(panel,videoId,idx,cmts);const cc=document.getElementById('cc-'+idx);if(cc)cc.textContent=fmtNum(cmts.length);/* Keep focus on input after submit */var inp2=document.getElementById('cmt-input-'+idx);if(inp2)inp2.focus();} -function toggle169View(videoId,iconId){const slide=videoId?document.querySelector(`.tiktok-slide[data-vid="${videoId}"]`):null;if(slide){slide.classList.toggle('ratio-wide');const iconEl=iconId?document.getElementById(iconId):null;if(iconEl)iconEl.textContent=slide.classList.contains('ratio-wide')?'📺':'🖥️';else{const btn=slide.querySelector('.tiktok-right-btn .icon');if(btn)btn.textContent=slide.classList.contains('ratio-wide')?'📺':'🖥️';}return}document.querySelectorAll('.tiktok-slide.ratio-wide').forEach(s=>s.classList.remove('ratio-wide'));document.querySelectorAll('.tiktok-slide').forEach(s=>s.classList.add('ratio-wide'));document.querySelectorAll('.tiktok-right-btn .icon').forEach(b=>{if(b.textContent==='🖥️')b.textContent='📺';})} -function initTikTokFeed(){const feed=document.getElementById('tiktok-feed');if(!feed)return;const slides=feed.querySelectorAll('.tiktok-slide');let cur=-1;function act(i){if(i===cur)return;slides.forEach((sl,idx)=>{const v=sl.querySelector('video');const fr=sl.querySelector('iframe');if(idx===i){sl._active=true;if(v&&v.dataset.hls&&!v._hls&&typeof Hls!=='undefined'&&Hls.isSupported()){const hls=new Hls();hls.loadSource(v.dataset.hls);hls.attachMedia(v);hls.on(Hls.Events.MANIFEST_PARSED,()=>v.play().catch(()=>{}));v._hls=hls}else if(v)v.play().catch(()=>{});if(fr&&!fr.src&&fr.dataset.ytSrc){fr.src=fr.dataset.ytSrc;const tw=fr.closest('.yt-thumb-wrap');if(tw)tw.style.backgroundImage='none'}const vid=sl.dataset.vid;if(vid&&!sl._viewed){sl._viewed=true;doInteract(vid,'view').then(j=>{if(j){const vc=document.getElementById('vc-'+idx);if(vc)vc.textContent=fmtNum(j.views);}});}}else{sl._active=false;if(v){v.pause();if(v._hls){v._hls.destroy();v._hls=null}}if(fr&&fr.src)fr.src=''}});cur=i}let sT;feed.addEventListener('scroll',()=>{clearTimeout(sT);sT=setTimeout(()=>{const rect=feed.getBoundingClientRect(),ctr=rect.top+rect.height/2;let best=-1,bestD=1e9;slides.forEach((sl,i)=>{const d=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(d=0)act(best)},150)});setTimeout(()=>act(0),400);slides.forEach(sl=>{const g=sl.querySelector('.slide-gesture');const v=sl.querySelector('video');const fr=sl.querySelector('iframe');const tapFn=e=>{if(g&&e.target!==g)return;if(v){e.preventDefault();if(e.detail>2)return;v.paused?v.play().catch(()=>{}):v.pause()}else if(fr&&fr.dataset.ytSrc&&sl._active){const src=fr.dataset.ytSrc;if(/tiktok\.com\/embed|facebook\.com\/plugins|instagram\.com\/p\/.*\/embed|platform\.twitter\.com|player\.vimeo\.com|youtube\.com\/embed/.test(src)){window.open(src.replace(/&/g,'&'),'_blank')}}};if(g)g.addEventListener('click',tapFn);if(v)v.addEventListener('click',e=>{e.preventDefault();v.paused?v.play().catch(()=>{}):v.pause()})});const ids=[...slides].map(sl=>sl.dataset.vid||'');loadCounters(ids)} -async function openHighlightFeed(league,idx,link,forceUrl){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='
Đang tải...
';let articles=(_hlLeagueData||{})[league]||[];if(!articles.length){try{articles=await fetch('/api/highlights/'+league).then(r=>r.json())}catch(e){articles=[]}}if(!articles.length){el.innerHTML='
Không có video
';return}const vids=[];const results=await Promise.all(articles.map(async(a,i)=>{try{const r=await fetch('/api/video_url?url='+encodeURIComponent(a.link)+'&img='+encodeURIComponent(a.img||''));const v=await r.json();if(v&&v.src){return{_idx:i,title:a.title||v.title||'',link:a.link||'',img:a.img||v.poster||'',src:v.src,type:v.type||'',poster:v.poster||a.img||''}}return null}catch(e){}return null}));results.forEach(r=>{if(r)vids.push(r)});vids.sort((a,b)=>a._idx-b._idx);if(!vids.length){el.innerHTML='
Không tìm thấy video
';return}let ti=-1;if(link){ti=vids.findIndex(v=>(v.link||'')===(link||''));}if(ti<0)ti=vids.findIndex(v=>v._idx===idx);if(ti<0)ti=0;const ordered=ti>0?[...vids.slice(ti),...vids.slice(0,ti)]:vids;let h=`
`;ordered.forEach((v,i)=>{const isYT=v.type==='youtube',isHLS=!isYT&&v.src?.includes('.m3u8'),poster=v.poster?` poster="${esc(v.poster)}"`:'';const vtag=isYT?`
`:isHLS?``:``;const videoId='hl-'+league+'-'+v._idx;const nv=(i+1)%ordered.length;h+=buildTikTokSlide({vtag,title:v.title,badge:'HL',badgeClass:'badge-fpt',videoId,idx:i,total:ordered.length,shareUrl:v.link||'',postId:'',extraBtn:``})});h+='
';el.innerHTML=h;setTimeout(()=>initTikTokFeed(),200);} -async function openYTShortsFeed(idx){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='
Đã xóa Shorts Dân trí/SKĐS
';} -async function openShortAIFeed(idx){showView('view-tiktok');const el=document.getElementById('view-tiktok');if(!_wallPosts||!_wallPosts.length){el.innerHTML='
Không có Short AI
';return}const aiPosts=_wallPosts.filter(p=>p.video);if(!aiPosts.length||idx>=aiPosts.length){el.innerHTML='
Không có Short AI
';return}const ordered=aiPosts.slice(idx).concat(aiPosts.slice(0,idx));let h=`
`;ordered.forEach((p,i)=>{const baseIdx=_wallPosts.indexOf(p);const isYT=/youtube\.com\/embed|youtu\.be\/|youtube-nocookie|tiktok\.com\/embed|facebook\.com\/plugins|instagram\.com\/p\/.*\/embed|platform\.twitter\.com|player\.vimeo\.com/.test(p.video||'');const vtag=p.video?isYT?`
`:``:'';h+=buildTikTokSlide({vtag,title:p.title,badge:'Short AI',badgeClass:'badge-ai',videoId:p.id||'ai-'+i,idx:i,total:ordered.length,shareUrl:p.video||'',postId:p.id||'',desc:(p.text&&p.text!==p.title?p.text:'').slice(0,400),extraBtn:``})});h+='
';el.innerHTML=h;setTimeout(()=>initTikTokFeed(),200);} -function readWallPost(idx){const p=_wallPosts&&_wallPosts[idx];if(!p)return;if(p.slides&&p.slides.length){readSlidePost(idx);return}const isEmb=p.embed_oembed||/(tiktok\.com\/embed|facebook\.com\/plugins|instagram\.com\/p\/.*\/embed|youtube\.com\/embed|platform\.twitter\.com|player\.vimeo\.com)/.test(p.video||'');if(isEmb||(p.video&&p.video.indexOf('/api/ai/short-file/')===0&&!p.url)){openShortAIFeed(_wallPosts.filter(x=>x.video).indexOf(p));return}readArticle(p.url||'','','',p.title,p.text);} -/** Show rewrite slide viewer - vertical slides with text+image */ -function readSlidePost(idx){const p=_wallPosts[idx];if(!p||!p.slides)return;showView('view-article');const el=document.getElementById('view-article');let h=`
`;p.slides.forEach((s,i)=>{h+=`
Slide ${s.index||i+1}/${p.slides.length}
${s.image?``:''}

${esc(s.text)}

`;});h+=`
`;el.innerHTML=h;} - -/** Slide Designer Modal - design a slide image with high-contrast text on background */ -function designerGetRatio(){const v=document.getElementById('designer-ratio')?.value||'3:4';const m=v.split(':');return m.length===2?{w:parseInt(m[0]),h:parseInt(m[1])}:1;} -function designerGetLayout(){return document.getElementById('designer-layout')?.value||'solid';} -function designerDebounce(fn,d){clearTimeout(fn._t);fn._t=setTimeout(fn,d);} -function openSlideDesigner(idx){ - designerSlideIdx=idx; - const p=_wallPosts[idx];if(!p||!p.slides)return; - const overlay=document.createElement('div');overlay.id='slide-designer-overlay';overlay.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:99999;display:flex;align-items:center;justify-content:center;padding:16px;overflow-y:auto'; - let h='
'; - h+='

🎨 Thiết kế ảnh slide

'; - h+='
'; - h+='
'; - h+='
'; - h+='
'; - h+='
'; - ['#ffffff','#000000','#ff4444','#44ff44','#4444ff','#ffff00','#ff00ff','#00ffff','#ff8800','#88ff00'].forEach(c=>{h+=``;}); - h+='
'; - h+='
'; - h+='
'; - h+=''; - h+='
'; - overlay.innerHTML=h;document.body.appendChild(overlay); - document.getElementById('designer-slide-select').addEventListener('change',function(){updateDesignerText(this.value);previewDesignerSlide();}); - document.getElementById('designer-preview-btn').addEventListener('click',function(){previewDesignerSlide();}); - document.getElementById('designer-save-btn').addEventListener('click',function(){saveDesignerSlide(p.title,(p.id||''),idx);}); - ['change','input'].forEach(function(evt){ - const txt=document.getElementById('designer-text');if(txt)txt.addEventListener(evt,function(){designerDebounce(previewDesignerSlide,150);}); - const bg=document.getElementById('designer-bg-url');if(bg)bg.addEventListener(evt,function(){designerDebounce(previewDesignerSlide,150);}); - const ratio=document.getElementById('designer-ratio');if(ratio)ratio.addEventListener(evt,function(){previewDesignerSlide();}); - const layout=document.getElementById('designer-layout');if(layout)layout.addEventListener(evt,function(){previewDesignerSlide();}); - }); - previewDesignerSlide(); -} -function updateDesignerText(slideIdx){const p=_wallPosts[designerSlideIdx];if(!p||!p.slides)return;const s=p.slides[parseInt(slideIdx)];if(s)document.getElementById('designer-text').value=s.text||'';document.getElementById('designer-bg-url').value=s.image||p.img||'';designerBgDataUrl=null;} -function handleDesignerBgFile(input){ - const file=input.files&&input.files[0];if(!file)return; - const reader=new FileReader(); - reader.onload=function(e){designerBgDataUrl=e.target.result;document.getElementById('designer-bg-url').value='[uploaded]';}; - reader.readAsDataURL(file); -} -function previewDesignerSlide(){ - const slideIdx=parseInt(document.getElementById('designer-slide-select')?.value||'0'); - const p=_wallPosts[designerSlideIdx];if(!p||!p.slides)return; - const s=p.slides[slideIdx];if(!s)return; - const text=document.getElementById('designer-text')?.value||s.text||''; - const bgUrlInput=document.getElementById('designer-bg-url')?.value||''; - const bgUrl=designerBgDataUrl||bgUrlInput||s.image||p.img||''; - const textColor=document.getElementById('designer-text')?.style.color||'#ffffff'; - const ratio=designerGetRatio(); - const layout=designerGetLayout(); - const previewArea=document.getElementById('designer-preview-area');if(!previewArea)return; - previewArea.style.display='block';previewArea.innerHTML='
⏳ Đang tạo xem trưởng...
'; - const canvas=document.createElement('canvas'); - const vw=Math.max(document.documentElement.clientWidth||540, document.body.clientWidth||540); - const maxW=Math.min(540, Math.floor(vw*0.9)); - canvas.width=maxW;canvas.height=Math.round(maxW*ratio.h/ratio.w); - canvas.style.maxWidth='100%';canvas.style.width='100%';canvas.style.height='auto'; - canvas.style.borderRadius='12px';canvas.style.boxShadow='0 8px 24px rgba(0,0,0,.5)'; - canvas.style.display='block';canvas.style.margin='0 auto'; - const ctx=canvas.getContext('2d');const img=new Image();img.crossOrigin='anonymous'; - function drawLayout(){ - if(layout==='vignette'){const g=ctx.createRadialGradient(canvas.width/2,canvas.height/2,0,canvas.width/2,canvas.height/2,Math.max(canvas.width,canvas.height)*0.6);g.addColorStop(0,'rgba(0,0,0,0)');g.addColorStop(1,'rgba(0,0,0,0.7)');ctx.fillStyle=g;ctx.fillRect(0,0,canvas.width,canvas.height);} - else if(layout==='split'){ctx.fillStyle='rgba(0,0,0,0)';ctx.fillRect(0,0,canvas.width,canvas.height/2);ctx.fillStyle='rgba(0,0,0,0.7)';ctx.fillRect(0,canvas.height/2,canvas.width,canvas.height/2);} - else if(layout==='spotlight'){const cx=canvas.width/2,cy=canvas.height/2,r=Math.max(canvas.width,canvas.height)*0.35;ctx.save();ctx.fillStyle='rgba(0,0,0,0.7)';ctx.fillRect(0,0,canvas.width,canvas.height);ctx.globalCompositeOperation='destination-out';ctx.fillStyle='white';ctx.beginPath();ctx.arc(cx,cy,r,0,Math.PI*2);ctx.fill();ctx.globalCompositeOperation='source-over';} - else if(layout==='diagonal'){ctx.fillStyle='rgba(0,0,0,0.55)';ctx.fillRect(0,0,canvas.width,canvas.height);ctx.fillStyle='rgba(0,0,0,0.75)';ctx.beginPath();ctx.moveTo(0,0);ctx.lineTo(canvas.width,0);ctx.lineTo(0,canvas.height);ctx.closePath();ctx.fill();} - else {ctx.fillStyle='rgba(0,0,0,0.55)';ctx.fillRect(0,0,canvas.width,canvas.height);} - } - function drawText(){ - ctx.fillStyle=textColor;ctx.font='bold '+Math.round(canvas.width*28/540)+'px sans-serif'; - ctx.textAlign='center';ctx.textBaseline='middle'; - const maxW=Math.round(canvas.width*0.82); - const words=text.split(' ');let line='';let y=Math.round(canvas.height/2); - const fontSize=Math.round(canvas.width*28/540);const lineHeight=Math.round(fontSize*1.3); - for(let i=0;imaxW&&i>0){ctx.fillText(line.trim(),canvas.width/2,y);y+=lineHeight;line=words[i];}else{line=test;}} - ctx.fillText(line.trim(),canvas.width/2,y); - } - function renderAll(){ - ctx.fillStyle='#1a1a1a';ctx.fillRect(0,0,canvas.width,canvas.height); - drawLayout(); - drawText(); - previewArea.innerHTML='';previewArea.appendChild(canvas); - } - img.onload=function(){ - const iw=img.naturalWidth||1,ih=img.naturalHeight||1; - const scale=Math.max(canvas.width/iw,canvas.height/ih); - const dw=iw*scale,dh=ih*scale; - const dx=(canvas.width-dw)/2,dy=(canvas.height-dh)/2; - ctx.drawImage(img,0,0,iw,ih,dx,dy,dw,dh); - drawLayout();drawText(); - previewArea.innerHTML='';previewArea.appendChild(canvas); - }; - img.onerror=function(){renderAll();}; - if(bgUrl){img.src=bgUrl.startsWith('/api/')?bgUrl:bgUrl.startsWith('data:')?bgUrl:'/api/proxy/img?url='+encodeURIComponent(bgUrl);}else{renderAll();} -} -async function saveDesignerSlide(title,postId,idx){ - const slideIdx=parseInt(document.getElementById('designer-slide-select')?.value||'0'); - const p=_wallPosts[idx];if(!p||!p.slides)return; - const s=p.slides[slideIdx];if(!s)return; - const text=document.getElementById('designer-text')?.value||s.text||''; - const bgUrlInput=document.getElementById('designer-bg-url')?.value||''; - const bgUrl=designerBgDataUrl||bgUrlInput||s.image||p.img||''; - const textColor=document.getElementById('designer-text')?.style.color||'#ffffff'; - const ratio=designerGetRatio(); - const layout=designerGetLayout(); - const statusEl=document.getElementById('designer-status');if(statusEl)statusEl.textContent='⏳ Đang tạo ảnh...'; - const FINAL_W=ratio.w>=ratio.h?1800:1350; - const FINAL_H=Math.round(FINAL_W*ratio.h/ratio.w); - const canvas=document.createElement('canvas');canvas.width=FINAL_W;canvas.height=FINAL_H; - const ctx=canvas.getContext('2d'); - const img=new Image();img.crossOrigin='anonymous'; - function drawLayoutF(){ - if(layout==='vignette'){const g=ctx.createRadialGradient(FINAL_W/2,FINAL_H/2,0,FINAL_W/2,FINAL_H/2,Math.max(FINAL_W,FINAL_H)*0.6);g.addColorStop(0,'rgba(0,0,0,0)');g.addColorStop(1,'rgba(0,0,0,0.7)');ctx.fillStyle=g;ctx.fillRect(0,0,FINAL_W,FINAL_H);} - else if(layout==='split'){ctx.fillStyle='rgba(0,0,0,0)';ctx.fillRect(0,0,FINAL_W,FINAL_H/2);ctx.fillStyle='rgba(0,0,0,0.7)';ctx.fillRect(0,FINAL_H/2,FINAL_W,FINAL_H/2);} - else if(layout==='spotlight'){const cx=FINAL_W/2,cy=FINAL_H/2,r=Math.max(FINAL_W,FINAL_H)*0.35;ctx.save();ctx.fillStyle='rgba(0,0,0,0.7)';ctx.fillRect(0,0,FINAL_W,FINAL_H);ctx.globalCompositeOperation='destination-out';ctx.fillStyle='white';ctx.beginPath();ctx.arc(cx,cy,r,0,Math.PI*2);ctx.fill();ctx.globalCompositeOperation='source-over';} - else if(layout==='diagonal'){ctx.fillStyle='rgba(0,0,0,0.55)';ctx.fillRect(0,0,FINAL_W,FINAL_H);ctx.fillStyle='rgba(0,0,0,0.75)';ctx.beginPath();ctx.moveTo(0,0);ctx.lineTo(FINAL_W,0);ctx.lineTo(0,FINAL_H);ctx.closePath();ctx.fill();} - else {ctx.fillStyle='rgba(0,0,0,0.55)';ctx.fillRect(0,0,FINAL_W,FINAL_H);} - } - function drawTextF(){ - ctx.fillStyle=textColor;ctx.font='bold '+Math.round(FINAL_W*48/1080)+'px sans-serif'; - ctx.textAlign='center';ctx.textBaseline='middle'; - const maxW=Math.round(FINAL_W*0.82);const words=text.split(' ');let line='';let y=Math.round(FINAL_H/2); - const fontSize=Math.round(FINAL_W*48/1080);const lineHeight=Math.round(fontSize*1.3); - for(let i=0;imaxW&&i>0){ctx.fillText(line.trim(),FINAL_W/2,y);y+=lineHeight;line=words[i];}else{line=test;}}ctx.fillText(line.trim(),FINAL_W/2,y); - } - function render(){ - ctx.fillStyle='#1a1a1a';ctx.fillRect(0,0,FINAL_W,FINAL_H); - drawLayoutF();drawTextF(); - canvas.toBlob(async function(blob){ - const formData=new FormData();formData.append('file',blob,'slide_'+slideIdx+'.png');formData.append('post_id',postId||p.id||''); - try{const r=await fetch('/api/wall/img',{method:'POST',body:formData});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi upload'); - s.image=j.url; - const post={id:p.id,title:title||p.title,text:p.text,img:j.url,url:p.url,slides:p.slides,images:p.images,voice:p.voice,emotion:p.emotion,language:p.language,ts:p.ts,kind:p.kind||'slide_summary'}; - const wr=await fetch('/api/wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(post)}); - const wj=await wr.json();if(wr.ok&&wj.post){prependWallPost(wj.post);toast('✅ Đã lưu ảnh đăng lên Tường AI!');}else{toast('✅ Đã lưu ảnh slide!');} - document.getElementById('slide-designer-overlay').remove(); - }catch(e){if(statusEl)statusEl.textContent='❌ '+e.message;toast('❌ '+e.message);} - },'image/png'); - } - img.onload=function(){ - const iw=img.naturalWidth||1,ih=img.naturalHeight||1; - const scale=Math.max(FINAL_W/iw,FINAL_H/ih); - const dw=iw*scale,dh=ih*scale; - const dx=(FINAL_W-dw)/2,dy=(FINAL_H-dh)/2; - ctx.drawImage(img,0,0,iw,ih,dx,dy,dw,dh); - drawLayoutF();drawTextF(); - canvas.toBlob(async function(blob){ - const formData=new FormData();formData.append('file',blob,'slide_'+slideIdx+'.png');formData.append('post_id',postId||p.id||''); - try{const r=await fetch('/api/wall/img',{method:'POST',body:formData});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi upload'); - s.image=j.url; - const post={id:p.id,title:title||p.title,text:p.text,img:j.url,url:p.url,slides:p.slides,images:p.images,voice:p.voice,emotion:p.emotion,language:p.language,ts:p.ts,kind:p.kind||'slide_summary'}; - const wr=await fetch('/api/wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(post)}); - const wj=await wr.json();if(wr.ok&&wj.post){prependWallPost(wj.post);toast('✅ Đã lưu ảnh đăng lên Tường AI!');}else{toast('✅ Đã lưu ảnh slide!');} - document.getElementById('slide-designer-overlay').remove(); - }catch(e){if(statusEl)statusEl.textContent='❌ '+e.message;toast('❌ '+e.message);} - },'image/png'); - }; - img.onerror=function(){if(statusEl)statusEl.textContent='⚠️ Không tải được ảnh nền, dùng nền mặc định';ctx.fillStyle='#1a1a1a';ctx.fillRect(0,0,FINAL_W,FINAL_H);drawLayoutF();drawTextF();canvas.toBlob(function(blob){ - const formData=new FormData();formData.append('file',blob,'slide_'+slideIdx+'.png');formData.append('post_id',postId||p.id||''); - fetch('/api/wall/img',{method:'POST',body:formData}).then(r=>r.json()).then(j=>{ - if(j.ok){s.image=j.url;const post={id:p.id,title:title||p.title,text:p.text,img:j.url,url:p.url,slides:p.slides,images:p.images,voice:p.voice,emotion:p.emotion,language:p.language,ts:p.ts,kind:p.kind||'slide_summary'}; - fetch('/api/wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(post)}).then(r=>r.json()).then(wj=>{if(wj.post)prependWallPost(wj.post);toast('✅ Đã lưu ảnh đăng lên Tường AI!');document.getElementById('slide-designer-overlay').remove();});} - }).catch(e=>{if(statusEl)statusEl.textContent='❌ '+e.message;toast('❌ '+e.message);}); - },'image/png');}; - if(bgUrl){img.src=bgUrl.startsWith('/api/')?bgUrl:bgUrl.startsWith('data:')?bgUrl:'/api/proxy/img?url='+encodeURIComponent(bgUrl);}else{render();} -} - -function readNewsTab(tab){loadNewsTab();} -function loadNewsTab(){const el=document.getElementById('view-cat');if(!el)return;el.innerHTML='
Đang tải tin tức...
';fetch('/api/homepage').then(r=>r.json()).then(articles=>{if(!articles||!articles.length){el.innerHTML='
Không có tin
';return}let h='
';articles.forEach(a=>{const src=a.source||'vne';const badge=a.group||a.source||'';h+=`
${a.img?``:''}
${esc(badge)}
${esc(a.title)}
`;});h+='
';el.innerHTML=h;}).catch(()=>{el.innerHTML='
Lỗi tải
';});} - -function readArticle(url,title,img,presetTitle,presetText){showView('view-article');const el=document.getElementById('view-article');el.innerHTML='
Đang tải...
';if(presetTitle){el.innerHTML=`

${esc(presetTitle)}

${presetText?`
${esc(presetText)}
`:''}
`;return;}if(!url)return;fetch('/api/article?url='+encodeURIComponent(url)).then(r=>r.json()).then(d=>{let h=`
`;if(d.title)h+=`

${esc(d.title)}

`;if(d.summary)h+=`
${esc(d.summary)}
`;if(d.body)d.body.forEach(b=>{if(b.type==='p')h+=`

${esc(b.text)}

`;else if(b.type==='heading')h+=`

${esc(b.text)}

`;else if(b.type==='img'&&b.src)h+=``;});h+=`
`;el.innerHTML=h;}).catch(()=>{el.innerHTML=`

Không thể tải bài viết

`;});} -async function rewriteSlide(url){if(!url)return;const btn=document.querySelector('.article-actions .primary')||event?.target;if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo slides...';}toast('⏳ Đang tạo slide rewrite...');try{const r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');toast('✅ Đã tạo slide! Đang tạo Short AI...');if(btn)btn.textContent='✅ Đang tạo Short AI...';if(j.post){j.post.slides=j.slides||[];prependWallPost(j.post);}// Show slides immediately if available -if(j.slides && j.slides.length){showView('view-article');const el=document.getElementById('view-article');let h=`
`;j.slides.forEach(s=>{h+=`
Slide ${s.index}/${j.slides.length}
${s.image?``:''}

${esc(s.text)}

`;});h+=`
⏳ Đang tạo video Short AI...
`;el.innerHTML=h;} -const postId=j.post&&j.post.id;if(postId){setTimeout(async()=>{const sr=await fetch('/api/ai/short/'+encodeURIComponent(postId),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:'vi-VN-HoaiMyNeural',emotion:'neutral',speed:1.2})});const sj=await sr.json();if(sr.ok&&sj.video&&typeof _wallPosts!=='undefined'){const p=_wallPosts.find(x=>String(x.id)===String(postId));if(p){p.video=sj.video;const itemId='wall-item-'+postId;const el2=document.getElementById(itemId);if(el2){const idx=_wallPosts.indexOf(p);el2.insertAdjacentHTML('afterend',makeWallItem(p,idx));el2.remove();}}toast('✅ Short AI đã sẵn sàng!');const statusEl=document.getElementById('short-ai-status');if(statusEl)statusEl.innerHTML='✅ Short AI đẵn sàng! ';}},500);}if(btn)btn.textContent='✅ Hoàn tất';}catch(e){toast('❌ '+e.message);if(btn){btn.disabled=false;btn.textContent='🤖 Slide Rewrite AI';}}} - -function downloadVideo(url, title){ - var a = document.createElement('a'); - a.href = url; - a.download = (title||'video').toString().replace(/[^a-zA-Z0-9_\-\p{L}]/gu,'_').substring(0,60)+'.mp4'; - a.target = '_blank'; - a.rel = 'noopener'; - a.style.display = 'none'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - toast('📥 Đang tải video xuống...'); -} - -// ===== PERSONAL OPINION POST: Viết bài dựa trên quan điểm cá nhân + tin HOT ===== -async function openPersonalPostPreview() { - const opinion = document.getElementById('opinion-input')?.value.trim(); - if (!opinion || opinion.length < 10) { - alert('Vui lòng nhập quan điểm cá nhân (ít nhất 10 ký tự)'); - return; - } - - // Get selected hot topics from UI (if any) - const selectedTopics = []; - if (window._htTopic) { - selectedTopics.push(window._htTopic); - } - - // Get selected sources from hashtag view - const selectedSources = []; - document.querySelectorAll('.hashtag-src-item.selected').forEach(el => { - const idx = parseInt(el.dataset.idx || '0'); - // Would need source tracking - }); - - const btn = event?.target; - const origText = btn ? btn.textContent : ''; - if (btn) { btn.disabled = true; btn.textContent = '⏳ Đang tạo preview...'; } - - try { - const resp = await fetch('/api/personal_post/preview', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - opinion: opinion, - selected_topics: selectedTopics, - selected_sources: selectedSources - }) - }); - const data = await resp.json(); - if (!resp.ok || data.error) throw new Error(data.error || 'Lỗi tạo preview'); - - showPersonalPostModal(data.preview, opinion, selectedTopics, selectedSources); - } catch (e) { - toast('❌ ' + e.message); - } finally { - if (btn) { btn.disabled = false; btn.textContent = origText; } - } -} - -function showPersonalPostModal(preview, originalOpinion, selectedTopics, selectedSources) { - // Remove existing modal - const existing = document.getElementById('personal-post-modal'); - if (existing) existing.remove(); - - const modal = document.createElement('div'); - modal.id = 'personal-post-modal'; - modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.9);z-index:99999;display:flex;align-items:center;justify-content:center;padding:16px;overflow-y:auto'; - - // Each slide renders EXACTLY like the designer's final PNG (same ratio, - // same bg draw, same text draw) — preview = pixel-identical to wall post image. - const slidesHtml = (preview.slides || []).map((s, i) => { - const ratio = {w:3, h:4}; // preview always 3:4 (same as designer final) - const slideStyle = 'width:100%;max-width:280px;margin:0 auto;display:block;border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,.5)'; - return '
' - + '
Slide ' + (i+1) + '
' - + '' - + '' - + '
'; - }).join(''); - - // Background image source: uploaded data-URL (set later) or slide.image / first source image. - // We keep a small state object so text edits re-render live. - window._pvState = { slides: (preview.slides||[]).map(function(s,i){ - var bg = s.image || (preview.images && preview.images[0]) || ''; - return { text: s.text||'', bg: bg }; - }), ratio: 3/4, layout: 'solid', filter: 'none', glow: 'white', pos: 'bottom', color: '#ffffff' }; - - const sourcesHtml = (preview.sources || []).map((s, i) => { - const imgSrc = (preview.images && preview.images[i+1]) ? preview.images[i+1] : ''; - return '
' - + '
' - + (imgSrc ? '' : '') - + '
' - + '
' + (s.title || '') + '
' - + '' - + '
'; - }).join(''); - - modal.innerHTML = '
' - + '
' - + '

📝 Xem trước bài quan điểm cá nhân

' - + '' - + '
' - + '
' - + '' - + '' - + '
' - + '
' - + '' - + '' - + '
' - + '
' - + '' - + '
' - + '' - + '' - + '
' - + '
' + slidesHtml + '
' - + '
' - + '
' - + '' - + '
' - + (sourcesHtml || '
Không có nguồn tin
') - + '
' - + '
' - + '' - + '' - + '
'; - - document.body.appendChild(modal); - - // draw every slide preview canvas - previewRedrawAllSlides(); - - // text edits re-render live - modal.querySelectorAll('.preview-slide-text').forEach(ta => { - ta.addEventListener('input', function(){ - const idx = parseInt(this.dataset.idx || '0', 10); - if(window._pvState && window._pvState.slides[idx]) window._pvState.slides[idx].text = this.value; - previewDrawSlide(idx); - }); - }); - - // Store preview data for publishing - window._personalPostPreview = preview; - window._personalPostOpinion = originalOpinion; -} - -/* Live-edit slide preview canvas (exact same rendering style as designer output) */ -function previewDrawSlide(idx){ - const canvas = document.querySelector('.preview-slide-canvas[data-idx="'+idx+'"]'); - if(!canvas || !window._pvState) return; - const st = window._pvState.slides[idx]; - if(!st) return; - const W = 600, H = Math.round(W * window._pvState.ratio); // 3:4 -> 800 - canvas.width = W; canvas.height = H; - const ctx = canvas.getContext('2d'); - const opts = { text: st.text, pos: window._pvState.pos, glow: window._pvState.glow, - color: window._pvState.color, layout: window._pvState.layout, - filter: window._pvState.filter, bgUrl: st.bg }; - function draw(img){ - // base dark - ctx.fillStyle='#141414'; ctx.fillRect(0,0,W,H); - if(img){ - ctx.save(); - let f=''; - if(opts.filter==='grayscale') f='grayscale(1)'; - else if(opts.filter==='sepia') f='sepia(0.85)'; - else if(opts.filter==='saturate') f='saturate(2.4)'; - else if(opts.filter==='warm') f='sepia(0.45) saturate(1.5) hue-rotate(-15deg)'; - else if(opts.filter==='cool') f='saturate(1.2) hue-rotate(15deg) brightness(1.05)'; - else if(opts.filter==='invert') f='invert(1)'; - else if(opts.filter==='noir') f='grayscale(1) contrast(1.6) brightness(0.9)'; - const iw=img.naturalWidth||1, ih=img.naturalHeight||1; - const scale=Math.max(W/iw, H/ih); - const dw=iw*scale, dh=ih*scale; - ctx.drawImage(img,0,0,iw,ih,(W-dw)/2,(H-dh)/2,dw,dh); - ctx.filter='none'; - ctx.restore(); - } - // solid overlay like designer - ctx.fillStyle='rgba(0,0,0,0.6)'; ctx.fillRect(0,0,W,H); - drawPreviewText(ctx,W,H,opts); - } - const bgSrc = (typeof _proxyImg==='function') ? _proxyImg(st.bg||'') : (st.bg || ''); - if(bgSrc){ - const im = new Image(); im.crossOrigin='anonymous'; - im.onload = function(){ draw(im); }; - im.onerror = function(){ draw(null); }; - im.src = bgSrc; - } else draw(null); -} -function drawPreviewText(ctx,W,H,opts){ - const text = opts.text || ''; - if(!text.trim()) return; - const fs = Math.min(52, Math.round(W*0.09)); - ctx.font = 'bold '+fs+'px "Segoe UI","Arial","Noto Sans",sans-serif'; - ctx.textAlign='center'; - const lines=[]; let line=''; - const maxW=Math.round(W*0.82); - text.split(' ').forEach(w=>{ - const test = line? line+' '+w : w; - if(ctx.measureText(test).width>maxW && line){ lines.push(line.trim()); line=w; } else { line=test; } - }); - if(line) lines.push(line.trim()); - const lh=Math.round(fs*1.25); - const blockH=lines.length*lh; - let y; - if(opts.pos==='top') y=Math.round(H*0.14)+lh/2; - else if(opts.pos==='bottom') y=Math.round(H*0.88)-blockH+lh/2; - else y=Math.round(H/2)-blockH/2+lh/2; - if(opts.glow==='white'){ ctx.shadowColor='rgba(255,255,255,0.95)'; ctx.shadowBlur=Math.max(8,fs*0.5); } - else if(opts.glow==='color'){ ctx.shadowColor=opts.color; ctx.shadowBlur=Math.max(10,fs*0.6); } - else if(opts.glow==='neon'){ ctx.shadowColor='#00ffff'; ctx.shadowBlur=Math.max(16,fs*0.9); } - ctx.fillStyle=opts.color; - lines.forEach((l,li)=>{ ctx.fillText(l, W/2, y+li*lh); }); - ctx.shadowBlur=0; -} -function previewRedrawAllSlides(){ - if(!window._pvState) return; - window._pvState.slides.forEach(function(_, i){ previewDrawSlide(i); }); -} -function openPreviewDesigner(){ - // opens the real slide designer on the FIRST preview slide as a background. - // The designer modal already renders canvas + uploads PNGs; we reuse it by - // temporarily creating a wall-post-like object from the preview slides. - if(!window._personalPostPreview || !window._pvState) return; - const slides = window._pvState.slides.map(function(s,i){ - return { text: s.text, image: s.bg, index: i+1 }; - }); - const fakePost = { - id: 'preview-fake', title: document.getElementById('preview-title')?.value || 'Bài quan điểm', - slides: slides, kind: 'personal_opinion', img: slides[0]?.image||'', url: '', video: '' - }; - if(typeof openSlideDesigner === 'function'){ - // designer expects index into _wallPosts; temporarily append so designer works - const existed = _wallPosts && _wallPosts.some(p => p.id === 'preview-fake'); - if(!existed && Array.isArray(_wallPosts)) _wallPosts.unshift(fakePost); - const idx = _wallPosts.findIndex(p => p.id === 'preview-fake'); - openSlideDesigner(idx >= 0 ? idx : 0); - // the fake post is only a designer context; the designer's own save posts - // the designed slides directly to the wall. Remove the fake entry on close. - setTimeout(function(){ - const ov = document.getElementById('slide-designer-overlay'); - if(!ov && Array.isArray(_wallPosts)){ - _wallPosts = _wallPosts.filter(p => p.id !== 'preview-fake'); - } - }, 30000); - } else { - toast('Không có công cụ thiết kế!'); - } -} - -function toggleSourceSelection(el, idx) { - const checkbox = el.querySelector('.source-checkbox'); - checkbox.checked = !checkbox.checked; - el.style.border = checkbox.checked ? '1px solid #2d8659' : '1px solid transparent'; - el.style.background = checkbox.checked ? '#1a2a1f' : '#202020'; -} - -async function publishPersonalPostFromModal() { - const title = document.getElementById('preview-title')?.value.trim(); - if (!title) { - alert('Vui lòng nhập tiêu đề'); - return; - } - - // Collect edited slides (text + designed/bg image from _pvState) - const slides = []; - if (window._pvState && window._pvState.slides) { - window._pvState.slides.forEach((s, i) => { - const text = (s.text || '').trim(); - if (text) slides.push({ text, image: s.bg || '', index: slides.length + 1 }); - }); - } else { - document.querySelectorAll('.preview-slide-text').forEach(ta => { - const text = ta.value.trim(); - if (text) slides.push({ text, image: '', index: slides.length + 1 }); - }); - } - - // Collect selected sources - const sources = []; - document.querySelectorAll('[data-source-idx]').forEach((el, i) => { - const checkbox = el.querySelector('.source-checkbox'); - if (checkbox.checked && window._personalPostPreview?.sources?.[i]) { - sources.push(window._personalPostPreview.sources[i]); - } - }); - - const btn = event.target; - const origText = btn.textContent; - btn.disabled = true; - btn.textContent = '⏳ Đang đăng...'; - - try { - const resp = await fetch('/api/personal_post', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - opinion: window._personalPostOpinion, - selected_topics: [], - selected_sources: sources, - custom_title: title, - custom_slides: slides - }) - }); - const data = await resp.json(); - if (!resp.ok || data.error) throw new Error(data.error || 'Lỗi đăng bài'); - - toast('✅ Đã đăng bài quan điểm lên Tường AI!'); - - // Prepend to wall - if (data.post && typeof prependWallPost === 'function') { - prependWallPost(data.post); - } - - // Close modal - document.getElementById('personal-post-modal')?.remove(); - - // Clear opinion input - document.getElementById('opinion-input').value = ''; - } catch (e) { - toast('❌ ' + e.message); - } finally { - btn.disabled = false; - btn.textContent = origText; - } -} - -// === M3U IPTV Player === -var _m3uChannels = []; - -async function loadM3UChannels(){ - var el = document.getElementById('view-m3u'); - if(!el) return; - el.innerHTML = '
Đang tải danh sách kênh trực tiếp...
'; - try{ - var r = await fetch('/api/m3u/channels'); - var j = await r.json(); - _m3uChannels = j.channels || []; - }catch(e){ - el.innerHTML = '
Lỗi tải kênh: '+esc(e.message||e)+'
'; - return; - } - if(!_m3uChannels.length){ - el.innerHTML = '
Không có kênh nào
'; - return; - } - // Group channels by group-title - var groups = {}; - _m3uChannels.forEach(function(c){ - var g = c.group || 'Khác'; - if(!groups[g]) groups[g] = []; - groups[g].push(c); - }); - var html = ''; - html += '
'; - Object.keys(groups).forEach(function(gname){ - html += '
'+esc(gname)+'
'; - html += '
'; - groups[gname].forEach(function(ch){ - var idx = _m3uChannels.indexOf(ch); - var logo = ch.logo || ''; - var imgTag = logo ? '' : ''; - var title = (ch.title || 'Kênh').replace(/\s{2,}/g, ' ').trim(); - html += '
' - + '
'+imgTag+'
' - + '
'+esc(title)+'
' - + '
'; - }); - html += '
'; - }); - html += '
'; - el.innerHTML = html; -} - -function openM3UPlayer(chIdx){ - var ch = _m3uChannels[chIdx]; - if(!ch) return; - var streamUrl = ch.url || ''; - if(!streamUrl) return toast('Không có link stream'); - - // Build ordered list (clicked first) - var ordered = []; - for(var i = chIdx; i < _m3uChannels.length; i++) ordered.push(_m3uChannels[i]); - for(var i = 0; i < chIdx; i++) ordered.push(_m3uChannels[i]); - - // Parse VLC opts for headers - var vlcopts = ch.vlcopts || {}; - var ua = vlcopts['http-user-agent'] || ''; - var cookie = ''; - var auth = ''; - if(vlcopts['http-cookie']){ - cookie = vlcopts['http-cookie']; - } - if(vlcopts['http-header']){ - // e.g. "Authorization: Bearer XXX" - auth = vlcopts['http-header'].replace(/^authorization:\s*/i, ''); - } - - // Proxy URL with stalker headers - var proxyUrl = '/api/proxy/m3u?url=' + esc(streamUrl) + - (ua ? '&ua=' + esc(ua) : '') + - (cookie ? '&cookie=' + esc(cookie) : '') + - (auth ? '&auth=' + esc(auth) : ''); - - var h = ''; - h += '
'; - ordered.forEach(function(ch, i){ - var streamUrl = ch.url || ''; - var vopts = ch.vlcopts || {}; - var pua = vopts['http-user-agent'] || ''; - var pcookie = vopts['http-cookie'] || ''; - var pauth = vopts['http-header'] ? vopts['http-header'].replace(/^authorization:\s*/i, '') : ''; - var purl = '/api/proxy/m3u?url=' + esc(streamUrl) + - (pua ? '&ua=' + esc(pua) : '') + - (pcookie ? '&cookie=' + esc(pcookie) : '') + - (pauth ? '&auth=' + esc(pauth) : ''); - var logo = ch.logo || ''; - var vtag = '
' - + '
'; - var title = (ch.title || 'Kênh').replace(/\s{2,}/g, ' ').trim(); - h += buildTikTokSlide({ - vtag: vtag, - title: title, - badge: 'LIVE', - badgeClass: 'badge-live', - videoId: 'm3u-' + i, - idx: i, - total: ordered.length, - shareUrl: streamUrl || location.href, - postId: '', - extraBtn: '' - }); - }); - h += '
'; - showView('view-tiktok'); - var el = document.getElementById('view-tiktok'); - if(el) el.innerHTML = h; - setTimeout(function(){ if(typeof initTikTokFeed === 'function') initTikTokFeed(); }, 200); -} - -function closeM3UViewer(){ - var feed = document.getElementById('tiktok-feed'); - if(feed){ feed.innerHTML = ''; } - document.querySelectorAll('video[data-m3u]').forEach(function(v){ - if(v._m3uRetry) clearTimeout(v._m3uRetry); - if(v._hls){ v._hls.destroy(); v._hls = null; } - v.pause(); - v.src = ''; +async function toggleComments(videoId,idx){const panel=document.getElementById('cmt-inline-'+idx);if(!panel)return;if(panel.style.display!=='none'){panel.style.display='none';return;}panel.style.display='block';panel.innerHTML='
Đang tải...
';const cmts=await getComments(videoId);renderInlineComments(panel,videoId,idx,cmts);} +function renderInlineComments(panel,videoId,idx,cmts){let h='
💬 Bình luận
';if(cmts.length){cmts.slice(-30).forEach(c=>{h+=`
${c.time||''}${esc(c.text)}
`;});}else{h+='
Chưa có bình luận
';}h+=`
`;panel.innerHTML=h;const list=panel.querySelector('.inline-cmt-list');if(list)list.scrollTop=list.scrollHeight;} +async function submitInlineCmt(videoId,idx){const inp=document.getElementById('cmt-input-'+idx);if(!inp)return;const text=inp.value.trim();if(!text)return;inp.value='';inp.disabled=true;const cmts=await postComment(videoId,text);inp.disabled=false;const panel=document.getElementById('cmt-inline-'+idx);if(panel)renderInlineComments(panel,videoId,idx,cmts);const cc=document.getElementById('cc-'+idx);if(cc)cc.textContent=fmtNum(cmts.length);} +function initTikTokFeed(){const feed=document.getElementById('tiktok-feed');if(!feed)return;const slides=feed.querySelectorAll('.tiktok-slide');let cur=-1;function act(i){if(i===cur)return;slides.forEach((sl,idx)=>{const v=sl.querySelector('video');const fr=sl.querySelector('iframe');if(idx===i){if(v&&v.dataset.hls&&!v._hls&&typeof Hls!=='undefined'&&Hls.isSupported()){const hls=new Hls();hls.loadSource(v.dataset.hls);hls.attachMedia(v);hls.on(Hls.Events.MANIFEST_PARSED,()=>v.play().catch(()=>{}));v._hls=hls}else if(v)v.play().catch(()=>{});if(fr&&!fr.src&&fr.dataset.ytSrc)fr.src=fr.dataset.ytSrc;const vid=sl.dataset.vid;if(vid&&!sl._viewed){sl._viewed=true;doInteract(vid,'view').then(j=>{if(j){const vc=document.getElementById('vc-'+idx);if(vc)vc.textContent=fmtNum(j.views);}});}}else{if(v){v.pause();if(v._hls){v._hls.destroy();v._hls=null}}if(fr&&fr.src)fr.src=''}});cur=i}let sT;feed.addEventListener('scroll',()=>{clearTimeout(sT);sT=setTimeout(()=>{const rect=feed.getBoundingClientRect(),ctr=rect.top+rect.height/2;let best=-1,bestD=1e9;slides.forEach((sl,i)=>{const d=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(d=0)act(best)},150)});setTimeout(()=>act(0),400);slides.forEach(sl=>{const v=sl.querySelector('video');if(v)v.addEventListener('click',e=>{e.preventDefault();v.paused?v.play().catch(()=>{}):v.pause()})});const ids=[...slides].map(sl=>sl.dataset.vid||'');loadCounters(ids);} +async function openHighlightFeed(league,idx){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='
Đang tải...
';let articles=(_hlLeagueData||{})[league]||[];if(!articles.length){try{articles=await fetch('/api/highlights/'+league).then(r=>r.json())}catch(e){articles=[]}}if(!articles.length){el.innerHTML='
Không có video
';return}const vids=[];const results=await Promise.all(articles.map(async(a,i)=>{try{const r=await fetch('/api/video_url?url='+encodeURIComponent(a.link));const v=await r.json();if(v&&v.src)return{...a,...v,_idx:i}}catch(e){}return null}));results.forEach(r=>{if(r)vids.push(r)});vids.sort((a,b)=>a._idx-b._idx);if(!vids.length){el.innerHTML='
Không tìm thấy video
';return}let ti=vids.findIndex(v=>v._idx===idx);if(ti<0)ti=0;const ordered=ti>0?[...vids.slice(ti),...vids.slice(0,ti)]:vids;let h=`
`;ordered.forEach((v,i)=>{const isYT=v.type==='youtube',isHLS=!isYT&&v.src?.includes('.m3u8'),poster=v.poster?` poster="${v.poster}"`:'';const vtag=isYT?``:isHLS?``:``;const videoId='hl-'+league+'-'+(v.id||v._idx);h+=buildTikTokSlide({vtag,title:v.title,badge:'HL',badgeClass:'badge-fpt',videoId,idx:i,total:ordered.length,shareUrl:v.link||'',extraBtn:``});});h+='
';el.innerHTML=h;initTikTokFeed();} +async function openYTShortsFeed(startIdx){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='
Đang tải...
';const arts=_shortsData.length?_shortsData:await fetch('/api/shorts').then(r=>r.json()).catch(()=>[]);if(!arts.length){el.innerHTML='
Không có shorts
';return}const ordered=startIdx>0?[...arts.slice(startIdx),...arts.slice(0,startIdx)]:arts;let h=`
`;ordered.forEach((v,i)=>{const id=v.id||'';const src=`https://www.youtube.com/embed/${id}?autoplay=1&rel=0&playsinline=1`;const vtag=``;const badge=v.channel==='baosuckhoedoisongboyte'?'SKĐS':'Dân trí';const videoId='yt-'+id;h+=buildTikTokSlide({vtag,title:v.title,badge,badgeClass:'badge-fpt',videoId,idx:i,total:ordered.length,shareUrl:'https://youtube.com/watch?v='+id});});h+='
';el.innerHTML=h;initTikTokFeed();} +async function openShortAIFeed(startIdx){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='
Đang tải...
';const wall=(await fetch('/api/wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];const vids=wall.filter(p=>p.video);if(!vids.length){el.innerHTML='
Chưa có Short AI
';return}const ordered=startIdx>0?[...vids.slice(startIdx),...vids.slice(0,startIdx)]:vids;let h=`
`;ordered.forEach((p,i)=>{const vtag=``;const videoId='ai-'+(p.id||i);h+=buildTikTokSlide({vtag,title:p.title,badge:'AI',badgeClass:'badge-ai',videoId,idx:i,total:ordered.length,shareUrl:SPACE});});h+='
';el.innerHTML=h;initTikTokFeed();} +async function readArticle(url){showView('view-article');const el=document.getElementById('view-article');el.innerHTML='
Đang tải...
';try{const r=await fetch('/api/article?url='+encodeURIComponent(url));const data=await r.json();if(data&&!data.error&&data.body&&data.body.length){_currentArticle={url,data};let h=`

${esc(data.title)}

`;if(data.summary)h+=`
${esc(data.summary)}
`;const seen={};data.body.forEach(b=>{if(b.type==='p')h+=`

${b.text}

`;else if(b.type==='img'&&b.src&&!seen[b.src]){seen[b.src]=1;h+=``}else if(b.type==='heading')h+=`

${esc(b.text)}

`});h+=`

🤖 Hỏi AI

`;el.innerHTML=h;window.scrollTo(0,0);return;}}catch(e){}el.innerHTML=`

Không đọc được.

Mở gốc →
`;} +async function rewriteArticle(){const url=_currentArticle?.url;if(!url)return;toast('⏳ Đang rewrite...');try{const r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,context:document.querySelector('.article-view')?.innerText?.slice(0,14000)||''})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error);toast('✅ Đã đăng Tường AI!');if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message)}} +async function rewriteUrl(){const url=document.getElementById('url-input')?.value.trim();if(!url)return alert('Dán URL');toast('⏳ Đang rewrite...');try{const r=await fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error);toast('✅ Đã đăng!');document.getElementById('url-input').value='';if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message)}} +async function askAI(){const q=document.getElementById('ask-q')?.value.trim();if(!q)return alert('Nhập câu hỏi');const a=document.getElementById('ask-a');a.textContent='Đang hỏi...';try{const r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:_currentArticle?.url||'',question:q,context:document.querySelector('.article-view')?.innerText?.slice(0,12000)||''})});const j=await r.json();a.textContent=j.answer||'Không trả lời được';}catch(e){a.textContent='Lỗi: '+e.message}} + +// ===== READ WALL POST — FIXED: gallery + voice selector ===== +async function readWallPost(i){ + const p=_wallPosts[i]; + if(!p)return; + showView('view-article'); + + // Build image gallery + const images = p.images || []; + let imgGallery = ''; + if(images.length > 0){ + imgGallery = '
'; + // Hero image (first) + imgGallery += ``; + // Thumbnail strip (remaining) + if(images.length > 1){ + imgGallery += ''; + } + imgGallery += '
'; + } + + const hasVideo = p.video && p.video.length > 0; + + // Voice selector — always shown (for create or recreate) + const allVoices = [ + {key:'hoaimy', flag:'🇻🇳', country:'Việt Nam', name:'Hoài My', gender:'Nữ'}, + {key:'namminh', flag:'🇻🇳', country:'Việt Nam', name:'Nam Minh', gender:'Nam'}, + {key:'en_us_f', flag:'🇺🇸', country:'Mỹ', name:'Ava', gender:'Nữ'}, + {key:'en_us_m', flag:'🇺🇸', country:'Mỹ', name:'Brian', gender:'Nam'}, + {key:'en_gb_f', flag:'🇬🇧', country:'Anh', name:'Sonia', gender:'Nữ'}, + {key:'en_gb_m', flag:'🇬🇧', country:'Anh', name:'Ryan', gender:'Nam'}, + {key:'zh_cn_f', flag:'🇨🇳', country:'TQ', name:'Xiaoxiao', gender:'Nữ'}, + {key:'zh_cn_m', flag:'🇨🇳', country:'TQ', name:'Yunyang', gender:'Nam'}, + {key:'zh_hk_f', flag:'🇭🇰', country:'Hồng Kông',name:'HiuGaai', gender:'Nữ'}, + {key:'ja_jp_f', flag:'🇯🇵', country:'Nhật', name:'Nanami', gender:'Nữ'}, + {key:'ja_jp_m', flag:'🇯🇵', country:'Nhật', name:'Keita', gender:'Nam'}, + {key:'ko_kr_f', flag:'🇰🇷', country:'Hàn', name:'Sun-Hi', gender:'Nữ'}, + {key:'ko_kr_m', flag:'🇰🇷', country:'Hàn', name:'InJoon', gender:'Nam'}, + {key:'fr_fr_f', flag:'🇫🇷', country:'Pháp', name:'Denise', gender:'Nữ'}, + {key:'fr_fr_m', flag:'🇫🇷', country:'Pháp', name:'Henri', gender:'Nam'}, + {key:'de_de_f', flag:'🇩🇪', country:'Đức', name:'Katja', gender:'Nữ'}, + {key:'de_de_m', flag:'🇩🇪', country:'Đức', name:'Killian', gender:'Nam'}, + {key:'es_es_f', flag:'🇪🇸', country:'TBN', name:'Ximena', gender:'Nữ'}, + {key:'es_mx_m', flag:'🇲🇽', country:'Mexico', name:'Jorge', gender:'Nam'}, + {key:'th_th_f', flag:'🇹🇭', country:'Thái', name:'Premwadee', gender:'Nữ'}, + {key:'th_th_m', flag:'🇹🇭', country:'Thái', name:'Niwat', gender:'Nam'}, + {key:'hi_in_f', flag:'🇮🇳', country:'Ấn Độ', name:'Swara', gender:'Nữ'}, + {key:'hi_in_m', flag:'🇮🇳', country:'Ấn Độ', name:'Madhur', gender:'Nam'}, + ]; + + const voiceGroups = {}; + allVoices.forEach(v=>{ + if(!voiceGroups[v.country]) voiceGroups[v.country] = []; + voiceGroups[v.country].push(v); }); - showView('view-m3u'); -} -function copyStreamLink(idx){ - var ch = _m3uChannels[idx]; - if(!ch || !ch.url) return toast('Không có link stream'); - if(navigator.clipboard && navigator.clipboard.writeText){ - navigator.clipboard.writeText(ch.url).then(function(){ toast('📋 Đã sao chép link stream!'); }).catch(function(){ - prompt('📋 Link stream:', ch.url); + let voiceSelector = ''; + voiceSelector = `
🎙️ Chọn giọng đọc (đa ngôn ngữ):
`; + for(const [country, voices] of Object.entries(voiceGroups)){ + voiceSelector += `
${voices[0].flag} ${country}
`; + voices.forEach(v=>{ + const label = `${v.flag} ${v.gender==='Nữ'?'👩':'👨'} ${v.name}`; + voiceSelector += ``; }); - }else{ - prompt('📋 Link stream:', ch.url); + voiceSelector += `
`; } -} - -// Patch video element to use data-m3u src when active (for MPEG-TS streams) -(function(){ - var _origInitTikTok = window.initTikTokFeed; - if(_origInitTikTok && !_origInitTikTok._patched){ - window.initTikTokFeed = function(){ - _origInitTikTok.call(this); - // Set up interval to activate m3u video streams - var feed = document.getElementById('tiktok-feed'); - if(!feed) return; - var slides = feed.querySelectorAll('.tiktok-slide'); - var check = function(){ - slides.forEach(function(sl){ - var v = sl.querySelector('video[data-m3u]'); - if(!v) return; - if(sl._active && !v.src && v.dataset.m3u){ - v.src = v.dataset.m3u; - v.play().catch(function(){}); - } - }); - }; - check(); - var int = setInterval(check, 500); - setTimeout(function(){ clearInterval(int); }, 30000); - }; - window.initTikTokFeed._patched = true; - } -})(); + voiceSelector += `
⚡ Tốc độ:
`; + voiceSelector += `
`; + // Build action buttons + let actionButtons = ''; + if(hasVideo){ + actionButtons += ``; + actionButtons += ``; + } else { + actionButtons += ``; + } + + document.getElementById('view-article').innerHTML= + ` +
+ AI +

${esc(p.title)}

+ ${imgGallery} +

${esc(p.text)}

+ ${hasVideo?``:''} +
+ ${voiceSelector} + ${actionButtons} + +
+
`; + + selectVoice('hoaimy'); + window.scrollTo(0,0); +} + +// Gallery thumbnail click — swap hero image +function swapGalleryHero(imgUrl, thumbEl){ + const gallery = thumbEl.closest('.article-image-gallery'); + if(!gallery) return; + const hero = gallery.querySelector('.article-hero-img'); + if(hero) hero.src = imgUrl; +} + +async function loadNewsTab(){const el=document.getElementById('view-cat');el.innerHTML='
Đang tải...
';try{const r=await fetch('/api/homepage');const news=await r.json();if(!news.length){el.innerHTML='
Không có tin
';return}const groups={};news.forEach(a=>{if(!groups[a.group])groups[a.group]=[];groups[a.group].push(a)});let h='';for(const[g,arts] of Object.entries(groups)){h+=`
${g}
`;arts.slice(0,6).forEach(a=>{h+=`
${a.img?``:''}
${esc(a.source||'VnE')}
${esc(a.title)}
`});h+='
'}el.innerHTML=h}catch(e){el.innerHTML='
Lỗi
'}} +async function loadCat(id){const el=document.getElementById('view-cat');el.innerHTML='
Đang tải...
';const arts=await fetch('/api/category/'+id).then(r=>r.json()).catch(()=>[]);if(!arts.length){el.innerHTML='
Không có tin
';return}let h='
';arts.forEach(a=>{h+=`
${a.img?``:''}
${esc(a.source||'')}
${esc(a.title)}
`});h+='
';el.innerHTML=h} +fetch('/api/storage_status').then(r=>r.json()).then(j=>{if(!j.persistent){const home=document.getElementById('view-home');if(home){const w=document.createElement('div');w.className='storage-warn';w.innerHTML='⚠️ Persistent Storage chưa bật.';home.prepend(w)}}}).catch(()=>{}); diff --git a/static/app_v2_shorts_fix.js b/static/app_v2_shorts_fix.js deleted file mode 100644 index 488520ab2edc21927c2ef5b49f0839b01c13ff63..0000000000000000000000000000000000000000 --- a/static/app_v2_shorts_fix.js +++ /dev/null @@ -1,2 +0,0 @@ -// No-op - all functionality built into app_v2.js -(function(){})(); diff --git a/static/designer_v2.js b/static/designer_v2.js deleted file mode 100644 index 31ff59f42126dbbc350a4d7c94575b7083bdd2d6..0000000000000000000000000000000000000000 --- a/static/designer_v2.js +++ /dev/null @@ -1,903 +0,0 @@ -/* ========================================================================= - * designer_v2.js — Slide Designer nâng cấp cho VNEWS - * - Nhiều hiệu ứng độc đáo (gradient, blur, neon, top-banner, lower-third, - * card, corner-frame, stars, vignette, split, spotlight, diagonal, solid) - * - Bộ lọc màu (grayscale, sepia, saturate, warm, cool, invert) - * - Chữ phát sáng (neon/glow) - * - Vị trí chữ (trên/giữa/dưới) - * - Chọn 1 / nhiều / TẤT CẢ slide trong modal sửa ảnh - * - Đăng lên Tường AI là bài dạng slide gồm CÁC SLIDE ĐÃ CHỌN - * - * 2026-08-12 FIX (2 lỗi báo cáo): - * - Bug 1: upload ảnh preview + nút "Áp dụng ảnh". _dsHandleBgFile được gắn - * trực tiếp làm listener 'change' nên nhận Event object, `input.files` - * undefined -> upload im lặng không làm gì. Giờ đọc evt.target.files. - * Nút "Áp dụng ảnh này cho TẤT CẢ slide đã chọn" luôn hiển thị. - * - Bug 2: chọn 1 ảnh rewrite áp dụng cho nhiều slide. Danh sách ảnh dùng - * data-index + delegated click (hết fragile inline onclick với URL thô). - * - * 2026-08-13 FIX (lỗi báo cáo tiếp): - * - Bug 3: "dùng 1 ảnh cho nhiều slide" vẫn chỉ áp dụng cho slide đầu. - * Giờ có _dsAppliedImg (ảnh đã áp dụng ở cấp thiết kế): khi chọn ảnh bất kỳ - * (rewrite / upload / URL) sẽ áp dụng cho TẤT CẢ slide đã chọn NGAY LẬP TỨC; - * khi đổi lựa chọn slide (chọn thêm / bỏ chọn / chọn tất cả) ảnh đó vẫn được - * áp dụng tự động cho các slide mới được chọn; khi lưu, mọi slide được chọn - * đều dùng ảnh đó. - * - Bug 4: ảnh preview hiển thị đúng tỉ lệ nhưng sau khi tạo ảnh xong hiển thị - * sai tỉ lệ + ảnh nền không định vị chính xác như preview. Preview và khi - * lưu giờ render Ở CÙNG một hàm `_dsRenderSlide(canvas, opts, cb)` (cùng - * resolution, cùng cover-fit, cùng filter, cùng text) nên file tạo ra khớp - * TUYỆT ĐỐI với preview. Ảnh trên Tường/slide viewer giữ đúng tỉ lệ khung - * (ko cắt xén bằng object-fit:cover nữa). - * - Thêm MÀU NỀN + nhiều ẢNH NỀN MẪU khi chọn "Nền đều (phủ màu tối)" — - * dải màu nền (solid color swatches) và bộ ảnh nền mẫu (gradient mờ) để - * người dùng tạo ảnh nền trơn thay vì chỉ phủ màu tối. - * ========================================================================= */ - -/* ---------------- state ---------------- */ -let _dsState = { - postIdx: -1, // index into _wallPosts - selected: [], // array of selected slide indices (into p.slides) -}; - -/* per-slide image override map: {si: imageUrl} set by the rewrite image list */ -let _dsImgMap = {}; - -/* srcs backing the rewrite image list (Bug 2 fix: index-based, no inline onclick) */ -let _dsRewriteImgSrcs = []; - -/* designer-level "applied image": the image the user picked (rewrite / upload / - URL). Applied to ALL selected slides, including slides selected LATER. - Bug 3 fix: keeps "1 ảnh cho nhiều slide" working when selection changes. */ -let _dsAppliedImg = ''; - -/* solid-color background for the 'solid' layout (map because _dsOptions reads it) */ -let _dsBgColor = ''; -/* sample background image (proxied URL) for the 'solid' layout */ -let _dsBgSample = ''; - -/* ---------------- ratio / layout helpers ---------------- */ -function designerGetRatioV2(){ - const v = document.getElementById('ds-ratio')?.value || '3:4'; - const m = v.split(':'); - return m.length === 2 ? {w: parseInt(m[0]), h: parseInt(m[1])} : {w:3,h:4}; -} -function designerGetLayoutV2(){ return document.getElementById('ds-layout')?.value || 'solid'; } -function designerGetFilterV2(){ return document.getElementById('ds-filter')?.value || 'none'; } -function designerGetGlowV2(){ return document.getElementById('ds-glow')?.value || 'none'; } -function designerGetPosV2(){ return document.getElementById('ds-pos')?.value || 'middle'; } - -/* ---------------- open modal (override) ---------------- */ -function openSlideDesigner(idx){ - const p = _wallPosts[idx]; if(!p || !p.slides || !p.slides.length) return; - _dsState.postIdx = idx; - // default: chỉ slide đầu tiên (slide 1 = ảnh đại diện bài đăng tường). - // Người dùng có thể chọn thêm bằng checkbox hoặc "Chọn tất cả". - _dsState.selected = p.slides.length ? [0] : []; - _dsBgDataUrl = null; // reset uploaded bg between opens - _dsTextColor = '#ffffff'; - _dsImgMode = 'rewrite'; // default: use rewrite image - _dsImgMap = {}; // reset per-slide image overrides - _dsRewriteImgSrcs = []; - _dsAppliedImg = ''; // reset designer-level applied image - _buildDesignerModal(p); -} - -function _buildDesignerModal(p){ - const overlay = document.createElement('div'); - overlay.id = 'slide-designer-overlay'; - overlay.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.94);z-index:99999;display:flex;align-items:center;justify-content:center;padding:12px;overflow-y:auto'; - let h='
'; - h+='

🎨 Thiết kế ảnh slide

'; - - /* ---- 1. Chọn slide (1 / nhiều / tất cả) ---- */ - h+='
'; - h+='
'; - h+=''; - h+=''; - h+='
'; - p.slides.forEach((s,i)=>{ - const preview = s.image ? `` : `
🖼️
`; - const isSel = _dsState.selected.includes(i); - h+=``; - }); - h+='
'; - - /* ---- 2. Nội dung từng slide đã chọn ---- */ - h+='
'; - - /* ---- 3. Cấu hình thiết kế ---- */ - h+='
'; - h+='
'; - h+='
'; - h+='
'; - h+='
'; - h+='
'; - h+='
'; - - /* ---- 3b. Màu nền + ảnh nền mẫu (chỉ hiển thị khi chọn "Nền đều/solid") ---- */ - h+=''; - - /* ---- 4. Màu chữ + nền ---- */ - h+='
'; - ['#ffffff','#000000','#ff4444','#44ff44','#4444ff','#ffff00','#ff00ff','#00ffff','#ff8800','#88ff00','#ffd700','#ff69b4'].forEach(c=>{h+=``;}); - h+='
'; - - /* ---- 5. Ảnh nền (source buttons + URL/upload) ---- */ - h+='
'; - h+='
'; - h+=''; - h+=''; - h+=''; - h+='
'; - /* URL + upload panel (only the URL/upload inputs collapse; apply button stays visible) */ - h+=''; - /* "Áp dụng ảnh này cho TẤT CẢ slide đã chọn" — ALWAYS visible (Bug 1 fix) */ - h+='
✓ Áp dụng để dùng 1 ảnh cho nhiều slide
'; - h+='
Ảnh tải lên/URL nhập từ ngoài (hoặc chọn từ danh sách dưới) sẽ thay thế ảnh bài rewrite và áp dụng cho TẤT CẢ slide đã chọn.
'; - h+=''; - h+='
'; - - h+='
'; - h+=''; - h+='
'; - overlay.innerHTML=h; document.body.appendChild(overlay); - - /* button label update fn */ - window._dsUpdateSaveLabel = function(){ - const n = _dsState.selected.length; - const b = document.getElementById('ds-save-btn'); - if(b) b.textContent = '📤 Lưu & Đăng lên Tường AI ('+n+' slide)'; - }; - - /* ---- slide selection wiring ---- */ - overlay.querySelectorAll('.ds-slide-chk').forEach(chk=>{ - chk.addEventListener('change', _dsSyncSelection); - }); - document.getElementById('ds-sel-all').addEventListener('click', ()=>{ _setAllSelected(true); }); - document.getElementById('ds-sel-none').addEventListener('click', ()=>{ _setAllSelected(false); }); - document.getElementById('ds-slide-list').addEventListener('click', e=>{ - const lbl = e.target.closest('.ds-slide-chip'); - if(!lbl) return; - const chk = lbl.querySelector('.ds-slide-chk'); - if(e.target === chk) return; - chk.checked = !chk.checked; _dsSyncSelection(); - }); - - /* ---- inputs wiring ---- */ - document.getElementById('ds-preview-btn').addEventListener('click', _dsPreview); - document.getElementById('ds-save-btn').addEventListener('click', ()=>_dsSave(p)); - ['ds-texts','ds-ratio','ds-layout','ds-filter','ds-glow','ds-pos'].forEach(id=>{ - ['change','input'].forEach(evt=>{ - const el=document.getElementById(id); if(el) el.addEventListener(evt, ()=>_dsDebounce(_dsPreview, 150)); - }); - }); - // URL input: as user types a custom image URL, live-preview + apply to selected - const bgUrlInput=document.getElementById('ds-bg-url'); - if(bgUrlInput){ - bgUrlInput.addEventListener('input', ()=>{ - const v=bgUrlInput.value.trim(); - if(v && v.length>8){ - _dsSetImgMode('custom'); - const proxied=_dsImageSrc(v); - _dsAppliedImg = proxied; // Bug 3: applies to current + future selections - const sel=_dsState.selected; - if(sel.length){ sel.forEach(si=>{ _dsImgMap[si]=proxied; }); } - else { _dsImgMap[0]=proxied; } - } - _dsDebounce(_dsPreview,150); - }); - } - // Bug 1 fix: pass through the event; _dsHandleBgFile reads evt.target.files - document.getElementById('ds-bg-file').addEventListener('change', _dsHandleBgFile); - ['ds-img-src-rewrite','ds-img-src-custom','ds-img-src-none'].forEach(id=>{ - const b=document.getElementById(id); - if(b) b.addEventListener('click', ()=>{ - _dsSetImgMode(id==='ds-img-src-rewrite'?'rewrite':id==='ds-img-src-custom'?'custom':'none'); - }); - }); - /* "Áp dụng ảnh này cho TẤT CẢ slide đã chọn" — always-visible apply button */ - const applyBtn=document.getElementById('ds-apply-img-btn'); - if(applyBtn){ - applyBtn.addEventListener('click', function(){ - let chosen = _dsBgDataUrl || (document.getElementById('ds-bg-url')?.value||''); - if(!chosen){ toast('⚠️ Chọn ảnh (tải lên hoặc nhập URL) trước khi áp dụng'); return; } - _dsSetImgMode('custom'); - let proxied = _dsImageSrc(chosen); - _dsAppliedImg = proxied; // Bug 3 fix - dsApplyImgToSelected(proxied); - }); - } - // Toggle the "nền trơn" (solid) color + sample-image panel when layout changes - const layoutSel=document.getElementById('ds-layout'); - if(layoutSel){ - const toggleSolidPanel=()=>{ - const wrap=document.getElementById('ds-bg-solid-wrap'); - if(wrap) wrap.style.display = layoutSel.value==='solid' ? 'block' : 'none'; - }; - layoutSel.addEventListener('change', toggleSolidPanel); - layoutSel.addEventListener('input', toggleSolidPanel); - toggleSolidPanel(); - } - // Solid background color swatches - const bgColorBox=document.getElementById('ds-bg-colors'); - if(bgColorBox){ - bgColorBox.addEventListener('click', e=>{ - const b=e.target.closest('.ds-bgcolor-btn'); if(!b) return; - document.querySelectorAll('#ds-bg-colors .ds-bgcolor-btn').forEach(x=>x.style.borderColor='#333'); - b.style.borderColor='#fff'; - _dsBgColor = b.dataset.color; - _dsBgSample = ''; // color takes precedence over sample image - document.querySelectorAll('#ds-bg-samples .ds-bgsample-btn').forEach(x=>x.style.borderColor='#333'); - _dsStatus('🎨 Đã chọn màu nền: '+_dsBgColor); - _dsDebounce(_dsPreview, 150); - }); - } - // Sample background images (only for solid layout) - const bgSampleBox=document.getElementById('ds-bg-samples'); - if(bgSampleBox){ - bgSampleBox.addEventListener('click', e=>{ - const b=e.target.closest('.ds-bgsample-btn'); if(!b) return; - const bi=parseInt(b.dataset.bi,10); - const src=_DS_BG_SAMPLES[bi]; - if(!src) return; - document.querySelectorAll('#ds-bg-samples .ds-bgsample-btn').forEach(x=>x.style.borderColor='#333'); - b.style.borderColor='#ffd700'; - _dsBgSample = _dsImageSrc(src); - _dsBgColor = ''; // sample image takes precedence over color - document.querySelectorAll('#ds-bg-colors .ds-bgcolor-btn').forEach(x=>x.style.borderColor='#333'); - _dsStatus('🖼️ Đã chọn ảnh nền mẫu'); - _dsDebounce(_dsPreview, 150); - }); - } - _syncImgModeBtns(); - _dsRefreshRewriteImgs(p); - document.getElementById('ds-colors').addEventListener('click', e=>{ - const b = e.target.closest('.ds-color-btn'); if(!b) return; - overlay.querySelectorAll('.ds-color-btn').forEach(x=>x.style.borderColor='#333'); - b.style.borderColor='#fff'; - _dsTextColor = b.dataset.color; - _dsDebounce(_dsPreview, 150); - }); - - _dsRefreshTexts(p); - _dsUpdateSaveLabel(); - _dsPreview(); -} - -/* ---------------- selection sync ---------------- */ -function _setAllSelected(on){ - _dsState.selected = []; - document.querySelectorAll('#ds-slide-list .ds-slide-chk').forEach(chk=>{ chk.checked = on; if(on) _dsState.selected.push(parseInt(chk.dataset.idx)); }); - // Bug 3 fix: if an image was already applied at designer level, push it to - // every slide that is (now) selected so "1 ảnh cho nhiều slide" also covers - // slides selected AFTER the image was picked. - if(on && _dsAppliedImg){ - _dsState.selected.forEach(si=>{ _dsImgMap[si] = _dsAppliedImg; }); - } - const p = _wallPosts[_dsState.postIdx]; - if(p) _dsRefreshTexts(p); - _dsUpdateSaveLabel(); - _dsDebounce(_dsPreview, 150); -} -function _dsSyncSelection(){ - _dsState.selected = []; - document.querySelectorAll('#ds-slide-list .ds-slide-chk:checked').forEach(chk=>{ _dsState.selected.push(parseInt(chk.dataset.idx)); }); - // Bug 3 fix: same propagation for checkbox changes. - if(_dsAppliedImg){ - _dsState.selected.forEach(si=>{ if(!_dsImgMap[si]) _dsImgMap[si] = _dsAppliedImg; }); - } - const p = _wallPosts[_dsState.postIdx]; - if(p) _dsRefreshTexts(p); - _dsUpdateSaveLabel(); - _dsDebounce(_dsPreview, 150); -} - -/* ---------------- per-slide text areas ---------------- */ -function _dsRefreshTexts(p){ - const box = document.getElementById('ds-texts'); if(!box) return; - if(!_dsState.selected.length){ box.innerHTML='
Chưa chọn slide nào.
'; return; } - let h='
'; - _dsState.selected.forEach((si, k)=>{ - const s = p.slides[si]; - h+=``; - }); - h+='
'; - box.innerHTML=h; - box.querySelectorAll('textarea').forEach(ta=>{ - ta.addEventListener('input', ()=>{ _dsDebounce(_dsPreview, 200); }); - }); -} - -/* ---------------- bg file ---------------- */ -let _dsBgDataUrl = null; -let _dsTextColor = '#ffffff'; -let _dsImgMode = 'rewrite'; // 'rewrite' | 'custom' | 'none' -/* Bug 1 fix: this is bound as a 'change' listener, so the first arg is the - Event. Read the from evt.target.files (previously input.files was - undefined, so the upload silently did nothing and the preview/apply button - never saw the image). */ -function _dsHandleBgFile(evt){ - const input = evt && evt.target ? evt.target : evt; - const file = (input && input.files && input.files[0]) ? input.files[0] : null; - if(!file) return; - _dsSetImgMode('custom'); // show the custom-img URL/upload panel - const reader=new FileReader(); - reader.onload=function(e){ - _dsBgDataUrl=e.target.result; - _dsImgMode='custom'; - _syncImgModeBtns(); - _dsStatus('✅ Đã chọn ảnh: '+(file.name||'').slice(0,30) + (file.size? ' ('+Math.round(file.size/1024)+'KB)':'')); - // Auto-apply uploaded image to ALL currently selected slides so the upload - // has an immediate, visible effect (preview + save will use it for every slide). - _dsAppliedImg = _dsBgDataUrl; // Bug 3 fix: applies to future selections too - const sel=_dsState.selected; - if(sel.length){ sel.forEach(si=>{ _dsImgMap[si]=_dsBgDataUrl; }); } - else { _dsImgMap[0]=_dsBgDataUrl; } - _dsDebounce(_dsPreview,150); - }; - reader.readAsDataURL(file); -} -/* Show a transient status line in the designer footer. */ -function _dsStatus(msg){ - const s=document.getElementById('ds-status'); if(s) s.textContent=msg; -} -function _syncImgModeBtns(){ - ['ds-img-src-rewrite','ds-img-src-custom','ds-img-src-none'].forEach(id=>{ - const b=document.getElementById(id); - if(!b) return; - const on = (id==='ds-img-src-rewrite' && _dsImgMode==='rewrite') || - (id==='ds-img-src-custom' && _dsImgMode==='custom') || - (id==='ds-img-src-none' && _dsImgMode==='none'); - b.style.background = on ? '#2d8659' : '#333'; - b.style.color = on ? '#fff' : '#ccc'; - }); - const wrap=document.getElementById('ds-custom-img-url-wrap'); - if(wrap) wrap.style.display = _dsImgMode==='custom' ? 'block' : 'none'; -} -function _dsSetImgMode(m){ - _dsImgMode = m; - _syncImgModeBtns(); - _dsDebounce(_dsPreview, 150); -} - -/* Build a list of available rewrite images (post.img + each slide.image) - and render them so the user can pick 1 image to apply to the selected slides. - Bug 2 fix: uses data-index + a delegated click handler (no fragile inline - onclick embedding the raw URL, which broke on URLs containing quotes). */ -function _dsRefreshRewriteImgs(p){ - const box = document.getElementById('ds-rewrite-list'); - if(!box) return; - const imgs = []; - const seen = new Set(); - // post-level image first - if(p.img && typeof p.img === 'string' && p.img.length){ - const key = p.img.startsWith('/api/') ? p.img : _dsImageSrc(p.img); - if(!seen.has(key)){ seen.add(key); imgs.push({src: key, label: 'Ảnh bài (đại diện)'}); } - } - // then per-slide images - if(Array.isArray(p.slides)){ - p.slides.forEach((s, i)=>{ - const raw = s.image || s.img || ''; - if(raw && typeof raw === 'string' && raw.length){ - const key = raw.startsWith('/api/') ? raw : _dsImageSrc(raw); - if(!seen.has(key)){ seen.add(key); imgs.push({src: key, label: `Slide ${s.index||i+1}`}); } - } - }); - } - if(!imgs.length){ - box.innerHTML = '
Không có ảnh rewrite nào.
'; - const wrap = document.getElementById('ds-rewrite-imgs'); - if(wrap) wrap.style.display = 'none'; - return; - } - const wrap = document.getElementById('ds-rewrite-imgs'); - if(wrap) wrap.style.display = 'block'; - _dsRewriteImgSrcs = imgs.map(x=>x.src); // index -> src - let h=''; - imgs.forEach((img, idx)=>{ - h += '
' - + '' - + '
'+img.label+'
' - + '
'; - }); - box.innerHTML = h; - // Bug 2 fix: delegated click handler (event delegation, URL-safe) - box.onclick = function(e){ - const im = e.target.closest('img[data-ds-rw]'); - if(!im) return; - const idx = parseInt(im.getAttribute('data-ds-rw'), 10); - const src = _dsRewriteImgSrcs[idx]; - if(src) dsApplyImgToSelected(src); - }; -} - -/* Apply a chosen image src to ALL selected slides (or the current preview slide). */ -function dsApplyImgToSelected(imgSrc){ - _dsAppliedImg = imgSrc; // Bug 3 fix: remember at designer level (future selections too) - const sel = _dsState.selected; - if(sel.length){ - sel.forEach(si=>{ _dsImgMap[si] = imgSrc; }); - }else{ - _dsImgMap[0] = imgSrc; - } - _dsImgMode = 'custom'; // visually marks we are using a chosen image - _syncImgModeBtns(); - _dsStatus('✅ Đã áp dụng ảnh cho '+(sel.length? sel.length+' slide đã chọn':'slide hiện tại')); - // refresh preview immediately (selected slide 0 is what we preview) - const p=_wallPosts[_dsState.postIdx]; - if(p && sel.length){ _dsOptions(p, sel[0]); } - _dsPreview(); -} - -function _dsDebounce(fn,d){ clearTimeout(fn._t); fn._t=setTimeout(fn,d); } - -/* ---------------- get design options ---------------- */ -function _dsOptions(p, si){ - const s = p.slides[si]; - const ta = document.querySelector(`#ds-texts textarea[data-sidx="${si}"]`); - const text = ta ? ta.value : (s.text||''); - const bgInput = document.getElementById('ds-bg-url')?.value||''; - // Per-slide image override: when user picks an image from the rewrite list, - // it is stored in _dsImgMap[si] and applied to that specific slide. - let bgUrl = ''; - // Solid layout + user picked a SAMPLE background image -> use it as the - // background photo (dark overlay for readability) instead of a flat color. - const isSolid = designerGetLayoutV2()==='solid'; - const useSample = isSolid && _dsBgSample && !_dsAppliedImg; - if(useSample) bgUrl = _dsBgSample; - // Bug 3 fix: designer-level applied image wins for every selected slide. - // Applied when the user picked an image (rewrite picker / upload / URL / - // "Áp dụng" button) — even for slides selected AFTER the image was picked. - if(!bgUrl && _dsAppliedImg) bgUrl = _dsAppliedImg; - if(!bgUrl && _dsImgMap[si]) bgUrl = _dsImgMap[si]; - if(!bgUrl){ - if(_dsImgMode === 'custom'){ - bgUrl = _dsBgDataUrl || bgInput || ''; - } else if(_dsImgMode === 'rewrite'){ - bgUrl = s.image || p.img || ''; - } // none -> '' - } - return { - si, s, text, bgUrl, - ratio: designerGetRatioV2(), - layout: isSolid ? 'solid' : designerGetLayoutV2(), - filter: designerGetFilterV2(), - glow: designerGetGlowV2(), - pos: designerGetPosV2(), - color: _dsTextColor, - bgColor: _dsBgColor, // solid-layout background color ('' = default dark) - bgSample: useSample ? _dsBgSample : '', // 'sample' mode: img passed to draw - }; -} - -/* ---------------- drawing core ---------------- */ -function _dsImageSrc(url){ - if(!url) return ''; - if(url.startsWith('/api/') || url.startsWith('data:')) return url; - return '/api/proxy/img?url='+encodeURIComponent(url); -} -/* Only tag crossOrigin=anonymous for proxied EXTERNAL images. - Setting it on data: URLs or local /api/ paths can cause silent onload - failures (black preview) in some browser/CORS configurations. */ -function _dsMakeImg(url){ - const img=new Image(); - if(url && !url.startsWith('data:') && !url.startsWith('/api/')) img.crossOrigin='anonymous'; - return img; -} - -/* Sample background images for the "Nền đều (solid)" layout — abstract - gradients that look good with white text. Served through the /api/proxy/img - endpoint (same-origin, no crossOrigin needed — _dsMakeImg handles that). - Keyed for the modal background swatches and for _dsDrawLayout. */ -const _DS_BG_SAMPLES = [ - 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=900&q=60', // sơn hùng vĩ (núi) - 'https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=900&q=60', // sương mù rừng - 'https://images.unsplash.com/photo-1462331940025-496dfbfc7564?w=900&q=60', // vũ trụ - 'https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=900&q=60', // núi rừng - 'https://images.unsplash.com/photo-1499002238440-d264edd596ec?w=900&q=60', // hoàng hôn - 'https://images.unsplash.com/photo-1448375240586-882707db888b?w=900&q=60', // rừng tối - 'https://images.unsplash.com/photo-1519681393784-d120267933ba?w=900&q=60', // trời sao - 'https://images.unsplash.com/photo-1518173946687-a4c8892bbd9f?w=900&q=60', // biển đêm - 'https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?w=900&q=60', // đồng cỏ -]; - -/* draw bg image with cover-fit + color filter */ -function _dsDrawBg(ctx, W, H, bgUrl, filter, blur){ - ctx.save(); - ctx.fillStyle='#111'; ctx.fillRect(0,0,W,H); - // filter string - let f=''; - if(filter==='grayscale') f='grayscale(1)'; - else if(filter==='sepia') f='sepia(0.85)'; - else if(filter==='saturate') f='saturate(2.4)'; - else if(filter==='warm') f='sepia(0.45) saturate(1.5) hue-rotate(-15deg)'; - else if(filter==='cool') f='saturate(1.2) hue-rotate(15deg) brightness(1.05)'; - else if(filter==='invert') f='invert(1)'; - else if(filter==='noir') f='grayscale(1) contrast(1.6) brightness(0.9)'; - if(blur) f=(f?f+' ':'')+'blur('+blur+'px)'; - ctx.filter = f; - if(bgUrl){ - const img = new Image(); img.crossOrigin='anonymous'; - // Note: async path handled by caller via onload; this fn is synchronous draw of already-loaded img - } - ctx.restore(); -} - -/* ---------------- layout effects (with loaded bg image) ---------------- */ -function _dsDrawLayout(ctx, W, H, opts, img){ - const { layout, filter, bgUrl, bgColor } = opts; - // base: solid layout can use the chosen background color (no image needed) - let baseColor = '#141414'; - if(layout==='solid' && !img && bgColor) baseColor = bgColor; - ctx.fillStyle = baseColor; ctx.fillRect(0,0,W,H); - // draw bg image cover-fit if present & loaded - if(img){ - const blur = layout==='blur' ? Math.max(6, Math.round(W/90)) : 0; - ctx.save(); - let f=''; - if(filter==='grayscale') f='grayscale(1)'; - else if(filter==='sepia') f='sepia(0.85)'; - else if(filter==='saturate') f='saturate(2.4)'; - else if(filter==='warm') f='sepia(0.45) saturate(1.5) hue-rotate(-15deg)'; - else if(filter==='cool') f='saturate(1.2) hue-rotate(15deg) brightness(1.05)'; - else if(filter==='invert') f='invert(1)'; - else if(filter==='noir') f='grayscale(1) contrast(1.6) brightness(0.9)'; - if(blur) f=(f?f+' ':'')+'blur('+blur+'px)'; - ctx.filter = f; - const iw=img.naturalWidth||1, ih=img.naturalHeight||1; - const scale=Math.max(W/iw, H/ih); - const dw=iw*scale, dh=ih*scale; - const dx=(W-dw)/2, dy=(H-dh)/2; - ctx.drawImage(img,0,0,iw,ih,dx,dy,dw,dh); - ctx.filter='none'; - ctx.restore(); - } - // overlay per layout - if(layout==='gradient'){ - const g=ctx.createLinearGradient(0,0,W,H); - g.addColorStop(0,'rgba(50,20,90,0.75)'); g.addColorStop(0.5,'rgba(20,60,120,0.6)'); g.addColorStop(1,'rgba(120,30,60,0.75)'); - ctx.fillStyle=g; ctx.fillRect(0,0,W,H); - } - else if(layout==='vignette'){ - const g=ctx.createRadialGradient(W/2,H/2,0,W/2,H/2,Math.max(W,H)*0.62); - g.addColorStop(0,'rgba(0,0,0,0)'); g.addColorStop(1,'rgba(0,0,0,0.78)'); - ctx.fillStyle=g; ctx.fillRect(0,0,W,H); - } - else if(layout==='split'){ - const g=ctx.createLinearGradient(0,0,0,H); - g.addColorStop(0,'rgba(0,0,0,0.85)'); g.addColorStop(0.5,'rgba(0,0,0,0.15)'); g.addColorStop(1,'rgba(0,0,0,0.85)'); - ctx.fillStyle=g; ctx.fillRect(0,0,W,H); - } - else if(layout==='spotlight'){ - ctx.fillStyle='rgba(0,0,0,0.72)'; ctx.fillRect(0,0,W,H); - const cx=W/2, cy=H*0.45, r=Math.max(W,H)*0.4; - const g=ctx.createRadialGradient(cx,cy,0,cx,cy,r); - g.addColorStop(0,'rgba(255,255,255,0.45)'); g.addColorStop(0.6,'rgba(255,255,255,0.12)'); g.addColorStop(1,'rgba(0,0,0,0.9)'); - ctx.fillStyle=g; ctx.fillRect(0,0,W,H); - } - else if(layout==='diagonal'){ - ctx.fillStyle='rgba(0,0,0,0.55)'; ctx.fillRect(0,0,W,H); - ctx.fillStyle='rgba(0,0,0,0.8)'; - ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(W*0.9,0); ctx.lineTo(0,H*0.9); ctx.closePath(); ctx.fill(); - } - else if(layout==='neon'){ - ctx.fillStyle='rgba(10,10,30,0.85)'; ctx.fillRect(0,0,W,H); - ctx.strokeStyle='rgba(80,220,255,0.5)'; ctx.lineWidth=Math.max(2,W/300); - ctx.strokeRect(W*0.03,H*0.03,W*0.94,H*0.94); - } - else if(layout==='top-banner'){ - ctx.fillStyle='rgba(0,0,0,0)'; - const g=ctx.createLinearGradient(0,0,0,H*0.5); - g.addColorStop(0,'rgba(0,0,0,0.8)'); g.addColorStop(1,'rgba(0,0,0,0)'); - ctx.fillStyle=g; ctx.fillRect(0,0,W,H*0.5); - } - else if(layout==='lower-third'){ - const g=ctx.createLinearGradient(0,H*0.55,0,H); - g.addColorStop(0,'rgba(0,0,0,0)'); g.addColorStop(1,'rgba(0,0,0,0.82)'); - ctx.fillStyle=g; ctx.fillRect(0,H*0.55,W,H*0.45); - } - else if(layout==='card'){ - ctx.fillStyle='rgba(0,0,0,0.35)'; ctx.fillRect(0,0,W,H); - // rounded card - const pad=Math.round(W*0.04); - ctx.fillStyle='rgba(0,0,0,0.65)'; - _dsRoundRect(ctx, pad, H*0.16, W-2*pad, H*0.68, Math.round(W*0.05)); - ctx.fill(); - ctx.strokeStyle='rgba(255,255,255,0.25)'; ctx.lineWidth=Math.max(2,W/400); _dsRoundRect(ctx,pad,H*0.16,W-2*pad,H*0.68,Math.round(W*0.05)); ctx.stroke(); - } - else if(layout==='corner'){ - ctx.fillStyle='rgba(0,0,0,0.3)'; ctx.fillRect(0,0,W,H); - ctx.strokeStyle='rgba(255,215,0,0.9)'; ctx.lineWidth=Math.max(4,W/160); - const L=Math.round(W*0.18), c=Math.round(W*0.04); - [[0,c],[c,0],[W,c],[W-c,0],[0,H-c],[c,H],[W,H-c],[W-c,H]].forEach(([x,y],i)=>{ - const top = y===0 || y===c; const left = x===0 || x===c; - ctx.beginPath(); - if(i<4){ // top corners - if(top && left){ ctx.moveTo(x,y+L); ctx.lineTo(x,y); ctx.lineTo(x+L,y); } - else if(top){ ctx.moveTo(x,y+L); ctx.lineTo(x,y); ctx.lineTo(x-L,y); } - else { ctx.moveTo(x-L,y); ctx.lineTo(x,y); ctx.lineTo(x,y+L); } - } else { // bottom corners - if(left){ ctx.moveTo(x,y-L); ctx.lineTo(x,y); ctx.lineTo(x+L,y); } - else { ctx.moveTo(x-L,y); ctx.lineTo(x,y); ctx.lineTo(x,y-L); } - } - ctx.stroke(); - }); - } - else if(layout==='stars'){ - ctx.fillStyle='rgba(8,8,30,0.82)'; ctx.fillRect(0,0,W,H); - const n=Math.round(W*H/18000); - for(let i=0;i dark overlay so text is readable - ctx.fillStyle='rgba(0,0,0,0.45)'; ctx.fillRect(0,0,W,H); - } else if(bgColor){ - // chosen solid color with a subtle gradient + soft vignette for depth - const g=ctx.createLinearGradient(0,0,W,H); - g.addColorStop(0, bgColor); - g.addColorStop(1, _dsShadeColor(bgColor, -0.25)); - ctx.fillStyle=g; ctx.fillRect(0,0,W,H); - const vg=ctx.createRadialGradient(W/2,H/2,0,W/2,H/2,Math.max(W,H)*0.66); - vg.addColorStop(0,'rgba(0,0,0,0)'); vg.addColorStop(1,'rgba(0,0,0,0.42)'); - ctx.fillStyle=vg; ctx.fillRect(0,0,W,H); - } else { - ctx.fillStyle='rgba(0,0,0,0.6)'; ctx.fillRect(0,0,W,H); - } - } -} - -/* Darken (n<0) or lighten (n>0) a hex color by n*100% (for solid bg gradient). */ -function _dsShadeColor(hex, n){ - try{ - hex=String(hex||'').replace('#',''); - if(hex.length===3) hex=hex.split('').map(c=>c+c).join(''); - if(hex.length!==6) return hex; - const num=parseInt(hex,16); - let r=(num>>16)&255, g=(num>>8)&255, b=num&255; - const t = n<0 ? 0 : 255; - const p = Math.abs(n); - r=Math.round((t-r)*p)+r; g=Math.round((t-g)*p)+g; b=Math.round((t-b)*p)+b; - return '#'+((1<<24)+(r<<16)+(g<<8)+b).toString(16).slice(1); - }catch(e){ return hex; } -} - -function _dsRoundRect(ctx,x,y,w,h,r){ - ctx.beginPath(); - ctx.moveTo(x+r,y); - ctx.arcTo(x+w,y,x+w,y+h,r); - ctx.arcTo(x+w,y+h,x,y+h,r); - ctx.arcTo(x,y+h,x,y,r); - ctx.arcTo(x,y,x+w,y,r); - ctx.closePath(); -} - -/* ---------------- text drawing ---------------- */ -function _dsDrawText(ctx, W, H, opts, baseFontScale){ - const { text, pos, glow, color } = opts; - if(!text || !text.trim()) return; - const base = Math.max(28, Math.round(W*baseFontScale)); - const fs = Math.min(base, Math.round(W*0.09)); - ctx.font = 'bold '+fs+'px "Segoe UI","Arial","Noto Sans",sans-serif'; - ctx.textAlign='center'; - // split lines - const lines=[]; let line=''; - const maxW=Math.round(W*0.82); - text.split(' ').forEach(w=>{ - const test = line? line+' '+w : w; - if(ctx.measureText(test).width>maxW && line){ lines.push(line.trim()); line=w; } else { line=test; } - }); - if(line) lines.push(line.trim()); - const lh=Math.round(fs*1.25); - const blockH=lines.length*lh; - let y; - if(pos==='top') y=Math.round(H*0.14)+lh/2; - else if(pos==='bottom') y=Math.round(H*0.88)-blockH+lh/2; - else y=Math.round(H/2)-blockH/2+lh/2; - // glow - if(glow==='white'){ ctx.shadowColor='rgba(255,255,255,0.95)'; ctx.shadowBlur=Math.max(8,fs*0.5); } - else if(glow==='color'){ ctx.shadowColor=color; ctx.shadowBlur=Math.max(10,fs*0.6); } - else if(glow==='neon'){ ctx.shadowColor='#00ffff'; ctx.shadowBlur=Math.max(16,fs*0.9); } - ctx.fillStyle=color; - // subtle stroke for legibility at top/bottom - if(pos==='top'||pos==='bottom'){ ctx.strokeStyle='rgba(0,0,0,0.6)'; ctx.lineWidth=Math.max(2,fs*0.12); ctx.strokeText(textLine(text),W/2,y); } - lines.forEach((l,li)=>{ ctx.fillText(l, W/2, y+li*lh); }); - ctx.shadowBlur=0; -} -function textLine(t){ return t; } - -/* ---------------- compute final canvas size for a design ---------------- */ -function _dsFinalSize(opts){ - const r=opts.ratio; - const w = (r.w>=r.h) ? 1800 : 1350; - const h = Math.round(w*r.h/r.w); - return { w, h }; -} - -/* Shared renderer (Bug 4 fix): renders a slide onto `canvas` using the SAME - code path and SAME resolution for BOTH preview and final save, so the - published image is pixel-identical to what the user saw in the preview — - same cover-fit, same filter, same text placement, same background. */ -function _dsRenderSlide(canvas, opts, done){ - const ctx = canvas.getContext('2d'); - const bgUrl = _dsImageSrc(opts.bgUrl); - function draw(img){ - _dsDrawLayout(ctx, canvas.width, canvas.height, opts, img||null); - _dsDrawText(ctx, canvas.width, canvas.height, opts, 0.052); - if(done) done(img||null); - } - if(bgUrl){ - const img=_dsMakeImg(bgUrl); - img.onload=function(){ draw(img); }; - img.onerror=function(){ console.warn('[designer] bg image failed to load:', bgUrl); draw(null); }; - img.src=bgUrl; - } else { - draw(null); - } -} - -/* ---------------- preview ---------------- */ -function _dsPreview(){ - const p=_wallPosts[_dsState.postIdx]; if(!p) return; - const area=document.getElementById('ds-preview-area'); if(!area) return; - const sel=_dsState.selected; - if(!sel.length){ area.style.display='block'; area.innerHTML='
Chưa chọn slide nào để xem trước.
'; return; } - area.style.display='block'; - area.innerHTML='
⏳ Đang tạo xem trước...
'; - // display-scale: cap visible width at 300px, keep aspect ratio per slide - const dispW=Math.min(300, Math.floor((Math.max(document.documentElement.clientWidth||540, document.body.clientWidth||540))*0.5)); - const cans=[]; - sel.forEach((si, k)=>{ - const opts=_dsOptions(p, si); - const { w, h }=_dsFinalSize(opts); - const canvas=document.createElement('canvas'); - canvas.width=w; canvas.height=h; - canvas.style.width=dispW+'px'; canvas.style.height='auto'; - canvas.style.borderRadius='12px'; canvas.style.boxShadow='0 8px 24px rgba(0,0,0,.5)'; canvas.style.display='block'; canvas.style.margin='0 auto 14px auto'; - cans.push({opts, canvas}); - }); - let remaining=cans.length; - function allDone(){ - if(--remaining>0) return; - let out=''; - if(cans.length>1) out+='
Xem trước '+(cans.length)+' slide đã chọn (cuộn xuống để xem hết):
'; - area.innerHTML=out; - cans.forEach((c,k)=>{ - if(k>0) area.appendChild(document.createElement('br')); - area.appendChild(c.canvas); - }); - } - cans.forEach(c=>{ _dsRenderSlide(c.canvas, c.opts, allDone); }); -} - -/* ---------------- save: render + upload each selected slide, then post ---------------- */ -async function _dsSave(p){ - const statusEl=document.getElementById('ds-status'); if(statusEl) statusEl.textContent='⏳ Đang tạo ảnh các slide...'; - const sel=_dsState.selected; - if(!sel.length){ if(statusEl) statusEl.textContent='⚠️ Chưa chọn slide nào để đăng.'; return; } - const saveBtn=document.getElementById('ds-save-btn'); if(saveBtn){ saveBtn.disabled=true; saveBtn.textContent='⏳ Đang xử lý...'; } - - const uploaded = []; // {si, url} - try{ - // render each selected slide at final resolution - for(let k=0;k{ - _dsRenderSlide(canvas, opts, function(){ - // Nén JPEG thay PNG: dung lượng rất nhỏ (thường 5-10x nhỏ hơn), - // ảnh tải nhanh hơn nhiều trên Tường AI. Chất lượng 0.82 giữ chữ sắc nét. - canvas.toBlob(async function(blob){ - const fd=new FormData(); - fd.append('file', blob, 'slide_'+(si+1)+'.jpg'); - fd.append('post_id', p.id||''); - try{ - const r=await fetch('/api/wall/img',{method:'POST',body:fd}); - const j=await r.json(); - if(!r.ok || j.error) throw new Error(j.error||'Lỗi upload'); - resolve(j.url); - }catch(e){ resolve({error:e.message}); } - },'image/jpeg', 0.82); - }); - }); - if(url && url.error) throw new Error(url.error); - uploaded.push({si, url}); - // update slide image live - if(p.slides[si]) p.slides[si].image = url; - } - - if(statusEl) statusEl.textContent='⏳ Đang đăng lên Tường AI...'; - - // build updated slides array (keep unselected unchanged) - const newSlides = p.slides.map((s,i)=>{ - const up = uploaded.find(u=>u.si===i); - // ensure text edits are applied too - const ta = document.querySelector(`#ds-texts textarea[data-sidx="${i}"]`); - return { - text: ta ? ta.value : (s.text||''), - image: up ? up.url : (s.image||''), - index: s.index || (i+1), - }; - }); - - // pick selected slides only -> the wall post is a SLIDE-IMAGE post of selected slides - const selSlides = newSlides.filter((_,i)=> sel.includes(i)); - - const post = { - id: p.id, - title: p.title||'', - text: selSlides.map(s=>'• '+s.text).join('\n\n'), - img: selSlides[0] ? selSlides[0].image : p.img||'', - url: p.url||'', - slides: selSlides, // <-- bài đăng bao gồm chỉ các slide đã chọn - images: selSlides.map(s=>s.image), - kind: p.kind||'slide_summary', - video: p.video||'', - voice: p.voice||'', - emotion: p.emotion||'', - language: p.language||'', - ts: p.ts||Math.floor(Date.now()/1000), - }; - - const wr=await fetch('/api/wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(post)}); - const wj=await wr.json(); - if(wr.ok && wj.post){ - prependWallPost(wj.post); - toast('✅ Đã đăng bài slide lên Tường AI ('+selSlides.length+' slide)!'); - } else { - toast('✅ Đã lưu ảnh slide!'); - } - document.getElementById('slide-designer-overlay').remove(); - }catch(e){ - if(statusEl) statusEl.textContent='❌ '+e.message; - toast('❌ '+e.message); - if(saveBtn){ saveBtn.disabled=false; saveBtn.textContent='📤 Lưu & Đăng lên Tường AI ('+sel.length+' slide)'; } - } -} - -/* keep old helper names harmless for any other caller */ -function updateDesignerText(){} -function designerGetRatio(){ return designerGetRatioV2(); } -function designerGetLayout(){ return designerGetLayoutV2(); } -function designerDebounce(fn,d){ _dsDebounce(fn,d); } -function handleDesignerBgFile(){} -function previewDesignerSlide(){ _dsPreview(); } \ No newline at end of file diff --git a/static/fm_fix.css b/static/fm_fix.css deleted file mode 100644 index cf9eb5404211f489ee6e1d0afc47bec6d7faff74..0000000000000000000000000000000000000000 --- a/static/fm_fix.css +++ /dev/null @@ -1,429 +0,0 @@ -/* v2 - Force override broken inline CSS (.fm-league missing } + -wall-img-count bad selector) */ -.featured-match{margin:6px 4px!important;background:linear-gradient(135deg,#1a2a1f,#0d1117)!important;border:1px solid #2d8659!important;border-radius:10px!important;padding:12px!important;cursor:pointer!important} -.fm-league{text-align:center!important;color:#5cb87a!important;font-size:9px!important;font-weight:700!important;text-transform:uppercase!important;display:block!important} -.fm-teams{display:flex!important;align-items:center!important;justify-content:center!important;gap:10px!important;margin-top:6px!important} -.fm-team{flex:1!important;display:flex!important;flex-direction:column!important;align-items:center!important;gap:4px!important} -.fm-team img{width:32px!important;height:32px!important;object-fit:contain!important} -.fm-team span{font-size:10px!important;color:#ccc!important;text-align:center!important} -.fm-score{font-size:22px!important;font-weight:900!important;min-width:60px!important;text-align:center!important;color:#fff!important} -.fm-status{text-align:center!important;margin-top:6px!important;font-size:9px!important;color:#e74c3c!important;font-weight:700!important} -.fm-status.upcoming{color:#f0c040!important} -.ai-compose{margin:6px 4px!important;background:#141414!important;border:1px solid #2a2a2a!important;border-radius:10px!important;padding:10px!important} -.ai-compose-title{font-size:13px!important;font-weight:800!important;color:#5cb87a!important;margin-bottom:8px!important} -.ai-compose-row{display:flex!important;gap:6px!important;margin-top:6px!important} -.ai-compose input{flex:1!important;background:#222!important;border:1px solid #333!important;color:#eee!important;border-radius:18px!important;padding:9px 12px!important;font-size:12px!important;min-width:0!important} -.ai-compose button{background:#2d8659!important;border:0!important;color:#fff!important;border-radius:18px!important;padding:9px 12px!important;font-size:11px!important;font-weight:700!important;cursor:pointer!important;white-space:nowrap!important} -.ai-compose button.secondary{background:#333!important} -.hot-topic-row{display:flex!important;gap:6px!important;overflow-x:auto!important;padding:4px 0!important} -.hot-chip{flex:0 0 auto!important;background:#222!important;border:1px solid #333!important;color:#ddd!important;border-radius:16px!important;padding:5px 10px!important;font-size:11px!important;cursor:pointer!important;white-space:nowrap!important} -.hashtag-sources{margin:8px 4px!important;background:#1a1a1a!important;border:1px solid #2a2a2a!important;border-radius:10px!important;padding:10px!important} -.hashtag-sources h3{font-size:13px!important;color:#5cb87a!important;margin-bottom:8px!important} -.hashtag-src-item{display:flex!important;gap:8px!important;padding:8px!important;background:#202020!important;border-radius:8px!important;margin:6px 0!important;cursor:pointer!important} -.hashtag-src-img{flex:0 0 80px!important;aspect-ratio:16/9!important;background:#333!important;border-radius:6px!important;overflow:hidden!important} -.hashtag-src-img img{width:100%!important;height:100%!important;object-fit:cover!important} -.hashtag-src-text{flex:1!important;min-width:0!important} -.hashtag-src-title{font-size:12px!important;font-weight:700!important;color:#eee!important;display:-webkit-box!important;-webkit-line-clamp:2!important;-webkit-box-orient:vertical!important;overflow:hidden!important} -.hashtag-src-via{font-size:10px!important;color:#888!important;margin-top:2px!important} -.hashtag-rewrite-btn{width:100%!important;margin-top:8px!important;background:#2d8659!important;border:0!important;color:#fff!important;padding:9px!important;border-radius:10px!important;font-size:12px!important;font-weight:700!important;cursor:pointer!important} -.hashtag-load-more{width:100%!important;margin-top:8px!important;background:#222!important;border:1px solid #333!important;color:#ccc!important;padding:9px!important;border-radius:10px!important;font-size:12px!important;cursor:pointer!important} -.hashtag-loading{display:flex!important;align-items:center!important;gap:8px!important;padding:12px!important;color:#888!important;font-size:12px!important} -.hashtag-spinner{width:16px!important;height:16px!important;border:2px solid #333!important;border-top-color:#5cb87a!important;border-radius:50%!important;animation:ht-spin .8s linear infinite!important} -@keyframes ht-spin{to{transform:rotate(360deg)}} -.wall-img-count{position:absolute;bottom:4px;left:4px;background:rgba(0,0,0,.7);color:#fff;font-size:9px;padding:1px 5px;border-radius:4px} -#progress-toast{position:fixed!important;bottom:70px!important;left:50%!important;transform:translateX(-50%)!important;background:#2d8659!important;color:#fff!important;padding:10px 20px!important;border-radius:20px!important;font-size:12px!important;z-index:99998!important;box-shadow:0 4px 12px rgba(0,0,0,.4)!important;display:none;white-space:nowrap!important} -/* === ARTICLE / REWRITE VIEW FIX === *//* === WORLD CUP 2026 SECTION === */ -.wc-section{margin:8px 4px!important;background:#1a1a1a!important;border:1px solid #2a2a2a!important;border-radius:10px!important;overflow:hidden!important} -.wc-header{padding:10px 12px!important;background:linear-gradient(135deg,#0d1b2b,#1a3a2a 50%,#0b4b8b)!important;display:flex!important;align-items:center!important;justify-content:space-between!important} -.wc-title{font-size:14px!important;font-weight:800!important;color:#fff!important} -.wc-tabs{display:flex!important;gap:4px!important;padding:8px 10px!important;background:#1a1a1a!important;overflow-x:auto!important;scrollbar-width:none!important;border-bottom:1px solid #2a2a2a!important} -.wc-tabs::-webkit-scrollbar{display:none!important} -.wc-tab{padding:6px 12px!important;background:#222!important;border:1px solid #333!important;border-radius:12px!important;color:#999!important;font-size:10px!important;white-space:nowrap!important;cursor:pointer!important;flex-shrink:0!important} -.wc-tab.active{background:#0b6bcb!important;border-color:#0b6bcb!important;color:#fff!important;font-weight:700!important} -.wc-tab-content{padding:10px!important;max-height:500px!important;overflow-y:auto!important} -.wc-news-list{display:flex!important;flex-direction:column!important;gap:8px!important} -.wc-news-item{display:flex!important;gap:10px!important;padding:8px!important;background:#202020!important;border-radius:8px!important;cursor:pointer!important} -.wc-news-item:hover{background:#2a2a2a!important} -.wc-news-img{flex:0 0 90px!important;aspect-ratio:16/9!important;background:#333!important;border-radius:6px!important;overflow:hidden!important} -.wc-news-img img{width:100%!important;height:100%!important;object-fit:cover!important} -.wc-news-text{flex:1!important;min-width:0!important;display:flex!important;flex-direction:column!important;justify-content:space-between!important} -.wc-news-title{font-size:12px!important;font-weight:700!important;color:#eee!important;display:-webkit-box!important;-webkit-line-clamp:2!important;-webkit-box-orient:vertical!important;overflow:hidden!important} -.wc-news-source{font-size:10px!important;color:#888!important;margin-top:4px!important} - -/* World Cup Fixtures */ -.wc-fixtures-list{display:flex!important;flex-direction:column!important;gap:6px!important} -.wc-match-item{padding:10px!important;background:#202020!important;border-radius:8px!important;border-left:3px solid #333!important} -.wc-match-item:nth-child(odd){background:#1e1e1e!important} -.wc-match-date{font-size:10px!important;color:#888!important;margin-bottom:4px!important} -.wc-match-teams{display:flex!important;align-items:center!important;justify-content:center!important;gap:12px!important} -.wc-match-teams .wc-team{flex:1!important;font-size:12px!important;color:#ddd!important;text-align:center!important} -.wc-match-teams .wc-score{font-size:16px!important;font-weight:900!important;color:#f0c040!important;min-width:60px!important;text-align:center!important} -.wc-score.wc-live{color:#e74c3c!important} -.wc-score.wc-finished{color:#888!important} -.wc-score.wc-upcoming{color:#5cb87a!important} -.wc-match-location{font-size:9px!important;color:#777!important;text-align:center!important;margin-top:4px!important} - -/* World Cup Standings & Stats Tables */ -.wc-standings-table,.wc-stats-table{font-size:11px!important;color:#ccc!important;overflow-x:auto!important;width:100%!important} -.wc-standings-table table,.wc-stats-table table{width:100%!important;border-collapse:collapse!important;table-layout:fixed!important} -.wc-standings-table th,.wc-stats-table th{background:#222!important;color:#999!important;padding:6px 4px!important;font-size:10px!important;border-bottom:1px solid #333!important;text-align:center!important} -.wc-standings-table th:first-child,.wc-stats-table th:first-child{text-align:left!important} -.wc-standings-table td,.wc-stats-table td{padding:5px 4px!important;border-bottom:1px solid #1a1a1a!important;text-align:center!important;font-size:11px!important} -.wc-standings-table td:first-child,.wc-stats-table td:first-child{text-align:left!important} -.wc-standings-table .pts,.wc-stats-table .pts{font-weight:800!important;color:#f0c040!important} -.wc-standings-table .team-name,.wc-stats-table .team-name{display:flex!important;align-items:center!important;gap:4px!important} -.wc-standings-table .team-name img,.wc-stats-table .team-name img{width:16px!important;height:16px!important;object-fit:contain!important} - -/* World Cup Highlights */ -.wc-highlights-grid{display:grid!important;grid-template-columns:repeat(2,1fr)!important;gap:8px!important}/* World Cup Stats */ -.wc-stats{padding:10px!important} -.wc-stat-group{margin-bottom:16px!important;padding:10px!important;background:#202020!important;border-radius:8px!important} -.wc-stat-group h4{font-size:12px!important;color:#5cb87a!important;margin-bottom:8px!important;font-weight:700!important} -.wc-stat-group .wc-empty{color:#888!important;font-size:11px!important;font-style:italic!important} -.wc-stat-group table{width:100%!important;border-collapse:collapse!important} -.wc-stat-group th{background:#2a2a2a!important;color:#999!important;padding:6px 4px!important;font-size:10px!important;text-align:left!important;border-bottom:1px solid #333!important} -.wc-stat-group td{padding:5px 4px!important;border-bottom:1px solid #1a1a1a!important;font-size:11px!important;color:#ccc!important} - -/* World Cup Group Standings */ -.wc-group{margin-bottom:16px!important} -.wc-group h4{font-size:12px!important;color:#f0c040!important;margin-bottom:6px!important;padding:6px 8px!important;background:#222!important;border-radius:6px!important;font-weight:700!important} -.wc-table{width:100%!important;border-collapse:collapse!important;font-size:11px!important} -.wc-table th{background:#2a2a2a!important;color:#999!important;padding:6px 4px!important;font-size:10px!important;text-align:center!important;border-bottom:1px solid #333!important} -.wc-table th:first-child{text-align:left!important} -.wc-table td{padding:5px 4px!important;border-bottom:1px solid #1a1a1a!important;text-align:center!important;color:#ccc!important} -.wc-table td:first-child{text-align:left!important;font-weight:600!important} -.wc-table .pts{font-weight:800!important;color:#f0c040!important} - -/* World Cup Highlights */ -.wc-highlights-grid{display:grid!important;grid-template-columns:repeat(2,1fr)!important;gap:8px!important} -.wc-hl-item{cursor:pointer!important} -.wc-hl-thumb{position:relative!important;aspect-ratio:16/9!important;background:#333!important;border-radius:6px!important;overflow:hidden!important} -.wc-hl-thumb img{width:100%!important;height:100%!important;object-fit:cover!important} -.wc-hl-thumb .card-play{position:absolute!important;left:50%!important;top:50%!important;transform:translate(-50%,-50%)!important;width:30px!important;height:30px!important;border-radius:50%!important;background:rgba(0,0,0,.55)!important;display:flex!important;align-items:center!important;justify-content:center!important;color:#fff!important;font-size:12px!important} -.wc-hl-title{font-size:10px!important;color:#ccc!important;margin-top:4px!important;line-height:1.2!important;display:-webkit-box!important;-webkit-line-clamp:2!important;-webkit-box-orient:vertical!important;overflow:hidden!important} - -/* === ARTICLE / REWRITE VIEW FIX === */ -.article-view{padding:12px 8px 40px!important;max-width:760px!important;margin:0 auto!important} -.article-title{font-size:18px!important;font-weight:800!important;line-height:1.3!important;margin-bottom:8px!important;color:#fff!important} -.article-p{font-size:14px!important;line-height:1.7!important;color:#ccc!important;margin-bottom:10px!important} -.article-img{width:100%!important;border-radius:6px!important;margin:10px 0!important} -.article-actions{display:flex!important;gap:8px!important;flex-wrap:wrap!important;border-top:1px solid #333!important;margin-top:16px!important;padding-top:10px!important} -.article-actions button{background:#1a1a1a!important;border:1px solid #333!important;color:#ccc!important;padding:7px 12px!important;border-radius:14px!important;font-size:11px!important;cursor:pointer!important} -.article-actions button.primary{background:#2d8659!important;border-color:#2d8659!important;color:#fff!important} -.badge-ai{background:#2d8659!important;color:#fff!important;font-size:8px!important;padding:1px 5px!important;border-radius:3px!important;font-weight:700!important;display:inline-block!important} -/* TTS voice selector */ -.tts-selector{margin:12px 0!important;padding:10px!important;background:#1a1a1a!important;border:1px solid #2a2a2a!important;border-radius:10px!important;width:100%!important} -.tts-selector-label{font-size:12px!important;font-weight:700!important;color:#5cb87a!important;margin-bottom:8px!important} -.tts-voice-groups{display:flex!important;flex-direction:column!important;gap:8px!important;max-height:280px!important;overflow-y:auto!important} -.tts-voice-group{background:#1e1e1e!important;border:1px solid #2a2a2a!important;border-radius:8px!important;padding:6px 8px!important} -.tts-voice-group-label{font-size:10px!important;font-weight:700!important;color:#888!important;margin-bottom:4px!important;text-transform:uppercase!important} -.tts-voice-btns{display:flex!important;gap:6px!important;flex-wrap:wrap!important} -.tts-voice-btn{background:#222!important;border:1px solid #333!important;color:#ddd!important;border-radius:12px!important;padding:6px 10px!important;font-size:10px!important;cursor:pointer!important;white-space:nowrap!important} -.tts-voice-btn.active{background:#2d8659!important;border-color:#2d8659!important;color:#fff!important;font-weight:700!important} -.tts-speed-row{display:flex!important;align-items:center!important;gap:8px!important;font-size:12px!important;color:#aaa!important;margin-top:10px!important} -.tts-speed-row select{flex:1!important;background:#222!important;border:1px solid #333!important;color:#eee!important;border-radius:14px!important;padding:6px 10px!important;font-size:11px!important} -/* Image gallery */ -.article-image-gallery{margin:10px 0!important} -.article-hero-img{width:100%!important;border-radius:8px!important;margin-bottom:6px!important} -.gallery-thumbs{display:flex!important;gap:6px!important;overflow-x:auto!important;padding-bottom:4px!important} -.gallery-thumb{flex:0 0 100px!important;aspect-ratio:16/9!important;border-radius:6px!important;overflow:hidden!important;background:#222!important;cursor:pointer!important} -.gallery-thumb img{width:100%!important;height:100%!important;object-fit:cover!important} -/* === BADGE FIX (article AI badge) === */ -.badge{display:inline-block!important;font-size:8px!important;padding:1px 5px!important;border-radius:3px!important;font-weight:700!important;vertical-align:middle!important} -.badge.badge-ai,.badge-ai{background:#2d8659!important;color:#fff!important} -/* article-view top spacing so badge+title not clipped */ -.article-view .badge{margin-bottom:6px!important} -.article-view .tts-selector{width:auto!important;display:block!important} - -/* ============================================================ - 1:1 ("ratio-square") view for normal horizontal Dan tri / SKDS videos. - Spec: fill 100% of the slide HEIGHT, crop the excess WIDTH (cut left/right). - A YouTube `; - const badge = s.channel==='vtvnambo' ? 'VTV Nam Bộ' : '⚽ WC'; - const badgeClass = s.channel==='vtvnambo' ? 'badge-wc' : 'badge-fpt'; - const videoId = 'wcshort-' + id; - h += buildTikTokSlide({vtag,title:s.title,badge,badgeClass,videoId,idx:i,total:ordered.length,shareUrl:'https://youtube.com/shorts/'+id}); - }); - - h += '
'; - el.innerHTML = h; - - // Init continuous autoplay feed - initWCShortsFeed(); -} - -function initWCShortsFeed(){ - const feed = document.getElementById('wc-shorts-feed'); - if(!feed) return; - const slides = feed.querySelectorAll('.tiktok-slide'); - let cur = -1; - let isManualScroll = false; - let manualTimer = null; - - function activate(i){ - if(i === cur) return; - slides.forEach((sl,idx)=>{ - const fr = sl.querySelector('iframe'); - if(idx === i){ - // Load and play - if(fr && !fr.src && fr.dataset.ytSrc) fr.src = fr.dataset.ytSrc; - // For continuous feel: set autoplay=1 on activate - if(fr && fr.src && !fr.src.includes('autoplay=1')){ - fr.src = fr.src.replace('autoplay=0','autoplay=1'); - } - const vid = sl.dataset.vid; - if(vid && !sl._viewed){ sl._viewed = true; doInteract(vid,'view'); } - } else { - // Pause: reload with autoplay=0 to stop - if(fr && fr.src){ - fr.src = fr.src.replace('autoplay=1','autoplay=0'); - } - } - }); - cur = i; - } - - // Scroll-based activation with snap - let scrollTimer; - feed.addEventListener('scroll', ()=>{ - isManualScroll = true; - clearTimeout(manualTimer); - manualTimer = setTimeout(()=>{ isManualScroll = false; }, 2000); - clearTimeout(scrollTimer); - scrollTimer = setTimeout(()=>{ - const rect = feed.getBoundingClientRect(); - const ctr = rect.top + rect.height / 2; - let best = -1, bestD = 1e9; - slides.forEach((sl,i)=>{ - const d = Math.abs(sl.getBoundingClientRect().top + sl.getBoundingClientRect().height/2 - ctr); - if(d < bestD){ bestD = d; best = i; } - }); - if(best >= 0) activate(best); - }, 100); - }); - - // Auto-advance every 8 seconds for continuous playback - let autoTimer = setInterval(()=>{ - if(isManualScroll) return; - if(cur < slides.length - 1){ - const nextSl = slides[cur + 1]; - if(nextSl){ - nextSl.scrollIntoView({behavior:'smooth', block:'start'}); - // activate will be called by scroll event - } - } else { - // Loop back to first - slides[0].scrollIntoView({behavior:'smooth', block:'start'}); - } - }, 8000); - - // Store timer for cleanup - feed._autoTimer = autoTimer; - - // Activate first after short delay - setTimeout(() => { - if(slides.length > 0){ - slides[0].scrollIntoView({behavior:'start'}); - activate(0); - } - }, 300); - - // Cleanup on leave - const origSwitchCat = window.switchCat; - if(origSwitchCat && !origSwitchCat.__wcCleaned){ - window.switchCat = function(id){ - if(feed._autoTimer) clearInterval(feed._autoTimer); - return origSwitchCat.apply(this, arguments); - }; - window.switchCat.__wcCleaned = true; - } + }catch(e){el.innerHTML='
Lỗi tải highlight
';} } // === LIVE AUTO-REFRESH (90s) === @@ -314,4 +104,4 @@ function startWCLiveRefresh(){ }catch(e){} },90000); } -setTimeout(startWCLiveRefresh,5000); \ No newline at end of file +setTimeout(startWCLiveRefresh,5000); diff --git a/static/wc_shorts_inject.js b/static/wc_shorts_inject.js deleted file mode 100644 index 9503be74204c12cf4a1dd93ca06c550d397632e3..0000000000000000000000000000000000000000 --- a/static/wc_shorts_inject.js +++ /dev/null @@ -1,76 +0,0 @@ -/** - * WC Shorts Inject — Chạy SAU app_v2.js - * Tự tạo anchor DIV rồi render WC Shorts slide TRÊN World Cup section - */ -(function(){ - 'use strict'; - - function inject(){ - // Tìm WC section - const wcSection = document.getElementById('wc2026-live-section'); - const homeEl = document.getElementById('view-home'); - if(!homeEl) return; - - // Đã inject? - if(document.getElementById('wc-shorts-slide-section')) return; - - // Đảm bảo renderWCSSlideSection đã load - if(typeof renderWCSSlideSection !== 'function'){ - // Chờ rồi thử lại - setTimeout(inject, 500); - return; - } - - // Tạo container rồi render vào - const div = document.createElement('div'); - div.id = 'wc-shorts-slide-container-tmp'; - div.style.display = 'none'; - - // Insert TRƯỚC WC section, hoặc vào đầu home - if(wcSection && wcSection.parentNode){ - wcSection.parentNode.insertBefore(div, wcSection); - } else { - homeEl.insertBefore(div, homeEl.firstChild); - } - - // Render slide section vào div - renderWCSSlideSection(div); - - // Bỏ wrapper thừa, di chuyển section ra đúng vị trí - const section = div.querySelector('.wc-shorts-slide-section'); - if(section){ - section.id = 'wc-shorts-slide-section'; - if(wcSection && wcSection.parentNode){ - wcSection.parentNode.insertBefore(section, wcSection); - } - div.remove(); - } - } - - // Chạy khi DOM sẵn sàng - if(document.readyState === 'loading'){ - document.addEventListener('DOMContentLoaded', ()=> setTimeout(inject, 800)); - } else { - setTimeout(inject, 800); - } - - // Cũng chạy sau khi loadHome xong - const origLoadHome = window.loadHome; - if(origLoadHome){ - window.loadHome = function(){ - const result = origLoadHome.apply(this, arguments); - setTimeout(inject, 200); - setTimeout(inject, 800); - setTimeout(inject, 2000); - return result; - }; - } - - // Fallback: retry nhiều lần - let retries = 0; - const retryIv = setInterval(()=>{ - retries++; - inject(); - if(retries > 20) clearInterval(retryIv); - }, 1000); -})(); diff --git a/static/yt_live.js b/static/yt_live.js deleted file mode 100644 index cadeb9193fcd78c62bea61b35f91162db309e3c7..0000000000000000000000000000000000000000 --- a/static/yt_live.js +++ /dev/null @@ -1,13 +0,0 @@ -// ===== VNEWS — VTV LIVE + Inline Recorder (v9) ===== -// NOTE: This file was truncated during a previous edit and broke page load. -// All VTV player functionality now lives in /static/vtv_init.js (v11). -// This stub keeps any legacy references working without errors. -(function() { - 'use strict'; - if (window._vtvLiveStubLoaded) return; - window._vtvLiveStubLoaded = true; - // Legacy function names that other modules may check via typeof guards. - window.initYTLive = window.initYTLive || function() {}; - window.openYTLive = window.openYTLive || function() {}; - window.toggleYTLive = window.toggleYTLive || function() {}; -})(); diff --git a/storage.py b/storage.py deleted file mode 100644 index 9f968e484faae118657dcd55ec580e200ca992e6..0000000000000000000000000000000000000000 --- a/storage.py +++ /dev/null @@ -1,150 +0,0 @@ -"""Persistent storage for VNEWS using Hugging Face Dataset as backend.""" -import os, json, time, threading -from pathlib import Path -from huggingface_hub import HfApi, hf_hub_download, upload_file -from huggingface_hub.utils import RepositoryNotFoundError - -DATASET_REPO = "bep40/VNEWS-data" -DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data" -os.makedirs(DATA_DIR, exist_ok=True) - -LOCAL_WALL_FILE = os.path.join(DATA_DIR, "wall_posts.json") -LOCAL_INTERACTIONS_FILE = os.path.join(DATA_DIR, "interactions_v2.json") -LOCAL_COMMENTS_FILE = os.path.join(DATA_DIR, "comments_v2.json") -LOCAL_SHORTS_CACHE = os.path.join(DATA_DIR, "shorts_cache.json") - -_lock = threading.Lock() -_api = HfApi() - -def _download_file(repo_path: str, local_path: str) -> bool: - """Download a file from HF dataset to local path.""" - try: - downloaded = hf_hub_download( - repo_id=DATASET_REPO, - filename=repo_path, - repo_type="dataset", - local_dir=DATA_DIR, - local_dir_use_symlinks=False, - ) - if downloaded and downloaded != local_path: - import shutil - shutil.copy2(downloaded, local_path) - return True - except Exception as e: - print(f"[storage] Download {repo_path} failed: {e}") - return False - -def _upload_file(local_path: str, repo_path: str) -> bool: - """Upload a local file to HF dataset.""" - try: - upload_file( - path_or_fileobj=local_path, - path_in_repo=repo_path, - repo_id=DATASET_REPO, - repo_type="dataset", - ) - return True - except Exception as e: - print(f"[storage] Upload {repo_path} failed: {e}") - return False - -def init_storage(): - """Initialize storage by downloading existing data from HF dataset.""" - print("[storage] Initializing from HF dataset...") - for local, remote in [ - (LOCAL_WALL_FILE, "wall_posts.json"), - (LOCAL_INTERACTIONS_FILE, "interactions_v2.json"), - (LOCAL_COMMENTS_FILE, "comments_v2.json"), - (LOCAL_SHORTS_CACHE, "shorts_cache.json"), - ]: - if not os.path.exists(local): - _download_file(remote, local) - print("[storage] Storage initialized") - -def _load_json(local_path: str, default): - """Load JSON from local file with default fallback.""" - try: - if os.path.exists(local_path): - with open(local_path, 'r', encoding='utf-8') as f: - return json.load(f) - except Exception: - pass - return default - -def _save_json(local_path: str, data): - """Save JSON to local file atomically.""" - try: - tmp = local_path + ".tmp" - with open(tmp, 'w', encoding='utf-8') as f: - json.dump(data, f, ensure_ascii=False) - os.replace(tmp, local_path) - return True - except Exception as e: - print(f"[storage] Save {local_path} failed: {e}") - return False - -# ===== Wall Posts ===== -def load_wall_posts() -> list: - """Load wall posts from local file (synced from HF dataset).""" - with _lock: - return _load_json(LOCAL_WALL_FILE, []) - -def save_wall_posts(posts: list) -> bool: - """Save wall posts to local file and async sync to HF dataset.""" - with _lock: - ok = _save_json(LOCAL_WALL_FILE, posts[:200]) # Keep max 200 - if ok: - # Async upload to HF dataset - threading.Thread( - target=lambda: _upload_file(LOCAL_WALL_FILE, "wall_posts.json"), - daemon=True - ).start() - return ok - -# ===== Interactions ===== -def load_interactions() -> dict: - with _lock: - return _load_json(LOCAL_INTERACTIONS_FILE, {}) - -def save_interactions(data: dict) -> bool: - with _lock: - ok = _save_json(LOCAL_INTERACTIONS_FILE, data) - if ok: - threading.Thread( - target=lambda: _upload_file(LOCAL_INTERACTIONS_FILE, "interactions_v2.json"), - daemon=True - ).start() - return ok - -# ===== Comments ===== -def load_comments() -> dict: - with _lock: - return _load_json(LOCAL_COMMENTS_FILE, {}) - -def save_comments(data: dict) -> bool: - with _lock: - ok = _save_json(LOCAL_COMMENTS_FILE, data) - if ok: - threading.Thread( - target=lambda: _upload_file(LOCAL_COMMENTS_FILE, "comments_v2.json"), - daemon=True - ).start() - return ok - -# ===== Shorts Cache ===== -def load_shorts_cache() -> dict: - with _lock: - return _load_json(LOCAL_SHORTS_CACHE, {}) - -def save_shorts_cache(data: dict) -> bool: - with _lock: - ok = _save_json(LOCAL_SHORTS_CACHE, data) - if ok: - threading.Thread( - target=lambda: _upload_file(LOCAL_SHORTS_CACHE, "shorts_cache.json"), - daemon=True - ).start() - return ok - -# Auto-sync on startup -init_storage() \ No newline at end of file diff --git a/vtv_api.py b/vtv_api.py deleted file mode 100644 index 921182436050e6aa1feaa451e55683cd882f636d..0000000000000000000000000000000000000000 --- a/vtv_api.py +++ /dev/null @@ -1,223 +0,0 @@ -# VTV Stream — SV2/sv.xemtivitop iframe URLs + M3U fallback + EPG + FPT Play -import re, time, threading, json, requests -from fastapi import APIRouter, Query -from fastapi.responses import JSONResponse, Response, StreamingResponse -from bs4 import BeautifulSoup -from datetime import datetime, timedelta, timezone -from urllib.parse import quote - -VN_TZ = timezone(timedelta(hours=7)) -router = APIRouter() - -UA = { - "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", -} - -HARDCODED_URLS = { - "vtv6": "https://freem3u.xyz/api/live/play.m3u8?vid=10043", - "vtv10": "https://live.fptplay53.net/live/media/vtv10/live247-hls-avc/index.m3u8", -} -CHANNEL_URLS = {k: (HARDCODED_URLS[k] if k in HARDCODED_URLS else None) for k in ["vtv1","vtv2","vtv3","vtv4","vtv5","vtv6","vtv7","vtv8","vtv9","vtv10"]} -CHANNEL_NAMES = {"vtv1":"VTV1","vtv2":"VTV2","vtv3":"VTV3","vtv4":"VTV4","vtv5":"VTV5","vtv6":"VTV6","vtv7":"VTV7","vtv8":"VTV8","vtv9":"VTV9","vtv10":"VTV10","vtvprime":"VTVPrime"} - -IFRAME_URLS = { - "vtv1": "https://sv2.xemtivitop.com/live/hot/vtv1.php", - "vtv2": "https://sv2.xemtivitop.com/live/hot/vtv2.php", - "vtv3": "https://sv2.xemtivitop.com/live/hot/vtv3.php", - "vtv4": "https://sv2.xemtivitop.com/live/hot/vtv4.php", - "vtv5": "https://sv2.xemtivitop.com/live/hot/vtv5.php", - "vtv6": "https://sv2.xemtivitop.com/live/hot/vtv6.php", - "vtv7": "https://sv.xemtivitop.com/live/vtv/vtv7.php", - "vtv8": "https://sv2.xemtivitop.com/live/hot/vtv8.php", - "vtv9": "https://sv2.xemtivitop.com/live/hot/vtv9.php", - "vtv10": "https://sv2.xemtivitop.com/live/hot/vtv10.php", -} - -# ===== FPT PLAY ===== -FPT_PLAY_IFRAMES = { - "fpt-the-thao": "https://hd1.xemtv.net/kenh-fptplay-truc-tuyen-the-thao.html", -} - -FPT_PLAY_STREAM_SELECTORS = { - "fpt-the-thao": { - "label": "FPT Play Thể Thao", - "streams": [ - {"label":"📡 IPTV Primary","url":"https://live.fptplay53.net/epzhd1/htvcthethao_mhls.smil/master.m3u8","type":"m3u8"}, - {"label":"📡 IPTV Fallback","url":"https://live.fptplay53.net/epzhd1/htvcthethao_vhls.smil/chunklist.m3u8","type":"m3u8"}, - {"label":"🔗 hd1.xemtv.net","url":"https://hd1.xemtv.net/kenh-fptplay-truc-tuyen-the-thao.html","type":"iframe","allow":"autoplay; encrypted-media; fullscreen"}, - ], - }, -} - -M3U_URLS = ["https://raw.githubusercontent.com/Love4vn/Test/refs/heads/main/IPTV.m3u", "https://raw.githubusercontent.com/Love4vn/love4vn/main/Out_Iptv_CXT.m3u", "https://raw.githubusercontent.com/konanda-sg/Test_Love4vn/main/IPTV.m3u", "https://raw.githubusercontent.com/iptv-org/iptv/master/streams/vn.m3u"] -_channel_urls_lock = threading.Lock() -_last_m3u_fetch = 0 -_M3U_TTL = 900 - -def _fetch_m3u(): - global _last_m3u_fetch - now = time.time() - if now - _last_m3u_fetch < _M3U_TTL: return - _last_m3u_fetch = now - all_urls = {} - for m3u_url in M3U_URLS: - try: - r = requests.get(m3u_url, headers=UA, timeout=10) - if r.status_code != 200: continue - text = r.text - lines = text.strip().split("\n") - current_channel = None - for line in lines: - s = line.strip() - if s.startswith("#EXTINF"): - m = re.search(r'tvg-id="?(\w+)"?', s) - if m: current_channel = m.group(1).lower() - else: - m2 = re.search(r'(VTV\d+|vtv\d+)', s, re.IGNORECASE) - if m2: current_channel = m2.group(1).lower() - else: current_channel = None - elif s.startswith("http") and current_channel: - ch_id = current_channel - ch_map = {"vtv1":"vtv1","vtv2":"vtv2","vtv3":"vtv3","vtv4":"vtv4","vtv5":"vtv5","vtv6":"vtv6","vtv7":"vtv7","vtv8":"vtv8","vtv9":"vtv9","vtv10":"vtv10","vtvcầnthơ":"vtv10","vtvcantho":"vtv10","vtvcan-tho":"vtv10"} - if ch_id in ch_map: ch_id = ch_map[ch_id] - if ch_id in CHANNEL_URLS and ch_id not in all_urls: - if CHANNEL_URLS[ch_id] and HARDCODED_URLS.get(ch_id): current_channel = None; continue - if ".flv" in s: all_urls[ch_id] = s - elif ch_id not in all_urls or ".flv" not in all_urls.get(ch_id,""): - if ch_id not in all_urls: all_urls[ch_id] = s - current_channel = None - if len(all_urls) >= 10: break - except: pass - with _channel_urls_lock: - found = 0 - for ch_id in CHANNEL_URLS: - if CHANNEL_URLS[ch_id] and HARDCODED_URLS.get(ch_id): continue - if ch_id in all_urls: CHANNEL_URLS[ch_id] = all_urls[ch_id]; found += 1 - print(f"[M3U] Found {found}/10 channels (hardcoded: vtv6, vtv10)") - -def get_channel_url(channel_id): - _fetch_m3u() - with _channel_urls_lock: return CHANNEL_URLS.get(channel_id) - -@router.get("/api/vtv/streams") -def api_vtv_streams(): - urls = dict(CHANNEL_URLS) - result = {} - for ch_id in CHANNEL_NAMES: - stream_url = urls.get(ch_id) - iframe_url = IFRAME_URLS.get(ch_id) - is_flv = stream_url and ".flv" in stream_url - result[ch_id] = {"name": CHANNEL_NAMES[ch_id], "stream_url": stream_url, "proxy_url": f"/api/proxy/flv?url={quote(stream_url, safe='')}" if stream_url and is_flv else "", "proxy_url_hls": f"/api/proxy/stream?url={quote(stream_url, safe='')}" if stream_url and not is_flv else "", "is_flv": is_flv, "iframe_url": iframe_url, "status": "ok"} - # Add FPT Play channels - for ch_id in FPT_PLAY_STREAM_SELECTORS: - streams = FPT_PLAY_STREAM_SELECTORS[ch_id]["streams"] - primary = streams[0]["url"] if streams else "" - result[ch_id] = {"name": FPT_PLAY_STREAM_SELECTORS[ch_id]["label"], "stream_url": primary, "m3u8_proxy_url": f"/api/proxy/m3u8?url={quote(primary, safe='')}" if primary else "", "iframe_url": FPT_PLAY_IFRAMES.get(ch_id), "stream_selectors": FPT_PLAY_STREAM_SELECTORS[ch_id], "is_fpt_play": True, "status": "ok"} - return JSONResponse(result) - -@router.get("/api/vtv/stream/{channel_id}") -def api_vtv_stream(channel_id: str): - channel_id = channel_id.lower().strip() - # Check FPT Play FIRST - if channel_id in FPT_PLAY_STREAM_SELECTORS: - streams = FPT_PLAY_STREAM_SELECTORS[channel_id]["streams"] - primary = streams[0]["url"] if streams else "" - return JSONResponse({"name": FPT_PLAY_STREAM_SELECTORS[channel_id]["label"], "stream_url": primary, "m3u8_proxy_url": f"/api/proxy/m3u8?url={quote(primary, safe='')}" if primary else "", "iframe_url": FPT_PLAY_IFRAMES.get(channel_id), "stream_selectors": FPT_PLAY_STREAM_SELECTORS[channel_id], "is_fpt_play": True, "status": "ok"}) - if channel_id not in CHANNEL_NAMES: return JSONResponse({"error":"not found"}, status_code=404) - stream_url = get_channel_url(channel_id) - iframe_url = IFRAME_URLS.get(channel_id) - is_flv = stream_url and ".flv" in stream_url - m3u8_proxy = "" - if stream_url and not is_flv and stream_url.startswith("http"): m3u8_proxy = f"/api/proxy/m3u8?url={quote(stream_url, safe='')}" - return JSONResponse({"name": CHANNEL_NAMES[channel_id], "stream_url": stream_url, "m3u8_proxy_url": m3u8_proxy, "proxy_url": f"/api/proxy/flv?url={quote(stream_url, safe='')}" if stream_url and is_flv else "", "proxy_url_hls": f"/api/proxy/stream?url={quote(stream_url, safe='')}" if stream_url and not is_flv else "", "is_flv": is_flv, "iframe_url": iframe_url, "status": "ok"}) - -@router.get("/api/vtv/m3u/refresh") -def api_vtv_m3u_refresh(): - global _last_m3u_fetch - _last_m3u_fetch = 0 - _fetch_m3u() - urls = dict(CHANNEL_URLS) - found = sum(1 for v in urls.values() if v) - return JSONResponse({"status":"refreshed","channels_found":found,"channels":{k:v for k,v in urls.items() if v}}) - -_epg_cache = {}; _epg_cache_time = 0; _EPG_CACHE_TTL = 600 -VTV_CHANNEL_MAP = {"vtv1":"vtv1","vtv2":"vtv2","vtv3":"vtv3","vtv4":"vtv4","vtv5":"vtv5","vtv5-tay-nam-bo":"vtv5","vtv5-tay-nguyen":"vtv5","vtv6":"vtv6","vtv7":"vtv7","vtv8":"vtv8","vtv9":"vtv9","vtv-can-tho":"vtv10"} - -def _parse_time(time_str, reference_date=None): - if not time_str: return None - time_str = time_str.strip().replace("h",":").replace("H",":") - m = re.search(r'(\d{1,2}):(\d{2})', time_str) - if m: - try: - hour, minute = int(m.group(1)), int(m.group(2)) - base = reference_date or datetime.now(VN_TZ) - if hour < 5: base = base - timedelta(days=1) if base.hour >= 5 else base - dt = base.replace(hour=hour, minute=minute, second=0, microsecond=0) - if dt.tzinfo is None: dt = dt.replace(tzinfo=VN_TZ) - return dt - except: pass - return None - -def _fetch_epg_from_vtv(): - global _epg_cache, _epg_cache_time - now_ts = time.time() - if _epg_cache and now_ts - _epg_cache_time < _EPG_CACHE_TTL: return _epg_cache - epg_data = {} - now_vn = datetime.now(VN_TZ) - try: - h = {"User-Agent": UA["User-Agent"], "Accept-Language": "vi-VN,vi;q=0.9", "Referer": "https://vtv.vn/"} - r = requests.get("https://vtv.vn/lich-phat-song.htm", headers=h, timeout=20) - if r.status_code != 200: return epg_data - r.encoding = "utf-8" - soup = BeautifulSoup(r.text, "lxml") - channel_order = [] - for link in soup.find_all('a', href=re.compile(r'truyen-hinh-truc-tuyen/([^.]+)\.htm')): - ch_id = re.search(r'truyen-hinh-truc-tuyen/([^.]+)\.htm', link.get('href','')) - if ch_id and ch_id.group(1) not in channel_order: channel_order.append(ch_id.group(1)) - containers = soup.find_all('ul', class_=re.compile(r'\bprograms\b')) - for i, container in enumerate(containers): - if i >= len(channel_order): break - vtv_id = channel_order[i] - our_id = VTV_CHANNEL_MAP.get(vtv_id, vtv_id) - if our_id not in epg_data: epg_data[our_id] = [] - for li in container.find_all('li', class_=re.compile(r'\bprogram\b')): - t = li.find('span', class_=re.compile(r'\btime\b')) - title_el = li.find('span', class_=re.compile(r'\btitle\b')) - genre = li.find('a', class_=re.compile(r'\bgenre\b')) - time_str = t.get_text(strip=True) if t else "" - title = genre.get_text(strip=True) if genre else "" - if not title and title_el: title = title_el.get_text(strip=True) - if not time_str or not title: continue - start_dt = _parse_time(time_str, reference_date=now_vn) - if not start_dt: continue - epg_data[our_id].append({"time":time_str[:5],"title":title[:80],"start_dt":start_dt,"date":start_dt.strftime("%d/%m/%Y")}) - for ch_id in epg_data: epg_data[ch_id].sort(key=lambda x: x.get("start_dt") or datetime.min) - except Exception as e: print(f"EPG error: {e}") - _epg_cache = epg_data; _epg_cache_time = now_ts - return epg_data - -@router.get("/api/vtv/epg/{channel_id}") -def api_vtv_epg(channel_id: str): - channel_id = channel_id.lower().strip() - if channel_id not in CHANNEL_NAMES: return JSONResponse({"error":"channel not found"}, status_code=404) - epg_data = _fetch_epg_from_vtv() - programmes = epg_data.get(channel_id, []) - now = datetime.now(VN_TZ) - today = now.date() - result = [] - today_progs = [p for p in programmes if p.get("start_dt") and p["start_dt"].date() == today] or programmes - for i, p in enumerate(today_progs): - start_dt = p.get("start_dt") - stop_dt = today_progs[i+1].get("start_dt") if i+1 < len(today_progs) else None - is_now = bool(start_dt and ((stop_dt and start_dt <= now < stop_dt) or start_dt <= now)) - end_time = stop_dt.strftime("%H:%M") if stop_dt else "" - result.append({"time":p["time"],"title":p["title"],"end_time":end_time,"now":is_now,"date":p.get("date","")}) - return JSONResponse({"channel":channel_id,"channel_name":CHANNEL_NAMES.get(channel_id,channel_id),"date":now.strftime("%Y-%m-%d"),"programs":result}) - -@router.get("/api/vtv/epg") -def api_vtv_epg_refresh(): - global _epg_cache, _epg_cache_time - _epg_cache = {}; _epg_cache_time = 0 - epg_data = _fetch_epg_from_vtv() - return JSONResponse({"status":"refreshed","channels":len(epg_data),"total":sum(len(v) for v in epg_data.values())}) diff --git a/vtv_epg_data.json b/vtv_epg_data.json deleted file mode 100644 index 4772c6fcc5dccf5f56686855928e565d33758744..0000000000000000000000000000000000000000 --- a/vtv_epg_data.json +++ /dev/null @@ -1 +0,0 @@ -{"vtv1":[{"time":"00:00","title":"PHIM TRUYỆN: MẸ BIỂN - TẬP 24"},{"time":"00:00","title":"MẸ BIỂN - TẬP 26"},{"time":"00:00","title":"PHIM TRUYỆN: MẸ BIỂN - TẬP 25"},{"time":"00:30","title":"ÁNH SÁNG TRI THỨC: KHƠI NGUỒN SÁNG TẠO"},{"time":"00:30","title":"HỌC VÀ LÀM THEO BÁC: CÓ CHÍ THÌ NÊN"},{"time":"00:30","title":"CHỐNG GIAN LẬN-BẢO VỆ NGƯỜI DÙNG"},{"time":"00:45","title":"VĂN HỌC NGHỆ THUẬT: NSND DƯƠNG MINH ĐỨC"},{"time":"00:45","title":"SỰ LỰA CHỌN"},{"time":"00:45","title":"SỰ KIỆN VÀ BÌNH LUẬN"},{"time":"01:10","title":"PHIM TÀI LIỆU: KHI VỸ HÁT"},{"time":"01:10","title":"TƯƠNG LAI XANH: CHẤN CHỈNH KHAI THÁC KHOÁNG SẢN"},{"time":"01:10","title":"GIAI ĐIỆU KẾT NỐI"},{"time":"01:40","title":"KHÁM PHÁ VIỆT NAM: DẤU ẤN ĐÀO XÁ"},{"time":"01:40","title":"KHÁM PHÁ VIỆT NAM: NGƯỜI BAHNAR BÊN DÒNG ĐẮK BLA"},{"time":"02:00","title":"THỂ THAO: KẾT NỐI THỂ THAO"},{"time":"02:00","title":"THỂ THAO: GIỜ VÀNG THỂ THAO"},{"time":"02:00","title":"THỂ THAO"},{"time":"02:30","title":"TỪ NHỮNG MIỀN QUÊ: BÌNH YÊN BẢO LỘC"},{"time":"02:30","title":"TỪ NHỮNG MIỀN QUÊ: SẮC MÀU TRÊN ĐẤT MƯỜNG VÀ"},{"time":"02:30","title":"TỪ NHỮNG MIỀN QUÊ - VÙNG ĐẤT CẨM NAM"},{"time":"02:45","title":"VTV SỐNG KHỎE: KHI TRÁI TIM LOẠN NHỊP"},{"time":"02:45","title":"VTV SỐNG KHỎE: VIÊM TAI GIỮA, NHỮNG NGUY CƠ NGÀY HÈ"},{"time":"02:45","title":"VTV SỐNG KHỎE"},{"time":"03:30","title":"PHIM TRUYỆN: GIA ĐÌNH MÌNH VUI BẤT THÌNH LÌNH - TẬP 39"},{"time":"03:30","title":"PHIM TRUYỆN: GIA ĐÌNH MÌNH VUI BẤT THÌNH LÌNH - TẬP 41"},{"time":"03:30","title":"GIA ĐÌNH MÌNH VUI BẤT THÌNH LÌNH - TẬP 43"},{"time":"04:15","title":"PHIM TRUYỆN: GIA ĐÌNH MÌNH VUI BẤT THÌNH LÌNH - TẬP 40"},{"time":"04:15","title":"PHIM TRUYỆN: GIA ĐÌNH MÌNH VUI BẤT THÌNH LÌNH - TẬP 42"},{"time":"04:15","title":"GIA ĐÌNH MÌNH VUI BẤT THÌNH LÌNH - TẬP 44"},{"time":"05:05","title":"S - VIỆT NAM: BÀ NÀ TINH HOA ẨM THỰC CHÂU ÂU"},{"time":"05:05","title":"S - VIỆT NAM: GIA LAI - MÙA LỄ HỘI CẦU NGƯ"},{"time":"05:05","title":"S - VIỆT NAM"},{"time":"05:10","title":"KINH TẾ BẠC: NGÔI NHÀ THỨ 2"},{"time":"05:10","title":"VÌ CỘNG ĐỒNG: DỆT HOA SẮC MÀU TRUYỀN THỐNG"},{"time":"05:10","title":"HÀNH TRÌNH HY VỌNG"},{"time":"05:25","title":"HẢI QUAN VIỆT NAM"},{"time":"05:30","title":"CHÀO BUỔI SÁNG"},{"time":"07:00","title":"TIÊU ĐIỂM CHÍNH SÁCH"},{"time":"07:00","title":"BÁO CHÍ TOÀN CẢNH"},{"time":"07:00","title":"TÀI CHÍNH - KINH DOANH"},{"time":"07:15","title":"KHÔNG GIAN VĂN HÓA NGHỆ THUẬT"},{"time":"07:25","title":"VIỆT NAM ĐA SẮC"},{"time":"07:30","title":"PHÓNG SỰ: CHUYỆN LÀNG TRONG VẬN NƯỚC"},{"time":"07:30","title":"NẺO VỀ NGUỒN CỘI - ÂM VANG TRỐNG ĐỒNG"},{"time":"07:45","title":"DÁM SỐNG: RỰC RỠ GIỮA BÓNG TỐI"},{"time":"07:45","title":"KHÁM PHÁ VIỆT NAM - NGHỀ XƯA GIỮA NHỊP SỐNG MỚI"},{"time":"08:00","title":"VTV KẾT NỐI"},{"time":"08:00","title":"SỐNG MỚI"},{"time":"08:00","title":"HÀNH TRÌNH HY VỌNG"},{"time":"08:15","title":"SÁCH HAY THAY ĐỔI CUỘC ĐỜI"},{"time":"08:15","title":"SỐNG XANH"},{"time":"08:30","title":"TẠP CHÍ KINH TẾ CUỐI TUẦN"},{"time":"08:40","title":"ĐI CÙNG CHÚNG TÔI"},{"time":"08:45","title":"CHỐNG GIAN LẬN-BẢO VỆ NGƯỜI DÙNG"},{"time":"09:00","title":"THỜI SỰ"},{"time":"09:05","title":"DU LỊCH VIỆT NAM"},{"time":"09:15","title":"SỰ KIỆN VÀ BÌNH LUẬN"},{"time":"09:15","title":"TOÀN CẢNH THẾ GIỚI"},{"time":"09:15","title":"TẠP CHÍ KINH TẾ CUỐI TUẦN"},{"time":"09:45","title":"DÁM SỐNG: KIẾN TRÚC SƯ MAI HƯNG TRUNG"},{"time":"09:45","title":"VIETNAM 360: QUY HOẠCH HÀ NỘI TẦM NHÌN 100 NĂM"},{"time":"09:45","title":"VTV KẾT NỐI"},{"time":"10:00","title":"ĐIỂM TỰA CUỘC SỐNG: BẢO VỆ TRẺ EM TRÊN KHÔNG GIAN MẠNG"},{"time":"10:00","title":"THỂ THAO"},{"time":"10:15","title":"VTV KẾT NỐI"},{"time":"10:25","title":"TOÀN CẢNH THẾ GIỚI"},{"time":"10:30","title":"TƯƠNG LAI XANH: CHẤN CHỈNH KHAI THÁC KHOÁNG SẢN"},{"time":"10:30","title":"CẶP LÁ YÊU THƯƠNG"},{"time":"11:00","title":"SỐNG AN TOÀN"},{"time":"11:00","title":"SỐNG XANH"},{"time":"11:00","title":"TÀI CHÍNH - KINH DOANH"},{"time":"11:30","title":"CUỘC SỐNG SỐ"},{"time":"11:45","title":"GÓC NHÌN VĂN HÓA"},{"time":"12:00","title":"THỜI SỰ"},{"time":"12:40","title":"SỰ LỰA CHỌN"},{"time":"12:40","title":"NÔNG NGHIỆP XANH: CANH TÁC SỐ"},{"time":"12:45","title":"NHỊP SỐNG TUỔI BẠC"},{"time":"12:55","title":"CẶP LÁ YÊU THƯƠNG"},{"time":"13:00","title":"VTV SỐNG KHỎE - DINH DƯỠNG CHO NGƯỜI VIỆT: ĐẢM BẢO DINH DƯỠNG CHO TRẺ TRONG THỜI BÃO GIÁ"},{"time":"13:00","title":"VTV SỐNG KHỎE: ĐIỀU TRỊ HIỆU QUẢ UNG THƯ VÚ"},{"time":"13:00","title":"VTV SỐNG KHỎE"},{"time":"13:45","title":"PHỤ NỮ VÀ CUỘC SỐNG: NỮ ĐẠI SỨ DU LỊCH LÀNG NGHỀ"},{"time":"13:45","title":"80 NĂM QUỐC HỘI VIỆT NAM"},{"time":"13:45","title":"TỪ NHỮNG MIỀN QUÊ - VÙNG ĐẤT CẨM NAM"},{"time":"14:00","title":"GÓC NHÌN VĂN HÓA"},{"time":"14:15","title":"HỌC VÀ LÀM THEO BÁC: CÓ CHÍ THÌ NÊN"},{"time":"14:15","title":"HÀNH TRÌNH DI SẢN: SỨC SỐNG BÊN DÒNG SUỐI MƯỜNG HOA"},{"time":"14:15","title":"SỐNG MỚI"},{"time":"14:30","title":"VĂN HỌC NGHỆ THUẬT: NHỮNG CÂY CẦU CỦA VĂN CHƯƠNG"},{"time":"14:55","title":"ÁNH SÁNG TRI THỨC - AI TRONG GIÁO DỤC"},{"time":"15:00","title":"DOANH NGHIỆP - DOANH NHÂN: CHUYỂN DỊCH"},{"time":"15:00","title":"GIAI ĐIỆU KẾT NỐI"},{"time":"15:10","title":"DOANH NGHIỆP - DOANH NHÂN - CHUYỂN DỊCH"},{"time":"15:25","title":"VTV KẾT NỐI"},{"time":"15:30","title":"TRÁI TIM CHO EM: ƯỚC MƠ TỪ NHỊP TIM NHỎ"},{"time":"15:30","title":"CẶP LÁ YÊU THƯƠNG"},{"time":"15:40","title":"THƯƠNG HIỆU QUỐC GIA VIỆT NAM: THƯƠNG HIỆU QUỐC GIA VÀ SỞ HỮU TRÍ TUỆ"},{"time":"15:45","title":"KHUYẾN HỌC - HÀNH TRÌNH TRI THỨC: DÒNG HỌ KHOA BẢNG NGUYỄN VŨ"},{"time":"15:55","title":"VỀ QUÊ: LIÊN KẾT BỀN VỮNG NƠI VÙNG CAO"},{"time":"15:55","title":"CÙNG EM ĐẾN TRƯỜNG: NHỮNG MÓN QUÀ TIẾP SỨC"},{"time":"16:00","title":"THỜI SỰ"},{"time":"16:15","title":"VĂN HOÁ CAND: BẢN LĨNH VÀ KHÁT VỌNG"},{"time":"16:15","title":"VĂN HOÁ QĐND"},{"time":"16:15","title":"NHÂN ĐẠO - ĐIỂM TỰA AN SINH XÃ HỘI"},{"time":"16:30","title":"SỰ KIỆN VÀ BÌNH LUẬN"},{"time":"16:45","title":"VÌ CỘNG ĐỒNG: DỆT HOA SẮC MÀU TRUYỀN THỐNG"},{"time":"16:45","title":"KINH TẾ BẠC: NHỮNG NGƯỜI TIÊN PHONG"},{"time":"17:00","title":"NHẬT KÝ NGƯỜI VIỆT: LÚA LAI HAI DÒNG Ở VIỆT NAM"},{"time":"17:00","title":"NHẬT KÝ NGƯỜI VIỆT: GIỮ HỒN NHẠC CỤ ĐÀO XÁ"},{"time":"17:00","title":"VIỆT NAM ĐA SẮC"},{"time":"17:05","title":"KHÁM PHÁ VIỆT NAM: NGƯỜI BAHNAR BÊN DÒNG ĐẮK BLA"},{"time":"17:05","title":"KHÁM PHÁ VIỆT NAM: THẠNH HÓA -BẢN HÒA CA TỪ BIỂN"},{"time":"17:10","title":"CẢI CÁCH HÀNH CHÍNH"},{"time":"17:20","title":"HÀNH TRÌNH VẺ ĐẸP: ĐỜN CA TÀI TỬ MIỀN TÂY XỨ DỪA"},{"time":"17:20","title":"HÀNH TRÌNH VẺ ĐẸP"},{"time":"17:30","title":"CHUYỂN ĐỘNG 24H"},{"time":"18:00","title":"VIỆT NAM HÔM NAY"},{"time":"18:25","title":"VÌ TẦM VÓC VIỆT"},{"time":"19:00","title":"THỜI SỰ"},{"time":"19:40","title":"THỜI TIẾT + THỂ THAO 24/7"},{"time":"19:40","title":"THỂ THAO 24/7"},{"time":"19:55","title":"ĐIỂM TIN"},{"time":"20:00","title":"S - VIỆT NAM: GIA LAI - MÙA LỄ HỘI CẦU NGƯ"},{"time":"20:00","title":"S - VIỆT NAM: DẤU ẤN ĐÔNG DƯƠNG GIỮA LÒNG HẢI PHÒNG"},{"time":"20:00","title":"VIỆT NAM - ĐIỂM HẸN"},{"time":"20:05","title":"VIỆT NAM VUI KHỎE"},{"time":"20:10","title":"TRUYỀN HÌNH TRỰC TIẾP: KHAI MẠC LỄ HỘI VÌ HÒA BÌNH NĂM 2026"},{"time":"20:10","title":"TRUYỀN HÌNH QUÂN ĐỘI NHÂN DÂN"},{"time":"20:10","title":"PHÓNG SỰ"},{"time":"20:30","title":"QUỐC DÂN HIỂU THUẾ"},{"time":"20:30","title":"PHIM TÀI LIỆU"},{"time":"20:45","title":"TIÊU ĐIỂM"},{"time":"20:55","title":"THUẾ VÀ ĐỜI SỐNG"},{"time":"21:00","title":"PHIM TÀI LIỆU: HÀNH TRÌNH CỦA NHỮNG GIÁ TRỊ SỐNG"},{"time":"21:00","title":"PHÍA BÊN KIA THÀNH PHỐ - TẬP 27"},{"time":"21:30","title":"ÁNH SÁNG TRI THỨC: AI TRONG GIÁO DỤC"},{"time":"21:30","title":"TÀI CHÍNH - KINH DOANH"},{"time":"21:45","title":"CÂU CHUYỆN QUỐC TẾ: RANH GIỚI MÀN HÌNH"},{"time":"21:45","title":"DÁM SỐNG: KIẾN TRÚC SƯ MAI HƯNG TRUNG"},{"time":"21:55","title":"VIỆC TỬ TẾ"},{"time":"22:00","title":"CHUYỂN ĐỘNG CUỐI NGÀY"},{"time":"22:30","title":"NHẬT KÝ FIFA WORLD CUP 2026"},{"time":"22:45","title":"TÁC PHẨM MỚI: NGÀY ĐẤT NƯỚC CHUYỂN MÌNH"},{"time":"22:45","title":"HÒA NHẠC THÍNH PHÒNG: HÒA NHẠC VIỄN PHƯƠNG - PHẦN 2"},{"time":"22:45","title":"VTV KẾT NỐI"},{"time":"23:00","title":"GIỜ VÀNG THỂ THAO"},{"time":"23:00","title":"SỐNG MỚI"},{"time":"23:15","title":"TỔ QUỐC TRONG TIM"},{"time":"23:15","title":"GIỜ VÀNG THỂ THAO"},{"time":"23:45","title":"KHÁM PHÁ VIỆT NAM: NGƯỜI BAHNAR BÊN DÒNG ĐẮK BLA"},{"time":"23:45","title":"KHÁM PHÁ VIỆT NAM: THẠNH HÓA -BẢN HÒA CA TỪ BIỂN"}],"vtv2":[{"time":"00:00","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 39"},{"time":"00:00","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 40"},{"time":"00:00","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 41"},{"time":"00:45","title":"KHÁM PHÁ VIỆT NAM: DẤU ẤN THIÊNG HAI BÀ TRƯNG"},{"time":"00:45","title":"KHÁM PHÁ VIỆT NAM: NHỊP SỐNG MIỀN CHIÊM TRŨNG"},{"time":"00:45","title":"KHÁM PHÁ VIỆT NAM: HƯƠNG VỊ GIỮ HỒN QUÊ"},{"time":"01:00","title":"BẠN CỦA NHÀ NÔNG"},{"time":"01:45","title":"DÁM SỐNG: HOÀNG VẼ CUỘC ĐỜI MÌNH"},{"time":"01:45","title":"DÁM SỐNG: KÌNH NGƯ"},{"time":"01:45","title":"DÁM SỐNG: CHÀNG ĐAM - SAN THỜI HIỆN ĐẠI"},{"time":"02:00","title":"KHÁM PHÁ THẾ GIỚI: SỰ SỐNG TRONG ĐẠI DƯƠNG - TẬP 5"},{"time":"02:00","title":"KHÁM PHÁ THẾ GIỚI: SỰ SỐNG TRONG ĐẠI DƯƠNG - TẬP 6"},{"time":"02:00","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 1"},{"time":"02:30","title":"HIỂU SÂU - SỐNG CHẤT: KHI MÂU THUẪN VƯỢT NGƯỠNG"},{"time":"02:30","title":"HIỂU SÂU - SỐNG CHẤT: ÔNG BÀ THỜI CHUYỂN ĐỔI SỐ"},{"time":"03:00","title":"S - TECH"},{"time":"03:30","title":"CHUYỆN NHÀ THỜI NAY"},{"time":"04:00","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 40"},{"time":"04:00","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 41"},{"time":"04:00","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 42"},{"time":"04:45","title":"DÁM SỐNG: KÌNH NGƯ"},{"time":"04:45","title":"DÁM SỐNG: CHÀNG ĐAM - SAN THỜI HIỆN ĐẠI"},{"time":"04:45","title":"DÁM SỐNG: ĐỂ MẠCH NGUỒN TUỒNG CHẢY MÃI"},{"time":"05:00","title":"EDUTALK - BÀN LUẬN GIÁO DỤC: TRƯỜNG CHUYÊN TIỆM CẬN CHUẨN QUỐC TẾ"},{"time":"05:00","title":"BÍ ẨN TỰ NHIÊN: BÍ ẨN NÚI LỬA ĐĂK NÔNG"},{"time":"05:00","title":"ĐƯỜNG TỚI NÔNG TRẠI: DU LỊCH NÔNG NGHIỆP TRẢI NGHIỆM"},{"time":"05:30","title":"BẠN CỦA NHÀ NÔNG"},{"time":"06:15","title":"VTV SỐNG KHỎE: SỐNG CHUNG VỚI TRÁI TIM SUY"},{"time":"06:15","title":"VTV SỐNG KHỎE: YOGA VỚI SỨC KHỎE NGƯỜI CAO TUỔI"},{"time":"06:15","title":"VTV SỐNG KHỎE: RỐI LOẠN TIC - HIỂU ĐỂ CHỮA LÀNH"},{"time":"06:45","title":"KHÁM PHÁ VIỆT NAM: NHỊP SỐNG MIỀN CHIÊM TRŨNG"},{"time":"06:45","title":"VTV KẾT NỐI"},{"time":"06:45","title":"KHÁM PHÁ VIỆT NAM: DẤU ẤN THIÊNG HAI BÀ TRƯNG"},{"time":"07:00","title":"CHUYỆN NHÀ THỜI NAY"},{"time":"07:30","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 1"},{"time":"07:30","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 2"},{"time":"07:30","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 3"},{"time":"08:00","title":"VTV SỐNG KHỎE: VIÊM TAI GIỮA, NHỮNG NGUY CƠ NGÀY HÈ"},{"time":"08:00","title":"TƯ VẤN TUYỂN SINH"},{"time":"08:00","title":"VTV SỐNG KHỎE: UNG THƯ TRỰC TRÀNG"},{"time":"08:45","title":"DÁM SỐNG: CHÀNG ĐAM - SAN THỜI HIỆN ĐẠI"},{"time":"08:45","title":"DÁM SỐNG: HÀNH TRÌNH TỎA SÁNG THẾ GIỚI"},{"time":"09:00","title":"BÍ ẨN TỰ NHIÊN: BÍ ẨN NÚI LỬA ĐĂK NÔNG"},{"time":"09:00","title":"ĐƯỜNG TỚI NÔNG TRẠI: DU LỊCH NÔNG NGHIỆP TRẢI NGHIỆM"},{"time":"09:25","title":"TRÁI TIM CHO EM: ƯỚC MƠ TỪ NHỊP TIM NHỎ"},{"time":"09:30","title":"S - TECH"},{"time":"10:00","title":"KHÁM PHÁ VIỆT NAM: NHỊP SỐNG MIỀN CHIÊM TRŨNG"},{"time":"10:00","title":"KHỞI NGHIỆP KIẾN QUỐC: CHỮ TÍN - NỀN MÓNG CỦA CÔNG TRÌNH"},{"time":"10:00","title":"KHÁM PHÁ VIỆT NAM: DẤU ẤN THIÊNG HAI BÀ TRƯNG"},{"time":"10:15","title":"VTV SỐNG KHỎE: RỐI LOẠN TIC - HIỂU ĐỂ CHỮA LÀNH"},{"time":"10:15","title":"VTV SỐNG KHỎE: SỐNG CHUNG VỚI TRÁI TIM SUY"},{"time":"10:15","title":"VTV SỐNG KHỎE: YOGA VỚI SỨC KHỎE NGƯỜI CAO TUỔI"},{"time":"10:45","title":"KHÁT VỌNG XANH: THÀNH PHỐ TÍCH NHIỆT"},{"time":"10:45","title":"KHÁT VỌNG XANH: HỆ LỤY TỪ MỘT THÚ CHƠI"},{"time":"10:45","title":"KHÁM PHÁ THẾ GIỚI: KHÁM PHÁ THÁI BÌNH DƯƠNG - TẬP 2"},{"time":"11:00","title":"PHIM HOẠT HÌNH: POKÉMON CHÂN TRỜI MỚI - TẬP 11"},{"time":"11:00","title":"PHIM HOẠT HÌNH: POKÉMON CHÂN TRỜI MỚI - TẬP 12"},{"time":"11:30","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 41"},{"time":"11:30","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 42"},{"time":"11:30","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 43"},{"time":"12:15","title":"TỪ NHỮNG MIỀN QUÊ: NGƯỜI THÁI Ở SƠN LA"},{"time":"12:15","title":"TỪ NHỮNG MIỀN QUÊ: NGHỀ NGÓI ÂM DƯƠNG BẮC SƠN"},{"time":"12:15","title":"TỪ NHỮNG MIỀN QUÊ: SẮC MÀU TRÊN ĐẤT MƯỜNG VÀ"},{"time":"12:30","title":"HIỂU SÂU - SỐNG CHẤT: VỈA HÈ CHUNG - LỢI ÍCH RIÊNG"},{"time":"12:30","title":"HIỂU SÂU - SỐNG CHẤT: HỘI NHÓM VÀ BÓC PHỐT TRÊN MẠNG"},{"time":"12:30","title":"HIỂU SÂU - SỐNG CHẤT: KHI MÂU THUẪN VƯỢT NGƯỠNG"},{"time":"13:00","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 1"},{"time":"13:00","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 2"},{"time":"13:00","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 3"},{"time":"13:30","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 2"},{"time":"13:30","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 3"},{"time":"13:30","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 4"},{"time":"14:00","title":"SẮC MÀU CÁC DÂN TỘC: CHUYỆN TỪ BẢN VẶT"},{"time":"14:00","title":"PHỤ NỮ LÀ ĐỂ YÊU THƯƠNG: BẠN ĐƯỢC QUYỀN HẠNH PHÚC"},{"time":"14:30","title":"CHUYỆN NHÀ THỜI NAY"},{"time":"15:00","title":"TƯ VẤN TUYỂN SINH"},{"time":"15:00","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 4"},{"time":"15:00","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG: BỆNH DA HIẾM GẶP - TẬP 1"},{"time":"15:30","title":"EDUTALK - BÀN LUẬN GIÁO DỤC: CHIẾN THUẬT CHỌN NGUYỆN VỌNG"},{"time":"15:30","title":"ĐƯỜNG TỚI NÔNG TRẠI: DU LỊCH NÔNG NGHIỆP TRẢI NGHIỆM"},{"time":"16:00","title":"CẢNH GIÁC 247: NGUY CƠ RỬA TIỀN TỪ VÍ ĐIỆN TỬ"},{"time":"16:15","title":"VTV KẾT NỐI"},{"time":"16:20","title":"CHECK IN VIỆT NAM: CÓ HẸN VỚI XỨ MƯỜNG"},{"time":"16:30","title":"KHÁM PHÁ VIỆT NAM: HƯƠNG VỊ GIỮ HỒN QUÊ"},{"time":"16:30","title":"KHÁM PHÁ VIỆT NAM: DẤU ẤN THIÊNG HAI BÀ TRƯNG"},{"time":"16:30","title":"KHÁM PHÁ VIỆT NAM: NHỊP SỐNG MIỀN CHIÊM TRŨNG"},{"time":"16:43","title":"THÔNG BÁO - GHI ƠN"},{"time":"16:45","title":"BẠN CỦA NHÀ NÔNG"},{"time":"17:25","title":"NHỊP ĐẬP VIỆT NAM: LÀNG CHÀI XUÂN HẢI"},{"time":"17:25","title":"NHỊP ĐẬP VIỆT NAM: NÉT ĐẸP CỔ KÍNH CHÙA TRÔNG"},{"time":"17:25","title":"NHỊP ĐẬP VIỆT NAM: CUỘC SỐNG LÀNG CHÀI CỬA NHƯỢNG"},{"time":"17:30","title":"ĐƯỜNG TỚI NÔNG TRẠI: DU LỊCH NÔNG NGHIỆP TRẢI NGHIỆM"},{"time":"17:30","title":"BÍ ẨN TỰ NHIÊN: BÍ ẨN NÚI LỬA ĐĂK NÔNG"},{"time":"17:30","title":"EDUTALK - BÀN LUẬN GIÁO DỤC: CHIẾN THUẬT CHỌN NGUYỆN VỌNG"},{"time":"18:00","title":"KIẾN THỨC CỘNG ĐỒNG: LIVESTREAM VÀ QUYỀN RIÊNG TƯ CÁ NHÂN"},{"time":"18:00","title":"KIẾN THỨC CỘNG ĐỒNG: HIỂU LUẬT MỖI NGÀY"},{"time":"18:00","title":"KIẾN THỨC CỘNG ĐỒNG: MIỄN GIẤY PHÉP XÂY DỰNG ÁP DỤNG RA SAO?"},{"time":"18:15","title":"NHỮNG BÔNG HOA NHỎ"},{"time":"18:30","title":"CHUYỆN NHÀ THỜI NAY"},{"time":"18:30","title":"CHUYỆN NHÀ THỜI NAY: CHUYỆN NHÀ THỜI NAY - TẬP 33"},{"time":"19:00","title":"CẬN CẢNH FIFA WORLD CUP 2026"},{"time":"19:30","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 1"},{"time":"19:30","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 2"},{"time":"19:30","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 3"},{"time":"20:00","title":"LÁT CẮT FIFA WORLD CUP 2026"},{"time":"20:05","title":"HIỂU SÂU - SỐNG CHẤT: VỈA HÈ CHUNG - LỢI ÍCH RIÊNG"},{"time":"20:10","title":"CẢNH GIÁC 247: NGUY CƠ RỬA TIỀN TỪ VÍ ĐIỆN TỬ"},{"time":"20:10","title":"EDUTALK - BÀN LUẬN GIÁO DỤC: CHIẾN THUẬT CHỌN NGUYỆN VỌNG"},{"time":"20:30","title":"SÁCH HAY THAY ĐỔI CUỘC ĐỜI"},{"time":"20:30","title":"S - TECH"},{"time":"20:35","title":"PHỤ NỮ LÀ ĐỂ YÊU THƯƠNG: BẠN ĐƯỢC QUYỀN HẠNH PHÚC"},{"time":"20:40","title":"BÍ ẨN TỰ NHIÊN: BÃO - CỖ MÁY CUỒNG PHONG - TẬP 1"},{"time":"21:00","title":"CHECK IN VIỆT NAM: CÓ HẸN VỚI XỨ MƯỜNG"},{"time":"21:00","title":"PHIM TÀI LIỆU NƯỚC NGOÀI: NHỮNG BÍ MẬT CHƯA KHÉP LẠI CỦA THỜI APARTHEID - TẬP 2"},{"time":"21:00","title":"KHÁT VỌNG XANH: GIA TĂNG LŨ CỰC ĐOAN"},{"time":"21:15","title":"VTV SỐNG KHỎE: RỐI LOẠN TIC - HIỂU ĐỂ CHỮA LÀNH"},{"time":"21:15","title":"VTV SỐNG KHỎE: SỐNG CHUNG VỚI TRÁI TIM SUY"},{"time":"21:45","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 4"},{"time":"21:45","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG: BỆNH DA HIẾM GẶP - TẬP 1"},{"time":"21:45","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 5"},{"time":"22:15","title":"CHUYỆN NHÀ THỜI NAY"},{"time":"22:15","title":"CHUYỆN NHÀ THỜI NAY: CHUYỆN NHÀ THỜI NAY - TẬP 33"},{"time":"22:45","title":"KHÁM PHÁ VIỆT NAM: HƯƠNG VỊ GIỮ HỒN QUÊ"},{"time":"22:45","title":"KHÁM PHÁ VIỆT NAM: DẤU ẤN THIÊNG HAI BÀ TRƯNG"},{"time":"22:45","title":"KHÁM PHÁ VIỆT NAM: NHỊP SỐNG MIỀN CHIÊM TRŨNG"},{"time":"23:00","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 1"},{"time":"23:00","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 2"},{"time":"23:00","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 3"},{"time":"23:30","title":"BÍ ẨN TỰ NHIÊN: BÃO - CỖ MÁY CUỒNG PHONG - TẬP 1"},{"time":"23:30","title":"S - TECH"}],"vtv3":[{"time":"00:00","title":"THANH ÂM TỪ ĐẤT VIỆT: ĐẤT HÁT"},{"time":"00:00","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 VÒNG 16 ĐỘI: CANADA - MA RỐC"},{"time":"00:10","title":"PHIM TRUYỆN: TÌNH YÊU CỦA ĐỜI TÔI - TẬP 39"},{"time":"00:20","title":"BÌNH LUẬN THỂ THAO"},{"time":"01:00","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 VÒNG 32 ĐỘI: AUSTRALIA - AI CẬP"},{"time":"01:00","title":"ĐƯỜNG LÊN ĐỈNH OLYMPIA"},{"time":"01:50","title":"SAO CHECK: CA SĨ VŨ THẢO MY"},{"time":"02:20","title":"BÌNH LUẬN THỂ THAO"},{"time":"03:00","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 VÒNG 16 ĐỘI: BRAZIL - NA UY"},{"time":"03:20","title":"BÌNH LUẬN THỂ THAO"},{"time":"04:00","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 VÒNG 16 ĐỘI: PARAGUAY - PHÁP"},{"time":"04:20","title":"BÌNH LUẬN THỂ THAO"},{"time":"05:00","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 VÒNG 32 ĐỘI: ARGENTINA - CAPE VERDE"},{"time":"06:20","title":"BÌNH LUẬN THỂ THAO"},{"time":"07:00","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 VÒNG 16 ĐỘI: MEXICO - ANH"},{"time":"07:20","title":"BÌNH LUẬN THỂ THAO"},{"time":"07:30","title":"CHUYỆN NHÀ THỜI NAY"},{"time":"07:55","title":"VIỆT NAM XANH: NGÀY HỘI ĐỔI RÁC LẤY QUÀ"},{"time":"08:00","title":"VTV KẾT NỐI"},{"time":"08:10","title":"THỂ THAO"},{"time":"08:30","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 VÒNG 32 ĐỘI: COLOMBIA - GHANA"},{"time":"08:40","title":"CẨM NANG VÀNG CHO SỨC KHỎE"},{"time":"09:00","title":"BỘ BA TRANH TÀI: HỒNG LUYẾN, THANH HUYỀN, ĐỨC THÀNH"},{"time":"10:00","title":"PHỤ NỮ SỐ"},{"time":"10:30","title":"NHẬT KÝ TRÊN KHÓA SOL: CA SĨ HOÀNG ANH"},{"time":"10:30","title":"HIỂU SÂU - SỐNG CHẤT: CHUYỆN ĐỌC SÁCH THỜI ĐẠI SỐ"},{"time":"10:55","title":"THANH ÂM TỪ ĐẤT VIỆT: NHỊP ĐIỆU ĐÔNG HỒ"},{"time":"11:00","title":"NHÀ MÌNH QUÁ ĐỈNH"},{"time":"11:10","title":"THÔNG TIN 260"},{"time":"11:15","title":"ĐIỀU NHỎ BÉ KỲ DIỆU"},{"time":"11:30","title":"GÌ THẾ NHỈ"},{"time":"11:45","title":"GIA ĐÌNH VUI VẺ"},{"time":"11:50","title":"QUÀ TẶNG CUỘC SỐNG: SỰ TÍCH CÁI DÂY LƯNG"},{"time":"12:00","title":"ÚM BA LA RA CHỮ GÌ?"},{"time":"12:00","title":"KHÁCH SẠN 5 SAO"},{"time":"12:00","title":"PHIM TRUYỆN: SỚM TỐI CÓ NHAU - TẬP 7"},{"time":"12:45","title":"ALO! ĐÂY LÀ..."},{"time":"12:50","title":"HẠNH PHÚC LÀ GÌ?: VÕ CAO ĐỈNH"},{"time":"12:50","title":"HẠNH PHÚC LÀ GÌ?: TỐNG THỊ LÂM"},{"time":"13:00","title":"VUA TIẾNG VIỆT"},{"time":"13:00","title":"ĐƯỜNG LÊN ĐỈNH OLYMPIA"},{"time":"13:00","title":"PHIM TRUYỆN: CÂU CHUYỆN HOA HỒNG - TẬP 22"},{"time":"13:50","title":"ĐƯỜNG LÊN ĐỈNH OLYMPIA"},{"time":"14:00","title":"PHIM TRUYỆN: DỊU DÀNG MÀU NẮNG - TẬP 11"},{"time":"14:00","title":"PHIM TRUYỆN: DỊU DÀNG MÀU NẮNG - TẬP 12"},{"time":"14:30","title":"ÚM BA LA RA CHỮ GÌ?"},{"time":"14:50","title":"CẢ NHÀ CÙNG VUI"},{"time":"15:00","title":"BẬT MÍ BÍ MẬT"},{"time":"15:20","title":"THỂ THAO: GIỜ VÀNG THỂ THAO"},{"time":"15:40","title":"ALO! ĐÂY LÀ..."},{"time":"15:50","title":"ALO! ĐÂY LÀ..."},{"time":"15:50","title":"GÌ THẾ NHỈ"},{"time":"16:00","title":"SÂN CỎ FIFA WORLD CUP 2026"},{"time":"16:10","title":"TALK VIETNAM: HẦU ĐỒNG - DI SẢN KHÔNG BIÊN GIỚI"},{"time":"16:10","title":"PHIM TRUYỆN: CUỘC ĐỜI VẪN ĐẸP SAO - TẬP 41"},{"time":"16:30","title":"SAO CHECK: DIỄN VIÊN NGỌC THANH TÂM"},{"time":"16:55","title":"THỜI TIẾT"},{"time":"16:55","title":"PHÚT GIÂY THƯ GIÃN: THẦN DƯỢC CHANH MUỐI"},{"time":"17:00","title":"S - VIỆT NAM (15)"},{"time":"17:00","title":"ĐIỀU NHỎ BÉ KỲ DIỆU"},{"time":"17:14","title":"THỜI TIẾT"},{"time":"17:15","title":"SÂN CỎ FIFA WORLD CUP 2026"},{"time":"17:15","title":"TALK VIETNAM: HẦU ĐỒNG - DI SẢN KHÔNG BIÊN GIỚI"},{"time":"17:20","title":"THÔNG TIN 260"},{"time":"17:29","title":"THỜI TIẾT"},{"time":"17:30","title":"SÂN CỎ FIFA WORLD CUP 2026"},{"time":"17:45","title":"VTV KẾT NỐI"},{"time":"18:00","title":"PHIM TRUYỆN: TRÁI TIM KHÔNG THỂ NGỪNG YÊU - TẬP 10"},{"time":"18:00","title":"PHIM TRUYỆN: TRÁI TIM KHÔNG THỂ NGỪNG YÊU - TẬP 11"},{"time":"18:00","title":"PHIM TRUYỆN: TRÁI TIM KHÔNG THỂ NGỪNG YÊU - TẬP 12"},{"time":"18:35","title":"CẢM HỨNG FIFA WORL CUP 2026"},{"time":"19:00","title":"THỜI SỰ"},{"time":"19:55","title":"SAO 24H"},{"time":"20:00","title":"ANH TRAI VƯỢT NGÀN CHÔNG GAI"},{"time":"20:00","title":"STUDIO 3"},{"time":"20:00","title":"PHIM TRUYỆN (20H00): DƯỚI Ô CỬA SÁNG ĐÈN - TẬP 22"},{"time":"20:50","title":"ĐIỀU NHỎ BÉ KỲ DIỆU"},{"time":"20:55","title":"FANZONE FIFA WORLD CUP 2026"},{"time":"21:00","title":"GAMEBOX - HỘP TRÒ CHƠI"},{"time":"21:00","title":"FANZONE FIFA WORLD CUP 2026"},{"time":"21:05","title":"ĐẦU BẾP THƯỢNG ĐỈNH"},{"time":"21:25","title":"FANZONE FIFA WORLD CUP 2026"},{"time":"21:30","title":"PHIM TRUYỆN: TIỂU TAM KHÔNG CÓ LỖI? - TẬP 18"},{"time":"21:55","title":"QUÀ TẶNG CUỘC SỐNG: CON VOI TRẮNG"},{"time":"22:00","title":"NÓNG CÙNG FIFA WORLD CUP 2026"},{"time":"22:35","title":"VTV KẾT NỐI"},{"time":"22:40","title":"PHIM TRUYỆN: NƠI TUYẾN LỬA - TẬP 9"},{"time":"22:50","title":"NÓNG CÙNG FIFA WORLD CUP 2026"},{"time":"23:00","title":"NÓNG CÙNG FIFA WORLD CUP 2026"},{"time":"23:20","title":"BÌNH LUẬN THỂ THAO"},{"time":"23:30","title":"THỂ THAO"}],"vtv4":[{"time":"00:00","title":"BẢN TIN THỜI SỰ"},{"time":"00:20","title":"SỨC SỐNG THỂ THAO"},{"time":"00:25","title":"THỜI TIẾT DU LỊCH"},{"time":"00:30","title":"PHIM TRUYỆN: HOA HỒNG TRÊN NGỰC TRÁI - TẬP 46"},{"time":"00:30","title":"PHIM TRUYỆN: HƯỚNG DƯƠNG NGƯỢC NẮNG - TẬP 1 - PHẦN 1"},{"time":"00:30","title":"PHIM TRUYỆN: HƯỚNG DƯƠNG NGƯỢC NẮNG - TẬP 2 - PHẦN 1"},{"time":"01:15","title":"NGƯỜI VIỆT BỐN PHƯƠNG"},{"time":"01:15","title":"S - VIỆT NAM"},{"time":"01:15","title":"CHECK IN VIỆT NAM: HƠI THỞ CỦA BIỂN"},{"time":"01:30","title":"NHÀ HÁT TRUYỀN HÌNH: VỞ CẢI LƯƠNG: HÀO KIỆT VỚI GIANG SƠN"},{"time":"01:30","title":"STUDIO 3"},{"time":"01:30","title":"PHIM TRUYỆN: PHỐ TRONG LÀNG - TẬP 31"},{"time":"02:00","title":"GIAI ĐIỆU CUỘC SỐNG: NEO ĐẬU BẾN QUÊ"},{"time":"02:15","title":"MIỀN ĐẤT VÕ: VOVINAM - NGHỆ THUẬT CẬN CHIẾN"},{"time":"02:30","title":"THẾ GIỚI TUỔI THƠ: VẦNG TRĂNG CỦA EM"},{"time":"02:55","title":"XIN CHÀO VIỆT NAM: ĐÀ NẴNG - MỘT GÓC BÌNH YÊN TRÊN ĐỈNH BÀ NÀ"},{"time":"03:00","title":"TỌA ĐÀM: CHÍNH PHỦ KIẾN TẠO PHÁT TRIỂN - SỐ 1"},{"time":"03:00","title":"CUỘC SỐNG VẪN TƯƠI ĐẸP: NHỮNG KHUNG HÌNH HẠNH PHÚC"},{"time":"03:00","title":"NÚI SÔNG BỜ CÕI: VIỆT NAM TRÚNG CỬ THẨM PHÁN ITLOS"},{"time":"03:30","title":"NÚI SÔNG BỜ CÕI: VIỆT NAM TRÚNG CỬ THẨM PHÁN ITLOS"},{"time":"03:30","title":"VĂN HỌC NGHỆ THUẬT: NSND HÀ THỦY - GỌI TÊN NHỮNG THANH ÂM"},{"time":"03:30","title":"TIẾNG VIỆT DIỆU KÌ: CÂU ĐỐ VỀ CON VẬT"},{"time":"03:45","title":"GÓC ĐỒNG HÀNH: ĐIỂM TỰA Y TẾ TẠI HÀN QUỐC"},{"time":"03:55","title":"XIN CHÀO VIỆT NAM: ĐÀ NẴNG - MỘT GÓC BÌNH YÊN TRÊN ĐỈNH BÀ NÀ"},{"time":"04:00","title":"GIAI ĐIỆU CUỘC SỐNG: NHẠT NẮNG"},{"time":"04:00","title":"PHIM CUỐI TUẦN: TRẢ GIÁ"},{"time":"04:00","title":"GIAI ĐIỆU CUỘC SỐNG: CỎ VÀ MƯA"},{"time":"04:15","title":"MIỀN ĐẤT VÕ: VOVINAM - DĨ NHU CHẾ CƯƠNG"},{"time":"04:15","title":"PHÓNG SỰ: PHÁT TRIỂN DU LỊCH SỨC KHOẺ TỪ DI SẢN"},{"time":"04:30","title":"ÁNH SÁNG TRI THỨC: TINH HOA KIẾN TRÚC"},{"time":"04:30","title":"GIAI ĐIỆU CUỘC SỐNG: LỜI MẸ RU"},{"time":"04:45","title":"TÁC PHẨM MỚI: NẾP NHÀ MIỀN XANH"},{"time":"05:00","title":"PHIM TRUYỆN: BIỆT DƯỢC ĐEN - TẬP 24"},{"time":"05:00","title":"PHIM TRUYỆN: BIỆT DƯỢC ĐEN - TẬP 25"},{"time":"05:45","title":"THỜI TIẾT DU LỊCH"},{"time":"05:50","title":"TRÁI TIM CHO EM: ƯỚC MƠ TỪ NHỊP TIM NHỎ"},{"time":"06:00","title":"ĐƯỜNG LÊN ĐỈNH OLYMPIA"},{"time":"06:00","title":"NHÀ MÌNH QUÁ ĐỈNH"},{"time":"06:45","title":"PHIM TRUYỆN: HOA HỒNG TRÊN NGỰC TRÁI - TẬP 46"},{"time":"06:45","title":"PHIM TRUYỆN: HƯỚNG DƯƠNG NGƯỢC NẮNG - TẬP 1 - PHẦN 1"},{"time":"06:45","title":"PHIM TRUYỆN: HƯỚNG DƯƠNG NGƯỢC NẮNG - TẬP 2 - PHẦN 1"},{"time":"07:30","title":"NGƯỜI VIỆT BỐN PHƯƠNG"},{"time":"07:30","title":"HƯỚNG VỀ TỔ QUỐC"},{"time":"07:30","title":"CUỘC SỐNG PHƯƠNG XA: LAN TỎA NGHỆ THUẬT MÚA VIỆT TẠI PHÁP"},{"time":"07:45","title":"GIAI ĐIỆU CUỘC SỐNG: NHẠT NẮNG"},{"time":"07:45","title":"TÁC PHẨM MỚI: NẾP NHÀ MIỀN XANH"},{"time":"07:55","title":"XIN CHÀO VIỆT NAM: ĐÀ NẴNG - MỘT GÓC BÌNH YÊN TRÊN ĐỈNH BÀ NÀ"},{"time":"08:00","title":"TỌA ĐÀM: CHÍNH PHỦ KIẾN TẠO PHÁT TRIỂN - SỐ 1"},{"time":"08:00","title":"SAO CHECK: CA SĨ VƯƠNG BÌNH"},{"time":"08:00","title":"TIẾNG VIỆT DIỆU KÌ: CÂU ĐỐ VỀ CON VẬT"},{"time":"08:15","title":"TALK VIETNAM: HẦU ĐỒNG - DI SẢN KHÔNG BIÊN GIỚI"},{"time":"08:30","title":"WHEN IN VIETNAM: NHỮNG NGƯỜI YÊU ĐỘNG VẬT"},{"time":"08:30","title":"VIETNAM DISCOVERY: KHÁM PHÁ DI SẢN THẾ GIỚI TẠI QUẢNG NINH"},{"time":"09:00","title":"NÚI SÔNG BỜ CÕI: VIỆT NAM TRÚNG CỬ THẨM PHÁN ITLOS"},{"time":"09:00","title":"VĂN HỌC NGHỆ THUẬT: NSND HÀ THỦY - GỌI TÊN NHỮNG THANH ÂM"},{"time":"09:00","title":"DU LỊCH VÀ ẨM THỰC: VĨNH LONG - NHỮNG CÂU CHUYỆN BÊN DÒNG PHÙ SA"},{"time":"09:30","title":"NHỊP SỐNG CỘNG ĐỒNG"},{"time":"09:30","title":"PHIM TÀI LIỆU: NHỮNG ĐỨA TRẺ HẠNH PHÚC - TẬP 3: KHI VỸ HÁT"},{"time":"09:30","title":"TỔ QUỐC TRONG TIM"},{"time":"09:35","title":"THẾ GIỚI TUỔI THƠ: ĐI ĐỂ LỚN LÊN"},{"time":"10:00","title":"NHÀ HÁT TRUYỀN HÌNH: VỞ CẢI LƯƠNG: HÀO KIỆT VỚI GIANG SƠN"},{"time":"10:00","title":"PHIM CUỐI TUẦN: TRẢ GIÁ"},{"time":"10:00","title":"KHÁM PHÁ VIỆT NAM: SÔNG THU BỒN VÀ LỄ HỘI BÀ MẸ SỨ XỞ"},{"time":"10:10","title":"S - VIỆT NAM"},{"time":"10:25","title":"XIN CHÀO VIỆT NAM: ĐÀ NẴNG - MỘT GÓC BÌNH YÊN TRÊN ĐỈNH BÀ NÀ"},{"time":"10:30","title":"GIAI ĐIỆU CUỘC SỐNG: LỜI MẸ RU"},{"time":"11:00","title":"ĐIỂM HẸN NGƯỜI VIỆT"},{"time":"11:15","title":"PHIM TRUYỆN: PHỐ TRONG LÀNG - TẬP 31"},{"time":"11:35","title":"ĐI ĐỂ BIẾT: MỘT NGÀY Ở ĐẢO NGỌC VỪNG"},{"time":"11:45","title":"DÁM SỐNG: HÀNH TRÌNH KỲ DIỆU"},{"time":"11:45","title":"GIAI ĐIỆU CUỘC SỐNG: MỖI KHI ANH NHÌN EM"},{"time":"12:00","title":"BẢN TIN THỜI SỰ"},{"time":"12:25","title":"SỨC SỐNG THỂ THAO"},{"time":"12:30","title":"NHÀ MÌNH QUÁ ĐỈNH"},{"time":"12:30","title":"STUDIO 3"},{"time":"12:30","title":"SẮC MÀU CÁC DÂN TỘC: CHUYỆN KỂ TỪ THANH ÂM"},{"time":"13:00","title":"PHÓNG SỰ: PHÁT TRIỂN DU LỊCH SỨC KHOẺ TỪ DI SẢN"},{"time":"13:15","title":"PHIM TRUYỆN: BIỆT DƯỢC ĐEN - TẬP 24"},{"time":"13:15","title":"PHIM TRUYỆN: BIỆT DƯỢC ĐEN - TẬP 25"},{"time":"14:00","title":"GÓC ĐỒNG HÀNH: ĐIỂM TỰA Y TẾ TẠI HÀN QUỐC"},{"time":"14:00","title":"HƯỚNG VỀ TỔ QUỐC"},{"time":"14:00","title":"ĐIỂM HẸN NGƯỜI VIỆT"},{"time":"14:15","title":"PHIM CA NHẠC: HÀ NỘI NGÀY TRỞ VỀ"},{"time":"14:15","title":"VUI - KHỎE - CÓ ÍCH"},{"time":"14:30","title":"NET ZERO - GỬI TƯƠNG LAI"},{"time":"15:00","title":"BẢN TIN THỜI SỰ"},{"time":"15:25","title":"NHỊP SỐNG CỘNG ĐỒNG"},{"time":"15:30","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 2"},{"time":"15:30","title":"SẮC MÀU CÁC DÂN TỘC: BẢO TỒN QUẦN THỂ DI TÍCH CỐ ĐÔ HUẾ"},{"time":"15:30","title":"GÓC NHÌN CỘNG ĐỒNG: ỨNG XỬ NƠI CÔNG CỘNG - CHUYỆN KHÔNG NHỎ"},{"time":"15:55","title":"XIN CHÀO VIỆT NAM: ĐÀ NẴNG - MỘT GÓC BÌNH YÊN TRÊN ĐỈNH BÀ NÀ"},{"time":"16:00","title":"PHIM CUỐI TUẦN: TRẢ GIÁ"},{"time":"16:00","title":"CHUYẾN XE ÂM NHẠC XANH"},{"time":"16:00","title":"PHIM TRUYỆN: PHỐ TRONG LÀNG - TẬP 31"},{"time":"16:30","title":"GIAI ĐIỆU CUỘC SỐNG: TÌNH EM"},{"time":"16:45","title":"KHÁM PHÁ VIỆT NAM: SÔNG THU BỒN VÀ LỄ HỘI BÀ MẸ SỨ XỞ"},{"time":"17:00","title":"ĐƯỜNG LÊN ĐỈNH OLYMPIA"},{"time":"17:15","title":"ÁNH SÁNG TRI THỨC: TINH HOA KIẾN TRÚC"},{"time":"17:30","title":"KHÁM PHÁ VIỆT NAM: SẮC MÀU VĂN HÓA LAI CHÂU"},{"time":"17:30","title":"KHÁM PHÁ VIỆT NAM: CÁT BÀ - BẢN GIAO HƯỞNG CỦA BIỂN VÀ RỪNG"},{"time":"17:45","title":"TỪ NHỮNG MIỀN QUÊ: LÀNG RAU DƯỚI CHÂN NÚI TAM ĐẢO"},{"time":"17:45","title":"TỪ NHỮNG MIỀN QUÊ: ĐỘC ĐÁO VŨ ĐIỆU CỦA NGƯỜI SÁN CHỈ"},{"time":"17:45","title":"ÁNH SÁNG TRI THỨC: TRỞ VỀ ĐỂ KIẾN TẠO"},{"time":"18:00","title":"GIA ĐÌNH VUI VẺ"},{"time":"18:00","title":"NGƯỜI VIỆT BỐN PHƯƠNG"},{"time":"18:15","title":"VIỆT NAM KẾT NỐI THIÊN NHIÊN"},{"time":"19:00","title":"VIỆT NAM - ĐIỂM HẸN: THẮP SÁNG BẦU TRỜI"},{"time":"19:00","title":"TOÀN CẢNH THẾ GIỚI"},{"time":"19:00","title":"S - TECH"},{"time":"19:05","title":"HITECH CÔNG NGHỆ TƯƠNG LAI"},{"time":"19:30","title":"GÓC NHÌN CỘNG ĐỒNG: ỨNG XỬ NƠI CÔNG CỘNG - CHUYỆN KHÔNG NHỎ"},{"time":"19:30","title":"TIẾNG VIỆT KHÔNG KHÓ: DẠO QUANH THẢO CẦM VIÊN"},{"time":"19:30","title":"KẾT NỐI THỂ THAO"},{"time":"19:45","title":"NHÌN TỪ HÀ NỘI: KHÁT VỌNG TĂNG TRƯỞNG VÀ NIỀM TIN PHÁT TRIỂN"},{"time":"19:55","title":"RẠNG RỠ VIỆT NAM"},{"time":"20:00","title":"DU LỊCH VÀ ẨM THỰC: VĨNH LONG - NHỮNG CÂU CHUYỆN BÊN DÒNG PHÙ SA"},{"time":"20:00","title":"NET ZERO - GỬI TƯƠNG LAI"},{"time":"20:00","title":"MÔI TRƯỜNG - GÓC NHÌN TỪ QUỐC TẾ: KHI TRÁI ĐẤT NÓNG LÊN"},{"time":"20:30","title":"NÚI SÔNG BỜ CÕI: VIỆT NAM TRÚNG CỬ THẨM PHÁN ITLOS"},{"time":"20:30","title":"PHIM TÀI LIỆU: NHỮNG ĐỨA TRẺ HẠNH PHÚC - TẬP 3: KHI VỸ HÁT"},{"time":"20:30","title":"MIỀN ĐẤT VÕ: VOVINAM - NGHỆ THUẬT CẬN CHIẾN"},{"time":"20:45","title":"VIỆT NAM QUA GÓC NHÌN QUỐC TẾ"},{"time":"21:00","title":"BẢN TIN THỜI SỰ"},{"time":"21:30","title":"SỨC SỐNG THỂ THAO"},{"time":"21:35","title":"THỜI TIẾT DU LỊCH"},{"time":"21:40","title":"TIỂU PHẨM HÀI: HẺM 168 - TẬP 132"},{"time":"21:40","title":"VĂN HỌC NGHỆ THUẬT: NSND HÀ THỦY - GỌI TÊN NHỮNG THANH ÂM"},{"time":"21:40","title":"TIỂU PHẨM HÀI: HẺM 168 - TẬP 133"},{"time":"21:55","title":"GÓC ĐỒNG HÀNH: ĐIỂM TỰA Y TẾ TẠI HÀN QUỐC"},{"time":"21:55","title":"ĐIỂM HẸN NGƯỜI VIỆT"},{"time":"22:10","title":"NHỊP ĐẬP VIỆT NAM: TẾT KHU CÙ TÊ CỦA NGƯỜI LA CHÍ"},{"time":"22:10","title":"NHỊP ĐẬP VIỆT NAM: LÀNG NGHỀ MỘC CÚC BỒ"},{"time":"22:10","title":"NHỊP ĐẬP VIỆT NAM: HƯƠNG THU TRÊN BẢN TÀY"},{"time":"22:15","title":"PHIM TRUYỆN: BIỆT DƯỢC ĐEN - TẬP 24"},{"time":"22:15","title":"TALK VIETNAM: HẦU ĐỒNG - DI SẢN KHÔNG BIÊN GIỚI"},{"time":"22:15","title":"PHIM TRUYỆN: BIỆT DƯỢC ĐEN - TẬP 25"},{"time":"23:00","title":"GIAI ĐIỆU CUỘC SỐNG: CHIẾC LÁ VÔ TÌNH"},{"time":"23:00","title":"GIAI ĐIỆU CUỘC SỐNG: HOA NẮNG TÔI"},{"time":"23:00","title":"GIAI ĐIỆU CUỘC SỐNG: CỎ VÀ MƯA"},{"time":"23:15","title":"NHỊP SỐNG CỘNG ĐỒNG"},{"time":"23:15","title":"VIỆT NAM - ĐIỂM HẸN: THẮP SÁNG BẦU TRỜI"},{"time":"23:15","title":"S - VIỆT NAM: MÙA XANH Ở CÁT BÀ"},{"time":"23:20","title":"THẾ GIỚI TUỔI THƠ: ĐI ĐỂ LỚN LÊN"},{"time":"23:20","title":"NET ZERO - GỬI TƯƠNG LAI"},{"time":"23:20","title":"TỔ QUỐC TRONG TIM"},{"time":"23:45","title":"QUYẾN RŨ VIỆT NAM: THANH ÂM XƯA NINH BÌNH"},{"time":"23:45","title":"KHÁM PHÁ VIỆT NAM: HÀNH TRÌNH XANH TRÊN ĐẤT QUẢNG NGÃI"}],"vtv5":[{"time":"00:00","title":"PHIM TRUYỆN: THIÊN LONG BÁT BỘ - TẬP 9"},{"time":"00:05","title":"PHIM TRUYỆN: MỸ NHÂN TẦNG 22- TẬP 9"},{"time":"00:05","title":"PHIM TRUYỆN: MỸ NHÂN TẦNG 22 - TẬP 10"},{"time":"00:05","title":"PHIM TRUYỆN: MỸ NHÂN TẦNG 22 - TẬP 11"},{"time":"00:45","title":"PHÓNG SỰ: BƯỚC TRÊN CON ĐƯỜNG TRI THỨC"},{"time":"00:45","title":"NHỊP SỐNG HÔM NAY"},{"time":"00:45","title":"PHÓNG SỰ: NGHỊ QUYẾT 14- ĐÒN BẨY ĐỂ VÙNG CAO BỨT PHÁ BẰNG CÔNG NGHỆ SỐ"},{"time":"01:00","title":"VTV5 KẾT NỐI"},{"time":"01:00","title":"VTV SỐNG KHỎE: KHI HUYẾT ÁP ÂM THẦM TĂNG CAO"},{"time":"01:15","title":"NÔNG NGHIỆP XANH: THÚC ĐẨY NÔNG NGHIỆP HÀNG HOÁ"},{"time":"01:15","title":"VTV5 KẾT NỐI"},{"time":"01:30","title":"SỔ TAY CÔNG NGHỆ: SÁNG TẠO NỘI DUNG SỐ VỀ LÀNG QUÊ"},{"time":"01:30","title":"KINH TẾ NÔNG THÔN: LÀNG NGHỀ TRƯỚC SỨC ÉP CỦA THỊ TRƯỜNG"},{"time":"01:45","title":"CHÍNH SÁCH VÀ CUỘC SỐNG: PHÁT TRIỂN LÀNG DU LỊCH CỘNG ĐỒNG VEN BIỂN"},{"time":"01:45","title":"THANH ÂM VIỆT: EM GÁI NƠI BẢN DAO"},{"time":"01:45","title":"THƯƠNG NHỚ MIỀN TÂY"},{"time":"02:00","title":"PHIM TRUYỆN: HOA SỮA VỀ TRONG GIÓ - TẬP 32"},{"time":"02:00","title":"PHIM TRUYỆN: HOA SỮA VỀ TRONG GIÓ - TẬP 33"},{"time":"02:00","title":"PHIM TRUYỆN: HOA SỮA VỀ TRONG GIÓ - TẬP 34"},{"time":"02:45","title":"THÔNG TIN CHÍNH SÁCH PHÁP LUẬT"},{"time":"02:45","title":"KIẾN THỨC VÀ CUỘC SỐNG"},{"time":"02:45","title":"KHÁM PHÁ VIỆT NAM: SÔNG THU BỒN VÀ LỄ HỘI BÀ MẸ XỨ SỞ"},{"time":"03:00","title":"PHIM HOẠT HÌNH: 100% SÓI - TẬP 46"},{"time":"03:00","title":"PHIM HOẠT HÌNH: 100% SÓI - TẬP 47"},{"time":"03:00","title":"PHIM HOẠT HÌNH: 100% SÓI - TẬP 48"},{"time":"03:15","title":"NHỮNG NGƯỜI CON ĐẤT VIỆT: NƠI SÁNG KIẾN NẢY MẦM"},{"time":"03:15","title":"CHECK IN VN: CHÍN TẦNG MÂY GIỮA ĐẠI NGÀN"},{"time":"03:15","title":"VTV5 KẾT NỐI"},{"time":"03:30","title":"AN TOÀN GIAO THÔNG"},{"time":"03:30","title":"DÂN TỘC TÔN GIÁO: GIEO DUYÊN LÀNH CHO ĐỜI"},{"time":"03:30","title":"KHÁM PHÁ VIỆT NAM"},{"time":"03:45","title":"PHỤ NỮ SỐ: HÀNH TRÌNH NÂNG TẦM TRÀ VIỆT"},{"time":"03:45","title":"SẮC MÀU CÁC DÂN TỘC: GIỮA MIỀN SỚN CƯỚC NẶM ĐẶM"},{"time":"03:45","title":"DÂN TỘC PHÁT TRIỂN: LAI CHÂU PHÁT TRIỂN CÔNG NGHIỆP NĂNG LƯỢNG"},{"time":"04:15","title":"PHIM TRUYỆN: GIÂY PHÚT GẶP EM - TẬP 3"},{"time":"04:15","title":"PHIM TRUYỆN: GIÂY PHÚT GẶP EM - TẬP 4"},{"time":"04:15","title":"PHIM TRUYỆN: GIÂY PHÚT GẶP EM - TẬP 5"},{"time":"05:00","title":"CHƯƠNG TRÌNH TIẾNG MƯỜNG"},{"time":"05:00","title":"CHƯƠNG TRÌNH TIẾNG SÁN CHÍ"},{"time":"05:30","title":"CHƯƠNG TRÌNH TIẾNG TÀY"},{"time":"06:00","title":"CHƯƠNG TRÌNH TIẾNG MÔNG"},{"time":"06:30","title":"CHƯƠNG TRÌNH TIẾNG DAO"},{"time":"07:00","title":"CHƯƠNG TRÌNH TIẾNG THÁI"},{"time":"07:00","title":"CHƯƠNG TRÌNH TIẾNG DAO"},{"time":"07:30","title":"CHƯƠNG TRÌNH TIẾNG MÔNG"},{"time":"08:00","title":"CHƯƠNG TRÌNH TIẾNG DAO"},{"time":"08:30","title":"CHƯƠNG TRÌNH TIẾNG THÁI"},{"time":"08:30","title":"CHÀO TUẦN MỚI"},{"time":"09:00","title":"CHÍNH SÁCH VÀ CUỘC SỐNG: SÁT CÁNH CÙNG HỘ NGHÈO"},{"time":"09:00","title":"VTV SỐNG KHỎE: NGUY CƠ NẮNG NÓNG KÉO DÀI"},{"time":"09:00","title":"NÔNG THÔN MỚI: GIỮ CHUẨN NÔNG THÔN MỚI SAU SÁP NHẬP"},{"time":"09:15","title":"KHÁM PHÁ VIỆT NAM: SÔNG THU BỒN VÀ LỄ HỘI BÀ MẸ XỨ SỞ"},{"time":"09:15","title":"VTV5 KẾT NỐI"},{"time":"09:30","title":"VTV5 KẾT NỐI"},{"time":"09:30","title":"PHÁT HUY VAI TRÒ CỦA MẶT TRẬN"},{"time":"09:35","title":"QUÀ TẶNG CUỘC SỐNG: TÌNH YÊU CỦA VỊT MÁI"},{"time":"09:45","title":"ĐƯỜNG LÊN ĐỈNH OLYMPIA"},{"time":"09:45","title":"VTV5 KẾT NỐI"},{"time":"09:45","title":"VUA TIẾNG VIỆT"},{"time":"10:00","title":"SAO CHECK: DIỄN VIÊN NGỌC THANH TÂM"},{"time":"10:30","title":"PHIM TRUYỆN: HOA SỮA VỀ TRONG GIÓ - TẬP 33"},{"time":"10:30","title":"PHIM TRUYỆN: HOA SỮA VỀ TRONG GIÓ - TẬP 34"},{"time":"10:30","title":"PHIM TRUYỆN: HOA SỮA VỀ TRONG GIÓ - TẬP 35"},{"time":"11:00","title":"VĂN HÓA TÂY NGUYÊN: ĐỂ TIẾNG CHIÊNG MÃI NGÂN VANG"},{"time":"11:00","title":"SẮC MÀU VIỆT NAM: GIỮ HỒN MẠNH NGUỒN VĂN HOÁ SƠN LA"},{"time":"11:00","title":"GÌ THẾ NHỈ?"},{"time":"11:15","title":"PHIM HOẠT HÌNH: 100% SÓI - TẬP 47"},{"time":"11:15","title":"PHIM HOẠT HÌNH: 100% SÓI - TẬP 48"},{"time":"11:15","title":"PHIM HOẠT HÌNH: 100% SÓI - TẬP 49"},{"time":"11:30","title":"THỜI SỰ"},{"time":"11:50","title":"BẢN TIN THỂ THAO"},{"time":"11:55","title":"BẢN TIN THỊ TRƯỜNG"},{"time":"11:55","title":"SỨC SỐNG NGHỊ QUYẾT"},{"time":"12:00","title":"PHIM TRUYỆN: GIA ĐÌNH LÀ TẤT CẢ - TẬP 55"},{"time":"12:00","title":"PHIM TRUYỆN: GIA ĐÌNH LÀ TẤT CẢ - TẬP 57"},{"time":"12:00","title":"PHIM TRUYỆN: GIA ĐÌNH LÀ TẤT CẢ - TẬP 59"},{"time":"12:45","title":"PHIM TRUYỆN: GIA ĐÌNH LÀ TẤT CẢ - TẬP 56"},{"time":"12:45","title":"PHIM TRUYỆN: GIA ĐÌNH LÀ TẤT CẢ - TẬP 58"},{"time":"12:45","title":"PHIM TRUYỆN: GIA ĐÌNH LÀ TẤT CẢ - TẬP 60"},{"time":"13:30","title":"CHƯƠNG TRÌNH TIẾNG CAO LAN"},{"time":"13:30","title":"CHƯƠNG TRÌNH TIẾNG HÀ NHÌ"},{"time":"13:30","title":"CHƯƠNG TRÌNH TIẾNG MƯỜNG"},{"time":"14:00","title":"CHƯƠNG TRÌNH TIẾNG MÔNG"},{"time":"14:30","title":"CHƯƠNG TRÌNH TIẾNG DAO"},{"time":"15:00","title":"CHƯƠNG TRÌNH TIẾNG THÁI"},{"time":"15:30","title":"CHƯƠNG TRÌNH TIẾNG MÔNG"},{"time":"16:00","title":"CHƯƠNG TRÌNH TIẾNG TÀY"},{"time":"16:30","title":"PHIM TRUYỆN: KHOẢNG CÁCH - TẬP 4"},{"time":"16:30","title":"PHIM TRUYỆN: KHOẢNG CÁCH - TẬP 5"},{"time":"16:30","title":"PHIM TRUYỆN: KHOẢNG CÁCH - TẬP 6"},{"time":"17:15","title":"KIẾN THỨC VÀ CUỘC SỐNG"},{"time":"17:15","title":"HÀNH TRÌNH MỞ LỐI"},{"time":"17:15","title":"THÔNG TIN CHÍNH SÁCH PHÁP LUẬT"},{"time":"17:30","title":"NHỊP SỐNG HÔM NAY"},{"time":"17:30","title":"TRẠM YÊU THƯƠNG: KHI NGHỀ XƯA THẮP LÊN HY VỌNG"},{"time":"17:45","title":"PHÓNG SỰ: HỢP NHẤT BA CHƯƠNG TRÌNH MỤC TIÊU QUỐC GIA"},{"time":"18:00","title":"THỜI SỰ"},{"time":"18:30","title":"THỜI TIẾT"},{"time":"18:35","title":"SẮC MÀU THỂ THAO"},{"time":"18:35","title":"BẢN TIN THỊ TRƯỜNG"},{"time":"18:35","title":"CHÀO TUẦN MỚI"},{"time":"18:40","title":"THIẾU NHI"},{"time":"18:40","title":"CẬN CẢNH THỂ THAO"},{"time":"19:00","title":"PHIM TRUYỆN: VÒNG TRÒN ĐỊNH MỆNH - TẬP 73"},{"time":"19:00","title":"PHIM TRUYỆN: VÒNG TRÒN ĐỊNH MỆNH - TẬP 75"},{"time":"19:00","title":"PHIM TRUYỆN: VÒNG TRÒN ĐỊNH MỆNH - TẬP 77"},{"time":"19:45","title":"PHIM TRUYỆN: VÒNG TRÒN ĐỊNH MỆNH - TẬP 74"},{"time":"19:45","title":"PHIM TRUYỆN: VÒNG TRÒN ĐỊNH MỆNH - TẬP 76"},{"time":"19:45","title":"PHIM TRUYỆN: VÒNG TRÒN ĐỊNH MỆNH - TẬP 78"},{"time":"20:30","title":"VUA TIẾNG VIỆT"},{"time":"20:30","title":"KHÁCH SẠN 5 SAO: CA SĨ ĐÔNG HÙNG - VÕ HẠ TRÂM"},{"time":"20:30","title":"PHIM TÀI LIỆU: HỒ TÙNG MẬU- TRỌN ĐỜI VÌ ĐẢNG, VÌ DÂN"},{"time":"21:00","title":"LÀM GIÀU TRÊN QUÊ HƯƠNG: HIỆU QUẢ MÔ HÌNH NUÔI CÁ GIỐNG"},{"time":"21:15","title":"VTV5 KẾT NỐI"},{"time":"21:15","title":"ĐIỂM HẸN BẢN SẮC: VANG XA VÓ NGỰA BẮC HÀ"},{"time":"21:15","title":"TRANG VĂN HÓA"},{"time":"21:30","title":"PHIM TRUYỆN: GIÂY PHÚT GẶP EM - TẬP 4"},{"time":"21:30","title":"PHIM TRUYỆN: GIÂY PHÚT GẶP EM - TẬP 5"},{"time":"21:30","title":"PHIM TRUYỆN: GIÂY PHÚT GẶP EM - TẬP 6"},{"time":"22:15","title":"BẠN KỂ TÔI NGHE"},{"time":"22:15","title":"VTV5 KẾT NỐI"},{"time":"22:15","title":"NHÌN RA THẾ GIỚI"},{"time":"22:30","title":"VĂN HỌC NGHỆ THUẬT: NGƯỜI GÓI MƯA MIỀN TRUNG"},{"time":"22:45","title":"XEM VÀ NGHĨ"},{"time":"22:45","title":"VTV5 KẾT NỐI"},{"time":"23:00","title":"PHIM TRUYỆN: THIÊN LONG BÁT BỘ - TẬP 8"},{"time":"23:00","title":"PHIM TRUYỆN: THIÊN LONG BÁT BỘ - TẬP 9"},{"time":"23:00","title":"PHIM TRUYỆN: THIÊN LONG BÁT BỘ - TẬP 10"}],"vtv6":[{"time":"00:00","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 32: COLOMBIA VS GHANA"},{"time":"00:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 32: ÚC - AI CẬP"},{"time":"00:20","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 1/8: PARAGUAY VS PHÁP"},{"time":"02:20","title":"TRUYỀN HÌNH TRỰC TIẾP FIFA WORLD CUP 2026 - VÒNG 1/8: BRAZIL VS NA UY"},{"time":"02:30","title":"360° THỂ THAO"},{"time":"03:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 1/8: PARAGUAY - PHÁP"},{"time":"03:30","title":"360° THỂ THAO"},{"time":"04:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 32: ARGENTINA - CABO VERDE"},{"time":"06:20","title":"TRUYỀN HÌNH TRỰC TIẾP FIFA WORLD CUP 2026 - VÒNG 1/8: MEXICO VS ANH"},{"time":"07:30","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 1/8: CANADA - MAROC"},{"time":"07:50","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 32: COLOMBIA - GHANA"},{"time":"09:20","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 1/8: PARAGUAY - PHÁP"},{"time":"10:15","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 1/8: BRAZIL VS NA UY"},{"time":"11:00","title":"CẬN CẢNH FIFA WORLD CUP 2026"},{"time":"11:30","title":"CẢM HỨNG FIFA WORL CUP 2026"},{"time":"11:45","title":"VTV SPORTS NEWS"},{"time":"12:00","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 32: ÚC - AI CẬP"},{"time":"12:00","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 32: TÂY BAN NHA - ÁO"},{"time":"12:00","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 1/8: MEXICO VS ANH"},{"time":"14:00","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 32: ARGENTINA - CABO VERDE"},{"time":"14:00","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 32: ÚC - AI CẬP"},{"time":"14:00","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 1/8: PARAGUAY VS PHÁP"},{"time":"15:50","title":"CẢM HỨNG FIFA WORL CUP 2026"},{"time":"16:00","title":"CẬN CẢNH FIFA WORLD CUP 2026"},{"time":"16:05","title":"SÂN CỎ FIFA WORLD CUP 2026"},{"time":"16:30","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 1/8: CANADA - MAROC"},{"time":"16:35","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 32: COLOMBIA - GHANA"},{"time":"16:35","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 1/8: CANADA - MAROC"},{"time":"18:30","title":"VTV SPORTS NEWS"},{"time":"18:45","title":"CẢM HỨNG FIFA WORL CUP 2026"},{"time":"18:45","title":"CẢM HỨNG FIFA WORLD CUP 2026"},{"time":"19:00","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 32: ÚC - AI CẬP"},{"time":"19:00","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 1/8: PARAGUAY - PHÁP"},{"time":"19:00","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 1/8: BRAZIL VS NA UY"},{"time":"21:00","title":"CẬN CẢNH FIFA WORLD CUP 2026"},{"time":"21:30","title":"SÂN CỎ FIFA WORLD CUP 2026"},{"time":"22:00","title":"360° THỂ THAO"},{"time":"22:30","title":"GIỜ VÀNG THỂ THAO"},{"time":"22:30","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 1/8: CANADA - MAROC"},{"time":"22:30","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 1/8: MEXICO VS ANH"},{"time":"22:50","title":"NÓNG CÙNG FIFA WORLD CUP 2026"},{"time":"23:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 1/8: CANADA - MAROC"}],"vtv7":[{"time":"06:03","title":"7 PHÚT CHO BỮA SÁNG: CÀ TÍM NHÚNG TRỨNG CHIÊN"},{"time":"06:03","title":"7 PHÚT CHO BỮA SÁNG: BÁNH MÌ NƯỚNG MUỐI ỚT"},{"time":"06:03","title":"7 PHÚT CHO BỮA SÁNG: CƠM HỘP BENTO CHO BÉ"},{"time":"06:13","title":"ĐẸP HƠN MỖI NGÀY: SỐ 7 - CHĂM SÓC MÙI HƯƠNG CƠ THỂ"},{"time":"06:13","title":"ĐẸP HƠN MỖI NGÀY: SỐ 8 - DỌN TỦ ĐỒ THEO PHONG CÁCH NHẬT BẢN"},{"time":"06:13","title":"ĐẸP HƠN MỖI NGÀY: SỐ 9 - CÁCH CHỌN ÁO SƠ MI CHO NAM"},{"time":"06:20","title":"CÙNG NHAU TA VẬN ĐỘNG: SỐ 8 - NGÔI NHÀ BIỂN XANH"},{"time":"06:20","title":"CÙNG NHAU TA VẬN ĐỘNG: SỐ 9 - TRÒ CHƠI GIẢI CỨU"},{"time":"06:20","title":"CÙNG NHAU TA VẬN ĐỘNG: SỐ 10 - CÙNG LẮC LƯ"},{"time":"06:30","title":"NHỮNG NGƯỜI BẠN DIỆU KỲ: TẬP 3"},{"time":"06:30","title":"BẠN LÀ HÌNH GÌ: VŨ ĐIỆU NGÔI SAO"},{"time":"06:30","title":"BẠN LÀ HÌNH GÌ: HÒN ĐẢO KỲ LẠ"},{"time":"06:35","title":"ĐỘI CỨU HỘ BIỂN XANH: TẬP 1"},{"time":"06:35","title":"ĐỘI CỨU HỘ BIỂN XANH - TẬP 2"},{"time":"06:45","title":"Ú ÒA: SỐ 14 - CHƠI VỚI BÓNG THỔI"},{"time":"06:45","title":"Ú ÒA: SỐ 15 - HAI BÀN TAY"},{"time":"06:45","title":"Ú ÒA: SỐ 16 - CẢM ƠN - XIN LỖI"},{"time":"07:00","title":"XỨ SỞ CẦU VỒNG: SỐ 18"},{"time":"07:00","title":"XỨ SỞ CẦU VỒNG: SỐ 19"},{"time":"07:00","title":"XỨ SỞ CẦU VỒNG - SỐ 20"},{"time":"07:30","title":"CRACK EM UP: SỐ 5"},{"time":"07:30","title":"CRACK EM UP: SỐ 6"},{"time":"07:30","title":"IELTS FACE OFF: SỐ 6 - GLOBETROTTER"},{"time":"08:00","title":"KHÁM PHÁ KHOA HỌC: SỐ 39 - TOÀ THÁP LỚN LÊN"},{"time":"08:00","title":"KHÁM PHÁ KHOA HỌC: SỐ 40 - DẠO BƯỚC TRÊN CẦU VỒNG"},{"time":"08:00","title":"KHÁM PHÁ KHOA HỌC: SỐ 41 - TỰ LÀM Ô TÔ ĐỘNG CƠ KHÍ NÉN"},{"time":"08:30","title":"BIỆT ĐỘI BICHILI: SỐ 2 - CÔNG CHÚA HOÁ HỌC"},{"time":"08:30","title":"CHUYỆN HỌC TRÒ: SỐ 7 - CÂU CHUYỆN VỚI HÌNH XĂM"},{"time":"08:30","title":"VĂN VUI VẺ: SỐ 1 - BỨC THƯ CỦA THỦ LĨNH DA ĐỎ"},{"time":"09:00","title":"HỌC SAO CHO TỐT: SỐ 3 - BƯỚC NGOẶT"},{"time":"09:00","title":"HỌC SAO CHO TỐT: SỐ 4 - MÔN TOÁN ĐÁNG SỢ"},{"time":"09:00","title":"HEO ĐẤT: SỐ 1 - TIỀN LÀ GÌ"},{"time":"09:20","title":"SÁNG TẠO 102: SỐ 10 - SA MẠC"},{"time":"09:35","title":"MỘT VÒNG TIẾNG VIỆT - SỐ 9"},{"time":"09:45","title":"ĐƯỜNG ĐẾN TRƯỜNG: TẤM VÉ TỚI TRƯỜNG"},{"time":"09:45","title":"ĐƯỜNG ĐẾN TRƯỜNG: NHỮNG CÂU CHUYỆN TRÊN ĐẢO"},{"time":"09:50","title":"MATH DORM: SỐ 5 - PHÉP NHÂN"},{"time":"10:00","title":"HÔM NAY CHƠI GÌ?: ĐỘNG VẬT - PHẦN 1"},{"time":"10:00","title":"HÔM NAY CHƠI GÌ?: ĐỘNG VẬT - PHẦN 2"},{"time":"10:05","title":"NHỮNG NGƯỜI BẠN CẦU VỒNG: HOA - TẬP 1"},{"time":"10:30","title":"CHUYẾN XE HẠT VỪNG: SỐ 16"},{"time":"10:30","title":"CHUYẾN XE HẠT VỪNG: SỐ 17"},{"time":"10:30","title":"CHUYẾN XE HẠT VỪNG - SỐ 18"},{"time":"11:00","title":"CHA MẸ THAY ĐỔI: SỐ 1 - ÂM THANH CỦA NHỮNG BẢN NHẠC BUỒN"},{"time":"11:00","title":"TRƯỜNG TEEN"},{"time":"11:00","title":"CÙNG LĂN VÀO BẾP - SỐ 23"},{"time":"11:15","title":"ĐẸP HƠN MỖI NGÀY: SỐ 8 - DỌN TỦ ĐỒ THEO PHONG CÁCH NHẬT BẢN"},{"time":"11:20","title":"5 KÝ HIỆU NGÔN NGỮ MỖI NGÀY: CẢM XÚC - PHẦN 2"},{"time":"11:30","title":"MẸ ƠI TẠI SAO: SỐ 3 - TẬP TRUNG"},{"time":"11:45","title":"TRƯỜNG HỌC HẠNH PHÚC: ĐỪNG ĐỂ NHỮNG ĐỨA TRẺ CÔ ĐƠN - TẬP 11"},{"time":"12:00","title":"XỨ SỞ CẦU VỒNG: SỐ 17"},{"time":"12:00","title":"XỨ SỞ CẦU VỒNG: SỐ 18"},{"time":"12:00","title":"XỨ SỞ CẦU VỒNG - SỐ 19"},{"time":"12:30","title":"ENGLISH BY STORIES: SỐ 10 - BẢY ĐIỀU ƯỚC"},{"time":"12:30","title":"ENGLISH BY STORIES: SỐ 1 - SỰ TÍCH SỌ DỪA"},{"time":"12:30","title":"VĂN VUI VẺ: SỐ 1 - BỨC THƯ CỦA THỦ LĨNH DA ĐỎ"},{"time":"12:55","title":"5 TỪ MỚI TIẾNG ANH MỖI NGÀY: MÀU SẮC"},{"time":"12:55","title":"5 TỪ MỚI TIẾNG ANH MỖI NGÀY: GIA VỊ"},{"time":"13:00","title":"JUMPING WITH TOEIC: SỐ 53"},{"time":"13:00","title":"JUMPING WITH TOEIC: SỐ 54"},{"time":"13:00","title":"JUMPING WITH TOEIC - SỐ 55"},{"time":"13:45","title":"FOLLOW US: SỐ 24 - LET ME GO HOME"},{"time":"13:45","title":"FOLLOW US: SỐ 25 - SKIN CARE"},{"time":"13:45","title":"FOLLOW US: SỐ 26 - SHOPAHOLICS"},{"time":"14:00","title":"CRACK EM UP: SỐ 5"},{"time":"14:00","title":"CRACK EM UP: SỐ 6"},{"time":"14:00","title":"IELTS FACE OFF: SỐ 6 - GLOBETROTTER"},{"time":"14:30","title":"KHÁM PHÁ KHOA HỌC: SỐ 39 - TOÀ THÁP LỚN LÊN"},{"time":"14:30","title":"KHÁM PHÁ KHOA HỌC: SỐ 40 - DẠO BƯỚC TRÊN CẦU VỒNG"},{"time":"14:30","title":"KHÁM PHÁ KHOA HỌC: SỐ 41 - TỰ LÀM Ô TÔ ĐỘNG CƠ KHÍ NÉN"},{"time":"15:00","title":"GÕ CỬA NGHỀ NGHIỆP: SỐ 3"},{"time":"15:00","title":"GÕ CỬA NGHỀ NGHIỆP: SỐ 5"},{"time":"15:00","title":"HEO ĐẤT: SỐ 1 - TIỀN LÀ GÌ"},{"time":"15:20","title":"SÁNG TẠO 102: SỐ 10 - SA MẠC"},{"time":"15:35","title":"MỘT VÒNG TIẾNG VIỆT - SỐ 9"},{"time":"15:45","title":"CON ĐƯỜNG NGHỀ NGHIỆP: SỐ 27 - CHĂM SÓC SẮC ĐẸP"},{"time":"15:45","title":"CON ĐƯỜNG NGHỀ NGHIỆP: SỐ 33 - KỸ SƯ CƠ KHÍ"},{"time":"15:50","title":"MATH DORM: SỐ 5 - PHÉP NHÂN"},{"time":"16:00","title":"CHA MẸ THAY ĐỔI: SỐ 1 - ÂM THANH CỦA NHỮNG BẢN NHẠC BUỒN"},{"time":"16:00","title":"TRƯỜNG TEEN"},{"time":"16:05","title":"CÙNG LĂN Vào Bếp - SỐ 23"},{"time":"16:20","title":"CUỐN SÁCH CỦA TÔI: SỐ 8 - RICO VÀ OSKAR"},{"time":"16:30","title":"MẸ ƠI TẠI SAO: SỐ 3 - TẬP TRUNG"},{"time":"16:45","title":"TRƯỜNG HỌC HẠNH PHÚC: ĐỪNG ĐỂ NHỮNG ĐỨA TRẺ CÔ ĐƠN - TẬP 11"},{"time":"17:00","title":"XỨ SỞ CẦU VỒNG: SỐ 18"},{"time":"17:00","title":"XỨ SỞ CẦU VỒNG: SỐ 19"},{"time":"17:00","title":"XỨ SỞ CẦU VỒNG - SỐ 20"},{"time":"17:30","title":"TRƯỜNG HỌC HẠNH PHÚC: ĐỪNG ĐỂ NHỮNG ĐỨA TRẺ CÔ ĐƠN - TẬP 9"},{"time":"17:30","title":"TRƯỜNG HỌC HẠNH PHÚC: ĐỪNG ĐỂ NHỮNG ĐỨA TRẺ CÔ ĐƠN - TẬP 10"},{"time":"17:30","title":"VĂN VUI VẺ: SỐ 1 - BỨC THƯ CỦA THỦ LĨNH DA ĐỎ"},{"time":"17:45","title":"EM YÊU VIỆT NAM: SỐ 6"},{"time":"17:45","title":"EM YÊU VIỆT NAM: SỐ 2 - VÀO MÙA"},{"time":"18:00","title":"THỬ THÁCH KHOA HỌC: SỐ 7 - HÓA HỌC ỨNG DỤNG"},{"time":"18:00","title":"THỬ THÁCH KHOA HỌC: SỐ 8 - KHÁM PHÁ CHẤT CHỈ THỊ"},{"time":"18:00","title":"THỬ THÁCH KHOA HỌC: SỐ 9 - ÁP SUẤT RẤT THÚ VỊ"},{"time":"18:15","title":"STREAM TOÁN HỌC: SỐ 8"},{"time":"18:15","title":"STREAM TOÁN HỌC: SỐ 9"},{"time":"18:15","title":"STREAM TOÁN HỌC - SỐ 10"},{"time":"18:30","title":"ENGLISH BY STORIES: SỐ 10 - BẢY ĐIỀU ƯỚC"},{"time":"18:30","title":"ENGLISH BY STORIES: SỐ 1 - SỰ TÍCH SỌ DỪA"},{"time":"18:30","title":"CUỐN SÁCH CỦA EM: SỐ 3 - PIPPY TẤT DÀI"},{"time":"18:45","title":"LÀ LA LÁ: SỐ 4 - CUỘC THI CAO ĐỘ"},{"time":"18:55","title":"5 TỪ MỚI TIẾNG ANH MỖI NGÀY: MÀU SẮC"},{"time":"18:55","title":"5 TỪ MỚI TIẾNG ANH MỖI NGÀY: GIA VỊ"},{"time":"19:00","title":"CHUYẾN XE HẠT VỪNG: SỐ 16"},{"time":"19:00","title":"CHUYẾN XE HẠT VỪNG: SỐ 17"},{"time":"19:00","title":"CHUYẾN XE HẠT VỪNG - SỐ 18"},{"time":"19:30","title":"HÔM NAY CHƠI GÌ?: ĐỘNG VẬT - PHẦN 1"},{"time":"19:30","title":"HÔM NAY CHƠI GÌ?: ĐỘNG VẬT - PHẦN 2"},{"time":"19:30","title":"NHỮNG NGƯỜI BẠN CẦU VỒNG: HOA - TẬP 1"},{"time":"19:50","title":"CHÔM CHÔM VÀ NHỮNG NGƯỜI BẠN: GIẢI CỨU CHÔM CHÔM"},{"time":"20:00","title":"123 TA CÙNG ĐẾM: SỐ 0"},{"time":"20:00","title":"123 TA CÙNG ĐẾM: SỐ 1"},{"time":"20:00","title":"KỸ NĂNG AN TOÀN CHO BÉ - SỐ 6"},{"time":"20:10","title":"HỌC VẼ CÙNG ẾCH CỐM: SỐ 39"},{"time":"20:10","title":"HỌC VẼ CÙNG ẾCH CỐM: SỐ 40"},{"time":"20:10","title":"NHỮNG NGƯỜI BẠN DIỆU KỲ - TẬP 4"},{"time":"20:20","title":"KIDS VOCAB: SỐ 12 - HEALTH PROBLEMS"},{"time":"20:20","title":"KIDS VOCAB: SỐ 13 - POSITIONS"},{"time":"20:20","title":"LÀ LA LÁ: SỐ 5 - CƯỜNG ĐỘ TO NHỎ CỦA ÂM THANH"},{"time":"20:35","title":"NGÀY XƯA CỔ TÍCH: SỐ 4 - QUẠ VÀ CÔNG"},{"time":"20:50","title":"CHUYỆN KỂ CỦA NHỮNG CHÚ CỪU: SỐ 20 - SỰ ĐOÀN KẾT CỦA BẦY CHIM"},{"time":"20:50","title":"CHUYỆN KỂ CỦA NHỮNG CHÚ CỪU: SỐ 21 - ĐÀN KIẾN TRẢ ƠN"},{"time":"20:50","title":"CHUYỆN KỂ CỦA NHỮNG CHÚ CỪU: SỐ 22 - CHÚ RÙA TẬP BAY"},{"time":"21:00","title":"GÕ CỬA NGHỀ NGHIỆP: SỐ 4"},{"time":"21:00","title":"GÕ CỬA NGHỀ NGHIỆP: SỐ 6"},{"time":"21:00","title":"GÕ CỬA NGHỀ NGHIỆP - SỐ 7"},{"time":"21:45","title":"HỌC TIẾNG ANH QUA BÀI HÁT: SỐ 23 - GIRL ON FIRE"},{"time":"21:45","title":"HỌC TIẾNG ANH QUA BÀI HÁT: SỐ 24 - WE DONT TALK ANYMORE"},{"time":"21:45","title":"HỌC TIẾNG ANH QUA BÀI HÁT: SỐ 25 - LA LA LA"},{"time":"22:00","title":"JUMPING WITH TOEIC: SỐ 53"},{"time":"22:00","title":"JUMPING WITH TOEIC: SỐ 54"},{"time":"22:00","title":"JUMPING WITH TOEIC - SỐ 55"},{"time":"22:45","title":"FOLLOW US: SỐ 24 - LET ME GO HOME"},{"time":"22:45","title":"FOLLOW US: SỐ 25 - SKIN CARE"},{"time":"22:45","title":"FOLLOW US: SỐ 26 - SHOPAHOLICS"},{"time":"23:00","title":"CUỐN SÁCH CỦA TÔI: SỐ 5 - NHÀ GIẢ KIM"},{"time":"23:00","title":"CUỐN SÁCH CỦA TÔI: SỐ 7 - DỐC HẾT TRÁI TIM"},{"time":"23:00","title":"CUỐN SÁCH CỦA TÔI: SỐ 8 - RICO VÀ OSKAR"},{"time":"23:15","title":"CHUYỆN HỌC TRÒ: SỐ 6 - ƯỚC MƠ TRỞ THÀNH GAME THỦ"},{"time":"23:15","title":"CHUYỆN HỌC TRÒ: SỐ 8 - NÓI DỐI"},{"time":"23:15","title":"CHUYỆN HỌC TRÒ: SỐ 9 - NHÀ BÁO TẬP SỰ"},{"time":"23:40","title":"KHÔNG THÌ THẦM: SỐ 11 - ẢO TƯỞNG"},{"time":"23:40","title":"KHÔNG THÌ THẦM: SỐ 12 - XA NHÀ"},{"time":"23:40","title":"KHÔNG THÌ THẦM: SỐ 13 - HẬU LẦN ĐẦU LÀM CHUYỆN ẤY"}],"vtv8":[{"time":"00:00","title":"KÝ SỰ: VỀ MIỀN DI SẢN: NGHỆ NHÂN - NGƯỜI GIỮ HỒN DI SẢN"},{"time":"00:00","title":"KÝ SỰ: PHÁT HUY GIÁ TRỊ CÁC DI SẢN VĂN HÓA MIỀN KINH BẮC"},{"time":"00:00","title":"KÝ SỰ"},{"time":"00:15","title":"TRƯỜNG SƠN VẠN DẶM: TINH HOA THỔ CẨM GIỮA ĐẠI NGÀN TRƯỜNG SƠN"},{"time":"00:15","title":"QUYẾN RŨ VIỆT NAM: ÂM SẮC LÂM BÌNH"},{"time":"00:15","title":"ĐIỂM TỰA BÌNH YÊN"},{"time":"00:30","title":"PHIM TRUYỆN: MỘNG HOA LỤC - TẬP 22"},{"time":"00:30","title":"PHIM TRUYỆN: MỘNG HOA LỤC - TẬP 23"},{"time":"00:30","title":"PHIM TRUYỆN"},{"time":"01:00","title":"PHÓNG SỰ: ĐẮK LẮK - MỞ CỬA TIỀM NĂNG, ĐÓN SÓNG ĐẦU TƯ"},{"time":"01:00","title":"GIAI ĐIỆU KẾT NỐI: MÙA HÈ YÊU THƯƠNG"},{"time":"01:00","title":"ĐI ĐỂ BIẾT"},{"time":"01:15","title":"KẾT NỐI VTV8"},{"time":"01:15","title":"PHÓNG SỰ: NÂNG CAO CHẤT LƯỢNG PHỤC VỤ TRẢI NGHIỆM DI SẢN HUẾ"},{"time":"01:30","title":"CÀ PHÊ TÁM: CHÂN DUNG HẠNH PHÚC"},{"time":"01:30","title":"ĐI ĐỂ BIẾT: NHỮNG NGÀY Ở ĐẢO TRẦN - ĐẢO TIỀN TIÊU CỦA TỔ QUỐC"},{"time":"01:30","title":"VĂN HỌC - NGHỆ THUẬT"},{"time":"01:45","title":"MIỀN ĐẤT VÕ: VOVINAM - DĨ NHU CHẾ CƯƠNG"},{"time":"01:55","title":"ẨM THỰC ĐỘC ĐÁO"},{"time":"02:00","title":"PHIM SITCOM: MỘT NHÀ TRĂM CHUYỆN - TẬP 21"},{"time":"02:00","title":"PHIM SITCOM: MỘT NHÀ TRĂM CHUYỆN - TẬP 22"},{"time":"02:00","title":"PHIM SITCOM"},{"time":"02:15","title":"KHÁM PHÁ THẾ GIỚI: BẢO TỒN CÁC LOÀI VẬT - TẬP 4"},{"time":"02:15","title":"KHÁM PHÁ THẾ GIỚI: BẢO TỒN CÁC LOÀI VẬT - TẬP 5"},{"time":"02:15","title":"KHÁM PHÁ THẾ GIỚI"},{"time":"02:45","title":"PHỐ TÀI CHÍNH: KỲ VỌNG THỊ TRƯỜNG TRONG TRUNG VÀ DÀI HẠN"},{"time":"02:45","title":"CHÉM GIÓ - GIÓ CHÉM: NGHỆ THUẬT GÓP Ý"},{"time":"02:45","title":"GIẢI MÃ CUỘC SỐNG"},{"time":"03:00","title":"ĐI ĐỂ BIẾT: TRẢI NGHIỆM CHÀI LƯỚI CÙNG NGƯ DÂN ĐẢO THANH LÂN"},{"time":"03:00","title":"GIAO LƯU - TỌA ĐÀM: TỪ TINH GỌN BỘ MÁY ĐẾN HIỆU QUẢ VẬN HÀNH"},{"time":"03:00","title":"ATLAS"},{"time":"03:25","title":"PHÓNG SỰ"},{"time":"03:30","title":"TỪ NHỮNG MIỀN QUÊ: NGƯỜI MÔNG NƠI ĐỈNH TRỜI ĐÁ XÁM"},{"time":"03:30","title":"PHÓNG SỰ: BẮC NINH - CÁC TÔN GIÁO ĐỒNG LÒNG CHUNG TAY XÂY DỰNG QUÊ HƯƠNG"},{"time":"03:35","title":"TỪ NHỮNG MIỀN QUÊ"},{"time":"03:45","title":"PHIM TRUYỆN: KIẾM CHỒNG CHO MẸ CHỒNG - TẬP 62"},{"time":"03:45","title":"PHIM TRUYỆN: KIẾM CHỒNG CHO MẸ CHỒNG - TẬP 63"},{"time":"03:45","title":"PHIM TRUYỆN"},{"time":"04:15","title":"PHIM TÀI LIỆU: DẤU ẤN PHẬT HOÀNG - DI SẢN NGÀN NĂM"},{"time":"04:15","title":"PHIM TÀI LIỆU: THIÊN NHIÊN HOANG DÃ PHONG NHA - KẺ BÀNG - PHẦN 1"},{"time":"04:15","title":"PHIM TÀI LIỆU"},{"time":"04:45","title":"PHÓNG SỰ: CHÍNH QUYỀN CẤP XÃ - BIẾN ÁP LỰC THÀNH ĐỘNG LỰC PHÁT TRIỂN"},{"time":"04:45","title":"TRƯỜNG SƠN VẠN DẶM: HÙNG SƠN - NƠI RỪNG KỂ CHUYỆN"},{"time":"04:45","title":"QUYẾN RŨ VIỆT NAM"},{"time":"05:00","title":"KHẾ ƯỚC THỜI GIAN: CỒNG CHIÊNG, TỪ ĐÁ ĐẾN ĐỒNG"},{"time":"05:00","title":"ATLAS: NGA SƠN - MIỀN QUÊ HUYỀN THOẠI"},{"time":"05:00","title":"DẤU ẤN LỊCH SỬ"},{"time":"05:30","title":"NẺO VỀ NGUỒN CỘI: DẤU ẤN VĂN HÓA CỔ ĐỒNG NAI"},{"time":"05:30","title":"GIẢI MÃ CUỘC SỐNG: NGHỀ VẼ TRUYỀN THẦN VÀ NHỮNG ĐIỀU CHƯA BIẾT"},{"time":"05:30","title":"NẺO VỀ NGUỒN CỘI"},{"time":"05:45","title":"KÝ SỰ: PHÁT HUY GIÁ TRỊ CÁC DI SẢN VĂN HÓA MIỀN KINH BẮC"},{"time":"05:45","title":"KÝ SỰ: CHÙA THIÊNG XỨ KINH BẮC: CHÙA DÂU"},{"time":"05:45","title":"KÝ SỰ"},{"time":"06:00","title":"CÂU CHUYỆN TỪ NHỮNG BÀI CA: LÁ THƯ TRUÔNG BỒN"},{"time":"06:00","title":"GIAI ĐIỆU KẾT NỐI: ƯỚC GÌ"},{"time":"06:00","title":"GIAI ĐIỆU KẾT NỐI"},{"time":"06:15","title":"NÔNG SẢN KỂ CHUYỆN: HƯƠNG QUẾ GIỮ RỪNG"},{"time":"06:20","title":"SẮC MÀU CÁC DÂN TỘC"},{"time":"06:25","title":"NẺO VỀ NGUỒN CỘI: DẤU ẤN VĂN HÓA CỔ ĐỒNG NAI"},{"time":"06:40","title":"THÔNG TIN - DỊCH VỤ"},{"time":"06:45","title":"HÀNH TRÌNH DI SẢN: MƯỜNG VANG TIẾNG ĐẤT"},{"time":"06:45","title":"HÀNH TRÌNH DI SẢN: HÀO KHÍ LAM SƠN"},{"time":"06:45","title":"GIẢI MÃ SỨC KHỎE"},{"time":"07:30","title":"NẺO VỀ NGUỒN CỘI: TRỐNG SÀNH CỦA NGƯỜI CAO LAN"},{"time":"07:30","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG: HÌNH TƯỢNG NGỰA TRIỀU NGUYỄN"},{"time":"07:30","title":"NẺO VỀ NGUỒN CỘI"},{"time":"07:45","title":"GIẢI MÃ SỨC KHỎE: CẢI THIỆN ĐAU NHỨC XƯƠNG KHỚP THƯỜNG XUYÊN TÁI PHÁT Ở NGƯỜI LỚN TUỔI"},{"time":"07:45","title":"GIẢI MÃ SỨC KHỎE: BÍ QUYẾT NÂNG CAO SỨC KHỎE TIM MẠCH, CHỐNG LÃO HÓA Ở NGƯỜI CAO TUỔI"},{"time":"07:45","title":"GIẢI MÃ SỨC KHỎE"},{"time":"08:45","title":"CÂU CHUYỆN TỪ NHỮNG BÀI CA: THỜI HOA ĐỎ"},{"time":"08:45","title":"SỰ SỐNG DIỆU KỲ: YJEK NIÊ KDĂM - NGƯỜI SƯU TẦM SỬ THI TÂY NGUYÊN"},{"time":"08:45","title":"SỰ SỐNG DIỆU KỲ"},{"time":"09:00","title":"HIỂU ĐÚNG - SỐNG KHỎE: ĐÔNG TRÙNG HẠ THẢO VỚI SỨC KHỎE TIM MẠCH PHÒNG NGỪA ĐỘT QUỴ"},{"time":"09:00","title":"HIỂU ĐÚNG - SỐNG KHỎE: ĐIỀU HÒA HUYẾT ÁP, BẢO VỆ SỨC KHỎE TIM MẠCH NHỜ SÂM DÂN GIAN"},{"time":"09:00","title":"HIỂU ĐÚNG - SỐNG KHỎE"},{"time":"10:00","title":"KẾT NỐI VTV8"},{"time":"10:00","title":"TỪ NHỮNG MIỀN QUÊ: NGƯỜI LÀO Ở NÚA NGAM"},{"time":"10:00","title":"TỪ NHỮNG MIỀN QUÊ"},{"time":"10:15","title":"PHIM TÀI LIỆU: GIẢM KÉP, ÁP LỰC GẤP BA"},{"time":"10:15","title":"PHIM CA NHẠC: VỌNG NGUYỆT"},{"time":"10:15","title":"CHẤT LƯỢNG CUỘC SỐNG"},{"time":"11:00","title":"NÓNG CÙNG V8: NÓNG CÙNG V8 (20)"},{"time":"11:00","title":"NÓNG CÙNG V8"},{"time":"11:15","title":"KINH TẾ KẾT NỐI"},{"time":"11:30","title":"24H ONLINE"},{"time":"11:45","title":"DỰ BÁO THỜI TIẾT: DỰ BÁO THỜI TIẾT (V8)"},{"time":"11:45","title":"DỰ BÁO THỜI TIẾT"},{"time":"11:50","title":"PHIM SITCOM: MỘT NHÀ TRĂM CHUYỆN - TẬP 22"},{"time":"11:50","title":"PHIM SITCOM: MỘT NHÀ TRĂM CHUYỆN - TẬP 23"},{"time":"11:50","title":"PHIM SITCOM"},{"time":"12:05","title":"PHIM TRUYỆN: MỘNG HOA LỤC - TẬP 23"},{"time":"12:05","title":"PHIM TRUYỆN: MỘNG HOA LỤC - TẬP 24"},{"time":"12:05","title":"PHIM TRUYỆN"},{"time":"12:35","title":"DỰ BÁO THỜI TIẾT: DỰ BÁO THỜI TIẾT (V8)"},{"time":"12:35","title":"DỰ BÁO THỜI TIẾT"},{"time":"12:40","title":"THỂ THAO"},{"time":"12:45","title":"PHIM TRUYỆN: SỨ MỆNH VẪY GỌI - TẬP 52"},{"time":"12:45","title":"PHIM TRUYỆN: SỨ MỆNH VẪY GỌI - TẬP 53"},{"time":"12:45","title":"PHIM TRUYỆN"},{"time":"13:15","title":"QUYẾN RŨ VIỆT NAM: ÂM SẮC LÂM BÌNH"},{"time":"13:15","title":"PHIM TÀI LIỆU: TRÚC LÂM YÊN TỬ - DÒNG THIỀN THUẦN VIỆT"},{"time":"13:15","title":"NƠI ĐÂU CŨNG LÀ NHÀ"},{"time":"13:30","title":"GIẢI MÃ SỨC KHỎE: BÍ QUYẾT NÂNG CAO SỨC KHỎE TIM MẠCH, CHỐNG LÃO HÓA Ở NGƯỜI CAO TUỔI"},{"time":"13:30","title":"GIẢI MÃ SỨC KHỎE: SUY GIẢM SINH LÝ NAM - XU HƯỚNG ĐIỀU TRỊ TỪ THẢO DƯỢC"},{"time":"13:30","title":"GIẢI MÃ SỨC KHỎE"},{"time":"14:30","title":"MIỀN ĐẤT VÕ: VOVINAM - DĨ NHU CHẾ CƯƠNG"},{"time":"14:30","title":"CHECK IN VIỆT NAM: VIÊN NGỌC BIỂN KHƠI"},{"time":"14:30","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG"},{"time":"14:50","title":"HIỂU ĐÚNG - SỐNG KHỎE: ĐIỀU HÒA HUYẾT ÁP, BẢO VỆ SỨC KHỎE TIM MẠCH NHỜ SÂM DÂN GIAN"},{"time":"14:50","title":"HIỂU ĐÚNG - SỐNG KHỎE: TĂNG CƯỜNG SỨC KHỎE TIM MẠCH, ỔN ĐỊNH HUYẾT ÁP"},{"time":"14:50","title":"HIỂU ĐÚNG - SỐNG KHỎE"},{"time":"15:50","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG: GỐM VIỆT - TẬP 3: GỐM BÀU TRÚC"},{"time":"15:50","title":"KINH TẾ KẾT NỐI"},{"time":"15:50","title":"PHỐ TÀI CHÍNH"},{"time":"16:05","title":"MUÔN MÀU CUỘC SỐNG: MÙA SEN HUẾ"},{"time":"16:05","title":"MUÔN MÀU CUỘC SỐNG"},{"time":"16:15","title":"PHIM TÀI LIỆU: THIÊN NHIÊN HOANG DÃ PHONG NHA - KẺ BÀNG - PHẦN 1"},{"time":"16:15","title":"CHẤT LƯỢNG CUỘC SỐNG: CHỦ ĐỘNG PHÒNG NGỪA SỐT XUẤT HUYẾT TRONG MÙA CAO ĐIỂM"},{"time":"16:15","title":"PHIM TÀI LIỆU"},{"time":"16:45","title":"KINH TẾ KẾT NỐI"},{"time":"17:00","title":"SỐNG KHỎE: SUY GIẢM SINH LÝ NAM - PHƯƠNG PHÁP ĐIỀU TRỊ TỪ GỐC"},{"time":"17:00","title":"PHIM TÀI LIỆU: DÒNG NƯỚC KHÔNG LỐI VỀ"},{"time":"17:00","title":"HÀNH TRÌNH DI SẢN"},{"time":"17:45","title":"THẾ GIỚI THỂ THAO"},{"time":"18:00","title":"TRẠM 18H"},{"time":"18:30","title":"CHUYỂN ĐỘNG HÔM NAY"},{"time":"18:45","title":"THỂ THAO"},{"time":"18:50","title":"NHÂN TÀI ĐẠI VIỆT: LŨ CHIM QUÁI ÁC"},{"time":"18:50","title":"NHÂN TÀI ĐẠI VIỆT: ĐỘI QUÂN BÙ NHÌN"},{"time":"18:50","title":"NHÂN TÀI ĐẠI VIỆT"},{"time":"19:00","title":"PHIM TRUYỆN: KIẾM CHỒNG CHO MẸ CHỒNG - TẬP 63"},{"time":"19:00","title":"PHIM TRUYỆN: KIẾM CHỒNG CHO MẸ CHỒNG - TẬP 64"},{"time":"19:00","title":"PHIM TRUYỆN"},{"time":"19:30","title":"CA NHẠC: LỜI TRÁI TIM: KỂ CHUYỆN ĐÊM MƯA"},{"time":"19:30","title":"ĐI ĐỂ BIẾT: CHUYỆN Ở ĐẢO NHỎ THANH LÂN"},{"time":"19:30","title":"ATLAS"},{"time":"20:00","title":"TRUYỀN HÌNH TRỰC TIẾP: BẾ MẠC LIÊN HOAN PHIM CHÂU Á ĐÀ NẴNG LẦN THỨ IV"},{"time":"20:00","title":"DỰ BÁO THỜI TIẾT: DỰ BÁO THỜI TIẾT (V8)"},{"time":"20:00","title":"DỰ BÁO THỜI TIẾT"},{"time":"20:05","title":"CHÉM GIÓ - GIÓ CHÉM: TÌNH YÊU TƯƠNG ĐỒNG"},{"time":"20:05","title":"KẾT NỐI VTV8"},{"time":"20:20","title":"ẨM THỰC ĐỘC ĐÁO: CÀ MUỐI HÀ TĨNH"},{"time":"20:20","title":"ẨM THỰC ĐỘC ĐÁO"},{"time":"20:25","title":"TÌNH CA BẤT HỦ: TÌNH KHÚC PHÚ QUANG"},{"time":"20:25","title":"PHIM TRUYỆN"},{"time":"21:05","title":"PHIM TÀI LIỆU: MỘT NĂM SẮP XẾP LẠI GIANG SƠN"},{"time":"21:10","title":"PHIM TRUYỆN"},{"time":"21:35","title":"GIẢI MÃ SỨC KHỎE: XU HƯỚNG SỬ DỤNG THẢO DƯỢC TRONG HỖ TRỢ TĂNG CƯỜNG SINH LÝ NAM"},{"time":"21:35","title":"DẤU ẤN LỊCH SỬ"},{"time":"22:00","title":"DỰ BÁO THỜI TIẾT: DỰ BÁO THỜI TIẾT (V8)"},{"time":"22:05","title":"DẤU ẤN LỊCH SỬ: CHUYỆN LÀNG TRONG VẬN NƯỚC"},{"time":"22:05","title":"VĂN HỌC - NGHỆ THUẬT"},{"time":"22:35","title":"MUÔN MÀU CUỘC SỐNG"},{"time":"22:40","title":"TRẠM 18H"},{"time":"23:10","title":"CHUYỂN ĐỘNG HÔM NAY"},{"time":"23:30","title":"KHÁM PHÁ THẾ GIỚI: BẢO TỒN CÁC LOÀI VẬT - TẬP 5"},{"time":"23:30","title":"KHÁM PHÁ THẾ GIỚI: NHỮNG ANH HÙNG ĐỜI THƯỜNG - TẬP 1"},{"time":"23:30","title":"KHÁM PHÁ THẾ GIỚI"}],"vtv9":[{"time":"00:00","title":"ĐƯỜNG DÂY NÓNG VTV9"},{"time":"00:00","title":"PHIM TRUYỆN"},{"time":"00:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 32 ĐỘI - AUSTRALIA – AI CẬP"},{"time":"00:45","title":"PHIM TRUYỆN"},{"time":"01:30","title":"NẺO VỀ NGUỒN CỘI"},{"time":"01:45","title":"TIÊU ĐIỂM CHÍNH SÁCH"},{"time":"02:00","title":"VÌ NHÂN DÂN QUÊN MÌNH"},{"time":"02:15","title":"PHIM TÀI LIỆU"},{"time":"02:45","title":"THTT FIFA WORLD CUP 2026 - VÒNG 16 ĐỘI: BRAZIL VS NA UY"},{"time":"03:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 1/8 - PARAGUAY - PHÁP"},{"time":"04:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 32 ĐỘI - ARGENTINA - CAPE VERDE"},{"time":"05:25","title":"TÁM CÔNG SỞ"},{"time":"05:35","title":"CHUYỆN PHỐ PHƯỜNG"},{"time":"05:40","title":"THỊ TRƯỜNG 360 ĐỘ"},{"time":"05:45","title":"VÕ THUẬT TỔNG HỢP MMA"},{"time":"06:15","title":"CÂU CHUYỆN TỪ CUỘC SỐNG"},{"time":"06:25","title":"THÀNH PHỐ ẤM ÁP TÌNH NGƯỜI"},{"time":"06:30","title":"THTT FIFA WORLD CUP 2026 - VÒNG 16 ĐỘI: MEXICO VS ANH"},{"time":"07:35","title":"TIẾU LÂM DU KÝ"},{"time":"07:50","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 32 ĐỘI - COLOMBIA - GHANA"},{"time":"07:50","title":"KÍNH ĐA CHIỀU"},{"time":"08:00","title":"NỘI THẤT KHÔNG GIỚI HẠN: NỘI THẤT TỐI GIẢN - GIÁ TRỊ CỦA KHÔNG GIAN"},{"time":"08:15","title":"CHỮA BỆNH CÙNG CHUYÊN GIA"},{"time":"09:00","title":"PHIM TÀI LIỆU: MỘT NĂM SẮP XẾP LẠI GIANG SƠN – TINH GỌN, GẦN DÂN, HIỆU QUẢ"},{"time":"09:20","title":"CÂU CHUYỆN TỪ CUỘC SỐNG"},{"time":"09:30","title":"CÂU CHUYỆN NHÂN VẬT"},{"time":"09:30","title":"ALO DOCTOR CUỐI TUẦN"},{"time":"10:00","title":"CUỐI TUẦN KỂ CHUYỆN"},{"time":"10:00","title":"VÕ THUẬT TỔNG HỢP MMA"},{"time":"10:30","title":"DẠO QUANH THỊ TRƯỜNG"},{"time":"10:45","title":"THỊ TRƯỜNG 360 ĐỘ"},{"time":"10:50","title":"GÓC NHÌN NGƯỜI TIÊU DÙNG"},{"time":"10:50","title":"THÀNH PHỐ ẤM ÁP TÌNH NGƯỜI"},{"time":"10:55","title":"CHUYỆN PHỐ PHƯỜNG"},{"time":"11:00","title":"VIỆT NAM ƠI - MÌNH CÙNG ĐI: NHỮNG ĐIỂM ĐẾN ẤN TƯỢNG TẠI VĨNH LONG"},{"time":"11:00","title":"PHIM TRUYỆN"},{"time":"11:20","title":"THUẬN VỢ THUẬN CHỒNG"},{"time":"11:30","title":"QUẢ CẦU THÔNG THÁI"},{"time":"11:50","title":"ALO DOCTOR"},{"time":"12:00","title":"PHƯƠNG NAM HÔM NAY"},{"time":"12:30","title":"PHIM TRUYỆN: NỮ BÁC SĨ TÂM LÝ - TẬP 32"},{"time":"12:30","title":"PHIM TRUYỆN: NỮ BÁC SĨ TÂM LÝ - TẬP 33"},{"time":"12:30","title":"PHIM TRUYỆN"},{"time":"13:15","title":"PHIM TRUYỆN: MỘT CUỘC TẤN CÔNG - TẬP 24"},{"time":"13:15","title":"PHIM TRUYỆN: MỘT CUỘC TẤN CÔNG - TẬP 25"},{"time":"13:15","title":"PHIM TRUYỆN"},{"time":"14:00","title":"ĐẤU TRƯỜNG ẨM THỰC NHÍ"},{"time":"14:00","title":"CHECK IN VIỆT NAM: VỀ VÙNG ĐẤT CỔ Ô DIÊN"},{"time":"14:00","title":"KỶ NIỆM THANH XUÂN"},{"time":"14:15","title":"CUỐI TUẦN KỂ CHUYỆN"},{"time":"14:15","title":"GAMESHOW NGƯỜI ĐỨNG THẲNG"},{"time":"14:20","title":"CHỊ EM GỠ RỐI"},{"time":"14:40","title":"GIA ĐÌNH HẾT SẢY"},{"time":"15:00","title":"NẺO VỀ NGUỒN CỘI: THANH ÂM CUNG ĐÌNH HUẾ"},{"time":"15:00","title":"NẺO VỀ NGUỒN CỘI"},{"time":"15:15","title":"BÍ MẬT THẾ KỶ"},{"time":"15:15","title":"BÍ MẬT THẾ KỶ: VƯỢT THÁI BÌNH DƯƠNG - PHẦN 4"},{"time":"15:15","title":"CHỮA BỆNH CÙNG CHUYÊN GIA"},{"time":"15:45","title":"ĐƯỜNG DÂY NÓNG VTV9"},{"time":"15:45","title":"PHÓNG SỰ: ỨNG DỤNG CÔNG NGHỆ HƯỚNG ĐẾN CHÍNH QUYỀN ĐÔ THỊ THÔNG MINH"},{"time":"16:00","title":"PHIM TRUYỆN: GẠO NẾP GẠO TẺ - PHẦN 2 - TẬP 10"},{"time":"16:00","title":"PHIM TRUYỆN: GẠO NẾP GẠO TẺ - PHẦN 2 - TẬP 11"},{"time":"16:00","title":"PHIM TRUYỆN"},{"time":"16:30","title":"VIỆT NAM ƠI - MÌNH CÙNG ĐI: CHUYỆN BÊN HỒ"},{"time":"16:30","title":"VIỆT NAM ƠI - MÌNH CÙNG ĐI: ĐẾN THĂM VÙNG ĐẤT ĐỒNG THÁP"},{"time":"16:30","title":"VIỆT NAM - 365 NGÀY THÚ VỊ"},{"time":"16:45","title":"VÌ NHÂN DÂN QUÊN MÌNH"},{"time":"16:45","title":"DẠO QUANH THỊ TRƯỜNG"},{"time":"16:45","title":"HÀNH TRÌNH NET ZERO"},{"time":"17:00","title":"NỘI THẤT KHÔNG GIỚI HẠN: XU HƯỚNG THIẾT KẾ NỘI THẤT KHÔNG GIAN BẾP HIỆN ĐẠI"},{"time":"17:00","title":"CANH TÁC THÔNG MINH"},{"time":"17:00","title":"VÌ NHÂN DÂN QUÊN MÌNH"},{"time":"17:15","title":"THỊ TRƯỜNG 360 ĐỘ"},{"time":"17:20","title":"ALO DOCTOR CUỐI TUẦN"},{"time":"17:20","title":"CÂU CHUYỆN NHÂN VẬT"},{"time":"17:20","title":"TRAI ĐẸP VÀO BẾP"},{"time":"17:50","title":"TÁM CÔNG SỞ: HỌP KÍN GIỜ HÀNH CHÍNH - TẬP 167"},{"time":"17:50","title":"TÁM CÔNG SỞ: HỌP KÍN GIỜ HÀNH CHÍNH - TẬP 168"},{"time":"17:50","title":"TÁM CÔNG SỞ"},{"time":"18:00","title":"TOÀN CẢNH 24H"},{"time":"18:30","title":"NHẬT KÝ FIFA WORLD CUP 2026"},{"time":"18:30","title":"TẦM NHÌN BẤT ĐỘNG SẢN"},{"time":"18:45","title":"PHIM TRUYỆN: ƯỚC MÌNH CÙNG BAY - TẬP 72"},{"time":"18:45","title":"PHIM TRUYỆN: ƯỚC MÌNH CÙNG BAY - TẬP 73"},{"time":"18:50","title":"KÍNH ĐA CHIỀU"},{"time":"19:00","title":"PHIM TRUYỆN"},{"time":"19:15","title":"ĐỜI RẤT ĐẸP"},{"time":"19:15","title":"KỶ NIỆM THANH XUÂN"},{"time":"19:30","title":"ĐỜI NGHỆ SĨ"},{"time":"19:30","title":"VÒNG XOAY LỐC XOÁY"},{"time":"19:35","title":"CHỊ EM GỠ RỐI"},{"time":"20:00","title":"TIẾU LÂM DU KÝ"},{"time":"20:00","title":"THỨC TỈNH TÂM HỒN: HIỂU LẦM CON"},{"time":"20:00","title":"CÂU CHUYỆN TỪ CUỘC SỐNG"},{"time":"20:10","title":"PHIM TRUYỆN"},{"time":"20:15","title":"PHIM TRUYỆN: MỘT CUỘC TẤN CÔNG - TẬP 26"},{"time":"20:15","title":"PHIM TRUYỆN: ÂN OÁN TÌNH THÙ - TẬP 29"},{"time":"21:00","title":"PHIM TRUYỆN: MỘT CUỘC TẤN CÔNG - TẬP 27"},{"time":"21:00","title":"PHIM TRUYỆN: ÂN OÁN TÌNH THÙ - TẬP 30"},{"time":"21:00","title":"ĐƯỜNG DÂY NÓNG VTV9"},{"time":"21:15","title":"PHIM TRUYỆN"},{"time":"21:45","title":"GAMESHOW NGƯỜI ĐỨNG THẲNG"},{"time":"21:45","title":"ĐỜI NGHỆ SỸ"},{"time":"22:00","title":"VIỆT NAM ƠI - MÌNH CÙNG ĐI"},{"time":"22:15","title":"ĐIỀU CON MUỐN NÓI"},{"time":"22:25","title":"CHECK IN VIỆT NAM: CỬU THÁC GIỮA ĐẠI NGÀN"},{"time":"22:30","title":"VIỆT NAM - 365 NGÀY THÚ VỊ"},{"time":"22:35","title":"MẢNH GHÉP HOÀN HẢO"},{"time":"22:40","title":"VÒNG XOAY LỐC XOÁY"},{"time":"22:45","title":"TRAI ĐẸP VÀO BẾP"},{"time":"23:00","title":"TIẾU LÂM DU KÝ"},{"time":"23:10","title":"BƯỚC CHÂN KHÁM PHÁ"},{"time":"23:15","title":"GIẢI MÃ CUỘC SỐNG"},{"time":"23:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 1/8 - CANADA - MOROCO"},{"time":"23:30","title":"ALO DOCTOR CUỐI TUẦN"},{"time":"23:30","title":"VÕ THUẬT TỔNG HỢP MMA"}],"vtv10":[{"time":"00:00","title":"TÌNH KHÚC VƯỢT THỜI GIAN - ĐÊM TÂM SỰ"},{"time":"00:20","title":"TRUYỀN HÌNH TRỰC TIẾP: WORLD CUP 2026: AUSTRALIA - AI CẬP"},{"time":"00:45","title":"DÂN CA NHẠC CỔ: DUYÊN DÁNG ĐỒNG BẰNG"},{"time":"00:45","title":"MẢNH GHÉP HOÀN HẢO - SỐ 102"},{"time":"01:15","title":"LẬP TRÌNH TRÁI TIM: NGƯỜI EM BẤT HẢO - PHẦN 1"},{"time":"01:15","title":"LẬP TRÌNH TRÁI TIM - NGƯỜI EM BẤT HẢO - PHẦN 2"},{"time":"01:30","title":"THỂ THAO: WORLD CUP 2026: TÂY BAN NHA - ÁO"},{"time":"01:30","title":"BẠN CỦA NHÀ NÔNG - SỐ 7"},{"time":"02:20","title":"TRUYỀN HÌNH TRỰC TIẾP - WORLD CUP 2026: BRAZIL - NA UY"},{"time":"03:00","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG: VƯỜN QUỐC GIA CÔN ĐẢO - TẬP 1"},{"time":"03:30","title":"KÝ SỰ: VỀ LẠI CỨ ĐỊA XƯA - TẬP 5"},{"time":"03:45","title":"KÝ ỨC MIỀN TÂY: CỎ BÀNG BẤT TẬN"},{"time":"04:00","title":"PHIM TRUYỆN: CHÚNG TA PHẢI HẠNH PHÚC - TẬP 34"},{"time":"04:20","title":"LIÊN KẾT VÀ HỘI NHẬP: TÁI ĐỊNH VỊ DU LỊCH ĐỒNG BẰNG"},{"time":"04:45","title":"THỂ THAO"},{"time":"04:45","title":"THỂ THAO: KẾT NỐI THỂ THAO"},{"time":"05:15","title":"KÝ SỰ: VỀ LẠI CỨ ĐỊA XƯA - TẬP 5"},{"time":"05:15","title":"KÝ ỨC MIỀN TÂY: HỒI ĐÓ LẤM LEM"},{"time":"05:30","title":"CA NHẠC: CHƠI VƠI"},{"time":"05:30","title":"CA NHẠC: NỖI NHỚ DỊU ÊM"},{"time":"06:00","title":"BẢN TIN NÔNG NGHIỆP"},{"time":"06:10","title":"BẢN TIN THỂ THAO"},{"time":"06:15","title":"KÝ ỨC MIỀN TÂY: CỎ BÀNG BẤT TẬN"},{"time":"06:15","title":"NÔNG DÂN SỐ: SỐ 1"},{"time":"06:30","title":"PHIM HOẠT HÌNH: CHUYỆN CỔ TÍCH - PHẦN 3"},{"time":"06:30","title":"PHIM HOẠT HÌNH: CHUYỆN CỔ TÍCH - PHẦN 4"},{"time":"06:30","title":"THƯƠNG NHỚ MIỀN TÂY - NGHỀ SÔNG NƯỚC"},{"time":"06:45","title":"THẾ GIỚI QUANH TA - SỐ 27"},{"time":"07:00","title":"THỂ THAO: WORLD CUP 2026: TÂY BAN NHA - ÁO"},{"time":"07:00","title":"THỂ THAO: WORLD CUP 2026: ARGENTINA - CAPE VERDE"},{"time":"07:00","title":"THỂ THAO - WORLD CUP 2026: PARAGUAY - PHÁP"},{"time":"08:30","title":"PHIM TRUYỆN: NỮ HOÀNG XU HƯỚNG - TẬP 41"},{"time":"08:30","title":"PHIM TRUYỆN: NỮ HOÀNG XU HƯỚNG - TẬP 42"},{"time":"08:30","title":"NỮ HOÀNG XU HƯỚNG - TẬP 43"},{"time":"09:15","title":"SỐNG KHỎE MỖI NGÀY: ỔN ĐỊNH ĐƯỜNG HUYẾT"},{"time":"09:15","title":"SỐNG KHỎE MỖI NGÀY: BẢO VỆ TIM MẠCH Ở NGƯỜI CAO TUỔI"},{"time":"09:15","title":"SỐNG KHỎE MỖI NGÀY - PHÌ ĐẠI TUYẾN TIỀN LIỆT"},{"time":"10:15","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG: VƯỜN QUỐC GIA CÔN ĐẢO - TẬP 1"},{"time":"10:15","title":"TẠP CHÍ KINH TẾ CUỐI TUẦN"},{"time":"10:15","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG - VƯỜN QUỐC GIA CÔN ĐẢO - TẬP 1"},{"time":"10:45","title":"PHÓNG SỰ: BÀI TOÁN CHO Y TẾ CƠ SỞ"},{"time":"10:45","title":"KÝ ỨC MIỀN TÂY: CỎ BÀNG BẤT TẬN"},{"time":"10:45","title":"MIỀN TÂY NĂNG ĐỘNG - VĨNH LONG - SỐ 5"},{"time":"11:00","title":"MIỀN TÂY HÔM NAY"},{"time":"11:30","title":"SÂN KHẤU - CẢI LƯƠNG: MỘT THỜI ĐỂ NHỚ"},{"time":"11:35","title":"PHIM TRUYỆN: CHÚNG TA PHẢI HẠNH PHÚC - TẬP 34"},{"time":"11:35","title":"ĐỪNG NÓI KHI YÊU - TẬP 1"},{"time":"12:15","title":"PHIM TRUYỆN: ANH TRAI NHÀ ĐỐI DIỆN - TẬP 14"},{"time":"12:15","title":"ANH TRAI NHÀ ĐỐI DIỆN - TẬP 15"},{"time":"13:00","title":"CHƯƠNG TRÌNH VỀ SỨC KHỎE: DƯỠNG TIM, BỔ NÃO"},{"time":"13:00","title":"TỌA ĐÀM: SỐT XUẤT HUYẾT NHẬN DIỆN SỚM, XỬ TRÍ ĐÚNG"},{"time":"13:00","title":"CHƯƠNG TRÌNH VỀ SỨC KHỎE"},{"time":"14:00","title":"THỂ THAO: KẾT NỐI THỂ THAO"},{"time":"14:00","title":"BẠN CỦA NHÀ NÔNG: SỐ 7"},{"time":"14:00","title":"THỂ THAO - GIỜ VÀNG THỂ THAO"},{"time":"14:30","title":"DÂN CA NHẠC CỔ: DUYÊN DÁNG ĐỒNG BẰNG"},{"time":"14:30","title":"DÂN CA NHẠC CỔ - MIỀN TÂY BAO THƯƠNG NHỚ"},{"time":"14:45","title":"PHÓNG SỰ: BÀI TOÁN CHO Y TẾ CƠ SỞ"},{"time":"15:00","title":"ĐẤT KHỎE – CÂY TRỒNG KHỎE: GIẢM CHI PHÍ, TĂNG NĂNG SUẤT, CHẤT LƯỢNG LÚA HÈ THU"},{"time":"15:00","title":"KHỎE CÙNG CHUYÊN GIA: PHÒNG NGỪA ĐỘT QUỴ"},{"time":"15:00","title":"KHỎE CÙNG CHUYÊN GIA - PHÒNG NGỪA ĐỘT QUỴ NÃO"},{"time":"16:00","title":"BẢN TIN NÔNG NGHIỆP"},{"time":"16:10","title":"XỔ SỐ KIẾN THIẾT: HẬU GIANG - LONG AN"},{"time":"16:10","title":"XỔ SỐ KIẾN THIẾT: KIÊN GIANG - TIỀN GIANG"},{"time":"16:10","title":"XỔ SỐ KIẾN THIẾT - ĐỒNG THÁP - CÀ MAU"},{"time":"16:40","title":"NHÀ NÔNG VÀ CÔNG NGHỆ: SỐ 27"},{"time":"16:45","title":"LẬP TRÌNH TRÁI TIM: NGƯỜI EM BẤT HẢO - PHẦN 1"},{"time":"16:45","title":"LẬP TRÌNH TRÁI TIM: NGƯỜI EM BẤT HẢO - PHẦN 2"},{"time":"16:45","title":"LẬP TRÌNH TRÁI TIM - NGƯỜI EM BẤT HẢO - PHẦN 3"},{"time":"17:00","title":"PHIM TRUYỆN: BA ƠI MẸ CÓ VỀ KHÔNG - TẬP 20"},{"time":"17:00","title":"PHIM TRUYỆN: BA ƠI MẸ CÓ VỀ KHÔNG - TẬP 21"},{"time":"17:00","title":"BA ƠI MẸ CÓ VỀ KHÔNG - TẬP 22"},{"time":"17:45","title":"CHƯƠNG TRÌNH THIẾU NHI: TRƯỞNG THÀNH CÙNG TIAN TIAN - TẬP 22"},{"time":"17:45","title":"CHƯƠNG TRÌNH THIẾU NHI: TRƯỞNG THÀNH CÙNG TIAN TIAN - TẬP 23"},{"time":"17:45","title":"KÝ SỰ - VỀ LẠI CỨ ĐỊA XƯA - TẬP 6"},{"time":"18:00","title":"MIỀN TÂY HÔM NAY"},{"time":"18:30","title":"THẾ GIỚI QUANH TA: SỐ 27"},{"time":"18:30","title":"360 ĐỘ MIỀN TÂY: SỐ 25"},{"time":"18:30","title":"BẢN TIN THỂ THAO"},{"time":"18:45","title":"THƯƠNG NHỚ MIỀN TÂY: NGHỀ SÔNG NƯỚC"},{"time":"18:45","title":"QUỐC PHÒNG TOÀN DÂN QK9: SỐ 27"},{"time":"18:45","title":"BIẾN ĐỔI KHÍ HẬU - TRẢ LẠI HƠI THỞ CHO TRÀM CHIM"},{"time":"19:00","title":"THỜI SỰ"},{"time":"19:45","title":"PHIM TRUYỆN: KẾ HOẠCH TRÁI TIM - TẬP 24"},{"time":"19:45","title":"TỌA ĐÀM: BẢO HIỂM Y TẾ - LÁ CHẮN SỨC KHỎE CHO MỌI NGƯỜI"},{"time":"19:45","title":"KẾ HOẠCH TRÁI TIM - TẬP 25"},{"time":"20:30","title":"PHIM HOẠT HÌNH: CHUYỆN CỔ TÍCH - PHẦN 4"},{"time":"20:30","title":"PHIM HOẠT HÌNH"},{"time":"20:45","title":"KÝ ỨC MIỀN TÂY: HỒI ĐÓ LẤM LEM"},{"time":"21:00","title":"NHẬT KÝ WORLD CUP 2026"},{"time":"21:15","title":"SỐNG KHỎE - ĐẸP: SỐ 40"},{"time":"21:15","title":"PHIM TÀI LIỆU: TƯỚNG VỀ HƯU VÀ 1.000 CĂN NHÀ ĐỒNG ĐỘI"},{"time":"21:15","title":"CẢNH GIÁC 247"},{"time":"21:45","title":"PHIM TRUYỆN: NHỮNG NẺO ĐƯỜNG GẦN XA - TẬP 23"},{"time":"21:45","title":"PHIM TRUYỆN: NHỮNG NẺO ĐƯỜNG GẦN XA - TẬP 24"},{"time":"21:45","title":"NHỮNG NẺO ĐƯỜNG GẦN XA - TẬP 25"},{"time":"22:30","title":"CHUYỆN CUỐI TUẦN: NSND KIM XUÂN"},{"time":"22:30","title":"MẢNH GHÉP HOÀN HẢO: SỐ 102"},{"time":"22:30","title":"PHIM TÀI LIỆU - TƯỚNG VỀ HƯU VÀ 1.000 CĂN NHÀ ĐỒNG ĐỘI"},{"time":"23:00","title":"TÌNH KHÚC VƯỢT THỜI GIAN: ĐÊM TÂM SỰ"},{"time":"23:00","title":"ĐỜI NGHỆ SỸ: NHẠC SỸ ĐÀI PHƯƠNG TRANG"},{"time":"23:00","title":"SẮC MÀU CÁC DÂN TỘC - CHUYỆN KỂ TỪ THANH ÂM"},{"time":"23:30","title":"THỂ THAO - KẾT NỐI THỂ THAO"},{"time":"23:45","title":"KÝ ỨC MIỀN TÂY: HỒI ĐÓ LẤM LEM"},{"time":"23:45","title":"THƯƠNG NHỚ MIỀN TÂY: DU LỊCH XUYÊN RỪNG XUYÊN ĐÊM"}]} \ No newline at end of file diff --git a/vtv_scraper.py b/vtv_scraper.py deleted file mode 100644 index 9d26efbc903ffd34bfac7c5887b8958e0ef6ab2e..0000000000000000000000000000000000000000 --- a/vtv_scraper.py +++ /dev/null @@ -1,147 +0,0 @@ -""" -VTV Channels Scraper - Optimized for stable streaming -Fetches stream URLs from multiple CDN sources for VTV1-VTV10 + VTV Cần Thơ -""" -import requests, re, time, threading -from datetime import datetime, timedelta, timezone - -VN_TZ = timezone(timedelta(hours=17)) - -UA = { - "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", - "Referer": "https://hd.xemtv.net/", -} - -# ===== PRIMARY CDN SOURCES (Optimized for stability) ===== -# Priority order: FPTPlay > VTVGo > MediaCDN - -XEMTV_PHP_ENDPOINTS = { - "vtv1": "https://hd.xemtv.net/kenh/vtv1.php", - "vtv2": "https://hd.xemtv.net/kenh/vtv2.php", - "vtv3": "https://hd.xemtv.net/kenh/vtv3.php", - "vtv4": "https://hd.xemtv.net/kenh/vtv4.php", - "vtv5": "https://hd.xemtv.net/kenh/vtv5.php", - "vtv6": "https://hd.xemtv.net/kenh/vtv6.php", - "vtv7": "https://hd.xemtv.net/kenh/vtv7.php", - "vtv8": "https://hd.xemtv.net/kenh/vtv8.php", - "vtv9": "https://hd.xemtv.net/kenh/vtv9.php", - "vtv10": "https://hd.xemtv.net/kenh/vtv10.php", -} - -CHANNEL_NAMES = { - "vtv1": "VTV1", "vtv2": "VTV2", "vtv3": "VTV3", "vtv4": "VTV4", - "vtv5": "VTV5", "vtv6": "VTV6", "vtv7": "VTV7", "vtv8": "VTV8", - "vtv9": "VTV9", "vtv10": "VTV10", -} - -# ===== RELIABLE CDN BACKUPS (verified working URLs) ===== -CDN_STREAMS = { - # FPTPlay - Primary (most stable) - "vtv1": "https://live-a.fptplay53.net/live/media/vtv1/live247-hls-avc/index.m3u8", - "vtv2": "https://live-a.fptplay53.net/live/media/vtv2/live247-hls-avc/index.m3u8", - "vtv3": "https://live-a.fptplay53.net/live/media/vtv3/live247-hls-avc/index.m3u8", - "vtv4": "https://live-a.fptplay53.net/live/media/vtv4/live247-hls-avc/index.m3u8", - "vtv5": "https://live-a.fptplay53.net/live/media/vtv5/live247-hls-avc/index.m3u8", - "vtv6": "https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/index.m3u8", - "vtv7": "https://live-a.fptplay53.net/live/media/vtv7/live247-hls-avc/index.m3u8", - "vtv8": "https://live-a.fptplay53.net/live/media/vtv8/live-hls-avc/index.m3u8", - "vtv9": "https://live-a.fptplay53.net/live/media/vtv9/live247-hls-avc/index.m3u8", - "vtv10": "https://live-a.fptplay53.net/live/media/vtv10/live247-hls-avc/index.m3u8", - - # VTVGo Failover - "vtv1_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8", - "vtv2_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv2-manifest.m3u8", - "vtv3_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv3-manifest.m3u8", - "vtv4_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv4-manifest.m3u8", - "vtv5_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv5-manifest.m3u8", - "vtv6_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv6-manifest.m3u8", - "vtv7_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv7-manifest.m3u8", - "vtv8_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv8-manifest.m3u8", - "vtv9_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv9-manifest.m3u8", - "vtv10_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv10-manifest.m3u8", -} - -_vtv_cache = {} -_vtv_lock = threading.Lock() -_CACHE_TTL = 180 - -def _cached(key): - with _vtv_lock: - if key in _vtv_cache and time.time() - _vtv_cache[key]['t'] < _CACHE_TTL: - return _vtv_cache[key]['d'] - return None - -def _set_cache(key, data): - with _vtv_lock: - _vtv_cache[key] = {'t': time.time(), 'd': data} - -def extract_m3u8_from_html(html): - if not html: return None - # Look for file: "..." pattern - m = re.search(r"file\s*:\s*['\"]([^'\"]*\.m3u8[^'\"]*)['\"]", html, re.IGNORECASE) - if m: - url = m.group(1).strip() - if len(url) > 20: return url - # Look for direct m3u8 URL - m = re.search(r"(https?://[^\s\"'<>\\]+\.m3u8[^\s\"'<>\\]*)", html, re.IGNORECASE) - if m: - url = m.group(1).strip() - if len(url) > 20: return url - return None - -def verify_cdn(url, referer="", timeout=8): - """Quick verify CDN is working""" - if not url: return None - try: - r = requests.get(url, headers={"User-Agent": UA["User-Agent"], "Referer": referer}, timeout=timeout, allow_redirects=True, verify=False) - if r.status_code == 200 and '#EXTM3U' in r.text[:500]: - return url - except: pass - return None - -def fetch_vtv_stream(channel_id): - """Fetch VTV stream with priority order for maximum stability""" - channel_id = channel_id.lower().strip() - - # Normalize channel ID - name_map = { - 'vtvct': 'vtv10', 'vtv-can-tho': 'vtv10', 'vtv can tho': 'vtv10', - 'vtv_can_tho': 'vtv10', 'cantho': 'vtv10', 'cần thơ': 'vtv10', - 'vietnam_vtv1': 'vtv1', 'vietnam_vtv2': 'vtv2', 'vietnam_vtv3': 'vtv3', - 'vietnam_vtv4': 'vtv4', 'vietnam_vtv5': 'vtv5', 'vietnam_vtv6': 'vtv6', - 'vietnam_vtv7': 'vtv7', 'vietnam_vtv8': 'vtv8', 'vietnam_vtv9': 'vtv9', - } - channel_id = name_map.get(channel_id, channel_id) - - # Try direct CDN URLs first (most stable) - if channel_id in CDN_STREAMS: - return CDN_STREAMS[channel_id] - - # Try PHP endpoints as fallback - php_url = XEMTV_PHP_ENDPOINTS.get(channel_id) - if php_url: - try: - r = requests.get(php_url, headers=UA, timeout=15, allow_redirects=True, verify=False) - if r.status_code == 200: - m3u8 = extract_m3u8_from_html(r.text) - if m3u8: return m3u8 - except: pass - - # Try failover backup - fb_key = f"{channel_id}_fb" - if fb_key in CDN_STREAMS: - return CDN_STREAMS[fb_key] - - return None - -def get_all_vtv_streams(): - channels = [] - for ch_id in CHANNEL_NAMES: - stream_url = fetch_vtv_stream(ch_id) - channels.append({ - 'id': ch_id, - 'name': CHANNEL_NAMES.get(ch_id, ch_id.upper()), - 'stream_url': stream_url, - }) - return channels \ No newline at end of file diff --git a/vtv_shorts.py b/vtv_shorts.py deleted file mode 100644 index fc838b9b226f277002408e97b5a858346d1a5ec0..0000000000000000000000000000000000000000 --- a/vtv_shorts.py +++ /dev/null @@ -1,140 +0,0 @@ -""" -VTV Nam Bộ YouTube Shorts Scraper -""" -import requests -import re -import json -import subprocess -import html as html_lib -import time -import threading -from xml.etree import ElementTree as ET - -_cache = {} -_lock = threading.Lock() -CACHE_TTL = 1800 - -UA = { - "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", -} - -def _cached(key): - with _lock: - if key in _cache and time.time() - _cache[key]['t'] < CACHE_TTL: - return _cache[key]['d'] - return None - -def _set_cache(key, data): - with _lock: - _cache[key] = {'t': time.time(), 'd': data} - -def get_channel_id(username): - cached = _cached(f'ch_id_{username}') - if cached: - return cached - channel_id = None - try: - url = f"https://www.youtube.com/@{username}" - r = requests.get(url, headers=UA, timeout=15) - if r.status_code == 200: - m = re.search(r' 0 and duration <= 120", - "--no-check-certificates", - "--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", - url - ], timeout=120) - - seen_ids = set() - for item in items: - vid = item.get('id', '') - if not vid or vid in seen_ids: - continue - seen_ids.add(vid) - - title = item.get('title', 'VTV Nam Bộ Short') - duration = item.get('duration', 0) or 0 - - shorts.append({ - 'id': vid, - 'title': title, - 'duration': duration, - 'channel': channel_username, - 'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg", - }) - - return shorts - -def get_shorts_with_direct_url(video_ids): - """Get direct video download URLs for given video IDs""" - results = [] - - for vid in video_ids[:20]: # Limit to avoid timeout - try: - url = f"https://www.youtube.com/shorts/{vid}" - items = run_yt_dlp([ - "--dump-json", - "--no-download", - "--no-check-certificates", - "--format", "best[filesize<10M]/best", - url - ], timeout=30) - - if items: - info = items[0] - direct_url = info.get('url', '') - if not direct_url: - # Try to get from formats - formats = info.get('formats', []) - for f in formats: - if f.get('vcodec') != 'none' and f.get('acodec') != 'none': - direct_url = f.get('url', '') - break - - if direct_url: - results.append({ - 'id': vid, - 'title': info.get('title', ''), - 'direct_url': direct_url, - 'thumbnail': info.get('thumbnail', f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg"), - 'duration': info.get('duration', 0), - }) - except Exception as e: - print(f"Error getting URL for {vid}: {e}") - - return results - -def get_vtvnambo_shorts(max_count=50): - """Get all shorts from VTV Nam Bộ using yt-dlp""" - cached = _cached('vtvnambo_shorts_yt') - if cached is not None: - return cached - - all_shorts = [] - seen_ids = set() - - # Method 1: /shorts page - print(f"[yt-dlp] Fetching /shorts page...") - shorts_page = get_channel_shorts_via_playlist('vtvnambo', max_count) - for s in shorts_page: - if s['id'] not in seen_ids: - seen_ids.add(s['id']) - all_shorts.append(s) - print(f"[yt-dlp] /shorts page: {len(shorts_page)} shorts") - - # Method 2: /videos page with duration filter - if len(all_shorts) < 5: - print(f"[yt-dlp] Fetching /videos page with filter...") - videos_filtered = get_channel_videos_filter_shorts('vtvnambo', max_count * 2) - for s in videos_filtered: - if s['id'] not in seen_ids: - seen_ids.add(s['id']) - all_shorts.append(s) - print(f"[yt-dlp] /videos filter: {len(videos_filtered)} shorts") - - result = all_shorts[:max_count] - _set_cache('vtvnambo_shorts_yt', result) - print(f"[yt-dlp] Total: {len(result)} shorts from VTV Nam Bộ") - return result - -def get_wc_related_shorts(max_count=30): - """Get World Cup / football related shorts""" - all_shorts = get_vtvnambo_shorts(max_count * 3) - - wc_kws = [ - 'world cup', 'wc 2026', 'worldcup', 'fifa', 'bóng đá', - 'trận đấu', 'đội tuyển', 'tuyển', 'vòng loại', - 'khoảnh khắc', 'highlights', 'bàn thắng', 'goal', - 'kết quả', 'tỉ số', 'việt nam', 'vn', - 'ngoại hạng', 'premier league', 'champions league', - 'laliga', 'serie a', 'bundesliga', 'ligue 1', - 'copa', 'europa', 'c1', 'c2', - 'messi', 'ronaldo', 'neymar', 'mbappe', 'haaland', - 'v-league', 'vleague', 'bóng đá việt', - 'đội bóng', 'hlv', 'huấn luyện viên', - 'chuyển nhượng', 'transfer', - 'asian cup', 'aff cup', 'sea games', - 'olympic', 'u23', 'u20', 'u17', - ] - - wc_shorts = [] - for s in all_shorts: - tl = s.get('title', '').lower() - if any(k in tl for k in wc_kws): - wc_shorts.append(s) - - if not wc_shorts: - wc_shorts = all_shorts - - return wc_shorts[:max_count] - -# Aliases -get_vtvnamo_shorts = get_vtvnambo_shorts diff --git a/yt_scraper_fixed.py b/yt_scraper_fixed.py deleted file mode 100644 index 8e0b939d183af4fb63a43784c4ea7c05cbb2bfd3..0000000000000000000000000000000000000000 --- a/yt_scraper_fixed.py +++ /dev/null @@ -1,162 +0,0 @@ -""" -YouTube Shorts Scraper using yt-dlp (already installed on Space) -Optimized for fast load with long cache + fallback -""" -import subprocess -import json -import time -import threading -import os -import re as re_mod - -_cache = {} -_lock = threading.Lock() -CACHE_TTL = 1800 # 30 min cache - longer to reduce timeout issues - -def _get_cached(key): - """Get cached data if still valid""" - with _lock: - if key in _cache: - entry = _cache[key] - if time.time() - entry['t'] < CACHE_TTL: - return entry['d'] - return None - -def _set_cached(key, data): - """Set cache with timestamp""" - with _lock: - _cache[key] = {'t': time.time(), 'd': data} - -def run_yt_dlp(args, timeout=45): - """Run yt-dlp and return parsed JSON lines - with shorter timeout""" - try: - result = subprocess.run( - ["yt-dlp"] + args, - capture_output=True, text=True, timeout=timeout - ) - if result.returncode != 0 and not result.stdout.strip(): - return [] - lines = result.stdout.strip().split('\n') - items = [] - for line in lines: - line = line.strip() - if not line: - continue - try: - items.append(json.loads(line)) - except json.JSONDecodeError: - continue - return items - except subprocess.TimeoutExpired: - print("yt-dlp timeout (this is OK - using fallback)") - return [] - except FileNotFoundError: - print("yt-dlp not found - using fallback") - return [] - except Exception as e: - print(f"yt-dlp exception: {e}") - return [] - -def get_channel_shorts_fast(channel_username, max_count=25): - """Get shorts fast - prioritize /shorts page only to avoid timeout""" - shorts = [] - - url = f"https://www.youtube.com/@{channel_username}/shorts" - items = run_yt_dlp([ - "--dump-json", - "--flat-playlist", - "--no-download", - "--playlist-end", str(max_count), - "--no-check-certificates", - "--quiet", # Reduce output for speed - "--no-warnings", - url - ], timeout=35) # Increased timeout but still reasonable - - seen_ids = set() - for item in items: - vid = item.get('id', '') - if not vid or vid in seen_ids: - continue - seen_ids.add(vid) - - title = item.get('title', f'{channel_username} Short') - - shorts.append({ - 'id': vid, - 'title': title, - 'channel': channel_username, - 'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg", - }) - - if len(shorts) >= max_count: - break - - return shorts - -def get_dantri_shorts(max_count=25): - """Get Dantri shorts - fast, no fallback needed, cache for 30min""" - cached = _get_cached('dantri_shorts_yt') - if cached is not None: - return cached - - shorts = get_channel_shorts_fast('baodantri7941', max_count) - - if shorts: - _set_cached('dantri_shorts_yt', shorts) - return shorts - - # Fallback to static list if scrape fails - return [ - {"id":"Lu_iCQ5YwNM","title":"Công an lập hồ sơ xử lý người phụ nữ chửi bới tát nam tài xế ô tô ở Hà Nội","channel":"baodantri7941"}, - {"id":"CwWvijF8BOA","title":"Chú rể Ninh Bình bật khóc nhận món quà bí mật người cha","channel":"baodantri7941"}, - ] - -def get_skds_shorts(max_count=25): - """Get SKĐS shorts - fast, no fallback needed, cache for 30min""" - cached = _get_cached('skds_shorts_yt') - if cached is not None: - return cached - - shorts = get_channel_shorts_fast('baosuckhoedoisongboyte', max_count) - - if shorts: - _set_cached('skds_shorts_yt', shorts) - return shorts - - # Fallback to static list if scrape fails - return [ - {"id":"7Pd6vZ2Lz1M","title":"Hành động ấm lòng của người đàn ông tìm kiếm 5 học sinh tử vong","channel":"baosuckhoedoisongboyte"}, - {"id":"SlHLt_ZyPiE","title":"Xử phạt người đàn ông xóa số điện thoại cứu hộ trên cao tốc Bắc Nam","channel":"baosuckhoedoisongboyte"}, - ] - -def get_dantri_skds_shorts(max_count=50): - """Get interleaved Dantri + SKĐS shorts - optimized with separate caching""" - # Get each channel's shorts separately (allows partial fallback) - dantri = get_dantri_shorts(max_count // 2 + 10) - skds = get_skds_shorts(max_count // 2 + 10) - - # Interleave them - result = [] - seen = set() - i, j = 0, 0 - - while (i < len(dantri) or j < len(skds)) and len(result) < max_count: - if i < len(dantri): - item = dantri[i] - if item.get('id') not in seen: - seen.add(item.get('id')) - result.append(item) - i += 1 - if j < len(skds): - item = skds[j] - if item.get('id') not in seen: - seen.add(item.get('id')) - result.append(item) - j += 1 - - return result - -# For backward compatibility -get_vtvnambo_shorts = get_dantri_skds_shorts -get_wc_related_shorts = get_dantri_skds_shorts \ No newline at end of file