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/.rebuild b/.rebuild new file mode 100644 index 0000000000000000000000000000000000000000..00384c1ba5543e7180368cc0d46eb6cfc5e6a7a9 --- /dev/null +++ b/.rebuild @@ -0,0 +1 @@ +rebuilt at 2026-06-18T09:24:34.973630 \ No newline at end of file diff --git a/.restart_trigger b/.restart_trigger new file mode 100644 index 0000000000000000000000000000000000000000..07bb82eb90ea41638a6b02f7b0e8e4c5dc29bd92 --- /dev/null +++ b/.restart_trigger @@ -0,0 +1 @@ +# restart trigger diff --git a/CHANGELOG.md b/CHANGELOG.md index 9663d9bbce33b00d18a58b11922a2f2fb4049a10..eb3e04f4d1b73039ecd39ad74200eaac55a4318a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,36 @@ -# CHANGELOG +# VNEWS v5 - Match Detail Fix -## v20 - Stream URL → HLS conversion (m3u channels) -- `proxy_m3u_hls` now content-sniffs **every** stream URL instead of relying on - the `.m3u8`-in-path suffix. This fixes `.ts`/`mp2t` channel links that actually - serve an HLS playlist (common on Stalker portals) — they are now rewritten - (not wrapped as raw TS), and vice-versa. -- Master playlists are resolved to their first playable media variant before - rewriting segment URIs through `/api/proxy/seg`. -- Raw MPEG-TS / Stalker streams are now wrapped in a `#EXT-X-PLAYLIST-TYPE:EVENT` - manifest with 3 repeating `#EXTINF` segments + `#EXT-X-DISCONTINUITY` (the old - single-segment manifest caused Hls.js to stall after ~8s). -- `proxy_m3u` (`.m3u`/`.m3u8`) now content-sniffs upstream and falls back to the - HLS wrapper for non-playlist responses. -- Probe timeouts widened (connect 10s) for servers that flag the HF Spaces egress IP. +## Changes -## v19 - AI ảnh nền SenseNova-U1.5 + text overlay overhaul -(see prev changelog) +### 1. match_detail_v2.py — Rewrote event parser with correct selectors +- Parse `.events > .period > .event` structure (not old `.timeline`) +- Extract event type from SVG icons in `.event-type` (goal/redcard/yellowcard/substitution) +- Parse player names from `.players > div` elements +- For goals: extract scorer + assist names +- For substitutions: extract player_out → player_in +- For cards: extract player name +- Normalize time format: `45' +2` → `45+2'` +- Fetch H2H stats from `/api/fixtures/h2h-stats` API +- Parse prediction card, recent matches, H2H standings + +### 2. static/match_detail.js — Complete rewrite with 2-tab layout +- **Tab "Thống kê"**: H2H stats comparison, prediction vote, recent match results +- **Tab "Diễn biến"**: Detailed timeline with: + - ⚽ BÀN THẮNG — scorer name + assist + - 🟥 THỺ ĐỎ — player name + - 🟨 THỺ VÀNG — player name + - ↔️ THAY ĐỔI — player_out → player_in +- Period grouping (H1, H2) with visual headers +- Team badges (HOME/AWAY) per event +- Color-coded event icons + +### 3. app_v2_entry.py — Updated +- Module cache clearing for fresh match_detail_v2 import on each API call +- Single clean import for both `/detail` and `/live` endpoints +- Removed duplicate inline scraping code + +### 4. _run.py — Fixed import +- Changed `import match_detail` to `import match_detail_v2` + +### 5. Dockerfile — Cache busting +- Added `RUN date > /app/.build_timestamp` to force Docker rebuild diff --git a/Dockerfile b/Dockerfile index 5e4cdaa1a67afb1e0ab5faa307e619fbb285f0c2..1ba3320590f4c27d463fa54b0d2e19320add3655 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,48 +2,14 @@ FROM python:3.12-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" +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg fonts-dejavu-core && rm -rf /var/lib/apt/lists/* +RUN pip install --no-cache-dir "beautifulsoup4>=4.12" lxml +RUN pip install --no-cache-dir fastapi uvicorn requests beautifulsoup4 jinja2 yt-dlp huggingface_hub gTTS pillow edge-tts python-dateutil httpx COPY requirements.txt . -RUN echo "[BUILD] step4: pip requirements.txt" && \ - pip install --no-cache-dir -r requirements.txt || true && \ - echo "[BUILD] step4 done" +RUN pip install --no-cache-dir -r requirements.txt || true 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" - -CMD ["uvicorn", "_run:app", "--host", "0.0.0.0", "--port", "7860"] -# v3.0-vn-font-fix-short-video-2026-07-19 -# rebuild-trigger: m3u-perf-fix-20260829 +CMD ["uvicorn", "_run:app", "--host", "0.0.0.0", "--port", "7860", "--reload"] \ No newline at end of file diff --git a/README.md b/README.md index af4322a9d78a69a83f506b28a80f8c511c18b451..dc357a7ef1b414efed6580b6ca6d3ed5e257e4fe 100644 --- a/README.md +++ b/README.md @@ -9,76 +9,17 @@ tags: - ml-intern --- -# VNEWS - Tin Tức Việt Nam +# bep40/vnews + -**v19 - AI ảnh nền SenseNova-U1.5 + text overlay overhaul** -## 🔧 Hotfix (2026-09-07) — chữ không hiện trên ảnh nền -"🖼️ Tạo Ảnh Bài Viết" modal: khi tính fi "High contrast, no blur" (loái bỏ nền -mờ/foggy band) đã giới thiệu, text overlay silently sp qué không vẽ — only the -background image rendered and the chữ disappeared. Root cause: -`_pidDrawTextWrapped` referenced `opts._deepBand` but `opts` was NOT a parameter -of that function (under `'use strict'` this raised a ReferenceError that was -swallowed by the try/catch in `_pidRenderSlide`). Fix: pass `opts` as the last -argument through all 8 call sites. -## 🔧 Changes in v19 (2026-09-06) -- **AI ảnh nền**: upgraded to **SenseNova-U1.5-8B-MoT** (same model family as - the official `hugging-apps/sensenova-sensenova-u1-5-8b-mot` demo) with the - reference-quality sampling recipe: cfg_scale=4.0, timestep_shift=3.0, - 28 default / 50 max denoising steps (was U1-8B Q4 GGUF at 4-8 steps; now U1.5-8B-MoT Q6_K GGUF), native - T2I resolution buckets (3:4 → 1760×2368, …) before resize. -- **🔠 Size chữ (NEW)**: S/M/L/XL dropdown to adjust font size of the text on - the image. -- **Vị trí chữ (fixed)**: Giữa / Trên / Dưới is now ALWAYS honored — also when - the "Chữ trên ảnh" overlay contains text (previously the overlay forced its - own top band and ignored the position). -- **High contrast, no blur (fixed)**: text now renders on a crisp solid dark - band with black outline + optional glow — readable on any background, no more - foggy striped scrim. -- Backend requests 28 steps + seed through `/api/pid/gen_bg`. -## 🔧 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 + diff --git a/RESTART_TRIGGER.md b/RESTART_TRIGGER.md deleted file mode 100644 index ae28daa4517dbe3e2571d7b62066a4ee1c709329..0000000000000000000000000000000000000000 --- a/RESTART_TRIGGER.md +++ /dev/null @@ -1 +0,0 @@ -pid-clean-v3 1788611499 \ No newline at end of file diff --git a/RESTORE_FROM_5B6000C.md b/RESTORE_FROM_5B6000C.md deleted file mode 100644 index e2fca9964ffb3359e9ad115ddd55a238fe7ee8b3..0000000000000000000000000000000000000000 --- a/RESTORE_FROM_5B6000C.md +++ /dev/null @@ -1,30 +0,0 @@ -# TRẠNG THÁI: ĐÃ KHÔI PHỤC ✅ - -## 2026-09-13 — Khôi phục hoàn tất (không cần làm gì thêm) - -**main.py** đã được khôi phục về đúng nội dung commit `5b6000c58f89723b3d228afb06c891ce909ac488` -(64.2KB, FastAPI app đầy đủ) và **đã persist lên branch main** qua cơ chế self-heal -trong `_run.py` (Space container dùng HF_TOKEN để upload_file trở lại repo). - -## Fix HLS v21 (đã áp dụng + persist lên main) - -1. **`main.py` — `/api/proxy/m3u_hls`** (raw MPEG-TS / Stalker stream): - - Segment lặp: 3 → **8×** `#EXTINF:8.0` - - Thêm `#EXT-X-DISCONTINUITY-SEQUENCE:0` - - Giữ: `#EXT-X-VERSION:6`, `#EXT-X-TARGETDURATION:8`, `#EXT-X-MEDIA-SEQUENCE:0`, - `#EXT-X-START:TIME-OFFSET=0,PRECISE=YES` - - Không có `#EXT-X-ENDLIST`, không có `#EXT-X-DISCONTINUITY` cuối - - Lý do: playlist 3 segment bị Hls.js cạn → stall sau ~8s; 8 segment + sliding window giữ live. - -2. **`static/vtv_init.js` — `playNativeHLS()`** (2 call site `new Hls(...)`): - - Thêm `liveDurationInfinity: true, liveSyncDurationCount: 5, maxBufferLength: 60, maxMaxBufferLength: 240` - -3. **`_run.py`** — entrypoint self-heal + idempotent patch (giữ an toàn): - - Nếu `main.py` <50KB hoặc chứa PLACEHOLDER → tự tải từ rev `5b6000c…` → ghi đè. - - Tự patch manifest + vtv_init.js nếu thiếu → persist lên repo (nếu có HF_TOKEN). - -## Lịch sử sự cố -- `main.py` trên `main` bị ghi đè thành `"PLACEHOLDER"` (11B) → import chain - `_run.py → app_v2_entry.py → main.py` crash → Space `RUNTIME_ERROR`. -- Khôi phục: self-heal `_run.py` → container tự sửa khi boot → persist lên main. -- SHA persist cuối: `f1562219b55c058092ea3c2eb19316269b74484e` (main). \ No newline at end of file diff --git a/_run.py b/_run.py index 53911ec6a9b8f1e9f6a111012e427084f8fdba25..4a7a26bd2553b700d918ace5f77e2a3e369e0be2 100644 --- a/_run.py +++ b/_run.py @@ -1,183 +1 @@ -"""VNEWS boot entry — self-heal + HLS live fixes before import. - -1) If main.py is the PLACEHOLDER stub (or otherwise damaged), restore the - known-good version from commit 5b6000c via huggingface_hub. -2) Apply the m3u_hls live fix to main.py if it still has the old 3-segment - manifest: bump to 8 repeating #EXTINF:8.0 segments, add - #EXT-X-DISCONTINUITY-SEQUENCE:0, keep the playlist open (no ENDLIST, no - trailing #EXT-X-DISCONTINUITY). -3) Apply Hls.js live tuning to static/vtv_init.js if not present: - liveDurationInfinity / liveSyncDurationCount / maxBufferLength. -4) Persist any local changes back to the repo 'main' branch (best-effort, - requires a write token in the Space env, e.g. HF_TOKEN secret). -""" -import os - -_APP_DIR = os.path.dirname(os.path.abspath(__file__)) -_MAIN = os.path.join(_APP_DIR, "main.py") -_VTV = os.path.join(_APP_DIR, "static", "vtv_init.js") -_GOOD_REV = "5b6000c58f89723b3d228afb06c891ce909ac488" -_REPO = "bep40/VNEWS" - - -def _read(p): - try: - with open(p, encoding="utf-8", errors="replace") as f: - return f.read() - except Exception: - return "" - - -def _write(p, s): - with open(p, "w", encoding="utf-8") as f: - f.write(s) - - -def _main_is_broken(path): - try: - if os.path.getsize(path) < 50000: - return True - head = _read(path)[:2048] - return "PLACEHOLDER" in head or "app = FastAPI()" not in head - except Exception: - return True - - -# --- old 3-segment manifest block in main.py (verified against 5b6000c) --- -_OLD_MANIFEST = ( - ' _seg = seg_proxy_base\n' - ' manifest = (\n' - ' "#EXTM3U\\n"\n' - ' "#EXT-X-VERSION:6\\n"\n' - ' "#EXT-X-TARGETDURATION:8\\n"\n' - ' "#EXT-X-MEDIA-SEQUENCE:0\\n"\n' - ' "#EXT-X-START:TIME-OFFSET=0,PRECISE=YES\\n"\n' - ' "#EXTINF:8.0,\\n"\n' - ' + _seg + "\\n"\n' - ' "#EXTINF:8.0,\\n"\n' - ' + _seg + "\\n"\n' - ' "#EXTINF:8.0,\\n"\n' - ' + _seg + "\\n"\n' - ' )\n' -) -_NEW_MANIFEST = ( - ' _seg = seg_proxy_base\n' - ' manifest = (\n' - ' "#EXTM3U\\n"\n' - ' "#EXT-X-VERSION:6\\n"\n' - ' "#EXT-X-TARGETDURATION:8\\n"\n' - ' "#EXT-X-MEDIA-SEQUENCE:0\\n"\n' - ' "#EXT-X-DISCONTINUITY-SEQUENCE:0\\n"\n' - ' "#EXT-X-START:TIME-OFFSET=0,PRECISE=YES\\n"\n' - + ''.join(' "#EXTINF:8.0,\\n"\n + _seg + "\\n"\n' for _ in range(8)) - + ' )\n' -) - - -def _fix_main_hls(content): - if "#EXT-X-DISCONTINUITY-SEQUENCE" in content: - return content, False - if content.count("#EXTINF:8.0") >= 8: - return content, False - if _OLD_MANIFEST not in content: - return content, False - new = content.replace(_OLD_MANIFEST, _NEW_MANIFEST, 1) - if new != content: - return new, True - return content, False - - -_OLD_HLS_CONFIG = "new Hls({ enableWorker: false })" -_NEW_HLS_CONFIG = ( - "new Hls({ enableWorker: false, liveDurationInfinity: true, " - "liveSyncDurationCount: 5, maxBufferLength: 60, maxMaxBufferLength: 240 })" -) - - -def _fix_vtv(content): - if "liveDurationInfinity" in content: - return content, False - if _OLD_HLS_CONFIG not in content: - return content, False - new = content.replace(_OLD_HLS_CONFIG, _NEW_HLS_CONFIG, 1) - return new, new != content - - -def _persist(fn, data): - """Best-effort: push patched file back to the repo 'main' branch so the - fix is permanent, not just container-local. Never raises.""" - try: - token = os.environ.get("HF_TOKEN") - if not token: - print(f"[self-heal] no HF_TOKEN — skip persist {fn}", flush=True) - return False - from huggingface_hub import upload_file - - upload_file( - path_or_fileobj=data.encode("utf-8"), - path_in_repo=fn, - repo_id=_REPO, - repo_type="space", - token=token, - commit_message=f"[hls-fix] {fn}: 8 segments + live config", - ) - print(f"[self-heal] persisted {fn}", flush=True) - return True - except Exception as e: - print(f"[self-heal] persist {fn} failed: {e}", flush=True) - return False - - -def _restore_main(): - print(f"[self-heal] main.py damaged -> restoring from {_GOOD_REV}", flush=True) - try: - from huggingface_hub import hf_hub_download - - good = hf_hub_download( - repo_id=_REPO, - filename="main.py", - revision=_GOOD_REV, - repo_type="space", - ) - with open(good, encoding="utf-8") as f: - data = f.read() - assert "app = FastAPI()" in data and "proxy_m3u_hls" in data - _write(_MAIN, data) - print(f"[self-heal] main.py restored ({len(data)} bytes)", flush=True) - return True - except Exception as e: - print(f"[self-heal] FAILED to restore main.py: {e}", flush=True) - return False - - -def _apply_fixes(): - changed = [] - try: - if _main_is_broken(_MAIN): - _restore_main() - mc = _read(_MAIN) - if mc: - nm, changed_m = _fix_main_hls(mc) - if changed_m: - _write(_MAIN, nm) - print("[hls] main.py patched: 8 segments + DISCONTINUITY-SEQUENCE:0", flush=True) - changed.append("main.py") - vc = _read(_VTV) - if vc: - nv, changed_v = _fix_vtv(vc) - if changed_v: - _write(_VTV, nv) - print("[hls] vtv_init.js patched: liveDurationInfinity config", flush=True) - changed.append("static/vtv_init.js") - except Exception as e: - print(f"[hls] review fixes failed (non-fatal): {e}", flush=True) - for fn in changed: - try: - _persist(fn, _read(os.path.join(_APP_DIR, fn))) - except Exception as e: - print(f"[hls] persist {fn} error: {e}", flush=True) - - -_apply_fixes() - -from app_v2_entry import app # v5-stable inline bongda proxy \ No newline at end of file +from app_v2_entry import app # v5-stable inline bongda proxy diff --git a/ai_ext.py b/ai_ext.py index 68e54701543e23e185793a937259b9224d6f6dc3..eaa1a45c2d01cd5b1f56ccb2cd6c30936891acb9 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,158 +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 - - -# ========================================================================= -# Shared Tường AI wall view logic (sort + filter) -# Used by /api/wall and /api/ai_wall so every consumer sees the SAME -# newest-first, content-filtered feed. -# ========================================================================= -_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(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(url): - return bool(url) and not _is_placeholder_url(url) - - -def _wall_created_ts(p): - """Best-effort creation timestamp, NORMALIZED TO SECONDS epoch. - `created` is stored as Unix milliseconds (e.g. 1788444727727) by some - paths while `ts` is Unix seconds (e.g. 1788696117) — mixing them without - normalization breaks newest-first ordering. Falls back to id-derived Unix - ms (converted to seconds), then 0. Never crashes.""" - if not isinstance(p, dict): - return 0 - for field in ("created", "ts"): - v = p.get(field) - if v is not None: - try: - v = int(v) - except (ValueError, TypeError): - continue - if v > 100000000000: # milliseconds -> seconds - v = v // 1000 - return v - pid = str(p.get("id", "")).strip() - if pid.isdigit() and len(pid) >= 12: - try: - v = int(pid) - if v > 100000000000: - v = v // 1000 - return v - except (ValueError, TypeError): - pass - return 0 - - -def _wall_post_has_valid_image(p): - """True when the post has at least one REAL (non-placeholder) image: - p.img, p.short_thumb, p.images[], p.slides[].image.""" - if _is_real_url(p.get("img")): - return True - if _is_real_url(p.get("short_thumb")): - return True - images = p.get("images") or [] - if any(_is_real_url(x) for x in images): - return True - slides = p.get("slides") or [] - if any(_is_real_url((s or {}).get("image")) for s in slides if isinstance(s, dict)): - return True - return False - - -def _wall_post_has_content(p): - """True when the post has meaningful content: non-empty text (>= 20 chars) - OR at least one slide with a non-empty text. Posts with empty/whitespace - text, empty slide lists, or slides that are all blank are removed.""" - if not isinstance(p, dict): - return False - text = (p.get("text") or "").strip() - if len(text) >= 20: - return True - slides = p.get("slides") or [] - if len(slides) >= 1: - for s in slides: - if isinstance(s, dict) and len((s.get("text") or "").strip()) >= 8: - return True - # Video-only posts (e.g. FPT wall videos / user uploads) have content via video - if (p.get("video") or "").strip(): - return True - return False - - -def _wall_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/FB) with a valid image.""" - 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 - vid = (p.get("video") or "").strip() - if vid and "api/ai/short-file/" in vid and (p.get("short_thumb") or "").strip(): - return True - 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 _wall_post_has_valid_image(p): - return True - return False - - -def _wall_is_auto_post(p): - 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 wall_posts_for_view(posts=None, limit=200): - """Unified Tường AI wall view: - 1) drop posts with NO content at all (empty text AND empty slides - AND no video) — the 'bài đăng rewrite fără nội dung'; - 2) drop auto-generated posts that BOTH have a placeholder/missing image - (pollinations.ai, vnexpress logo, empty) AND no slide-AI content — - these are the truly broken posts that show the wrong thumbnail; - 3) sort newest-first mixing all sources; - 4) cap at `limit` (default 200). - Non-destructive: never touches the persistent store.""" - if posts is None: - posts = _load_ai_wall() - if not isinstance(posts, list): - return [] - out = [] - for p in posts: - if not isinstance(p, dict): - continue - if not _wall_post_has_content(p): - continue - if _wall_is_auto_post(p) and not _wall_post_has_valid_image(p) and not _wall_post_has_slide_info(p): - # auto post with a wrong/placeholder image AND no slide AI content - # -> hide from the wall (non-destructive) - continue - out.append(p) - out.sort(key=_wall_created_ts, reverse=True) - return out[:limit] - try: from huggingface_hub import AsyncInferenceClient except Exception: @@ -221,260 +60,1078 @@ 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") +# Fast TEXT models for summaries that don't need vision (much faster than the VL model). +QWEN_TEXT_MODELS = [m.strip() for m in os.getenv( + "QWEN_TEXT_MODELS", + "Qwen/Qwen2.5-72B-Instruct,meta-llama/Llama-3.3-70B-Instruct,Qwen/Qwen2.5-7B-Instruct" +).split(",") if m.strip()] +_WORKING_MODEL_TEXT = None # cached last-working text model +_WORKING_MODEL_VL = None # cached last-working vision model +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 ===== +# Multilingual neural voices grouped by country/language +# Format: key -> {id, gender, name, country, lang, flag} +TTS_VOICES = { + # === VIETNAM (edge-tts) === + "hoaimy": {"id": "vi-VN-HoaiMyNeural", "gender": "female", "name": "Hoài My", "country": "Việt Nam", "lang": "vi", "flag": "🇻🇳", "engine": "edge"}, + "namminh": {"id": "vi-VN-NamMinhNeural", "gender": "male", "name": "Nam Minh", "country": "Việt Nam", "lang": "vi", "flag": "🇻🇳", "engine": "edge"}, + # === gTTS (Google, tiếng Việt cơ bản) === + "gtts_vi": {"id": "gtts", "gender": "female", "name": "gTTS Google", "country": "Việt Nam", "lang": "vi", "flag": "🇻🇳", "engine": "gtts"}, + # === MULTILINGUAL (đa ngôn ngữ — đọc được tiếng Việt + nhiều thứ tiếng) === + "en_au_william": {"id": "en-AU-WilliamMultilingualNeural", "gender": "male", "name": "William (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"}, + "en_us_andrew": {"id": "en-US-AndrewMultilingualNeural", "gender": "male", "name": "Andrew (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"}, + "en_us_ava": {"id": "en-US-AvaMultilingualNeural", "gender": "female", "name": "Ava (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"}, + "en_us_brian": {"id": "en-US-BrianMultilingualNeural", "gender": "male", "name": "Brian (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"}, + "en_us_emma": {"id": "en-US-EmmaMultilingualNeural", "gender": "female", "name": "Emma (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"}, + "fr_vivienne": {"id": "fr-FR-VivienneMultilingualNeural","gender": "female", "name": "Vivienne (Đa NN)","country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"}, + "fr_remy": {"id": "fr-FR-RemyMultilingualNeural", "gender": "male", "name": "Rémy (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"}, + "de_seraphina": {"id": "de-DE-SeraphinaMultilingualNeural","gender": "female","name": "Seraphina (Đa NN)","country": "Đa ngôn ngữ","lang": "multi", "flag": "🌐", "engine": "edge"}, + "de_florian": {"id": "de-DE-FlorianMultilingualNeural", "gender": "male", "name": "Florian (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"}, + "it_giuseppe": {"id": "it-IT-GiuseppeMultilingualNeural","gender": "male", "name": "Giuseppe (Đa NN)","country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"}, + "ko_hyunsu": {"id": "ko-KR-HyunsuMultilingualNeural", "gender": "male", "name": "Hyunsu (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"}, + "pt_thalita": {"id": "pt-BR-ThalitaMultilingualNeural", "gender": "female", "name": "Thalita (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"}, +} +TTS_DEFAULT_VOICE = "hoaimy" +TTS_DEFAULT_SPEED = 1.2 # 1.2x speed for faster reading + +# Topic -> voice mapping (auto-detect based on topic keywords) +TOPIC_VOICE_MAP = { + # Sports -> male voice + "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", + # Lifestyle/Health/Entertainment -> female voice + "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", + # Tech/Science -> male voice + "công nghệ": "namminh", "ai": "namminh", "robot": "namminh", + "khoa học": "namminh", "vũ trụ": "namminh", "điện thoại": "namminh", + "laptop": "namminh", "game": "namminh", + # News/Politics/Economy -> male voice + "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: + """Auto-detect the best voice based on topic keywords.""" + 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 + + +# ===== EMOTION (CẢM XÚC) FOR TTS ===== +# edge-tts does NOT support Azure express-as styles, so emotion is simulated +# with pitch + rate(speed multiplier) + volume. Each preset is a delta. +# rate_mul multiplies the user/auto speed; pitch is absolute Hz; volume is percent. +EMOTION_PRESETS = { + "vui": {"label": "Vui tươi", "emoji": "😊", "rate_mul": 1.06, "pitch": "+15Hz", "volume": "+6%"}, + "hao_hung": {"label": "Hào hứng", "emoji": "🔥", "rate_mul": 1.12, "pitch": "+24Hz", "volume": "+12%"}, + "nghiem": {"label": "Nghiêm túc", "emoji": "📰", "rate_mul": 1.00, "pitch": "-3Hz", "volume": "+0%"}, + "tram": {"label": "Trầm ấm", "emoji": "🌙", "rate_mul": 0.94, "pitch": "-10Hz", "volume": "+0%"}, + "buon": {"label": "Buồn/Xúc động", "emoji": "💧", "rate_mul": 0.88, "pitch": "-18Hz", "volume": "-4%"}, + "trung_tinh":{"label": "Trung tính", "emoji": "🎙️", "rate_mul": 1.00, "pitch": "+0Hz", "volume": "+0%"}, +} +EMOTION_DEFAULT = "trung_tinh" + +# Topic keyword -> emotion. Checked in order; first match wins. +TOPIC_EMOTION_MAP = { + # Sports / wins -> excited + "chiến thắng": "hao_hung", "vô địch": "hao_hung", "world cup": "hao_hung", + "bóng đá": "hao_hung", "thể thao": "hao_hung", "ghi bàn": "hao_hung", + "champions league": "hao_hung", "premier league": "hao_hung", "chung kết": "hao_hung", + "olympic": "hao_hung", "kỷ lục": "hao_hung", + # Entertainment / lifestyle / good news -> cheerful + "giải trí": "vui", "âm nhạc": "vui", "phim": "vui", "lễ hội": "vui", + "du lịch": "vui", "ẩm thực": "vui", "thời trang": "vui", "ra mắt": "vui", + "khai trương": "vui", "tin vui": "vui", "hạnh phúc": "vui", + # Sad / accidents / loss -> sad + "tai nạn": "buon", "qua đời": "buon", "tử vong": "buon", "thiệt mạng": "buon", + "động đất": "buon", "lũ lụt": "buon", "thiên tai": "buon", "cháy": "buon", + "tang lễ": "buon", "mất tích": "buon", "thương tâm": "buon", + # Health / science / calm -> calm warm + "sức khỏe": "tram", "y tế": "tram", "bệnh": "tram", "dinh dưỡng": "tram", + "tâm lý": "tram", "thiền": "tram", "giấc ngủ": "tram", + # News / politics / economy / law -> serious + "chính trị": "nghiem", "kinh tế": "nghiem", "tài chính": "nghiem", + "chứng khoán": "nghiem", "pháp luật": "nghiem", "tòa án": "nghiem", + "ngân hàng": "nghiem", "thị trường": "nghiem", "lạm phát": "nghiem", + "công nghệ": "nghiem", "ai": "nghiem", "khoa học": "nghiem", "giáo dục": "nghiem", +} + +def _detect_emotion_for_topic(title: str, text: str) -> str: + """Auto-detect emotion (cảm xúc) from topic/content keywords.""" + combined = (title + " " + text[:600]).lower() + for keyword, emo in TOPIC_EMOTION_MAP.items(): + if keyword in combined: + return emo + return EMOTION_DEFAULT + + +def _detect_voice_emotion(title: str, text: str) -> tuple: + """Return (voice_id, emotion_id) auto-chosen for the article's topic.""" + return _detect_voice_for_topic(title, text), _detect_emotion_for_topic(title, text) + + +# ===== 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. - """ - 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) +# ===== CLEAN AI OUTPUT ===== +def _clean_ai_output(text: str) -> str: + """Remove markdown artifacts, instruction leakage, and aggressively dedup content.""" + if not text: + return "" + # Remove markdown headings, bold, italic, horizontal rules + 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) + # Remove common AI instruction leakage phrases (entire line) + leakage = [ + 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', + ] + for phrase in leakage: + text = re.sub(r'^' + phrase + r'[^\n]*\n?', '', text, flags=re.MULTILINE | re.IGNORECASE) + text = re.sub(r'\n{3,}', '\n\n', text) + # --- Aggressive dedup: split into sentences, remove any that repeats --- + # Normalize: collapse whitespace, strip + def _norm(s): + return re.sub(r'\s+', ' ', s.strip().lower()) + + # Split by sentence-ending punctuation (keep delimiters) + raw_parts = re.split(r'(?<=[.!?])\s+', text.strip()) + seen_sentences = set() + unique_parts = [] + for part in raw_parts: + n = _norm(part) + # Skip near-duplicate: if >70% of an existing seen sentence matches + is_dup = False + if n: + if n in seen_sentences: + is_dup = True + else: + partial = re.sub(r'\W+', '', n) + for seen in seen_sentences: + seen_clean = re.sub(r'\W+', '', seen) + # Check substring match for very similar sentences + if partial and seen_clean and ( + partial in seen_clean or seen_clean in partial + ): + shorter = min(len(partial), len(seen_clean)) + longer = max(len(partial), len(seen_clean)) + if shorter > 20 and shorter / longer > 0.75: + is_dup = True + break + if is_dup: + continue + if n: + seen_sentences.add(n) + unique_parts.append(part) -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] + result = ' '.join(unique_parts).strip() + # Final pass: remove any remaining 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() + return result + + +# ===== EXTRACT ALL IMAGES FROM ARTICLE ===== +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", + ] + + def _add_image(src: str, alt: str = "", source_tag: str = "img"): + if not src or src.startswith("data:"): + return + abs_url = urljoin(base_url, src.strip()) + 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', '.gif']): + 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", ".gif"]): + _add_image(href, alt=a.get_text(strip=True)[:80], source_tag="link") + + 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 - 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: - break - if chunks: - return "\n".join("• " + c for c in chunks) - return "• Không có đủ nội dung để tóm tắt." - -# ===== 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 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"} -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) -import random as _random2 -from datetime import datetime, timezone, timedelta -_VN_TZ = timezone(timedelta(hours=7)) +# ===== 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() -def _safe_name(filename: str) -> str: - """Sanitize filename.""" - return re.sub(r"[^a-zA-Z0-9_.-]", "_", filename)[:120] + # 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 "" -def pollinations_image_url(topic: str) -> str: - """Generate a placeholder image URL via Pollinations.""" + # 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.""" + data = scrape_any_url_direct(url) + raw_text = (data.get("summary", "") + "\n" + data.get("text", "")).strip() + if len(raw_text) >= 120: + return data 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"): + if data.get("title") and data["title"] != url: + jr["title"] = data["title"] + if data.get("image"): + jr["image"] = data["image"] + if data.get("images"): + jr["images"] = data["images"] + 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" + + +@app.get("/api/ai/probe") +async def api_ai_probe(): + """Diagnostic: test which chat models actually work on this token + their latency.""" + import time as _t + tok = _hf_token() + out = [] + extra = os.getenv("PROBE_MODELS", "").split(",") + cand = [m.strip() for m in extra if m.strip()] + [ + "Qwen/Qwen2.5-VL-7B-Instruct", "Qwen/Qwen2.5-VL-3B-Instruct", "Qwen/Qwen2-VL-7B-Instruct", + "Qwen/Qwen2.5-VL-72B-Instruct", "Qwen/Qwen3-8B", "Qwen/Qwen3-4B", "Qwen/Qwen3-32B", + "Qwen/Qwen2.5-7B-Instruct-1M", "meta-llama/Llama-3.2-3B-Instruct"] + seen = set() + for m in cand: + if not m or m in seen: + continue + seen.add(m) + t0 = _t.time() + try: + c = AsyncInferenceClient(provider="auto", api_key=tok, timeout=40) + r = await c.chat_completion(model=m, messages=[{"role": "user", "content": "Trả lời đúng 1 từ: xin chào"}], max_tokens=10) + out.append({"model": m, "ok": True, "sec": round(_t.time() - t0, 1), "txt": (r.choices[0].message.content or "")[:30]}) + except Exception as e: + out.append({"model": m, "ok": False, "sec": round(_t.time() - t0, 1), "err": (type(e).__name__ + ": " + str(e))[-300:]}) + return JSONResponse({"results": out}) + + +# ===== QWEN AI (strict, concise) ===== +async def qwen_generate(prompt: str, image_url: Optional[str] = None, max_tokens: int = 500, 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 = [] + has_images = bool(image_urls) or bool(image_url) + if has_images: + # Vision needed -> VL models + candidate = [QWEN_VL_MODEL, "Qwen/Qwen2.5-VL-7B-Instruct", "Qwen/Qwen2.5-VL-3B-Instruct"] + else: + # Text-only summary -> FAST text models first, VL only as last resort. + candidate = QWEN_TEXT_MODELS + [QWEN_VL_MODEL] + # Use the last-known-working model first to avoid wasting time on unavailable models. + global _WORKING_MODEL_TEXT, _WORKING_MODEL_VL + cached_ok = _WORKING_MODEL_VL if has_images else _WORKING_MODEL_TEXT + if cached_ok and cached_ok in candidate: + candidate = [cached_ok] + [m for m in candidate if m != cached_ok] + for m in candidate: + if m and m not in models: + models.append(m) + for model in models: + try: + client = AsyncInferenceClient(provider="auto", api_key=HF_TOKEN, timeout=60) + 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." + )}, + {"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 = "" + if has_images: _WORKING_MODEL_VL = model + else: _WORKING_MODEL_TEXT = model + 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, emotion: str = None): + """Generate TTS using edge-tts (or gTTS if engine=gtts) with voice, speed & emotion control. -def _download_image(url: str, fallback_title: str, out_path: str) -> str: - """Download an image from URL or create a placeholder.""" + Emotion (cảm xúc) is simulated via pitch + rate + volume (edge-tts has no express-as). + """ + vcfg = TTS_VOICES.get(voice_id, TTS_VOICES[TTS_DEFAULT_VOICE]) + # gTTS engine (no voice/speed/emotion control) + if vcfg.get("engine") == "gtts": + _generate_tts_gtts(text, out_path) + return + if edge_tts is None: + raise RuntimeError("edge-tts chưa cài đặt") + voice = vcfg["id"] + emo = EMOTION_PRESETS.get(emotion or EMOTION_DEFAULT, EMOTION_PRESETS[EMOTION_DEFAULT]) + # Apply emotion rate multiplier on top of base speed + eff_speed = speed * emo.get("rate_mul", 1.0) + pct = int(round((eff_speed - 1.0) * 100)) + rate = f"+{pct}%" if pct >= 0 else f"{pct}%" + pitch = emo.get("pitch", "+0Hz") + volume = emo.get("volume", "+0%") + communicate = edge_tts.Communicate(text, voice, rate=rate, pitch=pitch, volume=volume) + await communicate.save(out_path) + +def _generate_tts_gtts(text: str, out_path: str): + """Fallback TTS using gTTS (no voice/speed control).""" + if gTTS is None: + raise RuntimeError("gTTS chưa cài đặt") + gTTS(text, lang="vi").save(out_path) + + +# ===== SHORT VIDEO GENERATION (multi-segment: each key point with its own image) ===== +def _download_image(url, fallback_topic, out_path): + """Download an image (un-proxying our own /api/proxy/img). Falls back to generated image.""" if url: + u = url + m = re.search(r'/api/proxy/img\?url=(.+)$', u) + if m: + from urllib.parse import unquote + u = unquote(m.group(1)) 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) + r = requests.get(u, headers={**HEADERS, "Referer": "https://dantri.com.vn/"}, timeout=15) + if r.status_code == 200 and len(r.content) > 1000: with open(out_path, "wb") as f: f.write(r.content) + if Image: + Image.open(out_path).verify() 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, 980), (30, 55, 42)).save(out_path) return out_path + raise RuntimeError("Không tạo được ảnh") -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": ""} - 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") +def _split_keypoint_sentences(text, max_points=6): + """Split summary text into key points: prefer bullet markers, else sentences.""" + text = _clean_text(text) + parts = re.split(r'\s*•\s*', text) + good = [p.strip() for p in parts if len(p.strip()) > 20] + if len(good) >= 2: + # Explicit bullet points: keep each one as-is (never merge). + return good[:max_points] + # Fallback: split into sentences and merge orphan short fragments. + pts = [p.strip() for p in re.split(r'(?<=[.!?])\s+', text) if len(p.strip()) > 20] + out = [] + for p in pts: + if out and len(p) < 40: + out[-1] = (out[-1] + " " + p).strip() + else: + out.append(p) + return out[:max_points] if out else ([text] if text else []) + + +def _build_keypoints(post, max_points=6): + """Return [{text, image}] pairing each key point with its own image.""" + slides = post.get("slides") or [] + images = post.get("images") or ([post.get("img")] if post.get("img") else []) + images = [i for i in images if i] + if slides: + kps = [] + for i, s in enumerate(slides[:max_points]): + t = _clean_text(s.get("text", "")) + img = s.get("image") or (images[i] if i < len(images) else (images[-1] if images else "")) 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} + kps.append({"text": t, "image": img}) + if kps: + return kps + points = _split_keypoint_sentences(post.get("text", ""), max_points) + kps = [] + for i, t in enumerate(points): + img = images[i] if i < len(images) else (images[-1] if images else "") + kps.append({"text": t, "image": img}) + if not kps: + kps = [{"text": _clean_text(post.get("title", "")) or "VNEWS", "image": images[0] if images else ""}] + return kps + + +def _wrap_text(draw, text, font, max_w): + words = text.split() + lines, cur = [], "" + for w in words: + test = (cur + " " + w).strip() + if draw.textlength(test, font=font) <= max_w: + cur = test + else: + if cur: + lines.append(cur) + cur = w + if cur: + lines.append(cur) + return lines + + +def _make_segment_frame(title, point_text, img_path, idx, total, out_path): + """Render a 1080x1920 vertical frame: image on top, key point text below.""" + if Image is None: + raise RuntimeError("Pillow chưa sẵn sàng") + W, H = 1080, 1920 + IMG_H = 980 + bg = Image.new("RGB", (W, H), (12, 14, 18)) + try: + im = Image.open(img_path).convert("RGB") + tr = W / IMG_H + ir = im.width / im.height + if ir > tr: + nh = IMG_H; nw = int(nh * ir) + else: + nw = W; nh = int(nw / ir) + im = im.resize((nw, nh)) + left = (nw - W) // 2; top = (nh - IMG_H) // 2 + im = im.crop((left, top, left + W, top + IMG_H)) + bg.paste(im, (0, 0)) + except Exception: + pass + draw = ImageDraw.Draw(bg) + draw.rectangle((0, IMG_H, W, H), fill=(12, 14, 18)) + try: + f_label = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 34) + f_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 46) + f_point = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 52) + except Exception: + f_label = f_title = f_point = ImageFont.load_default() + draw.text((54, IMG_H + 24), "VNEWS · Tường AI", fill=(92, 184, 122), font=f_label) + cnt = f"{idx + 1}/{total}" + draw.text((W - 54 - draw.textlength(cnt, font=f_label), IMG_H + 24), cnt, fill=(240, 192, 64), font=f_label) + y = IMG_H + 86 + for ln in _wrap_text(draw, _clean_text(title), f_title, W - 108)[:2]: + draw.text((54, y), ln, fill=(255, 255, 255), font=f_title) + y += 56 + y += 16 + for ln in _wrap_text(draw, _clean_text(point_text), f_point, W - 108)[:11]: + draw.text((54, y), ln, fill=(225, 230, 235), font=f_point) + y += 64 + bg.save(out_path, quality=92) + return out_path + + +def _ffmpeg_bin(): + return os.environ.get("FFMPEG_BIN", "ffmpeg") + + +def _audio_duration(path): + try: + out = subprocess.run([_ffmpeg_bin(), "-i", path], capture_output=True, text=True, timeout=30).stderr + m = re.search(r"Duration:\s*(\d+):(\d+):(\d+\.\d+)", out) + if m: + h, mi, s = m.groups() + return int(h) * 3600 + int(mi) * 60 + float(s) + except Exception: + pass + return 0.0 + + +async def _generate_short_video(post, post_id: str, voice_id: str = None, speed: float = None, emotion: str = None) -> str: + """Generate a multi-segment MP4 short: each key point shown with its OWN image + narration.""" + try: + 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_id is None and speed is None and emotion is None: + return "/api/ai/short-file/" + post_id + + work = os.path.join(SHORTS_DIR, _safe_name(post_id) + "_work") + os.makedirs(work, exist_ok=True) + + title = _clean_text(post.get("title", "")) or "VNEWS" + kps = _build_keypoints(post) + + # Resolve voice + emotion (auto from topic, or from post, or explicit args) + auto_voice, auto_emotion = _detect_voice_emotion(post.get("title", ""), post.get("text", "")) + if voice_id is None: + voice_id = post.get("voice") or auto_voice + if emotion is None: + emotion = post.get("emotion") or auto_emotion + if speed is None: + speed = TTS_DEFAULT_SPEED + vcfg = TTS_VOICES.get(voice_id, TTS_VOICES[TTS_DEFAULT_VOICE]) + + seg_files = [] + ff = _ffmpeg_bin() + for i, kp in enumerate(kps): + img_path = os.path.join(work, f"img{i}.jpg") + frame_path = os.path.join(work, f"frame{i}.jpg") + audio_path = os.path.join(work, f"voice{i}.mp3") + seg_mp4 = os.path.join(work, f"seg{i}.mp4") + _download_image(kp.get("image", ""), title, img_path) + _make_segment_frame(title, kp["text"], img_path, i, len(kps), frame_path) + narration = (title + ". " + kp["text"]) if i == 0 else kp["text"] + try: + await _generate_tts_edge(narration, voice_id, speed, audio_path, emotion=emotion) + except Exception as e: + print(f"[TTS edge-tts error] {e}, falling back to gTTS") + if gTTS: + _generate_tts_gtts(narration, audio_path) + else: + return "" + dur = _audio_duration(audio_path) + if dur < 1.0: + dur = 2.0 + cmd = [ff, "-y", "-loop", "1", "-i", frame_path, "-i", audio_path, + "-c:v", "libx264", "-tune", "stillimage", "-pix_fmt", "yuv420p", + "-t", f"{dur + 0.4:.2f}", "-c:a", "aac", "-b:a", "128k", "-ar", "44100", + "-vf", "scale=1080:1920", "-r", "25", seg_mp4] + subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180) + seg_files.append(seg_mp4) + + if not seg_files: + return "" + if len(seg_files) == 1: + os.replace(seg_files[0], out_mp4) + return "/api/ai/short-file/" + post_id + + listfile = os.path.join(work, "concat.txt") + with open(listfile, "w", encoding="utf-8") as f: + f.write("\n".join(f"file '{s}'" for s in seg_files)) + cmd = [ff, "-y", "-f", "concat", "-safe", "0", "-i", listfile, + "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k", out_mp4] + subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=300) + 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 "" +import threading as _threading +def _spawn_background_video(post): + """Generate the short video in a BACKGROUND thread so the rewrite/topic endpoint + can return immediately. When done, persist the video URL onto the wall post. + This is the main fix for 'rewrite tu cac nguon tin qua lau' — the user gets the + text post instantly; the video appears shortly after (or via the 'Tao Video' button).""" + pid = post.get("id") + if not pid: + return + def _run(): + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + video_url = loop.run_until_complete(_generate_short_video(post, pid)) + loop.close() + if video_url: + posts = _load_wall() + for i, p in enumerate(posts): + if str(p.get("id")) == str(pid): + posts[i]["video"] = video_url + break + _save_wall(posts) + print(f"[bg-video] done {pid} -> {video_url}") + except Exception as e: + print(f"[bg-video] error {pid}: {e}") + _threading.Thread(target=_run, daemon=True).start() -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, voice=None, emotion=None): + # Auto-pick voice + emotion from the article topic when not provided + auto_voice, auto_emotion = _detect_voice_emotion(title or "", text or "") 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()), + "voice": voice or auto_voice, + "emotion": emotion or auto_emotion, } + + +# ===== SHARED PROMPT BUILDER ===== +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"""Tóm tắt bài viết sau thành bài TÓM TẮT đăng Tường AI. + +QUY TẮC BẮT BUỘC: +1. Chỉ viết TÓM TẮT các ý chính. KHÔNG sao chép nguyên văn từ bài gốc. +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, 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". +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, đủ ý.{image_info} + +Tiêu đề gốc: {title} + +Nội dung gốc: +{raw[:14000]}""" + + +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 BẮT BUỘC: +1. Chỉ viết TÓM TẮT các ý chính từ nguồn. KHÔNG sao chép nguyên vă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, đủ ý. + +Nguồn thực tế: +{ctx[:12000]}""" + + +# ===== 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-only summary for SPEED (images are kept on the post for display + short video). + text = await qwen_generate(prompt, max_tokens=500) + 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) + + # Save post and return IMMEDIATELY; generate the short video in the background + # (so rewrite is fast). Video appears on the wall when ready / via 'Tao Video' button. + posts = _load_wall() + posts.insert(0, post) + _save_wall(posts) + _spawn_background_video(post) + 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-only summary for SPEED (images are kept on the post for display + short video). + text = await qwen_generate(prompt, max_tokens=500) + 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) + + posts = _load_wall() + posts.insert(0, post) + _save_wall(posts) + _spawn_background_video(post) + 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) + # NOTE: do NOT pass the decorative pollinations image to the VL model — feeding an + # image makes Qwen2.5-VL much slower (it must download+process it) with no benefit for + # a text summary. We keep the image only for display on the post. This is a major + # speed-up for 'rewrite tong hop'. (Text-only inference is several times faster.) + text = await qwen_generate(prompt, max_tokens=500) + 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") + + posts = _load_wall() + posts.insert(0, post) + _save_wall(posts) + _spawn_background_video(post) + 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 (with voice + speed params) ===== +@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), emotion: str = Query(default=None)): + """Generate (or retrieve cached) short video for a wall post. + + Query params: + - voice: 'hoaimy' (female) | 'namminh' (male) | auto-detect if not specified + - speed: float (default 1.2), e.g. 1.0=normal, 1.2=fast, 0.8=slow + - emotion: 'vui'|'hao_hung'|'nghiem'|'tram'|'buon'|'trung_tinh' | auto by topic + """ + 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 cached and no custom voice/speed/emotion requested, return cached + if os.path.exists(out_mp4) and voice is None and speed is None and emotion 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}) + + # Validate params + 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) + if emotion is not None and emotion not in EMOTION_PRESETS: + return JSONResponse({"error": f"emotion không hợp lệ. Chọn: {list(EMOTION_PRESETS.keys())}"}, status_code=400) + + video_url = await _generate_short_video(post, post_id, voice_id=voice, speed=speed, emotion=emotion) + if video_url: + for i, p in enumerate(posts): + if str(p.get("id")) == str(post_id): + posts[i]["video"] = video_url + if voice: + posts[i]["voice"] = voice + if emotion: + posts[i]["emotion"] = emotion + 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, + "last_error": LAST_QWEN_ERROR, + "working_text_model": _WORKING_MODEL_TEXT, + "working_vl_model": _WORKING_MODEL_VL, + }) diff --git a/ai_patch.py b/ai_patch.py index 4e892446b8a76012c9943fd73f7afe72c0fc55b2..9c958e6b08ea6e1b82cbc6ee39c6b504b1bdd3d1 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] @@ -268,7 +268,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"): @@ -278,11 +278,7 @@ base.qwen_generate = qwen_generate_resilient @app.get('/api/wall') def compat_wall(): - try: - from ai_ext import wall_posts_for_view - return JSONResponse({'posts': wall_posts_for_view(limit=200)}) - except Exception: - return JSONResponse({'posts': base._load_ai_wall()[:80]}) + return JSONResponse({'posts': base._load_ai_wall()[:80]}) _PATCHED_PATHS = { @@ -325,30 +321,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) @@ -369,142 +347,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') @@ -521,56 +371,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 @@ -685,7 +519,7 @@ 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): 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() @@ -696,7 +530,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) @@ -748,8 +582,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 @@ -761,11 +594,10 @@ 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)) + return max(1.5, float((pr.stdout or b'').decode().strip() or fallback)) except Exception: return fallback @@ -778,7 +610,7 @@ async def patched_ai_short(post_id: str, request: Request): 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() @@ -786,7 +618,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" @@ -809,63 +641,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): @@ -878,13 +658,13 @@ 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 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') @@ -919,3 +699,53 @@ 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_final6.py b/ai_runtime_final6.py index 4efc9c899924f859f2e644ac3e18c98919615c20..58d62a3f0bf6cd2035085c7dd1cb39ada792e96a 100644 --- a/ai_runtime_final6.py +++ b/ai_runtime_final6.py @@ -1,849 +1,394 @@ -"""Final6: robust topic synthesis, stable shorts, hot topic hashtags. - -This runtime intentionally overrides only the topic/shorts/root endpoints from the restored app. -""" -import re, time, json, os, threading, html as html_lib -from urllib.parse import quote, urlparse, parse_qs, unquote -import requests -from bs4 import BeautifulSoup +"""Final6 runtime: fast homepage, fast shorts, Qwen topic with source details, hashtag sources, rewrite auto-title.""" +import os, re, time, json, hashlib, asyncio, threading, requests +from urllib.parse import urlparse, quote import ai_runtime_final5 as f5 -from ai_runtime_final5 import app, rt, HTMLResponse, JSONResponse, Request, Query +from ai_runtime_final5 import app, base, rt, HTMLResponse, JSONResponse, Request, Query +import html as html_lib -_PATCH={('/api/topic_post','POST'),('/api/shorts','GET'),('/api/hot_topics','GET'),('/api/topic_sources','GET'),('/','GET')} -app.router.routes=[r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in _PATCH)] +SPACE_URL = "https://bep40-vnews.hf.space" +SHORT_CHANNELS = ["baodantri7941", "baosuckhoedoisongboyte"] +YOUTUBE_HANDLES = SHORT_CHANNELS +DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data" +os.makedirs(DATA_DIR, exist_ok=True) +SHORTS_CACHE = {"t": 0, "d": []} +AI_INTERACTIONS_FILE = os.path.join(DATA_DIR, "ai_interactions.json") +SHORT_COMMENTS_FILE = os.path.join(DATA_DIR, "short_comments.json") -_TOPIC_CACHE={} -_HOT_CACHE={"t":0,"d":[]} -_SHORTS_CACHE_FINAL6={"t":0,"d":[]} -_TRANSLATE_CACHE_PATH="/data/title_vi_cache.json" if os.path.isdir('/data') else "/app/data/title_vi_cache.json" -_translate_lock=threading.Lock() -YOUTUBE_HANDLES=["baodantri7941","baosuckhoedoisongboyte"] -UA={"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,en;q=0.8"} -STOP_WORDS=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'.split()) -TRUSTED_SITES=['vnexpress.net','dantri.com.vn','vietnamnet.vn','tuoitre.vn','thanhnien.vn','laodong.vn','vov.vn','vtv.vn','genk.vn','cafef.vn','thethaovanhoa.vn'] +def clean(s): + return re.sub(r"\s+", " ", html_lib.unescape(s or "")).strip() -def clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or ""))).strip() def _domain(u): - try:return urlparse(u or '').netloc.replace('www.','') - except Exception:return '' - -def _load_title_cache(): - try: - if os.path.exists(_TRANSLATE_CACHE_PATH): - with open(_TRANSLATE_CACHE_PATH,'r',encoding='utf-8') as f:return json.load(f) - except Exception:pass - return {} -def _save_title_cache(db): - try: - os.makedirs(os.path.dirname(_TRANSLATE_CACHE_PATH),exist_ok=True);tmp=_TRANSLATE_CACHE_PATH+'.tmp' - with open(tmp,'w',encoding='utf-8') as f:json.dump(db,f,ensure_ascii=False) - os.replace(tmp,_TRANSLATE_CACHE_PATH) - except Exception:pass - -def _looks_vietnamese(s): - s=s or '' - if re.search(r'[àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ]',s,re.I):return True - low=' '+s.lower()+' ' - return any(w in low for w in [' và ',' của ',' người ',' tại ',' trong ',' với ',' không ',' được ',' công an ',' bệnh viện ',' học sinh ',' tài xế ',' bóng đá ',' tin tức ',' sức khỏe ']) -def _translate_title_vi(title): - title=clean(title) - if not title or _looks_vietnamese(title):return title - with _translate_lock: - db=_load_title_cache() - if title in db:return db[title] - vi=title - try: - r=requests.get('https://translate.googleapis.com/translate_a/single',params={'client':'gtx','sl':'auto','tl':'vi','dt':'t','q':title},headers=UA,timeout=8) - if r.status_code==200: - data=r.json();vi=''.join(part[0] for part in data[0] if part and part[0]).strip() or title - except Exception:pass - vi=clean(vi) - with _translate_lock: - db=_load_title_cache();db[title]=vi;_save_title_cache(db) - return vi + try: return urlparse(u or '').netloc.replace('www.', '') + except: return '' -# ===== Hot topics / hashtags ===== -def _keywords_from_title(title): - title=clean(re.sub(r'\s+-\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_WORDS] - phrases=[] - for n in (4,3,2): - for i in range(0,max(0,len(words)-n+1)): - ph=' '.join(words[i:i+n]).strip() - if len(ph)>=8:phrases.append(ph) - if words:phrases.append(' '.join(words[:5])) - return phrases[:4] - -def _hot_topics(): - now=time.time() - if _HOT_CACHE['d'] and now-_HOT_CACHE['t']<900:return _HOT_CACHE['d'] - topics=[];seen=set() - feeds=[ - 'https://news.google.com/rss?hl=vi&gl=VN&ceid=VN:vi', - 'https://news.google.com/rss/headlines/section/topic/NATION?hl=vi&gl=VN&ceid=VN:vi', - 'https://news.google.com/rss/headlines/section/topic/BUSINESS?hl=vi&gl=VN&ceid=VN:vi', - 'https://news.google.com/rss/headlines/section/topic/SPORTS?hl=vi&gl=VN&ceid=VN:vi', - 'https://news.google.com/rss/headlines/section/topic/TECHNOLOGY?hl=vi&gl=VN&ceid=VN:vi' - ] - for feed in feeds: - try: - r=requests.get(feed,headers=UA,timeout=10);r.encoding='utf-8' - soup=BeautifulSoup(r.text,'xml') - for it in soup.find_all('item')[:15]: - title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '') - for kw in _keywords_from_title(title): - key=kw.lower() - if key not in seen and len(kw)<=60: - seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw}) - if len(topics)>=24:break - if len(topics)>=24:break - except Exception:pass - if len(topics)>=24:break - for kw in ['AI trong giáo dục','World Cup 2026','kinh tế Việt Nam','biến đổi khí hậu','giá vàng','bóng đá Việt Nam','an ninh mạng','xe điện','sức khỏe tinh thần','thị trường chứng khoán']: - 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'] -@app.get('/api/hot_topics') -def api_hot_topics():return JSONResponse({'topics':_hot_topics()}) - -# ===== Topic web research ===== -def _unwrap_ddg_href(href): - if not href:return '' - if href.startswith('//duckduckgo.com/l/?') or 'duckduckgo.com/l/?' in href: - qs=parse_qs(urlparse('https:'+href if href.startswith('//') else href).query) - return unquote(qs.get('uddg',[''])[0]) - return href - -def _ddg_search(query, limit=10): - items=[];seen=set() +def _lj(p, d): try: - url='https://html.duckduckgo.com/html/?q='+quote(query) - r=requests.get(url,headers=UA,timeout=14);r.encoding='utf-8' - soup=BeautifulSoup(r.text,'lxml') - for res in soup.select('.result'): - a=res.select_one('.result__title a') or res.find('a',href=True) - if not a:continue - link=_unwrap_ddg_href(a.get('href',''));title=clean(a.get_text(' ',strip=True));snippet=clean((res.select_one('.result__snippet') or res).get_text(' ',strip=True)) - if not link.startswith('http') or link in seen:continue - if any(bad in link for bad in ['duckduckgo.com','youtube.com','facebook.com','tiktok.com','twitter.com','x.com']):continue - seen.add(link);items.append({'title':title,'url':link,'source':_domain(link),'snippet':snippet}) - if len(items)>=limit:break - except Exception:pass - return items + if os.path.exists(p): return json.load(open(p, 'r', encoding='utf-8')) + except: pass + return d -def _google_news_items(topic, limit=8): - items=[];seen=set() +def _sj(p, d): try: - rss='https://news.google.com/rss/search?q='+quote(topic)+'&hl=vi&gl=VN&ceid=VN:vi' - r=requests.get(rss,headers=UA,timeout=12);r.encoding='utf-8' - soup=BeautifulSoup(r.text,'xml') - for it in soup.find_all('item')[:limit*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 '') - src=clean(it.find('source').get_text(' ',strip=True) if it.find('source') else _domain(link)) - if title and link and link not in seen: - seen.add(link);items.append({'title':title,'url':link,'source':src,'snippet':''}) - if len(items)>=limit:break - except Exception:pass - return items - -def _candidate_urls(topic): - seen=set();items=[] - queries=[topic+' tin tức Việt Nam', topic+' phân tích bối cảnh', topic+' site:vnexpress.net OR site:dantri.com.vn OR site:vietnamnet.vn'] - for q in queries: - for it in _ddg_search(q,8): - if it['url'] not in seen: - seen.add(it['url']);items.append(it) - if len(items)>=12:break - for site in TRUSTED_SITES[:8]: - for it in _ddg_search(f'{topic} site:{site}',3): - if it['url'] not in seen: - seen.add(it['url']);items.append(it) - for it in _google_news_items(topic,8): - if it['url'] not in seen: - seen.add(it['url']);items.append(it) - return items[:24] + os.makedirs(os.path.dirname(p), exist_ok=True) + open(p + '.tmp', 'w', encoding='utf-8').write(json.dumps(d, ensure_ascii=False)) + os.replace(p + '.tmp', p) + except: pass -def _extract_article_text_bs(url, max_chars=9000): +def _yt_ytdlp(handle, count=20): try: - r=requests.get(url,headers=UA,timeout=16,allow_redirects=True) - if r.status_code>=400:return '' - r.encoding='utf-8';soup=BeautifulSoup(r.text,'lxml') - for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe','svg']):tag.decompose() - candidates=[] - for sel in ['article','main','.article-content','.detail-content','.singular-content','.fck_detail','.content-detail','.entry-content','.story-body','.knc-content']: - el=soup.select_one(sel) - if el:candidates.append(el) - if not candidates:candidates=[soup.body or soup] - best=max(candidates,key=lambda el:len(el.find_all('p')) if el else 0) - ps=[] - for el in best.find_all(['p','h2','h3'],recursive=True): - t=clean(el.get_text(' ',strip=True)) - if len(t)>45 and not any(x in t.lower() for x in ['đăng ký nhận tin','theo dõi chúng tôi','chuyên mục','xem thêm','tin liên quan','advertisement']):ps.append(t) - if sum(len(x) for x in ps)>max_chars:break - return '\n'.join(ps)[:max_chars] - except Exception:return '' + import yt_dlp + url = f"https://www.youtube.com/@{handle}/shorts" + opts = {'quiet': True, 'extract_flat': True, 'skip_download': True, 'playlist-end': count, 'ignoreerrors': True, 'no_warnings': True} + with yt_dlp.YoutubeDL(opts) as ydl: + info = ydl.extract_info(url, download=False) + out = [] + for e in (info or {}).get('entries') or []: + vid = e.get('id') or '' + if not re.match(r'^[A-Za-z0-9_-]{11}$', vid): continue + title = e.get('title') or 'YouTube Short' + out.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': handle}) + return out + except: return [] -def _jina_read_text(url, max_chars=9000): +def _yt_html(handle, count=20): try: - ju='https://r.jina.ai/http://'+url - r=requests.get(ju,headers=UA,timeout=28);r.encoding='utf-8' - if r.status_code!=200 or not r.text:return '' - lines=[] - for ln in r.text.splitlines(): - t=clean(ln) - if not t or t.startswith(('Title:','URL Source:','Published Time:','Markdown Content:','Image:','Description:')):continue - if len(t)>45:lines.append(t) - if sum(len(x) for x in lines)>max_chars:break - return '\n'.join(lines)[:max_chars] - except Exception:return '' - -def _scrape_article_text(url, max_chars=9000): - text=_extract_article_text_bs(url,max_chars) - if len(text)<350:text=_jina_read_text(url,max_chars) - return text + r = requests.get(f"https://www.youtube.com/@{handle}/shorts", headers=getattr(base, 'HEADERS', {}), timeout=15) + ids = []; out = [] + for m in re.finditer(r'"videoId":"([A-Za-z0-9_-]{11})"', r.text): + vid = m.group(1) + if vid in ids: continue + ids.append(vid) + snip = r.text[max(0, m.start()-1000):m.start()+1800] + title = 'YouTube Short' + mt = re.search(r'"title":\{"runs":\[\{"text":"([^"]+)"', snip) or re.search(r'"accessibilityText":"([^"]+)"', snip) + if mt: title = clean(mt.group(1).replace('\\n', ' ')) + out.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': handle}) + if len(out) >= count: break + return out + except: return [] -def _score_relevance(topic, title, text, snippet=''): - keys=[w.lower() for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',topic) if len(w)>2 and w.lower() not in STOP_WORDS] - hay=(title+' '+snippet+' '+text[:2500]).lower() - if not keys:return 1 - return sum(1 for k in keys if k in hay) +def _fallback_shorts(): + out = []; seen = set() + hard = [ + ('Lu_iCQ5YwNM', 'Công an lập hồ sơ xử lý người phụ nữ chửi bới, tát tài xế ô tô | Dân trí', 'baodantri7941'), + ('CwWvijF8BOA', 'Chú rể bật khóc nhận món quà bí mật người cha quá cố gửi 26 năm trước | Dân trí', 'baodantri7941'), + ('7Pd6vZ2Lz1M', 'Hành động ấm lòng trong tìm kiếm học sinh tử vong ở sông Lô | SKĐS', 'baosuckhoedoisongboyte'), + ('SlHLt_ZyPiE', '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', 'baosuckhoedoisongboyte'), + ] + for vid, title, ch in hard: + if vid not in seen: + seen.add(vid) + out.append({'id': vid, 'title': title, 'channel': ch, 'link': f'https://www.youtube.com/watch?v={vid}', 'img': f'https://i.ytimg.com/vi/{vid}/hqdefault.jpg', 'source': 'yt'}) + return out -def _web_research_context(topic): - now=time.time();key=topic.lower().strip() - if key in _TOPIC_CACHE and now-_TOPIC_CACHE[key]['t']<900:return _TOPIC_CACHE[key]['d'] - items=_candidate_urls(topic) - crawled=[] - for it in items: - text=_scrape_article_text(it['url'],9000) - rel=_score_relevance(topic,it.get('title',''),text,it.get('snippet','')) - if text and len(text)>300 and rel>0: - crawled.append({**it,'text':text,'rel':rel}) - elif it.get('snippet') and rel>0: - crawled.append({**it,'text':it['snippet'],'rel':rel,'snippet_only':True}) - crawled=sorted(crawled,key=lambda x:(x.get('rel',0),len(x.get('text',''))),reverse=True)[:6] - blocks=[];sources=[] - for it in crawled: - label='ĐOẠN MÔ TẢ TỪ KẾT QUẢ TÌM KIẾM' if it.get('snippet_only') else 'NỘI DUNG BÀI VIẾT ĐÃ CRAWL' - blocks.append(f"NGUỒN: {it['source']}\nTIÊU ĐỀ: {it['title']}\n{label}:\n{it['text'][:8500]}") - sources.append({'title':it['title'],'url':it['url'],'via':it['source']}) - data={'context':'\n\n---\n\n'.join(blocks),'sources':sources[:8],'count':len(blocks)} - _TOPIC_CACHE[key]={'t':now,'d':data} - return data +def _fresh_shorts(): + items = []; seen = set() + for ch in YOUTUBE_HANDLES: + got = _yt_ytdlp(ch, 24) or _yt_html(ch, 24) + for v in got: + if v['id'] not in seen: + seen.add(v['id']); items.append(v) + for v in _fallback_shorts(): + if v['id'] not in seen: + seen.add(v['id']); items.append(v) + return items[:50] def _topic_image(topic): - try:return f5.base.pollinations_image_url(topic) - except Exception:return 'https://image.pollinations.ai/prompt/'+quote('Vietnamese editorial illustration, '+topic)+'?width=1024&height=576&nologo=true' - -@app.get('/api/topic_sources') -def api_topic_sources(topic:str=Query(...)): - data=_web_research_context(clean(topic)) - return JSONResponse({'count':data.get('count',0),'sources':data.get('sources',[]),'has_context':bool(data.get('context'))}) + try: return base.pollinations_image_url(topic) + except: return "https://image.pollinations.ai/prompt/" + quote("Vietnamese news editorial illustration " + topic) + "?width=1024&height=576&nologo=true" -@app.post('/api/topic_post') -async def topic_post_synthesis(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=_web_research_context(topic);context=research.get('context','');sources=research.get('sources',[]) - if not context or research.get('count',0)==0: - 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) - prompt=f"""Bạn là biên tập viên VNEWS. Người dùng chọn chủ đề: "{topic}". +def _web_research_context(topic, limit=5): + try: + ctx, sources = base.web_context(topic, limit=limit) + return {"context": ctx or "", "sources": sources or []} + except: + return {"context": "", "sources": []} + +def _fast_context(topic, limit=5): + return _web_research_context(topic, limit) + +def _extract_source_details_from_context(ctx, sources): + """Extract detailed source info from web context for source_details field.""" + details = [] + if not ctx or not sources: + return details + # Parse sources from context + for s in sources[:8]: + url = s.get('url', '') + if not url: continue + details.append({ + 'title': s.get('title', ''), + 'url': url, + 'via': s.get('via', _domain(url)), + 'content': s.get('excerpt', s.get('description', '')) + }) + return details -Dưới đây là NỘI DUNG các bài viết/đoạn mô tả đã crawl từ internet. Hãy đọc hiểu và TỔNG HỢP thành MỘT BÀI VIẾT HOÀN CHỈNH. Tuyệt đối không bê nguyên văn, không xếp danh sách tiêu đề thành bài viết, không viết kiểu trả lời chat. +_bg_home = {"t": 0, "d": []} +_bg_shorts = {"t": 0, "d": []} +_bg_lock = False -DỮ LIỆU CRAWL: -{context[:30000]} +def _bg(): + global _bg_lock + if _bg_lock: return + _bg_lock = True + try: + # Fast homepage: just return empty, let frontend handle it + _bg_home.update({"t": time.time(), "d": []}) + # Shorts + raw = [] + for h in YOUTUBE_HANDLES: + raw.extend(_yt_ytdlp(h, 20) or _yt_html(h, 20)) + raw.extend(_fallback_shorts()) + seen = set() + out = [v for v in raw if v.get('id') and v['id'] not in seen and not seen.add(v['id'])] + if out: _bg_shorts.update({"t": time.time(), "d": out[:40]}) + except: pass + finally: _bg_lock = False + +@app.on_event("startup") +async def _s(): + threading.Thread(target=_bg, daemon=True).start() + threading.Thread(target=lambda: [time.sleep(600) or _bg() for _ in iter(int, 1)], daemon=True).start() + +# Remove endpoints to override +app.router.routes = [r for r in app.router.routes if not ( + getattr(r, 'path', None) in ('/api/homepage', '/api/shorts', '/api/ai_wall', '/api/topic_post', '/api/article/ask', '/api/topic/rewrite', '/api/rewrite_share', '/api/url_wall', '/api/short/comments', '/api/short/comment', '/api/storage_status', '/') and + any(m in getattr(r, 'methods', set()) for m in ('GET', 'POST')) +)] -Yêu cầu bắt buộc: -- Viết bằng tiếng Việt, văn phong báo điện tử/tạp chí. -- Tiêu đề mới, rõ, hấp dẫn. -- Sapo 2-3 câu nêu vấn đề chính. -- 5-8 đoạn nội dung tổng hợp: bối cảnh, diễn biến/khái niệm, phân tích, tác động, điểm cần lưu ý. -- Dùng thông tin từ nội dung đã crawl để tổng hợp ý; nếu chỉ có mô tả tìm kiếm thì viết thận trọng. -- KHÔNG liệt kê các tiêu đề nguồn. KHÔNG mở đầu bằng "Dưới đây là" hay "Tôi sẽ". -- Cuối bài thêm mục "Nguồn tham khảo" gồm tên nguồn ngắn gọn. -""" - text=await f5.base.qwen_generate(prompt,image_url=img,max_tokens=2800) - if not text or len(text)<500: - parts=[] - for block in context.split('---'): - body=block.split('NỘI DUNG BÀI VIẾT ĐÃ CRAWL:')[-1].split('ĐOẠN MÔ TẢ TỪ KẾT QUẢ TÌM KIẾM:')[-1].strip() - if len(body)>120:parts.append(body) - joined='\n\n'.join(parts)[:8500] - text=(f"{topic}: những điểm chính cần biết\n\n{topic} đang thu hút sự chú ý vì liên quan đến nhiều khía cạnh thực tế. Tổng hợp từ các nội dung thu thập được, có thể nhìn vấn đề qua bối cảnh, tác động và những điểm cần theo dõi.\n\n"+joined+"\n\nNguồn tham khảo: "+', '.join(sorted({s.get('via','') for s in sources if s.get('via')}))) - post=f5.base.make_post(topic,text,img,'','topic_web_synthesis',sources=[s for s in sources if s.get('url')]);post['images']=[img] - posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts) - return JSONResponse({'post':post}) +@app.get('/api/homepage') +def _h(): + n = time.time() + if _bg_home['d']: + if n - _bg_home['t'] > 300: threading.Thread(target=_bg, daemon=True).start() + return JSONResponse(_bg_home['d']) + threading.Thread(target=_bg, daemon=True).start() + return JSONResponse([]) -# ===== Stable newest Dantri/SKDS Shorts ===== -def _yt_ytdlp(handle,count=30): +@app.get('/api/shorts') +def _sh(refresh: int = Query(default=0)): + n = time.time() + if _bg_shorts['d'] and (not refresh or n - _bg_shorts['t'] < 120): + if n - _bg_shorts['t'] > 600: threading.Thread(target=_bg, daemon=True).start() + return JSONResponse(_bg_shorts['d']) + data = _fresh_shorts() + _bg_shorts.update({'t': n, 'd': data}) + return JSONResponse(data) + +@app.get('/api/ai_wall') +def _w(): + n = int(time.time()) + return JSONResponse({'posts': [p for p in f5.base._load_ai_wall() if n - int(p.get('ts') or 0) < 86400], 'persistent': os.path.isdir('/data')}) + +@app.get('/api/storage_status') +def _st(): + return JSONResponse({'persistent': os.path.isdir('/data')}) + +@app.get('/api/short/comments') +def _gc(id: str = Query(...)): + return JSONResponse({'comments': _lj(SHORT_COMMENTS_FILE, {}).get(id, [])}) + +@app.post('/api/short/comment') +async def _pc(request: Request): + b = await request.json() + v = str(b.get('id', '')).strip() + t = clean(b.get('text', '')) + if not v or not t: return JSONResponse({'error': 'missing'}, status_code=400) + db = _lj(SHORT_COMMENTS_FILE, {}) + c = db.get(v, []) + c.insert(0, {'text': t[:300], 'ts': int(time.time())}) + db[v] = c[:100] + _sj(SHORT_COMMENTS_FILE, db) + return JSONResponse({'comments': db[v]}) + +@app.post('/api/article/ask') +async def _ask(request: Request): + b = await request.json() + q = clean(b.get('question', '')) + ctx = clean(b.get('context', '')) + url = clean(b.get('url', '')) + if not q: return JSONResponse({'error': 'missing question'}, status_code=400) + title = ''; raw = '' + if url: + try: + d = f5.base.scrape_any_url(url) + title = d.get('title', '') + raw = (d.get('summary', '') + '\n' + d.get('text', '')).strip() + except: pass + if not raw: raw = ctx[:12000] + ans = await f5.base.qwen_generate( + f'Bạn là VNEWS AI. Nội dung: "{title}"\n{raw[:9000]}\n\nHỏi: "{q}"\n\nTrả lời tự nhiên bằng tiếng Việt.', + max_tokens=1200) + return JSONResponse({'answer': ans or 'Chưa trả lời được.', 'title': title}) + +@app.post('/api/rewrite_share') +@app.post('/api/url_wall') +async def _rw(request: Request): + b = await request.json() + url = clean(b.get('url', '')) + ctx = clean(b.get('context', '')) + if not url.startswith('http'): return JSONResponse({'error': 'URL không hợp lệ'}, status_code=400) try: - import yt_dlp - urls=[f'https://www.youtube.com/@{handle}/shorts',f'https://www.youtube.com/@{handle}/videos'] - out=[];seen=set();opts={'quiet':True,'extract_flat':True,'skip_download':True,'playlistend':count,'ignoreerrors':True,'no_warnings':True,'extractor_args':{'youtube':{'player_client':['web']}}} - for url in urls: - with yt_dlp.YoutubeDL(opts) as ydl:info=ydl.extract_info(url,download=False) - for e in (info or {}).get('entries') or []: - vid=e.get('id') or '' - if not re.match(r'^[A-Za-z0-9_-]{11}$',vid) or vid in seen:continue - title=e.get('title') or 'YouTube Short' - if url.endswith('/videos') and '#short' not in title.lower() and 'shorts' not in title.lower():continue - seen.add(vid);out.append({'title':title,'link':'https://www.youtube.com/watch?v='+vid,'img':'https://i.ytimg.com/vi/'+vid+'/hqdefault.jpg','source':'yt','id':vid,'channel':handle}) - if len(out)>=count:break - if len(out)>=count:break - return out - except Exception:return [] -def _yt_html(handle,count=30): - out=[];seen=set() - for suffix in ['shorts','videos']: + d = f5.base.scrape_any_url(url) + title = d.get('title', '') + raw = (d.get('summary', '') + '\n' + d.get('text', '')).strip() + img = d.get('image') or '' + except: + title = ''; raw = ctx[:14000]; img = '' + if len(raw) < 50: return JSONResponse({'error': 'Không đọc được bài'}, status_code=422) + text = None + try: + text = await asyncio.wait_for(f5.base.qwen_generate( + f'Tóm tắt đăng Tường AI:\nTiêu đề: {title}\n{raw[:14000]}\n\n4-6 ý chính. Cuối ghi nguồn.', + image_url=img or None, max_tokens=1000), timeout=30) + except: pass + if not text or len(text) < 80: + text = f"Tóm tắt: {title}\n\n{raw[:1200]}\n\nNguồn: {_domain(url)}" + post = f5.base.make_post(title or 'Bài viết', text, img, url, 'rewrite', + sources=[{'title': title, 'url': url, 'via': _domain(url)}]) + ps = f5.base._load_ai_wall(); ps.insert(0, post); f5.base._save_ai_wall(ps) + return JSONResponse({'post': post}) + +@app.post('/api/topic/rewrite') +async def _tr(request: Request): + b = await request.json() + pid = str(b.get('post_id', '')).strip() + if not pid: return JSONResponse({'error': 'missing post_id'}, status_code=400) + ps = f5.base._load_ai_wall() + p = next((x for x in ps if str(x.get('id')) == pid), None) + if not p: return JSONResponse({'error': 'Bài không tồn tại'}, status_code=404) + urls = list(dict.fromkeys( + [s['url'] for s in (p.get('source_details') or []) if s.get('url')] + + [s['url'] for s in (p.get('sources') or []) if s.get('url')] + ))[:5] + parts = [] + for u in urls: try: - r=requests.get(f'https://www.youtube.com/@{handle}/{suffix}',headers=UA,timeout=15);html=r.text - for m in re.finditer(r'"videoId":"([A-Za-z0-9_-]{11})"',html): - vid=m.group(1) - if vid in seen:continue - snip=html[max(0,m.start()-1200):m.start()+2200];title='YouTube Short' - mt=re.search(r'"title":\{"runs":\[\{"text":"([^"]+)"',snip) or re.search(r'"accessibilityText":"([^"]+)"',snip) - if mt:title=clean(mt.group(1).replace('\\n',' ')) - if suffix=='videos' and '#short' not in title.lower() and 'shorts' not in title.lower():continue - seen.add(vid);out.append({'title':title,'link':'https://www.youtube.com/watch?v='+vid,'img':'https://i.ytimg.com/vi/'+vid+'/hqdefault.jpg','source':'yt','id':vid,'channel':handle}) - if len(out)>=count:break - except Exception:pass - if len(out)>=count:break - return out[:count] -def _fallback_shorts(): - try:return f5._fallback_shorts() - except Exception:return [] -@app.get('/api/shorts') -def api_shorts_final6(refresh:int=Query(default=0)): - now=time.time() - if not refresh and _SHORTS_CACHE_FINAL6['d'] and now-_SHORTS_CACHE_FINAL6['t']<600:return JSONResponse(_SHORTS_CACHE_FINAL6['d']) - raw=[] - for h in YOUTUBE_HANDLES:raw.extend(_yt_ytdlp(h,30) or _yt_html(h,30)) - raw.extend(_fallback_shorts()) - seen=set();out=[] - for v in raw: - vid=v.get('id') or '' - if not vid: - m=re.search(r'(?:v=|shorts/|youtu\.be/)([A-Za-z0-9_-]{11})',v.get('link',''));vid=m.group(1) if m else '' - title=_translate_title_vi(v.get('title') or 'YouTube Short');key=vid or re.sub(r'\W+','',title.lower())[:80] - if not key or key in seen:continue - seen.add(key);item=dict(v);item['id']=vid;item['title']=title - if vid:item['link']='https://www.youtube.com/watch?v='+vid;item['img']='https://i.ytimg.com/vi/'+vid+'/hqdefault.jpg' - item['source']='yt';out.append(item) - if len(out)>=40:break - _SHORTS_CACHE_FINAL6.update({'t':now,'d':out}) - return JSONResponse(out) + d = f5.base.scrape_any_url(u) + t = d.get('title', '') + r = (d.get('summary', '') + '\n' + d.get('text', '')).strip() + if r and len(r) > 150: + parts.append(f"[{_domain(u)}] {t}\n{r}") + except: pass + ac = '\n---\n'.join(parts) if parts else (p.get('text') or '') + title = p.get('title', '') + text = None + try: + text = await asyncio.wait_for(f5.base.qwen_generate( + f'Viết lại:\nChủ đề: {title}\n{ac[:16000]}\n\nTiêu đề mới + 4-6 ý + nguồn.', + image_url=p.get('img'), max_tokens=1200), timeout=35) + except: pass + if not text or len(text) < 100: + text = f"Tóm tắt: {title}\n\n{ac[:1500]}\n\nNguồn: VNEWS AI" + np = f5.base.make_post('Rewrite: ' + title, text, p.get('img', ''), '', 'rewrite_topic', sources=p.get('sources', [])) + np['images'] = p.get('images', []) + all_p = f5.base._load_ai_wall(); all_p.insert(0, np); f5.base._save_ai_wall(all_p) + return JSONResponse({'post': np}) -FINAL6_INJECT=r''' +@app.post('/api/topic_post') +async def _tp(request: Request): + b = await request.json() + topic = clean(b.get('topic', '')) + if not topic: return JSONResponse({'error': 'missing topic'}, status_code=400) + img = _topic_image(topic) + research = _fast_context(topic) + ctx = research.get('context', '') + src = research.get('sources', []) + det = _extract_source_details_from_context(ctx, src) + if not ctx or not src: + return JSONResponse({'error': 'Không tìm được nội dung.'}, status_code=422) + sb = '\n\n'.join([f"[{i+1}] {d.get('title', '')} ({d.get('via', '')})\n{d.get('content', '')[:1400]}" for i, d in enumerate(det)]) if det else ctx[:18000] + text = None + try: + text = await asyncio.wait_for(f5.base.qwen_generate( + f'Viết bài tiếng Việt VỀ: "{topic}"\nNGUỒN:\n{sb[:18000]}\nCHỈ viết về "{topic}". 5-8 đoạn. Cuối có nguồn.', + image_url=img, max_tokens=1700), timeout=35) + except: pass + if not text or len(text) < 300: + text = f"{topic}: tổng hợp\n\n" + '\n'.join([f"• {d['title']}: {d.get('content', '')[:300]}" for d in (det or [])[:6]]) + "\n\nNguồn: " + ', '.join(sorted({d.get('via', '') for d in (det or []) if d.get('via')})) + post = f5.base.make_post(topic, text, img, '', 'topic_focused', sources=[s for s in src if s.get('url')]) + post['images'] = [img]; post['source_details'] = det + ps = f5.base._load_ai_wall(); ps.insert(0, post); f5.base._save_ai_wall(ps) + return JSONResponse({'post': post}) + +FINAL6_INJECT = r''' ''' -@app.get('/') -async def index_final6(): - 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 - return HTMLResponse(html.replace('',body+'\n') if '' in html else html+body) - - -# ===== FINAL6B: Vietnam hot hashtags + reliable VN RSS/source retrieval ===== -VN_RSS_FEEDS = [ - ('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'), - ('VnExpress Giải trí','https://vnexpress.net/rss/giai-tri.rss'), - ('VnExpress Sức khỏe','https://vnexpress.net/rss/suc-khoe.rss'), - ('VnExpress Giáo dục','https://vnexpress.net/rss/giao-duc.rss'), - ('Dân trí Xã hội','https://dantri.com.vn/rss/xa-hoi.rss'), - ('Dân trí Thế giới','https://dantri.com.vn/rss/the-gioi.rss'), - ('Dân trí Kinh doanh','https://dantri.com.vn/rss/kinh-doanh.rss'), - ('Dân trí Sức khỏe','https://dantri.com.vn/rss/suc-khoe.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'), - ('Vietnamnet Thời sự','https://vietnamnet.vn/thoi-su.rss'), - ('Vietnamnet Kinh doanh','https://vietnamnet.vn/kinh-doanh.rss'), - ('Vietnamnet Công nghệ','https://vietnamnet.vn/cong-nghe.rss'), - ('Vietnamnet Thể thao','https://vietnamnet.vn/the-thao.rss'), -] - -def _fetch_rss_items(feed_name, feed_url, max_items=15): - items=[] - try: - r=requests.get(feed_url,headers=UA,timeout=10);r.encoding='utf-8' - 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=it.find('description').get_text(' ',strip=True) if it.find('description') else '' - desc_txt=clean(BeautifulSoup(desc,'lxml').get_text(' ',strip=True)) - if title and link: - items.append({'title':title,'url':link,'source':feed_name,'snippet':desc_txt}) - except Exception:pass - return items - -def _vn_rss_pool(): - now=time.time();key='vn_rss_pool' - if key in _TOPIC_CACHE and now-_TOPIC_CACHE[key]['t']<600:return _TOPIC_CACHE[key]['d'] - pool=[];seen=set() - for name,url in VN_RSS_FEEDS: - for it in _fetch_rss_items(name,url,12): - if it['url'] not in seen: - seen.add(it['url']);pool.append(it) - _TOPIC_CACHE[key]={'t':now,'d':pool} - return pool - -def _topic_tokens(topic): - toks=[w.lower() for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',topic or '') if len(w)>1] - return [t for t in toks if t not in STOP_WORDS] - -def _score_topic_item(topic,item): - toks=_topic_tokens(topic) - hay=(item.get('title','')+' '+item.get('snippet','')+' '+item.get('source','')).lower() - if not toks:return 0 - score=0 - for t in toks: - if t in hay:score+=2 if len(t)>3 else 1 - phrase=topic.lower().strip() - if phrase and phrase in hay:score+=8 - return score - -# Override: hashtags must be Việt Nam-focused, using VN news RSS directly. -def _hot_topics(): - now=time.time() - if _HOT_CACHE['d'] and now-_HOT_CACHE['t']<600:return _HOT_CACHE['d'] - pool=_vn_rss_pool() - freq={};display={} - for it in pool[:180]: - title=re.sub(r'\s+-\s+.*$','',it.get('title','')) - # Extract compact Vietnamese hot phrases from current VN headlines. - kws=[] - # quoted/name phrases first - 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]: - kw=clean(kw) - words=[w for w in 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 - ranked=sorted(freq.items(),key=lambda x:x[1],reverse=True) - topics=[];seen=set() - for key,_ in ranked: - kw=display[key] - if key in seen:continue - seen.add(key) - label='#'+re.sub(r'\s+','',kw.title()) - topics.append({'label':label,'topic':kw}) - if len(topics)>=24:break - # VN fallback, not generic global. - for kw in ['Giá vàng trong nước','Bão và mưa lũ','Bóng đá Việt Nam','Kinh tế Việt Nam','AI tại Việt Nam','Giá xăng dầu','Thị trường chứng khoán Việt Nam','Tuyển Việt Nam','Sức khỏe cộng đồng','An ninh mạng Việt Nam']: - 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=[] - # 1) VN RSS pool relevance is most reliable and has direct URLs. - scored=[] - for it in _vn_rss_pool(): - sc=_score_topic_item(topic,it) - if sc>0:scored.append((sc,it)) - for sc,it in sorted(scored,key=lambda x:x[0],reverse=True)[:12]: - if it['url'] not in seen: - seen.add(it['url']);items.append(it) - # 2) Search trusted web if RSS not enough. - queries=[topic+' Việt Nam tin tức',topic+' phân tích Việt Nam',topic+' mới nhất'] - for q in queries: - for it in _ddg_search(q,8): - if it['url'] not in seen: - seen.add(it['url']);items.append(it) - if len(items)>=14:break - # 3) Google News as supplemental titles/direct links. - for it in _google_news_items(topic,10): - if it['url'] not in seen: - seen.add(it['url']);items.append(it) - return items[:24] - -def _web_research_context(topic): - now=time.time();key='ctx2:'+topic.lower().strip() - if key in _TOPIC_CACHE and now-_TOPIC_CACHE[key]['t']<900:return _TOPIC_CACHE[key]['d'] - items=_candidate_urls(topic) - crawled=[] - for it in items: - text=_scrape_article_text(it['url'],9000) - rel=_score_relevance(topic,it.get('title',''),text,it.get('snippet','')) or _score_topic_item(topic,it) - # If RSS item has good snippet, keep it even when full text blocks. - if text and len(text)>300 and rel>0: - crawled.append({**it,'text':text,'rel':rel}) - elif it.get('snippet') and len(it['snippet'])>120 and rel>0: - crawled.append({**it,'text':it['snippet'],'rel':rel,'snippet_only':True}) - crawled=sorted(crawled,key=lambda x:(x.get('rel',0),len(x.get('text',''))),reverse=True)[:7] - blocks=[];sources=[] - for it in crawled: - label='ĐOẠN MÔ TẢ TỪ RSS/TÌM KIẾM' if it.get('snippet_only') else 'NỘI DUNG BÀI VIẾT ĐÃ CRAWL' - blocks.append(f"NGUỒN: {it['source']}\nTIÊU ĐỀ: {it['title']}\n{label}:\n{it['text'][:8500]}") - sources.append({'title':it['title'],'url':it['url'],'via':it['source']}) - data={'context':'\n\n---\n\n'.join(blocks),'sources':sources[:8],'count':len(blocks)} - _TOPIC_CACHE[key]={'t':now,'d':data} - return data - - -# ===== FINAL6C: FAST topic generation (RSS cache first, no slow full-page crawling) ===== -import asyncio -_FAST_TOPIC_CACHE={} -FAST_RSS_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'), - ('Vietnamnet','https://vietnamnet.vn/rss/tin-moi-nhat.rss'), - ('Vietnamnet Thời sự','https://vietnamnet.vn/thoi-su.rss'), - ('Vietnamnet Kinh doanh','https://vietnamnet.vn/kinh-doanh.rss'), - ('Vietnamnet Công nghệ','https://vietnamnet.vn/cong-nghe.rss'), - ('Vietnamnet Thể thao','https://vietnamnet.vn/the-thao.rss'), -] - -def _fast_fetch_rss(feed_name, feed_url, max_items=20): - items=[] - try: - r=requests.get(feed_url,headers=UA,timeout=6);r.encoding='utf-8' - 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 '' - desc=clean(BeautifulSoup(desc_raw,'lxml').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 _fast_rss_pool(): - now=time.time();key='fast_rss_pool' - if key in _FAST_TOPIC_CACHE and now-_FAST_TOPIC_CACHE[key]['t']<600:return _FAST_TOPIC_CACHE[key]['d'] - pool=[];seen=set() - # Sequential with short timeouts is predictable; RSS is small. - for name,url in FAST_RSS_FEEDS: - for it in _fast_fetch_rss(name,url,16): - if it['url'] not in seen: - seen.add(it['url']);pool.append(it) - _FAST_TOPIC_CACHE[key]={'t':now,'d':pool} - return pool - -def _fast_topic_tokens(topic): - toks=[w.lower() for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',topic or '') if len(w)>1] - return [t for t in toks if t not in STOP_WORDS] - -def _fast_score(topic,item): - toks=_fast_topic_tokens(topic) - hay=(item.get('title','')+' '+item.get('snippet','')+' '+item.get('source','')).lower() - if not toks:return 0 - score=0 - for t in toks: - if t in hay:score+=3 if len(t)>3 else 1 - phrase=topic.lower().strip() - if phrase and phrase in hay:score+=12 - return score - -def _fast_sources(topic, limit=8): - pool=_fast_rss_pool() - scored=[] - for it in pool: - sc=_fast_score(topic,it) - if sc>0:scored.append((sc,it)) - scored=sorted(scored,key=lambda x:(x[0],len(x[1].get('snippet',''))),reverse=True) - out=[];seen=set() - for sc,it in scored: - if it['url'] in seen:continue - seen.add(it['url']);out.append({**it,'score':sc}) - if len(out)>=limit:break - # If topic too narrow and no match, use top latest from VN RSS as weak context instead of slow crawling. - if not out: - out=pool[:min(limit,8)] - return out - -def _fast_context(topic): - now=time.time();key='fast_ctx:'+topic.lower().strip() - if key in _FAST_TOPIC_CACHE and now-_FAST_TOPIC_CACHE[key]['t']<600:return _FAST_TOPIC_CACHE[key]['d'] - sources=_fast_sources(topic,8) - blocks=[];src=[] - for it in sources: - text=(it.get('snippet') or '').strip() - # Use title + RSS description only: fast and reliable. - blocks.append(f"NGUỒN: {it.get('source','')}\nTIÊU ĐỀ: {it.get('title','')}\nTÓM TẮT RSS:\n{text}") - src.append({'title':it.get('title',''),'url':it.get('url',''),'via':it.get('source','')}) - data={'context':'\n\n---\n\n'.join(blocks),'sources':src,'count':len(blocks)} - _FAST_TOPIC_CACHE[key]={'t':now,'d':data} - return data - -def _fallback_fast_article(topic, sources): - lines=[] - for s in sources[:7]: - title=s.get('title','') - if title:lines.append(title) - body='\n'.join('• '+x for x in lines[:7]) - vias=', '.join(sorted({s.get('via','') for s in sources if s.get('via')})) - return (f"{topic}: những điểm đáng chú ý\n\n" - f"{topic} đang là chủ đề được quan tâm trong dòng tin tức hiện nay. Dựa trên các nguồn tin mới nhất, có thể tổng hợp nhanh một số điểm nổi bật để người đọc nắm bối cảnh và theo dõi tiếp diễn biến.\n\n" - f"Các nguồn tin liên quan cho thấy chủ đề này gắn với những diễn biến sau:\n{body}\n\n" - f"Nhìn chung, đây là vấn đề cần được theo dõi theo nhiều góc độ: bối cảnh, tác động thực tế, phản ứng của các bên liên quan và những thông tin cập nhật tiếp theo. Người đọc nên đối chiếu thêm các nguồn chính thống khi cần quyết định hoặc đánh giá chi tiết.\n\n" - f"Nguồn tham khảo: {vias}") - -# Remove previous slow topic routes and register fast versions last. -app.router.routes=[r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in {('/api/topic_post','POST'),('/api/topic_sources','GET')})] - -@app.get('/api/topic_sources') -def api_topic_sources_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':'fast_rss'}) - -@app.post('/api/topic_post') -async def topic_post_fast(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);context=research.get('context','');sources=research.get('sources',[]) - prompt=f"""Bạn là biên tập viên VNEWS. Hãy viết MỘT BÀI VIẾT HOÀN CHỈNH bằng tiếng Việt về chủ đề: {topic} - -Dữ liệu nhanh từ RSS nguồn Việt Nam: -{context[:12000]} - -Yêu cầu: -- Không liệt kê tiêu đề nguồn thành bài viết. -- Tổng hợp thành bài báo/tạp chí hoàn chỉnh. -- Có tiêu đề mới, sapo 2-3 câu, 4-6 đoạn phân tích/bối cảnh/tác động. -- Diễn đạt lại, không sao chép nguyên văn. -- Nếu dữ liệu ít, viết thận trọng và nêu các điểm cần theo dõi. -- Cuối bài có mục Nguồn tham khảo. -""" - text=None - try: - text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1300),timeout=28) - except Exception: - text=None - if not text or len(text)<350: - text=_fallback_fast_article(topic,sources) - post=f5.base.make_post(topic,text,img,'','topic_fast_rss',sources=[s for s in sources if s.get('url')]) - post['images']=[img] - posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts) - return JSONResponse({'post':post,'mode':'fast_rss','sources_count':len(sources)}) - - -# ===== FINAL6D: FAST HOME LOAD ===== -_FAST_HOME_CACHE={"t":0,"d":[]} -_FAST_DT_CACHE={"t":0,"d":[]} -_FAST_VNEGO_CACHE={"t":0,"d":[]} -_FAST_HL_CACHE={"t":0,"d":[]} - -def _rss_articles_fast(feed_url, group, source='vne', limit=6): - out=[] - try: - r=requests.get(feed_url,headers=UA,timeout=4);r.encoding='utf-8' - soup=BeautifulSoup(r.text,'xml') - for it in soup.find_all('item')[:limit*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') - im=ds.find('img'); img=im.get('src','') if im else '' - desc=clean(ds.get_text(' ',strip=True))[:160] - if title and link: - out.append({'title':title,'link':link,'img':img,'summary':desc,'source':source,'group':group}) - if len(out)>=limit:break - except Exception:pass - return out - -def _fast_homepage(): - now=time.time() - if _FAST_HOME_CACHE['d'] and now-_FAST_HOME_CACHE['t']<600:return _FAST_HOME_CACHE['d'] - feeds=[('Thời Sự','https://vnexpress.net/rss/thoi-su.rss'),('Thế Giới','https://vnexpress.net/rss/the-gioi.rss'),('Kinh Doanh','https://vnexpress.net/rss/kinh-doanh.rss'),('Công Nghệ','https://vnexpress.net/rss/so-hoa.rss'),('Thể Thao','https://vnexpress.net/rss/the-thao.rss'),('Giải Trí','https://vnexpress.net/rss/giai-tri.rss'),('Sức Khỏe','https://vnexpress.net/rss/suc-khoe.rss'),('Giáo Dục','https://vnexpress.net/rss/giao-duc.rss'),('Pháp Luật','https://vnexpress.net/rss/phap-luat.rss'),('Du Lịch','https://vnexpress.net/rss/du-lich.rss')] - arts=[] - try: - from concurrent.futures import ThreadPoolExecutor, as_completed - with ThreadPoolExecutor(max_workers=6) as ex: - futs=[ex.submit(_rss_articles_fast,u,g,'vne',6) for g,u in feeds] - for f in as_completed(futs,timeout=7): - try:arts.extend(f.result() or []) - except Exception:pass - except Exception: - for g,u in feeds[:5]:arts.extend(_rss_articles_fast(u,g,'vne',4)) - if arts:_FAST_HOME_CACHE.update({'t':now,'d':arts}) - return _FAST_HOME_CACHE['d'] or arts - -def _fast_dantri_hot(): - now=time.time() - if _FAST_DT_CACHE['d'] and now-_FAST_DT_CACHE['t']<900:return _FAST_DT_CACHE['d'] - data=_rss_articles_fast('https://dantri.com.vn/rss/home.rss','Tin Nổi Bật','dantri',12) - if data:_FAST_DT_CACHE.update({'t':now,'d':data}) - return data - -def _fast_vnego(): - now=time.time() - if _FAST_VNEGO_CACHE['d'] and now-_FAST_VNEGO_CACHE['t']<900:return _FAST_VNEGO_CACHE['d'] - out=[] - try: - r=requests.get('https://vnexpress.net/vne-go',headers=UA,timeout=4);r.encoding='utf-8' - soup=BeautifulSoup(r.text,'lxml');seen=set() - for a in soup.find_all('a',href=True): - href=a.get('href','');title=clean(a.get('title','') or a.get_text(' ',strip=True)) - if not title or len(title)<8 or not href.startswith('http') or href in seen:continue - if '/vne-go' not in href and '/video/' not in href:continue - seen.add(href);img='';im=a.find('img') or (a.parent.find('img') if a.parent else None) - if im:img=im.get('data-src') or im.get('src','') - out.append({'title':title,'link':href,'img':img,'source':'vne-video'}) - if len(out)>=10:break - except Exception:pass - _FAST_VNEGO_CACHE.update({'t':now,'d':out}) - return out - -def _fast_highlights(): - now=time.time() - if _FAST_HL_CACHE['d'] and now-_FAST_HL_CACHE['t']<900:return _FAST_HL_CACHE['d'] - _FAST_HL_CACHE.update({'t':now,'d':[]}) - return [] - -for _p in ['/api/homepage','/api/dantri_hot','/api/vne_video','/api/highlights']: - 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_fast():return JSONResponse(_fast_homepage()) -@app.get('/api/dantri_hot') -def api_dantri_hot_fast():return JSONResponse(_fast_dantri_hot()) -@app.get('/api/vne_video') -def api_vne_video_fast():return JSONResponse(_fast_vnego()) -@app.get('/api/highlights') -def api_highlights_fast():return JSONResponse(_fast_highlights()) - -FINAL6_FAST_HOME_INJECT = """ +FINAL6_FAST_HOME_INJECT = r''' + -""" -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_fast_home(): - 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+FINAL6_FAST_HOME_INJECT - return HTMLResponse(html.replace('',body+'\n') if '' in html else html+body) - - -# ===== FINAL6E: SHOW SOURCE CONTENTS IN TOPIC ARTICLE ===== -def _extract_source_details_from_context(context, sources): - details=[] - # Map source urls by title for URL/via enrichment - src_by_title={clean(s.get('title','')):s for s in (sources or [])} - for block in (context or '').split('---'): - block=block.strip() - if not block:continue - via='';title='';content='' - m=re.search(r'NGUỒN:\s*(.*)',block) - if m:via=clean(m.group(1)) - m=re.search(r'TIÊU ĐỀ:\s*(.*)',block) - if m:title=clean(m.group(1)) - if 'NỘI DUNG BÀI VIẾT ĐÃ CRAWL:' in block: - content=block.split('NỘI DUNG BÀI VIẾT ĐÃ CRAWL:',1)[1] - elif 'TÓM TẮT RSS:' in block: - content=block.split('TÓM TẮT RSS:',1)[1] - elif 'ĐOẠN MÔ TẢ' in block: - content=re.split(r'ĐOẠN MÔ TẢ[^:]*:',block,1)[-1] - content=clean(content) - if not title and not content:continue - s=src_by_title.get(title,{}) - details.append({'title':title or s.get('title','Nguồn tham khảo'),'url':s.get('url',''),'via':via or s.get('via',''),'content':content[:1800]}) - if len(details)>=8:break - return details - -# Remove prior topic endpoint and register one that stores source_details in post. -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_with_source_contents(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 not context or 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)]) - prompt=f"""Bạn là biên tập viên VNEWS. Hãy viết MỘT BÀI VIẾT HOÀN CHỈNH bằng tiếng Việt về chủ đề: {topic} - -Dưới đây là nội dung từng nguồn đã thu thập. Hãy tổng hợp ý chính, không sao chép nguyên văn, không biến các tiêu đề thành danh sách. - -NỘI DUNG NGUỒN: -{source_brief[:18000]} - -Yêu cầu: -- Tiêu đề mới, rõ, hấp dẫn. -- Sapo 2-3 câu. -- 5-8 đoạn phân tích/bối cảnh/tác động/điểm cần lưu ý. -- Không dùng câu "Dưới đây là" hoặc "Tôi sẽ". -- Cuối bài có mục "Nguồn tham khảo" nêu tên nguồn. -""" - 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['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ưới đây là phần tổng hợp nhanh từ những nội dung đã thu thập được.\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}") - post=f5.base.make_post(topic,text,img,'','topic_fast_rss_with_sources',sources=[s for s in sources if s.get('url')]) - post['images']=[img] - post['source_details']=details - posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts) - return JSONResponse({'post':post,'mode':'fast_rss_with_source_details','sources_count':len(details)}) +''' -FINAL6E_INJECT = """ +FINAL6E_INJECT = r''' ''' + +@app.get('/') +async def _index(): + html = f5.f4.f3.f2.f1._load_index_html() + body = '' + body += getattr(rt.old, 'PATCH_INJECT', '') + body += f5.f4.f3.f2.f1.FINAL_INJECT + f5.f4.f3.FINAL3_INJECT + f5.f4.FINAL4_INJECT + f5.FINAL5_INJECT + body += FINAL6_INJECT + FINAL6_FAST_HOME_INJECT + FINAL6E_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 755a0884b45cc0686f12680b8cd018047083f14e..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 @@ -56,15 +56,7 @@ def _sh(refresh:int=Query(default=0)): if _bg_shorts['d'] and (not refresh or n-_bg_shorts['t']<120):(threading.Thread(target=_bg,daemon=True).start() if n-_bg_shorts['t']>600 else None);return JSONResponse(_bg_shorts['d']) return f6.api_shorts_final6(refresh=refresh) if hasattr(f6,'api_shorts_final6') else JSONResponse([]) @app.get('/api/ai_wall') -def _w(): - n=int(time.time()) - try: - from ai_ext import wall_posts_for_view, _wall_created_ts - posts=wall_posts_for_view(limit=200) - posts=[p for p in posts if n-_wall_created_ts(p) .short-cmt-panel{position:fixed;bottom:0;left:0;right:0;max-height:55vh;background:#181818;border-radius:16px 16px 0 0;z-index:99999;padding:14px;display:none;overflow-y:auto}.short-cmt-panel.active{display:block}.short-cmt-panel textarea{width:100%;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px;margin:6px 0;min-height:60px}.short-cmt-panel button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin:4px}.cmt-item{background:#222;border-radius:8px;padding:7px;margin:5px 0;color:#ccc;font-size:12px} @@ -202,18 +131,6 @@ button[onclick*="rewriteCurrentArticle"]{display:none!important} /* Hide ALL old Short AI slides from previous layers */ #ai-short-home,.ai-short-home,.ai-short-card-final{display:none!important} .source-detail-box a[target="_blank"]{display:none!important} -/* M3U IPTV grid */ -.m3u-grid{padding:12px} -.m3u-group-label{color:#5cb87a;font-size:14px;font-weight:700;margin:10px 0 6px 4px} -.m3u-group-track{display:grid;grid-template-columns:repeat(2,1fr);gap:8px} -.m3u-channel-card{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:10px;padding:8px;cursor:pointer;transition:0.2s;position:relative} -.m3u-channel-card:hover{border-color:#5cb87a;transform:translateY(-1px)} -.m3u-channel-thumb{width:100%;height:60px;background:#222;border-radius:6px;overflow:hidden;display:flex;align-items:center;justify-content:center} -.m3u-channel-thumb img{width:100%;height:100%;object-fit-cover} -.m3u-no-logo{font-size:24px;color:#5cb87a} -.m3u-channel-title{font-size:11px;color:#ccc;margin-top:4px;line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} -.badge-live{background:#ff4444 !important;color:#fff !important;animation:pulse 1.5s infinite} -@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.6}}
''' 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._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 84dd958b19117b3cf33338d924989af64d617acb..9848e465c16db2ea564590c8d81b30dee3dfb89f 100644 --- a/app_v2_entry.py +++ b/app_v2_entry.py @@ -1,4 +1,4 @@ -"""VNEWS v2 Entry Point - with fast bongda proxy + rewrite endpoints + multilingual TTS""" +"""VNEWS v2 Entry Point - with fast bongda proxy""" import sys, os from main import app, HEADERS, BONGDA_HEADERS, fetch_bongda_api, HL_LEAGUES @@ -7,23 +7,7 @@ try: 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}") - -try: - import pid_gen - pid_gen.patch_app(app) -except Exception as e: - print(f"[WARN] pid_gen import failed: {e}") - -from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response, StreamingResponse +from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response from fastapi.staticfiles import StaticFiles from starlette.routing import Mount from fastapi import Query, Request, UploadFile, File, Form @@ -31,36 +15,17 @@ import requests as req from bs4 import BeautifulSoup import re, html as html_lib, json, threading, time, uuid from concurrent.futures import ThreadPoolExecutor, as_completed -from urllib.parse import quote, urljoin -import asyncio +from urllib.parse import quote 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 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()))] app.routes[:]=[r for r in app.routes if not isinstance(r, Mount)] 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 = {} @@ -237,7 +202,7 @@ _STOP=set('và của các những một được trong với cho tại sau trư 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) @@ -357,12 +322,15 @@ def _search_all(topic,limit=36): if i= 2: return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img, 'body': body[:50], 'source': domain, 'url': url} + + # No body found — use OG meta as fallback if title and (summary or og_img): fallback = [] if og_img: fallback.append({'type': 'img', 'src': og_img}) @@ -452,73 +432,98 @@ def _scrape_article_fast(url): if fallback: return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img, 'body': fallback, 'source': domain, 'url': url, 'fallback': True} + + # Got HTML but no body and no OG — return title at least 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 + + break # Got 200 but no content at all — don't retry other UA except Exception: continue + return None @app.get('/api/article') def api_article_v2(url: str = Query(...)): + """Scrape article and return JSON for VNEWS SPA. Fast, cached, with fallback.""" from urllib.parse import unquote safe_url = unquote(url) + try: + # Check cache first 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 + + # Fetch fresh — use fast scraper for ALL sites (simpler, more reliable) 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 + + # Last resort: try RSS fallback + try: + from main import _fetch_rss_fallback + from urllib.parse import urlparse as _up + rss_data = _fetch_rss_fallback(safe_url, _up(safe_url).netloc) + if rss_data and rss_data.get('title'): + body = [] + if rss_data.get('og_image'): + body.append({'type': 'img', 'src': rss_data['og_image']}) + if rss_data.get('summary'): + sentences = re.split(r'(?<=[.!?])\s+', rss_data['summary']) + for s in sentences[:10]: + if len(s.strip()) > 20: + body.append({'type': 'p', 'text': s.strip()}) + if body: + result = { + 'title': rss_data['title'], 'summary': rss_data['summary'][:500], + 'og_image': rss_data.get('og_image', ''), 'body': body[:50], + 'source': 'rss', 'url': safe_url, 'fallback': True, 'rss': True + } + _article_cache[safe_url] = {'d': result, 't': now} + resp = JSONResponse(result) + resp.headers["Cache-Control"] = "public, max-age=600" + return resp + except Exception: + pass + 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) + import traceback + tb = traceback.format_exc() + return JSONResponse({'error': f'Server error: {str(e)[:100]}', 'trace': tb[-500:], 'url': safe_url}, status_code=200) _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) @@ -545,228 +550,15 @@ 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', '') - og_img, _desc = _m3u_og_parts(safe_img, safe_title) - # 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" if safe_img else _M3U_SHARE_DESC - - # 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 - _fimg, _fdesc = _m3u_og_parts(safe_img, safe_title) - 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 - _fimg, _fdesc = _m3u_og_parts(safe_img, safe_title) - 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 +async def _sh(url:str='',title:str='',img:str=''):return HTMLResponse(f'') + +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) +# === XEMLAIBONGDA PROXY (CORS workaround for WC highlights) === _xlb_cache = {} _xlb_lock = threading.Lock() @@ -904,324 +696,54 @@ async def _pc(request:Request): 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}) +# ===== WALL / SHORT AI ENDPOINTS ===== + def _load_wall_posts(): + """Load wall posts from JSON file.""" with _wl_lock: - posts = _lj(WALL_FILE) - if not isinstance(posts, list): - posts = [] - return posts + return _lj(WALL_FILE) def _save_wall_posts(posts): + """Save wall posts to JSON file.""" 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 _post_has_content(p): - """True when the post has meaningful content: non-empty text (>= 20 chars) - OR at least one slide with non-empty text OR a video. Rewrite posts that - failed to generate content (empty text+slides) get dropped from the feed.""" - if not isinstance(p, dict): - return False - text = (p.get("text") or "").strip() - if len(text) >= 20: - return True - slides = p.get("slides") or [] - if len(slides) >= 1: - for s in slides: - if isinstance(s, dict) and len((s.get("text") or "").strip()) >= 8: - return True - if (p.get("video") or "").strip(): - return True - return False - -def _created_ts(p): - """Best-effort creation timestamp NORMALIZED TO SECONDS. `created` is - stored as Unix milliseconds by some paths while `ts` is Unix seconds — - normalize both so newest-first ordering mixes them correctly. Falls back - to id-derived Unix ms (converted to seconds), then 0.""" - for field in ("created", "ts"): - v = p.get(field) - if v is not None: - try: - v = int(v) - except (ValueError, TypeError): - pass - if v > 100000000000: # milliseconds -> seconds - v = v // 1000 - return v - # try to derive from id (numeric prefix = Unix ms timestamp) - pid = str(p.get("id", "")).strip() - if pid.isdigit() and len(pid) >= 12: - try: - v = int(pid) - if v > 100000000000: - v = v // 1000 - return v - except (ValueError, TypeError): - pass - return 0 - -def _wall_posts_for_view(): - """Apply the user-facing wall filtering + sorting (VIEW-ONLY — never - touches the persistent store): - 1) Drop posts with NO meaningful content (empty text AND empty slides - AND no video) — 'bài đăng rewrite fără nội dung'; - 2) 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); - 3) Sort newest-first by creation timestamp, mixing all sources.""" - try: - from ai_ext import wall_posts_for_view as _shared_view - return _shared_view(limit=200) - except Exception: - pass - posts = _load_wall_posts() - if not posts: - return [] - filtered = [] - for p in posts: - if not isinstance(p, dict): - continue - if not _post_has_content(p): - continue - if _is_auto_post(p) and not _post_has_valid_image(p) and not _post_has_slide_info(p): - # auto post with a wrong/placeholder/missing image AND 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() + """Get all wall posts.""" + posts = _load_wall_posts() if not posts: + # Return empty list, not error return JSONResponse({"posts": []}) return JSONResponse({"posts": posts}) @app.post('/api/wall') async def api_wall_post(request: Request): + """ + Create a wall post. Supports: + - JSON body: {title, text, img, source} + - Multipart form: title, text, source + video file upload + """ content_type = request.headers.get('content-type', '') + + # Handle multipart upload (video file) 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 + + # Save video file if provided if video_file and hasattr(video_file, 'filename') and video_file.filename: + # Determine extension fname = video_file.filename.lower() if fname.endswith('.mp4'): ext = '.mp4' @@ -1229,21 +751,31 @@ async def api_wall_post(request: Request): ext = '.webm' else: ext = '.webm' + video_filename = f"wall_{post_id}{ext}" video_path = os.path.join(WALL_VIDEO_DIR, video_filename) + try: + # Read file content content = await video_file.read() if not content: return JSONResponse({"error": "Empty video file"}, status_code=400) + + # Save to disk 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) + + # URL to access the video 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) + + # Create post post = { "id": post_id, "title": title[:200], @@ -1255,87 +787,55 @@ async def api_wall_post(request: Request): "created": int(time.time()), "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()), } + + # Save to wall posts = _load_wall_posts() if not isinstance(posts, list): posts = [] posts.insert(0, post) + # Keep max 200 posts posts = posts[:200] _save_wall_posts(posts) + return JSONResponse({"post": post, "ok": True}) + + # Handle JSON body (text-only post) 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, + "video": None, "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, + "images": [], "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.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): + """Serve a wall video file.""" + # Security: prevent path traversal if '..' in filename or '/' in filename: return Response(status_code=403) video_path = os.path.join(WALL_VIDEO_DIR, filename) @@ -1345,64 +845,16 @@ def api_wall_video(filename: str): 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): + """Delete a wall post and its video.""" 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: + # Delete video file if exists if p.get('video'): video_name = p['video'].split('/')[-1] video_path = os.path.join(WALL_VIDEO_DIR, video_name) @@ -1411,1052 +863,8 @@ def api_wall_delete(post_id: str): 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 ===== + return JSONResponse({"error": "Post not found"}, status_code=404) def _bg(): time.sleep(15) @@ -2466,1155 +874,4 @@ def _bg(): 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 (vmttv list + existing bundled playlist) ===== -_M3U_URL = "https://raw.githubusercontent.com/vuminhthanh12/vuminhthanh12/main/vmttv" -# Secondary source: the existing curated playlist bundled in the repo. We keep -# it as a second source so channels that work on the Space IP are preserved -# while vmttv adds new ones. Both are validated on the Space egress IP, dead -# ones are dropped, duplicates by URL are merged. -_M3U_URL_EXISTING = "https://huggingface.co/spaces/bep40/vnews/resolve/main/static/playlist.m3u" -# Event-only groups: these channels only have a working stream while an event -# / match is live (movie premieres, TV360/VTVPrime event slots, BLV co-commentator -# streams). When no event is running their URLs are dead -> they would be -# validated out anyway. We KEEP them (no longer dropped) so that live football / -# event streams are detected and grouped into the EVENT section at the top of -# the 📺 Trực tiếp tab. Dead event streams are still removed by the validator, -# so a group with no live event simply never appears. -_M3U_EVENT_GROUPS = { - "LIVE EVENTS 🔴", - "Sự Kiện TV360", - "Sự Kiện VTVPrime", - "🔴 ⚽ COLA TV", - "🔴 ⚽ 🏀 COLA TV SV2", - "🔴 ⚽ 🏐 PHÁO HOA TV", -} -_M3U_FOOTBALL_GROUPS = { - "🔴 ⚽ COLA TV", - "🔴 ⚽ 🏀 COLA TV SV2", - "🔴 ⚽ 🏐 PHÁO HOA TV", - "⚽| Thể thao quốc tế", - "THỂ THAO QUỐC TẾ", -} -# Title patterns that identify a live football match / event channel vs a -# regular 24/7 sports channel (e.g. "Bayern vs Dortmund", "01:45 02/09 Liverpool ..."). -# NOTE: bare " - " / " – " separators are NOT used — too many normal channel -# names contain them (e.g. "On Movies - You TV"). Only strong match signals. -_M3U_MATCH_TITLE_RE = re.compile( - r'\bvs\b|\d{1,2}:\d{2}\s*\d{1,2}/\d{1,2}|\bđấu\b|\bgặp\b|trực tiếp', - re.IGNORECASE) -_M3U_FOOTBALL_TITLE_RE = re.compile( - r'\bđấu\b|\bgặp\b|trực tiếp bóng đá|\bvs\b|bóng đá|champions|premier|v-league|vleague|nha\b|liga|bundesliga|serie a|world cup', - re.IGNORECASE) - -_M3U_EVENT_SUFFIXES = ("TV360+", "VTVPrime", "⚽ COLA", "COLA TV", "PHÁO HOA") -# Sports-cluster groups: when international football is LIVE (per bongda -# livescore), ALL channels in these groups are surfaced together with the -# EVENT slide on the 📺 Trực tiếp tab (the user asked: "khi có livescore bóng -# đá Quốc Tế cho tất cả kênh từ thể thao và thể thao quốc tế"). -_M3U_SPORTS_CLUSTER_GROUPS = { - "Thể Thao", - "THỂ THAO QUỐC TẾ", - "⚽| Thể thao quốc tế", -} -# Bongda livescore status labels: only matches that are actually in progress -# count as "live football". KT/FT/PEN etc = finished, VS = not started yet. -_M3U_LS_DONE_LABEL_RE = re.compile(r'^(KT|FT|ET|AET|PEN|VS|Hủy|Hoãn|Reported|Hủy bỏ)$', re.IGNORECASE) -# Vietnamese domestic competitions are NOT "bóng đá quốc tế" — exclude them. -_M3U_LS_VN_DOMESTIC_RE = re.compile(r'v[- ]?league|vleague|hạng nhất|hạng nhì|cúp quốc gia|siêu cúp|siêu cup|hạng ba|hạng tư', re.IGNORECASE) -_m3u_ls_cache = {"t": 0, "d": None} -_M3U_LS_TTL = 60 - -def _m3u_livescore_football_state(force=False): - """Return whether international football is LIVE right now, based on the - bongda.com.vn live fixtures feed. Cached for 60s (matches' status can - change, so a short TTL is important). - -> {"live": bool, "leagues": [..], "matches_count": int, "live_matches": [{..}]} - """ - now = time.time() - if not force and _m3u_ls_cache["d"] is not None and now - _m3u_ls_cache["t"] < _M3U_LS_TTL: - return _m3u_ls_cache["d"] - state = {"live": False, "leagues": [], "matches_count": 0, "live_matches": []} - try: - html_txt = fetch_bongda_api("/api/fixtures/live") - if html_txt: - soup = BeautifulSoup(html_txt, "lxml") - for li in soup.select("li.match-detail"): - status = li.select_one(".status") - if not status: - continue - label_el = status.select_one(".label") - label = label_el.get_text(" ", strip=True) if label_el else "" - if not label or _M3U_LS_DONE_LABEL_RE.match(label): - continue # KT / VS / FT -> finished or not started - league_el = li.select_one(".league") - league = league_el.get_text(" ", strip=True) if league_el else "" - if _M3U_LS_VN_DOMESTIC_RE.search(league): - continue # V-League etc is domestic, not "quốc tế" - home_el = li.select_one(".home-team .name") - away_el = li.select_one(".away-team .name") - state["live"] = True - state["matches_count"] += 1 - state["live_matches"].append({ - "home": home_el.get_text(" ", strip=True) if home_el else "", - "away": away_el.get_text(" ", strip=True) if away_el else "", - "league": league, - "label": label, - }) - if league and league not in state["leagues"]: - state["leagues"].append(league) - except Exception: - pass - _m3u_ls_cache["t"] = now - _m3u_ls_cache["d"] = state - return state - -_m3u_cache = {"t": 0, "d": []} -_M3U_CACHE_FILE = os.path.join("/data" if os.path.isdir('/data') else "/app/data", 'm3u_cache.json') - -def _load_m3u_disk(): - """Load M3U cache from disk (if available).""" - try: - if os.path.exists(_M3U_CACHE_FILE): - with open(_M3U_CACHE_FILE, 'r', encoding='utf-8') as f: - d = json.load(f) - if d and isinstance(d.get('channels'), list): - return d - except Exception: - pass - return None - -def _save_m3u_disk(channels, ts): - """Save M3U cache to disk.""" - try: - os.makedirs(os.path.dirname(_M3U_CACHE_FILE), exist_ok=True) - with open(_M3U_CACHE_FILE, 'w', encoding='utf-8') as f: - json.dump({"channels": channels, "t": ts}, f, ensure_ascii=False) - except Exception: - pass - -def _check_m3u_stream(c, timeout=9): - """Probe a single M3U channel URL. Returns True only if it actually - delivers STREAM data — raw MPEG-TS bytes (0x47 sync) OR an HLS playlist - (#EXTM3U). Rejects HTTP error pages: some servers return HTTP 200 for - HTML/JSON error responses, and those used to pass the old >=2-bytes check - (e.g. CANAL+ portals returning , 403 Forbidden pages). - Applies VLC http-user-agent/http-cookie when present.""" - try: - h = dict(HEADERS) - vopts = c.get("vlcopts", {}) or {} - if vopts.get("http-user-agent"): - h["User-Agent"] = vopts["http-user-agent"] - if vopts.get("http-cookie"): - h["Cookie"] = vopts["http-cookie"] - if vopts.get("http-header"): - hdr = vopts.get("http-header") - if ":" in hdr: - kk, vv = hdr.split(":", 1) - h[kk.strip()] = vv.strip() - rr = req.get(c["url"], headers=h, timeout=timeout, stream=True, allow_redirects=True) - if rr.status_code != 200: - try: rr.close() - except Exception: pass - return False - try: - chunk = next(rr.iter_content(4096)) - rr.close() - except Exception: - try: rr.close() - except Exception: pass - return False - if not chunk or len(chunk) < 2: - return False - head = chunk[:512].lstrip() - # Reject HTML/XML/JSON error pages (200-with-error-body servers). - if head[:1] in (b'<', b'{', b'['): - return False - # Accept real stream payloads: MPEG-TS sync bytes or an HLS playlist. - if head.startswith(b'#EXTM3U') or b'#EXT-X-' in head[:256]: - # For a MASTER playlist (#EXT-X-STREAM-INF) the URL alone being - # live is not enough — the variant(s) it points to must also be - # reachable. Follow variant chains recursively (up to 4 levels) - # until we hit a media playlist with #EXTINF or a stream; drop - # chains that 404 / return HTML / loop forever (e.g. gr.seetv.cc - # serves self-referencing nested master playlists whose depth-2 - # variant is 404). - try: - cur = c["url"] - depth = 0 - media_found = False - while depth < 4: - is_master = b'#EXT-X-STREAM-INF' in chunk - if not is_master: - media_found = True - break - body = '' - try: - fr = req.get(cur, headers=h, timeout=timeout) - if fr.status_code == 200: - body = fr.text - except Exception: - pass - variant = None - lines = body.split('\n') - for i, ln in enumerate(lines): - if ln.startswith('#EXT-X-STREAM-INF') and i + 1 < len(lines): - cand = lines[i + 1].strip() - if cand and not cand.startswith('#'): - variant = cand if cand.startswith('http') else urljoin(cur, cand) - break - if not variant: - return False - if variant == cur: - return False # self-referencing master -> dead CDN - try: - vr = req.get(variant, headers=h, timeout=timeout, stream=True, allow_redirects=True) - st2 = vr.status_code - try: - chunk = next(vr.iter_content(8192)) - vr.close() - except Exception: - vr.close() - chunk = b'' - except Exception: - return False - if st2 != 200 or not chunk: - return False - cur = variant - vhead = chunk[:512].lstrip() - if vhead[:1] in (b'<', b'{', b'['): - return False - depth += 1 - # media_found: we have a media playlist (#EXTINF, not master). - # The segment(s) inside are what Hls.js actually loads — probe - # the FIRST segment through the same URL-resolution the proxy - # uses and require real media bytes (TS or another playlist). - # This drops channels whose playlist is alive but whose - # segments 404 (e.g. Canal Showsport lumpentv: playlist 200, - # segment -> upstream 404 on the Space egress IP). - if media_found: - body = '' - try: - fr = req.get(cur, headers=h, timeout=timeout) - if fr.status_code == 200: - body = fr.text - except Exception: - pass - first_seg = None - for ln in body.split('\n'): - s = ln.strip() - if s and not s.startswith('#') and not s.startswith('http'): - first_seg = urljoin(cur, s) - break - elif s and s.startswith('http'): - first_seg = s - break - if first_seg: - try: - sr = req.get(first_seg, headers=h, timeout=timeout, stream=True, allow_redirects=True) - st3 = sr.status_code - try: - schunk = next(sr.iter_content(2048)) - sr.close() - except Exception: - sr.close() - schunk = b'' - if st3 != 200 or not schunk: - return False - shead = schunk[:256].lstrip() - if shead[:1] in (b'<', b'{', b'['): - return False - # Playlist or TS/binary media both OK (seg proxy - # handles playlist-in-seg via rewrite.) - return True - except Exception: - return False - return True - except Exception: - pass - return True - if head[0] == 0x47: - # MPEG-TS: verify the 188-byte sync pattern if we have enough data - if len(head) > 188: - return head[188] == 0x47 - return True - # Unknown binary but not HTML — accept (some servers send raw flv/ps) - return True - except Exception: - return False - -def _parse_m3u(): - """Fetch & parse the M3U playlists (vmttv remote + existing bundled list). - Validates EVERY channel (all groups) with a real byte-read so dead - streams are removed, then groups channels by NAME (title) so the API - returns one entry per channel name with its working mirror streams: - [{name, group, logo, streams:[{url, vlcopts}, ...]}, ...] - Unreachable / broken channels and event-only groups are filtered out.""" - out = [] - seen_raw = set() - for src_url in (_M3U_URL, _M3U_URL_EXISTING): - try: - r = req.get(src_url, headers={"User-Agent": "Mozilla/5.0"}, timeout=20) - if r.status_code != 200: - continue - lines = r.text.splitlines() - 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'([a-zA-Z0-9-]+)="([^"]*)"', 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 - if cur["url"] not in seen_raw: - seen_raw.add(cur["url"]) - out.append(cur) - cur = {} - except Exception: - continue - # Event channels are KEPT in the list (not pre-dropped): the validator - # below already removes streams that are dead, so a group with no live - # event simply never appears. Each channel is tagged below so the frontend - # can (a) place live events in the featured slide and (b) list event - # channels on top of the Trực tiếp tab. - def _event_kind(c): - g = (c.get("group") or "").strip() - t = (c.get("title") or "").strip() - if g in _M3U_EVENT_GROUPS or any(t.startswith(s) for s in _M3U_EVENT_SUFFIXES): - is_football = g in _M3U_FOOTBALL_GROUPS or bool(_M3U_FOOTBALL_TITLE_RE.search(t)) - return "football" if is_football else "event" - if _M3U_MATCH_TITLE_RE.search(t): - return "football" - return None - - for c in out: - k = _event_kind(c) - if k: - c["is_event"] = True - c["event_kind"] = k - # Deduplicate by URL (safety net — the seen_raw pass already dedupes) - seen = set() - deduped = [] - for c in out: - if c["url"] not in seen: - seen.add(c["url"]) - deduped.append(c) - # Validate ALL groups (not just Thể Thao) with a real byte-read. - valid = [] - with ThreadPoolExecutor(max_workers=24) as ex: - futures = {ex.submit(_check_m3u_stream, c): c for c in deduped} - for f in as_completed(futures): - if f.result(): - valid.append(futures[f]) - valid_urls = {c["url"] for c in valid} - good = [c for c in deduped if c["url"] in valid_urls] - # Group by channel NAME (title): each entry is one channel name with the - # list of working mirror streams so users can find a channel easily and - # the player can auto-failover between mirrors. - grouped = {} - order = [] - for c in good: - key = (c.get("group") or "Khác", c.get("title") or "Kênh") - if key not in grouped: - grouped[key] = { - "name": c.get("title") or "Kênh", - "group": c.get("group") or "Khác", - "logo": c.get("logo") or "", - "streams": [], - } - order.append(key) - if _M3U_EVENT_GROUPS and (c.get("group") or "").strip() in _M3U_EVENT_GROUPS: - # Keep one "live event" flag per channel (merged mirrors) - grouped[key]["is_event"] = True - grouped[key]["event_kind"] = c.get("event_kind") or "event" - elif c.get("is_event"): - grouped[key]["is_event"] = True - grouped[key]["event_kind"] = c.get("event_kind") or "event" - grouped[key]["streams"].append({ - "url": c["url"], - "vlcopts": c.get("vlcopts") or {}, - }) - # Sort: live events (football first, then movie/TV events) on top, then - # everything else in original order. Grouped dicts keep insertion order. - def _sort_key(entry): - if entry.get("is_event"): - return (0, 0) if entry.get("event_kind") == "football" else (0, 1) - return (1, 0) - entries = [grouped[k] for k in order] - entries.sort(key=_sort_key) - return entries - -def _m3u_refresh(): - """Refresh M3U cache in background.""" - n = int(time.time()) - try: - channels = _parse_m3u() - _m3u_cache["t"] = n - _m3u_cache["d"] = channels - _save_m3u_disk(channels, n) - except Exception: - pass - -def _m3u_daily_updater(): - """Auto-refresh the M3U list once per day. Re-fetches the remote list - (so channels added/removed upstream are picked up within 24h), re-validates - every channel (dead ones are dropped, tokens refreshed) and persists to - disk. Runs on a daemon thread.""" - while True: - try: - time.sleep(24 * 3600) # 24h - _m3u_refresh() - print(f"[M3U] daily auto-update complete @ {time.strftime('%Y-%m-%d %H:%M:%S')}") - except Exception as e: - print(f"[M3U] daily auto-update error: {e}") - try: - time.sleep(3600) - except Exception: - pass - -def _m3u_load(): - """Load M3U: return disk/in-memory cache immediately, refresh in background if stale.""" - global _m3u_cache - n = int(time.time()) - # Try in-memory cache first - if _m3u_cache["d"] and n - _m3u_cache["t"] < 180: - return _m3u_cache["d"], _m3u_cache["t"] - # Try disk cache - disk = _load_m3u_disk() - if disk and disk.get("channels"): - _m3u_cache = {"t": disk.get("t", 0), "d": disk["channels"]} - # Refresh in background if older than 3 min (Stalker play_tokens expire ~3-7 min) - if n - _m3u_cache["t"] >= 180: - threading.Thread(target=_m3u_refresh, daemon=True).start() - return _m3u_cache["d"], _m3u_cache["t"] - # No cache at all — fetch synchronously (first run) - _m3u_refresh() - return _m3u_cache["d"], _m3u_cache["t"] - -@app.get('/api/m3u/channels') -def api_m3u_channels(refresh: int = Query(default=0)): - if refresh: - # FIX: sync refresh — re-parse the M3U so stalker play_tokens are fresh - # before the frontend builds the /api/proxy/m3u_hls URL. The old code - # only kicked off a background thread and returned the STALE cache, - # so clients played with expired tokens -> black screen / 502. - try: - _m3u_refresh() - except Exception as e: - print(f"[M3U] sync refresh error: {e}") - channels = _m3u_cache["d"] - updated = _m3u_cache["t"] - else: - channels, updated = _m3u_load() - # LIVE international football (bongda livescore): surface ALL sports-cluster - # channels (Thể Thao / THỂ THAO QUỐC TẾ / ⚽| Thể thao quốc tế) together with - # the EVENT slide. This is computed per-request (NOT persisted to the disk - # cache) so when the last match ends the flag disappears automatically. - ls = _m3u_livescore_football_state() - if ls.get("live") and channels: - channels = list(channels) - for i, c in enumerate(channels): - if (c.get("group") or "").strip() in _M3U_SPORTS_CLUSTER_GROUPS: - channels[i] = dict(c) - channels[i]["is_event"] = True - channels[i]["event_kind"] = "football" - channels[i]["live_football_sports"] = True - return JSONResponse({ - "channels": channels, - "updated": updated, - "live_football": ls, - }) - -# Pre-warm M3U cache on startup + start the daily auto-update loop. -threading.Thread(target=lambda: _m3u_load(), daemon=True).start() -threading.Thread(target=_m3u_daily_updater, daemon=True).start() - -def _seg_url(base, ua=None, cookie=None, auth=None): - """Build an /api/proxy/seg URL carrying the stalker headers.""" - q = urlencode({'url': base}) - if ua: q += '&ua=' + quote(ua, safe="") - if cookie: q += '&cookie=' + quote(cookie, safe="") - if auth: q += '&auth=' + quote(auth, safe="") - return "/api/proxy/seg?" + q - -def _rewrite_media_playlist(text, base_url, ua=None, cookie=None, auth=None): - """Rewrite a MEDIA (not master) HLS playlist so every data line is proxied.""" - out = [] - for line in text.splitlines(): - s = line.strip() - if not s or s.startswith('#'): - out.append(line) - continue - resolved = s if s.startswith('http') else urljoin(base_url, s) - out.append(_seg_url(resolved, ua, cookie, auth)) - return '\n'.join(out) - -def _probe_upstream(url, headers): - """Best-effort HEAD/GET probe. Returns (is_playlist: bool|None, content, ctype, final_url). - None means 'unknown' (could not decide) -> caller treats as raw stream. - We NEVER block long: connect 10s, read 12s. A .ts stream that does not - respond in ~12s is treated as raw TS (wrap it). Only content that begins - with #EXTM3U / #EXT-X- or has an HLS content-type is treated as a playlist.""" - try: - r = req.get(url, headers=headers, timeout=(10, 12), stream=True, allow_redirects=True) - final = r.url - if r.status_code != 200: - try: r.close() - except Exception: pass - return None, "", "", final - ct = r.headers.get("Content-Type", "").lower() - lead = b"" - try: - lead = next(r.iter_content(4096)) - except Exception: - pass - try: r.close() - except Exception: pass - if not lead: - # Empty 200 — ambiguous. Content-Type decides. - return ('m3u8' in ct or 'mpegurl' in ct), "", ct, final - head = lead[:512].lstrip() - is_pl = head.startswith(b'#EXTM3U') or b'#EXT-X-' in head[:256] - if is_pl: - # Re-fetch full body (small text) for rewriting. - body = "" - try: - rr = req.get(url, headers=headers, timeout=(10, 12), allow_redirects=True) - if rr.status_code == 200: - body = rr.text - except Exception: - pass - return True, body, ct, final - return False, lead, ct, final - except Exception: - return None, "", "", url - -def _master_to_media(url, headers): - """Follow a MASTER playlist to its first playable MEDIA playlist. - Returns (media_url, media_text) or (None, None) if it can't resolve.""" - # Fetch up to 3 levels (rare nested masters). - cur = url - for _ in range(3): - try: - r = req.get(cur, headers=headers, timeout=(10, 12), allow_redirects=True) - if r.status_code != 200: - return None, None - body = r.text - except Exception: - return None, None - if '#EXT-X-STREAM-INF' not in body: - # Already a media playlist (or not a playlist at all — caller - # validates before calling). - return cur, body - variant = None - for i, ln in enumerate(body.splitlines()): - if ln.startswith('#EXT-X-STREAM-INF') and i + 1 < len(body.splitlines()): - cand = body.splitlines()[i + 1].strip() - if cand and not cand.startswith('#'): - variant = cand if cand.startswith('http') else urljoin(cur, cand) - break - if not variant or variant == cur: - return None, None - cur = variant - return cur, None - -@app.get('/api/proxy/m3u_hls') -def proxy_m3u_hls(url: str = Query(...), ua: str = Query(default=None), cookie: str = Query(default=None), auth: str = Query(default=None)): - """Convert ANY stream URL (MPEG-TS, Stalker .ts, .m3u8/.m3u/.m3u8 playlists, - MPD, raw) into an HLS live playlist that Hls.js can play in the browser. - - The old heuristic only probed URLs whose PATH contained '.m3u8'. That broke - the common case where a '.ts' / mp2t stream URL actually serves an .m3u8 - manifest (many Stalker portals redirect/serve a playlist under a .ts path) - AND the case where a plain '.m3u' URL is an HLS playlist rather than raw - TS. Those URL-suffix mismatches caused Hls.js to receive playlist TEXT as - video/mp2t media, stalling playback. - - New behaviour: content-sniff probe on EVERY URL. If the upstream bytes start - with #EXTM3U / #EXT-X- we rewrite it into a proxied HLS playlist (resolving - masters to their first media variant). Otherwise we wrap the URL in a live - open-ended HLS manifest whose segments stream raw through /api/proxy/seg. - A fast probe failure (timeout / 5xx) is treated as a raw stream and wrapped, - so Stalker servers that never answer on port 80 from the egress IP still - play via the TS wrapper.""" - if not url or not url.startswith('http'): - return Response(status_code=400, content="Invalid URL") - from urllib.parse import urlencode - headers = dict(HEADERS) - if ua: headers["User-Agent"] = ua - if cookie: headers["Cookie"] = cookie - if auth: headers["Authorization"] = auth - # Content-sniff probe (short timeout; failure -> assume raw TS & wrap). - is_pl, body, ct, final_url = _probe_upstream(url, headers) - if is_pl: - # Decide master vs media. - if '#EXT-X-STREAM-INF' in body: - media_url, media_text = _master_to_media(url, headers) - if media_url and media_text and '#EXT-X-STREAM-INF' not in media_text: - body = media_text - # Rewrite against the resolved media URL. - rewritten = _rewrite_media_playlist(body, media_url, ua, cookie, auth) - return Response(content=rewritten.encode('utf-8'), - media_type="application/vnd.apple.mpegurl", - headers={"Access-Control-Allow-Origin":"*", "Cache-Control":"no-cache, no-store, must-revalidate"}) - elif media_url: - # media_text None but media_url resolved to itself (media playlist) -> rewrite. - rewritten = _rewrite_media_playlist(media_text or body, media_url, ua, cookie, auth) - return Response(content=rewritten.encode('utf-8'), - media_type="application/vnd.apple.mpegurl", - headers={"Access-Control-Allow-Origin":"*", "Cache-Control":"no-cache, no-store, must-revalidate"}) - return Response(status_code=502, content="master playlist without playable variant") - # Media playlist (or a plain .m3u/.m3u8 that is itself a media list). - rewritten = _rewrite_media_playlist(body, final_url, ua, cookie, auth) - return Response(content=rewritten.encode('utf-8'), - media_type="application/vnd.apple.mpegurl", - headers={"Access-Control-Allow-Origin":"*", "Cache-Control":"no-cache, no-store, must-revalidate"}) - # Raw MPEG-TS / Stalker / unknown: emit a live open-ended HLS wrapper whose - # segments stream raw bytes through /api/proxy/seg. Multiple repeating - # segments keep Hls.js buffer alive and polling for the next window, which - # fixes the stall observed with single-segment manifests on short TS bursts. - _seg = _seg_url(url, ua, cookie, auth) - manifest = ( - "#EXTM3U\n" - "#EXT-X-VERSION:6\n" - "#EXT-X-PLAYLIST-TYPE:EVENT\n" - "#EXT-X-TARGETDURATION:8\n" - "#EXT-X-MEDIA-SEQUENCE:0\n" - "#EXT-X-START:TIME-OFFSET=0,PRECISE=YES\n" - "#EXTINF:8.0,\n" - f"{_seg}\n" - "#EXTINF:8.0,\n" - f"{_seg}\n" - "#EXTINF:8.0,\n" - f"{_seg}\n" - "#EXT-X-DISCONTINUITY\n" - ) - return Response(content=manifest.encode('utf-8'), - media_type="application/vnd.apple.mpegurl", - headers={"Access-Control-Allow-Origin":"*", "Cache-Control":"no-cache, no-store, must-revalidate"}) - - -@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. - - For MPEG-TS (.ts) / mp2t and other non-HLS URLs: redirects to the HLS - playlist generator (/api/proxy/m3u_hls) which content-sniffs the upstream - and emits a playable HLS manifest. For .m3u8 / .m3u / playlist URLs: proxies - segments through /api/proxy/seg with the stalker headers forwarded.""" - if not url or not url.startswith('http'): - return Response(status_code=400, content="Invalid URL") - from urllib.parse import urlencode - low = url.lower() - # URLs that are HLS playlists (m3u8 or plain m3u): stream through seg proxy. - if low.endswith(('.m3u8', '.m3u')) or 'm3u8' in low or '/m3u8' in low: - 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") - ct = r.headers.get("Content-Type", "") - # If a .m3u/.m3u8 URL actually serves raw TS (rare), fall back to the - # HLS wrapper instead of streaming mp2t bytes to Hls.js. - lead = b"" - try: - lead = next(r.iter_content(4096)) - except Exception: - pass - head = lead[:512].lstrip() - if head.startswith(b'#EXTM3U') or b'#EXT-X-' in head[:256] or 'mpegurl' in ct.lower(): - # HLS playlist: stream it through /api/proxy/seg so the rewritten - # manifest (produced lazily by seg) reaches the browser CORS-free. - def _stream2(src=r): - if lead: - yield lead - for chunk in src.iter_content(chunk_size=65536): - yield chunk - return StreamingResponse( - _stream2(), - media_type="application/vnd.apple.mpegurl", - headers={"Access-Control-Allow-Origin":"*", "Cache-Control":"no-cache", "Accept-Ranges":"bytes"}) - # Not actually a playlist — serve as TS through the seg proxy instead. - r.close() - except Exception: - pass - # Fall through to the HLS wrapper path below. - # MPEG-TS / stalker / non-playlist URL: redirect to the HLS playlist generator - # (content-sniffs upstream and wraps raw streams, or rewrites real playlists). - from fastapi.responses import RedirectResponse - params = {'url': url} - if ua: params['ua'] = ua - if cookie: params['cookie'] = cookie - if auth: params['auth'] = auth - return RedirectResponse(url="/api/proxy/m3u_hls?" + urlencode(params), status_code=302) - - -# ===== M3U LOGO FALLBACK ===== -@app.get('/api/m3u/logo_fallback') -def m3u_logo_fallback(group: str = Query(default="")): - """Generate a fallback SVG logo for M3U channels when tvg-logo fails to load. - Uses the group-title to pick an appropriate emoji/badge.""" - import html as _html - g = _html.escape((group or '').strip()) - # Determine emoji based on group content - g_lower = g.lower() - if 'premier' in g_lower or 'premier league' in g_lower: - emoji, bg = '⚽', '#1e3a8a' - elif 'liga' in g_lower and 'la' in g_lower: - emoji, bg = '⚽', '#0f172a' - elif 'champions' in g_lower: - emoji, bg = '⭐', '#7c2d12' - elif 'bundes' in g_lower: - emoji, bg = '⚽', '#047857' - elif 'serie' in g_lower: - emoji, bg = '⚽', '#713f12' - elif 'ligue' in g_lower: - emoji, bg = '⚽', '#7e22ce' - elif 'world cup' in g_lower: - emoji, bg = '🌍', '#075985' - elif 'fpt' in g_lower: - emoji, bg = '📺', '#be123c' - else: - emoji, bg = '📺', '#374151' - svg = f''' - -{emoji} -''' - return Response(content=svg.encode('utf-8'), media_type='image/svg+xml', - headers={"Access-Control-Allow-Origin":"*"}) - - -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_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/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 41294f55f9a4e079bb6e28822ac2dc633e35ede4..0000000000000000000000000000000000000000 --- a/index_v2.html +++ /dev/null @@ -1,82 +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...
-
-
- - - - - - - - - - - - - \ No newline at end of file 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 d61e2e9d9622d011dbacba15a7c62f989990a15d..7d80e4d500cb0c6a69bf5c9e82fee06b5e610aa8 100644 --- a/main.py +++ b/main.py @@ -1,42 +1,51 @@ -"""VNEWS - FastAPI backend with livescore + xemlaibongda highlights + VTV channels""" +"""VNEWS - FastAPI backend with livescore + xemlaibongda highlights + YouTube VTV shorts""" import re, time, subprocess, json, os, threading import html as html_lib -from datetime import datetime, timezone, timedelta, date +from datetime import datetime, timezone, timedelta from collections import defaultdict VN_TZ = timezone(timedelta(hours=7)) 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, urlencode, urljoin +from urllib.parse import quote import requests from bs4 import BeautifulSoup app = FastAPI() +# ===== RATE LIMITING =====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_data = defaultdict(list) # {ip: [timestamp1, timestamp2, ...]} _rate_limit_lock = threading.Lock() -RATE_LIMIT_MAX = 60 -RATE_LIMIT_WINDOW = 60 +RATE_LIMIT_MAX = 60 # Max requests per minute per IP +RATE_LIMIT_WINDOW = 60 # seconds def _check_rate_limit(ip: str) -> bool: + """Kiểm tra rate limit, return True nếu OK, False nếu bị limit""" with _rate_limit_lock: now = time.time() + # Xóa các request cũ _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 + 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): + """Middleware để kiểm tra rate limit""" + # Chỉ rate limit API endpoints 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) + if not _check_rate_limit(ip): + return JSONResponse({"error": "rate limit exceeded"}, status_code=429) + response = await call_next(request) + return response # ===== VTV CHANNELS API ===== from vtv_api import router as vtv_router @@ -50,8 +59,65 @@ _cache_ttl = 300 _cache_ttl_live = 60 _cache_ttl_yt = 1800 +# ===== VTV NAM BO SHORTS FALLBACK ===== +SHORTS_FALLBACK = [ + {"id":"nqlLH6chLRo","title":"Tin nóng VTV Nam Bộ | #shorts","channel":"vtvnambo"}, + {"id":"E7Kq0v3hG6w","title":"VTV Nam Bộ - Tin tức miền Nam | #shorts","channel":"vtvnambo"}, + {"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 quá cố gửi 26 năm trước","channel":"baodantri7941"}, + {"id":"tvPewsc2ph4","title":"Tính năng ẩn trên iPhone giúp giảm mỏi mắt","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","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ù","channel":"baodantri7941"}, + {"id":"Htzvwg6iOBM","title":"Xe điện Audi S6 Sportback e-tron có gì đặc biệt?","channel":"baodantri7941"}, + {"id":"iMdFmWvYdlo","title":"Cô gái người Nga yêu thời trang và đất nước Việt Nam","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","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","channel":"baodantri7941"}, + {"id":"VAfgNNgZDRs","title":"Khởi tố 4 đối tượng ném bom xăng vào nhà dân ở Đồng Nai","channel":"baodantri7941"}, + {"id":"sBH_-zGh0Xw","title":"Vì sao Times New Roman vẫn nổi tiếng sau hàng chục năm?","channel":"baodantri7941"}, + {"id":"woKn5f2bLHM","title":"Quảng Ninh ngập sâu diện rộng sau đợt mưa lớn","channel":"baodantri7941"}, + {"id":"bcpgRoxbLPw","title":"Giông lốc quật bay mái tôn ở TP.HCM","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","channel":"baodantri7941"}, + {"id":"uTMJ49NQpyc","title":"Sau lớp mascot 40kg Câu chuyện mưu sinh của người trẻ ở TPHCM","channel":"baodantri7941"}, + {"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 ở sông Lô","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"}, + {"id":"IUOprcJyYr4","title":"Phụ nữ táo bón có phải do lười ăn rau?","channel":"baosuckhoedoisongboyte"}, + {"id":"YY8ojFNE-AU","title":"Quái xế tự quay clip nẹt pô đánh võng đăng TikTok bị xử lý","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","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","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","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","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","channel":"baosuckhoedoisongboyte"}, + {"id":"pXWt0QbAzRQ","title":"Va chạm giao thông người phụ nữ lăng mạ tài xế ô tô","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","channel":"baosuckhoedoisongboyte"}, + {"id":"AxhVTQutsuo","title":"Xuất tinh sớm và những hiểu lầm thường gặp","channel":"baosuckhoedoisongboyte"}, + {"id":"cNy6FgaNxYM","title":"Cô dâu khóc sưng mắt vì 6 chỉ vàng không cánh mày bay trong ngày cưới","channel":"baosuckhoedoisongboyte"}, + {"id":"IDt_S6q59Ro","title":"Chở bạn gái không đội mũ bảo hiểm thanh niên đấm CSGT","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","channel":"baosuckhoedoisongboyte"}, +] +for _v in SHORTS_FALLBACK: + _v.setdefault("link", "https://www.youtube.com/watch?v="+_v["id"]) + _v.setdefault("img", "https://i.ytimg.com/vi/"+_v["id"]+"/hqdefault.jpg") + _v.setdefault("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":[]} + 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} +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":"🏆"}, @@ -69,17 +135,8 @@ def _cached(key, fn, ttl=None): except: data=_cache.get(key,{}).get("d",[]) _cache[key]={"d":data,"t":now}; return data def _get(url, headers=None): - h=headers or HEADERS - for attempt in range(3): - try: - r=requests.get(url, headers=h, timeout=15) - r.encoding="utf-8" - return BeautifulSoup(r.text,"lxml") - except Exception: - if attempt < 2: - time.sleep(0.5 * (attempt + 1)) - else: - raise + h=headers or HEADERS; r=requests.get(url, headers=h, timeout=15); r.encoding="utf-8" + return BeautifulSoup(r.text,"lxml") def fetch_bongda_api(endpoint): try: r=requests.get(f"https://bongda.com.vn{endpoint}", headers=BONGDA_HEADERS, timeout=10) @@ -120,275 +177,15 @@ def proxy_m3u8(url: str = Query(...)): 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/m3u_hls") -def proxy_m3u_hls(url: str = Query(...), ua: str = Query(default=None), cookie: str = Query(default=None), auth: str = Query(default=None), fallbacks: str = Query(default=None)): - """Wrap a raw stream URL into an HLS playlist that Hls.js can play. - - Handles two cases that previously broke on the browser (the Space player) - while working fine on native apps like Televizo (which play the raw URL - directly with their own headers): - - 1) The upstream is already an .m3u8 playlist -> re-proxy it through - /api/proxy/seg so the browser (CORS-blocked) can fetch every segment - via same-origin, forwarding stalker headers (ua/cookie/auth) to CDNs - that require a Referer/token. - 2) The upstream is raw MPEG-TS / a Stalker .php stream (not HLS) -> the - browser's Hls.js can't ingest a bare .ts URL, so we emit a live, - open-ended #EXTM3U playlist whose single segment proxies the raw - stream through /api/proxy/seg (media_type=video/mp2t). - """ - if not url or not url.startswith("http"): - return Response(status_code=400, content="Invalid URL") - seg_params = {"url": url} - if ua: seg_params["ua"] = ua - if cookie: seg_params["cookie"] = cookie - if auth: seg_params["auth"] = auth - if fallbacks: seg_params["fallbacks"] = fallbacks - seg_proxy_base = "/api/proxy/seg?" + urlencode(seg_params) - headers = dict(HEADERS) - if ua: headers["User-Agent"] = ua - if cookie: headers["Cookie"] = cookie - if auth: headers["Authorization"] = auth - # Determine if the upstream is already HLS. - looks_hls = ".m3u8" in url.lower() or "/m3u8" in url.lower() - if looks_hls: - try: - r = requests.get(url, headers=headers, timeout=(10, 60), stream=True) - if r.status_code != 200: - return Response(status_code=502, content="upstream error") - - # Read the playlist body to decide master vs media. - body_parts = [] - for ln in r.iter_lines(chunk_size=1024): - if not ln: - continue - body_parts.append(ln.decode("utf-8", "replace") if isinstance(ln, bytes) else str(ln)) - body = "\n".join(body_parts) - # MASTER playlist (multi-bitrate): follow the FIRST variant so the - # browser gets a playable media playlist immediately. The variant - # is itself fetched and rewritten so its segments go through - # /api/proxy/seg (which sniff-detects playlist vs TS and rewrites - # relative .ts URIs against the variant URL). - if "#EXT-X-STREAM-INF" in body: - var = None - lines = body.strip().split("\n") - for i, ln in enumerate(lines): - if ln.startswith("#EXT-X-STREAM-INF") and i + 1 < len(lines): - cand = lines[i + 1].strip() - if cand and not cand.startswith("#"): - var = cand if cand.startswith("http") else urljoin(url, cand) - break - if var: - try: - vr = requests.get(var, headers=headers, timeout=(10, 30)) - if vr.status_code == 200: - vtext = vr.text - if "#EXT-X-STREAM-INF" in vtext: - # Nested master (rare) — pick ITS first variant. - vlines = vtext.strip().split("\n") - for i, ln in enumerate(vlines): - if ln.startswith("#EXT-X-STREAM-INF") and i + 1 < len(vlines): - vcand = vlines[i + 1].strip() - if vcand and not vcand.startswith("#"): - var2 = vcand if vcand.startswith("http") else urljoin(var, vcand) - vr = requests.get(var2, headers=headers, timeout=(10, 30)) - if vr.status_code == 200: - vtext = vr.text - break - if vtext.lstrip().startswith("#EXTM3U"): - rewritten, _ = rewrite_hls_playlist(vtext, var, ua, cookie, auth, fallbacks) - return Response( - content=rewritten.encode("utf-8"), - media_type="application/vnd.apple.mpegurl", - headers={"Access-Control-Allow-Origin":"*","Cache-Control":"no-cache, no-store, must-revalidate"}, - ) - except Exception: - pass - # Fallback: rewrite the master itself (seg proxy will handle - # any variant playlists returned). - rewritten, _ = rewrite_hls_playlist(body, url, ua, cookie, auth, fallbacks) - return Response( - content=rewritten.encode("utf-8"), - media_type="application/vnd.apple.mpegurl", - headers={"Access-Control-Allow-Origin":"*","Cache-Control":"no-cache, no-store, must-revalidate"}, - ) - except Exception: - return Response(status_code=502, content="proxy error") - # Raw MPEG-TS / Stalker stream: emit a live HLS wrapper playlist. - # FIX: Emit a LIVE sliding window with multiple #EXTINF segments (all - # pointing at the same /api/proxy/seg URL). The old single-segment manifest - # caused Hls.js to play ~8s of data and then stall — the playlist had no - # new segments to discover. With 3 segments Hls.js keeps the playback - # buffer alive and polls for the next window, giving continuous live - # playback for MPEG-TS / Stalker streams. - _seg = seg_proxy_base - manifest = ( - "#EXTM3U\n" - "#EXT-X-VERSION:6\n" - "#EXT-X-TARGETDURATION:8\n" - "#EXT-X-MEDIA-SEQUENCE:0\n" - "#EXT-X-DISCONTINUITY-SEQUENCE:0\n" - "#EXT-X-START:TIME-OFFSET=0,PRECISE=YES\n" - "#EXTINF:8.0,\n" - + _seg + "\n" - "#EXTINF:8.0,\n" - + _seg + "\n" - "#EXTINF:8.0,\n" - + _seg + "\n" - "#EXTINF:8.0,\n" - + _seg + "\n" - "#EXTINF:8.0,\n" - + _seg + "\n" - "#EXTINF:8.0,\n" - + _seg + "\n" - "#EXTINF:8.0,\n" - + _seg + "\n" - "#EXTINF:8.0,\n" - + _seg + "\n" - ) - return Response(content=manifest.encode("utf-8"), - media_type="application/vnd.apple.mpegurl", - headers={"Access-Control-Allow-Origin":"*","Cache-Control":"no-cache, no-store, must-revalidate"}) - -def build_seg_qs(ua, cookie, auth, fallbacks): - qs = "" - if ua: qs += "&ua=" + quote(ua, safe="") - if cookie: qs += "&cookie=" + quote(cookie, safe="") - if auth: qs += "&auth=" + quote(auth, safe="") - if fallbacks: qs += "&fallbacks=" + quote(fallbacks, safe="") - return qs - - -def rewrite_hls_playlist(text, base_url, ua=None, cookie=None, auth=None, fallbacks=None): - """Rewrite an HLS playlist's segment/variant URIs so every data line goes - through /api/proxy/seg, resolving relative URIs against base_url and - forwarding stalker headers (ua/cookie/auth). Returns - (rewritten_text, is_master_playlist).""" - out = [] - seg_qs = build_seg_qs(ua, cookie, auth, fallbacks) - is_master = '#EXT-X-STREAM-INF' in text - for line in text.splitlines(): - s = line.strip() - if not s or s.startswith('#'): - out.append(line) - continue - seg = s if s.startswith('http') else urljoin(base_url, s) - out.append('/api/proxy/seg?url=' + quote(seg, safe='') + seg_qs) - return '\n'.join(out), is_master - - -def _looks_like_playlist(lead): - """True if the first bytes look like an HLS playlist (master or media).""" - if not lead: - return False - head = lead[:512].lstrip() - return head.startswith(b'#EXTM3U') or b'#EXT-X-' in head[:256] - - -@app.get("/api/proxy/stream") -def proxy_stream(url: str = Query(...), ua: str = Query(default=None), cookie: str = Query(default=None)): - """Proxy a raw .ts segment through the Space backend so the browser - (CORS-blocked from fetching the Stalker/xemtv origin directly) can load - it same-origin. Forwards stalker headers (ua/cookie) and a Referer that - these portals require, and sets Access-Control-Allow-Origin: *. - """ - if not url or not url.startswith("http"): - return Response(status_code=400, content="Invalid URL") - try: - h = {"User-Agent": ua or HEADERS["User-Agent"], "Referer": "http://a47.streamtrx.xyz/" if "streamtrx" in url else HEADERS.get("Referer","https://bep40-vnews.hf.space/"), "Accept": "*/*"} - if cookie: h["Cookie"] = cookie - r = requests.get(url, headers=h, timeout=(8, 60), stream=True) - if r.status_code != 200: - return Response(status_code=502, content="upstream %s" % r.status_code) - def _stream(src=r): - for chunk in src.iter_content(chunk_size=65536): - yield chunk - return StreamingResponse( - _stream(), - media_type="video/mp2t", - headers={"Access-Control-Allow-Origin":"*","Access-Control-Allow-Credentials":"false","Cache-Control":"no-cache"}, - ) - except Exception: - return Response(status_code=502, content="proxy error") - @app.get("/api/proxy/seg") -def proxy_segment(url: str = Query(...), ua: str = Query(default=None), cookie: str = Query(default=None), auth: str = Query(default=None), fallbacks: str = Query(default=None), max_sec: float = Query(default=0)): - """Proxy a stream segment. For live MPEG-TS streams, streams the raw data - continuously so Hls.js can buffer and play it. If the fetched URL turns - out to be an HLS *playlist* (master or media), it is rewritten so the - downstream segment(s) also go through this proxy. The max_sec parameter - is accepted for backwards compatibility but the stream is left open-ended - (the client / CDN closes the read when sufficient data is buffered or the - user navigates away).""" +def proxy_segment(url: str = Query(...)): try: - seg_headers = dict(HEADERS) - if ua: seg_headers["User-Agent"] = ua - if cookie: seg_headers["Cookie"] = cookie - if auth: seg_headers["Authorization"] = auth - # FIX: try the primary URL first; if it returns non-200 (e.g. an - # expired stalker play_token -> 502/403), fall through the "fallbacks" - # list (same stream ID on alternate servers) and use the first that - # returns 200. This fixes "black screen" on Stalker channels whose - # cached token on one server expired but works on another. - import json as _json - fb_urls = [] - if fallbacks: - try: - fb_urls = [u for u in _json.loads(fallbacks) if u and u.startswith("http")] - except Exception: - fb_urls = [] - attempt_urls = [url] + [u for u in fb_urls if u != url] - last_err = "upstream error" - for attempt_url in attempt_urls: - try: - r = requests.get(attempt_url, headers=seg_headers, timeout=(8, 300), stream=True) - if r.status_code == 200: - # Sniff the first bytes: if it's an HLS playlist (e.g. a - # master playlist from "/api/proxy/m3u_hls" for an .m3u8 - # URL, or a media playlist variant), rewrite it so every - # segment goes through this proxy again. MPEG-TS (0x47 - # sync bytes) or other media streams pass through raw. - lead = b"" - try: - lead = next(r.iter_content(4096)) - except Exception: - pass - if _looks_like_playlist(lead): - r.close() - body = "" - try: - rr = requests.get(attempt_url, headers=seg_headers, timeout=(8, 60)) - if rr.status_code == 200: - body = rr.text - except Exception: - pass - # Resolve against the playlist location (some variants - # use relative URIs) — use the attempt URL as base. - rewritten, _ = rewrite_hls_playlist(body, attempt_url, ua, cookie, auth, None) - return Response( - content=rewritten.encode("utf-8"), - media_type="application/vnd.apple.mpegurl", - headers={"Access-Control-Allow-Origin":"*", "Cache-Control":"no-cache, no-store, must-revalidate"}, - ) - # Real media segment — stream through. - def _stream(src=r): - if lead: - yield lead - for chunk in src.iter_content(chunk_size=65536): - yield chunk - return StreamingResponse( - _stream(), - media_type="video/mp2t", - headers={"Access-Control-Allow-Origin":"*","Cache-Control":"no-cache"} - ) - last_err = "upstream %s" % r.status_code - r.close() - except Exception as e: - last_err = str(e)[:120] - continue - return Response(status_code=502, content=last_err) - except Exception: - return Response(status_code=502, content="proxy error") + r = requests.get(url, headers=HEADERS, timeout=30) + 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") @app.get("/api/proxy/video") def proxy_video(url: str = Query(...), request: Request = None): @@ -405,345 +202,315 @@ def proxy_video(url: str = Query(...), request: Request = None): @app.get("/api/proxy/img") def proxy_img(url: str = Query(...)): 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) + r = requests.get(url, headers={**HEADERS, "Referer": "https://dantri.com.vn/"}, 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": "*"}) + 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): + """ + Scrape video từ xemlaibongda.top - Simple & Reliable + Dùng logic cũ đã test, không fetch từng trang (tránh timeout) + """ 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 = 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() + 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 + if "/video/" not in href and "/xem-lai/" not in href: + continue + + if not href.startswith("http"): + href = "https://xemlaibongda.top" + href + + # Bỏ query params clean_href = href.split("?")[0].split("#")[0] - if clean_href in seen: continue + if clean_href in seen: + continue seen.add(clean_href) + + # ===== Lấy THUMBNAIL ===== img_src = "" img = a.find("img") - if not img and a.parent: img = a.parent.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 + 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 + + # ===== Lấy TITLE ===== title = "" + # Thử attribute for attr in ["title", "aria-label"]: val = a.get(attr, "") - if val and len(val) >= 5: title = val; break + if val and len(val) >= 5: + title = val + break + + # Thử các selector 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 el: + t = el.get_text(strip=True) + if len(t) >= 5: + title = t + break + except: + pass + + # Thử text content if not title: text = a.get_text(strip=True) - if text and len(text) >= 5: title = text[:100] + if text and len(text) >= 5: + title = text[:100] + + # Fallback: tạo title từ slug 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 title or len(title) < 3: + continue + + # Fallback thumbnail từ slug 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 + + videos.append({ + "title": title[:100], + "link": clean_href, + "img": img_src, + "source": "xemlaibongda" + }) + + if len(videos) >= limit: + break + return videos except Exception as e: - print(f"[xemlaibongda] Error: {e}"); return [] + print(f"[xemlaibongda] Error: {e}") + return [] + +def _extract_img_src(a_tag): + """Extract image URL từ thẻ và parent elements""" + img = a_tag.find("img") + if not img and a_tag.parent: + img = a_tag.parent.find("img") + if not img: + p = a_tag.parent + for _ in range(5): # Tìm sâu hơn + if p and p.find("img"): + img = p.find("img") + break + p = p.parent if p else None + + if not img: + return "" + + # Thử tất cả các attribute có thể chứa img URL + attrs = ["data-src", "src", "data-lazy", "data-original", "data-srcset", "data-thumb", "data-image"] + for attr in attrs: + val = img.get(attr, "") + if val: + if attr == "data-srcset": + val = val.split(",")[0].strip().split(" ")[0] + break + else: + val = "" + + # Thử background-image từ style + if not val: + style = img.get("style", "") or img.get("data-bg", "") + bg_match = re.search(r'url\(["\']?(.*?)["\']?\)', style) + if bg_match: + val = bg_match.group(1) + + # Normalize URL + if val.startswith("//"): + val = "https:" + val + elif val.startswith("/"): + val = "https://xemlaibongda.top" + val + + return val + +def _extract_title(a_tag, href): + """Extract title từ thẻ và child/parent elements""" + title = "" + + # 1. Thử các selector phổ biến cho title + title_selectors = [ + "h3", "h2", "h4", "h5", + ".title", ".post-title", ".entry-title", ".video-title", + ".card-title", ".item-title", ".news-title", + "span.title", "strong", "b", + ".name", ".caption" + ] + for tag in title_selectors: + try: + t = a_tag.select_one(tag) if hasattr(a_tag, 'select_one') else None + if t: + title = t.get_text(" ", strip=True) + if len(title) >= 3: + return title + except: + pass + + # 2. Thử attribute của + for attr in ["title", "aria-label", "data-title"]: + val = a_tag.get(attr, "") + if val and len(val) >= 3: + return val + + # 3. Thử alt text của img + img = a_tag.find("img") + if img: + alt = img.get("alt", "") + if alt and len(alt) >= 3: + return alt + + # 4. Thử text content của (loại bỏ quá dài) + text = a_tag.get_text(" ", strip=True) + if text and len(text) >= 3: + # Lấy dòng đầu tiên nếu có nhiều dòng + lines = [l.strip() for l in text.split("\n") if l.strip()] + if lines: + first_line = lines[0] + if len(first_line) >= 3: + return first_line[:100] + + # 5. Fallback: tạo title từ slug + slug = href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/") + title = slug.replace("-", " ").replace("_", " ") + title = re.sub(r'\d{4}-\d{2}-\d{2}', '', title).strip() + if title: + return title.title() + + return "" 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 [] + 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) 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, timeout=25): 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.encoding="utf-8"; soup=BeautifulSoup(r.text,"lxml"); video=soup.find("video") if video: 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"} 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 +# ===== YOUTUBE SHORTS SCRAPING ===== +def _yt_channel_shorts_requests(channel, count=15): + 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(): + vids=[] + with ThreadPoolExecutor(3) as ex: + futs=[ex.submit(_yt_channel_shorts_requests,ch,24) for ch in ["baodantri7941","baosuckhoedoisongboyte","vtvnambo"]] + for f in as_completed(futs): + try: + r=f.result() + if r: vids.extend(r) + except: pass + merged=[]; seen=set() + for v in vids: + vid=v.get("id") + if not vid or vid in seen: continue + seen.add(vid); merged.append(v) + for v in SHORTS_FALLBACK: + vid=v.get("id") + if not vid or vid in seen: continue + seen.add(vid); merged.append(v) + return merged[:60] + +# ===== VTV NAM BO & WC SHORTS - using yt-dlp ===== +from yt_scraper import get_vtvnambo_shorts, get_wc_related_shorts + +@app.get("/api/shorts/vtvnamo") +def api_shorts_vtvnamo(count: int = Query(default=50, le=100)): + items = get_vtvnambo_shorts(count) + if not items: + items = [v for v in SHORTS_FALLBACK if v.get("channel") == "vtvnambo"] + nql = [v for v in items if v.get("id") == "nqlLH6chLRo"] + rest = [v for v in items if v.get("id") != "nqlLH6chLRo"] + items = nql + rest + return JSONResponse(items) + +@app.get("/api/shorts/wc") +def api_shorts_wc(count: int = Query(default=50, le=100)): + items = get_wc_related_shorts(count) + if not items: + items = [v for v in SHORTS_FALLBACK if v.get("channel") == "vtvnambo"] + nql = [v for v in items if v.get("id") == "nqlLH6chLRo"] + rest = [v for v in items if v.get("id") != "nqlLH6chLRo"] + items = nql + rest + return JSONResponse(items) + # ===== 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)}) @@ -757,207 +524,9 @@ 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)}) @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") @@ -994,8 +563,43 @@ def api_livescore_featured(): return None return JSONResponse(_cached("ls_featured",_f,ttl=30)) +@app.get("/api/shorts") +def api_shorts(channel: str = Query(default="")): + if channel == "vtvnambo": return api_shorts_vtvnamo() + if channel == "wc": return api_shorts_wc() + 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)) @app.get("/api/highlights/{league}") @@ -1003,49 +607,8 @@ def api_highlights_league(league:str): 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/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"}) @@ -1053,147 +616,69 @@ def api_video_url(url:str=Query(...), img:str=Query(default="")): 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 return JSONResponse(v) 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 +# Rate limiting cho WC API +_wc_request_times = [] +_wc_rate_limit_lock = threading.Lock() +_WC_RATE_LIMIT = 10 # Max 10 requests per minute + def _wc_rate_limit(): + """Kiểm tra rate limit cho WC API""" global _wc_request_times with _wc_rate_limit_lock: now = time.time() + # Xóa các request cũ hơn 60 giây _wc_request_times = [t for t in _wc_request_times if now - t < 60] - if len(_wc_request_times) >= _WC_RATE_LIMIT: return False + if len(_wc_request_times) >= _WC_RATE_LIMIT: + return False _wc_request_times.append(now) return True @app.get("/api/wc2026") def api_wc2026(): + """Trả về tất cả dữ liệu World Cup 2026""" return JSONResponse(_cached("wc2026", get_wc2026_all, ttl=_cache_ttl)) @app.get("/api/wc2026/{tab}") def api_wc2026_tab(tab: str): + """Trả về từng tab của World Cup""" valid_tabs = ["news", "fixtures", "standings", "stats", "highlights"] - if tab not in valid_tabs: return JSONResponse({"error": "invalid tab"}, status_code=400) + 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() + 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)) +# Note: WC functions (scrape_wc_news, scrape_fixtures, scrape_stats, scrape_standings) +# are imported from wc2026_scraper.py at the top of this file + +@app.get("/api/video_url") +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 "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="") + return JSONResponse(v) + return JSONResponse({"error":"not found"}) + @app.get("/api/bdp_videos") def api_bdp_videos(): def _f(): @@ -1230,8 +715,7 @@ def scrape_vne(cat_url): 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_genk_ai(): try: @@ -1251,7 +735,8 @@ def scrape_genk_ai(): 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 img_src: break + container=container.parent seen.add(href) if not img_src: try: @@ -1294,73 +779,9 @@ def api_categories(): 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.""" - try: - return scrape_bongdaplus_videos(limit) - except Exception: - return [] - @app.get("/api/article") def api_article(url:str=Query(...)): - 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": ""}) + return JSONResponse({"error":"not supported"}) @app.get("/api/storage_status") def api_storage_status(): @@ -1372,4 +793,5 @@ def api_hot_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 + return HTMLResponse("

VNEWS

Running

") +# v14 rebuild 2026-06-18T12:21:41.503230 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/pid_gen.py b/pid_gen.py deleted file mode 100644 index 4d2f87c1dbd1f423004f63fcba6e37c6252b8c9f..0000000000000000000000000000000000000000 --- a/pid_gen.py +++ /dev/null @@ -1,379 +0,0 @@ -"""pid_gen.py — AI background-image + best-text generation for Tạo Ảnh Bài Viết (VNEWS). - -Backend: ZeroGPU Space bep40/vnews-image-zerogpu (SenseNova-U1.5-8B-MoT, 100% GPU Zero) -- /api/pid/gen_bg -> SenseNova-U1.5-8B-MoT on ZeroGPU (Gradio API) -- /api/pid/gen_text -> synthesize fresh Vietnamese article lines from latest news - (primary: ZeroGPU SenseNova generate_text; - fallback: local news RSS + KNOWLEDGE_BANK synthesis) -Imported by app_v2_entry.py at startup (decorates shared `app`).""" -import os, re, time, requests -import xml.etree.ElementTree as ET -from fastapi import FastAPI, Request -from fastapi.responses import JSONResponse, Response -from pydantic import BaseModel - -ZEROGPU_BASE = 'https://bep40-vnews-image-zerogpu.hf.space' -_HF_TOKEN = os.environ.get('HF_TOKEN', '') -_HDR = {'Authorization': 'Bearer ' + _HF_TOKEN, 'Content-Type': 'application/json'} - -TEXT_MODELS = [ - 'Qwen/Qwen3-8B', - 'Qwen/Qwen2.5-7B-Instruct', - 'Qwen/Qwen2.5-3B-Instruct', - 'Qwen/Qwen2.5-1.5B-Instruct', -] -IMG_OPTIONS = [ - {'id': 'sensenova/SenseNova-U1.5-8B-MoT', 'label': '✨ SenseNova-U1.5-8B-MoT (AI ảnh nền)', 'poll': 'flux'}, -] -DEFAULT_IMG = 'sensenova/SenseNova-U1.5-8B-MoT' - - -def _split_vn_lines(txt, n): - lines, seen = [], set() - for ln in (txt or '').split('\n'): - l = re.sub(r'^[\s\-•*·▪◦‣0-9.]+', '', ln).strip() - if l and l.lower() not in seen and len(l) >= 3: - if lines and not re.search(r'[.!?…]["\')\]]?$', lines[-1]) and re.match(r'^[a-z\u00e0-\u024f]', l): - lines[-1] = (lines[-1] + ' ' + l).strip() - else: - seen.add(l.lower()) - lines.append(l) - return lines[:n] - - -# ---------- ZeroGPU Gradio API helpers ---------- -_ZG_TOKEN = os.environ.get('ZEROGPU_TOKEN', '') or _HF_TOKEN -_ZG_HDRS = {'User-Agent': 'Mozilla/5.0', 'Content-Type': 'application/json'} -if _ZG_TOKEN: - _ZG_HDRS['Authorization'] = 'Bearer ' + _ZG_TOKEN - -def _zg_submit(fn, data, timeout=60, _attempt=0): - """Submit to ZeroGPU Space with retry/backoff. 429 == GPU pool busy (concurrency/reservation - window), NOT quota exhaustion - retry with increasing waits before giving up.""" - import time as _t - if _attempt > 0: - _t.sleep([5, 15][min(_attempt - 1, 3)]) - try: - r = requests.post(f'{ZEROGPU_BASE}/gradio_api/call/{fn}', json={'data': data}, timeout=timeout, - headers=_ZG_HDRS) - except Exception as _e: - if _attempt < 2: - return _zg_submit(fn, data, timeout=timeout, _attempt=_attempt + 1) - raise RuntimeError(f'ZeroGPU submit {fn}: network error {type(_e).__name__}') - if r.status_code != 200: - body = re.sub(r'<[^>]+>', ' ', r.text or '')[:150] - status = '429 (ZeroGPU GPU pool busy)' if r.status_code == 429 else f'HTTP {r.status_code}' - if r.status_code in (429, 500, 502, 503, 504) and _attempt < 4: - return _zg_submit(fn, data, timeout=timeout, _attempt=_attempt + 1) - raise RuntimeError(f'ZeroGPU submit {fn}: {status} {body}') - return r.json().get('event_id') - - -def _zg_poll_result(fn, event_id, wait_max=240, interval=3): - import time as _t - t0 = _t.time() - while _t.time() - t0 < wait_max: - _t.sleep(interval) - try: - rr = requests.get(f'{ZEROGPU_BASE}/gradio_api/call/{fn}/{event_id}', timeout=30, - headers=_ZG_HDRS) - if rr.status_code != 200: - continue - data = rr.json() - if isinstance(data, dict) and data.get('status') == 'SUCCESS': - return data.get('result', [None, None])[0] - if isinstance(data, dict) and data.get('status') == 'FAILED': - return None - except Exception: - continue - return None - - -def _zg_generate_bg(prompt, w, h, steps=28, timeout=600): - """Generate an article-background image from text prompt.""" - import time as _t - t0 = _t.time() - event_id = _zg_submit('predict', [{'prompt': prompt, 'width': w, 'height': h, 'guidance': 7.0, 'steps': steps}, 0.8, 30], timeout=timeout) - if not event_id: - raise RuntimeError('ZeroGPU submit failed') - result = _zg_poll_result('predict', event_id, wait_max=timeout, interval=2) - if not result or not isinstance(result, (bytes, str)): - raise RuntimeError('ZeroGPU gen_bg: no image in result') - if isinstance(result, str): - raise RuntimeError('ZeroGPU gen_bg: text result') - return result, 'image/png' - - -def _zg_generate_text(prompt, n, timeout=120): - """Generate article text (Vietnamese) from a topic prompt.""" - import time as _t - t0 = _t.time() - event_id = _zg_submit('generate_text', [{'prompt': prompt, 'num_lines': n}], timeout=timeout) - if not event_id: - raise RuntimeError('ZeroGPU submit failed') - result = _zg_poll_result('generate_text', event_id, wait_max=timeout, interval=2) - if not result or not isinstance(result, str): - raise RuntimeError('ZeroGPU gen_text: no text result') - return result - - -def _fetch_news(topic, limit=10): - """Fetch latest news items from Vietnamese RSS feeds for the given topic.""" - feeds = [ - ('https://vnexpress.net/rss/the-thao.rss', topic), - ('https://dantri.com.vn/rss/tintuc.rss', topic), - ('https://tuoitre.vn/rss/latest.xml', topic), - ] - items = [] - for feed, _ in feeds: - try: - resp = requests.get(feed, timeout=25, headers={'User-Agent': 'Mozilla/5.0'}) - if resp.status_code != 200: - continue - root = ET.fromstring(resp.content) - for it in root.iter('item'): - t = (it.findtext('title') or '').strip() - s = (it.findtext('description') or '').strip() - if t and len(t) >= 12: - items.append({'title': t, 'snippet': s}) - except Exception: - continue - if len(items) >= limit: - break - return items[:limit] - - - -def _split_vn_sentences(txt): - """Split Vietnamese text into complete sentences. - - Only breaks at real sentence terminators (. ! ? …) followed by whitespace - (or end of text). Commas, semicolons and colons NOT followed by a terminator - keep the sentence together so we never cut mid-sentence. Quoted/abbreviated - periods ("3,5.", "12.5") stay intact via the ``(?]+>', '', txt or '') - txt = re.sub(r'\s+', ' ', txt).strip() - if not txt: - return [] - # Sentence terminators: period / ! / ? / ellipsis / Vietnamese full stop, - # optionally followed by a closing quote/paren, then whitespace or EOS. - parts = re.split(r'(?<=[.!?…])(? flush - lines.append(cur) - cur = grp - if len(lines) >= (n or 8): - break - if cur and len(lines) < (n or 8): - lines.append(cur) - lines = [l for l in lines if l] - return lines[:max(n or 8, 3)] - - -def _article_to_lines(article, n): - """Turn an article string into `n` complete-sentence lines (Vietnamese).""" - sentences = _split_vn_sentences(article) - if not sentences: - return [article or ''][:n] - # Group consecutive sentences into article lines, keeping every line complete. - lines = _assemble_lines(sentences, cap=130, n=n) - if not lines: - # last resort: relax cap to fit every sentence completely - lines = [s for s in sentences if s] or [t2 for t2 in [re.sub(r'[\s\-:]+', ' ', article or '')[:80]]] - return lines[:n] - - -KNOWLEDGE_BANK = { - 'trung thu': 'Tết Trung thu (rằm tháng 8 âm lịch) bắt nguồn từ văn hóa Trung Hoa cổ đại gắn với tích Hằng Nga - hậu Nghệ, chú Cuội cây đa; tại Việt Nam từ thời Lý - Trần đã thành lễ hội tết trông trăng: trẻ em rước đèn, phá cỗ, múa lân; tên "Trung thu" nghĩa là giữa mùa thu, đêm trăng rằm sáng tròn nhất năm, tượng trưng cho gia đình đoàn viên.', - 'tết': 'Tết Nguyên đán bắt nguồn từ truyền thống nông nghiệp lúa nước, "Tết" gốc từ chữ "Tiết" (tiết khí), "Nguyên đán" nghĩa là buổi sáng đầu năm mới; dịp đoàn viên, cúng ông Công ông Táo, gói bánh chưng, chúc Tết và lì xì.', - 'bóng đá': 'Bóng đá là môn thể thao vua, đội tuyển Việt Nam lập nhiều cột mốc: vô địch AFF Cup 2008, 2018, 2024; vào tứ kết Asian Cup 2019 và vòng loại cuối World Cup 2026.', - 'kinh tế': 'Kinh tế Việt Nam từ Đổi mới 1986 chuyển sang kinh tế thị trường định hướng xã hội chủ nghĩa, nhiều năm tăng trưởng 6-7%/năm, thu hút FDI và xuất khẩu mạnh.', - 'giáo dục': 'Giáo dục Việt Nam có truyền thống khoa bảng nghìn năm từ Văn Miếu - Quốc Tử Giám (1070), hiện đổi mới chương trình phổ thông 2018 và chuyển đổi số.', - 'chứng khoán': 'Thị trường chứng khoán Việt Nam thành lập năm 2000 với các sàn HOSE, HNX, là kênh huy động vốn trung dài hạn cho doanh nghiệp.', - 'thời tiết': 'Thời tiết Việt Nam chịu ảnh hưởng gió mùa Đông Bắc - Tây Nam, mùa mưa bão tập trung tháng 7-10, cần theo dõi dự báo để phòng tránh.', - 'công nghệ': 'Công nghệ Việt Nam phát triển mạnh từ 2000 với phần mềm, bán dẫn, AI; nhiều startup gây quỹ hàng trăm triệu USD tại Đông Nam Á.', - 'du lịch': 'Du lịch Việt Nam nổi tiếng với di sản UNESCO như Vịnh Hạ Long, Hội An, Huế, Phong Nha; đóng góp lớn vào GDP và thu hút hàng chục triệu khách quốc tế mỗi năm.', - 'trường học': 'Năm học mới ở Việt Nam khai giảng 5/9, học sinh háo hức tựu trường sau kỳ hè; lễ khai giảng gọn nhẹ, ý nghĩa.', -} -def _kb_line(topic): - tl = (topic or '').strip().lower() - for k, v in KNOWLEDGE_BANK.items(): - if k in tl or tl in k: - return v - return None - - -def _local_gen_text(topic, n): - """Full Vietnamese news-article prose (9-11 sentences) — richer output so - the "Tạo nội đúng AI" feature never returns a 1-3 line stub. Always yields - at least `n` usable lines.""" - news = _fetch_news(topic, limit=10) - frags = [] - for x in news: - t = re.sub(r"<[^>]+>", "", x.get("title") or "") - t = re.sub(r"\s+", " ", t).strip() - t = re.sub(r"\s*-\s*[^\n\r]{0,30}$", "", t) - t = t[:120] - if t and len(t) >= 15 and t not in frags: - frags.append(t) - tl = (topic or "").strip().lower() - kb = _kb_line(tl) - t2 = re.sub(r"[\s\-:]+", " ", topic)[:45] or "vấn đề này" - t2c = t2[0].upper() + t2[1:] if t2 else "Chủ đề này" - parts = [] - if kb: - parts.append(kb) - parts.append(f"{t2c} đang thu hút sự quan tâm rất lớn của dư luận trong thời gian gần đây, khi những diễn biến xoay quanh nó liên tục được cập nhật trên các mặt báo lớn.") - if frags: - parts.append(f"Theo ghi nhận, thông tin mới nhất cho thấy {t2} đang được nhiều chuyên gia đánh giá là có tác động sâu rộng đến đời sống kinh tế - xã hội.") - else: - parts.append(f"Theo ghi nhận, {t2} đang được nhiều chuyên gia đánh giá là vấn đề có tác động sâu rộng và cần được nghiên cứu kỹ lưỡng.") - for f in frags[:5]: - if not f: - continue - # keep acronyms/initials intact: only lower-case the first letter when - # the word starts with a capital letter AND is followed by lowercase - # letters (e.g. 'Thắng' -> 'thắng'); leave 'MU', 'HLV', 'FPT' as-is - fc = f - if f and f[0].isupper() and (len(f) < 2 or not f[1].isupper()): - fc = f[0].lower() + f[1:] - parts.append(f"{fc} — một diễn biến mới được báo cáo gần đây, tiếp tục hé lộ nhiều thông tin quan trọng liên quan đến chủ đề này.") - parts.append(f"Không chỉ dừng lại ở phạm vi một nhóm đối tượng, sự việc còn kéo theo những hệ lụy lan tỏa, buộc các bên liên quan phải có sự chuẩn bị kỹ lưỡng.") - parts.append(f"Các chuyên gia cũng khuyến nghị người dân nên tham khảo nhiều nguồn tin khác nhau, tránh bị chi phối bởi những thông tin chưa được kiểm chứng.") - parts.append(f"Trong bối cảnh đó, việc theo dõi sát sao và cập nhật thông tin thường xuyên là điều cần thiết để có cái nhìn toàn diện và chính xác nhất.") - parts.append(f"Nhìn chung, những diễn biến xoay quanh {t2} vẫn đang tiếp tục phát triển, hứa hẹn sẽ mang đến nhiều điều bất ngờ trong thời gian tới.") - parts.append(f"Với tầm quan trọng và sức ảnh hưởng của mình, {t2} chắc chắn sẽ còn được dư luận quan tâm theo dõi trong nhiều ngày nữa.") - article = " ".join(p for p in parts if p) - return [article] - - -# ---------- SenseNova text generation via ZeroGPU ---------- -def _zg_generate_text(topic, n=8, timeout=120): - """Generate a fresh Vietnamese article via SenseNova-U1.5 on ZeroGPU. - Returns text or None on failure.""" - try: - r = requests.post( - f'{ZEROGPU_BASE}/api/generate_text', - headers=_HDR, - json={'topic': topic, 'num_lines': n}, - timeout=timeout - ) - if r.status_code != 200: - print(f'[pid_gen] ZeroGPU gen_text error: {r.status_code} {r.text[:120]}') - return None - body = r.json() - txt = body.get('text') or body.get('article') or '' - txt = re.sub(r'<[^>]+>', '', txt).strip() - if len(txt) >= 20: - return txt - return None - except Exception as e: - print(f'[pid_gen] ZeroGPU gen_text exception: {type(e).__name__}: {str(e)[:150]}') - return None - - -_BG_CACHE = {} # prompt -> (img_bytes, content_type, ts); ttl 60s -_CACHE_TTL = 60 - -def _zg_generate_bg_cached(prompt, w, h, steps=28, timeout=600): - key = (prompt, w, h, steps) - now = time.time() - hit = _BG_CACHE.get(key) - if hit and now - hit[2] < _CACHE_TTL: - return hit[0], hit[1] - img, ct = _zg_generate_bg(prompt, w, h, steps=steps, timeout=timeout) - _BG_CACHE[key] = (img, ct, now) - # prune - for k in list(_BG_CACHE.keys()): - if now - _BG_CACHE[k][2] > _CACHE_TTL: - del _BG_CACHE[k] - return img, ct - -def _register(app): - @app.post('/api/pid/gen_bg') - def gen_bg(req: GenBGReq): - t0 = time.time() - w, h = 1024, 1536 - msz = re.match(r'\s*(\d+)[xX,]\s*(\d+)', (req.size or '').strip()) - if msz: - w, h = int(msz.group(1)), int(msz.group(2)) - # SenseNova-U1.5-8B-MoT via ZeroGPU Space ONLY — NO pollinations fallback - try: - img, ct = _zg_generate_bg_cached((req.prompt or '').strip(), w, h, steps=28, timeout=600) - except Exception as e: - err0 = re.sub(r'<[^>]+>', ' ', f'{type(e).__name__}: {str(e)[:300]}') - err0 = re.sub(r'\s+', ' ', err0).strip()[:180] - print(f'[pid_gen] SenseNova gen_bg FAILED (no fallback): {err0}', flush=True) - return JSONResponse( - {'error': 'SenseNova-U1.5 chưa sẵn sàng (GPU pool đang bận). Vui lòng thử lại sau vài giây.'}, - status_code=503, - headers={'X-Gen-Fail': 'sensenova-busy', 'X-Retry-After': '30'}, - ) - resp = Response(content=img, media_type=ct) - resp.headers['X-Gen-Ms'] = str(int((time.time() - t0) * 1000)) - resp.headers['X-Engine'] = 'sensenova-zerogpu' - return resp - - @app.post('/api/pid/gen_text') - def gen_text(req: GenTextReq): - try: - n = max(8, min(int(req.num_lines or 8), 12)) - topic = (req.topic or '').strip() - if not topic: - return JSONResponse({'error': 'missing topic'}, status_code=400) - article = None - engine_name = None - # ZeroGPU SenseNova text generation first - try: - txt_zg = _zg_generate_text(topic, n, timeout=120) - if txt_zg and len(txt_zg) >= 20: - article = txt_zg - engine_name = 'zerogpu-sensenova' - except Exception as e_zg: - print(f'[pid_gen] ZeroGPU gen_text failed: {type(e_zg).__name__}', flush=True) - # fallback: local news RSS + KNOWLEDGE_BANK synthesis (instant, always works) - if not article: - article = '\n'.join(_local_gen_text(topic, n)) - engine_name = engine_name or 'local-news-synthesis' - lines = _article_to_lines(article, n) - return JSONResponse({'lines': lines, 'text': article, 'article': article, - 'model': 'sensenova/SenseNova-U1.5-8B-MoT' if 'zerogpu' in engine_name else 'Qwen/Qwen2.5-7B-Instruct', - 'engine': engine_name}) - except Exception as e: - return JSONResponse({'error': f"[pid_gen] gen_text {type(e).__name__}: {str(e)[:300]}"}, status_code=500) - -def patch_app(app): - _register(app) - -print('[pid_gen] endpoints registered (ZeroGPU backend)') if __name__ != '__main__' else None 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 ceb2a11d8e85401aeff6593159896c121d08f7c4..0000000000000000000000000000000000000000 --- a/rebuild_trigger.txt +++ /dev/null @@ -1 +0,0 @@ -M3U perf fix: disk cache + background pre-warm Sat Aug 29 00:43:20 UTC 2026 diff --git a/requirements.txt b/requirements.txt index 5a7dc8fa59e562b34baac2b7323dc7cb06ae18a8..903c94c4f00bc6539698f298a80e4063f99f8cd0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,6 +11,3 @@ 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 26265fd21a1a5b993e5a0002f6e8bc9c4c0c43ca..0000000000000000000000000000000000000000 --- a/restart.txt +++ /dev/null @@ -1 +0,0 @@ -rebuild trigger 1789270802.2049925 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 index b86340c3e987415f5d233fccbb761c14b2223285..263ab8e7bbf5929c39eb16ff0183efa6ab4b896b 100644 --- a/rewrite_slide.py +++ b/rewrite_slide.py @@ -84,36 +84,17 @@ def _scrape_article_full(url): 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.""" + """Extract key points: take first 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]) + # Take first complete sentence (ends with . ! ?) + m = re.match(r'^(.+?[.!?])\s', p) + if m: + sentence = m.group(1) + else: + sentence = p[:150] + ('.' if not p.endswith('.') else '') # Skip if too short or duplicate if len(sentence) < 30: continue diff --git a/runtime.txt b/runtime.txt index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..64f28603a352df8286a8b7f4e33200f60e34206d 100644 --- a/runtime.txt +++ b/runtime.txt @@ -0,0 +1 @@ +python-3.12.x 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 aef1b14be9da4550222e36f0e6ab4ca1a88e9fe3..d30125e07d2a685b5c68ca221220d22b99b97a1a 100644 --- a/static/app_v2.js +++ b/static/app_v2.js @@ -1,64 +1,6 @@ -// 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 = '
'; - 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 = {}; +// === VNEWS Frontend v2 - Optimized for speed === +// === LOAD HOME - Fast: immediate shell + parallel fetch === function _fetchWithTimeout(url, ms){ return new Promise((resolve,reject)=>{ const ctrl=new AbortController(); @@ -71,125 +13,57 @@ function _fetchWithTimeout(url, ms){ }); } -// ===== 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; + // Build shell IMMEDIATELY — no skeleton, no delay homeEl.innerHTML = '' - +'
🤖 AI viết bài
' - +'
' + +'
🤖 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...
' + +'

⚽ Livescore

📅 Hôm nay🔴 Live⏰ 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'); + // Start critical loads immediately + loadLivescore('today'); loadHotTopics(); - const [featuredData, wallData, hlLeagues, wcData] = await Promise.allSettled([ + // Fetch all data in parallel with shorter timeouts + const [featuredData, shortsData, wallData, hlLeagues, aiData, wcData] = await Promise.allSettled([ _fetchWithTimeout('/api/livescore/featured', 5000), + _fetchWithTimeout('/api/shorts', 8000), _fetchWithTimeout('/api/wall', 5000), _fetchWithTimeout('/api/highlights/leagues', 10000), - _fetchWithTimeout('/api/wc2026', 20000), + _fetchWithTimeout('/api/genk_ai', 8000), + _fetchWithTimeout('/api/wc2026', 8000), ]).then(results => results.map(r => r.status === 'fulfilled' ? r.value : null)); + // Render featured match 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=``; + if(area) area.innerHTML=``; } + // Store globally + _shortsData = shortsData || []; _wallPosts = (wallData && wallData.posts) || []; _hlLeagueData = hlLeagues || {}; _wc2026Data = wcData; + // Render WC if data arrived if(wcData) switchWCTab('news'); - _renderHLSection(); - _fptStartAuto(); - - // === YOUTUBE RSS FEED (FPT Bóng Đá) — interleave latest videos onto Tường AI === - _ytFeedStartAuto(); - + // Render sections into the after-wc area + _renderShortsIn(afterEl); _renderWallIn(afterEl); + _renderHLIn(afterEl); + if(aiData && aiData.length) _renderSlidesIn('ai-articles','Ứng dụng AI','🤖',aiData,afterEl); } function _renderSlidesIn(key, label, emoji, vids, afterEl){ @@ -200,9 +74,9 @@ function _renderSlidesIn(key, label, emoji, vids, afterEl){ 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)}
`; + h+=`
${a.img?``:''}
▶
${esc(a.title)}
`; } else { - h+=`
${a.img?``:''}
${esc(a.title)}
`; + h+=`
${a.img?``:''}
${esc(a.title)}
`; } }); h+='
'; @@ -210,19 +84,39 @@ function _renderSlidesIn(key, label, emoji, vids, afterEl){ afterEl.parentNode.insertBefore(wrap, afterEl); } +function _renderShortsIn(afterEl){ + if(!_shortsData||!_shortsData.length||!afterEl) return; + const mixed=interleaveShorts(_shortsData); + if(!mixed.length) return; + const wrap=document.createElement('div'); + wrap.className='slider-wrap'; + let h=`
📱 Shorts Dân trí & SKĐSMới nhất · xen kẽ
`; + mixed.slice(0,30).forEach((a,i)=>{ + const badge=a.channel==='baosuckhoedoisongboyte'?'SKĐS':'Dân trí'; + h+=`
${a.img?``:''}
▶
${badge} ${esc(a.title)}
`; + }); + h+='
';wrap.innerHTML=h; + afterEl.parentNode.insertBefore(wrap,afterEl); +} + function _renderWallIn(afterEl){ - if(!_wallPosts||!_wallPosts.length) return; + if(!_wallPosts||!_wallPosts.length||!afterEl) return; const posts=_wallPosts; + const aiShorts=posts.filter(p=>p.video); + if(aiShorts.length){ + const wrap=document.createElement('div'); + wrap.className='slider-wrap'; + let h='
🎬 Short AI
'; + aiShorts.slice(0,20).forEach((p,i)=>{h+=`
▶
${esc(p.title)}
`;}); + h+='
';wrap.innerHTML=h; + afterEl.parentNode.insertBefore(wrap,afterEl); + } 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(); + afterEl.parentNode.insertBefore(wrap,afterEl); } function _renderHLIn(afterEl){ @@ -235,2813 +129,245 @@ function _renderHLIn(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'); -} - -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}
`; +} + +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); - } - }); -} - -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(); + toast('❌ '+e.message); + if(btn){btn.disabled=false;btn.textContent=origText;} } } -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) ? '' : ''; - var modalQuoteBtn = ''; - var modalGridBtn = ''; - return '
'+thumbContent+videoBadge+'
'+esc(p.title)+'
'+esc((p.text||'').slice(0,180))+'
'+voiceBar+'
'+modalQuoteBtn+modalGridBtn+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 ebtn = e.target.closest('.tts-emotion-btn'); - if(ebtn){ - var container = ebtn.closest('.tts-selector'); - if(container){ - var selKey = 'inline-'+container.dataset.postId; - if(!_ttsSelections[selKey]) _ttsSelections[selKey]={voice:'vi-VN-HoaiMyNeural',emotion:ebtn.dataset.emotion}; - var allBtns = container.querySelectorAll('.tts-emotion-btn'); - for(var i=0;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(); - } -} - + 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'); +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.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); + } + } }); - }catch(e){} + }); + el.querySelectorAll('a').forEach(a=>{ + a.addEventListener('click',e=>{e.preventDefault();e.stopPropagation()}); + }); } -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='';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 if(id==='m3u'){document.getElementById('view-m3u').classList.add('active');if(typeof loadM3UChannels==='function')loadM3UChannels()}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.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){ - // On mobile: lock to landscape for a true fullscreen experience - if(slide){ - const vid = slide.querySelector('video'); - if(vid){ - // Try the native Fullscreen API first (works on mobile with allowFullscreen attr) - if(vid.requestFullscreen){ - vid.requestFullscreen().catch(()=>{ - // Fallback: try to enter Picture-in-Picture if fullscreen fails - if(vid.disablePictureInPicture === false && vid.requestPictureInPicture){ - vid.requestPictureInPicture().catch(()=>{}); - } - }); - } - // Lock screen orientation on mobile browsers that support it - if(screen.orientation && screen.orientation.lock){ - screen.orientation.lock('landscape').catch(()=>{}); - } - } - } - // keep the active slide centered & playable in full height - setTimeout(()=>{ slide && slide.scrollIntoView({block:'nearest'}); }, 60); - } else if(slide){ - slide.scrollIntoView({block:'nearest'}); - // Restore portrait orientation on mobile - if(screen.orientation && screen.orientation.lock){ - screen.orientation.lock('portrait-primary').catch(()=>{}); - } - // Exit native fullscreen if active - if(document.fullscreenElement){ - document.exitFullscreen().catch(()=>{}); - } - } -} - -// Picture-in-Picture toggle for M3U player -function togglePiP(videoId){ - const slide = videoId ? document.querySelector('.tiktok-slide[data-vid="'+videoId+'"]') : null; - if(!slide) return; - const vid = slide.querySelector('video'); - if(!vid) return; - const icons = document.querySelectorAll('[id^="pip-toggle-"]'); - const icon = icons[0]; - if(!icon) return; - // Check if PiP is supported - if(!vid.disablePictureInPicture && vid.requestPictureInPicture){ - if(document.pictureInPictureElement){ - // Currently in PiP - exit - document.exitPictureInPicture().catch(()=>{}); - if(icon) icon.textContent = '⧉'; - } else { - // Enter PiP - vid.requestPictureInPicture().then(function(){ - if(icon) icon.textContent = '❐'; - }).catch(function(err){ - toast('PiP không khả dụng: ' + (err.message || err)); - }); - } - } else { - toast('Trình duyệt không hỗ trợ Picture-in-Picture'); - } -} +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; - // Inject the m3u status CSS (safe to run multiple times). - if(typeof injectM3UStatusCSS === 'function') injectM3UStatusCSS(); - const slides=feed.querySelectorAll('.tiktok-slide'); - let cur=-1; - function showStatus(sl, mode, title, msg){ - if(typeof setM3USlideStatus === 'function') setM3USlideStatus(sl, mode, title, msg); - } - function _hlsPlayable(v){ - return !!(v && v.dataset && v.dataset.hls && !v._hls && typeof Hls!=='undefined' && Hls.isSupported()); - } - function act(i){ - if(i===cur) return; - slides.forEach((sl,idx)=>{ - const v=sl.querySelector('video'); - const fr=sl.querySelector('iframe'); - const isM3U = (sl.dataset.slideKind === 'm3u'); - if(idx===i){ - sl._active=true; - if(v&&v.dataset.hls&&!v._hls&&typeof Hls!=='undefined'&&Hls.isSupported()){ - // Show loading status while the HLS stream initializes - if(isM3U) showStatus(sl,'loading', sl.dataset.chTitle||'', ''); - const hls=new Hls(); - hls.loadSource(v.dataset.hls); - hls.attachMedia(v); - let gotError=false; - hls.on(Hls.Events.MANIFEST_PARSED,()=>{ - v.play().then(function(){ - if(isM3U){ showStatus(sl,'playing'); sl.classList.add('playing'); } - }).catch(function(){ - // Autoplay may be blocked; keep status but try to continue - if(isM3U){ sl.classList.add('playing'); } - }); - }); - // ERROR handler: show error and auto-advance to the next channel - hls.on(Hls.Events.ERROR, function(ev,data){ - if(data && data.fatal){ - gotError=true; - try{hls.destroy();}catch(e){} - v._hls=null; - if(isM3U){ - showStatus(sl,'error', sl.dataset.chTitle||'', (data.type==='networkError'?'Mất kết nối mạng':'Luồng không khả dụng hoặc đã hết hạn.')); - sl.classList.remove('playing'); - // Auto-skip to the next channel - if(typeof m3uHandleError === 'function') m3uHandleError(sl, feed, slides, idx, true); - } else { - showStatus(sl,'error','Không tải được video',''); - } - } - }); - // Also hook the network/load error on the video element as a fallback - v.onerror = function(){ - if(gotError || !isM3U) return; - gotError=true; - try{if(v._hls){v._hls.destroy();v._hls=null;}}catch(e){} - showStatus(sl,'error', sl.dataset.chTitle||'', 'Không đọc được luồng phát.'); - sl.classList.remove('playing'); - if(typeof m3uHandleError === 'function') m3uHandleError(sl, feed, slides, idx, true); - }; - 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(isM3U) showStatus(sl,''); // clear status on inactive m3u slide - if(v){v.pause();if(v._hls){v._hls.destroy();v._hls=null}} - if(fr&&fr.src)fr.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,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));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}} +async function readWallPost(i){const p=_wallPosts[i];if(!p)return;showView('view-article'); + const images = p.images || []; + let imgGallery = ''; + if(images.length > 0){ + imgGallery = '
'; + images.forEach((imgUrl, imgIdx) => { + if(imgIdx === 0){ + imgGallery += ``; + } else { + if(imgIdx === 1) 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 += ``; }); - - 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. + 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(()=>{}); + +(function(){ + try{ + var hash = window.location.hash; + if(hash && hash.length > 1){ + var articleUrl = decodeURIComponent(hash.substring(1)); + if(articleUrl.startsWith('http')){ + history.replaceState(null, '', window.location.pathname); 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 = []; -var _m3uFiltered = []; -var _m3uPage = 0; -var _m3uPageSize = 24; // lazy-load 24 kênh mỗi trang để tránh treo tab -// Default: only show sports (Thể Thao) channels; 'all' shows every group -var _m3uActiveGroup = 'Thể Thao'; - -// === SỰ KIỆN TRỰC TIẾP (live events) support === -// Channels tagged by the backend as live event / football-match channels. -// They are (1) shown in the featured red slide at the top of the Trực tiếp -// tab and (2) always listed on top of the channel grid. -var _m3uEvents = []; -// live international football state from the backend livescore feed: -// {live, leagues, matches_count, live_matches:[{home,away,league,label}]} -var _m3uLiveFootball = {live: false, leagues: [], matches_count: 0, live_matches: []}; - -function _isM3UEvent(ch){ - return !!(ch && (ch.is_event || ch.event_kind)); -} -function _isM3UFootball(ch){ - return _isM3UEvent(ch) && ch.event_kind === 'football'; -} -// Collect event channels from the (grouped, normalized) channel list and -// expose them for the featured slide. -function _collectM3UEvents(channels){ - var out = []; - (channels || []).forEach(function(ch){ - if(_isM3UEvent(ch)) out.push(ch); - }); - _m3uEvents = out; - return out; -} -// Ordering helper: events (football first) before regular channels. -function _m3uSortKey(ch){ - if(_isM3UEvent(ch)) return (_isM3UFootball(ch) ? '0-football-' : '1-event-') + (ch.name || ch.title || ''); - return '2-' + (ch.name || ch.title || ''); -} -function _sortM3UChannels(arr){ - return (arr || []).slice().sort(function(a,b){ return _m3uSortKey(a) < _m3uSortKey(b) ? -1 : 1; }); -} -// Generic slot names (TV360+1, VTVPrime 3, promo...) have NO clear event -// title — they stay in the top-of-grid list but are NOT featured on the -// slide. Football live streams are always featured. -var _M3U_GENERIC_TITLE_RE = /^tv360\+?\d*\s*$|^vtvprime\s*\d*\s*$|promo|quảng cáo/i; -function _hasClearEventTitle(ch){ - var t = String(ch.name || ch.title || '').trim(); - if(t.length < 5) return false; - if(_M3U_GENERIC_TITLE_RE.test(t)) return false; - return true; -} -function _m3uSlideEvents(){ - return _m3uEvents.filter(function(ch){ - if(_isM3UFootball(ch)) return true; - return _hasClearEventTitle(ch); - }); -} -// Build the featured slide HTML ("SỰ KIỆN TRỰC TIẾP") — only rendered when -// at least one live event channel with a clear title exists (football always). -function renderM3UEventSlide(){ - var el = document.getElementById('view-m3u'); - if(!el) return; - var slideEvents = _m3uSlideEvents(); - var slot = document.getElementById('m3u-event-slide'); - if(!slideEvents.length){ - if(slot && slot.parentNode) slot.parentNode.removeChild(slot); - return; - } - var _events = slideEvents; - if(!slot){ - slot = document.createElement('div'); - slot.id = 'm3u-event-slide'; - // Insert below the sticky back button so the slide is the first thing - // users see in the tab content. - var backBtn = el.querySelector('.back-btn'); - if(backBtn && backBtn.nextSibling){ - el.insertBefore(slot, backBtn.nextSibling); - }else{ - el.insertBefore(slot, el.firstChild); + if(typeof readArticle==='function') readArticle(articleUrl); + }, 1500); + } } - } - var h = '
' - + '
SỰ KIỆN TRỰC TIẾP' - + '' - + (_m3uLiveFootball && _m3uLiveFootball.live ? '⚽ ' + (_m3uLiveFootball.matches_count || 0) + ' trận bóng đá quốc tế đang đấu' : _events.length + ' sự kiện đang phát') - + '
' - + '
'; - _events.forEach(function(ch, i){ - var logo = ch.logo || ''; - var title = ch.name || ch.title || 'Kênh'; - var sub = _isM3UFootball(ch) ? (ch.live_football_sports ? '⚽ Bóng đá quốc tế' : '⚽ Bóng đá trực tiếp') : (ch.group || 'Sự kiện'); - // Resolve the index against the filtered (sorted) list the player uses. - var chIdx = _m3uFiltered.indexOf(ch); - if(chIdx < 0) chIdx = _m3uChannels.indexOf(ch); - h += '
' - + '
' - + (logo ? '' : '') - + '
▶
' - + 'LIVE' - + '
' - + '
' + esc(title) + '
' - + '
' + esc(sub) + '
' - + '
'; - }); - h += '
'; - slot.innerHTML = h; -} -// Inject the keyframes used by the LIVE dot in the event slide (idempotent). -function injectM3UEventCSS(){ - var cid = 'm3u-event-css'; - if(document.getElementById(cid)) return; - var st = document.createElement('style'); - st.id = cid; - st.textContent = '@keyframes m3u-pulse{0%,100%{opacity:1}50%{opacity:.3}}'; - document.head.appendChild(st); -} - -function _applyM3UGroupFilter(channels){ - // Filter channel list to the active group. If _m3uActiveGroup === 'all' - // return everything; otherwise keep only channels whose group matches. - // Live event channels (is_event) are ALWAYS included regardless of the - // active group filter — events stay on top. - if(_m3uActiveGroup === 'all' || !_m3uActiveGroup){ - return _sortM3UChannels(channels); - } - return _sortM3UChannels(channels.filter(function(c){ - return _isM3UEvent(c) || (c.group || '') === _m3uActiveGroup; - })); -} - -function setM3UGroupFilter(group){ - // Change the active group filter and re-render the channel grid. - // Pass 'all' to show every group, or a group-title string like 'Thể Thao'. - _m3uActiveGroup = group; - _m3uPage = 0; - filterM3UChannels(); -} - -function _setM3UFallback(imgEl){ - // Called via onerror when a channel logo fails to load. Replaces it - // with a generated SVG fallback based on the channel's group-title. - // ch.group is stored in the closure via the card's dataset. - try{ - var card = imgEl.closest('.m3u-channel-card'); - var group = (card && card.dataset.group) || ''; - imgEl.src = '/api/m3u/logo_fallback?group=' + encodeURIComponent(group) + '&__t=' + Date.now(); }catch(e){} -} +})(); -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...
'; +(function(){ try{ - var controller = new AbortController(); - var timeout = setTimeout(function(){ controller.abort(); }, 15000); - var r = await fetch('/api/m3u/channels', {signal: controller.signal}); - clearTimeout(timeout); - var j = await r.json(); - _m3uLiveFootball = j.live_football || {live: false, leagues: [], matches_count: 0, live_matches: []}; - _m3uChannels = normalizeM3UChannels(j.channels || []); - // Keep live events (is_event) for the featured slide — the backend sorts - // them first, and normalize preserves the flags. - _collectM3UEvents(_m3uChannels); - injectM3UEventCSS(); - }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; - } - filterM3UChannels(); -} - -// Normalize the API response into grouped channel entries: -// [{name, group, logo, streams:[{url, vlcopts}, ...]}, ...] -// Accepts BOTH the new grouped format (streams[]) and the legacy flat format -// (title/url/vlcopts) so the player never breaks on a stale cache. -function normalizeM3UChannels(raw){ - var out = []; - (raw || []).forEach(function(c){ - if(!c) return; - if(Array.isArray(c.streams) && c.streams.length){ - var st = []; - c.streams.forEach(function(s){ - if(s && s.url) st.push({url: s.url, vlcopts: s.vlcopts || {}}); - }); - if(st.length){ - out.push({ - name: String(c.name || c.title || 'Kênh').replace(/\s{2,}/g, ' ').trim(), - group: c.group || 'Khác', - logo: c.logo || '', - streams: st, - is_event: !!c.is_event, - event_kind: c.event_kind || '', - live_football_sports: !!c.live_football_sports - }); - } - } else if(c.url){ - out.push({ - name: String(c.title || c.name || 'Kênh').replace(/\s{2,}/g, ' ').trim(), - group: c.group || 'Khác', - logo: c.logo || '', - streams: [{url: c.url, vlcopts: c.vlcopts || {}}], - is_event: !!c.is_event, - event_kind: c.event_kind || '', - live_football_sports: !!c.live_football_sports - }); - } - }); - return out; -} - -// Build the /api/proxy/m3u_hls URL for one stream, applying VLC headers. -function _m3uProxyUrl(stream){ - var u = stream && stream.url ? stream.url : ''; - if(!u) return ''; - var vopts = (stream && stream.vlcopts) || {}; - var ua = vopts['http-user-agent'] || ''; - var cookie = vopts['http-cookie'] || ''; - var auth = vopts['http-header'] ? vopts['http-header'].replace(/^authorization:\s*/i, '') : ''; - var purl = '/api/proxy/m3u_hls?url=' + encodeURIComponent(u); - if(ua) purl += '&ua=' + encodeURIComponent(ua); - if(cookie) purl += '&cookie=' + encodeURIComponent(cookie); - if(auth) purl += '&auth=' + encodeURIComponent(auth); - return purl; -} - -function _renderM3UGrid(){ - var el = document.getElementById('view-m3u'); - if(!el) return; - var start = _m3uPage * _m3uPageSize; - var pageChannels = _m3uFiltered.slice(start, start + _m3uPageSize); - if(_m3uPage === 0){ - if(!document.getElementById('m3u-grid')){ - var html = ''; - // Group filter selector — default is Thể Thao (sports) - var groups = {}; - _m3uChannels.forEach(function(c){ groups[c.group || 'Khác'] = true; }); - var groupOptions = ''; - if(groups['Giải Trí']) groupOptions += ''; - for(var g in groups){ - if(g === 'Thể Thao' || g === 'Giải Trí') continue; - groupOptions += ''; - } - groupOptions += ''; - html += '
'; - html += ''; - html += '
'; - html += ''; - html += '
'; - html += '
Xem thêm
'; - el.innerHTML = html; - // Restore the selected option after initial render - var gf = document.getElementById('m3u-group-filter'); - if(gf){ gf.value = _m3uActiveGroup; } - } else { - // Page-0 re-render after a search/group change: rebuild ONLY the grid - // cards (the search input/select keep their value and focus). - var _g0 = document.getElementById('m3u-grid'); - if(_g0){ _g0.innerHTML = ''; } + const pa=localStorage.getItem('pending_article'); + const pv=localStorage.getItem('pending_video'); + if(pa){ + localStorage.removeItem('pending_article'); + setTimeout(()=>{ + if(typeof readArticle==='function') readArticle(pa); + },1500); + } + if(pv){ + localStorage.removeItem('pending_video'); + try{ + const v=JSON.parse(pv); + if(v&&v.url) setTimeout(()=>{window.open(v.url,'_blank')},1500); + }catch(e){} } - } - var grid = document.getElementById('m3u-grid'); - if(!grid) return; - // Only wipe on page 0. For load-more (page > 0) we keep the existing cards - // and APPEND the next page, so 'Tải thêm' accumulates channels correctly. - if(_m3uPage === 0){ - grid.innerHTML = ''; - } - var fragment = document.createDocumentFragment(); - pageChannels.forEach(function(ch){ - var idx = _m3uFiltered.indexOf(ch); - var logo = ch.logo || ''; - var title = ch.name || ch.title || 'Kênh'; - var nStreams = (ch.streams && ch.streams.length) || 1; - // FIX: channels without a logo still get a visual thumbnail — use the - // SVG fallback generator so every card shows a proper image, not just text - var imgTag = logo ? '' : ''; - var card = document.createElement('div'); - card.className = 'm3u-channel-card'; - card.dataset.group = (ch.group || ''); - card.dataset.streams = String(nStreams); - card.onclick = function(){ openM3UPlayer(idx); }; - card.title = title + (nStreams > 1 ? ' (' + nStreams + ' luồng)' : ''); - card.innerHTML = '
'+imgTag+'
' - + '
'+esc(title)+'
' - + (nStreams > 1 ? '
'+ nStreams +' luồng
' : ''); - fragment.appendChild(card); - }); - grid.appendChild(fragment); - var moreBtn = document.getElementById('m3u-loadmore'); - if(moreBtn){ - moreBtn.style.display = (start + _m3uPageSize >= _m3uFiltered.length) ? 'none' : 'block'; - moreBtn.textContent = 'Xem thêm'; - moreBtn.style.opacity = '1'; - moreBtn.removeAttribute('data-loading'); - // Re-arm lazy-load observer for the (possibly moved) load-more button. - setupM3UAutoLoad(); - } -} - -// === M3U lazy loading: auto-load the next page when the user scrolls to the -// 'Tải thêm' button, and show a loading state while fetching/appending. === -var _m3uObserver = null; -var _m3uAutoLoading = false; - -function setupM3UAutoLoad(){ - var moreBtn = document.getElementById('m3u-loadmore'); - if(!moreBtn || moreBtn.style.display === 'none') return; - if(_m3uObserver){ _m3uObserver.disconnect(); _m3uObserver = null; } - if(!window.IntersectionObserver) return; // fall back to manual click - _m3uObserver = new IntersectionObserver(function(entries){ - entries.forEach(function(en){ - if(en.isIntersecting && !_m3uAutoLoading && moreBtn.dataset.loading !== '1'){ - loadMoreM3U(); - } - }); - }, {root: null, rootMargin: '250px'}); - _m3uObserver.observe(moreBtn); -} - -function loadMoreM3U(){ - var moreBtn = document.getElementById('m3u-loadmore'); - if(!moreBtn || moreBtn.style.display === 'none') return; - if(moreBtn.dataset.loading === '1' || _m3uAutoLoading) return; // guard double-load - moreBtn.dataset.loading = '1'; - _m3uAutoLoading = true; - moreBtn.textContent = '⏳ Đang tải thêm kênh...'; - moreBtn.style.opacity = '0.7'; - setTimeout(function(){ - _m3uPage++; - _renderM3UGrid(); - setTimeout(function(){ - moreBtn.dataset.loading = ''; - _m3uAutoLoading = false; - if(moreBtn.style.display !== 'none'){ - moreBtn.textContent = 'Xem thêm'; - moreBtn.style.opacity = '1'; - setupM3UAutoLoad(); - } - }, 80); - }, 200); -} - -function filterM3UChannels(){ - // Apply both the active group filter and the search query to _m3uChannels. - var q = (document.getElementById('m3u-search') || {}).value || ''; - var qq = q.toLowerCase().trim(); - // First: filter by group - var byGroup = _applyM3UGroupFilter(_m3uChannels); - // Second: filter by search text (case-insensitive) against the channel NAME - if(!qq){ - _m3uFiltered = byGroup; - }else{ - _m3uFiltered = byGroup.filter(function(c){ - var hay = ((c.name || c.title || '') + ' ' + (c.group || '')).toLowerCase(); - return hay.indexOf(qq) >= 0; - }); - } - _m3uPage = 0; - _renderM3UGrid(); - // Featured event slide on top of the Trực tiếp tab (only when events exist). - renderM3UEventSlide(); -} - -// Helper: escape a string for use inside an HTML attribute value. -function escAttr(s){ - return String(s == null ? '' : s).replace(/[&<\"]/g, function(m){ - return {'&':'&','<':'<','>':'>','"':'"'}[m]; - }); -}// Inject minimal CSS for the m3u channel status overlay (loading / error). -function injectM3UStatusCSS(){ - var _cssId = 'm3u-status-css'; - if(document.getElementById(_cssId)) return; - var st = document.createElement('style'); - st.id = _cssId; - st.textContent = '.slide-status{position:absolute;inset:0;z-index:4;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:20px;pointer-events:none;background:rgba(0,0,0,.55)}' - + '.slide-status:empty{display:none}' - + '.slide-status .st-spinner{width:34px;height:34px;border:3px solid rgba(255,255,255,.25);border-top-color:#5cb87a;border-radius:50%;animation:ht-spin .8s linear infinite;margin-bottom:10px}' - + '.slide-status .st-title{color:#fff;font-size:13px;font-weight:700}' - + '.slide-status .st-msg{color:#ccc;font-size:11px;margin-top:4px;line-height:1.4}' - + '.slide-status.st-error{background:rgba(20,20,20,.78)}' - + '.slide-status.st-error .st-title{color:#ff8b8b}' - + '.slide-status.st-error .st-retry{margin-top:10px;pointer-events:auto;background:#2d8659;border:0;color:#fff;padding:7px 14px;border-radius:16px;font-size:11px;cursor:pointer}' - + '.tiktok-slide.playing .slide-status{opacity:0;visibility:hidden;transition:opacity .3s}'; - document.head.appendChild(st); -} - -// Update the status overlay on a slide. mode: 'loading' | 'playing' | 'error' -function setM3USlideStatus(slide, mode, title, msg){ - if(!slide) return; - var st = slide.querySelector('.slide-status'); - if(!st) return; - st.className = 'slide-status' + (mode === 'error' ? ' st-error' : ''); - if(mode === 'loading'){ - st.innerHTML = '
Đang tải luồng...
' - + (title ? '
'+esc(title)+'
' : ''); - } else if(mode === 'error'){ - st.innerHTML = '
⚠️ Không tải được kênh
' - + (msg ? '
'+esc(msg)+'
' : '') - + ''; - } else if(mode === 'playing'){ - st.innerHTML = ''; - } -} - -// Retry the channel that owns the given retry button (reload its HLS). -function retryM3USlide(btn){ - var slide = btn ? btn.closest('.tiktok-slide') : null; - if(!slide) return; - var v = slide.querySelector('video[data-hls]'); - if(!v) return toast('Không có luồng để tải lại'); - if(v._hls){ try{v._hls.destroy();}catch(e){} v._hls = null; } - setM3USlideStatus(slide, 'loading', slide.dataset.chTitle || '', ''); - if(typeof Hls !== 'undefined' && Hls.isSupported()){ - var hls = new Hls(); - hls.loadSource(v.dataset.hls); - hls.attachMedia(v); - hls.on(Hls.Events.MANIFEST_PARSED, function(){ v.play().catch(function(){}); setM3USlideStatus(slide, 'playing'); slide.classList.add('playing'); }); - hls.on(Hls.Events.ERROR, function(ev, data){ - if(data && data.fatal){ - setM3USlideStatus(slide, 'error', slide.dataset.chTitle || '', 'Luồng lỗi hoặc đã hết hạn.'); - slide.classList.remove('playing'); - } - }); - v._hls = hls; - } else if(v.canPlayType('application/vnd.apple.mpegurl')){ - v.src = v.dataset.hls; - v.onerror = function(){ setM3USlideStatus(slide, 'error', slide.dataset.chTitle || '', 'Không đọc được luồng HLS.'); }; - setM3USlideStatus(slide, 'playing'); - } -} - -// M3U player auto-skip: when the currently active m3u channel fails to load, -// automatically advance to the next channel in the feed. Returns true if skipped. -var _m3uSkipGuard = {}; -function m3uHandleError(slide, feed, slides, curIdx, skipToNext){ - if(!slide) return; - function _tryNext(){ - if(!skipToNext) return; - var next = curIdx + 1; - if(next >= slides.length) next = 0; // wrap around - if(next === curIdx) return; - var key = slide.dataset.vid; - if(_m3uSkipGuard[key] && _m3uSkipGuard[key] > Date.now()) return; // don't loop forever - _m3uSkipGuard[key] = Date.now() + 2500; - slides[next].scrollIntoView({behavior:'smooth', block:'center'}); - setTimeout(function(){ - var ev = new Event('scroll'); - feed.dispatchEvent(ev); - }, 350); - } - _tryNext(); -} - -async function openM3UPlayer(chIdx){ - var ch = _m3uFiltered[chIdx] || _m3uChannels[chIdx]; - if(!ch) return; - var streams = (ch.streams && ch.streams.length) ? ch.streams : [{url: ch.url || '', vlcopts: ch.vlcopts || {}}]; - if(!streams.length || !streams[0].url) return toast('Không có link stream'); - // Show immediate feedback so the user knows the click was registered - toast('Đang tải kênh: ' + (ch.name || ch.title || '...') + '...'); - // FIX: Play immediately from cache — do NOT block on a full sync refresh. - // refresh=1 validates ~820 stream URLs (takes 30-60s). Instead, kick off - // a background refresh that updates the cache for future clicks. The current - // playback uses cached channel data right away. - var chIdxCurrent = chIdx; - (async function _refreshM3UInBackground(){ - try{ - var rr = await fetch('/api/m3u/channels?refresh=1'); - var jj = await rr.json().catch(function(){return {};}); - if(jj.channels && jj.channels.length){ - _m3uChannels = normalizeM3UChannels(jj.channels); - _m3uFiltered = _applyM3UGroupFilter(_m3uChannels); - } - }catch(e){} - })(); - // Use the current cached channel for playback - ch = _m3uFiltered[chIdxCurrent] || _m3uChannels[chIdxCurrent] || ch; - streams = (ch.streams && ch.streams.length) ? ch.streams : [{url: ch.url || '', vlcopts: ch.vlcopts || {}}]; - if(!streams.length || !streams[0].url) return toast('Không có link stream'); - - // Build ordered list (clicked first) from the filtered channels - var ordered = []; - for(var i = chIdxCurrent; i < _m3uFiltered.length; i++) ordered.push(_m3uFiltered[i]); - for(var i = 0; i < chIdxCurrent; i++) ordered.push(_m3uFiltered[i]); - - // Keep the ordered list globally so the copy-link/skip helpers can resolve - // the exact channel by its rotated index. - window._m3uOrdered = ordered; - var h = ''; - h += '
'; - ordered.forEach(function(och, i){ - var chStreams = (och.streams && och.streams.length) ? och.streams : [{url: och.url || '', vlcopts: och.vlcopts || {}}]; - var firstStream = chStreams[0] || {}; - var logo = och.logo || ''; - // Use the FIRST stream as the primary playback URL. If it fails, the - // m3uHandleError auto-skip advances to the next CHANNEL in the list — - // mirror fallback for the same channel is done via the status retry. - var purl = _m3uProxyUrl(firstStream); - var vtag = '
' - + '
'; - var title = och.name || och.title || 'Kênh'; - var _chJson = ''; - try{ _chJson = JSON.stringify(och).replace(/"/g, '"'); }catch(e){} - var _vtagData = ' data-ord="'+i+'"' + (_chJson ? ' data-ch="'+_chJson+'"' : ''); - h += buildTikTokSlide({ - vtag: vtag, - title: title, - badge: 'LIVE', - badgeClass: 'badge-live', - kind: 'm3u', - attrs: _vtagData, - videoId: 'm3u-' + i + '-ch' + (och.id || i), - idx: i, - total: ordered.length, - shareUrl: firstStream.url || location.href, - shareImg: logo, - desc: 'Xem trực tiếp bóng đá và sự kiện thể thao playlist m3u với trình xem Tiktok player!', - postId: '', - extraBtn: '' - }); - }); - h += '
'; - showView('view-tiktok'); - var el = document.getElementById('view-tiktok'); - if(el) el.innerHTML = h; - // After render, mark each m3u slide with its channel title for status messages - var slides = document.querySelectorAll('#tiktok-feed .tiktok-slide'); - slides.forEach(function(sl, i){ - sl.dataset.chTitle = (ordered[i] && (ordered[i].name || ordered[i].title)) || ''; - }); - setTimeout(function(){ if(typeof initTikTokFeed === 'function') initTikTokFeed(); }, 200); -} - -function closeM3UViewer(){ - var feed = document.getElementById('tiktok-feed'); - if(feed){ feed.innerHTML = ''; } - document.querySelectorAll('video[data-hls]').forEach(function(v){ - if(v._hls){ v._hls.destroy(); v._hls = null; } - v.pause(); - v.src = ''; - }); - showView('view-m3u'); -} - -function copyStreamLink(idx){ - // The player passes the ORDERED (rotated) list index; resolve the channel - // via the data attribute on the slide when available, else fall back to - // the filtered list. - var slide = document.querySelector('.tiktok-slide[data-ord="'+idx+'"]'); - var ch = null; - if(slide){ try{ ch = JSON.parse(slide.dataset.ch || 'null'); }catch(e){ ch = null; } } - if(!ch && window._m3uOrdered && _m3uOrdered[idx]) ch = _m3uOrdered[idx]; - if(!ch) ch = _m3uChannels[idx] || (_m3uFiltered ? _m3uFiltered[idx] : null); - if(!ch) return toast('Không có kênh'); - var streams = (ch.streams && ch.streams.length) ? ch.streams : [ch]; - var first = streams[0] || {}; - var streamUrl = first.url || ch.url || ''; - if(!streamUrl) return toast('Không có link stream'); - if(navigator.clipboard && navigator.clipboard.writeText){ - navigator.clipboard.writeText(streamUrl).then(function(){ toast('📋 Đã sao chép link stream!'); }).catch(function(){ - prompt('📋 Link stream:', streamUrl); - }); - }else{ - prompt('📋 Link stream:', streamUrl); - } -} - -// Open a mirror picker for a channel that has multiple streams. The user -// selects which mirror to use; each mirror is probed with the proxy URL. -function pickM3UStream(chIdx){ - var ch = _m3uFiltered[chIdx] || _m3uChannels[chIdx]; - if(!ch) return; - var streams = (ch.streams && ch.streams.length) ? ch.streams : [{url: ch.url || '', vlcopts: ch.vlcopts || {}}]; - if(streams.length <= 1){ openM3UPlayer(chIdx); return; } - var overlay = document.createElement('div'); - overlay.id = 'm3u-stream-picker'; - 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:14px'; - var h = '
'; - h += '

📡 ' + esc(ch.name || ch.title || 'Kênh') + '

'; - h += '
Kênh có ' + streams.length + ' luồng phát. Chọn luồng để xem (nếu luồng đầu lỗi sẽ tự chuyển luồng khác):
'; - h += '
'; - streams.forEach(function(s, i){ - var purl = _m3uProxyUrl(s); - var u = s.url || ''; - h += ''; - }); - h += '
'; - overlay.innerHTML = h; - document.body.appendChild(overlay); -} + }catch(e){} +})(); -// Play a specific stream index of a channel in the m3u player. -function playM3UStream(chIdx, streamIdx){ - var picker = document.getElementById('m3u-stream-picker'); - if(picker) picker.remove(); - var ch = _m3uFiltered[chIdx] || _m3uChannels[chIdx]; - if(!ch) return toast('Không có kênh'); - var streams = (ch.streams && ch.streams.length) ? ch.streams : [{url: ch.url || '', vlcopts: ch.vlcopts || {}}]; - var s = streams[streamIdx] || streams[0]; - if(!s || !s.url) return toast('Luồng không hợp lệ'); - // Build a single-channel feed that starts at this stream - var purl = _m3uProxyUrl(s); - showView('view-tiktok'); - var el = document.getElementById('view-tiktok'); - if(!el) return; - var title = ch.name || ch.title || 'Kênh'; - var h = ''; - h += '
'; - var ordered = [ch]; - window._m3uOrdered = ordered; - var logo = ch.logo || ''; - var vtag = '
' - + '
'; - var _chJson = ''; - try{ _chJson = JSON.stringify(ch).replace(/"/g, '"'); }catch(e){} - h += buildTikTokSlide({ - vtag: vtag, - title: title, - badge: 'LIVE', - badgeClass: 'badge-live', - kind: 'm3u', - attrs: ' data-ord="0"' + (_chJson ? ' data-ch="'+_chJson+'"' : ''), - videoId: 'm3u-0-ch' + (ch.id || 0) + '-' + streamIdx, - idx: 0, - total: 1, - shareUrl: s.url || location.href, - postId: '', - extraBtn: '' +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function() { + if (typeof loadHome === 'function') loadHome(); }); - h += '
'; - el.innerHTML = h; - var sl = el.querySelector('.tiktok-slide'); - if(sl) sl.dataset.chTitle = title; - setTimeout(function(){ if(typeof initTikTokFeed === 'function') initTikTokFeed(); }, 200); +} else { + if (typeof loadHome === 'function') loadHome(); } - -// M3U streams are MPEG-TS streams wrapped in HLS playlists via /api/proxy/m3u_hls. -// The data-hls attribute is handled by the existing initTikTokFeed Hls.js logic. -// No additional patch needed — initTikTokFeed already creates Hls.js instances -// for video[data-hls] elements when they become active. - 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 c47a322057b23ac2c46c67b3ce1a71d2da860744..0000000000000000000000000000000000000000 --- a/static/designer_v2.js +++ /dev/null @@ -1,1182 +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 — REMOVED (tùy chọn ảnh từ rewrite đã bỏ) */ - -/* 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 = ''; - -/* Quote design state (Style 1: Trích dẫn) */ -let _dsQuoteType = 'about'; // 'about' or 'speaker' -let _dsQuoteText = ''; // quote text -let _dsQuoteColor = '#ffd700'; // quote text color -let _dsQuoteSpeaker = ''; // speaker name -let _dsQuoteRole = ''; // speaker role -let _dsQuotePos = 'bottom'; // text position - -/* Grid design state (Style 2: Lưới) */ -let _dsGridQuotes = {}; // {imageIndex: quoteText} -let _dsGridPos = 'bottom'; // text position on grid - -/* ---------------- 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 = 'custom'; // default: no automatic rewrite image; user picks - _dsImgMap = {}; // reset per-slide image overrides - _dsAppliedImg = ''; // reset designer-level applied image - // Reset quote/grid state - _dsQuoteType = 'about'; - _dsQuoteText = ''; - _dsQuoteColor = '#ffd700'; - _dsQuoteSpeaker = ''; - _dsQuoteRole = ''; - _dsQuotePos = 'bottom'; - _dsGridQuotes = {}; - _dsGridPos = 'bottom'; - _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+='
'; - /* 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 sẽ áp dụng cho TẤT CẢ slide đã chọn. Nếu không chọn ảnh, dùng nền mẫu/màu nền.
'; - h+='
'; - - h+='
'; /* /ds-tab-content-design */ - h+=' '; - h+='