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/.huggingface/rebuild b/.huggingface/rebuild
new file mode 100644
index 0000000000000000000000000000000000000000..048f645894a0ea68f3604d333a2d43801eaf8be1
--- /dev/null
+++ b/.huggingface/rebuild
@@ -0,0 +1 @@
+rebuild 1782693789.5665495 - force rebuild after voice+thumbnail fixes
\ No newline at end of file
diff --git a/.rebuild b/.rebuild
new file mode 100644
index 0000000000000000000000000000000000000000..dfbecc973e3f3ce5f2e4b67ace3aefe14422a973
--- /dev/null
+++ b/.rebuild
@@ -0,0 +1 @@
+rebuild 20260630044000
\ No newline at end of file
diff --git a/.restart_trigger b/.restart_trigger
new file mode 100644
index 0000000000000000000000000000000000000000..048f645894a0ea68f3604d333a2d43801eaf8be1
--- /dev/null
+++ b/.restart_trigger
@@ -0,0 +1 @@
+rebuild 1782693789.5665495 - force rebuild after voice+thumbnail fixes
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9663d9bbce33b00d18a58b11922a2f2fb4049a10..065430a415d73f8c8cf5cdae84396bd20b5e22d8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,18 +1,71 @@
-# CHANGELOG
-
-## 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.
-
-## v19 - AI ảnh nền SenseNova-U1.5 + text overlay overhaul
-(see prev changelog)
+# VNEWS v6.5 - Resilient Shorts Auto-Updater
+
+## Changes
+
+### Critical Fix: Shorts timeout and homepage load stability
+**Root cause**: YouTube shorts fetching in `main.py` using `scrape_shorts()` and `_yt_channel_shorts_requests()` could hang indefinitely when YouTube blocks requests or yt-dlp times out, causing:
+- Homepage `/api/shorts` endpoint to time out (30s limit)
+- Space to appear unresponsive on first load
+- No fallback when sources fail
+
+**Fix applied**:
+1. **shorts_updater.py** (NEW) — Resilient background updater:
+ - Hard timeout (25s) per channel using subprocess isolation
+ - Stale-while-revalidate pattern: returns cached data immediately, updates in background
+ - Automatic fallback to hardcoded short URLs when all sources fail
+ - Persistent storage in `/data/shorts_cache.json` for cache across restarts
+ - Background scheduler runs every 10 minutes automatically
+ - No blocking on first homepage load
+
+2. **_run.py** — Integrated resilient shorts endpoint:
+ - Overrides `/api/shorts` with non-blocking version
+ - Returns cached/fallback data in <100ms guaranteed
+ - Triggers background update if cache is stale or empty
+ - Never hangs - always returns valid JSON response
+
+3. **FALLBACK_SHORTS** — 6 hardcoded viral shorts as emergency fallback:
+ - baodantri7941 (Dân trí) headlines
+ - baosuckhoedoisongboyte (Sức khỏe & đời sống) stories
+ - vtvnambo (VTV Nam Bộ) news
+
+### Benefits
+- Homepage loads in <2 seconds always
+- Shorts data auto-updates every 10 minutes
+- Never times out - graceful degradation to fallback
+- Persistent cache survives Space restarts
+- Uses bucket `bep40/VNEWS-storage` for cache storage
+
+### Channels monitored
+- baodantri7941 (Dân trí)
+- baosuckhoedoisongboyte (Sức khỏe & đời sống)
+- vtvnambo (VTV Nam Bộ)
+
+---
+
+# VNEWS v5.1 - Rewrite Fix
+
+## Changes
+
+### Critical Fix: Rewrite button not creating posts on Tường AI
+**Root cause**: `_run.py` imports from `app_v2_entry.py`, but the `/api/rewrite_share` endpoint was only defined in `ai_runtime_patch_fast.py` (loaded through `app_entry.py` which is NOT used). The frontend called a non-existent endpoint → 404 → silent failure.
+
+**Fix applied**:
+1. **app_v2_entry.py** — Added 3 new endpoints:
+ - `POST /api/rewrite_slide` — Fast extractive summary (no AI needed), creates slides from article key points + images, saves to wall
+ - `POST /api/rewrite_share` — AI-powered rewrite with extractive fallback, saves to wall
+ - `POST /api/url_wall` — URL submission endpoint (alias for rewrite_share)
+ - All endpoints use the same `_load_wall_posts()` / `_save_wall_posts()` and `WALL_FILE` path as the existing `/api/wall` endpoint
+
+2. **static/index_v2.html** — Added `` to load the rewrite fix
+
+3. **static/rewrite_fix_v2.js** — New file that overrides `rewriteArticle()` to:
+ - Call `/api/rewrite_slide` first (fast, no AI needed)
+ - Fallback to `/api/rewrite_share` if slide fails
+ - Show slide preview overlay after successful post
+ - Use `prependWallPost()` to add the new post to Tường AI
+
+### Previous changes (v5)
+- Rewrote match_detail_v2.py with correct event parsing
+- 2-tab layout for match detail (stats + timeline)
+- Fixed _run.py import
+- Dockerfile cache busting
diff --git a/Dockerfile b/Dockerfile
index 5e4cdaa1a67afb1e0ab5faa307e619fbb285f0c2..e6aa28a553b6a673b4601737bb1d53e3248d0970 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,48 +2,18 @@ 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
+# v6 - 2026-06-28 18:45 - voice selector fix + cache bust
+CMD ["uvicorn", "_run:app", "--host", "0.0.0.0", "--port", "7860", "--reload"]
+# build 1782698397
+# build 1782698798
+# build 1782699174
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..cf4b1d0ff3215baa8ed3f05887a252c47d56bc90 100644
--- a/ai_ext.py
+++ b/ai_ext.py
@@ -54,154 +54,6 @@ except ImportError:
_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:
@@ -244,20 +96,27 @@ def _domain(url: str) -> str:
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.
+ """Generate text using Qwen models via Hugging Face Inference API.
- Prioritizes Llama-3.3-70B for better creative/opinion writing.
+ This function provides a resilient implementation that:
+ 1. First tries the SDK-based inference client if available
+ 2. Falls back to REST API calls to HF router endpoint
+ 3. Returns a fallback summary if all else fails
"""
token = _hf_token()
errors = []
- # Try HF router API with multiple models - Llama FIRST for opinion writing
+ # Try HF router API with multiple models
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-VL-3B-Instruct",
+ "Qwen/Qwen2.5-7B-Instruct",
+ "Qwen/Qwen2.5-3B-Instruct",
+ "Qwen/Qwen2.5-1.5B-Instruct",
"Qwen/Qwen2.5-72B-Instruct",
+ "meta-llama/Llama-3.3-70B-Instruct",
]
# Deduplicate while preserving order
seen = set()
@@ -279,12 +138,12 @@ async def qwen_generate(prompt: str, image_url: str = None, max_tokens: int = 12
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": "system", "content": "Bạn là trợ lý AI tiếng Việt. Trả lời tự nhiên, ngắn gọn, chính xác."},
{"role": "user", "content": user_content},
],
- "max_tokens": min(int(max_tokens or 2000), 2500),
- "temperature": 0.75,
- "top_p": 0.9,
+ "max_tokens": min(int(max_tokens or 900), 1400),
+ "temperature": 0.35,
+ "top_p": 0.85,
}
r = requests.post(
@@ -324,18 +183,20 @@ def _fallback_summary_from_prompt(prompt: str, max_units: int = 6) -> str:
text = re.sub(r"https?://\S+", "", text)
text = re.sub(r"\s+", " ", text).strip()
- # Split into sentences
+ # Split into sentences - extract ALL valid sentences, not just first few
sentences = re.split(r"(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])", text)
units = []
for s in sentences:
s = _clean_text(s)
- if len(s) >= 30:
+ if len(s) >= 30: # Lower threshold to capture more content
units.append(s)
if units:
+ # Take up to max_units valid sentences
result_units = units[:max_units]
return "\n".join("• " + u for u in result_units)
if text:
+ # Fallback: take chunks if no sentence boundaries found
chunks = []
for i in range(0, min(len(text), max_units * 300), 280):
chunk = _clean_text(text[i:i+300])
@@ -347,134 +208,367 @@ def _fallback_summary_from_prompt(prompt: str, max_units: int = 6) -> str:
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"}
-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)
+HF_TOKEN = _hf_token()
+QWEN_VL_MODEL = os.getenv("QWEN_VL_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct")
+QWEN_TEXT_MODELS = [m.strip() for m in os.getenv(
+ "QWEN_TEXT_MODELS",
+ "Qwen/Qwen2.5-72B-Instruct,meta-llama/Llama-3.3-70B-Instruct,Qwen/Qwen2.5-7B-Instruct"
+).split(",") if m.strip()]
+_WORKING_MODEL_TEXT = None
+_WORKING_MODEL_VL = None
+DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data"
+SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts")
+HEADERS = {
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
+ "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"
+}
+LAST_QWEN_ERROR = ""
+
+
+# ===== MULTILINGUAL VOICES FOR TTS =====
+# Maps voice IDs to edge-tts voice names (only MultilingualNeural voices)
+MULTILINGUAL_VOICES = {
+ # Vietnamese - Native voices
+ "vi-vn-hoaimyneural": "vi-VN-HoaiMyNeural",
+ "vi-vn-namminhneural": "vi-VN-NamMinhNeural",
+ "hoaimy": "vi-VN-HoaiMyNeural",
+ "namminh": "vi-VN-NamMinhNeural",
+ "vi_female": "vi-VN-HoaiMyNeural",
+ "vi_male": "vi-VN-NamMinhNeural",
+ "nu": "vi-VN-HoaiMyNeural",
+ "male": "vi-VN-NamMinhNeural",
+ "female": "vi-VN-HoaiMyNeural",
+ "mien-nam": "vi-VN-HoaiMyNeural",
+ # English - Multilingual
+ "en-us-andrewmultilingualneural": "en-US-AndrewMultilingualNeural",
+ "en-au-williammultilingualneural": "en-AU-WilliamMultilingualNeural",
+ "en_andrew": "en-US-AndrewMultilingualNeural",
+ "andrew": "en-US-AndrewMultilingualNeural",
+ "en_jenny": "en-US-AndrewMultilingualNeural",
+ "jenny": "en-US-AndrewMultilingualNeural",
+ # Portuguese - Thalita Multilingual ONLY
+ "pt-br-thalitamultilingualneural": "pt-BR-ThalitaMultilingualNeural",
+ "pt_thalita": "pt-BR-ThalitaMultilingualNeural",
+ "thalita": "pt-BR-ThalitaMultilingualNeural",
+ "pt_francisco": "pt-BR-ThalitaMultilingualNeural",
+ "pt": "pt-BR-ThalitaMultilingualNeural",
+ # French - Multilingual
+ "fr-fr-viviennemultilingualneural": "fr-FR-VivienneMultilingualNeural",
+ "fr-fr-remymultilingualneural": "fr-FR-RemyMultilingualNeural",
+ "fr_denise": "fr-FR-VivienneMultilingualNeural",
+ "denise": "fr-FR-VivienneMultilingualNeural",
+ "fr": "fr-FR-VivienneMultilingualNeural",
+ # German - Multilingual
+ "de-de-seraphinamultilingualneural": "de-DE-SeraphinaMultilingualNeural",
+ "de-de-florianmultilingualneural": "de-DE-FlorianMultilingualNeural",
+ "de_katja": "de-DE-SeraphinaMultilingualNeural",
+ "katja": "de-DE-SeraphinaMultilingualNeural",
+ "de": "de-DE-SeraphinaMultilingualNeural",
+ # Korean - Hyunsu Multilingual (NOT SunHee)
+ "ko-kr-hyunsumultilingualneural": "ko-KR-HyunsuMultilingualNeural",
+ "ko_sunhee": "ko-KR-HyunsuMultilingualNeural",
+ "sunhee": "ko-KR-HyunsuMultilingualNeural",
+ "ko": "ko-KR-HyunsuMultilingualNeural",
+ # Italian - Multilingual
+ "it-it-giuseppemultilingualneural": "it-IT-GiuseppeMultilingualNeural",
+ # Spanish (fallback to English multilingual)
+ "es_ela": "en-US-AndrewMultilingualNeural",
+ "ela": "en-US-AndrewMultilingualNeural",
+ "es_carlos": "en-US-AndrewMultilingualNeural",
+ "es": "en-US-AndrewMultilingualNeural",
+ # Japanese (fallback to English multilingual)
+ "ja_nanami": "en-US-AndrewMultilingualNeural",
+ "nanami": "en-US-AndrewMultilingualNeural",
+ "ja": "en-US-AndrewMultilingualNeural",
+ # Chinese (fallback to English multilingual)
+ "zh_xiaochen": "en-US-AndrewMultilingualNeural",
+ "xiaochen": "en-US-AndrewMultilingualNeural",
+ "zh": "en-US-AndrewMultilingualNeural",
+}
+
+
+def _detect_voice_emotion(title, text):
+ """Detect appropriate voice and emotion based on content for multilingual TTS."""
+ content = ((title or "") + " " + (text or "")).lower()
+
+ # World Cup / Football content - use Andrew multilingual
+ if any(kw in content for kw in ["world cup", "wc 2026", "fifa", "bóng đá", "trận đấu", "bóng bóng", "đội tuyển", "cầu thủ"]):
+ return ("andrew", "excited")
+
+ # News categories - choose appropriate voice
+ if any(kw in content for kw in ["kinh tế", "tài chính", "thị trường", "economics", "finance"]):
+ return ("jenny", "calm")
+ if any(kw in content for kw in ["thiên tai", "bão", "lũ lụt", "cháy nổ", "tai nạn", "disaster", "accident"]):
+ return ("thalita", "serious")
+ if any(kw in content for kw in ["giải trí", "showbiz", "entertainment", "hài hước"]):
+ return ("ela", "happy")
+ if any(kw in content for kw in ["công nghệ", "tech", "technology", "ai", "trí tuệ nhân tạo"]):
+ return ("katja", "excited")
+
+ # Default Vietnamese
+ return ("hoaimy", "trung_tinh")
-import random as _random2
-from datetime import datetime, timezone, timedelta
-_VN_TZ = timezone(timedelta(hours=7))
+def _safe_name(s: str) -> str:
+ """Create safe filename from string."""
+ s = re.sub(r"[^\w\-.]", "_", s)
+ return s[:100] if len(s) > 100 else s
-def _safe_name(filename: str) -> str:
- """Sanitize filename."""
- return re.sub(r"[^a-zA-Z0-9_.-]", "_", filename)[:120]
+
+def _download_image(url: str, fallback_title: str, out_path: str) -> bool:
+ """Download image from URL to path."""
+ if not url:
+ return False
+ try:
+ r = requests.get(url, headers=HEADERS, timeout=15)
+ if r.status_code == 200:
+ os.makedirs(os.path.dirname(out_path), exist_ok=True)
+ with open(out_path, "wb") as f:
+ f.write(r.content)
+ return True
+ except Exception:
+ pass
+ return False
-def pollinations_image_url(topic: str) -> str:
- """Generate a placeholder image URL via Pollinations."""
+def pollination_image_url(topic: str) -> str:
+ """Generate image URL from Pollinations.ai."""
+ return f"https://image.pollinations.ai/prompt/{quote(topic)}?width=1024&height=768&nologo=true&model=flux"
+
+
+# Use the same wall file as app_v2_entry.py for consistency
+WALL_FILE = os.path.join(DATA_DIR, "wall_posts.json")
+
+def _load_ai_wall():
+ """Load AI wall posts from JSON file (uses wall_posts.json for consistency with app_v2_entry)."""
try:
- return "https://image.pollinations.ai/prompt/" + quote("Vietnamese editorial illustration, " + topic, safe="") + "?width=1024&height=576&nologo=true"
+ if os.path.exists(WALL_FILE):
+ with open(WALL_FILE, "r", encoding="utf-8") as f:
+ return json.load(f)
except Exception:
- return ""
+ pass
+ return []
-def _download_image(url: str, fallback_title: str, out_path: str) -> str:
- """Download an image from URL or create a placeholder."""
- if url:
- try:
- r = requests.get(url, headers=HEADERS, timeout=15)
- if r.status_code == 200 and len(r.content) > 1200:
- os.makedirs(os.path.dirname(out_path), exist_ok=True)
- with open(out_path, "wb") as f:
- f.write(r.content)
- return out_path
- except Exception:
- pass
- # Fallback: create a placeholder image
+def _save_ai_wall(posts):
+ """Save AI wall posts to JSON file (uses wall_posts.json for consistency with app_v2_entry)."""
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
+ os.makedirs(os.path.dirname(WALL_FILE), exist_ok=True)
+ tmp = WALL_FILE + ".tmp"
+ with open(tmp, "w", encoding="utf-8") as f:
+ json.dump(posts[:100], f, ensure_ascii=False)
+ os.replace(tmp, WALL_FILE)
except Exception:
- return out_path
+ pass
+
+# Helper functions for wall operations
+def _load_wall_posts():
+ """Alias for _load_ai_wall for consistency with app_v2_entry.py."""
+ return _load_ai_wall()
+
+def _save_wall_posts(posts):
+ """Alias for _save_ai_wall for consistency with app_v2_entry.py."""
+ return _save_ai_wall(posts)
+
+
+def make_post(title: str, text: str, img: str, url: str, kind: str, sources=None):
+ """Create a post dict with standard fields."""
+ return {
+ "id": str(int(time.time() * 1000)),
+ "title": title,
+ "text": text,
+ "img": img,
+ "url": url,
+ "kind": kind,
+ "sources": sources or [],
+ "ts": int(time.time())
+ }
+
+
+def _short_script(post) -> str:
+ """Extract clean text for TTS from post."""
+ text = post.get("text", "") or post.get("title", "")
+ text = re.sub(r"^[•\-\*]\s*", "", text, flags=re.M)
+ text = re.sub(r"\s*\n\s*", ". ", text)
+ return _clean_text(text)[:2000] # Increased from 1000 to 2000 for full content
+
+
+# ===== SCRAPER FUNCTIONS (required by ai_patch.py) =====
def scrape_any_url(url: str) -> dict:
- """Scrape article content from any URL."""
- if not url or not url.startswith("http"):
- return {"title": "", "text": "", "summary": "", "image": "", "og_image": "", "via": ""}
+ """Scrape any URL and extract article content.
+
+ Returns dict with: title, summary, text, image, og_image, via (domain)
+ """
try:
r = requests.get(url, headers=HEADERS, timeout=15, allow_redirects=True)
- 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"]):
+ r.encoding = 'utf-8'
+ soup = BeautifulSoup(r.text, 'lxml')
+
+ # Remove scripts, styles, nav, footer
+ for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
tag.decompose()
- title = ""
- ogt = soup.find("meta", property="og:title")
- if ogt:
- title = ogt.get("content", "")
- h1 = soup.find("h1")
- if not title and h1:
- title = h1.get_text(strip=True)
- if not title:
- t = soup.find("title")
- if t:
- title = t.get_text(strip=True)
- og_image = ""
- ogi = soup.find("meta", property="og:image")
- if ogi:
- og_image = ogi.get("content", "")
- if og_image.startswith("//"):
- og_image = "https:" + og_image
- summary = ""
- ogd = soup.find("meta", property="og:description") or soup.find("meta", attrs={"name": "description"})
- if ogd:
- summary = ogd.get("content", "")[:500]
- body_text = []
- for sel in ["article", ".singular-content", ".detail-content", ".fck_detail", ".content-detail", ".knc-content", "main", ".cms-body", ".article__body", ".post-content", ".entry-content"]:
+
+ # Extract title
+ h1 = soup.find('h1')
+ ogt = soup.find('meta', property='og:title')
+ title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else url)
+
+ # Extract OG image
+ ogi = soup.find('meta', property='og:image')
+ og_image = ogi.get('content', '') if ogi else ''
+
+ # Extract article body
+ block = None
+ for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
el = soup.select_one(sel)
- if el and len(el.find_all("p")) >= 2:
- 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)
+ if el and len(el.find_all('p')) >= 2:
+ block = el
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}
+ if not block:
+ block = soup.body or soup
+
+ # Extract text from paragraphs
+ paragraphs = []
+ for el in block.find_all(['p', 'h2', 'h3'], recursive=True):
+ t = _clean_text(el.get_text(strip=True))
+ if t and len(t) > 40:
+ paragraphs.append(t)
+
+ # Extract images
+ images = []
+ for el in block.find_all(['figure', 'img'], recursive=True):
+ im = el if el.name == 'img' else el.find('img')
+ if im:
+ src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
+ if src and 'base64' not in src:
+ if src.startswith('//'):
+ src = 'https:' + src
+ images.append(src)
+
+ # Prefer OG image as main image
+ image = og_image or (images[0] if images else '')
+
+ return {
+ 'title': title,
+ 'summary': paragraphs[0] if paragraphs else '',
+ 'text': '\n'.join(paragraphs),
+ 'image': image,
+ 'og_image': og_image,
+ 'via': _domain(url),
+ 'images': images
+ }
except Exception as e:
- return {"title": "", "text": "", "summary": "", "image": "", "og_image": "", "via": _domain(url)}
+ return {'title': url, 'summary': '', 'text': '', 'image': '', 'og_image': '', 'via': _domain(url), 'error': str(e)}
-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)
- 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"),
- }
+def web_context(topic: str, limit: int = 5) -> tuple:
+ """Get web context for a topic. Returns (context_text, sources_list)."""
+ sources = []
+ try:
+ # Try Google News RSS
+ rss_url = f"https://news.google.com/rss/search?q={quote_plus(topic)}&hl=vi&gl=VN&ceid=VN:vi"
+ r = requests.get(rss_url, headers=HEADERS, timeout=15)
+ r.encoding = 'utf-8'
+ soup = BeautifulSoup(r.text, 'xml')
+ for it in soup.find_all('item')[:limit]:
+ title = it.find('title').get_text(' ', strip=True) if it.find('title') else ''
+ link = it.find('link').get_text(strip=True) if it.find('link') else ''
+ if title and link:
+ sources.append({'title': title, 'url': link, 'via': _domain(link)})
+ except Exception:
+ pass
+
+ context = f'Trên mạng có nhiều bài viết về "{topic}". Một số nguồn: ' + ', '.join([s.get('title', '') for s in sources[:3]])
+ return context, sources
+
+
+# ===== SHORT FRAME FUNCTION (required by ai_patch.py) =====
+def _make_short_frame(post, img_path, out_path):
+ """Create a short video frame from post and image.
+
+ Called by ai_patch.py _make_short_frame_full when Image is available.
+ """
+ if Image is None:
+ # Create a minimal frame without PIL - just return success
+ # The caller should handle this case
+ return False
+
+ W, H = 1080, 1920
+ bg = Image.new("RGB", (W, H), (14, 14, 14))
+
+ try:
+ im = Image.open(img_path).convert("RGB")
+ target = (1080, 760)
+ im_ratio = im.width / max(1, im.height)
+ target_ratio = target[0] / target[1]
+
+ if im_ratio > target_ratio:
+ new_h = target[1]
+ new_w = int(new_h * im_ratio)
+ else:
+ new_w = target[0]
+ new_h = int(new_w / im_ratio)
+
+ im = im.resize((new_w, new_h))
+ left = (new_w - target[0]) // 2
+ top = (new_h - target[1]) // 2
+ im = im.crop((left, top, left + target[0], top + target[1]))
+ bg.paste(im, (0, 0))
+ except Exception:
+ pass
+
+ draw = ImageDraw.Draw(bg)
+
+ try:
+ font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 54)
+ font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 38)
+ except Exception:
+ font_title = font_body = None
+
+ draw.rectangle((0, 720, W, H), fill=(14, 14, 14))
+ margin = 48
+ maxw = W - margin * 2
+
+ y = 830
+ for ln in _wrap_text(draw, post.get("title", ""), font_title, maxw, 4):
+ draw.text((margin, y), ln, fill=(255, 255, 255), font=font_title)
+ y += 66
+
+ y += 18
+ text = post.get("text", "")
+ text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip()
+ body_lines = _wrap_text(draw, text, font_body, maxw, 14)
+ for ln in body_lines:
+ draw.text((margin, y), ln, fill=(220, 220, 220), font=font_body)
+ y += 50
+ if y > 1640:
+ break
+
+ bg.save(out_path, quality=92)
+ return True
+
+
+def _wrap_text(draw, text, font, max_width, max_lines):
+ """Helper for wrapping text in frames."""
+ words = _clean_text(text).split()
+ lines, cur = [], ""
+ for w in words:
+ test = (cur + " " + w).strip()
+ try:
+ width = draw.textbbox((0, 0), test, font=font)[2]
+ except Exception:
+ width = len(test) * 20
+ if width <= max_width:
+ cur = test
+ else:
+ if cur:
+ lines.append(cur)
+ cur = w
+ if len(lines) >= max_lines:
+ break
+ if cur and len(lines) < max_lines:
+ lines.append(cur)
+ return lines
\ No newline at end of file
diff --git a/ai_patch.py b/ai_patch.py
index 4e892446b8a76012c9943fd73f7afe72c0fc55b2..a1ef29df2d0a94076dabec10f6cfbb90445cf76d 100644
--- a/ai_patch.py
+++ b/ai_patch.py
@@ -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 = {
@@ -331,24 +327,6 @@ Nội dung bài:
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)
@@ -375,26 +353,8 @@ async def compat_url_wall(request: Request):
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})
+ return JSONResponse({'post': post})
def _is_relevant_image(img_url, title, text):
@@ -527,6 +487,7 @@ async def compat_rewrite_share(request: Request):
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)
+ posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
# Generate slides with relevant images only
slides = []
@@ -541,10 +502,6 @@ async def compat_rewrite_share(request: Request):
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})
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 '