Spaces:
Running
Running
Restore to commit bdf29090
#38
by bep40 - opened
- .gitignore +0 -6
- .huggingface/main.json +0 -0
- .restart_trigger +1 -0
- CHANGELOG.md +70 -12
- Dockerfile +1 -1
- README.md +1 -26
- RESTART_TRIGGER.md +5 -6
- _run.py +10 -1
- ai_ext.py +406 -137
- ai_patch.py +272 -869
- ai_runtime.py +2 -1
- ai_runtime_final.py +10 -10
- ai_runtime_final6.py +12 -18
- ai_runtime_fix.py +0 -16
- ai_runtime_patch_fast.py +549 -24
- ai_runtime_patch_final.py +0 -78
- ai_short_v2.py +0 -2083
- app_patch_unified.py +62 -3
- app_v2.js +0 -0
- app_v2_entry.py +92 -1032
- app_v2_entry_hot.py +0 -24
- auto_update_sse.py +0 -55
- bdp_full.html +0 -1250
- bdp_page.html +0 -1250
- embed.html +0 -56
- headers.txt +0 -9
- index_v2.html +0 -98
- main.py +200 -615
- patch_ai_hot.py +0 -55
- rebuild_trigger.txt +1 -1
- requirements.txt +1 -3
- restart.txt +1 -1
- restart_trigger.md +0 -14
- rewrite_slide.py +7 -26
- static/app_v2.js +0 -0
- static/designer_v2.js +0 -903
- static/fm_fix.css +0 -247
- static/hot_multi.js +16 -90
- static/index_v2.html +5 -24
- static/live_mode.js +1 -23
- static/rewrite_fix_v2.js +0 -2
- static/vtv_init.js +93 -115
- static/yt_live.js +175 -13
- vtv_api.py +35 -31
.gitignore
DELETED
|
@@ -1,6 +0,0 @@
|
|
| 1 |
-
__pycache__/
|
| 2 |
-
*.pyc
|
| 3 |
-
data/
|
| 4 |
-
.data
|
| 5 |
-
.huggingface/
|
| 6 |
-
.restart_trigger
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.huggingface/main.json
ADDED
|
File without changes
|
.restart_trigger
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
restart v5.1 - auto-scheduler trigger endpoint added
|
CHANGELOG.md
CHANGED
|
@@ -1,16 +1,74 @@
|
|
| 1 |
-
#
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
## Changes
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# VNEWS v2.8 - Icon Change
|
| 2 |
+
- Changed Short AI feed video share button icon from 📤 (upload/share) to 📥 (download) to distinguish from article share
|
| 3 |
|
| 4 |
+
# VNEWS v6.5 - Resilient Shorts Auto-Updater
|
| 5 |
|
| 6 |
+
## Changes
|
| 7 |
+
|
| 8 |
+
### Critical Fix: Shorts timeout and homepage load stability
|
| 9 |
+
**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:
|
| 10 |
+
- Homepage `/api/shorts` endpoint to time out (30s limit)
|
| 11 |
+
- Space to appear unresponsive on first load
|
| 12 |
+
- No fallback when sources fail
|
| 13 |
+
|
| 14 |
+
**Fix applied**:
|
| 15 |
+
1. **shorts_updater.py** (NEW) — Resilient background updater:
|
| 16 |
+
- Hard timeout (25s) per channel using subprocess isolation
|
| 17 |
+
- Stale-while-revalidate pattern: returns cached data immediately, updates in background
|
| 18 |
+
- Automatic fallback to hardcoded short URLs when all sources fail
|
| 19 |
+
- Persistent storage in `/data/shorts_cache.json` for cache across restarts
|
| 20 |
+
- Background scheduler runs every 10 minutes automatically
|
| 21 |
+
- No blocking on first homepage load
|
| 22 |
+
|
| 23 |
+
2. **_run.py** — Integrated resilient shorts endpoint:
|
| 24 |
+
- Overrides `/api/shorts` with non-blocking version
|
| 25 |
+
- Returns cached/fallback data in <100ms guaranteed
|
| 26 |
+
- Triggers background update if cache is stale or empty
|
| 27 |
+
- Never hangs - always returns valid JSON response
|
| 28 |
+
|
| 29 |
+
3. **FALLBACK_SHORTS** — 6 hardcoded viral shorts as emergency fallback:
|
| 30 |
+
- baodantri7941 (Dân trí) headlines
|
| 31 |
+
- baosuckhoedoisongboyte (Sức khỏe & đời sống) stories
|
| 32 |
+
- vtvnambo (VTV Nam Bộ) news
|
| 33 |
+
|
| 34 |
+
### Benefits
|
| 35 |
+
- Homepage loads in <2 seconds always
|
| 36 |
+
- Shorts data auto-updates every 10 minutes
|
| 37 |
+
- Never times out - graceful degradation to fallback
|
| 38 |
+
- Persistent cache survives Space restarts
|
| 39 |
+
- Uses bucket `bep40/VNEWS-storage` for cache storage
|
| 40 |
+
|
| 41 |
+
### Channels monitored
|
| 42 |
+
- baodantri7941 (Dân trí)
|
| 43 |
+
- baosuckhoedoisongboyte (Sức khỏe & đời sống)
|
| 44 |
+
- vtvnambo (VTV Nam Bộ)
|
| 45 |
+
|
| 46 |
+
---
|
| 47 |
+
|
| 48 |
+
# VNEWS v5.1 - Rewrite Fix
|
| 49 |
|
| 50 |
## Changes
|
| 51 |
+
|
| 52 |
+
### Critical Fix: Rewrite button not creating posts on Tường AI
|
| 53 |
+
**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.
|
| 54 |
+
|
| 55 |
+
**Fix applied**:
|
| 56 |
+
1. **app_v2_entry.py** — Added 3 new endpoints:
|
| 57 |
+
- `POST /api/rewrite_slide` — Fast extractive summary (no AI needed), creates slides from article key points + images, saves to wall
|
| 58 |
+
- `POST /api/rewrite_share` — AI-powered rewrite with extractive fallback, saves to wall
|
| 59 |
+
- `POST /api/url_wall` — URL submission endpoint (alias for rewrite_share)
|
| 60 |
+
- All endpoints use the same `_load_wall_posts()` / `_save_wall_posts()` and `WALL_FILE` path as the existing `/api/wall` endpoint
|
| 61 |
+
|
| 62 |
+
2. **static/index_v2.html** — Added `<script src="/static/rewrite_fix_v2.js"></script>` to load the rewrite fix
|
| 63 |
+
|
| 64 |
+
3. **static/rewrite_fix_v2.js** — New file that overrides `rewriteArticle()` to:
|
| 65 |
+
- Call `/api/rewrite_slide` first (fast, no AI needed)
|
| 66 |
+
- Fallback to `/api/rewrite_share` if slide fails
|
| 67 |
+
- Show slide preview overlay after successful post
|
| 68 |
+
- Use `prependWallPost()` to add the new post to Tường AI
|
| 69 |
+
|
| 70 |
+
### Previous changes (v5)
|
| 71 |
+
- Rewrote match_detail_v2.py with correct event parsing
|
| 72 |
+
- 2-tab layout for match detail (stats + timeline)
|
| 73 |
+
- Fixed _run.py import
|
| 74 |
+
- Dockerfile cache busting
|
Dockerfile
CHANGED
|
@@ -20,7 +20,7 @@ RUN echo "[BUILD] step2: pip base pkgs (bs4/lxml)" && \
|
|
| 20 |
echo "[BUILD] step2 done"
|
| 21 |
|
| 22 |
RUN echo "[BUILD] step3: pip main pkgs" && \
|
| 23 |
-
pip install --no-cache-dir fastapi uvicorn requests beautifulsoup4 jinja2 yt-dlp huggingface_hub gTTS pillow edge-tts python-dateutil httpx
|
| 24 |
echo "[BUILD] step3 done"
|
| 25 |
|
| 26 |
COPY requirements.txt .
|
|
|
|
| 20 |
echo "[BUILD] step2 done"
|
| 21 |
|
| 22 |
RUN echo "[BUILD] step3: pip main pkgs" && \
|
| 23 |
+
pip install --no-cache-dir fastapi uvicorn requests beautifulsoup4 jinja2 yt-dlp huggingface_hub gTTS pillow edge-tts python-dateutil httpx && \
|
| 24 |
echo "[BUILD] step3 done"
|
| 25 |
|
| 26 |
COPY requirements.txt .
|
README.md
CHANGED
|
@@ -30,29 +30,4 @@ tags:
|
|
| 30 |
- 🏆 World Cup 2026 (news, fixtures, standings, stats, highlights)
|
| 31 |
- 🤖 AI article writing + TTS (multilingual, emotion-aware)
|
| 32 |
- 🔍 Topic search (8 news sources)
|
| 33 |
-
- 🎤 TTS: voice selector + emotion selector + speed control
|
| 34 |
-
|
| 35 |
-
## 🎬 Short AI — Video từ link (scrap YouTube / TikTok / tin tức)
|
| 36 |
-
|
| 37 |
-
Short creator có chế độ **"🔗 Video từ link"**: dán link video YouTube / TikTok /
|
| 38 |
-
VnExpress / Dân trí / Znews / 24h... → bấm "Lấy video" để xem trước → tạo short
|
| 39 |
-
chạy video + ảnh đã chọn bù phần còn thiếu nếu video ngắn hơn giọng đọc.
|
| 40 |
-
|
| 41 |
-
### 🔑 Cài cookies cho YouTube (bỏ chặn "Sign in to confirm you're not a bot")
|
| 42 |
-
YouTube đôi khi chặn IP datacenter. Cách khắc phục bằng cookies:
|
| 43 |
-
|
| 44 |
-
1. Cài extension trình duyệt **"Get cookies.txt LOCALLY"** (Chrome/Edge) hoặc
|
| 45 |
-
**"cookies.txt"** (Firefox).
|
| 46 |
-
2. Mở `https://www.youtube.com` (đã đăng nhập) → bấm extension → **Export** → ra file `cookies.txt` (định dạng Netscape).
|
| 47 |
-
3. Đưa cookies vào Space bằng **một trong hai cách**:
|
| 48 |
-
- **Cách A (khuyến nghị):** Vào Settings của Space
|
| 49 |
-
`huggingface.co/spaces/bep40/VNEWS/settings` → **Variables and secrets** →
|
| 50 |
-
tạo secret tên **`YT_COOKIES`**, giá trị = toàn bộ nội dung file `cookies.txt`.
|
| 51 |
-
- **Cách B:** đặt file `cookies.txt` vào thư mục gốc repo `VNEWS/` và commit
|
| 52 |
-
(chú ý: cookies sẽ công khai nếu repo public — ưu tiên Cách A).
|
| 53 |
-
4. Rebuild Space (mỗi lần đổi secret phải **Restart** Space).
|
| 54 |
-
|
| 55 |
-
Backend tự đọc `YT_COOKIES` (secret) hoặc `/app/cookies.txt`, ghi thành file tạm
|
| 56 |
-
và truyền cho yt-dlp qua `cookiefile`. Không cần sửa code.
|
| 57 |
-
|
| 58 |
-
> 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.
|
|
|
|
| 30 |
- 🏆 World Cup 2026 (news, fixtures, standings, stats, highlights)
|
| 31 |
- 🤖 AI article writing + TTS (multilingual, emotion-aware)
|
| 32 |
- 🔍 Topic search (8 news sources)
|
| 33 |
+
- 🎤 TTS: voice selector + emotion selector + speed control
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
RESTART_TRIGGER.md
CHANGED
|
@@ -1,7 +1,6 @@
|
|
| 1 |
-
trigger rebuild
|
| 2 |
|
| 3 |
-
-
|
| 4 |
-
-
|
| 5 |
-
-
|
| 6 |
-
-
|
| 7 |
-
- FORCE REBUILD: this commit retriggers the Space container with the latest static/app_v2.js
|
|
|
|
| 1 |
+
trigger rebuild 2026-07-18T10:35 +0700 - add missing ai/short/ and ai/short-file/ endpoints
|
| 2 |
|
| 3 |
+
- Added POST /api/ai/short/{post_id} endpoint (was lost during route cleanup)
|
| 4 |
+
- Added GET /api/ai/short-file/{file_id} endpoint (file serving)
|
| 5 |
+
- Both were supposed to be in ai_patch.py but never existed there
|
| 6 |
+
- Also added FileResponse import
|
|
|
_run.py
CHANGED
|
@@ -1 +1,10 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""VNEWS main entry - load all modules in correct order."""
|
| 2 |
+
from app_v2_entry import app
|
| 3 |
+
import logs_route
|
| 4 |
+
import ai_runtime_patch_fast # Original patch (for rewrite, hashtag, etc.)
|
| 5 |
+
# Load fix module LAST to register short endpoints cleanly
|
| 6 |
+
try:
|
| 7 |
+
import ai_runtime_fix # <-- This registers /api/ai/short endpoints
|
| 8 |
+
except Exception as e:
|
| 9 |
+
import logging
|
| 10 |
+
logging.getLogger("_run").warning(f"ai_runtime_fix not loaded: {e}")
|
ai_ext.py
CHANGED
|
@@ -1,9 +1,5 @@
|
|
| 1 |
"""VNEWS AI Extension - rewrite + auto short video generation.
|
| 2 |
-
|
| 3 |
-
/api/ai_wall, /api/wall, /api/ai/short endpoints on the main FastAPI app.
|
| 4 |
-
|
| 5 |
-
Uses main.py's WALL_FILE (wall_posts.json) for unified data store.
|
| 6 |
-
TTS: edge-tts (HoaiMy female, NamMinh male) with speed control + gTTS fallback.
|
| 7 |
"""
|
| 8 |
import os, re, json, time, random, html as html_lib, subprocess, asyncio
|
| 9 |
from urllib.parse import quote_plus, quote, urlparse, urljoin
|
|
@@ -24,13 +20,18 @@ except ImportError:
|
|
| 24 |
except Exception:
|
| 25 |
app = None
|
| 26 |
|
| 27 |
-
# Import
|
| 28 |
try:
|
| 29 |
-
from
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
except ImportError:
|
| 31 |
_data_dir = "/data" if os.path.isdir("/data") else "/app/data"
|
|
|
|
| 32 |
_wall_file = os.path.join(_data_dir, "wall_posts.json")
|
| 33 |
-
def
|
| 34 |
try:
|
| 35 |
if os.path.exists(_wall_file):
|
| 36 |
with open(_wall_file, "r", encoding="utf-8") as f:
|
|
@@ -38,7 +39,7 @@ except ImportError:
|
|
| 38 |
except Exception:
|
| 39 |
pass
|
| 40 |
return []
|
| 41 |
-
def
|
| 42 |
try:
|
| 43 |
os.makedirs(os.path.dirname(_wall_file), exist_ok=True)
|
| 44 |
tmp = _wall_file + ".tmp"
|
|
@@ -47,12 +48,10 @@ except ImportError:
|
|
| 47 |
os.replace(tmp, _wall_file)
|
| 48 |
except Exception:
|
| 49 |
pass
|
| 50 |
-
def _web_context(topic):
|
| 51 |
-
return ""
|
| 52 |
|
| 53 |
-
#
|
| 54 |
-
|
| 55 |
-
|
| 56 |
|
| 57 |
try:
|
| 58 |
from huggingface_hub import AsyncInferenceClient
|
|
@@ -71,6 +70,65 @@ try:
|
|
| 71 |
except Exception:
|
| 72 |
edge_tts = None
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
def _hf_token():
|
| 76 |
for k in ("HF_TOKEN", "HUGGINGFACE_HUB_API_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HF_API_TOKEN"):
|
|
@@ -96,22 +154,21 @@ def _domain(url: str) -> str:
|
|
| 96 |
|
| 97 |
|
| 98 |
async def qwen_generate(prompt: str, image_url: str = None, max_tokens: int = 1200) -> str:
|
| 99 |
-
"""Generate text using
|
| 100 |
-
|
| 101 |
-
Prioritizes Llama-3.3-70B for better creative/opinion writing.
|
| 102 |
-
"""
|
| 103 |
token = _hf_token()
|
| 104 |
errors = []
|
| 105 |
|
| 106 |
-
# Try HF router API with multiple models - Llama FIRST for opinion writing
|
| 107 |
if token:
|
| 108 |
models = [
|
| 109 |
os.getenv("QWEN_VL_MODEL", ""),
|
| 110 |
-
"meta-llama/Llama-3.3-70B-Instruct", # FIRST - best for opinion/analysis
|
| 111 |
"Qwen/Qwen2.5-VL-7B-Instruct",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
"Qwen/Qwen2.5-72B-Instruct",
|
|
|
|
| 113 |
]
|
| 114 |
-
# Deduplicate while preserving order
|
| 115 |
seen = set()
|
| 116 |
models = [m for m in models if m and m not in seen and not seen.add(m)]
|
| 117 |
|
|
@@ -131,12 +188,12 @@ async def qwen_generate(prompt: str, image_url: str = None, max_tokens: int = 12
|
|
| 131 |
payload = {
|
| 132 |
"model": model,
|
| 133 |
"messages": [
|
| 134 |
-
{"role": "system", "content": "Bạn là
|
| 135 |
{"role": "user", "content": user_content},
|
| 136 |
],
|
| 137 |
-
"max_tokens": min(int(max_tokens or
|
| 138 |
-
"temperature": 0.
|
| 139 |
-
"top_p": 0.
|
| 140 |
}
|
| 141 |
|
| 142 |
r = requests.post(
|
|
@@ -161,7 +218,7 @@ async def qwen_generate(prompt: str, image_url: str = None, max_tokens: int = 12
|
|
| 161 |
except Exception as e:
|
| 162 |
errors.append(f"{model}: {type(e).__name__}")
|
| 163 |
|
| 164 |
-
|
| 165 |
LAST_QWEN_ERROR = errors[-3:] if errors else "unknown error"
|
| 166 |
return _fallback_summary_from_prompt(prompt, max_units=6)
|
| 167 |
|
|
@@ -176,7 +233,6 @@ def _fallback_summary_from_prompt(prompt: str, max_units: int = 6) -> str:
|
|
| 176 |
text = re.sub(r"https?://\S+", "", text)
|
| 177 |
text = re.sub(r"\s+", " ", text).strip()
|
| 178 |
|
| 179 |
-
# Split into sentences
|
| 180 |
sentences = re.split(r"(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])", text)
|
| 181 |
units = []
|
| 182 |
for s in sentences:
|
|
@@ -199,134 +255,347 @@ def _fallback_summary_from_prompt(prompt: str, max_units: int = 6) -> str:
|
|
| 199 |
return "\n".join("• " + c for c in chunks)
|
| 200 |
return "• Không có đủ nội dung để tóm tắt."
|
| 201 |
|
| 202 |
-
# ===== URL scraping & article processing =====
|
| 203 |
-
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"}
|
| 204 |
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
|
| 212 |
-
import random as _random2
|
| 213 |
-
from datetime import datetime, timezone, timedelta
|
| 214 |
-
_VN_TZ = timezone(timedelta(hours=7))
|
| 215 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
|
| 217 |
-
def _safe_name(filename: str) -> str:
|
| 218 |
-
"""Sanitize filename."""
|
| 219 |
-
return re.sub(r"[^a-zA-Z0-9_.-]", "_", filename)[:120]
|
| 220 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
|
| 222 |
-
def pollinations_image_url(topic: str) -> str:
|
| 223 |
-
"""Generate a placeholder image URL via Pollinations."""
|
| 224 |
-
try:
|
| 225 |
-
return "https://image.pollinations.ai/prompt/" + quote("Vietnamese editorial illustration, " + topic, safe="") + "?width=1024&height=576&nologo=true"
|
| 226 |
-
except Exception:
|
| 227 |
-
return ""
|
| 228 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
if r.status_code == 200 and len(r.content) > 1200:
|
| 236 |
-
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
| 237 |
-
with open(out_path, "wb") as f:
|
| 238 |
-
f.write(r.content)
|
| 239 |
-
return out_path
|
| 240 |
-
except Exception:
|
| 241 |
-
pass
|
| 242 |
-
# Fallback: create a placeholder image
|
| 243 |
try:
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
font = None
|
| 251 |
-
text = (fallback_title or "VNEWS")[:40]
|
| 252 |
-
try:
|
| 253 |
-
bbox = draw.textbbox((0, 0), text, font=font)
|
| 254 |
-
tw = bbox[2] - bbox[0]
|
| 255 |
-
except Exception:
|
| 256 |
-
tw = len(text) * 24
|
| 257 |
-
draw.text(((1080 - tw) // 2, 330), text, fill=(255, 255, 255), font=font)
|
| 258 |
-
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
| 259 |
-
img.save(out_path, quality=90)
|
| 260 |
-
return out_path
|
| 261 |
except Exception:
|
| 262 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
|
|
|
|
|
|
|
| 265 |
def scrape_any_url(url: str) -> dict:
|
| 266 |
-
"""Scrape
|
| 267 |
-
|
| 268 |
-
|
|
|
|
| 269 |
try:
|
| 270 |
r = requests.get(url, headers=HEADERS, timeout=15, allow_redirects=True)
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
for tag in soup.find_all([
|
| 276 |
tag.decompose()
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
for sel in ["article", ".singular-content", ".detail-content", ".fck_detail", ".content-detail", ".knc-content", "main", ".cms-body", ".article__body", ".post-content", ".entry-content"]:
|
| 300 |
el = soup.select_one(sel)
|
| 301 |
-
if el and len(el.find_all(
|
| 302 |
-
|
| 303 |
-
t = _clean_text(p.get_text(strip=True))
|
| 304 |
-
if t and len(t) > 30:
|
| 305 |
-
body_text.append(t)
|
| 306 |
break
|
| 307 |
-
if not
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
except Exception as e:
|
| 315 |
-
return {
|
| 316 |
|
| 317 |
|
| 318 |
-
def
|
| 319 |
-
"""
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""VNEWS AI Extension - rewrite + auto short video generation.
|
| 2 |
+
FIX v2: Vietnamese font auto-detection.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
import os, re, json, time, random, html as html_lib, subprocess, asyncio
|
| 5 |
from urllib.parse import quote_plus, quote, urlparse, urljoin
|
|
|
|
| 20 |
except Exception:
|
| 21 |
app = None
|
| 22 |
|
| 23 |
+
# Import storage for persistent data
|
| 24 |
try:
|
| 25 |
+
from storage import (
|
| 26 |
+
load_wall_posts as _load_ai_wall,
|
| 27 |
+
save_wall_posts as _save_ai_wall,
|
| 28 |
+
DATA_DIR
|
| 29 |
+
)
|
| 30 |
except ImportError:
|
| 31 |
_data_dir = "/data" if os.path.isdir("/data") else "/app/data"
|
| 32 |
+
DATA_DIR = _data_dir
|
| 33 |
_wall_file = os.path.join(_data_dir, "wall_posts.json")
|
| 34 |
+
def _load_ai_wall():
|
| 35 |
try:
|
| 36 |
if os.path.exists(_wall_file):
|
| 37 |
with open(_wall_file, "r", encoding="utf-8") as f:
|
|
|
|
| 39 |
except Exception:
|
| 40 |
pass
|
| 41 |
return []
|
| 42 |
+
def _save_ai_wall(posts):
|
| 43 |
try:
|
| 44 |
os.makedirs(os.path.dirname(_wall_file), exist_ok=True)
|
| 45 |
tmp = _wall_file + ".tmp"
|
|
|
|
| 48 |
os.replace(tmp, _wall_file)
|
| 49 |
except Exception:
|
| 50 |
pass
|
|
|
|
|
|
|
| 51 |
|
| 52 |
+
# Aliases for backward compatibility
|
| 53 |
+
_load_wall = _load_ai_wall
|
| 54 |
+
_save_wall = _save_ai_wall
|
| 55 |
|
| 56 |
try:
|
| 57 |
from huggingface_hub import AsyncInferenceClient
|
|
|
|
| 70 |
except Exception:
|
| 71 |
edge_tts = None
|
| 72 |
|
| 73 |
+
# ===== VIETNAMESE FONT DETECTION =====
|
| 74 |
+
_VN_FONT_REG = None
|
| 75 |
+
_VN_FONT_BOLD = None
|
| 76 |
+
|
| 77 |
+
def _get_vn_fonts():
|
| 78 |
+
"""Auto-detect Vietnamese-supporting fonts."""
|
| 79 |
+
global _VN_FONT_REG, _VN_FONT_BOLD
|
| 80 |
+
if _VN_FONT_REG is not None:
|
| 81 |
+
return _VN_FONT_REG, _VN_FONT_BOLD
|
| 82 |
+
|
| 83 |
+
reg_paths = [
|
| 84 |
+
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
|
| 85 |
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
| 86 |
+
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
|
| 87 |
+
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
| 88 |
+
]
|
| 89 |
+
bold_paths = [
|
| 90 |
+
"/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf",
|
| 91 |
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
| 92 |
+
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
|
| 93 |
+
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
|
| 94 |
+
]
|
| 95 |
+
|
| 96 |
+
# Search broadly
|
| 97 |
+
try:
|
| 98 |
+
result = subprocess.run(
|
| 99 |
+
['find', '/usr/share/fonts', '-name', '*.ttf', '-type', 'f'],
|
| 100 |
+
capture_output=True, text=True, timeout=5
|
| 101 |
+
)
|
| 102 |
+
all_ttf = result.stdout.strip().split('\n')
|
| 103 |
+
for key in ['noto', 'dejavu', 'liberation', 'freefont', 'vn']:
|
| 104 |
+
for path in all_ttf:
|
| 105 |
+
pl = path.lower()
|
| 106 |
+
if key in pl:
|
| 107 |
+
if ('bold' in pl or 'variable' in pl) and _VN_FONT_BOLD is None:
|
| 108 |
+
try: _VN_FONT_BOLD = ImageFont.truetype(path, 52)
|
| 109 |
+
except: pass
|
| 110 |
+
elif ('regular' in pl or path == '/usr/share/fonts/truetype/vn/VNFont.ttf') and _VN_FONT_REG is None:
|
| 111 |
+
try: _VN_FONT_REG = ImageFont.truetype(path, 40)
|
| 112 |
+
except: pass
|
| 113 |
+
except:
|
| 114 |
+
pass
|
| 115 |
+
|
| 116 |
+
for path in reg_paths:
|
| 117 |
+
if os.path.exists(path) and _VN_FONT_REG is None:
|
| 118 |
+
try: _VN_FONT_REG = ImageFont.truetype(path, 40)
|
| 119 |
+
except: continue
|
| 120 |
+
for path in bold_paths:
|
| 121 |
+
if os.path.exists(path) and _VN_FONT_BOLD is None:
|
| 122 |
+
try: _VN_FONT_BOLD = ImageFont.truetype(path, 52)
|
| 123 |
+
except: continue
|
| 124 |
+
|
| 125 |
+
if _VN_FONT_REG is None:
|
| 126 |
+
_VN_FONT_REG = ImageFont.load_default()
|
| 127 |
+
if _VN_FONT_BOLD is None:
|
| 128 |
+
_VN_FONT_BOLD = _VN_FONT_REG
|
| 129 |
+
|
| 130 |
+
return _VN_FONT_REG, _VN_FONT_BOLD
|
| 131 |
+
|
| 132 |
|
| 133 |
def _hf_token():
|
| 134 |
for k in ("HF_TOKEN", "HUGGINGFACE_HUB_API_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HF_API_TOKEN"):
|
|
|
|
| 154 |
|
| 155 |
|
| 156 |
async def qwen_generate(prompt: str, image_url: str = None, max_tokens: int = 1200) -> str:
|
| 157 |
+
"""Generate text using Qwen models via Hugging Face Inference API."""
|
|
|
|
|
|
|
|
|
|
| 158 |
token = _hf_token()
|
| 159 |
errors = []
|
| 160 |
|
|
|
|
| 161 |
if token:
|
| 162 |
models = [
|
| 163 |
os.getenv("QWEN_VL_MODEL", ""),
|
|
|
|
| 164 |
"Qwen/Qwen2.5-VL-7B-Instruct",
|
| 165 |
+
"Qwen/Qwen2.5-VL-3B-Instruct",
|
| 166 |
+
"Qwen/Qwen2.5-7B-Instruct",
|
| 167 |
+
"Qwen/Qwen2.5-3B-Instruct",
|
| 168 |
+
"Qwen/Qwen2.5-1.5B-Instruct",
|
| 169 |
"Qwen/Qwen2.5-72B-Instruct",
|
| 170 |
+
"meta-llama/Llama-3.3-70B-Instruct",
|
| 171 |
]
|
|
|
|
| 172 |
seen = set()
|
| 173 |
models = [m for m in models if m and m not in seen and not seen.add(m)]
|
| 174 |
|
|
|
|
| 188 |
payload = {
|
| 189 |
"model": model,
|
| 190 |
"messages": [
|
| 191 |
+
{"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."},
|
| 192 |
{"role": "user", "content": user_content},
|
| 193 |
],
|
| 194 |
+
"max_tokens": min(int(max_tokens or 900), 1400),
|
| 195 |
+
"temperature": 0.35,
|
| 196 |
+
"top_p": 0.85,
|
| 197 |
}
|
| 198 |
|
| 199 |
r = requests.post(
|
|
|
|
| 218 |
except Exception as e:
|
| 219 |
errors.append(f"{model}: {type(e).__name__}")
|
| 220 |
|
| 221 |
+
global LAST_QWEN_ERROR
|
| 222 |
LAST_QWEN_ERROR = errors[-3:] if errors else "unknown error"
|
| 223 |
return _fallback_summary_from_prompt(prompt, max_units=6)
|
| 224 |
|
|
|
|
| 233 |
text = re.sub(r"https?://\S+", "", text)
|
| 234 |
text = re.sub(r"\s+", " ", text).strip()
|
| 235 |
|
|
|
|
| 236 |
sentences = re.split(r"(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])", text)
|
| 237 |
units = []
|
| 238 |
for s in sentences:
|
|
|
|
| 255 |
return "\n".join("• " + c for c in chunks)
|
| 256 |
return "• Không có đủ nội dung để tóm tắt."
|
| 257 |
|
|
|
|
|
|
|
| 258 |
|
| 259 |
+
HF_TOKEN = _hf_token()
|
| 260 |
+
QWEN_VL_MODEL = os.getenv("QWEN_VL_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct")
|
| 261 |
+
QWEN_TEXT_MODELS = [m.strip() for m in os.getenv(
|
| 262 |
+
"QWEN_TEXT_MODELS",
|
| 263 |
+
"Qwen/Qwen2.5-72B-Instruct,meta-llama/Llama-3.3-70B-Instruct,Qwen/Qwen2.5-7B-Instruct"
|
| 264 |
+
).split(",") if m.strip()]
|
| 265 |
+
_WORKING_MODEL_TEXT = None
|
| 266 |
+
_WORKING_MODEL_VL = None
|
| 267 |
+
SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts")
|
| 268 |
+
HEADERS = {
|
| 269 |
+
"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",
|
| 270 |
+
"Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"
|
| 271 |
+
}
|
| 272 |
+
LAST_QWEN_ERROR = ""
|
| 273 |
|
|
|
|
|
|
|
|
|
|
| 274 |
|
| 275 |
+
# ===== MULTILINGUAL VOICES FOR TTS =====
|
| 276 |
+
MULTILINGUAL_VOICES = {
|
| 277 |
+
"vi-vn-hoaimyneural": "vi-VN-HoaiMyNeural",
|
| 278 |
+
"vi-vn-namminhneural": "vi-VN-NamMinhNeural",
|
| 279 |
+
"hoaimy": "vi-VN-HoaiMyNeural",
|
| 280 |
+
"namminh": "vi-VN-NamMinhNeural",
|
| 281 |
+
"vi_female": "vi-VN-HoaiMyNeural",
|
| 282 |
+
"vi_male": "vi-VN-NamMinhNeural",
|
| 283 |
+
"nu": "vi-VN-HoaiMyNeural",
|
| 284 |
+
"male": "vi-VN-NamMinhNeural",
|
| 285 |
+
"female": "vi-VN-HoaiMyNeural",
|
| 286 |
+
"mien-nam": "vi-VN-HoaiMyNeural",
|
| 287 |
+
"en-us-andrewmultilingualneural": "en-US-AndrewMultilingualNeural",
|
| 288 |
+
"en-au-williammultilingualneural": "en-AU-WilliamMultilingualNeural",
|
| 289 |
+
"en_andrew": "en-US-AndrewMultilingualNeural",
|
| 290 |
+
"andrew": "en-US-AndrewMultilingualNeural",
|
| 291 |
+
"en_jenny": "en-US-AndrewMultilingualNeural",
|
| 292 |
+
"jenny": "en-US-AndrewMultilingualNeural",
|
| 293 |
+
"pt-br-thalitamultilingualneural": "pt-BR-ThalitaMultilingualNeural",
|
| 294 |
+
"pt_thalita": "pt-BR-ThalitaMultilingualNeural",
|
| 295 |
+
"thalita": "pt-BR-ThalitaMultilingualNeural",
|
| 296 |
+
"pt_francisco": "pt-BR-ThalitaMultilingualNeural",
|
| 297 |
+
"pt": "pt-BR-ThalitaMultilingualNeural",
|
| 298 |
+
"fr-fr-viviennemultilingualneural": "fr-FR-VivienneMultilingualNeural",
|
| 299 |
+
"fr-fr-remymultilingualneural": "fr-FR-RemyMultilingualNeural",
|
| 300 |
+
"fr_denise": "fr-FR-VivienneMultilingualNeural",
|
| 301 |
+
"denise": "fr-FR-VivienneMultilingualNeural",
|
| 302 |
+
"fr": "fr-FR-VivienneMultilingualNeural",
|
| 303 |
+
"de-de-seraphinamultilingualneural": "de-DE-SeraphinaMultilingualNeural",
|
| 304 |
+
"de-de-florianmultilingualneural": "de-DE-FlorianMultilingualNeural",
|
| 305 |
+
"de_katja": "de-DE-SeraphinaMultilingualNeural",
|
| 306 |
+
"katja": "de-DE-SeraphinaMultilingualNeural",
|
| 307 |
+
"de": "de-DE-SeraphinaMultilingualNeural",
|
| 308 |
+
"ko-kr-hyunsumultilingualneural": "ko-KR-HyunsuMultilingualNeural",
|
| 309 |
+
"ko_sunhee": "ko-KR-HyunsuMultilingualNeural",
|
| 310 |
+
"sunhee": "ko-KR-HyunsuMultilingualNeural",
|
| 311 |
+
"ko": "ko-KR-HyunsuMultilingualNeural",
|
| 312 |
+
"it-it-giuseppemultilingualneural": "it-IT-GiuseppeMultilingualNeural",
|
| 313 |
+
"es_ela": "en-US-AndrewMultilingualNeural",
|
| 314 |
+
"ela": "en-US-AndrewMultilingualNeural",
|
| 315 |
+
"es_carlos": "en-US-AndrewMultilingualNeural",
|
| 316 |
+
"es": "en-US-AndrewMultilingualNeural",
|
| 317 |
+
"ja_nanami": "en-US-AndrewMultilingualNeural",
|
| 318 |
+
"nanami": "en-US-AndrewMultilingualNeural",
|
| 319 |
+
"ja": "en-US-AndrewMultilingualNeural",
|
| 320 |
+
"zh_xiaochen": "en-US-AndrewMultilingualNeural",
|
| 321 |
+
"xiaochen": "en-US-AndrewMultilingualNeural",
|
| 322 |
+
"zh": "en-US-AndrewMultilingualNeural",
|
| 323 |
+
}
|
| 324 |
|
|
|
|
|
|
|
|
|
|
| 325 |
|
| 326 |
+
def _detect_voice_emotion(title, text):
|
| 327 |
+
"""Detect appropriate voice and emotion based on content for multilingual TTS."""
|
| 328 |
+
content = ((title or "") + " " + (text or "")).lower()
|
| 329 |
+
|
| 330 |
+
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ủ"]):
|
| 331 |
+
return ("andrew", "excited")
|
| 332 |
+
|
| 333 |
+
if any(kw in content for kw in ["kinh tế", "tài chính", "thị trường", "economics", "finance"]):
|
| 334 |
+
return ("jenny", "calm")
|
| 335 |
+
if any(kw in content for kw in ["thiên tai", "bão", "lũ lụt", "cháy nổ", "tai nạn", "disaster", "accident"]):
|
| 336 |
+
return ("thalita", "serious")
|
| 337 |
+
if any(kw in content for kw in ["giải trí", "showbiz", "entertainment", "hài hước"]):
|
| 338 |
+
return ("ela", "happy")
|
| 339 |
+
if any(kw in content for kw in ["công nghệ", "tech", "technology", "ai", "trí tuệ nhân tạo"]):
|
| 340 |
+
return ("katja", "excited")
|
| 341 |
+
|
| 342 |
+
return ("hoaimy", "trung_tinh")
|
| 343 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
|
| 345 |
+
def _safe_name(s: str) -> str:
|
| 346 |
+
"""Create safe filename from string."""
|
| 347 |
+
s = re.sub(r"[^\w\-.]", "_", s)
|
| 348 |
+
return s[:100] if len(s) > 100 else s
|
| 349 |
|
| 350 |
+
|
| 351 |
+
def _download_image(url: str, fallback_title: str, out_path: str) -> bool:
|
| 352 |
+
"""Download image from URL to path."""
|
| 353 |
+
if not url:
|
| 354 |
+
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
try:
|
| 356 |
+
r = requests.get(url, headers=HEADERS, timeout=15)
|
| 357 |
+
if r.status_code == 200:
|
| 358 |
+
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
| 359 |
+
with open(out_path, "wb") as f:
|
| 360 |
+
f.write(r.content)
|
| 361 |
+
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
except Exception:
|
| 363 |
+
pass
|
| 364 |
+
return False
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
def pollination_image_url(topic: str) -> str:
|
| 368 |
+
"""Generate image URL from Pollinations.ai."""
|
| 369 |
+
return f"https://image.pollinations.ai/prompt/{quote(topic)}?width=1024&height=768&nologo=true&model=flux"
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
# Use the same wall file as app_v2_entry.py for consistency
|
| 373 |
+
WALL_FILE = os.path.join(DATA_DIR, "wall_posts.json")
|
| 374 |
+
|
| 375 |
+
# Helper functions for wall operations
|
| 376 |
+
def _load_wall_posts():
|
| 377 |
+
"""Alias for _load_ai_wall for consistency with app_v2_entry.py."""
|
| 378 |
+
return _load_ai_wall()
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def _save_wall_posts(posts):
|
| 382 |
+
"""Alias for _save_ai_wall for consistency with app_v2_entry.py."""
|
| 383 |
+
return _save_ai_wall(posts)
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
def make_post(title: str, text: str, img: str, url: str, kind: str, sources=None):
|
| 387 |
+
"""Create a post dict with standard fields."""
|
| 388 |
+
return {
|
| 389 |
+
"id": str(int(time.time() * 1000)),
|
| 390 |
+
"title": title,
|
| 391 |
+
"text": text,
|
| 392 |
+
"img": img,
|
| 393 |
+
"url": url,
|
| 394 |
+
"kind": kind,
|
| 395 |
+
"sources": sources or [],
|
| 396 |
+
"ts": int(time.time())
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
|
| 400 |
+
def _short_script(post) -> str:
|
| 401 |
+
"""Extract clean text for TTS from post."""
|
| 402 |
+
text = post.get("text", "") or post.get("title", "")
|
| 403 |
+
text = re.sub(r"^[•\-\*]\s*", "", text, flags=re.M)
|
| 404 |
+
text = re.sub(r"\s*\n\s*", ". ", text)
|
| 405 |
+
return _clean_text(text)[:2000]
|
| 406 |
|
| 407 |
+
|
| 408 |
+
# ===== SCRAPER FUNCTIONS (required by ai_patch.py) =====
|
| 409 |
def scrape_any_url(url: str) -> dict:
|
| 410 |
+
"""Scrape any URL and extract article content.
|
| 411 |
+
|
| 412 |
+
Returns dict with: title, summary, text, image, og_image, via (domain)
|
| 413 |
+
"""
|
| 414 |
try:
|
| 415 |
r = requests.get(url, headers=HEADERS, timeout=15, allow_redirects=True)
|
| 416 |
+
r.encoding = 'utf-8'
|
| 417 |
+
soup = BeautifulSoup(r.text, 'lxml')
|
| 418 |
+
|
| 419 |
+
# Remove scripts, styles, nav, footer, ads
|
| 420 |
+
for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form', 'noscript', 'iframe']):
|
| 421 |
tag.decompose()
|
| 422 |
+
|
| 423 |
+
# Extract title
|
| 424 |
+
h1 = soup.find('h1')
|
| 425 |
+
ogt = soup.find('meta', property='og:title')
|
| 426 |
+
title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else url)
|
| 427 |
+
|
| 428 |
+
# Extract OG image
|
| 429 |
+
ogi = soup.find('meta', property='og:image')
|
| 430 |
+
og_image = ogi.get('content', '') if ogi else ''
|
| 431 |
+
|
| 432 |
+
# Extract article body
|
| 433 |
+
block = None
|
| 434 |
+
selectors = [
|
| 435 |
+
'.fck_detail', '.sidebar-1',
|
| 436 |
+
'.singular-content', '.dt__content', '.article-content', '.content-detail', '#divNewsContent',
|
| 437 |
+
'.content-detail', '.main-content-detail', '.box-content',
|
| 438 |
+
'.knc-content', '.article-body', '.detail-body',
|
| 439 |
+
'.article-detail', '.detail-content',
|
| 440 |
+
'article', 'main', '.cms-body', '.article__body', '.post-content',
|
| 441 |
+
'.entry-content', '#content', '.article-text', '.story-body',
|
| 442 |
+
]
|
| 443 |
+
for sel in selectors:
|
|
|
|
| 444 |
el = soup.select_one(sel)
|
| 445 |
+
if el and len(el.find_all('p')) >= 2:
|
| 446 |
+
block = el
|
|
|
|
|
|
|
|
|
|
| 447 |
break
|
| 448 |
+
if not block:
|
| 449 |
+
best = None; best_score = 0
|
| 450 |
+
for el in soup.find_all(['article', 'main', 'section', 'div']):
|
| 451 |
+
ps = el.find_all('p')
|
| 452 |
+
if len(ps) >= 2:
|
| 453 |
+
text_len = sum(len(p.get_text(strip=True)) for p in ps)
|
| 454 |
+
score = text_len + len(ps) * 100
|
| 455 |
+
cls = ' '.join(el.get('class', [])) + ' ' + el.get('id', '')
|
| 456 |
+
if any(k in cls.lower() for k in ['content', 'detail', 'article', 'story', 'body']):
|
| 457 |
+
score += 500
|
| 458 |
+
if score > best_score:
|
| 459 |
+
best = el; best_score = score
|
| 460 |
+
block = best or soup.body or soup
|
| 461 |
+
|
| 462 |
+
# Extract text from paragraphs
|
| 463 |
+
paragraphs = []
|
| 464 |
+
for el in block.find_all(['p', 'h2', 'h3'], recursive=True):
|
| 465 |
+
t = _clean_text(el.get_text(strip=True))
|
| 466 |
+
if t and len(t) > 40:
|
| 467 |
+
if 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']):
|
| 468 |
+
continue
|
| 469 |
+
paragraphs.append(t)
|
| 470 |
+
|
| 471 |
+
# Extract images
|
| 472 |
+
images = []
|
| 473 |
+
seen_imgs = set()
|
| 474 |
+
for el in block.find_all(['figure', 'img'], recursive=True):
|
| 475 |
+
im = el if el.name == 'img' else el.find('img')
|
| 476 |
+
if im:
|
| 477 |
+
src = im.get('data-src') or im.get('src') or im.get('data-original') or im.get('data-lazy') or ''
|
| 478 |
+
if src and 'base64' not in src and src not in seen_imgs:
|
| 479 |
+
seen_imgs.add(src)
|
| 480 |
+
if src.startswith('//'):
|
| 481 |
+
src = 'https:' + src
|
| 482 |
+
images.append(src)
|
| 483 |
+
|
| 484 |
+
# Prefer OG image as main image
|
| 485 |
+
image = og_image or (images[0] if images else '')
|
| 486 |
+
|
| 487 |
+
return {
|
| 488 |
+
'title': title,
|
| 489 |
+
'summary': paragraphs[0] if paragraphs else '',
|
| 490 |
+
'text': '\n'.join(paragraphs),
|
| 491 |
+
'image': image,
|
| 492 |
+
'og_image': og_image,
|
| 493 |
+
'via': _domain(url),
|
| 494 |
+
'images': images
|
| 495 |
+
}
|
| 496 |
except Exception as e:
|
| 497 |
+
return {'title': url, 'summary': '', 'text': '', 'image': '', 'og_image': '', 'via': _domain(url), 'error': str(e)}
|
| 498 |
|
| 499 |
|
| 500 |
+
def web_context(topic: str, limit: int = 5) -> tuple:
|
| 501 |
+
"""Get web context for a topic. Returns (context_text, sources_list)."""
|
| 502 |
+
sources = []
|
| 503 |
+
try:
|
| 504 |
+
rss_url = f"https://news.google.com/rss/search?q={quote_plus(topic)}&hl=vi&gl=VN&ceid=VN:vi"
|
| 505 |
+
r = requests.get(rss_url, headers=HEADERS, timeout=15)
|
| 506 |
+
r.encoding = 'utf-8'
|
| 507 |
+
soup = BeautifulSoup(r.text, 'xml')
|
| 508 |
+
for it in soup.find_all('item')[:limit]:
|
| 509 |
+
title = it.find('title').get_text(' ', strip=True) if it.find('title') else ''
|
| 510 |
+
link = it.find('link').get_text(strip=True) if it.find('link') else ''
|
| 511 |
+
if title and link:
|
| 512 |
+
sources.append({'title': title, 'url': link, 'via': _domain(link)})
|
| 513 |
+
except Exception:
|
| 514 |
+
pass
|
| 515 |
+
|
| 516 |
+
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]])
|
| 517 |
+
return context, sources
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
# ===== SHORT FRAME FUNCTION (required by ai_patch.py) =====
|
| 521 |
+
def _make_short_frame(post, img_path, out_path):
|
| 522 |
+
"""Create a short video frame from post and image."""
|
| 523 |
+
if Image is None:
|
| 524 |
+
return False
|
| 525 |
+
|
| 526 |
+
_get_vn_fonts()
|
| 527 |
+
global _VN_FONT_REG, _VN_FONT_BOLD
|
| 528 |
+
|
| 529 |
+
W, H = 1080, 1920
|
| 530 |
+
bg = Image.new("RGB", (W, H), (14, 14, 14))
|
| 531 |
+
|
| 532 |
+
try:
|
| 533 |
+
im = Image.open(img_path).convert("RGB")
|
| 534 |
+
target = (1080, 760)
|
| 535 |
+
im_ratio = im.width / max(1, im.height)
|
| 536 |
+
target_ratio = target[0] / target[1]
|
| 537 |
+
|
| 538 |
+
if im_ratio > target_ratio:
|
| 539 |
+
new_h = target[1]
|
| 540 |
+
new_w = int(new_h * im_ratio)
|
| 541 |
+
else:
|
| 542 |
+
new_w = target[0]
|
| 543 |
+
new_h = int(new_w / im_ratio)
|
| 544 |
+
|
| 545 |
+
im = im.resize((new_w, new_h))
|
| 546 |
+
left = (new_w - target[0]) // 2
|
| 547 |
+
top = (new_h - target[1]) // 2
|
| 548 |
+
im = im.crop((left, top, left + target[0], top + target[1]))
|
| 549 |
+
bg.paste(im, (0, 0))
|
| 550 |
+
except Exception:
|
| 551 |
+
pass
|
| 552 |
+
|
| 553 |
+
draw = ImageDraw.Draw(bg)
|
| 554 |
+
|
| 555 |
+
font_title = _VN_FONT_BOLD
|
| 556 |
+
font_body = _VN_FONT_REG
|
| 557 |
+
|
| 558 |
+
draw.rectangle((0, 720, W, H), fill=(14, 14, 14))
|
| 559 |
+
margin = 48
|
| 560 |
+
maxw = W - margin * 2
|
| 561 |
+
|
| 562 |
+
y = 830
|
| 563 |
+
for ln in _wrap_text(draw, post.get("title", ""), font_title, maxw, 4):
|
| 564 |
+
draw.text((margin, y), ln, fill=(255, 255, 255), font=font_title)
|
| 565 |
+
y += 66
|
| 566 |
+
|
| 567 |
+
y += 18
|
| 568 |
+
text = post.get("text", "")
|
| 569 |
+
text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip()
|
| 570 |
+
body_lines = _wrap_text(draw, text, font_body, maxw, 14)
|
| 571 |
+
for ln in body_lines:
|
| 572 |
+
draw.text((margin, y), ln, fill=(220, 220, 220), font=font_body)
|
| 573 |
+
y += 50
|
| 574 |
+
if y > 1640:
|
| 575 |
+
break
|
| 576 |
+
|
| 577 |
+
bg.save(out_path, quality=92)
|
| 578 |
+
return True
|
| 579 |
+
|
| 580 |
+
|
| 581 |
+
def _wrap_text(draw, text, font, max_width, max_lines):
|
| 582 |
+
"""Helper for wrapping text in frames."""
|
| 583 |
+
words = _clean_text(text).split()
|
| 584 |
+
lines, cur = [], ""
|
| 585 |
+
for w in words:
|
| 586 |
+
test = (cur + " " + w).strip()
|
| 587 |
+
try:
|
| 588 |
+
width = draw.textbbox((0, 0), test, font=font)[2]
|
| 589 |
+
except Exception:
|
| 590 |
+
width = len(test) * 20
|
| 591 |
+
if width <= max_width:
|
| 592 |
+
cur = test
|
| 593 |
+
else:
|
| 594 |
+
if cur:
|
| 595 |
+
lines.append(cur)
|
| 596 |
+
cur = w
|
| 597 |
+
if len(lines) >= max_lines:
|
| 598 |
+
break
|
| 599 |
+
if cur and len(lines) < max_lines:
|
| 600 |
+
lines.append(cur)
|
| 601 |
+
return lines
|
ai_patch.py
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
import re
|
| 3 |
import time
|
|
@@ -7,6 +10,7 @@ import html as html_lib
|
|
| 7 |
import subprocess
|
| 8 |
import requests
|
| 9 |
import hashlib
|
|
|
|
| 10 |
import ai_ext as base
|
| 11 |
from ai_ext import app
|
| 12 |
from fastapi import Request
|
|
@@ -19,899 +23,298 @@ try:
|
|
| 19 |
except Exception:
|
| 20 |
Image = ImageDraw = ImageFont = None
|
| 21 |
|
|
|
|
| 22 |
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
s = re.sub(r"\n{3,}", "\n\n", s)
|
| 27 |
-
return s.strip()
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
def _norm(s):
|
| 31 |
-
s = s.lower()
|
| 32 |
-
s = re.sub(r"[^\wÀ-ỹ\s]", " ", s)
|
| 33 |
-
s = re.sub(r"\s+", " ", s).strip()
|
| 34 |
-
return s
|
| 35 |
-
|
| 36 |
|
| 37 |
-
def
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
if
|
| 41 |
-
return
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
return out
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
def _postprocess_ai_text(text, max_units=20):
|
| 64 |
-
text = _clean(text)
|
| 65 |
-
if not text:
|
| 66 |
-
return text
|
| 67 |
-
drop_prefixes = (
|
| 68 |
-
"dưới đây", "sau đây", "bài viết", "tôi sẽ", "mình sẽ",
|
| 69 |
-
"tóm tắt bài", "tiêu đề:", "sapo:", "nội dung:", "kết luận:"
|
| 70 |
-
)
|
| 71 |
-
raw_lines = []
|
| 72 |
-
for line in re.split(r"\n+", text):
|
| 73 |
-
line = _clean(line)
|
| 74 |
-
if not line:
|
| 75 |
-
continue
|
| 76 |
-
low = line.lower().strip()
|
| 77 |
-
if any(low.startswith(p) and len(line) < 80 for p in drop_prefixes):
|
| 78 |
-
continue
|
| 79 |
-
raw_lines.append(line)
|
| 80 |
-
units = []
|
| 81 |
-
for line in raw_lines:
|
| 82 |
-
# KEEP FULL bullet point - don't truncate or split into segments
|
| 83 |
-
if len(line) >= 18:
|
| 84 |
-
units.append(_clean(re.sub(r"^[-•*\d\.\)\s]+", "", line)))
|
| 85 |
-
units = _dedupe_units(units, max_units=max_units)
|
| 86 |
-
if not units:
|
| 87 |
-
return text[:900]
|
| 88 |
-
title = ""
|
| 89 |
-
if raw_lines and len(raw_lines[0]) <= 90 and not raw_lines[0].startswith(("-", "•", "*")):
|
| 90 |
-
title = raw_lines[0]
|
| 91 |
-
units = [u for u in units if not _similar(u, title)]
|
| 92 |
-
body = "\n".join("• " + u for u in units[:max_units])
|
| 93 |
-
return (title + "\n\n" + body).strip() if title else body
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
def _fallback_summary_from_prompt(prompt, max_units=6):
|
| 97 |
-
text = prompt or ""
|
| 98 |
-
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:"]:
|
| 99 |
-
if marker in text:
|
| 100 |
-
text = text.split(marker, 1)[1]
|
| 101 |
-
break
|
| 102 |
-
text = re.sub(r"https?://\S+", "", text)
|
| 103 |
-
text = re.sub(r"\s+", " ", text).strip()
|
| 104 |
-
sentences = re.split(r"(?<=[\.\!\?])\s+(?=[A-ZÀ-Ỹ0-9])", text)
|
| 105 |
-
candidates = []
|
| 106 |
-
for s in sentences:
|
| 107 |
-
s = _clean(s)
|
| 108 |
-
if 45 <= len(s) <= 260:
|
| 109 |
-
candidates.append(s)
|
| 110 |
-
units = _dedupe_units(candidates, max_units=max_units)
|
| 111 |
-
if units:
|
| 112 |
-
return "\n".join("• " + u for u in units)
|
| 113 |
-
if text:
|
| 114 |
-
return "• " + text[:700].rsplit(" ", 1)[0]
|
| 115 |
-
return "• Không có đủ nội dung nguồn để tóm tắt."
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
def _source_line(sources):
|
| 119 |
-
names = []
|
| 120 |
-
for s in (sources or [])[:5]:
|
| 121 |
-
via = s.get("via") or base._domain(s.get("url", "")) or s.get("title", "")
|
| 122 |
-
if via and via not in names:
|
| 123 |
-
names.append(via)
|
| 124 |
-
return "Nguồn tham khảo: " + ", ".join(names[:5]) if names else "Nguồn tham khảo: tổng hợp internet"
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
def _make_summary_prompt(title, raw, source_hint=""):
|
| 128 |
-
return f"""Bạn là biên tập viên tóm tắt tin tức tiếng Việt.
|
| 129 |
-
|
| 130 |
-
NHIỆM VỤ BẮT BUỘC:
|
| 131 |
-
- Chỉ TÓM TẮT nội dung chính, KHÔNG viết lại toàn bộ bài.
|
| 132 |
-
- Không lặp lại cùng một ý, cùng một câu, cùng một chi tiết.
|
| 133 |
-
- Không thêm thông tin ngoài nguồn.
|
| 134 |
-
- Tối đa 5 gạch đầu dòng, mỗi gạch đầu dòng 1 câu ngắn.
|
| 135 |
-
- Nếu bài có số liệu/nhân vật/thời điểm quan trọng thì giữ lại.
|
| 136 |
-
- Không viết phần mở bài dài, không viết văn kể lại.
|
| 137 |
-
|
| 138 |
-
Tiêu đề nguồn: {title}
|
| 139 |
-
Nguồn: {source_hint}
|
| 140 |
-
|
| 141 |
-
Nội dung nguồn:
|
| 142 |
-
{raw[:14000]}
|
| 143 |
-
"""
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
def _direct_news_rss(topic, limit=10):
|
| 147 |
-
out = []
|
| 148 |
try:
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
pass
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
except Exception:
|
| 169 |
-
sources = []
|
| 170 |
-
if not sources:
|
| 171 |
-
sources = _direct_news_rss(topic, limit=10)
|
| 172 |
-
out, seen = [], set()
|
| 173 |
-
for s in (sources or [])[:limit * 3]:
|
| 174 |
-
url = s.get("url") or ""
|
| 175 |
-
if not url.startswith("http") or url in seen:
|
| 176 |
-
continue
|
| 177 |
-
seen.add(url)
|
| 178 |
-
try:
|
| 179 |
-
page = base.scrape_any_url(url)
|
| 180 |
-
raw = (page.get("summary", "") + "\n" + page.get("text", "")).strip()
|
| 181 |
-
if len(raw) < 180:
|
| 182 |
continue
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
out.append({
|
| 186 |
-
"title": title,
|
| 187 |
-
"url": url,
|
| 188 |
-
"raw": raw,
|
| 189 |
-
"image": page.get("image") or "",
|
| 190 |
-
"via": via,
|
| 191 |
-
"source": {"title": title, "url": url, "excerpt": raw[:700], "via": via}
|
| 192 |
-
})
|
| 193 |
-
if len(out) >= limit:
|
| 194 |
-
break
|
| 195 |
-
except Exception:
|
| 196 |
-
continue
|
| 197 |
-
if not out:
|
| 198 |
-
for s in (sources or _direct_news_rss(topic, 6))[:limit]:
|
| 199 |
-
title = s.get("title") or topic
|
| 200 |
-
excerpt = s.get("excerpt") or s.get("description") or s.get("content") or title
|
| 201 |
-
url = s.get("url", "")
|
| 202 |
-
via = s.get("via") or base._domain(url)
|
| 203 |
-
out.append({
|
| 204 |
-
"title": title,
|
| 205 |
-
"url": url,
|
| 206 |
-
"raw": excerpt,
|
| 207 |
-
"image": base.pollinations_image_url(title),
|
| 208 |
-
"via": via,
|
| 209 |
-
"source": {"title": title, "url": url, "excerpt": excerpt[:700], "via": via}
|
| 210 |
-
})
|
| 211 |
-
return out[:limit]
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
async def qwen_generate_resilient(prompt: str, image_url=None, max_tokens: int = 1200):
|
| 215 |
-
errors = []
|
| 216 |
-
token = base._hf_token()
|
| 217 |
-
try:
|
| 218 |
-
original = getattr(base, "_original_qwen_generate", None)
|
| 219 |
-
if original:
|
| 220 |
-
txt = await original(prompt, image_url=image_url, max_tokens=max_tokens)
|
| 221 |
-
if txt:
|
| 222 |
-
base.LAST_QWEN_ERROR = ""
|
| 223 |
-
return txt
|
| 224 |
-
if getattr(base, "LAST_QWEN_ERROR", ""):
|
| 225 |
-
errors.append("sdk: " + str(base.LAST_QWEN_ERROR)[:260])
|
| 226 |
-
except Exception as e:
|
| 227 |
-
errors.append(f"sdk: {type(e).__name__}: {str(e)[:260]}")
|
| 228 |
-
if token:
|
| 229 |
-
models = []
|
| 230 |
-
for m in [
|
| 231 |
-
os.getenv("QWEN_VL_MODEL", ""),
|
| 232 |
-
"Qwen/Qwen2.5-VL-7B-Instruct",
|
| 233 |
-
"Qwen/Qwen2.5-VL-3B-Instruct",
|
| 234 |
-
"Qwen/Qwen2.5-7B-Instruct",
|
| 235 |
-
"Qwen/Qwen2.5-3B-Instruct",
|
| 236 |
-
"Qwen/Qwen2.5-1.5B-Instruct",
|
| 237 |
-
]:
|
| 238 |
-
if m and m not in models:
|
| 239 |
-
models.append(m)
|
| 240 |
-
headers = {"Authorization": "Bearer " + token, "Content-Type": "application/json"}
|
| 241 |
-
for model in models:
|
| 242 |
try:
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
"model": model,
|
| 247 |
-
"messages": [
|
| 248 |
-
{"role": "system", "content": "Bạn là biên tập viên AI tiếng Việt. Chỉ tóm tắt súc tích nội dung nguồn, không viết lại toàn bài, không lặp ý, không bịa chi tiết."},
|
| 249 |
-
{"role": "user", "content": user_content},
|
| 250 |
-
],
|
| 251 |
-
"max_tokens": min(int(max_tokens or 900), 1400),
|
| 252 |
-
"temperature": 0.35,
|
| 253 |
-
"top_p": 0.85,
|
| 254 |
-
}
|
| 255 |
-
r = requests.post("https://router.huggingface.co/v1/chat/completions", headers=headers, json=payload, timeout=95)
|
| 256 |
-
if r.status_code >= 300:
|
| 257 |
-
errors.append(f"{model}: HTTP {r.status_code} {r.text[:180]}")
|
| 258 |
-
continue
|
| 259 |
-
j = r.json()
|
| 260 |
-
txt = (j.get("choices", [{}])[0].get("message", {}).get("content") or "").strip()
|
| 261 |
-
if txt:
|
| 262 |
-
base.LAST_QWEN_ERROR = ""
|
| 263 |
-
return txt
|
| 264 |
-
errors.append(f"{model}: empty response")
|
| 265 |
-
except Exception as e:
|
| 266 |
-
errors.append(f"{model}: {type(e).__name__}: {str(e)[:220]}")
|
| 267 |
-
else:
|
| 268 |
-
errors.append("missing HF_TOKEN")
|
| 269 |
-
base.LAST_QWEN_ERROR = " | ".join(errors[-6:]) or "Qwen unavailable; used extractive fallback"
|
| 270 |
-
print("[qwen resilient fallback]", base.LAST_QWEN_ERROR)
|
| 271 |
-
return _fallback_summary_from_prompt(prompt, max_units=12)
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
if not hasattr(base, "_original_qwen_generate"):
|
| 275 |
-
base._original_qwen_generate = base.qwen_generate
|
| 276 |
-
base.qwen_generate = qwen_generate_resilient
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
@app.get('/api/wall')
|
| 280 |
-
def compat_wall():
|
| 281 |
-
return JSONResponse({'posts': base._load_ai_wall()[:80]})
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
_PATCHED_PATHS = {
|
| 285 |
-
('/api/topic_post', 'POST'),
|
| 286 |
-
('/api/url_wall', 'POST'),
|
| 287 |
-
('/api/rewrite_share', 'POST'),
|
| 288 |
-
('/api/ai/short/{post_id}', 'POST'),
|
| 289 |
-
}
|
| 290 |
-
app.router.routes = [
|
| 291 |
-
r for r in app.router.routes
|
| 292 |
-
if not any(getattr(r, 'path', None) == p and m in getattr(r, 'methods', set()) for p, m in _PATCHED_PATHS)
|
| 293 |
-
]
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
@app.post('/api/topic_post')
|
| 297 |
-
async def compat_topic_post(request: Request):
|
| 298 |
-
body = await request.json()
|
| 299 |
-
topic = base._clean_text(body.get('topic', ''))
|
| 300 |
-
if not topic:
|
| 301 |
-
return JSONResponse({'error': 'missing topic'}, status_code=400)
|
| 302 |
-
articles = _topic_source_articles(topic, limit=4)
|
| 303 |
-
if not articles:
|
| 304 |
-
return JSONResponse({'error': 'Không lấy được bài viết nguồn cho chủ đề này.'}, status_code=422)
|
| 305 |
-
new_posts = []
|
| 306 |
-
posts = base._load_ai_wall()
|
| 307 |
-
for art in articles:
|
| 308 |
-
prompt = f"""Tóm tắt RIÊNG bài viết nguồn sau để đăng Tường AI.
|
| 309 |
-
|
| 310 |
-
Chủ đề lọc: {topic}
|
| 311 |
-
Tiêu đề bài nguồn: {art['title']}
|
| 312 |
-
Nguồn: {art['via']}
|
| 313 |
-
|
| 314 |
-
Yêu cầu bắt buộc:
|
| 315 |
-
- Tóm tắt nội dung trong BÀI VIẾT này, không chỉ tiêu đề.
|
| 316 |
-
- Không trộn với bài khác.
|
| 317 |
-
- Không viết lại toàn bộ bài.
|
| 318 |
-
- Không lặp ý.
|
| 319 |
-
- 4-6 gạch đầu dòng, mỗi dòng 1 câu rõ ràng.
|
| 320 |
-
- Giữ số liệu/nhân vật/thời điểm quan trọng nếu có.
|
| 321 |
-
|
| 322 |
-
Nội dung bài:
|
| 323 |
-
{art['raw'][:14000]}"""
|
| 324 |
-
text = await base.qwen_generate(prompt, image_url=art.get('image') or None, max_tokens=1500)
|
| 325 |
-
text = _postprocess_ai_text(text, max_units=20)
|
| 326 |
-
src = [art['source']]
|
| 327 |
-
if 'Nguồn tham khảo:' not in text:
|
| 328 |
-
text += "\n\n" + _source_line(src)
|
| 329 |
-
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)
|
| 330 |
-
|
| 331 |
-
# Generate slides for this post so they persist after page reload
|
| 332 |
-
try:
|
| 333 |
-
page_data = _scrape_article_images(art.get('url', ''))
|
| 334 |
-
if page_data and page_data.get('paragraphs'):
|
| 335 |
-
key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12)
|
| 336 |
-
if key_points:
|
| 337 |
-
relevant_imgs = page_data.get('images', [])
|
| 338 |
-
if not relevant_imgs and page_data.get('og_img'):
|
| 339 |
-
relevant_imgs = [page_data['og_img']]
|
| 340 |
-
slides = []
|
| 341 |
-
for i, point in enumerate(key_points):
|
| 342 |
-
img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
|
| 343 |
-
slides.append({'text': point, 'image': img, 'index': i + 1})
|
| 344 |
-
post['slides'] = slides
|
| 345 |
-
except Exception:
|
| 346 |
-
pass
|
| 347 |
-
|
| 348 |
-
new_posts.append(post)
|
| 349 |
-
posts = new_posts + posts
|
| 350 |
-
base._save_ai_wall(posts)
|
| 351 |
-
return JSONResponse({'post': new_posts[0], 'posts': new_posts, 'count': len(new_posts)})
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
@app.post('/api/url_wall')
|
| 355 |
-
async def compat_url_wall(request: Request):
|
| 356 |
-
body = await request.json()
|
| 357 |
-
url = base._clean_text(body.get('url', ''))
|
| 358 |
-
if not url.startswith('http'):
|
| 359 |
-
return JSONResponse({'error': 'missing url'}, status_code=400)
|
| 360 |
-
try:
|
| 361 |
-
data = base.scrape_any_url(url)
|
| 362 |
-
except Exception as e:
|
| 363 |
-
return JSONResponse({'error': 'Không scrape được URL: ' + str(e)[:180]}, status_code=422)
|
| 364 |
-
raw = (data.get('summary', '') + '\n' + data.get('text', '')).strip()
|
| 365 |
-
if len(raw) < 120:
|
| 366 |
-
return JSONResponse({'error': 'URL không có đủ nội dung để tóm tắt'}, status_code=422)
|
| 367 |
-
prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
|
| 368 |
-
text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=1500)
|
| 369 |
-
text = _postprocess_ai_text(text, max_units=20)
|
| 370 |
-
src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
|
| 371 |
-
if 'Nguồn tham khảo:' not in text:
|
| 372 |
-
text += "\n\n" + _source_line(src)
|
| 373 |
-
post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'url', sources=src)
|
| 374 |
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
if page_data and page_data.get('paragraphs'):
|
| 380 |
-
key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12)
|
| 381 |
-
if key_points:
|
| 382 |
-
relevant_imgs = page_data.get('images', [])
|
| 383 |
-
if not relevant_imgs and page_data.get('og_img'):
|
| 384 |
-
relevant_imgs = [page_data['og_img']]
|
| 385 |
-
for i, point in enumerate(key_points):
|
| 386 |
-
img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
|
| 387 |
-
slides.append({'text': point, 'image': img, 'index': i + 1})
|
| 388 |
-
except Exception:
|
| 389 |
-
pass
|
| 390 |
-
post['slides'] = slides
|
| 391 |
|
| 392 |
-
|
| 393 |
-
return JSONResponse({'post': post, 'slides': slides})
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
def _is_relevant_image(img_url, title, text):
|
| 397 |
-
"""Check if an image is relevant to the article content."""
|
| 398 |
-
if not img_url:
|
| 399 |
-
return False
|
| 400 |
-
skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif',
|
| 401 |
-
'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite',
|
| 402 |
-
'advertisement', 'ad-banner', 'sponsored', 'banner-ads']
|
| 403 |
-
img_lower = img_url.lower()
|
| 404 |
-
for p in skip_patterns:
|
| 405 |
-
if p in img_lower:
|
| 406 |
-
return False
|
| 407 |
-
if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']):
|
| 408 |
-
return False
|
| 409 |
-
return True
|
| 410 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 411 |
|
| 412 |
-
def
|
| 413 |
-
"""
|
| 414 |
-
|
| 415 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 416 |
seen = set()
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
if
|
| 420 |
continue
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
return relevant[:max_images]
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
def _extract_key_points_for_slides(paragraphs, max_points=12):
|
| 428 |
-
"""Extract key points from paragraphs for slides - extracts ALL sentences, not just first one."""
|
| 429 |
-
points = []
|
| 430 |
-
for p in paragraphs:
|
| 431 |
-
if len(points) >= max_points:
|
| 432 |
-
break
|
| 433 |
-
p = _clean(p)
|
| 434 |
-
if not p:
|
| 435 |
continue
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
if len(points) >= max_points:
|
| 442 |
-
break
|
| 443 |
-
sentence = _clean(sentence)
|
| 444 |
-
if len(sentence) < 30:
|
| 445 |
-
continue
|
| 446 |
-
if any(sentence[:60] in existing for existing in points):
|
| 447 |
-
continue
|
| 448 |
-
if not sentence.endswith(('.', '!', '?')):
|
| 449 |
-
sentence = sentence + '.'
|
| 450 |
-
points.append(sentence)
|
| 451 |
-
return points
|
| 452 |
-
|
| 453 |
|
| 454 |
def _scrape_article_images(url):
|
| 455 |
-
"""
|
|
|
|
| 456 |
try:
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
if src.startswith('//'):
|
| 496 |
-
src = 'https:' + src
|
| 497 |
-
if src not in seen_imgs:
|
| 498 |
-
all_images.append(src)
|
| 499 |
-
seen_imgs.add(src)
|
| 500 |
-
relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5]))
|
| 501 |
-
return {'title': _clean(title), 'paragraphs': paragraphs, 'images': relevant_images, 'og_img': og_img}
|
| 502 |
-
except Exception:
|
| 503 |
-
return None
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
@app.post('/api/rewrite_share')
|
| 507 |
-
async def compat_rewrite_share(request: Request):
|
| 508 |
-
body = await request.json()
|
| 509 |
-
url = base._clean_text(body.get('url', ''))
|
| 510 |
-
if not url.startswith('http'):
|
| 511 |
-
return JSONResponse({'error': 'missing url'}, status_code=400)
|
| 512 |
-
try:
|
| 513 |
-
data = base.scrape_any_url(url)
|
| 514 |
except Exception as e:
|
| 515 |
-
return
|
| 516 |
-
raw = (data.get('summary', '') + '\n' + data.get('text', '')).strip()
|
| 517 |
-
if len(raw) < 120:
|
| 518 |
-
return JSONResponse({'error': 'Bài viết không đủ nội dung để tóm tắt'}, status_code=422)
|
| 519 |
-
prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
|
| 520 |
-
text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=1500)
|
| 521 |
-
text = _postprocess_ai_text(text, max_units=20)
|
| 522 |
-
src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
|
| 523 |
-
if 'Nguồn tham khảo:' not in text:
|
| 524 |
-
text += "\n\n" + _source_line(src)
|
| 525 |
-
post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'summary', sources=src)
|
| 526 |
-
|
| 527 |
-
# Generate slides with relevant images only
|
| 528 |
-
slides = []
|
| 529 |
-
page_data = _scrape_article_images(url)
|
| 530 |
-
if page_data and page_data.get('paragraphs'):
|
| 531 |
-
key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12)
|
| 532 |
-
if key_points:
|
| 533 |
-
relevant_imgs = page_data.get('images', [])
|
| 534 |
-
if not relevant_imgs and page_data.get('og_img'):
|
| 535 |
-
relevant_imgs = [page_data['og_img']]
|
| 536 |
-
for i, point in enumerate(key_points):
|
| 537 |
-
img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
|
| 538 |
-
slides.append({'text': point, 'image': img, 'index': i + 1})
|
| 539 |
-
|
| 540 |
-
# FIX: Save slides into post so they persist after page reload
|
| 541 |
-
post['slides'] = slides
|
| 542 |
-
posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
|
| 543 |
-
|
| 544 |
-
return JSONResponse({'post': post, 'slides': slides})
|
| 545 |
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 549 |
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
"""
|
| 553 |
-
text = _clean(text)
|
| 554 |
-
# REMOVED: No prefix added to keep content clean and natural
|
| 555 |
-
return text
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
def _tts_script_smart(post, emotion):
|
| 559 |
-
raw = base._short_script(post) if hasattr(base, '_short_script') else _clean(post.get('text', '') or post.get('title', ''))
|
| 560 |
-
raw = re.sub(r"^[•\-\*]\s*", "", raw, flags=re.M)
|
| 561 |
-
raw = re.sub(r"\s*\n\s*", ". ", raw)
|
| 562 |
-
raw = re.sub(r"([\.\!\?])\s*", r"\1\n", raw)
|
| 563 |
-
raw = re.sub(r"\n{2,}", "\n", raw).strip()
|
| 564 |
-
# REMOVED: _emotion_script call - read content cleanly without prefix
|
| 565 |
-
# INCREASED to 3000 to read full content of all bullet points
|
| 566 |
-
if len(raw) > 3000:
|
| 567 |
-
raw = raw[:3000]
|
| 568 |
-
cut = max(raw.rfind("."), raw.rfind("!"), raw.rfind("?"))
|
| 569 |
-
if cut > 700:
|
| 570 |
-
raw = raw[:cut + 1]
|
| 571 |
-
return raw
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
def _split_subtitle_sentences(script):
|
| 575 |
-
parts = []
|
| 576 |
-
for line in script.splitlines():
|
| 577 |
line = _clean(line)
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
f.write(f"{i}\n{_srt_time(start)} --> {_srt_time(end)}\n{s}\n\n")
|
| 608 |
-
|
| 609 |
|
| 610 |
-
def
|
| 611 |
-
|
| 612 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 613 |
for w in words:
|
| 614 |
-
test = (cur +
|
| 615 |
try:
|
| 616 |
-
|
| 617 |
-
except
|
| 618 |
-
|
| 619 |
-
if
|
| 620 |
-
cur
|
| 621 |
else:
|
| 622 |
if cur:
|
| 623 |
-
lines.append(cur)
|
| 624 |
-
cur = w
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
if cur and len(lines) <
|
| 628 |
-
lines.append(cur)
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
W, H = 1080, 1920
|
| 636 |
-
bg = Image.new("RGB", (W, H), (14, 14, 14))
|
| 637 |
-
try:
|
| 638 |
-
im = Image.open(img_path).convert("RGB")
|
| 639 |
-
target = (1080, 760)
|
| 640 |
-
im_ratio = im.width / im.height
|
| 641 |
-
target_ratio = target[0] / target[1]
|
| 642 |
-
if im_ratio > target_ratio:
|
| 643 |
-
new_h = target[1]
|
| 644 |
-
new_w = int(new_h * im_ratio)
|
| 645 |
-
else:
|
| 646 |
-
new_w = target[0]
|
| 647 |
-
new_h = int(new_w / im_ratio)
|
| 648 |
-
im = im.resize((new_w, new_h))
|
| 649 |
-
left = (new_w - target[0]) // 2
|
| 650 |
-
top = (new_h - target[1]) // 2
|
| 651 |
-
im = im.crop((left, top, left + target[0], top + target[1]))
|
| 652 |
-
bg.paste(im, (0, 0))
|
| 653 |
-
except Exception:
|
| 654 |
-
pass
|
| 655 |
-
draw = ImageDraw.Draw(bg)
|
| 656 |
-
try:
|
| 657 |
-
font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 54)
|
| 658 |
-
font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 38)
|
| 659 |
-
font_label = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 30)
|
| 660 |
-
except Exception:
|
| 661 |
-
font_title = font_body = font_label = None
|
| 662 |
-
draw.rectangle((0, 720, W, H), fill=(14, 14, 14))
|
| 663 |
-
margin = 48
|
| 664 |
-
maxw = W - margin * 2
|
| 665 |
-
draw.text((margin, 770), "VNEWS · Tường AI", fill=(92, 184, 122), font=font_label)
|
| 666 |
-
y = 830
|
| 667 |
-
for ln in _wrap_text_px(draw, post.get("title", ""), font_title, maxw, 4):
|
| 668 |
-
draw.text((margin, y), ln, fill=(255, 255, 255), font=font_title)
|
| 669 |
-
y += 66
|
| 670 |
-
y += 18
|
| 671 |
-
text = post.get("text", "")
|
| 672 |
-
text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip()
|
| 673 |
-
body_lines = _wrap_text_px(draw, text, font_body, maxw, 14)
|
| 674 |
-
for ln in body_lines:
|
| 675 |
-
draw.text((margin, y), ln, fill=(220, 220, 220), font=font_body)
|
| 676 |
-
y += 50
|
| 677 |
-
if y > 1640:
|
| 678 |
break
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
def _summary_segments_from_post(post, max_segments=25):
|
| 685 |
-
raw = _clean(post.get('text') or post.get('title') or '')
|
| 686 |
-
raw = re.sub(r'^Bản tin AI viết lại:\s*', '', raw, flags=re.I)
|
| 687 |
-
raw = re.sub(r'Nguồn tham khảo:.*$', '', raw, flags=re.I|re.S).strip()
|
| 688 |
-
lines=[]
|
| 689 |
-
for ln in raw.splitlines():
|
| 690 |
-
ln=_clean(re.sub(r'^[•\-\*\d\.\)\s]+','',ln))
|
| 691 |
-
if not ln: continue
|
| 692 |
-
low=ln.lower()
|
| 693 |
-
if low.startswith(('điểm chính','tiêu đề','sapo','nguồn tham khảo')): continue
|
| 694 |
-
if len(ln)>=18: lines.append(ln)
|
| 695 |
-
if len(lines)<3:
|
| 696 |
-
lines=[]
|
| 697 |
-
for s in re.split(r'(?<=[\.\!\?])\s+', raw):
|
| 698 |
-
s=_clean(s)
|
| 699 |
-
if len(s)>=25: lines.append(s)
|
| 700 |
-
segs=_dedupe_units(lines, max_units=max_segments)
|
| 701 |
-
return segs[:max_segments] if segs else [post.get('title','Bản tin VNEWS')]
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='neutral'):
|
| 705 |
-
if Image is None:
|
| 706 |
-
return _make_short_frame_full(post, img_path, out_path)
|
| 707 |
-
W,H=1080,1920
|
| 708 |
-
bg=Image.new('RGB',(W,H),(10,10,10))
|
| 709 |
-
try:
|
| 710 |
-
im=Image.open(img_path).convert('RGB')
|
| 711 |
-
ratio=im.width/max(1,im.height); target=W/H
|
| 712 |
-
if ratio>target:
|
| 713 |
-
nh=H; nw=int(nh*ratio)
|
| 714 |
-
else:
|
| 715 |
-
nw=W; nh=int(nw/ratio)
|
| 716 |
-
cover=im.resize((nw,nh)); left=(nw-W)//2; top=(nh-H)//2
|
| 717 |
-
cover=cover.crop((left,top,left+W,top+H))
|
| 718 |
-
bg.paste(cover,(0,0))
|
| 719 |
-
bg=Image.blend(bg, Image.new('RGB',(W,H),(0,0,0)), 0.50)
|
| 720 |
-
hero_h=720; target=W/hero_h
|
| 721 |
-
if ratio>target:
|
| 722 |
-
nh=hero_h; nw=int(nh*ratio)
|
| 723 |
-
else:
|
| 724 |
-
nw=W; nh=int(nw/ratio)
|
| 725 |
-
hero=im.resize((nw,nh)); left=(nw-W)//2; top=(nh-hero_h)//2
|
| 726 |
-
hero=hero.crop((left,top,left+W,top+hero_h))
|
| 727 |
-
bg.paste(hero,(0,0))
|
| 728 |
-
except Exception:
|
| 729 |
-
pass
|
| 730 |
-
draw=ImageDraw.Draw(bg)
|
| 731 |
-
try:
|
| 732 |
-
font_brand=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',34)
|
| 733 |
-
font_small=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',28)
|
| 734 |
-
font_seg=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',58)
|
| 735 |
-
font_title=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',34)
|
| 736 |
-
except Exception:
|
| 737 |
-
font_brand=font_small=font_seg=font_title=None
|
| 738 |
-
draw.rectangle((0,680,W,H), fill=(12,12,12))
|
| 739 |
-
dot_x=48; dot_y=742
|
| 740 |
-
for i in range(total):
|
| 741 |
-
fill=(92,184,122) if i==idx else (70,70,70)
|
| 742 |
-
draw.rounded_rectangle((dot_x+i*38,dot_y,dot_x+i*38+24,dot_y+10), radius=5, fill=fill)
|
| 743 |
-
draw.text((48,780),'VNEWS AI SHORT',fill=(110,231,143),font=font_brand)
|
| 744 |
-
draw.rounded_rectangle((48,834,260,880), radius=20, fill=(28,70,45))
|
| 745 |
-
draw.text((66,842),f'Đoạn {idx+1}/{total}',fill=(235,235,235),font=font_small)
|
| 746 |
-
y=940; maxw=W-96
|
| 747 |
-
# INCREASED from 12 to 18 for full content display - each key point can span multiple lines
|
| 748 |
-
for ln in _wrap_text_px(draw, segment, font_seg, maxw, 18):
|
| 749 |
-
draw.text((48,y),ln,fill=(255,255,255),font=font_seg)
|
| 750 |
-
y+=74
|
| 751 |
-
if y>1500: break
|
| 752 |
-
y2=1640
|
| 753 |
-
draw.line((48,y2-22,W-48,y2-22),fill=(70,70,70),width=2)
|
| 754 |
-
for ln in _wrap_text_px(draw, post.get('title',''), font_title, maxw, 3):
|
| 755 |
-
draw.text((48,y2),ln,fill=(220,220,220),font=font_title)
|
| 756 |
-
y2+=46
|
| 757 |
-
bg.save(out_path, quality=92)
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
def _estimate_audio_duration(path, fallback=15.0):
|
| 761 |
-
"""Estimate audio duration with 15s minimum per segment for complete bullet reading."""
|
| 762 |
-
try:
|
| 763 |
-
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)
|
| 764 |
-
return max(12.0, float((pr.stdout or b'').decode().strip() or fallback))
|
| 765 |
-
except Exception:
|
| 766 |
-
return fallback
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
@app.post('/api/ai/short/{post_id}')
|
| 770 |
-
async def patched_ai_short(post_id: str, request: Request):
|
| 771 |
-
try:
|
| 772 |
-
body = await request.json()
|
| 773 |
-
except Exception:
|
| 774 |
-
body = {}
|
| 775 |
-
voice = str(body.get('voice', 'nu')).strip().lower()
|
| 776 |
-
emotion = str(body.get('emotion', 'neutral')).strip().lower()
|
| 777 |
-
speed = float(body.get('speed', 1.0) or 1.0)
|
| 778 |
-
speed = max(0.85, min(1.35, speed))
|
| 779 |
-
|
| 780 |
-
posts = base._load_ai_wall()
|
| 781 |
-
post = next((p for p in posts if str(p.get('id')) == str(post_id)), None)
|
| 782 |
-
if not post:
|
| 783 |
-
return JSONResponse({'error': 'post not found'}, status_code=404)
|
| 784 |
-
|
| 785 |
-
segments = _summary_segments_from_post(post, max_segments=25)
|
| 786 |
-
seg_hash = hashlib.md5(('|'.join(segments)+voice+emotion+str(speed)).encode('utf-8')).hexdigest()[:8]
|
| 787 |
-
os.makedirs(base.SHORTS_DIR, exist_ok=True)
|
| 788 |
-
suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_{seg_hash}_scenes_nosub"
|
| 789 |
-
out_mp4 = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix) + '.mp4')
|
| 790 |
-
if os.path.exists(out_mp4):
|
| 791 |
-
post['video'] = '/api/ai/short-file/' + post_id + suffix
|
| 792 |
-
post['short_voice'] = voice
|
| 793 |
-
post['short_emotion'] = emotion
|
| 794 |
-
post['short_speed'] = speed
|
| 795 |
-
post['short_segments'] = segments
|
| 796 |
-
post['short_subtitles'] = False
|
| 797 |
-
base._save_ai_wall(posts)
|
| 798 |
-
return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': False, 'segments': segments})
|
| 799 |
-
if base.gTTS is None:
|
| 800 |
-
return JSONResponse({'error': 'gTTS chưa sẵn sàng'}, status_code=503)
|
| 801 |
-
|
| 802 |
-
work = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix))
|
| 803 |
-
os.makedirs(work, exist_ok=True)
|
| 804 |
-
img = os.path.join(work, 'image.jpg')
|
| 805 |
-
try:
|
| 806 |
-
base._download_image(post.get('img'), post.get('title', 'AI news'), img)
|
| 807 |
-
edge_voice = {
|
| 808 |
-
# Vietnamese
|
| 809 |
-
'vi-vn-hoaimyneural': 'vi-VN-HoaiMyNeural',
|
| 810 |
-
'vi-vn-namminhneural': 'vi-VN-NamMinhNeural',
|
| 811 |
-
'hoaimy': 'vi-VN-HoaiMyNeural',
|
| 812 |
-
'namminh': 'vi-VN-NamMinhNeural',
|
| 813 |
-
'nam': 'vi-VN-NamMinhNeural',
|
| 814 |
-
'male': 'vi-VN-NamMinhNeural',
|
| 815 |
-
'nu': 'vi-VN-HoaiMyNeural',
|
| 816 |
-
'female': 'vi-VN-HoaiMyNeural',
|
| 817 |
-
'mien-nam': 'vi-VN-HoaiMyNeural',
|
| 818 |
-
# English - Multilingual
|
| 819 |
-
'en-us-andrewmultilingualneural': 'en-US-AndrewMultilingualNeural',
|
| 820 |
-
'en-au-williammultilingualneural': 'en-AU-WilliamMultilingualNeural',
|
| 821 |
-
'andrew': 'en-US-AndrewMultilingualNeural',
|
| 822 |
-
'en_andrew': 'en-US-AndrewMultilingualNeural',
|
| 823 |
-
'jenny': 'en-US-AndrewMultilingualNeural',
|
| 824 |
-
'en_jenny': 'en-US-AndrewMultilingualNeural',
|
| 825 |
-
# Portuguese - Multilingual (ONLY Thalita)
|
| 826 |
-
'pt-br-thalitamultilingualneural': 'pt-BR-ThalitaMultilingualNeural',
|
| 827 |
-
'thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 828 |
-
'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 829 |
-
'pt_br_thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 830 |
-
'pt': 'pt-BR-ThalitaMultilingualNeural',
|
| 831 |
-
'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
|
| 832 |
-
# French - Multilingual
|
| 833 |
-
'fr-fr-viviennemultilingualneural': 'fr-FR-VivienneMultilingualNeural',
|
| 834 |
-
'fr-fr-remymultilingualneural': 'fr-FR-RemyMultilingualNeural',
|
| 835 |
-
'denise': 'fr-FR-VivienneMultilingualNeural',
|
| 836 |
-
'fr': 'fr-FR-VivienneMultilingualNeural',
|
| 837 |
-
'fr_denise': 'fr-FR-VivienneMultilingualNeural',
|
| 838 |
-
# German - Multilingual
|
| 839 |
-
'de-de-seraphinamultilingualneural': 'de-DE-SeraphinaMultilingualNeural',
|
| 840 |
-
'de-de-florianmultilingualneural': 'de-DE-FlorianMultilingualNeural',
|
| 841 |
-
'katja': 'de-DE-SeraphinaMultilingualNeural',
|
| 842 |
-
'de': 'de-DE-SeraphinaMultilingualNeural',
|
| 843 |
-
'de_katja': 'de-DE-SeraphinaMultilingualNeural',
|
| 844 |
-
# Korean - Multilingual (Hyunsu, NOT SunHee)
|
| 845 |
-
'ko-kr-hyusumultilingualneural': 'ko-KR-HyunsuMultilingualNeural',
|
| 846 |
-
'ko-kr-hyunsuneural': 'ko-KR-HyunsuMultilingualNeural',
|
| 847 |
-
'sunhee': 'ko-KR-HyunsuMultilingualNeural',
|
| 848 |
-
'ko': 'ko-KR-HyunsuMultilingualNeural',
|
| 849 |
-
'ko_sunhee': 'ko-KR-HyunsuMultilingualNeural',
|
| 850 |
-
# Italian - Multilingual
|
| 851 |
-
'it-it-giuseppemultilingualneural': 'it-IT-GiuseppeMultilingualNeural',
|
| 852 |
-
# Spanish (keep for backward compat)
|
| 853 |
-
'ela': 'en-US-AndrewMultilingualNeural',
|
| 854 |
-
'es_ela': 'en-US-AndrewMultilingualNeural',
|
| 855 |
-
'es': 'en-US-AndrewMultilingualNeural',
|
| 856 |
-
'es_carlos': 'en-US-AndrewMultilingualNeural',
|
| 857 |
-
# Japanese (keep for backward compat)
|
| 858 |
-
'nanami': 'en-US-AndrewMultilingualNeural',
|
| 859 |
-
'ja': 'en-US-AndrewMultilingualNeural',
|
| 860 |
-
'ja_nanami': 'en-US-AndrewMultilingualNeural',
|
| 861 |
-
# Chinese (keep for backward compat)
|
| 862 |
-
'xiaochen': 'en-US-AndrewMultilingualNeural',
|
| 863 |
-
'zh': 'en-US-AndrewMultilingualNeural',
|
| 864 |
-
'zh_xiaochen': 'en-US-AndrewMultilingualNeural',
|
| 865 |
-
}.get(voice, 'vi-VN-HoaiMyNeural')
|
| 866 |
-
part_files=[]
|
| 867 |
-
for idx, seg in enumerate(segments):
|
| 868 |
-
frame=os.path.join(work,f'frame_{idx:02d}.jpg')
|
| 869 |
-
aud=os.path.join(work,f'voice_{idx:02d}.mp3')
|
| 870 |
-
aud_fast=os.path.join(work,f'voice_{idx:02d}_fast.mp3')
|
| 871 |
-
part=os.path.join(work,f'part_{idx:02d}.mp4')
|
| 872 |
-
_make_scene_frame(post, seg, idx, len(segments), img, frame, emotion=emotion)
|
| 873 |
-
spoken=_emotion_script(seg, emotion)
|
| 874 |
-
try:
|
| 875 |
-
subprocess.run(['python','-m','edge_tts','--voice',edge_voice,'--text',spoken,'--write-media',aud], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
| 876 |
-
except Exception:
|
| 877 |
-
tld='com.vn' if voice in ('nu','female','mien-nam','hoaimy') else 'com'
|
| 878 |
-
try:
|
| 879 |
-
base.gTTS(spoken, lang='vi', tld=tld, slow=False).save(aud)
|
| 880 |
-
except TypeError:
|
| 881 |
-
base.gTTS(spoken, lang='vi', slow=False).save(aud)
|
| 882 |
-
subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
|
| 883 |
-
dur=_estimate_audio_duration(aud_fast, fallback=15.0)+0.35
|
| 884 |
-
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)
|
| 885 |
-
part_files.append(part)
|
| 886 |
-
concat=os.path.join(work,'concat.txt')
|
| 887 |
-
with open(concat,'w',encoding='utf-8') as f:
|
| 888 |
-
for p in part_files:
|
| 889 |
-
f.write("file '" + p.replace("'", "'\\''") + "'\n")
|
| 890 |
-
subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',concat,'-c','copy',out_mp4], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
|
| 891 |
-
post['video'] = '/api/ai/short-file/' + post_id + suffix
|
| 892 |
-
post['short_voice'] = voice
|
| 893 |
-
post['short_emotion'] = emotion
|
| 894 |
-
post['short_speed'] = speed
|
| 895 |
-
post['short_segments'] = segments
|
| 896 |
-
post['short_subtitles'] = False
|
| 897 |
-
base._save_ai_wall(posts)
|
| 898 |
-
return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': False, 'segments': segments})
|
| 899 |
-
except Exception as e:
|
| 900 |
-
return JSONResponse({'error': 'Không tạo được shorts: ' + str(e)[:220]}, status_code=500)
|
| 901 |
-
|
| 902 |
-
|
| 903 |
-
@app.get('/api/ai/short-file/{file_id}')
|
| 904 |
-
def patched_ai_short_file(file_id: str):
|
| 905 |
-
path = os.path.join(base.SHORTS_DIR, base._safe_name(file_id) + '.mp4')
|
| 906 |
-
if not os.path.exists(path):
|
| 907 |
-
return JSONResponse({'error': 'not found'}, status_code=404)
|
| 908 |
-
return FileResponse(path, media_type='video/mp4', filename=f'vnews-ai-{file_id}.mp4')
|
| 909 |
-
|
| 910 |
-
|
| 911 |
-
@app.get('/api/ai_shorts')
|
| 912 |
-
def api_ai_shorts():
|
| 913 |
-
posts = [p for p in base._load_ai_wall() if p.get('video')]
|
| 914 |
-
return JSONResponse({'posts': posts[:80]})
|
| 915 |
-
|
| 916 |
|
| 917 |
-
app.
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ai_patch.py — Helper functions for rewrite, summary, short frame creation.
|
| 2 |
+
FIX v2: Vietnamese font auto-detection (not hardcoded DejaVu paths).
|
| 3 |
+
"""
|
| 4 |
import os
|
| 5 |
import re
|
| 6 |
import time
|
|
|
|
| 10 |
import subprocess
|
| 11 |
import requests
|
| 12 |
import hashlib
|
| 13 |
+
from datetime import datetime, timezone
|
| 14 |
import ai_ext as base
|
| 15 |
from ai_ext import app
|
| 16 |
from fastapi import Request
|
|
|
|
| 23 |
except Exception:
|
| 24 |
Image = ImageDraw = ImageFont = None
|
| 25 |
|
| 26 |
+
_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())
|
| 27 |
|
| 28 |
+
# ===== VIETNAMESE FONT DETECTION =====
|
| 29 |
+
_VN_FONT_REG = None
|
| 30 |
+
_VN_FONT_BOLD = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
+
def _get_vn_fonts():
|
| 33 |
+
"""Auto-detect fonts that support Vietnamese diacritics."""
|
| 34 |
+
global _VN_FONT_REG, _VN_FONT_BOLD
|
| 35 |
+
if _VN_FONT_REG is not None:
|
| 36 |
+
return _VN_FONT_REG, _VN_FONT_BOLD
|
| 37 |
+
|
| 38 |
+
# Try system fonts in priority order (Noto first for full Vietnamese)
|
| 39 |
+
reg_paths = [
|
| 40 |
+
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
|
| 41 |
+
"/usr/share/fonts/truetype/noto/NotoSans-Variable.ttf",
|
| 42 |
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
| 43 |
+
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
|
| 44 |
+
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
| 45 |
+
]
|
| 46 |
+
bold_paths = [
|
| 47 |
+
"/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf",
|
| 48 |
+
"/usr/share/fonts/truetype/noto/NotoSans-Variable.ttf",
|
| 49 |
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
| 50 |
+
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
|
| 51 |
+
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
|
| 52 |
+
]
|
| 53 |
+
|
| 54 |
+
# Also search broadly
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
try:
|
| 56 |
+
result = subprocess.run(
|
| 57 |
+
['find', '/usr/share/fonts', '-name', '*.ttf', '-type', 'f'],
|
| 58 |
+
capture_output=True, text=True, timeout=5
|
| 59 |
+
)
|
| 60 |
+
all_ttf = result.stdout.strip().split('\n')
|
| 61 |
+
for key in ['noto', 'dejavu', 'liberation', 'freefont', 'vn']:
|
| 62 |
+
for path in all_ttf:
|
| 63 |
+
pl = path.lower()
|
| 64 |
+
if key in pl:
|
| 65 |
+
if ('bold' in pl or 'variable' in pl) and _VN_FONT_BOLD is None:
|
| 66 |
+
try:
|
| 67 |
+
_VN_FONT_BOLD = ImageFont.truetype(path, 56)
|
| 68 |
+
except:
|
| 69 |
+
pass
|
| 70 |
+
elif ('regular' in pl or path == '/usr/share/fonts/truetype/vn/VNFont.ttf') and _VN_FONT_REG is None:
|
| 71 |
+
try:
|
| 72 |
+
_VN_FONT_REG = ImageFont.truetype(path, 48)
|
| 73 |
+
except:
|
| 74 |
+
pass
|
| 75 |
+
except:
|
| 76 |
pass
|
| 77 |
+
|
| 78 |
+
# Try regular paths
|
| 79 |
+
for path in reg_paths:
|
| 80 |
+
if os.path.exists(path) and _VN_FONT_REG is None:
|
| 81 |
+
try:
|
| 82 |
+
_VN_FONT_REG = ImageFont.truetype(path, 48)
|
| 83 |
+
except:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
continue
|
| 85 |
+
for path in bold_paths:
|
| 86 |
+
if os.path.exists(path) and _VN_FONT_BOLD is None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
try:
|
| 88 |
+
_VN_FONT_BOLD = ImageFont.truetype(path, 56)
|
| 89 |
+
except:
|
| 90 |
+
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
+
if _VN_FONT_REG is None:
|
| 93 |
+
_VN_FONT_REG = ImageFont.load_default()
|
| 94 |
+
if _VN_FONT_BOLD is None:
|
| 95 |
+
_VN_FONT_BOLD = _VN_FONT_REG
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
+
return _VN_FONT_REG, _VN_FONT_BOLD
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
+
def _clean(s):
|
| 100 |
+
"""Clean text - replace None with empty string, collapse whitespace."""
|
| 101 |
+
s = s or ''
|
| 102 |
+
s = s.strip()
|
| 103 |
+
s = re.sub(r'\s+', ' ', s)
|
| 104 |
+
return s
|
| 105 |
|
| 106 |
+
def _postprocess_ai_text(text, max_units=20):
|
| 107 |
+
"""Clean AI-generated text: remove HTML artifacts, deduplicate, return string with bullet points."""
|
| 108 |
+
text = _clean(text)
|
| 109 |
+
if not text:
|
| 110 |
+
return ""
|
| 111 |
+
# Remove malformed HTML with both href and src attributes (e.g., <a href="..." src="...">)
|
| 112 |
+
text = re.sub(r'<a[^>]*href\s*=\s*"[^"]*"[^>]*\s*src\s*=\s*"[^"]*"[^>]*>', '', text, flags=re.I)
|
| 113 |
+
# Remove any remaining HTML artifacts
|
| 114 |
+
text = re.sub(r'<a\s+[^>]*>', '', text, flags=re.I)
|
| 115 |
+
text = re.sub(r'<img\s+[^>]*>', '', text, flags=re.I)
|
| 116 |
+
text = re.sub(r'<[^>]+>', '', text)
|
| 117 |
+
text = re.sub(r'</a>', '', text, flags=re.I)
|
| 118 |
+
# Fix truncated HTML tags (missing closing >)
|
| 119 |
+
text = text.replace('<a href=" src="', '').replace("<a href=' src='", '')
|
| 120 |
+
text = text.replace('<a href=" src=', '').replace("<a href=' src=", '')
|
| 121 |
+
# Remove leading bullet/dash
|
| 122 |
+
text = re.sub(r'^\s*[-•]\s*', '', text)
|
| 123 |
+
# Split by double-space (AI often separates items with double space)
|
| 124 |
+
units = text.split(' ')
|
| 125 |
+
out = []
|
| 126 |
seen = set()
|
| 127 |
+
for u in units:
|
| 128 |
+
u = _clean(u)
|
| 129 |
+
if not u:
|
| 130 |
continue
|
| 131 |
+
# Deduplicate by word signature
|
| 132 |
+
w = tuple(re.findall(r"[\w']+", u.lower()))
|
| 133 |
+
if w in seen:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
continue
|
| 135 |
+
seen.add(w)
|
| 136 |
+
out.append("• " + u)
|
| 137 |
+
if len(out) >= max_units:
|
| 138 |
+
break
|
| 139 |
+
return "\n".join(out) if out else ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
def _scrape_article_images(url):
|
| 142 |
+
"""Use the comprehensive scraper from ai_ext for images + paragraphs."""
|
| 143 |
+
sents = []; imgs = []; og = ''
|
| 144 |
try:
|
| 145 |
+
if url:
|
| 146 |
+
# Use the comprehensive scraper first
|
| 147 |
+
try:
|
| 148 |
+
data = base.scrape_any_url(url)
|
| 149 |
+
if data and data.get('text'):
|
| 150 |
+
sents = [p.strip() for p in data['text'].split('\n') if len(p.strip()) > 40]
|
| 151 |
+
imgs = data.get('images', [])
|
| 152 |
+
og = data.get('og_image', '')
|
| 153 |
+
if not imgs and og:
|
| 154 |
+
imgs = [og]
|
| 155 |
+
if sents or imgs:
|
| 156 |
+
return {'paragraphs': sents, 'images': imgs, 'og_img': og}
|
| 157 |
+
except:
|
| 158 |
+
pass
|
| 159 |
+
|
| 160 |
+
# Fallback: basic scrape
|
| 161 |
+
r = requests.get(url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=8)
|
| 162 |
+
r.encoding = 'utf-8'
|
| 163 |
+
soup = BeautifulSoup(r.text, 'lxml')
|
| 164 |
+
og = soup.find('meta', property='og:image')
|
| 165 |
+
og = og.get('content', '') if og else ''
|
| 166 |
+
if og and og.startswith('//'):
|
| 167 |
+
og = 'https:' + og
|
| 168 |
+
for p in soup.find_all('p'):
|
| 169 |
+
t = _clean(p.get_text())
|
| 170 |
+
if len(t) > 30:
|
| 171 |
+
sents.append(t)
|
| 172 |
+
seen = set()
|
| 173 |
+
for im in soup.find_all('img'):
|
| 174 |
+
src = im.get('data-src') or im.get('src') or im.get('data-lazy') or ''
|
| 175 |
+
if src and 'base64' not in src and src not in seen:
|
| 176 |
+
seen.add(src)
|
| 177 |
+
if src.startswith('//'):
|
| 178 |
+
src = 'https:' + src
|
| 179 |
+
imgs.append(src)
|
| 180 |
+
# Filter: prefer large images
|
| 181 |
+
imgs = [im for im in imgs if not any(x in im.lower() for x in ['logo', 'icon', 'avatar', '.svg', 'banner'])]
|
| 182 |
+
return {'paragraphs': sents, 'images': imgs, 'og_img': og}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
except Exception as e:
|
| 184 |
+
return {'paragraphs': [], 'images': [], 'og_img': ''}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
|
| 186 |
+
def _extract_key_points_for_slides(paragraphs, max_points=8):
|
| 187 |
+
"""Extract key points from paragraphs, splitting long ones into sentence-level points."""
|
| 188 |
+
pts = []
|
| 189 |
+
for p in paragraphs:
|
| 190 |
+
if len(p) < 40:
|
| 191 |
+
continue
|
| 192 |
+
s = _clean(p)
|
| 193 |
+
# Split long paragraphs into sentence-level points
|
| 194 |
+
if len(s) > 200:
|
| 195 |
+
sentences = re.split(r'(?<=[.!?])\s+(?=[A-Z0-9À-Ỹ])', s)
|
| 196 |
+
for sent in sentences:
|
| 197 |
+
sent = _clean(sent)
|
| 198 |
+
if len(sent) > 60 and sent not in pts:
|
| 199 |
+
pts.append(sent)
|
| 200 |
+
if len(pts) >= max_points:
|
| 201 |
+
break
|
| 202 |
+
else:
|
| 203 |
+
if s and s not in pts:
|
| 204 |
+
pts.append(s)
|
| 205 |
+
if len(pts) >= max_points:
|
| 206 |
+
break
|
| 207 |
+
return pts
|
| 208 |
+
|
| 209 |
+
def _make_summary_prompt(title, body, source):
|
| 210 |
+
topic = source or "báo chính"
|
| 211 |
+
return f"""Tóm tắt tin tức: {title}
|
| 212 |
+
Nguồn: {topic}
|
| 213 |
+
---
|
| 214 |
+
{body[:1200] if body else ''}
|
| 215 |
+
---
|
| 216 |
+
Hãy viết tóm tắt ngắn gọn, súc tích bằng tiếng Việt. Dùng bullet point (•) để liệt kê điểm chính. Không dùng định dạng HTML. Không lặp lại tiêu đề. Không viết "Bản tin VNEWS" hay tiêu đề phụ."""
|
| 217 |
+
|
| 218 |
+
def _source_line(sources=None):
|
| 219 |
+
if not sources:
|
| 220 |
+
return "Nguồn tham khảo: VNEWS"
|
| 221 |
+
src = sources[0] if sources else {}
|
| 222 |
+
title = src.get('title', '')
|
| 223 |
+
url = src.get('url', '')
|
| 224 |
+
via = src.get('via', '')
|
| 225 |
+
if url and via:
|
| 226 |
+
return f"Nguồn: [{via}]({url})"
|
| 227 |
+
if url:
|
| 228 |
+
return f"Nguồn: {url}"
|
| 229 |
+
if via:
|
| 230 |
+
return f"Nguồn: {via}"
|
| 231 |
+
return "Nguồn tham khảo: VNEWS"
|
| 232 |
+
|
| 233 |
+
def _summary_segments_from_post(post, max_segments=15):
|
| 234 |
+
"""Split post text into segments for short video. Uses multi-strategy like main generator."""
|
| 235 |
+
text = post.get('text', '')
|
| 236 |
+
segs = []
|
| 237 |
+
if not text:
|
| 238 |
+
return segs
|
| 239 |
|
| 240 |
+
# Strategy 1: split by newlines (bullet points)
|
| 241 |
+
for line in text.split('\n'):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
line = _clean(line)
|
| 243 |
+
line_bare = re.sub(r'^[•\-\*\d\.\)\s]+', '', line).strip()
|
| 244 |
+
if len(line_bare) > 25:
|
| 245 |
+
segs.append(line_bare)
|
| 246 |
+
|
| 247 |
+
# Strategy 2: split by sentences
|
| 248 |
+
if len(segs) < 3:
|
| 249 |
+
segs = []
|
| 250 |
+
for sent in re.split(r'(?<=[.!?])\s+(?=[A-Z0-9À-ỸĐ])', text):
|
| 251 |
+
sent = _clean(sent)
|
| 252 |
+
if len(sent) > 25:
|
| 253 |
+
segs.append(sent)
|
| 254 |
+
|
| 255 |
+
# Strategy 3: chunk by size
|
| 256 |
+
if len(segs) < 2:
|
| 257 |
+
segs = []
|
| 258 |
+
words = text.split()
|
| 259 |
+
chunk = []
|
| 260 |
+
ccount = 0
|
| 261 |
+
for w in words:
|
| 262 |
+
chunk.append(w)
|
| 263 |
+
ccount += len(w) + 1
|
| 264 |
+
if ccount > 150:
|
| 265 |
+
segs.append(' '.join(chunk))
|
| 266 |
+
chunk = []
|
| 267 |
+
ccount = 0
|
| 268 |
+
if chunk:
|
| 269 |
+
segs.append(' '.join(chunk))
|
| 270 |
+
|
| 271 |
+
return segs[:max_segments]
|
|
|
|
|
|
|
| 272 |
|
| 273 |
+
def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='neutral'):
|
| 274 |
+
"""Create a scene frame with Vietnamese font support."""
|
| 275 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 276 |
+
f, fb = _get_vn_fonts()
|
| 277 |
+
|
| 278 |
+
sz = (1080, 1920)
|
| 279 |
+
bg = Image.new('RGB', sz, (15, 23, 38))
|
| 280 |
+
d = ImageDraw.Draw(bg)
|
| 281 |
+
|
| 282 |
+
# Draw title bar
|
| 283 |
+
d.rectangle([0, 0, sz[0], 120], fill=(25, 118, 210))
|
| 284 |
+
title = post.get('title', '')[:60]
|
| 285 |
+
d.text((sz[0]//2, 60), title, fill='white', font=fb, anchor='mm')
|
| 286 |
+
|
| 287 |
+
# Draw segment text with word wrapping
|
| 288 |
+
max_width = 920
|
| 289 |
+
words = (segment or '')[:200].split()
|
| 290 |
+
lines = []
|
| 291 |
+
cur = []
|
| 292 |
for w in words:
|
| 293 |
+
test = ' '.join(cur + [w])
|
| 294 |
try:
|
| 295 |
+
tw = d.textbbox((0, 0), test, font=f)[2]
|
| 296 |
+
except:
|
| 297 |
+
tw = len(test) * 22
|
| 298 |
+
if tw <= max_width:
|
| 299 |
+
cur.append(w)
|
| 300 |
else:
|
| 301 |
if cur:
|
| 302 |
+
lines.append(' '.join(cur))
|
| 303 |
+
cur = [w]
|
| 304 |
+
if len(lines) >= 10:
|
| 305 |
+
break
|
| 306 |
+
if cur and len(lines) < 10:
|
| 307 |
+
lines.append(' '.join(cur))
|
| 308 |
+
|
| 309 |
+
y = 200
|
| 310 |
+
for line in lines:
|
| 311 |
+
d.text((80, y), line, fill='white', font=f)
|
| 312 |
+
y += 56
|
| 313 |
+
if y > sz[1] - 80:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
break
|
| 315 |
+
|
| 316 |
+
bg.save(out_path, quality=85)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
|
| 318 |
+
@app.get('/health')
|
| 319 |
+
async def health():
|
| 320 |
+
return JSONResponse({'status': 'ok'})
|
ai_runtime.py
CHANGED
|
@@ -253,7 +253,8 @@ def make_frame(post,seg,idx,total,img_path,out_path):
|
|
| 253 |
for i in range(total):
|
| 254 |
fill=(92,184,122) if i==idx else (70,70,70)
|
| 255 |
draw.rounded_rectangle((start+i*38,820,start+i*38+24,832),radius=6,fill=fill)
|
| 256 |
-
brand
|
|
|
|
| 257 |
try:
|
| 258 |
bb=draw.textbbox((0,0),brand,font=ft);tx=(W-(bb[2]-bb[0]))//2
|
| 259 |
except Exception:tx=360
|
|
|
|
| 253 |
for i in range(total):
|
| 254 |
fill=(92,184,122) if i==idx else (70,70,70)
|
| 255 |
draw.rounded_rectangle((start+i*38,820,start+i*38+24,832),radius=6,fill=fill)
|
| 256 |
+
# Determine brand text based on post kind (auto_scheduled vs manual)
|
| 257 |
+
brand = 'Tin tóm tắt VNEWS 7h sáng, 13h trưa, 19h tối' if post.get('kind') == 'auto_scheduled' else 'VNEWS AI SHORT'
|
| 258 |
try:
|
| 259 |
bb=draw.textbbox((0,0),brand,font=ft);tx=(W-(bb[2]-bb[0]))//2
|
| 260 |
except Exception:tx=360
|
ai_runtime_final.py
CHANGED
|
@@ -297,19 +297,19 @@ async def final_short(post_id:str,request:Request):
|
|
| 297 |
clips.append(clip)
|
| 298 |
lf=os.path.join(work,'list.txt')
|
| 299 |
with open(lf,'w',encoding='utf-8') as f:
|
| 300 |
-
for c in clips:f.write("file '"+c
|
| 301 |
subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',lf,'-c','copy',out],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=240)
|
| 302 |
post['video']='/api/ai/short-file/'+post_id+suffix;post['short_subtitles']=False;post['short_segments']=segs;post['short_speed']=speed;base._save_ai_wall(posts)
|
| 303 |
return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False})
|
| 304 |
except Exception as e:return JSONResponse({'error':'Không tạo được shorts: '+str(e)[:220]},status_code=500)
|
| 305 |
|
| 306 |
-
@app.get('/aw')
|
| 307 |
-
def ai_wall_share(post:str=Query(default=''), short:int=Query(default=0)):
|
| 308 |
-
posts=base._load_ai_wall();p=next((x for x in posts if str(x.get('id'))==str(post)),None)
|
| 309 |
-
if not p:return HTMLResponse(f'<script>location.href="{SPACE_URL}"</script>')
|
| 310 |
-
title=p.get('title') or 'VNEWS AI';img=p.get('img') or DEFAULT_IMG
|
| 311 |
-
desc=(p.get('text') or '')[:220]
|
| 312 |
-
return HTMLResponse(f'<!doctype html><html><head><meta charset="utf-8"><title>{title}</title><meta property="og:title" content="{title}"><meta property="og:description" content="{desc}"><meta property="og:image" content="{img}"><meta property="og:type" content="article"><meta name="twitter:card" content="summary_large_image"></head><body><script>localStorage.setItem('pending_ai_post','{post}');location.href='{SPACE_URL}'</script></body></html>')
|
| 313 |
|
| 314 |
-
FINAL_INJECT = r'''
|
| 315 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
clips.append(clip)
|
| 298 |
lf=os.path.join(work,'list.txt')
|
| 299 |
with open(lf,'w',encoding='utf-8') as f:
|
| 300 |
+
for c in clips: f.write("file '" + c + "'\n")
|
| 301 |
subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',lf,'-c','copy',out],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=240)
|
| 302 |
post['video']='/api/ai/short-file/'+post_id+suffix;post['short_subtitles']=False;post['short_segments']=segs;post['short_speed']=speed;base._save_ai_wall(posts)
|
| 303 |
return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False})
|
| 304 |
except Exception as e:return JSONResponse({'error':'Không tạo được shorts: '+str(e)[:220]},status_code=500)
|
| 305 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
|
| 307 |
+
FINAL_INJECT = r'''<style>.fix{}</style>'''
|
| 308 |
+
|
| 309 |
+
@app.get('/')
|
| 310 |
+
async def index_final():
|
| 311 |
+
try:
|
| 312 |
+
html = open('/app/static/index.html', 'r', encoding='utf-8').read()
|
| 313 |
+
except Exception:
|
| 314 |
+
html = '<html><body><h1>VNEWS AI</h1></body></html>'
|
| 315 |
+
return HTMLResponse(html)
|
ai_runtime_final6.py
CHANGED
|
@@ -354,7 +354,7 @@ setInterval(()=>{document.querySelectorAll('#ai-topic-input-final3,.topic-final3
|
|
| 354 |
|
| 355 |
@app.get('/')
|
| 356 |
async def index_final6():
|
| 357 |
-
html=
|
| 358 |
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
|
| 359 |
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|
| 360 |
|
|
@@ -752,7 +752,7 @@ setTimeout(()=>{window.__allowShortRefresh=true;},7000);
|
|
| 752 |
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
|
| 753 |
@app.get('/')
|
| 754 |
async def index_final6_fast_home():
|
| 755 |
-
html=
|
| 756 |
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
|
| 757 |
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|
| 758 |
|
|
@@ -831,19 +831,13 @@ Yêu cầu:
|
|
| 831 |
posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts)
|
| 832 |
return JSONResponse({'post':post,'mode':'fast_rss_with_source_details','sources_count':len(details)})
|
| 833 |
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
</style>
|
| 838 |
-
|
| 839 |
-
(
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
window.readTopicWallE=function(i){let p=window.__topicWallE[i];if(!p)return;showView('view-article');let imgs=(p.images||[]).filter(Boolean);let gal=imgs.length?'<div class="ai-wall-gallery">'+imgs.slice(0,12).map(u=>`<img src="${escE(u)}" loading="lazy">`).join('')+'</div>':(p.img?`<img class="article-img" src="${escE(p.img)}">`:'');let srcDetails=sourceDetailsHtml(p);document.getElementById('view-article').innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${escE(p.title)}</h1>${gal}<p class="article-p" style="white-space:pre-wrap">${escE(p.text)}</p>${srcDetails}<div class="article-actions"><button onclick="shareAI?shareAI(${JSON.stringify(p).replace(/"/g,'"')},false):navigator.clipboard.writeText(location.href)">📤 Chia sẻ</button></div></div>`;window.scrollTo(0,0)};
|
| 845 |
-
window.createTopicPostFinal5=async function(){let inp=document.getElementById('ai-topic-input-final5');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');let btn=document.getElementById('ai-topic-btn-final5');if(btn){btn.disabled=true;btn.textContent='Đang tìm nguồn...'}try{let src=await fetch('/api/topic_sources?topic='+encodeURIComponent(topic)).then(r=>r.json()).catch(()=>null);if(btn&&src)btn.textContent='Đã tìm '+(src.count||0)+' nguồn, đang tổng hợp...';let r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');window.__topicWallE.unshift(j.post);if(inp)inp.value='';renderTopicWallE();readTopicWallE(0);alert('Đã tạo bài tổng hợp từ nội dung web và đăng lên Tường AI.');}catch(e){alert(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='✨ Tạo bài tổng hợp từ web bằng Qwen'}}};
|
| 846 |
-
setInterval(()=>{document.querySelectorAll('#ai-topic-input-final3,.topic-final3,#ai-topic-input-final4,.topic-final4').forEach(e=>(e.closest('.topic-final3,.topic-final4,.ai-compose-row')||e).remove());let b=document.getElementById('ai-topic-btn-final5');if(b){b.style.display='block';b.textContent='✨ Tạo bài tổng hợp từ web bằng Qwen';}},1200);
|
| 847 |
-
})();
|
| 848 |
-
</script>
|
| 849 |
-
'''
|
|
|
|
| 354 |
|
| 355 |
@app.get('/')
|
| 356 |
async def index_final6():
|
| 357 |
+
html=open('/app/static/index_v2.html','r',encoding='utf-8').read() if os.path.exists('/app/static/index_v2.html') else '<html></html>'
|
| 358 |
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
|
| 359 |
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|
| 360 |
|
|
|
|
| 752 |
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
|
| 753 |
@app.get('/')
|
| 754 |
async def index_final6_fast_home():
|
| 755 |
+
html=open('/app/static/index_v2.html','r',encoding='utf-8').read() if os.path.exists('/app/static/index_v2.html') else '<html></html>'
|
| 756 |
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
|
| 757 |
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|
| 758 |
|
|
|
|
| 831 |
posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts)
|
| 832 |
return JSONResponse({'post':post,'mode':'fast_rss_with_source_details','sources_count':len(details)})
|
| 833 |
|
| 834 |
+
|
| 835 |
+
|
| 836 |
+
|
| 837 |
+
FINAL6E_INJECT = r'''<style>.placeholder{}</style>'''
|
| 838 |
+
|
| 839 |
+
@app.get('/')
|
| 840 |
+
async def index_final6():
|
| 841 |
+
html = open('/app/static/index_v2.html','r',encoding='utf-8').read() if os.path.exists('/app/static/index_v2.html') else '<html></html>'
|
| 842 |
+
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
|
| 843 |
+
return HTMLResponse(html.replace('</body>', body + '\n</body>') if '</body>' in html else html + body)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ai_runtime_fix.py
CHANGED
|
@@ -1,7 +1,6 @@
|
|
| 1 |
"""VNEWS Short Video Fix - standalone module with clean registration.
|
| 2 |
This module MUST be imported LAST to register /api/ai/short endpoints.
|
| 3 |
FIX v1: No route filtering issues - registers endpoints unconditionally.
|
| 4 |
-
FIX v2: SSE inline endpoint for auto homepage updates
|
| 5 |
"""
|
| 6 |
import os
|
| 7 |
import re
|
|
@@ -337,12 +336,6 @@ def _gen_short_sync(post) -> str:
|
|
| 337 |
wall[i] = p
|
| 338 |
break
|
| 339 |
base._save_ai_wall(wall)
|
| 340 |
-
# Notify SSE for auto-update
|
| 341 |
-
try:
|
| 342 |
-
from auto_update_sse import notify_new_short
|
| 343 |
-
notify_new_short(post)
|
| 344 |
-
except:
|
| 345 |
-
pass
|
| 346 |
except Exception as e:
|
| 347 |
_log.warning(f"Wall update: {e}")
|
| 348 |
return result
|
|
@@ -381,14 +374,5 @@ async def api_short_file(file_id: str):
|
|
| 381 |
return JSONResponse({'error': 'Not found'}, status_code=404)
|
| 382 |
|
| 383 |
|
| 384 |
-
# ===== SSE ENDPOINT FOR AUTO-UPDATE =====
|
| 385 |
-
try:
|
| 386 |
-
from auto_update_sse import sse_events as _sse_handler
|
| 387 |
-
app.add_api_route('/api/events', _sse_handler, methods=['GET'])
|
| 388 |
-
_log.info("SSE endpoint registered at /api/events")
|
| 389 |
-
except Exception as e:
|
| 390 |
-
_log.warning(f"SSE route not loaded: {e}")
|
| 391 |
-
|
| 392 |
-
|
| 393 |
# Log startup
|
| 394 |
_log.info("Short video endpoints registered")
|
|
|
|
| 1 |
"""VNEWS Short Video Fix - standalone module with clean registration.
|
| 2 |
This module MUST be imported LAST to register /api/ai/short endpoints.
|
| 3 |
FIX v1: No route filtering issues - registers endpoints unconditionally.
|
|
|
|
| 4 |
"""
|
| 5 |
import os
|
| 6 |
import re
|
|
|
|
| 336 |
wall[i] = p
|
| 337 |
break
|
| 338 |
base._save_ai_wall(wall)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
except Exception as e:
|
| 340 |
_log.warning(f"Wall update: {e}")
|
| 341 |
return result
|
|
|
|
| 374 |
return JSONResponse({'error': 'Not found'}, status_code=404)
|
| 375 |
|
| 376 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 377 |
# Log startup
|
| 378 |
_log.info("Short video endpoints registered")
|
ai_runtime_patch_fast.py
CHANGED
|
@@ -1,9 +1,16 @@
|
|
| 1 |
-
"""Final patch
|
| 2 |
-
import re, threading, time, json, os, asyncio
|
| 3 |
import ai_runtime_final6 as f6
|
| 4 |
from ai_runtime_final6 import app, rt, f5, HTMLResponse, JSONResponse, Request, Query
|
|
|
|
| 5 |
import html as html_lib
|
| 6 |
from urllib.parse import urlparse
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
def clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or ""))).strip()
|
| 9 |
def _domain(u):
|
|
@@ -13,6 +20,8 @@ DATA_DIR="/data" if os.path.isdir('/data') else "/app/data"
|
|
| 13 |
os.makedirs(DATA_DIR,exist_ok=True)
|
| 14 |
SHORT_COMMENTS_FILE=os.path.join(DATA_DIR,'short_comments.json')
|
| 15 |
TTL_24H=86400;HAS_PERSISTENT=os.path.isdir('/data')
|
|
|
|
|
|
|
| 16 |
def _lj(p,d):
|
| 17 |
try:
|
| 18 |
if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8'))
|
|
@@ -27,6 +36,473 @@ def _cleanup():
|
|
| 27 |
def _scrape(url,mc=8000):
|
| 28 |
try:d=f5.base.scrape_any_url(url);return(d.get('title',''),((d.get('summary','')+'\n'+d.get('text','')).strip())[:mc],d.get('image') or d.get('og_image') or '')
|
| 29 |
except:return('','','')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
_bg_home={"t":0,"d":[]};_bg_shorts={"t":0,"d":[]};_bg_lock=False
|
| 31 |
def _bg():
|
| 32 |
global _bg_lock
|
|
@@ -43,7 +519,7 @@ def _bg():
|
|
| 43 |
@app.on_event("startup")
|
| 44 |
async def _s():threading.Thread(target=_bg,daemon=True).start()
|
| 45 |
threading.Thread(target=lambda:[time.sleep(600) or _bg() for _ in iter(int,1)],daemon=True).start()
|
| 46 |
-
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')))]
|
| 47 |
@app.get('/api/homepage')
|
| 48 |
def _h():
|
| 49 |
n=time.time()
|
|
@@ -75,6 +551,48 @@ async def _ask(request:Request):
|
|
| 75 |
if not raw:raw=ctx[:12000]
|
| 76 |
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)
|
| 77 |
return JSONResponse({'answer':ans or 'Chưa trả lời được.','title':title})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
@app.post('/api/rewrite_share')
|
| 79 |
@app.post('/api/url_wall')
|
| 80 |
async def _rw(request:Request):
|
|
@@ -83,12 +601,33 @@ async def _rw(request:Request):
|
|
| 83 |
title,raw,img=_scrape(url,14000)
|
| 84 |
if len(raw)<50:raw=ctx[:14000]
|
| 85 |
if len(raw)<50:return JSONResponse({'error':'Không đọc được bài'},status_code=422)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
text=None
|
| 87 |
-
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=
|
| 88 |
except:pass
|
| 89 |
if not text or len(text)<80:text=f"Tóm tắt: {title}\n\n{raw[:1200]}\n\nNguồn: {_domain(url)}"
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
@app.post('/api/topic/rewrite')
|
| 93 |
async def _tr(request:Request):
|
| 94 |
b=await request.json();pid=str(b.get('post_id','')).strip()
|
|
@@ -128,7 +667,6 @@ PATCH_INJECT=r'''
|
|
| 128 |
.article-ai-ask{margin-top:12px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.article-ai-ask textarea{width:100%;min-height:60px;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px}.article-ai-ask button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin-top:6px}.article-ai-answer{white-space:pre-wrap;color:#ccc;font-size:13px;line-height:1.55;margin-top:8px}
|
| 129 |
.storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 4px}
|
| 130 |
button[onclick*="rewriteCurrentArticle"]{display:none!important}
|
| 131 |
-
/* Hide ALL old Short AI slides from previous layers */
|
| 132 |
#ai-short-home,.ai-short-home,.ai-short-card-final{display:none!important}
|
| 133 |
.source-detail-box a[target="_blank"]{display:none!important}
|
| 134 |
</style>
|
|
@@ -137,40 +675,27 @@ button[onclick*="rewriteCurrentArticle"]{display:none!important}
|
|
| 137 |
(function(){
|
| 138 |
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}
|
| 139 |
fetch('/api/storage_status').then(r=>r.json()).then(j=>{if(!j.persistent){let h=document.getElementById('view-home');if(h){let w=document.createElement('div');w.className='storage-warn';w.innerHTML='⚠️ <b>Persistent Storage chưa bật.</b> Bật: Space Settings → Persistent Storage → Small.';h.prepend(w);}}});
|
| 140 |
-
|
| 141 |
-
// === Short AI Slide on homepage (same as Dantri shorts) ===
|
| 142 |
async function renderShortAISlide(){let home=document.getElementById('view-home');if(!home)return;document.getElementById('short-ai-final-slide')?.remove();let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];let vids=wall.filter(p=>p.video);if(!vids.length)return;let wrap=document.createElement('div');wrap.id='short-ai-final-slide';wrap.className='slider-wrap';wrap.innerHTML='<div class="slider-header"><span class="slider-label">🎬 Short AI</span></div><div class="slider-track">'+vids.slice(0,30).map((p,i)=>`<div class="slider-item shorts-item" onclick="openAIShortFeed(${i})"><div class="slider-thumb shorts-thumb"><video src="${p.video}" muted preload="metadata" style="width:100%;height:100%;object-fit:cover"></video><div class="card-play">▶</div></div><div class="slider-title">${esc(p.title)}</div></div>`).join('')+'</div>';let comp=home.querySelector('.ai-compose');if(comp&&comp.nextSibling)comp.parentNode.insertBefore(wrap,comp.nextSibling);else home.prepend(wrap);}
|
| 143 |
-
setTimeout(renderShortAISlide,2500);
|
| 144 |
-
|
| 145 |
-
// === Source Details ===
|
| 146 |
function renderSourceDetails(post,container){let det=post.source_details||[];if(!det.length)return;container.querySelectorAll('.source-detail-box').forEach(e=>e.remove());let box=document.createElement('div');box.className='source-detail-box';box.innerHTML='<h3 style="font-size:14px;color:#5cb87a;margin-bottom:8px">📚 Bài nguồn</h3>'+det.map((s,i)=>`<div class="source-detail-item" data-url="${esc(s.url||'')}"><div class="source-detail-title">${i+1}. ${esc(s.title)}</div><div class="source-detail-content">${esc((s.content||'').slice(0,300))}</div><span class="source-vnews-btn">📖 Xem trên VNEWS</span></div>`).join('');container.appendChild(box);box.querySelectorAll('.source-detail-item').forEach(el=>{el.onclick=function(){let u=el.dataset.url;if(u&&typeof readArticle==='function')readArticle(u);}});det.forEach((s,i)=>{if(!s.url)return;fetch('/api/article?url='+encodeURIComponent(s.url)).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){let items=box.querySelectorAll('.source-detail-item');if(items[i]){let img=document.createElement('img');img.src=d.og_image||d.img;img.loading='lazy';img.onerror=function(){this.style.display='none'};items[i].prepend(img);}}}).catch(()=>{});});}
|
| 147 |
-
|
| 148 |
-
// === AI Wall Post View ===
|
| 149 |
async function readAIWallPost(i){let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];let p=wall[i];if(!p)return;showView('view-article');let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${esc(p.title)}</h1>${p.img?`<img class="article-img" src="${p.img}">`:''}`;h+=`<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>`;h+=`<div class="article-actions"><button class="primary" onclick="doRewriteTopic(this,'${esc(p.id)}')">🤖 Rewrite AI đăng tường</button>${p.video?`<button onclick="openAIShortFeed(${i})">🎬 Xem Short</button>`:''}<button onclick="doShare('${esc(p.title)}','${location.origin}','${esc(p.img||'')}')">📤</button></div>`;h+=`<div class="article-ai-ask"><h3 style="font-size:14px;color:#5cb87a">🤖 Hỏi AI</h3><textarea id="article-ai-q" placeholder="Hỏi về nội dung..."></textarea><button onclick="askAIWall(${i})">Hỏi</button><div id="article-ai-ans" class="article-ai-answer"></div></div></div>`;document.getElementById('view-article').innerHTML=h;let art=document.querySelector('.article-view');if(art)renderSourceDetails(p,art);window.scrollTo(0,0);}
|
| 150 |
window.readAIWallPost=readAIWallPost;window.aiReadWallPatched=window.aiReadWall=window.readWallPost=function(i){readAIWallPost(i)};
|
| 151 |
-
|
| 152 |
-
// === Short AI Feed: FULL interaction buttons like Dantri Shorts ===
|
| 153 |
window.openAIShortFeed=async function(startIdx){let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];let vids=wall.filter(p=>p.video);if(!vids.length)return alert('Chưa có Short AI');let ordered=startIdx>0?vids.slice(startIdx).concat(vids.slice(0,startIdx)):vids;showView('view-tiktok');let h='<button class="back-btn" onclick="switchCat(\'home\')">← Short AI</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">';ordered.forEach((p,i)=>{h+=`<div class="tiktok-slide" data-id="${p.id}"><video src="${p.video}" playsinline loop></video><div class="tiktok-bottom"><span class="badge badge-ai">AI Short</span><p class="tiktok-title">${esc(p.title)}</p></div><div class="tiktok-right"><button class="tiktok-right-btn" onclick="event.stopPropagation()"><div class="icon">👁</div><div class="count">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();likeShort('${p.id}',this)"><div class="icon">❤️</div><div class="count">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();openShortComments('${p.id}')"><div class="icon">💬</div><div class="count" id="cc-${p.id}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();shareShort('${esc(p.title)}')"><div class="icon">📤</div><div class="count">Share</div></button></div><span class="tiktok-counter">${i+1}/${ordered.length}</span></div>`});h+='</div></div>';document.getElementById('view-tiktok').innerHTML=h;initShortFeed();ordered.forEach(p=>{fetch('/api/short/comments?id='+encodeURIComponent(p.id)).then(r=>r.json()).then(j=>{let el=document.getElementById('cc-'+p.id);if(el)el.textContent=(j.comments||[]).length}).catch(()=>{});});}
|
| 154 |
window.likeShort=function(id,btn){let c=btn.querySelector('.count');c.textContent=parseInt(c.textContent||0)+1;}
|
| 155 |
window.shareShort=function(title){if(navigator.share)navigator.share({title,url:location.href}).catch(()=>{});else{navigator.clipboard.writeText(location.href);alert('Đã sao chép link!');}}
|
| 156 |
function initShortFeed(){let feed=document.getElementById('tiktok-feed');if(!feed)return;let slides=feed.querySelectorAll('.tiktok-slide');let cur=-1;function act(i){if(i===cur)return;slides.forEach((sl,idx)=>{let v=sl.querySelector('video');let fr=sl.querySelector('iframe');if(idx===i){if(v)v.play().catch(()=>{});if(fr&&!fr.src&&fr.dataset.ytSrc)fr.src=fr.dataset.ytSrc;}else{if(v)v.pause();if(fr&&fr.src)fr.src='';}});cur=i}let t;feed.addEventListener('scroll',()=>{clearTimeout(t);t=setTimeout(()=>{let rect=feed.getBoundingClientRect(),ctr=rect.top+rect.height/2,b=-1,d=1e9;slides.forEach((sl,i)=>{let dd=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(dd<d){d=dd;b=i}});if(b>=0)act(b)},130)});setTimeout(()=>act(0),300);slides.forEach(sl=>{let v=sl.querySelector('video');if(v)v.addEventListener('click',e=>{e.preventDefault();v.paused?v.play().catch(()=>{}):v.pause()})});}
|
| 157 |
-
|
| 158 |
-
// === Handlers ===
|
| 159 |
window.doRewriteTopic=async function(btn,pid){btn.disabled=true;btn.textContent='Đang rewrite...';try{let r=await fetch('/api/topic/rewrite',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({post_id:pid})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');alert('Rewrite thành công!');showRewriteResult(j.post);}catch(e){alert(e.message)}finally{btn.disabled=false;btn.textContent='🤖 Rewrite AI đăng tường';}};
|
| 160 |
window.doRewriteArticle=async function(btn){let url=(window._currentArticle&&window._currentArticle.url)||'';if(!url){let a=document.querySelector('#view-article a[href*="://"]');if(a)url=a.href;}if(!url){let text=document.querySelector('.article-view')?.innerText?.slice(0,14000)||'';if(text.length<100){alert('Không tìm được nội dung để rewrite');return;}btn.disabled=true;btn.textContent='Đang rewrite...';try{let r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:'https://vnews.local/inline',context:text})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error);alert('Rewrite thành công!');showRewriteResult(j.post);}catch(e){alert(e.message)}finally{btn.disabled=false;btn.textContent='🤖 Rewrite AI đăng tường';}return;}btn.disabled=true;btn.textContent='Đang rewrite...';try{let ctx=document.querySelector('.article-view')?.innerText?.slice(0,14000)||'';let r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,context:ctx})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');alert('Rewrite thành công!');showRewriteResult(j.post);}catch(e){alert(e.message)}finally{btn.disabled=false;btn.textContent='🤖 Rewrite AI đăng tường';}};
|
| 161 |
function showRewriteResult(post){if(!post)return;showView('view-article');document.getElementById('view-article').innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">Rewrite</span><h1 class="article-title">${esc(post.title)}</h1>${post.img?`<img class="article-img" src="${post.img}">`:''}` +`<p class="article-p" style="white-space:pre-wrap">${esc(post.text)}</p><div class="article-actions"><button class="primary" onclick="makeShortFromPost('${esc(post.id)}',this)">🎬 Tạo Short AI</button><button onclick="doShare('${esc(post.title)}','${location.origin}','${esc(post.img||'')}')">📤</button></div></div>`;window.scrollTo(0,0);}
|
| 162 |
-
window.makeShortFromPost=async function(pid,btn){if(btn){btn.disabled=true;btn.textContent='Đang tạo...';}try{let r=await fetch('/api/ai/short/'+pid,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:'nu',emotion:'neutral',speed:1.2})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');alert('Đã tạo Short AI!');renderShortAISlide();}catch(e){alert(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='🎬 Tạo Short AI';}}};
|
| 163 |
window.rewriteCurrentArticle=function(){let btn=document.querySelector('[data-rw-article]');if(btn)doRewriteArticle(btn);};
|
| 164 |
window.askAIWall=async function(i){let q=document.getElementById('article-ai-q')?.value.trim();if(!q)return alert('Nhập câu hỏi');document.getElementById('article-ai-ans').textContent='Đang hỏi...';let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];let p=wall[i]||{};let ctx=(p.text||'');for(let s of (p.source_details||[]))ctx+='\n'+(s.content||'');try{let r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({question:q,context:ctx.slice(0,12000)})});let j=await r.json();document.getElementById('article-ai-ans').textContent=j.answer||'Không trả lời được';}catch(e){document.getElementById('article-ai-ans').textContent='Lỗi: '+e.message}};
|
| 165 |
window.askArticleAI=async function(){let q=document.getElementById('article-ai-question')?.value.trim();if(!q)return alert('Nhập câu hỏi');let a=document.getElementById('article-ai-answer');a.textContent='Đang hỏi...';let url=(window._currentArticle&&window._currentArticle.url)||'';let ctx=document.querySelector('.article-view')?.innerText?.slice(0,12000)||'';try{let r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,question:q,context:ctx})});let j=await r.json();a.textContent=j.answer||'Không trả lời được';}catch(e){a.textContent='Lỗi: '+e.message}};
|
| 166 |
window.openShortComments=async function(id){let panel=document.getElementById('short-cmt-panel');let j=await fetch('/api/short/comments?id='+encodeURIComponent(id)).then(r=>r.json()).catch(()=>({comments:[]}));panel.innerHTML=`<h3 style="color:#5cb87a">💬 Bình luận</h3><div id="cmt-list">${(j.comments||[]).map(c=>`<div class="cmt-item">${esc(c.text)}</div>`).join('')||'<div class="cmt-item" style="color:#777">Chưa có</div>'}</div><textarea id="cmt-text" placeholder="Bình luận..."></textarea><button onclick="submitShortCmt('${esc(id)}')">Gửi</button><button onclick="document.getElementById('short-cmt-panel').classList.remove('active')">Đóng</button>`;panel.classList.add('active');}
|
| 167 |
window.submitShortCmt=async function(id){let t=document.getElementById('cmt-text')?.value.trim();if(!t)return;let j=await fetch('/api/short/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,text:t})}).then(r=>r.json()).catch(()=>({comments:[]}));document.getElementById('cmt-list').innerHTML=(j.comments||[]).map(c=>`<div class="cmt-item">${esc(c.text)}</div>`).join('');document.getElementById('cmt-text').value='';let el=document.getElementById('cc-'+id);if(el)el.textContent=(j.comments||[]).length;}
|
| 168 |
-
|
| 169 |
-
// === Patch regular articles ===
|
| 170 |
function patchArticle(){let art=document.querySelector('#view-article .article-view');if(!art)return;art.querySelectorAll('button[onclick*="rewriteCurrentArticle"],[data-rewrite],.rewrite-injected').forEach(e=>e.remove());art.querySelectorAll('.article-ai-ask').forEach((e,i)=>{if(i>0)e.remove();});if(!art.querySelector('[data-rw-article]')){let a=art.querySelector('.article-actions');if(a){let b=document.createElement('button');b.className='primary';b.setAttribute('data-rw-article','1');b.textContent='🤖 Rewrite AI đăng tường';b.onclick=function(){doRewriteArticle(b)};a.insertBefore(b,a.firstChild);}}if(!art.querySelector('.article-ai-ask')){let box=document.createElement('div');box.className='article-ai-ask';box.innerHTML='<h3 style="font-size:14px;color:#5cb87a">🤖 Hỏi AI</h3><textarea id="article-ai-question" placeholder="Hỏi..."></textarea><button onclick="askArticleAI()">Hỏi</button><div id="article-ai-answer" class="article-ai-answer"></div>';art.appendChild(box);}}
|
| 171 |
function patchShortBtns(){document.querySelectorAll('.tiktok-slide').forEach(sl=>{if(sl.dataset.cmtDone)return;sl.dataset.cmtDone='1';let id=sl.dataset.id||'';if(!id)return;let r=sl.querySelector('.tiktok-right');if(!r||r.querySelector('[data-cmt]'))return;let b=document.createElement('button');b.className='tiktok-right-btn';b.setAttribute('data-cmt','1');b.innerHTML='<div class="icon">💬</div><div class="count">0</div>';b.onclick=function(e){e.stopPropagation();openShortComments(id);};r.appendChild(b);fetch('/api/short/comments?id='+encodeURIComponent(id)).then(r=>r.json()).then(j=>{b.querySelector('.count').textContent=(j.comments||[]).length}).catch(()=>{});});}
|
| 172 |
function patchOldSourceLinks(){document.querySelectorAll('.source-detail-item a[target="_blank"],.source-detail-item a[href]').forEach(a=>{if(a.dataset.p7)return;a.dataset.p7='1';let url=a.href||'';a.removeAttribute('target');a.removeAttribute('href');a.textContent='📖 Xem trên VNEWS';a.className='source-vnews-btn';a.style.cursor='pointer';a.onclick=function(e){e.preventDefault();e.stopPropagation();if(url&&typeof readArticle==='function')readArticle(url);}});}
|
| 173 |
-
|
| 174 |
let oldRA=window.readArticle;if(oldRA){window.readArticle=async function(){let ret=await oldRA.apply(this,arguments);setTimeout(patchArticle,500);return ret;}}
|
| 175 |
let _hl=false;function dH(){if(_hl)return;_hl=true;setTimeout(()=>{if(typeof ensureHotTopics==='function')ensureHotTopics();if(typeof ensureNewsShortsHome==='function')ensureNewsShortsHome();},4000);}
|
| 176 |
if(document.readyState==='complete')dH();else window.addEventListener('load',dH);
|
|
@@ -181,8 +706,8 @@ setInterval(()=>{patchArticle();patchShortBtns();patchOldSourceLinks();},1500);
|
|
| 181 |
|
| 182 |
@app.get('/')
|
| 183 |
async def _index():
|
| 184 |
-
html=
|
| 185 |
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
|
| 186 |
body+=getattr(f6,'FINAL6_INJECT','');body+=getattr(f6,'FINAL6_FAST_HOME_INJECT','');body+=getattr(f6,'FINAL6E_INJECT','')
|
| 187 |
body+=PATCH_INJECT
|
| 188 |
-
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|
|
|
|
| 1 |
+
"""Final patch v6: FIXED short video generation — Vietnamese fonts, robust text splitting, no silent errors."""
|
| 2 |
+
import re, threading, time, json, os, asyncio, hashlib, subprocess, requests, sys, logging
|
| 3 |
import ai_runtime_final6 as f6
|
| 4 |
from ai_runtime_final6 import app, rt, f5, HTMLResponse, JSONResponse, Request, Query
|
| 5 |
+
from fastapi.responses import FileResponse
|
| 6 |
import html as html_lib
|
| 7 |
from urllib.parse import urlparse
|
| 8 |
+
from datetime import datetime, timezone, timedelta
|
| 9 |
+
|
| 10 |
+
_log = logging.getLogger("patch_fast")
|
| 11 |
+
_log.setLevel(logging.INFO)
|
| 12 |
+
if not _log.handlers:
|
| 13 |
+
_log.addHandler(logging.StreamHandler(sys.stderr))
|
| 14 |
|
| 15 |
def clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or ""))).strip()
|
| 16 |
def _domain(u):
|
|
|
|
| 20 |
os.makedirs(DATA_DIR,exist_ok=True)
|
| 21 |
SHORT_COMMENTS_FILE=os.path.join(DATA_DIR,'short_comments.json')
|
| 22 |
TTL_24H=86400;HAS_PERSISTENT=os.path.isdir('/data')
|
| 23 |
+
SHORTS_DIR = os.path.join(DATA_DIR, 'ai_shorts')
|
| 24 |
+
os.makedirs(SHORTS_DIR, exist_ok=True)
|
| 25 |
def _lj(p,d):
|
| 26 |
try:
|
| 27 |
if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8'))
|
|
|
|
| 36 |
def _scrape(url,mc=8000):
|
| 37 |
try:d=f5.base.scrape_any_url(url);return(d.get('title',''),((d.get('summary','')+'\n'+d.get('text','')).strip())[:mc],d.get('image') or d.get('og_image') or '')
|
| 38 |
except:return('','','')
|
| 39 |
+
|
| 40 |
+
# ======================================================================
|
| 41 |
+
# VIETNAMESE FONT DETECTION — find ANY font that supports Vietnamese
|
| 42 |
+
# ======================================================================
|
| 43 |
+
_FONT_REGULAR = None
|
| 44 |
+
_FONT_BOLD = None
|
| 45 |
+
_FONT_REGULAR_PATH = None
|
| 46 |
+
_FONT_BOLD_PATH = None
|
| 47 |
+
|
| 48 |
+
def _find_vn_fonts():
|
| 49 |
+
"""Find fonts that support Vietnamese diacritics (ấ, ắ, ỗ, ệ, ỷ, etc.)."""
|
| 50 |
+
global _FONT_REGULAR, _FONT_BOLD, _FONT_REGULAR_PATH, _FONT_BOLD_PATH
|
| 51 |
+
if _FONT_REGULAR is not None:
|
| 52 |
+
return True
|
| 53 |
+
|
| 54 |
+
from PIL import ImageFont
|
| 55 |
+
|
| 56 |
+
# Priority 1: Noto Sans (full Vietnamese support)
|
| 57 |
+
candidates_regular = [
|
| 58 |
+
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
|
| 59 |
+
"/usr/share/fonts/truetype/noto/NotoSans-Variable.ttf",
|
| 60 |
+
"/usr/share/fonts/truetype/vn/VNFont.ttf",
|
| 61 |
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
| 62 |
+
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
| 63 |
+
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
|
| 64 |
+
]
|
| 65 |
+
candidates_bold = [
|
| 66 |
+
"/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf",
|
| 67 |
+
"/usr/share/fonts/truetype/noto/NotoSans-Variable.ttf", # Variable weight
|
| 68 |
+
"/usr/share/fonts/truetype/vn/VNFont-Bold.ttf",
|
| 69 |
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
| 70 |
+
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
|
| 71 |
+
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
|
| 72 |
+
]
|
| 73 |
+
|
| 74 |
+
# Search for any ttf files in the system
|
| 75 |
+
try:
|
| 76 |
+
result = subprocess.run(
|
| 77 |
+
['find', '/usr/share/fonts', '-name', '*.ttf', '-type', 'f'],
|
| 78 |
+
capture_output=True, text=True, timeout=10
|
| 79 |
+
)
|
| 80 |
+
all_ttf = result.stdout.strip().split('\n')
|
| 81 |
+
# Prefer Noto/DejaVu/Liberation
|
| 82 |
+
for key in ['noto', 'dejavu', 'liberation', 'freefont', 'vn']:
|
| 83 |
+
for path in all_ttf:
|
| 84 |
+
if key in path.lower():
|
| 85 |
+
if 'bold' in path.lower() and _FONT_BOLD_PATH is None:
|
| 86 |
+
_FONT_BOLD_PATH = path
|
| 87 |
+
elif 'regular' in path.lower() and _FONT_REGULAR_PATH is None:
|
| 88 |
+
_FONT_REGULAR_PATH = path
|
| 89 |
+
except:
|
| 90 |
+
pass
|
| 91 |
+
|
| 92 |
+
# Try candidate paths
|
| 93 |
+
for path in candidates_regular:
|
| 94 |
+
if os.path.exists(path):
|
| 95 |
+
try:
|
| 96 |
+
_FONT_REGULAR = ImageFont.truetype(path, 40)
|
| 97 |
+
_FONT_REGULAR_PATH = path
|
| 98 |
+
_log.info(f"Font regular: {path}")
|
| 99 |
+
break
|
| 100 |
+
except:
|
| 101 |
+
continue
|
| 102 |
+
|
| 103 |
+
for path in candidates_bold:
|
| 104 |
+
if os.path.exists(path):
|
| 105 |
+
try:
|
| 106 |
+
_FONT_BOLD = ImageFont.truetype(path, 52)
|
| 107 |
+
_FONT_BOLD_PATH = path
|
| 108 |
+
_log.info(f"Font bold: {path}")
|
| 109 |
+
break
|
| 110 |
+
except:
|
| 111 |
+
continue
|
| 112 |
+
|
| 113 |
+
# Fallback: use whatever we found
|
| 114 |
+
if _FONT_REGULAR is None:
|
| 115 |
+
_FONT_REGULAR = ImageFont.load_default()
|
| 116 |
+
_FONT_BOLD = _FONT_REGULAR
|
| 117 |
+
_log.warning("No TrueType font found! Using PIL default (may not show Vietnamese)")
|
| 118 |
+
if _FONT_BOLD is None:
|
| 119 |
+
_FONT_BOLD = _FONT_REGULAR
|
| 120 |
+
|
| 121 |
+
return True
|
| 122 |
+
|
| 123 |
+
# ======================================================================
|
| 124 |
+
# ROBUST TEXT SEGMENTATION — split ANY text format into segments
|
| 125 |
+
# ======================================================================
|
| 126 |
+
def _split_into_segments(text, max_segments=10, min_len=30):
|
| 127 |
+
"""Split text into segments for short video, handling any format."""
|
| 128 |
+
text = clean(text)
|
| 129 |
+
if not text:
|
| 130 |
+
return []
|
| 131 |
+
|
| 132 |
+
# Strategy 1: Try bullet points (•, -, * numbered)
|
| 133 |
+
lines = text.split('\n')
|
| 134 |
+
segmented = []
|
| 135 |
+
for line in lines:
|
| 136 |
+
line = clean(line)
|
| 137 |
+
if not line:
|
| 138 |
+
continue
|
| 139 |
+
# Remove leading bullets
|
| 140 |
+
line_clean = re.sub(r'^[•\-\*\d\.\)\s]+', '', line).strip()
|
| 141 |
+
if len(line_clean) > min_len:
|
| 142 |
+
segmented.append(line_clean)
|
| 143 |
+
elif len(line) > min_len:
|
| 144 |
+
segmented.append(line)
|
| 145 |
+
|
| 146 |
+
# Strategy 2: If no bullet points found, split by sentences
|
| 147 |
+
if len(segmented) < 2:
|
| 148 |
+
# Split by sentences (Vietnamese period, exclamation, question)
|
| 149 |
+
sents = re.split(r'(?<=[.!?])\s+(?=[A-Z0-9À-ỸĐ])', text)
|
| 150 |
+
for s in sents:
|
| 151 |
+
s = clean(s)
|
| 152 |
+
if len(s) > min_len:
|
| 153 |
+
segmented.append(s)
|
| 154 |
+
|
| 155 |
+
# Strategy 3: If still no good segments, split by character chunks
|
| 156 |
+
if not segmented:
|
| 157 |
+
words = text.split()
|
| 158 |
+
chunk = []
|
| 159 |
+
char_count = 0
|
| 160 |
+
for w in words:
|
| 161 |
+
chunk.append(w)
|
| 162 |
+
char_count += len(w) + 1
|
| 163 |
+
if char_count > 150:
|
| 164 |
+
segmented.append(' '.join(chunk))
|
| 165 |
+
chunk = []
|
| 166 |
+
char_count = 0
|
| 167 |
+
if chunk:
|
| 168 |
+
segmented.append(' '.join(chunk))
|
| 169 |
+
|
| 170 |
+
# Strategy 4: Last resort — take whole text
|
| 171 |
+
if not segmented:
|
| 172 |
+
segmented = [text[:300]]
|
| 173 |
+
|
| 174 |
+
return segmented[:max_segments]
|
| 175 |
+
|
| 176 |
+
# ======================================================================
|
| 177 |
+
# FRAME CREATION — with Vietnamese font support
|
| 178 |
+
# ======================================================================
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _make_frame(post, seg, idx, total, img_path, downloaded, frame_path):
|
| 182 |
+
"""Create a single frame with Vietnamese text support - matching original layout."""
|
| 183 |
+
from PIL import Image, ImageDraw
|
| 184 |
+
_find_vn_fonts()
|
| 185 |
+
|
| 186 |
+
W, H = 1080, 1920
|
| 187 |
+
hero_h = 760
|
| 188 |
+
bg = Image.new('RGB', (W, H), (12, 12, 12))
|
| 189 |
+
draw = ImageDraw.Draw(bg)
|
| 190 |
+
|
| 191 |
+
# Background image (hero)
|
| 192 |
+
if downloaded:
|
| 193 |
+
try:
|
| 194 |
+
im = Image.open(img_path).convert('RGB')
|
| 195 |
+
ratio = im.width / max(1, im.height)
|
| 196 |
+
tr = W / hero_h
|
| 197 |
+
if ratio > tr:
|
| 198 |
+
nh = hero_h; nw = int(nh * ratio)
|
| 199 |
+
else:
|
| 200 |
+
nw = W; nh = int(nw / ratio)
|
| 201 |
+
im = im.resize((nw, nh))
|
| 202 |
+
left = (nw - W) // 2
|
| 203 |
+
top = (nh - hero_h) // 2
|
| 204 |
+
bg.paste(im.crop((left, top, left + W, top + hero_h)), (0, 0))
|
| 205 |
+
except Exception as e:
|
| 206 |
+
_log.warning(f"paste img: {e}")
|
| 207 |
+
|
| 208 |
+
try:
|
| 209 |
+
fb = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 58)
|
| 210 |
+
except:
|
| 211 |
+
fb = None
|
| 212 |
+
|
| 213 |
+
# Source badge on top image corner
|
| 214 |
+
badge = 'Nguồn: ' + _source_badge_url_first(post)
|
| 215 |
+
try:
|
| 216 |
+
b = draw.textbbox((0, 0), badge, font=fb); bw = b[2] - b[0]; bh = b[3] - b[1]
|
| 217 |
+
except:
|
| 218 |
+
bw = len(badge) * 16; bh = 34
|
| 219 |
+
bx = W - bw - 42; by = 24
|
| 220 |
+
draw.rounded_rectangle((bx - 16, by - 8, W - 24, by + bh + 14), radius=18, fill=(0, 0, 0, 170))
|
| 221 |
+
draw.text((bx, by), badge, fill=(255, 255, 255), font=fb)
|
| 222 |
+
|
| 223 |
+
# Bottom text area
|
| 224 |
+
draw.rectangle((0, hero_h - 20, W, H), fill=(12, 12, 12))
|
| 225 |
+
|
| 226 |
+
# Progress bars centered
|
| 227 |
+
total = max(1, total)
|
| 228 |
+
total_w = total * 38 - 14
|
| 229 |
+
start = (W - total_w) // 2
|
| 230 |
+
for i in range(total):
|
| 231 |
+
fill = (92, 184, 122) if i == idx else (70, 70, 70)
|
| 232 |
+
draw.rounded_rectangle((start + i * 38, 820, start + i * 38 + 24, 832), radius=6, fill=fill)
|
| 233 |
+
|
| 234 |
+
brand = 'VNEWS AI SHORT'
|
| 235 |
+
try:
|
| 236 |
+
bb = draw.textbbox((0, 0), brand, font=fb); tx = (W - (bb[2] - bb[0])) // 2
|
| 237 |
+
except:
|
| 238 |
+
tx = 360
|
| 239 |
+
draw.text((tx, 870), brand, fill=(110, 231, 143), font=fb)
|
| 240 |
+
|
| 241 |
+
seg_text = _strip_bullet_prefix(seg)
|
| 242 |
+
lines = wrap_text_vn(draw, seg_text, fb, W - 120, 8) if fb else [seg_text[:80]]
|
| 243 |
+
block_h = len(lines) * 74
|
| 244 |
+
y = max(980, 1250 - block_h // 2)
|
| 245 |
+
_draw_center(draw, lines, fb, y, (255, 255, 255), W, 74)
|
| 246 |
+
|
| 247 |
+
title_lines = wrap_text_vn(draw, _strip_bullet_prefix(post.get('title', '')), fb, W - 120, 3) if fb else []
|
| 248 |
+
y2 = 1640
|
| 249 |
+
draw.line((80, y2 - 26, W - 80, y2 - 26), fill=(70, 70, 70), width=2)
|
| 250 |
+
_draw_center(draw, title_lines, fb, y2, (220, 220, 220), W, 42)
|
| 251 |
+
|
| 252 |
+
bg.save(frame_path, quality=92)
|
| 253 |
+
return True
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def _source_badge_url_first(post):
|
| 257 |
+
d = _domain(post.get('url', ''))
|
| 258 |
+
if d:
|
| 259 |
+
return d
|
| 260 |
+
for s in post.get('sources') or []:
|
| 261 |
+
d = _domain(s.get('url', ''))
|
| 262 |
+
if d:
|
| 263 |
+
return d
|
| 264 |
+
return 'VNEWS'
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def wrap_text_vn(draw, text, font, maxw, max_lines):
|
| 268 |
+
words = _clean(text).split()
|
| 269 |
+
lines = []
|
| 270 |
+
cur = ''
|
| 271 |
+
for w in words:
|
| 272 |
+
test = (cur + ' ' + w).strip()
|
| 273 |
+
try:
|
| 274 |
+
width = draw.textbbox((0, 0), test, font=font)[2]
|
| 275 |
+
except:
|
| 276 |
+
width = len(test) * 22
|
| 277 |
+
if width <= maxw:
|
| 278 |
+
cur = test
|
| 279 |
+
else:
|
| 280 |
+
if cur:
|
| 281 |
+
lines.append(cur)
|
| 282 |
+
cur = w
|
| 283 |
+
if len(lines) >= max_lines:
|
| 284 |
+
break
|
| 285 |
+
if cur and len(lines) < max_lines:
|
| 286 |
+
lines.append(cur)
|
| 287 |
+
return lines
|
| 288 |
+
|
| 289 |
+
# ======================================================================
|
| 290 |
+
# SHORT VIDEO GENERATOR — FULLY ROBUST
|
| 291 |
+
# ======================================================================
|
| 292 |
+
def _gen_short_sync(post) -> str:
|
| 293 |
+
"""Generate short video NOW. Returns video URL or empty string on failure."""
|
| 294 |
+
post_id = post.get('id', '')
|
| 295 |
+
if not post_id:
|
| 296 |
+
_log.error("_gen_short_sync: no post_id")
|
| 297 |
+
return ''
|
| 298 |
+
|
| 299 |
+
text = post.get('text', '') or post.get('title', '')
|
| 300 |
+
if len(text) < 100:
|
| 301 |
+
_log.warning(f"_gen_short_sync: text too short ({len(text)})")
|
| 302 |
+
return ''
|
| 303 |
+
|
| 304 |
+
segments = _split_into_segments(text, max_segments=10, min_len=30)
|
| 305 |
+
if not segments:
|
| 306 |
+
_log.error("_gen_short_sync: no segments after splitting")
|
| 307 |
+
return ''
|
| 308 |
+
|
| 309 |
+
_log.info(f"Segments: {len(segments)} (first: '{segments[0][:50]}...')")
|
| 310 |
+
|
| 311 |
+
seg_hash = hashlib.md5(('|'.join(segments) + 'nu' + 'neutral' + '1.0').encode('utf-8')).hexdigest()[:8]
|
| 312 |
+
suffix = f"_nu_neutral_1p0_{seg_hash}_scenes_nosub"
|
| 313 |
+
out_name = f5.base._safe_name(post_id + suffix) + '.mp4'
|
| 314 |
+
out_mp4 = os.path.join(SHORTS_DIR, out_name)
|
| 315 |
+
video_url = '/api/ai/short-file/' + post_id + suffix
|
| 316 |
+
|
| 317 |
+
if os.path.exists(out_mp4):
|
| 318 |
+
_log.info(f"_gen_short_sync: already exists {out_mp4}")
|
| 319 |
+
return video_url
|
| 320 |
+
|
| 321 |
+
work = os.path.join(SHORTS_DIR, f5.base._safe_name(post_id + suffix))
|
| 322 |
+
os.makedirs(work, exist_ok=True)
|
| 323 |
+
|
| 324 |
+
# Download image
|
| 325 |
+
img_path = os.path.join(work, 'image.jpg')
|
| 326 |
+
downloaded = False
|
| 327 |
+
try:
|
| 328 |
+
img_url = post.get('img', '')
|
| 329 |
+
if img_url and img_url.startswith('http'):
|
| 330 |
+
r = requests.get(img_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=15)
|
| 331 |
+
if r.status_code == 200:
|
| 332 |
+
with open(img_path, 'wb') as f:
|
| 333 |
+
f.write(r.content)
|
| 334 |
+
downloaded = True
|
| 335 |
+
except Exception as e:
|
| 336 |
+
_log.warning(f"download image: {e}")
|
| 337 |
+
|
| 338 |
+
# Check PIL/ffmpeg
|
| 339 |
+
try:
|
| 340 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 341 |
+
has_pil = True
|
| 342 |
+
except Exception as e:
|
| 343 |
+
_log.warning(f"PIL import: {e}")
|
| 344 |
+
has_pil = False
|
| 345 |
+
|
| 346 |
+
try:
|
| 347 |
+
subprocess.run(['ffmpeg', '-version'], capture_output=True, timeout=5)
|
| 348 |
+
except:
|
| 349 |
+
_log.error("ffmpeg not found!")
|
| 350 |
+
return ''
|
| 351 |
+
|
| 352 |
+
try:
|
| 353 |
+
from gtts import gTTS
|
| 354 |
+
has_tts = True
|
| 355 |
+
except Exception as e:
|
| 356 |
+
_log.warning(f"gTTS: {e}, using silent video")
|
| 357 |
+
has_tts = False
|
| 358 |
+
|
| 359 |
+
part_files = []
|
| 360 |
+
errors = []
|
| 361 |
+
|
| 362 |
+
for idx, seg in enumerate(segments[:10]):
|
| 363 |
+
frame_path = os.path.join(work, f'frame_{idx:02d}.jpg')
|
| 364 |
+
audio_path = os.path.join(work, f'voice_{idx:02d}.mp3')
|
| 365 |
+
audio_fast = os.path.join(work, f'voice_{idx:02d}_fast.mp3')
|
| 366 |
+
part_path = os.path.join(work, f'part_{idx:02d}.mp4')
|
| 367 |
+
|
| 368 |
+
# ----- Frame -----
|
| 369 |
+
frame_ok = False
|
| 370 |
+
if has_pil:
|
| 371 |
+
try:
|
| 372 |
+
_make_frame(post, seg, idx, len(segments), img_path, downloaded, frame_path)
|
| 373 |
+
if os.path.exists(frame_path) and os.path.getsize(frame_path) > 1000:
|
| 374 |
+
frame_ok = True
|
| 375 |
+
else:
|
| 376 |
+
_log.warning(f"frame {idx}: file too small or missing")
|
| 377 |
+
except Exception as e:
|
| 378 |
+
_log.error(f"frame {idx} creation: {e}")
|
| 379 |
+
errors.append(f"frame{idx}: {e}")
|
| 380 |
+
|
| 381 |
+
if not frame_ok:
|
| 382 |
+
try:
|
| 383 |
+
# Fallback: ffmpeg solid color frame
|
| 384 |
+
subprocess.run(['ffmpeg', '-y', '-f', 'lavfi', '-i', 'color=c=0x0f1726:s=1080x1920:d=1',
|
| 385 |
+
'-frames:v', '1', frame_path], capture_output=True, timeout=30)
|
| 386 |
+
frame_ok = os.path.exists(frame_path)
|
| 387 |
+
except:
|
| 388 |
+
continue
|
| 389 |
+
|
| 390 |
+
# ----- Audio -----
|
| 391 |
+
dur = 10.0
|
| 392 |
+
if has_tts:
|
| 393 |
+
try:
|
| 394 |
+
tts_text = re.sub(r'^[•\-\*\d\.\)\s]+', '', seg).strip()[:300]
|
| 395 |
+
if tts_text:
|
| 396 |
+
gTTS(tts_text, lang='vi', slow=False).save(audio_path)
|
| 397 |
+
# Copy audio directly (no atempo filter — it's a no-op at 1.0 and can fail)
|
| 398 |
+
subprocess.run(['ffmpeg', '-y', '-i', audio_path, '-vn', '-c:a', 'libmp3lame', audio_fast],
|
| 399 |
+
check=True, capture_output=True, timeout=90)
|
| 400 |
+
pr = subprocess.run(['ffprobe', '-v', 'error', '-show_entries', 'format=duration',
|
| 401 |
+
'-of', 'default=noprint_wrappers=1:no_key=1', audio_fast],
|
| 402 |
+
capture_output=True, timeout=20)
|
| 403 |
+
dur = max(6.0, float((pr.stdout or b'').decode().strip() or 10.0)) + 1.0
|
| 404 |
+
else:
|
| 405 |
+
dur = 8.0
|
| 406 |
+
except Exception as e:
|
| 407 |
+
_log.warning(f"TTS part {idx}: {e}")
|
| 408 |
+
errors.append(f"tts{idx}: {e}")
|
| 409 |
+
dur = 8.0
|
| 410 |
+
|
| 411 |
+
# ----- Combine frame + audio -----
|
| 412 |
+
try:
|
| 413 |
+
cmd = ['ffmpeg', '-y', '-loop', '1', '-t', str(dur), '-i', frame_path]
|
| 414 |
+
if has_tts and os.path.exists(audio_fast):
|
| 415 |
+
cmd += ['-i', audio_fast, '-shortest']
|
| 416 |
+
else:
|
| 417 |
+
cmd += ['-f', 'lavfi', '-i', 'anullsrc=r=44100:cl=mono', '-shortest']
|
| 418 |
+
cmd += ['-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p',
|
| 419 |
+
'-c:a', 'aac', '-b:a', '128k',
|
| 420 |
+
'-preset', 'fast', '-crf', '23',
|
| 421 |
+
part_path]
|
| 422 |
+
subprocess.run(cmd, check=True, capture_output=True, timeout=150)
|
| 423 |
+
if os.path.exists(part_path) and os.path.getsize(part_path) > 5000:
|
| 424 |
+
part_files.append(part_path)
|
| 425 |
+
_log.info(f"Part {idx}: {os.path.getsize(part_path)} bytes")
|
| 426 |
+
else:
|
| 427 |
+
_log.warning(f"Part {idx}: file too small or missing")
|
| 428 |
+
except Exception as e:
|
| 429 |
+
_log.warning(f"ffmpeg part {idx}: {e}")
|
| 430 |
+
errors.append(f"part{idx}: {e}")
|
| 431 |
+
|
| 432 |
+
if part_files:
|
| 433 |
+
try:
|
| 434 |
+
concat_file = os.path.join(work, 'concat.txt')
|
| 435 |
+
with open(concat_file, 'w', encoding='utf-8') as f:
|
| 436 |
+
for p in part_files:
|
| 437 |
+
f.write("file '" + p.replace("'", "'\\''") + "'\n")
|
| 438 |
+
subprocess.run(['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', concat_file, '-c', 'copy', out_mp4],
|
| 439 |
+
check=True, capture_output=True, timeout=180)
|
| 440 |
+
_log.info(f"Short video generated: {out_mp4} ({len(part_files)} parts, {os.path.getsize(out_mp4)} bytes)")
|
| 441 |
+
|
| 442 |
+
post['video'] = video_url
|
| 443 |
+
post['short_voice'] = 'nu'
|
| 444 |
+
post['short_emotion'] = 'neutral'
|
| 445 |
+
post['short_speed'] = 1.0
|
| 446 |
+
post['short_segments'] = segments
|
| 447 |
+
post['short_subtitles'] = False
|
| 448 |
+
|
| 449 |
+
wall = f5.base._load_ai_wall()
|
| 450 |
+
for i, p in enumerate(wall):
|
| 451 |
+
if p.get('id') == post_id:
|
| 452 |
+
wall[i] = post
|
| 453 |
+
break
|
| 454 |
+
f5.base._save_ai_wall(wall)
|
| 455 |
+
return video_url
|
| 456 |
+
except Exception as e:
|
| 457 |
+
_log.error(f"concat: {e}")
|
| 458 |
+
errors.append(f"concat: {e}")
|
| 459 |
+
else:
|
| 460 |
+
_log.error(f"No part files generated! Errors: {'; '.join(errors)}")
|
| 461 |
+
return ''
|
| 462 |
+
|
| 463 |
+
# ===== Auto Scheduler Trigger =====
|
| 464 |
+
_AUTO_SCHEDULER_RUNNING = False
|
| 465 |
+
_AUTO_SCHEDULER_LAST = 0
|
| 466 |
+
_AUTO_SCHEDULER_RESULTS = []
|
| 467 |
+
|
| 468 |
+
def _run_scheduler_now():
|
| 469 |
+
"""Trigger auto_scheduler._run_scheduled_posting() safely."""
|
| 470 |
+
global _AUTO_SCHEDULER_RUNNING, _AUTO_SCHEDULER_LAST, _AUTO_SCHEDULER_RESULTS
|
| 471 |
+
if _AUTO_SCHEDULER_RUNNING:
|
| 472 |
+
_log.warning("Scheduler already running!")
|
| 473 |
+
return {"status": "running", "message": "Scheduler đang chạy, vui lòng đợi."}
|
| 474 |
+
_AUTO_SCHEDULER_RUNNING = True
|
| 475 |
+
try:
|
| 476 |
+
import auto_scheduler as _as
|
| 477 |
+
_as.LOG.info("Triggered manually via API")
|
| 478 |
+
_as._run_scheduled_posting()
|
| 479 |
+
_AUTO_SCHEDULER_LAST = time.time()
|
| 480 |
+
_AUTO_SCHEDULER_RESULTS = [{'time': datetime.now().strftime('%H:%M %d/%m/%Y'), 'status': 'done'}]
|
| 481 |
+
_log.info("Auto scheduler completed successfully")
|
| 482 |
+
return {"status": "done", "message": "Đã đăng 3 bài tự động thành công!"}
|
| 483 |
+
except Exception as e:
|
| 484 |
+
_log.error(f"Auto scheduler failed: {e}", exc_info=True)
|
| 485 |
+
return {"status": "error", "message": f"Lỗi: {e}"}
|
| 486 |
+
finally:
|
| 487 |
+
_AUTO_SCHEDULER_RUNNING = False
|
| 488 |
+
|
| 489 |
+
@app.post('/api/auto/schedule')
|
| 490 |
+
async def _api_auto_schedule():
|
| 491 |
+
"""Trigger auto-scheduler to post 3 articles NOW."""
|
| 492 |
+
_log.info("POST /api/auto/schedule triggered")
|
| 493 |
+
result = await asyncio.get_event_loop().run_in_executor(None, _run_scheduler_now)
|
| 494 |
+
return JSONResponse(result)
|
| 495 |
+
|
| 496 |
+
@app.get('/api/auto/status')
|
| 497 |
+
async def _api_auto_status():
|
| 498 |
+
"""Check auto-scheduler status."""
|
| 499 |
+
return JSONResponse({
|
| 500 |
+
'running': _AUTO_SCHEDULER_RUNNING,
|
| 501 |
+
'last_run': _AUTO_SCHEDULER_LAST,
|
| 502 |
+
'last_run_str': datetime.fromtimestamp(_AUTO_SCHEDULER_LAST).strftime('%H:%M %d/%m/%Y') if _AUTO_SCHEDULER_LAST else 'never',
|
| 503 |
+
'results': _AUTO_SCHEDULER_RESULTS
|
| 504 |
+
})
|
| 505 |
+
|
| 506 |
_bg_home={"t":0,"d":[]};_bg_shorts={"t":0,"d":[]};_bg_lock=False
|
| 507 |
def _bg():
|
| 508 |
global _bg_lock
|
|
|
|
| 519 |
@app.on_event("startup")
|
| 520 |
async def _s():threading.Thread(target=_bg,daemon=True).start()
|
| 521 |
threading.Thread(target=lambda:[time.sleep(600) or _bg() for _ in iter(int,1)],daemon=True).start()
|
| 522 |
+
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None) in ('/api/homepage','/api/shorts','/api/ai_wall','/api/topic_post','/api/article/ask','/api/topic/rewrite','/api/rewrite_share','/api/url_wall','/api/short/comments','/api/short/comment','/api/storage_status','/api/ai/short/{post_id}','/api/ai/short-file/{file_id}','/api/auto/schedule','/api/auto/status','/') and any(m in getattr(r,'methods',set()) for m in ('GET','POST')))]
|
| 523 |
@app.get('/api/homepage')
|
| 524 |
def _h():
|
| 525 |
n=time.time()
|
|
|
|
| 551 |
if not raw:raw=ctx[:12000]
|
| 552 |
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)
|
| 553 |
return JSONResponse({'answer':ans or 'Chưa trả lời được.','title':title})
|
| 554 |
+
|
| 555 |
+
# ===== SHORT VIDEO ENDPOINT (SYNCHRONOUS) =====
|
| 556 |
+
@app.post('/api/ai/short/{post_id}')
|
| 557 |
+
async def api_ai_short_generate(post_id: str, request: Request):
|
| 558 |
+
wall = f5.base._load_ai_wall()
|
| 559 |
+
post = None
|
| 560 |
+
for p in wall:
|
| 561 |
+
if p.get('id') == post_id:
|
| 562 |
+
post = p
|
| 563 |
+
break
|
| 564 |
+
if not post:
|
| 565 |
+
return JSONResponse({'error': 'Post not found'}, status_code=404)
|
| 566 |
+
if post.get('video'):
|
| 567 |
+
return JSONResponse({'post': post, 'video': post['video'], 'status': 'done'})
|
| 568 |
+
_log.info(f"Generating short for post {post_id}...")
|
| 569 |
+
loop = asyncio.get_event_loop()
|
| 570 |
+
video_url = await loop.run_in_executor(None, _gen_short_sync, post)
|
| 571 |
+
if video_url:
|
| 572 |
+
_log.info(f"Short generated: {video_url}")
|
| 573 |
+
return JSONResponse({'post': post, 'video': video_url, 'status': 'done'})
|
| 574 |
+
else:
|
| 575 |
+
_log.error(f"Short generation FAILED for {post_id}")
|
| 576 |
+
return JSONResponse({'error': 'Không thể tạo video. Kiểm tra logs Space để biết chi tiết.'}, status_code=500)
|
| 577 |
+
|
| 578 |
+
@app.get('/api/ai/short-file/{file_id:path}')
|
| 579 |
+
async def api_ai_short_file(file_id: str):
|
| 580 |
+
safe_name = f5.base._safe_name(file_id)
|
| 581 |
+
fpath = os.path.join(SHORTS_DIR, safe_name + '.mp4')
|
| 582 |
+
if os.path.exists(fpath):
|
| 583 |
+
return FileResponse(fpath, media_type='video/mp4')
|
| 584 |
+
for fname in os.listdir(SHORTS_DIR):
|
| 585 |
+
if fname.endswith('.mp4') and (safe_name in fname or file_id in fname):
|
| 586 |
+
fpath = os.path.join(SHORTS_DIR, fname)
|
| 587 |
+
if os.path.exists(fpath):
|
| 588 |
+
return FileResponse(fpath, media_type='video/mp4')
|
| 589 |
+
base_id = f5.base._safe_name((file_id.split('_')[0] if '_' in file_id else file_id))
|
| 590 |
+
for fname in os.listdir(SHORTS_DIR):
|
| 591 |
+
if fname.endswith('.mp4') and fname.startswith(base_id):
|
| 592 |
+
return FileResponse(os.path.join(SHORTS_DIR, fname), media_type='video/mp4')
|
| 593 |
+
return JSONResponse({'error': 'File not found'}, status_code=404)
|
| 594 |
+
|
| 595 |
+
# ===== REWRITE =====
|
| 596 |
@app.post('/api/rewrite_share')
|
| 597 |
@app.post('/api/url_wall')
|
| 598 |
async def _rw(request:Request):
|
|
|
|
| 601 |
title,raw,img=_scrape(url,14000)
|
| 602 |
if len(raw)<50:raw=ctx[:14000]
|
| 603 |
if len(raw)<50:return JSONResponse({'error':'Không đọc được bài'},status_code=422)
|
| 604 |
+
og_img = img if img and img.startswith('http') else ''
|
| 605 |
+
if not og_img:
|
| 606 |
+
try:
|
| 607 |
+
d = f5.base.scrape_any_url(url)
|
| 608 |
+
if d: og_img = d.get('og_image', '') or d.get('image', '') or ''
|
| 609 |
+
except: pass
|
| 610 |
+
if not og_img:
|
| 611 |
+
og_img = 'https://image.pollinations.ai/prompt/Vietnamese%20news%20update'
|
| 612 |
text=None
|
| 613 |
+
try:text=await asyncio.wait_for(f5.base.qwen_generate(f'Tóm tắt đăng Tường AI:\nTiêu đề: {title}\n{raw[:14000]}\n\n4-6 ý chính. Cuối ghi nguồn.',image_url=og_img or None,max_tokens=1000),timeout=30)
|
| 614 |
except:pass
|
| 615 |
if not text or len(text)<80:text=f"Tóm tắt: {title}\n\n{raw[:1200]}\n\nNguồn: {_domain(url)}"
|
| 616 |
+
paragraphs = [p.strip() for p in raw.split('\n') if len(p.strip()) > 60]
|
| 617 |
+
if not paragraphs:
|
| 618 |
+
paragraphs = [p.strip() for p in raw.split('\n') if len(p.strip()) > 20]
|
| 619 |
+
slides = []
|
| 620 |
+
for i, pt in enumerate(paragraphs[:8]):
|
| 621 |
+
slides.append({'text': pt, 'image': og_img, 'index': i + 1})
|
| 622 |
+
post=f5.base.make_post(title or 'Bài viết',text,og_img,url,'rewrite',sources=[{'title':title,'url':url,'via':_domain(url)}])
|
| 623 |
+
post['images'] = [og_img] if og_img else []
|
| 624 |
+
post['slides'] = slides
|
| 625 |
+
ps=f5.base._load_ai_wall();ps.insert(0,post);f5.base._save_ai_wall(ps)
|
| 626 |
+
if len(text) > 100:
|
| 627 |
+
t = threading.Thread(target=_gen_short_sync, args=(post,), daemon=True)
|
| 628 |
+
t.start()
|
| 629 |
+
return JSONResponse({'post':post})
|
| 630 |
+
|
| 631 |
@app.post('/api/topic/rewrite')
|
| 632 |
async def _tr(request:Request):
|
| 633 |
b=await request.json();pid=str(b.get('post_id','')).strip()
|
|
|
|
| 667 |
.article-ai-ask{margin-top:12px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.article-ai-ask textarea{width:100%;min-height:60px;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px}.article-ai-ask button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin-top:6px}.article-ai-answer{white-space:pre-wrap;color:#ccc;font-size:13px;line-height:1.55;margin-top:8px}
|
| 668 |
.storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 4px}
|
| 669 |
button[onclick*="rewriteCurrentArticle"]{display:none!important}
|
|
|
|
| 670 |
#ai-short-home,.ai-short-home,.ai-short-card-final{display:none!important}
|
| 671 |
.source-detail-box a[target="_blank"]{display:none!important}
|
| 672 |
</style>
|
|
|
|
| 675 |
(function(){
|
| 676 |
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}
|
| 677 |
fetch('/api/storage_status').then(r=>r.json()).then(j=>{if(!j.persistent){let h=document.getElementById('view-home');if(h){let w=document.createElement('div');w.className='storage-warn';w.innerHTML='⚠️ <b>Persistent Storage chưa bật.</b> Bật: Space Settings → Persistent Storage → Small.';h.prepend(w);}}});
|
|
|
|
|
|
|
| 678 |
async function renderShortAISlide(){let home=document.getElementById('view-home');if(!home)return;document.getElementById('short-ai-final-slide')?.remove();let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];let vids=wall.filter(p=>p.video);if(!vids.length)return;let wrap=document.createElement('div');wrap.id='short-ai-final-slide';wrap.className='slider-wrap';wrap.innerHTML='<div class="slider-header"><span class="slider-label">🎬 Short AI</span></div><div class="slider-track">'+vids.slice(0,30).map((p,i)=>`<div class="slider-item shorts-item" onclick="openAIShortFeed(${i})"><div class="slider-thumb shorts-thumb"><video src="${p.video}" muted preload="metadata" style="width:100%;height:100%;object-fit:cover"></video><div class="card-play">▶</div></div><div class="slider-title">${esc(p.title)}</div></div>`).join('')+'</div>';let comp=home.querySelector('.ai-compose');if(comp&&comp.nextSibling)comp.parentNode.insertBefore(wrap,comp.nextSibling);else home.prepend(wrap);}
|
| 679 |
+
setTimeout(renderShortAISlide,2500);setInterval(renderShortAISlide,15000);
|
|
|
|
|
|
|
| 680 |
function renderSourceDetails(post,container){let det=post.source_details||[];if(!det.length)return;container.querySelectorAll('.source-detail-box').forEach(e=>e.remove());let box=document.createElement('div');box.className='source-detail-box';box.innerHTML='<h3 style="font-size:14px;color:#5cb87a;margin-bottom:8px">📚 Bài nguồn</h3>'+det.map((s,i)=>`<div class="source-detail-item" data-url="${esc(s.url||'')}"><div class="source-detail-title">${i+1}. ${esc(s.title)}</div><div class="source-detail-content">${esc((s.content||'').slice(0,300))}</div><span class="source-vnews-btn">📖 Xem trên VNEWS</span></div>`).join('');container.appendChild(box);box.querySelectorAll('.source-detail-item').forEach(el=>{el.onclick=function(){let u=el.dataset.url;if(u&&typeof readArticle==='function')readArticle(u);}});det.forEach((s,i)=>{if(!s.url)return;fetch('/api/article?url='+encodeURIComponent(s.url)).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){let items=box.querySelectorAll('.source-detail-item');if(items[i]){let img=document.createElement('img');img.src=d.og_image||d.img;img.loading='lazy';img.onerror=function(){this.style.display='none'};items[i].prepend(img);}}}).catch(()=>{});});}
|
|
|
|
|
|
|
| 681 |
async function readAIWallPost(i){let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];let p=wall[i];if(!p)return;showView('view-article');let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${esc(p.title)}</h1>${p.img?`<img class="article-img" src="${p.img}">`:''}`;h+=`<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>`;h+=`<div class="article-actions"><button class="primary" onclick="doRewriteTopic(this,'${esc(p.id)}')">🤖 Rewrite AI đăng tường</button>${p.video?`<button onclick="openAIShortFeed(${i})">🎬 Xem Short</button>`:''}<button onclick="doShare('${esc(p.title)}','${location.origin}','${esc(p.img||'')}')">📤</button></div>`;h+=`<div class="article-ai-ask"><h3 style="font-size:14px;color:#5cb87a">🤖 Hỏi AI</h3><textarea id="article-ai-q" placeholder="Hỏi về nội dung..."></textarea><button onclick="askAIWall(${i})">Hỏi</button><div id="article-ai-ans" class="article-ai-answer"></div></div></div>`;document.getElementById('view-article').innerHTML=h;let art=document.querySelector('.article-view');if(art)renderSourceDetails(p,art);window.scrollTo(0,0);}
|
| 682 |
window.readAIWallPost=readAIWallPost;window.aiReadWallPatched=window.aiReadWall=window.readWallPost=function(i){readAIWallPost(i)};
|
|
|
|
|
|
|
| 683 |
window.openAIShortFeed=async function(startIdx){let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];let vids=wall.filter(p=>p.video);if(!vids.length)return alert('Chưa có Short AI');let ordered=startIdx>0?vids.slice(startIdx).concat(vids.slice(0,startIdx)):vids;showView('view-tiktok');let h='<button class="back-btn" onclick="switchCat(\'home\')">← Short AI</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">';ordered.forEach((p,i)=>{h+=`<div class="tiktok-slide" data-id="${p.id}"><video src="${p.video}" playsinline loop></video><div class="tiktok-bottom"><span class="badge badge-ai">AI Short</span><p class="tiktok-title">${esc(p.title)}</p></div><div class="tiktok-right"><button class="tiktok-right-btn" onclick="event.stopPropagation()"><div class="icon">👁</div><div class="count">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();likeShort('${p.id}',this)"><div class="icon">❤️</div><div class="count">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();openShortComments('${p.id}')"><div class="icon">💬</div><div class="count" id="cc-${p.id}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();shareShort('${esc(p.title)}')"><div class="icon">📤</div><div class="count">Share</div></button></div><span class="tiktok-counter">${i+1}/${ordered.length}</span></div>`});h+='</div></div>';document.getElementById('view-tiktok').innerHTML=h;initShortFeed();ordered.forEach(p=>{fetch('/api/short/comments?id='+encodeURIComponent(p.id)).then(r=>r.json()).then(j=>{let el=document.getElementById('cc-'+p.id);if(el)el.textContent=(j.comments||[]).length}).catch(()=>{});});}
|
| 684 |
window.likeShort=function(id,btn){let c=btn.querySelector('.count');c.textContent=parseInt(c.textContent||0)+1;}
|
| 685 |
window.shareShort=function(title){if(navigator.share)navigator.share({title,url:location.href}).catch(()=>{});else{navigator.clipboard.writeText(location.href);alert('Đã sao chép link!');}}
|
| 686 |
function initShortFeed(){let feed=document.getElementById('tiktok-feed');if(!feed)return;let slides=feed.querySelectorAll('.tiktok-slide');let cur=-1;function act(i){if(i===cur)return;slides.forEach((sl,idx)=>{let v=sl.querySelector('video');let fr=sl.querySelector('iframe');if(idx===i){if(v)v.play().catch(()=>{});if(fr&&!fr.src&&fr.dataset.ytSrc)fr.src=fr.dataset.ytSrc;}else{if(v)v.pause();if(fr&&fr.src)fr.src='';}});cur=i}let t;feed.addEventListener('scroll',()=>{clearTimeout(t);t=setTimeout(()=>{let rect=feed.getBoundingClientRect(),ctr=rect.top+rect.height/2,b=-1,d=1e9;slides.forEach((sl,i)=>{let dd=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(dd<d){d=dd;b=i}});if(b>=0)act(b)},130)});setTimeout(()=>act(0),300);slides.forEach(sl=>{let v=sl.querySelector('video');if(v)v.addEventListener('click',e=>{e.preventDefault();v.paused?v.play().catch(()=>{}):v.pause()})});}
|
|
|
|
|
|
|
| 687 |
window.doRewriteTopic=async function(btn,pid){btn.disabled=true;btn.textContent='Đang rewrite...';try{let r=await fetch('/api/topic/rewrite',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({post_id:pid})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');alert('Rewrite thành công!');showRewriteResult(j.post);}catch(e){alert(e.message)}finally{btn.disabled=false;btn.textContent='🤖 Rewrite AI đăng tường';}};
|
| 688 |
window.doRewriteArticle=async function(btn){let url=(window._currentArticle&&window._currentArticle.url)||'';if(!url){let a=document.querySelector('#view-article a[href*="://"]');if(a)url=a.href;}if(!url){let text=document.querySelector('.article-view')?.innerText?.slice(0,14000)||'';if(text.length<100){alert('Không tìm được nội dung để rewrite');return;}btn.disabled=true;btn.textContent='Đang rewrite...';try{let r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:'https://vnews.local/inline',context:text})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error);alert('Rewrite thành công!');showRewriteResult(j.post);}catch(e){alert(e.message)}finally{btn.disabled=false;btn.textContent='🤖 Rewrite AI đăng tường';}return;}btn.disabled=true;btn.textContent='Đang rewrite...';try{let ctx=document.querySelector('.article-view')?.innerText?.slice(0,14000)||'';let r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,context:ctx})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');alert('Rewrite thành công!');showRewriteResult(j.post);}catch(e){alert(e.message)}finally{btn.disabled=false;btn.textContent='🤖 Rewrite AI đăng tường';}};
|
| 689 |
function showRewriteResult(post){if(!post)return;showView('view-article');document.getElementById('view-article').innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">Rewrite</span><h1 class="article-title">${esc(post.title)}</h1>${post.img?`<img class="article-img" src="${post.img}">`:''}` +`<p class="article-p" style="white-space:pre-wrap">${esc(post.text)}</p><div class="article-actions"><button class="primary" onclick="makeShortFromPost('${esc(post.id)}',this)">🎬 Tạo Short AI</button><button onclick="doShare('${esc(post.title)}','${location.origin}','${esc(post.img||'')}')">📤</button></div></div>`;window.scrollTo(0,0);}
|
| 690 |
+
window.makeShortFromPost=async function(pid,btn){if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo video...';}try{let r=await fetch('/api/ai/short/'+pid,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:'nu',emotion:'neutral',speed:1.2})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');if(j.video){alert('✅ Đã tạo Short AI thành công!');renderShortAISlide();}else{alert(j.message||'Đã tạo!');renderShortAISlide();}}catch(e){alert('Lỗi: '+e.message)}finally{if(btn){btn.disabled=false;btn.textContent='🎬 Tạo Short AI';}}};
|
| 691 |
window.rewriteCurrentArticle=function(){let btn=document.querySelector('[data-rw-article]');if(btn)doRewriteArticle(btn);};
|
| 692 |
window.askAIWall=async function(i){let q=document.getElementById('article-ai-q')?.value.trim();if(!q)return alert('Nhập câu hỏi');document.getElementById('article-ai-ans').textContent='Đang hỏi...';let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];let p=wall[i]||{};let ctx=(p.text||'');for(let s of (p.source_details||[]))ctx+='\n'+(s.content||'');try{let r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({question:q,context:ctx.slice(0,12000)})});let j=await r.json();document.getElementById('article-ai-ans').textContent=j.answer||'Không trả lời được';}catch(e){document.getElementById('article-ai-ans').textContent='Lỗi: '+e.message}};
|
| 693 |
window.askArticleAI=async function(){let q=document.getElementById('article-ai-question')?.value.trim();if(!q)return alert('Nhập câu hỏi');let a=document.getElementById('article-ai-answer');a.textContent='Đang hỏi...';let url=(window._currentArticle&&window._currentArticle.url)||'';let ctx=document.querySelector('.article-view')?.innerText?.slice(0,12000)||'';try{let r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,question:q,context:ctx})});let j=await r.json();a.textContent=j.answer||'Không trả lời được';}catch(e){a.textContent='Lỗi: '+e.message}};
|
| 694 |
window.openShortComments=async function(id){let panel=document.getElementById('short-cmt-panel');let j=await fetch('/api/short/comments?id='+encodeURIComponent(id)).then(r=>r.json()).catch(()=>({comments:[]}));panel.innerHTML=`<h3 style="color:#5cb87a">💬 Bình luận</h3><div id="cmt-list">${(j.comments||[]).map(c=>`<div class="cmt-item">${esc(c.text)}</div>`).join('')||'<div class="cmt-item" style="color:#777">Chưa có</div>'}</div><textarea id="cmt-text" placeholder="Bình luận..."></textarea><button onclick="submitShortCmt('${esc(id)}')">Gửi</button><button onclick="document.getElementById('short-cmt-panel').classList.remove('active')">Đóng</button>`;panel.classList.add('active');}
|
| 695 |
window.submitShortCmt=async function(id){let t=document.getElementById('cmt-text')?.value.trim();if(!t)return;let j=await fetch('/api/short/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,text:t})}).then(r=>r.json()).catch(()=>({comments:[]}));document.getElementById('cmt-list').innerHTML=(j.comments||[]).map(c=>`<div class="cmt-item">${esc(c.text)}</div>`).join('');document.getElementById('cmt-text').value='';let el=document.getElementById('cc-'+id);if(el)el.textContent=(j.comments||[]).length;}
|
|
|
|
|
|
|
| 696 |
function patchArticle(){let art=document.querySelector('#view-article .article-view');if(!art)return;art.querySelectorAll('button[onclick*="rewriteCurrentArticle"],[data-rewrite],.rewrite-injected').forEach(e=>e.remove());art.querySelectorAll('.article-ai-ask').forEach((e,i)=>{if(i>0)e.remove();});if(!art.querySelector('[data-rw-article]')){let a=art.querySelector('.article-actions');if(a){let b=document.createElement('button');b.className='primary';b.setAttribute('data-rw-article','1');b.textContent='🤖 Rewrite AI đăng tường';b.onclick=function(){doRewriteArticle(b)};a.insertBefore(b,a.firstChild);}}if(!art.querySelector('.article-ai-ask')){let box=document.createElement('div');box.className='article-ai-ask';box.innerHTML='<h3 style="font-size:14px;color:#5cb87a">🤖 Hỏi AI</h3><textarea id="article-ai-question" placeholder="Hỏi..."></textarea><button onclick="askArticleAI()">Hỏi</button><div id="article-ai-answer" class="article-ai-answer"></div>';art.appendChild(box);}}
|
| 697 |
function patchShortBtns(){document.querySelectorAll('.tiktok-slide').forEach(sl=>{if(sl.dataset.cmtDone)return;sl.dataset.cmtDone='1';let id=sl.dataset.id||'';if(!id)return;let r=sl.querySelector('.tiktok-right');if(!r||r.querySelector('[data-cmt]'))return;let b=document.createElement('button');b.className='tiktok-right-btn';b.setAttribute('data-cmt','1');b.innerHTML='<div class="icon">💬</div><div class="count">0</div>';b.onclick=function(e){e.stopPropagation();openShortComments(id);};r.appendChild(b);fetch('/api/short/comments?id='+encodeURIComponent(id)).then(r=>r.json()).then(j=>{b.querySelector('.count').textContent=(j.comments||[]).length}).catch(()=>{});});}
|
| 698 |
function patchOldSourceLinks(){document.querySelectorAll('.source-detail-item a[target="_blank"],.source-detail-item a[href]').forEach(a=>{if(a.dataset.p7)return;a.dataset.p7='1';let url=a.href||'';a.removeAttribute('target');a.removeAttribute('href');a.textContent='📖 Xem trên VNEWS';a.className='source-vnews-btn';a.style.cursor='pointer';a.onclick=function(e){e.preventDefault();e.stopPropagation();if(url&&typeof readArticle==='function')readArticle(url);}});}
|
|
|
|
| 699 |
let oldRA=window.readArticle;if(oldRA){window.readArticle=async function(){let ret=await oldRA.apply(this,arguments);setTimeout(patchArticle,500);return ret;}}
|
| 700 |
let _hl=false;function dH(){if(_hl)return;_hl=true;setTimeout(()=>{if(typeof ensureHotTopics==='function')ensureHotTopics();if(typeof ensureNewsShortsHome==='function')ensureNewsShortsHome();},4000);}
|
| 701 |
if(document.readyState==='complete')dH();else window.addEventListener('load',dH);
|
|
|
|
| 706 |
|
| 707 |
@app.get('/')
|
| 708 |
async def _index():
|
| 709 |
+
html=open('/app/static/index_v2.html','r',encoding='utf-8').read() if os.path.exists('/app/static/index_v2.html') else ('<html><body>VNEWS</body></html>' if os.path.exists('/app/static/index.html') else '')
|
| 710 |
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
|
| 711 |
body+=getattr(f6,'FINAL6_INJECT','');body+=getattr(f6,'FINAL6_FAST_HOME_INJECT','');body+=getattr(f6,'FINAL6E_INJECT','')
|
| 712 |
body+=PATCH_INJECT
|
| 713 |
+
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|
ai_runtime_patch_final.py
DELETED
|
@@ -1,78 +0,0 @@
|
|
| 1 |
-
"""Final patch: homepage fix + AI topics at top + SSE auto-update"""
|
| 2 |
-
import re, json, time
|
| 3 |
-
from fastapi.responses import HTMLResponse, JSONResponse
|
| 4 |
-
from fastapi import Query
|
| 5 |
-
|
| 6 |
-
# Import chain - must be after ai_runtime_final6
|
| 7 |
-
try:
|
| 8 |
-
import ai_runtime_final6 as f6
|
| 9 |
-
from ai_runtime_final6 import app, f5
|
| 10 |
-
from main import rt
|
| 11 |
-
except Exception as e:
|
| 12 |
-
print(f"[ERROR] f6 import: {e}")
|
| 13 |
-
f6 = None
|
| 14 |
-
f5 = None
|
| 15 |
-
rt = None
|
| 16 |
-
|
| 17 |
-
PATCH_CSS_JS = r'''
|
| 18 |
-
<style>
|
| 19 |
-
.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}
|
| 20 |
-
.storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 0}
|
| 21 |
-
button[onclick*="rewriteCurrentArticle"]{display:none!important}
|
| 22 |
-
#ai-short-home,.ai-short-home,.ai-short-card-final{display:none!important}
|
| 23 |
-
</style>
|
| 24 |
-
<div id="short-cmt-panel" class="short-cmt-panel"></div>
|
| 25 |
-
<script>
|
| 26 |
-
(function(){
|
| 27 |
-
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}
|
| 28 |
-
fetch('/api/storage_status').then(r=>r.json()).then(j=>{if(!j.persistent){let h=document.getElementById('view-home');if(h){let w=document.createElement('div');w.className='storage-warn';w.innerHTML='⚠️ Persistent Storage chưa bật. Bật: Space Settings → Persistent Storage → Small.';h.prepend(w);}}});
|
| 29 |
-
|
| 30 |
-
// ===== AI HOT TOPICS PREPEND =====
|
| 31 |
-
const AI_HOT_TOPICS = ['Công nghệ AI', 'World Cup 2026', 'Kinh tế Việt Nam', 'Bóng đá châu Âu'];
|
| 32 |
-
async function ensureHotTopics(){let inp=document.getElementById('ai-topic-input-final5');if(!inp||document.getElementById('hot-topic-row-ai'))return;let row=document.createElement('div');row.id='hot-topic-row-ai';row.style.cssText='display:flex;gap:6px;overflow-x:auto;padding:4px 0;margin:6px 0';let topics=[];try{let j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));topics=j.topics||[];}catch(e){topics=[];}AI_HOT_TOPICS.forEach(ai=>{if(!topics.find(t=>(t.topic||'').toLowerCase()===ai.toLowerCase())){topics.unshift({label:'#'+ai.replace(/\s+/g,''),topic:ai,count:0});}});row.innerHTML=topics.slice(0,14).map(t=>`<button class="hot-chip" style="flex:0 0 auto;background:#222;border:1px solid #333;color:#ddd;border-radius:16px;padding:5px 10px;font-size:11px;cursor:pointer" onclick="document.getElementById('ai-topic-input-final5').value='${esc(t.topic).replace(/'/g,'\\''}';document.getElementById('ai-topic-input-final5').focus();searchTopic('${esc(t.topic).replace(/'/g,'\\''}')">${esc(t.label)}</button>`).join('');inp.insertAdjacentElement('afterend',row);}
|
| 33 |
-
|
| 34 |
-
// ===== SSE AUTO UPDATE =====
|
| 35 |
-
let _sseSource=null;
|
| 36 |
-
function connectSSE(){try{_sseSource=new EventSource('/api/events');_sseSource.onmessage=e=>{try{const d=JSON.parse(e.data);if(d.type==='new_post'||d.type==='new_short'){fetch('/api/ai_wall').then(r=>r.json()).then(j=>{_wallPosts=j.posts||[];if(typeof renderShortAISlide==='function')renderShortAISlide();const track=document.getElementById('ai-wall-track');if(track)_wallPosts.length?track.innerHTML=_wallPosts.slice(0,20).map((p,i)=>makeWallItem(p,i)).join(''):null;});}}catch{}};_sseSource.onerror=()=>setTimeout(connectSSE,5000);}catch(e){}}
|
| 37 |
-
let _lastWallLen=0;
|
| 38 |
-
setInterval(()=>{fetch('/api/ai_wall').then(r=>r.json()).then(j=>{const w=j.posts||[];if(w.length!==_lastWallLen){_lastWallLen=w.length;_wallPosts=w;const track=document.getElementById('ai-wall-track');if(track)w.length?track.innerHTML=w.slice(0,20).map((p,i)=>makeWallItem(p,i)).join(''):null;if(typeof renderShortAISlide==='function')renderShortAISlide();}}).catch(()=>{});},30000);
|
| 39 |
-
connectSSE();
|
| 40 |
-
|
| 41 |
-
// ===== Short AI slide =====
|
| 42 |
-
async function renderShortAISlide(){let home=document.getElementById('view-home');if(!home)return;document.getElementById('short-ai-final-slide')?.remove();let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];let vids=wall.filter(p=>p.video);if(!vids.length)return;let wrap=document.createElement('div');wrap.id='short-ai-final-slide';wrap.className='slider-wrap';wrap.innerHTML='<div class="slider-header"><span class="slider-label">🎬 Short AI</span></div><div class="slider-track">'+vids.slice(0,30).map((p,i)=>`<div class="slider-item shorts-item" onclick="openAIShortFeed(${i})"><div class="slider-thumb shorts-thumb"><video src="${p.video}" muted preload="metadata" style="width:100%;height:100%;object-fit:cover"></video><div class="card-play">▶</div></div><div class="slider-title">${esc(p.title)}</div></div>`).join('')+'</div>';let comp=home.querySelector('.ai-compose');(comp&&comp.nextSibling?comp.parentNode.insertBefore(wrap,comp.nextSibling):home.prepend(wrap));}
|
| 43 |
-
setTimeout(renderShortAISlide,2500);
|
| 44 |
-
|
| 45 |
-
setInterval(ensureHotTopics,2000);
|
| 46 |
-
})();
|
| 47 |
-
</script>
|
| 48 |
-
'''
|
| 49 |
-
|
| 50 |
-
# Register route if possible
|
| 51 |
-
if f6 and app:
|
| 52 |
-
try:
|
| 53 |
-
# Remove duplicate / route to avoid conflict
|
| 54 |
-
original_routes = [r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
|
| 55 |
-
app.router.routes = original_routes
|
| 56 |
-
|
| 57 |
-
@app.get('/')
|
| 58 |
-
async def patch_homepage():
|
| 59 |
-
html = f5.f4.f3.f2.f1._load_index_html() if f5 else "<html><body></body></html>"
|
| 60 |
-
body = ""
|
| 61 |
-
if hasattr(rt,'old') and hasattr(rt.old,'PATCH_INJECT'):
|
| 62 |
-
body += getattr(rt.old,'PATCH_INJECT','')
|
| 63 |
-
if f5:
|
| 64 |
-
body += getattr(f5.f4.f3.f2.f1,'FINAL_INJECT','') if hasattr(f5,'f4') else ''
|
| 65 |
-
body += getattr(f5.f4.f3,'FINAL3_INJECT','') if hasattr(f5,'f4') else ''
|
| 66 |
-
body += getattr(f5.f4,'FINAL4_INJECT','') if hasattr(f5,'f4') else ''
|
| 67 |
-
body += getattr(f5,'FINAL5_INJECT','') if hasattr(f5,'f4') else ''
|
| 68 |
-
body += getattr(f6,'FINAL6_INJECT','') if f6 else ''
|
| 69 |
-
body += getattr(f6,'FINAL6_FAST_HOME_INJECT','') if f6 else ''
|
| 70 |
-
body += getattr(f6,'FINAL6E_INJECT','') if f6 else ''
|
| 71 |
-
body += PATCH_CSS_JS
|
| 72 |
-
if '</body>' in html:
|
| 73 |
-
html = html.replace('</body>', body + '\n</body>')
|
| 74 |
-
else:
|
| 75 |
-
html = html + body
|
| 76 |
-
return HTMLResponse(html)
|
| 77 |
-
except Exception as e:
|
| 78 |
-
print(f"[ERROR] register route: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ai_short_v2.py
DELETED
|
@@ -1,2083 +0,0 @@
|
|
| 1 |
-
"""VNEWS Short AI v2 — Short creator with TikTok background music, uploaded audio,
|
| 2 |
-
uploaded video/image background, and 'recreate from designed slides' (image-only,
|
| 3 |
-
reusing previous short audio, NO text overlays) mode.
|
| 4 |
-
|
| 5 |
-
Registered by app_v2_entry.py (import ai_short_v2). Uses the SAME wall store
|
| 6 |
-
(WALL_FILE) as the wall endpoints so posts created by the designer survive and
|
| 7 |
-
can be re-shorted purely from designed images.
|
| 8 |
-
"""
|
| 9 |
-
import os
|
| 10 |
-
import re
|
| 11 |
-
import json
|
| 12 |
-
import time
|
| 13 |
-
import uuid
|
| 14 |
-
import hashlib
|
| 15 |
-
import subprocess
|
| 16 |
-
import threading
|
| 17 |
-
|
| 18 |
-
import requests
|
| 19 |
-
from urllib.parse import quote as urllib_quote
|
| 20 |
-
from fastapi import Request, UploadFile, File, Query
|
| 21 |
-
from fastapi.responses import JSONResponse, FileResponse, Response, StreamingResponse
|
| 22 |
-
|
| 23 |
-
try:
|
| 24 |
-
import yt_dlp
|
| 25 |
-
except Exception: # pragma: no cover
|
| 26 |
-
yt_dlp = None
|
| 27 |
-
|
| 28 |
-
try:
|
| 29 |
-
from PIL import Image, ImageDraw, ImageFont
|
| 30 |
-
except Exception: # pragma: no cover
|
| 31 |
-
Image = ImageDraw = ImageFont = None
|
| 32 |
-
|
| 33 |
-
try:
|
| 34 |
-
from main import app
|
| 35 |
-
except Exception: # pragma: no cover
|
| 36 |
-
from fastapi import FastAPI
|
| 37 |
-
app = FastAPI()
|
| 38 |
-
|
| 39 |
-
DATA_DIR = "/data" if os.path.isdir("/data") else os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
|
| 40 |
-
WALL_FILE = os.path.join(DATA_DIR, "wall_posts.json")
|
| 41 |
-
SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts")
|
| 42 |
-
UPLOAD_DIR = os.path.join(DATA_DIR, "short_uploads")
|
| 43 |
-
WALL_IMG_DIR = os.path.join(DATA_DIR, "wall_imgs")
|
| 44 |
-
os.makedirs(SHORTS_DIR, exist_ok=True)
|
| 45 |
-
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
| 46 |
-
|
| 47 |
-
_wl_lock = threading.Lock()
|
| 48 |
-
|
| 49 |
-
UA_HEADERS = {
|
| 50 |
-
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
| 51 |
-
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
| 52 |
-
"Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8",
|
| 53 |
-
}
|
| 54 |
-
|
| 55 |
-
# ---------------------------------------------------------------------------
|
| 56 |
-
# Video scraping (YouTube / TikTok / news sites) via yt-dlp + oEmbed fallback
|
| 57 |
-
# ---------------------------------------------------------------------------
|
| 58 |
-
_SCRAPE_CACHE = {} # url -> {ok, ...} (in-memory, TTL 30 min)
|
| 59 |
-
_SCRAPE_TTL = 30 * 60
|
| 60 |
-
|
| 61 |
-
YTDLP_OPTS = {
|
| 62 |
-
"format": "best[height<=720][ext=mp4]/best[height<=720]/best",
|
| 63 |
-
"quiet": True,
|
| 64 |
-
"no_warnings": True,
|
| 65 |
-
"noplaylist": True,
|
| 66 |
-
"socket_timeout": 30,
|
| 67 |
-
"retries": 5,
|
| 68 |
-
"fragment_retries": 5,
|
| 69 |
-
"ignoreerrors": False,
|
| 70 |
-
"extractor_args": {"youtube": {"player_client": ["tv", "ios", "mweb"]}},
|
| 71 |
-
"http_headers": UA_HEADERS,
|
| 72 |
-
}
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
# ---------------------------------------------------------------------------
|
| 76 |
-
# YouTube cookies (bypasses the "Sign in to confirm you're not a bot" block).
|
| 77 |
-
# Sources, in priority order:
|
| 78 |
-
# 1. YT_COOKIES env var (HF Space secret — raw Netscape-format cookies.txt)
|
| 79 |
-
# 2. /app/cookies.txt (a cookies.txt file baked into the repo/runtime)
|
| 80 |
-
# yt-dlp reads a Netscape cookies.txt via the 'cookiefile' option.
|
| 81 |
-
# ---------------------------------------------------------------------------
|
| 82 |
-
_cookie_file = None
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
def _ensure_cookies():
|
| 86 |
-
"""Materialise a cookies.txt (Netscape format) for yt-dlp if any cookie
|
| 87 |
-
source is available. Returns the cookiefile path or None."""
|
| 88 |
-
global _cookie_file
|
| 89 |
-
if _cookie_file and os.path.exists(_cookie_file):
|
| 90 |
-
return _cookie_file
|
| 91 |
-
content = None
|
| 92 |
-
# 1. HF Space secret YT_COOKIES (raw cookies.txt content)
|
| 93 |
-
secret = os.environ.get("YT_COOKIES", "").strip()
|
| 94 |
-
if secret and ("# Netscape" in secret or "#HTTP" in secret or "youtube.com" in secret or "\tTRUE" in secret):
|
| 95 |
-
content = secret
|
| 96 |
-
# 2. baked-in file
|
| 97 |
-
if not content:
|
| 98 |
-
for p in ("/app/cookies.txt", "cookies.txt"):
|
| 99 |
-
if os.path.exists(p):
|
| 100 |
-
content = open(p, encoding="utf-8", errors="ignore").read()
|
| 101 |
-
break
|
| 102 |
-
if not content:
|
| 103 |
-
_cookie_file = None
|
| 104 |
-
return None
|
| 105 |
-
try:
|
| 106 |
-
path = os.path.join(DATA_DIR, "yt_cookies.txt")
|
| 107 |
-
os.makedirs(os.path.dirname(path), exist_ok=True)
|
| 108 |
-
with open(path, "w", encoding="utf-8") as f:
|
| 109 |
-
f.write(content)
|
| 110 |
-
_cookie_file = path
|
| 111 |
-
return path
|
| 112 |
-
except Exception:
|
| 113 |
-
return None
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
def _ytdlp_opts():
|
| 117 |
-
"""YTDLP opts + cookiefile (only when cookies are available)."""
|
| 118 |
-
opts = dict(YTDLP_OPTS)
|
| 119 |
-
cf = _ensure_cookies()
|
| 120 |
-
if cf:
|
| 121 |
-
opts["cookiefile"] = cf
|
| 122 |
-
# also add "cookiesfrombrowser" fallback? No — datacenter IPs can't read
|
| 123 |
-
# a local browser. cookiefile is the reliable path.
|
| 124 |
-
return opts
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
def _oembed_probe(url):
|
| 128 |
-
"""Cheap metadata fallback for TikTok/Facebook/Instagram/YouTube (no direct URL)."""
|
| 129 |
-
try:
|
| 130 |
-
host = (re.sub(r"^https?://", "", url).split("/")[0] or "").lower()
|
| 131 |
-
api = None
|
| 132 |
-
if "tiktok" in host:
|
| 133 |
-
api = "https://www.tiktok.com/oembed?url=" + urllib_quote(url, safe="")
|
| 134 |
-
elif "facebook" in host or "fb.watch" in host:
|
| 135 |
-
api = "https://www.facebook.com/plugins/video/oembed.json?url=" + urllib_quote(url, safe="")
|
| 136 |
-
elif "youtube.com" in host or "youtu.be" in host:
|
| 137 |
-
api = "https://www.youtube.com/oembed?url=" + urllib_quote(url, safe="") + "&format=json"
|
| 138 |
-
if not api:
|
| 139 |
-
return None
|
| 140 |
-
# Facebook oEmbed often times out from datacenter IPs; use a shorter timeout
|
| 141 |
-
_timeout = 8 if ("facebook" in host or "fb.watch" in host) else 12
|
| 142 |
-
j = {}
|
| 143 |
-
try:
|
| 144 |
-
r = requests.get(api, headers=UA_HEADERS, timeout=_timeout)
|
| 145 |
-
if r.status_code == 200:
|
| 146 |
-
j = r.json() or {}
|
| 147 |
-
except Exception:
|
| 148 |
-
j = {}
|
| 149 |
-
# YouTube: youtube.com/oembed is often SNI-blocked from datacenter IPs.
|
| 150 |
-
# Fall back to _yt_meta (noembed.com cascade) which reliably returns
|
| 151 |
-
# title+thumbnail+description from restricted networks.
|
| 152 |
-
yt_meta = None
|
| 153 |
-
if "youtube.com" in host or "youtu.be" in host:
|
| 154 |
-
yt_meta = _yt_meta(url)
|
| 155 |
-
title = ((j.get("title") or "") or (yt_meta or {}).get("title") or "").strip()[:200]
|
| 156 |
-
thumb = ((j.get("thumbnail_url") or "") or (yt_meta or {}).get("thumbnail") or "").strip()
|
| 157 |
-
desc = (yt_meta or {}).get("description") or ""
|
| 158 |
-
if "youtube.com" in host or "youtu.be" in host:
|
| 159 |
-
if not desc:
|
| 160 |
-
desc = (yt_meta or {}).get("description") or ""
|
| 161 |
-
embed_url = _oembed_embed_url(url)
|
| 162 |
-
# oEmbed providers (notably TikTok short links like vm.tiktok.com) don't
|
| 163 |
-
# expose the video ID in the URL, but the `html` field contains the
|
| 164 |
-
# canonical <iframe src> — parse it so short links still embed.
|
| 165 |
-
if not embed_url:
|
| 166 |
-
try:
|
| 167 |
-
m_html = re.search(r'<iframe[^>]+src="([^"]+)"', ((j or {}).get("html") or ""))
|
| 168 |
-
if m_html:
|
| 169 |
-
embed_url = m_html.group(1).replace("&", "&")
|
| 170 |
-
if "?" in embed_url and "autoplay" not in embed_url:
|
| 171 |
-
embed_url += "&autoplay=1&rel=0"
|
| 172 |
-
except Exception:
|
| 173 |
-
pass
|
| 174 |
-
return {
|
| 175 |
-
"ok": True,
|
| 176 |
-
"title": title[:200],
|
| 177 |
-
"thumbnail": thumb,
|
| 178 |
-
"duration": None,
|
| 179 |
-
"extractor": "oembed",
|
| 180 |
-
"direct_url": None,
|
| 181 |
-
"previewable": False, # no direct streamable URL from oEmbed alone
|
| 182 |
-
"embed_url": embed_url,
|
| 183 |
-
"description": desc[:2000],
|
| 184 |
-
"note": "Có thể lấy được thông tin, nhưng không tải được video trực tiếp từ link này.",
|
| 185 |
-
}
|
| 186 |
-
except Exception:
|
| 187 |
-
return None
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
def _yt_meta(url):
|
| 191 |
-
"""Fetch YouTube video metadata (title/description/thumbnail).
|
| 192 |
-
|
| 193 |
-
Cascades over several providers so at least title+thumbnail always come
|
| 194 |
-
back even from datacenter IPs where www.youtube.com is SNI-blocked:
|
| 195 |
-
1. noembed.com — reliable title+thumb (works from restricted IPs)
|
| 196 |
-
2. youtube oEmbed — title + thumbnail_url
|
| 197 |
-
3. watch page og:tags — title + description + thumbnail (best-effort)
|
| 198 |
-
Description is best-effort (YT oEmbed has no description field); when
|
| 199 |
-
unavailable the caller falls back to the title. Returns dict or None."""
|
| 200 |
-
try:
|
| 201 |
-
vid = _yt_id_from_url(url)
|
| 202 |
-
if not vid:
|
| 203 |
-
return None
|
| 204 |
-
out = {"video_id": vid, "title": "", "description": "", "thumbnail": ""}
|
| 205 |
-
watch = f"https://www.youtube.com/watch?v={vid}"
|
| 206 |
-
|
| 207 |
-
# 1) noembed (title+thumb, no desc)
|
| 208 |
-
try:
|
| 209 |
-
r = requests.get(f"https://noembed.com/embed?url={urllib_quote(watch, safe='')}",
|
| 210 |
-
headers=UA_HEADERS, timeout=15)
|
| 211 |
-
if r.status_code == 200:
|
| 212 |
-
j = r.json() or {}
|
| 213 |
-
out["title"] = (j.get("title") or "").strip()[:250]
|
| 214 |
-
out["thumbnail"] = (j.get("thumbnail_url") or "").strip()
|
| 215 |
-
except Exception:
|
| 216 |
-
pass
|
| 217 |
-
|
| 218 |
-
# 2) youtube oEmbed (title+thumb)
|
| 219 |
-
if not out["title"]:
|
| 220 |
-
try:
|
| 221 |
-
r2 = requests.get(f"https://www.youtube.com/oembed?url={urllib_quote(watch, safe='')}&format=json",
|
| 222 |
-
headers=UA_HEADERS, timeout=15)
|
| 223 |
-
if r2.status_code == 200:
|
| 224 |
-
j2 = r2.json() or {}
|
| 225 |
-
out["title"] = (j2.get("title") or "").strip()[:250]
|
| 226 |
-
out["thumbnail"] = (j2.get("thumbnail_url") or "").strip()
|
| 227 |
-
except Exception:
|
| 228 |
-
pass
|
| 229 |
-
|
| 230 |
-
# 3) watch page og:tags (title + description + thumb, best-effort)
|
| 231 |
-
try:
|
| 232 |
-
r3 = requests.get(watch, headers=UA_HEADERS, timeout=20)
|
| 233 |
-
if r3.status_code == 200:
|
| 234 |
-
h3 = r3.text
|
| 235 |
-
def _og(prop):
|
| 236 |
-
mm = re.search(r'<meta[^>]+property="og:' + prop + r'"[^>]+content="([^"]*)"', h3) or \
|
| 237 |
-
re.search(r'<meta[^>]+content="([^"]*)"[^>]+property="og:' + prop + r'"', h3)
|
| 238 |
-
return mm.group(1).strip() if mm else ""
|
| 239 |
-
def _nm(name):
|
| 240 |
-
mm = re.search(r'<meta[^>]+name="' + name + r'"[^>]+content="([^"]*)"', h3)
|
| 241 |
-
return mm.group(1).strip() if mm else ""
|
| 242 |
-
if not out["title"]:
|
| 243 |
-
out["title"] = _og("title") or _nm("title")
|
| 244 |
-
out["description"] = _og("description") or _nm("description")
|
| 245 |
-
if not out["thumbnail"]:
|
| 246 |
-
out["thumbnail"] = _og("image") or ""
|
| 247 |
-
if out["thumbnail"].startswith("//"):
|
| 248 |
-
out["thumbnail"] = "https:" + out["thumbnail"]
|
| 249 |
-
except Exception:
|
| 250 |
-
pass
|
| 251 |
-
|
| 252 |
-
# 4) r.jina.ai reader proxy — server-side fetch that renders the watch
|
| 253 |
-
# page and returns Title/Description/Thumbnail lines as markdown.
|
| 254 |
-
# Requires the Space origin in the Origin header (CORS check). Works
|
| 255 |
-
# from datacenter IPs where youtube.com is SNI-blocked.
|
| 256 |
-
if not out["description"] or not out["title"]:
|
| 257 |
-
try:
|
| 258 |
-
_hdrs = dict(UA_HEADERS)
|
| 259 |
-
_hdrs["Origin"] = "https://bep40-vnews.hf.space"
|
| 260 |
-
r4 = requests.get(f"https://r.jina.ai/{watch}", headers=_hdrs, timeout=30)
|
| 261 |
-
if r4.status_code == 200:
|
| 262 |
-
h4 = r4.text
|
| 263 |
-
_tm = re.search(r'(?:^|\n)Title:\s*(.+)', h4)
|
| 264 |
-
_dm = re.search(r'(?:^|\n)Description:\s*(.+)', h4, re.S)
|
| 265 |
-
_im = re.search(r'(?:^|\n)Thumbnail:\s*(\S+)', h4)
|
| 266 |
-
if _tm:
|
| 267 |
-
out["title"] = _tm.group(1).strip()[:250]
|
| 268 |
-
if _dm:
|
| 269 |
-
out["description"] = _dm.group(1).strip()[:2000]
|
| 270 |
-
if _im:
|
| 271 |
-
th = _im.group(1).strip()
|
| 272 |
-
if th.startswith("//"):
|
| 273 |
-
th = "https:" + th
|
| 274 |
-
out["thumbnail"] = th
|
| 275 |
-
except Exception:
|
| 276 |
-
pass
|
| 277 |
-
|
| 278 |
-
if not out["title"] and not out["description"]:
|
| 279 |
-
return None
|
| 280 |
-
return {
|
| 281 |
-
"video_id": vid,
|
| 282 |
-
"title": (out["title"] or "").strip()[:250],
|
| 283 |
-
"description": (out["description"] or "").strip()[:2000],
|
| 284 |
-
"thumbnail": out["thumbnail"],
|
| 285 |
-
}
|
| 286 |
-
except Exception:
|
| 287 |
-
return None
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
def _yt_id_from_url(url):
|
| 291 |
-
m = re.search(r'(?:youtube\.com/(?:embed|watch)\?v=|youtu\.be/|youtube-nocookie\.com/embed/)([a-zA-Z0-9_-]{11})', (url or ""))
|
| 292 |
-
return m.group(1) if m else ""
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
def _is_known_social(url):
|
| 296 |
-
"""True for platforms whose content is embeddable via oEmbed iframes."""
|
| 297 |
-
try:
|
| 298 |
-
host = (re.sub(r"^https?://", "", url or "").split("/")[0] or "").lower()
|
| 299 |
-
return any(k in host for k in ("tiktok", "facebook", "fb.watch", "instagram", "youtube", "youtu.be", "vimeo", "twitter", "x.com"))
|
| 300 |
-
except Exception:
|
| 301 |
-
return False
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
def _oembed_embed_url(url):
|
| 305 |
-
"""Derive a browser-embeddable iframe URL for social video links.
|
| 306 |
-
|
| 307 |
-
Used as a Short HOT fallback when the direct media cannot be downloaded:
|
| 308 |
-
the post is still created, and the TikTok-style feed renders it as an
|
| 309 |
-
oEmbed iframe slide (exactly like the football video slides on YouTube)
|
| 310 |
-
while the title/description/thumb come from the oEmbed metadata.
|
| 311 |
-
"""
|
| 312 |
-
try:
|
| 313 |
-
host = (re.sub(r"^https?://", "", url).split("/")[0] or "").lower()
|
| 314 |
-
u = (url or "").strip()
|
| 315 |
-
if "tiktok" in host:
|
| 316 |
-
m = re.search(r"/(?:video|@[\w.\-]+/video|embed/v2)/(\d+)", u)
|
| 317 |
-
if m:
|
| 318 |
-
return f"https://www.tiktok.com/embed/v2/{m.group(1)}"
|
| 319 |
-
return ""
|
| 320 |
-
if "facebook" in host or "fb.watch" in host or "fb.gg" in host:
|
| 321 |
-
return "https://www.facebook.com/plugins/video.php?href=" + urllib_quote(u, safe="") + "&show_text=0"
|
| 322 |
-
if "instagram" in host:
|
| 323 |
-
m = re.search(r"/p/([\w\-]+)/?", u)
|
| 324 |
-
if m:
|
| 325 |
-
return f"https://www.instagram.com/p/{m.group(1)}/embed/"
|
| 326 |
-
if "youtube.com" in host or "youtu.be" in host:
|
| 327 |
-
m = re.search(r"(?:v=|shorts/|embed/|youtu\.be/)([a-zA-Z0-9_-]{11})", u)
|
| 328 |
-
if m:
|
| 329 |
-
return f"https://www.youtube.com/embed/{m.group(1)}?autoplay=1&rel=0"
|
| 330 |
-
if "x.com" in host or "twitter" in host:
|
| 331 |
-
m = re.search(r"/status/(\d+)", u)
|
| 332 |
-
if m:
|
| 333 |
-
return f"https://platform.twitter.com/embed/Tweet.html?id={m.group(1)}&dnt=true"
|
| 334 |
-
if "vimeo" in host:
|
| 335 |
-
m = re.search(r"/(\d+)$", u)
|
| 336 |
-
if m:
|
| 337 |
-
return f"https://player.vimeo.com/video/{m.group(1)}"
|
| 338 |
-
except Exception:
|
| 339 |
-
pass
|
| 340 |
-
return ""
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
def _is_real_http(u):
|
| 344 |
-
"""True if a candidate URL is a real http(s) media URL (not a JS template)."""
|
| 345 |
-
u = (u or "").strip()
|
| 346 |
-
if not re.match(r"^https?://", u):
|
| 347 |
-
return False
|
| 348 |
-
if "'" in u or '"' in u or "+" in u or u.count("(") > 0:
|
| 349 |
-
return False
|
| 350 |
-
return True
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
def _scrape_page_html(url):
|
| 354 |
-
"""Fallback scraper for news sites (24h, dantri, znews...): parse the article
|
| 355 |
-
HTML for og:video / <video>/<source> / m3u8|mp4 regex inside JS configs
|
| 356 |
-
(e.g. 24h 'playlistConf' blob). Returns info dict or None."""
|
| 357 |
-
try:
|
| 358 |
-
r = requests.get(url, headers=UA_HEADERS, timeout=25)
|
| 359 |
-
if r.status_code != 200:
|
| 360 |
-
return None
|
| 361 |
-
r.encoding = "utf-8"
|
| 362 |
-
html = r.text
|
| 363 |
-
except Exception:
|
| 364 |
-
return None
|
| 365 |
-
title = ""
|
| 366 |
-
mt = re.search(r'<meta[^>]+property="og:title"[^>]+content="([^"]+)"', html) or \
|
| 367 |
-
re.search(r'<meta[^>]+content="([^"]+)"[^>]+property="og:title"', html) or \
|
| 368 |
-
re.search(r"<title[^>]*>([^<]+)</title>", html)
|
| 369 |
-
if mt:
|
| 370 |
-
title = re.sub(r"\s+", " ", mt.group(1)).strip()[:250]
|
| 371 |
-
|
| 372 |
-
# 1) og:video
|
| 373 |
-
cands = []
|
| 374 |
-
for pat in (r'property="og:video[^"]*"\s+content="([^"]+)"',
|
| 375 |
-
r'content="([^"]+)"\s+property="og:video[^"]*"'):
|
| 376 |
-
cands += re.findall(pat, html)
|
| 377 |
-
# 2) <video>/<source> tags
|
| 378 |
-
cands += re.findall(r'<source[^>]+src="([^"]+)"', html)
|
| 379 |
-
cands += re.findall(r"<source[^>]+src='([^']+)'", html)
|
| 380 |
-
cands += re.findall(r'<video[^>]+src="([^"]+)"', html)
|
| 381 |
-
# 3) raw .mp4/.m3u8 URLs in the page (incl. JS playlistConf src:"...")
|
| 382 |
-
cands += re.findall(r'https?://[^"\'\s<>\\]+?\.(?:m3u8|mp4)[^"\'\s<>\\]*', html)
|
| 383 |
-
# 4) src: "..." inside JS (m3u8/mp4)
|
| 384 |
-
cands += re.findall(r'src\s*:\s*["\']([^"\']+?\.(?:m3u8|mp4)[^"\']*)["\']', html)
|
| 385 |
-
|
| 386 |
-
# 5) YouTube iframe embeds (e.g. bongdaplus.vn video pages embed highlights)
|
| 387 |
-
for yt_iframe_src in re.findall(r'<iframe[^>]+src="([^"]*(?:youtube\.com/embed|youtu\.be/|youtube-nocookie\.com/embed)[^"]*)"[^>]*>', html, re.I):
|
| 388 |
-
yt_iframe_src = yt_iframe_src.strip()
|
| 389 |
-
if yt_iframe_src.startswith("//"): yt_iframe_src = "https:" + yt_iframe_src
|
| 390 |
-
yt_id = re.search(r'(?:embed/|youtu\.be/|v=)([a-zA-Z0-9_-]{11})', yt_iframe_src)
|
| 391 |
-
if yt_id:
|
| 392 |
-
cands.append(f"https://www.youtube.com/embed/{yt_id.group(1)}?autoplay=1&rel=0&enablejsapi=1")
|
| 393 |
-
else:
|
| 394 |
-
cands.append(yt_iframe_src)
|
| 395 |
-
|
| 396 |
-
# 6) YouTube embed URLs in JS strings (src: "https://youtube.com/embed/...")
|
| 397 |
-
for yt_js in re.findall(r'src\s*:\s*["\']([^"\']*(?:youtube\.com/embed|youtu\.be/)[^"\']*)["\']', html, re.I):
|
| 398 |
-
yt_js = yt_js.strip()
|
| 399 |
-
if yt_js.startswith("//"): yt_js = "https:" + yt_js
|
| 400 |
-
cands.append(yt_js)
|
| 401 |
-
|
| 402 |
-
# normalize relative URLs + dedupe + prefer 720p m3u8 then mp4
|
| 403 |
-
from urllib.parse import urljoin
|
| 404 |
-
seen = set()
|
| 405 |
-
good = []
|
| 406 |
-
for c in cands:
|
| 407 |
-
c = c.strip().strip('"').strip("'")
|
| 408 |
-
if not c or not _is_real_http(c) and not c.startswith("/"):
|
| 409 |
-
continue
|
| 410 |
-
if c.startswith("/"):
|
| 411 |
-
c = urljoin(url, c)
|
| 412 |
-
if not c.startswith("http"):
|
| 413 |
-
continue
|
| 414 |
-
if c in seen:
|
| 415 |
-
continue
|
| 416 |
-
seen.add(c)
|
| 417 |
-
good.append(c)
|
| 418 |
-
if not good:
|
| 419 |
-
return None
|
| 420 |
-
|
| 421 |
-
# score: prefer m3u8 720p > m3u8 > mp4 720p > mp4
|
| 422 |
-
def score(c):
|
| 423 |
-
s = 0
|
| 424 |
-
if "720p" in c or "720" in c:
|
| 425 |
-
s += 20
|
| 426 |
-
if ".m3u8" in c:
|
| 427 |
-
s += 10
|
| 428 |
-
if "1080" in c:
|
| 429 |
-
s += 18
|
| 430 |
-
if "480" in c:
|
| 431 |
-
s += 8
|
| 432 |
-
return s
|
| 433 |
-
good.sort(key=score, reverse=True)
|
| 434 |
-
direct = good[0]
|
| 435 |
-
|
| 436 |
-
thumb = ""
|
| 437 |
-
mt2 = re.search(r'<meta[^>]+property="og:image[^"]*"\s+content="([^"]+)"', html) or \
|
| 438 |
-
re.search(r'<meta[^>]+content="([^"]+)"[^>]+property="og:image[^"]*"', html)
|
| 439 |
-
if mt2:
|
| 440 |
-
thumb = mt2.group(1).strip()
|
| 441 |
-
|
| 442 |
-
is_hls = ".m3u8" in direct.lower()
|
| 443 |
-
is_youtube = "youtube.com/embed" in direct.lower() or "youtu.be" in direct.lower() or "youtube-nocookie" in direct.lower()
|
| 444 |
-
return {
|
| 445 |
-
"ok": True,
|
| 446 |
-
"url": url,
|
| 447 |
-
"title": title,
|
| 448 |
-
"duration": None,
|
| 449 |
-
"thumbnail": thumb,
|
| 450 |
-
"direct_url": direct,
|
| 451 |
-
"extractor": "youtube" if is_youtube else "html-page",
|
| 452 |
-
"previewable": True,
|
| 453 |
-
"is_hls": is_hls,
|
| 454 |
-
"note": "",
|
| 455 |
-
}
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
def _resolve_media_url(url):
|
| 459 |
-
"""Fetch the body of a media URL using the right headers (referer for news CDNs)."""
|
| 460 |
-
HDRS = dict(UA_HEADERS)
|
| 461 |
-
host = (re.sub(r"^https?://", "", url).split("/")[0] or "").lower()
|
| 462 |
-
if "vnecdn" in host or "vnexpress" in host:
|
| 463 |
-
HDRS["Referer"] = "https://vnexpress.net/"
|
| 464 |
-
elif "cdn.24h" in host or "24h.com.vn" in host:
|
| 465 |
-
HDRS["Referer"] = "https://www.24h.com.vn/"
|
| 466 |
-
return HDRS
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
def _scrape_video_info(url):
|
| 470 |
-
"""Extract direct video URL + metadata for a user-pasted link.
|
| 471 |
-
|
| 472 |
-
Returns dict with keys: ok, url (original), title, duration, thumbnail,
|
| 473 |
-
direct_url (proxy-able), extractor, previewable, note.
|
| 474 |
-
"""
|
| 475 |
-
try:
|
| 476 |
-
now = time.time()
|
| 477 |
-
cached = _SCRAPE_CACHE.get(url)
|
| 478 |
-
if cached and now - cached.get("_t", 0) < _SCRAPE_TTL:
|
| 479 |
-
return cached
|
| 480 |
-
except Exception:
|
| 481 |
-
pass
|
| 482 |
-
|
| 483 |
-
# Early-detect social/video platforms that yt-dlp handles slowly or not at all.
|
| 484 |
-
# Facebook URLs block datacenter IPs → yt-dlp hangs for 15s+; skip it and
|
| 485 |
-
# go straight to oEmbed metadata-only fallback.
|
| 486 |
-
_host_l = (re.sub(r"^https?://", "", url).split("/")[0] or "").lower()
|
| 487 |
-
_is_facebook = "facebook" in _host_l or "fb.watch" in _host_l
|
| 488 |
-
|
| 489 |
-
out = None
|
| 490 |
-
if yt_dlp is not None and not _is_facebook:
|
| 491 |
-
try:
|
| 492 |
-
with yt_dlp.YoutubeDL(_ytdlp_opts()) as ydl:
|
| 493 |
-
info = ydl.extract_info(url, download=False)
|
| 494 |
-
# pick best direct (non-m3u8) progressive URL
|
| 495 |
-
direct = None
|
| 496 |
-
if info:
|
| 497 |
-
direct = info.get("url") or ""
|
| 498 |
-
for f in info.get("formats") or []:
|
| 499 |
-
proto = (f.get("protocol") or "")
|
| 500 |
-
if f.get("url") and proto not in ("m3u8_native", "m3u8"):
|
| 501 |
-
direct = f.get("url")
|
| 502 |
-
break
|
| 503 |
-
dur = info.get("duration") if info else None
|
| 504 |
-
try:
|
| 505 |
-
dur = float(dur) if dur is not None else None
|
| 506 |
-
except Exception:
|
| 507 |
-
dur = None
|
| 508 |
-
extractor = (info.get("extractor_key") or info.get("extractor") or "generic") if info else "generic"
|
| 509 |
-
is_hls = bool(direct) and ".m3u8" in direct.lower()
|
| 510 |
-
# If yt-dlp returned a bogus JS template URL ('+d+' etc.), treat as
|
| 511 |
-
# a scrape failure -> fall through to the HTML page parser.
|
| 512 |
-
yt_bad = not _is_real_http(direct)
|
| 513 |
-
out = {
|
| 514 |
-
"ok": not yt_bad,
|
| 515 |
-
"url": url,
|
| 516 |
-
"title": (info.get("title") or "").strip()[:250] if info else "",
|
| 517 |
-
"duration": dur,
|
| 518 |
-
"thumbnail": (info.get("thumbnail") or "").strip() or "" if not yt_bad else "",
|
| 519 |
-
"direct_url": direct or "",
|
| 520 |
-
"extractor": extractor,
|
| 521 |
-
"previewable": bool(direct) and not yt_bad,
|
| 522 |
-
"is_hls": is_hls,
|
| 523 |
-
"note": "",
|
| 524 |
-
}
|
| 525 |
-
except Exception as e:
|
| 526 |
-
if getattr(e, "args", None):
|
| 527 |
-
msg = str(e.args[0]) if isinstance(e.args[0], str) else str(e)
|
| 528 |
-
else:
|
| 529 |
-
msg = str(e)
|
| 530 |
-
# split real error after '[xxx] '
|
| 531 |
-
m = re.search(r"\]\s*(.+)$", msg)
|
| 532 |
-
err = m.group(1) if m else msg[:200]
|
| 533 |
-
out = {"ok": False, "url": url, "error": err[:200]}
|
| 534 |
-
|
| 535 |
-
# YouTube-specific fallback: if yt-dlp fails (SSL/EOF, rate-limit),
|
| 536 |
-
# extract video ID and return the embed iframe URL + thumbnail.
|
| 537 |
-
# The frontend renders YouTube embed URLs as iframes.
|
| 538 |
-
yt_id_match = re.search(r'(?:youtube\.com/(?:embed|watch)\?v=|youtu\.be/|youtube-nocookie\.com/embed/)([a-zA-Z0-9_-]{11})', url)
|
| 539 |
-
if yt_id_match:
|
| 540 |
-
yt_id = yt_id_match.group(1)
|
| 541 |
-
out = {
|
| 542 |
-
"ok": True,
|
| 543 |
-
"url": url,
|
| 544 |
-
"title": f"YouTube video {yt_id}",
|
| 545 |
-
"duration": None,
|
| 546 |
-
"thumbnail": f"https://i.ytimg.com/vi/{yt_id}/hqdefault.jpg",
|
| 547 |
-
"direct_url": f"https://www.youtube.com/embed/{yt_id}?autoplay=1&rel=0&enablejsapi=1",
|
| 548 |
-
"extractor": "youtube-fallback",
|
| 549 |
-
"previewable": True,
|
| 550 |
-
"is_hls": False,
|
| 551 |
-
"note": "yt-dlp failed, using YouTube embed directly",
|
| 552 |
-
}
|
| 553 |
-
|
| 554 |
-
# YouTube enrichment: grab the watch-page meta tags (title/description/
|
| 555 |
-
# thumbnail). yt-dlp often gives a title + mp4 URL but NO description, and
|
| 556 |
-
# the fallback path gives a bare 'YouTube video {id}' — the og: tags from
|
| 557 |
-
# the watch page fix both. Only fills gaps; keeps the existing direct_url.
|
| 558 |
-
if "youtube.com" in _host_l or "youtu.be" in _host_l:
|
| 559 |
-
_meta = _yt_meta(url)
|
| 560 |
-
if _meta:
|
| 561 |
-
if not out or not out.get("ok") or not (out.get("title") or "").strip() or "YouTube video" in (out.get("title") or ""):
|
| 562 |
-
out_title = _meta.get("title") or ""
|
| 563 |
-
else:
|
| 564 |
-
out_title = (out.get("title") or "")
|
| 565 |
-
desc = _meta.get("description") or ""
|
| 566 |
-
thumb = _meta.get("thumbnail") or (out or {}).get("thumbnail", "")
|
| 567 |
-
if not out:
|
| 568 |
-
out = {"ok": True, "url": url}
|
| 569 |
-
out["title"] = out_title or _meta.get("title") or (out.get("title") or "")
|
| 570 |
-
out["description"] = desc
|
| 571 |
-
if thumb:
|
| 572 |
-
out["thumbnail"] = thumb
|
| 573 |
-
|
| 574 |
-
# If yt-dlp failed / gave a bad URL, try parsing the article HTML directly
|
| 575 |
-
# (handles 24h.com.vn JS configs, dantri, znews, some vnexpress pages).
|
| 576 |
-
if not out or not out.get("ok") or not out.get("direct_url"):
|
| 577 |
-
page = _scrape_page_html(url)
|
| 578 |
-
if page:
|
| 579 |
-
# keep yt-dlp title/duration for social sites, take media from page
|
| 580 |
-
base = {"ok": True, "url": url,
|
| 581 |
-
"title": page.get("title") or (out or {}).get("title", ""),
|
| 582 |
-
"duration": (out or {}).get("duration") if (out or {}).get("direct_url") else page.get("duration"),
|
| 583 |
-
"thumbnail": page.get("thumbnail") or (out or {}).get("thumbnail", ""),
|
| 584 |
-
"direct_url": page.get("direct_url"),
|
| 585 |
-
"extractor": page.get("extractor") or "html-page",
|
| 586 |
-
"previewable": True,
|
| 587 |
-
"is_hls": page.get("is_hls", False),
|
| 588 |
-
"note": page.get("note", ""),
|
| 589 |
-
}
|
| 590 |
-
out = base
|
| 591 |
-
|
| 592 |
-
# metadata-only fallback (oEmbed) — lets the user at least see title/thumb
|
| 593 |
-
if not out or not out.get("ok"):
|
| 594 |
-
probe = _oembed_probe(url)
|
| 595 |
-
if probe:
|
| 596 |
-
probe["url"] = url
|
| 597 |
-
out = probe
|
| 598 |
-
|
| 599 |
-
if not out:
|
| 600 |
-
out = {"ok": False, "url": url, "error": "Không lấy được thông tin video từ link này."}
|
| 601 |
-
if "is_hls" not in out:
|
| 602 |
-
out["is_hls"] = bool(out.get("direct_url", "")) and ".m3u8" in (out.get("direct_url", "") or "").lower()
|
| 603 |
-
try:
|
| 604 |
-
out["_t"] = time.time()
|
| 605 |
-
_SCRAPE_CACHE[url] = out
|
| 606 |
-
if len(_SCRAPE_CACHE) > 200:
|
| 607 |
-
for k in list(_SCRAPE_CACHE)[:50]:
|
| 608 |
-
_SCRAPE_CACHE.pop(k, None)
|
| 609 |
-
except Exception:
|
| 610 |
-
pass
|
| 611 |
-
return out
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
def _load_wall_posts():
|
| 615 |
-
try:
|
| 616 |
-
with _wl_lock:
|
| 617 |
-
if os.path.exists(WALL_FILE):
|
| 618 |
-
with open(WALL_FILE, "r", encoding="utf-8") as f:
|
| 619 |
-
return json.load(f) or []
|
| 620 |
-
except Exception:
|
| 621 |
-
pass
|
| 622 |
-
return []
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
def _save_wall_posts(posts):
|
| 626 |
-
try:
|
| 627 |
-
with _wl_lock:
|
| 628 |
-
tmp = WALL_FILE + ".tmp"
|
| 629 |
-
with open(tmp, "w", encoding="utf-8") as f:
|
| 630 |
-
json.dump(posts, f, ensure_ascii=False)
|
| 631 |
-
os.replace(tmp, WALL_FILE)
|
| 632 |
-
except Exception:
|
| 633 |
-
pass
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
def _clean(s):
|
| 637 |
-
s = re.sub(r"[ \t]+", " ", s or "")
|
| 638 |
-
s = re.sub(r"\n{3,}", "\n\n", s)
|
| 639 |
-
return s.strip()
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
def _safe_name(name):
|
| 643 |
-
return re.sub(r"[^a-zA-Z0-9_.-]", "_", name)[:120]
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
# =========================================================================
|
| 647 |
-
# TikTok / background MUSIC list — royalty-free tracks with verified direct
|
| 648 |
-
# mp3 URLs (incompetech CC-BY 4.0, Pixabay, Archive.org).
|
| 649 |
-
# =========================================================================
|
| 650 |
-
MUSIC_LIST = [
|
| 651 |
-
{"id": "none", "name": "🚫 Không có nhạc nền", "url": ""},
|
| 652 |
-
{"id": "carefree", "name": "🎵 Carefree (Joyful)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Carefree.mp3"},
|
| 653 |
-
{"id": "wholesome", "name": "🎵 Wholesome (Warm)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Wholesome.mp3"},
|
| 654 |
-
{"id": "fluffing", "name": "🎵 Fluffing a Duck (Quirky)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Fluffing%20a%20Duck.mp3"},
|
| 655 |
-
{"id": "loping", "name": "🎵 Loping Sting (Short)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Loping%20Sting.mp3"},
|
| 656 |
-
{"id": "modern", "name": "🎵 Modern Vibes (Electronic)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Modern%20Vibes.mp3"},
|
| 657 |
-
{"id": "cheezee", "name": "🎵 Chee Zee Jungle (Upbeat)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Chee%20Zee%20Jungle.mp3"},
|
| 658 |
-
{"id": "constance", "name": "🎵 Constance (Cinematic)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Constance.mp3"},
|
| 659 |
-
{"id": "edm", "name": "🎵 EDM Detection Mode (Energetic)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/EDM%20Detection%20Mode.mp3"},
|
| 660 |
-
{"id": "spyglass", "name": "🎵 Spy Glass (Mystery)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Spy%20Glass.mp3"},
|
| 661 |
-
{"id": "raving", "name": "🎵 Raving Energy (Dance)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Raving%20Energy.mp3"},
|
| 662 |
-
{"id": "pixabay", "name": "🎵 Pixabay Sunset Vibes", "url": "https://cdn.pixabay.com/download/audio/2022/05/27/audio_1808fbf07a.mp3"},
|
| 663 |
-
]
|
| 664 |
-
|
| 665 |
-
MUSIC_BY_ID = {m["id"]: m for m in MUSIC_LIST}
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
# =========================================================================
|
| 669 |
-
# local URL resolution (uploaded files / wall images) + remote fetch
|
| 670 |
-
# =========================================================================
|
| 671 |
-
def _fetch_bytes(url):
|
| 672 |
-
"""Fetch image/audio/video bytes from a URL. Handles local /api/ URLs."""
|
| 673 |
-
if not url:
|
| 674 |
-
return None
|
| 675 |
-
if url.startswith("/api/ai-short/upload/"):
|
| 676 |
-
p = os.path.join(UPLOAD_DIR, os.path.basename(url.rstrip("/")))
|
| 677 |
-
if os.path.exists(p):
|
| 678 |
-
with open(p, "rb") as f:
|
| 679 |
-
return f.read()
|
| 680 |
-
return None
|
| 681 |
-
if url.startswith("/api/wall/img/"):
|
| 682 |
-
p = os.path.join(WALL_IMG_DIR, os.path.basename(url.rstrip("/")))
|
| 683 |
-
if os.path.exists(p):
|
| 684 |
-
with open(p, "rb") as f:
|
| 685 |
-
return f.read()
|
| 686 |
-
return None
|
| 687 |
-
try:
|
| 688 |
-
r = requests.get(url, headers=UA_HEADERS, timeout=30)
|
| 689 |
-
if r.status_code == 200 and len(r.content) > 900:
|
| 690 |
-
return r.content
|
| 691 |
-
except Exception:
|
| 692 |
-
pass
|
| 693 |
-
return None
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
def _download_to_file(url, dst, max_mb=400):
|
| 697 |
-
"""Stream a remote file (video) to disk without loading it into RAM.
|
| 698 |
-
Returns True on success. Handles same-origin /api/ URLs by copy.
|
| 699 |
-
If the URL is an HLS playlist (.m3u8), downloads+decrypts all segments
|
| 700 |
-
via _download_hls_to_ts (handles AES-128 encryption from news CDNs).
|
| 701 |
-
YouTube URLs are downloaded via yt-dlp."""
|
| 702 |
-
try:
|
| 703 |
-
# YouTube embed / watch URLs → use yt-dlp to fetch best MP4
|
| 704 |
-
yt_match = re.search(r'youtube\.com/(?:embed|watch)\?v=([a-zA-Z0-9_-]{11})|youtu\.be/([a-zA-Z0-9_-]{11})', (url or ""))
|
| 705 |
-
if yt_dlp is not None and yt_match:
|
| 706 |
-
yt_id = yt_match.group(1) or yt_match.group(2)
|
| 707 |
-
tmp_out = dst + ".yt.mp4"
|
| 708 |
-
cf = _ensure_cookies()
|
| 709 |
-
ytdlp_args = ["yt-dlp", "-f", "best[ext=mp4][height<=720]/best[ext=mp4]/best",
|
| 710 |
-
"--no-playlist", "--remux-video", "mp4", "-o", tmp_out,
|
| 711 |
-
"--timeout", "30", "--no-warnings"]
|
| 712 |
-
if cf:
|
| 713 |
-
ytdlp_args += ["--cookiefile", cf]
|
| 714 |
-
# use the video ID to construct the canonical watch URL
|
| 715 |
-
ytdlp_args.append(f"https://www.youtube.com/watch?v={yt_id}")
|
| 716 |
-
try:
|
| 717 |
-
subprocess.run(ytdlp_args, check=True, stdout=subprocess.PIPE,
|
| 718 |
-
stderr=subprocess.PIPE, timeout=180)
|
| 719 |
-
if os.path.exists(tmp_out) and os.path.getsize(tmp_out) > 0:
|
| 720 |
-
os.rename(tmp_out, dst)
|
| 721 |
-
return True
|
| 722 |
-
except Exception:
|
| 723 |
-
pass
|
| 724 |
-
return False
|
| 725 |
-
|
| 726 |
-
# HLS playlists need segment-wise download + AES decryption
|
| 727 |
-
if ".m3u8" in (url or "").lower():
|
| 728 |
-
return _download_hls_to_ts(url, dst, max_mb=max_mb)
|
| 729 |
-
if url.startswith("/api/ai-short/upload/"):
|
| 730 |
-
p = os.path.join(UPLOAD_DIR, os.path.basename(url.rstrip("/")))
|
| 731 |
-
if os.path.exists(p):
|
| 732 |
-
with open(p, "rb") as f, open(dst, "wb") as g:
|
| 733 |
-
g.write(f.read())
|
| 734 |
-
return True
|
| 735 |
-
return False
|
| 736 |
-
if url.startswith("/api/wall/img/"):
|
| 737 |
-
p = os.path.join(WALL_IMG_DIR, os.path.basename(url.rstrip("/")))
|
| 738 |
-
if os.path.exists(p):
|
| 739 |
-
with open(p, "rb") as f, open(dst, "wb") as g:
|
| 740 |
-
g.write(f.read())
|
| 741 |
-
return True
|
| 742 |
-
return False
|
| 743 |
-
with requests.get(url, headers=UA_HEADERS, timeout=60, stream=True,
|
| 744 |
-
allow_redirects=True) as r:
|
| 745 |
-
if r.status_code != 200:
|
| 746 |
-
return False
|
| 747 |
-
total = 0
|
| 748 |
-
with open(dst, "wb") as f:
|
| 749 |
-
for chunk in r.iter_content(chunk_size=256 * 1024):
|
| 750 |
-
if not chunk:
|
| 751 |
-
continue
|
| 752 |
-
f.write(chunk)
|
| 753 |
-
total += len(chunk)
|
| 754 |
-
if total > max_mb * 1024 * 1024:
|
| 755 |
-
return False
|
| 756 |
-
return os.path.exists(dst) and os.path.getsize(dst) > 900
|
| 757 |
-
except Exception:
|
| 758 |
-
return False
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
def _download_hls_to_ts(m3u8_url, dst, max_mb=400):
|
| 762 |
-
"""Download an HLS stream (incl. AES-128 encrypted) to a single .ts file,
|
| 763 |
-
decrypting segments in Python. Returns True on success.
|
| 764 |
-
|
| 765 |
-
Avoids relying on ffmpeg's network HLS demuxer (which can be fragile on
|
| 766 |
-
news CDNs like vnecdn/24h that require referer + AES-128 keys)."""
|
| 767 |
-
try:
|
| 768 |
-
from urllib.parse import urljoin, urlparse
|
| 769 |
-
try:
|
| 770 |
-
from Crypto.Cipher import AES
|
| 771 |
-
except Exception:
|
| 772 |
-
AES = None
|
| 773 |
-
HDRS = _resolve_media_url(m3u8_url)
|
| 774 |
-
r = requests.get(m3u8_url, headers=HDRS, timeout=30)
|
| 775 |
-
if r.status_code != 200:
|
| 776 |
-
return False
|
| 777 |
-
text = r.text
|
| 778 |
-
if "#EXTM3U" not in text:
|
| 779 |
-
# Not actually HLS — write raw bytes
|
| 780 |
-
with open(dst, "wb") as f:
|
| 781 |
-
f.write(r.content)
|
| 782 |
-
return os.path.exists(dst) and os.path.getsize(dst) > 900
|
| 783 |
-
# master playlist? pick a variant (prefer 720)
|
| 784 |
-
if "#EXT-X-STREAM-INF" in text:
|
| 785 |
-
ch = re.findall(r'#EXT-X-STREAM-INF[^\n]*\n([^\n]+)', text)
|
| 786 |
-
variants = [v.strip() for v in ch if v.strip()]
|
| 787 |
-
# pick one containing 720, else first
|
| 788 |
-
chosen = next((v for v in variants if "720" in v), variants[0] if variants else None)
|
| 789 |
-
if chosen:
|
| 790 |
-
if not chosen.startswith("http"):
|
| 791 |
-
chosen = urljoin(m3u8_url, chosen)
|
| 792 |
-
return _download_hls_to_ts(chosen, dst, max_mb=max_mb)
|
| 793 |
-
|
| 794 |
-
key = None
|
| 795 |
-
key_uri = None
|
| 796 |
-
km = re.search(r'#EXT-X-KEY:METHOD=AES-128,URI="([^"]+)"', text)
|
| 797 |
-
if km:
|
| 798 |
-
key_uri = km.group(1)
|
| 799 |
-
if not key_uri.startswith("http"):
|
| 800 |
-
key_uri = urljoin(m3u8_url, key_uri)
|
| 801 |
-
kr = requests.get(key_uri, headers=HDRS, timeout=20)
|
| 802 |
-
if kr.status_code == 200:
|
| 803 |
-
key = kr.content
|
| 804 |
-
use_aes = key is not None and AES is not None
|
| 805 |
-
|
| 806 |
-
# media sequence drives the AES-CBC IV
|
| 807 |
-
seq = 0
|
| 808 |
-
mm = re.search(r'#EXT-X-MEDIA-SEQUENCE:\s*(\d+)', text)
|
| 809 |
-
if mm:
|
| 810 |
-
seq = int(mm.group(1))
|
| 811 |
-
keyfm = re.search(r'#EXT-X-KEY:METHOD=AES-128,.*?IV=0x([0-9A-Fa-f]+)', text)
|
| 812 |
-
explicit_iv = keyfm.group(1) if keyfm else None
|
| 813 |
-
|
| 814 |
-
segs = [l.strip() for l in text.splitlines()
|
| 815 |
-
if l.strip() and not l.startswith("#")]
|
| 816 |
-
total = 0
|
| 817 |
-
with open(dst, "wb") as out:
|
| 818 |
-
for i, s in enumerate(segs):
|
| 819 |
-
if not s.startswith("http"):
|
| 820 |
-
s = urljoin(m3u8_url, s)
|
| 821 |
-
sr = requests.get(s, headers=HDRS, timeout=60)
|
| 822 |
-
if sr.status_code != 200:
|
| 823 |
-
return False
|
| 824 |
-
data = sr.content
|
| 825 |
-
sr.close()
|
| 826 |
-
if use_aes:
|
| 827 |
-
# AES-128-CBC decrypt the ENTIRE segment with one call so
|
| 828 |
-
# PKCS7 + CBC chaining stay correct (segment is self-contained).
|
| 829 |
-
if explicit_iv:
|
| 830 |
-
iv = bytes.fromhex(explicit_iv)
|
| 831 |
-
else:
|
| 832 |
-
iv = (seq + i).to_bytes(16, "big")
|
| 833 |
-
data = AES.new(key, AES.MODE_CBC, iv).decrypt(data)
|
| 834 |
-
out.write(data)
|
| 835 |
-
total += len(data)
|
| 836 |
-
if total > max_mb * 1024 * 1024:
|
| 837 |
-
return False
|
| 838 |
-
return os.path.exists(dst) and os.path.getsize(dst) > 900
|
| 839 |
-
except Exception:
|
| 840 |
-
return False
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
def _probe_duration(path, fallback=15.0):
|
| 844 |
-
try:
|
| 845 |
-
pr = subprocess.run(
|
| 846 |
-
["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
| 847 |
-
"-of", "default=noprint_wrappers=1:no_key=1", path],
|
| 848 |
-
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30)
|
| 849 |
-
return max(1.0, float((pr.stdout or b"").decode().strip() or fallback))
|
| 850 |
-
except Exception:
|
| 851 |
-
return fallback
|
| 852 |
-
|
| 853 |
-
|
| 854 |
-
def _probe_has_audio(path):
|
| 855 |
-
try:
|
| 856 |
-
pr = subprocess.run(
|
| 857 |
-
["ffprobe", "-v", "error", "-select_streams", "a", "-show_entries",
|
| 858 |
-
"stream=index", "-of", "csv=p=0", path],
|
| 859 |
-
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30)
|
| 860 |
-
return bool((pr.stdout or b"").decode().strip())
|
| 861 |
-
except Exception:
|
| 862 |
-
return False
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
def _first_frame(video_path, thumb_path, t=0.5):
|
| 866 |
-
"""Extract a thumbnail frame from a video at time t seconds.
|
| 867 |
-
Returns the served URL path or empty string."""
|
| 868 |
-
try:
|
| 869 |
-
os.makedirs(os.path.dirname(thumb_path), exist_ok=True)
|
| 870 |
-
subprocess.run(
|
| 871 |
-
["ffmpeg", "-y", "-ss", str(t), "-i", video_path,
|
| 872 |
-
"-frames:v", "1", "-q:v", "3", thumb_path],
|
| 873 |
-
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=60)
|
| 874 |
-
if os.path.exists(thumb_path) and os.path.getsize(thumb_path) > 500:
|
| 875 |
-
return f"/api/wall/img/{os.path.basename(thumb_path)}"
|
| 876 |
-
except Exception:
|
| 877 |
-
pass
|
| 878 |
-
return ""
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
# =========================================================================
|
| 882 |
-
# Image helpers
|
| 883 |
-
# =========================================================================
|
| 884 |
-
def _wrap_text_px(draw, text, font, max_width, max_lines):
|
| 885 |
-
words = _clean(text).split()
|
| 886 |
-
lines, cur = [], ""
|
| 887 |
-
for w in words:
|
| 888 |
-
test = (cur + " " + w).strip()
|
| 889 |
-
try:
|
| 890 |
-
width = draw.textbbox((0, 0), test, font=font)[2]
|
| 891 |
-
except Exception:
|
| 892 |
-
width = len(test) * 20
|
| 893 |
-
if width <= max_width:
|
| 894 |
-
cur = test
|
| 895 |
-
else:
|
| 896 |
-
if cur:
|
| 897 |
-
lines.append(cur)
|
| 898 |
-
cur = w
|
| 899 |
-
if len(lines) >= max_lines:
|
| 900 |
-
break
|
| 901 |
-
if cur and len(lines) < max_lines:
|
| 902 |
-
lines.append(cur)
|
| 903 |
-
return lines
|
| 904 |
-
|
| 905 |
-
|
| 906 |
-
def _cover_resize(img, w, h):
|
| 907 |
-
"""Center-crop + resize to exactly (w, h)."""
|
| 908 |
-
rw, rh = w / h, img.width / img.height
|
| 909 |
-
if rw > rh: # image too tall -> crop bottom/top
|
| 910 |
-
nh = int(img.width / rw)
|
| 911 |
-
top = max(0, (img.height - nh) // 2)
|
| 912 |
-
img = img.crop((0, top, img.width, top + nh))
|
| 913 |
-
else: # image too wide -> crop left/right
|
| 914 |
-
nw = int(img.height * rw)
|
| 915 |
-
left = max(0, (img.width - nw) // 2)
|
| 916 |
-
img = img.crop((left, 0, left + nw, img.height))
|
| 917 |
-
return img.resize((w, h))
|
| 918 |
-
|
| 919 |
-
|
| 920 |
-
def _frame_from_image(image_bytes, out_path, seg_idx=0, seg_total=1):
|
| 921 |
-
"""Full-bleed designed image background + tiny progress chip. NO text."""
|
| 922 |
-
W, H = 1080, 1920
|
| 923 |
-
bg = Image.new("RGB", (W, H), (10, 10, 10))
|
| 924 |
-
try:
|
| 925 |
-
im = Image.open(io_bytes(image_bytes)).convert("RGB")
|
| 926 |
-
bg = _cover_resize(im, W, H)
|
| 927 |
-
except Exception:
|
| 928 |
-
pass
|
| 929 |
-
draw = ImageDraw.Draw(bg)
|
| 930 |
-
try:
|
| 931 |
-
font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 26)
|
| 932 |
-
except Exception:
|
| 933 |
-
font_small = None
|
| 934 |
-
if seg_total > 1:
|
| 935 |
-
chip = f"{seg_idx + 1}/{seg_total}"
|
| 936 |
-
try:
|
| 937 |
-
tw = draw.textbbox((0, 0), chip, font=font_small)[2]
|
| 938 |
-
except Exception:
|
| 939 |
-
tw = len(chip) * 14
|
| 940 |
-
draw.rounded_rectangle((W - 40 - tw - 24, 34, W - 22, 74), radius=18, fill=(0, 0, 0))
|
| 941 |
-
draw.text((W - 40 - tw - 10, 40), chip, fill=(255, 255, 255), font=font_small)
|
| 942 |
-
bg.save(out_path, quality=92)
|
| 943 |
-
|
| 944 |
-
|
| 945 |
-
def _frame_with_text(image_bytes, out_path, segment, title, seg_idx=0, seg_total=1):
|
| 946 |
-
"""Designed image bg + centered key point + footer title (default design)."""
|
| 947 |
-
W, H = 1080, 1920
|
| 948 |
-
bg = Image.new("RGB", (W, H), (10, 10, 10))
|
| 949 |
-
try:
|
| 950 |
-
im = Image.open(io_bytes(image_bytes)).convert("RGB")
|
| 951 |
-
cover = _cover_resize(im, W, H)
|
| 952 |
-
bg = Image.blend(cover, Image.new("RGB", (W, H), (0, 0, 0)), 0.45)
|
| 953 |
-
except Exception:
|
| 954 |
-
pass
|
| 955 |
-
draw = ImageDraw.Draw(bg)
|
| 956 |
-
try:
|
| 957 |
-
font_brand = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 34)
|
| 958 |
-
font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 28)
|
| 959 |
-
font_seg = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 56)
|
| 960 |
-
font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 32)
|
| 961 |
-
except Exception:
|
| 962 |
-
font_brand = font_small = font_seg = font_title = None
|
| 963 |
-
draw.rectangle((0, 620, W, H), fill=(12, 12, 12))
|
| 964 |
-
y = 700
|
| 965 |
-
dot_x, dot_y = 48, 762
|
| 966 |
-
for i in range(max(1, seg_total)):
|
| 967 |
-
fill = (92, 184, 122) if i == seg_idx else (70, 70, 70)
|
| 968 |
-
draw.rounded_rectangle((dot_x + i * 38, dot_y, dot_x + i * 38 + 24, dot_y + 10), radius=5, fill=fill)
|
| 969 |
-
draw.text((48, 800), "VNEWS AI SHORT", fill=(110, 231, 143), font=font_brand)
|
| 970 |
-
draw.rounded_rectangle((48, 854, 260, 900), radius=20, fill=(28, 70, 45))
|
| 971 |
-
draw.text((66, 862), f"Đoạn {seg_idx + 1}/{max(1, seg_total)}", fill=(235, 235, 235), font=font_small)
|
| 972 |
-
y = 960
|
| 973 |
-
maxw = W - 96
|
| 974 |
-
for ln in _wrap_text_px(draw, segment, font_seg, maxw, 16):
|
| 975 |
-
draw.text((48, y), ln, fill=(255, 255, 255), font=font_seg)
|
| 976 |
-
y += 72
|
| 977 |
-
if y > 1560:
|
| 978 |
-
break
|
| 979 |
-
y2 = 1700
|
| 980 |
-
draw.line((48, y2 - 24, W - 48, y2 - 24), fill=(70, 70, 70), width=2)
|
| 981 |
-
for ln in _wrap_text_px(draw, title, font_title, maxw, 3):
|
| 982 |
-
draw.text((48, y2), ln, fill=(220, 220, 220), font=font_title)
|
| 983 |
-
y2 += 44
|
| 984 |
-
bg.save(out_path, quality=92)
|
| 985 |
-
|
| 986 |
-
|
| 987 |
-
def io_bytes(b):
|
| 988 |
-
import io
|
| 989 |
-
return io.BytesIO(b)
|
| 990 |
-
|
| 991 |
-
|
| 992 |
-
def _tts_one(text, voice, speed, out_path):
|
| 993 |
-
"""Generate one TTS segment (edge-tts with gTTS fallback), speed-adjusted.
|
| 994 |
-
Returns the final file path or None on failure."""
|
| 995 |
-
edge_voice = {
|
| 996 |
-
"vi-vn-hoaimyneural": "vi-VN-HoaiMyNeural", "vi-vn-namminhneural": "vi-VN-NamMinhNeural",
|
| 997 |
-
"hoaimy": "vi-VN-HoaiMyNeural", "namminh": "vi-VN-NamMinhNeural", "nam": "vi-VN-NamMinhNeural",
|
| 998 |
-
"male": "vi-VN-NamMinhNeural", "nu": "vi-VN-HoaiMyNeural", "female": "vi-VN-HoaiMyNeural",
|
| 999 |
-
"mien-nam": "vi-VN-HoaiMyNeural",
|
| 1000 |
-
"en-us-andrewmultilingualneural": "en-US-AndrewMultilingualNeural",
|
| 1001 |
-
"en-au-williammultilingualneural": "en-AU-WilliamMultilingualNeural",
|
| 1002 |
-
"andrew": "en-US-AndrewMultilingualNeural", "en_andrew": "en-US-AndrewMultilingualNeural",
|
| 1003 |
-
"jenny": "en-US-AndrewMultilingualNeural", "en_jenny": "en-US-AndrewMultilingualNeural",
|
| 1004 |
-
"pt-br-thalitamultilingualneural": "pt-BR-ThalitaMultilingualNeural",
|
| 1005 |
-
"thalita": "pt-BR-ThalitaMultilingualNeural", "pt": "pt-BR-ThalitaMultilingualNeural",
|
| 1006 |
-
"fr-fr-viviennemultilingualneural": "fr-FR-VivienneMultilingualNeural",
|
| 1007 |
-
"fr-fr-remymultilingualneural": "fr-FR-RemyMultilingualNeural",
|
| 1008 |
-
"denise": "fr-FR-VivienneMultilingualNeural", "fr": "fr-FR-VivienneMultilingualNeural",
|
| 1009 |
-
"de-de-seraphinamultilingualneural": "de-DE-SeraphinaMultilingualNeural",
|
| 1010 |
-
"de-de-florianmultilingualneural": "de-DE-FlorianMultilingualNeural",
|
| 1011 |
-
"katja": "de-DE-SeraphinaMultilingualNeural", "de": "de-DE-SeraphinaMultilingualNeural",
|
| 1012 |
-
"ko-kr-hyusumultilingualneural": "ko-KR-HyunsuMultilingualNeural",
|
| 1013 |
-
"ko-kr-hyunsuneural": "ko-KR-HyunsuMultilingualNeural", "sunhee": "ko-KR-HyunsuMultilingualNeural",
|
| 1014 |
-
"ko": "ko-KR-HyunsuMultilingualNeural",
|
| 1015 |
-
"it-it-giuseppemultilingualneural": "it-IT-GiuseppeMultilingualNeural",
|
| 1016 |
-
"ela": "en-US-AndrewMultilingualNeural", "es": "en-US-AndrewMultilingualNeural",
|
| 1017 |
-
}.get(voice.lower(), voice)
|
| 1018 |
-
aud = out_path
|
| 1019 |
-
aud_fast = re.sub(r"\.mp3$", "_fast.mp3", out_path)
|
| 1020 |
-
try:
|
| 1021 |
-
subprocess.run(
|
| 1022 |
-
["python", "-m", "edge_tts", "--voice", edge_voice, "--text", text,
|
| 1023 |
-
"--write-media", aud],
|
| 1024 |
-
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
| 1025 |
-
except Exception:
|
| 1026 |
-
aud = None
|
| 1027 |
-
try:
|
| 1028 |
-
from gtts import gTTS
|
| 1029 |
-
tld = "com.vn" if voice.lower() in ("nu", "female", "mien-nam", "hoaimy") else "com"
|
| 1030 |
-
try:
|
| 1031 |
-
gTTS(text, lang="vi", tld=tld, slow=False).save(aud)
|
| 1032 |
-
except TypeError:
|
| 1033 |
-
gTTS(text, lang="vi", slow=False).save(aud)
|
| 1034 |
-
except Exception:
|
| 1035 |
-
pass
|
| 1036 |
-
if aud and os.path.exists(aud) and os.path.getsize(aud) > 900:
|
| 1037 |
-
try:
|
| 1038 |
-
subprocess.run(["ffmpeg", "-y", "-i", aud, "-filter:a", f"atempo={speed}", "-vn", aud_fast],
|
| 1039 |
-
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
|
| 1040 |
-
if os.path.exists(aud_fast) and os.path.getsize(aud_fast) > 900:
|
| 1041 |
-
return aud_fast
|
| 1042 |
-
except Exception:
|
| 1043 |
-
pass
|
| 1044 |
-
return aud
|
| 1045 |
-
return None
|
| 1046 |
-
|
| 1047 |
-
|
| 1048 |
-
def _concat_audio(paths, out_path, pad=0.3):
|
| 1049 |
-
"""Concatenate audio files into one track (m4a/aac)."""
|
| 1050 |
-
if not paths:
|
| 1051 |
-
return None
|
| 1052 |
-
lst = os.path.join(os.path.dirname(out_path), "_alist.txt")
|
| 1053 |
-
with open(lst, "w", encoding="utf-8") as f:
|
| 1054 |
-
for p in paths:
|
| 1055 |
-
f.write("file '" + p.replace("'", "'\\''") + "'\n")
|
| 1056 |
-
subprocess.run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", lst,
|
| 1057 |
-
"-c:a", "aac", "-b:a", "128k", out_path],
|
| 1058 |
-
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=240)
|
| 1059 |
-
return out_path if os.path.exists(out_path) and os.path.getsize(out_path) > 900 else None
|
| 1060 |
-
|
| 1061 |
-
|
| 1062 |
-
def _assemble_video_short(work, video_path, video_dur, img_paths, segs,
|
| 1063 |
-
reuse_audio_path, video_audio_path, voice, speed,
|
| 1064 |
-
music_path, out_mp4, fallback_title):
|
| 1065 |
-
"""New video-first short assembly.
|
| 1066 |
-
|
| 1067 |
-
Timeline: scraped/uploaded video plays first (up to min(video_dur, voice)),
|
| 1068 |
-
then the designed/selected images fill the remainder of the voice duration
|
| 1069 |
-
(or until the video covers the whole short). The voice track is
|
| 1070 |
-
continuous TTS narration of the segments (or reused previous short audio).
|
| 1071 |
-
|
| 1072 |
-
Returns (out_mp4, total_dur) or raises on failure.
|
| 1073 |
-
"""
|
| 1074 |
-
# ---------- 1. narration track (continuous) ----------
|
| 1075 |
-
narr = None
|
| 1076 |
-
if reuse_audio_path and os.path.exists(reuse_audio_path):
|
| 1077 |
-
narr = os.path.join(work, "narr.m4a")
|
| 1078 |
-
subprocess.run(["ffmpeg", "-y", "-i", reuse_audio_path,
|
| 1079 |
-
"-c:a", "aac", "-b:a", "128k", narr],
|
| 1080 |
-
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
|
| 1081 |
-
narr = narr if os.path.exists(narr) and os.path.getsize(narr) > 900 else None
|
| 1082 |
-
else:
|
| 1083 |
-
voice_parts = []
|
| 1084 |
-
for i, seg in enumerate(segs):
|
| 1085 |
-
vf = _tts_one(seg, voice, speed, os.path.join(work, f"vf_{i:02d}.mp3"))
|
| 1086 |
-
if vf:
|
| 1087 |
-
voice_parts.append(vf)
|
| 1088 |
-
if voice_parts:
|
| 1089 |
-
narr = _concat_audio(voice_parts, os.path.join(work, "narr.m4a"))
|
| 1090 |
-
if narr and os.path.exists(narr):
|
| 1091 |
-
# small tail silence so the last word isn't cut
|
| 1092 |
-
tail = os.path.join(work, "narr_tail.m4a")
|
| 1093 |
-
subprocess.run(["ffmpeg", "-y", "-i", narr, "-af", "apad=pad_dur=0.6",
|
| 1094 |
-
"-c:a", "aac", "-b:a", "128k", tail],
|
| 1095 |
-
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
| 1096 |
-
if os.path.exists(tail) and os.path.getsize(tail) > 900:
|
| 1097 |
-
narr = tail
|
| 1098 |
-
|
| 1099 |
-
narr_dur = _probe_duration(narr, fallback=0.0) if narr else 0.0
|
| 1100 |
-
total_dur = narr_dur
|
| 1101 |
-
if total_dur < 1.0:
|
| 1102 |
-
# no voice -> short is driven by video length (or music overlays later)
|
| 1103 |
-
total_dur = max(1.0, video_dur)
|
| 1104 |
-
|
| 1105 |
-
# ---------- 2. split timeline ----------
|
| 1106 |
-
# When video is longer than narration (e.g. scraped YouTube video in HOT mode),
|
| 1107 |
-
# let the video play at its full length and fill remaining time with images.
|
| 1108 |
-
# When narration is longer, video plays first then images fill the remainder.
|
| 1109 |
-
if video_dur > 0 and narr_dur < video_dur:
|
| 1110 |
-
# Video is longer than narration → video plays first at full length,
|
| 1111 |
-
# then images fill any remaining narration time (usually minimal).
|
| 1112 |
-
use_video = video_dur
|
| 1113 |
-
total_dur = max(use_video, narr_dur)
|
| 1114 |
-
img_dur = total_dur - use_video
|
| 1115 |
-
else:
|
| 1116 |
-
use_video = max(0.0, min(video_dur, total_dur))
|
| 1117 |
-
img_dur = total_dur - use_video
|
| 1118 |
-
imgs = img_paths or []
|
| 1119 |
-
# image durations: split remaining time across available images (min 1.2s,
|
| 1120 |
-
# repeat cyclically if the voice is longer than all images)
|
| 1121 |
-
img_plan = []
|
| 1122 |
-
if img_dur > 0.5 and imgs:
|
| 1123 |
-
per = max(1.2, min(8.0, img_dur / len(imgs)))
|
| 1124 |
-
remaining = img_dur
|
| 1125 |
-
i = 0
|
| 1126 |
-
while remaining > 0.3:
|
| 1127 |
-
img_plan.append((imgs[i % len(imgs)], min(per, remaining)))
|
| 1128 |
-
remaining -= per
|
| 1129 |
-
i += 1
|
| 1130 |
-
|
| 1131 |
-
# ---------- 3. build ffmpeg command ----------
|
| 1132 |
-
cmd = ["ffmpeg", "-y"]
|
| 1133 |
-
# video input first (index 0)
|
| 1134 |
-
cmd += ["-i", video_path]
|
| 1135 |
-
# image loop inputs
|
| 1136 |
-
for idx, (_, dur) in enumerate(img_plan):
|
| 1137 |
-
cmd += ["-framerate", "25", "-loop", "1", "-t", str(max(0.4, dur)), "-i", img_plan[idx][0]]
|
| 1138 |
-
# narration + optional video audio inputs
|
| 1139 |
-
audio_inputs = []
|
| 1140 |
-
narr_idx = None
|
| 1141 |
-
va_idx = None
|
| 1142 |
-
if narr:
|
| 1143 |
-
narr_idx = 1 + len(img_plan)
|
| 1144 |
-
cmd += ["-i", narr]
|
| 1145 |
-
audio_inputs.append(narr_idx)
|
| 1146 |
-
if video_audio_path and os.path.exists(video_audio_path):
|
| 1147 |
-
va_idx = 1 + len(img_plan) + (1 if narr_idx is not None else 0)
|
| 1148 |
-
cmd += ["-i", video_audio_path]
|
| 1149 |
-
audio_inputs.append(va_idx)
|
| 1150 |
-
|
| 1151 |
-
# filter graph
|
| 1152 |
-
fc = []
|
| 1153 |
-
# video inputs: [0:v] = scraped video, [k:v] = images
|
| 1154 |
-
vlabels = []
|
| 1155 |
-
# video: trim to use_video, scale/crop to 1080x1920 portrait, normalize fps
|
| 1156 |
-
fc.append(
|
| 1157 |
-
f"[0:v]trim=duration={use_video:.3f},setpts=PTS-STARTPTS,"
|
| 1158 |
-
f"scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,"
|
| 1159 |
-
f"fps=25,format=yuv420p[v0]"
|
| 1160 |
-
)
|
| 1161 |
-
vlabels.append("[v0]")
|
| 1162 |
-
for idx, (_, dur) in enumerate(img_plan):
|
| 1163 |
-
lab = f"v{idx + 1}"
|
| 1164 |
-
fc.append(
|
| 1165 |
-
f"[{1 + idx}:v]trim=duration={dur:.3f},setpts=PTS-STARTPTS,"
|
| 1166 |
-
f"scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,"
|
| 1167 |
-
f"fps=25,format=yuv420p[{lab}]"
|
| 1168 |
-
)
|
| 1169 |
-
vlabels.append(f"[{lab}]")
|
| 1170 |
-
if len(vlabels) > 1:
|
| 1171 |
-
concat_in = "".join(vlabels)
|
| 1172 |
-
fc.append(f"{concat_in}concat=n={len(vlabels)}:v=1:a=0[vout]")
|
| 1173 |
-
vmap = "[vout]"
|
| 1174 |
-
else:
|
| 1175 |
-
vmap = "[v0]"
|
| 1176 |
-
|
| 1177 |
-
# audio graph
|
| 1178 |
-
if audio_inputs:
|
| 1179 |
-
amix_sources = []
|
| 1180 |
-
if narr_idx is not None:
|
| 1181 |
-
fc.append(f"[{narr_idx}:a]aresample=44100,atrim=duration={total_dur:.3f},asetpts=PTS-STARTPTS[n]")
|
| 1182 |
-
amix_sources.append("[n]")
|
| 1183 |
-
if va_idx is not None:
|
| 1184 |
-
fc.append(f"[{va_idx}:a]aresample=44100,volume=0.85,atrim=duration={use_video:.3f},asetpts=PTS-STARTPTS[xva]")
|
| 1185 |
-
amix_sources.append("[xva]")
|
| 1186 |
-
fc.append(f"{''.join(amix_sources)}amix=inputs={len(amix_sources)}:duration=first:dropout_transition=0:normalize=0[aout]")
|
| 1187 |
-
amap = "[aout]"
|
| 1188 |
-
else:
|
| 1189 |
-
amap = None
|
| 1190 |
-
|
| 1191 |
-
fc_str = ";".join(fc)
|
| 1192 |
-
cmd += ["-filter_complex", fc_str]
|
| 1193 |
-
cmd += ["-map", vmap]
|
| 1194 |
-
if amap:
|
| 1195 |
-
cmd += ["-map", amap, "-c:a", "aac", "-b:a", "160k"]
|
| 1196 |
-
else:
|
| 1197 |
-
cmd += ["-an"]
|
| 1198 |
-
cmd += ["-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
|
| 1199 |
-
"-pix_fmt", "yuv420p", "-movflags", "+faststart",
|
| 1200 |
-
"-t", f"{total_dur:.3f}", "-shortest", out_mp4]
|
| 1201 |
-
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=600)
|
| 1202 |
-
return out_mp4, total_dur
|
| 1203 |
-
|
| 1204 |
-
|
| 1205 |
-
def _segments_from_post(post, max_segments=25):
|
| 1206 |
-
raw = _clean(post.get("text") or post.get("title") or "")
|
| 1207 |
-
raw = re.sub(r"^Bản tin AI viết lại:\s*", "", raw, flags=re.I)
|
| 1208 |
-
raw = re.sub(r"Nguồn tham khảo:.*$", "", raw, flags=re.I | re.S).strip()
|
| 1209 |
-
lines = []
|
| 1210 |
-
for ln in raw.splitlines():
|
| 1211 |
-
ln = _clean(re.sub(r"^[•\-\*\d\.\)\s]+", "", ln))
|
| 1212 |
-
if not ln:
|
| 1213 |
-
continue
|
| 1214 |
-
low = ln.lower()
|
| 1215 |
-
if low.startswith(("điểm chính", "tiêu đề", "sapo", "nguồn tham khảo")):
|
| 1216 |
-
continue
|
| 1217 |
-
if len(ln) >= 18:
|
| 1218 |
-
lines.append(ln)
|
| 1219 |
-
if len(lines) < 3:
|
| 1220 |
-
lines = []
|
| 1221 |
-
for s in re.split(r"(?<=[\.\!\?])\s+", raw):
|
| 1222 |
-
s = _clean(s)
|
| 1223 |
-
if len(s) >= 25:
|
| 1224 |
-
lines.append(s)
|
| 1225 |
-
seen = set()
|
| 1226 |
-
out = []
|
| 1227 |
-
for u in lines[: max_segments * 3]:
|
| 1228 |
-
nu = re.sub(r"[^\wÀ-ỹ\s]", "", u.lower())
|
| 1229 |
-
if nu in seen:
|
| 1230 |
-
continue
|
| 1231 |
-
seen.add(nu)
|
| 1232 |
-
out.append(u)
|
| 1233 |
-
if len(out) >= max_segments:
|
| 1234 |
-
break
|
| 1235 |
-
return out[:max_segments] if out else [post.get("title", "Bản tin VNEWS")]
|
| 1236 |
-
|
| 1237 |
-
|
| 1238 |
-
def _find_previous_short(post_id):
|
| 1239 |
-
"""Find the most recent short mp4 for this post (prior generation)."""
|
| 1240 |
-
prefix = _safe_name(post_id)
|
| 1241 |
-
best, best_mtime = None, 0
|
| 1242 |
-
try:
|
| 1243 |
-
for f in os.listdir(SHORTS_DIR):
|
| 1244 |
-
# current naming: <post_id>v2_<...>.mp4
|
| 1245 |
-
if f.startswith(prefix + "v2_") and f.endswith(".mp4"):
|
| 1246 |
-
p = os.path.join(SHORTS_DIR, f)
|
| 1247 |
-
mt = os.path.getmtime(p)
|
| 1248 |
-
if mt > best_mtime:
|
| 1249 |
-
best, best_mtime = p, mt
|
| 1250 |
-
except Exception:
|
| 1251 |
-
pass
|
| 1252 |
-
# also legacy patched files: <post_id>_<voice>_..._scenes_nosub.mp4
|
| 1253 |
-
if not best:
|
| 1254 |
-
try:
|
| 1255 |
-
for f in os.listdir(SHORTS_DIR):
|
| 1256 |
-
if f.startswith(prefix + "_") and f.endswith("_scenes_nosub.mp4"):
|
| 1257 |
-
p = os.path.join(SHORTS_DIR, f)
|
| 1258 |
-
mt = os.path.getmtime(p)
|
| 1259 |
-
if mt > best_mtime:
|
| 1260 |
-
best, best_mtime = p, mt
|
| 1261 |
-
except Exception:
|
| 1262 |
-
pass
|
| 1263 |
-
return best
|
| 1264 |
-
|
| 1265 |
-
|
| 1266 |
-
# =========================================================================
|
| 1267 |
-
# Endpoints
|
| 1268 |
-
# =========================================================================
|
| 1269 |
-
@app.get("/api/ai-short/music")
|
| 1270 |
-
def api_short_music_list():
|
| 1271 |
-
return JSONResponse({"music": MUSIC_LIST})
|
| 1272 |
-
|
| 1273 |
-
|
| 1274 |
-
@app.get("/api/ai-short/music/stream")
|
| 1275 |
-
def api_short_music_stream(url: str = Query(...)):
|
| 1276 |
-
"""Proxy một track nhạc nền với CORS headers để trình duyệt phát trước khi chọn."""
|
| 1277 |
-
try:
|
| 1278 |
-
r = requests.get(url, headers=UA_HEADERS, timeout=30,
|
| 1279 |
-
stream=True, allow_redirects=True)
|
| 1280 |
-
if r.status_code != 200:
|
| 1281 |
-
return Response(status_code=502)
|
| 1282 |
-
return Response(
|
| 1283 |
-
content=r.content,
|
| 1284 |
-
media_type="audio/mpeg",
|
| 1285 |
-
headers={
|
| 1286 |
-
"Cache-Control": "public, max-age=86400",
|
| 1287 |
-
"Access-Control-Allow-Origin": "*",
|
| 1288 |
-
"Accept-Ranges": "bytes",
|
| 1289 |
-
"Content-Disposition": "inline",
|
| 1290 |
-
},
|
| 1291 |
-
)
|
| 1292 |
-
except Exception:
|
| 1293 |
-
return Response(status_code=502)
|
| 1294 |
-
|
| 1295 |
-
|
| 1296 |
-
@app.post("/api/ai-short/upload")
|
| 1297 |
-
async def api_short_upload(file: UploadFile = File(...)):
|
| 1298 |
-
"""Upload audio/video/image used as short background."""
|
| 1299 |
-
try:
|
| 1300 |
-
if not file or not hasattr(file, "filename"):
|
| 1301 |
-
return JSONResponse({"error": "Thiếu file"}, status_code=400)
|
| 1302 |
-
fname = file.filename or ""
|
| 1303 |
-
ext = os.path.splitext(fname)[1].lower()
|
| 1304 |
-
if ext not in (".mp3", ".wav", ".m4a", ".aac", ".ogg",
|
| 1305 |
-
".mp4", ".webm", ".mov",
|
| 1306 |
-
".jpg", ".jpeg", ".png", ".webp"):
|
| 1307 |
-
return JSONResponse({"error": "Định dạng file không hỗ trợ"}, status_code=400)
|
| 1308 |
-
content = await file.read()
|
| 1309 |
-
if not content:
|
| 1310 |
-
return JSONResponse({"error": "File rỗng"}, status_code=400)
|
| 1311 |
-
if len(content) > 60 * 1024 * 1024:
|
| 1312 |
-
return JSONResponse({"error": "File quá lớn (>60MB)"}, status_code=400)
|
| 1313 |
-
fuid = str(uuid.uuid4())[:12]
|
| 1314 |
-
out_name = f"su_{fuid}{ext}"
|
| 1315 |
-
with open(os.path.join(UPLOAD_DIR, out_name), "wb") as f:
|
| 1316 |
-
f.write(content)
|
| 1317 |
-
kind = ("audio" if ext in (".mp3", ".wav", ".m4a", ".aac", ".ogg")
|
| 1318 |
-
else ("video" if ext in (".mp4", ".webm", ".mov") else "image"))
|
| 1319 |
-
return JSONResponse({"ok": True, "url": f"/api/ai-short/upload/{out_name}", "kind": kind})
|
| 1320 |
-
except Exception as e:
|
| 1321 |
-
return JSONResponse({"error": f"Lỗi upload: {str(e)[:150]}"}, status_code=500)
|
| 1322 |
-
|
| 1323 |
-
|
| 1324 |
-
@app.get("/api/ai-short/upload/{fname}")
|
| 1325 |
-
def api_short_upload_file(fname: str):
|
| 1326 |
-
if ".." in fname or "/" in fname:
|
| 1327 |
-
return JSONResponse({"error": "bad name"}, status_code=400)
|
| 1328 |
-
p = os.path.join(UPLOAD_DIR, fname)
|
| 1329 |
-
if not os.path.exists(p):
|
| 1330 |
-
return JSONResponse({"error": "not found"}, status_code=404)
|
| 1331 |
-
ext = os.path.splitext(fname)[1].lower()
|
| 1332 |
-
mt = {
|
| 1333 |
-
".mp3": "audio/mpeg", ".wav": "audio/wav", ".m4a": "audio/mp4",
|
| 1334 |
-
".aac": "audio/aac", ".ogg": "audio/ogg",
|
| 1335 |
-
".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime",
|
| 1336 |
-
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
|
| 1337 |
-
".webp": "image/webp",
|
| 1338 |
-
}.get(ext, "application/octet-stream")
|
| 1339 |
-
return FileResponse(p, media_type=mt)
|
| 1340 |
-
|
| 1341 |
-
|
| 1342 |
-
@app.get("/api/ai-short/scrape")
|
| 1343 |
-
def api_scrape_video(url: str = Query(...)):
|
| 1344 |
-
"""Scrap video info from any link (YouTube/TikTok/news...).
|
| 1345 |
-
|
| 1346 |
-
Returns direct media URL + duration + thumbnail so the frontend can
|
| 1347 |
-
show a preview before generating the short.
|
| 1348 |
-
"""
|
| 1349 |
-
if not url or not re.match(r"^https?://", url):
|
| 1350 |
-
return JSONResponse({"error": "Link không hợp lệ"}, status_code=400)
|
| 1351 |
-
info = _scrape_video_info(url.strip())
|
| 1352 |
-
if not info.get("ok"):
|
| 1353 |
-
return JSONResponse({"error": info.get("error", "Không lấy được video")}, status_code=422)
|
| 1354 |
-
# strip cache-internal key before returning to the client
|
| 1355 |
-
info = {k: v for k, v in info.items() if k != "_t"}
|
| 1356 |
-
return JSONResponse(info)
|
| 1357 |
-
|
| 1358 |
-
|
| 1359 |
-
@app.get("/api/ai-short/scrape/preview")
|
| 1360 |
-
def api_scrape_video_preview(url: str = Query(...), request: Request = None):
|
| 1361 |
-
"""Stream the scraped video (or its direct mp4) so the browser can play
|
| 1362 |
-
it inside the short creator modal. Range-capable proxy.
|
| 1363 |
-
"""
|
| 1364 |
-
if not url or not re.match(r"^https?://", url):
|
| 1365 |
-
return JSONResponse({"error": "Link không hợp lệ"}, status_code=400)
|
| 1366 |
-
req_headers = dict(UA_HEADERS)
|
| 1367 |
-
if request and request.headers.get("range"):
|
| 1368 |
-
req_headers["Range"] = request.headers["range"]
|
| 1369 |
-
try:
|
| 1370 |
-
r = requests.get(url, headers=req_headers, timeout=30, stream=True,
|
| 1371 |
-
allow_redirects=True)
|
| 1372 |
-
except Exception:
|
| 1373 |
-
return JSONResponse({"error": "Không tải được video" }, status_code=502)
|
| 1374 |
-
if r.status_code >= 400:
|
| 1375 |
-
return JSONResponse({"error": "Lỗi nguồn video"}, status_code=502)
|
| 1376 |
-
resp_headers = {
|
| 1377 |
-
"Access-Control-Allow-Origin": "*",
|
| 1378 |
-
"Accept-Ranges": "bytes",
|
| 1379 |
-
"Content-Type": r.headers.get("Content-Type", "video/mp4"),
|
| 1380 |
-
}
|
| 1381 |
-
if "Content-Range" in r.headers:
|
| 1382 |
-
resp_headers["Content-Range"] = r.headers["Content-Range"]
|
| 1383 |
-
if "Content-Length" in r.headers:
|
| 1384 |
-
resp_headers["Content-Length"] = r.headers["Content-Length"]
|
| 1385 |
-
if "Cache-Control" in r.headers:
|
| 1386 |
-
resp_headers["Cache-Control"] = r.headers["Cache-Control"]
|
| 1387 |
-
else:
|
| 1388 |
-
resp_headers["Cache-Control"] = "public, max-age=3600"
|
| 1389 |
-
|
| 1390 |
-
def gen():
|
| 1391 |
-
try:
|
| 1392 |
-
yield from r.iter_content(chunk_size=256 * 1024)
|
| 1393 |
-
finally:
|
| 1394 |
-
r.close()
|
| 1395 |
-
|
| 1396 |
-
return StreamingResponse(gen(), status_code=r.status_code, headers=resp_headers)
|
| 1397 |
-
|
| 1398 |
-
|
| 1399 |
-
@app.get("/api/ai-short/scrape/hls")
|
| 1400 |
-
def api_scrape_video_hls(url: str = Query(...)):
|
| 1401 |
-
"""Proxy an HLS master/media playlist and rewrite segment + key URIs to
|
| 1402 |
-
same-origin proxy routes so the browser (hls.js) can play AES-128 HLS
|
| 1403 |
-
from news CDNs (vnexpress/24h) without CORS errors."""
|
| 1404 |
-
if not url or not re.match(r"^https?://", url):
|
| 1405 |
-
return JSONResponse({"error": "Link không hợp lệ"}, status_code=400)
|
| 1406 |
-
try:
|
| 1407 |
-
HDRS = _resolve_media_url(url)
|
| 1408 |
-
r = requests.get(url, headers=HDRS, timeout=25)
|
| 1409 |
-
if r.status_code != 200:
|
| 1410 |
-
return JSONResponse({"error": "Lỗi nguồn HLS"}, status_code=502)
|
| 1411 |
-
text = r.text
|
| 1412 |
-
if "#EXTM3U" not in text:
|
| 1413 |
-
# not a playlist; pass through as binary
|
| 1414 |
-
return Response(content=r.content,
|
| 1415 |
-
media_type=r.headers.get("Content-Type", "video/mp4"),
|
| 1416 |
-
headers={"Access-Control-Allow-Origin": "*"})
|
| 1417 |
-
from urllib.parse import urljoin, quote
|
| 1418 |
-
out_lines = []
|
| 1419 |
-
for line in text.splitlines():
|
| 1420 |
-
s = line.strip()
|
| 1421 |
-
if s.startswith("#"):
|
| 1422 |
-
# rewrite #EXT-X-KEY URI to same-origin key proxy
|
| 1423 |
-
if "#EXT-X-KEY" in s and "URI=" in s:
|
| 1424 |
-
m = re.search(r'URI="([^"]+)"', s)
|
| 1425 |
-
if m:
|
| 1426 |
-
ku = m.group(1)
|
| 1427 |
-
if not ku.startswith("http"):
|
| 1428 |
-
ku = urljoin(url, ku)
|
| 1429 |
-
s2 = s.replace(m.group(0), 'URI="/api/ai-short/scrape/key?url=' + urllib_quote(ku, safe="") + '"')
|
| 1430 |
-
out_lines.append(s2)
|
| 1431 |
-
continue
|
| 1432 |
-
out_lines.append(s)
|
| 1433 |
-
elif s.startswith("http"): # absolute segment URI
|
| 1434 |
-
out_lines.append("/api/ai-short/scrape/seg?url=" + urllib_quote(s, safe=""))
|
| 1435 |
-
elif s: # relative segment URI
|
| 1436 |
-
out_lines.append("/api/ai-short/scrape/seg?url=" + urllib_quote(urljoin(url, s), safe=""))
|
| 1437 |
-
return Response(content="\n".join(out_lines).encode("utf-8"),
|
| 1438 |
-
media_type="application/vnd.apple.mpegurl",
|
| 1439 |
-
headers={"Access-Control-Allow-Origin": "*",
|
| 1440 |
-
"Cache-Control": "public, max-age=300"})
|
| 1441 |
-
except Exception:
|
| 1442 |
-
return JSONResponse({"error": "Lỗi proxy HLS"}, status_code=502)
|
| 1443 |
-
|
| 1444 |
-
|
| 1445 |
-
@app.get("/api/ai-short/scrape/seg")
|
| 1446 |
-
def api_scrape_video_seg(url: str = Query(...)):
|
| 1447 |
-
"""Stream a single HLS segment (.ts) with proper referer headers."""
|
| 1448 |
-
if not url or not re.match(r"^https?://", url):
|
| 1449 |
-
return JSONResponse({"error": "Link không hợp lệ"}, status_code=400)
|
| 1450 |
-
try:
|
| 1451 |
-
HDRS = _resolve_media_url(url)
|
| 1452 |
-
r = requests.get(url, headers=HDRS, timeout=60, stream=True)
|
| 1453 |
-
if r.status_code != 200:
|
| 1454 |
-
return JSONResponse({"error": "Lỗi segment"}, status_code=502)
|
| 1455 |
-
return StreamingResponse(r.iter_content(chunk_size=256 * 1024),
|
| 1456 |
-
media_type="video/mp2t",
|
| 1457 |
-
headers={"Access-Control-Allow-Origin": "*",
|
| 1458 |
-
"Cache-Control": "public, max-age=3600"})
|
| 1459 |
-
except Exception:
|
| 1460 |
-
return JSONResponse({"error": "Lỗi segment"}, status_code=502)
|
| 1461 |
-
|
| 1462 |
-
|
| 1463 |
-
@app.get("/api/ai-short/scrape/key")
|
| 1464 |
-
def api_scrape_video_key(url: str = Query(...)):
|
| 1465 |
-
"""Proxy the AES-128 key URI for HLS playlists."""
|
| 1466 |
-
if not url or not re.match(r"^https?://", url):
|
| 1467 |
-
return JSONResponse({"error": "Link không hợp lệ"}, status_code=400)
|
| 1468 |
-
try:
|
| 1469 |
-
HDRS = _resolve_media_url(url)
|
| 1470 |
-
r = requests.get(url, headers=HDRS, timeout=20)
|
| 1471 |
-
if r.status_code != 200:
|
| 1472 |
-
return JSONResponse({"error": "Lỗi key"}, status_code=502)
|
| 1473 |
-
return Response(content=r.content, media_type="application/octet-stream",
|
| 1474 |
-
headers={"Access-Control-Allow-Origin": "*",
|
| 1475 |
-
"Cache-Control": "public, max-age=3600"})
|
| 1476 |
-
except Exception:
|
| 1477 |
-
return JSONResponse({"error": "Lỗi key"}, status_code=502)
|
| 1478 |
-
|
| 1479 |
-
|
| 1480 |
-
@app.post("/api/ai-short")
|
| 1481 |
-
async def api_short_generate(request: Request):
|
| 1482 |
-
"""Generate a Short AI video.
|
| 1483 |
-
|
| 1484 |
-
JSON body:
|
| 1485 |
-
post_id (str) wall post id (required)
|
| 1486 |
-
use_slides (bool) one scene per designed slide; image = designed slide
|
| 1487 |
-
image; NO text overlays; narration = audio of previous
|
| 1488 |
-
short if available, else TTS of slide text (audio only).
|
| 1489 |
-
images (list[str]) custom background image URLs (replaces default)
|
| 1490 |
-
audio_music (str) music id from MUSIC_LIST ('' -> none)
|
| 1491 |
-
audio_url (str) custom audio URL (uploaded /api/ai-short/upload/...)
|
| 1492 |
-
voice/emotion/speed — TTS controls
|
| 1493 |
-
"""
|
| 1494 |
-
try:
|
| 1495 |
-
body = await request.json()
|
| 1496 |
-
except Exception:
|
| 1497 |
-
body = {}
|
| 1498 |
-
post_id = _clean(str(body.get("post_id", "")))
|
| 1499 |
-
create_new = bool(body.get("create_new"))
|
| 1500 |
-
posts = _load_wall_posts()
|
| 1501 |
-
if not isinstance(posts, list):
|
| 1502 |
-
posts = []
|
| 1503 |
-
post = None
|
| 1504 |
-
if create_new:
|
| 1505 |
-
# "Thêm Short HOT" from the homepage: no source wall post. Create a
|
| 1506 |
-
# brand-new post now; the generated video is attached to it and it is
|
| 1507 |
-
# posted to the AI wall. Retention: if generation fails, the draft is
|
| 1508 |
-
# removed again so no empty post stays on the wall.
|
| 1509 |
-
import uuid as _uuid
|
| 1510 |
-
new_id = str(_uuid.uuid4())[:12]
|
| 1511 |
-
hot_title = _clean(str(body.get("title", "")))[:200]
|
| 1512 |
-
hot_text = _clean(str(body.get("text", "")))[:2000]
|
| 1513 |
-
hot_imgs = [str(x) for x in (body.get("images") or []) if str(x).strip()][:10]
|
| 1514 |
-
post = {
|
| 1515 |
-
"id": new_id,
|
| 1516 |
-
"title": hot_title or "Short HOT",
|
| 1517 |
-
"text": hot_text,
|
| 1518 |
-
"source": "hot_short",
|
| 1519 |
-
"kind": "hot_short",
|
| 1520 |
-
"video": None,
|
| 1521 |
-
"img": hot_imgs[0] if hot_imgs else None,
|
| 1522 |
-
"images": hot_imgs,
|
| 1523 |
-
"url": _clean(str(body.get("url", ""))),
|
| 1524 |
-
"voice": _clean(str(body.get("voice", ""))),
|
| 1525 |
-
"emotion": _clean(str(body.get("emotion", ""))),
|
| 1526 |
-
"language": _clean(str(body.get("language", "vi"))),
|
| 1527 |
-
"created": int(time.time()),
|
| 1528 |
-
"created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
|
| 1529 |
-
}
|
| 1530 |
-
posts.insert(0, post)
|
| 1531 |
-
post_id = new_id
|
| 1532 |
-
else:
|
| 1533 |
-
if not post_id:
|
| 1534 |
-
return JSONResponse({"error": "Thiếu post_id"}, status_code=400)
|
| 1535 |
-
post = next((p for p in posts if str(p.get("id")) == str(post_id)), None)
|
| 1536 |
-
if not post:
|
| 1537 |
-
return JSONResponse({"error": "Không tìm thấy bài viết"}, status_code=404)
|
| 1538 |
-
|
| 1539 |
-
use_slides = bool(body.get("use_slides"))
|
| 1540 |
-
reuse_audio = body.get("reuse_audio")
|
| 1541 |
-
if reuse_audio is None:
|
| 1542 |
-
# default: when recreating from slides, reuse the previous short's audio
|
| 1543 |
-
reuse_audio = use_slides
|
| 1544 |
-
custom_images = [str(x) for x in (body.get("images") or []) if str(x).strip()]
|
| 1545 |
-
fixed_image = _clean(str(body.get("fixed_image", ""))) # 1 ảnh cố định cho nhiều slide
|
| 1546 |
-
raw_indices = body.get("slide_indices") or []
|
| 1547 |
-
slide_indices = []
|
| 1548 |
-
for v in raw_indices:
|
| 1549 |
-
try:
|
| 1550 |
-
slide_indices.append(int(v))
|
| 1551 |
-
except Exception:
|
| 1552 |
-
pass
|
| 1553 |
-
video_url = _clean(str(body.get("video_url", "")))
|
| 1554 |
-
video_muted = bool(body.get("video_muted"))
|
| 1555 |
-
# oEmbed fallback for Short HOT: passed by the frontend when the scraped
|
| 1556 |
-
# link has metadata but the direct media download failed (e.g. TikTok/
|
| 1557 |
-
# Facebook/Instagram which block datacenter IPs). The short is then saved
|
| 1558 |
-
# as an embeddable slide (like the football video slides) with the
|
| 1559 |
-
# metadata auto-filled from oEmbed.
|
| 1560 |
-
embed_url = _clean(str(body.get("embed_url", "")))
|
| 1561 |
-
embed_title = _clean(str(body.get("embed_title", "")))[:250]
|
| 1562 |
-
embed_thumb = _clean(str(body.get("embed_thumb", "")))
|
| 1563 |
-
embed_desc = _clean(str(body.get("embed_desc", "")))[:5000]
|
| 1564 |
-
try:
|
| 1565 |
-
video_dur = float(body.get("video_dur")) if body.get("video_dur") is not None else None
|
| 1566 |
-
except Exception:
|
| 1567 |
-
video_dur = None
|
| 1568 |
-
scrape_mode = bool(body.get("scrape_mode"))
|
| 1569 |
-
audio_music = _clean(str(body.get("audio_music", "")))
|
| 1570 |
-
audio_url = _clean(str(body.get("audio_url", "")))
|
| 1571 |
-
voice = _clean(str(body.get("voice", "vi-VN-HoaiMyNeural")))
|
| 1572 |
-
emotion = _clean(str(body.get("emotion", "neutral")))
|
| 1573 |
-
try:
|
| 1574 |
-
speed = max(0.85, min(1.35, float(body.get("speed", 1.0) or 1.0)))
|
| 1575 |
-
except Exception:
|
| 1576 |
-
speed = 1.0
|
| 1577 |
-
|
| 1578 |
-
os.makedirs(SHORTS_DIR, exist_ok=True)
|
| 1579 |
-
|
| 1580 |
-
# ---------- scene images ----------
|
| 1581 |
-
scene_images = []
|
| 1582 |
-
slide_texts = []
|
| 1583 |
-
if use_slides:
|
| 1584 |
-
slides = post.get("slides") or []
|
| 1585 |
-
if not slides:
|
| 1586 |
-
return JSONResponse({"error": "Bài chưa có ảnh slide thiết kế. Hãy chọn '🎨 Thiết kế ảnh' trước."}, status_code=400)
|
| 1587 |
-
# optional per-slide selection from the recreate modal
|
| 1588 |
-
if slide_indices:
|
| 1589 |
-
slides = [s for i, s in enumerate(slides) if i in slide_indices]
|
| 1590 |
-
if not slides:
|
| 1591 |
-
return JSONResponse({"error": "Slide đã chọn không hợp lệ."}, status_code=400)
|
| 1592 |
-
for s in slides:
|
| 1593 |
-
im = (s.get("image") or "").strip()
|
| 1594 |
-
if im:
|
| 1595 |
-
scene_images.append(im)
|
| 1596 |
-
slide_texts.append(_clean(s.get("text", "")))
|
| 1597 |
-
# fixed_image: 1 ảnh do người dùng chọn (trong danh sách rewrite hoặc upload)
|
| 1598 |
-
# thay thế toàn bộ ảnh slide -> dùng ảnh này cho N slide (sau dedupe = 1 scene)
|
| 1599 |
-
if fixed_image:
|
| 1600 |
-
scene_images = [fixed_image]
|
| 1601 |
-
slide_texts = [slide_texts[0] if slide_texts else ""]
|
| 1602 |
-
# dedupe identical designed images globally: if the same image appears
|
| 1603 |
-
# in multiple slides (e.g. only 1 slide was designed, others share the
|
| 1604 |
-
# fallback cover), collapse to one scene so the slide isn't repeated
|
| 1605 |
-
deduped = []
|
| 1606 |
-
seen_imgs = set()
|
| 1607 |
-
for im in scene_images:
|
| 1608 |
-
if im not in seen_imgs:
|
| 1609 |
-
seen_imgs.add(im)
|
| 1610 |
-
deduped.append(im)
|
| 1611 |
-
scene_images = deduped
|
| 1612 |
-
if not scene_images:
|
| 1613 |
-
return JSONResponse({"error": "Các slide chưa có ảnh đã thiết kế."}, status_code=400)
|
| 1614 |
-
# align slide_texts to the deduped scenes (keep first text per image group)
|
| 1615 |
-
if slide_texts and len(slide_texts) > len(scene_images):
|
| 1616 |
-
grouped = []
|
| 1617 |
-
seen = set()
|
| 1618 |
-
for idx, s in enumerate(slides):
|
| 1619 |
-
im = (s.get("image") or "").strip()
|
| 1620 |
-
if not im or im in seen:
|
| 1621 |
-
continue
|
| 1622 |
-
seen.add(im)
|
| 1623 |
-
grouped.append(slide_texts[idx] if idx < len(slide_texts) else "")
|
| 1624 |
-
if grouped:
|
| 1625 |
-
slide_texts = grouped
|
| 1626 |
-
else:
|
| 1627 |
-
slide_texts = slide_texts[:len(scene_images)]
|
| 1628 |
-
else:
|
| 1629 |
-
# fixed_image (chosen from rewrite list) replaces the bg for the short
|
| 1630 |
-
if fixed_image:
|
| 1631 |
-
scene_images = [fixed_image]
|
| 1632 |
-
else:
|
| 1633 |
-
for im in custom_images:
|
| 1634 |
-
scene_images.append(im)
|
| 1635 |
-
if not scene_images:
|
| 1636 |
-
scene_images = [(x or "").strip() for x in (post.get("images") or []) if (x or "").strip()]
|
| 1637 |
-
if not scene_images:
|
| 1638 |
-
slides = post.get("slides") or []
|
| 1639 |
-
for s in slides:
|
| 1640 |
-
im = (s.get("image") or "").strip()
|
| 1641 |
-
if im:
|
| 1642 |
-
scene_images.append(im)
|
| 1643 |
-
if not scene_images and post.get("img"):
|
| 1644 |
-
scene_images = [post.get("img")]
|
| 1645 |
-
# video-only (scraped/uploaded) short: video is the background, so
|
| 1646 |
-
# images are OPTIONAL. Let the video-first assembler handle it.
|
| 1647 |
-
if not scene_images and not video_url:
|
| 1648 |
-
# Short HOT oEmbed fallback: no direct media AND no background
|
| 1649 |
-
# image -> still create the embeddable slide with the oEmbed
|
| 1650 |
-
# thumbnail as the visual (if any), so the wall/feed can show it.
|
| 1651 |
-
if embed_url:
|
| 1652 |
-
pass
|
| 1653 |
-
else:
|
| 1654 |
-
# AUTO oEmbed FALLBACK: the frontend couldn't get a video
|
| 1655 |
-
# (or even embed metadata) from the link — derive the
|
| 1656 |
-
# embed iframe URL from the source link itself (cheap, no
|
| 1657 |
-
# network) and enrich with oEmbed metadata if available.
|
| 1658 |
-
src_url = _clean(str(body.get("url", "")) or post.get("url") or "")
|
| 1659 |
-
if src_url and (_is_known_social(src_url) or body.get("allow_oembed_fallback")):
|
| 1660 |
-
embed_url = _oembed_embed_url(src_url)
|
| 1661 |
-
_probe3 = _oembed_probe(src_url)
|
| 1662 |
-
if _probe3:
|
| 1663 |
-
embed_url = _probe3.get("embed_url") or embed_url
|
| 1664 |
-
embed_title = _probe3.get("title") or embed_title
|
| 1665 |
-
embed_thumb = _probe3.get("thumbnail") or embed_thumb
|
| 1666 |
-
embed_desc = _probe3.get("description") or embed_desc
|
| 1667 |
-
if not embed_url:
|
| 1668 |
-
return JSONResponse({"error": "Bài viết không có ảnh nền"}, status_code=400)
|
| 1669 |
-
segs0 = _segments_from_post(post)
|
| 1670 |
-
if len(scene_images) > len(segs0):
|
| 1671 |
-
scene_images = scene_images[:max(1, len(segs0))]
|
| 1672 |
-
|
| 1673 |
-
# ---------- Short HOT oEmbed shortcut ----------
|
| 1674 |
-
# When the source link is oEmbed-only (TikTok/FB/IG block datacenter IPs,
|
| 1675 |
-
# or the scrape returned no direct media URL), save the post as an
|
| 1676 |
-
# embeddable slide right away with the auto-fetched title/desc/thumb.
|
| 1677 |
-
if not video_url and embed_url:
|
| 1678 |
-
# Don't trust the client for metadata: when the post has a source URL
|
| 1679 |
-
# and we still lack a thumbnail/title at save time (e.g. an older page
|
| 1680 |
-
# cached in the browser didn't send embed_thumb), probe oEmbed NOW so
|
| 1681 |
-
# the wall post always gets its thumbnail + title.
|
| 1682 |
-
_src_url = _clean(str(body.get("url", "")) or post.get("url") or "")
|
| 1683 |
-
if (not embed_thumb or not embed_title) and _src_url:
|
| 1684 |
-
_probe_save = _oembed_probe(_src_url)
|
| 1685 |
-
if _probe_save:
|
| 1686 |
-
embed_title = embed_title or _probe_save.get("title") or ""
|
| 1687 |
-
embed_thumb = embed_thumb or _probe_save.get("thumbnail") or ""
|
| 1688 |
-
embed_desc = embed_desc or _probe_save.get("description") or ""
|
| 1689 |
-
post["video"] = embed_url
|
| 1690 |
-
post["embed_oembed"] = True
|
| 1691 |
-
post["title"] = embed_title or post.get("title") or "Short HOT"
|
| 1692 |
-
post["text"] = embed_desc or post.get("text") or embed_title or ""
|
| 1693 |
-
post["description"] = embed_desc or ""
|
| 1694 |
-
if embed_thumb:
|
| 1695 |
-
post["img"] = embed_thumb
|
| 1696 |
-
post["images"] = [embed_thumb] + [x for x in (post.get("images") or []) if x != embed_thumb]
|
| 1697 |
-
post["short_thumb"] = embed_thumb
|
| 1698 |
-
else:
|
| 1699 |
-
post["short_thumb"] = post.get("img")
|
| 1700 |
-
post["extractor"] = "oembed"
|
| 1701 |
-
_save_wall_posts(posts)
|
| 1702 |
-
return JSONResponse({"video": embed_url, "oembed": True, "post": post})
|
| 1703 |
-
|
| 1704 |
-
# ---------- naming / cache ----------
|
| 1705 |
-
scene_key = "slides" if use_slides else "norm"
|
| 1706 |
-
sel_key = ("s" + "".join(str(i) for i in slide_indices)) if slide_indices else ""
|
| 1707 |
-
vid_key = hashlib.md5(video_url.encode("utf-8")).hexdigest()[:8] if video_url else ""
|
| 1708 |
-
img_key = hashlib.md5("|".join(scene_images).encode("utf-8")).hexdigest()[:8]
|
| 1709 |
-
mus_key = audio_music or "nomus"
|
| 1710 |
-
aud_key = hashlib.md5(audio_url.encode("utf-8")).hexdigest()[:8] if audio_url else "none"
|
| 1711 |
-
suffix = f"v2_{scene_key}_{'v'+vid_key if vid_key else ''}{sel_key}{img_key}_{mus_key}_{aud_key}_{voice}_{emotion}_{str(speed).replace('.', 'p')}"
|
| 1712 |
-
out_mp4 = os.path.join(SHORTS_DIR, _safe_name(post_id + suffix) + ".mp4")
|
| 1713 |
-
out_url = "/api/ai/short-file/" + post_id + suffix
|
| 1714 |
-
|
| 1715 |
-
if os.path.exists(out_mp4):
|
| 1716 |
-
post["video"] = out_url
|
| 1717 |
-
post["short_voice"] = voice
|
| 1718 |
-
post["short_emotion"] = emotion
|
| 1719 |
-
post["short_speed"] = speed
|
| 1720 |
-
post["short_music"] = audio_music
|
| 1721 |
-
post["short_audio_url"] = audio_url
|
| 1722 |
-
post["short_use_slides"] = use_slides
|
| 1723 |
-
post["short_images"] = scene_images
|
| 1724 |
-
_save_wall_posts(posts)
|
| 1725 |
-
return JSONResponse({"video": out_url, "voice": voice, "emotion": emotion,
|
| 1726 |
-
"speed": speed, "music": audio_music, "use_slides": use_slides,
|
| 1727 |
-
"post": post})
|
| 1728 |
-
|
| 1729 |
-
work = os.path.join(SHORTS_DIR, _safe_name(post_id + suffix))
|
| 1730 |
-
os.makedirs(work, exist_ok=True)
|
| 1731 |
-
|
| 1732 |
-
# ---------- download scene images ----------
|
| 1733 |
-
local_imgs = []
|
| 1734 |
-
for i, im_url in enumerate(scene_images):
|
| 1735 |
-
dst = os.path.join(work, f"scene_{i:02d}.jpg")
|
| 1736 |
-
data = _fetch_bytes(im_url)
|
| 1737 |
-
if data:
|
| 1738 |
-
with open(dst, "wb") as f:
|
| 1739 |
-
f.write(data)
|
| 1740 |
-
local_imgs.append(dst)
|
| 1741 |
-
if not local_imgs:
|
| 1742 |
-
img = Image.new("RGB", (1080, 1920), (24, 24, 24))
|
| 1743 |
-
img.save(os.path.join(work, "scene_00.jpg"), quality=90)
|
| 1744 |
-
local_imgs = [os.path.join(work, "scene_00.jpg")]
|
| 1745 |
-
|
| 1746 |
-
# ---------- uploaded/scraped video background ----------
|
| 1747 |
-
video_audio_path = None
|
| 1748 |
-
using_video_first = False # video plays as-is (video-first assembly), images fill tail
|
| 1749 |
-
if video_url:
|
| 1750 |
-
vid_local = os.path.join(work, "bg_video.mp4")
|
| 1751 |
-
dl_ok = _download_to_file(video_url, vid_local)
|
| 1752 |
-
if not dl_ok:
|
| 1753 |
-
# ---- Short HOT oEmbed fallback: can't download the media -> save
|
| 1754 |
-
# the post as an embeddable slide with auto title/desc/thumb from
|
| 1755 |
-
# the scrape metadata. The feed renders it as an iframe slide,
|
| 1756 |
-
# exactly like the football video slides on YouTube.
|
| 1757 |
-
fb_embed = embed_url or _oembed_embed_url(_clean(str(body.get("url", "")) or post.get("url") or ""))
|
| 1758 |
-
fb_title = embed_title or post.get("title") or ""
|
| 1759 |
-
fb_thumb = embed_thumb or post.get("img") or ""
|
| 1760 |
-
fb_desc = embed_desc or ""
|
| 1761 |
-
if not fb_title or not fb_thumb or not fb_desc:
|
| 1762 |
-
_probe2 = _oembed_probe(_clean(str(body.get("url", "")) or post.get("url") or ""))
|
| 1763 |
-
if _probe2:
|
| 1764 |
-
fb_embed = fb_embed or _probe2.get("embed_url") or ""
|
| 1765 |
-
fb_title = fb_title or _probe2.get("title") or ""
|
| 1766 |
-
fb_thumb = fb_thumb or _probe2.get("thumbnail") or ""
|
| 1767 |
-
fb_desc = fb_desc or (_probe2.get("description") or "")
|
| 1768 |
-
if fb_embed:
|
| 1769 |
-
post["video"] = fb_embed
|
| 1770 |
-
post["embed_oembed"] = True
|
| 1771 |
-
post["title"] = fb_title or post.get("title") or "Short HOT"
|
| 1772 |
-
post["text"] = fb_desc or post.get("text") or fb_title or ""
|
| 1773 |
-
post["description"] = fb_desc or ""
|
| 1774 |
-
if fb_thumb:
|
| 1775 |
-
post["img"] = fb_thumb
|
| 1776 |
-
post["images"] = [fb_thumb] + [x for x in (post.get("images") or []) if x != fb_thumb]
|
| 1777 |
-
post["short_thumb"] = fb_thumb
|
| 1778 |
-
else:
|
| 1779 |
-
post["short_thumb"] = post.get("img")
|
| 1780 |
-
post["short_video_url"] = video_url
|
| 1781 |
-
post["extractor"] = "oembed"
|
| 1782 |
-
_save_wall_posts(posts)
|
| 1783 |
-
return JSONResponse({"video": fb_embed, "oembed": True, "post": post})
|
| 1784 |
-
return JSONResponse({"error": "Không tải được video từ link. Link đã hết hạn hoặc nguồn chặn."}, status_code=422)
|
| 1785 |
-
try:
|
| 1786 |
-
vdur = _probe_duration(vid_local, fallback=6.0)
|
| 1787 |
-
except Exception:
|
| 1788 |
-
vdur = 6.0
|
| 1789 |
-
if video_dur and video_dur > 0:
|
| 1790 |
-
vdur = float(video_dur)
|
| 1791 |
-
# extract video's own audio (used as narration track)
|
| 1792 |
-
vaudio = os.path.join(work, "vaudio.m4a")
|
| 1793 |
-
subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", vid_local, "-vn",
|
| 1794 |
-
"-c:a", "aac", "-b:a", "128k", vaudio],
|
| 1795 |
-
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
|
| 1796 |
-
if os.path.exists(vaudio) and os.path.getsize(vaudio) > 900:
|
| 1797 |
-
video_audio_path = vaudio
|
| 1798 |
-
|
| 1799 |
-
# --- NEW: video-first mode (scraped video or muted uploaded video)
|
| 1800 |
-
# Video plays first; designed/selected images fill the rest if the
|
| 1801 |
-
# video is shorter than the voice. No per-frame mosaic.
|
| 1802 |
-
using_video_first = scrape_mode or video_muted
|
| 1803 |
-
if not using_video_first:
|
| 1804 |
-
# legacy uploaded-video mode: extract up to N frames as the
|
| 1805 |
-
# moving-background scenes
|
| 1806 |
-
n_frames = max(1, min(5, len(local_imgs) if len(local_imgs) >= 1 else 5))
|
| 1807 |
-
frame_imgs = []
|
| 1808 |
-
for i in range(n_frames):
|
| 1809 |
-
t = vdur * (i + 0.5) / n_frames
|
| 1810 |
-
fimg = os.path.join(work, f"vc_{i:02d}.jpg")
|
| 1811 |
-
subprocess.run(["ffmpeg", "-y", "-v", "error", "-ss", str(t), "-i", vid_local,
|
| 1812 |
-
"-frames:v", "1", "-q:v", "3", fimg],
|
| 1813 |
-
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=60)
|
| 1814 |
-
if os.path.exists(fimg) and os.path.getsize(fimg) > 1200:
|
| 1815 |
-
frame_imgs.append(fimg)
|
| 1816 |
-
if frame_imgs:
|
| 1817 |
-
local_imgs = frame_imgs
|
| 1818 |
-
|
| 1819 |
-
# ---------- narration plan ----------
|
| 1820 |
-
reuse_audio_path = None
|
| 1821 |
-
if reuse_audio:
|
| 1822 |
-
prev = _find_previous_short(post_id)
|
| 1823 |
-
if prev:
|
| 1824 |
-
try:
|
| 1825 |
-
prev_audio = os.path.join(work, "prev_audio.m4a")
|
| 1826 |
-
subprocess.run(["ffmpeg", "-y", "-i", prev, "-vn", "-acodec", "aac", "-b:a", "128k", prev_audio],
|
| 1827 |
-
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
|
| 1828 |
-
if os.path.exists(prev_audio) and os.path.getsize(prev_audio) > 900:
|
| 1829 |
-
reuse_audio_path = prev_audio
|
| 1830 |
-
except Exception:
|
| 1831 |
-
reuse_audio_path = None
|
| 1832 |
-
if video_audio_path and not reuse_audio_path and not video_muted and not using_video_first:
|
| 1833 |
-
# uploaded/scraped video has its own audio -> use it instead of TTS
|
| 1834 |
-
# (only in legacy mosaic mode; video-first mode keeps video audio as a
|
| 1835 |
-
# background layer mixed with the TTS narration)
|
| 1836 |
-
reuse_audio_path = video_audio_path
|
| 1837 |
-
segs = slide_texts if (use_slides and slide_texts) else _segments_from_post(post)
|
| 1838 |
-
while len(segs) < len(local_imgs):
|
| 1839 |
-
segs.append(segs[-1] if segs else post.get("title", "Bản tin VNEWS"))
|
| 1840 |
-
|
| 1841 |
-
# ---------- NEW: video-first short assembly ----------
|
| 1842 |
-
if using_video_first:
|
| 1843 |
-
# images for the tail: designed slides first, then post images
|
| 1844 |
-
tail_imgs = list(local_imgs) if (local_imgs and not (use_slides and fixed_image)) else local_imgs
|
| 1845 |
-
# if user chose a fixed image -> only that image for the tail
|
| 1846 |
-
if fixed_image:
|
| 1847 |
-
fimg = os.path.join(work, "fixed_tail.jpg")
|
| 1848 |
-
data = _fetch_bytes(fixed_image)
|
| 1849 |
-
if data:
|
| 1850 |
-
with open(fimg, "wb") as f:
|
| 1851 |
-
f.write(data)
|
| 1852 |
-
tail_imgs = [fimg] if os.path.getsize(fimg) > 1200 else (tail_imgs or [])
|
| 1853 |
-
# ensure at least the post image exists as tail
|
| 1854 |
-
if not tail_imgs:
|
| 1855 |
-
fallback_img = os.path.join(work, "scene_00.jpg")
|
| 1856 |
-
if os.path.exists(fallback_img):
|
| 1857 |
-
tail_imgs = [fallback_img]
|
| 1858 |
-
# TTS segments: for video-first, use per-slide texts if slides,
|
| 1859 |
-
# else the whole article as segments (each read out)
|
| 1860 |
-
tts_segs = slide_texts if (use_slides and slide_texts) else _segments_from_post(post)
|
| 1861 |
-
if not tts_segs:
|
| 1862 |
-
tts_segs = [post.get("title", "Bản tin VNEWS")]
|
| 1863 |
-
try:
|
| 1864 |
-
out_mp4, _total = _assemble_video_short(
|
| 1865 |
-
work, vid_local, vdur,
|
| 1866 |
-
tail_imgs, tts_segs,
|
| 1867 |
-
reuse_audio_path,
|
| 1868 |
-
(video_audio_path if not video_muted else None),
|
| 1869 |
-
voice, speed,
|
| 1870 |
-
None, out_mp4, post.get("title", ""),
|
| 1871 |
-
)
|
| 1872 |
-
# music overlay handled by the shared block below
|
| 1873 |
-
music_url = ""
|
| 1874 |
-
if audio_url:
|
| 1875 |
-
music_url = audio_url
|
| 1876 |
-
elif audio_music and audio_music in MUSIC_BY_ID:
|
| 1877 |
-
music_url = MUSIC_BY_ID[audio_music].get("url", "")
|
| 1878 |
-
if music_url:
|
| 1879 |
-
music_path = os.path.join(work, "music.mp3")
|
| 1880 |
-
data = _fetch_bytes(music_url)
|
| 1881 |
-
if data:
|
| 1882 |
-
with open(music_path, "wb") as f:
|
| 1883 |
-
f.write(data)
|
| 1884 |
-
if os.path.exists(music_path) and os.path.getsize(music_path) > 900:
|
| 1885 |
-
muxed = os.path.join(work, "muxed.mp4")
|
| 1886 |
-
vid_dur2 = _probe_duration(out_mp4, fallback=30.0)
|
| 1887 |
-
if _probe_has_audio(out_mp4):
|
| 1888 |
-
fc = (
|
| 1889 |
-
f"[1:a]volume=0.5,aloop=loop=-1:size=2e9,atrim=duration={vid_dur2}[m];"
|
| 1890 |
-
f"[0:a][m]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[aout]"
|
| 1891 |
-
)
|
| 1892 |
-
cmd = ["ffmpeg", "-y", "-i", out_mp4, "-i", music_path,
|
| 1893 |
-
"-filter_complex", fc, "-map", "0:v", "-map", "[aout]",
|
| 1894 |
-
"-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-shortest", muxed]
|
| 1895 |
-
else:
|
| 1896 |
-
fc = f"[1:a]volume=0.55,aloop=loop=-1:size=2e9,atrim=duration={vid_dur2}[a]"
|
| 1897 |
-
cmd = ["ffmpeg", "-y", "-i", out_mp4, "-i", music_path,
|
| 1898 |
-
"-filter_complex", fc, "-map", "0:v", "-map", "[a]",
|
| 1899 |
-
"-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-shortest", muxed]
|
| 1900 |
-
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=240)
|
| 1901 |
-
os.replace(muxed, out_mp4)
|
| 1902 |
-
post["video"] = out_url
|
| 1903 |
-
post["short_thumb"] = _first_frame(out_mp4, os.path.join(WALL_IMG_DIR, _safe_name(post_id + suffix + "_thumb") + ".jpg"))
|
| 1904 |
-
post["short_voice"] = voice
|
| 1905 |
-
post["short_emotion"] = emotion
|
| 1906 |
-
post["short_speed"] = speed
|
| 1907 |
-
post["short_music"] = audio_music
|
| 1908 |
-
post["short_audio_url"] = audio_url
|
| 1909 |
-
post["short_use_slides"] = use_slides
|
| 1910 |
-
post["short_images"] = scene_images
|
| 1911 |
-
post["short_video_url"] = video_url
|
| 1912 |
-
post["short_video_muted"] = video_muted
|
| 1913 |
-
_save_wall_posts(posts)
|
| 1914 |
-
return JSONResponse({"video": out_url, "voice": voice, "emotion": emotion,
|
| 1915 |
-
"speed": speed, "music": audio_music, "use_slides": use_slides,
|
| 1916 |
-
"post": post})
|
| 1917 |
-
except Exception as e:
|
| 1918 |
-
if create_new:
|
| 1919 |
-
_load_wall_posts() # ensure fresh list
|
| 1920 |
-
posts2 = [p for p in _load_wall_posts() if str(p.get("id")) != str(post_id)]
|
| 1921 |
-
_save_wall_posts(posts2)
|
| 1922 |
-
return JSONResponse({"error": "Không tạo được shorts: " + str(e)[:220]}, status_code=500)
|
| 1923 |
-
|
| 1924 |
-
# ---------- frame rendering ----------
|
| 1925 |
-
frames = []
|
| 1926 |
-
for idx in range(len(local_imgs)):
|
| 1927 |
-
frame = os.path.join(work, f"frame_{idx:02d}.jpg")
|
| 1928 |
-
with open(local_imgs[idx], "rb") as f:
|
| 1929 |
-
data = f.read()
|
| 1930 |
-
if use_slides:
|
| 1931 |
-
_frame_from_image(data, frame, seg_idx=idx, seg_total=len(local_imgs))
|
| 1932 |
-
else:
|
| 1933 |
-
_frame_with_text(data, frame, segs[idx], post.get("title", ""), seg_idx=idx, seg_total=len(local_imgs))
|
| 1934 |
-
frames.append(frame)
|
| 1935 |
-
|
| 1936 |
-
part_files = []
|
| 1937 |
-
try:
|
| 1938 |
-
if reuse_audio_path:
|
| 1939 |
-
# ---------- reuse previous short audio ----------
|
| 1940 |
-
total_dur = _probe_duration(reuse_audio_path, fallback=30.0)
|
| 1941 |
-
per = max(2.2, min(8.0, total_dur / max(1, len(frames))))
|
| 1942 |
-
for idx, frame in enumerate(frames):
|
| 1943 |
-
part = os.path.join(work, f"part_{idx:02d}.mp4")
|
| 1944 |
-
subprocess.run(
|
| 1945 |
-
["ffmpeg", "-y", "-loop", "1", "-t", str(per), "-i", frame,
|
| 1946 |
-
"-c:v", "libx264", "-tune", "stillimage", "-pix_fmt", "yuv420p", "-an", part],
|
| 1947 |
-
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=150)
|
| 1948 |
-
part_files.append(part)
|
| 1949 |
-
concat = os.path.join(work, "concat.txt")
|
| 1950 |
-
with open(concat, "w", encoding="utf-8") as f:
|
| 1951 |
-
for p in part_files:
|
| 1952 |
-
f.write("file '" + p.replace("'", "'\\''") + "'\n")
|
| 1953 |
-
subprocess.run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat, "-c", "copy", out_mp4],
|
| 1954 |
-
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=240)
|
| 1955 |
-
# mux previous audio (trim to video length)
|
| 1956 |
-
vid_dur = _probe_duration(out_mp4, fallback=total_dur)
|
| 1957 |
-
with_audio = os.path.join(work, "with_audio.mp4")
|
| 1958 |
-
subprocess.run(
|
| 1959 |
-
["ffmpeg", "-y", "-i", out_mp4, "-i", reuse_audio_path,
|
| 1960 |
-
"-filter_complex", f"[1:a]atrim=duration={vid_dur}[a]",
|
| 1961 |
-
"-map", "0:v", "-map", "[a]", "-c:v", "copy", "-c:a", "aac",
|
| 1962 |
-
"-b:a", "128k", "-shortest", with_audio],
|
| 1963 |
-
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=240)
|
| 1964 |
-
os.replace(with_audio, out_mp4)
|
| 1965 |
-
else:
|
| 1966 |
-
# ---------- fresh TTS narration ----------
|
| 1967 |
-
edge_voice = {
|
| 1968 |
-
"vi-vn-hoaimyneural": "vi-VN-HoaiMyNeural", "vi-vn-namminhneural": "vi-VN-NamMinhNeural",
|
| 1969 |
-
"hoaimy": "vi-VN-HoaiMyNeural", "namminh": "vi-VN-NamMinhNeural", "nam": "vi-VN-NamMinhNeural",
|
| 1970 |
-
"male": "vi-VN-NamMinhNeural", "nu": "vi-VN-HoaiMyNeural", "female": "vi-VN-HoaiMyNeural",
|
| 1971 |
-
"mien-nam": "vi-VN-HoaiMyNeural",
|
| 1972 |
-
"en-us-andrewmultilingualneural": "en-US-AndrewMultilingualNeural",
|
| 1973 |
-
"en-au-williammultilingualneural": "en-AU-WilliamMultilingualNeural",
|
| 1974 |
-
"andrew": "en-US-AndrewMultilingualNeural", "en_andrew": "en-US-AndrewMultilingualNeural",
|
| 1975 |
-
"jenny": "en-US-AndrewMultilingualNeural", "en_jenny": "en-US-AndrewMultilingualNeural",
|
| 1976 |
-
"pt-br-thalitamultilingualneural": "pt-BR-ThalitaMultilingualNeural",
|
| 1977 |
-
"thalita": "pt-BR-ThalitaMultilingualNeural", "pt": "pt-BR-ThalitaMultilingualNeural",
|
| 1978 |
-
"fr-fr-viviennemultilingualneural": "fr-FR-VivienneMultilingualNeural",
|
| 1979 |
-
"fr-fr-remymultilingualneural": "fr-FR-RemyMultilingualNeural",
|
| 1980 |
-
"denise": "fr-FR-VivienneMultilingualNeural", "fr": "fr-FR-VivienneMultilingualNeural",
|
| 1981 |
-
"de-de-seraphinamultilingualneural": "de-DE-SeraphinaMultilingualNeural",
|
| 1982 |
-
"de-de-florianmultilingualneural": "de-DE-FlorianMultilingualNeural",
|
| 1983 |
-
"katja": "de-DE-SeraphinaMultilingualNeural", "de": "de-DE-SeraphinaMultilingualNeural",
|
| 1984 |
-
"ko-kr-hyusumultilingualneural": "ko-KR-HyunsuMultilingualNeural",
|
| 1985 |
-
"ko-kr-hyunsuneural": "ko-KR-HyunsuMultilingualNeural", "sunhee": "ko-KR-HyunsuMultilingualNeural",
|
| 1986 |
-
"ko": "ko-KR-HyunsuMultilingualNeural",
|
| 1987 |
-
"it-it-giuseppemultilingualneural": "it-IT-GiuseppeMultilingualNeural",
|
| 1988 |
-
"ela": "en-US-AndrewMultilingualNeural", "es": "en-US-AndrewMultilingualNeural",
|
| 1989 |
-
}.get(voice.lower(), voice)
|
| 1990 |
-
for idx, frame in enumerate(frames):
|
| 1991 |
-
aud = os.path.join(work, f"voice_{idx:02d}.mp3")
|
| 1992 |
-
aud_fast = os.path.join(work, f"voice_{idx:02d}_fast.mp3")
|
| 1993 |
-
part = os.path.join(work, f"part_{idx:02d}.mp4")
|
| 1994 |
-
spoken = segs[idx] if idx < len(segs) else post.get("title", "Bản tin VNEWS")
|
| 1995 |
-
try:
|
| 1996 |
-
subprocess.run(
|
| 1997 |
-
["python", "-m", "edge_tts", "--voice", edge_voice, "--text", spoken,
|
| 1998 |
-
"--write-media", aud],
|
| 1999 |
-
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
| 2000 |
-
except Exception:
|
| 2001 |
-
aud = None
|
| 2002 |
-
try:
|
| 2003 |
-
from gtts import gTTS
|
| 2004 |
-
tld = "com.vn" if voice.lower() in ("nu", "female", "mien-nam", "hoaimy") else "com"
|
| 2005 |
-
try:
|
| 2006 |
-
gTTS(spoken, lang="vi", tld=tld, slow=False).save(aud)
|
| 2007 |
-
except TypeError:
|
| 2008 |
-
gTTS(spoken, lang="vi", slow=False).save(aud)
|
| 2009 |
-
except Exception:
|
| 2010 |
-
pass
|
| 2011 |
-
if aud and os.path.exists(aud) and os.path.getsize(aud) > 900:
|
| 2012 |
-
subprocess.run(["ffmpeg", "-y", "-i", aud, "-filter:a", f"atempo={speed}", "-vn", aud_fast],
|
| 2013 |
-
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
|
| 2014 |
-
dur = _probe_duration(aud_fast, fallback=15.0) + 0.35
|
| 2015 |
-
subprocess.run(
|
| 2016 |
-
["ffmpeg", "-y", "-loop", "1", "-t", str(dur), "-i", frame, "-i", aud_fast,
|
| 2017 |
-
"-shortest", "-c:v", "libx264", "-tune", "stillimage", "-pix_fmt", "yuv420p",
|
| 2018 |
-
"-c:a", "aac", "-b:a", "128k", part],
|
| 2019 |
-
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=150)
|
| 2020 |
-
else:
|
| 2021 |
-
subprocess.run(
|
| 2022 |
-
["ffmpeg", "-y", "-loop", "1", "-t", "4", "-i", frame,
|
| 2023 |
-
"-c:v", "libx264", "-tune", "stillimage", "-pix_fmt", "yuv420p", "-an", part],
|
| 2024 |
-
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
| 2025 |
-
part_files.append(part)
|
| 2026 |
-
concat = os.path.join(work, "concat.txt")
|
| 2027 |
-
with open(concat, "w", encoding="utf-8") as f:
|
| 2028 |
-
for p in part_files:
|
| 2029 |
-
f.write("file '" + p.replace("'", "'\\''") + "'\n")
|
| 2030 |
-
subprocess.run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat, "-c", "copy", out_mp4],
|
| 2031 |
-
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=240)
|
| 2032 |
-
|
| 2033 |
-
# ---------- background music overlay ----------
|
| 2034 |
-
music_url = ""
|
| 2035 |
-
if audio_url:
|
| 2036 |
-
music_url = audio_url
|
| 2037 |
-
elif audio_music and audio_music in MUSIC_BY_ID:
|
| 2038 |
-
music_url = MUSIC_BY_ID[audio_music].get("url", "")
|
| 2039 |
-
if music_url:
|
| 2040 |
-
music_path = os.path.join(work, "music.mp3")
|
| 2041 |
-
data = _fetch_bytes(music_url)
|
| 2042 |
-
if data:
|
| 2043 |
-
with open(music_path, "wb") as f:
|
| 2044 |
-
f.write(data)
|
| 2045 |
-
if os.path.exists(music_path) and os.path.getsize(music_path) > 900:
|
| 2046 |
-
muxed = os.path.join(work, "muxed.mp4")
|
| 2047 |
-
vid_dur = _probe_duration(out_mp4, fallback=30.0)
|
| 2048 |
-
if _probe_has_audio(out_mp4):
|
| 2049 |
-
# narration at full volume + music at 0.5, no amix downscale (normalize=0)
|
| 2050 |
-
fc = (
|
| 2051 |
-
f"[1:a]volume=0.5,aloop=loop=-1:size=2e9,atrim=duration={vid_dur}[m];"
|
| 2052 |
-
f"[0:a][m]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[aout]"
|
| 2053 |
-
)
|
| 2054 |
-
cmd = ["ffmpeg", "-y", "-i", out_mp4, "-i", music_path,
|
| 2055 |
-
"-filter_complex", fc, "-map", "0:v", "-map", "[aout]",
|
| 2056 |
-
"-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-shortest", muxed]
|
| 2057 |
-
else:
|
| 2058 |
-
fc = f"[1:a]volume=0.55,aloop=loop=-1:size=2e9,atrim=duration={vid_dur}[a]"
|
| 2059 |
-
cmd = ["ffmpeg", "-y", "-i", out_mp4, "-i", music_path,
|
| 2060 |
-
"-filter_complex", fc, "-map", "0:v", "-map", "[a]",
|
| 2061 |
-
"-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-shortest", muxed]
|
| 2062 |
-
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=240)
|
| 2063 |
-
os.replace(muxed, out_mp4)
|
| 2064 |
-
|
| 2065 |
-
except Exception as e:
|
| 2066 |
-
if create_new:
|
| 2067 |
-
posts2 = [p for p in _load_wall_posts() if str(p.get("id")) != str(post_id)]
|
| 2068 |
-
_save_wall_posts(posts2)
|
| 2069 |
-
return JSONResponse({"error": "Không tạo được shorts: " + str(e)[:220]}, status_code=500)
|
| 2070 |
-
|
| 2071 |
-
post["video"] = out_url
|
| 2072 |
-
post["short_thumb"] = _first_frame(out_mp4, os.path.join(WALL_IMG_DIR, _safe_name(post_id + suffix + "_thumb") + ".jpg"))
|
| 2073 |
-
post["short_voice"] = voice
|
| 2074 |
-
post["short_emotion"] = emotion
|
| 2075 |
-
post["short_speed"] = speed
|
| 2076 |
-
post["short_music"] = audio_music
|
| 2077 |
-
post["short_audio_url"] = audio_url
|
| 2078 |
-
post["short_use_slides"] = use_slides
|
| 2079 |
-
post["short_images"] = scene_images
|
| 2080 |
-
|
| 2081 |
-
return JSONResponse({"video": out_url, "voice": voice, "emotion": emotion,
|
| 2082 |
-
"speed": speed, "music": audio_music, "use_slides": use_slides,
|
| 2083 |
-
"post": post})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app_patch_unified.py
CHANGED
|
@@ -11,6 +11,7 @@ Features:
|
|
| 11 |
4. Rewrite auto-title, no "xem trên VNEWS" junk
|
| 12 |
5. Topic post uses source og:image instead of AI image
|
| 13 |
6. Fast homepage load (non-blocking)
|
|
|
|
| 14 |
"""
|
| 15 |
from ai_runtime_patch_fast import *
|
| 16 |
from ai_runtime_patch_fast import app, f5, f6, rt, PATCH_INJECT, _scrape, _domain, clean, _bg, _bg_home, _bg_shorts
|
|
@@ -74,6 +75,30 @@ def _source_image(sources, details):
|
|
| 74 |
def _ensure_img(img):
|
| 75 |
return img if (img and len(img)>20 and img.startswith('http')) else DEFAULT_IMG
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
# ============================================================
|
| 78 |
# TOPIC POST (source image instead of AI image)
|
| 79 |
# ============================================================
|
|
@@ -99,7 +124,7 @@ async def _topic(request:Request):
|
|
| 99 |
return JSONResponse({'post':post})
|
| 100 |
|
| 101 |
# ============================================================
|
| 102 |
-
# REWRITE (auto-title, clean text)
|
| 103 |
# ============================================================
|
| 104 |
@app.post('/api/rewrite_share')
|
| 105 |
@app.post('/api/url_wall')
|
|
@@ -110,6 +135,29 @@ async def _rewrite(request:Request):
|
|
| 110 |
if len(raw)<50:raw=ctx[:14000]
|
| 111 |
if len(raw)<50:return JSONResponse({'error':'Không đọc được bài'},status_code=422)
|
| 112 |
img=_ensure_img(img)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
prompt=f"""Tóm tắt bài viết thành bản tin ngắn. Dòng đầu tiên là tiêu đề mới hấp dẫn (tự đặt, không copy gốc).
|
| 114 |
|
| 115 |
Tiêu đề gốc: {title}
|
|
@@ -130,7 +178,18 @@ Yêu cầu:
|
|
| 130 |
lines=text.strip().split('\n')
|
| 131 |
body='\n'.join(lines[1:]).strip() if lines and lines[0].strip()==ai_title else text
|
| 132 |
post=f5.base.make_post(ai_title,_clean_text(body),img,url,'rewrite',sources=[{'title':title,'url':url,'via':_domain(url)}])
|
|
|
|
|
|
|
| 133 |
ps=f5.base._load_ai_wall();ps.insert(0,post);f5.base._save_ai_wall(ps)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
return JSONResponse({'post':post})
|
| 135 |
|
| 136 |
@app.post('/api/topic/rewrite')
|
|
@@ -198,7 +257,7 @@ Object.defineProperty(window,'renderAIShorts7',{get:function(){return function()
|
|
| 198 |
.source-detail-item img{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:6px;margin-bottom:6px;background:#222}
|
| 199 |
.source-vnews-btn{display:inline-block;margin-top:6px;background:#2d8659;color:#fff;padding:4px 10px;border-radius:10px;font-size:10px;font-weight:700}
|
| 200 |
/* Livescore */
|
| 201 |
-
.ls-content{max-height:480px;overflow-y:auto;padding:0 6px 8px;font-size:12px;color:#ddd}.ls-content ul{list-style:none;padding:0;margin:0}.ls-content .title-content{display:flex;gap:6px;align-items:center;background:#222;border-radius:4px;margin:4px 0;padding:5px 8px}.ls-content .title-content img{width:18px;height:18px}.ls-content .title-content strong{font-size:11px;color:#ccc}.ls-content .match-detail{padding:6px;border-bottom:1px solid #262626;cursor:pointer}.ls-content .match-detail:hover{background:#1a2a1f}.ls-content .match{display:flex;flex-wrap:wrap;align-items:center;gap:4px}.ls-content .datetime{width:100%;font-size:9px;color:#888}.ls-content .teams{display:flex;width:100%;align-items:center;gap:4px}.ls-content .team{flex:1;display:flex;align-items:center;gap:4px;min-width:0}.ls-content .team .name{font-size:11px;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ls-content .team .logo img{width:18px;height:18px}.ls-content .home-team{justify-content:flex-end;text-align:right}.ls-content .status{flex:0 0 54px;text-align:center}.ls-content .status a{color:#fff;text-decoration:none;font-weight:800;font-size:12px}.ls-content .status .label{font-size:8px;color:#888;display:block}.ls-content .status .label.live{color:#e74c3c}.ls-content .info,.ls-content .btns{display:none}.ls-content table,.mo-body table{width:100%;border-collapse:collapse;font-size:11px;color:#ccc}.ls-content table th,.mo-body table th{background:#222;color:#999;padding:5px 4px;font-size:10px;border-bottom:1px solid #333}.ls-content table td,.
|
| 202 |
</style>
|
| 203 |
<div id="short-progress-toast"></div>
|
| 204 |
<script>
|
|
@@ -270,4 +329,4 @@ async def _index():
|
|
| 270 |
body += getattr(f6,'FINAL6E_INJECT','') # Keep it — our PRE_KILL in UNIFIED neutralizes its destructive parts
|
| 271 |
body += PATCH_INJECT
|
| 272 |
body += UNIFIED_INJECT # This goes LAST and contains PRE_KILL at the TOP (runs first in browser)
|
| 273 |
-
return HTMLResponse(html.replace('</body>', body + '\n</body>') if '</body>' in html else html + body)
|
|
|
|
| 11 |
4. Rewrite auto-title, no "xem trên VNEWS" junk
|
| 12 |
5. Topic post uses source og:image instead of AI image
|
| 13 |
6. Fast homepage load (non-blocking)
|
| 14 |
+
7. Rewrite generates slides with images + auto short video trigger
|
| 15 |
"""
|
| 16 |
from ai_runtime_patch_fast import *
|
| 17 |
from ai_runtime_patch_fast import app, f5, f6, rt, PATCH_INJECT, _scrape, _domain, clean, _bg, _bg_home, _bg_shorts
|
|
|
|
| 75 |
def _ensure_img(img):
|
| 76 |
return img if (img and len(img)>20 and img.startswith('http')) else DEFAULT_IMG
|
| 77 |
|
| 78 |
+
def _make_slides_from_article(url, img, all_images):
|
| 79 |
+
"""Build slides from article paragraphs with images."""
|
| 80 |
+
slides = []
|
| 81 |
+
try:
|
| 82 |
+
art_data = f5.base.scrape_any_url(url)
|
| 83 |
+
if art_data:
|
| 84 |
+
imgs = art_data.get('images', [])
|
| 85 |
+
if not imgs and art_data.get('og_image'):
|
| 86 |
+
imgs = [art_data.get('og_image', '')]
|
| 87 |
+
if not imgs and img:
|
| 88 |
+
imgs = [img]
|
| 89 |
+
paragraphs = art_data.get('text', '').split('\n')
|
| 90 |
+
paragraphs = [p.strip() for p in paragraphs if len(p.strip()) > 60]
|
| 91 |
+
key_pts = paragraphs[:8]
|
| 92 |
+
if key_pts:
|
| 93 |
+
for i, pt in enumerate(key_pts):
|
| 94 |
+
slide_img = imgs[i] if i < len(imgs) else (imgs[-1] if imgs else img if img else '')
|
| 95 |
+
slides.append({'text': pt, 'image': slide_img, 'index': i + 1})
|
| 96 |
+
except:
|
| 97 |
+
if all_images:
|
| 98 |
+
for i, im in enumerate(all_images[:3]):
|
| 99 |
+
slides.append({'text': '', 'image': im, 'index': i + 1})
|
| 100 |
+
return slides
|
| 101 |
+
|
| 102 |
# ============================================================
|
| 103 |
# TOPIC POST (source image instead of AI image)
|
| 104 |
# ============================================================
|
|
|
|
| 124 |
return JSONResponse({'post':post})
|
| 125 |
|
| 126 |
# ============================================================
|
| 127 |
+
# REWRITE (auto-title, clean text) + SLIDES + SHORT
|
| 128 |
# ============================================================
|
| 129 |
@app.post('/api/rewrite_share')
|
| 130 |
@app.post('/api/url_wall')
|
|
|
|
| 135 |
if len(raw)<50:raw=ctx[:14000]
|
| 136 |
if len(raw)<50:return JSONResponse({'error':'Không đọc được bài'},status_code=422)
|
| 137 |
img=_ensure_img(img)
|
| 138 |
+
|
| 139 |
+
# Get ALL images from article for slides
|
| 140 |
+
all_images = []
|
| 141 |
+
slides = []
|
| 142 |
+
try:
|
| 143 |
+
art_data = f5.base.scrape_any_url(url)
|
| 144 |
+
if art_data:
|
| 145 |
+
all_images = art_data.get('images', [])
|
| 146 |
+
if not all_images and art_data.get('og_image'):
|
| 147 |
+
all_images = [art_data.get('og_image', '')]
|
| 148 |
+
if not all_images:
|
| 149 |
+
all_images = [img] if img and img.startswith('http') else []
|
| 150 |
+
# Build slides from paragraphs
|
| 151 |
+
paragraphs = art_data.get('text', '').split('\n')
|
| 152 |
+
paragraphs = [p.strip() for p in paragraphs if len(p.strip()) > 60]
|
| 153 |
+
key_pts = paragraphs[:8]
|
| 154 |
+
if key_pts:
|
| 155 |
+
for i, pt in enumerate(key_pts):
|
| 156 |
+
slide_img = all_images[i] if i < len(all_images) else (all_images[-1] if all_images else img if img else '')
|
| 157 |
+
slides.append({'text': pt, 'image': slide_img, 'index': i + 1})
|
| 158 |
+
except:
|
| 159 |
+
all_images = [img] if img and img.startswith('http') else []
|
| 160 |
+
|
| 161 |
prompt=f"""Tóm tắt bài viết thành bản tin ngắn. Dòng đầu tiên là tiêu đề mới hấp dẫn (tự đặt, không copy gốc).
|
| 162 |
|
| 163 |
Tiêu đề gốc: {title}
|
|
|
|
| 178 |
lines=text.strip().split('\n')
|
| 179 |
body='\n'.join(lines[1:]).strip() if lines and lines[0].strip()==ai_title else text
|
| 180 |
post=f5.base.make_post(ai_title,_clean_text(body),img,url,'rewrite',sources=[{'title':title,'url':url,'via':_domain(url)}])
|
| 181 |
+
post['images'] = all_images
|
| 182 |
+
post['slides'] = slides
|
| 183 |
ps=f5.base._load_ai_wall();ps.insert(0,post);f5.base._save_ai_wall(ps)
|
| 184 |
+
|
| 185 |
+
# Try generate short video in background
|
| 186 |
+
if post.get('text') and len(post.get('text','')) > 100:
|
| 187 |
+
try:
|
| 188 |
+
import auto_scheduler
|
| 189 |
+
threading.Thread(target=lambda: auto_scheduler._try_generate_short(post), daemon=True).start()
|
| 190 |
+
except:
|
| 191 |
+
pass
|
| 192 |
+
|
| 193 |
return JSONResponse({'post':post})
|
| 194 |
|
| 195 |
@app.post('/api/topic/rewrite')
|
|
|
|
| 257 |
.source-detail-item img{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:6px;margin-bottom:6px;background:#222}
|
| 258 |
.source-vnews-btn{display:inline-block;margin-top:6px;background:#2d8659;color:#fff;padding:4px 10px;border-radius:10px;font-size:10px;font-weight:700}
|
| 259 |
/* Livescore */
|
| 260 |
+
.ls-content{max-height:480px;overflow-y:auto;padding:0 6px 8px;font-size:12px;color:#ddd}.ls-content ul{list-style:none;padding:0;margin:0}.ls-content .title-content{display:flex;gap:6px;align-items:center;background:#222;border-radius:4px;margin:4px 0;padding:5px 8px}.ls-content .title-content img{width:18px;height:18px}.ls-content .title-content strong{font-size:11px;color:#ccc}.ls-content .match-detail{padding:6px;border-bottom:1px solid #262626;cursor:pointer}.ls-content .match-detail:hover{background:#1a2a1f}.ls-content .match{display:flex;flex-wrap:wrap;align-items:center;gap:4px}.ls-content .datetime{width:100%;font-size:9px;color:#888}.ls-content .teams{display:flex;width:100%;align-items:center;gap:4px}.ls-content .team{flex:1;display:flex;align-items:center;gap:4px;min-width:0}.ls-content .team .name{font-size:11px;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ls-content .team .logo img{width:18px;height:18px}.ls-content .home-team{justify-content:flex-end;text-align:right}.ls-content .status{flex:0 0 54px;text-align:center}.ls-content .status a{color:#fff;text-decoration:none;font-weight:800;font-size:12px}.ls-content .status .label{font-size:8px;color:#888;display:block}.ls-content .status .label.live{color:#e74c3c}.ls-content .info,.ls-content .btns{display:none}.ls-content table,.mo-body table{width:100%;border-collapse:collapse;font-size:11px;color:#ccc}.ls-content table th,.mo-body table th{background:#222;color:#999;padding:5px 4px;font-size:10px;border-bottom:1px solid #333}.ls-content table td,.ls-content table td{padding:4px 3px;border-bottom:1px solid #1a1a1a}.ls-content table .team-name,.mo-body table .team-name{display:flex;align-items:center;gap:4px}.ls-content table .team-name img,.mo-body table .team-name img{width:16px;height:16px}.ls-content table .pts{font-weight:800;color:#f0c040}.mo-body{padding:8px;font-size:12px;color:#ddd}.mo-body ul{list-style:none;padding:0}.mo-body li{padding:5px 0;border-bottom:1px solid #222}
|
| 261 |
</style>
|
| 262 |
<div id="short-progress-toast"></div>
|
| 263 |
<script>
|
|
|
|
| 329 |
body += getattr(f6,'FINAL6E_INJECT','') # Keep it — our PRE_KILL in UNIFIED neutralizes its destructive parts
|
| 330 |
body += PATCH_INJECT
|
| 331 |
body += UNIFIED_INJECT # This goes LAST and contains PRE_KILL at the TOP (runs first in browser)
|
| 332 |
+
return HTMLResponse(html.replace('</body>', body + '\n</body>') if '</body>' in html else html + body)
|
app_v2.js
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
app_v2_entry.py
CHANGED
|
@@ -12,11 +12,6 @@ try:
|
|
| 12 |
except Exception as e:
|
| 13 |
print(f"[WARN] ai_patch import failed: {e}")
|
| 14 |
|
| 15 |
-
try:
|
| 16 |
-
import ai_short_v2 # Short AI v2: TikTok music, uploaded audio/video/img, recreate from slides
|
| 17 |
-
except Exception as e:
|
| 18 |
-
print(f"[WARN] ai_short_v2 import failed: {e}")
|
| 19 |
-
|
| 20 |
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response
|
| 21 |
from fastapi.staticfiles import StaticFiles
|
| 22 |
from starlette.routing import Mount
|
|
@@ -38,23 +33,6 @@ app.router.routes=[r for r in app.router.routes if not isinstance(r, Mount)]
|
|
| 38 |
|
| 39 |
def _clean(s): return re.sub(r"\s+"," ",html_lib.unescape(str(s or""))).strip()
|
| 40 |
|
| 41 |
-
def _ensure_sentence_complete(text):
|
| 42 |
-
"""Ensure text ends with complete sentence (ends with . ! or ?). Trim if cut mid-sentence."""
|
| 43 |
-
text = _clean(text)
|
| 44 |
-
if not text:
|
| 45 |
-
return text
|
| 46 |
-
# Find last sentence ending
|
| 47 |
-
for end_char in ['.', '!', '?']:
|
| 48 |
-
last_pos = text.rfind(end_char)
|
| 49 |
-
if last_pos > len(text) * 0.5: # Keep if ending is in latter half
|
| 50 |
-
return text[:last_pos + 1].strip()
|
| 51 |
-
# If no ending found, try to find last complete sentence
|
| 52 |
-
sentences = re.split(r'(?<=[.!?])\s+', text)
|
| 53 |
-
complete = [s.strip() for s in sentences if s.strip() and len(s.strip()) > 20]
|
| 54 |
-
if complete[:-1]: # Return all but last incomplete
|
| 55 |
-
return ' '.join(complete[:-1])
|
| 56 |
-
return text[:150] + '.' if len(text) > 150 else text
|
| 57 |
-
|
| 58 |
# Cache for match details (5 min TTL)
|
| 59 |
_match_cache = {}
|
| 60 |
|
|
@@ -231,7 +209,7 @@ _STOP=set('và của các những một được trong với cho tại sau trư
|
|
| 231 |
def _has_kw(topic,title):
|
| 232 |
tl=topic.lower();tt=(title or'').lower()
|
| 233 |
if tl in tt:return True
|
| 234 |
-
words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',tl) if len(w)>2 and w
|
| 235 |
if not words:return True
|
| 236 |
return any(w in tt for w in words)
|
| 237 |
|
|
@@ -354,6 +332,11 @@ def _search_all(topic,limit=36):
|
|
| 354 |
for _path in ['/api/article', '/api/hot_topics', '/api/categories', '/api/storage_status']:
|
| 355 |
app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)==_path and 'GET' in getattr(r,'methods',set()))]
|
| 356 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
_article_cache = {}
|
| 358 |
_article_cache_ttl = 1800
|
| 359 |
|
|
@@ -480,39 +463,24 @@ def api_article_v2(url: str = Query(...)):
|
|
| 480 |
return JSONResponse({'error': f'Server error: {str(e)[:100]}', 'url': safe_url}, status_code=200)
|
| 481 |
|
| 482 |
_hot_cache={'t':0,'d':[]}
|
| 483 |
-
def _parse_feed(feed_url):
|
| 484 |
-
try:
|
| 485 |
-
r=req.get(feed_url,headers={'User-Agent':'Mozilla/5.0'},timeout=4)
|
| 486 |
-
r.encoding='utf-8';soup=BeautifulSoup(r.text,'xml')
|
| 487 |
-
out=[]
|
| 488 |
-
for item in soup.find_all('item')[:12]:
|
| 489 |
-
t=item.find('title')
|
| 490 |
-
title=_clean(t.get_text() if t else '')
|
| 491 |
-
if title: out.append(title)
|
| 492 |
-
return out
|
| 493 |
-
except Exception:
|
| 494 |
-
return []
|
| 495 |
def _get_hot_topics():
|
| 496 |
now=time.time()
|
| 497 |
if _hot_cache['d'] and now-_hot_cache['t']<600:return _hot_cache['d']
|
| 498 |
-
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']
|
| 499 |
-
titles=[]
|
| 500 |
-
with ThreadPoolExecutor(8) as ex:
|
| 501 |
-
futs=[ex.submit(_parse_feed,f) for f in feeds]
|
| 502 |
-
for f in as_completed(futs,timeout=5):
|
| 503 |
-
try:
|
| 504 |
-
for t in f.result(): titles.append(t)
|
| 505 |
-
except Exception: pass
|
| 506 |
freq={};display={}
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 516 |
ranked=sorted(freq.items(),key=lambda x:x[1],reverse=True);topics=[];seen=set()
|
| 517 |
for key,count in ranked:
|
| 518 |
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)
|
|
@@ -539,15 +507,7 @@ def _ht(topic:str=Query(...),page:int=Query(default=0)):
|
|
| 539 |
items=_search_all(topic,36);per_page=8;start=page*per_page;end=start+per_page
|
| 540 |
return JSONResponse({'sources':items[start:end],'topic':topic,'page':page,'has_more':end<len(items),'total':len(items)})
|
| 541 |
@app.get('/api/categories')
|
| 542 |
-
def _cat():
|
| 543 |
-
try:
|
| 544 |
-
from main import VNE_CATS
|
| 545 |
-
cats=[{"id":k,"name":n,"source":"vne"} for k,(u,n) in VNE_CATS.items()]
|
| 546 |
-
resp=JSONResponse(cats)
|
| 547 |
-
resp.headers["Cache-Control"]="public, max-age=600"
|
| 548 |
-
return resp
|
| 549 |
-
except Exception:
|
| 550 |
-
return JSONResponse([])
|
| 551 |
@app.get('/api/storage_status')
|
| 552 |
def _st():return JSONResponse({'persistent':os.path.isdir('/data') and os.access('/data',os.W_OK)})
|
| 553 |
# ===== SHARE HELPERS: render content pages for shared links =====
|
|
@@ -638,24 +598,33 @@ video{{width:100%;height:100%;max-height:100vh;object-fit:contain;background:#00
|
|
| 638 |
@app.get('/s/{slug}')
|
| 639 |
async def _sh_slug(slug: str, request: Request, url: str = '', title: str = '', img: str = ''):
|
| 640 |
"""SEO-friendly share endpoint with slug in URL path.
|
| 641 |
-
Shows slide content when
|
| 642 |
"""
|
| 643 |
safe_title = _clean(title) if title else 'VNEWS - Tin tức'
|
| 644 |
safe_img = _clean(img) if img else ''
|
| 645 |
safe_url = _clean(url) if url else '/'
|
| 646 |
|
| 647 |
-
# Try to find post by
|
| 648 |
post = None
|
| 649 |
try:
|
| 650 |
-
if
|
| 651 |
posts = _load_wall_posts()
|
| 652 |
for p in posts:
|
| 653 |
-
if p.get('
|
| 654 |
post = p
|
| 655 |
safe_title = p.get('title', safe_title) or safe_title
|
| 656 |
safe_img = p.get('img', safe_img) or safe_img
|
| 657 |
safe_url = p.get('url', safe_url) or safe_url
|
| 658 |
break
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 659 |
except:
|
| 660 |
pass
|
| 661 |
|
|
@@ -680,6 +649,21 @@ async def _sh_slug(slug: str, request: Request, url: str = '', title: str = '',
|
|
| 680 |
<meta http-equiv="refresh" content="0;url={safe_url}">
|
| 681 |
</head><body></body></html>''')
|
| 682 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 683 |
@app.get('/s')
|
| 684 |
async def _sh(url:str='',title:str='',img:str='',post_id:str=''):
|
| 685 |
safe_title = _clean(title) if title else 'VNEWS - Tin tức'
|
|
@@ -726,6 +710,17 @@ async def _sh(url:str='',title:str='',img:str='',post_id:str=''):
|
|
| 726 |
return _render_video_page(post, safe_title, safe_img, safe_url)
|
| 727 |
|
| 728 |
# Fallback: redirect to original URL
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 729 |
return HTMLResponse(f'''<!DOCTYPE html>
|
| 730 |
<html lang="vi">
|
| 731 |
<head>
|
|
@@ -851,7 +846,7 @@ def _lj(p):
|
|
| 851 |
try:
|
| 852 |
if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8'))
|
| 853 |
except:pass
|
| 854 |
-
return
|
| 855 |
def _sj(p,d):
|
| 856 |
try:open(p+'.tmp','w',encoding='utf-8').write(json.dumps(d,ensure_ascii=False));os.replace(p+'.tmp',p)
|
| 857 |
except:pass
|
|
@@ -881,273 +876,15 @@ async def _pc(request:Request):
|
|
| 881 |
|
| 882 |
def _load_wall_posts():
|
| 883 |
with _wl_lock:
|
| 884 |
-
|
| 885 |
-
if not isinstance(posts, list):
|
| 886 |
-
posts = []
|
| 887 |
-
return posts
|
| 888 |
|
| 889 |
def _save_wall_posts(posts):
|
| 890 |
with _wl_lock:
|
| 891 |
_sj(WALL_FILE, posts)
|
| 892 |
|
| 893 |
-
def _post_has_valid_image(p):
|
| 894 |
-
"""A wall post is considered to have a 'valid' (non-placeholder) image if
|
| 895 |
-
at least one of the following is a real, non-placeholder URL:
|
| 896 |
-
- p.img (main thumbnail)
|
| 897 |
-
- p.short_thumb (generated by Short AI / oEmbed fallback)
|
| 898 |
-
- p.images[] (fallback image list)
|
| 899 |
-
- p.slides[].image (designed slide images)
|
| 900 |
-
|
| 901 |
-
Auto posts that failed metadata enrichment — e.g. pollinations.ai
|
| 902 |
-
AI-generated placeholder images, vnexpress logo placeholders, or empty/None
|
| 903 |
-
fields — are flagged as having NO valid image so they can be filtered out
|
| 904 |
-
of the wall (they show the wrong picture vs the original article)."""
|
| 905 |
-
PLACEHOLDER_MARKERS = (
|
| 906 |
-
"pollinations.ai", # AI-generated editorial illustration placeholder
|
| 907 |
-
"logo_default.jpg", # vnexpress placeholder logo
|
| 908 |
-
"logo.png",
|
| 909 |
-
"/logo",
|
| 910 |
-
"placeholder",
|
| 911 |
-
"no-image",
|
| 912 |
-
"blank",
|
| 913 |
-
)
|
| 914 |
-
|
| 915 |
-
def _is_placeholder(url):
|
| 916 |
-
if not url or not isinstance(url, str):
|
| 917 |
-
return True
|
| 918 |
-
u = url.strip().lower()
|
| 919 |
-
if not u or u.startswith(("about:blank", "data:")):
|
| 920 |
-
return True
|
| 921 |
-
return any(m in u for m in PLACEHOLDER_MARKERS)
|
| 922 |
-
|
| 923 |
-
def _is_real(url):
|
| 924 |
-
return bool(url) and not _is_placeholder(url)
|
| 925 |
-
|
| 926 |
-
img = p.get("img")
|
| 927 |
-
if _is_real(img):
|
| 928 |
-
return True
|
| 929 |
-
thumb = p.get("short_thumb")
|
| 930 |
-
if _is_real(thumb):
|
| 931 |
-
return True
|
| 932 |
-
images = p.get("images") or []
|
| 933 |
-
if any(_is_real(x) for x in images):
|
| 934 |
-
return True
|
| 935 |
-
slides = p.get("slides") or []
|
| 936 |
-
if any(_is_real(s.get("image")) for s in slides if isinstance(s, dict)):
|
| 937 |
-
return True
|
| 938 |
-
return False
|
| 939 |
-
|
| 940 |
-
def _post_has_slide_info(p):
|
| 941 |
-
"""True if the post has usable slide/rewrite AI content:
|
| 942 |
-
- designed slides with text+image, OR
|
| 943 |
-
- a generated short video (/api/ai/short-file/) with a real thumbnail, OR
|
| 944 |
-
- an oEmbed iframe (YouTube/TikTok/Facebook embed) with a real thumbnail
|
| 945 |
-
(Short HOT fallback case — embeddable, viewable as a slide)."""
|
| 946 |
-
slides = p.get("slides") or []
|
| 947 |
-
if any((s.get("image") or "").strip() and (s.get("text") or "").strip() for s in slides if isinstance(s, dict)):
|
| 948 |
-
return True
|
| 949 |
-
# Generated short video with a real thumbnail
|
| 950 |
-
vid = (p.get("video") or "").strip()
|
| 951 |
-
if vid and "api/ai/short-file/" in vid and (p.get("short_thumb") or "").strip():
|
| 952 |
-
return True
|
| 953 |
-
# oEmbed iframe (YouTube/TikTok/FB) with a valid image -> viewable embed slide
|
| 954 |
-
if vid and ("youtube.com/embed" in vid or "tiktok.com/embed" in vid or
|
| 955 |
-
"facebook.com/plugins" in vid or "instagram.com" in vid or
|
| 956 |
-
"twitter.com" in vid or "x.com" in vid or "player.vimeo" in vid):
|
| 957 |
-
if _post_has_valid_image(p):
|
| 958 |
-
return True
|
| 959 |
-
return False
|
| 960 |
-
|
| 961 |
-
def _is_auto_post(p):
|
| 962 |
-
"""Auto-generated wall posts: Short HOT, oEmbed fallback, FPT auto,
|
| 963 |
-
slide-rewrite AI (auto_rewrite), topic auto-summary (topic_article), or
|
| 964 |
-
posts with no meaningful source / created by the AI pipeline."""
|
| 965 |
-
kind = (p.get("kind") or "").strip().lower()
|
| 966 |
-
source = (p.get("source") or "").strip().lower()
|
| 967 |
-
return kind in ("hot_short", "slide_summary", "auto_rewrite", "topic_article", "summary") or \
|
| 968 |
-
source in ("hot_short", "fptplay", "ai", "auto_rewrite", "topic_article")
|
| 969 |
-
|
| 970 |
-
def _created_ts(p):
|
| 971 |
-
"""Best-effort creation timestamp. Posts created by the AI pipeline often
|
| 972 |
-
store `created` as a millisecond epoch; some older posts (auto_rewrite)
|
| 973 |
-
omit it and instead embed the timestamp in the `id` (Unix ms). Fall back
|
| 974 |
-
to 0 so sorting never crashes."""
|
| 975 |
-
for field in ("created", "ts"):
|
| 976 |
-
v = p.get(field)
|
| 977 |
-
if v is not None:
|
| 978 |
-
try:
|
| 979 |
-
return int(v)
|
| 980 |
-
except (ValueError, TypeError):
|
| 981 |
-
pass
|
| 982 |
-
# try to derive from id (numeric prefix = Unix ms timestamp)
|
| 983 |
-
pid = str(p.get("id", "")).strip()
|
| 984 |
-
if pid.isdigit() and len(pid) >= 12:
|
| 985 |
-
try:
|
| 986 |
-
return int(pid)
|
| 987 |
-
except (ValueError, TypeError):
|
| 988 |
-
pass
|
| 989 |
-
return 0
|
| 990 |
-
|
| 991 |
-
def _wall_posts_for_view():
|
| 992 |
-
"""Apply the user-facing wall filtering + sorting:
|
| 993 |
-
1) Hide auto-generated posts that have a wrong/placeholder image (no
|
| 994 |
-
valid image at all — pollinations.ai, vnexpress logo, empty) or have
|
| 995 |
-
no slide-AI content (no slides / no short-video / no embed). These
|
| 996 |
-
are the 'bài đăng tự động bị sai ảnh / ko có thông tin slide AI' that
|
| 997 |
-
don't match the original article. This filter is VIEW-ONLY — it does
|
| 998 |
-
NOT delete from the persistent store.
|
| 999 |
-
2) Sort newest-first by creation timestamp, mixing all sources (FPT,
|
| 1000 |
-
slide rewrite AI, etc.) without source separation."""
|
| 1001 |
-
posts = _load_wall_posts()
|
| 1002 |
-
if not posts:
|
| 1003 |
-
return []
|
| 1004 |
-
filtered = []
|
| 1005 |
-
for p in posts:
|
| 1006 |
-
if not isinstance(p, dict):
|
| 1007 |
-
continue
|
| 1008 |
-
if _is_auto_post(p) and not (_post_has_valid_image(p) and _post_has_slide_info(p)):
|
| 1009 |
-
# auto post with a wrong/placeholder/missing image or no slide AI
|
| 1010 |
-
# content -> hide from the wall (non-destructive)
|
| 1011 |
-
continue
|
| 1012 |
-
filtered.append(p)
|
| 1013 |
-
filtered.sort(key=_created_ts, reverse=True)
|
| 1014 |
-
return filtered
|
| 1015 |
-
|
| 1016 |
-
@app.get("/api/ai/wall")
|
| 1017 |
-
def api_ai_wall():
|
| 1018 |
-
return JSONResponse({"posts": _wall_posts_for_view()})
|
| 1019 |
-
|
| 1020 |
-
|
| 1021 |
-
# ===== YOUTUBE RSS FEED PROXY (avoids browser CORS) =====
|
| 1022 |
-
_YT_FEED_CACHE = {}
|
| 1023 |
-
_YT_FEED_TTL = 600 # 10 minutes
|
| 1024 |
-
|
| 1025 |
-
|
| 1026 |
-
def _fetch_youtube_rss_feed(channel_id: str = "UC4LvrpNXujjbGOS4RDvr41g"):
|
| 1027 |
-
"""Fetch & parse the YouTube RSS feed server-side.
|
| 1028 |
-
|
| 1029 |
-
The browser cannot reach youtube.com directly (CORS / network block).
|
| 1030 |
-
We try two sources in order:
|
| 1031 |
-
1) Direct feed XML (works if the server has outbound YouTube access)
|
| 1032 |
-
2) rss2json.com proxy (always works — it fetches the feed server-side)
|
| 1033 |
-
Cached for _YT_FEED_TTL seconds."""
|
| 1034 |
-
now = time.time()
|
| 1035 |
-
cached = _YT_FEED_CACHE.get(channel_id)
|
| 1036 |
-
if cached and now - cached.get('_ts', 0) < _YT_FEED_TTL:
|
| 1037 |
-
return cached.get('videos', [])
|
| 1038 |
-
out = []
|
| 1039 |
-
try:
|
| 1040 |
-
# Attempt 1: direct YouTube RSS
|
| 1041 |
-
feed_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
|
| 1042 |
-
try:
|
| 1043 |
-
r = req.get(feed_url, headers=HEADERS, timeout=10)
|
| 1044 |
-
if r.status_code == 200 and r.text.strip():
|
| 1045 |
-
soup = BeautifulSoup(r.text, 'xml')
|
| 1046 |
-
for e in soup.find_all('entry'):
|
| 1047 |
-
out.extend(_parse_yt_entry(e))
|
| 1048 |
-
except Exception as ex2:
|
| 1049 |
-
print(f"[yt-feed] direct fetch failed, trying rss2json: {ex2}")
|
| 1050 |
-
# Attempt 2: rss2json proxy (fallback / always)
|
| 1051 |
-
if not out:
|
| 1052 |
-
rss2json_url = f"https://api.rss2json.com/v1/api.json?rss_url={feed_url}"
|
| 1053 |
-
r2 = req.get(rss2json_url, headers=HEADERS, timeout=15)
|
| 1054 |
-
if r2.status_code == 200:
|
| 1055 |
-
data = r2.json()
|
| 1056 |
-
if data.get('status') == 'ok':
|
| 1057 |
-
for item in data.get('items', []):
|
| 1058 |
-
vid_m = re.search(r'([a-zA-Z0-9_-]{11})', item.get('id', ''))
|
| 1059 |
-
if not vid_m:
|
| 1060 |
-
continue
|
| 1061 |
-
vid = vid_m.group(1)
|
| 1062 |
-
title = item.get('title', 'Video') or 'Video'
|
| 1063 |
-
# prefer the youtube watch URL, fallback to item link
|
| 1064 |
-
link = item.get('link', '')
|
| 1065 |
-
if '/watch' not in link:
|
| 1066 |
-
link = f'https://www.youtube.com/watch?v={vid}'
|
| 1067 |
-
# thumbnail: prefer media:thumbnail, fallback to youtube hqdefault
|
| 1068 |
-
thumb = item.get('thumbnail', f'https://i.ytimg.com/vi/{vid}/hqdefault.jpg')
|
| 1069 |
-
published = item.get('pubDate', '')
|
| 1070 |
-
out.append({
|
| 1071 |
-
'id': 'yt-' + vid,
|
| 1072 |
-
'videoId': vid,
|
| 1073 |
-
'title': title.strip()[:200],
|
| 1074 |
-
'link': link,
|
| 1075 |
-
'img': thumb,
|
| 1076 |
-
'published': published,
|
| 1077 |
-
'source': 'yt-feed'
|
| 1078 |
-
})
|
| 1079 |
-
if out:
|
| 1080 |
-
_YT_FEED_CACHE[channel_id] = {'_ts': now, 'videos': out}
|
| 1081 |
-
return out
|
| 1082 |
-
except Exception as ex:
|
| 1083 |
-
print(f"[yt-feed] server error: {ex}")
|
| 1084 |
-
return []
|
| 1085 |
-
|
| 1086 |
-
|
| 1087 |
-
def _parse_yt_entry(e):
|
| 1088 |
-
"""Parse a single YouTube RSS <entry> element into a video dict."""
|
| 1089 |
-
out = []
|
| 1090 |
-
try:
|
| 1091 |
-
id_el = e.find('id')
|
| 1092 |
-
if not id_el:
|
| 1093 |
-
return out
|
| 1094 |
-
vid_m = re.search(r'([a-zA-Z0-9_-]{11})', id_el.get_text(''))
|
| 1095 |
-
if not vid_m:
|
| 1096 |
-
return out
|
| 1097 |
-
vid = vid_m.group(1)
|
| 1098 |
-
title_el = e.find('title')
|
| 1099 |
-
title = title_el.get_text('') if title_el else 'Video'
|
| 1100 |
-
link_el = e.find('link', {'rel': 'alternate'})
|
| 1101 |
-
link = link_el.get('href') if link_el else f'https://www.youtube.com/watch?v={vid}'
|
| 1102 |
-
pub_el = e.find('published')
|
| 1103 |
-
published = pub_el.get_text('') if pub_el else ''
|
| 1104 |
-
ns = {'media': 'http://search.yahoo.com/mrss/'}
|
| 1105 |
-
thumb_el = e.find('media:thumbnail', ns)
|
| 1106 |
-
thumb_url = thumb_el.get('url') if thumb_el else f'https://i.ytimg.com/vi/{vid}/hqdefault.jpg'
|
| 1107 |
-
out.append({
|
| 1108 |
-
'id': 'yt-' + vid,
|
| 1109 |
-
'videoId': vid,
|
| 1110 |
-
'title': title.strip()[:200],
|
| 1111 |
-
'link': link,
|
| 1112 |
-
'img': thumb_url,
|
| 1113 |
-
'published': published,
|
| 1114 |
-
'source': 'yt-feed'
|
| 1115 |
-
})
|
| 1116 |
-
except Exception as ex:
|
| 1117 |
-
print(f"[yt-feed] parse entry error: {ex}")
|
| 1118 |
-
return out
|
| 1119 |
-
|
| 1120 |
-
|
| 1121 |
-
@app.get("/api/yt/feed")
|
| 1122 |
-
def api_yt_feed(channel_id: str = Query(default="UC4LvrpNXujjbGOS4RDvr41g")):
|
| 1123 |
-
"""Server-side YouTube RSS feed fetcher — avoids browser CORS.
|
| 1124 |
-
Returns parsed feed videos as JSON for the Tường AI wall."""
|
| 1125 |
-
videos = _fetch_youtube_rss_feed(channel_id)
|
| 1126 |
-
return JSONResponse({"videos": videos, "count": len(videos)})
|
| 1127 |
-
@app.post("/api/ai/short/{post_id}")
|
| 1128 |
-
def api_ai_short(post_id: str):
|
| 1129 |
-
"""Generate a short video for a wall post.
|
| 1130 |
-
Returns clear JSON error when video not available yet.
|
| 1131 |
-
"""
|
| 1132 |
-
post_id_s = str(post_id)
|
| 1133 |
-
posts = _load_wall_posts()
|
| 1134 |
-
if not isinstance(posts, list):
|
| 1135 |
-
posts = []
|
| 1136 |
-
post = None
|
| 1137 |
-
for p in posts:
|
| 1138 |
-
pid = str(p.get("id", ""))
|
| 1139 |
-
if pid == post_id_s or pid.startswith(post_id_s):
|
| 1140 |
-
post = p
|
| 1141 |
-
break
|
| 1142 |
-
if not post:
|
| 1143 |
-
return JSONResponse({"error": "Không tìm thấy bài viết", "post_id": post_id}, status_code=404)
|
| 1144 |
-
if post.get("video"):
|
| 1145 |
-
return JSONResponse({"video": post["video"], "post": post})
|
| 1146 |
-
return JSONResponse({"error": "Chưa có video cho bài này. Vui lòng upload video trước."}, status_code=409)
|
| 1147 |
-
|
| 1148 |
@app.get('/api/wall')
|
| 1149 |
def api_wall():
|
| 1150 |
-
posts =
|
| 1151 |
if not posts:
|
| 1152 |
return JSONResponse({"posts": []})
|
| 1153 |
return JSONResponse({"posts": posts})
|
|
@@ -1215,66 +952,22 @@ async def api_wall_post(request: Request):
|
|
| 1215 |
text = body.get('text', '') or ''
|
| 1216 |
img = body.get('img', None)
|
| 1217 |
source = body.get('source', 'user') or 'user'
|
| 1218 |
-
incoming_id = body.get('id') or '' # allow client to (re)publish an existing post by id
|
| 1219 |
post_id = str(uuid.uuid4())[:12]
|
| 1220 |
-
# Preserve slide-design post fields (slides, kind, url, images, video, voice, etc.)
|
| 1221 |
-
# so that "Thiết kế ảnh" -> "Đăng lên Tường AI" keeps the selected slides.
|
| 1222 |
-
slides = body.get('slides')
|
| 1223 |
-
kind = body.get('kind') or 'user'
|
| 1224 |
-
post_url = body.get('url') or ''
|
| 1225 |
-
images = body.get('images') or []
|
| 1226 |
-
video = body.get('video')
|
| 1227 |
-
voice = body.get('voice') or ''
|
| 1228 |
-
emotion = body.get('emotion') or ''
|
| 1229 |
-
language = body.get('language') or ''
|
| 1230 |
post = {
|
| 1231 |
"id": post_id,
|
| 1232 |
"title": title[:200],
|
| 1233 |
"text": text[:2000],
|
| 1234 |
"source": source,
|
| 1235 |
-
"video":
|
| 1236 |
"img": img,
|
| 1237 |
-
"images":
|
| 1238 |
-
"url": post_url,
|
| 1239 |
-
"kind": kind,
|
| 1240 |
-
"slides": slides if slides is not None else None,
|
| 1241 |
-
"voice": voice,
|
| 1242 |
-
"emotion": emotion,
|
| 1243 |
-
"language": language,
|
| 1244 |
"created": int(time.time()),
|
| 1245 |
"created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
|
| 1246 |
}
|
| 1247 |
-
# Remove keys with None so the JSON stays lean (but keep structure)
|
| 1248 |
-
post = {k: v for k, v in post.items() if v is not None}
|
| 1249 |
posts = _load_wall_posts()
|
| 1250 |
if not isinstance(posts, list):
|
| 1251 |
posts = []
|
| 1252 |
-
|
| 1253 |
-
# the original post id), update that post in place instead of creating a
|
| 1254 |
-
# duplicate. This keeps the homepage wall clean (no duplicate slide posts).
|
| 1255 |
-
updated = False
|
| 1256 |
-
if incoming_id:
|
| 1257 |
-
for _p in posts:
|
| 1258 |
-
if str(_p.get('id')) == str(incoming_id):
|
| 1259 |
-
# Merge new fields into the existing post, but PRESERVE the
|
| 1260 |
-
# original id (and created timestamp) so the post identity is
|
| 1261 |
-
# stable and no duplicate is created.
|
| 1262 |
-
preserve_id = _p.get('id')
|
| 1263 |
-
preserve_created = _p.get('created')
|
| 1264 |
-
preserve_created_str = _p.get('created_str')
|
| 1265 |
-
_p.update(post)
|
| 1266 |
-
_p['id'] = preserve_id
|
| 1267 |
-
if preserve_created is not None: _p['created'] = preserve_created
|
| 1268 |
-
if preserve_created_str is not None: _p['created_str'] = preserve_created_str
|
| 1269 |
-
updated = True
|
| 1270 |
-
post = _p # return the merged post (with stable id) to the client
|
| 1271 |
-
break
|
| 1272 |
-
if not updated:
|
| 1273 |
-
posts.insert(0, post)
|
| 1274 |
-
else:
|
| 1275 |
-
# Move the updated post to the top so it re-appears at the top of the wall.
|
| 1276 |
-
posts = [p for p in posts if str(p.get('id')) != str(incoming_id)]
|
| 1277 |
-
posts.insert(0, post)
|
| 1278 |
posts = posts[:200]
|
| 1279 |
_save_wall_posts(posts)
|
| 1280 |
return JSONResponse({"post": post, "ok": True})
|
|
@@ -1290,57 +983,6 @@ def api_wall_video(filename: str):
|
|
| 1290 |
media_type = 'video/mp4' if ext == '.mp4' else 'video/webm'
|
| 1291 |
return FileResponse(video_path, media_type=media_type)
|
| 1292 |
|
| 1293 |
-
WALL_IMG_DIR = os.path.join(DATA_DIR, 'wall_imgs')
|
| 1294 |
-
os.makedirs(WALL_IMG_DIR, exist_ok=True)
|
| 1295 |
-
|
| 1296 |
-
@app.post('/api/wall/img')
|
| 1297 |
-
async def api_wall_img(request: Request):
|
| 1298 |
-
"""Upload a designed slide image (PNG) and return a served URL."""
|
| 1299 |
-
global WALL_IMG_DIR
|
| 1300 |
-
try:
|
| 1301 |
-
form = await request.form()
|
| 1302 |
-
f = form.get('file')
|
| 1303 |
-
if not f or not hasattr(f, 'filename') or not f.filename:
|
| 1304 |
-
return JSONResponse({"error": "Thiếu file ảnh"}, status_code=400)
|
| 1305 |
-
ext = os.path.splitext(f.filename)[1].lower()
|
| 1306 |
-
if ext not in ('.png', '.jpg', '.jpeg', '.webp'):
|
| 1307 |
-
ext = '.png'
|
| 1308 |
-
img_id = str(uuid.uuid4())[:12]
|
| 1309 |
-
fname = f"wallimg_{img_id}{ext}"
|
| 1310 |
-
fpath = os.path.join(WALL_IMG_DIR, fname)
|
| 1311 |
-
content = await f.read()
|
| 1312 |
-
if not content:
|
| 1313 |
-
return JSONResponse({"error": "File rỗng"}, status_code=400)
|
| 1314 |
-
if len(content) > 15 * 1024 * 1024:
|
| 1315 |
-
return JSONResponse({"error": "Ảnh quá lớn (>15MB)"}, status_code=400)
|
| 1316 |
-
with open(fpath, 'wb') as fh:
|
| 1317 |
-
fh.write(content)
|
| 1318 |
-
post_id = (form.get('post_id') or '').strip()
|
| 1319 |
-
if post_id:
|
| 1320 |
-
posts = _load_wall_posts()
|
| 1321 |
-
if isinstance(posts, list):
|
| 1322 |
-
for p in posts:
|
| 1323 |
-
if str(p.get('id')) == str(post_id):
|
| 1324 |
-
p['img'] = f"/api/wall/img/{fname}"
|
| 1325 |
-
break
|
| 1326 |
-
_save_wall_posts(posts)
|
| 1327 |
-
url = f"/api/wall/img/{fname}"
|
| 1328 |
-
return JSONResponse({"ok": True, "url": url, "img_id": img_id})
|
| 1329 |
-
except Exception as e:
|
| 1330 |
-
return JSONResponse({"error": f"Lỗi upload ảnh: {str(e)[:150]}"}, status_code=500)
|
| 1331 |
-
|
| 1332 |
-
|
| 1333 |
-
@app.get('/api/wall/img/{fname}')
|
| 1334 |
-
def api_wall_img_file(fname: str):
|
| 1335 |
-
if '..' in fname or '/' in fname:
|
| 1336 |
-
return Response(status_code=403)
|
| 1337 |
-
img_path = os.path.join(WALL_IMG_DIR, fname)
|
| 1338 |
-
if not os.path.exists(img_path):
|
| 1339 |
-
return Response(status_code=404)
|
| 1340 |
-
ext = os.path.splitext(fname)[1].lower()
|
| 1341 |
-
media_type = 'image/png' if ext == '.png' else ('image/jpeg' if ext in ('.jpg', '.jpeg') else 'image/webp')
|
| 1342 |
-
return FileResponse(img_path, media_type=media_type)
|
| 1343 |
-
|
| 1344 |
@app.delete('/api/wall/{post_id}')
|
| 1345 |
def api_wall_delete(post_id: str):
|
| 1346 |
posts = _load_wall_posts()
|
|
@@ -1358,41 +1000,6 @@ def api_wall_delete(post_id: str):
|
|
| 1358 |
return JSONResponse({"ok": True})
|
| 1359 |
return JSONResponse({"error": "Post not found"}, status_code=404)
|
| 1360 |
|
| 1361 |
-
@app.post('/api/wall/cleanup')
|
| 1362 |
-
def api_wall_cleanup():
|
| 1363 |
-
"""Permanently remove auto-generated wall posts that are completely
|
| 1364 |
-
unrecoverable (no valid image, no video, no slides, no embed — i.e. a
|
| 1365 |
-
post that can never be displayed), and sort the remaining posts
|
| 1366 |
-
newest-first. Returns the number of removed posts and the new count.
|
| 1367 |
-
|
| 1368 |
-
NOTE: Conservative — auto posts that HAVE a valid image or a video/embed
|
| 1369 |
-
are preserved (they remain visible at view time via
|
| 1370 |
-
_wall_posts_for_view). Only use this to purge genuine garbage."""
|
| 1371 |
-
posts = _load_wall_posts()
|
| 1372 |
-
if not isinstance(posts, list):
|
| 1373 |
-
return JSONResponse({"error": "No posts"}, status_code=404)
|
| 1374 |
-
before = len(posts)
|
| 1375 |
-
kept = []
|
| 1376 |
-
removed = []
|
| 1377 |
-
for p in posts:
|
| 1378 |
-
if not isinstance(p, dict):
|
| 1379 |
-
removed.append(str(p))
|
| 1380 |
-
continue
|
| 1381 |
-
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():
|
| 1382 |
-
removed.append(p.get('id', '?'))
|
| 1383 |
-
else:
|
| 1384 |
-
kept.append(p)
|
| 1385 |
-
# sort newest-first
|
| 1386 |
-
kept.sort(key=_created_ts, reverse=True)
|
| 1387 |
-
_save_wall_posts(kept)
|
| 1388 |
-
return JSONResponse({
|
| 1389 |
-
"ok": True,
|
| 1390 |
-
"before": before,
|
| 1391 |
-
"removed_count": before - len(kept),
|
| 1392 |
-
"removed_ids": removed,
|
| 1393 |
-
"after": len(kept),
|
| 1394 |
-
})
|
| 1395 |
-
|
| 1396 |
# ===== LANGUAGE & EMOTION DETECTION =====
|
| 1397 |
import random as _random2
|
| 1398 |
from urllib.parse import quote as _quote2
|
|
@@ -1803,15 +1410,8 @@ async def api_rewrite_slide(request: Request):
|
|
| 1803 |
return JSONResponse({"post": post, "slides": slides})
|
| 1804 |
|
| 1805 |
|
| 1806 |
-
|
| 1807 |
-
|
| 1808 |
-
"""Rewrite article and post to Tường AI with SLIDES + AI text."""
|
| 1809 |
-
body = await request.json()
|
| 1810 |
-
url = _clean(body.get("url", ""))
|
| 1811 |
-
ctx = _clean(body.get("context", ""))
|
| 1812 |
-
preferred_voice = body.get("voice", "") # Accept custom voice selection
|
| 1813 |
-
if not url and not ctx:
|
| 1814 |
-
return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
|
| 1815 |
data = None
|
| 1816 |
if url and url.startswith("http"):
|
| 1817 |
data = _scrape_article_for_rewrite(url)
|
|
@@ -1819,12 +1419,12 @@ async def api_rewrite_share(request: Request):
|
|
| 1819 |
paragraphs = [_clean(p) for p in ctx.split('\n') if len(_clean(p)) > 40]
|
| 1820 |
data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
|
| 1821 |
if not data or not data.get('paragraphs'):
|
| 1822 |
-
return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
|
| 1823 |
raw_text = '\n'.join(data['paragraphs'])
|
| 1824 |
if len(raw_text) < 50:
|
| 1825 |
raw_text = ctx[:14000]
|
| 1826 |
if len(raw_text) < 50:
|
| 1827 |
-
return JSONResponse({"error": "Bài viết quá ngắn"}, status_code=422)
|
| 1828 |
domain = ''
|
| 1829 |
try:
|
| 1830 |
from urllib.parse import urlparse
|
|
@@ -1848,7 +1448,7 @@ async def api_rewrite_share(request: Request):
|
|
| 1848 |
else:
|
| 1849 |
ai_text = f"Tóm tắt: {data['title']}\n\n{raw_text[:1200]}\n\nNguồn: {domain}"
|
| 1850 |
|
| 1851 |
-
# Build slides from key points
|
| 1852 |
points = _extract_key_points_rw(data['paragraphs'], max_points=12)
|
| 1853 |
images = data.get('images', [])
|
| 1854 |
slides = []
|
|
@@ -1878,10 +1478,25 @@ async def api_rewrite_share(request: Request):
|
|
| 1878 |
"language": lang,
|
| 1879 |
"ts": int(time.time())
|
| 1880 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1881 |
posts = _load_wall_posts()
|
| 1882 |
posts.insert(0, post)
|
| 1883 |
_save_wall_posts(posts)
|
| 1884 |
-
return JSONResponse({"post": post, "slides": slides})
|
| 1885 |
|
| 1886 |
|
| 1887 |
@app.post("/api/url_wall")
|
|
@@ -1891,517 +1506,14 @@ async def api_url_wall(request: Request):
|
|
| 1891 |
url = _clean(body.get("url", ""))
|
| 1892 |
if not url or not url.startswith('http'):
|
| 1893 |
return JSONResponse({"error": "URL không hợp lệ"}, status_code=400)
|
| 1894 |
-
|
| 1895 |
-
|
| 1896 |
-
|
| 1897 |
-
|
| 1898 |
-
|
| 1899 |
-
# ===== PERSONAL OPINION POST v2: AI tổng hợp bài viết từ quan điểm + nguồn tin HOT =====
|
| 1900 |
-
|
| 1901 |
-
# ===== KEYWORD EXTRACTION FROM OPINION =====
|
| 1902 |
-
_STOP_WORDS_EX = set("""
|
| 1903 |
-
và của các những một được trong với cho tại sau trước khi không người
|
| 1904 |
-
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ì
|
| 1905 |
-
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
|
| 1906 |
-
đang vào ra hơn đây đó nào cả cùng đã từng hãy còn chỉ cũng đều khiến
|
| 1907 |
-
đượ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
|
| 1908 |
-
ông bà thầy vì vậy chính phải ấy đấy đâu đó thôi nhé đấy ạ nhỉ
|
| 1909 |
-
ngày tháng năm giờ phút giây tuần tháng quý
|
| 1910 |
-
""".strip().split())
|
| 1911 |
-
|
| 1912 |
-
def _extract_keywords_from_opinion(text, max_keywords=5):
|
| 1913 |
-
"""Extract meaningful keywords from user's opinion for news search."""
|
| 1914 |
-
if not text:
|
| 1915 |
-
return []
|
| 1916 |
-
text = text.lower()
|
| 1917 |
-
text = re.sub(r'https?://\S+', '', text)
|
| 1918 |
-
text = re.sub(r'[^\w\sÀ-ỹ]', ' ', text)
|
| 1919 |
-
text = re.sub(r'\s+', ' ', text).strip()
|
| 1920 |
-
words = [w for w in text.split() if len(w) > 2 and w not in _STOP_WORDS_EX]
|
| 1921 |
-
word_scores = {}
|
| 1922 |
-
for w in words:
|
| 1923 |
-
word_scores[w] = word_scores.get(w, 0) + 1
|
| 1924 |
-
sorted_words = sorted(word_scores.items(), key=lambda x: -x[1])
|
| 1925 |
-
top_words = [w for w, s in sorted_words[:max_keywords]]
|
| 1926 |
-
phrases = []
|
| 1927 |
-
for i in range(len(words) - 1):
|
| 1928 |
-
phrase = words[i] + ' ' + words[i + 1]
|
| 1929 |
-
if len(phrase) > 5:
|
| 1930 |
-
phrases.append(phrase)
|
| 1931 |
-
phrase_scores = {}
|
| 1932 |
-
for p in phrases:
|
| 1933 |
-
phrase_scores[p] = phrase_scores.get(p, 0) + 1
|
| 1934 |
-
sorted_phrases = sorted(phrase_scores.items(), key=lambda x: -x[1])
|
| 1935 |
-
top_phrases = [p for p, s in sorted_phrases[:3]]
|
| 1936 |
-
result = []
|
| 1937 |
-
for p in top_phrases:
|
| 1938 |
-
if p not in result:
|
| 1939 |
-
result.append(p)
|
| 1940 |
-
for w in top_words:
|
| 1941 |
-
if w not in result:
|
| 1942 |
-
result.append(w)
|
| 1943 |
-
return result[:max_keywords]
|
| 1944 |
-
|
| 1945 |
-
|
| 1946 |
-
@app.post("/api/personal_post/preview")
|
| 1947 |
-
async def api_personal_post_preview(request: Request):
|
| 1948 |
-
"""Preview personal post: fetch full articles, let AI compose logical article with images."""
|
| 1949 |
-
body = await request.json()
|
| 1950 |
-
opinion = _clean(body.get("opinion", ""))
|
| 1951 |
-
selected_topics = body.get("selected_topics", []) or []
|
| 1952 |
-
selected_sources = body.get("selected_sources", []) or []
|
| 1953 |
-
|
| 1954 |
-
if not opinion or len(opinion) < 10:
|
| 1955 |
-
return JSONResponse({"error": "Quan điểm cá nhân quá ngắn (cần ít nhất 10 ký tự)"}, status_code=400)
|
| 1956 |
-
|
| 1957 |
-
# Lấy keywords từ QUAN ĐIỂM CÁ NHÂN để tìm nguồn tin chính xác
|
| 1958 |
-
keywords = _extract_keywords_from_opinion(opinion, max_keywords=5)
|
| 1959 |
-
if keywords:
|
| 1960 |
-
selected_topics = keywords[:3]
|
| 1961 |
-
else:
|
| 1962 |
-
# Fallback: hot topics
|
| 1963 |
-
hot = _get_hot_topics()
|
| 1964 |
-
selected_topics = [t.get("topic", "") for t in hot[:3] if t.get("topic")]
|
| 1965 |
-
|
| 1966 |
-
# Tìm nguồn tin
|
| 1967 |
-
all_sources = []
|
| 1968 |
-
seen_urls = set()
|
| 1969 |
-
for topic in selected_topics[:3]:
|
| 1970 |
-
sources = _search_all(topic, limit=5)
|
| 1971 |
-
for s in sources:
|
| 1972 |
-
if s.get("url") and s["url"] not in seen_urls:
|
| 1973 |
-
seen_urls.add(s["url"])
|
| 1974 |
-
all_sources.append(s)
|
| 1975 |
-
if len(all_sources) >= 6:
|
| 1976 |
-
break
|
| 1977 |
-
if len(all_sources) >= 6:
|
| 1978 |
-
break
|
| 1979 |
-
|
| 1980 |
-
for src in selected_sources:
|
| 1981 |
-
if src.get("url") and src["url"] not in seen_urls:
|
| 1982 |
-
all_sources.insert(0, src)
|
| 1983 |
-
|
| 1984 |
-
# Scrape nội dung đầy đủ từng nguồn (paragraphs + images)
|
| 1985 |
-
source_details = []
|
| 1986 |
-
source_images = []
|
| 1987 |
-
for src in all_sources[:5]:
|
| 1988 |
-
url = src.get("url", "")
|
| 1989 |
-
if not url:
|
| 1990 |
-
continue
|
| 1991 |
-
try:
|
| 1992 |
-
art = _scrape_article_for_rewrite(url)
|
| 1993 |
-
if art:
|
| 1994 |
-
src_detail = {
|
| 1995 |
-
"title": art.get("title", src.get("title", "")),
|
| 1996 |
-
"url": url,
|
| 1997 |
-
"via": src.get("via", ""),
|
| 1998 |
-
"paragraphs": art.get("paragraphs", [])[:8],
|
| 1999 |
-
"images": art.get("images", [])[:3],
|
| 2000 |
-
"og_image": art.get("og_img", "")
|
| 2001 |
-
}
|
| 2002 |
-
source_details.append(src_detail)
|
| 2003 |
-
# Collect images for proxy
|
| 2004 |
-
for img in art.get("images", [])[:2]:
|
| 2005 |
-
if any(x in img for x in ["cdnphoto.dantri", "vnexpress", "vcdn", "refooty"]):
|
| 2006 |
-
img = "/api/proxy/img?url=" + _quote2(img, safe="")
|
| 2007 |
-
source_images.append(img)
|
| 2008 |
-
except:
|
| 2009 |
-
pass
|
| 2010 |
-
if len(source_details) >= 5:
|
| 2011 |
-
break
|
| 2012 |
-
|
| 2013 |
-
# Tạo title từ opinion
|
| 2014 |
-
opinion_words = re.findall(r"[A-Za-zÀ-ỹ0-9]+", opinion)
|
| 2015 |
-
title_words = opinion_words[:8] if len(opinion_words) >= 8 else opinion_words[:4]
|
| 2016 |
-
title = " ".join([w[0].upper() + w[1:] for w in title_words]) if title_words else "Quan điểm cá nhân"
|
| 2017 |
-
title = title[:80]
|
| 2018 |
-
|
| 2019 |
-
# AI sinh bài viết hoàn chỉnh
|
| 2020 |
-
ai_text = None
|
| 2021 |
-
try:
|
| 2022 |
-
import ai_ext
|
| 2023 |
-
if hasattr(ai_ext, 'qwen_generate'):
|
| 2024 |
-
# Build detailed context from source articles
|
| 2025 |
-
source_context = ""
|
| 2026 |
-
for i, sd in enumerate(source_details[:5]):
|
| 2027 |
-
src_title = sd.get("title", "")
|
| 2028 |
-
src_via = sd.get("via", "")
|
| 2029 |
-
src_paras = sd.get("paragraphs", [])
|
| 2030 |
-
source_context += f"\n=== Nguồn {i+1}: {src_title} ({src_via}) ===\n"
|
| 2031 |
-
for j, p in enumerate(src_paras[:4]):
|
| 2032 |
-
source_context += f" - {p[:300]}\n"
|
| 2033 |
-
|
| 2034 |
-
prompt = (
|
| 2035 |
-
"QUAN ĐIỂM: " + opinion[:500] + "\nNGUỒN: " + source_context[:1000] + "\n\n"
|
| 2036 |
-
"=== NGUỒN TIN THAM KHẢO ===\n" + source_context + "\n\n"
|
| 2037 |
-
"=== YÊU CẦU VIẾT BÀI THEO SLIDE ===\n"
|
| 2038 |
-
"Viết bài thành 5-6 ĐOẠN VĂN NGẮN, mỗi đoạn là 1 SLIDE.\n"
|
| 2039 |
-
"\n"
|
| 2040 |
-
"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"
|
| 2041 |
-
"\n"
|
| 2042 |
-
"SLIDE 1 - MỞ ĐẦU:\n"
|
| 2043 |
-
"- NHIỆN HỮU QUAN ĐIỂM CÁ NHÂN LÊN ĐẦU\n"
|
| 2044 |
-
"- 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"
|
| 2045 |
-
"- 2-4 câu hoàn chỉnh\n"
|
| 2046 |
-
"\n"
|
| 2047 |
-
"SLIDE 2-3-4-5 - PHÂN TÍCH:\n"
|
| 2048 |
-
"- Mỗi slide: B�Commencer bằng QUAN ĐIỂM CÁ NHÂN, sau đó dẫn chứng từ 1 nguồn tin\n"
|
| 2049 |
-
"- Ví dụ: \"Theo quan điểm của tôi, đây là vấn đề cần lưu ý. Theo VnExpress...\"\n"
|
| 2050 |
-
"- Dẫn chứng từ nguồn (ghi rõ tên báo: Theo VnExpress, Theo Thanh Niên...)\n"
|
| 2051 |
-
"- 2-4 câu hoàn chỉnh mỗi slide\n"
|
| 2052 |
-
"\n"
|
| 2053 |
-
"SLIDE 6 - KẾT LUẬN:\n"
|
| 2054 |
-
"- Tổng kết quan điểm cá nhân, đưa ra nhận định cuối cùng\n"
|
| 2055 |
-
"- 2-3 câu hoàn chỉnh\n"
|
| 2056 |
-
"\n"
|
| 2057 |
-
"Định dạng đầu ra:\n"
|
| 2058 |
-
"---SLIDE 1---\n"
|
| 2059 |
-
"[nội dung đoạn văn slide 1]\n"
|
| 2060 |
-
"---SLIDE 2---\n"
|
| 2061 |
-
"[nội dung đoạn văn slide 2]\n"
|
| 2062 |
-
"...v.v...\n"
|
| 2063 |
-
"\n"
|
| 2064 |
-
"QUAN TRỌNG:\n"
|
| 2065 |
-
"- Mỗi slide là 1 đoạn văn HOÀN CHỈNH, 2-4 câu\n"
|
| 2066 |
-
"- PHẢI KẾT THÚC BẰNG DẤU CHẤM (.) HOẢN TOÀN\n"
|
| 2067 |
-
"- Kết hợp QUAN ĐIỂM CÁ NHÂN với NỘI DUNG NGUỒN TIN\n"
|
| 2068 |
-
"- Không gạch đầu dòng, không bullet points\n"
|
| 2069 |
-
"- Viết liền mạch tự nhiên, giọng văn báo chí\n"
|
| 2070 |
-
"- Mỗi slide phải khác nhau, không lặp ý\n"
|
| 2071 |
-
"- Độ dài: 300-600 từ"
|
| 2072 |
-
)
|
| 2073 |
-
ai_text = None # Không dùng AI, để code tự kết hợp opinion + source
|
| 2074 |
-
except:
|
| 2075 |
-
pass
|
| 2076 |
-
|
| 2077 |
-
if not ai_text or len(ai_text) < 100:
|
| 2078 |
-
# Fallback: build article manually
|
| 2079 |
-
ai_text = "## " + title + "\n\n"
|
| 2080 |
-
ai_text += opinion + "\n\n"
|
| 2081 |
-
for i, sd in enumerate(source_details[:5]):
|
| 2082 |
-
ai_text += "### " + sd.get("title", f"Nguồn {i+1}") + "\n"
|
| 2083 |
-
for p in sd.get("paragraphs", [])[:3]:
|
| 2084 |
-
ai_text += p[:250] + "\n"
|
| 2085 |
-
ai_text += "*Nguồn: " + sd.get("via", "") + "*\n\n"
|
| 2086 |
-
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*"
|
| 2087 |
-
|
| 2088 |
-
# Parse slides từ AI output (format: ---SLIDE N--- content)
|
| 2089 |
-
slides = []
|
| 2090 |
-
if ai_text:
|
| 2091 |
-
# Try to parse the ---SLIDE--- format
|
| 2092 |
-
pattern = r'---SLIDE\s*(\d+)---\s*\n(.*?)(?=---SLIDE|\Z)'
|
| 2093 |
-
matches = re.findall(pattern, ai_text, re.DOTALL)
|
| 2094 |
-
|
| 2095 |
-
if matches:
|
| 2096 |
-
for idx, (num, content) in enumerate(matches):
|
| 2097 |
-
# Normalize: ensure complete sentences
|
| 2098 |
-
text = _ensure_sentence_complete(content)
|
| 2099 |
-
if len(text) > 40:
|
| 2100 |
-
img = source_images[idx] if idx < len(source_images) else ""
|
| 2101 |
-
slides.append({"text": text, "image": img, "index": idx + 1})
|
| 2102 |
-
|
| 2103 |
-
# If we have parsed slides, ensure minimum 3
|
| 2104 |
-
if len(slides) < 3:
|
| 2105 |
-
# Use parsed slides as base, fill remaining from AI text
|
| 2106 |
-
used_indices = set()
|
| 2107 |
-
for s in slides:
|
| 2108 |
-
used_indices.add(s['index'] - 1)
|
| 2109 |
-
|
| 2110 |
-
# Split remaining AI text into more slides
|
| 2111 |
-
sentences = re.split(r'(?<=[.!?])\s+', ai_text)
|
| 2112 |
-
current_chunk = ""
|
| 2113 |
-
next_idx = len(slides)
|
| 2114 |
-
|
| 2115 |
-
for sent in sentences:
|
| 2116 |
-
sent = _ensure_sentence_complete(sent)
|
| 2117 |
-
if len(sent) < 20:
|
| 2118 |
-
continue
|
| 2119 |
-
|
| 2120 |
-
# Skip if this sentence is already in parsed slides
|
| 2121 |
-
found = False
|
| 2122 |
-
for slide in slides:
|
| 2123 |
-
if sent[:50] in slide['text']:
|
| 2124 |
-
found = True
|
| 2125 |
-
break
|
| 2126 |
-
|
| 2127 |
-
if found:
|
| 2128 |
-
continue
|
| 2129 |
-
|
| 2130 |
-
if current_chunk and len(current_chunk + " " + sent) <= 380:
|
| 2131 |
-
current_chunk += " " + sent
|
| 2132 |
-
else:
|
| 2133 |
-
if len(current_chunk) > 50:
|
| 2134 |
-
img = source_images[next_idx] if next_idx < len(source_images) else ""
|
| 2135 |
-
slides.append({"text": current_chunk, "image": img, "index": next_idx + 1})
|
| 2136 |
-
current_chunk = sent
|
| 2137 |
-
next_idx += 1
|
| 2138 |
-
|
| 2139 |
-
# Add final chunk
|
| 2140 |
-
if len(current_chunk) > 50 and next_idx < 6:
|
| 2141 |
-
img = source_images[next_idx] if next_idx < len(source_images) else ""
|
| 2142 |
-
slides.append({"text": current_chunk, "image": img, "index": next_idx + 1})
|
| 2143 |
-
|
| 2144 |
-
# Ultimate fallback: create slides from opinion + source
|
| 2145 |
-
if len(slides) < 2:
|
| 2146 |
-
slides = []
|
| 2147 |
-
# Slide 1: opinion
|
| 2148 |
-
if opinion and len(opinion) > 20:
|
| 2149 |
-
slides.append({"text": opinion[:450], "image": source_images[0] if source_images else "", "index": 1})
|
| 2150 |
-
|
| 2151 |
-
# Slide 2-6: from AI text or sources
|
| 2152 |
-
if ai_text:
|
| 2153 |
-
sentences = re.split(r'(?<=[.!?])\s+', ai_text)
|
| 2154 |
-
for i, sent in enumerate(sentences[:5]):
|
| 2155 |
-
text = _ensure_sentence_complete(_clean(sent))
|
| 2156 |
-
if len(text) > 60:
|
| 2157 |
-
if len(slides) < 6:
|
| 2158 |
-
img = source_images[len(slides)] if len(slides) < len(source_images) else ""
|
| 2159 |
-
slides.append({"text": text, "image": img, "index": len(slides) + 1})
|
| 2160 |
-
|
| 2161 |
-
# Fill remaining with key points from sources - KẾT HỢP VỚI QUAN ĐIỂM CÁ NHÂN
|
| 2162 |
-
src_idx = len(slides)
|
| 2163 |
-
while len(slides) < 4 and src_idx < len(source_details):
|
| 2164 |
-
paragraphs = source_details[src_idx].get("paragraphs", [])
|
| 2165 |
-
src_title = source_details[src_idx].get("title", "")
|
| 2166 |
-
src_via = source_details[src_idx].get("via", "")
|
| 2167 |
-
for p in paragraphs[:2]:
|
| 2168 |
-
if len(p) > 60 and len(slides) < 6:
|
| 2169 |
-
# Kết hợp opinion với nội dung source
|
| 2170 |
-
combined = f"Theo góc nhìn của tôi, {opinion[:100]}... Theo {src_via}: {p[:250]}"
|
| 2171 |
-
img = source_images[len(slides)] if len(slides) < len(source_images) else ""
|
| 2172 |
-
slides.append({"text": _ensure_sentence_complete(combined), "image": img, "index": len(slides) + 1})
|
| 2173 |
-
break # Mỗi nguồn 1 slide
|
| 2174 |
-
src_idx += 1
|
| 2175 |
-
|
| 2176 |
-
# Final fallback: ensure at least 2-3 slides
|
| 2177 |
-
while len(slides) < 3:
|
| 2178 |
-
idx = len(slides)
|
| 2179 |
-
if idx == 0 and opinion:
|
| 2180 |
-
slides.append({"text": opinion[:400], "image": "", "index": 1})
|
| 2181 |
-
elif ai_text:
|
| 2182 |
-
slides.append({"text": ai_text[idx*300:(idx+1)*300], "image": "", "index": idx + 1})
|
| 2183 |
-
else:
|
| 2184 |
-
slides.append({"text": f"Nguồn tham khảo {idx + 1}", "image": "", "index": idx + 1})
|
| 2185 |
-
|
| 2186 |
-
preview = {
|
| 2187 |
-
"title": title,
|
| 2188 |
-
"text": ai_text,
|
| 2189 |
-
"opinion": opinion,
|
| 2190 |
-
"images": source_images[:10],
|
| 2191 |
-
"sources": source_details[:5],
|
| 2192 |
-
"slides": slides[:6] # Max 6 slides
|
| 2193 |
-
}
|
| 2194 |
-
|
| 2195 |
-
return JSONResponse({"preview": preview})
|
| 2196 |
-
|
| 2197 |
-
|
| 2198 |
-
@app.post("/api/personal_post")
|
| 2199 |
-
async def api_personal_post(request: Request):
|
| 2200 |
-
"""Create and save personal opinion post."""
|
| 2201 |
-
body = await request.json()
|
| 2202 |
-
opinion = _clean(body.get("opinion", ""))
|
| 2203 |
-
selected_topics = body.get("selected_topics", []) or []
|
| 2204 |
-
selected_sources = body.get("selected_sources", []) or []
|
| 2205 |
-
custom_title = body.get("custom_title", "")
|
| 2206 |
-
custom_slides = body.get("custom_slides", [])
|
| 2207 |
-
|
| 2208 |
-
if not opinion or len(opinion) < 10:
|
| 2209 |
-
return JSONResponse({"error": "Quan điểm cá nhân quá ngắn (cần ít nhất 10 ký tự)"}, status_code=400)
|
| 2210 |
-
|
| 2211 |
-
if not selected_topics:
|
| 2212 |
-
# Lấy keywords từ QUAN ĐIỂM CÁ NHÂN để tìm nguồn tin chính xác
|
| 2213 |
-
keywords = _extract_keywords_from_opinion(opinion, max_keywords=5)
|
| 2214 |
-
if keywords:
|
| 2215 |
-
selected_topics = keywords[:3]
|
| 2216 |
-
else:
|
| 2217 |
-
hot = _get_hot_topics()
|
| 2218 |
-
selected_topics = [t.get("topic", "") for t in hot[:3] if t.get("topic")]
|
| 2219 |
-
|
| 2220 |
-
all_sources = []
|
| 2221 |
-
seen_urls = set()
|
| 2222 |
-
for topic in selected_topics[:3]:
|
| 2223 |
-
sources = _search_all(topic, limit=5)
|
| 2224 |
-
for s in sources:
|
| 2225 |
-
if s.get("url") and s["url"] not in seen_urls:
|
| 2226 |
-
seen_urls.add(s["url"])
|
| 2227 |
-
all_sources.append(s)
|
| 2228 |
-
if len(all_sources) >= 6:
|
| 2229 |
-
break
|
| 2230 |
-
if len(all_sources) >= 6:
|
| 2231 |
-
break
|
| 2232 |
-
|
| 2233 |
-
for src in selected_sources:
|
| 2234 |
-
if src.get("url") and src["url"] not in seen_urls:
|
| 2235 |
-
all_sources.insert(0, src)
|
| 2236 |
-
|
| 2237 |
-
source_details = []
|
| 2238 |
-
source_images = []
|
| 2239 |
-
for src in all_sources[:5]:
|
| 2240 |
-
url = src.get("url", "")
|
| 2241 |
-
if not url:
|
| 2242 |
-
continue
|
| 2243 |
-
try:
|
| 2244 |
-
art = _scrape_article_for_rewrite(url)
|
| 2245 |
-
if art:
|
| 2246 |
-
src_detail = {
|
| 2247 |
-
"title": art.get("title", src.get("title", "")),
|
| 2248 |
-
"url": url,
|
| 2249 |
-
"via": src.get("via", ""),
|
| 2250 |
-
"paragraphs": art.get("paragraphs", [])[:6],
|
| 2251 |
-
"images": art.get("images", [])[:2],
|
| 2252 |
-
"og_image": art.get("og_img", "")
|
| 2253 |
-
}
|
| 2254 |
-
source_details.append(src_detail)
|
| 2255 |
-
for img in art.get("images", [])[:2]:
|
| 2256 |
-
if any(x in img for x in ["cdnphoto.dantri", "vnexpress", "vcdn", "refooty"]):
|
| 2257 |
-
img = "/api/proxy/img?url=" + _quote2(img, safe="")
|
| 2258 |
-
source_images.append(img)
|
| 2259 |
-
except:
|
| 2260 |
-
pass
|
| 2261 |
-
|
| 2262 |
-
# Title
|
| 2263 |
-
if custom_title:
|
| 2264 |
-
title = custom_title[:80]
|
| 2265 |
-
else:
|
| 2266 |
-
opinion_words = re.findall(r"[A-Za-zÀ-ỹ0-9]+", opinion)
|
| 2267 |
-
title_words = opinion_words[:8] if len(opinion_words) >= 8 else opinion_words[:4]
|
| 2268 |
-
title = " ".join([w[0].upper() + w[1:] for w in title_words]) if title_words else "Quan điểm cá nhân"
|
| 2269 |
-
title = title[:80]
|
| 2270 |
-
|
| 2271 |
-
# AI sinh bài
|
| 2272 |
-
ai_text = None
|
| 2273 |
-
try:
|
| 2274 |
-
import ai_ext
|
| 2275 |
-
if hasattr(ai_ext, 'qwen_generate'):
|
| 2276 |
-
source_context = ""
|
| 2277 |
-
for i, sd in enumerate(source_details[:5]):
|
| 2278 |
-
src_title = sd.get("title", "")
|
| 2279 |
-
src_via = sd.get("via", "")
|
| 2280 |
-
src_paras = sd.get("paragraphs", [])
|
| 2281 |
-
source_context += f"\nNguồn {i+1}: {src_title} ({src_via})\n"
|
| 2282 |
-
for j, p in enumerate(src_paras[:3]):
|
| 2283 |
-
source_context += f" - {p[:300]}\n"
|
| 2284 |
-
prompt = (
|
| 2285 |
-
"QUAN ĐIỂM: " + opinion[:500] + "\nNGUỒN: " + source_context[:1000] + "\n\n"
|
| 2286 |
-
"=== NGUỒN TIN ===\n" + source_context + "\n\n"
|
| 2287 |
-
"=== YÊU CẦU VIẾT BÀI THEO SLIDE ===\n"
|
| 2288 |
-
"Viết bài thành 5-6 ĐOẠN VĂN NGẮN, mỗi đoạn là 1 SLIDE.\n"
|
| 2289 |
-
"\n"
|
| 2290 |
-
"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"
|
| 2291 |
-
"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"
|
| 2292 |
-
"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"
|
| 2293 |
-
"\n"
|
| 2294 |
-
"Định dạng:\n"
|
| 2295 |
-
"---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"
|
| 2296 |
-
"\n"
|
| 2297 |
-
"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ừ."
|
| 2298 |
-
)
|
| 2299 |
-
ai_text = None # Không dùng AI, để code tự kết hợp opinion + source
|
| 2300 |
-
except:
|
| 2301 |
-
pass
|
| 2302 |
-
|
| 2303 |
-
if not ai_text or len(ai_text) < 100:
|
| 2304 |
-
ai_text = "## " + title + "\n\n" + opinion + "\n\n"
|
| 2305 |
-
for i, sd in enumerate(source_details[:5]):
|
| 2306 |
-
ai_text += "### " + sd.get("title", "") + "\n"
|
| 2307 |
-
for p in sd.get("paragraphs", [])[:2]:
|
| 2308 |
-
ai_text += p[:250] + "\n"
|
| 2309 |
-
ai_text += "\n---\n*Nguồn: " + sd.get("via", "") + "*\n\n"
|
| 2310 |
-
|
| 2311 |
-
# Tạo slides
|
| 2312 |
-
if custom_slides and len(custom_slides) > 0:
|
| 2313 |
-
slides = []
|
| 2314 |
-
for i, slide in enumerate(custom_slides):
|
| 2315 |
-
slides.append({
|
| 2316 |
-
"text": slide.get("text", ""),
|
| 2317 |
-
"image": slide.get("image", ""),
|
| 2318 |
-
"index": i + 1
|
| 2319 |
-
})
|
| 2320 |
-
else:
|
| 2321 |
-
slides = []
|
| 2322 |
-
# Parse từ AI output (format: ---SLIDE N---)
|
| 2323 |
-
if ai_text:
|
| 2324 |
-
pattern = r'---SLIDE\s*(\d+)---\s*\n(.*?)(?=---SLIDE|\Z)'
|
| 2325 |
-
matches = re.findall(pattern, ai_text, re.DOTALL)
|
| 2326 |
-
if matches:
|
| 2327 |
-
for idx, (num, content) in enumerate(matches):
|
| 2328 |
-
# Normalize: ensure complete sentences
|
| 2329 |
-
text = _ensure_sentence_complete(content)
|
| 2330 |
-
if len(text) > 30:
|
| 2331 |
-
img = source_images[idx] if idx < len(source_images) else ""
|
| 2332 |
-
slides.append({"text": text, "image": img, "index": idx + 1})
|
| 2333 |
-
|
| 2334 |
-
# Fallback: split by paragraphs
|
| 2335 |
-
if len(slides) < 3:
|
| 2336 |
-
paragraphs = [p.strip() for p in re.split(r'\n\n+', ai_text) if p.strip()]
|
| 2337 |
-
slides = []
|
| 2338 |
-
para_count = 0
|
| 2339 |
-
for p in paragraphs:
|
| 2340 |
-
p = p.strip()
|
| 2341 |
-
if p.startswith('#') or p.startswith('---') or p.startswith('*Nguồn'):
|
| 2342 |
-
continue
|
| 2343 |
-
# Normalize: ensure complete sentences
|
| 2344 |
-
p_normalized = _ensure_sentence_complete(p)
|
| 2345 |
-
if len(p_normalized) > 50:
|
| 2346 |
-
img = source_images[para_count] if para_count < len(source_images) else ""
|
| 2347 |
-
slides.append({"text": p_normalized, "image": img, "index": para_count + 1})
|
| 2348 |
-
para_count += 1
|
| 2349 |
-
if para_count >= 6:
|
| 2350 |
-
break
|
| 2351 |
-
|
| 2352 |
-
if len(slides) < 2:
|
| 2353 |
-
slides = []
|
| 2354 |
-
# Slide 1: QUAN ĐIỂM CÁ NHÂN (BẮT BUỘC)
|
| 2355 |
-
slides.append({"text": f"Theo quan điểm cá nhân: {opinion[:400]}", "image": source_images[0] if source_images else "", "index": 1})
|
| 2356 |
-
|
| 2357 |
-
# Slide 2-6: KẾT HỢP QUAN ĐIỂM + SOURCE
|
| 2358 |
-
for i in range(min(5, len(source_details))):
|
| 2359 |
-
if len(slides) >= 6:
|
| 2360 |
-
break
|
| 2361 |
-
src = source_details[i]
|
| 2362 |
-
src_via = src.get("via", "")
|
| 2363 |
-
src_paras = src.get("paragraphs", [])
|
| 2364 |
-
|
| 2365 |
-
src_text = ""
|
| 2366 |
-
for p in src_paras[:2]:
|
| 2367 |
-
p = p.strip()[:280]
|
| 2368 |
-
if len(p) > 50:
|
| 2369 |
-
src_text = p
|
| 2370 |
-
break
|
| 2371 |
-
|
| 2372 |
-
if src_text:
|
| 2373 |
-
combined = f"Theo góc nhìn cá nhân, {opinion[:60]}. Theo {src_via}: {src_text}"
|
| 2374 |
-
img = source_images[len(slides)] if len(slides) < len(source_images) else (source_images[-1] if source_images else "")
|
| 2375 |
-
slides.append({"text": _ensure_sentence_complete(combined), "image": img, "index": len(slides) + 1})
|
| 2376 |
-
|
| 2377 |
-
lang, emotion = detect_language_and_emotion(title, ai_text)
|
| 2378 |
-
voice = get_voice_for_content(title, ai_text)
|
| 2379 |
-
|
| 2380 |
-
post = {
|
| 2381 |
-
"id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
|
| 2382 |
-
"title": title,
|
| 2383 |
-
"text": ai_text,
|
| 2384 |
-
"img": source_images[0] if source_images else "",
|
| 2385 |
-
"url": "",
|
| 2386 |
-
"kind": "personal_opinion",
|
| 2387 |
-
"slides": slides,
|
| 2388 |
-
"images": source_images[:10],
|
| 2389 |
-
"video": "",
|
| 2390 |
-
"voice": voice,
|
| 2391 |
-
"emotion": emotion,
|
| 2392 |
-
"language": lang,
|
| 2393 |
-
"ts": int(time.time()),
|
| 2394 |
-
"sources": source_details[:5]
|
| 2395 |
-
}
|
| 2396 |
-
|
| 2397 |
posts = _load_wall_posts()
|
| 2398 |
posts.insert(0, post)
|
| 2399 |
_save_wall_posts(posts)
|
| 2400 |
-
|
| 2401 |
-
return JSONResponse({"post": post, "slides": slides})
|
| 2402 |
-
|
| 2403 |
|
| 2404 |
-
# ===== END PERSONAL OPINION POST v2 =====
|
| 2405 |
|
| 2406 |
def _bg():
|
| 2407 |
time.sleep(15)
|
|
@@ -2651,58 +1763,6 @@ def _scheduler_loop():
|
|
| 2651 |
|
| 2652 |
threading.Thread(target=_scheduler_loop, daemon=True, name='auto-rewrite-scheduler').start()
|
| 2653 |
|
| 2654 |
-
def _wall_cleanup_once():
|
| 2655 |
-
"""One-time startup cleanup: sort the persistent wall store newest-first
|
| 2656 |
-
and remove truly-garbage auto posts (no valid image at ALL, no video, no
|
| 2657 |
-
slides, no embed URL — i.e. a post that can never be displayed).
|
| 2658 |
-
|
| 2659 |
-
NOTE: This is intentionally conservative. Posts with a valid image or a
|
| 2660 |
-
valid embed/video are NEVER deleted here — they are only hidden at view
|
| 2661 |
-
time by _wall_posts_for_view() when the image is a placeholder. This
|
| 2662 |
-
prevents accidental data loss of FPT Short AI posts whose image is valid
|
| 2663 |
-
but may temporarily lack a short_thumb."""
|
| 2664 |
-
try:
|
| 2665 |
-
time.sleep(8)
|
| 2666 |
-
posts = _load_wall_posts()
|
| 2667 |
-
if not isinstance(posts, list):
|
| 2668 |
-
return
|
| 2669 |
-
kept = []
|
| 2670 |
-
removed = 0
|
| 2671 |
-
removed_ids = []
|
| 2672 |
-
needs_sort = False
|
| 2673 |
-
for p in posts:
|
| 2674 |
-
if not isinstance(p, dict):
|
| 2675 |
-
removed += 1
|
| 2676 |
-
continue
|
| 2677 |
-
if _is_auto_post(p):
|
| 2678 |
-
has_img = _post_has_valid_image(p)
|
| 2679 |
-
has_content = _post_has_slide_info(p)
|
| 2680 |
-
vid = (p.get("video") or "").strip()
|
| 2681 |
-
# Only delete auto posts that are completely unrecoverable:
|
| 2682 |
-
# no valid image AND no video/slides/embed at all.
|
| 2683 |
-
if not has_img and not has_content and not vid:
|
| 2684 |
-
removed += 1
|
| 2685 |
-
removed_ids.append(p.get('id', '?'))
|
| 2686 |
-
continue
|
| 2687 |
-
kept.append(p)
|
| 2688 |
-
# Sort if any post lacks proper ordering
|
| 2689 |
-
prev_ts = None
|
| 2690 |
-
is_sorted = True
|
| 2691 |
-
for p in kept:
|
| 2692 |
-
ts = _created_ts(p)
|
| 2693 |
-
if prev_ts is not None and ts > prev_ts:
|
| 2694 |
-
is_sorted = False
|
| 2695 |
-
break
|
| 2696 |
-
prev_ts = ts
|
| 2697 |
-
if removed > 0 or not is_sorted:
|
| 2698 |
-
kept.sort(key=_created_ts, reverse=True)
|
| 2699 |
-
_save_wall_posts(kept)
|
| 2700 |
-
print(f"[wall] startup cleanup: removed {removed} garbage posts ({removed_ids[:10]}), kept {len(kept)}, sorted newest-first")
|
| 2701 |
-
except Exception as e:
|
| 2702 |
-
print(f"[wall] startup cleanup error: {e}")
|
| 2703 |
-
|
| 2704 |
-
threading.Thread(target=_wall_cleanup_once, daemon=True, name='wall-cleanup').start()
|
| 2705 |
-
|
| 2706 |
@app.get('/api/debug/auto_schedule')
|
| 2707 |
async def debug_auto_schedule(slot: str = '07:00'):
|
| 2708 |
"""Manually trigger auto scheduler for debugging."""
|
|
@@ -2822,4 +1882,4 @@ def shorts_rss():
|
|
| 2822 |
|
| 2823 |
return {"shorts": shorts, "count": len(shorts)}
|
| 2824 |
|
| 2825 |
-
app.mount('/static',StaticFiles(directory=STATIC_DIR),name='vnews_static')
|
|
|
|
| 12 |
except Exception as e:
|
| 13 |
print(f"[WARN] ai_patch import failed: {e}")
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response
|
| 16 |
from fastapi.staticfiles import StaticFiles
|
| 17 |
from starlette.routing import Mount
|
|
|
|
| 33 |
|
| 34 |
def _clean(s): return re.sub(r"\s+"," ",html_lib.unescape(str(s or""))).strip()
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
# Cache for match details (5 min TTL)
|
| 37 |
_match_cache = {}
|
| 38 |
|
|
|
|
| 209 |
def _has_kw(topic,title):
|
| 210 |
tl=topic.lower();tt=(title or'').lower()
|
| 211 |
if tl in tt:return True
|
| 212 |
+
words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',tl) if len(w)>2 and w not in _STOP]
|
| 213 |
if not words:return True
|
| 214 |
return any(w in tt for w in words)
|
| 215 |
|
|
|
|
| 332 |
for _path in ['/api/article', '/api/hot_topics', '/api/categories', '/api/storage_status']:
|
| 333 |
app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)==_path and 'GET' in getattr(r,'methods',set()))]
|
| 334 |
|
| 335 |
+
# Remove ai_patch.py's duplicate routes so app_v2_entry's versions take priority
|
| 336 |
+
# Keep ai_patch.py's /api/ai/short/{post_id} and /api/ai/short-file/{file_id} — they work correctly
|
| 337 |
+
for _p, _m in [('/api/url_wall','POST'), ('/api/rewrite_share','POST'), ('/api/topic_post','POST')]:
|
| 338 |
+
app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)==_p and _m in getattr(r,'methods',set()))]
|
| 339 |
+
|
| 340 |
_article_cache = {}
|
| 341 |
_article_cache_ttl = 1800
|
| 342 |
|
|
|
|
| 463 |
return JSONResponse({'error': f'Server error: {str(e)[:100]}', 'url': safe_url}, status_code=200)
|
| 464 |
|
| 465 |
_hot_cache={'t':0,'d':[]}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 466 |
def _get_hot_topics():
|
| 467 |
now=time.time()
|
| 468 |
if _hot_cache['d'] and now-_hot_cache['t']<600:return _hot_cache['d']
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
freq={};display={}
|
| 470 |
+
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']
|
| 471 |
+
for feed_url in feeds:
|
| 472 |
+
try:
|
| 473 |
+
r=req.get(feed_url,headers={'User-Agent':'Mozilla/5.0'},timeout=6);r.encoding='utf-8';soup=BeautifulSoup(r.text,'xml')
|
| 474 |
+
for item in soup.find_all('item')[:12]:
|
| 475 |
+
title=_clean(item.find('title').get_text() if item.find('title') else '')
|
| 476 |
+
if not title:continue
|
| 477 |
+
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]
|
| 478 |
+
if len(words)<2:continue
|
| 479 |
+
for n in(3,4,2):
|
| 480 |
+
for i in range(max(0,len(words)-n+1)):
|
| 481 |
+
phrase=' '.join(words[i:i+n])
|
| 482 |
+
if 8<=len(phrase)<=45:key=phrase.lower();freq[key]=freq.get(key,0)+1;display[key]=phrase
|
| 483 |
+
except:continue
|
| 484 |
ranked=sorted(freq.items(),key=lambda x:x[1],reverse=True);topics=[];seen=set()
|
| 485 |
for key,count in ranked:
|
| 486 |
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)
|
|
|
|
| 507 |
items=_search_all(topic,36);per_page=8;start=page*per_page;end=start+per_page
|
| 508 |
return JSONResponse({'sources':items[start:end],'topic':topic,'page':page,'has_more':end<len(items),'total':len(items)})
|
| 509 |
@app.get('/api/categories')
|
| 510 |
+
def _cat():return JSONResponse([])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 511 |
@app.get('/api/storage_status')
|
| 512 |
def _st():return JSONResponse({'persistent':os.path.isdir('/data') and os.access('/data',os.W_OK)})
|
| 513 |
# ===== SHARE HELPERS: render content pages for shared links =====
|
|
|
|
| 598 |
@app.get('/s/{slug}')
|
| 599 |
async def _sh_slug(slug: str, request: Request, url: str = '', title: str = '', img: str = ''):
|
| 600 |
"""SEO-friendly share endpoint with slug in URL path.
|
| 601 |
+
Shows slide content when url matches a wall post, otherwise redirects.
|
| 602 |
"""
|
| 603 |
safe_title = _clean(title) if title else 'VNEWS - Tin tức'
|
| 604 |
safe_img = _clean(img) if img else ''
|
| 605 |
safe_url = _clean(url) if url else '/'
|
| 606 |
|
| 607 |
+
# Try to find post by URL first (most reliable)
|
| 608 |
post = None
|
| 609 |
try:
|
| 610 |
+
if url:
|
| 611 |
posts = _load_wall_posts()
|
| 612 |
for p in posts:
|
| 613 |
+
if p.get('url') == url and p.get('slides'):
|
| 614 |
post = p
|
| 615 |
safe_title = p.get('title', safe_title) or safe_title
|
| 616 |
safe_img = p.get('img', safe_img) or safe_img
|
| 617 |
safe_url = p.get('url', safe_url) or safe_url
|
| 618 |
break
|
| 619 |
+
# Fallback: any matching URL
|
| 620 |
+
if not post and url:
|
| 621 |
+
for p in posts:
|
| 622 |
+
if p.get('url') == url:
|
| 623 |
+
post = p
|
| 624 |
+
safe_title = p.get('title', safe_title) or safe_title
|
| 625 |
+
safe_img = p.get('img', safe_img) or safe_img
|
| 626 |
+
safe_url = p.get('url', safe_url) or safe_url
|
| 627 |
+
break
|
| 628 |
except:
|
| 629 |
pass
|
| 630 |
|
|
|
|
| 649 |
<meta http-equiv="refresh" content="0;url={safe_url}">
|
| 650 |
</head><body></body></html>''')
|
| 651 |
|
| 652 |
+
|
| 653 |
+
|
| 654 |
+
@app.get('/api/proxy/img')
|
| 655 |
+
def proxy_img(url: str = Query(default=""), max_size: int = Query(default=1200)):
|
| 656 |
+
"""Proxy image from blocked CDN to public URL."""
|
| 657 |
+
from urllib.parse import unquote
|
| 658 |
+
safe_url = unquote(url)
|
| 659 |
+
import requests as _req
|
| 660 |
+
try:
|
| 661 |
+
r = _req.get(safe_url, timeout=10, headers={"User-Agent": "Mozilla/5.0"})
|
| 662 |
+
if r.status_code == 200 and r.content:
|
| 663 |
+
return Response(r.content, media_type=r.headers.get("content-type", "image/jpeg"))
|
| 664 |
+
except Exception as e:
|
| 665 |
+
pass
|
| 666 |
+
return Response(status_code=502)
|
| 667 |
@app.get('/s')
|
| 668 |
async def _sh(url:str='',title:str='',img:str='',post_id:str=''):
|
| 669 |
safe_title = _clean(title) if title else 'VNEWS - Tin tức'
|
|
|
|
| 710 |
return _render_video_page(post, safe_title, safe_img, safe_url)
|
| 711 |
|
| 712 |
# Fallback: redirect to original URL
|
| 713 |
+
# Fetch og:image for better rich preview
|
| 714 |
+
if url and not safe_img:
|
| 715 |
+
try:
|
| 716 |
+
art = _scrape_article_fast(url)
|
| 717 |
+
if art and art.get('og_image'):
|
| 718 |
+
safe_img = art.get('og_image', '')
|
| 719 |
+
if art and art.get('title'):
|
| 720 |
+
safe_title = art.get('title', safe_title)
|
| 721 |
+
except:
|
| 722 |
+
pass
|
| 723 |
+
|
| 724 |
return HTMLResponse(f'''<!DOCTYPE html>
|
| 725 |
<html lang="vi">
|
| 726 |
<head>
|
|
|
|
| 846 |
try:
|
| 847 |
if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8'))
|
| 848 |
except:pass
|
| 849 |
+
return [] # Return empty list instead of dict for wall posts
|
| 850 |
def _sj(p,d):
|
| 851 |
try:open(p+'.tmp','w',encoding='utf-8').write(json.dumps(d,ensure_ascii=False));os.replace(p+'.tmp',p)
|
| 852 |
except:pass
|
|
|
|
| 876 |
|
| 877 |
def _load_wall_posts():
|
| 878 |
with _wl_lock:
|
| 879 |
+
return _lj(WALL_FILE)
|
|
|
|
|
|
|
|
|
|
| 880 |
|
| 881 |
def _save_wall_posts(posts):
|
| 882 |
with _wl_lock:
|
| 883 |
_sj(WALL_FILE, posts)
|
| 884 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 885 |
@app.get('/api/wall')
|
| 886 |
def api_wall():
|
| 887 |
+
posts = _load_wall_posts()
|
| 888 |
if not posts:
|
| 889 |
return JSONResponse({"posts": []})
|
| 890 |
return JSONResponse({"posts": posts})
|
|
|
|
| 952 |
text = body.get('text', '') or ''
|
| 953 |
img = body.get('img', None)
|
| 954 |
source = body.get('source', 'user') or 'user'
|
|
|
|
| 955 |
post_id = str(uuid.uuid4())[:12]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 956 |
post = {
|
| 957 |
"id": post_id,
|
| 958 |
"title": title[:200],
|
| 959 |
"text": text[:2000],
|
| 960 |
"source": source,
|
| 961 |
+
"video": None,
|
| 962 |
"img": img,
|
| 963 |
+
"images": [],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 964 |
"created": int(time.time()),
|
| 965 |
"created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
|
| 966 |
}
|
|
|
|
|
|
|
| 967 |
posts = _load_wall_posts()
|
| 968 |
if not isinstance(posts, list):
|
| 969 |
posts = []
|
| 970 |
+
posts.insert(0, post)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 971 |
posts = posts[:200]
|
| 972 |
_save_wall_posts(posts)
|
| 973 |
return JSONResponse({"post": post, "ok": True})
|
|
|
|
| 983 |
media_type = 'video/mp4' if ext == '.mp4' else 'video/webm'
|
| 984 |
return FileResponse(video_path, media_type=media_type)
|
| 985 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 986 |
@app.delete('/api/wall/{post_id}')
|
| 987 |
def api_wall_delete(post_id: str):
|
| 988 |
posts = _load_wall_posts()
|
|
|
|
| 1000 |
return JSONResponse({"ok": True})
|
| 1001 |
return JSONResponse({"error": "Post not found"}, status_code=404)
|
| 1002 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1003 |
# ===== LANGUAGE & EMOTION DETECTION =====
|
| 1004 |
import random as _random2
|
| 1005 |
from urllib.parse import quote as _quote2
|
|
|
|
| 1410 |
return JSONResponse({"post": post, "slides": slides})
|
| 1411 |
|
| 1412 |
|
| 1413 |
+
async def _do_rewrite(url, ctx="", preferred_voice=""):
|
| 1414 |
+
"""Shared rewrite logic used by both /api/rewrite_share and /api/url_wall."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1415 |
data = None
|
| 1416 |
if url and url.startswith("http"):
|
| 1417 |
data = _scrape_article_for_rewrite(url)
|
|
|
|
| 1419 |
paragraphs = [_clean(p) for p in ctx.split('\n') if len(_clean(p)) > 40]
|
| 1420 |
data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
|
| 1421 |
if not data or not data.get('paragraphs'):
|
| 1422 |
+
return None, JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
|
| 1423 |
raw_text = '\n'.join(data['paragraphs'])
|
| 1424 |
if len(raw_text) < 50:
|
| 1425 |
raw_text = ctx[:14000]
|
| 1426 |
if len(raw_text) < 50:
|
| 1427 |
+
return None, JSONResponse({"error": "Bài viết quá ngắn"}, status_code=422)
|
| 1428 |
domain = ''
|
| 1429 |
try:
|
| 1430 |
from urllib.parse import urlparse
|
|
|
|
| 1448 |
else:
|
| 1449 |
ai_text = f"Tóm tắt: {data['title']}\n\n{raw_text[:1200]}\n\nNguồn: {domain}"
|
| 1450 |
|
| 1451 |
+
# Build slides from key points
|
| 1452 |
points = _extract_key_points_rw(data['paragraphs'], max_points=12)
|
| 1453 |
images = data.get('images', [])
|
| 1454 |
slides = []
|
|
|
|
| 1478 |
"language": lang,
|
| 1479 |
"ts": int(time.time())
|
| 1480 |
}
|
| 1481 |
+
return post, None
|
| 1482 |
+
|
| 1483 |
+
|
| 1484 |
+
@app.post("/api/rewrite_share")
|
| 1485 |
+
async def api_rewrite_share(request: Request):
|
| 1486 |
+
"""Rewrite article and post to Tường AI with SLIDES + AI text."""
|
| 1487 |
+
body = await request.json()
|
| 1488 |
+
url = _clean(body.get("url", ""))
|
| 1489 |
+
ctx = _clean(body.get("context", ""))
|
| 1490 |
+
preferred_voice = body.get("voice", "")
|
| 1491 |
+
if not url and not ctx:
|
| 1492 |
+
return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
|
| 1493 |
+
post, err = await _do_rewrite(url, ctx, preferred_voice)
|
| 1494 |
+
if err:
|
| 1495 |
+
return err
|
| 1496 |
posts = _load_wall_posts()
|
| 1497 |
posts.insert(0, post)
|
| 1498 |
_save_wall_posts(posts)
|
| 1499 |
+
return JSONResponse({"post": post, "slides": post.get("slides", [])})
|
| 1500 |
|
| 1501 |
|
| 1502 |
@app.post("/api/url_wall")
|
|
|
|
| 1506 |
url = _clean(body.get("url", ""))
|
| 1507 |
if not url or not url.startswith('http'):
|
| 1508 |
return JSONResponse({"error": "URL không hợp lệ"}, status_code=400)
|
| 1509 |
+
post, err = await _do_rewrite(url, "", "")
|
| 1510 |
+
if err:
|
| 1511 |
+
return err
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1512 |
posts = _load_wall_posts()
|
| 1513 |
posts.insert(0, post)
|
| 1514 |
_save_wall_posts(posts)
|
| 1515 |
+
return JSONResponse({"post": post, "slides": post.get("slides", [])})
|
|
|
|
|
|
|
| 1516 |
|
|
|
|
| 1517 |
|
| 1518 |
def _bg():
|
| 1519 |
time.sleep(15)
|
|
|
|
| 1763 |
|
| 1764 |
threading.Thread(target=_scheduler_loop, daemon=True, name='auto-rewrite-scheduler').start()
|
| 1765 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1766 |
@app.get('/api/debug/auto_schedule')
|
| 1767 |
async def debug_auto_schedule(slot: str = '07:00'):
|
| 1768 |
"""Manually trigger auto scheduler for debugging."""
|
|
|
|
| 1882 |
|
| 1883 |
return {"shorts": shorts, "count": len(shorts)}
|
| 1884 |
|
| 1885 |
+
app.mount('/static',StaticFiles(directory=STATIC_DIR),name='vnews_static')
|
app_v2_entry_hot.py
DELETED
|
@@ -1,24 +0,0 @@
|
|
| 1 |
-
"""Hot topics patch - makes AI topics always visible at top of HOT list."""
|
| 2 |
-
# This file is imported by app_v2_entry.py
|
| 3 |
-
|
| 4 |
-
# AI topics to prepend to hot topics
|
| 5 |
-
AI_HOT_TOPICS = [
|
| 6 |
-
{'label': '#Công nghệ AI', 'topic': 'Công nghệ AI', 'count': 0},
|
| 7 |
-
{'label': '#World Cup 2026', 'topic': 'World Cup 2026', 'count': 0},
|
| 8 |
-
{'label': '#Kinh tế Việt Nam', 'topic': 'Kinh tế Việt Nam', 'count': 0},
|
| 9 |
-
{'label': '#Bóng đá châu Âu', 'topic': 'Bóng đá châu Âu', 'count': 0},
|
| 10 |
-
{'label': '#Giá vàng', 'topic': 'Giá vàng', 'count': 0},
|
| 11 |
-
{'label': '#Thời tiết', 'topic': 'Thời tiết', 'count': 0},
|
| 12 |
-
]
|
| 13 |
-
|
| 14 |
-
def prepend_ai_hot_topics(topics):
|
| 15 |
-
"""Prepend AI topics to hot topics list, ensuring they're always visible."""
|
| 16 |
-
if not topics:
|
| 17 |
-
return AI_HOT_TOPICS[:]
|
| 18 |
-
# Remove duplicates that already exist
|
| 19 |
-
existing_topics = [t.get('topic', '').lower() for t in topics]
|
| 20 |
-
result = []
|
| 21 |
-
for ai_topic in AI_HOT_TOPICS:
|
| 22 |
-
if ai_topic.get('topic', '').lower() not in existing_topics:
|
| 23 |
-
result.append(ai_topic)
|
| 24 |
-
return result + topics
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
auto_update_sse.py
DELETED
|
@@ -1,55 +0,0 @@
|
|
| 1 |
-
"""Auto-update SSE endpoint for VNEWS - pushes updates when new posts/shorts published."""
|
| 2 |
-
import asyncio
|
| 3 |
-
import json
|
| 4 |
-
import time
|
| 5 |
-
from fastapi import Request
|
| 6 |
-
from fastapi.responses import StreamingResponse
|
| 7 |
-
|
| 8 |
-
# Connected clients queue
|
| 9 |
-
_clients = []
|
| 10 |
-
_lock = asyncio.Lock()
|
| 11 |
-
|
| 12 |
-
async def _notify_clients(event_type: str, data: dict):
|
| 13 |
-
"""Send notification to all SSE clients."""
|
| 14 |
-
if not _clients:
|
| 15 |
-
return
|
| 16 |
-
msg = f"data: {json.dumps({'type': event_type, 'data': data, 'ts': int(time.time())})}\n\n"
|
| 17 |
-
async with _lock:
|
| 18 |
-
dead = []
|
| 19 |
-
for q in _clients:
|
| 20 |
-
try:
|
| 21 |
-
await q.put_nowait(msg)
|
| 22 |
-
except asyncio.QueueFull:
|
| 23 |
-
pass
|
| 24 |
-
except:
|
| 25 |
-
dead.append(q)
|
| 26 |
-
for q in dead:
|
| 27 |
-
if q in _clients:
|
| 28 |
-
_clients.remove(q)
|
| 29 |
-
|
| 30 |
-
# Public functions to call from other modules
|
| 31 |
-
notify_new_post = lambda post: asyncio.create_task(_notify_clients("new_post", post)) if post else None
|
| 32 |
-
notify_new_short = lambda post: asyncio.create_task(_notify_clients("new_short", post)) if post else None
|
| 33 |
-
|
| 34 |
-
async def sse_events(request: Request):
|
| 35 |
-
"""SSE endpoint for real-time updates on homepage."""
|
| 36 |
-
q = asyncio.Queue(maxsize=10)
|
| 37 |
-
_clients.append(q)
|
| 38 |
-
|
| 39 |
-
async def event_generator():
|
| 40 |
-
try:
|
| 41 |
-
# Send initial connection message
|
| 42 |
-
yield "data: {\"type\":\"connected\",\"ts\":null}\n\n"
|
| 43 |
-
while not await request.is_disconnected():
|
| 44 |
-
try:
|
| 45 |
-
msg = await asyncio.wait_for(q.get(), timeout=25.0)
|
| 46 |
-
yield msg
|
| 47 |
-
except asyncio.TimeoutError:
|
| 48 |
-
yield ":keepalive\n\n"
|
| 49 |
-
except:
|
| 50 |
-
pass
|
| 51 |
-
finally:
|
| 52 |
-
if q in _clients:
|
| 53 |
-
_clients.remove(q)
|
| 54 |
-
|
| 55 |
-
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
bdp_full.html
DELETED
|
@@ -1,1250 +0,0 @@
|
|
| 1 |
-
<!DOCTYPE html>
|
| 2 |
-
<html lang="en">
|
| 3 |
-
<head>
|
| 4 |
-
<title>Hull City vs MU: Quỷ Đỏ vào hang bắt hổ - Bongdaplus.vn</title>
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
<meta name="MobileOptimized" content="100" />
|
| 8 |
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 9 |
-
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
| 10 |
-
<meta http-equiv="content-language" content="vi" />
|
| 11 |
-
<meta http-equiv="refresh" content="300" />
|
| 12 |
-
<meta property="fb:pages" content="1738672379699789" />
|
| 13 |
-
<meta property="dable:item_id" content="2015111202028" />
|
| 14 |
-
<meta property="og:image" itemprop="thumbnailUrl" content="https://bongdaplus.vn/img/logo-daily.png" />
|
| 15 |
-
<link href="/favicon.ico" rel="shortcut icon" type="image/x-icon" />
|
| 16 |
-
<link href="/favicon.svg" rel="icon" sizes="any" type="image/svg+xml" />
|
| 17 |
-
<link href="/apple-touch-icon.png" rel="apple-touch-icon" />
|
| 18 |
-
<!--iPhone icons-->
|
| 19 |
-
<link href="/logo-57.png" rel="apple-touch-icon-precomposed" sizes="57x57" />
|
| 20 |
-
<link href="/logo-114.png" rel="apple-touch-icon-precomposed" sizes="114x114" />
|
| 21 |
-
<!--iPad icons-->
|
| 22 |
-
<link href="/logo-72.png" rel="apple-touch-icon-precomposed" sizes="72x72" />
|
| 23 |
-
<link href="/logo-114.png" rel="apple-touch-icon-precomposed" sizes="114x114" />
|
| 24 |
-
<!--Android icon-->
|
| 25 |
-
<link href="/logo-114.png" rel="apple-touch-icon-precomposed" sizes="1x1" />
|
| 26 |
-
<!--Symbian icon-->
|
| 27 |
-
<link href="/logo-57.png" rel="nokia-touch-icon" sizes="57x57" />
|
| 28 |
-
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 29 |
-
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 30 |
-
<link href="https://fonts.googleapis.com/css2?family=Mulish:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,200;1,300;1,400;1,500;1,600;1,700;1,800&display=swap" rel="stylesheet">
|
| 31 |
-
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap" rel="stylesheet">
|
| 32 |
-
<link href="/css/premium.css?id=0000&v=ivL0rSTnUIBx45p3hw7UIAzbXjyQ9rgPVSyjqPglN_o" rel="stylesheet">
|
| 33 |
-
|
| 34 |
-
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-20374657-1"></script>
|
| 35 |
-
<script>
|
| 36 |
-
window.dataLayer = window.dataLayer || [];
|
| 37 |
-
function gtag() { dataLayer.push(arguments); }
|
| 38 |
-
gtag('js', new Date());
|
| 39 |
-
|
| 40 |
-
gtag('config', 'UA-20374657-1');
|
| 41 |
-
</script>
|
| 42 |
-
<!-- Google tag (gtag.js) -->
|
| 43 |
-
<script async src="https://www.googletagmanager.com/gtag/js?id=G-Y1DBNQWMVW"></script>
|
| 44 |
-
<script>
|
| 45 |
-
window.dataLayer = window.dataLayer || [];
|
| 46 |
-
function gtag() { dataLayer.push(arguments); }
|
| 47 |
-
gtag('js', new Date());
|
| 48 |
-
|
| 49 |
-
gtag('config', 'G-Y1DBNQWMVW');
|
| 50 |
-
</script>
|
| 51 |
-
|
| 52 |
-
<script async src="https://static.amcdn.vn/tka/cdn.js" type="text/javascript"></script>
|
| 53 |
-
<script>var arfAsync = arfAsync || [];</script>
|
| 54 |
-
<script id="arf-core-js" onerror="window.arferrorload=true;" src="//media1.admicro.vn/cms/Arf.min.js" async></script>
|
| 55 |
-
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-3619133031508264" crossorigin="anonymous"></script>
|
| 56 |
-
</head>
|
| 57 |
-
<body>
|
| 58 |
-
<script type="application/ld+json">
|
| 59 |
-
{
|
| 60 |
-
"@context": "https://schema.org",
|
| 61 |
-
"@type": "Organization",
|
| 62 |
-
"name": "TẠP CHÍ ĐIỆN TỬ BÓNG ĐÁ - BONGDAPLUS",
|
| 63 |
-
"url": "https://bongdaplus.vn",
|
| 64 |
-
"logo": "https://bongdaplus.vn/img/logo.png",
|
| 65 |
-
"contactPoint": {
|
| 66 |
-
"@type": "ContactPoint",
|
| 67 |
-
"telephone": "(84.24)35541188",
|
| 68 |
-
"contactType": "customer service",
|
| 69 |
-
"areaServed": "VN",
|
| 70 |
-
"availableLanguage": "Vietnamese"
|
| 71 |
-
},
|
| 72 |
-
"sameAs": [
|
| 73 |
-
"https://www.facebook.com/baobongda",
|
| 74 |
-
"https://www.youtube.com/channel/UCvDYMBE6GipX2VGacqOU2_Q",
|
| 75 |
-
"https://bongdaplus.vn",
|
| 76 |
-
"https://www.pinterest.com/bongdaplus",
|
| 77 |
-
"https://soundcloud.com/baobongda"
|
| 78 |
-
]
|
| 79 |
-
}
|
| 80 |
-
</script>
|
| 81 |
-
<header>
|
| 82 |
-
<center><div id="ADMTOP" class="banner admob" banner-adpos="top"></div></center>
|
| 83 |
-
|
| 84 |
-
<div class="cont-wrap">
|
| 85 |
-
<div class="mmnu-nav">
|
| 86 |
-
<button class="ico mnu-sch mmnu-trigger hide-w"></button>
|
| 87 |
-
<button class="ico menubar mmnu-trigger hide-m"></button>
|
| 88 |
-
<button class="ico close mmnu-closer hide"></button>
|
| 89 |
-
</div>
|
| 90 |
-
<a class="logo" href="/"></a>
|
| 91 |
-
<button class="ico bell hide-w hide"></button>
|
| 92 |
-
|
| 93 |
-
<div class="authen-nav dropper">
|
| 94 |
-
<a class="member">
|
| 95 |
-
<span class="avatar">
|
| 96 |
-
</span>
|
| 97 |
-
</a>
|
| 98 |
-
<div class="drop-down rgt">
|
| 99 |
-
<ul class="lst">
|
| 100 |
-
|
| 101 |
-
<li><a href="#" data-showpaner="#loginview">Đăng nhập</a></li>
|
| 102 |
-
<li><a href="https://member.bongdaplus.vn/Identity/Account/Register?returnUrl=%2F" target="_blank">Tạo tài khoản</a></li>
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
</ul>
|
| 106 |
-
</div>
|
| 107 |
-
</div>
|
| 108 |
-
</div>
|
| 109 |
-
</header>
|
| 110 |
-
<input type="hidden" id="catrefid" value="video" />
|
| 111 |
-
<input type="hidden" id="objectid" value="98267" />
|
| 112 |
-
<input type="hidden" id="objecttype" value="1" />
|
| 113 |
-
<input type="hidden" id="parentid" value="0" />
|
| 114 |
-
<input type="hidden" id="commentnum" value="0" />
|
| 115 |
-
<img src="/imgthumbnail/98267/1/1" alt="" height="0" width="0" />
|
| 116 |
-
|
| 117 |
-
<main>
|
| 118 |
-
<div class="nav-menu">
|
| 119 |
-
<div class="menu-bar">
|
| 120 |
-
<div class="cont-wrap">
|
| 121 |
-
<div class="menu-side">
|
| 122 |
-
<ul class="bar-w">
|
| 123 |
-
<li><a class="ico home" href="/"></a></li>
|
| 124 |
-
<li><a title="AFF Cup" href="/aff-cup">AFF Cup</a></li>
|
| 125 |
-
<li class="popover">
|
| 126 |
-
<a title="Việt Nam" href="/bong-da-viet-nam">Việt Nam</a>
|
| 127 |
-
<div class="drop-down">
|
| 128 |
-
<ul class="lst">
|
| 129 |
-
<li><a title="V-League" href="/v-league">V-League</a></li>
|
| 130 |
-
<li><a title="Hạng Nhất" href="/hang-nhat-quoc-gia">Hạng Nhất</a></li>
|
| 131 |
-
<li><a title="Cúp Quốc gia" href="/cup-quoc-gia">Cúp Quốc gia</a></li>
|
| 132 |
-
<li><a title="ĐTQG" href="/doi-tuyen-quoc-gia-viet-nam">ĐTQG</a></li>
|
| 133 |
-
<li><a title="Bóng đá Nữ" href="/bong-da-nu-viet-nam">Bóng đá Nữ</a></li>
|
| 134 |
-
<li><a title="U17 Quốc gia" href="/u17-viet-nam">U17 Quốc gia</a></li>
|
| 135 |
-
<li><a title="U19 Quốc Gia" href="/u19-viet-nam">U19 Quốc Gia</a></li>
|
| 136 |
-
<li><a title="U21 Quốc Gia" href="/u21-viet-nam">U21 Quốc Gia</a></li>
|
| 137 |
-
<li><a title="Các đội tuyển trẻ" href="/cac-doi-tuyen-tre-viet-nam">Các đội tuyển trẻ</a></li>
|
| 138 |
-
<li><a title="Futsal" href="/futsal">Futsal</a></li>
|
| 139 |
-
<li><a title="Phong trào" href="/bong-da-phong-trao">Phong trào</a></li>
|
| 140 |
-
<li><a title="Đơn vị Kết nghĩa" href="/ket-nghia-anh-em">Đơn vị Kết nghĩa</a></li>
|
| 141 |
-
</ul>
|
| 142 |
-
</div>
|
| 143 |
-
</li>
|
| 144 |
-
<li class="popover">
|
| 145 |
-
<a title="Chuyển nhượng" href="/tin-chuyen-nhuong">Chuyển nhượng</a>
|
| 146 |
-
<div class="drop-down">
|
| 147 |
-
<ul class="lst">
|
| 148 |
-
<li><a title="Tin chuyển nhượng Ngoại Hạng Anh" href="/tin-chuyen-nhuong-ngoai-hang-anh">Tin chuyển nhượng Ngoại Hạng Anh</a></li>
|
| 149 |
-
<li><a title="Tin chuyển nhượng V-league" href="/tin-chuyen-nhuong-v-league">Tin chuyển nhượng V-league</a></li>
|
| 150 |
-
</ul>
|
| 151 |
-
</div>
|
| 152 |
-
</li>
|
| 153 |
-
<li><a title="Champions League" href="/champions-league-cup-c1">Champions League</a></li>
|
| 154 |
-
<li class="popover">
|
| 155 |
-
<a title="Tây Ban Nha" href="/bong-da-tay-ban-nha">Tây Ban Nha</a>
|
| 156 |
-
<div class="drop-down">
|
| 157 |
-
<ul class="lst">
|
| 158 |
-
<li><a title="VĐQG Tây Ban Nha" href="/la-liga">VĐQG Tây Ban Nha</a></li>
|
| 159 |
-
<li><a title="Cúp Nhà Vua" href="/cup-nha-vua-tay-ban-nha">Cúp Nhà Vua</a></li>
|
| 160 |
-
<li><a title="Đội tuyển TBN" href="/doi-tuyen-tay-ban-nha">Đội tuyển TBN</a></li>
|
| 161 |
-
</ul>
|
| 162 |
-
</div>
|
| 163 |
-
</li>
|
| 164 |
-
<li class="popover">
|
| 165 |
-
<a title="Pháp" href="/bong-da-phap">Pháp</a>
|
| 166 |
-
<div class="drop-down">
|
| 167 |
-
<ul class="lst">
|
| 168 |
-
<li><a title="VĐQG Pháp" href="/ligue-1">VĐQG Pháp</a></li>
|
| 169 |
-
<li><a title="Cúp Quốc gia Pháp" href="/cup-quoc-gia-phap">Cúp Quốc gia Pháp</a></li>
|
| 170 |
-
<li><a title="Đội tuyển Pháp" href="/doi-tuyen-phap">Đội tuyển Pháp</a></li>
|
| 171 |
-
</ul>
|
| 172 |
-
</div>
|
| 173 |
-
</li>
|
| 174 |
-
<li class="popover">
|
| 175 |
-
<a title="Italia" href="/bong-da-y">Italia</a>
|
| 176 |
-
<div class="drop-down">
|
| 177 |
-
<ul class="lst">
|
| 178 |
-
<li><a title="VĐQG Italia" href="/serie-a">VĐQG Italia</a></li>
|
| 179 |
-
<li><a title="Coppa Italia" href="/coppa-italia">Coppa Italia</a></li>
|
| 180 |
-
<li><a title="Đội tuyển Italia" href="/doi-tuyen-y">Đội tuyển Italia</a></li>
|
| 181 |
-
</ul>
|
| 182 |
-
</div>
|
| 183 |
-
</li>
|
| 184 |
-
<li class="popover">
|
| 185 |
-
<a title="Thể thao" href="/the-thao">Thể thao</a>
|
| 186 |
-
<div class="drop-down">
|
| 187 |
-
<ul class="lst">
|
| 188 |
-
<li><a title="eSports" href="/esports">eSports</a></li>
|
| 189 |
-
<li><a title="Tennis" href="/tennis">Tennis</a></li>
|
| 190 |
-
<li><a title="Pickleball" href="/pickleball">Pickleball</a></li>
|
| 191 |
-
<li><a title="MMA" href="/mma">MMA</a></li>
|
| 192 |
-
<li><a title="Đua xe F1" href="/dua-xe-f1">Đua xe F1</a></li>
|
| 193 |
-
<li><a title="Bóng rổ" href="/bong-ro">Bóng rổ</a></li>
|
| 194 |
-
<li><a title="Olympic 2028" href="/olympic">Olympic 2028</a></li>
|
| 195 |
-
</ul>
|
| 196 |
-
</div>
|
| 197 |
-
</li>
|
| 198 |
-
<li><a title="Nhận định bóng đá" href="/nhan-dinh-bong-da-tags">Nhận định</a></li>
|
| 199 |
-
<li><a title="Multimedia" href="/video">Multimedia</a></li>
|
| 200 |
-
<li class="hide"><a title="Bongdaplus Premium" href="/premium">Premium <i class="ico crown"></i></a></li>
|
| 201 |
-
</ul>
|
| 202 |
-
</div>
|
| 203 |
-
<div class="menu-nav">
|
| 204 |
-
<a class="ico home hide-w" href="/"></a>
|
| 205 |
-
<ul class="nav">
|
| 206 |
-
<li class="hide-m"><i class="sepr"></i></li>
|
| 207 |
-
<li><a href="/tin-moi">Mới nhất</a></li>
|
| 208 |
-
<li class="hide-w"><a title="Bongdaplus Premium" href="/premium">Premium <i class="ico crown"></i></a></li>
|
| 209 |
-
<li><a title="Anh" href="/bong-da-anh">Bóng đá Anh</a></li>
|
| 210 |
-
<li class="hide-w"><a title="AFF Cup" href="/aff-cup">AFF Cup</a></li>
|
| 211 |
-
<li class="hide-w"><a title="Việt Nam" href="/bong-da-viet-nam">Việt Nam</a></li>
|
| 212 |
-
<li class="hide-w"><a title="Chuyển nhượng" href="/tin-chuyen-nhuong">Chuyển nhượng</a></li>
|
| 213 |
-
<li class="hide-w"><a title="Champions League" href="/champions-league-cup-c1">Champions League</a></li>
|
| 214 |
-
<li class="hide-w"><a title="Tây Ban Nha" href="/bong-da-tay-ban-nha">Tây Ban Nha</a></li>
|
| 215 |
-
<li class="hide-w"><a title="Pháp" href="/bong-da-phap">Pháp</a></li>
|
| 216 |
-
<li class="hide-w"><a title="Italia" href="/bong-da-y">Italia</a></li>
|
| 217 |
-
<li class="hide-w"><a title="Thể thao" href="/the-thao">Thể thao</a></li>
|
| 218 |
-
<li class="hide-w"><a title="Nhận định" href="/nhan-dinh-bong-da-tags">Nhận định</a></li>
|
| 219 |
-
<li class="hide-w"><a title="Thế giới" href="/the-gioi">Thế giới</a></li>
|
| 220 |
-
<li class="hide-w"><a title="Multimedia" href="/video">Multimedia</a></li>
|
| 221 |
-
<li class="hide-w"><a title="Bóng đá Cuộc sống" href="/bong-da-cuoc-song">Bóng đá Cuộc sống</a></li>
|
| 222 |
-
</ul>
|
| 223 |
-
<div class="srch-bar hide-m">
|
| 224 |
-
<button class="ico close"></button>
|
| 225 |
-
<form class="srch-form" action="/tim-kiem">
|
| 226 |
-
<input class="pattern toggler" name="q" type="text" placeholder="Nhập nội dung cần tìm" />
|
| 227 |
-
</form>
|
| 228 |
-
</div>
|
| 229 |
-
</div>
|
| 230 |
-
</div>
|
| 231 |
-
</div>
|
| 232 |
-
<div class="clr"></div>
|
| 233 |
-
</div>
|
| 234 |
-
<div class="mob-menu">
|
| 235 |
-
<div class="mmnu-cap">
|
| 236 |
-
<button class="ico close" data-action="hide"></button>
|
| 237 |
-
<a class="logo" href="/"></a>
|
| 238 |
-
</div>
|
| 239 |
-
<form class="mmnu-sch " action="/tim-kiem">
|
| 240 |
-
<input type="text" name="q">
|
| 241 |
-
<button class="ico search" type="submit"></button>
|
| 242 |
-
</form>
|
| 243 |
-
<div class="mmnu-lst">
|
| 244 |
-
<div class="menu-row">
|
| 245 |
-
<ul class="bar">
|
| 246 |
-
<li class="exp">
|
| 247 |
-
<a class="major" title="Tin mới nhất" href="/tin-moi"><span class="ico blinkr"></span> Mới nhất</a>
|
| 248 |
-
</li>
|
| 249 |
-
<li>
|
| 250 |
-
<ul class="lst">
|
| 251 |
-
<li><a class="fst" title="Nh���n định Bóng đá" href="/nhan-dinh-bong-da-tags">Nhận định</a></li>
|
| 252 |
-
</ul>
|
| 253 |
-
</li>
|
| 254 |
-
<li>
|
| 255 |
-
<ul class="lst">
|
| 256 |
-
<li><a class="fst" title="Lịch thi đấu bóng đá" href="/lich-thi-dau-bong-da">Lịch thi đấu</a></li>
|
| 257 |
-
</ul>
|
| 258 |
-
</li>
|
| 259 |
-
<li class="exp"></li>
|
| 260 |
-
<li class="exp"><a class="major" title="Bóng đá Plus Premium" href="/premium">Bóng đá Premium <i class="ico crown"></i></a></li>
|
| 261 |
-
<li>
|
| 262 |
-
<ul class="lst">
|
| 263 |
-
<li><a class="fst" title="Bóng đá Việt Nam" href="/bong-da-viet-nam">Bóng đá Việt Nam</a></li>
|
| 264 |
-
<li><a title="Đội tuyển Việt Nam" href="/doi-tuyen-quoc-gia-viet-nam">Đội tuyển Việt Nam</a></li>
|
| 265 |
-
<li><a title="V.League" href="/v-league">V.League</a></li>
|
| 266 |
-
<li><a title="Hạng Nhất" href="/hang-nhat-viet-nam">Hạng Nhất</a></li>
|
| 267 |
-
<li><a title="Cúp Quốc gia" href="/cup-quoc-gia-viet-nam">Cúp Quốc gia</a></li>
|
| 268 |
-
<li><a title="Bóng đá nữ" href="/bong-da-nu-viet-nam">Bóng đá nữ</a></li>
|
| 269 |
-
<li><a title="Futsal" href="/futsal">Futsal</a></li>
|
| 270 |
-
<li><a title="Phong trào" href="/bong-da-phong-trao">Bóng đá Phong trào</a></li>
|
| 271 |
-
<li><a title="Chuyển nhượng V.League" href="/tin-chuyen-nhuong-v-league">Chuyển nhượng</a></li>
|
| 272 |
-
</ul>
|
| 273 |
-
</li>
|
| 274 |
-
<li>
|
| 275 |
-
<ul class="lst">
|
| 276 |
-
<li><a class="fst" title="Bóng đá Anh" href="/bong-da-anh">Bóng đá Anh</a></li>
|
| 277 |
-
<li><a title="Đội tuyển Anh" href="/doi-tuyen-anh">Đội tuyển Anh</a></li>
|
| 278 |
-
<li><a title="Ngoại hạng Anh" href="/ngoai-hang-anh">Ngoại hạng Anh</a></li>
|
| 279 |
-
<li><a title="FA Cup" href="/fa-cup">FA Cup</a></li>
|
| 280 |
-
<li><a title="League Cup" href="/cup-lien-doan-anh">League Cup</a></li>
|
| 281 |
-
</ul>
|
| 282 |
-
<div class="clx"></div>
|
| 283 |
-
<ul class="lst">
|
| 284 |
-
<li><a class="fst" title="Tây Ban nha" href="/bong-da-tay-ban-nha">Tây Ban nha</a></li>
|
| 285 |
-
<li><a title="Đội tuyển TBN" href="/doi-tuyen-tay-ban-nha">Đội tuyển TBN</a></li>
|
| 286 |
-
<li><a title="La Liga" href="/la-liga">La Liga</a></li>
|
| 287 |
-
<li><a title="Cúp Nhà Vua" href="/cup-nha-vua-tay-ban-nha">Cúp Nhà Vua</a></li>
|
| 288 |
-
</ul>
|
| 289 |
-
</li>
|
| 290 |
-
<li class="exp"></li>
|
| 291 |
-
<li>
|
| 292 |
-
<ul class="lst">
|
| 293 |
-
<li><a class="fst" title="Bóng đá Pháp" href="/bong-da-phap">Bóng đá Pháp</a></li>
|
| 294 |
-
<li><a title="Đội tuyển Pháp" href="/doi-tuyen-phap">Đội tuyển Pháp</a></li>
|
| 295 |
-
<li><a title="Ligue 1" href="/ligue-1">Ligue 1</a></li>
|
| 296 |
-
<li><a title="Cúp QG Pháp" href="/cup-quoc-gia-phap">Cúp QG Pháp</a></li>
|
| 297 |
-
<li><a title="Cúp Liên đoàn Pháp" href="/cup-lien-doan-phap-tags">Cúp Liên đoàn Pháp</a></li>
|
| 298 |
-
</ul>
|
| 299 |
-
</li>
|
| 300 |
-
<li>
|
| 301 |
-
<ul class="lst">
|
| 302 |
-
<li><a class="fst" href="#">Bóng đá Khu vực</a></li>
|
| 303 |
-
<li><a title="Asian Cup" href="/asian-cup">Asian Cup</a></li>
|
| 304 |
-
<li><a title="Asian Games" href="/asian-games">Asian Games</a></li>
|
| 305 |
-
<li><a title="AFF Cup" href="/aff-cup">AFF Cup</a></li>
|
| 306 |
-
<li><a title="SEA Games" href="/sea-games">SEA Games</a></li>
|
| 307 |
-
</ul>
|
| 308 |
-
</li>
|
| 309 |
-
<li class="exp"></li>
|
| 310 |
-
<li>
|
| 311 |
-
<ul class="lst">
|
| 312 |
-
<li><a class="fst" title="Bóng đá Italia" href="/bong-da-y">Bóng đá Italia</a></li>
|
| 313 |
-
<li><a title="Đội tuyển Italia" href="/doi-tuyen-y">Đội tuyển Italia</a></li>
|
| 314 |
-
<li><a title="Serie A" href="/serie-a">Serie A</a></li>
|
| 315 |
-
<li><a title="Coppa Italia" href="/coppa-italia">Coppa Italia</a></li>
|
| 316 |
-
</ul>
|
| 317 |
-
</li>
|
| 318 |
-
<li>
|
| 319 |
-
<ul class="lst">
|
| 320 |
-
<li><a class="fst" title="Bóng đá Đức" href="/bong-da-duc">Bóng đá Đức</a></li>
|
| 321 |
-
<li><a title="Đội tuyển Đức" href="/doi-tuyen-duc">Đội tuyển Đức</a></li>
|
| 322 |
-
<li><a title="Bundesliga" href="/bundesliga">Bundesliga</a></li>
|
| 323 |
-
<li><a title="Cúp QG Đức" href="/cup-quoc-gia-duc">Cúp QG Đức</a></li>
|
| 324 |
-
</ul>
|
| 325 |
-
</li>
|
| 326 |
-
<li class="exp"></li>
|
| 327 |
-
<li>
|
| 328 |
-
<ul class="lst">
|
| 329 |
-
<li><a class="fst" href="#">Nội dung Đặc biệt</a></li>
|
| 330 |
-
<li><a title="Big Story" href="/emagazine">Big Story</a></li>
|
| 331 |
-
<li><a title="X-File" href="/x-file">X-File</a></li>
|
| 332 |
-
<li><a title="Infographic" href="/infographic">Infographic</a></li>
|
| 333 |
-
<li><a title="Interactive" href="/interactive">Interactive</a></li>
|
| 334 |
-
</ul>
|
| 335 |
-
</li>
|
| 336 |
-
<li>
|
| 337 |
-
<ul class="lst">
|
| 338 |
-
<li><a class="fst" href="#">Giải đấu CLB</a></li>
|
| 339 |
-
<li><a title="Champions Leage" href="/champions-league-cup-c1">Champions League</a></li>
|
| 340 |
-
<li><a title="Europa League" href="/europa-league">Europa League</a></li>
|
| 341 |
-
<li><a title="Club World Cup" href="/fifa-club-world-cup-tags">Club World Cup</a></li>
|
| 342 |
-
<li><a title="Europa Conference League" href="/europa-conference-league">Europa Conf League</a></li>
|
| 343 |
-
</ul>
|
| 344 |
-
</li>
|
| 345 |
-
<li class="exp"></li>
|
| 346 |
-
<li>
|
| 347 |
-
<ul class="lst">
|
| 348 |
-
<li><a class="fst" href="#">Chuyên đề</a></li>
|
| 349 |
-
<li><a title="Tin chuyển nhượng" href="/tin-chuyen-nhuong">Chuyển nhượng</a></li>
|
| 350 |
-
<li><a title="Hậu trường bóng đá" href="/hau-truong-bong-da">Hậu trường</a></li>
|
| 351 |
-
<li><a title="Điểm tin" href="/diem-tin">Điểm tin</a></li>
|
| 352 |
-
<li><a title="Showbiz Ngôi sao" href="/ngoi-sao-showbiz">Showbiz Ngôi sao</a></li>
|
| 353 |
-
<li><a title="Góc check var" href="/goc-check-var">Góc check var</a></li>
|
| 354 |
-
<li><a title="Công nghệ" href="/cong-nghe">Công nghệ</a></li>
|
| 355 |
-
<li><a title="Thông tin Thị trường" href="/thi-truong">Thị trường</a></li>
|
| 356 |
-
<li><a title="Bóng đá & Cuộc sống" href="/bong-da-cuoc-song">Bóng đá & Cuộc sống</a></li>
|
| 357 |
-
</ul>
|
| 358 |
-
</li>
|
| 359 |
-
<li>
|
| 360 |
-
<ul class="lst">
|
| 361 |
-
<li><a class="fst" href="#">Đội tuyển</a></li>
|
| 362 |
-
<li><a title="World Cup nữ 2028" href="/world-cup-nu-2023-tags">World Cup nữ 2028</a></li>
|
| 363 |
-
<li><a title="World Cup" href="/world-cup">World Cup 2026</a></li>
|
| 364 |
-
<li><a title="European Championship 2028" href="/euro-cup-chau-au">EURO 2028</a></li>
|
| 365 |
-
<li><a title="Nations League" href="/nations-league">Nations League</a></li>
|
| 366 |
-
<li><a title="Copa America" href="/copa-america">Copa America</a></li>
|
| 367 |
-
<li><a title="CAN" href="/can">CAN</a></li>
|
| 368 |
-
<li><a title="Olympic 2028" href="/olympic">Olympic 2028</a></li>
|
| 369 |
-
</ul>
|
| 370 |
-
</li>
|
| 371 |
-
<li class="exp"></li>
|
| 372 |
-
<li>
|
| 373 |
-
<ul class="lst">
|
| 374 |
-
<li><a class="fst" title="Multimedia" href="/video">Multimedia</a></li>
|
| 375 |
-
</ul>
|
| 376 |
-
</li>
|
| 377 |
-
<li>
|
| 378 |
-
<ul class="lst">
|
| 379 |
-
<li> </li>
|
| 380 |
-
</ul>
|
| 381 |
-
</li>
|
| 382 |
-
<li class="exp"></li>
|
| 383 |
-
<li>
|
| 384 |
-
<ul class="lst">
|
| 385 |
-
<li><a class="fst" href="/lich-thi-dau-bong-da">Lịch thi đấu</a></li>
|
| 386 |
-
<li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-viet-nam" title="Lịch thi đấu V.League">V.League</a></li>
|
| 387 |
-
<li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-anh" title="Lịch thi đấu Ngoại hạng Anh">Ngoại hạng Anh</a></li>
|
| 388 |
-
<li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-phap" title="Lịch thi đấu Ligue 1 Pháp">Ligue 1</a></li>
|
| 389 |
-
<li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-tay-ban-nha" title="Lịch thi đấu La Liga Tây Ban Nha">La Liga</a></li>
|
| 390 |
-
<li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-duc" title="Lịch thi đấu Bundesliga Đức">Bundesliga</a></li>
|
| 391 |
-
<li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-y" title="Lịch thi đấu Serie A Italia">Serie A</a></li>
|
| 392 |
-
<li class="hide-m"><a href="/lich-thi-dau-bong-da?champions-league-cup-c1" title="Lịch thi đấu Champions League">Champions League</a></li>
|
| 393 |
-
<li class="hide-m"><a href="/lich-thi-dau-bong-da?europa-league" title="Lịch thi đấu Europa League">Europa League</a></li>
|
| 394 |
-
</ul>
|
| 395 |
-
</li>
|
| 396 |
-
<li>
|
| 397 |
-
<ul class="lst">
|
| 398 |
-
<li><a class="fst" href="/bang-xep-hang-bong-da">Bảng xếp hạng</a></li>
|
| 399 |
-
<li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-viet-nam" title="Bảng xếp hạng V.League">V.League</a></li>
|
| 400 |
-
<li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-anh" title="Bảng xếp hạng Ngoại hạng Anh">Ngoại hạng Anh</a></li>
|
| 401 |
-
<li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-phap" title="Bảng xếp hạng Ligue 1 Pháp">Ligue 1</a></li>
|
| 402 |
-
<li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-tay-ban-nha" title="Bảng xếp hạng La Liga Tây Ban Nha">La Liga</a></li>
|
| 403 |
-
<li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-duc" title="Bảng xếp hạng Bundesliga Đức">Bundesliga</a></li>
|
| 404 |
-
<li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-y" title="Bảng xếp hạng Serie A Italia">Serie A</a></li>
|
| 405 |
-
<li class="hide-m"><a href="/bang-xep-hang-bong-da?champions-league-cup-c1" title="Bảng xếp hạng Champions League">Champions League</a></li>
|
| 406 |
-
<li class="hide-m"><a href="/bang-xep-hang-bong-da?europa-league" title="Bảng xếp hạng Europa League">Europa League</a></li>
|
| 407 |
-
</ul>
|
| 408 |
-
</li>
|
| 409 |
-
<li class="exp"></li>
|
| 410 |
-
<li><a class="caption" href="https://phui.bongdaplus.vn" target="_blank">Bóng đá phủi</a></li>
|
| 411 |
-
<li><a class="caption" href="/tennis">Tennis</a></li>
|
| 412 |
-
<li><a class="caption" href="/esports">Esports</a></li>
|
| 413 |
-
<li><a class="caption" href="/the-thao">Thể thao +</a></li>
|
| 414 |
-
</ul>
|
| 415 |
-
</div>
|
| 416 |
-
</div>
|
| 417 |
-
<div class="mmnu-end tx-cen">
|
| 418 |
-
<p>Theo dõi thông tin</p>
|
| 419 |
-
<ul class="bar social-lnk">
|
| 420 |
-
<li class="hide-m">Theo dõi thông tin</li>
|
| 421 |
-
<li><a class="ico vff-lnk" target="_blank" href="https://vff.org.vn/" style="background-size:28px;opacity:1;" rel="nofollow"></a></li>
|
| 422 |
-
<li><a class="ico fb-lnk" target="_blank" href="https://www.facebook.com/BaoBongDa/" style="background-size:28px;opacity:1;"></a></li>
|
| 423 |
-
<li><a class="ico yt-lnk" target="_blank" href="https://www.youtube.com/channel/UCF0VFBZu-5SRJXMV_FMysEA" style="background-size:28px;opacity:1;"></a></li>
|
| 424 |
-
</ul>
|
| 425 |
-
</div>
|
| 426 |
-
</div>
|
| 427 |
-
<div class="mega-menu">
|
| 428 |
-
<div class="cont-wrap">
|
| 429 |
-
<div class="menu-row">
|
| 430 |
-
<ul class="bar">
|
| 431 |
-
<li><a class="caption" title="Tin mới nhất" href="/tin-moi">Mới nhất</a></li>
|
| 432 |
-
<li>
|
| 433 |
-
<ul class="lst">
|
| 434 |
-
<li><a class="fst" title="Bóng đá Việt Nam" href="/bong-da-viet-nam">Bóng đá Việt Nam</a></li>
|
| 435 |
-
<li><a title="Đội tuyển Việt Nam" href="/doi-tuyen-quoc-gia-viet-nam">Đội tuyển Việt Nam</a></li>
|
| 436 |
-
<li><a title="V.League" href="/v-league">V.League</a></li>
|
| 437 |
-
<li><a title="Hạng Nhất" href="/hang-nhat-viet-nam">Hạng Nhất</a></li>
|
| 438 |
-
<li><a title="Cúp Quốc gia" href="/cup-quoc-gia-viet-nam">Cúp Quốc gia</a></li>
|
| 439 |
-
<li><a title="Cúp châu Á" href="/asian-cup">Cúp châu Á</a></li>
|
| 440 |
-
<li><a title="Bóng đá nữ" href="/bong-da-nu-viet-nam">Bóng đá nữ</a></li>
|
| 441 |
-
<li><a title="Futsal" href="/futsal">Futsal</a></li>
|
| 442 |
-
<li><a title="Phong trào" href="/bong-da-phong-trao">Phong trào</a></li>
|
| 443 |
-
<li><a title="Chuyển nhượng V.League" href="/tin-chuyen-nhuong-v-league">Chuyển nhượng V.League</a></li>
|
| 444 |
-
<li><a title="Tin khác" href="/tin-khac">Tin khác</a></li>
|
| 445 |
-
</ul>
|
| 446 |
-
</li>
|
| 447 |
-
|
| 448 |
-
<li>
|
| 449 |
-
<ul class="lst">
|
| 450 |
-
<li><a class="fst" title="Bóng đá Anh" href="/bong-da-anh">Bóng đá Anh</a></li>
|
| 451 |
-
<li><a title="Đội tuyển Anh" href="/doi-tuyen-anh">Đội tuyển Anh</a></li>
|
| 452 |
-
<li><a title="Ngoại hạng Anh" href="/ngoai-hang-anh">Ngoại hạng Anh</a></li>
|
| 453 |
-
<li><a title="FA Cup" href="/fa-cup">FA Cup</a></li>
|
| 454 |
-
<li><a title="League Cup" href="/cup-lien-doan-anh">League Cup</a></li>
|
| 455 |
-
</ul>
|
| 456 |
-
<div class="clx"></div>
|
| 457 |
-
<ul class="lst">
|
| 458 |
-
<li><a class="fst" href="#">Giải đấu CLB</a></li>
|
| 459 |
-
<li><a title="Champions Leage" href="/champions-league-cup-c1">Champions League</a></li>
|
| 460 |
-
<li><a title="Europa League" href="/europa-league">Europa League</a></li>
|
| 461 |
-
<li><a title="Club World Cup" href="/fifa-club-world-cup-tags">Club World Cup</a></li>
|
| 462 |
-
<li><a title="Europa Conference League" href="/europa-conference-league">Europa Conference League</a></li>
|
| 463 |
-
</ul>
|
| 464 |
-
</li>
|
| 465 |
-
|
| 466 |
-
<li>
|
| 467 |
-
<ul class="lst">
|
| 468 |
-
<li><a class="fst" title="Bóng đá Italia" href="/bong-da-y">Bóng đá Italia</a></li>
|
| 469 |
-
<li><a title="Đội tuyển Italia" href="/doi-tuyen-y">Đội tuyển Italia</a></li>
|
| 470 |
-
<li><a title="Serie A" href="/serie-a">Serie A</a></li>
|
| 471 |
-
<li><a title="Coppa Italia" href="/coppa-italia">Coppa Italia</a></li>
|
| 472 |
-
</ul>
|
| 473 |
-
<div class="clx"></div>
|
| 474 |
-
<ul class="lst">
|
| 475 |
-
<li><a class="fst" href="#">Đội tuyển</a></li>
|
| 476 |
-
<li><a title="World Cup nữ 2028" href="/world-cup-nu-2023-tags">World Cup nữ 2028</a></li>
|
| 477 |
-
<li><a title="World Cup" href="/world-cup">World Cup 2026</a></li>
|
| 478 |
-
<li><a title="European Championship 2028" href="/euro-cup-chau-au">EURO 2028</a></li>
|
| 479 |
-
<li><a title="Nations League" href="/nations-league">Nations League</a></li>
|
| 480 |
-
<li><a title="Copa America" href="/copa-america">Copa America</a></li>
|
| 481 |
-
<li><a title="CAN" href="/can">CAN</a></li>
|
| 482 |
-
<li><a title="Olympic" href="/olympic">Olympic 2028</a></li>
|
| 483 |
-
</ul>
|
| 484 |
-
</li>
|
| 485 |
-
|
| 486 |
-
<li>
|
| 487 |
-
<ul class="lst">
|
| 488 |
-
<li><a class="fst" title="Bóng đá Pháp" href="/bong-da-phap">Bóng đá Pháp</a></li>
|
| 489 |
-
<li><a title="Đội tuyển Pháp" href="/doi-tuyen-phap">Đội tuyển Pháp</a></li>
|
| 490 |
-
<li><a title="Ligue 1" href="/ligue-1">Ligue 1</a></li>
|
| 491 |
-
<li><a title="Cúp QG Pháp" href="/cup-quoc-gia-phap">Cúp QG Pháp</a></li>
|
| 492 |
-
<li><a title="Cúp Liên đoàn Pháp" href="/cup-lien-doan-phap-tags">Cúp Liên đoàn Pháp</a></li>
|
| 493 |
-
</ul>
|
| 494 |
-
<div class="clx"></div>
|
| 495 |
-
<ul class="lst">
|
| 496 |
-
<li><a class="fst" href="#">Khu vực</a></li>
|
| 497 |
-
<li><a title="Asian Cup" href="/asian-cup">Asian Cup</a></li>
|
| 498 |
-
<li><a title="Asian Games" href="/asian-games">Asian Games</a></li>
|
| 499 |
-
<li><a title="AFF Cup" href="/aff-cup">AFF Cup</a></li>
|
| 500 |
-
<li><a title="SEA Games" href="/sea-games">SEA Games</a></li>
|
| 501 |
-
</ul>
|
| 502 |
-
</li>
|
| 503 |
-
|
| 504 |
-
<li>
|
| 505 |
-
<ul class="lst">
|
| 506 |
-
<li><a class="fst" title="Tây Ban nha" href="/bong-da-tay-ban-nha">Tây Ban nha</a></li>
|
| 507 |
-
<li><a title="Đội tuyển TBN" href="/doi-tuyen-tay-ban-nha">Đội tuyển TBN</a></li>
|
| 508 |
-
<li><a title="La Liga" href="/la-liga">La Liga</a></li>
|
| 509 |
-
<li><a title="Cúp Nhà Vua" href="/cup-nha-vua-tay-ban-nha">Cúp Nhà Vua</a></li>
|
| 510 |
-
</ul>
|
| 511 |
-
<div class="clx"></div>
|
| 512 |
-
<ul class="lst">
|
| 513 |
-
<li><a class="fst" href="#">Chuyên đề</a></li>
|
| 514 |
-
<li><a title="Tin chuyển nhượng" href="/tin-chuyen-nhuong">Chuyển nhượng</a></li>
|
| 515 |
-
<li><a title="Hậu trường bóng đá" href="/hau-truong-bong-da">Hậu trường</a></li>
|
| 516 |
-
<li><a title="Điểm tin" href="/diem-tin">Điểm tin</a></li>
|
| 517 |
-
<li><a title="Showbiz Ngôi sao" href="/ngoi-sao-showbiz">Showbiz Ngôi sao</a></li>
|
| 518 |
-
<li><a title="Góc check var" href="/goc-check-var">Góc check var</a></li>
|
| 519 |
-
<li><a title="Công nghệ" href="/cong-nghe">Công nghệ</a></li>
|
| 520 |
-
<li><a title="Thông tin Thị trường" href="/thi-truong">Thị trường</a></li>
|
| 521 |
-
</ul>
|
| 522 |
-
</li>
|
| 523 |
-
|
| 524 |
-
<li>
|
| 525 |
-
<ul class="lst">
|
| 526 |
-
<li><a class="fst" title="Bóng đá Đức" href="/bong-da-duc">Bóng đá Đức</a></li>
|
| 527 |
-
<li><a title="Đội tuyển Đức" href="/doi-tuyen-duc">Đội tuyển Đức</a></li>
|
| 528 |
-
<li><a title="Bundesliga" href="/bundesliga">Bundesliga</a></li>
|
| 529 |
-
<li><a title="Cúp QG Đức" href="/cup-quoc-gia-duc">Cúp QG Đức</a></li>
|
| 530 |
-
</ul>
|
| 531 |
-
<div class="clx"></div>
|
| 532 |
-
<ul class="lst">
|
| 533 |
-
<li><a class="fst" href="#">Đặc biệt</a></li>
|
| 534 |
-
<li><a title="Big Story" href="/emagazine">Big Story</a></li>
|
| 535 |
-
<li><a title="X-File" href="/x-file">X-File</a></li>
|
| 536 |
-
<li><a title="Infographic" href="/infographic">Infographic</a></li>
|
| 537 |
-
<li><a title="Interactive" href="/interactive">Interactive</a></li>
|
| 538 |
-
</ul>
|
| 539 |
-
</li>
|
| 540 |
-
</ul>
|
| 541 |
-
</div>
|
| 542 |
-
|
| 543 |
-
<div class="menu-row">
|
| 544 |
-
<ul class="bar">
|
| 545 |
-
<li> </li>
|
| 546 |
-
<li><a title="Tin chuyển nhượng" href="/tin-chuyen-nhuong">Tin chuyển nhượng</a></li>
|
| 547 |
-
<li><a title="Bóng đá & Cuộc sống" href="/bong-da-cuoc-song">Bóng đá & Cuộc sống</a></li>
|
| 548 |
-
<li><a title="Thông tin Thị trường" href="/thi-truong">Thông tin Thị trường</a></li>
|
| 549 |
-
<li><a title="Tin tức Công nghệ" href="/cong-nghe">Công nghệ</a></li>
|
| 550 |
-
<li><a title="Tin tức Xe auto bike" href="/xe-co">Xe auto</a></li>
|
| 551 |
-
</ul>
|
| 552 |
-
</div>
|
| 553 |
-
|
| 554 |
-
<div class="menu-row">
|
| 555 |
-
<ul class="bar">
|
| 556 |
-
<li> </li>
|
| 557 |
-
<li><a class="caption" title="Nhận định Bóng đá" href="/nhan-dinh-bong-da-tags">Nhận định</a></li>
|
| 558 |
-
<li><a class="caption" title="Tư vấn Bóng đá" href="/soi-keo-bong-da">Tư vấn</a></li>
|
| 559 |
-
<li><a class="caption" title="Multimedia" href="/video">Multimedia</a></li>
|
| 560 |
-
<li><a class="caption" title="Youtube" target="_blank" href="https://www.youtube.com/channel/UCF0VFBZu-5SRJXMV_FMysEA">Youtube</a></li>
|
| 561 |
-
<li><a class="caption" title="Facebook fanpage" target="_blank" href="https://www.facebook.com/BaoBongDa">FB Fanpage</a></li>
|
| 562 |
-
<li>
|
| 563 |
-
<a class="caption" title="Premium" href="/premium">Premium <i class="ico crown"></i></a>
|
| 564 |
-
</li>
|
| 565 |
-
</ul>
|
| 566 |
-
</div>
|
| 567 |
-
|
| 568 |
-
<div class="menu-row">
|
| 569 |
-
<ul class="bar">
|
| 570 |
-
<li><a class="caption" href="/lich-thi-dau-bong-da">Lịch thi đấu</a></li>
|
| 571 |
-
<li>
|
| 572 |
-
<ul class="lst">
|
| 573 |
-
<li><a class="fst" href="/lich-thi-dau-bong-da">Lịch thi đấu hôm nay</a></li>
|
| 574 |
-
<li><a href="/lich-thi-dau-bong-da?bong-da-viet-nam" title="Lịch thi đấu V.League">V.League</a></li>
|
| 575 |
-
<li><a href="/lich-thi-dau-bong-da?bong-da-anh" title="Lịch thi đấu Ngoại hạng Anh">Ngoại hạng Anh</a></li>
|
| 576 |
-
<li><a href="/lich-thi-dau-bong-da?bong-da-phap" title="Lịch thi đấu Ligue 1 Pháp">Ligue 1</a></li>
|
| 577 |
-
<li><a href="/lich-thi-dau-bong-da?bong-da-tay-ban-nha" title="Lịch thi đấu La Liga Tây Ban Nha">La Liga</a></li>
|
| 578 |
-
<li><a href="/lich-thi-dau-bong-da?bong-da-duc" title="Lịch thi đấu Bundesliga Đức">Bundesliga</a></li>
|
| 579 |
-
<li><a href="/lich-thi-dau-bong-da?bong-da-y" title="Lịch thi đấu Serie A Italia">Serie A</a></li>
|
| 580 |
-
<li><a href="/lich-thi-dau-bong-da?champions-league-cup-c1" title="Lịch thi đấu Champions League">Champions League</a></li>
|
| 581 |
-
<li><a href="/lich-thi-dau-bong-da?europa-league" title="Lịch thi đấu Europa League">Europa League</a></li>
|
| 582 |
-
</ul>
|
| 583 |
-
</li>
|
| 584 |
-
|
| 585 |
-
<li>
|
| 586 |
-
<ul class="lst">
|
| 587 |
-
<li><a class="fst" href="/ket-qua-bong-da">Kết quả mới nhất</a></li>
|
| 588 |
-
<li><a href="/lich-thi-dau-bong-da?bong-da-viet-nam" title="Kết quả V.League">V.League</a></li>
|
| 589 |
-
<li><a href="/lich-thi-dau-bong-da?bong-da-anh" title="Kết quả Ngoại hạng Anh">Ngoại hạng Anh</a></li>
|
| 590 |
-
<li><a href="/lich-thi-dau-bong-da?bong-da-phap" title="Kết quả Ligue 1 Pháp">Ligue 1</a></li>
|
| 591 |
-
<li><a href="/lich-thi-dau-bong-da?bong-da-tay-ban-nha" title="Kết quả La Liga Tây Ban Nha">La Liga</a></li>
|
| 592 |
-
<li><a href="/lich-thi-dau-bong-da?bong-da-duc" title="Kết quả Bundesliga Đức">Bundesliga</a></li>
|
| 593 |
-
<li><a href="/lich-thi-dau-bong-da?bong-da-y" title="Kết quả Serie A Italia">Serie A</a></li>
|
| 594 |
-
<li><a href="/lich-thi-dau-bong-da?champions-league-cup-c1" title="Kết quả Champions League">Champions League</a></li>
|
| 595 |
-
<li><a href="/lich-thi-dau-bong-da?europa-league" title="Kết quả Europa League">Europa League</a></li>
|
| 596 |
-
</ul>
|
| 597 |
-
</li>
|
| 598 |
-
|
| 599 |
-
<li>
|
| 600 |
-
<ul class="lst">
|
| 601 |
-
<li><a class="fst" href="#">Bảng xếp hạng</a></li>
|
| 602 |
-
<li><a href="/bang-xep-hang-bong-da?bong-da-viet-nam" title="Bảng xếp hạng V.League">V.League</a></li>
|
| 603 |
-
<li><a href="/bang-xep-hang-bong-da?bong-da-anh" title="Bảng xếp hạng Ngoại hạng Anh">Ngoại hạng Anh</a></li>
|
| 604 |
-
<li><a href="/bang-xep-hang-bong-da?bong-da-phap" title="Bảng xếp hạng Ligue 1 Pháp">Ligue 1</a></li>
|
| 605 |
-
<li><a href="/bang-xep-hang-bong-da?bong-da-tay-ban-nha" title="Bảng xếp hạng La Liga Tây Ban Nha">La Liga</a></li>
|
| 606 |
-
<li><a href="/bang-xep-hang-bong-da?bong-da-duc" title="Bảng xếp hạng Bundesliga Đức">Bundesliga</a></li>
|
| 607 |
-
<li><a href="/bang-xep-hang-bong-da?bong-da-y" title="Bảng xếp hạng Serie A Italia">Serie A</a></li>
|
| 608 |
-
<li><a href="/bang-xep-hang-bong-da?champions-league-cup-c1" title="Bảng xếp hạng Champions League">Champions League</a></li>
|
| 609 |
-
<li><a href="/bang-xep-hang-bong-da?europa-league" title="Bảng xếp hạng Europa League">Europa League</a></li>
|
| 610 |
-
</ul>
|
| 611 |
-
</li>
|
| 612 |
-
|
| 613 |
-
<li>
|
| 614 |
-
<ul class="lst">
|
| 615 |
-
<li><a class="caption" href="https://phui.bongdaplus.vn" target="_blank">Bóng đá phủi</a></li>
|
| 616 |
-
<li><a class="caption" href="/tennis">Tennis</a></li>
|
| 617 |
-
</ul>
|
| 618 |
-
</li>
|
| 619 |
-
<li>
|
| 620 |
-
<ul class="lst">
|
| 621 |
-
<li><a class="caption" href="/esports">Esports</a></li>
|
| 622 |
-
<li><a class="caption" href="/the-thao">Thể thao +</a></li>
|
| 623 |
-
</ul>
|
| 624 |
-
</li>
|
| 625 |
-
|
| 626 |
-
<li>
|
| 627 |
-
<ul class="lst">
|
| 628 |
-
<li>
|
| 629 |
-
<div class="copyr">
|
| 630 |
-
<div class="splt hide-w"></div>
|
| 631 |
-
<p><b class="allcap">Tạp chí Bóng Đá</b></p>
|
| 632 |
-
<p>Giấy phép số 48/GP-BTTTT cấp ngày 05/02/2020</p>
|
| 633 |
-
<p> </p>
|
| 634 |
-
<p>
|
| 635 |
-
Tổng biên tập:<br /> <b>Vũ Khắc Sơn</b><br />
|
| 636 |
-
Phó tổng biên tập:<br /> <b>Nguyễn Hà Thanh</b>
|
| 637 |
-
</p>
|
| 638 |
-
<p> </p>
|
| 639 |
-
<p>
|
| 640 |
-
<a class="contact-showr" href="#">Thông tin tòa soạn</a><br />
|
| 641 |
-
<a class="contact-showr" href="#">Liên hệ quảng cáo</a>
|
| 642 |
-
</p>
|
| 643 |
-
<p>Liên đoàn BĐVN <a href="https://vff.org.vn/" target="_blank" rel="nofollow"><img alt="VFF" src="/img/logo_Vff.png" style="width:28px;" /></a></p>
|
| 644 |
-
</div>
|
| 645 |
-
</li>
|
| 646 |
-
</ul>
|
| 647 |
-
</li>
|
| 648 |
-
</ul>
|
| 649 |
-
</div>
|
| 650 |
-
</div>
|
| 651 |
-
</div>
|
| 652 |
-
<div class="menu-tags hide-w">
|
| 653 |
-
<div class="today"></div>
|
| 654 |
-
<div class="mtags">
|
| 655 |
-
<a title="#HighlightBóngđá" href="/video/highlight">#HighlightBóngđá</a>
|
| 656 |
-
<a title="#PremierLeague" href="/ngoai-hang-anh">#PremierLeague</a>
|
| 657 |
-
<a title="#V.League" href="/v-league">#V.League</a>
|
| 658 |
-
</div>
|
| 659 |
-
</div>
|
| 660 |
-
|
| 661 |
-
<div id="fxtSlideCover" class="fixt-strm" style="margin-bottom:5px;">
|
| 662 |
-
<div class="cont-wrap">
|
| 663 |
-
<a class="brand" href="https://admin.giaminhmedia.vn/www/delivery/cl.php?bannerid=1411&zoneid=328&sig=8300d1283663cec85d90499507ffd94240954967132167c0f302d7388b8f9bb5&dest=http%3A%2F%2Fadmin.giaminhmedia.vn%2Fwww%2Funiscore.php" target="_blank" rel="nofollow" style="margin-left:5px;">
|
| 664 |
-
<img alt="" src="https://bongdaplus.vn/img/logouniscore-tiny.png">
|
| 665 |
-
</a>
|
| 666 |
-
<ul id="fxtSlide" class="nav"></ul>
|
| 667 |
-
</div>
|
| 668 |
-
</div>
|
| 669 |
-
|
| 670 |
-
<div class="clz"></div>
|
| 671 |
-
<div id="ADTOP" class="banner adweb admob" banner-adpos="top"></div>
|
| 672 |
-
|
| 673 |
-
<section class="bread-crums">
|
| 674 |
-
<div class="clz"></div>
|
| 675 |
-
<div class="cont-wrap">
|
| 676 |
-
<div class="chain-navs">
|
| 677 |
-
<a href="/video">Multimedia</a>»
|
| 678 |
-
</div>
|
| 679 |
-
</div>
|
| 680 |
-
</section>
|
| 681 |
-
|
| 682 |
-
<section class="media-box">
|
| 683 |
-
<div class="clz"></div>
|
| 684 |
-
<div class="cont-wrap video-box">
|
| 685 |
-
<div class="row">
|
| 686 |
-
<div class="col m12 w8 clip-box">
|
| 687 |
-
<div class="play-box rbox">
|
| 688 |
-
<iframe id="ytbFrBpl" class="play-frame" src="https://www.youtube.com/embed/Qokb_8w6p0s?si=QNKAZIYxNrtn2hSN" cid="UCmn1v6lrIDZghUfbxXaorUw" vnum="0" class="latestVideoEmbed" title="Bongdaplus trên Youtube" frameborder="0" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen=""></iframe>
|
| 689 |
-
</div>
|
| 690 |
-
</div>
|
| 691 |
-
|
| 692 |
-
<div class="col m12 w4 clip-cont">
|
| 693 |
-
<div class="ovrscrl">
|
| 694 |
-
<div class="clip-info">
|
| 695 |
-
<h1 class="title">Hull City vs MU: Quỷ Đỏ vào hang bắt hổ</h1>
|
| 696 |
-
<p class="desc">
|
| 697 |
-
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.
|
| 698 |
-
</p>
|
| 699 |
-
|
| 700 |
-
<div>08:07 - 22/08/2026</div>
|
| 701 |
-
<div class="editor"></div>
|
| 702 |
-
<div class="hash-tags">
|
| 703 |
-
<b>Tags:</b>
|
| 704 |
-
<a href="/video/hull-city-vs-mu-tags">Hull City vs MU</a>
|
| 705 |
-
<a href="/video/nhan-dinh-hull-city-vs-mu-tags">nhận định Hull City vs MU</a>
|
| 706 |
-
<a href="/video/premier-league-tags">Premier League</a>
|
| 707 |
-
<a href="/video/mu-tags">MU</a>
|
| 708 |
-
</div>
|
| 709 |
-
<style>
|
| 710 |
-
.emobar .emo:not(:empty) {
|
| 711 |
-
background-position: left 10px top 50%;
|
| 712 |
-
padding: 0 13px 0 36px;
|
| 713 |
-
}
|
| 714 |
-
.emobar .emo {
|
| 715 |
-
border-radius: 16px;
|
| 716 |
-
background-color: #d7d3d3;
|
| 717 |
-
background-size: auto 18px;
|
| 718 |
-
cursor: pointer;
|
| 719 |
-
margin: 0 3px;
|
| 720 |
-
padding: 0 16px;
|
| 721 |
-
line-height: 32px;
|
| 722 |
-
height: 32px;
|
| 723 |
-
}
|
| 724 |
-
.emopicked {
|
| 725 |
-
border: solid 1px #767676;
|
| 726 |
-
}
|
| 727 |
-
</style>
|
| 728 |
-
<div class="emobar tx-cen">
|
| 729 |
-
<div class="clx"></div>
|
| 730 |
-
<a class="emo like" onclick="btnNewsEmotion_click(1)" id="btnEmo1"> <span id="numemo1"></span></a>
|
| 731 |
-
<a class="emo heart" onclick="btnNewsEmotion_click(2)" id="btnEmo2"> <span id="numemo2"></span></a>
|
| 732 |
-
<a class="emo star" onclick="btnNewsEmotion_click(4)" id="btnEmo4"> <span id="numemo4"></span></a>
|
| 733 |
-
<a class="emo comment" id="ncmt" href="#commenting" data-action="comment" data-act-only="true"></a>
|
| 734 |
-
</div>
|
| 735 |
-
</div>
|
| 736 |
-
</div>
|
| 737 |
-
</div>
|
| 738 |
-
</div>
|
| 739 |
-
</div>
|
| 740 |
-
|
| 741 |
-
<div class="clx"></div>
|
| 742 |
-
<div class="cont-view">
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
<style>
|
| 748 |
-
.btnlikedislikecmt {
|
| 749 |
-
border-radius: 9px;
|
| 750 |
-
border: solid 1px #767676;
|
| 751 |
-
background-color: #cecece;
|
| 752 |
-
}
|
| 753 |
-
|
| 754 |
-
sup {
|
| 755 |
-
padding: 3px;
|
| 756 |
-
position: initial;
|
| 757 |
-
vertical-align: initial;
|
| 758 |
-
}
|
| 759 |
-
|
| 760 |
-
.TopComment {
|
| 761 |
-
border: solid 1px #fdd835;
|
| 762 |
-
}
|
| 763 |
-
|
| 764 |
-
.TopComment .info .member {
|
| 765 |
-
color: red;
|
| 766 |
-
}
|
| 767 |
-
|
| 768 |
-
.TopComment .info .avatar {
|
| 769 |
-
border: solid 1px red;
|
| 770 |
-
}
|
| 771 |
-
|
| 772 |
-
.comment .summ .replyat {
|
| 773 |
-
/*color: #1b5e20;*/
|
| 774 |
-
display: inline-block;
|
| 775 |
-
margin: 0 8px 0 0;
|
| 776 |
-
font-weight: bold;
|
| 777 |
-
/* text-decoration: underline; */
|
| 778 |
-
}
|
| 779 |
-
</style>
|
| 780 |
-
<div id="postComment" class="comm-zone">
|
| 781 |
-
<a class="anchor" name="#comments"></a>
|
| 782 |
-
<div class="clz"></div>
|
| 783 |
-
<div class="xcap">Bình luận</div>
|
| 784 |
-
<div class="clz"></div>
|
| 785 |
-
<div class="comm-post ">
|
| 786 |
-
<div id="formcomment" class="comm-form">
|
| 787 |
-
<label class="txt-fld">
|
| 788 |
-
<textarea id="txtcomment_0" name="txtcomment_0" maxlength="1000" placeholder="Hãy chia sẻ suy nghĩ của bạn..." onclick="showcommentloginform();"></textarea>
|
| 789 |
-
</label>
|
| 790 |
-
<div class="actions">
|
| 791 |
-
<button id="btnComment" class="btn btn-send base" onclick="btnComment_click(0,0)">Gửi</button>
|
| 792 |
-
</div>
|
| 793 |
-
<center><div class="cmterr"></div></center>
|
| 794 |
-
</div>
|
| 795 |
-
</div>
|
| 796 |
-
<div class="clz"></div>
|
| 797 |
-
<div class="comm-frame">
|
| 798 |
-
<div id="NewsComments"></div>
|
| 799 |
-
<div class="clz"></div>
|
| 800 |
-
<div class="cat-news">
|
| 801 |
-
<div id="btnloadviewmore" class="view-more hide"><a onclick="loadviewmorecomment();">Xem thêm</a></div>
|
| 802 |
-
</div>
|
| 803 |
-
<div class="clz"></div>
|
| 804 |
-
</div>
|
| 805 |
-
</div>
|
| 806 |
-
</div>
|
| 807 |
-
<div class="clz"></div>
|
| 808 |
-
</section>
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
<div class="clx"></div>
|
| 815 |
-
<center><div id="ADHOT" class="banner adweb admob" banner-adpos="hot"></div></center>
|
| 816 |
-
<div class="clx"></div>
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
<section class="cat-news">
|
| 820 |
-
<div class="cont-wrap">
|
| 821 |
-
<div class="capt-cover">
|
| 822 |
-
<div class="caption">Video mới nhất</div>
|
| 823 |
-
<button class="more-lnk"></button>
|
| 824 |
-
</div>
|
| 825 |
-
<div class="row flex">
|
| 826 |
-
<div class="col m12 w3">
|
| 827 |
-
<div class="news">
|
| 828 |
-
<a class="thumb" href="/video/du-doan-ty-so-hull-mu-than-rua-ali-33-chon-doi-nao-982702608.html"><img alt="Dự đoán tỷ số Hull vs MU: Thần Rùa Ali 33 chọn đội nào?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/18/80/Anh-chu-480-x-270.jpg" /></a>
|
| 829 |
-
<a class="title" href="/video/du-doan-ty-so-hull-mu-than-rua-ali-33-chon-doi-nao-982702608.html">Dự đoán tỷ số Hull vs MU: Thần Rùa Ali 33 chọn đội nào?</a>
|
| 830 |
-
<span class="info">
|
| 831 |
-
43 phút trước
|
| 832 |
-
</span>
|
| 833 |
-
<span class="info">
|
| 834 |
-
</span>
|
| 835 |
-
</div>
|
| 836 |
-
</div>
|
| 837 |
-
<div class="col m12 w3">
|
| 838 |
-
<div class="news">
|
| 839 |
-
<a class="thumb" href="/video/hull-city-vs-mu-quy-do-vao-hang-bat-ho-982672608.html"><img alt="Hull City vs MU: Quỷ Đỏ vào hang bắt hổ" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/18/13/ThumbHullvsMU-up.jpg" /></a>
|
| 840 |
-
<a class="title" href="/video/hull-city-vs-mu-quy-do-vao-hang-bat-ho-982672608.html">Hull City vs MU: Quỷ Đỏ vào hang bắt hổ</a>
|
| 841 |
-
<span class="info">
|
| 842 |
-
44 phút trước
|
| 843 |
-
</span>
|
| 844 |
-
<span class="info">
|
| 845 |
-
</span>
|
| 846 |
-
</div>
|
| 847 |
-
</div>
|
| 848 |
-
<div class="col m12 w3">
|
| 849 |
-
<div class="news">
|
| 850 |
-
<a class="thumb" href="/video/du-doan-ty-so-brentford-vs-tottenham-premier-league-2026-27-than-rua-ali-33-chon-doi-nao-982862608.html"><img alt="Dự đoán tỷ số Brentford vs Tottenham: Thần Rùa Ali 33 chọn đội nào?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/19/80/Brentford-vs-Tottenham-480-x-270-moi.jpg" /></a>
|
| 851 |
-
<a class="title" href="/video/du-doan-ty-so-brentford-vs-tottenham-premier-league-2026-27-than-rua-ali-33-chon-doi-nao-982862608.html">Dự đoán tỷ số Brentford vs Tottenham: Thần Rùa Ali 33 chọn đội nào?</a>
|
| 852 |
-
<span class="info">
|
| 853 |
-
44 phút trước
|
| 854 |
-
</span>
|
| 855 |
-
<span class="info">
|
| 856 |
-
</span>
|
| 857 |
-
</div>
|
| 858 |
-
</div>
|
| 859 |
-
<div class="col m12 w3">
|
| 860 |
-
<div class="news">
|
| 861 |
-
<a class="thumb" href="/video/brentford-vs-tottenham-de-zerbi-vuot-ai-gtech-982822608.html"><img alt="Brentford vs Tottenham: De Zerbi vượt ải Gtech" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/19/13/Brenfort-vs-Tottenham.jpg" /></a>
|
| 862 |
-
<a class="title" href="/video/brentford-vs-tottenham-de-zerbi-vuot-ai-gtech-982822608.html">Brentford vs Tottenham: De Zerbi vượt ải Gtech</a>
|
| 863 |
-
<span class="info">
|
| 864 |
-
45 phút trước
|
| 865 |
-
</span>
|
| 866 |
-
<span class="info">
|
| 867 |
-
</span>
|
| 868 |
-
</div>
|
| 869 |
-
</div>
|
| 870 |
-
<div class="col m12 w3">
|
| 871 |
-
<div class="news">
|
| 872 |
-
<a class="thumb" href="/video/du-doan-ty-so-arsenal-vs-coventry-than-rua-ali-33-chon-doi-nao-982612608.html"><img alt="Dự đoán tỷ số Arsenal vs Coventry: Thần Rùa Ali 33 chọn đội nào?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/17/0/thumblai.jpg" /></a>
|
| 873 |
-
<a class="title" href="/video/du-doan-ty-so-arsenal-vs-coventry-than-rua-ali-33-chon-doi-nao-982612608.html">Dự đoán tỷ số Arsenal vs Coventry: Thần Rùa Ali 33 chọn đội nào?</a>
|
| 874 |
-
<span class="info">
|
| 875 |
-
09 giờ trước
|
| 876 |
-
</span>
|
| 877 |
-
<span class="info">
|
| 878 |
-
</span>
|
| 879 |
-
</div>
|
| 880 |
-
</div>
|
| 881 |
-
<div class="col m12 w3">
|
| 882 |
-
<div class="news">
|
| 883 |
-
<a class="thumb" href="/video/arsenal-vs-coventry-nha-vua-mo-man-bang-mot-chien-thang-982662608.html"><img alt="Arsenal vs Coventry: Nhà vua mở màn bằng một chiến thắng?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/18/13/ThumbArsenalvsCoventry-up.jpg" /></a>
|
| 884 |
-
<a class="title" href="/video/arsenal-vs-coventry-nha-vua-mo-man-bang-mot-chien-thang-982662608.html">Arsenal vs Coventry: Nhà vua mở màn bằng một chiến thắng?</a>
|
| 885 |
-
<span class="info">
|
| 886 |
-
09 giờ trước
|
| 887 |
-
</span>
|
| 888 |
-
<span class="info">
|
| 889 |
-
</span>
|
| 890 |
-
</div>
|
| 891 |
-
</div>
|
| 892 |
-
<div class="col m12 w3">
|
| 893 |
-
<div class="news">
|
| 894 |
-
<a class="thumb" href="/video/truc-tuyen-truoc-tran-arsenal-vs-coventry-hull-vs-mu-983222608.html"><img alt="Trực tuyến trước trận Arsenal vs Coventry, Hull vs MU" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/truc-tuyen-truoc-tran-Arsenal-vs-Coventry.jpg" /></a>
|
| 895 |
-
<a class="title" href="/video/truc-tuyen-truoc-tran-arsenal-vs-coventry-hull-vs-mu-983222608.html">Trực tuyến trước trận Arsenal vs Coventry, Hull vs MU</a>
|
| 896 |
-
<span class="info">
|
| 897 |
-
11 giờ trước
|
| 898 |
-
</span>
|
| 899 |
-
<span class="info">
|
| 900 |
-
</span>
|
| 901 |
-
</div>
|
| 902 |
-
</div>
|
| 903 |
-
<div class="col m12 w3">
|
| 904 |
-
<div class="news">
|
| 905 |
-
<a class="thumb" href="/video/dt-thai-lan-tap-cach-san-rajamangala-40km-hlv-anthony-hudson-chot-luon-doi-hinh-dau-viet-nam-983202608.html"><img alt=" ĐT Thái Lan tập cách sân Rajamangala 40km, HLV Anthony Hudson chốt luôn đội hình đấu Việt Nam" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/80/HLV-Thai-Lan-chot-doi-hinh.jpg" /></a>
|
| 906 |
-
<a class="title" href="/video/dt-thai-lan-tap-cach-san-rajamangala-40km-hlv-anthony-hudson-chot-luon-doi-hinh-dau-viet-nam-983202608.html"> ĐT Thái Lan tập cách sân Rajamangala 40km, HLV Anthony Hudson chốt luôn đội hình đấu Việt Nam</a>
|
| 907 |
-
<span class="info">
|
| 908 |
-
13 giờ trước
|
| 909 |
-
</span>
|
| 910 |
-
<span class="info">
|
| 911 |
-
</span>
|
| 912 |
-
</div>
|
| 913 |
-
</div>
|
| 914 |
-
<div class="col m12 w3">
|
| 915 |
-
<div class="news">
|
| 916 |
-
<a class="thumb" href="/video/tro-lai-san-tung-vo-dich-aff-cup-2024-dt-viet-nam-quyet-lam-nen-lich-su-983212608.html"><img alt=" Trở lại sân từng vô địch AFF Cup 2024, ĐT Việt Nam quyết làm nên lịch sử" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/80/Viet-Nam-tap-2-1-8.jpg" /></a>
|
| 917 |
-
<a class="title" href="/video/tro-lai-san-tung-vo-dich-aff-cup-2024-dt-viet-nam-quyet-lam-nen-lich-su-983212608.html"> Trở lại sân từng vô địch AFF Cup 2024, ĐT Việt Nam quyết làm nên lịch sử</a>
|
| 918 |
-
<span class="info">
|
| 919 |
-
14 giờ trước
|
| 920 |
-
</span>
|
| 921 |
-
<span class="info">
|
| 922 |
-
</span>
|
| 923 |
-
</div>
|
| 924 |
-
</div>
|
| 925 |
-
<div class="col m12 w3">
|
| 926 |
-
<div class="news">
|
| 927 |
-
<a class="thumb" href="/video/vong-1-premier-league-2026-27-lieu-co-cu-soc-983112608.html"><img alt="Nhận định Premier League vòng 1: Liệu có cú sốc?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/thumbvong1-480.jpg" /></a>
|
| 928 |
-
<a class="title" href="/video/vong-1-premier-league-2026-27-lieu-co-cu-soc-983112608.html">Nhận định Premier League vòng 1: Liệu có cú sốc?</a>
|
| 929 |
-
<span class="info">
|
| 930 |
-
14 giờ trước
|
| 931 |
-
</span>
|
| 932 |
-
<span class="info">
|
| 933 |
-
</span>
|
| 934 |
-
</div>
|
| 935 |
-
</div>
|
| 936 |
-
<div class="col m12 w3">
|
| 937 |
-
<div class="news">
|
| 938 |
-
<a class="thumb" href="/video/ronaldinho-gay-soc-46-tuoi-van-tro-lai-san-co-khoac-ao-clb-serie-c-983192608.html"><img alt="Ronaldinho gây sốc: 46 tuổi vẫn trở lại sân cỏ, khoác áo CLB Serie C" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/26/Ronaldinho480.jpg" /></a>
|
| 939 |
-
<a class="title" href="/video/ronaldinho-gay-soc-46-tuoi-van-tro-lai-san-co-khoac-ao-clb-serie-c-983192608.html">Ronaldinho gây sốc: 46 tuổi vẫn trở lại sân cỏ, khoác áo CLB Serie C</a>
|
| 940 |
-
<span class="info">
|
| 941 |
-
14 giờ trước
|
| 942 |
-
</span>
|
| 943 |
-
<span class="info">
|
| 944 |
-
</span>
|
| 945 |
-
</div>
|
| 946 |
-
</div>
|
| 947 |
-
<div class="col m12 w3">
|
| 948 |
-
<div class="news">
|
| 949 |
-
<a class="thumb" href="/video/arsenal-no-bom-tan-51-trieu-bang-de-va-hang-thu-983122608.html"><img alt="Arsenal nổ bom tấn 51 triệu bảng để vá hàng thủ" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/26/Konsa480.jpg" /></a>
|
| 950 |
-
<a class="title" href="/video/arsenal-no-bom-tan-51-trieu-bang-de-va-hang-thu-983122608.html">Arsenal nổ bom tấn 51 triệu bảng để vá hàng thủ</a>
|
| 951 |
-
<span class="info">
|
| 952 |
-
14 giờ trước
|
| 953 |
-
</span>
|
| 954 |
-
<span class="info">
|
| 955 |
-
</span>
|
| 956 |
-
</div>
|
| 957 |
-
</div>
|
| 958 |
-
<div class="col m12 w3">
|
| 959 |
-
<div class="news">
|
| 960 |
-
<a class="thumb" href="/video/truc-tuyen-hop-bao-viet-nam-hlv-kim-sang-sik-noi-gi-khi-pho-chu-tich-thai-lan-mia-mai-dan-nhap-tich-983182608.html"><img alt="Trực tuyến họp báo Việt Nam: HLV Kim Sang Sik nói gì khi Phó Chủ tịch Thái Lan mỉa mai dàn nhập tịch" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/anhliveVN.jpg" /></a>
|
| 961 |
-
<a class="title" href="/video/truc-tuyen-hop-bao-viet-nam-hlv-kim-sang-sik-noi-gi-khi-pho-chu-tich-thai-lan-mia-mai-dan-nhap-tich-983182608.html">Trực tuyến họp báo Việt Nam: HLV Kim Sang Sik nói gì khi Phó Chủ tịch Thái Lan mỉa mai dàn nhập tịch</a>
|
| 962 |
-
<span class="info">
|
| 963 |
-
16 giờ trước
|
| 964 |
-
</span>
|
| 965 |
-
<span class="info">
|
| 966 |
-
</span>
|
| 967 |
-
</div>
|
| 968 |
-
</div>
|
| 969 |
-
<div class="col m12 w3">
|
| 970 |
-
<div class="news">
|
| 971 |
-
<a class="thumb" href="/video/truc-tuyen-thai-lan-hop-bao-hlv-thach-dau-dt-viet-nam-983162608.html"><img alt=" Trực tuyến Thái Lan họp báo: HLV Anthony Hudson thách đấu ĐT Việt Nam" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/anhlivesua-480.jpg" /></a>
|
| 972 |
-
<a class="title" href="/video/truc-tuyen-thai-lan-hop-bao-hlv-thach-dau-dt-viet-nam-983162608.html"> Trực tuyến Thái Lan họp báo: HLV Anthony Hudson thách đấu ĐT Việt Nam</a>
|
| 973 |
-
<span class="info">
|
| 974 |
-
18 giờ trước
|
| 975 |
-
</span>
|
| 976 |
-
<span class="info">
|
| 977 |
-
</span>
|
| 978 |
-
</div>
|
| 979 |
-
</div>
|
| 980 |
-
<div class="col m12 w3">
|
| 981 |
-
<div class="news">
|
| 982 |
-
<a class="thumb" href="/video/ban-tin-21-8-dt-viet-nam-chiu-thong-ke-cuc-xau-truoc-chung-ket-thai-lan-choi-chieu-nhu-indo-983152608.html"><img alt="Bản tin 21/8: ĐT Việt Nam chịu thống kê cực xấu trước chung kết, Thái Lan chơi chiêu như Indo" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/Thumb-tin-nong-21-8-480-up.jpg" /></a>
|
| 983 |
-
<a class="title" href="/video/ban-tin-21-8-dt-viet-nam-chiu-thong-ke-cuc-xau-truoc-chung-ket-thai-lan-choi-chieu-nhu-indo-983152608.html">Bản tin 21/8: ĐT Việt Nam chịu thống kê cực xấu trước chung kết, Thái Lan chơi chiêu như Indo</a>
|
| 984 |
-
<span class="info">
|
| 985 |
-
18 giờ trước
|
| 986 |
-
</span>
|
| 987 |
-
<span class="info">
|
| 988 |
-
</span>
|
| 989 |
-
</div>
|
| 990 |
-
</div>
|
| 991 |
-
<div class="col m12 w3">
|
| 992 |
-
<div class="news">
|
| 993 |
-
<a class="thumb" href="/video/thai-lan-khoa-cdv-viet-nam-chi-3-000-ve-cho-tran-chung-ket-tai-rajamangala-983142608.html"><img alt="Thái Lan 'khóa' CĐV Việt Nam: Chỉ 3.000 vé cho trận chung kết tại Rajamangala" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/26/cdv.jpg" /></a>
|
| 994 |
-
<a class="title" href="/video/thai-lan-khoa-cdv-viet-nam-chi-3-000-ve-cho-tran-chung-ket-tai-rajamangala-983142608.html">Thái Lan 'khóa' CĐV Việt Nam: Chỉ 3.000 vé cho trận chung kết tại Rajamangala</a>
|
| 995 |
-
<span class="info">
|
| 996 |
-
20 giờ trước
|
| 997 |
-
</span>
|
| 998 |
-
<span class="info">
|
| 999 |
-
</span>
|
| 1000 |
-
</div>
|
| 1001 |
-
</div>
|
| 1002 |
-
<div class="col m12 w3">
|
| 1003 |
-
<div class="news">
|
| 1004 |
-
<a class="thumb" href="/video/truoc-tran-thai-lan-vs-viet-nam-svd-rajamagala-vang-tanh-hon-chua-ba-danh-983132608.html"><img alt="Trước trận Thái Lan vs Việt Nam: SVĐ Rajamagala vắng tanh hơn chùa Bà Đanh" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/livethumbYT-480.jpg" /></a>
|
| 1005 |
-
<a class="title" href="/video/truoc-tran-thai-lan-vs-viet-nam-svd-rajamagala-vang-tanh-hon-chua-ba-danh-983132608.html">Trước trận Thái Lan vs Việt Nam: SVĐ Rajamagala vắng tanh hơn chùa Bà Đanh</a>
|
| 1006 |
-
<span class="info">
|
| 1007 |
-
22 giờ trước
|
| 1008 |
-
</span>
|
| 1009 |
-
<span class="info">
|
| 1010 |
-
</span>
|
| 1011 |
-
</div>
|
| 1012 |
-
</div>
|
| 1013 |
-
<div class="col m12 w3">
|
| 1014 |
-
<div class="news">
|
| 1015 |
-
<a class="thumb" href="/video/du-doan-ty-so-thai-lan-vs-viet-nam-than-rua-ali-33-chon-doi-nao-983092608.html"><img alt="Dự đoán tỷ số Thái Lan vs Việt Nam: Thần Rùa Ali 33 chọn đội nào?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/20/80/ThumbRuaThaivsVietnam-480.jpg" /></a>
|
| 1016 |
-
<a class="title" href="/video/du-doan-ty-so-thai-lan-vs-viet-nam-than-rua-ali-33-chon-doi-nao-983092608.html">Dự đoán tỷ số Thái Lan vs Việt Nam: Thần Rùa Ali 33 chọn đội nào?</a>
|
| 1017 |
-
<span class="info">
|
| 1018 |
-
19:18 ngày 20/08/2026
|
| 1019 |
-
</span>
|
| 1020 |
-
<span class="info">
|
| 1021 |
-
</span>
|
| 1022 |
-
</div>
|
| 1023 |
-
</div>
|
| 1024 |
-
<div class="col m12 w3">
|
| 1025 |
-
<div class="news">
|
| 1026 |
-
<a class="thumb" href="/video/truyen-thong-anh-dac-biet-chu-y-den-ngoi-no-cua-dt-viet-nam-983072608.html"><img alt="Truyền thông Anh đặc biệt chú ý đến 'ngòi nổ' của ĐT Việt Nam" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/20/26/xuan-son.jpg" /></a>
|
| 1027 |
-
<a class="title" href="/video/truyen-thong-anh-dac-biet-chu-y-den-ngoi-no-cua-dt-viet-nam-983072608.html">Truyền thông Anh đặc biệt chú ý đến 'ngòi nổ' của ĐT Việt Nam</a>
|
| 1028 |
-
<span class="info">
|
| 1029 |
-
19:18 ngày 20/08/2026
|
| 1030 |
-
</span>
|
| 1031 |
-
<span class="info">
|
| 1032 |
-
</span>
|
| 1033 |
-
</div>
|
| 1034 |
-
</div>
|
| 1035 |
-
<div class="col m12 w3">
|
| 1036 |
-
<div class="news">
|
| 1037 |
-
<a class="thumb" href="/video/dt-viet-nam-co-mat-tai-thai-lan-ao-trang-van-chua-bi-thay-kim-vut-het-983082608.html"><img alt="ĐT Việt Nam có mặt tại Thái Lan, áo trắng vẫn chưa bị thầy Kim bỏ hết" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/20/26/Kim-Sang-Sik.jpg" /></a>
|
| 1038 |
-
<a class="title" href="/video/dt-viet-nam-co-mat-tai-thai-lan-ao-trang-van-chua-bi-thay-kim-vut-het-983082608.html">ĐT Việt Nam có mặt tại Thái Lan, áo trắng vẫn chưa bị thầy Kim bỏ hết</a>
|
| 1039 |
-
<span class="info">
|
| 1040 |
-
17:32 ngày 20/08/2026
|
| 1041 |
-
</span>
|
| 1042 |
-
<span class="info">
|
| 1043 |
-
</span>
|
| 1044 |
-
</div>
|
| 1045 |
-
</div>
|
| 1046 |
-
</div>
|
| 1047 |
-
</div>
|
| 1048 |
-
<div class="clz"></div>
|
| 1049 |
-
</section>
|
| 1050 |
-
|
| 1051 |
-
|
| 1052 |
-
|
| 1053 |
-
<div class="clx"></div>
|
| 1054 |
-
<center><div id="ADBOT" class="banner adweb admob" banner-adpos="footer"></div></center>
|
| 1055 |
-
</main>
|
| 1056 |
-
|
| 1057 |
-
<style>
|
| 1058 |
-
.iframpanel {
|
| 1059 |
-
width: 100%;
|
| 1060 |
-
max-width: 500px;
|
| 1061 |
-
height: 560px;
|
| 1062 |
-
overflow: hidden;
|
| 1063 |
-
}
|
| 1064 |
-
|
| 1065 |
-
</style>
|
| 1066 |
-
<div class="paner-wrap">
|
| 1067 |
-
<div class="over-layer hide"></div>
|
| 1068 |
-
<div id="loginview" class="login-box paner hide iframpanel">
|
| 1069 |
-
<div class="pan-capt">
|
| 1070 |
-
<i class="pan-close" data-action="hide"></i>
|
| 1071 |
-
</div>
|
| 1072 |
-
|
| 1073 |
-
|
| 1074 |
-
<iframe src="https://member.bongdaplus.vn/Identity/Account/Login?ReturnUrl=%2FHome%2FLoginFromBongdaplus" frameborder="0" style="overflow:hidden;height:100%;width:100%"></iframe>
|
| 1075 |
-
|
| 1076 |
-
</div>
|
| 1077 |
-
<div id="commentloginview" class="login-box paner hide iframpanel">
|
| 1078 |
-
|
| 1079 |
-
|
| 1080 |
-
<iframe src="https://member.bongdaplus.vn/Identity/Account/Login?ReturnUrl=%2FHome%2FCommentLoginFromBongdaplus" frameborder="0" style="overflow:hidden;height:100%;width:100%"></iframe>
|
| 1081 |
-
|
| 1082 |
-
</div>
|
| 1083 |
-
</div>
|
| 1084 |
-
<input type="hidden" id="txtFullname" value="" />
|
| 1085 |
-
<input type="hidden" id="txtUserid" value="" />
|
| 1086 |
-
|
| 1087 |
-
|
| 1088 |
-
|
| 1089 |
-
|
| 1090 |
-
|
| 1091 |
-
|
| 1092 |
-
<center><div id="ADFT" class="banner adweb admob" banner-adpos="footer"></div></center>
|
| 1093 |
-
<footer>
|
| 1094 |
-
<div class="cont-wrap">
|
| 1095 |
-
<div class="row">
|
| 1096 |
-
<div class="col m12 w3">
|
| 1097 |
-
<div class="caption">Tạp chí điện tử Bóng đá</div>
|
| 1098 |
-
</div>
|
| 1099 |
-
<div class="col m12 w6">
|
| 1100 |
-
<p>Giấy phép số 48/GP-BTTTT cấp ngày 05/02/2020</p>
|
| 1101 |
-
<p>Tổng biên tập: <b>Vũ Khắc Sơn</b></p>
|
| 1102 |
-
<p>Phó tổng biên tập: <b>Nguyễn Hà Thanh</b></p>
|
| 1103 |
-
<div class="hide-w">
|
| 1104 |
-
<p>Địa chỉ: <b>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</b></p>
|
| 1105 |
-
<p>Văn phòng giao dịch: <b>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</b></p>
|
| 1106 |
-
<p>Điện thoại: <b>(84.24) 3554 1188 - (84.24) 3554 1199</b></p>
|
| 1107 |
-
<p>
|
| 1108 |
-
Email: <a href="mailto:toasoan@bongdaplus.vn"><b>toasoan@bongdaplus.vn</b></a> |
|
| 1109 |
-
<a href="mailto:vanphong@bongdaplus.vn"><b>vanphong@bongdaplus.vn</b></a>
|
| 1110 |
-
</p>
|
| 1111 |
-
<div class="spl"></div>
|
| 1112 |
-
<p class="caption">Liên hệ quảng cáo</p>
|
| 1113 |
-
<p>Hotline: <b>0903 203 412</b></p>
|
| 1114 |
-
<p>Email: <a href="mailto:tien.nguyen@giaminhmedia.vn"><b>tien.nguyen@giaminhmedia.vn</b></a></p>
|
| 1115 |
-
</div>
|
| 1116 |
-
</div>
|
| 1117 |
-
<div class="col m12 w3">
|
| 1118 |
-
<ul class="bar social-lnk rgt-w">
|
| 1119 |
-
<li class="hide-m">Theo dõi thông tin</li>
|
| 1120 |
-
<li><a class="ico vff-lnk" href="https://vff.org.vn/" target="_blank" rel="nofollow"></a></li>
|
| 1121 |
-
<li><a class="ico fb-lnk" href="https://www.facebook.com/BaoBongDa/"></a></li>
|
| 1122 |
-
<li><a class="ico yt-lnk" href="https://www.youtube.com/channel/UCvDYMBE6GipX2VGacqOU2_Q"></a></li>
|
| 1123 |
-
</ul>
|
| 1124 |
-
</div>
|
| 1125 |
-
</div>
|
| 1126 |
-
<div class="foot-menu">
|
| 1127 |
-
<ul class="bar hide-m">
|
| 1128 |
-
<li><a href="#" data-showpaner="#contactview">Thông tin tòa soạn</a></li>
|
| 1129 |
-
<li><a href="#" data-showpaner="#advertview">Liên hệ quảng cáo</a></li>
|
| 1130 |
-
</ul>
|
| 1131 |
-
<div class="copy-rgt">
|
| 1132 |
-
Bản quyền ©2011 Bongdaplus.vn. Chỉ được phát hành lại thông tin khi
|
| 1133 |
-
có sự đồng ý bằng văn bản của Tạp chí điện tử Bóng đá
|
| 1134 |
-
</div>
|
| 1135 |
-
<div class="clx hide-m"></div>
|
| 1136 |
-
</div>
|
| 1137 |
-
</div>
|
| 1138 |
-
</footer>
|
| 1139 |
-
|
| 1140 |
-
<div class="paner-wrap">
|
| 1141 |
-
<div class="over-layer hide"></div>
|
| 1142 |
-
<div id="contactview" class="contact-box paner hide">
|
| 1143 |
-
<div class="pan-capt">
|
| 1144 |
-
<div class="pan-title">Thông tin Toà soạn</div>
|
| 1145 |
-
<i class="pan-close" data-action="hide"></i>
|
| 1146 |
-
</div>
|
| 1147 |
-
<div class="pan-cont">
|
| 1148 |
-
<div class="xcap lead tx-prime tx-cen">Tạp chí Điện tử Bóng Đá</div>
|
| 1149 |
-
<div class="row">
|
| 1150 |
-
<div class="col w3 tx-base tx-rgt">Tổng biên tập:</div>
|
| 1151 |
-
<div class="col w9"><b>Vũ Khắc Sơn</b></div>
|
| 1152 |
-
<div class="clx"></div>
|
| 1153 |
-
<div class="col w3 tx-base tx-rgt">Phó Tổng biên tập:</div>
|
| 1154 |
-
<div class="col w9">
|
| 1155 |
-
<b>Nguyễn Hà Thanh</b>
|
| 1156 |
-
</div>
|
| 1157 |
-
</div>
|
| 1158 |
-
<div class="splt"></div>
|
| 1159 |
-
<div class="row">
|
| 1160 |
-
<div class="col w3 tx-base tx-rgt">Địa chỉ:</div>
|
| 1161 |
-
<div class="col w9"><b>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</b></div>
|
| 1162 |
-
<div class="col w3 tx-base tx-rgt">Văn phòng giao dịch:</div>
|
| 1163 |
-
<div class="col w9"><b>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</b></div>
|
| 1164 |
-
<div class="col w3 tx-base tx-rgt">Tel:</div>
|
| 1165 |
-
<div class="col w9"><b>(84.24) 3554 1188 - (84.24) 3554 1199</b></div>
|
| 1166 |
-
<div class="col w3 tx-base tx-rgt">Fax:</div>
|
| 1167 |
-
<div class="col w9"><b>(84.24) 3553 9898</b></div>
|
| 1168 |
-
<div class="col w3 tx-base tx-rgt">Email:</div>
|
| 1169 |
-
<div class="col w9">
|
| 1170 |
-
<a href="mailto:toasoan@bongdaplus.vn"><b>toasoan@bongdaplus.vn</b></a>
|
| 1171 |
-
<a href="mailto:vanphong@bongdaplus.vn"><b>vanphong@bongdaplus.vn</b></a>
|
| 1172 |
-
</div>
|
| 1173 |
-
</div>
|
| 1174 |
-
</div>
|
| 1175 |
-
</div>
|
| 1176 |
-
|
| 1177 |
-
<div id="advertview" class="contact-box paner hide">
|
| 1178 |
-
<div class="pan-capt">
|
| 1179 |
-
<div class="pan-title">Thông tin Liên hệ</div>
|
| 1180 |
-
<i class="pan-close" data-action="hide"></i>
|
| 1181 |
-
</div>
|
| 1182 |
-
<div class="pan-cont">
|
| 1183 |
-
<div class="xcap lead tx-prime tx-cen">Tạp chí Điện tử Bóng Đá</div>
|
| 1184 |
-
<div class="row">
|
| 1185 |
-
<div class="col w4 tx-base tx-rgt">Hotline:</div>
|
| 1186 |
-
<div class="col w8"><b>0903 203 412</b></div>
|
| 1187 |
-
<div class="col w4 tx-base tx-rgt">Email:</div>
|
| 1188 |
-
<div class="col w8"><a href="mailto:tien.nguyen@giaminhmedia.vn"><b>tien.nguyen@giaminhmedia.vn</b></a></div>
|
| 1189 |
-
</div>
|
| 1190 |
-
<p class="tx-base">Địa chỉ liên hệ:</p>
|
| 1191 |
-
<b>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</b>
|
| 1192 |
-
<p class="tx-base">Văn phòng giao dịch:</p>
|
| 1193 |
-
<b>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</b>
|
| 1194 |
-
</div>
|
| 1195 |
-
</div>
|
| 1196 |
-
|
| 1197 |
-
|
| 1198 |
-
<div id="loginview" class="login-box paner hide">
|
| 1199 |
-
<div class="pan-capt">
|
| 1200 |
-
<div class="pan-title">Đăng nhập</div>
|
| 1201 |
-
<i class="pan-close" data-action="hide"></i>
|
| 1202 |
-
</div>
|
| 1203 |
-
<div class="pan-cont">
|
| 1204 |
-
<form class="soc-auth" action="#">
|
| 1205 |
-
<button><i class="ico gg-color"></i>Google</button>
|
| 1206 |
-
<button><i class="ico fb-color"></i>Facebook</button>
|
| 1207 |
-
</form>
|
| 1208 |
-
<div class="clx"></div>
|
| 1209 |
-
<div class="splt"><span>hoặc</span></div>
|
| 1210 |
-
<form class="loginer" action="//member.bongdaplus.vn/Identity/Account/Login?ReturnUrl=/" method="post">
|
| 1211 |
-
<p>Email:</p>
|
| 1212 |
-
<label class="txt-fld" for="email">
|
| 1213 |
-
<input id="email" name="Input.Email" type="email" />
|
| 1214 |
-
<span>Nhập địa chỉ email của bạn</span>
|
| 1215 |
-
</label>
|
| 1216 |
-
<label class="err-fld hide" for="email">Địa chỉ email không được để trống</label>
|
| 1217 |
-
<p>Mật khẩu:</p>
|
| 1218 |
-
<label class="txt-fld" for="passw">
|
| 1219 |
-
<input id="passw" name="Input.Password" type="password" data-required="Mật khẩu không được để trống" />
|
| 1220 |
-
<span>Nhập mật khẩu của bạn</span>
|
| 1221 |
-
</label>
|
| 1222 |
-
<label class="err-fld hide" for="passw">Mật khẩu không được để trống</label>
|
| 1223 |
-
<p class="ft-sm tx-rgt"><a class="fg-psw" href="#" data-action="hide" data-showpaner="#forgetview">Quên mật khẩu?</a></p>
|
| 1224 |
-
<label class="rdo-fld" for="persist">
|
| 1225 |
-
<input id="persist" name="Input.RememberMe" type="checkbox" />
|
| 1226 |
-
Ghi nhớ thông tin đăng nhập
|
| 1227 |
-
</label>
|
| 1228 |
-
<br>
|
| 1229 |
-
<div class="fld-box">
|
| 1230 |
-
<button class="btn major" type="submit">Đăng nhập</button>
|
| 1231 |
-
</div>
|
| 1232 |
-
<p class="ft-sm tx-cen">
|
| 1233 |
-
Bạn chưa có tài khoản?
|
| 1234 |
-
<a class="act-now" href="#" data-action="hide" data-showpaner="#registview">Đăng ký ngay</a>
|
| 1235 |
-
</p>
|
| 1236 |
-
</form>
|
| 1237 |
-
</div>
|
| 1238 |
-
</div>
|
| 1239 |
-
</div>
|
| 1240 |
-
|
| 1241 |
-
<script src="/lib/jquery/dist/jquery.min.js"></script>
|
| 1242 |
-
<script src="/js/js_cookie.min.js?v=0H3Nuz3aug3afVbUlsu12Puxva3CP4EhJtPExqs54Vg"></script>
|
| 1243 |
-
<script src="/js/bongdaplus.js?v=gIIX1pYQ8DB66N40YPyoHGaBgBFoHkPMMCARX4L6NdA"></script>
|
| 1244 |
-
<script src="/js/premium.js?v=mbXB8QoXMl1zoEFSeE6z-mwQTb0ot7C8zj_F8JqsvNc"></script>
|
| 1245 |
-
|
| 1246 |
-
<script>var _ad_info_code = 'mda/video';</script>
|
| 1247 |
-
<script src="/js/advloader.js?v=dvvR0ygTjyYzNcQmQ_dI5bwYeX2Ww0nBjBBmCt9DWjQ"></script>
|
| 1248 |
-
|
| 1249 |
-
</body>
|
| 1250 |
-
</html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
bdp_page.html
DELETED
|
@@ -1,1250 +0,0 @@
|
|
| 1 |
-
<!DOCTYPE html>
|
| 2 |
-
<html lang="en">
|
| 3 |
-
<head>
|
| 4 |
-
<title>Hull City vs MU: Quỷ Đỏ vào hang bắt hổ - Bongdaplus.vn</title>
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
<meta name="MobileOptimized" content="100" />
|
| 8 |
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 9 |
-
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
| 10 |
-
<meta http-equiv="content-language" content="vi" />
|
| 11 |
-
<meta http-equiv="refresh" content="300" />
|
| 12 |
-
<meta property="fb:pages" content="1738672379699789" />
|
| 13 |
-
<meta property="dable:item_id" content="2015111202028" />
|
| 14 |
-
<meta property="og:image" itemprop="thumbnailUrl" content="https://bongdaplus.vn/img/logo-daily.png" />
|
| 15 |
-
<link href="/favicon.ico" rel="shortcut icon" type="image/x-icon" />
|
| 16 |
-
<link href="/favicon.svg" rel="icon" sizes="any" type="image/svg+xml" />
|
| 17 |
-
<link href="/apple-touch-icon.png" rel="apple-touch-icon" />
|
| 18 |
-
<!--iPhone icons-->
|
| 19 |
-
<link href="/logo-57.png" rel="apple-touch-icon-precomposed" sizes="57x57" />
|
| 20 |
-
<link href="/logo-114.png" rel="apple-touch-icon-precomposed" sizes="114x114" />
|
| 21 |
-
<!--iPad icons-->
|
| 22 |
-
<link href="/logo-72.png" rel="apple-touch-icon-precomposed" sizes="72x72" />
|
| 23 |
-
<link href="/logo-114.png" rel="apple-touch-icon-precomposed" sizes="114x114" />
|
| 24 |
-
<!--Android icon-->
|
| 25 |
-
<link href="/logo-114.png" rel="apple-touch-icon-precomposed" sizes="1x1" />
|
| 26 |
-
<!--Symbian icon-->
|
| 27 |
-
<link href="/logo-57.png" rel="nokia-touch-icon" sizes="57x57" />
|
| 28 |
-
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 29 |
-
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 30 |
-
<link href="https://fonts.googleapis.com/css2?family=Mulish:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,200;1,300;1,400;1,500;1,600;1,700;1,800&display=swap" rel="stylesheet">
|
| 31 |
-
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap" rel="stylesheet">
|
| 32 |
-
<link href="/css/premium.css?id=0000&v=ivL0rSTnUIBx45p3hw7UIAzbXjyQ9rgPVSyjqPglN_o" rel="stylesheet">
|
| 33 |
-
|
| 34 |
-
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-20374657-1"></script>
|
| 35 |
-
<script>
|
| 36 |
-
window.dataLayer = window.dataLayer || [];
|
| 37 |
-
function gtag() { dataLayer.push(arguments); }
|
| 38 |
-
gtag('js', new Date());
|
| 39 |
-
|
| 40 |
-
gtag('config', 'UA-20374657-1');
|
| 41 |
-
</script>
|
| 42 |
-
<!-- Google tag (gtag.js) -->
|
| 43 |
-
<script async src="https://www.googletagmanager.com/gtag/js?id=G-Y1DBNQWMVW"></script>
|
| 44 |
-
<script>
|
| 45 |
-
window.dataLayer = window.dataLayer || [];
|
| 46 |
-
function gtag() { dataLayer.push(arguments); }
|
| 47 |
-
gtag('js', new Date());
|
| 48 |
-
|
| 49 |
-
gtag('config', 'G-Y1DBNQWMVW');
|
| 50 |
-
</script>
|
| 51 |
-
|
| 52 |
-
<script async src="https://static.amcdn.vn/tka/cdn.js" type="text/javascript"></script>
|
| 53 |
-
<script>var arfAsync = arfAsync || [];</script>
|
| 54 |
-
<script id="arf-core-js" onerror="window.arferrorload=true;" src="//media1.admicro.vn/cms/Arf.min.js" async></script>
|
| 55 |
-
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-3619133031508264" crossorigin="anonymous"></script>
|
| 56 |
-
</head>
|
| 57 |
-
<body>
|
| 58 |
-
<script type="application/ld+json">
|
| 59 |
-
{
|
| 60 |
-
"@context": "https://schema.org",
|
| 61 |
-
"@type": "Organization",
|
| 62 |
-
"name": "TẠP CHÍ ĐIỆN TỬ BÓNG ĐÁ - BONGDAPLUS",
|
| 63 |
-
"url": "https://bongdaplus.vn",
|
| 64 |
-
"logo": "https://bongdaplus.vn/img/logo.png",
|
| 65 |
-
"contactPoint": {
|
| 66 |
-
"@type": "ContactPoint",
|
| 67 |
-
"telephone": "(84.24)35541188",
|
| 68 |
-
"contactType": "customer service",
|
| 69 |
-
"areaServed": "VN",
|
| 70 |
-
"availableLanguage": "Vietnamese"
|
| 71 |
-
},
|
| 72 |
-
"sameAs": [
|
| 73 |
-
"https://www.facebook.com/baobongda",
|
| 74 |
-
"https://www.youtube.com/channel/UCvDYMBE6GipX2VGacqOU2_Q",
|
| 75 |
-
"https://bongdaplus.vn",
|
| 76 |
-
"https://www.pinterest.com/bongdaplus",
|
| 77 |
-
"https://soundcloud.com/baobongda"
|
| 78 |
-
]
|
| 79 |
-
}
|
| 80 |
-
</script>
|
| 81 |
-
<header>
|
| 82 |
-
<center><div id="ADMTOP" class="banner admob" banner-adpos="top"></div></center>
|
| 83 |
-
|
| 84 |
-
<div class="cont-wrap">
|
| 85 |
-
<div class="mmnu-nav">
|
| 86 |
-
<button class="ico mnu-sch mmnu-trigger hide-w"></button>
|
| 87 |
-
<button class="ico menubar mmnu-trigger hide-m"></button>
|
| 88 |
-
<button class="ico close mmnu-closer hide"></button>
|
| 89 |
-
</div>
|
| 90 |
-
<a class="logo" href="/"></a>
|
| 91 |
-
<button class="ico bell hide-w hide"></button>
|
| 92 |
-
|
| 93 |
-
<div class="authen-nav dropper">
|
| 94 |
-
<a class="member">
|
| 95 |
-
<span class="avatar">
|
| 96 |
-
</span>
|
| 97 |
-
</a>
|
| 98 |
-
<div class="drop-down rgt">
|
| 99 |
-
<ul class="lst">
|
| 100 |
-
|
| 101 |
-
<li><a href="#" data-showpaner="#loginview">Đăng nhập</a></li>
|
| 102 |
-
<li><a href="https://member.bongdaplus.vn/Identity/Account/Register?returnUrl=%2F" target="_blank">Tạo tài khoản</a></li>
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
</ul>
|
| 106 |
-
</div>
|
| 107 |
-
</div>
|
| 108 |
-
</div>
|
| 109 |
-
</header>
|
| 110 |
-
<input type="hidden" id="catrefid" value="video" />
|
| 111 |
-
<input type="hidden" id="objectid" value="98267" />
|
| 112 |
-
<input type="hidden" id="objecttype" value="1" />
|
| 113 |
-
<input type="hidden" id="parentid" value="0" />
|
| 114 |
-
<input type="hidden" id="commentnum" value="0" />
|
| 115 |
-
<img src="/imgthumbnail/98267/1/1" alt="" height="0" width="0" />
|
| 116 |
-
|
| 117 |
-
<main>
|
| 118 |
-
<div class="nav-menu">
|
| 119 |
-
<div class="menu-bar">
|
| 120 |
-
<div class="cont-wrap">
|
| 121 |
-
<div class="menu-side">
|
| 122 |
-
<ul class="bar-w">
|
| 123 |
-
<li><a class="ico home" href="/"></a></li>
|
| 124 |
-
<li><a title="AFF Cup" href="/aff-cup">AFF Cup</a></li>
|
| 125 |
-
<li class="popover">
|
| 126 |
-
<a title="Việt Nam" href="/bong-da-viet-nam">Việt Nam</a>
|
| 127 |
-
<div class="drop-down">
|
| 128 |
-
<ul class="lst">
|
| 129 |
-
<li><a title="V-League" href="/v-league">V-League</a></li>
|
| 130 |
-
<li><a title="Hạng Nhất" href="/hang-nhat-quoc-gia">Hạng Nhất</a></li>
|
| 131 |
-
<li><a title="Cúp Quốc gia" href="/cup-quoc-gia">Cúp Quốc gia</a></li>
|
| 132 |
-
<li><a title="ĐTQG" href="/doi-tuyen-quoc-gia-viet-nam">ĐTQG</a></li>
|
| 133 |
-
<li><a title="Bóng đá Nữ" href="/bong-da-nu-viet-nam">Bóng đá Nữ</a></li>
|
| 134 |
-
<li><a title="U17 Quốc gia" href="/u17-viet-nam">U17 Quốc gia</a></li>
|
| 135 |
-
<li><a title="U19 Quốc Gia" href="/u19-viet-nam">U19 Quốc Gia</a></li>
|
| 136 |
-
<li><a title="U21 Quốc Gia" href="/u21-viet-nam">U21 Quốc Gia</a></li>
|
| 137 |
-
<li><a title="Các đội tuyển trẻ" href="/cac-doi-tuyen-tre-viet-nam">Các đội tuyển trẻ</a></li>
|
| 138 |
-
<li><a title="Futsal" href="/futsal">Futsal</a></li>
|
| 139 |
-
<li><a title="Phong trào" href="/bong-da-phong-trao">Phong trào</a></li>
|
| 140 |
-
<li><a title="Đơn vị Kết nghĩa" href="/ket-nghia-anh-em">Đơn vị Kết nghĩa</a></li>
|
| 141 |
-
</ul>
|
| 142 |
-
</div>
|
| 143 |
-
</li>
|
| 144 |
-
<li class="popover">
|
| 145 |
-
<a title="Chuyển nhượng" href="/tin-chuyen-nhuong">Chuyển nhượng</a>
|
| 146 |
-
<div class="drop-down">
|
| 147 |
-
<ul class="lst">
|
| 148 |
-
<li><a title="Tin chuyển nhượng Ngoại Hạng Anh" href="/tin-chuyen-nhuong-ngoai-hang-anh">Tin chuyển nhượng Ngoại Hạng Anh</a></li>
|
| 149 |
-
<li><a title="Tin chuyển nhượng V-league" href="/tin-chuyen-nhuong-v-league">Tin chuyển nhượng V-league</a></li>
|
| 150 |
-
</ul>
|
| 151 |
-
</div>
|
| 152 |
-
</li>
|
| 153 |
-
<li><a title="Champions League" href="/champions-league-cup-c1">Champions League</a></li>
|
| 154 |
-
<li class="popover">
|
| 155 |
-
<a title="Tây Ban Nha" href="/bong-da-tay-ban-nha">Tây Ban Nha</a>
|
| 156 |
-
<div class="drop-down">
|
| 157 |
-
<ul class="lst">
|
| 158 |
-
<li><a title="VĐQG Tây Ban Nha" href="/la-liga">VĐQG Tây Ban Nha</a></li>
|
| 159 |
-
<li><a title="Cúp Nhà Vua" href="/cup-nha-vua-tay-ban-nha">Cúp Nhà Vua</a></li>
|
| 160 |
-
<li><a title="Đội tuyển TBN" href="/doi-tuyen-tay-ban-nha">Đội tuyển TBN</a></li>
|
| 161 |
-
</ul>
|
| 162 |
-
</div>
|
| 163 |
-
</li>
|
| 164 |
-
<li class="popover">
|
| 165 |
-
<a title="Pháp" href="/bong-da-phap">Pháp</a>
|
| 166 |
-
<div class="drop-down">
|
| 167 |
-
<ul class="lst">
|
| 168 |
-
<li><a title="VĐQG Pháp" href="/ligue-1">VĐQG Pháp</a></li>
|
| 169 |
-
<li><a title="Cúp Quốc gia Pháp" href="/cup-quoc-gia-phap">Cúp Quốc gia Pháp</a></li>
|
| 170 |
-
<li><a title="Đội tuyển Pháp" href="/doi-tuyen-phap">Đội tuyển Pháp</a></li>
|
| 171 |
-
</ul>
|
| 172 |
-
</div>
|
| 173 |
-
</li>
|
| 174 |
-
<li class="popover">
|
| 175 |
-
<a title="Italia" href="/bong-da-y">Italia</a>
|
| 176 |
-
<div class="drop-down">
|
| 177 |
-
<ul class="lst">
|
| 178 |
-
<li><a title="VĐQG Italia" href="/serie-a">VĐQG Italia</a></li>
|
| 179 |
-
<li><a title="Coppa Italia" href="/coppa-italia">Coppa Italia</a></li>
|
| 180 |
-
<li><a title="Đội tuyển Italia" href="/doi-tuyen-y">Đội tuyển Italia</a></li>
|
| 181 |
-
</ul>
|
| 182 |
-
</div>
|
| 183 |
-
</li>
|
| 184 |
-
<li class="popover">
|
| 185 |
-
<a title="Thể thao" href="/the-thao">Thể thao</a>
|
| 186 |
-
<div class="drop-down">
|
| 187 |
-
<ul class="lst">
|
| 188 |
-
<li><a title="eSports" href="/esports">eSports</a></li>
|
| 189 |
-
<li><a title="Tennis" href="/tennis">Tennis</a></li>
|
| 190 |
-
<li><a title="Pickleball" href="/pickleball">Pickleball</a></li>
|
| 191 |
-
<li><a title="MMA" href="/mma">MMA</a></li>
|
| 192 |
-
<li><a title="Đua xe F1" href="/dua-xe-f1">Đua xe F1</a></li>
|
| 193 |
-
<li><a title="Bóng rổ" href="/bong-ro">Bóng rổ</a></li>
|
| 194 |
-
<li><a title="Olympic 2028" href="/olympic">Olympic 2028</a></li>
|
| 195 |
-
</ul>
|
| 196 |
-
</div>
|
| 197 |
-
</li>
|
| 198 |
-
<li><a title="Nhận định bóng đá" href="/nhan-dinh-bong-da-tags">Nhận định</a></li>
|
| 199 |
-
<li><a title="Multimedia" href="/video">Multimedia</a></li>
|
| 200 |
-
<li class="hide"><a title="Bongdaplus Premium" href="/premium">Premium <i class="ico crown"></i></a></li>
|
| 201 |
-
</ul>
|
| 202 |
-
</div>
|
| 203 |
-
<div class="menu-nav">
|
| 204 |
-
<a class="ico home hide-w" href="/"></a>
|
| 205 |
-
<ul class="nav">
|
| 206 |
-
<li class="hide-m"><i class="sepr"></i></li>
|
| 207 |
-
<li><a href="/tin-moi">Mới nhất</a></li>
|
| 208 |
-
<li class="hide-w"><a title="Bongdaplus Premium" href="/premium">Premium <i class="ico crown"></i></a></li>
|
| 209 |
-
<li><a title="Anh" href="/bong-da-anh">Bóng đá Anh</a></li>
|
| 210 |
-
<li class="hide-w"><a title="AFF Cup" href="/aff-cup">AFF Cup</a></li>
|
| 211 |
-
<li class="hide-w"><a title="Việt Nam" href="/bong-da-viet-nam">Việt Nam</a></li>
|
| 212 |
-
<li class="hide-w"><a title="Chuyển nhượng" href="/tin-chuyen-nhuong">Chuyển nhượng</a></li>
|
| 213 |
-
<li class="hide-w"><a title="Champions League" href="/champions-league-cup-c1">Champions League</a></li>
|
| 214 |
-
<li class="hide-w"><a title="Tây Ban Nha" href="/bong-da-tay-ban-nha">Tây Ban Nha</a></li>
|
| 215 |
-
<li class="hide-w"><a title="Pháp" href="/bong-da-phap">Pháp</a></li>
|
| 216 |
-
<li class="hide-w"><a title="Italia" href="/bong-da-y">Italia</a></li>
|
| 217 |
-
<li class="hide-w"><a title="Thể thao" href="/the-thao">Thể thao</a></li>
|
| 218 |
-
<li class="hide-w"><a title="Nhận định" href="/nhan-dinh-bong-da-tags">Nhận định</a></li>
|
| 219 |
-
<li class="hide-w"><a title="Thế giới" href="/the-gioi">Thế giới</a></li>
|
| 220 |
-
<li class="hide-w"><a title="Multimedia" href="/video">Multimedia</a></li>
|
| 221 |
-
<li class="hide-w"><a title="Bóng đá Cuộc sống" href="/bong-da-cuoc-song">Bóng đá Cuộc sống</a></li>
|
| 222 |
-
</ul>
|
| 223 |
-
<div class="srch-bar hide-m">
|
| 224 |
-
<button class="ico close"></button>
|
| 225 |
-
<form class="srch-form" action="/tim-kiem">
|
| 226 |
-
<input class="pattern toggler" name="q" type="text" placeholder="Nhập nội dung cần tìm" />
|
| 227 |
-
</form>
|
| 228 |
-
</div>
|
| 229 |
-
</div>
|
| 230 |
-
</div>
|
| 231 |
-
</div>
|
| 232 |
-
<div class="clr"></div>
|
| 233 |
-
</div>
|
| 234 |
-
<div class="mob-menu">
|
| 235 |
-
<div class="mmnu-cap">
|
| 236 |
-
<button class="ico close" data-action="hide"></button>
|
| 237 |
-
<a class="logo" href="/"></a>
|
| 238 |
-
</div>
|
| 239 |
-
<form class="mmnu-sch " action="/tim-kiem">
|
| 240 |
-
<input type="text" name="q">
|
| 241 |
-
<button class="ico search" type="submit"></button>
|
| 242 |
-
</form>
|
| 243 |
-
<div class="mmnu-lst">
|
| 244 |
-
<div class="menu-row">
|
| 245 |
-
<ul class="bar">
|
| 246 |
-
<li class="exp">
|
| 247 |
-
<a class="major" title="Tin mới nhất" href="/tin-moi"><span class="ico blinkr"></span> Mới nhất</a>
|
| 248 |
-
</li>
|
| 249 |
-
<li>
|
| 250 |
-
<ul class="lst">
|
| 251 |
-
<li><a class="fst" title="Nh���n định Bóng đá" href="/nhan-dinh-bong-da-tags">Nhận định</a></li>
|
| 252 |
-
</ul>
|
| 253 |
-
</li>
|
| 254 |
-
<li>
|
| 255 |
-
<ul class="lst">
|
| 256 |
-
<li><a class="fst" title="Lịch thi đấu bóng đá" href="/lich-thi-dau-bong-da">Lịch thi đấu</a></li>
|
| 257 |
-
</ul>
|
| 258 |
-
</li>
|
| 259 |
-
<li class="exp"></li>
|
| 260 |
-
<li class="exp"><a class="major" title="Bóng đá Plus Premium" href="/premium">Bóng đá Premium <i class="ico crown"></i></a></li>
|
| 261 |
-
<li>
|
| 262 |
-
<ul class="lst">
|
| 263 |
-
<li><a class="fst" title="Bóng đá Việt Nam" href="/bong-da-viet-nam">Bóng đá Việt Nam</a></li>
|
| 264 |
-
<li><a title="Đội tuyển Việt Nam" href="/doi-tuyen-quoc-gia-viet-nam">Đội tuyển Việt Nam</a></li>
|
| 265 |
-
<li><a title="V.League" href="/v-league">V.League</a></li>
|
| 266 |
-
<li><a title="Hạng Nhất" href="/hang-nhat-viet-nam">Hạng Nhất</a></li>
|
| 267 |
-
<li><a title="Cúp Quốc gia" href="/cup-quoc-gia-viet-nam">Cúp Quốc gia</a></li>
|
| 268 |
-
<li><a title="Bóng đá nữ" href="/bong-da-nu-viet-nam">Bóng đá nữ</a></li>
|
| 269 |
-
<li><a title="Futsal" href="/futsal">Futsal</a></li>
|
| 270 |
-
<li><a title="Phong trào" href="/bong-da-phong-trao">Bóng đá Phong trào</a></li>
|
| 271 |
-
<li><a title="Chuyển nhượng V.League" href="/tin-chuyen-nhuong-v-league">Chuyển nhượng</a></li>
|
| 272 |
-
</ul>
|
| 273 |
-
</li>
|
| 274 |
-
<li>
|
| 275 |
-
<ul class="lst">
|
| 276 |
-
<li><a class="fst" title="Bóng đá Anh" href="/bong-da-anh">Bóng đá Anh</a></li>
|
| 277 |
-
<li><a title="Đội tuyển Anh" href="/doi-tuyen-anh">Đội tuyển Anh</a></li>
|
| 278 |
-
<li><a title="Ngoại hạng Anh" href="/ngoai-hang-anh">Ngoại hạng Anh</a></li>
|
| 279 |
-
<li><a title="FA Cup" href="/fa-cup">FA Cup</a></li>
|
| 280 |
-
<li><a title="League Cup" href="/cup-lien-doan-anh">League Cup</a></li>
|
| 281 |
-
</ul>
|
| 282 |
-
<div class="clx"></div>
|
| 283 |
-
<ul class="lst">
|
| 284 |
-
<li><a class="fst" title="Tây Ban nha" href="/bong-da-tay-ban-nha">Tây Ban nha</a></li>
|
| 285 |
-
<li><a title="Đội tuyển TBN" href="/doi-tuyen-tay-ban-nha">Đội tuyển TBN</a></li>
|
| 286 |
-
<li><a title="La Liga" href="/la-liga">La Liga</a></li>
|
| 287 |
-
<li><a title="Cúp Nhà Vua" href="/cup-nha-vua-tay-ban-nha">Cúp Nhà Vua</a></li>
|
| 288 |
-
</ul>
|
| 289 |
-
</li>
|
| 290 |
-
<li class="exp"></li>
|
| 291 |
-
<li>
|
| 292 |
-
<ul class="lst">
|
| 293 |
-
<li><a class="fst" title="Bóng đá Pháp" href="/bong-da-phap">Bóng đá Pháp</a></li>
|
| 294 |
-
<li><a title="Đội tuyển Pháp" href="/doi-tuyen-phap">Đội tuyển Pháp</a></li>
|
| 295 |
-
<li><a title="Ligue 1" href="/ligue-1">Ligue 1</a></li>
|
| 296 |
-
<li><a title="Cúp QG Pháp" href="/cup-quoc-gia-phap">Cúp QG Pháp</a></li>
|
| 297 |
-
<li><a title="Cúp Liên đoàn Pháp" href="/cup-lien-doan-phap-tags">Cúp Liên đoàn Pháp</a></li>
|
| 298 |
-
</ul>
|
| 299 |
-
</li>
|
| 300 |
-
<li>
|
| 301 |
-
<ul class="lst">
|
| 302 |
-
<li><a class="fst" href="#">Bóng đá Khu vực</a></li>
|
| 303 |
-
<li><a title="Asian Cup" href="/asian-cup">Asian Cup</a></li>
|
| 304 |
-
<li><a title="Asian Games" href="/asian-games">Asian Games</a></li>
|
| 305 |
-
<li><a title="AFF Cup" href="/aff-cup">AFF Cup</a></li>
|
| 306 |
-
<li><a title="SEA Games" href="/sea-games">SEA Games</a></li>
|
| 307 |
-
</ul>
|
| 308 |
-
</li>
|
| 309 |
-
<li class="exp"></li>
|
| 310 |
-
<li>
|
| 311 |
-
<ul class="lst">
|
| 312 |
-
<li><a class="fst" title="Bóng đá Italia" href="/bong-da-y">Bóng đá Italia</a></li>
|
| 313 |
-
<li><a title="Đội tuyển Italia" href="/doi-tuyen-y">Đội tuyển Italia</a></li>
|
| 314 |
-
<li><a title="Serie A" href="/serie-a">Serie A</a></li>
|
| 315 |
-
<li><a title="Coppa Italia" href="/coppa-italia">Coppa Italia</a></li>
|
| 316 |
-
</ul>
|
| 317 |
-
</li>
|
| 318 |
-
<li>
|
| 319 |
-
<ul class="lst">
|
| 320 |
-
<li><a class="fst" title="Bóng đá Đức" href="/bong-da-duc">Bóng đá Đức</a></li>
|
| 321 |
-
<li><a title="Đội tuyển Đức" href="/doi-tuyen-duc">Đội tuyển Đức</a></li>
|
| 322 |
-
<li><a title="Bundesliga" href="/bundesliga">Bundesliga</a></li>
|
| 323 |
-
<li><a title="Cúp QG Đức" href="/cup-quoc-gia-duc">Cúp QG Đức</a></li>
|
| 324 |
-
</ul>
|
| 325 |
-
</li>
|
| 326 |
-
<li class="exp"></li>
|
| 327 |
-
<li>
|
| 328 |
-
<ul class="lst">
|
| 329 |
-
<li><a class="fst" href="#">Nội dung Đặc biệt</a></li>
|
| 330 |
-
<li><a title="Big Story" href="/emagazine">Big Story</a></li>
|
| 331 |
-
<li><a title="X-File" href="/x-file">X-File</a></li>
|
| 332 |
-
<li><a title="Infographic" href="/infographic">Infographic</a></li>
|
| 333 |
-
<li><a title="Interactive" href="/interactive">Interactive</a></li>
|
| 334 |
-
</ul>
|
| 335 |
-
</li>
|
| 336 |
-
<li>
|
| 337 |
-
<ul class="lst">
|
| 338 |
-
<li><a class="fst" href="#">Giải đấu CLB</a></li>
|
| 339 |
-
<li><a title="Champions Leage" href="/champions-league-cup-c1">Champions League</a></li>
|
| 340 |
-
<li><a title="Europa League" href="/europa-league">Europa League</a></li>
|
| 341 |
-
<li><a title="Club World Cup" href="/fifa-club-world-cup-tags">Club World Cup</a></li>
|
| 342 |
-
<li><a title="Europa Conference League" href="/europa-conference-league">Europa Conf League</a></li>
|
| 343 |
-
</ul>
|
| 344 |
-
</li>
|
| 345 |
-
<li class="exp"></li>
|
| 346 |
-
<li>
|
| 347 |
-
<ul class="lst">
|
| 348 |
-
<li><a class="fst" href="#">Chuyên đề</a></li>
|
| 349 |
-
<li><a title="Tin chuyển nhượng" href="/tin-chuyen-nhuong">Chuyển nhượng</a></li>
|
| 350 |
-
<li><a title="Hậu trường bóng đá" href="/hau-truong-bong-da">Hậu trường</a></li>
|
| 351 |
-
<li><a title="Điểm tin" href="/diem-tin">Điểm tin</a></li>
|
| 352 |
-
<li><a title="Showbiz Ngôi sao" href="/ngoi-sao-showbiz">Showbiz Ngôi sao</a></li>
|
| 353 |
-
<li><a title="Góc check var" href="/goc-check-var">Góc check var</a></li>
|
| 354 |
-
<li><a title="Công nghệ" href="/cong-nghe">Công nghệ</a></li>
|
| 355 |
-
<li><a title="Thông tin Thị trường" href="/thi-truong">Thị trường</a></li>
|
| 356 |
-
<li><a title="Bóng đá & Cuộc sống" href="/bong-da-cuoc-song">Bóng đá & Cuộc sống</a></li>
|
| 357 |
-
</ul>
|
| 358 |
-
</li>
|
| 359 |
-
<li>
|
| 360 |
-
<ul class="lst">
|
| 361 |
-
<li><a class="fst" href="#">Đội tuyển</a></li>
|
| 362 |
-
<li><a title="World Cup nữ 2028" href="/world-cup-nu-2023-tags">World Cup nữ 2028</a></li>
|
| 363 |
-
<li><a title="World Cup" href="/world-cup">World Cup 2026</a></li>
|
| 364 |
-
<li><a title="European Championship 2028" href="/euro-cup-chau-au">EURO 2028</a></li>
|
| 365 |
-
<li><a title="Nations League" href="/nations-league">Nations League</a></li>
|
| 366 |
-
<li><a title="Copa America" href="/copa-america">Copa America</a></li>
|
| 367 |
-
<li><a title="CAN" href="/can">CAN</a></li>
|
| 368 |
-
<li><a title="Olympic 2028" href="/olympic">Olympic 2028</a></li>
|
| 369 |
-
</ul>
|
| 370 |
-
</li>
|
| 371 |
-
<li class="exp"></li>
|
| 372 |
-
<li>
|
| 373 |
-
<ul class="lst">
|
| 374 |
-
<li><a class="fst" title="Multimedia" href="/video">Multimedia</a></li>
|
| 375 |
-
</ul>
|
| 376 |
-
</li>
|
| 377 |
-
<li>
|
| 378 |
-
<ul class="lst">
|
| 379 |
-
<li> </li>
|
| 380 |
-
</ul>
|
| 381 |
-
</li>
|
| 382 |
-
<li class="exp"></li>
|
| 383 |
-
<li>
|
| 384 |
-
<ul class="lst">
|
| 385 |
-
<li><a class="fst" href="/lich-thi-dau-bong-da">Lịch thi đấu</a></li>
|
| 386 |
-
<li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-viet-nam" title="Lịch thi đấu V.League">V.League</a></li>
|
| 387 |
-
<li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-anh" title="Lịch thi đấu Ngoại hạng Anh">Ngoại hạng Anh</a></li>
|
| 388 |
-
<li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-phap" title="Lịch thi đấu Ligue 1 Pháp">Ligue 1</a></li>
|
| 389 |
-
<li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-tay-ban-nha" title="Lịch thi đấu La Liga Tây Ban Nha">La Liga</a></li>
|
| 390 |
-
<li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-duc" title="Lịch thi đấu Bundesliga Đức">Bundesliga</a></li>
|
| 391 |
-
<li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-y" title="Lịch thi đấu Serie A Italia">Serie A</a></li>
|
| 392 |
-
<li class="hide-m"><a href="/lich-thi-dau-bong-da?champions-league-cup-c1" title="Lịch thi đấu Champions League">Champions League</a></li>
|
| 393 |
-
<li class="hide-m"><a href="/lich-thi-dau-bong-da?europa-league" title="Lịch thi đấu Europa League">Europa League</a></li>
|
| 394 |
-
</ul>
|
| 395 |
-
</li>
|
| 396 |
-
<li>
|
| 397 |
-
<ul class="lst">
|
| 398 |
-
<li><a class="fst" href="/bang-xep-hang-bong-da">Bảng xếp hạng</a></li>
|
| 399 |
-
<li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-viet-nam" title="Bảng xếp hạng V.League">V.League</a></li>
|
| 400 |
-
<li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-anh" title="Bảng xếp hạng Ngoại hạng Anh">Ngoại hạng Anh</a></li>
|
| 401 |
-
<li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-phap" title="Bảng xếp hạng Ligue 1 Pháp">Ligue 1</a></li>
|
| 402 |
-
<li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-tay-ban-nha" title="Bảng xếp hạng La Liga Tây Ban Nha">La Liga</a></li>
|
| 403 |
-
<li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-duc" title="Bảng xếp hạng Bundesliga Đức">Bundesliga</a></li>
|
| 404 |
-
<li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-y" title="Bảng xếp hạng Serie A Italia">Serie A</a></li>
|
| 405 |
-
<li class="hide-m"><a href="/bang-xep-hang-bong-da?champions-league-cup-c1" title="Bảng xếp hạng Champions League">Champions League</a></li>
|
| 406 |
-
<li class="hide-m"><a href="/bang-xep-hang-bong-da?europa-league" title="Bảng xếp hạng Europa League">Europa League</a></li>
|
| 407 |
-
</ul>
|
| 408 |
-
</li>
|
| 409 |
-
<li class="exp"></li>
|
| 410 |
-
<li><a class="caption" href="https://phui.bongdaplus.vn" target="_blank">Bóng đá phủi</a></li>
|
| 411 |
-
<li><a class="caption" href="/tennis">Tennis</a></li>
|
| 412 |
-
<li><a class="caption" href="/esports">Esports</a></li>
|
| 413 |
-
<li><a class="caption" href="/the-thao">Thể thao +</a></li>
|
| 414 |
-
</ul>
|
| 415 |
-
</div>
|
| 416 |
-
</div>
|
| 417 |
-
<div class="mmnu-end tx-cen">
|
| 418 |
-
<p>Theo dõi thông tin</p>
|
| 419 |
-
<ul class="bar social-lnk">
|
| 420 |
-
<li class="hide-m">Theo dõi thông tin</li>
|
| 421 |
-
<li><a class="ico vff-lnk" target="_blank" href="https://vff.org.vn/" style="background-size:28px;opacity:1;" rel="nofollow"></a></li>
|
| 422 |
-
<li><a class="ico fb-lnk" target="_blank" href="https://www.facebook.com/BaoBongDa/" style="background-size:28px;opacity:1;"></a></li>
|
| 423 |
-
<li><a class="ico yt-lnk" target="_blank" href="https://www.youtube.com/channel/UCF0VFBZu-5SRJXMV_FMysEA" style="background-size:28px;opacity:1;"></a></li>
|
| 424 |
-
</ul>
|
| 425 |
-
</div>
|
| 426 |
-
</div>
|
| 427 |
-
<div class="mega-menu">
|
| 428 |
-
<div class="cont-wrap">
|
| 429 |
-
<div class="menu-row">
|
| 430 |
-
<ul class="bar">
|
| 431 |
-
<li><a class="caption" title="Tin mới nhất" href="/tin-moi">Mới nhất</a></li>
|
| 432 |
-
<li>
|
| 433 |
-
<ul class="lst">
|
| 434 |
-
<li><a class="fst" title="Bóng đá Việt Nam" href="/bong-da-viet-nam">Bóng đá Việt Nam</a></li>
|
| 435 |
-
<li><a title="Đội tuyển Việt Nam" href="/doi-tuyen-quoc-gia-viet-nam">Đội tuyển Việt Nam</a></li>
|
| 436 |
-
<li><a title="V.League" href="/v-league">V.League</a></li>
|
| 437 |
-
<li><a title="Hạng Nhất" href="/hang-nhat-viet-nam">Hạng Nhất</a></li>
|
| 438 |
-
<li><a title="Cúp Quốc gia" href="/cup-quoc-gia-viet-nam">Cúp Quốc gia</a></li>
|
| 439 |
-
<li><a title="Cúp châu Á" href="/asian-cup">Cúp châu Á</a></li>
|
| 440 |
-
<li><a title="Bóng đá nữ" href="/bong-da-nu-viet-nam">Bóng đá nữ</a></li>
|
| 441 |
-
<li><a title="Futsal" href="/futsal">Futsal</a></li>
|
| 442 |
-
<li><a title="Phong trào" href="/bong-da-phong-trao">Phong trào</a></li>
|
| 443 |
-
<li><a title="Chuyển nhượng V.League" href="/tin-chuyen-nhuong-v-league">Chuyển nhượng V.League</a></li>
|
| 444 |
-
<li><a title="Tin khác" href="/tin-khac">Tin khác</a></li>
|
| 445 |
-
</ul>
|
| 446 |
-
</li>
|
| 447 |
-
|
| 448 |
-
<li>
|
| 449 |
-
<ul class="lst">
|
| 450 |
-
<li><a class="fst" title="Bóng đá Anh" href="/bong-da-anh">Bóng đá Anh</a></li>
|
| 451 |
-
<li><a title="Đội tuyển Anh" href="/doi-tuyen-anh">Đội tuyển Anh</a></li>
|
| 452 |
-
<li><a title="Ngoại hạng Anh" href="/ngoai-hang-anh">Ngoại hạng Anh</a></li>
|
| 453 |
-
<li><a title="FA Cup" href="/fa-cup">FA Cup</a></li>
|
| 454 |
-
<li><a title="League Cup" href="/cup-lien-doan-anh">League Cup</a></li>
|
| 455 |
-
</ul>
|
| 456 |
-
<div class="clx"></div>
|
| 457 |
-
<ul class="lst">
|
| 458 |
-
<li><a class="fst" href="#">Giải đấu CLB</a></li>
|
| 459 |
-
<li><a title="Champions Leage" href="/champions-league-cup-c1">Champions League</a></li>
|
| 460 |
-
<li><a title="Europa League" href="/europa-league">Europa League</a></li>
|
| 461 |
-
<li><a title="Club World Cup" href="/fifa-club-world-cup-tags">Club World Cup</a></li>
|
| 462 |
-
<li><a title="Europa Conference League" href="/europa-conference-league">Europa Conference League</a></li>
|
| 463 |
-
</ul>
|
| 464 |
-
</li>
|
| 465 |
-
|
| 466 |
-
<li>
|
| 467 |
-
<ul class="lst">
|
| 468 |
-
<li><a class="fst" title="Bóng đá Italia" href="/bong-da-y">Bóng đá Italia</a></li>
|
| 469 |
-
<li><a title="Đội tuyển Italia" href="/doi-tuyen-y">Đội tuyển Italia</a></li>
|
| 470 |
-
<li><a title="Serie A" href="/serie-a">Serie A</a></li>
|
| 471 |
-
<li><a title="Coppa Italia" href="/coppa-italia">Coppa Italia</a></li>
|
| 472 |
-
</ul>
|
| 473 |
-
<div class="clx"></div>
|
| 474 |
-
<ul class="lst">
|
| 475 |
-
<li><a class="fst" href="#">Đội tuyển</a></li>
|
| 476 |
-
<li><a title="World Cup nữ 2028" href="/world-cup-nu-2023-tags">World Cup nữ 2028</a></li>
|
| 477 |
-
<li><a title="World Cup" href="/world-cup">World Cup 2026</a></li>
|
| 478 |
-
<li><a title="European Championship 2028" href="/euro-cup-chau-au">EURO 2028</a></li>
|
| 479 |
-
<li><a title="Nations League" href="/nations-league">Nations League</a></li>
|
| 480 |
-
<li><a title="Copa America" href="/copa-america">Copa America</a></li>
|
| 481 |
-
<li><a title="CAN" href="/can">CAN</a></li>
|
| 482 |
-
<li><a title="Olympic" href="/olympic">Olympic 2028</a></li>
|
| 483 |
-
</ul>
|
| 484 |
-
</li>
|
| 485 |
-
|
| 486 |
-
<li>
|
| 487 |
-
<ul class="lst">
|
| 488 |
-
<li><a class="fst" title="Bóng đá Pháp" href="/bong-da-phap">Bóng đá Pháp</a></li>
|
| 489 |
-
<li><a title="Đội tuyển Pháp" href="/doi-tuyen-phap">Đội tuyển Pháp</a></li>
|
| 490 |
-
<li><a title="Ligue 1" href="/ligue-1">Ligue 1</a></li>
|
| 491 |
-
<li><a title="Cúp QG Pháp" href="/cup-quoc-gia-phap">Cúp QG Pháp</a></li>
|
| 492 |
-
<li><a title="Cúp Liên đoàn Pháp" href="/cup-lien-doan-phap-tags">Cúp Liên đoàn Pháp</a></li>
|
| 493 |
-
</ul>
|
| 494 |
-
<div class="clx"></div>
|
| 495 |
-
<ul class="lst">
|
| 496 |
-
<li><a class="fst" href="#">Khu vực</a></li>
|
| 497 |
-
<li><a title="Asian Cup" href="/asian-cup">Asian Cup</a></li>
|
| 498 |
-
<li><a title="Asian Games" href="/asian-games">Asian Games</a></li>
|
| 499 |
-
<li><a title="AFF Cup" href="/aff-cup">AFF Cup</a></li>
|
| 500 |
-
<li><a title="SEA Games" href="/sea-games">SEA Games</a></li>
|
| 501 |
-
</ul>
|
| 502 |
-
</li>
|
| 503 |
-
|
| 504 |
-
<li>
|
| 505 |
-
<ul class="lst">
|
| 506 |
-
<li><a class="fst" title="Tây Ban nha" href="/bong-da-tay-ban-nha">Tây Ban nha</a></li>
|
| 507 |
-
<li><a title="Đội tuyển TBN" href="/doi-tuyen-tay-ban-nha">Đội tuyển TBN</a></li>
|
| 508 |
-
<li><a title="La Liga" href="/la-liga">La Liga</a></li>
|
| 509 |
-
<li><a title="Cúp Nhà Vua" href="/cup-nha-vua-tay-ban-nha">Cúp Nhà Vua</a></li>
|
| 510 |
-
</ul>
|
| 511 |
-
<div class="clx"></div>
|
| 512 |
-
<ul class="lst">
|
| 513 |
-
<li><a class="fst" href="#">Chuyên đề</a></li>
|
| 514 |
-
<li><a title="Tin chuyển nhượng" href="/tin-chuyen-nhuong">Chuyển nhượng</a></li>
|
| 515 |
-
<li><a title="Hậu trường bóng đá" href="/hau-truong-bong-da">Hậu trường</a></li>
|
| 516 |
-
<li><a title="Điểm tin" href="/diem-tin">Điểm tin</a></li>
|
| 517 |
-
<li><a title="Showbiz Ngôi sao" href="/ngoi-sao-showbiz">Showbiz Ngôi sao</a></li>
|
| 518 |
-
<li><a title="Góc check var" href="/goc-check-var">Góc check var</a></li>
|
| 519 |
-
<li><a title="Công nghệ" href="/cong-nghe">Công nghệ</a></li>
|
| 520 |
-
<li><a title="Thông tin Thị trường" href="/thi-truong">Thị trường</a></li>
|
| 521 |
-
</ul>
|
| 522 |
-
</li>
|
| 523 |
-
|
| 524 |
-
<li>
|
| 525 |
-
<ul class="lst">
|
| 526 |
-
<li><a class="fst" title="Bóng đá Đức" href="/bong-da-duc">Bóng đá Đức</a></li>
|
| 527 |
-
<li><a title="Đội tuyển Đức" href="/doi-tuyen-duc">Đội tuyển Đức</a></li>
|
| 528 |
-
<li><a title="Bundesliga" href="/bundesliga">Bundesliga</a></li>
|
| 529 |
-
<li><a title="Cúp QG Đức" href="/cup-quoc-gia-duc">Cúp QG Đức</a></li>
|
| 530 |
-
</ul>
|
| 531 |
-
<div class="clx"></div>
|
| 532 |
-
<ul class="lst">
|
| 533 |
-
<li><a class="fst" href="#">Đặc biệt</a></li>
|
| 534 |
-
<li><a title="Big Story" href="/emagazine">Big Story</a></li>
|
| 535 |
-
<li><a title="X-File" href="/x-file">X-File</a></li>
|
| 536 |
-
<li><a title="Infographic" href="/infographic">Infographic</a></li>
|
| 537 |
-
<li><a title="Interactive" href="/interactive">Interactive</a></li>
|
| 538 |
-
</ul>
|
| 539 |
-
</li>
|
| 540 |
-
</ul>
|
| 541 |
-
</div>
|
| 542 |
-
|
| 543 |
-
<div class="menu-row">
|
| 544 |
-
<ul class="bar">
|
| 545 |
-
<li> </li>
|
| 546 |
-
<li><a title="Tin chuyển nhượng" href="/tin-chuyen-nhuong">Tin chuyển nhượng</a></li>
|
| 547 |
-
<li><a title="Bóng đá & Cuộc sống" href="/bong-da-cuoc-song">Bóng đá & Cuộc sống</a></li>
|
| 548 |
-
<li><a title="Thông tin Thị trường" href="/thi-truong">Thông tin Thị trường</a></li>
|
| 549 |
-
<li><a title="Tin tức Công nghệ" href="/cong-nghe">Công nghệ</a></li>
|
| 550 |
-
<li><a title="Tin tức Xe auto bike" href="/xe-co">Xe auto</a></li>
|
| 551 |
-
</ul>
|
| 552 |
-
</div>
|
| 553 |
-
|
| 554 |
-
<div class="menu-row">
|
| 555 |
-
<ul class="bar">
|
| 556 |
-
<li> </li>
|
| 557 |
-
<li><a class="caption" title="Nhận định Bóng đá" href="/nhan-dinh-bong-da-tags">Nhận định</a></li>
|
| 558 |
-
<li><a class="caption" title="Tư vấn Bóng đá" href="/soi-keo-bong-da">Tư vấn</a></li>
|
| 559 |
-
<li><a class="caption" title="Multimedia" href="/video">Multimedia</a></li>
|
| 560 |
-
<li><a class="caption" title="Youtube" target="_blank" href="https://www.youtube.com/channel/UCF0VFBZu-5SRJXMV_FMysEA">Youtube</a></li>
|
| 561 |
-
<li><a class="caption" title="Facebook fanpage" target="_blank" href="https://www.facebook.com/BaoBongDa">FB Fanpage</a></li>
|
| 562 |
-
<li>
|
| 563 |
-
<a class="caption" title="Premium" href="/premium">Premium <i class="ico crown"></i></a>
|
| 564 |
-
</li>
|
| 565 |
-
</ul>
|
| 566 |
-
</div>
|
| 567 |
-
|
| 568 |
-
<div class="menu-row">
|
| 569 |
-
<ul class="bar">
|
| 570 |
-
<li><a class="caption" href="/lich-thi-dau-bong-da">Lịch thi đấu</a></li>
|
| 571 |
-
<li>
|
| 572 |
-
<ul class="lst">
|
| 573 |
-
<li><a class="fst" href="/lich-thi-dau-bong-da">Lịch thi đấu hôm nay</a></li>
|
| 574 |
-
<li><a href="/lich-thi-dau-bong-da?bong-da-viet-nam" title="Lịch thi đấu V.League">V.League</a></li>
|
| 575 |
-
<li><a href="/lich-thi-dau-bong-da?bong-da-anh" title="Lịch thi đấu Ngoại hạng Anh">Ngoại hạng Anh</a></li>
|
| 576 |
-
<li><a href="/lich-thi-dau-bong-da?bong-da-phap" title="Lịch thi đấu Ligue 1 Pháp">Ligue 1</a></li>
|
| 577 |
-
<li><a href="/lich-thi-dau-bong-da?bong-da-tay-ban-nha" title="Lịch thi đấu La Liga Tây Ban Nha">La Liga</a></li>
|
| 578 |
-
<li><a href="/lich-thi-dau-bong-da?bong-da-duc" title="Lịch thi đấu Bundesliga Đức">Bundesliga</a></li>
|
| 579 |
-
<li><a href="/lich-thi-dau-bong-da?bong-da-y" title="Lịch thi đấu Serie A Italia">Serie A</a></li>
|
| 580 |
-
<li><a href="/lich-thi-dau-bong-da?champions-league-cup-c1" title="Lịch thi đấu Champions League">Champions League</a></li>
|
| 581 |
-
<li><a href="/lich-thi-dau-bong-da?europa-league" title="Lịch thi đấu Europa League">Europa League</a></li>
|
| 582 |
-
</ul>
|
| 583 |
-
</li>
|
| 584 |
-
|
| 585 |
-
<li>
|
| 586 |
-
<ul class="lst">
|
| 587 |
-
<li><a class="fst" href="/ket-qua-bong-da">Kết quả mới nhất</a></li>
|
| 588 |
-
<li><a href="/lich-thi-dau-bong-da?bong-da-viet-nam" title="Kết quả V.League">V.League</a></li>
|
| 589 |
-
<li><a href="/lich-thi-dau-bong-da?bong-da-anh" title="Kết quả Ngoại hạng Anh">Ngoại hạng Anh</a></li>
|
| 590 |
-
<li><a href="/lich-thi-dau-bong-da?bong-da-phap" title="Kết quả Ligue 1 Pháp">Ligue 1</a></li>
|
| 591 |
-
<li><a href="/lich-thi-dau-bong-da?bong-da-tay-ban-nha" title="Kết quả La Liga Tây Ban Nha">La Liga</a></li>
|
| 592 |
-
<li><a href="/lich-thi-dau-bong-da?bong-da-duc" title="Kết quả Bundesliga Đức">Bundesliga</a></li>
|
| 593 |
-
<li><a href="/lich-thi-dau-bong-da?bong-da-y" title="Kết quả Serie A Italia">Serie A</a></li>
|
| 594 |
-
<li><a href="/lich-thi-dau-bong-da?champions-league-cup-c1" title="Kết quả Champions League">Champions League</a></li>
|
| 595 |
-
<li><a href="/lich-thi-dau-bong-da?europa-league" title="Kết quả Europa League">Europa League</a></li>
|
| 596 |
-
</ul>
|
| 597 |
-
</li>
|
| 598 |
-
|
| 599 |
-
<li>
|
| 600 |
-
<ul class="lst">
|
| 601 |
-
<li><a class="fst" href="#">Bảng xếp hạng</a></li>
|
| 602 |
-
<li><a href="/bang-xep-hang-bong-da?bong-da-viet-nam" title="Bảng xếp hạng V.League">V.League</a></li>
|
| 603 |
-
<li><a href="/bang-xep-hang-bong-da?bong-da-anh" title="Bảng xếp hạng Ngoại hạng Anh">Ngoại hạng Anh</a></li>
|
| 604 |
-
<li><a href="/bang-xep-hang-bong-da?bong-da-phap" title="Bảng xếp hạng Ligue 1 Pháp">Ligue 1</a></li>
|
| 605 |
-
<li><a href="/bang-xep-hang-bong-da?bong-da-tay-ban-nha" title="Bảng xếp hạng La Liga Tây Ban Nha">La Liga</a></li>
|
| 606 |
-
<li><a href="/bang-xep-hang-bong-da?bong-da-duc" title="Bảng xếp hạng Bundesliga Đức">Bundesliga</a></li>
|
| 607 |
-
<li><a href="/bang-xep-hang-bong-da?bong-da-y" title="Bảng xếp hạng Serie A Italia">Serie A</a></li>
|
| 608 |
-
<li><a href="/bang-xep-hang-bong-da?champions-league-cup-c1" title="Bảng xếp hạng Champions League">Champions League</a></li>
|
| 609 |
-
<li><a href="/bang-xep-hang-bong-da?europa-league" title="Bảng xếp hạng Europa League">Europa League</a></li>
|
| 610 |
-
</ul>
|
| 611 |
-
</li>
|
| 612 |
-
|
| 613 |
-
<li>
|
| 614 |
-
<ul class="lst">
|
| 615 |
-
<li><a class="caption" href="https://phui.bongdaplus.vn" target="_blank">Bóng đá phủi</a></li>
|
| 616 |
-
<li><a class="caption" href="/tennis">Tennis</a></li>
|
| 617 |
-
</ul>
|
| 618 |
-
</li>
|
| 619 |
-
<li>
|
| 620 |
-
<ul class="lst">
|
| 621 |
-
<li><a class="caption" href="/esports">Esports</a></li>
|
| 622 |
-
<li><a class="caption" href="/the-thao">Thể thao +</a></li>
|
| 623 |
-
</ul>
|
| 624 |
-
</li>
|
| 625 |
-
|
| 626 |
-
<li>
|
| 627 |
-
<ul class="lst">
|
| 628 |
-
<li>
|
| 629 |
-
<div class="copyr">
|
| 630 |
-
<div class="splt hide-w"></div>
|
| 631 |
-
<p><b class="allcap">Tạp chí Bóng Đá</b></p>
|
| 632 |
-
<p>Giấy phép số 48/GP-BTTTT cấp ngày 05/02/2020</p>
|
| 633 |
-
<p> </p>
|
| 634 |
-
<p>
|
| 635 |
-
Tổng biên tập:<br /> <b>Vũ Khắc Sơn</b><br />
|
| 636 |
-
Phó tổng biên tập:<br /> <b>Nguyễn Hà Thanh</b>
|
| 637 |
-
</p>
|
| 638 |
-
<p> </p>
|
| 639 |
-
<p>
|
| 640 |
-
<a class="contact-showr" href="#">Thông tin tòa soạn</a><br />
|
| 641 |
-
<a class="contact-showr" href="#">Liên hệ quảng cáo</a>
|
| 642 |
-
</p>
|
| 643 |
-
<p>Liên đoàn BĐVN <a href="https://vff.org.vn/" target="_blank" rel="nofollow"><img alt="VFF" src="/img/logo_Vff.png" style="width:28px;" /></a></p>
|
| 644 |
-
</div>
|
| 645 |
-
</li>
|
| 646 |
-
</ul>
|
| 647 |
-
</li>
|
| 648 |
-
</ul>
|
| 649 |
-
</div>
|
| 650 |
-
</div>
|
| 651 |
-
</div>
|
| 652 |
-
<div class="menu-tags hide-w">
|
| 653 |
-
<div class="today"></div>
|
| 654 |
-
<div class="mtags">
|
| 655 |
-
<a title="#HighlightBóngđá" href="/video/highlight">#HighlightBóngđá</a>
|
| 656 |
-
<a title="#PremierLeague" href="/ngoai-hang-anh">#PremierLeague</a>
|
| 657 |
-
<a title="#V.League" href="/v-league">#V.League</a>
|
| 658 |
-
</div>
|
| 659 |
-
</div>
|
| 660 |
-
|
| 661 |
-
<div id="fxtSlideCover" class="fixt-strm" style="margin-bottom:5px;">
|
| 662 |
-
<div class="cont-wrap">
|
| 663 |
-
<a class="brand" href="https://admin.giaminhmedia.vn/www/delivery/cl.php?bannerid=1411&zoneid=328&sig=8300d1283663cec85d90499507ffd94240954967132167c0f302d7388b8f9bb5&dest=http%3A%2F%2Fadmin.giaminhmedia.vn%2Fwww%2Funiscore.php" target="_blank" rel="nofollow" style="margin-left:5px;">
|
| 664 |
-
<img alt="" src="https://bongdaplus.vn/img/logouniscore-tiny.png">
|
| 665 |
-
</a>
|
| 666 |
-
<ul id="fxtSlide" class="nav"></ul>
|
| 667 |
-
</div>
|
| 668 |
-
</div>
|
| 669 |
-
|
| 670 |
-
<div class="clz"></div>
|
| 671 |
-
<div id="ADTOP" class="banner adweb admob" banner-adpos="top"></div>
|
| 672 |
-
|
| 673 |
-
<section class="bread-crums">
|
| 674 |
-
<div class="clz"></div>
|
| 675 |
-
<div class="cont-wrap">
|
| 676 |
-
<div class="chain-navs">
|
| 677 |
-
<a href="/video">Multimedia</a>»
|
| 678 |
-
</div>
|
| 679 |
-
</div>
|
| 680 |
-
</section>
|
| 681 |
-
|
| 682 |
-
<section class="media-box">
|
| 683 |
-
<div class="clz"></div>
|
| 684 |
-
<div class="cont-wrap video-box">
|
| 685 |
-
<div class="row">
|
| 686 |
-
<div class="col m12 w8 clip-box">
|
| 687 |
-
<div class="play-box rbox">
|
| 688 |
-
<iframe id="ytbFrBpl" class="play-frame" src="https://www.youtube.com/embed/Qokb_8w6p0s?si=QNKAZIYxNrtn2hSN" cid="UCmn1v6lrIDZghUfbxXaorUw" vnum="0" class="latestVideoEmbed" title="Bongdaplus trên Youtube" frameborder="0" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen=""></iframe>
|
| 689 |
-
</div>
|
| 690 |
-
</div>
|
| 691 |
-
|
| 692 |
-
<div class="col m12 w4 clip-cont">
|
| 693 |
-
<div class="ovrscrl">
|
| 694 |
-
<div class="clip-info">
|
| 695 |
-
<h1 class="title">Hull City vs MU: Quỷ Đỏ vào hang bắt hổ</h1>
|
| 696 |
-
<p class="desc">
|
| 697 |
-
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.
|
| 698 |
-
</p>
|
| 699 |
-
|
| 700 |
-
<div>08:07 - 22/08/2026</div>
|
| 701 |
-
<div class="editor"></div>
|
| 702 |
-
<div class="hash-tags">
|
| 703 |
-
<b>Tags:</b>
|
| 704 |
-
<a href="/video/hull-city-vs-mu-tags">Hull City vs MU</a>
|
| 705 |
-
<a href="/video/nhan-dinh-hull-city-vs-mu-tags">nhận định Hull City vs MU</a>
|
| 706 |
-
<a href="/video/premier-league-tags">Premier League</a>
|
| 707 |
-
<a href="/video/mu-tags">MU</a>
|
| 708 |
-
</div>
|
| 709 |
-
<style>
|
| 710 |
-
.emobar .emo:not(:empty) {
|
| 711 |
-
background-position: left 10px top 50%;
|
| 712 |
-
padding: 0 13px 0 36px;
|
| 713 |
-
}
|
| 714 |
-
.emobar .emo {
|
| 715 |
-
border-radius: 16px;
|
| 716 |
-
background-color: #d7d3d3;
|
| 717 |
-
background-size: auto 18px;
|
| 718 |
-
cursor: pointer;
|
| 719 |
-
margin: 0 3px;
|
| 720 |
-
padding: 0 16px;
|
| 721 |
-
line-height: 32px;
|
| 722 |
-
height: 32px;
|
| 723 |
-
}
|
| 724 |
-
.emopicked {
|
| 725 |
-
border: solid 1px #767676;
|
| 726 |
-
}
|
| 727 |
-
</style>
|
| 728 |
-
<div class="emobar tx-cen">
|
| 729 |
-
<div class="clx"></div>
|
| 730 |
-
<a class="emo like" onclick="btnNewsEmotion_click(1)" id="btnEmo1"> <span id="numemo1"></span></a>
|
| 731 |
-
<a class="emo heart" onclick="btnNewsEmotion_click(2)" id="btnEmo2"> <span id="numemo2"></span></a>
|
| 732 |
-
<a class="emo star" onclick="btnNewsEmotion_click(4)" id="btnEmo4"> <span id="numemo4"></span></a>
|
| 733 |
-
<a class="emo comment" id="ncmt" href="#commenting" data-action="comment" data-act-only="true"></a>
|
| 734 |
-
</div>
|
| 735 |
-
</div>
|
| 736 |
-
</div>
|
| 737 |
-
</div>
|
| 738 |
-
</div>
|
| 739 |
-
</div>
|
| 740 |
-
|
| 741 |
-
<div class="clx"></div>
|
| 742 |
-
<div class="cont-view">
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
<style>
|
| 748 |
-
.btnlikedislikecmt {
|
| 749 |
-
border-radius: 9px;
|
| 750 |
-
border: solid 1px #767676;
|
| 751 |
-
background-color: #cecece;
|
| 752 |
-
}
|
| 753 |
-
|
| 754 |
-
sup {
|
| 755 |
-
padding: 3px;
|
| 756 |
-
position: initial;
|
| 757 |
-
vertical-align: initial;
|
| 758 |
-
}
|
| 759 |
-
|
| 760 |
-
.TopComment {
|
| 761 |
-
border: solid 1px #fdd835;
|
| 762 |
-
}
|
| 763 |
-
|
| 764 |
-
.TopComment .info .member {
|
| 765 |
-
color: red;
|
| 766 |
-
}
|
| 767 |
-
|
| 768 |
-
.TopComment .info .avatar {
|
| 769 |
-
border: solid 1px red;
|
| 770 |
-
}
|
| 771 |
-
|
| 772 |
-
.comment .summ .replyat {
|
| 773 |
-
/*color: #1b5e20;*/
|
| 774 |
-
display: inline-block;
|
| 775 |
-
margin: 0 8px 0 0;
|
| 776 |
-
font-weight: bold;
|
| 777 |
-
/* text-decoration: underline; */
|
| 778 |
-
}
|
| 779 |
-
</style>
|
| 780 |
-
<div id="postComment" class="comm-zone">
|
| 781 |
-
<a class="anchor" name="#comments"></a>
|
| 782 |
-
<div class="clz"></div>
|
| 783 |
-
<div class="xcap">Bình luận</div>
|
| 784 |
-
<div class="clz"></div>
|
| 785 |
-
<div class="comm-post ">
|
| 786 |
-
<div id="formcomment" class="comm-form">
|
| 787 |
-
<label class="txt-fld">
|
| 788 |
-
<textarea id="txtcomment_0" name="txtcomment_0" maxlength="1000" placeholder="Hãy chia sẻ suy nghĩ của bạn..." onclick="showcommentloginform();"></textarea>
|
| 789 |
-
</label>
|
| 790 |
-
<div class="actions">
|
| 791 |
-
<button id="btnComment" class="btn btn-send base" onclick="btnComment_click(0,0)">Gửi</button>
|
| 792 |
-
</div>
|
| 793 |
-
<center><div class="cmterr"></div></center>
|
| 794 |
-
</div>
|
| 795 |
-
</div>
|
| 796 |
-
<div class="clz"></div>
|
| 797 |
-
<div class="comm-frame">
|
| 798 |
-
<div id="NewsComments"></div>
|
| 799 |
-
<div class="clz"></div>
|
| 800 |
-
<div class="cat-news">
|
| 801 |
-
<div id="btnloadviewmore" class="view-more hide"><a onclick="loadviewmorecomment();">Xem thêm</a></div>
|
| 802 |
-
</div>
|
| 803 |
-
<div class="clz"></div>
|
| 804 |
-
</div>
|
| 805 |
-
</div>
|
| 806 |
-
</div>
|
| 807 |
-
<div class="clz"></div>
|
| 808 |
-
</section>
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
<div class="clx"></div>
|
| 815 |
-
<center><div id="ADHOT" class="banner adweb admob" banner-adpos="hot"></div></center>
|
| 816 |
-
<div class="clx"></div>
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
<section class="cat-news">
|
| 820 |
-
<div class="cont-wrap">
|
| 821 |
-
<div class="capt-cover">
|
| 822 |
-
<div class="caption">Video mới nhất</div>
|
| 823 |
-
<button class="more-lnk"></button>
|
| 824 |
-
</div>
|
| 825 |
-
<div class="row flex">
|
| 826 |
-
<div class="col m12 w3">
|
| 827 |
-
<div class="news">
|
| 828 |
-
<a class="thumb" href="/video/du-doan-ty-so-hull-mu-than-rua-ali-33-chon-doi-nao-982702608.html"><img alt="Dự đoán tỷ số Hull vs MU: Thần Rùa Ali 33 chọn đội nào?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/18/80/Anh-chu-480-x-270.jpg" /></a>
|
| 829 |
-
<a class="title" href="/video/du-doan-ty-so-hull-mu-than-rua-ali-33-chon-doi-nao-982702608.html">Dự đoán tỷ số Hull vs MU: Thần Rùa Ali 33 chọn đội nào?</a>
|
| 830 |
-
<span class="info">
|
| 831 |
-
42 phút trước
|
| 832 |
-
</span>
|
| 833 |
-
<span class="info">
|
| 834 |
-
</span>
|
| 835 |
-
</div>
|
| 836 |
-
</div>
|
| 837 |
-
<div class="col m12 w3">
|
| 838 |
-
<div class="news">
|
| 839 |
-
<a class="thumb" href="/video/hull-city-vs-mu-quy-do-vao-hang-bat-ho-982672608.html"><img alt="Hull City vs MU: Quỷ Đỏ vào hang bắt hổ" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/18/13/ThumbHullvsMU-up.jpg" /></a>
|
| 840 |
-
<a class="title" href="/video/hull-city-vs-mu-quy-do-vao-hang-bat-ho-982672608.html">Hull City vs MU: Quỷ Đỏ vào hang bắt hổ</a>
|
| 841 |
-
<span class="info">
|
| 842 |
-
43 phút trước
|
| 843 |
-
</span>
|
| 844 |
-
<span class="info">
|
| 845 |
-
</span>
|
| 846 |
-
</div>
|
| 847 |
-
</div>
|
| 848 |
-
<div class="col m12 w3">
|
| 849 |
-
<div class="news">
|
| 850 |
-
<a class="thumb" href="/video/du-doan-ty-so-brentford-vs-tottenham-premier-league-2026-27-than-rua-ali-33-chon-doi-nao-982862608.html"><img alt="Dự đoán tỷ số Brentford vs Tottenham: Thần Rùa Ali 33 chọn đội nào?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/19/80/Brentford-vs-Tottenham-480-x-270-moi.jpg" /></a>
|
| 851 |
-
<a class="title" href="/video/du-doan-ty-so-brentford-vs-tottenham-premier-league-2026-27-than-rua-ali-33-chon-doi-nao-982862608.html">Dự đoán tỷ số Brentford vs Tottenham: Thần Rùa Ali 33 chọn đội nào?</a>
|
| 852 |
-
<span class="info">
|
| 853 |
-
43 phút trước
|
| 854 |
-
</span>
|
| 855 |
-
<span class="info">
|
| 856 |
-
</span>
|
| 857 |
-
</div>
|
| 858 |
-
</div>
|
| 859 |
-
<div class="col m12 w3">
|
| 860 |
-
<div class="news">
|
| 861 |
-
<a class="thumb" href="/video/brentford-vs-tottenham-de-zerbi-vuot-ai-gtech-982822608.html"><img alt="Brentford vs Tottenham: De Zerbi vượt ải Gtech" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/19/13/Brenfort-vs-Tottenham.jpg" /></a>
|
| 862 |
-
<a class="title" href="/video/brentford-vs-tottenham-de-zerbi-vuot-ai-gtech-982822608.html">Brentford vs Tottenham: De Zerbi vượt ải Gtech</a>
|
| 863 |
-
<span class="info">
|
| 864 |
-
43 phút trước
|
| 865 |
-
</span>
|
| 866 |
-
<span class="info">
|
| 867 |
-
</span>
|
| 868 |
-
</div>
|
| 869 |
-
</div>
|
| 870 |
-
<div class="col m12 w3">
|
| 871 |
-
<div class="news">
|
| 872 |
-
<a class="thumb" href="/video/du-doan-ty-so-arsenal-vs-coventry-than-rua-ali-33-chon-doi-nao-982612608.html"><img alt="Dự đoán tỷ số Arsenal vs Coventry: Thần Rùa Ali 33 chọn đội nào?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/17/0/thumblai.jpg" /></a>
|
| 873 |
-
<a class="title" href="/video/du-doan-ty-so-arsenal-vs-coventry-than-rua-ali-33-chon-doi-nao-982612608.html">Dự đoán tỷ số Arsenal vs Coventry: Thần Rùa Ali 33 chọn đội nào?</a>
|
| 874 |
-
<span class="info">
|
| 875 |
-
09 giờ trước
|
| 876 |
-
</span>
|
| 877 |
-
<span class="info">
|
| 878 |
-
</span>
|
| 879 |
-
</div>
|
| 880 |
-
</div>
|
| 881 |
-
<div class="col m12 w3">
|
| 882 |
-
<div class="news">
|
| 883 |
-
<a class="thumb" href="/video/arsenal-vs-coventry-nha-vua-mo-man-bang-mot-chien-thang-982662608.html"><img alt="Arsenal vs Coventry: Nhà vua mở màn bằng một chiến thắng?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/18/13/ThumbArsenalvsCoventry-up.jpg" /></a>
|
| 884 |
-
<a class="title" href="/video/arsenal-vs-coventry-nha-vua-mo-man-bang-mot-chien-thang-982662608.html">Arsenal vs Coventry: Nhà vua mở màn bằng một chiến thắng?</a>
|
| 885 |
-
<span class="info">
|
| 886 |
-
09 giờ trước
|
| 887 |
-
</span>
|
| 888 |
-
<span class="info">
|
| 889 |
-
</span>
|
| 890 |
-
</div>
|
| 891 |
-
</div>
|
| 892 |
-
<div class="col m12 w3">
|
| 893 |
-
<div class="news">
|
| 894 |
-
<a class="thumb" href="/video/truc-tuyen-truoc-tran-arsenal-vs-coventry-hull-vs-mu-983222608.html"><img alt="Trực tuyến trước trận Arsenal vs Coventry, Hull vs MU" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/truc-tuyen-truoc-tran-Arsenal-vs-Coventry.jpg" /></a>
|
| 895 |
-
<a class="title" href="/video/truc-tuyen-truoc-tran-arsenal-vs-coventry-hull-vs-mu-983222608.html">Trực tuyến trước trận Arsenal vs Coventry, Hull vs MU</a>
|
| 896 |
-
<span class="info">
|
| 897 |
-
11 giờ trước
|
| 898 |
-
</span>
|
| 899 |
-
<span class="info">
|
| 900 |
-
</span>
|
| 901 |
-
</div>
|
| 902 |
-
</div>
|
| 903 |
-
<div class="col m12 w3">
|
| 904 |
-
<div class="news">
|
| 905 |
-
<a class="thumb" href="/video/dt-thai-lan-tap-cach-san-rajamangala-40km-hlv-anthony-hudson-chot-luon-doi-hinh-dau-viet-nam-983202608.html"><img alt=" ĐT Thái Lan tập cách sân Rajamangala 40km, HLV Anthony Hudson chốt luôn đội hình đấu Việt Nam" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/80/HLV-Thai-Lan-chot-doi-hinh.jpg" /></a>
|
| 906 |
-
<a class="title" href="/video/dt-thai-lan-tap-cach-san-rajamangala-40km-hlv-anthony-hudson-chot-luon-doi-hinh-dau-viet-nam-983202608.html"> ĐT Thái Lan tập cách sân Rajamangala 40km, HLV Anthony Hudson chốt luôn đội hình đấu Việt Nam</a>
|
| 907 |
-
<span class="info">
|
| 908 |
-
13 giờ trước
|
| 909 |
-
</span>
|
| 910 |
-
<span class="info">
|
| 911 |
-
</span>
|
| 912 |
-
</div>
|
| 913 |
-
</div>
|
| 914 |
-
<div class="col m12 w3">
|
| 915 |
-
<div class="news">
|
| 916 |
-
<a class="thumb" href="/video/tro-lai-san-tung-vo-dich-aff-cup-2024-dt-viet-nam-quyet-lam-nen-lich-su-983212608.html"><img alt=" Trở lại sân từng vô địch AFF Cup 2024, ĐT Việt Nam quyết làm nên lịch sử" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/80/Viet-Nam-tap-2-1-8.jpg" /></a>
|
| 917 |
-
<a class="title" href="/video/tro-lai-san-tung-vo-dich-aff-cup-2024-dt-viet-nam-quyet-lam-nen-lich-su-983212608.html"> Trở lại sân từng vô địch AFF Cup 2024, ĐT Việt Nam quyết làm nên lịch sử</a>
|
| 918 |
-
<span class="info">
|
| 919 |
-
14 giờ trước
|
| 920 |
-
</span>
|
| 921 |
-
<span class="info">
|
| 922 |
-
</span>
|
| 923 |
-
</div>
|
| 924 |
-
</div>
|
| 925 |
-
<div class="col m12 w3">
|
| 926 |
-
<div class="news">
|
| 927 |
-
<a class="thumb" href="/video/vong-1-premier-league-2026-27-lieu-co-cu-soc-983112608.html"><img alt="Nhận định Premier League vòng 1: Liệu có cú sốc?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/thumbvong1-480.jpg" /></a>
|
| 928 |
-
<a class="title" href="/video/vong-1-premier-league-2026-27-lieu-co-cu-soc-983112608.html">Nhận định Premier League vòng 1: Liệu có cú sốc?</a>
|
| 929 |
-
<span class="info">
|
| 930 |
-
14 giờ trước
|
| 931 |
-
</span>
|
| 932 |
-
<span class="info">
|
| 933 |
-
</span>
|
| 934 |
-
</div>
|
| 935 |
-
</div>
|
| 936 |
-
<div class="col m12 w3">
|
| 937 |
-
<div class="news">
|
| 938 |
-
<a class="thumb" href="/video/ronaldinho-gay-soc-46-tuoi-van-tro-lai-san-co-khoac-ao-clb-serie-c-983192608.html"><img alt="Ronaldinho gây sốc: 46 tuổi vẫn trở lại sân cỏ, khoác áo CLB Serie C" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/26/Ronaldinho480.jpg" /></a>
|
| 939 |
-
<a class="title" href="/video/ronaldinho-gay-soc-46-tuoi-van-tro-lai-san-co-khoac-ao-clb-serie-c-983192608.html">Ronaldinho gây sốc: 46 tuổi vẫn trở lại sân cỏ, khoác áo CLB Serie C</a>
|
| 940 |
-
<span class="info">
|
| 941 |
-
14 giờ trước
|
| 942 |
-
</span>
|
| 943 |
-
<span class="info">
|
| 944 |
-
</span>
|
| 945 |
-
</div>
|
| 946 |
-
</div>
|
| 947 |
-
<div class="col m12 w3">
|
| 948 |
-
<div class="news">
|
| 949 |
-
<a class="thumb" href="/video/arsenal-no-bom-tan-51-trieu-bang-de-va-hang-thu-983122608.html"><img alt="Arsenal nổ bom tấn 51 triệu bảng để vá hàng thủ" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/26/Konsa480.jpg" /></a>
|
| 950 |
-
<a class="title" href="/video/arsenal-no-bom-tan-51-trieu-bang-de-va-hang-thu-983122608.html">Arsenal nổ bom tấn 51 triệu bảng để vá hàng thủ</a>
|
| 951 |
-
<span class="info">
|
| 952 |
-
14 giờ trước
|
| 953 |
-
</span>
|
| 954 |
-
<span class="info">
|
| 955 |
-
</span>
|
| 956 |
-
</div>
|
| 957 |
-
</div>
|
| 958 |
-
<div class="col m12 w3">
|
| 959 |
-
<div class="news">
|
| 960 |
-
<a class="thumb" href="/video/truc-tuyen-hop-bao-viet-nam-hlv-kim-sang-sik-noi-gi-khi-pho-chu-tich-thai-lan-mia-mai-dan-nhap-tich-983182608.html"><img alt="Trực tuyến họp báo Việt Nam: HLV Kim Sang Sik nói gì khi Phó Chủ tịch Thái Lan mỉa mai dàn nhập tịch" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/anhliveVN.jpg" /></a>
|
| 961 |
-
<a class="title" href="/video/truc-tuyen-hop-bao-viet-nam-hlv-kim-sang-sik-noi-gi-khi-pho-chu-tich-thai-lan-mia-mai-dan-nhap-tich-983182608.html">Trực tuyến họp báo Việt Nam: HLV Kim Sang Sik nói gì khi Phó Chủ tịch Thái Lan mỉa mai dàn nhập tịch</a>
|
| 962 |
-
<span class="info">
|
| 963 |
-
16 giờ trước
|
| 964 |
-
</span>
|
| 965 |
-
<span class="info">
|
| 966 |
-
</span>
|
| 967 |
-
</div>
|
| 968 |
-
</div>
|
| 969 |
-
<div class="col m12 w3">
|
| 970 |
-
<div class="news">
|
| 971 |
-
<a class="thumb" href="/video/truc-tuyen-thai-lan-hop-bao-hlv-thach-dau-dt-viet-nam-983162608.html"><img alt=" Trực tuyến Thái Lan họp báo: HLV Anthony Hudson thách đấu ĐT Việt Nam" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/anhlivesua-480.jpg" /></a>
|
| 972 |
-
<a class="title" href="/video/truc-tuyen-thai-lan-hop-bao-hlv-thach-dau-dt-viet-nam-983162608.html"> Trực tuyến Thái Lan họp báo: HLV Anthony Hudson thách đấu ĐT Việt Nam</a>
|
| 973 |
-
<span class="info">
|
| 974 |
-
18 giờ trước
|
| 975 |
-
</span>
|
| 976 |
-
<span class="info">
|
| 977 |
-
</span>
|
| 978 |
-
</div>
|
| 979 |
-
</div>
|
| 980 |
-
<div class="col m12 w3">
|
| 981 |
-
<div class="news">
|
| 982 |
-
<a class="thumb" href="/video/ban-tin-21-8-dt-viet-nam-chiu-thong-ke-cuc-xau-truoc-chung-ket-thai-lan-choi-chieu-nhu-indo-983152608.html"><img alt="Bản tin 21/8: ĐT Việt Nam chịu thống kê cực xấu trước chung kết, Thái Lan chơi chiêu như Indo" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/Thumb-tin-nong-21-8-480-up.jpg" /></a>
|
| 983 |
-
<a class="title" href="/video/ban-tin-21-8-dt-viet-nam-chiu-thong-ke-cuc-xau-truoc-chung-ket-thai-lan-choi-chieu-nhu-indo-983152608.html">Bản tin 21/8: ĐT Việt Nam chịu thống kê cực xấu trước chung kết, Thái Lan chơi chiêu như Indo</a>
|
| 984 |
-
<span class="info">
|
| 985 |
-
18 giờ trước
|
| 986 |
-
</span>
|
| 987 |
-
<span class="info">
|
| 988 |
-
</span>
|
| 989 |
-
</div>
|
| 990 |
-
</div>
|
| 991 |
-
<div class="col m12 w3">
|
| 992 |
-
<div class="news">
|
| 993 |
-
<a class="thumb" href="/video/thai-lan-khoa-cdv-viet-nam-chi-3-000-ve-cho-tran-chung-ket-tai-rajamangala-983142608.html"><img alt="Thái Lan 'khóa' CĐV Việt Nam: Chỉ 3.000 vé cho trận chung kết tại Rajamangala" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/26/cdv.jpg" /></a>
|
| 994 |
-
<a class="title" href="/video/thai-lan-khoa-cdv-viet-nam-chi-3-000-ve-cho-tran-chung-ket-tai-rajamangala-983142608.html">Thái Lan 'khóa' CĐV Việt Nam: Chỉ 3.000 vé cho trận chung kết tại Rajamangala</a>
|
| 995 |
-
<span class="info">
|
| 996 |
-
20 giờ trước
|
| 997 |
-
</span>
|
| 998 |
-
<span class="info">
|
| 999 |
-
</span>
|
| 1000 |
-
</div>
|
| 1001 |
-
</div>
|
| 1002 |
-
<div class="col m12 w3">
|
| 1003 |
-
<div class="news">
|
| 1004 |
-
<a class="thumb" href="/video/truoc-tran-thai-lan-vs-viet-nam-svd-rajamagala-vang-tanh-hon-chua-ba-danh-983132608.html"><img alt="Trước trận Thái Lan vs Việt Nam: SVĐ Rajamagala vắng tanh hơn chùa Bà Đanh" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/livethumbYT-480.jpg" /></a>
|
| 1005 |
-
<a class="title" href="/video/truoc-tran-thai-lan-vs-viet-nam-svd-rajamagala-vang-tanh-hon-chua-ba-danh-983132608.html">Trước trận Thái Lan vs Việt Nam: SVĐ Rajamagala vắng tanh hơn chùa Bà Đanh</a>
|
| 1006 |
-
<span class="info">
|
| 1007 |
-
21 giờ trước
|
| 1008 |
-
</span>
|
| 1009 |
-
<span class="info">
|
| 1010 |
-
</span>
|
| 1011 |
-
</div>
|
| 1012 |
-
</div>
|
| 1013 |
-
<div class="col m12 w3">
|
| 1014 |
-
<div class="news">
|
| 1015 |
-
<a class="thumb" href="/video/du-doan-ty-so-thai-lan-vs-viet-nam-than-rua-ali-33-chon-doi-nao-983092608.html"><img alt="Dự đoán tỷ số Thái Lan vs Việt Nam: Thần Rùa Ali 33 chọn đội nào?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/20/80/ThumbRuaThaivsVietnam-480.jpg" /></a>
|
| 1016 |
-
<a class="title" href="/video/du-doan-ty-so-thai-lan-vs-viet-nam-than-rua-ali-33-chon-doi-nao-983092608.html">Dự đoán tỷ số Thái Lan vs Việt Nam: Thần Rùa Ali 33 chọn đội nào?</a>
|
| 1017 |
-
<span class="info">
|
| 1018 |
-
19:18 ngày 20/08/2026
|
| 1019 |
-
</span>
|
| 1020 |
-
<span class="info">
|
| 1021 |
-
</span>
|
| 1022 |
-
</div>
|
| 1023 |
-
</div>
|
| 1024 |
-
<div class="col m12 w3">
|
| 1025 |
-
<div class="news">
|
| 1026 |
-
<a class="thumb" href="/video/truyen-thong-anh-dac-biet-chu-y-den-ngoi-no-cua-dt-viet-nam-983072608.html"><img alt="Truyền thông Anh đặc biệt chú ý đến 'ngòi nổ' của ĐT Việt Nam" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/20/26/xuan-son.jpg" /></a>
|
| 1027 |
-
<a class="title" href="/video/truyen-thong-anh-dac-biet-chu-y-den-ngoi-no-cua-dt-viet-nam-983072608.html">Truyền thông Anh đặc biệt chú ý đến 'ngòi nổ' của ĐT Việt Nam</a>
|
| 1028 |
-
<span class="info">
|
| 1029 |
-
19:18 ngày 20/08/2026
|
| 1030 |
-
</span>
|
| 1031 |
-
<span class="info">
|
| 1032 |
-
</span>
|
| 1033 |
-
</div>
|
| 1034 |
-
</div>
|
| 1035 |
-
<div class="col m12 w3">
|
| 1036 |
-
<div class="news">
|
| 1037 |
-
<a class="thumb" href="/video/dt-viet-nam-co-mat-tai-thai-lan-ao-trang-van-chua-bi-thay-kim-vut-het-983082608.html"><img alt="ĐT Việt Nam có mặt tại Thái Lan, áo trắng vẫn chưa bị thầy Kim bỏ hết" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/20/26/Kim-Sang-Sik.jpg" /></a>
|
| 1038 |
-
<a class="title" href="/video/dt-viet-nam-co-mat-tai-thai-lan-ao-trang-van-chua-bi-thay-kim-vut-het-983082608.html">ĐT Việt Nam có mặt tại Thái Lan, áo trắng vẫn chưa bị thầy Kim bỏ hết</a>
|
| 1039 |
-
<span class="info">
|
| 1040 |
-
17:32 ngày 20/08/2026
|
| 1041 |
-
</span>
|
| 1042 |
-
<span class="info">
|
| 1043 |
-
</span>
|
| 1044 |
-
</div>
|
| 1045 |
-
</div>
|
| 1046 |
-
</div>
|
| 1047 |
-
</div>
|
| 1048 |
-
<div class="clz"></div>
|
| 1049 |
-
</section>
|
| 1050 |
-
|
| 1051 |
-
|
| 1052 |
-
|
| 1053 |
-
<div class="clx"></div>
|
| 1054 |
-
<center><div id="ADBOT" class="banner adweb admob" banner-adpos="footer"></div></center>
|
| 1055 |
-
</main>
|
| 1056 |
-
|
| 1057 |
-
<style>
|
| 1058 |
-
.iframpanel {
|
| 1059 |
-
width: 100%;
|
| 1060 |
-
max-width: 500px;
|
| 1061 |
-
height: 560px;
|
| 1062 |
-
overflow: hidden;
|
| 1063 |
-
}
|
| 1064 |
-
|
| 1065 |
-
</style>
|
| 1066 |
-
<div class="paner-wrap">
|
| 1067 |
-
<div class="over-layer hide"></div>
|
| 1068 |
-
<div id="loginview" class="login-box paner hide iframpanel">
|
| 1069 |
-
<div class="pan-capt">
|
| 1070 |
-
<i class="pan-close" data-action="hide"></i>
|
| 1071 |
-
</div>
|
| 1072 |
-
|
| 1073 |
-
|
| 1074 |
-
<iframe src="https://member.bongdaplus.vn/Identity/Account/Login?ReturnUrl=%2FHome%2FLoginFromBongdaplus" frameborder="0" style="overflow:hidden;height:100%;width:100%"></iframe>
|
| 1075 |
-
|
| 1076 |
-
</div>
|
| 1077 |
-
<div id="commentloginview" class="login-box paner hide iframpanel">
|
| 1078 |
-
|
| 1079 |
-
|
| 1080 |
-
<iframe src="https://member.bongdaplus.vn/Identity/Account/Login?ReturnUrl=%2FHome%2FCommentLoginFromBongdaplus" frameborder="0" style="overflow:hidden;height:100%;width:100%"></iframe>
|
| 1081 |
-
|
| 1082 |
-
</div>
|
| 1083 |
-
</div>
|
| 1084 |
-
<input type="hidden" id="txtFullname" value="" />
|
| 1085 |
-
<input type="hidden" id="txtUserid" value="" />
|
| 1086 |
-
|
| 1087 |
-
|
| 1088 |
-
|
| 1089 |
-
|
| 1090 |
-
|
| 1091 |
-
|
| 1092 |
-
<center><div id="ADFT" class="banner adweb admob" banner-adpos="footer"></div></center>
|
| 1093 |
-
<footer>
|
| 1094 |
-
<div class="cont-wrap">
|
| 1095 |
-
<div class="row">
|
| 1096 |
-
<div class="col m12 w3">
|
| 1097 |
-
<div class="caption">Tạp chí điện tử Bóng đá</div>
|
| 1098 |
-
</div>
|
| 1099 |
-
<div class="col m12 w6">
|
| 1100 |
-
<p>Giấy phép số 48/GP-BTTTT cấp ngày 05/02/2020</p>
|
| 1101 |
-
<p>Tổng biên tập: <b>Vũ Khắc Sơn</b></p>
|
| 1102 |
-
<p>Phó tổng biên tập: <b>Nguyễn Hà Thanh</b></p>
|
| 1103 |
-
<div class="hide-w">
|
| 1104 |
-
<p>Địa chỉ: <b>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</b></p>
|
| 1105 |
-
<p>Văn phòng giao dịch: <b>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</b></p>
|
| 1106 |
-
<p>Điện thoại: <b>(84.24) 3554 1188 - (84.24) 3554 1199</b></p>
|
| 1107 |
-
<p>
|
| 1108 |
-
Email: <a href="mailto:toasoan@bongdaplus.vn"><b>toasoan@bongdaplus.vn</b></a> |
|
| 1109 |
-
<a href="mailto:vanphong@bongdaplus.vn"><b>vanphong@bongdaplus.vn</b></a>
|
| 1110 |
-
</p>
|
| 1111 |
-
<div class="spl"></div>
|
| 1112 |
-
<p class="caption">Liên hệ quảng cáo</p>
|
| 1113 |
-
<p>Hotline: <b>0903 203 412</b></p>
|
| 1114 |
-
<p>Email: <a href="mailto:tien.nguyen@giaminhmedia.vn"><b>tien.nguyen@giaminhmedia.vn</b></a></p>
|
| 1115 |
-
</div>
|
| 1116 |
-
</div>
|
| 1117 |
-
<div class="col m12 w3">
|
| 1118 |
-
<ul class="bar social-lnk rgt-w">
|
| 1119 |
-
<li class="hide-m">Theo dõi thông tin</li>
|
| 1120 |
-
<li><a class="ico vff-lnk" href="https://vff.org.vn/" target="_blank" rel="nofollow"></a></li>
|
| 1121 |
-
<li><a class="ico fb-lnk" href="https://www.facebook.com/BaoBongDa/"></a></li>
|
| 1122 |
-
<li><a class="ico yt-lnk" href="https://www.youtube.com/channel/UCvDYMBE6GipX2VGacqOU2_Q"></a></li>
|
| 1123 |
-
</ul>
|
| 1124 |
-
</div>
|
| 1125 |
-
</div>
|
| 1126 |
-
<div class="foot-menu">
|
| 1127 |
-
<ul class="bar hide-m">
|
| 1128 |
-
<li><a href="#" data-showpaner="#contactview">Thông tin tòa soạn</a></li>
|
| 1129 |
-
<li><a href="#" data-showpaner="#advertview">Liên hệ quảng cáo</a></li>
|
| 1130 |
-
</ul>
|
| 1131 |
-
<div class="copy-rgt">
|
| 1132 |
-
Bản quyền ©2011 Bongdaplus.vn. Chỉ được phát hành lại thông tin khi
|
| 1133 |
-
có sự đồng ý bằng văn bản của Tạp chí điện tử Bóng đá
|
| 1134 |
-
</div>
|
| 1135 |
-
<div class="clx hide-m"></div>
|
| 1136 |
-
</div>
|
| 1137 |
-
</div>
|
| 1138 |
-
</footer>
|
| 1139 |
-
|
| 1140 |
-
<div class="paner-wrap">
|
| 1141 |
-
<div class="over-layer hide"></div>
|
| 1142 |
-
<div id="contactview" class="contact-box paner hide">
|
| 1143 |
-
<div class="pan-capt">
|
| 1144 |
-
<div class="pan-title">Thông tin Toà soạn</div>
|
| 1145 |
-
<i class="pan-close" data-action="hide"></i>
|
| 1146 |
-
</div>
|
| 1147 |
-
<div class="pan-cont">
|
| 1148 |
-
<div class="xcap lead tx-prime tx-cen">Tạp chí Điện tử Bóng Đá</div>
|
| 1149 |
-
<div class="row">
|
| 1150 |
-
<div class="col w3 tx-base tx-rgt">Tổng biên tập:</div>
|
| 1151 |
-
<div class="col w9"><b>Vũ Khắc Sơn</b></div>
|
| 1152 |
-
<div class="clx"></div>
|
| 1153 |
-
<div class="col w3 tx-base tx-rgt">Phó Tổng biên tập:</div>
|
| 1154 |
-
<div class="col w9">
|
| 1155 |
-
<b>Nguyễn Hà Thanh</b>
|
| 1156 |
-
</div>
|
| 1157 |
-
</div>
|
| 1158 |
-
<div class="splt"></div>
|
| 1159 |
-
<div class="row">
|
| 1160 |
-
<div class="col w3 tx-base tx-rgt">Địa chỉ:</div>
|
| 1161 |
-
<div class="col w9"><b>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</b></div>
|
| 1162 |
-
<div class="col w3 tx-base tx-rgt">Văn phòng giao dịch:</div>
|
| 1163 |
-
<div class="col w9"><b>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</b></div>
|
| 1164 |
-
<div class="col w3 tx-base tx-rgt">Tel:</div>
|
| 1165 |
-
<div class="col w9"><b>(84.24) 3554 1188 - (84.24) 3554 1199</b></div>
|
| 1166 |
-
<div class="col w3 tx-base tx-rgt">Fax:</div>
|
| 1167 |
-
<div class="col w9"><b>(84.24) 3553 9898</b></div>
|
| 1168 |
-
<div class="col w3 tx-base tx-rgt">Email:</div>
|
| 1169 |
-
<div class="col w9">
|
| 1170 |
-
<a href="mailto:toasoan@bongdaplus.vn"><b>toasoan@bongdaplus.vn</b></a>
|
| 1171 |
-
<a href="mailto:vanphong@bongdaplus.vn"><b>vanphong@bongdaplus.vn</b></a>
|
| 1172 |
-
</div>
|
| 1173 |
-
</div>
|
| 1174 |
-
</div>
|
| 1175 |
-
</div>
|
| 1176 |
-
|
| 1177 |
-
<div id="advertview" class="contact-box paner hide">
|
| 1178 |
-
<div class="pan-capt">
|
| 1179 |
-
<div class="pan-title">Thông tin Liên hệ</div>
|
| 1180 |
-
<i class="pan-close" data-action="hide"></i>
|
| 1181 |
-
</div>
|
| 1182 |
-
<div class="pan-cont">
|
| 1183 |
-
<div class="xcap lead tx-prime tx-cen">Tạp chí Điện tử Bóng Đá</div>
|
| 1184 |
-
<div class="row">
|
| 1185 |
-
<div class="col w4 tx-base tx-rgt">Hotline:</div>
|
| 1186 |
-
<div class="col w8"><b>0903 203 412</b></div>
|
| 1187 |
-
<div class="col w4 tx-base tx-rgt">Email:</div>
|
| 1188 |
-
<div class="col w8"><a href="mailto:tien.nguyen@giaminhmedia.vn"><b>tien.nguyen@giaminhmedia.vn</b></a></div>
|
| 1189 |
-
</div>
|
| 1190 |
-
<p class="tx-base">Địa chỉ liên hệ:</p>
|
| 1191 |
-
<b>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</b>
|
| 1192 |
-
<p class="tx-base">Văn phòng giao dịch:</p>
|
| 1193 |
-
<b>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</b>
|
| 1194 |
-
</div>
|
| 1195 |
-
</div>
|
| 1196 |
-
|
| 1197 |
-
|
| 1198 |
-
<div id="loginview" class="login-box paner hide">
|
| 1199 |
-
<div class="pan-capt">
|
| 1200 |
-
<div class="pan-title">Đăng nhập</div>
|
| 1201 |
-
<i class="pan-close" data-action="hide"></i>
|
| 1202 |
-
</div>
|
| 1203 |
-
<div class="pan-cont">
|
| 1204 |
-
<form class="soc-auth" action="#">
|
| 1205 |
-
<button><i class="ico gg-color"></i>Google</button>
|
| 1206 |
-
<button><i class="ico fb-color"></i>Facebook</button>
|
| 1207 |
-
</form>
|
| 1208 |
-
<div class="clx"></div>
|
| 1209 |
-
<div class="splt"><span>hoặc</span></div>
|
| 1210 |
-
<form class="loginer" action="//member.bongdaplus.vn/Identity/Account/Login?ReturnUrl=/" method="post">
|
| 1211 |
-
<p>Email:</p>
|
| 1212 |
-
<label class="txt-fld" for="email">
|
| 1213 |
-
<input id="email" name="Input.Email" type="email" />
|
| 1214 |
-
<span>Nhập địa chỉ email của bạn</span>
|
| 1215 |
-
</label>
|
| 1216 |
-
<label class="err-fld hide" for="email">Địa chỉ email không được để trống</label>
|
| 1217 |
-
<p>Mật khẩu:</p>
|
| 1218 |
-
<label class="txt-fld" for="passw">
|
| 1219 |
-
<input id="passw" name="Input.Password" type="password" data-required="Mật khẩu không được để trống" />
|
| 1220 |
-
<span>Nhập mật khẩu của bạn</span>
|
| 1221 |
-
</label>
|
| 1222 |
-
<label class="err-fld hide" for="passw">Mật khẩu không được để trống</label>
|
| 1223 |
-
<p class="ft-sm tx-rgt"><a class="fg-psw" href="#" data-action="hide" data-showpaner="#forgetview">Quên mật khẩu?</a></p>
|
| 1224 |
-
<label class="rdo-fld" for="persist">
|
| 1225 |
-
<input id="persist" name="Input.RememberMe" type="checkbox" />
|
| 1226 |
-
Ghi nhớ thông tin đăng nhập
|
| 1227 |
-
</label>
|
| 1228 |
-
<br>
|
| 1229 |
-
<div class="fld-box">
|
| 1230 |
-
<button class="btn major" type="submit">Đăng nhập</button>
|
| 1231 |
-
</div>
|
| 1232 |
-
<p class="ft-sm tx-cen">
|
| 1233 |
-
Bạn chưa có tài khoản?
|
| 1234 |
-
<a class="act-now" href="#" data-action="hide" data-showpaner="#registview">Đăng ký ngay</a>
|
| 1235 |
-
</p>
|
| 1236 |
-
</form>
|
| 1237 |
-
</div>
|
| 1238 |
-
</div>
|
| 1239 |
-
</div>
|
| 1240 |
-
|
| 1241 |
-
<script src="/lib/jquery/dist/jquery.min.js"></script>
|
| 1242 |
-
<script src="/js/js_cookie.min.js?v=0H3Nuz3aug3afVbUlsu12Puxva3CP4EhJtPExqs54Vg"></script>
|
| 1243 |
-
<script src="/js/bongdaplus.js?v=gIIX1pYQ8DB66N40YPyoHGaBgBFoHkPMMCARX4L6NdA"></script>
|
| 1244 |
-
<script src="/js/premium.js?v=mbXB8QoXMl1zoEFSeE6z-mwQTb0ot7C8zj_F8JqsvNc"></script>
|
| 1245 |
-
|
| 1246 |
-
<script>var _ad_info_code = 'mda/video';</script>
|
| 1247 |
-
<script src="/js/advloader.js?v=dvvR0ygTjyYzNcQmQ_dI5bwYeX2Ww0nBjBBmCt9DWjQ"></script>
|
| 1248 |
-
|
| 1249 |
-
</body>
|
| 1250 |
-
</html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
embed.html
DELETED
|
@@ -1,56 +0,0 @@
|
|
| 1 |
-
<!DOCTYPE html>
|
| 2 |
-
<html>
|
| 3 |
-
<head>
|
| 4 |
-
<meta name="viewport" content="width=device-width" />
|
| 5 |
-
<title>EmbedVideo - Bongdaplus.vn </title>
|
| 6 |
-
|
| 7 |
-
<link href="/css/video-js.css" rel="stylesheet">
|
| 8 |
-
<link href="/css/style.css" rel="stylesheet">
|
| 9 |
-
<link href="/css/player.css?new=43666246" rel="stylesheet">
|
| 10 |
-
<script src="https://imasdk.googleapis.com/js/sdkloader/ima3.js"></script>
|
| 11 |
-
<script src="/js/video.min.js"></script>
|
| 12 |
-
<!-- Google tag (gtag.js) -->
|
| 13 |
-
<script async src="https://www.googletagmanager.com/gtag/js?id=G-EDHD2H8E9T"></script>
|
| 14 |
-
<script>window.dataLayer = window.dataLayer || []; function gtag() { dataLayer.push(arguments); } gtag('js', new Date()); gtag('config', 'G-EDHD2H8E9T');</script>
|
| 15 |
-
</head>
|
| 16 |
-
<body class="vjs-fullscreen">
|
| 17 |
-
|
| 18 |
-
<div class="">
|
| 19 |
-
<video id="videoPlayer" class="video-js" preload="auto" width="100%" height="100%" poster="https://cdn.bongdaplus.vn/assets/Media/2026/08/18/13/ThumbHullvsMU-up.jpg" controls disablePictureInPicture>
|
| 20 |
-
<source src="https://cdn.bongdaplus.vn/assets//Media/2026/08/18/13/thumbhullvsmu-up.jpg" type="video/mp4">
|
| 21 |
-
</video>
|
| 22 |
-
<script>
|
| 23 |
-
var adpath = 'mob/'; var cat = 127, evt = 0;
|
| 24 |
-
if (window.matchMedia('(min-width: 1023px)').matches || (window.parent.Detect && !window.parent.Detect.isMob())) {
|
| 25 |
-
adpath = 'web/';
|
| 26 |
-
}
|
| 27 |
-
adpath += 'mda';
|
| 28 |
-
document.write('<script src="/adscripts/' + adpath + '/vast.js?new=' + (new Date()).getTime() + '"></' + 'script>');
|
| 29 |
-
</script>
|
| 30 |
-
|
| 31 |
-
<script>
|
| 32 |
-
var vdo = videojs('videoPlayer', { autoplay: true });
|
| 33 |
-
vdo.relatedCarousel([{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/18/80/Anh-chu-480-x-270.jpg', vdoref: '/video/du-doan-ty-so-hull-mu-than-rua-ali-33-chon-doi-nao-982702608.html', title: 'Dự đoán tỷ số Hull vs MU: Thần Rùa Ali 33 chọn đội nào?', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/18/13/ThumbHullvsMU-up.jpg', vdoref: '/video/hull-city-vs-mu-quy-do-vao-hang-bat-ho-982672608.html', title: 'Hull City vs MU: Quỷ Đỏ vào hang bắt hổ', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/19/80/Brentford-vs-Tottenham-480-x-270-moi.jpg', vdoref: '/video/du-doan-ty-so-brentford-vs-tottenham-premier-league-2026-27-than-rua-ali-33-chon-doi-nao-982862608.html', title: 'Dự đoán tỷ số Brentford vs Tottenham: Thần Rùa Ali 33 chọn đội nào?', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/19/13/Brenfort-vs-Tottenham.jpg', vdoref: '/video/brentford-vs-tottenham-de-zerbi-vuot-ai-gtech-982822608.html', title: 'Brentford vs Tottenham: De Zerbi vượt ải Gtech', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/17/0/thumblai.jpg', vdoref: '/video/du-doan-ty-so-arsenal-vs-coventry-than-rua-ali-33-chon-doi-nao-982612608.html', title: 'Dự đoán tỷ số Arsenal vs Coventry: Thần Rùa Ali 33 chọn đội nào?', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/18/13/ThumbArsenalvsCoventry-up.jpg', vdoref: '/video/arsenal-vs-coventry-nha-vua-mo-man-bang-mot-chien-thang-982662608.html', title: 'Arsenal vs Coventry: Nhà vua mở màn bằng một chiến thắng?', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/truc-tuyen-truoc-tran-Arsenal-vs-Coventry.jpg', vdoref: '/video/truc-tuyen-truoc-tran-arsenal-vs-coventry-hull-vs-mu-983222608.html', title: 'Trực tuyến trước trận Arsenal vs Coventry, Hull vs MU', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/80/HLV-Thai-Lan-chot-doi-hinh.jpg', vdoref: '/video/dt-thai-lan-tap-cach-san-rajamangala-40km-hlv-anthony-hudson-chot-luon-doi-hinh-dau-viet-nam-983202608.html', title: ' ĐT Thái Lan tập cách sân Rajamangala 40km, HLV Anthony Hudson chốt luôn đội hình đấu Việt Nam', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/80/Viet-Nam-tap-2-1-8.jpg', vdoref: '/video/tro-lai-san-tung-vo-dich-aff-cup-2024-dt-viet-nam-quyet-lam-nen-lich-su-983212608.html', title: ' Trở lại sân từng vô địch AFF Cup 2024, ĐT Việt Nam quyết làm nên lịch sử', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/thumbvong1-480.jpg', vdoref: '/video/vong-1-premier-league-2026-27-lieu-co-cu-soc-983112608.html', title: 'Nhận định Premier League vòng 1: Liệu có cú sốc?', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/26/Ronaldinho480.jpg', vdoref: '/video/ronaldinho-gay-soc-46-tuoi-van-tro-lai-san-co-khoac-ao-clb-serie-c-983192608.html', title: 'Ronaldinho gây sốc: 46 tuổi vẫn trở lại sân cỏ, khoác áo CLB Serie C', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/26/Konsa480.jpg', vdoref: '/video/arsenal-no-bom-tan-51-trieu-bang-de-va-hang-thu-983122608.html', title: 'Arsenal nổ bom tấn 51 triệu bảng để vá hàng thủ', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/anhliveVN.jpg', vdoref: '/video/truc-tuyen-hop-bao-viet-nam-hlv-kim-sang-sik-noi-gi-khi-pho-chu-tich-thai-lan-mia-mai-dan-nhap-tich-983182608.html', title: 'Trực tuyến họp báo Việt Nam: HLV Kim Sang Sik nói gì khi Phó Chủ tịch Thái Lan mỉa mai dàn nhập tịch', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/anhlivesua-480.jpg', vdoref: '/video/truc-tuyen-thai-lan-hop-bao-hlv-thach-dau-dt-viet-nam-983162608.html', title: ' Trực tuyến Thái Lan họp báo: HLV Anthony Hudson thách đấu ĐT Việt Nam', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/Thumb-tin-nong-21-8-480-up.jpg', vdoref: '/video/ban-tin-21-8-dt-viet-nam-chiu-thong-ke-cuc-xau-truoc-chung-ket-thai-lan-choi-chieu-nhu-indo-983152608.html', title: 'Bản tin 218: ĐT Việt Nam chịu thống kê cực xấu trước chung kết, Thái Lan chơi chiêu như Indo', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/26/cdv.jpg', vdoref: '/video/thai-lan-khoa-cdv-viet-nam-chi-3-000-ve-cho-tran-chung-ket-tai-rajamangala-983142608.html', title: 'Thái Lan khóa CĐV Việt Nam: Chỉ 3.000 vé cho trận chung kết tại Rajamangala', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/livethumbYT-480.jpg', vdoref: '/video/truoc-tran-thai-lan-vs-viet-nam-svd-rajamagala-vang-tanh-hon-chua-ba-danh-983132608.html', title: 'Trước trận Thái Lan vs Việt Nam: SVĐ Rajamagala vắng tanh hơn chùa Bà Đanh', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/20/80/ThumbRuaThaivsVietnam-480.jpg', vdoref: '/video/du-doan-ty-so-thai-lan-vs-viet-nam-than-rua-ali-33-chon-doi-nao-983092608.html', title: 'Dự đoán tỷ số Thái Lan vs Việt Nam: Thần Rùa Ali 33 chọn đội nào?', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/20/26/xuan-son.jpg', vdoref: '/video/truyen-thong-anh-dac-biet-chu-y-den-ngoi-no-cua-dt-viet-nam-983072608.html', title: 'Truyền thông Anh đặc biệt chú ý đến ngòi nổ của ĐT Việt Nam', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/20/26/Kim-Sang-Sik.jpg', vdoref: '/video/dt-viet-nam-co-mat-tai-thai-lan-ao-trang-van-chua-bi-thay-kim-vut-het-983082608.html', title: 'ĐT Việt Nam có mặt tại Thái Lan, áo trắng vẫn chưa bị thầy Kim bỏ hết', target: '_parent' }]);
|
| 34 |
-
|
| 35 |
-
var vst = window['vast'] || '';
|
| 36 |
-
if (vst) {
|
| 37 |
-
vst = vst.replace('[yo_page_url]', window.parent.location.href);
|
| 38 |
-
vdo.adsVastClient(vst);
|
| 39 |
-
}
|
| 40 |
-
vdo.ready(function () { vdo.play(); }); var counter = 0;
|
| 41 |
-
if (window.parent.location.href.indexOf('.vn/video/') < 0) {
|
| 42 |
-
window.addEventListener("click", function () {
|
| 43 |
-
if (!counter) {
|
| 44 |
-
var img = document.createElement("img");
|
| 45 |
-
img.src = "/imgthumbnail/98267/1/1";
|
| 46 |
-
img.width = 0;
|
| 47 |
-
img.height = 0;
|
| 48 |
-
document.body.appendChild(img);
|
| 49 |
-
counter++;
|
| 50 |
-
}
|
| 51 |
-
});
|
| 52 |
-
}
|
| 53 |
-
</script>
|
| 54 |
-
</div>
|
| 55 |
-
</body>
|
| 56 |
-
</html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
headers.txt
DELETED
|
@@ -1,9 +0,0 @@
|
|
| 1 |
-
HTTP/2 200
|
| 2 |
-
strict-transport-security: max-age=31536000; includeSubDomains; preload
|
| 3 |
-
set-cookie: BongdaplusView_98267_1=1; expires=Sat, 22 Aug 2026 02:21:27 GMT; path=/
|
| 4 |
-
x-frame-options: SAMEORIGIN
|
| 5 |
-
x-xss-protection: 1; mode=block
|
| 6 |
-
x-content-type-options: nosniff
|
| 7 |
-
x-powered-by: ASP.NET
|
| 8 |
-
date: Sat, 22 Aug 2026 01:51:27 GMT
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
index_v2.html
DELETED
|
@@ -1,98 +0,0 @@
|
|
| 1 |
-
<!DOCTYPE html>
|
| 2 |
-
<html lang="vi">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="utf-8">
|
| 5 |
-
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
|
| 6 |
-
<title>VNEWS - Tin Tức Việt Nam</title>
|
| 7 |
-
<meta name="description" content="Tin tức tổng hợp, bóng đá trực tiếp, video highlight, AI tóm tắt.">
|
| 8 |
-
<meta property="og:title" content="VNEWS - Tin Tức Việt Nam">
|
| 9 |
-
<meta property="og:image" content="https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg">
|
| 10 |
-
<link rel="canonical" href="https://bep40-vnews.hf.space">
|
| 11 |
-
<link rel="stylesheet" href="/static/wc2026.css"><link rel="stylesheet" href="/static/fm_fix.css">
|
| 12 |
-
<script src="https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js"></script>
|
| 13 |
-
<style>
|
| 14 |
-
*{box-sizing:border-box;margin:0;padding:0}body{background:#111;color:#eee;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;overflow-x:hidden}.header{background:linear-gradient(135deg,#0d1117,#1a3a2a 50%,#8b7500);padding:12px;text-align:center}.header h1{font-size:18px;color:#fff}.header p{font-size:10px;color:#aaa}.cats{display:flex;overflow-x:auto;background:#1a1a1a;border-bottom:1px solid #333;padding:0 4px;position:sticky;top:0;z-index:50;scrollbar-width:none}.cats::-webkit-scrollbar{display:none}.cat{padding:9px 11px;color:#888;font-size:11px;white-space:nowrap;border-bottom:2px solid transparent;cursor:pointer;flex-shrink:0}.cat.active{color:#5cb87a;border-bottom-color:#5cb87a;font-weight:700}.view{display:none}.view.active{display:block}.loading{text-align:center;padding:30px;color:#777;font-size:12px}.slider-wrap{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.slider-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.slider-label{color:#f0c040;font-size:13px;font-weight:800}.slider-note{font-size:10px;color:#777}.slider-track{display:flex;overflow-x:auto;gap:8px;padding:4px 10px 10px;scrollbar-width:none}.slider-track::-webkit-scrollbar{display:none}.slider-item{flex:0 0 160px;cursor:pointer}.slider-thumb{position:relative;width:100%;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#333}.slider-thumb img,.slider-thumb video{width:100%;height:100%;object-fit:cover}.slider-title{font-size:10px;color:#ccc;margin-top:3px;line-height:1.2;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.card-play{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:30px;height:30px;border-radius:50%;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;color:#fff;font-size:12px}.grid{display:grid;grid-template-columns:repeat(2,1fr);gap:6px;padding:6px 4px}@media(min-width:650px){.grid{grid-template-columns:repeat(3,1fr)}}.card{background:#1a1a1a;border:1px solid #222;border-radius:8px;overflow:hidden;cursor:pointer}.card-img{position:relative;aspect-ratio:16/9;background:#333}.card-img img{width:100%;height:100%;object-fit:cover}.card-body{padding:6px 8px}.card-title{font-size:11px;line-height:1.35;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.badge{font-size:8px;padding:1px 5px;border-radius:3px;font-weight:700;display:inline-block;margin-bottom:2px;color:#fff}.badge-vne{background:#c0392b}.badge-genk{background:#6a1b9a}.badge-ai{background:#2d8659}.badge-wc{background:#0b6bcb}.section-title{font-size:13px;font-weight:800;color:#5cb87a;margin:8px 0 4px;padding-left:8px;border-left:3px solid #5cb87a}.back-btn{background:#111;color:#fff;border:none;padding:10px;font-size:12px;width:100%;position:sticky;top:0;z-index:60;cursor:pointer}.article-view{padding:12px 8px 40px;max-width:760px;margin:0 auto}.article-title{font-size:18px;font-weight:800;line-height:1.3;margin-bottom:8px}.article-summary{background:#1a2a1f;border-left:3px solid #2d8659;padding:10px;margin-bottom:14px;color:#ccc;font-size:13px}.article-p{font-size:14px;line-height:1.7;color:#ccc;margin-bottom:10px}.article-img{width:100%;border-radius:6px;margin:10px 0}.article-h2{font-size:16px;margin:16px 0 8px;color:#eee}.article-actions{display:flex;gap:8px;flex-wrap:wrap;border-top:1px solid #333;margin-top:16px;padding-top:10px}.article-actions button{background:#1a1a1a;border:1px solid #333;color:#ccc;padding:7px 12px;border-radius:14px;font-size:11px;cursor:pointer}.article-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}.article-ai-ask{margin-top:12px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.article-ai-ask textarea{width:100%;min-height:60px;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px;font-size:12px}.article-ai-ask button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin-top:6px;font-size:11px;cursor:pointer}.article-ai-answer{white-space:pre-wrap;color:#ccc;font-size:13px;line-height:1.55;margin-top:8px}.tiktok-container{width:100%;height:80vh;max-height:680px;min-height:400px;background:#000}.tiktok-feed{height:100%;overflow-y:scroll;scroll-snap-type:y mandatory;scrollbar-width:none}.tiktok-feed::-webkit-scrollbar{display:none}.tiktok-slide{height:80vh;max-height:680px;min-height:400px;scroll-snap-align:start;position:relative;background:#000;display:flex;align-items:center;justify-content:center}.tiktok-slide video{width:100%;height:100%;object-fit:cover;border:none}.tiktok-slide iframe{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:177.78vh;min-width:100%;height:100%;border:none;pointer-events:none;max-width:none}.tiktok-slide.ratio-wide video{width:100%;height:100%;object-fit:contain}.tiktok-slide.ratio-wide iframe{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:calc(100vw - 1px);height:56.25vw;max-width:100vw;max-height:100%;border:none;pointer-events:none}.tiktok-bottom{position:absolute;bottom:0;left:0;right:60px;padding:12px 10px 16px;background:linear-gradient(transparent,rgba(0,0,0,.85));z-index:3}.tiktok-title{font-size:12px;color:#fff}.tiktok-counter{position:absolute;top:8px;left:8px;background:rgba(0,0,0,.5);font-size:9px;padding:2px 7px;border-radius:8px;color:#fff;z-index:4}.tiktok-right{position:absolute;right:8px;bottom:100px;display:flex;flex-direction:column;align-items:center;gap:14px;z-index:5}.tiktok-right-btn{display:flex;flex-direction:column;align-items:center;gap:2px;background:none;border:0;color:#fff;cursor:pointer;font-size:10px}.tiktok-right-btn .icon{width:42px;height:42px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:20px}.tiktok-right-btn .count{font-size:10px;color:#ddd}.inline-comments{position:absolute;bottom:0;left:0;right:0;max-height:60%;min-height:140px;background:rgba(18,18,18,.95);border-radius:14px 14px 0 0;z-index:10;overflow:clip;display:flex;flex-direction:column}.inline-cmt-header{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-bottom:1px solid #333;color:#5cb87a;font-size:12px;font-weight:700;flex-shrink:0}.inline-cmt-header button{background:none;border:0;color:#fff;font-size:16px;cursor:pointer}.inline-cmt-list{flex:1 1 auto;overflow-y:auto;padding:6px 10px;max-height:140px;min-height:40px}.inline-cmt-item{background:#222;border-radius:8px;padding:6px 8px;margin:4px 0;color:#ccc;font-size:11px;line-height:1.3}.inline-cmt-time{font-size:9px;color:#777;margin-right:6px}.inline-cmt-input{display:flex;gap:6px;padding:8px 10px;border-top:1px solid #333;flex-shrink:0;position:sticky;bottom:0;background:rgba(18,18,18,.98)}.inline-cmt-input input{flex:1;background:#222;border:1px solid #444;color:#eee;border-radius:16px;padding:7px 12px;font-size:11px;min-height:32px}.inline-cmt-input button{background:#2d8659;border:0;color:#fff;border-radius:16px;padding:7px 12px;font-size:11px;cursor:pointer;min-height:32px}.wc2026-section{margin:6px 4px;background:linear-gradient(135deg,#0d1117,#1a1a3a);border:1px solid #1a3a5a;border-radius:10px;overflow:hidden}.wc-header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:linear-gradient(90deg,#0b2e4a,#1a3a5a)}.wc-header h2{font-size:15px;color:#fff;margin:0}.wc-live-badge{font-size:10px;color:#e74c3c;font-weight:700;animation:wc-pulse 1.5s infinite}@keyframes wc-pulse{0%,100%{opacity:1}50%{opacity:.4}}.wc-tabs{display:flex;gap:4px;padding:8px 10px;overflow-x:auto;scrollbar-width:none}.wc-tabs::-webkit-scrollbar{display:none}.wc-tab{padding:5px 10px;background:#1a2a3a;border:1px solid #2a3a4a;border-radius:12px;color:#8ab4d8;font-size:10px;cursor:pointer;white-space:nowrap;flex-shrink:0}.wc-tab.active{background:#0b6bcb;border-color:#0b6bcb;color:#fff;font-weight:700}.wc-content{padding:8px 10px;max-height:500px;overflow-y:auto}.wc-news-grid{display:flex;flex-direction:column;gap:8px}.wc-news-item{display:flex;gap:8px;padding:8px;background:#1a2030;border-radius:8px;cursor:pointer}.wc-news-item:active{opacity:.8}.wc-news-img{flex:0 0 70px;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#222}.wc-news-img img{width:100%;height:100%;object-fit:cover}.wc-news-text{flex:1;min-width:0}.wc-news-title{font-size:11px;font-weight:700;color:#eee;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wc-news-via{font-size:9px;color:#6a9fca;margin-top:2px}.ls-section{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.ls-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.ls-header h3{color:#f0c040;font-size:13px;font-weight:800}.ls-tabs{display:flex;gap:4px;padding:0 10px 8px;overflow-x:auto;scrollbar-width:none}.ls-tabs::-webkit-scrollbar{display:none}.ls-tab{padding:4px 10px;background:#222;border:1px solid #333;border-radius:12px;color:#999;font-size:10px;white-space:nowrap;cursor:pointer;flex-shrink:0}.ls-tab.active{background:#2d8659;border-color:#2d8659;color:#fff;font-weight:700}.ls-content{max-height:420px;overflow-y:auto;padding:0 6px 8px;font-size:12px;color:#ddd}.ls-content ul{list-style:none;padding:0;margin:0}.ls-content .title-content{display:flex;gap:6px;align-items:center;background:#222;border-radius:4px;margin:4px 0;padding:5px 8px}.ls-content .title-content img{width:18px;height:18px}.ls-content .title-content strong{font-size:11px;color:#ccc}.ls-content .match-detail{padding:6px;border-bottom:1px solid #262626;cursor:pointer}.ls-content .match-detail:hover{background:#1a2a1f}.ls-content .match{display:flex;flex-wrap:wrap;align-items:center;gap:4px}.ls-content .datetime{width:100%;font-size:9px;color:#888}.ls-content .teams{display:flex;width:100%;align-items:center;gap:4px}.ls-content .team{flex:1;display:flex;align-items:center;gap:4px;min-width:0;text-decoration:none}.ls-content .team .name{font-size:11px;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ls-content .team .logo img{width:18px;height:18px}.ls-content .home-team{justify-content:flex-end;text-align:right}.ls-content .status{flex:0 0 54px;text-align:center}.ls-content .status a{color:#fff;text-decoration:none;font-weight:800;font-size:12px}.ls-content .status .label{font-size:8px;color:#888;display:block}.ls-content .status .label.live{color:#e74c3c}.ls-content .info,.ls-content .btns{display:none}.ls-content table{width:100%;border-collapse:collapse;font-size:11px;color:#ccc}.ls-content table th{background:#222;color:#999;padding:5px 4px;font-size:10px;border-bottom:1px solid #333}.ls-content table td{padding:4px 3px;border-bottom:1px solid #1a1a1a}.ls-content table .team-name{display:flex;align-items:center;gap:4px}.ls-content table .team-name img{width:16px;height:16px}.ls-content table .pts{font-weight:800;color:#f0c040}.match-overlay{position:fixed;inset:0;background:#111;z-index:9999;display:none;flex-direction:column;overflow:auto}.match-overlay.active{display:flex}.mo-header{padding:10px;background:#1a1a1a;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:1}.mo-header h3{font-size:13px;color:#eee}.mo-close{background:none;border:0;color:#fff;font-size:22px;cursor:pointer}.mo-tabs{display:flex;gap:4px;padding:8px 10px;background:#1a1a1a;overflow-x:auto}.mo-tab{padding:5px 12px;background:#222;border:1px solid #333;border-radius:10px;color:#999;font-size:10px;cursor:pointer;white-space:nowrap}.mo-tab.active{background:#2d8659;color:#fff}.mo-body{padding:8px;overflow-x:auto;font-size:12px;color:#ddd}.mo-body ul{list-style:none;padding:0;margin:0}.mo-body li{padding:5px 0;border-bottom:1px solid #222}.featured-match{margin:6px 4px;background:linear-gradient(135deg,#1a2a1f,#0d1117);border:1px solid #2d8659;border-radius:10px;padding:12px;cursor:pointer}.fm-league{text-align:center;color:#5cb87a;font-size:9px;font-weight:700;text-transform:uppercase}.fm-teams{display:flex;align-items:center;justify-content:center;gap:10px;margin-top:6px}.fm-team{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px}.fm-team img{width:32px;height:32px;object-fit:contain}.fm-team span{font-size:10px;color:#ccc;text-align:center}.fm-score{font-size:22px;font-weight:900;min-width:60px;text-align:center;color:#fff}.fm-status{text-align:center;margin-top:6px;font-size:9px;color:#e74c3c;font-weight:700}.fm-status.upcoming{color:#f0c040}.ai-compose{margin:6px 4px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.ai-compose-title{font-size:13px;font-weight:800;color:#5cb87a;margin-bottom:8px}.ai-compose-row{display:flex;gap:6px;margin-top:6px}.ai-compose input{flex:1;background:#222;border:1px solid #333;color:#eee;border-radius:18px;padding:9px 12px;font-size:12px;min-width:0}.ai-compose button{background:#2d8659;border:0;color:#fff;border-radius:18px;padding:9px 12px;font-size:11px;font-weight:700;cursor:pointer;white-space:nowrap}.ai-compose button.secondary{background:#333}.ai-compose button.hot-short-btn{background:linear-gradient(135deg,#8b1a5a,#c0392b);border:1px solid #e07050;color:#fff;box-shadow:0 0 10px rgba(192,57,43,.4);animation:hot-pulse 2s infinite}@keyframes hot-pulse{0%,100%{box-shadow:0 0 6px rgba(192,57,43,.35)}50%{box-shadow:0 0 16px rgba(192,57,43,.75)}}.hot-topic-row{display:flex;gap:6px;overflow-x:auto;padding:4px 0;scrollbar-width:none}.hot-topic-row::-webkit-scrollbar{display:none}.hot-chip{flex:0 0 auto;background:#222;border:1px solid #333;color:#ddd;border-radius:16px;padding:5px 10px;font-size:11px;cursor:pointer;white-space:nowrap}.hot-chip:active{transform:scale(.96)}.hashtag-sources{margin:8px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.hashtag-sources h3{font-size:13px;color:#5cb87a;margin-bottom:8px}.hashtag-src-item{display:flex;gap:8px;padding:8px;background:#202020;border-radius:8px;margin:6px 0;cursor:pointer}.hashtag-src-item:active{opacity:.8}.hashtag-src-img{flex:0 0 80px;aspect-ratio:16/9;background:#333;border-radius:6px;overflow:hidden}.hashtag-src-img img{width:100%;height:100%;object-fit:cover}.hashtag-src-text{flex:1;min-width:0}.hashtag-src-title{font-size:12px;font-weight:700;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.hashtag-src-via{font-size:10px;color:#888;margin-top:2px}.hashtag-rewrite-btn{width:100%;margin-top:8px;background:#2d8659;border:0;color:#fff;padding:9px;border-radius:10px;font-size:12px;font-weight:700;cursor:pointer}.hashtag-load-more{width:100%;margin-top:8px;background:#222;border:1px solid #333;color:#ccc;padding:9px;border-radius:10px;font-size:12px;cursor:pointer}.hashtag-loading{display:flex;align-items:center;gap:8px;padding:12px;color:#888;font-size:12px}.hashtag-spinner{width:16px;height:16px;border:2px solid #333;border-top-color:#5cb87a;border-radius:50%;animation:ht-spin .8s linear infinite}@keyframes ht-spin{to{transform:rotate(360deg)}}.wall-item{flex:0 0 260px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:8px}.wall-item-new{animation:wall-flash 1.8s ease-out}@keyframes wall-flash{0%{border-color:#f0c040;box-shadow:0 0 18px rgba(240,192,64,.35)}30%{border-color:#f0c040;box-shadow:0 0 12px rgba(240,192,64,.2)}100%{border-color:#2b2b2b;box-shadow:none}}.wall-thumb{width:100%;aspect-ratio:16/9;border-radius:8px;background:#222;overflow:hidden;margin-bottom:6px;position:relative}.wall-thumb img{width:100%;height:100%;object-fit:cover}.wall-video-badge{position:absolute;top:4px;right:4px;background:rgba(45,134,89,.9);color:#fff;font-size:10px;padding:2px 6px;border-radius:6px;font-weight:700}.wall-title{font-size:12px;color:#5cb87a;font-weight:800;line-height:1.3;margin-bottom:4px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wall-text{font-size:11px;color:#bbb;line-height:1.4;white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden}.wall-actions{display:flex;gap:6px;margin-top:8px}.wall-actions button{flex:1;border:1px solid #333;background:#222;color:#ddd;border-radius:14px;padding:6px 8px;font-size:10px;cursor:pointer}.wall-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}.wall-actions button.wall-btn-design{background:#1a3a5a;border-color:#3a5a7a;color:#fff;font-weight:700}#progress-toast{position:fixed;bottom:70px;left:50%;transform:translateX(-50%);background:#2d8659;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;z-index:99998;box-shadow:0 4px 12px rgba(0,0,0,.4);display:none;white-space:nowrap}.storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 4px}
|
| 15 |
-
.hl-load-more:hover{background:#2a2a2a !important}
|
| 16 |
-
.slider-wrap{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}
|
| 17 |
-
.slider-track{display:flex;overflow-x:auto;gap:8px;padding:4px 10px 10px;scrollbar-width:none}
|
| 18 |
-
.slider-track::-webkit-scrollbar{display:none}
|
| 19 |
-
.slider-item{flex:0 0 160px;cursor:pointer}
|
| 20 |
-
.slider-thumb{position:relative;width:100%;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#333}
|
| 21 |
-
.slider-thumb img,.slider-thumb video{width:100%;height:100%;object-fit:cover}
|
| 22 |
-
.card-play{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:30px;height:30px;border-radius:50%;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;color:#fff;font-size:12px}
|
| 23 |
-
.slider-title{font-size:10px;color:#ccc;margin-top:3px;line-height:1.2;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
| 24 |
-
.ls-section-header{padding:8px 10px;font-size:11px;font-weight:700;color:#5cb87a;border-bottom:1px solid #2a2a2a}
|
| 25 |
-
.ls-section-header.upcoming{color:#f0c040}
|
| 26 |
-
.ls-section-header.finished{color:#e74c3c}
|
| 27 |
-
.ls-section-header.yesterday{color:#8ab4f0}
|
| 28 |
-
.ls-section-header.older{color:#888}
|
| 29 |
-
.hl-load-more{margin:4px 8px 8px;background:#222;border:1px solid #333;color:#ccc;padding:6px 12px;border-radius:8px;font-size:11px;cursor:pointer;width:calc(100% - 16px);display:block}
|
| 30 |
-
.hl-load-more:hover{background:#2a2a2a}
|
| 31 |
-
</style>
|
| 32 |
-
</head>
|
| 33 |
-
<body>
|
| 34 |
-
<div class="header"><h1>📰 VNEWS</h1><p>Tin tức · Bóng đá LIVE · Highlight · AI · World Cup 2026</p></div>
|
| 35 |
-
<div class="cats" id="cat-bar"></div>
|
| 36 |
-
<div id="home-hl-section"></div>
|
| 37 |
-
<div id="view-home" class="view active"><div class="loading">Đang tải...</div></div>
|
| 38 |
-
<div id="view-cat" class="view"></div>
|
| 39 |
-
<div id="view-video" class="view"></div>
|
| 40 |
-
<div id="view-tiktok" class="view"></div>
|
| 41 |
-
<div id="view-article" class="view"></div>
|
| 42 |
-
<div class="match-overlay" id="match-overlay">
|
| 43 |
-
<div class="mo-header"><h3 id="mo-title">Chi tiết trận đấu</h3><button class="mo-close" onclick="closeMatch()">✕</button></div>
|
| 44 |
-
<div class="mo-tabs"><span class="mo-tab active" onclick="loadMatchTab('detail')">📋 Chi tiết</span><span class="mo-tab" onclick="loadMatchTab('comm')">Diễn biến</span><span class="mo-tab" onclick="loadMatchTab('stats')">Thống kê</span></div>
|
| 45 |
-
<div class="mo-body" id="mo-body"><div class="loading">Đang tải...</div></div>
|
| 46 |
-
</div>
|
| 47 |
-
<div id="progress-toast"></div>
|
| 48 |
-
<script>
|
| 49 |
-
var _cats=[],_hlLeagueData={},_currentArticle=null;window._currentEventId='';
|
| 50 |
-
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]))}
|
| 51 |
-
function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')}
|
| 52 |
-
function switchCat(id){document.querySelectorAll('.cat').forEach(c=>c.classList.remove('active'));document.querySelector('[data-cat="'+id+'"]')?.classList.add('active');document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.querySelectorAll('video').forEach(v=>{v.pause();if(v._hls){v._hls.destroy();v._hls=null}});document.querySelectorAll('iframe[data-yt-src]').forEach(f=>{f.src=''});if(id==='home')document.getElementById('view-home').classList.add('active');else if(id==='news-all'){document.getElementById('view-cat').classList.add('active');loadNewsTab()}else{document.getElementById('view-cat').classList.add('active');loadCat(id)}}
|
| 53 |
-
function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}}
|
| 54 |
-
function doShare(title,url,img,postId){
|
| 55 |
-
var shareUrl;
|
| 56 |
-
if(postId){
|
| 57 |
-
shareUrl = SPACE+'/s?post_id='+encodeURIComponent(postId)+'&title='+encodeURIComponent(title);
|
| 58 |
-
} else {
|
| 59 |
-
shareUrl = SPACE+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||'');
|
| 60 |
-
}
|
| 61 |
-
// Use clipboard API first (modern, works on Chrome mobile 85+)
|
| 62 |
-
if(navigator.clipboard && navigator.clipboard.writeText){
|
| 63 |
-
navigator.clipboard.writeText(shareUrl).then(function(){
|
| 64 |
-
toast('📋 Đã sao chép link!');
|
| 65 |
-
try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){}
|
| 66 |
-
}).catch(function(){
|
| 67 |
-
try{
|
| 68 |
-
var ta=document.createElement('textarea');ta.value=shareUrl;ta.style.position='fixed';ta.style.left='-9999px';ta.style.top='-9999px';ta.style.opacity='0';
|
| 69 |
-
document.body.appendChild(ta);ta.select();ta.setSelectionRange(0,99999);
|
| 70 |
-
if(document.execCommand('copy')){toast('📋 Đã sao chép link!');}else{prompt('📋 Sao chép link:', shareUrl);}
|
| 71 |
-
document.body.removeChild(ta);
|
| 72 |
-
}catch(e){prompt('📋 Sao chép link:', shareUrl);}
|
| 73 |
-
try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){}
|
| 74 |
-
});
|
| 75 |
-
} else {
|
| 76 |
-
try{
|
| 77 |
-
var ta=document.createElement('textarea');ta.value=shareUrl;ta.style.position='fixed';ta.style.left='-9999px';ta.style.top='-9999px';ta.style.opacity='0';
|
| 78 |
-
document.body.appendChild(ta);ta.select();ta.setSelectionRange(0,99999);
|
| 79 |
-
if(document.execCommand('copy')){toast('📋 Đã sao chép link!');}else{prompt('📋 Sao chép link:', shareUrl);}
|
| 80 |
-
document.body.removeChild(ta);
|
| 81 |
-
}catch(e){prompt('📋 Sao chép link:', shareUrl);}
|
| 82 |
-
try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){}
|
| 83 |
-
}
|
| 84 |
-
}
|
| 85 |
-
async function init(){_cats=await fetch('/api/categories').then(r=>r.json()).catch(()=>[]);let bar='<div class="cat active" data-cat="home">🏠</div><div class="cat" data-cat="news-all">📰 Tin tức</div>';_cats.forEach(c=>{bar+='<div class="cat" data-cat="'+c.id+'">'+c.name+'</div>'});document.getElementById('cat-bar').innerHTML=bar;document.querySelectorAll('.cat').forEach(t=>{t.onclick=()=>switchCat(t.dataset.cat)});}
|
| 86 |
-
var SPACE=location.origin;
|
| 87 |
-
</script>
|
| 88 |
-
<script src="/static/app_v2.js?v=20260827fptwall"></script>
|
| 89 |
-
<script src="/static/designer_v2.js?v=20260817fix1"></script>
|
| 90 |
-
<script src="/static/yt_live.js"></script>
|
| 91 |
-
<script src="/static/vtv_init.js"></script>
|
| 92 |
-
<script src="/static/hot_multi.js?v=1"></script>
|
| 93 |
-
<script src="/static/wc2026_v2.js"></script>
|
| 94 |
-
<script src="/static/live_mode.js"></script>
|
| 95 |
-
<script src="/static/match_detail_v6.js"></script>
|
| 96 |
-
<script>init();loadHome();</script>
|
| 97 |
-
</body>
|
| 98 |
-
</html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
main.py
CHANGED
|
@@ -1,17 +1,25 @@
|
|
| 1 |
-
"""VNEWS - FastAPI backend with livescore + xemlaibongda highlights + VTV channels"""
|
| 2 |
import re, time, subprocess, json, os, threading
|
| 3 |
import html as html_lib
|
| 4 |
-
from datetime import datetime, timezone, timedelta
|
| 5 |
from collections import defaultdict
|
| 6 |
|
| 7 |
VN_TZ = timezone(timedelta(hours=7))
|
| 8 |
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 9 |
from fastapi import FastAPI, Query, Request
|
| 10 |
-
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, Response
|
| 11 |
from urllib.parse import quote
|
| 12 |
import requests
|
| 13 |
from bs4 import BeautifulSoup
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
app = FastAPI()
|
| 16 |
|
| 17 |
# ===== WORLD CUP 2026 SCRAPER =====
|
|
@@ -51,7 +59,7 @@ _cache_ttl_live = 60
|
|
| 51 |
_cache_ttl_yt = 1800
|
| 52 |
|
| 53 |
PRIORITY_LEAGUES = ["Ngoại Hạng Anh","FA Cup","Champions League","LaLiga","Copa del Rey","Serie A","Bundesliga","Ligue 1","V-League"]
|
| 54 |
-
LEAGUE_IDS = {"nha":
|
| 55 |
HL_LEAGUES = {
|
| 56 |
"premier-league":{"path":"anh/premier-league","name":"Premier League","emoji":"🏴"},
|
| 57 |
"fa-cup":{"path":"anh/fa-cup","name":"FA Cup","emoji":"🏆"},
|
|
@@ -69,17 +77,8 @@ def _cached(key, fn, ttl=None):
|
|
| 69 |
except: data=_cache.get(key,{}).get("d",[])
|
| 70 |
_cache[key]={"d":data,"t":now}; return data
|
| 71 |
def _get(url, headers=None):
|
| 72 |
-
h=headers or HEADERS
|
| 73 |
-
|
| 74 |
-
try:
|
| 75 |
-
r=requests.get(url, headers=h, timeout=15)
|
| 76 |
-
r.encoding="utf-8"
|
| 77 |
-
return BeautifulSoup(r.text,"lxml")
|
| 78 |
-
except Exception:
|
| 79 |
-
if attempt < 2:
|
| 80 |
-
time.sleep(0.5 * (attempt + 1))
|
| 81 |
-
else:
|
| 82 |
-
raise
|
| 83 |
def fetch_bongda_api(endpoint):
|
| 84 |
try:
|
| 85 |
r=requests.get(f"https://bongda.com.vn{endpoint}", headers=BONGDA_HEADERS, timeout=10)
|
|
@@ -160,7 +159,7 @@ def proxy_img(url: str = Query(...)):
|
|
| 160 |
def _scrape_xemlaibongda_page(page_path, limit=20):
|
| 161 |
try:
|
| 162 |
url = f"https://xemlaibongda.top/{page_path}" if page_path else "https://xemlaibongda.top/"
|
| 163 |
-
r = requests.get(url, headers=HEADERS, timeout=
|
| 164 |
if r.status_code != 200: return []
|
| 165 |
r.encoding = "utf-8"
|
| 166 |
soup = BeautifulSoup(r.text, "lxml")
|
|
@@ -218,7 +217,7 @@ def _scrape_xemlaibongda_page(page_path, limit=20):
|
|
| 218 |
if not img_src:
|
| 219 |
slug = clean_href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
|
| 220 |
img_src = f"https://xemlaibongda.top/uploads/thumb/{slug}.jpg"
|
| 221 |
-
videos.append({"title": title[:100], "link": clean_href, "img": img_src, "source": "xemlaibongda"
|
| 222 |
if len(videos) >= limit: break
|
| 223 |
return videos
|
| 224 |
except Exception as e:
|
|
@@ -227,240 +226,18 @@ def _scrape_xemlaibongda_page(page_path, limit=20):
|
|
| 227 |
def scrape_xemlaibongda(): return _scrape_xemlaibongda_page("", 20)
|
| 228 |
def scrape_highlights_by_league(league_key):
|
| 229 |
if league_key not in HL_LEAGUES: return []
|
| 230 |
-
|
| 231 |
-
bp_vids = scrape_bongdaplus_by_league(league_key, 20)
|
| 232 |
-
if bp_vids: return bp_vids
|
| 233 |
-
# Fallback: xemlaibongda.top (may be slow/unreachable)
|
| 234 |
-
vids = _scrape_xemlaibongda_page(HL_LEAGUES[league_key]["path"], 20)
|
| 235 |
-
if vids: return vids
|
| 236 |
-
# Ultimate fallback: bongdaplus.vn general video feed, only for primary home league
|
| 237 |
-
if league_key == "premier-league":
|
| 238 |
-
return scrape_bongdaplus_videos(20)
|
| 239 |
-
return []
|
| 240 |
def scrape_all_league_highlights():
|
| 241 |
results = {}
|
| 242 |
def _fetch(key): return key, scrape_highlights_by_league(key)
|
| 243 |
with ThreadPoolExecutor(8) as ex:
|
| 244 |
futs = [ex.submit(_fetch, k) for k in HL_LEAGUES]
|
| 245 |
-
for f in as_completed(futs, timeout=
|
| 246 |
try: key, vids = f.result()
|
| 247 |
except: continue
|
| 248 |
if vids: results[key] = vids
|
| 249 |
-
if not results:
|
| 250 |
-
fb = scrape_bongdaplus_videos(20)
|
| 251 |
-
if fb:
|
| 252 |
-
results["premier-league"] = fb
|
| 253 |
-
# The league sub-pages resolve to the SAME content from this server (the
|
| 254 |
-
# football sites serve one generic list regardless of league path). Return
|
| 255 |
-
# the unique videos once per league (deduped against the global set) and
|
| 256 |
-
# enrich with the general bongdaplus feed so the homepage slider gets a
|
| 257 |
-
# genuinely bigger, duplicate-free list instead of 8x the same 10 clips.
|
| 258 |
-
global_seen = set()
|
| 259 |
-
seen_per_league = {}
|
| 260 |
-
enriched = {}
|
| 261 |
-
try:
|
| 262 |
-
extra = scrape_bongdaplus_videos(40)
|
| 263 |
-
except Exception:
|
| 264 |
-
extra = []
|
| 265 |
-
for key, vids in list(results.items()):
|
| 266 |
-
uniq = []
|
| 267 |
-
fresh = []
|
| 268 |
-
for v in vids:
|
| 269 |
-
link = (v or {}).get("link", "")
|
| 270 |
-
if not link or link in seen_per_league.get(key, set()): continue
|
| 271 |
-
seen_per_league.setdefault(key, set()).add(link)
|
| 272 |
-
if link in global_seen:
|
| 273 |
-
fresh.append(v) # duplicate across leagues — will dedupe
|
| 274 |
-
continue
|
| 275 |
-
global_seen.add(link)
|
| 276 |
-
uniq.append(v)
|
| 277 |
-
# league-local unique list
|
| 278 |
-
if uniq:
|
| 279 |
-
results[key] = uniq
|
| 280 |
-
# still add across-league dupes to each league so per-league feeds
|
| 281 |
-
# are non-empty, but ONLY if the league has < 10 unique videos
|
| 282 |
-
if len(uniq) < 10:
|
| 283 |
-
for v in fresh:
|
| 284 |
-
link = (v or {}).get("link", "")
|
| 285 |
-
if link in seen_per_league.get(key, set()): continue
|
| 286 |
-
seen_per_league[key].add(link)
|
| 287 |
-
uniq.append(v)
|
| 288 |
-
results[key] = uniq
|
| 289 |
-
# enrich league with global feed leftovers when it's still thin
|
| 290 |
-
if len(uniq) < 10:
|
| 291 |
-
for v in extra:
|
| 292 |
-
link = (v or {}).get("link", "")
|
| 293 |
-
if not link or link in seen_per_league.get(key, set()): continue
|
| 294 |
-
seen_per_league[key].add(link)
|
| 295 |
-
uniq.append(v)
|
| 296 |
-
results[key] = uniq
|
| 297 |
return results
|
| 298 |
|
| 299 |
-
BDP_HL = "https://bongdaplus.vn/video"
|
| 300 |
-
def _video_published(img_src, link=""):
|
| 301 |
-
"""Best-effort publish date from image URL (bongdaplus stores dates as
|
| 302 |
-
/Media/YYYY/MM/DD/) or from a dated slug. Returns 'YYYY-MM-DD' or ''."""
|
| 303 |
-
try:
|
| 304 |
-
if img_src:
|
| 305 |
-
m = re.search(r"/Media/(\d{4})/(\d{2})/(\d{2})/", img_src)
|
| 306 |
-
if m:
|
| 307 |
-
return "%s-%s-%s" % (m.group(1), m.group(2), m.group(3))
|
| 308 |
-
if link:
|
| 309 |
-
m2 = re.search(r"/(\d{4})/(\d{2})/(\d{2})/", link)
|
| 310 |
-
if m2:
|
| 311 |
-
return "%s-%s-%s" % (m2.group(1), m2.group(2), m2.group(3))
|
| 312 |
-
except Exception:
|
| 313 |
-
pass
|
| 314 |
-
return ""
|
| 315 |
-
|
| 316 |
-
def scrape_bongdaplus_videos(limit=20):
|
| 317 |
-
"""Scrape football highlight/clip videos from bongdaplus.vn/video (reachable from HF Spaces)."""
|
| 318 |
-
try:
|
| 319 |
-
r = requests.get(BDP_HL, headers=HEADERS, timeout=15)
|
| 320 |
-
if r.status_code != 200: return []
|
| 321 |
-
r.encoding = "utf-8"
|
| 322 |
-
soup = BeautifulSoup(r.text, "lxml")
|
| 323 |
-
videos = []; seen = set()
|
| 324 |
-
for a in soup.find_all("a", href=True):
|
| 325 |
-
href = a.get("href","")
|
| 326 |
-
if "/video/" not in href or not href.endswith(".html"): continue
|
| 327 |
-
if not href.startswith("http"): href = "https://bongdaplus.vn" + href
|
| 328 |
-
link = href.split("?")[0].split("#")[0]
|
| 329 |
-
if link in seen: continue
|
| 330 |
-
seen.add(link)
|
| 331 |
-
img_src = ""
|
| 332 |
-
img = a.find("img")
|
| 333 |
-
if img:
|
| 334 |
-
img_src = img.get("data-src","") or img.get("src","") or ""
|
| 335 |
-
title = ""
|
| 336 |
-
t = a.get("title","") or ""
|
| 337 |
-
if t and len(t)>=5: title = t
|
| 338 |
-
if not title:
|
| 339 |
-
img_alt = img.get("alt","") if img else ""
|
| 340 |
-
if img_alt and len(img_alt)>=5: title = img_alt
|
| 341 |
-
if not title:
|
| 342 |
-
txt = a.get_text(strip=True)
|
| 343 |
-
if txt and len(txt)>=5: title = txt
|
| 344 |
-
title = re.sub(r'^(VIDEO\s*:\s*|VIDEO\s+)', '', title, flags=re.I).strip()
|
| 345 |
-
if not title or len(title)<5: continue
|
| 346 |
-
if img_src and img_src.startswith("//"): img_src = "https:" + img_src
|
| 347 |
-
videos.append({"title": title[:100], "link": link, "img": img_src, "source": "bongdaplus", "published": _video_published(img_src, link)})
|
| 348 |
-
if len(videos) >= limit: break
|
| 349 |
-
return videos
|
| 350 |
-
except Exception as e:
|
| 351 |
-
print(f"[bongdaplus] Error: {e}"); return []
|
| 352 |
-
|
| 353 |
-
# Bongdaplus.vn league-specific video URLs (used as fallback when xemlaibongda.top fails)
|
| 354 |
-
BDP_LEAGUE_URLS = {
|
| 355 |
-
"premier-league": "https://bongdaplus.vn/video/premier-league",
|
| 356 |
-
"la-liga": "https://bongdaplus.vn/video/la-liga",
|
| 357 |
-
"serie-a": "https://bongdaplus.vn/video/serie-a",
|
| 358 |
-
"bundesliga": "https://bongdaplus.vn/video/bundesliga",
|
| 359 |
-
"champions-league": "https://bongdaplus.vn/video/champions-league",
|
| 360 |
-
"fa-cup": "https://bongdaplus.vn/video/fa-cup",
|
| 361 |
-
"europa-league": "https://bongdaplus.vn/video/europa-league",
|
| 362 |
-
"world-cup": "https://bongdaplus.vn/video/world-cup",
|
| 363 |
-
}
|
| 364 |
-
def scrape_bongdaplus_by_league(league_key, limit=20):
|
| 365 |
-
"""Scrape bongdaplus.vn video page for a specific league."""
|
| 366 |
-
url = BDP_LEAGUE_URLS.get(league_key)
|
| 367 |
-
if not url: return []
|
| 368 |
-
try:
|
| 369 |
-
r = requests.get(url, headers=HEADERS, timeout=15)
|
| 370 |
-
if r.status_code != 200: return []
|
| 371 |
-
r.encoding = "utf-8"
|
| 372 |
-
soup = BeautifulSoup(r.text, "lxml")
|
| 373 |
-
videos = []; seen = set()
|
| 374 |
-
for a in soup.find_all("a", href=True):
|
| 375 |
-
href = a.get("href","")
|
| 376 |
-
if "/video/" not in href or not href.endswith(".html"): continue
|
| 377 |
-
if not href.startswith("http"): href = "https://bongdaplus.vn" + href
|
| 378 |
-
link = href.split("?")[0].split("#")[0]
|
| 379 |
-
if link in seen: continue
|
| 380 |
-
seen.add(link)
|
| 381 |
-
img_src = ""
|
| 382 |
-
img = a.find("img")
|
| 383 |
-
if img:
|
| 384 |
-
img_src = img.get("data-src","") or img.get("src","") or ""
|
| 385 |
-
title = ""
|
| 386 |
-
t = a.get("title","") or ""
|
| 387 |
-
if t and len(t)>=5: title = t
|
| 388 |
-
if not title:
|
| 389 |
-
img_alt = img.get("alt","") if img else ""
|
| 390 |
-
if img_alt and len(img_alt)>=5: title = img_alt
|
| 391 |
-
if not title:
|
| 392 |
-
txt = a.get_text(strip=True)
|
| 393 |
-
if txt and len(txt)>=5: title = txt
|
| 394 |
-
title = re.sub(r'^(VIDEO\s*:\s*|VIDEO\s+)', '', title, flags=re.I).strip()
|
| 395 |
-
if not title or len(title)<5: continue
|
| 396 |
-
if img_src and img_src.startswith("//"): img_src = "https:" + img_src
|
| 397 |
-
videos.append({"title": title[:100], "link": link, "img": img_src, "source": "bongdaplus", "published": _video_published(img_src, link)})
|
| 398 |
-
if len(videos) >= limit: break
|
| 399 |
-
return videos
|
| 400 |
-
except Exception as e:
|
| 401 |
-
print(f"[bongdaplus:{league_key}] Error: {e}"); return []
|
| 402 |
-
|
| 403 |
-
def extract_bongdaplus_video(url):
|
| 404 |
-
"""Extract direct MP4 from bongdaplus.vn video detail via its embed page.
|
| 405 |
-
|
| 406 |
-
The embed page (/video-embed/{id}.html) sometimes only has a JPG placeholder
|
| 407 |
-
as <source>. In that case, fall back to the main video page
|
| 408 |
-
(/video/{slug}.html) which contains a YouTube iframe with the actual
|
| 409 |
-
highlight.
|
| 410 |
-
"""
|
| 411 |
-
try:
|
| 412 |
-
m = re.search(r'/video/(?:[^/]+-)?(\d+)\.html', url)
|
| 413 |
-
if not m:
|
| 414 |
-
return None
|
| 415 |
-
vid = m.group(1)
|
| 416 |
-
embed = f"https://bongdaplus.vn/video-embed/{vid}.html"
|
| 417 |
-
r = requests.get(embed, headers=HEADERS, timeout=15)
|
| 418 |
-
if r.status_code != 200: return None
|
| 419 |
-
r.encoding = "utf-8"
|
| 420 |
-
soup = BeautifulSoup(r.text, "lxml")
|
| 421 |
-
video = soup.find("video")
|
| 422 |
-
src = ""
|
| 423 |
-
poster = ""
|
| 424 |
-
if video:
|
| 425 |
-
src = video.get("src","")
|
| 426 |
-
poster = video.get("poster","")
|
| 427 |
-
if not src:
|
| 428 |
-
src_el = soup.find("source")
|
| 429 |
-
if src_el: src = src_el.get("src","")
|
| 430 |
-
if not src: return None
|
| 431 |
-
src = src.split("?")[0].split("#")[0]
|
| 432 |
-
# Only accept real media; placeholder .jpg/jpeg/png means video not available
|
| 433 |
-
if not re.search(r'\.(mp4|m3u8|webm)$', src, re.I):
|
| 434 |
-
# The embed source is a JPG placeholder — fall back to the main video
|
| 435 |
-
# page and look for a YouTube iframe (actual highlight video).
|
| 436 |
-
try:
|
| 437 |
-
r2 = requests.get(url, headers=HEADERS, timeout=15)
|
| 438 |
-
if r2.status_code == 200:
|
| 439 |
-
soup2 = BeautifulSoup(r2.text, "lxml")
|
| 440 |
-
yt_iframe = soup2.find("iframe", src=re.compile(r"youtube\.com/embed|youtube-nocookie\.com/embed"))
|
| 441 |
-
if yt_iframe:
|
| 442 |
-
yt_src = yt_iframe.get("src","").strip()
|
| 443 |
-
if yt_src.startswith("//"): yt_src = "https:" + yt_src
|
| 444 |
-
yt_id = re.search(r'/(?:embed|v)/([a-zA-Z0-9_-]{11})', yt_src)
|
| 445 |
-
if yt_id:
|
| 446 |
-
poster = poster or ""
|
| 447 |
-
if not poster:
|
| 448 |
-
og = soup2.find("meta", property="og:image")
|
| 449 |
-
if og: poster = og.get("content","")
|
| 450 |
-
if not poster:
|
| 451 |
-
og = soup.find("meta", property="og:image")
|
| 452 |
-
if og: poster = og.get("content","")
|
| 453 |
-
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"}
|
| 454 |
-
except Exception:
|
| 455 |
-
pass
|
| 456 |
-
return None
|
| 457 |
-
if not poster:
|
| 458 |
-
og = soup.find("meta",property="og:image")
|
| 459 |
-
if og: poster = og.get("content","")
|
| 460 |
-
return {"src": src, "poster": poster, "type": "video"}
|
| 461 |
-
except Exception:
|
| 462 |
-
return None
|
| 463 |
-
|
| 464 |
def extract_xemlaibongda_video(url):
|
| 465 |
try:
|
| 466 |
r=requests.get(url, headers=HEADERS, timeout=15)
|
|
@@ -497,207 +274,9 @@ def api_livescore_results():
|
|
| 497 |
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)})
|
| 498 |
@app.get("/api/livescore/standings/{league}")
|
| 499 |
def api_livescore_standings(league:str):
|
| 500 |
-
tid=LEAGUE_IDS.get(league,
|
| 501 |
@app.get("/api/livescore/date/{date}")
|
| 502 |
def api_livescore_date(date:str):return JSONResponse({"html":fetch_bongda_api(f"/api/fixtures/get-by-date?date={date}")})
|
| 503 |
-
|
| 504 |
-
def _compute_updates7d():
|
| 505 |
-
from datetime import date as _date
|
| 506 |
-
today = _date.today()
|
| 507 |
-
all_html = []
|
| 508 |
-
# Past 7 days (results)
|
| 509 |
-
for i in range(7, 0, -1):
|
| 510 |
-
d = (today - timedelta(days=i)).strftime("%Y-%m-%d")
|
| 511 |
-
html = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}&status=finished")
|
| 512 |
-
if html and len(html) > 50:
|
| 513 |
-
soup = BeautifulSoup(html, "lxml")
|
| 514 |
-
day_label = (today - timedelta(days=i)).strftime("%d/%m")
|
| 515 |
-
for match in soup.select(".match-detail"):
|
| 516 |
-
dt = soup.new_tag("div", **{"class": "datetime"})
|
| 517 |
-
dt.string = f"📅 {day_label}"
|
| 518 |
-
match.insert(0, dt)
|
| 519 |
-
all_html.append(str(soup))
|
| 520 |
-
# Next 7 days (upcoming)
|
| 521 |
-
for i in range(7):
|
| 522 |
-
d = (today + timedelta(days=i)).strftime("%Y-%m-%d")
|
| 523 |
-
html = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}")
|
| 524 |
-
if html and len(html) > 50:
|
| 525 |
-
soup = BeautifulSoup(html, "lxml")
|
| 526 |
-
day_label = (today + timedelta(days=i)).strftime("%d/%m")
|
| 527 |
-
for match in soup.select(".match-detail"):
|
| 528 |
-
dt = soup.new_tag("div", **{"class": "datetime"})
|
| 529 |
-
dt.string = f"📅 {day_label}"
|
| 530 |
-
match.insert(0, dt)
|
| 531 |
-
all_html.append(str(soup))
|
| 532 |
-
combined = "<div class='updates7d'>" + "".join(all_html) + "</div>"
|
| 533 |
-
return combined if all_html else ""
|
| 534 |
-
|
| 535 |
-
_updates7d_cache = {"t": 0, "d": "", "busy": False}
|
| 536 |
-
_updates7d_lock = threading.Lock()
|
| 537 |
-
def _start_updates7d_refresh():
|
| 538 |
-
def _run():
|
| 539 |
-
try:
|
| 540 |
-
with _updates7d_lock:
|
| 541 |
-
if _updates7d_cache["busy"]: return
|
| 542 |
-
_updates7d_cache["busy"] = True
|
| 543 |
-
data = _compute_updates7d()
|
| 544 |
-
with _updates7d_lock:
|
| 545 |
-
if data:
|
| 546 |
-
_updates7d_cache["t"] = time.time()
|
| 547 |
-
_updates7d_cache["d"] = data
|
| 548 |
-
_updates7d_cache["busy"] = False
|
| 549 |
-
except Exception:
|
| 550 |
-
with _updates7d_lock:
|
| 551 |
-
_updates7d_cache["busy"] = False
|
| 552 |
-
th = threading.Thread(target=_run, daemon=True)
|
| 553 |
-
th.start()
|
| 554 |
-
|
| 555 |
-
@app.get("/api/livescore/updates7d")
|
| 556 |
-
def api_livescore_updates7d():
|
| 557 |
-
"""Aggregate matches. Never blocks synchronously — returns cached value instantly
|
| 558 |
-
and refreshes in a background thread. Pre-warmed at startup."""
|
| 559 |
-
now = time.time()
|
| 560 |
-
with _updates7d_lock:
|
| 561 |
-
fresh = _updates7d_cache["d"] and (now - _updates7d_cache["t"] < _cache_ttl)
|
| 562 |
-
stale = _updates7d_cache["d"]
|
| 563 |
-
if not fresh:
|
| 564 |
-
_start_updates7d_refresh()
|
| 565 |
-
return JSONResponse({"html": stale or "", "cached": bool(stale)})
|
| 566 |
-
|
| 567 |
-
# ===== LIVESCORE RECENT (prioritized: upcoming → just-finished today → yesterday → older) =====
|
| 568 |
-
def _compute_recent():
|
| 569 |
-
"""Compute recent livescore with priority: upcoming → just-finished today → yesterday → older."""
|
| 570 |
-
from datetime import date as _date
|
| 571 |
-
today = _date.today()
|
| 572 |
-
all_html = []
|
| 573 |
-
|
| 574 |
-
# Helper to extract match details with date label
|
| 575 |
-
def _extract_matches(date_str, day_label, status_param=""):
|
| 576 |
-
url_path = f"/api/fixtures/get-by-date?date={date_str}"
|
| 577 |
-
if status_param:
|
| 578 |
-
url_path += f"&status={status_param}"
|
| 579 |
-
html = fetch_bongda_api(url_path)
|
| 580 |
-
if not html or len(html) <= 50:
|
| 581 |
-
return
|
| 582 |
-
soup = BeautifulSoup(html, "lxml")
|
| 583 |
-
for match in soup.select(".match-detail"):
|
| 584 |
-
dt = soup.new_tag("div", **{"class": "datetime section-date"})
|
| 585 |
-
dt.string = f"📅 {day_label}"
|
| 586 |
-
match.insert(0, dt)
|
| 587 |
-
all_html.append(str(soup))
|
| 588 |
-
|
| 589 |
-
# 1) Trận sắp tới (hôm nay + ngày mai)
|
| 590 |
-
upcoming_html = []
|
| 591 |
-
for offset in [0, 1]:
|
| 592 |
-
d = (today + timedelta(days=offset)).strftime("%Y-%m-%d")
|
| 593 |
-
day_label = (today + timedelta(days=offset)).strftime("%d/%m")
|
| 594 |
-
html = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}")
|
| 595 |
-
if html and len(html) > 50:
|
| 596 |
-
soup = BeautifulSoup(html, "lxml")
|
| 597 |
-
for match in soup.select(".match-detail"):
|
| 598 |
-
dt = soup.new_tag("div", **{"class": "datetime section-date"})
|
| 599 |
-
dt.string = f"📅 {day_label}"
|
| 600 |
-
match.insert(0, dt)
|
| 601 |
-
upcoming_html.append(str(soup))
|
| 602 |
-
if upcoming_html:
|
| 603 |
-
all_html.append("<div class='ls-section-header upcoming'><span>⏰ Trận sắp tới</span></div><div class='ls-section-body upcoming'>" + "".join(upcoming_html) + "</div>")
|
| 604 |
-
|
| 605 |
-
# 2) Vừa kết th��c hôm nay
|
| 606 |
-
today_str = today.strftime("%Y-%m-%d")
|
| 607 |
-
finished_today = fetch_bongda_api(f"/api/fixtures/get-by-date?date={today_str}&status=finished")
|
| 608 |
-
if finished_today and len(finished_today) > 50:
|
| 609 |
-
soup = BeautifulSoup(finished_today, "lxml")
|
| 610 |
-
for match in soup.select(".match-detail"):
|
| 611 |
-
dt = soup.new_tag("div", **{"class": "datetime section-date"})
|
| 612 |
-
dt.string = f"📅 {today.strftime('%d/%m')}"
|
| 613 |
-
match.insert(0, dt)
|
| 614 |
-
all_html.append("<div class='ls-section-header finished'><span>✅ Vừa kết thúc</span></div><div class='ls-section-body finished'>" + str(soup) + "</div>")
|
| 615 |
-
|
| 616 |
-
# 3) Hôm qua
|
| 617 |
-
yesterday = today - timedelta(days=1)
|
| 618 |
-
d = yesterday.strftime("%Y-%m-%d")
|
| 619 |
-
html_yesterday = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}&status=finished")
|
| 620 |
-
if html_yesterday and len(html_yesterday) > 50:
|
| 621 |
-
soup = BeautifulSoup(html_yesterday, "lxml")
|
| 622 |
-
for match in soup.select(".match-detail"):
|
| 623 |
-
dt = soup.new_tag("div", **{"class": "datetime section-date"})
|
| 624 |
-
dt.string = f"📅 {yesterday.strftime('%d/%m')}"
|
| 625 |
-
match.insert(0, dt)
|
| 626 |
-
all_html.append("<div class='ls-section-header yesterday'><span>📅 Hôm qua</span></div><div class='ls-section-body yesterday'>" + str(soup) + "</div>")
|
| 627 |
-
|
| 628 |
-
# 4) Các ngày cũ hơn (2 ngày trước → 6 ngày trước)
|
| 629 |
-
older_html = []
|
| 630 |
-
for i in range(2, 7):
|
| 631 |
-
d = (today - timedelta(days=i)).strftime("%Y-%m-%d")
|
| 632 |
-
day_label = (today - timedelta(days=i)).strftime("%d/%m")
|
| 633 |
-
html = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}&status=finished")
|
| 634 |
-
if html and len(html) > 50:
|
| 635 |
-
soup = BeautifulSoup(html, "lxml")
|
| 636 |
-
for match in soup.select(".match-detail"):
|
| 637 |
-
dt = soup.new_tag("div", **{"class": "datetime section-date"})
|
| 638 |
-
dt.string = f"📅 {day_label}"
|
| 639 |
-
match.insert(0, dt)
|
| 640 |
-
older_html.append(str(soup))
|
| 641 |
-
if older_html:
|
| 642 |
-
all_html.append("<div class='ls-section-header older'><span>🗓️ Kết quả trước</span></div><div class='ls-section-body older'>" + "".join(older_html) + "</div>")
|
| 643 |
-
|
| 644 |
-
combined = "<div class='updates7d'>" + "".join(all_html) + "</div>"
|
| 645 |
-
return combined if all_html else ""
|
| 646 |
-
|
| 647 |
-
_recent_cache = {"t": 0, "d": "", "busy": False}
|
| 648 |
-
_recent_lock = threading.Lock()
|
| 649 |
-
def _start_recent_refresh():
|
| 650 |
-
def _run():
|
| 651 |
-
try:
|
| 652 |
-
with _recent_lock:
|
| 653 |
-
if _recent_cache["busy"]: return
|
| 654 |
-
_recent_cache["busy"] = True
|
| 655 |
-
data = _compute_recent()
|
| 656 |
-
with _recent_lock:
|
| 657 |
-
if data:
|
| 658 |
-
_recent_cache["t"] = time.time()
|
| 659 |
-
_recent_cache["d"] = data
|
| 660 |
-
_recent_cache["busy"] = False
|
| 661 |
-
except Exception:
|
| 662 |
-
with _recent_lock:
|
| 663 |
-
_recent_cache["busy"] = False
|
| 664 |
-
th = threading.Thread(target=_run, daemon=True)
|
| 665 |
-
th.start()
|
| 666 |
-
|
| 667 |
-
@app.get("/api/livescore/recent")
|
| 668 |
-
def api_livescore_recent():
|
| 669 |
-
"""Recent matches with new priority: upcoming today/tomorrow → finished today → yesterday → older."""
|
| 670 |
-
now = time.time()
|
| 671 |
-
with _recent_lock:
|
| 672 |
-
fresh = _recent_cache["d"] and (now - _recent_cache["t"] < _cache_ttl)
|
| 673 |
-
stale = _recent_cache["d"]
|
| 674 |
-
if not fresh:
|
| 675 |
-
_start_recent_refresh()
|
| 676 |
-
return JSONResponse({"html": stale or "", "cached": bool(stale)})
|
| 677 |
-
|
| 678 |
-
def _warm_recent():
|
| 679 |
-
def _warm():
|
| 680 |
-
try:
|
| 681 |
-
time.sleep(3)
|
| 682 |
-
_start_recent_refresh()
|
| 683 |
-
except Exception:
|
| 684 |
-
pass
|
| 685 |
-
th = threading.Thread(target=_warm, daemon=True)
|
| 686 |
-
th.start()
|
| 687 |
-
_warm_recent()
|
| 688 |
-
|
| 689 |
-
def _warm_updates7d():
|
| 690 |
-
# Lightweight startup pre-warm so the first user request is instant.
|
| 691 |
-
def _warm():
|
| 692 |
-
try:
|
| 693 |
-
time.sleep(3)
|
| 694 |
-
_start_updates7d_refresh()
|
| 695 |
-
except Exception:
|
| 696 |
-
pass
|
| 697 |
-
th = threading.Thread(target=_warm, daemon=True)
|
| 698 |
-
th.start()
|
| 699 |
-
_warm_updates7d()
|
| 700 |
-
|
| 701 |
@app.get("/api/match/{event_id}/commentaries")
|
| 702 |
def api_match_commentaries(event_id:int):return JSONResponse({"html":fetch_bongda_api(f"/api/fixtures/commentaries?event_id={event_id}")})
|
| 703 |
@app.get("/api/match/{event_id}/stats")
|
|
@@ -735,7 +314,7 @@ def api_livescore_featured():
|
|
| 735 |
return JSONResponse(_cached("ls_featured",_f,ttl=30))
|
| 736 |
|
| 737 |
@app.get("/api/highlights")
|
| 738 |
-
def api_highlights(): return JSONResponse(_cached("xemlaibongda_hl",
|
| 739 |
@app.get("/api/highlights/leagues")
|
| 740 |
def api_highlights_leagues(): return JSONResponse(_cached("hl_leagues",scrape_all_league_highlights,ttl=_cache_ttl))
|
| 741 |
@app.get("/api/highlights/{league}")
|
|
@@ -743,47 +322,6 @@ def api_highlights_league(league:str):
|
|
| 743 |
if league not in HL_LEAGUES: return JSONResponse({"error":"league not found"})
|
| 744 |
return JSONResponse(_cached(f"hl_{league}",lambda:scrape_highlights_by_league(league),ttl=_cache_ttl))
|
| 745 |
|
| 746 |
-
@app.get("/api/highlights/{league}/page")
|
| 747 |
-
def api_highlights_league_page(league:str, page:int=Query(default=0, ge=0), limit:int=Query(default=15, ge=5, le=40)):
|
| 748 |
-
"""Paginated league highlights for 'Xem thêm' load-more. Supports 'all' to fetch every league aggregated."""
|
| 749 |
-
if league == "all":
|
| 750 |
-
all_vids = []
|
| 751 |
-
with ThreadPoolExecutor(8) as ex:
|
| 752 |
-
futs = {ex.submit(scrape_highlights_by_league, k): k for k in HL_LEAGUES}
|
| 753 |
-
for f in as_completed(futs, timeout=20):
|
| 754 |
-
try:
|
| 755 |
-
all_vids.extend(f.result())
|
| 756 |
-
except: pass
|
| 757 |
-
# The league sub-pages resolve to the same content from this server,
|
| 758 |
-
# so dedupe by link — the slider/feed must not show 8x the same 10
|
| 759 |
-
# videos. If dedup leaves few unique items, enrich from the general
|
| 760 |
-
# bongdaplus video feed to add genuinely different videos.
|
| 761 |
-
seen = set(); uniq = []
|
| 762 |
-
for v in all_vids:
|
| 763 |
-
link = (v or {}).get("link", "")
|
| 764 |
-
if not link or link in seen: continue
|
| 765 |
-
seen.add(link); uniq.append(v)
|
| 766 |
-
if len(uniq) < 20:
|
| 767 |
-
try:
|
| 768 |
-
extra = scrape_bongdaplus_videos(40)
|
| 769 |
-
for v in extra:
|
| 770 |
-
link = (v or {}).get("link", "")
|
| 771 |
-
if not link or link in seen: continue
|
| 772 |
-
seen.add(link); uniq.append(v)
|
| 773 |
-
except Exception:
|
| 774 |
-
pass
|
| 775 |
-
all_vids = uniq
|
| 776 |
-
start = page * limit
|
| 777 |
-
end = start + limit
|
| 778 |
-
paged = all_vids[start:end]
|
| 779 |
-
return JSONResponse({"videos": paged, "league": "all", "page": page, "has_more": end < len(all_vids), "total": len(all_vids)})
|
| 780 |
-
if league not in HL_LEAGUES: return JSONResponse({"error":"league not found"})
|
| 781 |
-
all_vids = scrape_highlights_by_league(league)
|
| 782 |
-
start = page * limit
|
| 783 |
-
end = start + limit
|
| 784 |
-
paged = all_vids[start:end]
|
| 785 |
-
return JSONResponse({"videos": paged, "league": league, "page": page, "has_more": end < len(all_vids), "total": len(all_vids)})
|
| 786 |
-
|
| 787 |
@app.get("/api/video_url")
|
| 788 |
def api_video_url(url:str=Query(...), img:str=Query(default="")):
|
| 789 |
if "youtube.com" in url or "youtu.be" in url:
|
|
@@ -795,116 +333,8 @@ def api_video_url(url:str=Query(...), img:str=Query(default="")):
|
|
| 795 |
if v["type"]=="hls": v["src"]="/api/proxy/m3u8?url="+quote(v["src"],safe="")
|
| 796 |
if not v.get("poster") and img: v["poster"] = img
|
| 797 |
return JSONResponse(v)
|
| 798 |
-
if "bongdaplus.vn" in url and "/video/" in url:
|
| 799 |
-
v=extract_bongdaplus_video(url)
|
| 800 |
-
if v:
|
| 801 |
-
if not v.get("poster") and img: v["poster"] = img
|
| 802 |
-
return JSONResponse(v)
|
| 803 |
return JSONResponse({"error":"not found"})
|
| 804 |
|
| 805 |
-
# ===== FPT PLAY (YouTube channel) API =====
|
| 806 |
-
# The datacenter cannot reach youtube.com directly, so this endpoint keeps a
|
| 807 |
-
# persisted list of the channel's latest videos. The frontend (running in the
|
| 808 |
-
# user's browser, which CAN reach YouTube) periodically fetches the channel via
|
| 809 |
-
# r.jina.ai, then POSTs the result here so the slide auto-updates when the
|
| 810 |
-
# channel uploads — exactly like "Thêm Short HOT" but fully automatic.
|
| 811 |
-
FPT_FILE = "/data/fpt_play_videos.json"
|
| 812 |
-
FPT_CHANNEL_URL = "https://www.youtube.com/@fptbongdaofficial"
|
| 813 |
-
FPT_CACHE_TTL = 600 # 10 min
|
| 814 |
-
|
| 815 |
-
def _fpt_load():
|
| 816 |
-
try:
|
| 817 |
-
if os.path.exists(FPT_FILE):
|
| 818 |
-
with open(FPT_FILE, encoding="utf-8") as f:
|
| 819 |
-
j = json.load(f)
|
| 820 |
-
if isinstance(j, list):
|
| 821 |
-
return j
|
| 822 |
-
except Exception:
|
| 823 |
-
pass
|
| 824 |
-
return []
|
| 825 |
-
|
| 826 |
-
def _fpt_save(videos):
|
| 827 |
-
try:
|
| 828 |
-
with open(FPT_FILE, "w", encoding="utf-8") as f:
|
| 829 |
-
json.dump(videos, f, ensure_ascii=False)
|
| 830 |
-
except Exception:
|
| 831 |
-
pass
|
| 832 |
-
|
| 833 |
-
def _fpt_try_server_fetch():
|
| 834 |
-
"""Server-side attempt (jina proxy with our Origin). Usually blocked from
|
| 835 |
-
the datacenter, but harmless to try once so the slide works even without
|
| 836 |
-
a client push."""
|
| 837 |
-
try:
|
| 838 |
-
_hdrs = dict(HEADERS)
|
| 839 |
-
_hdrs["Origin"] = "https://bep40-vnews.hf.space"
|
| 840 |
-
r = requests.get("https://r.jina.ai/" + FPT_CHANNEL_URL + "/videos",
|
| 841 |
-
headers=_hdrs, timeout=40)
|
| 842 |
-
if r.status_code != 200:
|
| 843 |
-
return []
|
| 844 |
-
txt = r.text
|
| 845 |
-
out = []
|
| 846 |
-
seen = set()
|
| 847 |
-
# jina markdown lists entries as "1. Title - url" or link lines with yt ids
|
| 848 |
-
for m in re.finditer(r"https?://(?:www\.)?youtube\.com/watch\?v=([a-zA-Z0-9_-]{11})", txt):
|
| 849 |
-
vid = m.group(1)
|
| 850 |
-
if vid in seen: continue
|
| 851 |
-
seen.add(vid)
|
| 852 |
-
out.append({
|
| 853 |
-
"id": vid,
|
| 854 |
-
"title": "",
|
| 855 |
-
"link": "https://youtu.be/" + vid,
|
| 856 |
-
"img": "https://i.ytimg.com/vi/%s/hqdefault.jpg" % vid,
|
| 857 |
-
"source": "fptplay",
|
| 858 |
-
"published": "",
|
| 859 |
-
})
|
| 860 |
-
return out
|
| 861 |
-
except Exception:
|
| 862 |
-
return []
|
| 863 |
-
|
| 864 |
-
@app.get("/api/fptplay/videos")
|
| 865 |
-
def api_fptplay_videos():
|
| 866 |
-
vids = _fpt_load()
|
| 867 |
-
if not vids:
|
| 868 |
-
vids = _fpt_try_server_fetch()
|
| 869 |
-
if vids: _fpt_save(vids)
|
| 870 |
-
return JSONResponse({"channel": FPT_CHANNEL_URL, "videos": vids, "updated": int(time.time())})
|
| 871 |
-
|
| 872 |
-
@app.post("/api/fptplay/update")
|
| 873 |
-
async def api_fptplay_update(request: Request):
|
| 874 |
-
try:
|
| 875 |
-
body = await request.json()
|
| 876 |
-
except Exception:
|
| 877 |
-
body = {}
|
| 878 |
-
incoming = body.get("videos") or []
|
| 879 |
-
if not isinstance(incoming, list) or not incoming:
|
| 880 |
-
return JSONResponse({"ok": True, "videos": _fpt_load()})
|
| 881 |
-
seen = set()
|
| 882 |
-
merged = []
|
| 883 |
-
for v in _fpt_load():
|
| 884 |
-
vid = (v or {}).get("id", "")
|
| 885 |
-
if vid and vid in seen: continue
|
| 886 |
-
if vid: seen.add(vid)
|
| 887 |
-
merged.append(v)
|
| 888 |
-
added = 0
|
| 889 |
-
for v in incoming:
|
| 890 |
-
vid = (v or {}).get("id", "") or ""
|
| 891 |
-
if not vid or vid in seen: continue
|
| 892 |
-
seen.add(vid)
|
| 893 |
-
# keep user-friendly fields, always normalize the watch link
|
| 894 |
-
merged.append({
|
| 895 |
-
"id": vid,
|
| 896 |
-
"title": (v.get("title") or "").strip()[:200],
|
| 897 |
-
"link": (v.get("link") or "https://youtu.be/" + vid),
|
| 898 |
-
"img": (v.get("img") or "https://i.ytimg.com/vi/%s/hqdefault.jpg" % vid),
|
| 899 |
-
"source": "fptplay",
|
| 900 |
-
"published": (v.get("published") or "")[:10],
|
| 901 |
-
})
|
| 902 |
-
added += 1
|
| 903 |
-
# newest first
|
| 904 |
-
merged.sort(key=lambda x: (x.get("published") or ""), reverse=True)
|
| 905 |
-
_fpt_save(merged[:50])
|
| 906 |
-
return JSONResponse({"ok": True, "added": added, "videos": merged[:50]})
|
| 907 |
-
|
| 908 |
# ===== WORLD CUP 2026 API =====
|
| 909 |
_wc_request_times = []; _wc_rate_limit_lock = threading.Lock()
|
| 910 |
_WC_RATE_LIMIT = 10
|
|
@@ -970,8 +400,7 @@ def scrape_vne(cat_url):
|
|
| 970 |
if src: img=src.get("srcset","").split(",")[0].strip().split(" ")[0]
|
| 971 |
arts.append({"title":t,"link":lk,"img":img,"source":"vne"})
|
| 972 |
return arts
|
| 973 |
-
except
|
| 974 |
-
return []
|
| 975 |
|
| 976 |
def scrape_genk_ai():
|
| 977 |
try:
|
|
@@ -1036,19 +465,10 @@ def api_categories():
|
|
| 1036 |
|
| 1037 |
@app.get("/api/proxy/xlb")
|
| 1038 |
def api_xlb(path: str = Query(default=""), limit: int = Query(default=20)):
|
| 1039 |
-
# Try xemlaibongda.top first (fast timeout), fallback to bongdaplus.vn
|
| 1040 |
-
videos = _do_xlb_scrape_xlb(path, limit)
|
| 1041 |
-
if not videos:
|
| 1042 |
-
videos = _do_xlb_scrape_bdp(limit)
|
| 1043 |
-
if not videos:
|
| 1044 |
-
return JSONResponse({"videos": [], "error": "No videos available"})
|
| 1045 |
-
return JSONResponse({"videos": videos})
|
| 1046 |
-
|
| 1047 |
-
def _do_xlb_scrape_xlb(path, limit):
|
| 1048 |
try:
|
| 1049 |
url = f"https://xemlaibongda.top/{path}" if path else "https://xemlaibongda.top/"
|
| 1050 |
-
r = requests.get(url, headers=HEADERS, timeout=
|
| 1051 |
-
if r.status_code != 200: return []
|
| 1052 |
r.encoding = "utf-8"
|
| 1053 |
soup = BeautifulSoup(r.text, "lxml")
|
| 1054 |
videos, seen = [], set()
|
|
@@ -1077,18 +497,11 @@ def _do_xlb_scrape_xlb(path, limit):
|
|
| 1077 |
if not t:
|
| 1078 |
slug = clean.split("/video/")[-1].rstrip("/")
|
| 1079 |
t = slug.replace("-", " ").title()
|
| 1080 |
-
videos.append({"title": t[:100], "link": clean, "img": img_src, "source": "xemlaibongda"
|
| 1081 |
if len(videos) >= limit: break
|
| 1082 |
-
return videos
|
| 1083 |
-
except Exception:
|
| 1084 |
-
return []
|
| 1085 |
-
|
| 1086 |
-
def _do_xlb_scrape_bdp(limit):
|
| 1087 |
-
"""Fallback: scrape bongdaplus.vn videos for the xlb endpoint."""
|
| 1088 |
-
try:
|
| 1089 |
-
return scrape_bongdaplus_videos(limit)
|
| 1090 |
-
except Exception:
|
| 1091 |
-
return []
|
| 1092 |
|
| 1093 |
@app.get("/api/article")
|
| 1094 |
def api_article(url:str=Query(...)):
|
|
@@ -1112,4 +525,176 @@ def api_hot_topics():
|
|
| 1112 |
|
| 1113 |
@app.get("/", response_class=HTMLResponse)
|
| 1114 |
async def root():
|
| 1115 |
-
return HTMLResponse("<h1>VNEWS v17</h1><p>VTV Digital CDN ssaimh · No shorts Dantri/SKDS · Homepage full content</p>")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""VNEWS - FastAPI backend with livescore + xemlaibongda highlights + VTV channels + AI scheduler"""
|
| 2 |
import re, time, subprocess, json, os, threading
|
| 3 |
import html as html_lib
|
| 4 |
+
from datetime import datetime, timezone, timedelta
|
| 5 |
from collections import defaultdict
|
| 6 |
|
| 7 |
VN_TZ = timezone(timedelta(hours=7))
|
| 8 |
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 9 |
from fastapi import FastAPI, Query, Request
|
| 10 |
+
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, Response, FileResponse
|
| 11 |
from urllib.parse import quote
|
| 12 |
import requests
|
| 13 |
from bs4 import BeautifulSoup
|
| 14 |
|
| 15 |
+
# Import storage for persistent data
|
| 16 |
+
from storage import (
|
| 17 |
+
load_wall_posts, save_wall_posts,
|
| 18 |
+
load_interactions, save_interactions,
|
| 19 |
+
load_comments, save_comments,
|
| 20 |
+
DATA_DIR
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
app = FastAPI()
|
| 24 |
|
| 25 |
# ===== WORLD CUP 2026 SCRAPER =====
|
|
|
|
| 59 |
_cache_ttl_yt = 1800
|
| 60 |
|
| 61 |
PRIORITY_LEAGUES = ["Ngoại Hạng Anh","FA Cup","Champions League","LaLiga","Copa del Rey","Serie A","Bundesliga","Ligue 1","V-League"]
|
| 62 |
+
LEAGUE_IDS = {"nha":27110,"laliga":27233,"seriea":27044,"bundesliga":26891,"ligue1":27212}
|
| 63 |
HL_LEAGUES = {
|
| 64 |
"premier-league":{"path":"anh/premier-league","name":"Premier League","emoji":"🏴"},
|
| 65 |
"fa-cup":{"path":"anh/fa-cup","name":"FA Cup","emoji":"🏆"},
|
|
|
|
| 77 |
except: data=_cache.get(key,{}).get("d",[])
|
| 78 |
_cache[key]={"d":data,"t":now}; return data
|
| 79 |
def _get(url, headers=None):
|
| 80 |
+
h=headers or HEADERS; r=requests.get(url, headers=h, timeout=15); r.encoding="utf-8"
|
| 81 |
+
return BeautifulSoup(r.text,"lxml")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
def fetch_bongda_api(endpoint):
|
| 83 |
try:
|
| 84 |
r=requests.get(f"https://bongda.com.vn{endpoint}", headers=BONGDA_HEADERS, timeout=10)
|
|
|
|
| 159 |
def _scrape_xemlaibongda_page(page_path, limit=20):
|
| 160 |
try:
|
| 161 |
url = f"https://xemlaibongda.top/{page_path}" if page_path else "https://xemlaibongda.top/"
|
| 162 |
+
r = requests.get(url, headers=HEADERS, timeout=15)
|
| 163 |
if r.status_code != 200: return []
|
| 164 |
r.encoding = "utf-8"
|
| 165 |
soup = BeautifulSoup(r.text, "lxml")
|
|
|
|
| 217 |
if not img_src:
|
| 218 |
slug = clean_href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
|
| 219 |
img_src = f"https://xemlaibongda.top/uploads/thumb/{slug}.jpg"
|
| 220 |
+
videos.append({"title": title[:100], "link": clean_href, "img": img_src, "source": "xemlaibongda"})
|
| 221 |
if len(videos) >= limit: break
|
| 222 |
return videos
|
| 223 |
except Exception as e:
|
|
|
|
| 226 |
def scrape_xemlaibongda(): return _scrape_xemlaibongda_page("", 20)
|
| 227 |
def scrape_highlights_by_league(league_key):
|
| 228 |
if league_key not in HL_LEAGUES: return []
|
| 229 |
+
return _scrape_xemlaibongda_page(HL_LEAGUES[league_key]["path"], 20)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
def scrape_all_league_highlights():
|
| 231 |
results = {}
|
| 232 |
def _fetch(key): return key, scrape_highlights_by_league(key)
|
| 233 |
with ThreadPoolExecutor(8) as ex:
|
| 234 |
futs = [ex.submit(_fetch, k) for k in HL_LEAGUES]
|
| 235 |
+
for f in as_completed(futs, timeout=25):
|
| 236 |
try: key, vids = f.result()
|
| 237 |
except: continue
|
| 238 |
if vids: results[key] = vids
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
return results
|
| 240 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
def extract_xemlaibongda_video(url):
|
| 242 |
try:
|
| 243 |
r=requests.get(url, headers=HEADERS, timeout=15)
|
|
|
|
| 274 |
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)})
|
| 275 |
@app.get("/api/livescore/standings/{league}")
|
| 276 |
def api_livescore_standings(league:str):
|
| 277 |
+
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)})
|
| 278 |
@app.get("/api/livescore/date/{date}")
|
| 279 |
def api_livescore_date(date:str):return JSONResponse({"html":fetch_bongda_api(f"/api/fixtures/get-by-date?date={date}")})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
@app.get("/api/match/{event_id}/commentaries")
|
| 281 |
def api_match_commentaries(event_id:int):return JSONResponse({"html":fetch_bongda_api(f"/api/fixtures/commentaries?event_id={event_id}")})
|
| 282 |
@app.get("/api/match/{event_id}/stats")
|
|
|
|
| 314 |
return JSONResponse(_cached("ls_featured",_f,ttl=30))
|
| 315 |
|
| 316 |
@app.get("/api/highlights")
|
| 317 |
+
def api_highlights(): return JSONResponse(_cached("xemlaibongda_hl",scrape_xemlaibongda,ttl=_cache_ttl))
|
| 318 |
@app.get("/api/highlights/leagues")
|
| 319 |
def api_highlights_leagues(): return JSONResponse(_cached("hl_leagues",scrape_all_league_highlights,ttl=_cache_ttl))
|
| 320 |
@app.get("/api/highlights/{league}")
|
|
|
|
| 322 |
if league not in HL_LEAGUES: return JSONResponse({"error":"league not found"})
|
| 323 |
return JSONResponse(_cached(f"hl_{league}",lambda:scrape_highlights_by_league(league),ttl=_cache_ttl))
|
| 324 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
@app.get("/api/video_url")
|
| 326 |
def api_video_url(url:str=Query(...), img:str=Query(default="")):
|
| 327 |
if "youtube.com" in url or "youtu.be" in url:
|
|
|
|
| 333 |
if v["type"]=="hls": v["src"]="/api/proxy/m3u8?url="+quote(v["src"],safe="")
|
| 334 |
if not v.get("poster") and img: v["poster"] = img
|
| 335 |
return JSONResponse(v)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
return JSONResponse({"error":"not found"})
|
| 337 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
# ===== WORLD CUP 2026 API =====
|
| 339 |
_wc_request_times = []; _wc_rate_limit_lock = threading.Lock()
|
| 340 |
_WC_RATE_LIMIT = 10
|
|
|
|
| 400 |
if src: img=src.get("srcset","").split(",")[0].strip().split(" ")[0]
|
| 401 |
arts.append({"title":t,"link":lk,"img":img,"source":"vne"})
|
| 402 |
return arts
|
| 403 |
+
except: return []
|
|
|
|
| 404 |
|
| 405 |
def scrape_genk_ai():
|
| 406 |
try:
|
|
|
|
| 465 |
|
| 466 |
@app.get("/api/proxy/xlb")
|
| 467 |
def api_xlb(path: str = Query(default=""), limit: int = Query(default=20)):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 468 |
try:
|
| 469 |
url = f"https://xemlaibongda.top/{path}" if path else "https://xemlaibongda.top/"
|
| 470 |
+
r = requests.get(url, headers=HEADERS, timeout=15)
|
| 471 |
+
if r.status_code != 200: return JSONResponse({"videos": []})
|
| 472 |
r.encoding = "utf-8"
|
| 473 |
soup = BeautifulSoup(r.text, "lxml")
|
| 474 |
videos, seen = [], set()
|
|
|
|
| 497 |
if not t:
|
| 498 |
slug = clean.split("/video/")[-1].rstrip("/")
|
| 499 |
t = slug.replace("-", " ").title()
|
| 500 |
+
videos.append({"title": t[:100], "link": clean, "img": img_src, "source": "xemlaibongda"})
|
| 501 |
if len(videos) >= limit: break
|
| 502 |
+
return JSONResponse({"videos": videos})
|
| 503 |
+
except Exception as e:
|
| 504 |
+
return JSONResponse({"videos": [], "error": str(e)})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 505 |
|
| 506 |
@app.get("/api/article")
|
| 507 |
def api_article(url:str=Query(...)):
|
|
|
|
| 525 |
|
| 526 |
@app.get("/", response_class=HTMLResponse)
|
| 527 |
async def root():
|
| 528 |
+
return HTMLResponse("<h1>VNEWS v17</h1><p>VTV Digital CDN ssaimh · No shorts Dantri/SKDS · Homepage full content</p>")
|
| 529 |
+
|
| 530 |
+
# ===== WALL POSTS API =====
|
| 531 |
+
_wl_lock = threading.Lock()
|
| 532 |
+
|
| 533 |
+
def _load_wall_posts():
|
| 534 |
+
with _wl_lock:
|
| 535 |
+
return load_wall_posts()
|
| 536 |
+
|
| 537 |
+
def _save_wall_posts(posts):
|
| 538 |
+
with _wl_lock:
|
| 539 |
+
return save_wall_posts(posts)
|
| 540 |
+
|
| 541 |
+
@app.get('/api/wall')
|
| 542 |
+
def api_wall():
|
| 543 |
+
posts = _load_wall_posts()
|
| 544 |
+
if not posts:
|
| 545 |
+
return JSONResponse({"posts": []})
|
| 546 |
+
return JSONResponse({"posts": posts})
|
| 547 |
+
|
| 548 |
+
@app.post('/api/wall')
|
| 549 |
+
async def api_wall_post(request: Request):
|
| 550 |
+
content_type = request.headers.get('content-type', '')
|
| 551 |
+
if 'multipart/form-data' in content_type:
|
| 552 |
+
try:
|
| 553 |
+
form = await request.form()
|
| 554 |
+
except Exception as e:
|
| 555 |
+
return JSONResponse({"error": f"Form parse error: {str(e)}"}, status_code=400)
|
| 556 |
+
title = form.get('title', 'Video mới') or 'Video mới'
|
| 557 |
+
text = form.get('text', '') or ''
|
| 558 |
+
source = form.get('source', 'vtv_recorder') or 'vtv_recorder'
|
| 559 |
+
video_file = form.get('video')
|
| 560 |
+
post_id = str(int(time.time() * 1000))
|
| 561 |
+
video_url = None
|
| 562 |
+
if video_file and hasattr(video_file, 'filename') and video_file.filename:
|
| 563 |
+
fname = video_file.filename.lower()
|
| 564 |
+
if fname.endswith('.mp4'):
|
| 565 |
+
ext = '.mp4'
|
| 566 |
+
elif fname.endswith('.webm'):
|
| 567 |
+
ext = '.webm'
|
| 568 |
+
else:
|
| 569 |
+
ext = '.webm'
|
| 570 |
+
video_filename = f"wall_{post_id}{ext}"
|
| 571 |
+
video_path = os.path.join(DATA_DIR, 'wall_videos', video_filename)
|
| 572 |
+
os.makedirs(os.path.dirname(video_path), exist_ok=True)
|
| 573 |
+
try:
|
| 574 |
+
content = await video_file.read()
|
| 575 |
+
if not content:
|
| 576 |
+
return JSONResponse({"error": "Empty video file"}, status_code=400)
|
| 577 |
+
with open(video_path, 'wb') as f:
|
| 578 |
+
f.write(content)
|
| 579 |
+
file_size_mb = len(content) / 1024 / 1024
|
| 580 |
+
if file_size_mb > 50:
|
| 581 |
+
os.remove(video_path)
|
| 582 |
+
return JSONResponse({"error": f"Video quá lớn ({file_size_mb:.1f}MB). Tối đa 50MB."}, status_code=400)
|
| 583 |
+
video_url = f"/api/wall/video/{video_filename}"
|
| 584 |
+
except Exception as e:
|
| 585 |
+
return JSONResponse({"error": f"Lỗi lưu video: {str(e)}"}, status_code=500)
|
| 586 |
+
post = {
|
| 587 |
+
"id": post_id,
|
| 588 |
+
"title": title[:200],
|
| 589 |
+
"text": text[:2000],
|
| 590 |
+
"source": source,
|
| 591 |
+
"video": video_url,
|
| 592 |
+
"img": None,
|
| 593 |
+
"images": [],
|
| 594 |
+
"created": int(time.time()),
|
| 595 |
+
"created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
|
| 596 |
+
}
|
| 597 |
+
posts = _load_wall_posts()
|
| 598 |
+
if not isinstance(posts, list):
|
| 599 |
+
posts = []
|
| 600 |
+
posts.insert(0, post)
|
| 601 |
+
posts = posts[:200]
|
| 602 |
+
_save_wall_posts(posts)
|
| 603 |
+
return JSONResponse({"post": post, "ok": True})
|
| 604 |
+
try:
|
| 605 |
+
body = await request.json()
|
| 606 |
+
except:
|
| 607 |
+
body = {}
|
| 608 |
+
title = body.get('title', 'Bài mới') or 'Bài mới'
|
| 609 |
+
text = body.get('text', '') or ''
|
| 610 |
+
img = body.get('img', None)
|
| 611 |
+
source = body.get('source', 'user') or 'user'
|
| 612 |
+
post_id = str(int(time.time() * 1000))
|
| 613 |
+
post = {
|
| 614 |
+
"id": post_id,
|
| 615 |
+
"title": title[:200],
|
| 616 |
+
"text": text[:2000],
|
| 617 |
+
"source": source,
|
| 618 |
+
"video": None,
|
| 619 |
+
"img": img,
|
| 620 |
+
"images": [],
|
| 621 |
+
"created": int(time.time()),
|
| 622 |
+
"created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
|
| 623 |
+
}
|
| 624 |
+
posts = _load_wall_posts()
|
| 625 |
+
if not isinstance(posts, list):
|
| 626 |
+
posts = []
|
| 627 |
+
posts.insert(0, post)
|
| 628 |
+
posts = posts[:200]
|
| 629 |
+
_save_wall_posts(posts)
|
| 630 |
+
return JSONResponse({"post": post, "ok": True})
|
| 631 |
+
|
| 632 |
+
@app.get('/api/wall/video/{filename}')
|
| 633 |
+
def api_wall_video(filename: str):
|
| 634 |
+
if '..' in filename or '/' in filename:
|
| 635 |
+
return Response(status_code=403)
|
| 636 |
+
video_path = os.path.join(DATA_DIR, 'wall_videos', filename)
|
| 637 |
+
if not os.path.exists(video_path):
|
| 638 |
+
return Response(status_code=404)
|
| 639 |
+
ext = os.path.splitext(filename)[1].lower()
|
| 640 |
+
media_type = 'video/mp4' if ext == '.mp4' else 'video/webm'
|
| 641 |
+
return FileResponse(video_path, media_type=media_type)
|
| 642 |
+
|
| 643 |
+
@app.delete('/api/wall/{post_id}')
|
| 644 |
+
def api_wall_delete(post_id: str):
|
| 645 |
+
posts = _load_wall_posts()
|
| 646 |
+
if not isinstance(posts, list):
|
| 647 |
+
return JSONResponse({"error": "No posts"}, status_code=404)
|
| 648 |
+
for i, p in enumerate(posts):
|
| 649 |
+
if p.get('id') == post_id:
|
| 650 |
+
if p.get('video'):
|
| 651 |
+
video_name = p['video'].split('/')[-1]
|
| 652 |
+
video_path = os.path.join(DATA_DIR, 'wall_videos', video_name)
|
| 653 |
+
if os.path.exists(video_path):
|
| 654 |
+
os.remove(video_path)
|
| 655 |
+
posts.pop(i)
|
| 656 |
+
_save_wall_posts(posts)
|
| 657 |
+
return JSONResponse({"ok": True})
|
| 658 |
+
return JSONResponse({"error": "Post not found"}, status_code=404)
|
| 659 |
+
|
| 660 |
+
# ===== INTERACTIONS API =====
|
| 661 |
+
_il = threading.Lock()
|
| 662 |
+
@app.post('/api/v2/interact')
|
| 663 |
+
async def _int(request:Request):
|
| 664 |
+
b=await request.json();v=str(b.get('id','')).strip();t=str(b.get('type','')).strip()
|
| 665 |
+
if not v or t not in('view','like'):return JSONResponse({'error':'x'},status_code=400)
|
| 666 |
+
with _il:
|
| 667 |
+
db=load_interactions()
|
| 668 |
+
db.setdefault(v,{'views':0,'likes':0,'comments':0})
|
| 669 |
+
db[v][t+'s']+=1
|
| 670 |
+
save_interactions(db)
|
| 671 |
+
return JSONResponse(db[v])
|
| 672 |
+
|
| 673 |
+
@app.get('/api/v2/interactions')
|
| 674 |
+
def _gi(id:str=Query(...)):
|
| 675 |
+
with _il:return JSONResponse(load_interactions().get(id.strip(),{'views':0,'likes':0,'comments':0}))
|
| 676 |
+
|
| 677 |
+
# ===== COMMENTS API =====
|
| 678 |
+
_cl = threading.Lock()
|
| 679 |
+
@app.get('/api/v2/comments')
|
| 680 |
+
def _gc(id:str=Query(...)):
|
| 681 |
+
with _cl:return JSONResponse({'comments':load_comments().get(id.strip(),[])})
|
| 682 |
+
|
| 683 |
+
@app.post('/api/v2/comment')
|
| 684 |
+
async def _pc(request:Request):
|
| 685 |
+
b=await request.json();v=str(b.get('id','')).strip();tx=str(b.get('text','')).strip()[:500]
|
| 686 |
+
if not v or not tx:return JSONResponse({'error':'x'},status_code=400)
|
| 687 |
+
c={'text':tx,'time':time.strftime('%H:%M %d/%m',time.localtime()),'ts':int(time.time())}
|
| 688 |
+
with _cl:
|
| 689 |
+
db=load_comments()
|
| 690 |
+
db.setdefault(v,[])
|
| 691 |
+
db[v].append(c)
|
| 692 |
+
db[v]=db[v][-200:]
|
| 693 |
+
save_comments(db)
|
| 694 |
+
cms=db[v]
|
| 695 |
+
with _il:
|
| 696 |
+
idb=load_interactions()
|
| 697 |
+
idb.setdefault(v,{'views':0,'likes':0,'comments':0})
|
| 698 |
+
idb[v]['comments']=len(cms)
|
| 699 |
+
save_interactions(idb)
|
| 700 |
+
return JSONResponse({'comments':cms})
|
patch_ai_hot.py
DELETED
|
@@ -1,55 +0,0 @@
|
|
| 1 |
-
"""PATCH AI: prepend AI topics to hot list + homepage route fix"""
|
| 2 |
-
import re, json, time
|
| 3 |
-
from fastapi.responses import HTMLResponse
|
| 4 |
-
|
| 5 |
-
# Import at runtime to avoid circular
|
| 6 |
-
try:
|
| 7 |
-
from main import app, rt
|
| 8 |
-
import ai_runtime_final6 as f6
|
| 9 |
-
from ai_runtime_final6 import f5
|
| 10 |
-
except:
|
| 11 |
-
f6, f5, rt = None, None, None
|
| 12 |
-
|
| 13 |
-
# Patch hot_topics to prepend AI topics
|
| 14 |
-
if f6 and hasattr(f6, '_HOT_CACHE') and hasattr(f6, '_hot_topics'):
|
| 15 |
-
_orig_hot = f6._hot_topics
|
| 16 |
-
def _hot_topics_patched():
|
| 17 |
-
topics = _orig_hot()
|
| 18 |
-
# Prepend AI topics to front
|
| 19 |
-
for ai in ['Công nghệ AI', 'World Cup 2026', 'Kinh tế Việt Nam']:
|
| 20 |
-
if not any(ai.lower() == t.get('topic','').lower() for t in topics):
|
| 21 |
-
topics.insert(0, {'label': f'#{ai.replace(" ", "")}', 'topic': ai, 'count': 0})
|
| 22 |
-
return topics[:24]
|
| 23 |
-
f6._hot_topics = f6._HOT_CACHE['d'] = _hot_topics_patched()
|
| 24 |
-
f6._HOT_CACHE['t'] = time.time()
|
| 25 |
-
|
| 26 |
-
PATCH_INJECT = r'''
|
| 27 |
-
<script>
|
| 28 |
-
const AI_HOT_TOPICS = ['Công nghệ AI', 'World Cup 2026', 'Kinh tế Việt Nam', 'Bóng đá châu Âu'];
|
| 29 |
-
async function ensureHotTopics(){let i=document.getElementById('ai-topic-input-final5');if(!i||document.getElementById('ai-hot-row'))return;let r=document.createElement('div');r.id='ai-hot-row';r.style.cssText='display:flex;gap:6px;overflow-x:auto;padding:4px 0;margin:6px 0';let t=[];try{let j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));t=j.topics||[];}catch(e){}AI_HOT_TOPICS.forEach(ai=>{if(!t.find(x=>(x.topic||'').toLowerCase()===ai.toLowerCase())){t.unshift({label:'#'+ai.replace(/\s+/g,''),topic:ai});}});r.innerHTML=t.slice(0,14).map(x=>`<button class="hot-chip" style="flex:0 0 auto;background:#222;border:1px solid #333;color:#ddd;border-radius:16px;padding:5px 10px;font-size:11px;cursor:pointer" onclick="document.getElementById('ai-topic-input-final5').value='${x.topic.replace(/'/g,'\\''}';document.getElementById('ai-topic-input-final5').focus();searchTopic('${x.topic.replace(/'/g,'\\''}')">${x.label}</button>`).join('');i.insertAdjacentElement('afterend',r);}
|
| 30 |
-
setInterval(ensureHotTopics,1500);
|
| 31 |
-
</script>
|
| 32 |
-
'''
|
| 33 |
-
|
| 34 |
-
# Register homepage route
|
| 35 |
-
if app and f5 and f6:
|
| 36 |
-
# Remove old / route
|
| 37 |
-
app.router.routes = [r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
|
| 38 |
-
|
| 39 |
-
@app.get('/')
|
| 40 |
-
async def patch_homepage():
|
| 41 |
-
html = f5.f4.f3.f2.f1._load_index_html() if f5 else "<html><body></body></html>"
|
| 42 |
-
body = (getattr(rt.old,'PATCH_INJECT','') if hasattr(rt,'old') else '') + \
|
| 43 |
-
(getattr(f5.f4.f3.f2.f1,'FINAL_INJECT','') if f5 else '') + \
|
| 44 |
-
(getattr(f5.f4.f3,'FINAL3_INJECT','') if f5 else '') + \
|
| 45 |
-
(getattr(f5.f4,'FINAL4_INJECT','') if f5 else '') + \
|
| 46 |
-
(getattr(f5,'FINAL5_INJECT','') if f5 else '') + \
|
| 47 |
-
(getattr(f6,'FINAL6_INJECT','') or '') + \
|
| 48 |
-
(getattr(f6,'FINAL6_FAST_HOME_INJECT','') or '') + \
|
| 49 |
-
(getattr(f6,'FINAL6E_INJECT','') or '') + \
|
| 50 |
-
PATCH_INJECT
|
| 51 |
-
if '</body>' in html:
|
| 52 |
-
html = html.replace('</body>', body + '\n</body>')
|
| 53 |
-
else:
|
| 54 |
-
html += body
|
| 55 |
-
return HTMLResponse(html)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
rebuild_trigger.txt
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
TRIGGER_REBUILD=20260719
|
requirements.txt
CHANGED
|
@@ -11,6 +11,4 @@ pillow
|
|
| 11 |
edge-tts
|
| 12 |
python-dateutil
|
| 13 |
httpx
|
| 14 |
-
|
| 15 |
-
pycryptodome
|
| 16 |
-
# trigger rebuild 1784359130
|
|
|
|
| 11 |
edge-tts
|
| 12 |
python-dateutil
|
| 13 |
httpx
|
| 14 |
+
# trigger rebuild 1784359129
|
|
|
|
|
|
restart.txt
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
restart at 13:35
|
restart_trigger.md
DELETED
|
@@ -1,14 +0,0 @@
|
|
| 1 |
-
# VNEWS Space Restart Trigger
|
| 2 |
-
|
| 3 |
-
## Version: v20260822d
|
| 4 |
-
|
| 5 |
-
### All fixes applied:
|
| 6 |
-
1. **Scrolling fix**: `-webkit-overflow-scrolling: touch` + `scroll-behavior: smooth` on `.tiktok-feed`
|
| 7 |
-
2. **Thumbnail fix**: `.yt-thumb-wrap` with background image for YouTube iframes, YouTube hqdefault fallback
|
| 8 |
-
3. **Lazy loading**: IntersectionObserver to unload slides far from viewport
|
| 9 |
-
4. **SHORT HOT video looping**: Video plays at full duration instead of being trimmed to narration length
|
| 10 |
-
5. **CSS consistency**: Same poster/thumbnail styling for all TikTok slides
|
| 11 |
-
6. **Homepage loading**: Retry logic for `scrape_vne`, bongdaplus.vn as primary source, faster xemlaibongda timeout
|
| 12 |
-
7. **YouTube SSL error**: Increased yt-dlp retries (5), socket_timeout 30s, YouTube embed fallback
|
| 13 |
-
|
| 14 |
-
Last restart: 20260822d
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
rewrite_slide.py
CHANGED
|
@@ -84,36 +84,17 @@ def _scrape_article_full(url):
|
|
| 84 |
return None
|
| 85 |
|
| 86 |
|
| 87 |
-
def _ensure_complete_sentence(text):
|
| 88 |
-
"""Ensure text ends with complete sentence ending."""
|
| 89 |
-
text = _clean(text)
|
| 90 |
-
if not text:
|
| 91 |
-
return text
|
| 92 |
-
# Find last complete sentence ending
|
| 93 |
-
for end_char in ['.', '!', '?']:
|
| 94 |
-
last_pos = text.rfind(end_char)
|
| 95 |
-
if last_pos > len(text) * 0.5: # Keep if ending is in latter half
|
| 96 |
-
return text[:last_pos + 1].strip()
|
| 97 |
-
return text
|
| 98 |
-
|
| 99 |
def _extract_key_points(paragraphs, max_points=5):
|
| 100 |
-
"""Extract key points: take first
|
| 101 |
points = []
|
| 102 |
for p in paragraphs:
|
| 103 |
if len(points) >= max_points: break
|
| 104 |
-
#
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
if len(s) >= 30 and re.search(r'[.!?]$', s):
|
| 111 |
-
sentence = s
|
| 112 |
-
break
|
| 113 |
-
|
| 114 |
-
# If no complete sentence found, take first sentence and ensure it ends
|
| 115 |
-
if not sentence and sentences:
|
| 116 |
-
sentence = _ensure_complete_sentence(sentences[0])
|
| 117 |
|
| 118 |
# Skip if too short or duplicate
|
| 119 |
if len(sentence) < 30: continue
|
|
|
|
| 84 |
return None
|
| 85 |
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
def _extract_key_points(paragraphs, max_points=5):
|
| 88 |
+
"""Extract key points: take first sentence of each significant paragraph."""
|
| 89 |
points = []
|
| 90 |
for p in paragraphs:
|
| 91 |
if len(points) >= max_points: break
|
| 92 |
+
# Take first complete sentence (ends with . ! ?)
|
| 93 |
+
m = re.match(r'^(.+?[.!?])\s', p)
|
| 94 |
+
if m:
|
| 95 |
+
sentence = m.group(1)
|
| 96 |
+
else:
|
| 97 |
+
sentence = p[:150] + ('.' if not p.endswith('.') else '')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
# Skip if too short or duplicate
|
| 100 |
if len(sentence) < 30: continue
|
static/app_v2.js
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
static/designer_v2.js
DELETED
|
@@ -1,903 +0,0 @@
|
|
| 1 |
-
/* =========================================================================
|
| 2 |
-
* designer_v2.js — Slide Designer nâng cấp cho VNEWS
|
| 3 |
-
* - Nhiều hiệu ứng độc đáo (gradient, blur, neon, top-banner, lower-third,
|
| 4 |
-
* card, corner-frame, stars, vignette, split, spotlight, diagonal, solid)
|
| 5 |
-
* - Bộ lọc màu (grayscale, sepia, saturate, warm, cool, invert)
|
| 6 |
-
* - Chữ phát sáng (neon/glow)
|
| 7 |
-
* - Vị trí chữ (trên/giữa/dưới)
|
| 8 |
-
* - Chọn 1 / nhiều / TẤT CẢ slide trong modal sửa ảnh
|
| 9 |
-
* - Đăng lên Tường AI là bài dạng slide gồm CÁC SLIDE ĐÃ CHỌN
|
| 10 |
-
*
|
| 11 |
-
* 2026-08-12 FIX (2 lỗi báo cáo):
|
| 12 |
-
* - Bug 1: upload ảnh preview + nút "Áp dụng ảnh". _dsHandleBgFile được gắn
|
| 13 |
-
* trực tiếp làm listener 'change' nên nhận Event object, `input.files`
|
| 14 |
-
* undefined -> upload im lặng không làm gì. Giờ đọc evt.target.files.
|
| 15 |
-
* Nút "Áp dụng ảnh này cho TẤT CẢ slide đã chọn" luôn hiển thị.
|
| 16 |
-
* - Bug 2: chọn 1 ảnh rewrite áp dụng cho nhiều slide. Danh sách ảnh dùng
|
| 17 |
-
* data-index + delegated click (hết fragile inline onclick với URL thô).
|
| 18 |
-
*
|
| 19 |
-
* 2026-08-13 FIX (lỗi báo cáo tiếp):
|
| 20 |
-
* - Bug 3: "dùng 1 ảnh cho nhiều slide" vẫn chỉ áp dụng cho slide đầu.
|
| 21 |
-
* Giờ có _dsAppliedImg (ảnh đã áp dụng ở cấp thiết kế): khi chọn ảnh bất kỳ
|
| 22 |
-
* (rewrite / upload / URL) sẽ áp dụng cho TẤT CẢ slide đã chọn NGAY LẬP TỨC;
|
| 23 |
-
* khi đổi lựa chọn slide (chọn thêm / bỏ chọn / chọn tất cả) ảnh đó vẫn được
|
| 24 |
-
* áp dụng tự động cho các slide mới được chọn; khi lưu, mọi slide được chọn
|
| 25 |
-
* đều dùng ảnh đó.
|
| 26 |
-
* - Bug 4: ảnh preview hiển thị đúng tỉ lệ nhưng sau khi tạo ảnh xong hiển thị
|
| 27 |
-
* sai tỉ lệ + ảnh nền không định vị chính xác như preview. Preview và khi
|
| 28 |
-
* lưu giờ render Ở CÙNG một hàm `_dsRenderSlide(canvas, opts, cb)` (cùng
|
| 29 |
-
* resolution, cùng cover-fit, cùng filter, cùng text) nên file tạo ra khớp
|
| 30 |
-
* TUYỆT ĐỐI với preview. Ảnh trên Tường/slide viewer giữ đúng tỉ lệ khung
|
| 31 |
-
* (ko cắt xén bằng object-fit:cover nữa).
|
| 32 |
-
* - Thêm MÀU NỀN + nhiều ẢNH NỀN MẪU khi chọn "Nền đều (phủ màu tối)" —
|
| 33 |
-
* dải màu nền (solid color swatches) và bộ ảnh nền mẫu (gradient mờ) để
|
| 34 |
-
* người dùng tạo ảnh nền trơn thay vì chỉ phủ màu tối.
|
| 35 |
-
* ========================================================================= */
|
| 36 |
-
|
| 37 |
-
/* ---------------- state ---------------- */
|
| 38 |
-
let _dsState = {
|
| 39 |
-
postIdx: -1, // index into _wallPosts
|
| 40 |
-
selected: [], // array of selected slide indices (into p.slides)
|
| 41 |
-
};
|
| 42 |
-
|
| 43 |
-
/* per-slide image override map: {si: imageUrl} set by the rewrite image list */
|
| 44 |
-
let _dsImgMap = {};
|
| 45 |
-
|
| 46 |
-
/* srcs backing the rewrite image list (Bug 2 fix: index-based, no inline onclick) */
|
| 47 |
-
let _dsRewriteImgSrcs = [];
|
| 48 |
-
|
| 49 |
-
/* designer-level "applied image": the image the user picked (rewrite / upload /
|
| 50 |
-
URL). Applied to ALL selected slides, including slides selected LATER.
|
| 51 |
-
Bug 3 fix: keeps "1 ảnh cho nhiều slide" working when selection changes. */
|
| 52 |
-
let _dsAppliedImg = '';
|
| 53 |
-
|
| 54 |
-
/* solid-color background for the 'solid' layout (map because _dsOptions reads it) */
|
| 55 |
-
let _dsBgColor = '';
|
| 56 |
-
/* sample background image (proxied URL) for the 'solid' layout */
|
| 57 |
-
let _dsBgSample = '';
|
| 58 |
-
|
| 59 |
-
/* ---------------- ratio / layout helpers ---------------- */
|
| 60 |
-
function designerGetRatioV2(){
|
| 61 |
-
const v = document.getElementById('ds-ratio')?.value || '3:4';
|
| 62 |
-
const m = v.split(':');
|
| 63 |
-
return m.length === 2 ? {w: parseInt(m[0]), h: parseInt(m[1])} : {w:3,h:4};
|
| 64 |
-
}
|
| 65 |
-
function designerGetLayoutV2(){ return document.getElementById('ds-layout')?.value || 'solid'; }
|
| 66 |
-
function designerGetFilterV2(){ return document.getElementById('ds-filter')?.value || 'none'; }
|
| 67 |
-
function designerGetGlowV2(){ return document.getElementById('ds-glow')?.value || 'none'; }
|
| 68 |
-
function designerGetPosV2(){ return document.getElementById('ds-pos')?.value || 'middle'; }
|
| 69 |
-
|
| 70 |
-
/* ---------------- open modal (override) ---------------- */
|
| 71 |
-
function openSlideDesigner(idx){
|
| 72 |
-
const p = _wallPosts[idx]; if(!p || !p.slides || !p.slides.length) return;
|
| 73 |
-
_dsState.postIdx = idx;
|
| 74 |
-
// default: chỉ slide đầu tiên (slide 1 = ảnh đại diện bài đăng tường).
|
| 75 |
-
// Người dùng có thể chọn thêm bằng checkbox hoặc "Chọn tất cả".
|
| 76 |
-
_dsState.selected = p.slides.length ? [0] : [];
|
| 77 |
-
_dsBgDataUrl = null; // reset uploaded bg between opens
|
| 78 |
-
_dsTextColor = '#ffffff';
|
| 79 |
-
_dsImgMode = 'rewrite'; // default: use rewrite image
|
| 80 |
-
_dsImgMap = {}; // reset per-slide image overrides
|
| 81 |
-
_dsRewriteImgSrcs = [];
|
| 82 |
-
_dsAppliedImg = ''; // reset designer-level applied image
|
| 83 |
-
_buildDesignerModal(p);
|
| 84 |
-
}
|
| 85 |
-
|
| 86 |
-
function _buildDesignerModal(p){
|
| 87 |
-
const overlay = document.createElement('div');
|
| 88 |
-
overlay.id = 'slide-designer-overlay';
|
| 89 |
-
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';
|
| 90 |
-
let h='<div style="background:#16161a;border:2px solid #5cb87a;border-radius:16px;padding:18px;max-width:780px;width:100%;max-height:94vh;overflow-y:auto">';
|
| 91 |
-
h+='<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px"><h3 style="color:#5cb87a;margin:0;font-size:16px">🎨 Thiết kế ảnh slide</h3><button onclick="document.getElementById(\'slide-designer-overlay\').remove()" style="background:none;border:0;color:#fff;font-size:24px;cursor:pointer">✕</button></div>';
|
| 92 |
-
|
| 93 |
-
/* ---- 1. Chọn slide (1 / nhiều / tất cả) ---- */
|
| 94 |
-
h+='<div style="margin-bottom:10px"><label style="color:#888;font-size:11px;display:block;margin-bottom:6px">📌 Chọn slide để chỉnh sửa & đăng lên Tường AI:</label>';
|
| 95 |
-
h+='<div style="margin-bottom:8px;display:flex;gap:8px;flex-wrap:wrap">';
|
| 96 |
-
h+='<button id="ds-sel-all" style="background:#2d8659;border:0;color:#fff;padding:6px 12px;border-radius:8px;font-size:11px;cursor:pointer">✅ Chọn tất cả</button>';
|
| 97 |
-
h+='<button id="ds-sel-none" style="background:#333;border:0;color:#ddd;padding:6px 12px;border-radius:8px;font-size:11px;cursor:pointer">🚫 Bỏ chọn</button>';
|
| 98 |
-
h+='</div><div id="ds-slide-list" style="display:flex;flex-wrap:wrap;gap:8px">';
|
| 99 |
-
p.slides.forEach((s,i)=>{
|
| 100 |
-
const preview = s.image ? `<img src="${_proxyImg(s.image)}" style="width:100%;height:54px;object-fit:cover;border-radius:6px;margin-bottom:4px" onerror="this.style.display='none'">` : `<div style="width:100%;height:54px;background:#222;border-radius:6px;margin-bottom:4px;display:flex;align-items:center;justify-content:center;color:#555;font-size:20px">🖼️</div>`;
|
| 101 |
-
const isSel = _dsState.selected.includes(i);
|
| 102 |
-
h+=`<label data-idx="${i}" class="ds-slide-chip" style="width:110px;border:2px solid #2d8659;background:#1e2621;border-radius:10px;padding:6px;cursor:pointer;text-align:center">
|
| 103 |
-
<input type="checkbox" class="ds-slide-chk" data-idx="${i}"${isSel?' checked':''} style="accent-color:#5cb87a;width:14px;height:14px">
|
| 104 |
-
${preview}
|
| 105 |
-
<div style="color:#eee;font-size:10px;line-height:1.3;height:32px;overflow:hidden">Slide ${s.index||i+1}: ${(s.text||'').slice(0,34)}</div>
|
| 106 |
-
</label>`;
|
| 107 |
-
});
|
| 108 |
-
h+='</div></div>';
|
| 109 |
-
|
| 110 |
-
/* ---- 2. Nội dung từng slide đã chọn ---- */
|
| 111 |
-
h+='<div style="margin-bottom:10px"><label style="color:#888;font-size:11px;display:block;margin-bottom:4px">✏️ Nội dung từng slide:</label><div id="ds-texts"></div></div>';
|
| 112 |
-
|
| 113 |
-
/* ---- 3. Cấu hình thiết kế ---- */
|
| 114 |
-
h+='<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px">';
|
| 115 |
-
h+='<div><label style="color:#888;font-size:11px;display:block;margin-bottom:4px">Tỉ lệ cắt ảnh:</label><select id="ds-ratio" style="width:100%;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:12px"><option value="3:4">📱 3:4 (dọc)</option><option value="1:1">📐 1:1 (vuông)</option><option value="4:3">🖥️ 4:3 (ngang)</option><option value="16:9">🎞️ 16:9 (rộng)</option></select></div>';
|
| 116 |
-
h+='<div><label style="color:#888;font-size:11px;display:block;margin-bottom:4px">Vị trí chữ:</label><select id="ds-pos" style="width:100%;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:12px"><option value="middle">⬅️ Giữa</option><option value="top">⬆️ Trên</option><option value="bottom">⬇️ Dưới</option></select></div>';
|
| 117 |
-
h+='<div style="grid-column:1/3"><label style="color:#888;font-size:11px;display:block;margin-bottom:4px">✨ Hiệu ứng nền:</label><select id="ds-layout" style="width:100%;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:12px">';
|
| 118 |
-
h+='<option value="solid">🎨 Nền đều (phủ màu tối)</option>';
|
| 119 |
-
h+='<option value="gradient">🌈 Gradient (đổ màu)</option>';
|
| 120 |
-
h+='<option value="vignette">🌙 Vignette (viền tối dần)</option>';
|
| 121 |
-
h+='<option value="blur">🌀 Làm mờ hậu cảnh</option>';
|
| 122 |
-
h+='<option value="split">✂️ Chia đôi (trên mờ, dưới rõ)</option>';
|
| 123 |
-
h+='<option value="spotlight">🎯 Spotlight (ánh sáng giữa)</option>';
|
| 124 |
-
h+='<option value="diagonal">↘️ Đường chéo</option>';
|
| 125 |
-
h+='<option value="neon">💡 Neon (nền tối + chữ phát sáng)</option>';
|
| 126 |
-
h+='<option value="top-banner">🏷️ Banner trên cùng</option>';
|
| 127 |
-
h+='<option value="lower-third">📺 Lower-third (chữ dưới)</option>';
|
| 128 |
-
h+='<option value="card">🃏 Card bo góc</option>';
|
| 129 |
-
h+='<option value="corner">🖼️ Khung viền góc</option>';
|
| 130 |
-
h+='<option value="stars">🌠 Nền sao lấp lánh</option>';
|
| 131 |
-
h+='</select></div>';
|
| 132 |
-
h+='<div><label style="color:#888;font-size:11px;display:block;margin-bottom:4px">🎚️ Bộ lọc màu:</label><select id="ds-filter" style="width:100%;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:12px"><option value="none">Nguyên bản</option><option value="grayscale">⚪ Đen trắng</option><option value="sepia">🟤 Sepia</option><option value="saturate">🎨 Bão hòa mạnh</option><option value="warm">🔥 Ấm áp</option><option value="cool">❄️ Mát lạnh</option><option value="invert">🔁 Đảo màu</option><option value="noir">🖤 Noir tương phản</option></select></div>';
|
| 133 |
-
h+='<div><label style="color:#888;font-size:11px;display:block;margin-bottom:4px">💡 Phát sáng chữ:</label><select id="ds-glow" style="width:100%;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:12px"><option value="none">Tắt</option><option value="white">✨ Tỏa trắng</option><option value="color">🌈 Tỏa màu chữ</option><option value="neon">💡 Neon mạnh</option></select></div>';
|
| 134 |
-
h+='</div>';
|
| 135 |
-
|
| 136 |
-
/* ---- 3b. Màu nền + ảnh nền mẫu (chỉ hiển thị khi chọn "Nền đều/solid") ---- */
|
| 137 |
-
h+='<div id="ds-bg-solid-wrap" style="display:none;margin-bottom:10px;padding:10px;background:#1e2621;border:1px solid #2d8659;border-radius:10px">';
|
| 138 |
-
h+='<div style="color:#888;font-size:11px;display:block;margin-bottom:6px">🎨 Màu nền (nền trơn):</div><div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px" id="ds-bg-colors">';
|
| 139 |
-
['#0f172a','#1e293b','#334155','#1a1a2e','#16213e','#0f3460','#202040','#2d3436','#2c2c54','#3d1e1e','#4a1e3d','#1e3d2f','#3a2e1e','#111827','#0b0b1a','#233329'].forEach(c=>{h+=`<button class="ds-bgcolor-btn" data-color="${c}" style="width:30px;height:30px;border-radius:8px;border:2px solid #333;background:${c};cursor:pointer;padding:0" title="${c}"></button>`;});
|
| 140 |
-
h+='</div>';
|
| 141 |
-
h+='<div style="color:#888;font-size:11px;display:block;margin-bottom:6px">🖼️ Hoặc chọn ẢNH NỀN MẪU:</div><div style="display:flex;flex-wrap:wrap;gap:6px" id="ds-bg-samples">';
|
| 142 |
-
_DS_BG_SAMPLES.forEach((b,bi)=>{h+=`<button type="button" class="ds-bgsample-btn" data-bi="${bi}" style="width:52px;height:68px;border-radius:8px;border:2px solid #333;background:#222;background-image:url('${_dsImageSrc(b)}');background-size:cover;background-position:center;cursor:pointer;padding:0;position:relative"><span style="position:absolute;right:3px;top:3px;font-size:9px;line-height:1;color:#fff;text-shadow:0 0 3px #000">🖼️</span></button>`;});
|
| 143 |
-
h+='</div>';
|
| 144 |
-
h+='<div style="color:#666;font-size:9px;margin-top:6px">Chọn màu nền hoặc ảnh nền mẫu — ảnh nền mẫu sẽ được phủ mờ để chữ dễ đọc. Nếu không chọn, dùng màu tối mặc định.</div>';
|
| 145 |
-
h+='</div>';
|
| 146 |
-
|
| 147 |
-
/* ---- 4. Màu chữ + nền ---- */
|
| 148 |
-
h+='<div style="margin-bottom:10px"><label style="color:#888;font-size:11px;display:block;margin-bottom:4px">🎨 Màu chữ:</label><div style="display:flex;gap:6px;flex-wrap:wrap" id="ds-colors">';
|
| 149 |
-
['#ffffff','#000000','#ff4444','#44ff44','#4444ff','#ffff00','#ff00ff','#00ffff','#ff8800','#88ff00','#ffd700','#ff69b4'].forEach(c=>{h+=`<button class="ds-color-btn" data-color="${c}" style="width:26px;height:26px;border-radius:50%;border:2px solid ${c==='#ffffff'?'#888':'#333'};background:${c};cursor:pointer;padding:0"></button>`;});
|
| 150 |
-
h+='</div></div>';
|
| 151 |
-
|
| 152 |
-
/* ---- 5. Ảnh nền (source buttons + URL/upload) ---- */
|
| 153 |
-
h+='<div style="margin-bottom:6px"><label style="color:#888;font-size:11px;display:block;margin-bottom:4px">🖼️ Ảnh nền:</label>';
|
| 154 |
-
h+='<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:6px">';
|
| 155 |
-
h+='<button id="ds-img-src-rewrite" class="ds-img-src-btn" style="background:#2d8659;border:0;color:#fff;padding:6px 10px;border-radius:8px;font-size:10px;cursor:pointer">📰 Ảnh từ bài rewrite</button>';
|
| 156 |
-
h+='<button id="ds-img-src-custom" class="ds-img-src-btn" style="background:#333;border:0;color:#ccc;padding:6px 10px;border-radius:8px;font-size:10px;cursor:pointer">🖼️ Ảnh riêng (URL/upload)</button>';
|
| 157 |
-
h+='<button id="ds-img-src-none" class="ds-img-src-btn" style="background:#333;border:0;color:#ccc;padding:6px 10px;border-radius:8px;font-size:10px;cursor:pointer">🚫 Không ảnh (nền trơn)</button>';
|
| 158 |
-
h+='</div>';
|
| 159 |
-
/* URL + upload panel (only the URL/upload inputs collapse; apply button stays visible) */
|
| 160 |
-
h+='<div id="ds-custom-img-url-wrap" style="display:none">';
|
| 161 |
-
h+='<input id="ds-bg-url" type="text" placeholder="https://... (URL ảnh)" style="width:100%;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:12px;margin-bottom:6px">';
|
| 162 |
-
h+='<input type="file" id="ds-bg-file" accept="image/*" style="font-size:11px;color:#888">';
|
| 163 |
-
h+='</div>';
|
| 164 |
-
/* "Áp dụng ảnh này cho TẤT CẢ slide đã chọn" — ALWAYS visible (Bug 1 fix) */
|
| 165 |
-
h+='<div style="display:flex;gap:6px;margin-top:6px;flex-wrap:wrap;align-items:center"><button id="ds-apply-img-btn" style="background:#2d8659;border:0;color:#fff;padding:6px 12px;border-radius:8px;font-size:10px;cursor:pointer">📌 Áp dụng ảnh này cho TẤT CẢ slide đã chọn</button><span id="ds-apply-img-hint" style="color:#5cb87a;font-size:9px;line-height:1.6">✓ Áp dụng để dùng 1 ảnh cho nhiều slide</span></div>';
|
| 166 |
-
h+='<div style="color:#888;font-size:10px">Ảnh tải lên/URL nhập từ ngoài (hoặc chọn từ danh sách dưới) sẽ thay thế ảnh bài rewrite và áp dụng cho TẤT CẢ slide đã chọn.</div>';
|
| 167 |
-
h+='<div id="ds-rewrite-imgs" style="margin-top:8px;display:none"><label style="color:#888;font-size:10px;display:block;margin-bottom:4px">📋 Danh sách ảnh từ bài rewrite (bấm để chọn ảnh):</label><div id="ds-rewrite-list" style="display:flex;flex-wrap:wrap;gap:6px"></div></div>';
|
| 168 |
-
h+='</div>';
|
| 169 |
-
|
| 170 |
-
h+='<div style="margin-top:14px;display:flex;gap:8px;justify-content:center;flex-wrap:wrap"><button id="ds-preview-btn" style="background:#2d8659;border:0;color:#fff;padding:10px 18px;border-radius:10px;font-size:12px;font-weight:700;cursor:pointer">👁 Xem trước</button><button id="ds-save-btn" style="background:#1a3a5a;border:1px solid #3a5a7b;color:#fff;padding:10px 18px;border-radius:10px;font-size:12px;font-weight:700;cursor:pointer">📤 Lưu & Đăng lên Tường AI ('+p.slides.length+' slide)</button></div>';
|
| 171 |
-
h+='<div id="ds-preview-area" style="margin-top:12px;text-align:center;display:none"></div>';
|
| 172 |
-
h+='<div id="ds-status" style="color:#888;font-size:11px;margin-top:8px;text-align:center"></div>';
|
| 173 |
-
overlay.innerHTML=h; document.body.appendChild(overlay);
|
| 174 |
-
|
| 175 |
-
/* button label update fn */
|
| 176 |
-
window._dsUpdateSaveLabel = function(){
|
| 177 |
-
const n = _dsState.selected.length;
|
| 178 |
-
const b = document.getElementById('ds-save-btn');
|
| 179 |
-
if(b) b.textContent = '📤 Lưu & Đăng lên Tường AI ('+n+' slide)';
|
| 180 |
-
};
|
| 181 |
-
|
| 182 |
-
/* ---- slide selection wiring ---- */
|
| 183 |
-
overlay.querySelectorAll('.ds-slide-chk').forEach(chk=>{
|
| 184 |
-
chk.addEventListener('change', _dsSyncSelection);
|
| 185 |
-
});
|
| 186 |
-
document.getElementById('ds-sel-all').addEventListener('click', ()=>{ _setAllSelected(true); });
|
| 187 |
-
document.getElementById('ds-sel-none').addEventListener('click', ()=>{ _setAllSelected(false); });
|
| 188 |
-
document.getElementById('ds-slide-list').addEventListener('click', e=>{
|
| 189 |
-
const lbl = e.target.closest('.ds-slide-chip');
|
| 190 |
-
if(!lbl) return;
|
| 191 |
-
const chk = lbl.querySelector('.ds-slide-chk');
|
| 192 |
-
if(e.target === chk) return;
|
| 193 |
-
chk.checked = !chk.checked; _dsSyncSelection();
|
| 194 |
-
});
|
| 195 |
-
|
| 196 |
-
/* ---- inputs wiring ---- */
|
| 197 |
-
document.getElementById('ds-preview-btn').addEventListener('click', _dsPreview);
|
| 198 |
-
document.getElementById('ds-save-btn').addEventListener('click', ()=>_dsSave(p));
|
| 199 |
-
['ds-texts','ds-ratio','ds-layout','ds-filter','ds-glow','ds-pos'].forEach(id=>{
|
| 200 |
-
['change','input'].forEach(evt=>{
|
| 201 |
-
const el=document.getElementById(id); if(el) el.addEventListener(evt, ()=>_dsDebounce(_dsPreview, 150));
|
| 202 |
-
});
|
| 203 |
-
});
|
| 204 |
-
// URL input: as user types a custom image URL, live-preview + apply to selected
|
| 205 |
-
const bgUrlInput=document.getElementById('ds-bg-url');
|
| 206 |
-
if(bgUrlInput){
|
| 207 |
-
bgUrlInput.addEventListener('input', ()=>{
|
| 208 |
-
const v=bgUrlInput.value.trim();
|
| 209 |
-
if(v && v.length>8){
|
| 210 |
-
_dsSetImgMode('custom');
|
| 211 |
-
const proxied=_dsImageSrc(v);
|
| 212 |
-
_dsAppliedImg = proxied; // Bug 3: applies to current + future selections
|
| 213 |
-
const sel=_dsState.selected;
|
| 214 |
-
if(sel.length){ sel.forEach(si=>{ _dsImgMap[si]=proxied; }); }
|
| 215 |
-
else { _dsImgMap[0]=proxied; }
|
| 216 |
-
}
|
| 217 |
-
_dsDebounce(_dsPreview,150);
|
| 218 |
-
});
|
| 219 |
-
}
|
| 220 |
-
// Bug 1 fix: pass through the event; _dsHandleBgFile reads evt.target.files
|
| 221 |
-
document.getElementById('ds-bg-file').addEventListener('change', _dsHandleBgFile);
|
| 222 |
-
['ds-img-src-rewrite','ds-img-src-custom','ds-img-src-none'].forEach(id=>{
|
| 223 |
-
const b=document.getElementById(id);
|
| 224 |
-
if(b) b.addEventListener('click', ()=>{
|
| 225 |
-
_dsSetImgMode(id==='ds-img-src-rewrite'?'rewrite':id==='ds-img-src-custom'?'custom':'none');
|
| 226 |
-
});
|
| 227 |
-
});
|
| 228 |
-
/* "Áp dụng ảnh này cho TẤT CẢ slide đã chọn" — always-visible apply button */
|
| 229 |
-
const applyBtn=document.getElementById('ds-apply-img-btn');
|
| 230 |
-
if(applyBtn){
|
| 231 |
-
applyBtn.addEventListener('click', function(){
|
| 232 |
-
let chosen = _dsBgDataUrl || (document.getElementById('ds-bg-url')?.value||'');
|
| 233 |
-
if(!chosen){ toast('⚠️ Chọn ảnh (tải lên hoặc nhập URL) trước khi áp dụng'); return; }
|
| 234 |
-
_dsSetImgMode('custom');
|
| 235 |
-
let proxied = _dsImageSrc(chosen);
|
| 236 |
-
_dsAppliedImg = proxied; // Bug 3 fix
|
| 237 |
-
dsApplyImgToSelected(proxied);
|
| 238 |
-
});
|
| 239 |
-
}
|
| 240 |
-
// Toggle the "nền trơn" (solid) color + sample-image panel when layout changes
|
| 241 |
-
const layoutSel=document.getElementById('ds-layout');
|
| 242 |
-
if(layoutSel){
|
| 243 |
-
const toggleSolidPanel=()=>{
|
| 244 |
-
const wrap=document.getElementById('ds-bg-solid-wrap');
|
| 245 |
-
if(wrap) wrap.style.display = layoutSel.value==='solid' ? 'block' : 'none';
|
| 246 |
-
};
|
| 247 |
-
layoutSel.addEventListener('change', toggleSolidPanel);
|
| 248 |
-
layoutSel.addEventListener('input', toggleSolidPanel);
|
| 249 |
-
toggleSolidPanel();
|
| 250 |
-
}
|
| 251 |
-
// Solid background color swatches
|
| 252 |
-
const bgColorBox=document.getElementById('ds-bg-colors');
|
| 253 |
-
if(bgColorBox){
|
| 254 |
-
bgColorBox.addEventListener('click', e=>{
|
| 255 |
-
const b=e.target.closest('.ds-bgcolor-btn'); if(!b) return;
|
| 256 |
-
document.querySelectorAll('#ds-bg-colors .ds-bgcolor-btn').forEach(x=>x.style.borderColor='#333');
|
| 257 |
-
b.style.borderColor='#fff';
|
| 258 |
-
_dsBgColor = b.dataset.color;
|
| 259 |
-
_dsBgSample = ''; // color takes precedence over sample image
|
| 260 |
-
document.querySelectorAll('#ds-bg-samples .ds-bgsample-btn').forEach(x=>x.style.borderColor='#333');
|
| 261 |
-
_dsStatus('🎨 Đã chọn màu nền: '+_dsBgColor);
|
| 262 |
-
_dsDebounce(_dsPreview, 150);
|
| 263 |
-
});
|
| 264 |
-
}
|
| 265 |
-
// Sample background images (only for solid layout)
|
| 266 |
-
const bgSampleBox=document.getElementById('ds-bg-samples');
|
| 267 |
-
if(bgSampleBox){
|
| 268 |
-
bgSampleBox.addEventListener('click', e=>{
|
| 269 |
-
const b=e.target.closest('.ds-bgsample-btn'); if(!b) return;
|
| 270 |
-
const bi=parseInt(b.dataset.bi,10);
|
| 271 |
-
const src=_DS_BG_SAMPLES[bi];
|
| 272 |
-
if(!src) return;
|
| 273 |
-
document.querySelectorAll('#ds-bg-samples .ds-bgsample-btn').forEach(x=>x.style.borderColor='#333');
|
| 274 |
-
b.style.borderColor='#ffd700';
|
| 275 |
-
_dsBgSample = _dsImageSrc(src);
|
| 276 |
-
_dsBgColor = ''; // sample image takes precedence over color
|
| 277 |
-
document.querySelectorAll('#ds-bg-colors .ds-bgcolor-btn').forEach(x=>x.style.borderColor='#333');
|
| 278 |
-
_dsStatus('🖼️ Đã chọn ảnh nền mẫu');
|
| 279 |
-
_dsDebounce(_dsPreview, 150);
|
| 280 |
-
});
|
| 281 |
-
}
|
| 282 |
-
_syncImgModeBtns();
|
| 283 |
-
_dsRefreshRewriteImgs(p);
|
| 284 |
-
document.getElementById('ds-colors').addEventListener('click', e=>{
|
| 285 |
-
const b = e.target.closest('.ds-color-btn'); if(!b) return;
|
| 286 |
-
overlay.querySelectorAll('.ds-color-btn').forEach(x=>x.style.borderColor='#333');
|
| 287 |
-
b.style.borderColor='#fff';
|
| 288 |
-
_dsTextColor = b.dataset.color;
|
| 289 |
-
_dsDebounce(_dsPreview, 150);
|
| 290 |
-
});
|
| 291 |
-
|
| 292 |
-
_dsRefreshTexts(p);
|
| 293 |
-
_dsUpdateSaveLabel();
|
| 294 |
-
_dsPreview();
|
| 295 |
-
}
|
| 296 |
-
|
| 297 |
-
/* ---------------- selection sync ---------------- */
|
| 298 |
-
function _setAllSelected(on){
|
| 299 |
-
_dsState.selected = [];
|
| 300 |
-
document.querySelectorAll('#ds-slide-list .ds-slide-chk').forEach(chk=>{ chk.checked = on; if(on) _dsState.selected.push(parseInt(chk.dataset.idx)); });
|
| 301 |
-
// Bug 3 fix: if an image was already applied at designer level, push it to
|
| 302 |
-
// every slide that is (now) selected so "1 ảnh cho nhiều slide" also covers
|
| 303 |
-
// slides selected AFTER the image was picked.
|
| 304 |
-
if(on && _dsAppliedImg){
|
| 305 |
-
_dsState.selected.forEach(si=>{ _dsImgMap[si] = _dsAppliedImg; });
|
| 306 |
-
}
|
| 307 |
-
const p = _wallPosts[_dsState.postIdx];
|
| 308 |
-
if(p) _dsRefreshTexts(p);
|
| 309 |
-
_dsUpdateSaveLabel();
|
| 310 |
-
_dsDebounce(_dsPreview, 150);
|
| 311 |
-
}
|
| 312 |
-
function _dsSyncSelection(){
|
| 313 |
-
_dsState.selected = [];
|
| 314 |
-
document.querySelectorAll('#ds-slide-list .ds-slide-chk:checked').forEach(chk=>{ _dsState.selected.push(parseInt(chk.dataset.idx)); });
|
| 315 |
-
// Bug 3 fix: same propagation for checkbox changes.
|
| 316 |
-
if(_dsAppliedImg){
|
| 317 |
-
_dsState.selected.forEach(si=>{ if(!_dsImgMap[si]) _dsImgMap[si] = _dsAppliedImg; });
|
| 318 |
-
}
|
| 319 |
-
const p = _wallPosts[_dsState.postIdx];
|
| 320 |
-
if(p) _dsRefreshTexts(p);
|
| 321 |
-
_dsUpdateSaveLabel();
|
| 322 |
-
_dsDebounce(_dsPreview, 150);
|
| 323 |
-
}
|
| 324 |
-
|
| 325 |
-
/* ---------------- per-slide text areas ---------------- */
|
| 326 |
-
function _dsRefreshTexts(p){
|
| 327 |
-
const box = document.getElementById('ds-texts'); if(!box) return;
|
| 328 |
-
if(!_dsState.selected.length){ box.innerHTML='<div style="color:#888;font-size:11px;padding:6px">Chưa chọn slide nào.</div>'; return; }
|
| 329 |
-
let h='<div style="display:flex;flex-direction:column;gap:6px">';
|
| 330 |
-
_dsState.selected.forEach((si, k)=>{
|
| 331 |
-
const s = p.slides[si];
|
| 332 |
-
h+=`<textarea data-sivec="${k}" data-sidx="${si}" style="width:100%;min-height:44px;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:13px;resize:vertical">${esc(s.text||'')}</textarea>`;
|
| 333 |
-
});
|
| 334 |
-
h+='</div>';
|
| 335 |
-
box.innerHTML=h;
|
| 336 |
-
box.querySelectorAll('textarea').forEach(ta=>{
|
| 337 |
-
ta.addEventListener('input', ()=>{ _dsDebounce(_dsPreview, 200); });
|
| 338 |
-
});
|
| 339 |
-
}
|
| 340 |
-
|
| 341 |
-
/* ---------------- bg file ---------------- */
|
| 342 |
-
let _dsBgDataUrl = null;
|
| 343 |
-
let _dsTextColor = '#ffffff';
|
| 344 |
-
let _dsImgMode = 'rewrite'; // 'rewrite' | 'custom' | 'none'
|
| 345 |
-
/* Bug 1 fix: this is bound as a 'change' listener, so the first arg is the
|
| 346 |
-
Event. Read the <input> from evt.target.files (previously input.files was
|
| 347 |
-
undefined, so the upload silently did nothing and the preview/apply button
|
| 348 |
-
never saw the image). */
|
| 349 |
-
function _dsHandleBgFile(evt){
|
| 350 |
-
const input = evt && evt.target ? evt.target : evt;
|
| 351 |
-
const file = (input && input.files && input.files[0]) ? input.files[0] : null;
|
| 352 |
-
if(!file) return;
|
| 353 |
-
_dsSetImgMode('custom'); // show the custom-img URL/upload panel
|
| 354 |
-
const reader=new FileReader();
|
| 355 |
-
reader.onload=function(e){
|
| 356 |
-
_dsBgDataUrl=e.target.result;
|
| 357 |
-
_dsImgMode='custom';
|
| 358 |
-
_syncImgModeBtns();
|
| 359 |
-
_dsStatus('✅ Đã chọn ảnh: '+(file.name||'').slice(0,30) + (file.size? ' ('+Math.round(file.size/1024)+'KB)':''));
|
| 360 |
-
// Auto-apply uploaded image to ALL currently selected slides so the upload
|
| 361 |
-
// has an immediate, visible effect (preview + save will use it for every slide).
|
| 362 |
-
_dsAppliedImg = _dsBgDataUrl; // Bug 3 fix: applies to future selections too
|
| 363 |
-
const sel=_dsState.selected;
|
| 364 |
-
if(sel.length){ sel.forEach(si=>{ _dsImgMap[si]=_dsBgDataUrl; }); }
|
| 365 |
-
else { _dsImgMap[0]=_dsBgDataUrl; }
|
| 366 |
-
_dsDebounce(_dsPreview,150);
|
| 367 |
-
};
|
| 368 |
-
reader.readAsDataURL(file);
|
| 369 |
-
}
|
| 370 |
-
/* Show a transient status line in the designer footer. */
|
| 371 |
-
function _dsStatus(msg){
|
| 372 |
-
const s=document.getElementById('ds-status'); if(s) s.textContent=msg;
|
| 373 |
-
}
|
| 374 |
-
function _syncImgModeBtns(){
|
| 375 |
-
['ds-img-src-rewrite','ds-img-src-custom','ds-img-src-none'].forEach(id=>{
|
| 376 |
-
const b=document.getElementById(id);
|
| 377 |
-
if(!b) return;
|
| 378 |
-
const on = (id==='ds-img-src-rewrite' && _dsImgMode==='rewrite') ||
|
| 379 |
-
(id==='ds-img-src-custom' && _dsImgMode==='custom') ||
|
| 380 |
-
(id==='ds-img-src-none' && _dsImgMode==='none');
|
| 381 |
-
b.style.background = on ? '#2d8659' : '#333';
|
| 382 |
-
b.style.color = on ? '#fff' : '#ccc';
|
| 383 |
-
});
|
| 384 |
-
const wrap=document.getElementById('ds-custom-img-url-wrap');
|
| 385 |
-
if(wrap) wrap.style.display = _dsImgMode==='custom' ? 'block' : 'none';
|
| 386 |
-
}
|
| 387 |
-
function _dsSetImgMode(m){
|
| 388 |
-
_dsImgMode = m;
|
| 389 |
-
_syncImgModeBtns();
|
| 390 |
-
_dsDebounce(_dsPreview, 150);
|
| 391 |
-
}
|
| 392 |
-
|
| 393 |
-
/* Build a list of available rewrite images (post.img + each slide.image)
|
| 394 |
-
and render them so the user can pick 1 image to apply to the selected slides.
|
| 395 |
-
Bug 2 fix: uses data-index + a delegated click handler (no fragile inline
|
| 396 |
-
onclick embedding the raw URL, which broke on URLs containing quotes). */
|
| 397 |
-
function _dsRefreshRewriteImgs(p){
|
| 398 |
-
const box = document.getElementById('ds-rewrite-list');
|
| 399 |
-
if(!box) return;
|
| 400 |
-
const imgs = [];
|
| 401 |
-
const seen = new Set();
|
| 402 |
-
// post-level image first
|
| 403 |
-
if(p.img && typeof p.img === 'string' && p.img.length){
|
| 404 |
-
const key = p.img.startsWith('/api/') ? p.img : _dsImageSrc(p.img);
|
| 405 |
-
if(!seen.has(key)){ seen.add(key); imgs.push({src: key, label: 'Ảnh bài (đại diện)'}); }
|
| 406 |
-
}
|
| 407 |
-
// then per-slide images
|
| 408 |
-
if(Array.isArray(p.slides)){
|
| 409 |
-
p.slides.forEach((s, i)=>{
|
| 410 |
-
const raw = s.image || s.img || '';
|
| 411 |
-
if(raw && typeof raw === 'string' && raw.length){
|
| 412 |
-
const key = raw.startsWith('/api/') ? raw : _dsImageSrc(raw);
|
| 413 |
-
if(!seen.has(key)){ seen.add(key); imgs.push({src: key, label: `Slide ${s.index||i+1}`}); }
|
| 414 |
-
}
|
| 415 |
-
});
|
| 416 |
-
}
|
| 417 |
-
if(!imgs.length){
|
| 418 |
-
box.innerHTML = '<div style="color:#666;font-size:10px">Không có ảnh rewrite nào.</div>';
|
| 419 |
-
const wrap = document.getElementById('ds-rewrite-imgs');
|
| 420 |
-
if(wrap) wrap.style.display = 'none';
|
| 421 |
-
return;
|
| 422 |
-
}
|
| 423 |
-
const wrap = document.getElementById('ds-rewrite-imgs');
|
| 424 |
-
if(wrap) wrap.style.display = 'block';
|
| 425 |
-
_dsRewriteImgSrcs = imgs.map(x=>x.src); // index -> src
|
| 426 |
-
let h='';
|
| 427 |
-
imgs.forEach((img, idx)=>{
|
| 428 |
-
h += '<div style="display:flex;flex-direction:column;align-items:center;gap:2px">'
|
| 429 |
-
+ '<img src="'+esc(img.src)+'" data-ds-rw="'+idx+'" style="width:48px;height:48px;object-fit:cover;border-radius:6px;border:2px solid #2d8659;cursor:pointer" '
|
| 430 |
-
+ 'onerror="this.style.display=\'none\'">'
|
| 431 |
-
+ '<div style="color:#aaa;font-size:8px;text-align:center">'+img.label+'</div>'
|
| 432 |
-
+ '</div>';
|
| 433 |
-
});
|
| 434 |
-
box.innerHTML = h;
|
| 435 |
-
// Bug 2 fix: delegated click handler (event delegation, URL-safe)
|
| 436 |
-
box.onclick = function(e){
|
| 437 |
-
const im = e.target.closest('img[data-ds-rw]');
|
| 438 |
-
if(!im) return;
|
| 439 |
-
const idx = parseInt(im.getAttribute('data-ds-rw'), 10);
|
| 440 |
-
const src = _dsRewriteImgSrcs[idx];
|
| 441 |
-
if(src) dsApplyImgToSelected(src);
|
| 442 |
-
};
|
| 443 |
-
}
|
| 444 |
-
|
| 445 |
-
/* Apply a chosen image src to ALL selected slides (or the current preview slide). */
|
| 446 |
-
function dsApplyImgToSelected(imgSrc){
|
| 447 |
-
_dsAppliedImg = imgSrc; // Bug 3 fix: remember at designer level (future selections too)
|
| 448 |
-
const sel = _dsState.selected;
|
| 449 |
-
if(sel.length){
|
| 450 |
-
sel.forEach(si=>{ _dsImgMap[si] = imgSrc; });
|
| 451 |
-
}else{
|
| 452 |
-
_dsImgMap[0] = imgSrc;
|
| 453 |
-
}
|
| 454 |
-
_dsImgMode = 'custom'; // visually marks we are using a chosen image
|
| 455 |
-
_syncImgModeBtns();
|
| 456 |
-
_dsStatus('✅ Đã áp dụng ảnh cho '+(sel.length? sel.length+' slide đã chọn':'slide hiện tại'));
|
| 457 |
-
// refresh preview immediately (selected slide 0 is what we preview)
|
| 458 |
-
const p=_wallPosts[_dsState.postIdx];
|
| 459 |
-
if(p && sel.length){ _dsOptions(p, sel[0]); }
|
| 460 |
-
_dsPreview();
|
| 461 |
-
}
|
| 462 |
-
|
| 463 |
-
function _dsDebounce(fn,d){ clearTimeout(fn._t); fn._t=setTimeout(fn,d); }
|
| 464 |
-
|
| 465 |
-
/* ---------------- get design options ---------------- */
|
| 466 |
-
function _dsOptions(p, si){
|
| 467 |
-
const s = p.slides[si];
|
| 468 |
-
const ta = document.querySelector(`#ds-texts textarea[data-sidx="${si}"]`);
|
| 469 |
-
const text = ta ? ta.value : (s.text||'');
|
| 470 |
-
const bgInput = document.getElementById('ds-bg-url')?.value||'';
|
| 471 |
-
// Per-slide image override: when user picks an image from the rewrite list,
|
| 472 |
-
// it is stored in _dsImgMap[si] and applied to that specific slide.
|
| 473 |
-
let bgUrl = '';
|
| 474 |
-
// Solid layout + user picked a SAMPLE background image -> use it as the
|
| 475 |
-
// background photo (dark overlay for readability) instead of a flat color.
|
| 476 |
-
const isSolid = designerGetLayoutV2()==='solid';
|
| 477 |
-
const useSample = isSolid && _dsBgSample && !_dsAppliedImg;
|
| 478 |
-
if(useSample) bgUrl = _dsBgSample;
|
| 479 |
-
// Bug 3 fix: designer-level applied image wins for every selected slide.
|
| 480 |
-
// Applied when the user picked an image (rewrite picker / upload / URL /
|
| 481 |
-
// "Áp dụng" button) — even for slides selected AFTER the image was picked.
|
| 482 |
-
if(!bgUrl && _dsAppliedImg) bgUrl = _dsAppliedImg;
|
| 483 |
-
if(!bgUrl && _dsImgMap[si]) bgUrl = _dsImgMap[si];
|
| 484 |
-
if(!bgUrl){
|
| 485 |
-
if(_dsImgMode === 'custom'){
|
| 486 |
-
bgUrl = _dsBgDataUrl || bgInput || '';
|
| 487 |
-
} else if(_dsImgMode === 'rewrite'){
|
| 488 |
-
bgUrl = s.image || p.img || '';
|
| 489 |
-
} // none -> ''
|
| 490 |
-
}
|
| 491 |
-
return {
|
| 492 |
-
si, s, text, bgUrl,
|
| 493 |
-
ratio: designerGetRatioV2(),
|
| 494 |
-
layout: isSolid ? 'solid' : designerGetLayoutV2(),
|
| 495 |
-
filter: designerGetFilterV2(),
|
| 496 |
-
glow: designerGetGlowV2(),
|
| 497 |
-
pos: designerGetPosV2(),
|
| 498 |
-
color: _dsTextColor,
|
| 499 |
-
bgColor: _dsBgColor, // solid-layout background color ('' = default dark)
|
| 500 |
-
bgSample: useSample ? _dsBgSample : '', // 'sample' mode: img passed to draw
|
| 501 |
-
};
|
| 502 |
-
}
|
| 503 |
-
|
| 504 |
-
/* ---------------- drawing core ---------------- */
|
| 505 |
-
function _dsImageSrc(url){
|
| 506 |
-
if(!url) return '';
|
| 507 |
-
if(url.startsWith('/api/') || url.startsWith('data:')) return url;
|
| 508 |
-
return '/api/proxy/img?url='+encodeURIComponent(url);
|
| 509 |
-
}
|
| 510 |
-
/* Only tag crossOrigin=anonymous for proxied EXTERNAL images.
|
| 511 |
-
Setting it on data: URLs or local /api/ paths can cause silent onload
|
| 512 |
-
failures (black preview) in some browser/CORS configurations. */
|
| 513 |
-
function _dsMakeImg(url){
|
| 514 |
-
const img=new Image();
|
| 515 |
-
if(url && !url.startsWith('data:') && !url.startsWith('/api/')) img.crossOrigin='anonymous';
|
| 516 |
-
return img;
|
| 517 |
-
}
|
| 518 |
-
|
| 519 |
-
/* Sample background images for the "Nền đều (solid)" layout — abstract
|
| 520 |
-
gradients that look good with white text. Served through the /api/proxy/img
|
| 521 |
-
endpoint (same-origin, no crossOrigin needed — _dsMakeImg handles that).
|
| 522 |
-
Keyed for the modal background swatches and for _dsDrawLayout. */
|
| 523 |
-
const _DS_BG_SAMPLES = [
|
| 524 |
-
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=900&q=60', // sơn hùng vĩ (núi)
|
| 525 |
-
'https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=900&q=60', // sương mù rừng
|
| 526 |
-
'https://images.unsplash.com/photo-1462331940025-496dfbfc7564?w=900&q=60', // vũ trụ
|
| 527 |
-
'https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=900&q=60', // núi rừng
|
| 528 |
-
'https://images.unsplash.com/photo-1499002238440-d264edd596ec?w=900&q=60', // hoàng hôn
|
| 529 |
-
'https://images.unsplash.com/photo-1448375240586-882707db888b?w=900&q=60', // rừng tối
|
| 530 |
-
'https://images.unsplash.com/photo-1519681393784-d120267933ba?w=900&q=60', // trời sao
|
| 531 |
-
'https://images.unsplash.com/photo-1518173946687-a4c8892bbd9f?w=900&q=60', // biển đêm
|
| 532 |
-
'https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?w=900&q=60', // đồng cỏ
|
| 533 |
-
];
|
| 534 |
-
|
| 535 |
-
/* draw bg image with cover-fit + color filter */
|
| 536 |
-
function _dsDrawBg(ctx, W, H, bgUrl, filter, blur){
|
| 537 |
-
ctx.save();
|
| 538 |
-
ctx.fillStyle='#111'; ctx.fillRect(0,0,W,H);
|
| 539 |
-
// filter string
|
| 540 |
-
let f='';
|
| 541 |
-
if(filter==='grayscale') f='grayscale(1)';
|
| 542 |
-
else if(filter==='sepia') f='sepia(0.85)';
|
| 543 |
-
else if(filter==='saturate') f='saturate(2.4)';
|
| 544 |
-
else if(filter==='warm') f='sepia(0.45) saturate(1.5) hue-rotate(-15deg)';
|
| 545 |
-
else if(filter==='cool') f='saturate(1.2) hue-rotate(15deg) brightness(1.05)';
|
| 546 |
-
else if(filter==='invert') f='invert(1)';
|
| 547 |
-
else if(filter==='noir') f='grayscale(1) contrast(1.6) brightness(0.9)';
|
| 548 |
-
if(blur) f=(f?f+' ':'')+'blur('+blur+'px)';
|
| 549 |
-
ctx.filter = f;
|
| 550 |
-
if(bgUrl){
|
| 551 |
-
const img = new Image(); img.crossOrigin='anonymous';
|
| 552 |
-
// Note: async path handled by caller via onload; this fn is synchronous draw of already-loaded img
|
| 553 |
-
}
|
| 554 |
-
ctx.restore();
|
| 555 |
-
}
|
| 556 |
-
|
| 557 |
-
/* ---------------- layout effects (with loaded bg image) ---------------- */
|
| 558 |
-
function _dsDrawLayout(ctx, W, H, opts, img){
|
| 559 |
-
const { layout, filter, bgUrl, bgColor } = opts;
|
| 560 |
-
// base: solid layout can use the chosen background color (no image needed)
|
| 561 |
-
let baseColor = '#141414';
|
| 562 |
-
if(layout==='solid' && !img && bgColor) baseColor = bgColor;
|
| 563 |
-
ctx.fillStyle = baseColor; ctx.fillRect(0,0,W,H);
|
| 564 |
-
// draw bg image cover-fit if present & loaded
|
| 565 |
-
if(img){
|
| 566 |
-
const blur = layout==='blur' ? Math.max(6, Math.round(W/90)) : 0;
|
| 567 |
-
ctx.save();
|
| 568 |
-
let f='';
|
| 569 |
-
if(filter==='grayscale') f='grayscale(1)';
|
| 570 |
-
else if(filter==='sepia') f='sepia(0.85)';
|
| 571 |
-
else if(filter==='saturate') f='saturate(2.4)';
|
| 572 |
-
else if(filter==='warm') f='sepia(0.45) saturate(1.5) hue-rotate(-15deg)';
|
| 573 |
-
else if(filter==='cool') f='saturate(1.2) hue-rotate(15deg) brightness(1.05)';
|
| 574 |
-
else if(filter==='invert') f='invert(1)';
|
| 575 |
-
else if(filter==='noir') f='grayscale(1) contrast(1.6) brightness(0.9)';
|
| 576 |
-
if(blur) f=(f?f+' ':'')+'blur('+blur+'px)';
|
| 577 |
-
ctx.filter = f;
|
| 578 |
-
const iw=img.naturalWidth||1, ih=img.naturalHeight||1;
|
| 579 |
-
const scale=Math.max(W/iw, H/ih);
|
| 580 |
-
const dw=iw*scale, dh=ih*scale;
|
| 581 |
-
const dx=(W-dw)/2, dy=(H-dh)/2;
|
| 582 |
-
ctx.drawImage(img,0,0,iw,ih,dx,dy,dw,dh);
|
| 583 |
-
ctx.filter='none';
|
| 584 |
-
ctx.restore();
|
| 585 |
-
}
|
| 586 |
-
// overlay per layout
|
| 587 |
-
if(layout==='gradient'){
|
| 588 |
-
const g=ctx.createLinearGradient(0,0,W,H);
|
| 589 |
-
g.addColorStop(0,'rgba(50,20,90,0.75)'); g.addColorStop(0.5,'rgba(20,60,120,0.6)'); g.addColorStop(1,'rgba(120,30,60,0.75)');
|
| 590 |
-
ctx.fillStyle=g; ctx.fillRect(0,0,W,H);
|
| 591 |
-
}
|
| 592 |
-
else if(layout==='vignette'){
|
| 593 |
-
const g=ctx.createRadialGradient(W/2,H/2,0,W/2,H/2,Math.max(W,H)*0.62);
|
| 594 |
-
g.addColorStop(0,'rgba(0,0,0,0)'); g.addColorStop(1,'rgba(0,0,0,0.78)');
|
| 595 |
-
ctx.fillStyle=g; ctx.fillRect(0,0,W,H);
|
| 596 |
-
}
|
| 597 |
-
else if(layout==='split'){
|
| 598 |
-
const g=ctx.createLinearGradient(0,0,0,H);
|
| 599 |
-
g.addColorStop(0,'rgba(0,0,0,0.85)'); g.addColorStop(0.5,'rgba(0,0,0,0.15)'); g.addColorStop(1,'rgba(0,0,0,0.85)');
|
| 600 |
-
ctx.fillStyle=g; ctx.fillRect(0,0,W,H);
|
| 601 |
-
}
|
| 602 |
-
else if(layout==='spotlight'){
|
| 603 |
-
ctx.fillStyle='rgba(0,0,0,0.72)'; ctx.fillRect(0,0,W,H);
|
| 604 |
-
const cx=W/2, cy=H*0.45, r=Math.max(W,H)*0.4;
|
| 605 |
-
const g=ctx.createRadialGradient(cx,cy,0,cx,cy,r);
|
| 606 |
-
g.addColorStop(0,'rgba(255,255,255,0.45)'); g.addColorStop(0.6,'rgba(255,255,255,0.12)'); g.addColorStop(1,'rgba(0,0,0,0.9)');
|
| 607 |
-
ctx.fillStyle=g; ctx.fillRect(0,0,W,H);
|
| 608 |
-
}
|
| 609 |
-
else if(layout==='diagonal'){
|
| 610 |
-
ctx.fillStyle='rgba(0,0,0,0.55)'; ctx.fillRect(0,0,W,H);
|
| 611 |
-
ctx.fillStyle='rgba(0,0,0,0.8)';
|
| 612 |
-
ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(W*0.9,0); ctx.lineTo(0,H*0.9); ctx.closePath(); ctx.fill();
|
| 613 |
-
}
|
| 614 |
-
else if(layout==='neon'){
|
| 615 |
-
ctx.fillStyle='rgba(10,10,30,0.85)'; ctx.fillRect(0,0,W,H);
|
| 616 |
-
ctx.strokeStyle='rgba(80,220,255,0.5)'; ctx.lineWidth=Math.max(2,W/300);
|
| 617 |
-
ctx.strokeRect(W*0.03,H*0.03,W*0.94,H*0.94);
|
| 618 |
-
}
|
| 619 |
-
else if(layout==='top-banner'){
|
| 620 |
-
ctx.fillStyle='rgba(0,0,0,0)';
|
| 621 |
-
const g=ctx.createLinearGradient(0,0,0,H*0.5);
|
| 622 |
-
g.addColorStop(0,'rgba(0,0,0,0.8)'); g.addColorStop(1,'rgba(0,0,0,0)');
|
| 623 |
-
ctx.fillStyle=g; ctx.fillRect(0,0,W,H*0.5);
|
| 624 |
-
}
|
| 625 |
-
else if(layout==='lower-third'){
|
| 626 |
-
const g=ctx.createLinearGradient(0,H*0.55,0,H);
|
| 627 |
-
g.addColorStop(0,'rgba(0,0,0,0)'); g.addColorStop(1,'rgba(0,0,0,0.82)');
|
| 628 |
-
ctx.fillStyle=g; ctx.fillRect(0,H*0.55,W,H*0.45);
|
| 629 |
-
}
|
| 630 |
-
else if(layout==='card'){
|
| 631 |
-
ctx.fillStyle='rgba(0,0,0,0.35)'; ctx.fillRect(0,0,W,H);
|
| 632 |
-
// rounded card
|
| 633 |
-
const pad=Math.round(W*0.04);
|
| 634 |
-
ctx.fillStyle='rgba(0,0,0,0.65)';
|
| 635 |
-
_dsRoundRect(ctx, pad, H*0.16, W-2*pad, H*0.68, Math.round(W*0.05));
|
| 636 |
-
ctx.fill();
|
| 637 |
-
ctx.strokeStyle='rgba(255,255,255,0.25)'; ctx.lineWidth=Math.max(2,W/400); _dsRoundRect(ctx,pad,H*0.16,W-2*pad,H*0.68,Math.round(W*0.05)); ctx.stroke();
|
| 638 |
-
}
|
| 639 |
-
else if(layout==='corner'){
|
| 640 |
-
ctx.fillStyle='rgba(0,0,0,0.3)'; ctx.fillRect(0,0,W,H);
|
| 641 |
-
ctx.strokeStyle='rgba(255,215,0,0.9)'; ctx.lineWidth=Math.max(4,W/160);
|
| 642 |
-
const L=Math.round(W*0.18), c=Math.round(W*0.04);
|
| 643 |
-
[[0,c],[c,0],[W,c],[W-c,0],[0,H-c],[c,H],[W,H-c],[W-c,H]].forEach(([x,y],i)=>{
|
| 644 |
-
const top = y===0 || y===c; const left = x===0 || x===c;
|
| 645 |
-
ctx.beginPath();
|
| 646 |
-
if(i<4){ // top corners
|
| 647 |
-
if(top && left){ ctx.moveTo(x,y+L); ctx.lineTo(x,y); ctx.lineTo(x+L,y); }
|
| 648 |
-
else if(top){ ctx.moveTo(x,y+L); ctx.lineTo(x,y); ctx.lineTo(x-L,y); }
|
| 649 |
-
else { ctx.moveTo(x-L,y); ctx.lineTo(x,y); ctx.lineTo(x,y+L); }
|
| 650 |
-
} else { // bottom corners
|
| 651 |
-
if(left){ ctx.moveTo(x,y-L); ctx.lineTo(x,y); ctx.lineTo(x+L,y); }
|
| 652 |
-
else { ctx.moveTo(x-L,y); ctx.lineTo(x,y); ctx.lineTo(x,y-L); }
|
| 653 |
-
}
|
| 654 |
-
ctx.stroke();
|
| 655 |
-
});
|
| 656 |
-
}
|
| 657 |
-
else if(layout==='stars'){
|
| 658 |
-
ctx.fillStyle='rgba(8,8,30,0.82)'; ctx.fillRect(0,0,W,H);
|
| 659 |
-
const n=Math.round(W*H/18000);
|
| 660 |
-
for(let i=0;i<n;i++){
|
| 661 |
-
const x=Math.random()*W, y=Math.random()*H, r=Math.random()*Math.max(1,W/300)+0.5;
|
| 662 |
-
ctx.fillStyle=`rgba(255,255,255,${0.4+Math.random()*0.6})`;
|
| 663 |
-
ctx.beginPath(); ctx.arc(x,y,r,0,Math.PI*2); ctx.fill();
|
| 664 |
-
}
|
| 665 |
-
}
|
| 666 |
-
else { // solid
|
| 667 |
-
if(img && bgUrl){
|
| 668 |
-
// sample background photo (or user image) -> dark overlay so text is readable
|
| 669 |
-
ctx.fillStyle='rgba(0,0,0,0.45)'; ctx.fillRect(0,0,W,H);
|
| 670 |
-
} else if(bgColor){
|
| 671 |
-
// chosen solid color with a subtle gradient + soft vignette for depth
|
| 672 |
-
const g=ctx.createLinearGradient(0,0,W,H);
|
| 673 |
-
g.addColorStop(0, bgColor);
|
| 674 |
-
g.addColorStop(1, _dsShadeColor(bgColor, -0.25));
|
| 675 |
-
ctx.fillStyle=g; ctx.fillRect(0,0,W,H);
|
| 676 |
-
const vg=ctx.createRadialGradient(W/2,H/2,0,W/2,H/2,Math.max(W,H)*0.66);
|
| 677 |
-
vg.addColorStop(0,'rgba(0,0,0,0)'); vg.addColorStop(1,'rgba(0,0,0,0.42)');
|
| 678 |
-
ctx.fillStyle=vg; ctx.fillRect(0,0,W,H);
|
| 679 |
-
} else {
|
| 680 |
-
ctx.fillStyle='rgba(0,0,0,0.6)'; ctx.fillRect(0,0,W,H);
|
| 681 |
-
}
|
| 682 |
-
}
|
| 683 |
-
}
|
| 684 |
-
|
| 685 |
-
/* Darken (n<0) or lighten (n>0) a hex color by n*100% (for solid bg gradient). */
|
| 686 |
-
function _dsShadeColor(hex, n){
|
| 687 |
-
try{
|
| 688 |
-
hex=String(hex||'').replace('#','');
|
| 689 |
-
if(hex.length===3) hex=hex.split('').map(c=>c+c).join('');
|
| 690 |
-
if(hex.length!==6) return hex;
|
| 691 |
-
const num=parseInt(hex,16);
|
| 692 |
-
let r=(num>>16)&255, g=(num>>8)&255, b=num&255;
|
| 693 |
-
const t = n<0 ? 0 : 255;
|
| 694 |
-
const p = Math.abs(n);
|
| 695 |
-
r=Math.round((t-r)*p)+r; g=Math.round((t-g)*p)+g; b=Math.round((t-b)*p)+b;
|
| 696 |
-
return '#'+((1<<24)+(r<<16)+(g<<8)+b).toString(16).slice(1);
|
| 697 |
-
}catch(e){ return hex; }
|
| 698 |
-
}
|
| 699 |
-
|
| 700 |
-
function _dsRoundRect(ctx,x,y,w,h,r){
|
| 701 |
-
ctx.beginPath();
|
| 702 |
-
ctx.moveTo(x+r,y);
|
| 703 |
-
ctx.arcTo(x+w,y,x+w,y+h,r);
|
| 704 |
-
ctx.arcTo(x+w,y+h,x,y+h,r);
|
| 705 |
-
ctx.arcTo(x,y+h,x,y,r);
|
| 706 |
-
ctx.arcTo(x,y,x+w,y,r);
|
| 707 |
-
ctx.closePath();
|
| 708 |
-
}
|
| 709 |
-
|
| 710 |
-
/* ---------------- text drawing ---------------- */
|
| 711 |
-
function _dsDrawText(ctx, W, H, opts, baseFontScale){
|
| 712 |
-
const { text, pos, glow, color } = opts;
|
| 713 |
-
if(!text || !text.trim()) return;
|
| 714 |
-
const base = Math.max(28, Math.round(W*baseFontScale));
|
| 715 |
-
const fs = Math.min(base, Math.round(W*0.09));
|
| 716 |
-
ctx.font = 'bold '+fs+'px "Segoe UI","Arial","Noto Sans",sans-serif';
|
| 717 |
-
ctx.textAlign='center';
|
| 718 |
-
// split lines
|
| 719 |
-
const lines=[]; let line='';
|
| 720 |
-
const maxW=Math.round(W*0.82);
|
| 721 |
-
text.split(' ').forEach(w=>{
|
| 722 |
-
const test = line? line+' '+w : w;
|
| 723 |
-
if(ctx.measureText(test).width>maxW && line){ lines.push(line.trim()); line=w; } else { line=test; }
|
| 724 |
-
});
|
| 725 |
-
if(line) lines.push(line.trim());
|
| 726 |
-
const lh=Math.round(fs*1.25);
|
| 727 |
-
const blockH=lines.length*lh;
|
| 728 |
-
let y;
|
| 729 |
-
if(pos==='top') y=Math.round(H*0.14)+lh/2;
|
| 730 |
-
else if(pos==='bottom') y=Math.round(H*0.88)-blockH+lh/2;
|
| 731 |
-
else y=Math.round(H/2)-blockH/2+lh/2;
|
| 732 |
-
// glow
|
| 733 |
-
if(glow==='white'){ ctx.shadowColor='rgba(255,255,255,0.95)'; ctx.shadowBlur=Math.max(8,fs*0.5); }
|
| 734 |
-
else if(glow==='color'){ ctx.shadowColor=color; ctx.shadowBlur=Math.max(10,fs*0.6); }
|
| 735 |
-
else if(glow==='neon'){ ctx.shadowColor='#00ffff'; ctx.shadowBlur=Math.max(16,fs*0.9); }
|
| 736 |
-
ctx.fillStyle=color;
|
| 737 |
-
// subtle stroke for legibility at top/bottom
|
| 738 |
-
if(pos==='top'||pos==='bottom'){ ctx.strokeStyle='rgba(0,0,0,0.6)'; ctx.lineWidth=Math.max(2,fs*0.12); ctx.strokeText(textLine(text),W/2,y); }
|
| 739 |
-
lines.forEach((l,li)=>{ ctx.fillText(l, W/2, y+li*lh); });
|
| 740 |
-
ctx.shadowBlur=0;
|
| 741 |
-
}
|
| 742 |
-
function textLine(t){ return t; }
|
| 743 |
-
|
| 744 |
-
/* ---------------- compute final canvas size for a design ---------------- */
|
| 745 |
-
function _dsFinalSize(opts){
|
| 746 |
-
const r=opts.ratio;
|
| 747 |
-
const w = (r.w>=r.h) ? 1800 : 1350;
|
| 748 |
-
const h = Math.round(w*r.h/r.w);
|
| 749 |
-
return { w, h };
|
| 750 |
-
}
|
| 751 |
-
|
| 752 |
-
/* Shared renderer (Bug 4 fix): renders a slide onto `canvas` using the SAME
|
| 753 |
-
code path and SAME resolution for BOTH preview and final save, so the
|
| 754 |
-
published image is pixel-identical to what the user saw in the preview —
|
| 755 |
-
same cover-fit, same filter, same text placement, same background. */
|
| 756 |
-
function _dsRenderSlide(canvas, opts, done){
|
| 757 |
-
const ctx = canvas.getContext('2d');
|
| 758 |
-
const bgUrl = _dsImageSrc(opts.bgUrl);
|
| 759 |
-
function draw(img){
|
| 760 |
-
_dsDrawLayout(ctx, canvas.width, canvas.height, opts, img||null);
|
| 761 |
-
_dsDrawText(ctx, canvas.width, canvas.height, opts, 0.052);
|
| 762 |
-
if(done) done(img||null);
|
| 763 |
-
}
|
| 764 |
-
if(bgUrl){
|
| 765 |
-
const img=_dsMakeImg(bgUrl);
|
| 766 |
-
img.onload=function(){ draw(img); };
|
| 767 |
-
img.onerror=function(){ console.warn('[designer] bg image failed to load:', bgUrl); draw(null); };
|
| 768 |
-
img.src=bgUrl;
|
| 769 |
-
} else {
|
| 770 |
-
draw(null);
|
| 771 |
-
}
|
| 772 |
-
}
|
| 773 |
-
|
| 774 |
-
/* ---------------- preview ---------------- */
|
| 775 |
-
function _dsPreview(){
|
| 776 |
-
const p=_wallPosts[_dsState.postIdx]; if(!p) return;
|
| 777 |
-
const area=document.getElementById('ds-preview-area'); if(!area) return;
|
| 778 |
-
const sel=_dsState.selected;
|
| 779 |
-
if(!sel.length){ area.style.display='block'; area.innerHTML='<div style="color:#888;font-size:12px">Chưa chọn slide nào để xem trước.</div>'; return; }
|
| 780 |
-
area.style.display='block';
|
| 781 |
-
area.innerHTML='<div style="color:#888;font-size:12px">⏳ Đang tạo xem trước...</div>';
|
| 782 |
-
// display-scale: cap visible width at 300px, keep aspect ratio per slide
|
| 783 |
-
const dispW=Math.min(300, Math.floor((Math.max(document.documentElement.clientWidth||540, document.body.clientWidth||540))*0.5));
|
| 784 |
-
const cans=[];
|
| 785 |
-
sel.forEach((si, k)=>{
|
| 786 |
-
const opts=_dsOptions(p, si);
|
| 787 |
-
const { w, h }=_dsFinalSize(opts);
|
| 788 |
-
const canvas=document.createElement('canvas');
|
| 789 |
-
canvas.width=w; canvas.height=h;
|
| 790 |
-
canvas.style.width=dispW+'px'; canvas.style.height='auto';
|
| 791 |
-
canvas.style.borderRadius='12px'; canvas.style.boxShadow='0 8px 24px rgba(0,0,0,.5)'; canvas.style.display='block'; canvas.style.margin='0 auto 14px auto';
|
| 792 |
-
cans.push({opts, canvas});
|
| 793 |
-
});
|
| 794 |
-
let remaining=cans.length;
|
| 795 |
-
function allDone(){
|
| 796 |
-
if(--remaining>0) return;
|
| 797 |
-
let out='';
|
| 798 |
-
if(cans.length>1) out+='<div style="color:#888;font-size:10px;margin-bottom:8px">Xem trước '+(cans.length)+' slide đã chọn (cuộn xuống để xem hết):</div>';
|
| 799 |
-
area.innerHTML=out;
|
| 800 |
-
cans.forEach((c,k)=>{
|
| 801 |
-
if(k>0) area.appendChild(document.createElement('br'));
|
| 802 |
-
area.appendChild(c.canvas);
|
| 803 |
-
});
|
| 804 |
-
}
|
| 805 |
-
cans.forEach(c=>{ _dsRenderSlide(c.canvas, c.opts, allDone); });
|
| 806 |
-
}
|
| 807 |
-
|
| 808 |
-
/* ---------------- save: render + upload each selected slide, then post ---------------- */
|
| 809 |
-
async function _dsSave(p){
|
| 810 |
-
const statusEl=document.getElementById('ds-status'); if(statusEl) statusEl.textContent='⏳ Đang tạo ảnh các slide...';
|
| 811 |
-
const sel=_dsState.selected;
|
| 812 |
-
if(!sel.length){ if(statusEl) statusEl.textContent='⚠️ Chưa chọn slide nào để đăng.'; return; }
|
| 813 |
-
const saveBtn=document.getElementById('ds-save-btn'); if(saveBtn){ saveBtn.disabled=true; saveBtn.textContent='⏳ Đang xử lý...'; }
|
| 814 |
-
|
| 815 |
-
const uploaded = []; // {si, url}
|
| 816 |
-
try{
|
| 817 |
-
// render each selected slide at final resolution
|
| 818 |
-
for(let k=0;k<sel.length;k++){
|
| 819 |
-
const si=sel[k];
|
| 820 |
-
if(statusEl) statusEl.textContent=`⏳ Đang tạo ảnh slide ${k+1}/${sel.length}... (${p.slides.length} slide trong bài)`;
|
| 821 |
-
const opts=_dsOptions(p, si);
|
| 822 |
-
const { w: FINAL_W, h: FINAL_H }=_dsFinalSize(opts);
|
| 823 |
-
const canvas=document.createElement('canvas'); canvas.width=FINAL_W; canvas.height=FINAL_H;
|
| 824 |
-
// Shared render path (Bug 4 fix) — identical to preview.
|
| 825 |
-
const url = await new Promise((resolve)=>{
|
| 826 |
-
_dsRenderSlide(canvas, opts, function(){
|
| 827 |
-
// Nén JPEG thay PNG: dung lượng rất nhỏ (thường 5-10x nhỏ hơn),
|
| 828 |
-
// ảnh tải nhanh hơn nhiều trên Tường AI. Chất lượng 0.82 giữ chữ sắc nét.
|
| 829 |
-
canvas.toBlob(async function(blob){
|
| 830 |
-
const fd=new FormData();
|
| 831 |
-
fd.append('file', blob, 'slide_'+(si+1)+'.jpg');
|
| 832 |
-
fd.append('post_id', p.id||'');
|
| 833 |
-
try{
|
| 834 |
-
const r=await fetch('/api/wall/img',{method:'POST',body:fd});
|
| 835 |
-
const j=await r.json();
|
| 836 |
-
if(!r.ok || j.error) throw new Error(j.error||'Lỗi upload');
|
| 837 |
-
resolve(j.url);
|
| 838 |
-
}catch(e){ resolve({error:e.message}); }
|
| 839 |
-
},'image/jpeg', 0.82);
|
| 840 |
-
});
|
| 841 |
-
});
|
| 842 |
-
if(url && url.error) throw new Error(url.error);
|
| 843 |
-
uploaded.push({si, url});
|
| 844 |
-
// update slide image live
|
| 845 |
-
if(p.slides[si]) p.slides[si].image = url;
|
| 846 |
-
}
|
| 847 |
-
|
| 848 |
-
if(statusEl) statusEl.textContent='⏳ Đang đăng lên Tường AI...';
|
| 849 |
-
|
| 850 |
-
// build updated slides array (keep unselected unchanged)
|
| 851 |
-
const newSlides = p.slides.map((s,i)=>{
|
| 852 |
-
const up = uploaded.find(u=>u.si===i);
|
| 853 |
-
// ensure text edits are applied too
|
| 854 |
-
const ta = document.querySelector(`#ds-texts textarea[data-sidx="${i}"]`);
|
| 855 |
-
return {
|
| 856 |
-
text: ta ? ta.value : (s.text||''),
|
| 857 |
-
image: up ? up.url : (s.image||''),
|
| 858 |
-
index: s.index || (i+1),
|
| 859 |
-
};
|
| 860 |
-
});
|
| 861 |
-
|
| 862 |
-
// pick selected slides only -> the wall post is a SLIDE-IMAGE post of selected slides
|
| 863 |
-
const selSlides = newSlides.filter((_,i)=> sel.includes(i));
|
| 864 |
-
|
| 865 |
-
const post = {
|
| 866 |
-
id: p.id,
|
| 867 |
-
title: p.title||'',
|
| 868 |
-
text: selSlides.map(s=>'• '+s.text).join('\n\n'),
|
| 869 |
-
img: selSlides[0] ? selSlides[0].image : p.img||'',
|
| 870 |
-
url: p.url||'',
|
| 871 |
-
slides: selSlides, // <-- bài đăng bao gồm chỉ các slide đã chọn
|
| 872 |
-
images: selSlides.map(s=>s.image),
|
| 873 |
-
kind: p.kind||'slide_summary',
|
| 874 |
-
video: p.video||'',
|
| 875 |
-
voice: p.voice||'',
|
| 876 |
-
emotion: p.emotion||'',
|
| 877 |
-
language: p.language||'',
|
| 878 |
-
ts: p.ts||Math.floor(Date.now()/1000),
|
| 879 |
-
};
|
| 880 |
-
|
| 881 |
-
const wr=await fetch('/api/wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(post)});
|
| 882 |
-
const wj=await wr.json();
|
| 883 |
-
if(wr.ok && wj.post){
|
| 884 |
-
prependWallPost(wj.post);
|
| 885 |
-
toast('✅ Đã đăng bài slide lên Tường AI ('+selSlides.length+' slide)!');
|
| 886 |
-
} else {
|
| 887 |
-
toast('✅ Đã lưu ảnh slide!');
|
| 888 |
-
}
|
| 889 |
-
document.getElementById('slide-designer-overlay').remove();
|
| 890 |
-
}catch(e){
|
| 891 |
-
if(statusEl) statusEl.textContent='❌ '+e.message;
|
| 892 |
-
toast('❌ '+e.message);
|
| 893 |
-
if(saveBtn){ saveBtn.disabled=false; saveBtn.textContent='📤 Lưu & Đăng lên Tường AI ('+sel.length+' slide)'; }
|
| 894 |
-
}
|
| 895 |
-
}
|
| 896 |
-
|
| 897 |
-
/* keep old helper names harmless for any other caller */
|
| 898 |
-
function updateDesignerText(){}
|
| 899 |
-
function designerGetRatio(){ return designerGetRatioV2(); }
|
| 900 |
-
function designerGetLayout(){ return designerGetLayoutV2(); }
|
| 901 |
-
function designerDebounce(fn,d){ _dsDebounce(fn,d); }
|
| 902 |
-
function handleDesignerBgFile(){}
|
| 903 |
-
function previewDesignerSlide(){ _dsPreview(); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/fm_fix.css
CHANGED
|
@@ -168,250 +168,3 @@
|
|
| 168 |
min-width:100%!important;max-width:none!important;max-height:none!important;
|
| 169 |
object-fit:cover!important;
|
| 170 |
}
|
| 171 |
-
|
| 172 |
-
/* ============================================================
|
| 173 |
-
BXH / BÅNG XẼP HẠNG (standing league table) FIX
|
| 174 |
-
Bongda returns div-based Tailwind rows (.ranking-table .leaderboard
|
| 175 |
-
.leaderboard-item / .head / .team / .copy). The old CSS only styled
|
| 176 |
-
<table> so the standings rendered broken. Style the divs directly.
|
| 177 |
-
============================================================ */
|
| 178 |
-
#ls-content .ranking-table,
|
| 179 |
-
#ls-content .leaderboard{display:block!important;width:100%!important}
|
| 180 |
-
#ls-content .ranking-table .head,
|
| 181 |
-
#ls-content .leaderboard .head{display:flex!important;width:100%!important;background:#222!important;border-bottom:1px solid #333!important;padding:5px 0!important;font-size:10px!important;color:#999!important}
|
| 182 |
-
#ls-content .ranking-table .head .team,
|
| 183 |
-
#ls-content .leaderboard .head .team{flex:1 1 auto!important;min-width:0!important}
|
| 184 |
-
#ls-content .ranking-table .head .team span,
|
| 185 |
-
#ls-content .leaderboard .head .team span{display:inline-block!important;width:18px!important;text-align:center!important;color:#888!important}
|
| 186 |
-
#ls-content .ranking-table .head .team .link,
|
| 187 |
-
#ls-content .leaderboard .head .team .link{font-size:10px!important;color:#999!important;font-weight:700!important}
|
| 188 |
-
#ls-content .ranking-table .head .copy,
|
| 189 |
-
#ls-content .leaderboard .head .copy{display:flex!important;gap:2px!important;align-items:center!important;justify-content:flex-end!important;min-width:0!important}
|
| 190 |
-
#ls-content .ranking-table .head .copy p,
|
| 191 |
-
#ls-content .leaderboard .head .copy p{font-size:9px!important;color:#888!important;min-width:16px!important;text-align:center!important;margin:0!important}
|
| 192 |
-
#ls-content .ranking-table .head .copy p.form,
|
| 193 |
-
#ls-content .leaderboard .head .copy p.form{display:none!important}
|
| 194 |
-
#ls-content .ranking-table .leaderboard-item,
|
| 195 |
-
#ls-content .ranking-table #team_,
|
| 196 |
-
#ls-content .leaderboard .leaderboard-item{
|
| 197 |
-
display:table!important;width:100%!important;table-layout:fixed!important;
|
| 198 |
-
border-collapse:collapse!important;border-bottom:1px solid #1a1a1a!important;
|
| 199 |
-
font-size:11px!important;color:#ccc!important;background:transparent!important
|
| 200 |
-
}
|
| 201 |
-
#ls-content .ranking-table .leaderboard-item>div.team,
|
| 202 |
-
#ls-content .ranking-table .leaderboard-item>.team,
|
| 203 |
-
#ls-content .leaderboard .leaderboard-item .team{
|
| 204 |
-
display:table-cell!important;vertical-align:middle!important;
|
| 205 |
-
padding:5px 4px!important;text-align:left!important;min-width:0!important
|
| 206 |
-
}
|
| 207 |
-
#ls-content .ranking-table .leaderboard-item>div.team span,
|
| 208 |
-
#ls-content .ranking-table .leaderboard-item>.team span{
|
| 209 |
-
display:inline-block!important;width:18px!important;min-width:18px!important;
|
| 210 |
-
text-align:center!important;font-size:10px!important;color:#888!important;font-weight:600!important
|
| 211 |
-
}
|
| 212 |
-
#ls-content .ranking-table .leaderboard-item>div.team a.link,
|
| 213 |
-
#ls-content .ranking-table .leaderboard-item>.team a.link{
|
| 214 |
-
display:inline-flex!important;align-items:center!important;gap:4px!important;
|
| 215 |
-
min-width:0!important;font-size:11px!important;color:#ccc!important;text-decoration:none!important
|
| 216 |
-
}
|
| 217 |
-
#ls-content .ranking-table .leaderboard-item>div.team a.link img,
|
| 218 |
-
#ls-content .ranking-table .leaderboard-item>.team a.link img{width:16px!important;height:16px!important;object-fit:contain!important;flex:0 0 auto!important}
|
| 219 |
-
#ls-content .ranking-table .leaderboard-item>div.team a.link p,
|
| 220 |
-
#ls-content .ranking-table .leaderboard-item>.team a.link p{
|
| 221 |
-
font-size:11px!important;color:#ccc!important;margin:0!important;
|
| 222 |
-
white-space:nowrap!important;overflow:hidden!important;text-overflow:ellipsis!important
|
| 223 |
-
}
|
| 224 |
-
#ls-content .ranking-table .leaderboard-item>div.copy,
|
| 225 |
-
#ls-content .ranking-table .leaderboard-item>.copy,
|
| 226 |
-
#ls-content .leaderboard .leaderboard-item .copy{
|
| 227 |
-
display:table-cell!important;vertical-align:middle!important;
|
| 228 |
-
padding:5px 2px!important;text-align:center!important;white-space:nowrap!important
|
| 229 |
-
}
|
| 230 |
-
#ls-content .ranking-table .leaderboard-item>div.copy p,
|
| 231 |
-
#ls-content .ranking-table .leaderboard-item>.copy p{
|
| 232 |
-
display:inline-block!important;min-width:22px!important;text-align:center!important;
|
| 233 |
-
font-size:11px!important;color:#ccc!important;margin:0 1px!important
|
| 234 |
-
}
|
| 235 |
-
#ls-content .ranking-table .leaderboard-item>div.copy p strong,
|
| 236 |
-
#ls-content .ranking-table .leaderboard-item>.copy p strong{
|
| 237 |
-
display:inline-block!important;min-width:22px!important;text-align:center!important;
|
| 238 |
-
font-weight:800!important;color:#f0c040!important;font-size:12px!important
|
| 239 |
-
}
|
| 240 |
-
#ls-content .ranking-table .leaderboard-item>div.copy p.form,
|
| 241 |
-
#ls-content .ranking-table .leaderboard-item>.copy p.form{display:inline-flex!important;flex-wrap:nowrap!important}
|
| 242 |
-
#ls-content .ranking-table .leaderboard-item>div.copy p.form span,
|
| 243 |
-
#ls-content .ranking-table .leaderboard-item>.copy p.form span{
|
| 244 |
-
display:inline-block!important;min-width:14px!important;height:14px!important;
|
| 245 |
-
line-height:14px!important;text-align:center!important;font-size:9px!important;
|
| 246 |
-
border-radius:2px!important;margin:0 1px!important;color:#fff!important
|
| 247 |
-
}
|
| 248 |
-
#ls-content .ranking-table .leaderboard-item>div.copy p.form span.bg-green,
|
| 249 |
-
#ls-content .ranking-table .leaderboard-item>.copy p.form span.bg-green{background:#2d8659!important}
|
| 250 |
-
#ls-content .ranking-table .leaderboard-item>div.copy p.form span.bg-gray-5,
|
| 251 |
-
#ls-content .ranking-table .leaderboard-item>.copy p.form span.bg-gray-5,
|
| 252 |
-
#ls-content .leaderboard .leaderboard-item .copy p.form span.bg-gray-5,
|
| 253 |
-
#ls-content .ranking-table .leaderboard-item .copy p.form span.bg-gray-5{background:#555!important;color:#ccc!important}
|
| 254 |
-
#ls-content .ranking-table .leaderboard-item>div.copy p.form span.bg-red,
|
| 255 |
-
#ls-content .ranking-table .leaderboard-item>.copy p.form span.bg-red{background:#c0392b!important}
|
| 256 |
-
/* hide the bongda left accent bar that floats absolutely */
|
| 257 |
-
#ls-content .ranking-table .leaderboard-item>div[class*="w-["],
|
| 258 |
-
#ls-content .ranking-table .leaderboard-item div.relative{display:none!important}
|
| 259 |
-
/* top row = champion accent can stay, but clean layout */
|
| 260 |
-
#ls-content .leaderboard-item{margin:0!important}
|
| 261 |
-
#ls-content .rank-table{width:100%!important;overflow-x:auto!important}
|
| 262 |
-
/* fallback generic so ANY div-based standings table gets readable */
|
| 263 |
-
#ls-content .team .name,#ls-content .team .logo{font-size:11px!important;color:#ccc!important}
|
| 264 |
-
#ls-content .copy p{font-size:11px!important;color:#ccc!important}
|
| 265 |
-
/* injected BXH header + row layout (see _decorateStandings in app_v2.js) */
|
| 266 |
-
#ls-content .ls-bxh-head{
|
| 267 |
-
display:flex!important;width:100%!important;align-items:center!important;gap:4px!important;
|
| 268 |
-
background:#222!important;border-bottom:1px solid #333!important;
|
| 269 |
-
padding:5px 2px!important;position:sticky!important;top:0!important;z-index:2!important
|
| 270 |
-
}
|
| 271 |
-
#ls-content .ls-bxh-head .team{flex:1!important;min-width:0!important;display:flex!important;align-items:center!important;gap:4px!important}
|
| 272 |
-
#ls-content .ls-bxh-head .team span{display:inline-block!important;width:18px!important;min-width:18px!important;text-align:center!important;color:#888!important;font-size:10px!important;font-weight:700!important}
|
| 273 |
-
#ls-content .ls-bxh-head .team p{font-size:10px!important;color:#999!important;font-weight:700!important;margin:0!important;text-transform:uppercase!important}
|
| 274 |
-
#ls-content .ls-bxh-head .copy{display:flex!important;align-items:center!important;gap:2px!important;white-space:nowrap!important}
|
| 275 |
-
#ls-content .ls-bxh-head .copy p{display:inline-block!important;min-width:20px!important;text-align:center!important;font-size:9px!important;color:#888!important;font-weight:700!important;margin:0!important}
|
| 276 |
-
#ls-content .leaderboard-item{display:flex!important;width:100%!important;align-items:center!important;gap:4px!important;border-bottom:1px solid #1a1a1a!important;padding:4px 2px!important;background:transparent!important}
|
| 277 |
-
#ls-content .leaderboard-item .team{flex:1!important;min-width:0!important;display:flex!important;align-items:center!important;gap:4px!important}
|
| 278 |
-
#ls-content .leaderboard-item .team span{display:inline-block!important;width:18px!important;min-width:18px!important;text-align:center!important;color:#888!important;font-size:10px!important;font-weight:600!important}
|
| 279 |
-
#ls-content .leaderboard-item .team a{display:inline-flex!important;align-items:center!important;gap:4px!important;font-size:11px!important;color:#ccc!important;text-decoration:none!important;overflow:hidden!important;min-width:0!important}
|
| 280 |
-
#ls-content .leaderboard-item .team a img{width:16px!important;height:16px!important;object-fit:contain!important;flex:0 0 auto!important}
|
| 281 |
-
#ls-content .leaderboard-item .team a p{font-size:11px!important;color:#ccc!important;margin:0!important;white-space:nowrap!important;overflow:hidden!important;text-overflow:ellipsis!important}
|
| 282 |
-
#ls-content .leaderboard-item .copy{display:flex!important;align-items:center!important;gap:2px!important;white-space:nowrap!important}
|
| 283 |
-
#ls-content .leaderboard-item .copy p{display:inline-block!important;min-width:20px!important;text-align:center!important;font-size:11px!important;color:#ccc!important;margin:0!important}
|
| 284 |
-
#ls-content .leaderboard-item .copy p strong{font-weight:800!important;color:#f0c040!important;min-width:20px!important;text-align:center!important}
|
| 285 |
-
#ls-content .leaderboard-item .copy p.form{display:inline-flex!important;flex-wrap:nowrap!important}
|
| 286 |
-
#ls-content .leaderboard-item .copy p.form span{width:14px!important;height:14px!important;line-height:14px!important;font-size:9px!important;border-radius:2px!important;color:#fff!important;margin:0 1px!important;text-align:center!important}
|
| 287 |
-
#ls-content .leaderboard-item .copy p.form span.bg-green{background:#2d8659!important;display:inline-block!important}
|
| 288 |
-
#ls-content .leaderboard-item .copy p.form span.bg-gray-5{background:#555!important;color:#ccc!important;display:inline-block!important}
|
| 289 |
-
#ls-content .leaderboard-item .copy p.form span.bg-red{background:#c0392b!important;display:inline-block!important}
|
| 290 |
-
#ls-content .leaderboard-item>div[class*="w-["]{display:none!important}#ls-content .leaderboard-item>div[class*="w-["]{display:none!important}
|
| 291 |
-
|
| 292 |
-
/* ============================================================
|
| 293 |
-
FPT PLAY / HIGHLIGHT VIDEO OVERFLOW FIX v2
|
| 294 |
-
fptplay videos open in the TikTok-style feed inside .yt-thumb-wrap >
|
| 295 |
-
iframe (YouTube) or <video>. <iframe> ignores object-fit, so a 9:16
|
| 296 |
-
Shorts (or any non-16:9) source stretched the slide and overflowed
|
| 297 |
-
width/height — the iframe's intrinsic aspect ratio pushed it beyond the
|
| 298 |
-
slide bounds.
|
| 299 |
-
|
| 300 |
-
BUG FIX v2 (two issues):
|
| 301 |
-
1) "FPT Play videos displayed differently from Short AI videos — too tall
|
| 302 |
-
and wide": The old rule sized iframes to 177.78vh (16:9 of viewport
|
| 303 |
-
height, ~1.78x screen wide). Short AI <video> tags used width:100%;
|
| 304 |
-
height:100%; object-fit:contain. The iframe was much wider/taller than
|
| 305 |
-
the video. Fix: make iframe use width:100%; height:100% — identical to
|
| 306 |
-
<video> — centered and clipped by the slide's overflow:hidden.
|
| 307 |
-
|
| 308 |
-
2) "9:16 ↔ 16:9 ratio toggle button (🖥️/📺) doesn't work on football
|
| 309 |
-
player slides and wall videos": The inline CSS .ratio-wide rule
|
| 310 |
-
(specificity 0,3,1) was overridden by fm_fix.css selectors using the
|
| 311 |
-
#tiktok-feed ID (specificity 1,3,1). The .ratio-wide rules never applied
|
| 312 |
-
to iframes inside #tiktok-feed. Fix: add .ratio-wide override rules
|
| 313 |
-
with #tiktok-feed ID prefix (matching or higher specificity) AFTER the
|
| 314 |
-
default iframe rules, so the !important cascade honors the toggle.
|
| 315 |
-
|
| 316 |
-
Sizing model: iframes don't support object-fit, so we use
|
| 317 |
-
position:absolute + transform centering inside .yt-thumb-wrap (which is
|
| 318 |
-
position:relative, width:100%, height:100%). The slide's
|
| 319 |
-
overflow:hidden clips any letterbox/pillarbox bars — same visual result
|
| 320 |
-
as object-fit:contain on <video>.
|
| 321 |
-
============================================================ */
|
| 322 |
-
.tiktok-slide{overflow:hidden!important;position:relative!important}
|
| 323 |
-
.tiktok-slide video{max-width:100%!important;max-height:100%!important;object-fit:contain!important;width:100%!important;height:100%!important;border:none!important}
|
| 324 |
-
/* FPT play / YouTube iframe in the TikTok viewer: SAME sizing as <video>.
|
| 325 |
-
Width and height 100% of .yt-thumb-wrap (which fills .tiktok-slide),
|
| 326 |
-
centered via transform, clipped by overflow:hidden on the slide.
|
| 327 |
-
This makes iframes appear IDENTICAL to Short AI <video> tags. */
|
| 328 |
-
#tiktok-feed .tiktok-slide .yt-thumb-wrap iframe,
|
| 329 |
-
#wc-shorts-feed .tiktok-slide .yt-thumb-wrap iframe,
|
| 330 |
-
#tt-feed .tiktok-slide .yt-thumb-wrap iframe,
|
| 331 |
-
.tiktok-slide .yt-thumb-wrap iframe{
|
| 332 |
-
position:absolute!important;
|
| 333 |
-
top:50%!important;
|
| 334 |
-
left:50%!important;
|
| 335 |
-
transform:translate(-50%,-50%)!important;
|
| 336 |
-
width:100%!important; /* match <video> — 100% of slide */
|
| 337 |
-
height:100%!important; /* match <video> — 100% of slide */
|
| 338 |
-
min-width:100%!important;
|
| 339 |
-
min-height:100%!important;
|
| 340 |
-
max-height:100%!important;
|
| 341 |
-
max-width:100vw!important; /* never overflow screen width */
|
| 342 |
-
border:none!important;
|
| 343 |
-
}
|
| 344 |
-
/* WC Shorts path: iframe is a DIRECT child of .tiktok-slide (no .yt-thumb-wrap) */
|
| 345 |
-
#tiktok-feed .tiktok-slide > iframe,
|
| 346 |
-
#wc-shorts-feed .tiktok-slide > iframe,
|
| 347 |
-
.tiktok-slide > iframe{
|
| 348 |
-
position:absolute!important;
|
| 349 |
-
top:50%!important;
|
| 350 |
-
left:50%!important;
|
| 351 |
-
transform:translate(-50%,-50%)!important;
|
| 352 |
-
width:100%!important; /* match <video> — 100% of slide */
|
| 353 |
-
height:100%!important; /* match <video> — 100% of slide */
|
| 354 |
-
min-width:100%!important;
|
| 355 |
-
min-height:100%!important;
|
| 356 |
-
max-height:100%!important;
|
| 357 |
-
max-width:100vw!important; /* never overflow screen width */
|
| 358 |
-
border:none!important;
|
| 359 |
-
}
|
| 360 |
-
/* RATIO TOGGLE FIX: when .ratio-wide is on a .tiktok-slide inside
|
| 361 |
-
#tiktok-feed, switch from 9:16 portrait to 16:9 landscape.
|
| 362 |
-
These rules use #tiktok-feed ID prefix (same specificity as the default
|
| 363 |
-
iframe rules above) AND .ratio-wide class, so they win the CSS cascade
|
| 364 |
-
only when the toggle button is active. The default 9:16 sizing (width:100%,
|
| 365 |
-
height:100%) is overridden to 16:9 landscape (width:100vw, height:56.25vw). */
|
| 366 |
-
#tiktok-feed .tiktok-slide.ratio-wide .yt-thumb-wrap iframe,
|
| 367 |
-
#wc-shorts-feed .tiktok-slide.ratio-wide .yt-thumb-wrap iframe,
|
| 368 |
-
#tt-feed .tiktok-slide.ratio-wide .yt-thumb-wrap iframe,
|
| 369 |
-
.tiktok-slide.ratio-wide .yt-thumb-wrap iframe{
|
| 370 |
-
position:absolute!important;
|
| 371 |
-
top:50%!important;
|
| 372 |
-
left:50%!important;
|
| 373 |
-
transform:translate(-50%,-50%)!important;
|
| 374 |
-
width:calc(100vw - 1px)!important;
|
| 375 |
-
height:56.25vw!important;
|
| 376 |
-
max-width:100vw!important;
|
| 377 |
-
max-height:100dvh!important;
|
| 378 |
-
min-width:100%!important;
|
| 379 |
-
min-height:100%!important;
|
| 380 |
-
border:none!important;
|
| 381 |
-
pointer-events:auto!important;
|
| 382 |
-
}
|
| 383 |
-
#tiktok-feed .tiktok-slide.ratio-wide > iframe,
|
| 384 |
-
#wc-shorts-feed .tiktok-slide.ratio-wide > iframe,
|
| 385 |
-
.tiktok-slide.ratio-wide > iframe{
|
| 386 |
-
position:absolute!important;
|
| 387 |
-
top:50%!important;
|
| 388 |
-
left:50%!important;
|
| 389 |
-
transform:translate(-50%,-50%)!important;
|
| 390 |
-
width:calc(100vw - 1px)!important;
|
| 391 |
-
height:56.25vw!important;
|
| 392 |
-
max-width:100vw!important;
|
| 393 |
-
max-height:100dvh!important;
|
| 394 |
-
min-width:100%!important;
|
| 395 |
-
min-height:100%!important;
|
| 396 |
-
border:none!important;
|
| 397 |
-
pointer-events:auto!important;
|
| 398 |
-
}
|
| 399 |
-
/* Toggle back to 9:16 (default) — reset to 100% sizing */
|
| 400 |
-
#tt-feed .tiktok-slide:not(.ratio-wide) .yt-thumb-wrap iframe,
|
| 401 |
-
#tt-feed .tiktok-slide:not(.ratio-wide) > iframe{
|
| 402 |
-
width:100%!important;
|
| 403 |
-
height:100%!important;
|
| 404 |
-
max-width:100vw!important;
|
| 405 |
-
min-width:100%!important;
|
| 406 |
-
}
|
| 407 |
-
/* .yt-thumb-wrap is the positioning context for the centered iframe */
|
| 408 |
-
.yt-thumb-wrap{position:relative!important;overflow:hidden!important;width:100%!important;height:100%!important;background:#000!important}
|
| 409 |
-
.yt-thumb-wrap img,.yt-thumb-wrap video{width:100%!important;height:100%!important;object-fit:cover!important}
|
| 410 |
-
.wall-item{overflow:hidden!important}
|
| 411 |
-
.wall-thumb img,.wall-thumb video{max-width:100%!important;max-height:100%!important;object-fit:cover!important}
|
| 412 |
-
.slider-thumb{overflow:hidden!important}
|
| 413 |
-
.slider-thumb img,.slider-thumb video{max-width:100%!important;max-height:100%!important;object-fit:cover!important}
|
| 414 |
-
.fpt-slider-thumb img,.fpt-slider-thumb video{max-width:100%!important;max-height:100%!important;object-fit:cover!important}
|
| 415 |
-
|
| 416 |
-
/* FPT Play slider moved into Livescore section - ensure it fits within the ls-section */
|
| 417 |
-
.ls-section #home-fpt-section{margin-top:4px;padding:0 4px}
|
|
|
|
| 168 |
min-width:100%!important;max-width:none!important;max-height:none!important;
|
| 169 |
object-fit:cover!important;
|
| 170 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/hot_multi.js
CHANGED
|
@@ -1,35 +1,17 @@
|
|
| 1 |
// === OVERRIDE: loadHotTopics loads from MULTIPLE hashtags ===
|
| 2 |
-
// v3: 5 topics (AI Thế Giới + AI Việt Nam + top 3 hot) + lazy load more
|
| 3 |
// This file loaded AFTER app_v2.js, overrides the function
|
| 4 |
|
| 5 |
-
const HT_AI_TOPICS = ['AI Thế Giới', 'AI Việt Nam'];
|
| 6 |
-
let _htAllSources = [];
|
| 7 |
-
let _htTopics = [];
|
| 8 |
-
let _htPages = 0;
|
| 9 |
-
let _htDates = [];
|
| 10 |
-
|
| 11 |
async function loadHotTopics(){
|
| 12 |
const j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));
|
| 13 |
const el=document.getElementById('hot-topics');if(!el)return;
|
| 14 |
const topics=j.topics||[];
|
| 15 |
-
|
| 16 |
-
// Prepend AI topics that aren't already in the list
|
| 17 |
-
const prepend = HT_AI_TOPICS.filter(ai =>
|
| 18 |
-
!topics.some(t => (t.topic || '').toLowerCase().includes(ai.toLowerCase()))
|
| 19 |
-
).map(ai => ({label: '#' + ai.replace(/\s+/g, ''), topic: ai, count: 0}));
|
| 20 |
-
|
| 21 |
-
const finalTopics = [...prepend, ...topics].slice(0, 18);
|
| 22 |
-
|
| 23 |
-
el.innerHTML=finalTopics.map(t=>{
|
| 24 |
const topicText=t.topic||t.label.replace(/^#/,'');
|
| 25 |
return`<button class="hot-chip" onclick="searchTopic('${topicText.replace(/'/g,"\\'")}')">${esc(t.label)}</button>`;
|
| 26 |
}).join('');
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
if(top5.length>=2){
|
| 31 |
-
_htTopics = top5.map(t=>t.topic||t.label.replace(/^#/,''));
|
| 32 |
-
loadMultiHashtag(_htTopics);
|
| 33 |
}else if(topics.length){
|
| 34 |
searchTopic(topics[0].topic||topics[0].label.replace(/^#/,''));
|
| 35 |
}
|
|
@@ -37,9 +19,6 @@ async function loadHotTopics(){
|
|
| 37 |
|
| 38 |
async function loadMultiHashtag(topicList){
|
| 39 |
const box=document.getElementById('hashtag-box');if(!box)return;
|
| 40 |
-
_htAllSources = [];
|
| 41 |
-
_htPages = 0;
|
| 42 |
-
|
| 43 |
box.innerHTML=`<div class="hashtag-sources"><h3>🔥 Tin HOT tổng hợp</h3><div class="hashtag-loading"><div class="hashtag-spinner"></div>Tổng hợp từ ${topicList.length} chủ đề nóng nhất...</div></div>`;
|
| 44 |
try{
|
| 45 |
const results=await Promise.all(topicList.map(topic=>
|
|
@@ -54,70 +33,17 @@ async function loadMultiHashtag(topicList){
|
|
| 54 |
if(src&&src.url&&!seen.has(src.url)){seen.add(src.url);src._topic=topicList[j];all.push(src);}
|
| 55 |
}
|
| 56 |
}
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
}catch(e){box.innerHTML=`<div class="hashtag-sources"><h3>🔥 Tin HOT</h3><div style="color:#e74c3c;padding:8px">Lỗi tải tin</div></div>`;}
|
| 60 |
}
|
| 61 |
-
|
| 62 |
-
function _renderHT(){
|
| 63 |
-
const box=document.getElementById('hashtag-box');if(!box)return;
|
| 64 |
-
const all = _htAllSources;
|
| 65 |
-
if(!all.length){box.innerHTML=`<div class="hashtag-sources"><h3>🔥 Tin HOT</h3><div style="color:#888;padding:8px">Đang cập nhật...</div></div>`;return;}
|
| 66 |
-
|
| 67 |
-
const perPage = 12;
|
| 68 |
-
const total = all.length;
|
| 69 |
-
const shown = Math.min((_htPages + 1) * perPage, total);
|
| 70 |
-
const visible = all.slice(0, shown);
|
| 71 |
-
|
| 72 |
-
let h=`<div class="hashtag-sources"><h3>🔥 Tin HOT tổng hợp <span style="font-size:10px;color:#888">(${total} bài · ${_htTopics.length} chủ đề)</span></h3><div id="ht-list">`;
|
| 73 |
-
visible.forEach((s,i)=>{
|
| 74 |
-
const dateStr = _htDates[i] ? `<span style="font-size:9px;color:#666;margin-left:4px">${esc(_htDates[i])}</span>` : '';
|
| 75 |
-
h+=`<div class="hashtag-src-item" onclick="readArticle('${esc(s.url)}')"><div class="hashtag-src-img" id="ht-img-${i}"></div><div class="hashtag-src-text"><div class="hashtag-src-title">${esc(s.title)}</div><div class="hashtag-src-via">${esc(s.via||'')}${dateStr} · <span style="color:#f0c040;font-size:9px">#${esc(s._topic||'')}</span></div></div></div>`;
|
| 76 |
-
});
|
| 77 |
-
h+=`</div><div style="display:flex;gap:4px;flex-wrap:wrap;margin-top:8px">`;
|
| 78 |
-
_htTopics.forEach(t=>{h+=`<button class="hot-chip" onclick="searchTopic('${t.replace(/'/g,"\\'")}')" style="font-size:10px">🔍 #${esc(t)}</button>`;});
|
| 79 |
-
h+=`</div>`;
|
| 80 |
-
if(shown < total){
|
| 81 |
-
h+=`<button class="hashtag-load-more" id="ht-load-more" onclick="htLoadMore()">Tải thêm (${total - shown} tin) ▼</button>`;
|
| 82 |
-
}
|
| 83 |
-
h+=`</div>`;
|
| 84 |
-
box.innerHTML=h;
|
| 85 |
-
|
| 86 |
-
// Lazy load images + dates
|
| 87 |
-
visible.forEach((s,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-'+i);if(el)el.innerHTML=`<img src="${esc(d.og_image||d.img)}" onerror="this.style.display='none'">`;}if(d&&d.date_vn){_htDates[i]=d.date_vn;const viaEl=document.querySelector('#ht-list .hashtag-src-item:nth-child('+(i+1)+') .hashtag-src-via');if(viaEl&&!viaEl.querySelector('.ht-date')){const sp=document.createElement('span');sp.className='ht-date';sp.style.cssText='font-size:9px;color:#666;margin-left:4px';sp.textContent=d.date_vn;viaEl.insertBefore(sp,viaEl.querySelector('span')||null);}}}).catch(()=>{});});
|
| 88 |
-
_htTopic = _htTopics[0];
|
| 89 |
-
}
|
| 90 |
-
|
| 91 |
-
async function htLoadMore(){
|
| 92 |
-
const btn=document.getElementById('ht-load-more');if(!btn)return;
|
| 93 |
-
btn.disabled=true;btn.textContent='Đang tải thêm...';
|
| 94 |
-
const nextPage = _htPages + 1;
|
| 95 |
-
|
| 96 |
-
try{
|
| 97 |
-
// Fetch next page from each topic
|
| 98 |
-
const results=await Promise.all(_htTopics.map(topic=>
|
| 99 |
-
fetch(`/api/hashtag/sources?topic=${encodeURIComponent(topic)}&page=${nextPage}`).then(r=>r.json()).catch(()=>({sources:[]}))
|
| 100 |
-
));
|
| 101 |
-
_htPages = nextPage;
|
| 102 |
-
|
| 103 |
-
// Interleave new sources
|
| 104 |
-
const seen=new Set(_htAllSources.map(s=>s.url));
|
| 105 |
-
const mx=Math.max(...results.map(r=>(r.sources||[]).length));
|
| 106 |
-
const newItems=[];
|
| 107 |
-
for(let i=0;i<mx;i++){
|
| 108 |
-
for(let j=0;j<results.length;j++){
|
| 109 |
-
const src=(results[j].sources||[])[i];
|
| 110 |
-
if(src&&src.url&&!seen.has(src.url)){seen.add(src.url);src._topic=_htTopics[j];newItems.push(src);}
|
| 111 |
-
}
|
| 112 |
-
}
|
| 113 |
-
|
| 114 |
-
if(newItems.length){
|
| 115 |
-
_htAllSources = [..._htAllSources, ...newItems];
|
| 116 |
-
}
|
| 117 |
-
|
| 118 |
-
_renderHT();
|
| 119 |
-
}catch(e){
|
| 120 |
-
btn.disabled=false;btn.textContent='Tải thêm ▼';
|
| 121 |
-
toast('❌ Lỗi tải thêm');
|
| 122 |
-
}
|
| 123 |
-
}
|
|
|
|
| 1 |
// === OVERRIDE: loadHotTopics loads from MULTIPLE hashtags ===
|
|
|
|
| 2 |
// This file loaded AFTER app_v2.js, overrides the function
|
| 3 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
async function loadHotTopics(){
|
| 5 |
const j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));
|
| 6 |
const el=document.getElementById('hot-topics');if(!el)return;
|
| 7 |
const topics=j.topics||[];
|
| 8 |
+
el.innerHTML=topics.slice(0,18).map(t=>{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
const topicText=t.topic||t.label.replace(/^#/,'');
|
| 10 |
return`<button class="hot-chip" onclick="searchTopic('${topicText.replace(/'/g,"\\'")}')">${esc(t.label)}</button>`;
|
| 11 |
}).join('');
|
| 12 |
+
// Load tin HOT = tổng hợp từ TOP 3 hashtag nóng nhất
|
| 13 |
+
if(topics.length>=2){
|
| 14 |
+
loadMultiHashtag(topics.slice(0,3).map(t=>t.topic||t.label.replace(/^#/,'')));
|
|
|
|
|
|
|
|
|
|
| 15 |
}else if(topics.length){
|
| 16 |
searchTopic(topics[0].topic||topics[0].label.replace(/^#/,''));
|
| 17 |
}
|
|
|
|
| 19 |
|
| 20 |
async function loadMultiHashtag(topicList){
|
| 21 |
const box=document.getElementById('hashtag-box');if(!box)return;
|
|
|
|
|
|
|
|
|
|
| 22 |
box.innerHTML=`<div class="hashtag-sources"><h3>🔥 Tin HOT tổng hợp</h3><div class="hashtag-loading"><div class="hashtag-spinner"></div>Tổng hợp từ ${topicList.length} chủ đề nóng nhất...</div></div>`;
|
| 23 |
try{
|
| 24 |
const results=await Promise.all(topicList.map(topic=>
|
|
|
|
| 33 |
if(src&&src.url&&!seen.has(src.url)){seen.add(src.url);src._topic=topicList[j];all.push(src);}
|
| 34 |
}
|
| 35 |
}
|
| 36 |
+
if(!all.length){box.innerHTML=`<div class="hashtag-sources"><h3>🔥 Tin HOT</h3><div style="color:#888;padding:8px">Đang cập nhật...</div></div>`;return;}
|
| 37 |
+
let h=`<div class="hashtag-sources"><h3>🔥 Tin HOT tổng hợp <span style="font-size:10px;color:#888">(${all.length} bài · ${topicList.length} chủ đề)</span></h3><div id="ht-list">`;
|
| 38 |
+
all.slice(0,12).forEach((s,i)=>{
|
| 39 |
+
h+=`<div class="hashtag-src-item" onclick="readArticle('${esc(s.url)}')"><div class="hashtag-src-img" id="ht-img-${i}"></div><div class="hashtag-src-text"><div class="hashtag-src-title">${esc(s.title)}</div><div class="hashtag-src-via">${esc(s.via||'')} · <span style="color:#f0c040;font-size:9px">#${esc(s._topic||'')}</span></div></div></div>`;
|
| 40 |
+
});
|
| 41 |
+
h+=`</div><div style="display:flex;gap:4px;flex-wrap:wrap;margin-top:8px">`;
|
| 42 |
+
topicList.forEach(t=>{h+=`<button class="hot-chip" onclick="searchTopic('${t.replace(/'/g,"\\'")}')" style="font-size:10px">🔍 #${esc(t)}</button>`;});
|
| 43 |
+
h+=`</div></div>`;
|
| 44 |
+
box.innerHTML=h;
|
| 45 |
+
// Lazy load images
|
| 46 |
+
all.slice(0,12).forEach((s,i)=>{if(!s.url)return;fetch('/api/article?url='+encodeURIComponent(s.url)).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){const el=document.getElementById('ht-img-'+i);if(el)el.innerHTML=`<img src="${esc(d.og_image||d.img)}" onerror="this.style.display='none'">`;}}).catch(()=>{});});
|
| 47 |
+
_htTopic=topicList[0];
|
| 48 |
}catch(e){box.innerHTML=`<div class="hashtag-sources"><h3>🔥 Tin HOT</h3><div style="color:#e74c3c;padding:8px">Lỗi tải tin</div></div>`;}
|
| 49 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/index_v2.html
CHANGED
|
@@ -8,34 +8,15 @@
|
|
| 8 |
<meta property="og:title" content="VNEWS - Tin Tức Việt Nam">
|
| 9 |
<meta property="og:image" content="https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg">
|
| 10 |
<link rel="canonical" href="https://bep40-vnews.hf.space">
|
| 11 |
-
<link rel="stylesheet" href="/static/wc2026.css">
|
| 12 |
<script src="https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js"></script>
|
| 13 |
<style>
|
| 14 |
-
*{box-sizing:border-box;margin:0;padding:0}body{background:#111;color:#eee;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;overflow-x:hidden}.header{background:linear-gradient(135deg,#0d1117,#1a3a2a 50%,#8b7500);padding:12px;text-align:center}.header h1{font-size:18px;color:#fff}.header p{font-size:10px;color:#aaa}.cats{display:flex;overflow-x:auto;background:#1a1a1a;border-bottom:1px solid #333;padding:0 4px;position:sticky;top:0;z-index:50;scrollbar-width:none}.cats::-webkit-scrollbar{display:none}.cat{padding:9px 11px;color:#888;font-size:11px;white-space:nowrap;border-bottom:2px solid transparent;cursor:pointer;flex-shrink:0}.cat.active{color:#5cb87a;border-bottom-color:#5cb87a;font-weight:700}.view{display:none}.view.active{display:block}.loading{text-align:center;padding:30px;color:#777;font-size:12px}.slider-wrap{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.slider-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.slider-label{color:#f0c040;font-size:13px;font-weight:800}.slider-note{font-size:10px;color:#777}.slider-track{display:flex;overflow-x:auto;overflow-y:hidden;gap:8px;padding:4px 10px 10px;scrollbar-width:none;-webkit-overflow-scrolling:touch;touch-action:pan-x pan-y;overscroll-behavior-x:contain;scroll-snap-type:x proximity}.slider-track>*{flex:0 0 auto;scroll-snap-align:start;-webkit-tap-highlight-color:transparent}.slider-track::-webkit-scrollbar{display:none}.slider-item{flex:0 0 160px;cursor:pointer;box-sizing:border-box}.hl-league-block{display:inline-flex;align-items:flex-start;flex:0 0 auto;min-width:0}.slider-thumb{position:relative;width:100%;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#333}.slider-thumb img,.slider-thumb video{width:100%;height:100%;object-fit:cover}.slider-title{font-size:10px;color:#ccc;margin-top:3px;line-height:1.2;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.card-play{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:30px;height:30px;border-radius:50%;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;color:#fff;font-size:12px}.grid{display:grid;grid-template-columns:repeat(2,1fr);gap:6px;padding:6px 4px}@media(min-width:650px){.grid{grid-template-columns:repeat(3,1fr)}}.card{background:#1a1a1a;border:1px solid #222;border-radius:8px;overflow:hidden;cursor:pointer}.card-img{position:relative;aspect-ratio:16/9;background:#333}.card-img img{width:100%;height:100%;object-fit:cover}.card-body{padding:6px 8px}.card-title{font-size:11px;line-height:1.35;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.badge{font-size:8px;padding:1px 5px;border-radius:3px;font-weight:700;display:inline-block;margin-bottom:2px;color:#fff}.badge-vne{background:#c0392b}.badge-genk{background:#6a1b9a}.badge-ai{background:#2d8659}.badge-wc{background:#0b6bcb}.section-title{font-size:13px;font-weight:800;color:#5cb87a;margin:8px 0 4px;padding-left:8px;border-left:3px solid #5cb87a}.back-btn{background:#111;color:#fff;border:none;padding:10px;font-size:12px;width:100%;position:sticky;top:0;z-index:60;cursor:pointer}.article-view{padding:12px 8px 40px;max-width:760px;margin:0 auto}.article-title{font-size:18px;font-weight:800;line-height:1.3;margin-bottom:8px}.article-summary{background:#1a2a1f;border-left:3px solid #2d8659;padding:10px;margin-bottom:14px;color:#ccc;font-size:13px}.article-p{font-size:14px;line-height:1.7;color:#ccc;margin-bottom:10px}.article-img{width:100%;border-radius:6px;margin:10px 0}.article-h2{font-size:16px;margin:16px 0 8px;color:#eee}.article-actions{display:flex;gap:8px;flex-wrap:wrap;border-top:1px solid #333;margin-top:16px;padding-top:10px}.article-actions button{background:#1a1a1a;border:1px solid #333;color:#ccc;padding:7px 12px;border-radius:14px;font-size:11px;cursor:pointer}.article-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}.article-ai-ask{margin-top:12px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.article-ai-ask textarea{width:100%;min-height:60px;background:#222;border:1px solid #444;color:#eee;border-radius:8px;padding:9px;font-size:12px}.article-ai-ask button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin-top:6px;font-size:11px;cursor:pointer}.article-ai-answer{white-space:pre-wrap;color:#ccc;font-size:13px;line-height:1.55;margin-top:8px}.tiktok-container{width:100%;height:80vh;max-height:680px;min-height:400px;background:#000}.tiktok-container.feed-fullscreen{position:fixed;inset:0;width:100vw;height:100dvh;max-height:none;min-height:0;z-index:99990;background:#000}.tiktok-container.feed-fullscreen .tiktok-feed,.tiktok-container.feed-fullscreen .tiktok-slide{height:100dvh;max-height:100dvh;min-height:0}.tiktok-feed{height:100%;overflow-y:scroll;-webkit-overflow-scrolling:touch;scroll-behavior:smooth;scroll-snap-type:y mandatory;scrollbar-width:none;touch-action:pan-y;-webkit-user-select:none;user-select:none;overscroll-behavior:contain}.tiktok-feed::-webkit-scrollbar{display:none}.tiktok-slide{height:80vh;max-height:680px;min-height:400px;scroll-snap-align:start;position:relative;background:#000;display:flex;align-items:center;justify-content:center;touch-action:pan-y;cursor:pointer}.tiktok-slide video{width:100%;height:100%;object-fit:cover;border:none;touch-action:manipulation}.tiktok-slide iframe{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:100%;height:100%;border:none;pointer-events:none}.tiktok-slide .slide-gesture{position:absolute;inset:0;z-index:2;touch-action:pan-y;pointer-events:auto;cursor:pointer;background:transparent}.tiktok-slide .tiktok-bottom,.tiktok-slide .tiktok-right,.tiktok-slide .tiktok-counter{pointer-events:auto}.tiktok-slide .inline-comments{pointer-events:auto}.yt-thumb-wrap{position:relative;width:100%;height:100%;background-size:cover;background-position:center;background-image:url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="1" height="1"%3E%3C/svg%3E')}.yt-thumb-wrap iframe{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:100%;height:100%;border:none;pointer-events:auto;cursor:pointer}.tiktok-slide.ratio-wide video{width:100%!important;height:100%!important;object-fit:contain!important}.tiktok-slide.ratio-wide iframe{position:absolute!important;top:50%!important;left:50%!important;transform:translate(-50%,-50%)!important;width:calc(100vw - 1px)!important;height:56.25vw!important;max-width:100vw!important;max-height:100%!important;border:none!important;pointer-events:none!important}.tiktok-bottom{position:absolute;bottom:0;left:0;right:60px;padding:12px 10px 16px;background:linear-gradient(transparent,rgba(0,0,0,.85));z-index:3}.tiktok-title{font-size:12px;color:#fff;max-height:34px;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.tiktok-desc{font-size:10px;color:#bbb;margin-top:3px;max-height:40px;overflow:hidden;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical}.tiktok-counter{position:absolute;top:8px;left:8px;background:rgba(0,0,0,.5);font-size:9px;padding:2px 7px;border-radius:8px;color:#fff;z-index:4}.tiktok-right{position:absolute;right:8px;bottom:100px;display:flex;flex-direction:column;align-items:center;gap:14px;z-index:5}.tiktok-right-btn{display:flex;flex-direction:column;align-items:center;gap:2px;background:none;border:0;color:#fff;cursor:pointer;font-size:10px}.tiktok-right-btn .icon{width:42px;height:42px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:20px}.tiktok-right-btn .count{font-size:10px;color:#ddd}.inline-comments{position:absolute;bottom:0;left:0;right:0;max-height:60%;min-height:140px;background:rgba(18,18,18,.95);border-radius:14px 14px 0 0;z-index:10;overflow:clip;display:flex;flex-direction:column}.inline-cmt-header{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-bottom:1px solid #333;color:#5cb87a;font-size:12px;font-weight:700;flex-shrink:0}.inline-cmt-header button{background:none;border:0;color:#fff;font-size:16px;cursor:pointer}.inline-cmt-list{flex:1 1 auto;overflow-y:auto;padding:6px 10px;max-height:140px;min-height:40px}.inline-cmt-item{background:#222;border-radius:8px;padding:6px 8px;margin:4px 0;color:#ccc;font-size:11px;line-height:1.3}.inline-cmt-time{font-size:9px;color:#777;margin-right:6px}.inline-cmt-input{display:flex;gap:6px;padding:8px 10px;border-top:1px solid #333;flex-shrink:0;position:sticky;bottom:0;background:rgba(18,18,18,.98)}.inline-cmt-input input{flex:1;background:#222;border:1px solid #444;color:#eee;border-radius:16px;padding:7px 12px;font-size:11px;min-height:32px}.inline-cmt-input button{background:#2d8659;border:0;color:#fff;border-radius:16px;padding:7px 12px;font-size:11px;cursor:pointer;min-height:32px}.wc2026-section{margin:6px 4px;background:linear-gradient(135deg,#0d1117,#1a1a3a);border:1px solid #1a3a5a;border-radius:10px;overflow:hidden}.wc-header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:linear-gradient(90deg,#0b2e4a,#1a3a5a)}.wc-header h2{font-size:15px;color:#fff;margin:0}.wc-live-badge{font-size:10px;color:#e74c3c;font-weight:700;animation:wc-pulse 1.5s infinite}@keyframes wc-pulse{0%,100%{opacity:1}50%{opacity:.4}}.wc-tabs{display:flex;gap:4px;padding:8px 10px;overflow-x:auto;scrollbar-width:none}.wc-tabs::-webkit-scrollbar{display:none}.wc-tab{padding:5px 10px;background:#1a2a3a;border:1px solid #2a3a4a;border-radius:12px;color:#8ab4f0;font-size:10px;cursor:pointer;white-space:nowrap;flex-shrink:0}.wc-tab.active{background:#0b6bcb;border-color:#0b6bcb;color:#fff;font-weight:700}.wc-content{padding:8px 10px;max-height:500px;overflow-y:auto}.wc-news-grid{display:flex;flex-direction:column;gap:8px}.wc-news-item{display:flex;gap:8px;padding:8px;background:#1a2030;border-radius:8px;cursor:pointer}.wc-news-item:active{opacity:.8}.wc-news-img{flex:0 0 70px;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#222}.wc-news-img img{width:100%;height:100%;object-fit:cover}.wc-news-text{flex:1;min-width:0}.wc-news-title{font-size:11px;font-weight:700;color:#eee;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wc-news-via{font-size:9px;color:#6a9fca;margin-top:2px}.ls-section{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.ls-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.ls-header h3{color:#f0c040;font-size:13px;font-weight:800}.ls-tabs{display:flex;gap:4px;padding:0 10px 8px;overflow-x:auto;scrollbar-width:none}.ls-tabs::-webkit-scrollbar{display:none}.ls-tab{padding:4px 10px;background:#222;border:1px solid #333;border-radius:12px;color:#999;font-size:10px;white-space:nowrap;cursor:pointer;flex-shrink:0}.ls-tab.active{background:#2d8659;border-color:#2d8659;color:#fff;font-weight:700}.ls-content{max-height:420px;overflow-y:auto;padding:0 6px 8px;font-size:12px;color:#ddd}.ls-content ul{list-style:none;padding:0;margin:0}.ls-content .title-content{display:flex;gap:6px;align-items:center;background:#222;border-radius:4px;margin:4px 0;padding:5px 8px}.ls-content .title-content img{width:18px;height:18px}.ls-content .title-content strong{font-size:11px;color:#ccc}.ls-content .match-detail{padding:6px;border-bottom:1px solid #262626;cursor:pointer}.ls-content .match-detail:hover{background:#1a2a1f}.ls-content .match{display:flex;flex-wrap:wrap;align-items:center;gap:4px}.ls-content .datetime{width:100%;font-size:9px;color:#888}.ls-content .teams{display:flex;width:100%;align-items:center;gap:4px}.ls-content .team{flex:1;display:flex;align-items:center;gap:4px;min-width:0;text-decoration:none}.ls-content .team .name{font-size:11px;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ls-content .team .logo img{width:18px;height:18px}.ls-content .home-team{justify-content:flex-end;text-align:right}.ls-content .status{flex:0 0 54px;text-align:center}.ls-content .status a{color:#fff;text-decoration:none;font-weight:800;font-size:12px}.ls-content .status .label{font-size:8px;color:#888;display:block}.ls-content .status .label.live{color:#e74c3c}.ls-content .info,.ls-content .btns{display:none}.ls-content table{width:100%;border-collapse:collapse;font-size:11px;color:#ccc}.ls-content table th{background:#222;color:#999;padding:5px 4px;font-size:10px;border-bottom:1px solid #333}.ls-content table td{padding:4px 3px;border-bottom:1px solid #1a1a1a}.ls-content table .team-name{display:flex;align-items:center;gap:4px}.ls-content table .team-name img{width:16px;height:16px}.ls-content table .pts{font-weight:800;color:#f0c040}.match-overlay{position:fixed;inset:0;background:#111;z-index:9999;display:none;flex-direction:column;overflow:auto}.match-overlay.active{display:flex}.mo-header{padding:10px;background:#1a1a1a;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:1}.mo-header h3{font-size:13px;color:#eee}.mo-close{background:none;border:0;color:#fff;font-size:22px;cursor:pointer}.mo-tabs{display:flex;gap:4px;padding:8px 10px;background:#1a1a1a;overflow-x:auto}.mo-tab{padding:5px 12px;background:#222;border:1px solid #333;border-radius:10px;color:#999;font-size:10px;cursor:pointer;white-space:nowrap}.mo-tab.active{background:#2d8659;color:#fff}.mo-body{padding:8px;overflow-x:auto;font-size:12px;color:#ddd}.mo-body ul{list-style:none;padding:0;margin:0}.mo-body li{padding:5px 0;border-bottom:1px solid #222}.featured-match{margin:6px 4px;background:linear-gradient(135deg,#1a2a1f,#0d1117);border:1px solid #2d8659;border-radius:10px;padding:12px;cursor:pointer}.fm-league{text-align:center;color:#5cb87a;font-size:9px;font-weight:700;text-transform:uppercase}.fm-teams{display:flex;align-items:center;justify-content:center;gap:10px;margin-top:6px}.fm-team{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px}.fm-team img{width:32px;height:32px;object-fit:contain}.fm-team span{font-size:10px;color:#ccc;text-align:center}.fm-score{font-size:22px;font-weight:900;min-width:60px;text-align:center;color:#fff}.fm-status{text-align:center;margin-top:6px;font-size:9px;color:#e74c3c;font-weight:700}.fm-status.upcoming{color:#f0c040}.ai-compose{margin:6px 4px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.ai-compose-title{font-size:13px;font-weight:800;color:#5cb87a;margin-bottom:8px}.ai-compose-row{display:flex;gap:6px;margin-top:6px}.ai-compose input{flex:1;background:#222;border:1px solid #333;color:#eee;border-radius:18px;padding:9px 12px;font-size:12px;min-width:0}.ai-compose button{background:#2d8659;border:0;color:#fff;border-radius:18px;padding:9px 12px;font-size:11px;font-weight:700;cursor:pointer;white-space:nowrap}.ai-compose button.secondary{background:#333}.ai-compose button.hot-short-btn{background:linear-gradient(135deg,#8b1a5a,#c0392b);border:1px solid #e07050;color:#fff;box-shadow:0 0 10px rgba(192,57,43,.4);animation:hot-pulse 2s infinite}@keyframes hot-pulse{0%,100%{box-shadow:0 0 6px rgba(192,57,43,.35)}50%{box-shadow:0 0 16px rgba(192,57,43,.75)}}.hot-topic-row{display:flex;gap:6px;overflow-x:auto;padding:4px 0;scrollbar-width:none}.hot-topic-row::-webkit-scrollbar{display:none}.hot-chip{flex:0 0 auto;background:#222;border:1px solid #333;color:#ddd;border-radius:16px;padding:5px 10px;font-size:11px;cursor:pointer;white-space:nowrap}.hot-chip:active{transform:scale(.96)}.hashtag-sources{margin:8px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.hashtag-sources h3{font-size:13px;color:#5cb87a;margin-bottom:8px}.hashtag-src-item{display:flex;gap:8px;padding:8px;background:#202020;border-radius:8px;margin:6px 0;cursor:pointer}.hashtag-src-item:active{opacity:.8}.hashtag-src-img{flex:0 0 80px;aspect-ratio:16/9;background:#333;border-radius:6px;overflow:hidden}.hashtag-src-img img{width:100%;height:100%;object-fit:cover}.hashtag-src-text{flex:1;min-width:0}.hashtag-src-title{font-size:12px;font-weight:700;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.hashtag-src-via{font-size:10px;color:#888;margin-top:2px}.hashtag-rewrite-btn{width:100%;margin-top:8px;background:#2d8659;border:0;color:#fff;padding:9px;border-radius:10px;font-size:12px;font-weight:700;cursor:pointer}.hashtag-load-more{width:100%;margin-top:8px;background:#222;border:1px solid #333;color:#ccc;padding:9px;border-radius:10px;font-size:12px;cursor:pointer}.hashtag-loading{display:flex;align-items:center;gap:8px;padding:12px;color:#888;font-size:12px}.hashtag-spinner{width:16px;height:16px;border:2px solid #333;border-top-color:#5cb87a;border-radius:50%;animation:ht-spin .8s linear infinite}@keyframes ht-spin{to{transform:rotate(360deg)}}.wall-item{flex:0 0 260px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:8px}.wall-item-new{animation:wall-flash 1.8s ease-out}@keyframes wall-flash{0%{border-color:#f0c040;box-shadow:0 0 18px rgba(240,192,64,.35)}30%{border-color:#f0c040;box-shadow:0 0 12px rgba(240,192,64,.2)}100%{border-color:#2b2b2b;box-shadow:none}}.wall-thumb{width:100%;aspect-ratio:16/9;border-radius:8px;background:#222;overflow:hidden;margin-bottom:6px;position:relative}.wall-thumb img,.wall-thumb video{width:100%;height:100%;object-fit:cover}.wall-video-badge{position:absolute;top:4px;right:4px;background:rgba(45,134,89,.9);color:#fff;font-size:10px;padding:2px 6px;border-radius:6px;font-weight:700}.wall-title{font-size:12px;color:#5cb87a;font-weight:800;line-height:1.3;margin-bottom:4px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wall-text{font-size:11px;color:#bbb;line-height:1.4;white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden}.wall-actions{display:flex;gap:6px;margin-top:8px}.wall-actions button{flex:1;border:1px solid #333;background:#222;color:#ddd;border-radius:14px;padding:6px 8px;font-size:10px;cursor:pointer}.wall-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}.wall-actions button.wall-btn-design{background:#1a3a5a;border-color:#3a5a7a;color:#fff;font-weight:700}#progress-toast{position:fixed;bottom:70px;left:50%;transform:translateX(-50%);background:#2d8659;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;z-index:99998;box-shadow:0 4px 12px rgba(0,0,0,.4);display:none;white-space:nowrap}.storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 4px}
|
| 15 |
-
.hl-load-more{margin:4px 8px 8px;background:#222;border:1px solid #333;color:#ccc;padding:6px 12px;border-radius:8px;font-size:11px;cursor:pointer;width:calc(100% - 16px);display:block}
|
| 16 |
-
.hl-load-more:hover{background:#2a2a2a}
|
| 17 |
-
.ls-section-header{padding:8px 10px;font-size:11px;font-weight:700;color:#5cb87a;border-bottom:1px solid #2a2a2a}
|
| 18 |
-
.ls-section-header.upcoming{color:#f0c040}
|
| 19 |
-
.ls-section-header.finished{color:#e74c3c}
|
| 20 |
-
.ls-section-header.yesterday{color:#8ab4f0}
|
| 21 |
-
.ls-section-header.older{color:#888}.ls-section-header.older{color:#888}
|
| 22 |
-
/* FPT PLAY / HIGHLIGHT video overflow fix v2 — same containment as short-AI wall
|
| 23 |
-
FIX: iframes now use width:100%/height:100% (matching <video>) instead of
|
| 24 |
-
width:177.78vh. FPT/YouTube iframes now appear IDENTICAL to Short AI <video> tags.
|
| 25 |
-
The .ratio-wide toggle (16:9) is handled by fm_fix.css with matching ID specificity. */
|
| 26 |
-
.tiktok-slide{overflow:hidden!important}
|
| 27 |
-
.tiktok-slide video{position:absolute!important;top:50%!important;left:50%!important;transform:translate(-50%,-50%)!important;width:100%!important;height:100%!important;object-fit:contain!important;border:none!important;pointer-events:auto!important}
|
| 28 |
-
.tiktok-slide iframe{position:absolute!important;top:50%!important;left:50%!important;transform:translate(-50%,-50%)!important;width:100%!important;height:100%!important;border:none!important;pointer-events:none!important}
|
| 29 |
-
.tiktok-slide.ratio-wide iframe{position:absolute!important;top:50%!important;left:50%!important;transform:translate(-50%,-50%)!important;width:calc(100vw - 1px)!important;height:56.25vw!important;max-width:100vw!important;max-height:100dvh!important;border:none!important;pointer-events:auto!important}
|
| 30 |
-
.yt-thumb-wrap{overflow:hidden!important}
|
| 31 |
-
.yt-thumb-wrap iframe{position:absolute!important;top:50%!important;left:50%!important;transform:translate(-50%,-50%)!important;width:100%!important;height:100%!important;border:none!important;pointer-events:auto!important;cursor:pointer!important}
|
| 32 |
-
.wall-thumb img,.wall-thumb video{max-width:100%!important;max-height:100%!important;object-fit:cover!important}
|
| 33 |
</style>
|
| 34 |
</head>
|
| 35 |
<body>
|
| 36 |
<div class="header"><h1>📰 VNEWS</h1><p>Tin tức · Bóng đá LIVE · Highlight · AI · World Cup 2026</p></div>
|
| 37 |
<div class="cats" id="cat-bar"></div>
|
| 38 |
-
<div id="home-hl-section"></div>
|
| 39 |
<div id="view-home" class="view active"><div class="loading">Đang tải...</div></div>
|
| 40 |
<div id="view-cat" class="view"></div>
|
| 41 |
<div id="view-video" class="view"></div>
|
|
@@ -60,6 +41,7 @@ function doShare(title,url,img,postId){
|
|
| 60 |
} else {
|
| 61 |
shareUrl = SPACE+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||'');
|
| 62 |
}
|
|
|
|
| 63 |
if(navigator.clipboard && navigator.clipboard.writeText){
|
| 64 |
navigator.clipboard.writeText(shareUrl).then(function(){
|
| 65 |
toast('📋 Đã sao chép link!');
|
|
@@ -78,7 +60,7 @@ function doShare(title,url,img,postId){
|
|
| 78 |
var ta=document.createElement('textarea');ta.value=shareUrl;ta.style.position='fixed';ta.style.left='-9999px';ta.style.top='-9999px';ta.style.opacity='0';
|
| 79 |
document.body.appendChild(ta);ta.select();ta.setSelectionRange(0,99999);
|
| 80 |
if(document.execCommand('copy')){toast('📋 Đã sao chép link!');}else{prompt('📋 Sao chép link:', shareUrl);}
|
| 81 |
-
|
| 82 |
}catch(e){prompt('📋 Sao chép link:', shareUrl);}
|
| 83 |
try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){}
|
| 84 |
}
|
|
@@ -86,8 +68,7 @@ function doShare(title,url,img,postId){
|
|
| 86 |
async function init(){_cats=await fetch('/api/categories').then(r=>r.json()).catch(()=>[]);let bar='<div class="cat active" data-cat="home">🏠</div><div class="cat" data-cat="news-all">📰 Tin tức</div>';_cats.forEach(c=>{bar+='<div class="cat" data-cat="'+c.id+'">'+c.name+'</div>'});document.getElementById('cat-bar').innerHTML=bar;document.querySelectorAll('.cat').forEach(t=>{t.onclick=()=>switchCat(t.dataset.cat)});}
|
| 87 |
var SPACE=location.origin;
|
| 88 |
</script>
|
| 89 |
-
<script src="/static/app_v2.js?v=
|
| 90 |
-
<script src="/static/designer_v2.js?v=20260817fix1"></script>
|
| 91 |
<script src="/static/yt_live.js"></script>
|
| 92 |
<script src="/static/vtv_init.js"></script>
|
| 93 |
<script src="/static/hot_multi.js?v=1"></script>
|
|
|
|
| 8 |
<meta property="og:title" content="VNEWS - Tin Tức Việt Nam">
|
| 9 |
<meta property="og:image" content="https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg">
|
| 10 |
<link rel="canonical" href="https://bep40-vnews.hf.space">
|
| 11 |
+
<link rel="stylesheet" href="/static/wc2026.css">
|
| 12 |
<script src="https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js"></script>
|
| 13 |
<style>
|
| 14 |
+
*{box-sizing:border-box;margin:0;padding:0}body{background:#111;color:#eee;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;overflow-x:hidden}.header{background:linear-gradient(135deg,#0d1117,#1a3a2a 50%,#8b7500);padding:12px;text-align:center}.header h1{font-size:18px;color:#fff}.header p{font-size:10px;color:#aaa}.cats{display:flex;overflow-x:auto;background:#1a1a1a;border-bottom:1px solid #333;padding:0 4px;position:sticky;top:0;z-index:50;scrollbar-width:none}.cats::-webkit-scrollbar{display:none}.cat{padding:9px 11px;color:#888;font-size:11px;white-space:nowrap;border-bottom:2px solid transparent;cursor:pointer;flex-shrink:0}.cat.active{color:#5cb87a;border-bottom-color:#5cb87a;font-weight:700}.view{display:none}.view.active{display:block}.loading{text-align:center;padding:30px;color:#777;font-size:12px}.slider-wrap{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.slider-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.slider-label{color:#f0c040;font-size:13px;font-weight:800}.slider-note{font-size:10px;color:#777}.slider-track{display:flex;overflow-x:auto;gap:8px;padding:4px 10px 10px;scrollbar-width:none}.slider-track::-webkit-scrollbar{display:none}.slider-item{flex:0 0 160px;cursor:pointer}.slider-thumb{position:relative;width:100%;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#333}.slider-thumb img,.slider-thumb video{width:100%;height:100%;object-fit:cover}.slider-title{font-size:10px;color:#ccc;margin-top:3px;line-height:1.2;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.card-play{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:30px;height:30px;border-radius:50%;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;color:#fff;font-size:12px}.grid{display:grid;grid-template-columns:repeat(2,1fr);gap:6px;padding:6px 4px}@media(min-width:650px){.grid{grid-template-columns:repeat(3,1fr)}}.card{background:#1a1a1a;border:1px solid #222;border-radius:8px;overflow:hidden;cursor:pointer}.card-img{position:relative;aspect-ratio:16/9;background:#333}.card-img img{width:100%;height:100%;object-fit:cover}.card-body{padding:6px 8px}.card-title{font-size:11px;line-height:1.35;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.badge{font-size:8px;padding:1px 5px;border-radius:3px;font-weight:700;display:inline-block;margin-bottom:2px;color:#fff}.badge-vne{background:#c0392b}.badge-genk{background:#6a1b9a}.badge-ai{background:#2d8659}.badge-wc{background:#0b6bcb}.section-title{font-size:13px;font-weight:800;color:#5cb87a;margin:8px 0 4px;padding-left:8px;border-left:3px solid #5cb87a}.back-btn{background:#111;color:#fff;border:none;padding:10px;font-size:12px;width:100%;position:sticky;top:0;z-index:60;cursor:pointer}.article-view{padding:12px 8px 40px;max-width:760px;margin:0 auto}.article-title{font-size:18px;font-weight:800;line-height:1.3;margin-bottom:8px}.article-summary{background:#1a2a1f;border-left:3px solid #2d8659;padding:10px;margin-bottom:14px;color:#ccc;font-size:13px}.article-p{font-size:14px;line-height:1.7;color:#ccc;margin-bottom:10px}.article-img{width:100%;border-radius:6px;margin:10px 0}.article-h2{font-size:16px;margin:16px 0 8px;color:#eee}.article-actions{display:flex;gap:8px;flex-wrap:wrap;border-top:1px solid #333;margin-top:16px;padding-top:10px}.article-actions button{background:#1a1a1a;border:1px solid #333;color:#ccc;padding:7px 12px;border-radius:14px;font-size:11px;cursor:pointer}.article-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}.article-ai-ask{margin-top:12px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.article-ai-ask textarea{width:100%;min-height:60px;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px;font-size:12px}.article-ai-ask button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin-top:6px;font-size:11px;cursor:pointer}.article-ai-answer{white-space:pre-wrap;color:#ccc;font-size:13px;line-height:1.55;margin-top:8px}.tiktok-container{width:100%;height:80vh;max-height:680px;min-height:400px;background:#000}.tiktok-feed{height:100%;overflow-y:scroll;scroll-snap-type:y mandatory;scrollbar-width:none}.tiktok-feed::-webkit-scrollbar{display:none}.tiktok-slide{height:80vh;max-height:680px;min-height:400px;scroll-snap-align:start;position:relative;background:#000;display:flex;align-items:center;justify-content:center}.tiktok-slide video,.tiktok-slide iframe{width:100%;height:100%;object-fit:cover;border:none}.tiktok-slide.ratio-wide video,.tiktok-slide.ratio-wide iframe{object-fit:contain}.tiktok-bottom{position:absolute;bottom:0;left:0;right:60px;padding:12px 10px 16px;background:linear-gradient(transparent,rgba(0,0,0,.85));z-index:3}.tiktok-title{font-size:12px;color:#fff}.tiktok-counter{position:absolute;top:8px;left:8px;background:rgba(0,0,0,.5);font-size:9px;padding:2px 7px;border-radius:8px;color:#fff;z-index:4}.tiktok-right{position:absolute;right:8px;bottom:100px;display:flex;flex-direction:column;align-items:center;gap:14px;z-index:5}.tiktok-right-btn{display:flex;flex-direction:column;align-items:center;gap:2px;background:none;border:0;color:#fff;cursor:pointer;font-size:10px}.tiktok-right-btn .icon{width:42px;height:42px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:20px}.tiktok-right-btn .count{font-size:10px;color:#ddd}.inline-comments{position:absolute;bottom:0;left:0;right:0;max-height:60%;min-height:140px;background:rgba(18,18,18,.95);border-radius:14px 14px 0 0;z-index:10;overflow:clip;display:flex;flex-direction:column}.inline-cmt-header{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-bottom:1px solid #333;color:#5cb87a;font-size:12px;font-weight:700;flex-shrink:0}.inline-cmt-header button{background:none;border:0;color:#fff;font-size:16px;cursor:pointer}.inline-cmt-list{flex:1 1 auto;overflow-y:auto;padding:6px 10px;max-height:140px;min-height:40px}.inline-cmt-item{background:#222;border-radius:8px;padding:6px 8px;margin:4px 0;color:#ccc;font-size:11px;line-height:1.3}.inline-cmt-time{font-size:9px;color:#777;margin-right:6px}.inline-cmt-input{display:flex;gap:6px;padding:8px 10px;border-top:1px solid #333;flex-shrink:0;position:sticky;bottom:0;background:rgba(18,18,18,.98)}.inline-cmt-input input{flex:1;background:#222;border:1px solid #444;color:#eee;border-radius:16px;padding:7px 12px;font-size:11px;min-height:32px}.inline-cmt-input button{background:#2d8659;border:0;color:#fff;border-radius:16px;padding:7px 12px;font-size:11px;cursor:pointer;min-height:32px}.wc2026-section{margin:6px 4px;background:linear-gradient(135deg,#0d1117,#1a1a3a);border:1px solid #1a3a5a;border-radius:10px;overflow:hidden}.wc-header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:linear-gradient(90deg,#0b2e4a,#1a3a5a)}.wc-header h2{font-size:15px;color:#fff;margin:0}.wc-live-badge{font-size:10px;color:#e74c3c;font-weight:700;animation:wc-pulse 1.5s infinite}@keyframes wc-pulse{0%,100%{opacity:1}50%{opacity:.4}}.wc-tabs{display:flex;gap:4px;padding:8px 10px;overflow-x:auto;scrollbar-width:none}.wc-tabs::-webkit-scrollbar{display:none}.wc-tab{padding:5px 10px;background:#1a2a3a;border:1px solid #2a3a4a;border-radius:12px;color:#8ab4d8;font-size:10px;cursor:pointer;white-space:nowrap;flex-shrink:0}.wc-tab.active{background:#0b6bcb;border-color:#0b6bcb;color:#fff;font-weight:700}.wc-content{padding:8px 10px;max-height:500px;overflow-y:auto}.wc-news-grid{display:flex;flex-direction:column;gap:8px}.wc-news-item{display:flex;gap:8px;padding:8px;background:#1a2030;border-radius:8px;cursor:pointer}.wc-news-item:active{opacity:.8}.wc-news-img{flex:0 0 70px;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#222}.wc-news-img img{width:100%;height:100%;object-fit:cover}.wc-news-text{flex:1;min-width:0}.wc-news-title{font-size:11px;font-weight:700;color:#eee;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wc-news-via{font-size:9px;color:#6a9fca;margin-top:2px}.ls-section{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.ls-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.ls-header h3{color:#f0c040;font-size:13px;font-weight:800}.ls-tabs{display:flex;gap:4px;padding:0 10px 8px;overflow-x:auto;scrollbar-width:none}.ls-tabs::-webkit-scrollbar{display:none}.ls-tab{padding:4px 10px;background:#222;border:1px solid #333;border-radius:12px;color:#999;font-size:10px;white-space:nowrap;cursor:pointer;flex-shrink:0}.ls-tab.active{background:#2d8659;border-color:#2d8659;color:#fff;font-weight:700}.ls-content{max-height:420px;overflow-y:auto;padding:0 6px 8px;font-size:12px;color:#ddd}.ls-content ul{list-style:none;padding:0;margin:0}.ls-content .title-content{display:flex;gap:6px;align-items:center;background:#222;border-radius:4px;margin:4px 0;padding:5px 8px}.ls-content .title-content img{width:18px;height:18px}.ls-content .title-content strong{font-size:11px;color:#ccc}.ls-content .match-detail{padding:6px;border-bottom:1px solid #262626;cursor:pointer}.ls-content .match-detail:hover{background:#1a2a1f}.ls-content .match{display:flex;flex-wrap:wrap;align-items:center;gap:4px}.ls-content .datetime{width:100%;font-size:9px;color:#888}.ls-content .teams{display:flex;width:100%;align-items:center;gap:4px}.ls-content .team{flex:1;display:flex;align-items:center;gap:4px;min-width:0;text-decoration:none}.ls-content .team .name{font-size:11px;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ls-content .team .logo img{width:18px;height:18px}.ls-content .home-team{justify-content:flex-end;text-align:right}.ls-content .status{flex:0 0 54px;text-align:center}.ls-content .status a{color:#fff;text-decoration:none;font-weight:800;font-size:12px}.ls-content .status .label{font-size:8px;color:#888;display:block}.ls-content .status .label.live{color:#e74c3c}.ls-content .info,.ls-content .btns{display:none}.ls-content table{width:100%;border-collapse:collapse;font-size:11px;color:#ccc}.ls-content table th{background:#222;color:#999;padding:5px 4px;font-size:10px;border-bottom:1px solid #333}.ls-content table td{padding:4px 3px;border-bottom:1px solid #1a1a1a}.ls-content table .team-name{display:flex;align-items:center;gap:4px}.ls-content table .team-name img{width:16px;height:16px}.ls-content table .pts{font-weight:800;color:#f0c040}.match-overlay{position:fixed;inset:0;background:#111;z-index:9999;display:none;flex-direction:column;overflow:auto}.match-overlay.active{display:flex}.mo-header{padding:10px;background:#1a1a1a;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:1}.mo-header h3{font-size:13px;color:#eee}.mo-close{background:none;border:0;color:#fff;font-size:22px;cursor:pointer}.mo-tabs{display:flex;gap:4px;padding:8px 10px;background:#1a1a1a;overflow-x:auto}.mo-tab{padding:5px 12px;background:#222;border:1px solid #333;border-radius:10px;color:#999;font-size:10px;cursor:pointer;white-space:nowrap}.mo-tab.active{background:#2d8659;color:#fff}.mo-body{padding:8px;overflow-x:auto;font-size:12px;color:#ddd}.mo-body ul{list-style:none;padding:0;margin:0}.mo-body li{padding:5px 0;border-bottom:1px solid #222}.featured-match{margin:6px 4px;background:linear-gradient(135deg,#1a2a1f,#0d1117);border:1px solid #2d8659;border-radius:10px;padding:12px;cursor:pointer}.fm-league{text-align:center;color:#5cb87a;font-size:9px;font-weight:700;text-transform:uppercase}.fm-teams{display:flex;align-items:center;justify-content:center;gap:10px;margin-top:6px}.fm-team{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px}.fm-team img{width:32px;height:32px;object-fit:contain}.fm-team span{font-size:10px;color:#ccc;text-align:center}.fm-score{font-size:22px;font-weight:900;min-width:60px;text-align:center;color:#fff}.fm-status{text-align:center;margin-top:6px;font-size:9px;color:#e74c3c;font-weight:700}.fm-status.upcoming{color:#f0c040}.ai-compose{margin:6px 4px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.ai-compose-title{font-size:13px;font-weight:800;color:#5cb87a;margin-bottom:8px}.ai-compose-row{display:flex;gap:6px;margin-top:6px}.ai-compose input{flex:1;background:#222;border:1px solid #333;color:#eee;border-radius:18px;padding:9px 12px;font-size:12px;min-width:0}.ai-compose button{background:#2d8659;border:0;color:#fff;border-radius:18px;padding:9px 12px;font-size:11px;font-weight:700;cursor:pointer;white-space:nowrap}.ai-compose button.secondary{background:#333}.hot-topic-row{display:flex;gap:6px;overflow-x:auto;padding:4px 0;scrollbar-width:none}.hot-topic-row::-webkit-scrollbar{display:none}.hot-chip{flex:0 0 auto;background:#222;border:1px solid #333;color:#ddd;border-radius:16px;padding:5px 10px;font-size:11px;cursor:pointer;white-space:nowrap}.hot-chip:active{transform:scale(.96)}.hashtag-sources{margin:8px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.hashtag-sources h3{font-size:13px;color:#5cb87a;margin-bottom:8px}.hashtag-src-item{display:flex;gap:8px;padding:8px;background:#202020;border-radius:8px;margin:6px 0;cursor:pointer}.hashtag-src-item:active{opacity:.8}.hashtag-src-img{flex:0 0 80px;aspect-ratio:16/9;background:#333;border-radius:6px;overflow:hidden}.hashtag-src-img img{width:100%;height:100%;object-fit:cover}.hashtag-src-text{flex:1;min-width:0}.hashtag-src-title{font-size:12px;font-weight:700;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.hashtag-src-via{font-size:10px;color:#888;margin-top:2px}.hashtag-rewrite-btn{width:100%;margin-top:8px;background:#2d8659;border:0;color:#fff;padding:9px;border-radius:10px;font-size:12px;font-weight:700;cursor:pointer}.hashtag-load-more{width:100%;margin-top:8px;background:#222;border:1px solid #333;color:#ccc;padding:9px;border-radius:10px;font-size:12px;cursor:pointer}.hashtag-loading{display:flex;align-items:center;gap:8px;padding:12px;color:#888;font-size:12px}.hashtag-spinner{width:16px;height:16px;border:2px solid #333;border-top-color:#5cb87a;border-radius:50%;animation:ht-spin .8s linear infinite}@keyframes ht-spin{to{transform:rotate(360deg)}}.wall-item{flex:0 0 260px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:8px}.wall-item-new{animation:wall-flash 1.8s ease-out}@keyframes wall-flash{0%{border-color:#f0c040;box-shadow:0 0 18px rgba(240,192,64,.35)}30%{border-color:#f0c040;box-shadow:0 0 12px rgba(240,192,64,.2)}100%{border-color:#2b2b2b;box-shadow:none}}.wall-thumb{width:100%;aspect-ratio:16/9;border-radius:8px;background:#222;overflow:hidden;margin-bottom:6px;position:relative}.wall-thumb img{width:100%;height:100%;object-fit:cover}.wall-video-badge{position:absolute;top:4px;right:4px;background:rgba(45,134,89,.9);color:#fff;font-size:10px;padding:2px 6px;border-radius:6px;font-weight:700}.wall-title{font-size:12px;color:#5cb87a;font-weight:800;line-height:1.3;margin-bottom:4px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wall-text{font-size:11px;color:#bbb;line-height:1.4;white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden}.wall-actions{display:flex;gap:6px;margin-top:8px}.wall-actions button{flex:1;border:1px solid #333;background:#222;color:#ddd;border-radius:14px;padding:6px 8px;font-size:10px;cursor:pointer}.wall-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}#progress-toast{position:fixed;bottom:70px;left:50%;transform:translateX(-50%);background:#2d8659;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;z-index:99998;box-shadow:0 4px 12px rgba(0,0,0,.4);display:none;white-space:nowrap}.storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 4px}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
</style>
|
| 16 |
</head>
|
| 17 |
<body>
|
| 18 |
<div class="header"><h1>📰 VNEWS</h1><p>Tin tức · Bóng đá LIVE · Highlight · AI · World Cup 2026</p></div>
|
| 19 |
<div class="cats" id="cat-bar"></div>
|
|
|
|
| 20 |
<div id="view-home" class="view active"><div class="loading">Đang tải...</div></div>
|
| 21 |
<div id="view-cat" class="view"></div>
|
| 22 |
<div id="view-video" class="view"></div>
|
|
|
|
| 41 |
} else {
|
| 42 |
shareUrl = SPACE+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||'');
|
| 43 |
}
|
| 44 |
+
// Use clipboard API first (modern, works on Chrome mobile 85+)
|
| 45 |
if(navigator.clipboard && navigator.clipboard.writeText){
|
| 46 |
navigator.clipboard.writeText(shareUrl).then(function(){
|
| 47 |
toast('📋 Đã sao chép link!');
|
|
|
|
| 60 |
var ta=document.createElement('textarea');ta.value=shareUrl;ta.style.position='fixed';ta.style.left='-9999px';ta.style.top='-9999px';ta.style.opacity='0';
|
| 61 |
document.body.appendChild(ta);ta.select();ta.setSelectionRange(0,99999);
|
| 62 |
if(document.execCommand('copy')){toast('📋 Đã sao chép link!');}else{prompt('📋 Sao chép link:', shareUrl);}
|
| 63 |
+
document.body.removeChild(ta);
|
| 64 |
}catch(e){prompt('📋 Sao chép link:', shareUrl);}
|
| 65 |
try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){}
|
| 66 |
}
|
|
|
|
| 68 |
async function init(){_cats=await fetch('/api/categories').then(r=>r.json()).catch(()=>[]);let bar='<div class="cat active" data-cat="home">🏠</div><div class="cat" data-cat="news-all">📰 Tin tức</div>';_cats.forEach(c=>{bar+='<div class="cat" data-cat="'+c.id+'">'+c.name+'</div>'});document.getElementById('cat-bar').innerHTML=bar;document.querySelectorAll('.cat').forEach(t=>{t.onclick=()=>switchCat(t.dataset.cat)});}
|
| 69 |
var SPACE=location.origin;
|
| 70 |
</script>
|
| 71 |
+
<script src="/static/app_v2.js?v=20260711"></script>
|
|
|
|
| 72 |
<script src="/static/yt_live.js"></script>
|
| 73 |
<script src="/static/vtv_init.js"></script>
|
| 74 |
<script src="/static/hot_multi.js?v=1"></script>
|
static/live_mode.js
CHANGED
|
@@ -23,29 +23,7 @@
|
|
| 23 |
.hashtag-src-img img{width:100%!important;height:100%!important;object-fit:cover!important}
|
| 24 |
.hashtag-src-text{flex:1!important;min-width:0!important}
|
| 25 |
.hashtag-src-title{font-size:12px!important;font-weight:700!important;color:#eee!important;display:-webkit-box!important;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden!important}
|
| 26 |
-
.hashtag-src-via{font-size:10px!important;color:#888!important;margin-top:2px!important}
|
| 27 |
-
#ls-content .ranking-table .head,#ls-content .leaderboard .head{width:100%!important}
|
| 28 |
-
#ls-content .ranking-table .leaderboard-item,#ls-content .leaderboard .leaderboard-item{display:flex!important;width:100%!important;align-items:center!important;gap:4px!important;border-bottom:1px solid #1a1a1a!important;padding:4px 2px!important;background:transparent!important}
|
| 29 |
-
#ls-content .ranking-table .leaderboard-item>div.team,#ls-content .ranking-table .leaderboard-item>.team,#ls-content .leaderboard .leaderboard-item .team{flex:1!important;min-width:0!important;display:flex!important;align-items:center!important;gap:4px!important}
|
| 30 |
-
#ls-content .leaderboard-item .team a{display:inline-flex!important;align-items:center!important;gap:4px!important;font-size:11px!important;color:#ccc!important;text-decoration:none!important;overflow:hidden!important}
|
| 31 |
-
#ls-content .leaderboard-item .team a img{width:16px!important;height:16px!important;object-fit:contain!important}
|
| 32 |
-
#ls-content .leaderboard-item .team a p{font-size:11px!important;color:#ccc!important;margin:0!important;white-space:nowrap!important;overflow:hidden!important;text-overflow:ellipsis!important}
|
| 33 |
-
#ls-content .leaderboard-item .copy{display:flex!important;align-items:center!important;gap:2px!important;white-space:nowrap!important}
|
| 34 |
-
#ls-content .leaderboard-item .copy p{display:inline-block!important;min-width:20px!important;text-align:center!important;font-size:11px!important;color:#ccc!important;margin:0!important}
|
| 35 |
-
#ls-content .leaderboard-item .copy p strong{font-weight:800!important;color:#f0c040!important;min-width:20px!important;text-align:center!important}
|
| 36 |
-
#ls-content .leaderboard-item .copy p.form span{width:14px!important;height:14px!important;line-height:14px!important;font-size:9px!important;border-radius:2px!important;color:#fff!important;margin:0 1px!important}
|
| 37 |
-
#ls-content .leaderboard-item .copy p.form span.bg-green{background:#2d8659!important}
|
| 38 |
-
#ls-content .leaderboard-item .copy p.form span.bg-gray-5{background:#555!important;color:#ccc!important}
|
| 39 |
-
#ls-content .leaderboard-item .copy p.form span.bg-red{background:#c0392b!important}
|
| 40 |
-
#ls-content .leaderboard-item>div[class*="w-["]{display:none!important}
|
| 41 |
-
#ls-content .leaderboard-item .w-\[2px\]{display:none!important}
|
| 42 |
-
/* FPT PLAY / HIGHLIGHT video overflow fix - same containment as short-AI wall */
|
| 43 |
-
.tiktok-slide{overflow:hidden!important}
|
| 44 |
-
.tiktok-slide video,.tiktok-slide iframe{max-width:100%!important;max-height:100%!important;object-fit:contain!important}
|
| 45 |
-
.yt-thumb-wrap{overflow:hidden!important}
|
| 46 |
-
.yt-thumb-wrap iframe{max-width:100%!important;max-height:100%!important}
|
| 47 |
-
.wall-item{overflow:hidden!important}
|
| 48 |
-
.wall-thumb img,.wall-thumb video{max-width:100%!important;max-height:100%!important;object-fit:cover!important}`;
|
| 49 |
document.head.appendChild(style);
|
| 50 |
|
| 51 |
// Load rewrite fix
|
|
|
|
| 23 |
.hashtag-src-img img{width:100%!important;height:100%!important;object-fit:cover!important}
|
| 24 |
.hashtag-src-text{flex:1!important;min-width:0!important}
|
| 25 |
.hashtag-src-title{font-size:12px!important;font-weight:700!important;color:#eee!important;display:-webkit-box!important;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden!important}
|
| 26 |
+
.hashtag-src-via{font-size:10px!important;color:#888!important;margin-top:2px!important}`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
document.head.appendChild(style);
|
| 28 |
|
| 29 |
// Load rewrite fix
|
static/rewrite_fix_v2.js
DELETED
|
@@ -1,2 +0,0 @@
|
|
| 1 |
-
// No-op - all functionality built into app_v2.js
|
| 2 |
-
(function(){})();
|
|
|
|
|
|
|
|
|
static/vtv_init.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
// ===== VNEWS VTV Player v11
|
| 2 |
(function() {
|
| 3 |
if (window._vtvInitLoaded) return;
|
| 4 |
window._vtvInitLoaded = true;
|
|
@@ -10,19 +10,14 @@
|
|
| 10 |
{id:'vtv7',name:'VTV7',badge:'Giáo Dục'},{id:'vtv8',name:'VTV8',badge:'Miền Trung'},
|
| 11 |
{id:'vtv9',name:'VTV9',badge:'Miền Bắc'},{id:'vtv10',name:'VTV10',badge:'Cần Thơ'},
|
| 12 |
{id:'vtvprime',name:'VTVPrime',badge:'Prime'},
|
| 13 |
-
{id:'fpt-the-thao',name:'FPT',badge:'Thể Thao'},
|
| 14 |
];
|
| 15 |
|
| 16 |
-
// FPT Play iframe source — lấy từ iframe #1 của https://hd1.xemtv.net/kenh-fptplay-truc-tuyen-the-thao.html
|
| 17 |
-
// Trang đó có 3 iframe: ads/below1.php (quảng cáo), ads/below.php (quảng cáo), kenh/fptplay.php (stream chính)
|
| 18 |
-
var _fptIfUrl = 'https://hd1.xemtv.net/kenh/fptplay.php';
|
| 19 |
-
|
| 20 |
var _currentCh = null;
|
| 21 |
var _epgTimer = null;
|
| 22 |
var _epgData = [];
|
| 23 |
var _timeTimer = null;
|
| 24 |
var _serverDate = '';
|
| 25 |
-
var _nativeMode = false;
|
| 26 |
var _hls = null;
|
| 27 |
|
| 28 |
// ===== CSS =====
|
|
@@ -43,8 +38,6 @@
|
|
| 43 |
'#vtv-player-section .vtv-pro-tab{padding:5px 10px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.06);border-radius:8px;color:#9aa0a6;font-size:10px;font-weight:500;cursor:pointer;white-space:nowrap;flex-shrink:0;transition:all .2s ease}',
|
| 44 |
'#vtv-player-section .vtv-pro-tab:hover{background:rgba(0,102,204,.1);border-color:rgba(0,102,204,.3);color:#e8eaed}',
|
| 45 |
'#vtv-player-section .vtv-pro-tab.on{background:rgba(0,102,204,.2);border-color:#0066cc;color:#fff;font-weight:600}',
|
| 46 |
-
'#vtv-player-section .vtv-pro-tab.fpt{background:rgba(255,100,0,.04);border-color:rgba(255,120,0,.08);color:#ffa866}',
|
| 47 |
-
'#vtv-player-section .vtv-pro-tab.fpt.on{background:rgba(255,100,0,.2);border-color:#ff6400;color:#fff}',
|
| 48 |
'#vtv-player-section .vtv-pro-tab .b{font-size:7px;opacity:.5;display:block;margin-top:1px}',
|
| 49 |
'#vtv-player-section .vtv-pro-frame{position:relative;width:100%;aspect-ratio:16/9;background:#000;min-height:200px;border-top:1px solid rgba(255,255,255,.04)}',
|
| 50 |
'#vtv-player-section .vtv-pro-frame iframe{position:absolute;inset:0;width:100%!important;height:100%!important;border:none;background:#000}',
|
|
@@ -116,7 +109,6 @@
|
|
| 116 |
|
| 117 |
// ===== EPG =====
|
| 118 |
function loadEPG(chId) {
|
| 119 |
-
if (chId.indexOf('fpt') >= 0) return; // FPT ko có EPG
|
| 120 |
var epgEl = document.getElementById('vtv-pro-epg-body');
|
| 121 |
if (!epgEl) return;
|
| 122 |
epgEl.innerHTML = '<div class="vtv-pro-epg-load"><div class="vtv-pro-spinner" style="width:12px;height:12px;border-width:1.5px;flex-shrink:0"></div>Đang tải lịch...</div>';
|
|
@@ -157,7 +149,7 @@
|
|
| 157 |
var cls = 'vtv-pro-epg-row';
|
| 158 |
if (p.now) cls += ' now';
|
| 159 |
else if (p.time < nowStr) cls += ' passed';
|
| 160 |
-
var xt = p.end_time ? '
|
| 161 |
var db = '';
|
| 162 |
if (p.date && p.date !== prevDate) { db = '<span class="d">' + p.date + '</span>'; prevDate = p.date; }
|
| 163 |
html += '<div class="' + cls + '"><span class="t">' + p.time + xt + '</span><span class="bar"></span><span class="n">' + p.title + '</span>' + db + '</div>';
|
|
@@ -169,7 +161,7 @@
|
|
| 169 |
|
| 170 |
function startEpgRefresh() {
|
| 171 |
if (_epgTimer) clearInterval(_epgTimer);
|
| 172 |
-
_epgTimer = setInterval(function() { if (_currentCh
|
| 173 |
}
|
| 174 |
|
| 175 |
function updateClock() {
|
|
@@ -189,152 +181,139 @@
|
|
| 189 |
}
|
| 190 |
|
| 191 |
// ===== Play native HLS via proxy =====
|
| 192 |
-
function playNativeHLS(
|
| 193 |
destroyHLS();
|
| 194 |
var frame = document.getElementById('vtv-pro-frame');
|
| 195 |
if (!frame) return;
|
| 196 |
frame.innerHTML = '<video id="vtv-native-video" controls autoplay playsinline webkit-playsinline style="position:absolute;inset:0;width:100%;height:100%;background:#000"></video>';
|
|
|
|
| 197 |
var video = document.getElementById('vtv-native-video');
|
| 198 |
if (!video) return;
|
|
|
|
|
|
|
| 199 |
if (typeof Hls !== 'undefined' && Hls.isSupported()) {
|
| 200 |
_hls = new Hls({ enableWorker: false });
|
| 201 |
-
_hls.loadSource(
|
| 202 |
_hls.attachMedia(video);
|
| 203 |
_hls.on(Hls.Events.MANIFEST_PARSED, function() {
|
| 204 |
video.play().catch(function(){});
|
| 205 |
});
|
| 206 |
_hls.on(Hls.Events.ERROR, function(evt, data) {
|
| 207 |
if (data.fatal) {
|
|
|
|
| 208 |
destroyHLS();
|
| 209 |
-
video.src =
|
| 210 |
video.play().catch(function(){});
|
| 211 |
}
|
| 212 |
});
|
| 213 |
} else {
|
| 214 |
-
|
|
|
|
| 215 |
}
|
| 216 |
}
|
| 217 |
|
| 218 |
-
// =====
|
| 219 |
-
|
| 220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
var frame = document.getElementById('vtv-pro-frame');
|
| 222 |
if (!frame) return;
|
| 223 |
-
var iframe = document.createElement('iframe');
|
| 224 |
-
iframe.src = iframeUrl;
|
| 225 |
-
iframe.setAttribute('allowfullscreen', 'true');
|
| 226 |
-
iframe.setAttribute('allow', 'autoplay; encrypted-media; fullscreen');
|
| 227 |
-
iframe.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;border:none;background:#000';
|
| 228 |
-
frame.innerHTML = '';
|
| 229 |
-
frame.appendChild(iframe);
|
| 230 |
-
}
|
| 231 |
|
| 232 |
-
|
| 233 |
-
function fetchStreamData(chId, callback) {
|
| 234 |
-
if (chId.indexOf('fpt') >= 0) {
|
| 235 |
-
// FPT Play: use iframe #1 from hd1.xemtv.net page (kenh/fptplay.php)
|
| 236 |
-
callback({ iframe_url: _fptIfUrl });
|
| 237 |
-
return;
|
| 238 |
-
}
|
| 239 |
-
// All other channels: use server API
|
| 240 |
var xhr = new XMLHttpRequest();
|
| 241 |
-
xhr.open('GET', '/api/vtv/stream/' +
|
| 242 |
xhr.timeout = 15000;
|
| 243 |
xhr.onload = function() {
|
| 244 |
try {
|
| 245 |
-
|
| 246 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
};
|
| 248 |
-
xhr.onerror = xhr.ontimeout = function() { callback(null); };
|
| 249 |
xhr.send();
|
| 250 |
-
}
|
| 251 |
|
| 252 |
// ===== Switch channel =====
|
| 253 |
window._vtvProSwitch = function(chId) {
|
| 254 |
_currentCh = chId;
|
| 255 |
_nativeMode = false;
|
| 256 |
|
| 257 |
-
// Update tabs
|
| 258 |
var tabs = document.querySelectorAll('#vtv-player-section .vtv-pro-tab');
|
| 259 |
-
for (var i = 0; i < tabs.length; i++)
|
| 260 |
-
|
| 261 |
-
var isActive = (t.getAttribute('data-ch') === chId);
|
| 262 |
-
t.className = 'vtv-pro-tab' + (isActive ? ' on' : '') + (isActive && chId.indexOf('fpt') >= 0 ? ' fpt on' : '');
|
| 263 |
-
}
|
| 264 |
|
|
|
|
| 265 |
var toggleBtn = document.getElementById('vtv-pro-toggle-btn');
|
|
|
|
| 266 |
|
| 267 |
-
// Show/hide toggle button (same for all channels including FPT)
|
| 268 |
-
if (toggleBtn) {
|
| 269 |
-
toggleBtn.style.display = '';
|
| 270 |
-
toggleBtn.classList.remove('active');
|
| 271 |
-
toggleBtn.innerHTML = '\ud83d\udd0c JWPlayer';
|
| 272 |
-
}
|
| 273 |
-
|
| 274 |
-
// Loading state
|
| 275 |
-
var frame = document.getElementById('vtv-pro-frame');
|
| 276 |
-
if (frame) frame.innerHTML = '<div class="vtv-pro-load"><div class="vtv-pro-spinner"></div><span>Đang kết nối ' + chId.toUpperCase() + '...</span></div>';
|
| 277 |
-
destroyHLS();
|
| 278 |
updateClock();
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
// Fetch stream and load
|
| 283 |
-
fetchStreamData(chId, function(data) {
|
| 284 |
-
if (!data || !data.iframe_url) {
|
| 285 |
-
frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">\ud83d\udce1</div><div class="msg">Kênh không khả dụng</div><button onclick="_vtvProSwitch(\''+chId+'\')">Thử lại</button></div>';
|
| 286 |
-
return;
|
| 287 |
-
}
|
| 288 |
-
loadIframe(data.iframe_url);
|
| 289 |
-
});
|
| 290 |
-
};
|
| 291 |
|
| 292 |
-
|
| 293 |
-
window._vtvToggleNative = function(btn) {
|
| 294 |
-
if (!_currentCh) return;
|
| 295 |
-
_nativeMode = !_nativeMode;
|
| 296 |
-
btn.classList.toggle('active', _nativeMode);
|
| 297 |
-
btn.innerHTML = _nativeMode ? '\ud83d\udce1 HLS' : '\ud83d\udd0c JWPlayer';
|
| 298 |
|
| 299 |
var frame = document.getElementById('vtv-pro-frame');
|
| 300 |
if (!frame) return;
|
|
|
|
| 301 |
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
}
|
| 312 |
-
}
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
xhr.onload = function() {
|
| 318 |
-
try {
|
| 319 |
-
var data = JSON.parse(xhr.responseText);
|
| 320 |
-
if (_nativeMode) {
|
| 321 |
-
var m3u8Url = data.m3u8_proxy_url || data.proxy_url_hls || data.stream_url;
|
| 322 |
-
if (m3u8Url) {
|
| 323 |
-
playNativeHLS(m3u8Url);
|
| 324 |
-
} else {
|
| 325 |
-
frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">\ud83d\udce1</div><div class="msg">Kênh không hỗ trợ HLS</div></div>';
|
| 326 |
-
_nativeMode = false;
|
| 327 |
-
btn.classList.remove('active');
|
| 328 |
-
btn.innerHTML = '\ud83d\udd0c JWPlayer';
|
| 329 |
-
}
|
| 330 |
-
} else {
|
| 331 |
-
if (data.iframe_url) loadIframe(data.iframe_url);
|
| 332 |
-
}
|
| 333 |
-
} catch(e) { frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">\u26a0\ufe0f</div></div>'; }
|
| 334 |
-
};
|
| 335 |
-
xhr.onerror = function() { frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">\ud83d\udd0c</div><div class="msg">Lỗi kết nối</div></div>'; };
|
| 336 |
-
xhr.send();
|
| 337 |
-
}
|
| 338 |
};
|
| 339 |
|
| 340 |
// ===== HTML builder =====
|
|
@@ -342,8 +321,7 @@
|
|
| 342 |
var tabs = '';
|
| 343 |
for (var i = 0; i < CHANNELS.length; i++) {
|
| 344 |
var ch = CHANNELS[i];
|
| 345 |
-
|
| 346 |
-
tabs += '<span class="vtv-pro-tab' + (isFpt ? ' fpt' : '') + '" data-ch="'+ch.id+'" onclick="_vtvProSwitch(\''+ch.id+'\')" ' + (isFpt ? 'data-fpt="true"' : '') + '>' +
|
| 347 |
ch.name + '<span class="b">'+ch.badge+'</span></span>';
|
| 348 |
}
|
| 349 |
return '<div id="vtv-player-section">' +
|
|
@@ -358,10 +336,10 @@
|
|
| 358 |
'</div>' +
|
| 359 |
'<div class="vtv-pro-controls">' +
|
| 360 |
'<span id="vtv-pro-time" class="vtv-pro-btn" style="background:none;border:none;font-size:10px;color:#5f6368;margin-right:auto"></span>' +
|
| 361 |
-
'<span class="vtv-pro-switch" id="vtv-pro-toggle-btn" onclick="_vtvToggleNative(this)">
|
| 362 |
'</div>' +
|
| 363 |
'<div class="vtv-pro-epg">' +
|
| 364 |
-
'<div class="vtv-pro-epg-hdr"><span class="vtv-pro-epg-title">Lịch phát sóng
|
| 365 |
'<div class="vtv-pro-epg-list" id="vtv-pro-epg-body"><div class="vtv-pro-epg-empty">Chọn kênh để xem lịch phát sóng</div></div>' +
|
| 366 |
'</div>' +
|
| 367 |
'</div>';
|
|
@@ -389,4 +367,4 @@
|
|
| 389 |
else if (!document.getElementById('vtv-player-section')) setTimeout(waitAndInject, 1000);
|
| 390 |
})();
|
| 391 |
}
|
| 392 |
-
})();
|
|
|
|
| 1 |
+
// ===== VNEWS VTV Player v11 — SV2 iframe + HLS.js native player toggle =====
|
| 2 |
(function() {
|
| 3 |
if (window._vtvInitLoaded) return;
|
| 4 |
window._vtvInitLoaded = true;
|
|
|
|
| 10 |
{id:'vtv7',name:'VTV7',badge:'Giáo Dục'},{id:'vtv8',name:'VTV8',badge:'Miền Trung'},
|
| 11 |
{id:'vtv9',name:'VTV9',badge:'Miền Bắc'},{id:'vtv10',name:'VTV10',badge:'Cần Thơ'},
|
| 12 |
{id:'vtvprime',name:'VTVPrime',badge:'Prime'},
|
|
|
|
| 13 |
];
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
var _currentCh = null;
|
| 16 |
var _epgTimer = null;
|
| 17 |
var _epgData = [];
|
| 18 |
var _timeTimer = null;
|
| 19 |
var _serverDate = '';
|
| 20 |
+
var _nativeMode = false; // false=iframe, true=HLSjs
|
| 21 |
var _hls = null;
|
| 22 |
|
| 23 |
// ===== CSS =====
|
|
|
|
| 38 |
'#vtv-player-section .vtv-pro-tab{padding:5px 10px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.06);border-radius:8px;color:#9aa0a6;font-size:10px;font-weight:500;cursor:pointer;white-space:nowrap;flex-shrink:0;transition:all .2s ease}',
|
| 39 |
'#vtv-player-section .vtv-pro-tab:hover{background:rgba(0,102,204,.1);border-color:rgba(0,102,204,.3);color:#e8eaed}',
|
| 40 |
'#vtv-player-section .vtv-pro-tab.on{background:rgba(0,102,204,.2);border-color:#0066cc;color:#fff;font-weight:600}',
|
|
|
|
|
|
|
| 41 |
'#vtv-player-section .vtv-pro-tab .b{font-size:7px;opacity:.5;display:block;margin-top:1px}',
|
| 42 |
'#vtv-player-section .vtv-pro-frame{position:relative;width:100%;aspect-ratio:16/9;background:#000;min-height:200px;border-top:1px solid rgba(255,255,255,.04)}',
|
| 43 |
'#vtv-player-section .vtv-pro-frame iframe{position:absolute;inset:0;width:100%!important;height:100%!important;border:none;background:#000}',
|
|
|
|
| 109 |
|
| 110 |
// ===== EPG =====
|
| 111 |
function loadEPG(chId) {
|
|
|
|
| 112 |
var epgEl = document.getElementById('vtv-pro-epg-body');
|
| 113 |
if (!epgEl) return;
|
| 114 |
epgEl.innerHTML = '<div class="vtv-pro-epg-load"><div class="vtv-pro-spinner" style="width:12px;height:12px;border-width:1.5px;flex-shrink:0"></div>Đang tải lịch...</div>';
|
|
|
|
| 149 |
var cls = 'vtv-pro-epg-row';
|
| 150 |
if (p.now) cls += ' now';
|
| 151 |
else if (p.time < nowStr) cls += ' passed';
|
| 152 |
+
var xt = p.end_time ? ' → ' + p.end_time : (i < _epgData.length - 1 ? ' → ' + _epgData[i+1].time : '');
|
| 153 |
var db = '';
|
| 154 |
if (p.date && p.date !== prevDate) { db = '<span class="d">' + p.date + '</span>'; prevDate = p.date; }
|
| 155 |
html += '<div class="' + cls + '"><span class="t">' + p.time + xt + '</span><span class="bar"></span><span class="n">' + p.title + '</span>' + db + '</div>';
|
|
|
|
| 161 |
|
| 162 |
function startEpgRefresh() {
|
| 163 |
if (_epgTimer) clearInterval(_epgTimer);
|
| 164 |
+
_epgTimer = setInterval(function() { if (_currentCh) loadEPG(_currentCh); }, 60000);
|
| 165 |
}
|
| 166 |
|
| 167 |
function updateClock() {
|
|
|
|
| 181 |
}
|
| 182 |
|
| 183 |
// ===== Play native HLS via proxy =====
|
| 184 |
+
function playNativeHLS(chId, m3u8ProxyUrl) {
|
| 185 |
destroyHLS();
|
| 186 |
var frame = document.getElementById('vtv-pro-frame');
|
| 187 |
if (!frame) return;
|
| 188 |
frame.innerHTML = '<video id="vtv-native-video" controls autoplay playsinline webkit-playsinline style="position:absolute;inset:0;width:100%;height:100%;background:#000"></video>';
|
| 189 |
+
|
| 190 |
var video = document.getElementById('vtv-native-video');
|
| 191 |
if (!video) return;
|
| 192 |
+
|
| 193 |
+
// Try HLS.js if available (injected via index_v2.html)
|
| 194 |
if (typeof Hls !== 'undefined' && Hls.isSupported()) {
|
| 195 |
_hls = new Hls({ enableWorker: false });
|
| 196 |
+
_hls.loadSource(m3u8ProxyUrl);
|
| 197 |
_hls.attachMedia(video);
|
| 198 |
_hls.on(Hls.Events.MANIFEST_PARSED, function() {
|
| 199 |
video.play().catch(function(){});
|
| 200 |
});
|
| 201 |
_hls.on(Hls.Events.ERROR, function(evt, data) {
|
| 202 |
if (data.fatal) {
|
| 203 |
+
// Fallback: direct video src
|
| 204 |
destroyHLS();
|
| 205 |
+
video.src = m3u8ProxyUrl;
|
| 206 |
video.play().catch(function(){});
|
| 207 |
}
|
| 208 |
});
|
| 209 |
} else {
|
| 210 |
+
// Native HLS fallback (Safari/iOS)
|
| 211 |
+
video.src = m3u8ProxyUrl;
|
| 212 |
}
|
| 213 |
}
|
| 214 |
|
| 215 |
+
// ===== Toggle iframe / native HLS =====
|
| 216 |
+
window._vtvToggleNative = function(btn) {
|
| 217 |
+
if (!_currentCh) return;
|
| 218 |
+
_nativeMode = !_nativeMode;
|
| 219 |
+
btn.classList.toggle('active', _nativeMode);
|
| 220 |
+
btn.innerHTML = _nativeMode ? '📡 HLS' : '🔌 JWPlayer';
|
| 221 |
+
|
| 222 |
var frame = document.getElementById('vtv-pro-frame');
|
| 223 |
if (!frame) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
|
| 225 |
+
// Re-fetch stream data then switch
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
var xhr = new XMLHttpRequest();
|
| 227 |
+
xhr.open('GET', '/api/vtv/stream/' + _currentCh, true);
|
| 228 |
xhr.timeout = 15000;
|
| 229 |
xhr.onload = function() {
|
| 230 |
try {
|
| 231 |
+
var data = JSON.parse(xhr.responseText);
|
| 232 |
+
if (_nativeMode) {
|
| 233 |
+
var m3u8Url = data.m3u8_proxy_url || data.proxy_url_hls || data.stream_url;
|
| 234 |
+
if (m3u8Url) {
|
| 235 |
+
playNativeHLS(_currentCh, m3u8Url);
|
| 236 |
+
} else {
|
| 237 |
+
frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">📡</div><div class="msg">Kênh không hỗ trợ HLS</div></div>';
|
| 238 |
+
_nativeMode = false;
|
| 239 |
+
btn.classList.remove('active');
|
| 240 |
+
btn.innerHTML = '🔌 JWPlayer';
|
| 241 |
+
}
|
| 242 |
+
} else {
|
| 243 |
+
var iframeUrl = data.iframe_url;
|
| 244 |
+
if (iframeUrl) {
|
| 245 |
+
destroyHLS();
|
| 246 |
+
var iframe = document.createElement('iframe');
|
| 247 |
+
iframe.src = iframeUrl;
|
| 248 |
+
iframe.setAttribute('allowfullscreen', 'true');
|
| 249 |
+
iframe.setAttribute('allow', 'autoplay; encrypted-media; fullscreen');
|
| 250 |
+
iframe.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;border:none;background:#000';
|
| 251 |
+
frame.innerHTML = '';
|
| 252 |
+
frame.appendChild(iframe);
|
| 253 |
+
}
|
| 254 |
+
}
|
| 255 |
+
} catch(e) {
|
| 256 |
+
frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">⚠️</div></div>';
|
| 257 |
+
}
|
| 258 |
+
};
|
| 259 |
+
xhr.onerror = function() {
|
| 260 |
+
frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">🔌</div><div class="msg">Lỗi kết nối</div></div>';
|
| 261 |
};
|
|
|
|
| 262 |
xhr.send();
|
| 263 |
+
};
|
| 264 |
|
| 265 |
// ===== Switch channel =====
|
| 266 |
window._vtvProSwitch = function(chId) {
|
| 267 |
_currentCh = chId;
|
| 268 |
_nativeMode = false;
|
| 269 |
|
|
|
|
| 270 |
var tabs = document.querySelectorAll('#vtv-player-section .vtv-pro-tab');
|
| 271 |
+
for (var i = 0; i < tabs.length; i++)
|
| 272 |
+
tabs[i].className = 'vtv-pro-tab' + (tabs[i].getAttribute('data-ch') === chId ? ' on' : '');
|
|
|
|
|
|
|
|
|
|
| 273 |
|
| 274 |
+
// Reset toggle button
|
| 275 |
var toggleBtn = document.getElementById('vtv-pro-toggle-btn');
|
| 276 |
+
if (toggleBtn) { toggleBtn.classList.remove('active'); toggleBtn.innerHTML = '🔌 JWPlayer'; }
|
| 277 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
updateClock();
|
| 279 |
+
if (_timeTimer) clearInterval(_timeTimer);
|
| 280 |
+
_timeTimer = setInterval(updateClock, 60000);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
|
| 282 |
+
destroyHLS();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
|
| 284 |
var frame = document.getElementById('vtv-pro-frame');
|
| 285 |
if (!frame) return;
|
| 286 |
+
frame.innerHTML = '<div class="vtv-pro-load"><div class="vtv-pro-spinner"></div><span>Đang kết nối ' + chId.toUpperCase() + '...</span></div>';
|
| 287 |
|
| 288 |
+
loadEPG(chId);
|
| 289 |
+
startEpgRefresh();
|
| 290 |
+
|
| 291 |
+
var xhr = new XMLHttpRequest();
|
| 292 |
+
xhr.open('GET', '/api/vtv/stream/' + chId, true);
|
| 293 |
+
xhr.timeout = 15000;
|
| 294 |
+
xhr.onload = function() {
|
| 295 |
+
try {
|
| 296 |
+
var data = JSON.parse(xhr.responseText);
|
| 297 |
+
var iframeUrl = data.iframe_url;
|
| 298 |
+
if (!iframeUrl) {
|
| 299 |
+
frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">📡</div><div class="msg">Kênh không khả dụng</div><button onclick="_vtvProSwitch(\''+chId+'\')">Thử lại</button></div>';
|
| 300 |
+
return;
|
| 301 |
+
}
|
| 302 |
+
var iframe = document.createElement('iframe');
|
| 303 |
+
iframe.src = iframeUrl;
|
| 304 |
+
iframe.setAttribute('allowfullscreen', 'true');
|
| 305 |
+
iframe.setAttribute('allow', 'autoplay; encrypted-media; fullscreen');
|
| 306 |
+
iframe.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;border:none;background:#000';
|
| 307 |
+
frame.innerHTML = '';
|
| 308 |
+
frame.appendChild(iframe);
|
| 309 |
+
} catch(e) {
|
| 310 |
+
frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">⚠️</div><div class="msg">Lỗi tải dữ liệu</div><button onclick="_vtvProSwitch(\''+chId+'\')">Thử lại</button></div>';
|
| 311 |
}
|
| 312 |
+
};
|
| 313 |
+
xhr.onerror = xhr.ontimeout = function() {
|
| 314 |
+
frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">🔌</div><div class="msg">Mất kết nối máy chủ</div><button onclick="_vtvProSwitch(\''+chId+'\')">Thử lại</button></div>';
|
| 315 |
+
};
|
| 316 |
+
xhr.send();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
};
|
| 318 |
|
| 319 |
// ===== HTML builder =====
|
|
|
|
| 321 |
var tabs = '';
|
| 322 |
for (var i = 0; i < CHANNELS.length; i++) {
|
| 323 |
var ch = CHANNELS[i];
|
| 324 |
+
tabs += '<span class="vtv-pro-tab" data-ch="'+ch.id+'" onclick="_vtvProSwitch(\''+ch.id+'\')">' +
|
|
|
|
| 325 |
ch.name + '<span class="b">'+ch.badge+'</span></span>';
|
| 326 |
}
|
| 327 |
return '<div id="vtv-player-section">' +
|
|
|
|
| 336 |
'</div>' +
|
| 337 |
'<div class="vtv-pro-controls">' +
|
| 338 |
'<span id="vtv-pro-time" class="vtv-pro-btn" style="background:none;border:none;font-size:10px;color:#5f6368;margin-right:auto"></span>' +
|
| 339 |
+
'<span class="vtv-pro-switch" id="vtv-pro-toggle-btn" onclick="_vtvToggleNative(this)">🔌 JWPlayer</span>' +
|
| 340 |
'</div>' +
|
| 341 |
'<div class="vtv-pro-epg">' +
|
| 342 |
+
'<div class="vtv-pro-epg-hdr"><span class="vtv-pro-epg-title">Lịch phát sóng — nguồn VTV.vn</span></div>' +
|
| 343 |
'<div class="vtv-pro-epg-list" id="vtv-pro-epg-body"><div class="vtv-pro-epg-empty">Chọn kênh để xem lịch phát sóng</div></div>' +
|
| 344 |
'</div>' +
|
| 345 |
'</div>';
|
|
|
|
| 367 |
else if (!document.getElementById('vtv-player-section')) setTimeout(waitAndInject, 1000);
|
| 368 |
})();
|
| 369 |
}
|
| 370 |
+
})();
|
static/yt_live.js
CHANGED
|
@@ -1,13 +1,175 @@
|
|
| 1 |
-
// ===
|
| 2 |
-
//
|
| 3 |
-
//
|
| 4 |
-
//
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
if
|
| 8 |
-
window.
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// === VNEWS — VTV LIVE + Inline Recorder v9 ===
|
| 2 |
+
// Features: PiP, mini-player, INLINE RECORDER, VTVGO POPUP players for each channel
|
| 3 |
+
// FIX v10: Correct VTVGO popup URLs for all channels + World Cup 2026 TS stream links
|
| 4 |
+
// FIX v11: Allow clicking off tabs (always clickable), better stream status handling
|
| 5 |
+
|
| 6 |
+
(function(){
|
| 7 |
+
if(window._ytLiveLoaded) return;
|
| 8 |
+
window._ytLiveLoaded = true;
|
| 9 |
+
|
| 10 |
+
const CHANNELS = [
|
| 11 |
+
{id:'vtv1',name:'VTV1',badge:'Tin tức'},{id:'vtv2',name:'VTV2',badge:'Khoa học'},
|
| 12 |
+
{id:'vtv3',name:'VTV3',badge:'Giải trí'},{id:'vtv4',name:'VTV4',badge:'Quốc tế'},
|
| 13 |
+
{id:'vtv5',name:'VTV5',badge:'Miền Nam'},{id:'vtv6',name:'VTV6',badge:'Thanh niên'},
|
| 14 |
+
{id:'vtv7',name:'VTV7',badge:'Giáo dục'},{id:'vtv8',name:'VTV8',badge:'Miền Trung'},
|
| 15 |
+
{id:'vtv9',name:'VTV9',badge:'Miền Bắc'},{id:'vtv10',name:'VTV10',badge:'Cần Thơ'},
|
| 16 |
+
{id:'vtvprime',name:'VTVPrime',badge:'Prime'},
|
| 17 |
+
];
|
| 18 |
+
|
| 19 |
+
// VTVGO popup URLs for each channel (FIXED v10 - verified correct URLs)
|
| 20 |
+
const VTV_POPUP_URLS = {
|
| 21 |
+
vtv1: 'https://package.vtvgo.vn/channel/vtv1-1,1.html',
|
| 22 |
+
vtv2: 'https://package.vtvgo.vn/channel/vtv2-1,2.html',
|
| 23 |
+
vtv3: 'https://package.vtvgo.vn/channel/vtv3-1,3.html',
|
| 24 |
+
vtv4: 'https://package.vtvgo.vn/channel/vtv4-1,4.html',
|
| 25 |
+
vtv5: 'https://package.vtvgo.vn/channel/vtv5-1,5.html',
|
| 26 |
+
vtv6: 'https://package.vtvgo.vn/channel/vtv6-1,13.html',
|
| 27 |
+
vtv7: 'https://package.vtvgo.vn/channel/vtv7-1,27.html',
|
| 28 |
+
vtv8: 'https://package.vtvgo.vn/channel/vtv8-1,36.html',
|
| 29 |
+
vtv9: 'https://package.vtvgo.vn/channel/vtv9-1,39.html',
|
| 30 |
+
vtv10: 'https://package.vtvgo.vn/channel/vtv10-1,6.html',
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
const DEFAULT_CHANNEL = 'vtv3';
|
| 34 |
+
const NEEDS_PROXY = /fptplay\.net|vtvgo\.vn/;
|
| 35 |
+
const STREAMS = {};
|
| 36 |
+
let _currentCh = null, _hls = null, _loading = false, _blockInserted = false;
|
| 37 |
+
let _streamsLoaded = false, _pipActive = false, _miniActive = false, _vtvPinned = false, _vtvPopupActive = false;
|
| 38 |
+
|
| 39 |
+
// ===== RECORDER STATE =====
|
| 40 |
+
const _rec = {
|
| 41 |
+
active: false, startTime: null, endTime: null,
|
| 42 |
+
isRecording: false, recorder: null, chunks: [], blob: null,
|
| 43 |
+
ratio: 'original', _dragMarker: null, _recTimer: null,
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
// ===== STYLES =====
|
| 47 |
+
const style = document.createElement('style');
|
| 48 |
+
style.textContent = `
|
| 49 |
+
.vtv-wrap{position:relative;margin:6px 4px;background:#111;border:1px solid #0066cc;border-radius:10px;overflow:hidden}
|
| 50 |
+
.vtv-head{display:flex;align-items:center;gap:8px;padding:8px 10px;background:linear-gradient(90deg,#003366,#1a1a1a)}
|
| 51 |
+
.vtv-title{font-size:13px;font-weight:800;color:#00ccff}
|
| 52 |
+
.vtv-badge{font-size:10px;font-weight:800;color:#00ccff;animation:vtvp 1.3s infinite}
|
| 53 |
+
@keyframes vtvp{0%,100%{opacity:1}50%{opacity:.3}}
|
| 54 |
+
.vtv-tabs{display:flex;flex-wrap:wrap;gap:3px;padding:6px 8px;overflow-x:auto;scrollbar-width:none;background:#0d1a2a}
|
| 55 |
+
.vtv-tabs::-webkit-scrollbar{display:none}
|
| 56 |
+
.vtv-tab{padding:4px 8px;background:#1a2a3a;border:1px solid #2a3a4a;border-radius:10px;color:#8ab4d8;font-size:9px;cursor:pointer;white-space:nowrap;flex-shrink:0;transition:all .2s}
|
| 57 |
+
.vtv-tab:hover{background:#0b4a7a;color:#fff}
|
| 58 |
+
.vtv-tab.on{background:#0066cc;border-color:#00ccff;color:#fff;font-weight:700}
|
| 59 |
+
.vtv-tab.off{opacity:.35}
|
| 60 |
+
.vtv-frame{position:relative;width:100%;aspect-ratio:16/9;background:#000;min-height:180px}
|
| 61 |
+
.vtv-frame video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
|
| 62 |
+
.vtv-err{display:flex;align-items:center;justify-content:center;height:180px;color:#888;font-size:12px;text-align:center;padding:20px;flex-direction:column;gap:8px}
|
| 63 |
+
.vtv-err button{background:#0066cc;border:none;color:#fff;padding:6px 14px;border-radius:8px;font-size:11px;cursor:pointer}
|
| 64 |
+
.vtv-load{display:flex;align-items:center;justify-content:center;height:180px;color:#00ccff;font-size:12px;flex-direction:column;gap:8px}
|
| 65 |
+
.vtv-spinner{width:24px;height:24px;border:2px solid #333;border-top-color:#00ccff;border-radius:50%;animation:vtvspin .8s linear infinite}
|
| 66 |
+
@keyframes vtvspin{to{transform:rotate(360deg)}}
|
| 67 |
+
.vtv-controls{display:flex;align-items:center;gap:4px;padding:4px 8px;background:#0d1a2a;border-top:1px solid #1a2a3a}
|
| 68 |
+
.vtv-pip-btn{background:#1a2a3a;border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:3px 8px;border-radius:6px;cursor:pointer;transition:all .2s;display:flex;align-items:center;gap:4px}
|
| 69 |
+
.vtv-pip-btn:hover{background:#0b4a7a;color:#fff}
|
| 70 |
+
.vtv-pip-btn.on{background:#0066cc;border-color:#00ccff;color:#fff}
|
| 71 |
+
.vtv-pip-btn svg{width:12px;height:12px;fill:currentColor}
|
| 72 |
+
.vtv-epg{margin:0;padding:6px 10px;background:#0a1628;border-top:1px solid #1a2a3a}
|
| 73 |
+
.vtv-epg-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}
|
| 74 |
+
.vtv-epg-title{font-size:10px;font-weight:700;color:#00ccff}
|
| 75 |
+
.vtv-epg-toggle{background:none;border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:2px 8px;border-radius:6px;cursor:pointer}
|
| 76 |
+
.vtv-epg-list{display:flex;gap:4px;overflow-x:auto;scrollbar-width:none;padding-bottom:4px}
|
| 77 |
+
.vtv-epg-list::-webkit-scrollbar{display:none}
|
| 78 |
+
.vtv-epg-item{flex:0 0 auto;padding:3px 6px;background:#1a2a3a;border-radius:4px;font-size:8px;color:#8ab4d8;white-space:nowrap;cursor:pointer}
|
| 79 |
+
.vtv-epg-item:hover{background:#2a4a6a}
|
| 80 |
+
.vtv-epg-item.now{background:#0066cc;color:#fff;font-weight:700}
|
| 81 |
+
.vtv-epg-item .t{font-size:7px;color:#6a8aaa}
|
| 82 |
+
.vtv-epg-item.now .t{color:#aaccee}
|
| 83 |
+
.vtv-epg-item .n{color:#ccc;font-size:8px}
|
| 84 |
+
.vtv-epg-item.now .n{color:#fff}
|
| 85 |
+
.vtv-epg-empty{color:#666;font-size:9px;padding:4px}
|
| 86 |
+
.vtv-epg-loading{color:#00ccff;font-size:9px;padding:4px;display:flex;align-items:center;gap:6px}
|
| 87 |
+
.vtv-epg-sp{width:10px;height:10px;border:1px solid #333;border-top-color:#00ccff;border-radius:50%;animation:vtvspin .8s linear infinite}
|
| 88 |
+
.vtv-pin-btn{position:absolute;top:6px;right:8px;z-index:5;background:rgba(0,0,0,.5);border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:2px 6px;border-radius:4px;cursor:pointer;font-weight:700;display:flex;align-items:center;gap:3px}
|
| 89 |
+
.vtv-pin-btn:hover{background:#0b4a7a;color:#fff}
|
| 90 |
+
.vtv-pin-btn.pinned{background:#0066cc;border-color:#00ccff;color:#fff}
|
| 91 |
+
.vtv-wrap.vtv-sticky{position:sticky;top:0;z-index:48;transition:all .25s ease;box-shadow:0 4px 24px rgba(0,102,204,.35)}
|
| 92 |
+
.vtv-wrap.vtv-sticky .vtv-epg{display:none}
|
| 93 |
+
.vtv-wrap.vtv-sticky .vtv-controls{display:none}
|
| 94 |
+
.vtv-wrap.vtv-sticky .vtv-tabs{padding:3px 8px}
|
| 95 |
+
.vtv-wrap.vtv-sticky .vtv-tab{padding:3px 7px;font-size:8px}
|
| 96 |
+
.vtv-wrap.vtv-sticky .vtv-frame{max-height:140px;min-height:100px}
|
| 97 |
+
.vtv-wrap.vtv-sticky .vtv-frame video{max-height:140px}
|
| 98 |
+
.vtv-wrap.vtv-sticky .vtv-load{height:100px}
|
| 99 |
+
.vtv-wrap.vtv-sticky .vtv-err{height:100px}
|
| 100 |
+
.vtv-wrap.vtv-sticky .vtv-head{padding:5px 10px}
|
| 101 |
+
.vtv-wrap.vtv-sticky .vtv-title{font-size:11px}
|
| 102 |
+
.vtv-wrap.vtv-sticky .vtv-badge{font-size:8px}
|
| 103 |
+
.vtv-wrap.vtv-sticky .vtv-pin-btn{top:4px;right:6px}
|
| 104 |
+
.vtv-rec-btn{background:#660000;border:1px solid #990000;color:#ff6666;font-size:9px;padding:3px 8px;border-radius:6px;cursor:pointer;transition:all .2s;display:flex;align-items:center;gap:4px;font-weight:700}
|
| 105 |
+
.vtv-rec-btn:hover{background:#990000;color:#fff}
|
| 106 |
+
.vtv-rec-btn.recording{background:#cc0000;border-color:#ff0000;color:#fff;animation:vtv-rec-pulse 1s infinite}
|
| 107 |
+
@keyframes vtv-rec-pulse{0%,100%{opacity:1}50%{opacity:.5}}
|
| 108 |
+
.vtv-rec-btn svg{width:12px;height:12px;fill:currentColor}
|
| 109 |
+
.vtv-inline-rec{display:none;padding:8px 10px;background:#0a0a1a;border-top:1px solid #1a1a3a}
|
| 110 |
+
.vtv-inline-rec.show{display:block}
|
| 111 |
+
.vtv-inline-rec .rec-bar{position:relative;height:32px;background:#000;border-radius:4px;overflow:hidden;cursor:pointer;border:1px solid #2a2a4a;margin-bottom:6px;user-select:none}
|
| 112 |
+
.vtv-inline-rec .rec-bar .rec-progress{position:absolute;top:0;bottom:0;background:rgba(155,89,182,.3);left:0;width:0%;pointer-events:none}
|
| 113 |
+
.vtv-inline-rec .rec-bar .rec-marker{position:absolute;top:0;bottom:0;width:4px;z-index:2;border-radius:2px}
|
| 114 |
+
.vtv-inline-rec .rec-bar .rec-marker.s{background:#2ecc71}
|
| 115 |
+
.vtv-inline-rec .rec-bar .rec-marker.e{background:#e74c3c}
|
| 116 |
+
.vtv-inline-rec .rec-time{display:flex;justify-content:space-between;font-size:9px;color:#888;margin-bottom:6px}
|
| 117 |
+
.vtv-inline-rec .rec-controls{display:flex;gap:4px}
|
| 118 |
+
.vtv-inline-rec .rec-controls button{flex:1;padding:6px;border:none;border-radius:6px;font-size:10px;font-weight:700;cursor:pointer}
|
| 119 |
+
.vtv-inline-rec .rec-controls .rec-set-start{background:#1a3a1a;border:1px solid #2d6a2d;color:#5cb87a}
|
| 120 |
+
.vtv-inline-rec .rec-controls .rec-set-end{background:#3a1a1a;border:1px solid #6a2d2d;color:#e74c3c}
|
| 121 |
+
.vtv-inline-rec .rec-controls .rec-go{background:#1a0a3a;border:1px solid #3a2a6a;color:#9b59b6}
|
| 122 |
+
.vtv-inline-rec .rec-controls .rec-reset{background:#222;border:1px solid #333;color:#888}
|
| 123 |
+
.vtv-inline-rec .rec-hint{font-size:9px;color:#666;text-align:center;margin-top:4px}
|
| 124 |
+
.vtv-inline-rec .rec-status{font-size:10px;color:#888;text-align:center;padding:4px;margin-top:4px;background:#111;border-radius:4px}
|
| 125 |
+
.vtv-inline-rec .rec-status.ok{color:#2ecc71}
|
| 126 |
+
.vtv-inline-rec .rec-status.err{color:#e74c3c}
|
| 127 |
+
.vtv-inline-rec .rec-status.recording{color:#e74c3c;animation:vtv-rec-pulse 1s infinite}
|
| 128 |
+
.vtv-rec-panel{display:none;padding:10px;background:#0d0d20;border-top:1px solid #2a1a4a}
|
| 129 |
+
.vtv-rec-panel.show{display:block}
|
| 130 |
+
.vtv-rec-panel .rec-panel-title{font-size:11px;font-weight:700;color:#9b59b6;margin-bottom:8px}
|
| 131 |
+
.vtv-rec-panel .rec-preview-wrap{margin-bottom:8px;text-align:center}
|
| 132 |
+
.vtv-rec-panel .rec-preview-wrap video{max-width:100%;max-height:180px;border-radius:6px;background:#000}
|
| 133 |
+
.vtv-rec-panel .rec-ratio-row{display:flex;gap:4px;margin-bottom:8px}
|
| 134 |
+
.vtv-rec-panel .rec-ratio-row button{flex:1;padding:6px;background:#1a1a2e;border:1px solid #2a2a4a;border-radius:6px;color:#888;font-size:10px;cursor:pointer}
|
| 135 |
+
.vtv-rec-panel .rec-ratio-row button.active{border-color:#9b59b6;color:#9b59b6;background:#2a1a4a}
|
| 136 |
+
.vtv-rec-panel .rec-actions{display:flex;gap:4px}
|
| 137 |
+
.vtv-rec-panel .rec-actions button{flex:1;padding:8px;border:none;border-radius:6px;font-size:11px;font-weight:700;cursor:pointer}
|
| 138 |
+
.vtv-rec-panel .rec-actions .rec-download{background:#2d8659;color:#fff}
|
| 139 |
+
.vtv-rec-panel .rec-actions .rec-share{background:#9b59b6;color:#fff}
|
| 140 |
+
.vtv-rec-panel .rec-proc{text-align:center;padding:12px;color:#9b59b6;font-size:12px;display:none}
|
| 141 |
+
.vtv-rec-panel .rec-title-input{width:100%;background:#1a1a2e;border:1px solid #2a2a4a;border-radius:6px;padding:8px;color:#ccc;font-size:11px;margin-bottom:8px;box-sizing:border-box}
|
| 142 |
+
.vtv-rec-panel .rec-title-input:focus{border-color:#9b59b6;outline:none}
|
| 143 |
+
.vtv-rec-panel .rec-ai-title-row{display:flex;gap:4px;margin-bottom:8px}
|
| 144 |
+
.vtv-rec-panel .rec-ai-title-row button{flex:1;padding:6px;background:#1a1a2e;border:1px solid #2a2a4a;border-radius:6px;color:#888;font-size:10px;cursor:pointer}
|
| 145 |
+
.vtv-rec-panel .rec-ai-title-row button:hover{border-color:#9b59b6;color:#9b59b6}
|
| 146 |
+
.vtv-rec-panel .rec-ai-title-row button:disabled{opacity:.4;cursor:not-allowed}
|
| 147 |
+
.vtv-mini{position:fixed;top:0;left:0;right:0;z-index:99990;background:#000;border-bottom:2px solid #0066cc;box-shadow:0 4px 20px rgba(0,102,204,.4);display:none;transition:transform .3s ease}
|
| 148 |
+
.vtv-mini.show{display:block}
|
| 149 |
+
.vtv-mini.hidden{transform:translateY(-88%)}
|
| 150 |
+
.vtv-mini-frame{position:relative;width:100%;aspect-ratio:16/9;background:#000;max-height:180px}
|
| 151 |
+
.vtv-mini-frame video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
|
| 152 |
+
.vtv-mini-bar{display:flex;align-items:center;justify-content:space-between;padding:3px 8px;background:linear-gradient(90deg,#003366,#1a1a1a)}
|
| 153 |
+
.vtv-mini-ch{font-size:10px;font-weight:700;color:#00ccff}
|
| 154 |
+
.vtv-mini-epg{font-size:9px;color:#8ab4d8;max-width:180px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-left:8px}
|
| 155 |
+
.vtv-mini-btns{display:flex;gap:3px}
|
| 156 |
+
.vtv-mini-btn{background:#1a2a3a;border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:2px 6px;border-radius:4px;cursor:pointer}
|
| 157 |
+
.vtv-mini-btn:hover{background:#0b4a7a;color:#fff}
|
| 158 |
+
.vtv-mini-btn.x{background:#600;border-color:#900;color:#f66}
|
| 159 |
+
.vtv-mini-btn.x:hover{background:#900;color:#fff}
|
| 160 |
+
.vtv-mini-peek{position:absolute;bottom:-18px;right:10px;background:#0066cc;color:#fff;font-size:9px;padding:2px 8px;border-radius:0 0 6px 6px;cursor:pointer;display:none}
|
| 161 |
+
.vtv-mini.hidden .vtv-mini-peek{display:block}
|
| 162 |
+
.vtv-popup{position:fixed;top:0;left:0;right:0;margin:8px auto 0;background:rgba(0,0,0,.95);z-index:99999;display:none;flex-direction:column;max-height:calc(100vh - 16px);max-width:800px;border-radius:12px;overflow:hidden}
|
| 163 |
+
.vtv-popup.show{display:flex}
|
| 164 |
+
.vtv-popup:has(#vtv-popup-hdr[style*="none"]) .vtv-popup-frame{height:100%}
|
| 165 |
+
.vtv-popup-header{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:#111;border-bottom:1px solid #333}
|
| 166 |
+
.vtv-popup-title{font-size:14px;font-weight:700;color:#00ccff}
|
| 167 |
+
.vtv-popup-close{background:#333;border:none;color:#fff;font-size:18px;width:32px;height:32px;border-radius:6px;cursor:pointer}
|
| 168 |
+
.vtv-popup-frame{flex:1;position:relative;background:#000;min-height:300px}
|
| 169 |
+
.vtv-popup-frame iframe{position:absolute;inset:0;width:100%;height:100%;border:none}
|
| 170 |
+
.vtv-popup-frame iframe{position:absolute;inset:0;width:100%;height:100%;border:none}
|
| 171 |
+
.vtv-popup-loader{display:flex;align-items:center;justify-content:center;height:280px;color:#00ccff;font-size:12px;flex-direction:column;gap:8px}
|
| 172 |
+
.vtv-popup-btn{background:#1a2a3a;border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:3px 8px;border-radius:6px;cursor:pointer;margin-left:4px}
|
| 173 |
+
.vtv-popup-btn:hover{background:#0b4a7a;color:#fff}
|
| 174 |
+
`;
|
| 175 |
+
document.head.appendChild(style);
|
vtv_api.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
# VTV Stream — SV2/sv.xemtivitop iframe URLs + M3U fallback + EPG
|
| 2 |
import re, time, threading, json, requests
|
| 3 |
from fastapi import APIRouter, Query
|
| 4 |
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
|
@@ -21,6 +21,8 @@ HARDCODED_URLS = {
|
|
| 21 |
CHANNEL_URLS = {k: (HARDCODED_URLS[k] if k in HARDCODED_URLS else None) for k in ["vtv1","vtv2","vtv3","vtv4","vtv5","vtv6","vtv7","vtv8","vtv9","vtv10"]}
|
| 22 |
CHANNEL_NAMES = {"vtv1":"VTV1","vtv2":"VTV2","vtv3":"VTV3","vtv4":"VTV4","vtv5":"VTV5","vtv6":"VTV6","vtv7":"VTV7","vtv8":"VTV8","vtv9":"VTV9","vtv10":"VTV10","vtvprime":"VTVPrime"}
|
| 23 |
|
|
|
|
|
|
|
| 24 |
IFRAME_URLS = {
|
| 25 |
"vtv1": "https://sv2.xemtivitop.com/live/hot/vtv1.php",
|
| 26 |
"vtv2": "https://sv2.xemtivitop.com/live/hot/vtv2.php",
|
|
@@ -28,29 +30,20 @@ IFRAME_URLS = {
|
|
| 28 |
"vtv4": "https://sv2.xemtivitop.com/live/hot/vtv4.php",
|
| 29 |
"vtv5": "https://sv2.xemtivitop.com/live/hot/vtv5.php",
|
| 30 |
"vtv6": "https://sv2.xemtivitop.com/live/hot/vtv6.php",
|
|
|
|
| 31 |
"vtv7": "https://sv.xemtivitop.com/live/vtv/vtv7.php",
|
| 32 |
"vtv8": "https://sv2.xemtivitop.com/live/hot/vtv8.php",
|
| 33 |
"vtv9": "https://sv2.xemtivitop.com/live/hot/vtv9.php",
|
| 34 |
"vtv10": "https://sv2.xemtivitop.com/live/hot/vtv10.php",
|
| 35 |
}
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
"
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
"fpt-the-thao": {
|
| 44 |
-
"label": "FPT Play Thể Thao",
|
| 45 |
-
"streams": [
|
| 46 |
-
{"label":"📡 IPTV Primary","url":"https://live.fptplay53.net/epzhd1/htvcthethao_mhls.smil/master.m3u8","type":"m3u8"},
|
| 47 |
-
{"label":"📡 IPTV Fallback","url":"https://live.fptplay53.net/epzhd1/htvcthethao_vhls.smil/chunklist.m3u8","type":"m3u8"},
|
| 48 |
-
{"label":"🔗 hd1.xemtv.net","url":"https://hd1.xemtv.net/kenh-fptplay-truc-tuyen-the-thao.html","type":"iframe","allow":"autoplay; encrypted-media; fullscreen"},
|
| 49 |
-
],
|
| 50 |
-
},
|
| 51 |
-
}
|
| 52 |
|
| 53 |
-
M3U_URLS = ["https://raw.githubusercontent.com/Love4vn/Test/refs/heads/main/IPTV.m3u", "https://raw.githubusercontent.com/Love4vn/love4vn/main/Out_Iptv_CXT.m3u", "https://raw.githubusercontent.com/konanda-sg/Test_Love4vn/main/IPTV.m3u", "https://raw.githubusercontent.com/iptv-org/iptv/master/streams/vn.m3u"]
|
| 54 |
_channel_urls_lock = threading.Lock()
|
| 55 |
_last_m3u_fetch = 0
|
| 56 |
_M3U_TTL = 900
|
|
@@ -100,6 +93,8 @@ def get_channel_url(channel_id):
|
|
| 100 |
_fetch_m3u()
|
| 101 |
with _channel_urls_lock: return CHANNEL_URLS.get(channel_id)
|
| 102 |
|
|
|
|
|
|
|
| 103 |
@router.get("/api/vtv/streams")
|
| 104 |
def api_vtv_streams():
|
| 105 |
urls = dict(CHANNEL_URLS)
|
|
@@ -108,29 +103,37 @@ def api_vtv_streams():
|
|
| 108 |
stream_url = urls.get(ch_id)
|
| 109 |
iframe_url = IFRAME_URLS.get(ch_id)
|
| 110 |
is_flv = stream_url and ".flv" in stream_url
|
| 111 |
-
result[ch_id] = {
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
| 117 |
return JSONResponse(result)
|
| 118 |
|
| 119 |
@router.get("/api/vtv/stream/{channel_id}")
|
| 120 |
def api_vtv_stream(channel_id: str):
|
| 121 |
channel_id = channel_id.lower().strip()
|
| 122 |
-
# Check FPT Play FIRST
|
| 123 |
-
if channel_id in FPT_PLAY_STREAM_SELECTORS:
|
| 124 |
-
streams = FPT_PLAY_STREAM_SELECTORS[channel_id]["streams"]
|
| 125 |
-
primary = streams[0]["url"] if streams else ""
|
| 126 |
-
return JSONResponse({"name": FPT_PLAY_STREAM_SELECTORS[channel_id]["label"], "stream_url": primary, "m3u8_proxy_url": f"/api/proxy/m3u8?url={quote(primary, safe='')}" if primary else "", "iframe_url": FPT_PLAY_IFRAMES.get(channel_id), "stream_selectors": FPT_PLAY_STREAM_SELECTORS[channel_id], "is_fpt_play": True, "status": "ok"})
|
| 127 |
if channel_id not in CHANNEL_NAMES: return JSONResponse({"error":"not found"}, status_code=404)
|
| 128 |
stream_url = get_channel_url(channel_id)
|
| 129 |
iframe_url = IFRAME_URLS.get(channel_id)
|
| 130 |
is_flv = stream_url and ".flv" in stream_url
|
| 131 |
m3u8_proxy = ""
|
| 132 |
-
if stream_url and not is_flv and stream_url.startswith("http"):
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
@router.get("/api/vtv/m3u/refresh")
|
| 136 |
def api_vtv_m3u_refresh():
|
|
@@ -141,6 +144,7 @@ def api_vtv_m3u_refresh():
|
|
| 141 |
found = sum(1 for v in urls.values() if v)
|
| 142 |
return JSONResponse({"status":"refreshed","channels_found":found,"channels":{k:v for k,v in urls.items() if v}})
|
| 143 |
|
|
|
|
| 144 |
_epg_cache = {}; _epg_cache_time = 0; _EPG_CACHE_TTL = 600
|
| 145 |
VTV_CHANNEL_MAP = {"vtv1":"vtv1","vtv2":"vtv2","vtv3":"vtv3","vtv4":"vtv4","vtv5":"vtv5","vtv5-tay-nam-bo":"vtv5","vtv5-tay-nguyen":"vtv5","vtv6":"vtv6","vtv7":"vtv7","vtv8":"vtv8","vtv9":"vtv9","vtv-can-tho":"vtv10"}
|
| 146 |
|
|
@@ -220,4 +224,4 @@ def api_vtv_epg_refresh():
|
|
| 220 |
global _epg_cache, _epg_cache_time
|
| 221 |
_epg_cache = {}; _epg_cache_time = 0
|
| 222 |
epg_data = _fetch_epg_from_vtv()
|
| 223 |
-
return JSONResponse({"status":"refreshed","channels":len(epg_data),"total":sum(len(v) for v in epg_data.values())})
|
|
|
|
| 1 |
+
# VTV Stream — SV2/sv.xemtivitop iframe URLs + M3U fallback + EPG
|
| 2 |
import re, time, threading, json, requests
|
| 3 |
from fastapi import APIRouter, Query
|
| 4 |
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
|
|
|
| 21 |
CHANNEL_URLS = {k: (HARDCODED_URLS[k] if k in HARDCODED_URLS else None) for k in ["vtv1","vtv2","vtv3","vtv4","vtv5","vtv6","vtv7","vtv8","vtv9","vtv10"]}
|
| 22 |
CHANNEL_NAMES = {"vtv1":"VTV1","vtv2":"VTV2","vtv3":"VTV3","vtv4":"VTV4","vtv5":"VTV5","vtv6":"VTV6","vtv7":"VTV7","vtv8":"VTV8","vtv9":"VTV9","vtv10":"VTV10","vtvprime":"VTVPrime"}
|
| 23 |
|
| 24 |
+
# Iframe URLs cho từng kênh — lấy từ xemtivitop.com
|
| 25 |
+
# Với VTV7: LINK 1 = blogspot (lỗi), LINK 2 = sv.xemtivitop.com (JWPlayer, hoạt động)
|
| 26 |
IFRAME_URLS = {
|
| 27 |
"vtv1": "https://sv2.xemtivitop.com/live/hot/vtv1.php",
|
| 28 |
"vtv2": "https://sv2.xemtivitop.com/live/hot/vtv2.php",
|
|
|
|
| 30 |
"vtv4": "https://sv2.xemtivitop.com/live/hot/vtv4.php",
|
| 31 |
"vtv5": "https://sv2.xemtivitop.com/live/hot/vtv5.php",
|
| 32 |
"vtv6": "https://sv2.xemtivitop.com/live/hot/vtv6.php",
|
| 33 |
+
# VTV7: LINK 2 trên xemtivitop.com — sv.xemtivitop.com (ko phải sv2), JWPlayer, cho phép iframe
|
| 34 |
"vtv7": "https://sv.xemtivitop.com/live/vtv/vtv7.php",
|
| 35 |
"vtv8": "https://sv2.xemtivitop.com/live/hot/vtv8.php",
|
| 36 |
"vtv9": "https://sv2.xemtivitop.com/live/hot/vtv9.php",
|
| 37 |
"vtv10": "https://sv2.xemtivitop.com/live/hot/vtv10.php",
|
| 38 |
}
|
| 39 |
|
| 40 |
+
M3U_URLS = [
|
| 41 |
+
"https://raw.githubusercontent.com/Love4vn/Test/refs/heads/main/IPTV.m3u",
|
| 42 |
+
"https://raw.githubusercontent.com/Love4vn/love4vn/main/Out_Iptv_CXT.m3u",
|
| 43 |
+
"https://raw.githubusercontent.com/konanda-sg/Test_Love4vn/main/IPTV.m3u",
|
| 44 |
+
"https://raw.githubusercontent.com/iptv-org/iptv/master/streams/vn.m3u",
|
| 45 |
+
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
|
|
|
| 47 |
_channel_urls_lock = threading.Lock()
|
| 48 |
_last_m3u_fetch = 0
|
| 49 |
_M3U_TTL = 900
|
|
|
|
| 93 |
_fetch_m3u()
|
| 94 |
with _channel_urls_lock: return CHANNEL_URLS.get(channel_id)
|
| 95 |
|
| 96 |
+
# ===================== API ENDPOINTS =====================
|
| 97 |
+
|
| 98 |
@router.get("/api/vtv/streams")
|
| 99 |
def api_vtv_streams():
|
| 100 |
urls = dict(CHANNEL_URLS)
|
|
|
|
| 103 |
stream_url = urls.get(ch_id)
|
| 104 |
iframe_url = IFRAME_URLS.get(ch_id)
|
| 105 |
is_flv = stream_url and ".flv" in stream_url
|
| 106 |
+
result[ch_id] = {
|
| 107 |
+
"name": CHANNEL_NAMES[ch_id],
|
| 108 |
+
"stream_url": stream_url,
|
| 109 |
+
"proxy_url": f"/api/proxy/flv?url={quote(stream_url, safe='')}" if stream_url and is_flv else "",
|
| 110 |
+
"proxy_url_hls": f"/api/proxy/stream?url={quote(stream_url, safe='')}" if stream_url and not is_flv else "",
|
| 111 |
+
"is_flv": is_flv,
|
| 112 |
+
"iframe_url": iframe_url,
|
| 113 |
+
"status": "ok"
|
| 114 |
+
}
|
| 115 |
return JSONResponse(result)
|
| 116 |
|
| 117 |
@router.get("/api/vtv/stream/{channel_id}")
|
| 118 |
def api_vtv_stream(channel_id: str):
|
| 119 |
channel_id = channel_id.lower().strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
if channel_id not in CHANNEL_NAMES: return JSONResponse({"error":"not found"}, status_code=404)
|
| 121 |
stream_url = get_channel_url(channel_id)
|
| 122 |
iframe_url = IFRAME_URLS.get(channel_id)
|
| 123 |
is_flv = stream_url and ".flv" in stream_url
|
| 124 |
m3u8_proxy = ""
|
| 125 |
+
if stream_url and not is_flv and stream_url.startswith("http"):
|
| 126 |
+
m3u8_proxy = f"/api/proxy/m3u8?url={quote(stream_url, safe='')}"
|
| 127 |
+
return JSONResponse({
|
| 128 |
+
"name": CHANNEL_NAMES[channel_id],
|
| 129 |
+
"stream_url": stream_url,
|
| 130 |
+
"m3u8_proxy_url": m3u8_proxy,
|
| 131 |
+
"proxy_url": f"/api/proxy/flv?url={quote(stream_url, safe='')}" if stream_url and is_flv else "",
|
| 132 |
+
"proxy_url_hls": f"/api/proxy/stream?url={quote(stream_url, safe='')}" if stream_url and not is_flv else "",
|
| 133 |
+
"is_flv": is_flv,
|
| 134 |
+
"iframe_url": iframe_url,
|
| 135 |
+
"status": "ok"
|
| 136 |
+
})
|
| 137 |
|
| 138 |
@router.get("/api/vtv/m3u/refresh")
|
| 139 |
def api_vtv_m3u_refresh():
|
|
|
|
| 144 |
found = sum(1 for v in urls.values() if v)
|
| 145 |
return JSONResponse({"status":"refreshed","channels_found":found,"channels":{k:v for k,v in urls.items() if v}})
|
| 146 |
|
| 147 |
+
# ===================== EPG =====================
|
| 148 |
_epg_cache = {}; _epg_cache_time = 0; _EPG_CACHE_TTL = 600
|
| 149 |
VTV_CHANNEL_MAP = {"vtv1":"vtv1","vtv2":"vtv2","vtv3":"vtv3","vtv4":"vtv4","vtv5":"vtv5","vtv5-tay-nam-bo":"vtv5","vtv5-tay-nguyen":"vtv5","vtv6":"vtv6","vtv7":"vtv7","vtv8":"vtv8","vtv9":"vtv9","vtv-can-tho":"vtv10"}
|
| 150 |
|
|
|
|
| 224 |
global _epg_cache, _epg_cache_time
|
| 225 |
_epg_cache = {}; _epg_cache_time = 0
|
| 226 |
epg_data = _fetch_epg_from_vtv()
|
| 227 |
+
return JSONResponse({"status":"refreshed","channels":len(epg_data),"total":sum(len(v) for v in epg_data.values())})
|