Spaces:
Running
Running
Restore main to commit 0cc342c - fix VTV streams
#18
by bep40 - opened
- .docker_rebuild +0 -1
- .huggingface/rebuild +0 -1
- .rebuild +0 -1
- .rebuild_flag +0 -1
- .rebuild_v2 +0 -1
- .restart_trigger +0 -1
- CHANGELOG.md +33 -68
- Dockerfile +1 -2
- README.md +14 -22
- TRIGGER_REBUILD_V6.md +1 -0
- TRIGGER_REBUILD_V7.md +12 -0
- _static_build_trigger.txt +6 -0
- ai_ext.py +1031 -441
- ai_fix2.py +1 -1
- ai_patch.py +89 -216
- ai_runtime_final6.py +481 -5
- app_v2_entry.py +86 -956
- bongda_proxy.py +148 -17
- main.py +232 -176
- rewrite_fix_v2.js +0 -2
- runtime.txt +0 -1
- shorts_cache.py +0 -86
- shorts_rss_proxy.py +0 -114
- static/app_v2.js +190 -322
- static/app_v2_shorts_fix.js +0 -2
- static/fm_fix.css +0 -70
- static/index_v2.html +12 -23
- static/restart_trigger.txt +1 -0
- static/rewrite_fix.js +90 -0
- static/rewrite_fix_v2.js +0 -164
- static/shorts_fresh.js +0 -5
- static/vtv_init.js +0 -370
- static/vtv_init_loader.html +0 -2
- static/vtv_test2.html +116 -0
- static/wc2026_v2.js +28 -236
- static/wc_shorts_inject.js +0 -76
- static/yt_live.js +405 -40
- vtv_api.py +491 -175
- vtv_epg_data.json +0 -1
- vtv_scraper.py +85 -76
- vtv_shorts.py +160 -6
- vtv_test.html +78 -0
- yt_scraper_fixed.py +0 -162
.docker_rebuild
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
trigger
|
|
|
|
|
|
.huggingface/rebuild
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
rebuild
|
|
|
|
|
|
.rebuild
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
Rebuild: vtv_api.py VTVGo proxy (player thật, no XFO) cho tất cả kênh
|
|
|
|
|
|
.rebuild_flag
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
rebuild
|
|
|
|
|
|
.rebuild_v2
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
rebuild
|
|
|
|
|
|
.restart_trigger
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
rebuild
|
|
|
|
|
|
CHANGELOG.md
CHANGED
|
@@ -1,71 +1,36 @@
|
|
| 1 |
-
# VNEWS
|
| 2 |
|
| 3 |
## Changes
|
| 4 |
|
| 5 |
-
###
|
| 6 |
-
|
| 7 |
-
-
|
| 8 |
-
-
|
| 9 |
-
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
-
|
| 37 |
-
|
| 38 |
-
### Channels monitored
|
| 39 |
-
- baodantri7941 (Dân trí)
|
| 40 |
-
- baosuckhoedoisongboyte (Sức khỏe & đời sống)
|
| 41 |
-
- vtvnambo (VTV Nam Bộ)
|
| 42 |
-
|
| 43 |
-
---
|
| 44 |
-
|
| 45 |
-
# VNEWS v5.1 - Rewrite Fix
|
| 46 |
-
|
| 47 |
-
## Changes
|
| 48 |
-
|
| 49 |
-
### Critical Fix: Rewrite button not creating posts on Tường AI
|
| 50 |
-
**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.
|
| 51 |
-
|
| 52 |
-
**Fix applied**:
|
| 53 |
-
1. **app_v2_entry.py** — Added 3 new endpoints:
|
| 54 |
-
- `POST /api/rewrite_slide` — Fast extractive summary (no AI needed), creates slides from article key points + images, saves to wall
|
| 55 |
-
- `POST /api/rewrite_share` — AI-powered rewrite with extractive fallback, saves to wall
|
| 56 |
-
- `POST /api/url_wall` — URL submission endpoint (alias for rewrite_share)
|
| 57 |
-
- All endpoints use the same `_load_wall_posts()` / `_save_wall_posts()` and `WALL_FILE` path as the existing `/api/wall` endpoint
|
| 58 |
-
|
| 59 |
-
2. **static/index_v2.html** — Added `<script src="/static/rewrite_fix_v2.js"></script>` to load the rewrite fix
|
| 60 |
-
|
| 61 |
-
3. **static/rewrite_fix_v2.js** — New file that overrides `rewriteArticle()` to:
|
| 62 |
-
- Call `/api/rewrite_slide` first (fast, no AI needed)
|
| 63 |
-
- Fallback to `/api/rewrite_share` if slide fails
|
| 64 |
-
- Show slide preview overlay after successful post
|
| 65 |
-
- Use `prependWallPost()` to add the new post to Tường AI
|
| 66 |
-
|
| 67 |
-
### Previous changes (v5)
|
| 68 |
-
- Rewrote match_detail_v2.py with correct event parsing
|
| 69 |
-
- 2-tab layout for match detail (stats + timeline)
|
| 70 |
-
- Fixed _run.py import
|
| 71 |
-
- Dockerfile cache busting
|
|
|
|
| 1 |
+
# VNEWS v5 - Match Detail Fix
|
| 2 |
|
| 3 |
## Changes
|
| 4 |
|
| 5 |
+
### 1. match_detail_v2.py — Rewrote event parser with correct selectors
|
| 6 |
+
- Parse `.events > .period > .event` structure (not old `.timeline`)
|
| 7 |
+
- Extract event type from SVG icons in `.event-type` (goal/redcard/yellowcard/substitution)
|
| 8 |
+
- Parse player names from `.players > div` elements
|
| 9 |
+
- For goals: extract scorer + assist names
|
| 10 |
+
- For substitutions: extract player_out → player_in
|
| 11 |
+
- For cards: extract player name
|
| 12 |
+
- Normalize time format: `45' +2` → `45+2'`
|
| 13 |
+
- Fetch H2H stats from `/api/fixtures/h2h-stats` API
|
| 14 |
+
- Parse prediction card, recent matches, H2H standings
|
| 15 |
+
|
| 16 |
+
### 2. static/match_detail.js — Complete rewrite with 2-tab layout
|
| 17 |
+
- **Tab "Thống kê"**: H2H stats comparison, prediction vote, recent match results
|
| 18 |
+
- **Tab "Diễn biến"**: Detailed timeline with:
|
| 19 |
+
- ⚽ BÀN THẮNG — scorer name + assist
|
| 20 |
+
- 🟥 THỺ ĐỎ — player name
|
| 21 |
+
- 🟨 THỺ VÀNG — player name
|
| 22 |
+
- ↔️ THAY ĐỔI — player_out → player_in
|
| 23 |
+
- Period grouping (H1, H2) with visual headers
|
| 24 |
+
- Team badges (HOME/AWAY) per event
|
| 25 |
+
- Color-coded event icons
|
| 26 |
+
|
| 27 |
+
### 3. app_v2_entry.py — Updated
|
| 28 |
+
- Module cache clearing for fresh match_detail_v2 import on each API call
|
| 29 |
+
- Single clean import for both `/detail` and `/live` endpoints
|
| 30 |
+
- Removed duplicate inline scraping code
|
| 31 |
+
|
| 32 |
+
### 4. _run.py — Fixed import
|
| 33 |
+
- Changed `import match_detail` to `import match_detail_v2`
|
| 34 |
+
|
| 35 |
+
### 5. Dockerfile — Cache busting
|
| 36 |
+
- Added `RUN date > /app/.build_timestamp` to force Docker rebuild
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Dockerfile
CHANGED
|
@@ -12,5 +12,4 @@ RUN pip install --no-cache-dir -r requirements.txt || true
|
|
| 12 |
COPY . .
|
| 13 |
EXPOSE 7860
|
| 14 |
|
| 15 |
-
CMD ["uvicorn", "_run:app", "--host", "0.0.0.0", "--port", "7860"]
|
| 16 |
-
# v2
|
|
|
|
| 12 |
COPY . .
|
| 13 |
EXPOSE 7860
|
| 14 |
|
| 15 |
+
CMD ["uvicorn", "_run:app", "--host", "0.0.0.0", "--port", "7860", "--reload"]
|
|
|
README.md
CHANGED
|
@@ -9,25 +9,17 @@ tags:
|
|
| 9 |
- ml-intern
|
| 10 |
---
|
| 11 |
|
| 12 |
-
#
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
-
|
| 26 |
-
- ⚽ Livescore from bongda.com.vn (live, today, upcoming, results, standings)
|
| 27 |
-
- 🎬 Football highlights from xemlaibongda.top (8 leagues)
|
| 28 |
-
- 📺 VTV live channels (VTV1→VTV10, VTV Prime)
|
| 29 |
-
- Priority: ssaimh CDN → sv2.xemtivitop.com → xemtv.us → xemtivitop blogspot → FPTPlay → VTVGo → mediacdn → xemtv.net
|
| 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
|
|
|
|
| 9 |
- ml-intern
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# bep40/vnews
|
| 13 |
+
<!-- build: 2026-06-12T06:45:00 -->
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
<!-- rebuild: v6.5-final-1781173463 -->
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
TRIGGER_REBUILD_V6.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Rebuild trigger v6 - 1780971280.8544343
|
TRIGGER_REBUILD_V7.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Rebuild Trigger v7
|
| 2 |
+
|
| 3 |
+
Changes:
|
| 4 |
+
- Fixed vtv_shorts.py: added get_vtvnamo_shorts alias (fix import typo)
|
| 5 |
+
- Fixed cache key mismatch in vtv_shorts.py
|
| 6 |
+
- Created wc_shorts_slide.js: standalone TikTok-style WC Shorts slide above WC section
|
| 7 |
+
- Created wc_shorts_inject.js: auto-injects WC Shorts slide before WC section
|
| 8 |
+
- Updated wc2026_v2.js: removed Shorts tab (now separate slide)
|
| 9 |
+
- Updated main.py: ensure nqlLH6chLRo always first in shorts list, added VTV WB fallback
|
| 10 |
+
- Updated index_v2.html: added wc_shorts_slide.js and wc_shorts_inject.js scripts
|
| 11 |
+
|
| 12 |
+
Build time: 2026-06-13
|
_static_build_trigger.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
BUILD_TRIGGER: wc-shorts-tiktok-slideshow-2026-06-13
|
| 2 |
+
Changes:
|
| 3 |
+
- vtv_shorts.py: New scraper using YouTube RSS feed + yt-dlp fallback
|
| 4 |
+
- main.py: Import vtv_shorts, add /api/shorts/vtvnamo and /api/shorts/wc endpoints
|
| 5 |
+
- static/wc2026_v2.js: Complete TikTok-style slideshow with autoplay, title overlay, action buttons
|
| 6 |
+
- static/app_v2.js: Fetch VTV Nam Bộ shorts from dedicated endpoint in loadHome
|
ai_ext.py
CHANGED
|
@@ -13,16 +13,7 @@ from bs4 import BeautifulSoup
|
|
| 13 |
from fastapi import Request, Query
|
| 14 |
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
|
| 15 |
|
| 16 |
-
|
| 17 |
-
try:
|
| 18 |
-
from main import app
|
| 19 |
-
except ImportError:
|
| 20 |
-
# Create a minimal FastAPI app for standalone testing
|
| 21 |
-
try:
|
| 22 |
-
from fastapi import FastAPI
|
| 23 |
-
app = FastAPI()
|
| 24 |
-
except Exception:
|
| 25 |
-
app = None
|
| 26 |
|
| 27 |
# Import wall store from main.py so we read/write the SAME file
|
| 28 |
try:
|
|
@@ -50,10 +41,6 @@ except ImportError:
|
|
| 50 |
def _web_context(topic):
|
| 51 |
return ""
|
| 52 |
|
| 53 |
-
# ai_ext alias for backward compatibility
|
| 54 |
-
_load_ai_wall = _load_wall
|
| 55 |
-
_save_ai_wall = _save_wall
|
| 56 |
-
|
| 57 |
try:
|
| 58 |
from huggingface_hub import AsyncInferenceClient
|
| 59 |
except Exception:
|
|
@@ -73,150 +60,21 @@ except Exception:
|
|
| 73 |
|
| 74 |
|
| 75 |
def _hf_token():
|
| 76 |
-
for k in ("HF_TOKEN", "
|
| 77 |
v = os.getenv(k, "").strip()
|
| 78 |
if v:
|
| 79 |
return v
|
| 80 |
return ""
|
| 81 |
|
| 82 |
-
|
| 83 |
-
def _clean_text(s: str) -> str:
|
| 84 |
-
"""Clean text for processing."""
|
| 85 |
-
s = html_lib.unescape(s or "")
|
| 86 |
-
s = re.sub(r"\s+", " ", s)
|
| 87 |
-
return s.strip()
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
def _domain(url: str) -> str:
|
| 91 |
-
"""Extract domain from URL."""
|
| 92 |
-
try:
|
| 93 |
-
return urlparse(url or "").netloc.replace("www.", "")
|
| 94 |
-
except Exception:
|
| 95 |
-
return ""
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
async def qwen_generate(prompt: str, image_url: str = None, max_tokens: int = 1200) -> str:
|
| 99 |
-
"""Generate text using Qwen models via Hugging Face Inference API.
|
| 100 |
-
|
| 101 |
-
This function provides a resilient implementation that:
|
| 102 |
-
1. First tries the SDK-based inference client if available
|
| 103 |
-
2. Falls back to REST API calls to HF router endpoint
|
| 104 |
-
3. Returns a fallback summary if all else fails
|
| 105 |
-
"""
|
| 106 |
-
token = _hf_token()
|
| 107 |
-
errors = []
|
| 108 |
-
|
| 109 |
-
# Try HF router API with multiple models
|
| 110 |
-
if token:
|
| 111 |
-
models = [
|
| 112 |
-
os.getenv("QWEN_VL_MODEL", ""),
|
| 113 |
-
"Qwen/Qwen2.5-VL-7B-Instruct",
|
| 114 |
-
"Qwen/Qwen2.5-VL-3B-Instruct",
|
| 115 |
-
"Qwen/Qwen2.5-7B-Instruct",
|
| 116 |
-
"Qwen/Qwen2.5-3B-Instruct",
|
| 117 |
-
"Qwen/Qwen2.5-1.5B-Instruct",
|
| 118 |
-
"Qwen/Qwen2.5-72B-Instruct",
|
| 119 |
-
"meta-llama/Llama-3.3-70B-Instruct",
|
| 120 |
-
]
|
| 121 |
-
# Deduplicate while preserving order
|
| 122 |
-
seen = set()
|
| 123 |
-
models = [m for m in models if m and m not in seen and not seen.add(m)]
|
| 124 |
-
|
| 125 |
-
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
| 126 |
-
|
| 127 |
-
for model in models:
|
| 128 |
-
try:
|
| 129 |
-
is_vl = "VL" in model and image_url
|
| 130 |
-
if is_vl:
|
| 131 |
-
user_content = [
|
| 132 |
-
{"type": "image_url", "image_url": {"url": image_url}},
|
| 133 |
-
{"type": "text", "text": prompt}
|
| 134 |
-
]
|
| 135 |
-
else:
|
| 136 |
-
user_content = prompt
|
| 137 |
-
|
| 138 |
-
payload = {
|
| 139 |
-
"model": model,
|
| 140 |
-
"messages": [
|
| 141 |
-
{"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."},
|
| 142 |
-
{"role": "user", "content": user_content},
|
| 143 |
-
],
|
| 144 |
-
"max_tokens": min(int(max_tokens or 900), 1400),
|
| 145 |
-
"temperature": 0.35,
|
| 146 |
-
"top_p": 0.85,
|
| 147 |
-
}
|
| 148 |
-
|
| 149 |
-
r = requests.post(
|
| 150 |
-
"https://router.huggingface.co/v1/chat/completions",
|
| 151 |
-
headers=headers,
|
| 152 |
-
json=payload,
|
| 153 |
-
timeout=95
|
| 154 |
-
)
|
| 155 |
-
|
| 156 |
-
if r.status_code >= 300:
|
| 157 |
-
errors.append(f"{model}: HTTP {r.status_code}")
|
| 158 |
-
continue
|
| 159 |
-
|
| 160 |
-
j = r.json()
|
| 161 |
-
txt = (j.get("choices", [{}])[0].get("message", {}).get("content") or "").strip()
|
| 162 |
-
|
| 163 |
-
if txt:
|
| 164 |
-
return txt
|
| 165 |
-
|
| 166 |
-
errors.append(f"{model}: empty response")
|
| 167 |
-
|
| 168 |
-
except Exception as e:
|
| 169 |
-
errors.append(f"{model}: {type(e).__name__}")
|
| 170 |
-
|
| 171 |
-
# Fallback: extractive summary from prompt
|
| 172 |
-
LAST_QWEN_ERROR = errors[-3:] if errors else "unknown error"
|
| 173 |
-
return _fallback_summary_from_prompt(prompt, max_units=6)
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
def _fallback_summary_from_prompt(prompt: str, max_units: int = 6) -> str:
|
| 177 |
-
"""Generate a simple fallback summary when AI is unavailable."""
|
| 178 |
-
text = prompt or ""
|
| 179 |
-
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:"]:
|
| 180 |
-
if marker in text:
|
| 181 |
-
text = text.split(marker, 1)[1]
|
| 182 |
-
break
|
| 183 |
-
text = re.sub(r"https?://\S+", "", text)
|
| 184 |
-
text = re.sub(r"\s+", " ", text).strip()
|
| 185 |
-
|
| 186 |
-
# Split into sentences - extract ALL valid sentences, not just first few
|
| 187 |
-
sentences = re.split(r"(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])", text)
|
| 188 |
-
units = []
|
| 189 |
-
for s in sentences:
|
| 190 |
-
s = _clean_text(s)
|
| 191 |
-
if len(s) >= 30: # Lower threshold to capture more content
|
| 192 |
-
units.append(s)
|
| 193 |
-
|
| 194 |
-
if units:
|
| 195 |
-
# Take up to max_units valid sentences
|
| 196 |
-
result_units = units[:max_units]
|
| 197 |
-
return "\n".join("• " + u for u in result_units)
|
| 198 |
-
if text:
|
| 199 |
-
# Fallback: take chunks if no sentence boundaries found
|
| 200 |
-
chunks = []
|
| 201 |
-
for i in range(0, min(len(text), max_units * 300), 280):
|
| 202 |
-
chunk = _clean_text(text[i:i+300])
|
| 203 |
-
if chunk and chunk not in chunks:
|
| 204 |
-
chunks.append(chunk)
|
| 205 |
-
if len(chunks) >= max_units:
|
| 206 |
-
break
|
| 207 |
-
if chunks:
|
| 208 |
-
return "\n".join("• " + c for c in chunks)
|
| 209 |
-
return "• Không có đủ nội dung để tóm tắt."
|
| 210 |
-
|
| 211 |
-
|
| 212 |
HF_TOKEN = _hf_token()
|
| 213 |
QWEN_VL_MODEL = os.getenv("QWEN_VL_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct")
|
|
|
|
| 214 |
QWEN_TEXT_MODELS = [m.strip() for m in os.getenv(
|
| 215 |
"QWEN_TEXT_MODELS",
|
| 216 |
"Qwen/Qwen2.5-72B-Instruct,meta-llama/Llama-3.3-70B-Instruct,Qwen/Qwen2.5-7B-Instruct"
|
| 217 |
).split(",") if m.strip()]
|
| 218 |
-
_WORKING_MODEL_TEXT = None
|
| 219 |
-
_WORKING_MODEL_VL = None
|
| 220 |
DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data"
|
| 221 |
SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts")
|
| 222 |
HEADERS = {
|
|
@@ -225,350 +83,1082 @@ HEADERS = {
|
|
| 225 |
}
|
| 226 |
LAST_QWEN_ERROR = ""
|
| 227 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
|
| 229 |
-
#
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
"
|
| 234 |
-
"
|
| 235 |
-
"
|
| 236 |
-
|
| 237 |
-
"
|
| 238 |
-
"
|
| 239 |
-
"
|
| 240 |
-
"
|
| 241 |
-
|
| 242 |
-
"
|
| 243 |
-
|
| 244 |
-
"
|
| 245 |
-
|
| 246 |
-
"
|
| 247 |
-
"
|
| 248 |
-
"
|
| 249 |
-
"jenny": "en-US-AndrewMultilingualNeural",
|
| 250 |
-
# Portuguese - Thalita Multilingual ONLY
|
| 251 |
-
"pt-br-thalitamultilingualneural": "pt-BR-ThalitaMultilingualNeural",
|
| 252 |
-
"pt_thalita": "pt-BR-ThalitaMultilingualNeural",
|
| 253 |
-
"thalita": "pt-BR-ThalitaMultilingualNeural",
|
| 254 |
-
"pt_francisco": "pt-BR-ThalitaMultilingualNeural",
|
| 255 |
-
"pt": "pt-BR-ThalitaMultilingualNeural",
|
| 256 |
-
# French - Multilingual
|
| 257 |
-
"fr-fr-viviennemultilingualneural": "fr-FR-VivienneMultilingualNeural",
|
| 258 |
-
"fr-fr-remymultilingualneural": "fr-FR-RemyMultilingualNeural",
|
| 259 |
-
"fr_denise": "fr-FR-VivienneMultilingualNeural",
|
| 260 |
-
"denise": "fr-FR-VivienneMultilingualNeural",
|
| 261 |
-
"fr": "fr-FR-VivienneMultilingualNeural",
|
| 262 |
-
# German - Multilingual
|
| 263 |
-
"de-de-seraphinamultilingualneural": "de-DE-SeraphinaMultilingualNeural",
|
| 264 |
-
"de-de-florianmultilingualneural": "de-DE-FlorianMultilingualNeural",
|
| 265 |
-
"de_katja": "de-DE-SeraphinaMultilingualNeural",
|
| 266 |
-
"katja": "de-DE-SeraphinaMultilingualNeural",
|
| 267 |
-
"de": "de-DE-SeraphinaMultilingualNeural",
|
| 268 |
-
# Korean - Hyunsu Multilingual (NOT SunHee)
|
| 269 |
-
"ko-kr-hyunsumultilingualneural": "ko-KR-HyunsuMultilingualNeural",
|
| 270 |
-
"ko_sunhee": "ko-KR-HyunsuMultilingualNeural",
|
| 271 |
-
"sunhee": "ko-KR-HyunsuMultilingualNeural",
|
| 272 |
-
"ko": "ko-KR-HyunsuMultilingualNeural",
|
| 273 |
-
# Italian - Multilingual
|
| 274 |
-
"it-it-giuseppemultilingualneural": "it-IT-GiuseppeMultilingualNeural",
|
| 275 |
-
# Spanish (fallback to English multilingual)
|
| 276 |
-
"es_ela": "en-US-AndrewMultilingualNeural",
|
| 277 |
-
"ela": "en-US-AndrewMultilingualNeural",
|
| 278 |
-
"es_carlos": "en-US-AndrewMultilingualNeural",
|
| 279 |
-
"es": "en-US-AndrewMultilingualNeural",
|
| 280 |
-
# Japanese (fallback to English multilingual)
|
| 281 |
-
"ja_nanami": "en-US-AndrewMultilingualNeural",
|
| 282 |
-
"nanami": "en-US-AndrewMultilingualNeural",
|
| 283 |
-
"ja": "en-US-AndrewMultilingualNeural",
|
| 284 |
-
# Chinese (fallback to English multilingual)
|
| 285 |
-
"zh_xiaochen": "en-US-AndrewMultilingualNeural",
|
| 286 |
-
"xiaochen": "en-US-AndrewMultilingualNeural",
|
| 287 |
-
"zh": "en-US-AndrewMultilingualNeural",
|
| 288 |
}
|
| 289 |
|
| 290 |
|
| 291 |
-
def
|
| 292 |
-
"""
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
# News categories - choose appropriate voice
|
| 300 |
-
if any(kw in content for kw in ["kinh tế", "tài chính", "thị trường", "economics", "finance"]):
|
| 301 |
-
return ("jenny", "calm")
|
| 302 |
-
if any(kw in content for kw in ["thiên tai", "bão", "lũ lụt", "cháy nổ", "tai nạn", "disaster", "accident"]):
|
| 303 |
-
return ("thalita", "serious")
|
| 304 |
-
if any(kw in content for kw in ["giải trí", "showbiz", "entertainment", "hài hước"]):
|
| 305 |
-
return ("ela", "happy")
|
| 306 |
-
if any(kw in content for kw in ["công nghệ", "tech", "technology", "ai", "trí tuệ nhân tạo"]):
|
| 307 |
-
return ("katja", "excited")
|
| 308 |
-
|
| 309 |
-
# Default Vietnamese
|
| 310 |
-
return ("hoaimy", "trung_tinh")
|
| 311 |
|
| 312 |
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
|
| 319 |
-
def _download_image(url: str, fallback_title: str, out_path: str) -> bool:
|
| 320 |
-
"""Download image from URL to path."""
|
| 321 |
-
if not url:
|
| 322 |
-
return False
|
| 323 |
-
try:
|
| 324 |
-
r = requests.get(url, headers=HEADERS, timeout=15)
|
| 325 |
-
if r.status_code == 200:
|
| 326 |
-
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
| 327 |
-
with open(out_path, "wb") as f:
|
| 328 |
-
f.write(r.content)
|
| 329 |
-
return True
|
| 330 |
-
except Exception:
|
| 331 |
-
pass
|
| 332 |
-
return False
|
| 333 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
|
| 335 |
-
def pollination_image_url(topic: str) -> str:
|
| 336 |
-
"""Generate image URL from Pollinations.ai."""
|
| 337 |
-
return f"https://image.pollinations.ai/prompt/{quote(topic)}?width=1024&height=768&nologo=true&model=flux"
|
| 338 |
|
|
|
|
|
|
|
|
|
|
| 339 |
|
| 340 |
-
# Use the same wall file as app_v2_entry.py for consistency
|
| 341 |
-
WALL_FILE = os.path.join(DATA_DIR, "wall_posts.json")
|
| 342 |
|
| 343 |
-
|
| 344 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
try:
|
| 346 |
-
|
| 347 |
-
with open(WALL_FILE, "r", encoding="utf-8") as f:
|
| 348 |
-
return json.load(f)
|
| 349 |
except Exception:
|
| 350 |
-
|
| 351 |
-
return []
|
| 352 |
|
|
|
|
|
|
|
| 353 |
|
| 354 |
-
def _save_ai_wall(posts):
|
| 355 |
-
"""Save AI wall posts to JSON file (uses wall_posts.json for consistency with app_v2_entry)."""
|
| 356 |
-
try:
|
| 357 |
-
os.makedirs(os.path.dirname(WALL_FILE), exist_ok=True)
|
| 358 |
-
tmp = WALL_FILE + ".tmp"
|
| 359 |
-
with open(tmp, "w", encoding="utf-8") as f:
|
| 360 |
-
json.dump(posts[:100], f, ensure_ascii=False)
|
| 361 |
-
os.replace(tmp, WALL_FILE)
|
| 362 |
-
except Exception:
|
| 363 |
-
pass
|
| 364 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
|
| 371 |
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
|
| 377 |
-
def make_post(title: str, text: str, img: str, url: str, kind: str, sources=None):
|
| 378 |
-
"""Create a post dict with standard fields."""
|
| 379 |
return {
|
| 380 |
-
"
|
| 381 |
-
"
|
| 382 |
-
"
|
| 383 |
-
"img": img,
|
| 384 |
-
"url": url,
|
| 385 |
-
"kind": kind,
|
| 386 |
-
"sources": sources or [],
|
| 387 |
-
"ts": int(time.time())
|
| 388 |
}
|
| 389 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
|
| 391 |
-
def _short_script(post) -> str:
|
| 392 |
-
"""Extract clean text for TTS from post."""
|
| 393 |
-
text = post.get("text", "") or post.get("title", "")
|
| 394 |
-
text = re.sub(r"^[•\-\*]\s*", "", text, flags=re.M)
|
| 395 |
-
text = re.sub(r"\s*\n\s*", ". ", text)
|
| 396 |
-
return _clean_text(text)[:2000] # Increased from 1000 to 2000 for full content
|
| 397 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
"""
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
block = el
|
| 429 |
-
break
|
| 430 |
-
if not block:
|
| 431 |
-
block = soup.body or soup
|
| 432 |
-
|
| 433 |
-
# Extract text from paragraphs
|
| 434 |
-
paragraphs = []
|
| 435 |
-
for el in block.find_all(['p', 'h2', 'h3'], recursive=True):
|
| 436 |
-
t = _clean_text(el.get_text(strip=True))
|
| 437 |
-
if t and len(t) > 40:
|
| 438 |
-
paragraphs.append(t)
|
| 439 |
-
|
| 440 |
-
# Extract images
|
| 441 |
-
images = []
|
| 442 |
-
for el in block.find_all(['figure', 'img'], recursive=True):
|
| 443 |
-
im = el if el.name == 'img' else el.find('img')
|
| 444 |
-
if im:
|
| 445 |
-
src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
|
| 446 |
-
if src and 'base64' not in src:
|
| 447 |
-
if src.startswith('//'):
|
| 448 |
-
src = 'https:' + src
|
| 449 |
-
images.append(src)
|
| 450 |
-
|
| 451 |
-
# Prefer OG image as main image
|
| 452 |
-
image = og_image or (images[0] if images else '')
|
| 453 |
-
|
| 454 |
-
return {
|
| 455 |
-
'title': title,
|
| 456 |
-
'summary': paragraphs[0] if paragraphs else '',
|
| 457 |
-
'text': '\n'.join(paragraphs),
|
| 458 |
-
'image': image,
|
| 459 |
-
'og_image': og_image,
|
| 460 |
-
'via': _domain(url),
|
| 461 |
-
'images': images
|
| 462 |
-
}
|
| 463 |
-
except Exception as e:
|
| 464 |
-
return {'title': url, 'summary': '', 'text': '', 'image': '', 'og_image': '', 'via': _domain(url), 'error': str(e)}
|
| 465 |
|
| 466 |
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 470 |
try:
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
for it in soup.find_all('item')[:limit]:
|
| 477 |
-
title = it.find('title').get_text(' ', strip=True) if it.find('title') else ''
|
| 478 |
-
link = it.find('link').get_text(strip=True) if it.find('link') else ''
|
| 479 |
-
if title and link:
|
| 480 |
-
sources.append({'title': title, 'url': link, 'via': _domain(link)})
|
| 481 |
except Exception:
|
| 482 |
pass
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
|
|
|
| 486 |
|
| 487 |
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 494 |
if Image is None:
|
| 495 |
-
|
| 496 |
-
# The caller should handle this case
|
| 497 |
-
return False
|
| 498 |
-
|
| 499 |
W, H = 1080, 1920
|
| 500 |
-
|
| 501 |
-
|
| 502 |
try:
|
| 503 |
im = Image.open(img_path).convert("RGB")
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
if im_ratio > target_ratio:
|
| 509 |
-
new_h = target[1]
|
| 510 |
-
new_w = int(new_h * im_ratio)
|
| 511 |
else:
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
im = im.
|
| 516 |
-
left = (new_w - target[0]) // 2
|
| 517 |
-
top = (new_h - target[1]) // 2
|
| 518 |
-
im = im.crop((left, top, left + target[0], top + target[1]))
|
| 519 |
bg.paste(im, (0, 0))
|
| 520 |
except Exception:
|
| 521 |
pass
|
| 522 |
-
|
| 523 |
draw = ImageDraw.Draw(bg)
|
| 524 |
-
|
| 525 |
try:
|
| 526 |
-
|
| 527 |
-
|
|
|
|
| 528 |
except Exception:
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
text = post.get("text", "")
|
| 542 |
-
text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip()
|
| 543 |
-
body_lines = _wrap_text(draw, text, font_body, maxw, 14)
|
| 544 |
-
for ln in body_lines:
|
| 545 |
-
draw.text((margin, y), ln, fill=(220, 220, 220), font=font_body)
|
| 546 |
-
y += 50
|
| 547 |
-
if y > 1640:
|
| 548 |
-
break
|
| 549 |
-
|
| 550 |
bg.save(out_path, quality=92)
|
| 551 |
-
return
|
| 552 |
|
| 553 |
|
| 554 |
-
def
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 560 |
try:
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 566 |
else:
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
from fastapi import Request, Query
|
| 14 |
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
|
| 15 |
|
| 16 |
+
from main import app
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
# Import wall store from main.py so we read/write the SAME file
|
| 19 |
try:
|
|
|
|
| 41 |
def _web_context(topic):
|
| 42 |
return ""
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
try:
|
| 45 |
from huggingface_hub import AsyncInferenceClient
|
| 46 |
except Exception:
|
|
|
|
| 60 |
|
| 61 |
|
| 62 |
def _hf_token():
|
| 63 |
+
for k in ("HF_TOKEN", "HUGGINGFACEHUB_API_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HF_API_TOKEN"):
|
| 64 |
v = os.getenv(k, "").strip()
|
| 65 |
if v:
|
| 66 |
return v
|
| 67 |
return ""
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
HF_TOKEN = _hf_token()
|
| 70 |
QWEN_VL_MODEL = os.getenv("QWEN_VL_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct")
|
| 71 |
+
# Fast TEXT models for summaries that don't need vision (much faster than the VL model).
|
| 72 |
QWEN_TEXT_MODELS = [m.strip() for m in os.getenv(
|
| 73 |
"QWEN_TEXT_MODELS",
|
| 74 |
"Qwen/Qwen2.5-72B-Instruct,meta-llama/Llama-3.3-70B-Instruct,Qwen/Qwen2.5-7B-Instruct"
|
| 75 |
).split(",") if m.strip()]
|
| 76 |
+
_WORKING_MODEL_TEXT = None # cached last-working text model
|
| 77 |
+
_WORKING_MODEL_VL = None # cached last-working vision model
|
| 78 |
DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data"
|
| 79 |
SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts")
|
| 80 |
HEADERS = {
|
|
|
|
| 83 |
}
|
| 84 |
LAST_QWEN_ERROR = ""
|
| 85 |
|
| 86 |
+
# ===== TTS VOICE CONFIG =====
|
| 87 |
+
# Multilingual neural voices grouped by country/language
|
| 88 |
+
# Format: key -> {id, gender, name, country, lang, flag}
|
| 89 |
+
TTS_VOICES = {
|
| 90 |
+
# === VIETNAM (edge-tts) ===
|
| 91 |
+
"hoaimy": {"id": "vi-VN-HoaiMyNeural", "gender": "female", "name": "Hoài My", "country": "Việt Nam", "lang": "vi", "flag": "🇻🇳", "engine": "edge"},
|
| 92 |
+
"namminh": {"id": "vi-VN-NamMinhNeural", "gender": "male", "name": "Nam Minh", "country": "Việt Nam", "lang": "vi", "flag": "🇻🇳", "engine": "edge"},
|
| 93 |
+
# === gTTS (Google, tiếng Việt cơ bản) ===
|
| 94 |
+
"gtts_vi": {"id": "gtts", "gender": "female", "name": "gTTS Google", "country": "Việt Nam", "lang": "vi", "flag": "🇻🇳", "engine": "gtts"},
|
| 95 |
+
# === MULTILINGUAL (đa ngôn ngữ — đọc được tiếng Việt + nhiều thứ tiếng) ===
|
| 96 |
+
"en_au_william": {"id": "en-AU-WilliamMultilingualNeural", "gender": "male", "name": "William (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
|
| 97 |
+
"en_us_andrew": {"id": "en-US-AndrewMultilingualNeural", "gender": "male", "name": "Andrew (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
|
| 98 |
+
"en_us_ava": {"id": "en-US-AvaMultilingualNeural", "gender": "female", "name": "Ava (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
|
| 99 |
+
"en_us_brian": {"id": "en-US-BrianMultilingualNeural", "gender": "male", "name": "Brian (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
|
| 100 |
+
"en_us_emma": {"id": "en-US-EmmaMultilingualNeural", "gender": "female", "name": "Emma (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
|
| 101 |
+
"fr_vivienne": {"id": "fr-FR-VivienneMultilingualNeural","gender": "female", "name": "Vivienne (Đa NN)","country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
|
| 102 |
+
"fr_remy": {"id": "fr-FR-RemyMultilingualNeural", "gender": "male", "name": "Rémy (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
|
| 103 |
+
"de_seraphina": {"id": "de-DE-SeraphinaMultilingualNeural","gender": "female","name": "Seraphina (Đa NN)","country": "Đa ngôn ngữ","lang": "multi", "flag": "🌐", "engine": "edge"},
|
| 104 |
+
"de_florian": {"id": "de-DE-FlorianMultilingualNeural", "gender": "male", "name": "Florian (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
|
| 105 |
+
"it_giuseppe": {"id": "it-IT-GiuseppeMultilingualNeural","gender": "male", "name": "Giuseppe (Đa NN)","country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
|
| 106 |
+
"ko_hyunsu": {"id": "ko-KR-HyunsuMultilingualNeural", "gender": "male", "name": "Hyunsu (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
|
| 107 |
+
"pt_thalita": {"id": "pt-BR-ThalitaMultilingualNeural", "gender": "female", "name": "Thalita (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
|
| 108 |
+
}
|
| 109 |
+
TTS_DEFAULT_VOICE = "hoaimy"
|
| 110 |
+
TTS_DEFAULT_SPEED = 1.2 # 1.2x speed for faster reading
|
| 111 |
|
| 112 |
+
# Topic -> voice mapping (auto-detect based on topic keywords)
|
| 113 |
+
TOPIC_VOICE_MAP = {
|
| 114 |
+
# Sports -> male voice
|
| 115 |
+
"bóng đá": "namminh", "thể thao": "namminh", "world cup": "namminh",
|
| 116 |
+
"premier league": "namminh", "champions league": "namminh", "la liga": "namminh",
|
| 117 |
+
"serie a": "namminh", "bundesliga": "namminh", "v-league": "namminh",
|
| 118 |
+
"tennis": "namminh", "olympic": "namminh", "f1": "namminh", "moto": "namminh",
|
| 119 |
+
# Lifestyle/Health/Entertainment -> female voice
|
| 120 |
+
"sức khỏe": "hoaimy", "làm đẹp": "hoaimy", "giải trí": "hoaimy",
|
| 121 |
+
"âm nhạc": "hoaimy", "phim": "hoaimy", "thời trang": "hoaimy",
|
| 122 |
+
"ẩm thực": "hoaimy", "du lịch": "hoaimy", "gia đình": "hoaimy",
|
| 123 |
+
"tình yêu": "hoaimy", "hôn nhân": "hoaimy", "mẹ và bé": "hoaimy",
|
| 124 |
+
# Tech/Science -> male voice
|
| 125 |
+
"công nghệ": "namminh", "ai": "namminh", "robot": "namminh",
|
| 126 |
+
"khoa học": "namminh", "vũ trụ": "namminh", "điện thoại": "namminh",
|
| 127 |
+
"laptop": "namminh", "game": "namminh",
|
| 128 |
+
# News/Politics/Economy -> male voice
|
| 129 |
+
"chính trị": "namminh", "kinh tế": "namminh", "tài chính": "namminh",
|
| 130 |
+
"chứng khoán": "namminh", "ngân hàng": "namminh", "thị trường": "namminh",
|
| 131 |
+
"xã hội": "namminh", "pháp luật": "namminh", "giáo dục": "namminh",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
}
|
| 133 |
|
| 134 |
|
| 135 |
+
def _detect_voice_for_topic(title: str, text: str) -> str:
|
| 136 |
+
"""Auto-detect the best voice based on topic keywords."""
|
| 137 |
+
combined = (title + " " + text[:500]).lower()
|
| 138 |
+
for keyword, voice_id in TOPIC_VOICE_MAP.items():
|
| 139 |
+
if keyword in combined:
|
| 140 |
+
return voice_id
|
| 141 |
+
return TTS_DEFAULT_VOICE
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
|
| 144 |
+
# ===== EMOTION (CẢM XÚC) FOR TTS =====
|
| 145 |
+
# edge-tts does NOT support Azure express-as styles, so emotion is simulated
|
| 146 |
+
# with pitch + rate(speed multiplier) + volume. Each preset is a delta.
|
| 147 |
+
# rate_mul multiplies the user/auto speed; pitch is absolute Hz; volume is percent.
|
| 148 |
+
EMOTION_PRESETS = {
|
| 149 |
+
"vui": {"label": "Vui tươi", "emoji": "😊", "rate_mul": 1.06, "pitch": "+15Hz", "volume": "+6%"},
|
| 150 |
+
"hao_hung": {"label": "Hào hứng", "emoji": "🔥", "rate_mul": 1.12, "pitch": "+24Hz", "volume": "+12%"},
|
| 151 |
+
"nghiem": {"label": "Nghiêm túc", "emoji": "📰", "rate_mul": 1.00, "pitch": "-3Hz", "volume": "+0%"},
|
| 152 |
+
"tram": {"label": "Trầm ấm", "emoji": "🌙", "rate_mul": 0.94, "pitch": "-10Hz", "volume": "+0%"},
|
| 153 |
+
"buon": {"label": "Buồn/Xúc động", "emoji": "💧", "rate_mul": 0.88, "pitch": "-18Hz", "volume": "-4%"},
|
| 154 |
+
"trung_tinh":{"label": "Trung tính", "emoji": "🎙️", "rate_mul": 1.00, "pitch": "+0Hz", "volume": "+0%"},
|
| 155 |
+
}
|
| 156 |
+
EMOTION_DEFAULT = "trung_tinh"
|
| 157 |
|
| 158 |
+
# Topic keyword -> emotion. Checked in order; first match wins.
|
| 159 |
+
TOPIC_EMOTION_MAP = {
|
| 160 |
+
# Sports / wins -> excited
|
| 161 |
+
"chiến thắng": "hao_hung", "vô địch": "hao_hung", "world cup": "hao_hung",
|
| 162 |
+
"bóng đá": "hao_hung", "thể thao": "hao_hung", "ghi bàn": "hao_hung",
|
| 163 |
+
"champions league": "hao_hung", "premier league": "hao_hung", "chung kết": "hao_hung",
|
| 164 |
+
"olympic": "hao_hung", "kỷ lục": "hao_hung",
|
| 165 |
+
# Entertainment / lifestyle / good news -> cheerful
|
| 166 |
+
"giải trí": "vui", "âm nhạc": "vui", "phim": "vui", "lễ hội": "vui",
|
| 167 |
+
"du lịch": "vui", "ẩm thực": "vui", "thời trang": "vui", "ra mắt": "vui",
|
| 168 |
+
"khai trương": "vui", "tin vui": "vui", "hạnh phúc": "vui",
|
| 169 |
+
# Sad / accidents / loss -> sad
|
| 170 |
+
"tai nạn": "buon", "qua đời": "buon", "tử vong": "buon", "thiệt mạng": "buon",
|
| 171 |
+
"động đất": "buon", "lũ lụt": "buon", "thiên tai": "buon", "cháy": "buon",
|
| 172 |
+
"tang lễ": "buon", "mất tích": "buon", "thương tâm": "buon",
|
| 173 |
+
# Health / science / calm -> calm warm
|
| 174 |
+
"sức khỏe": "tram", "y tế": "tram", "bệnh": "tram", "dinh dưỡng": "tram",
|
| 175 |
+
"tâm lý": "tram", "thiền": "tram", "giấc ngủ": "tram",
|
| 176 |
+
# News / politics / economy / law -> serious
|
| 177 |
+
"chính trị": "nghiem", "kinh tế": "nghiem", "tài chính": "nghiem",
|
| 178 |
+
"chứng khoán": "nghiem", "pháp luật": "nghiem", "tòa án": "nghiem",
|
| 179 |
+
"ngân hàng": "nghiem", "thị trường": "nghiem", "lạm phát": "nghiem",
|
| 180 |
+
"công nghệ": "nghiem", "ai": "nghiem", "khoa học": "nghiem", "giáo dục": "nghiem",
|
| 181 |
+
}
|
| 182 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
|
| 184 |
+
def _detect_emotion_for_topic(title: str, text: str) -> str:
|
| 185 |
+
"""Auto-detect emotion (cảm xúc) from topic/content keywords."""
|
| 186 |
+
combined = (title + " " + text[:600]).lower()
|
| 187 |
+
for keyword, emo in TOPIC_EMOTION_MAP.items():
|
| 188 |
+
if keyword in combined:
|
| 189 |
+
return emo
|
| 190 |
+
return EMOTION_DEFAULT
|
| 191 |
|
|
|
|
|
|
|
|
|
|
| 192 |
|
| 193 |
+
def _detect_voice_emotion(title: str, text: str) -> tuple:
|
| 194 |
+
"""Return (voice_id, emotion_id) auto-chosen for the article's topic."""
|
| 195 |
+
return _detect_voice_for_topic(title, text), _detect_emotion_for_topic(title, text)
|
| 196 |
|
|
|
|
|
|
|
| 197 |
|
| 198 |
+
# ===== TEXT HELPERS =====
|
| 199 |
+
def _clean_text(s: str) -> str:
|
| 200 |
+
s = html_lib.unescape(s or "")
|
| 201 |
+
return re.sub(r"\s+", " ", s).strip()
|
| 202 |
+
|
| 203 |
+
def _domain(u):
|
| 204 |
try:
|
| 205 |
+
return urlparse(u).netloc.replace("www.", "")
|
|
|
|
|
|
|
| 206 |
except Exception:
|
| 207 |
+
return ""
|
|
|
|
| 208 |
|
| 209 |
+
def _safe_name(s):
|
| 210 |
+
return re.sub(r"[^a-zA-Z0-9_-]+", "_", str(s))[:80]
|
| 211 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
|
| 213 |
+
# ===== CLEAN AI OUTPUT =====
|
| 214 |
+
def _clean_ai_output(text: str) -> str:
|
| 215 |
+
"""Remove markdown artifacts, instruction leakage, and aggressively dedup content."""
|
| 216 |
+
if not text:
|
| 217 |
+
return ""
|
| 218 |
+
# Remove markdown headings, bold, italic, horizontal rules
|
| 219 |
+
text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
|
| 220 |
+
text = re.sub(r'\*\*([^*]+)\*\*', r'\1', text)
|
| 221 |
+
text = re.sub(r'\*([^*]+)\*', r'\1', text)
|
| 222 |
+
text = re.sub(r'^---+\s*$', '', text, flags=re.MULTILINE)
|
| 223 |
+
text = re.sub(r'^[-*_]{3,}\s*$', '', text, flags=re.MULTILINE)
|
| 224 |
+
# Remove common AI instruction leakage phrases (entire line)
|
| 225 |
+
leakage = [
|
| 226 |
+
r'Dưới đây là', r'Theo yêu cầu', r'Tôi sẽ viết', r'Tôi sẽ tóm tắt',
|
| 227 |
+
r'Đây là bài', r'Đây là nội dung', r'Bài viết sau đây',
|
| 228 |
+
r'Nội dung (tóm tắt|chính)', r'Nhiệm vụ', r'Vai trò', r'Tôi là',
|
| 229 |
+
r'Dựa trên.*tôi sẽ', r'Hãy', r'Bạn cần', r'Đọc bài viết',
|
| 230 |
+
r'Tôi xin', r'Xin chào', r'Trân trọng', r'Kính thưa',
|
| 231 |
+
r'Dựa trên.*dưới đây', r'Sau đây là', r'Dưới đây là bài',
|
| 232 |
+
]
|
| 233 |
+
for phrase in leakage:
|
| 234 |
+
text = re.sub(r'^' + phrase + r'[^\n]*\n?', '', text, flags=re.MULTILINE | re.IGNORECASE)
|
| 235 |
+
text = re.sub(r'\n{3,}', '\n\n', text)
|
| 236 |
+
# --- Aggressive dedup: split into sentences, remove any that repeats ---
|
| 237 |
+
# Normalize: collapse whitespace, strip
|
| 238 |
+
def _norm(s):
|
| 239 |
+
return re.sub(r'\s+', ' ', s.strip().lower())
|
| 240 |
+
|
| 241 |
+
# Split by sentence-ending punctuation (keep delimiters)
|
| 242 |
+
raw_parts = re.split(r'(?<=[.!?])\s+', text.strip())
|
| 243 |
+
seen_sentences = set()
|
| 244 |
+
unique_parts = []
|
| 245 |
+
for part in raw_parts:
|
| 246 |
+
n = _norm(part)
|
| 247 |
+
# Skip near-duplicate: if >70% of an existing seen sentence matches
|
| 248 |
+
is_dup = False
|
| 249 |
+
if n:
|
| 250 |
+
if n in seen_sentences:
|
| 251 |
+
is_dup = True
|
| 252 |
+
else:
|
| 253 |
+
partial = re.sub(r'\W+', '', n)
|
| 254 |
+
for seen in seen_sentences:
|
| 255 |
+
seen_clean = re.sub(r'\W+', '', seen)
|
| 256 |
+
# Check substring match for very similar sentences
|
| 257 |
+
if partial and seen_clean and (
|
| 258 |
+
partial in seen_clean or seen_clean in partial
|
| 259 |
+
):
|
| 260 |
+
shorter = min(len(partial), len(seen_clean))
|
| 261 |
+
longer = max(len(partial), len(seen_clean))
|
| 262 |
+
if shorter > 20 and shorter / longer > 0.75:
|
| 263 |
+
is_dup = True
|
| 264 |
+
break
|
| 265 |
+
if is_dup:
|
| 266 |
+
continue
|
| 267 |
+
if n:
|
| 268 |
+
seen_sentences.add(n)
|
| 269 |
+
unique_parts.append(part)
|
| 270 |
|
| 271 |
+
result = ' '.join(unique_parts).strip()
|
| 272 |
+
# Final pass: remove any remaining consecutive duplicate lines
|
| 273 |
+
lines = result.split('\n')
|
| 274 |
+
final_lines = []
|
| 275 |
+
prev_line = ""
|
| 276 |
+
for line in lines:
|
| 277 |
+
stripped = line.strip()
|
| 278 |
+
if stripped and stripped == prev_line:
|
| 279 |
+
continue
|
| 280 |
+
final_lines.append(line)
|
| 281 |
+
prev_line = stripped
|
| 282 |
+
result = '\n'.join(final_lines).strip()
|
| 283 |
+
return result
|
| 284 |
|
| 285 |
|
| 286 |
+
# ===== EXTRACT ALL IMAGES FROM ARTICLE =====
|
| 287 |
+
def _extract_all_images(soup, base_url: str) -> List[Dict]:
|
| 288 |
+
"""Extract ALL content images from an article page using multi-strategy approach."""
|
| 289 |
+
images = []
|
| 290 |
+
seen_urls = set()
|
| 291 |
+
skip_patterns = [
|
| 292 |
+
"avatar", "icon", "logo", "button", "banner-ad", "tracking",
|
| 293 |
+
"beacon", "pixel", "1x1", "spacer", "emoji", "sprite", "placeholder",
|
| 294 |
+
"advertisement", "ads", "widget", "sidebar", "footer-logo",
|
| 295 |
+
]
|
| 296 |
|
| 297 |
+
def _add_image(src: str, alt: str = "", source_tag: str = "img"):
|
| 298 |
+
if not src or src.startswith("data:"):
|
| 299 |
+
return
|
| 300 |
+
abs_url = urljoin(base_url, src.strip())
|
| 301 |
+
if abs_url in seen_urls:
|
| 302 |
+
return
|
| 303 |
+
# Skip non-content images by URL pattern
|
| 304 |
+
if any(p in abs_url.lower() for p in skip_patterns):
|
| 305 |
+
return
|
| 306 |
+
# Skip very small images (likely icons)
|
| 307 |
+
try:
|
| 308 |
+
parsed = urlparse(abs_url)
|
| 309 |
+
path = parsed.path.lower()
|
| 310 |
+
if any(path.endswith(ext) for ext in ['.svg', '.ico', '.gif']):
|
| 311 |
+
return
|
| 312 |
+
except Exception:
|
| 313 |
+
pass
|
| 314 |
+
seen_urls.add(abs_url)
|
| 315 |
+
images.append({"url": abs_url, "alt": alt, "source": source_tag})
|
| 316 |
+
|
| 317 |
+
# Strategy 1: Standard <img> tags with all lazy-load attributes
|
| 318 |
+
for img in soup.find_all("img"):
|
| 319 |
+
src = (img.get("src") or img.get("data-src") or img.get("data-lazy-src") or
|
| 320 |
+
img.get("data-original") or img.get("data-srcset", "").split(",")[0].strip().split(" ")[0])
|
| 321 |
+
_add_image(src, alt=img.get("alt", ""), source_tag="img")
|
| 322 |
+
|
| 323 |
+
# Strategy 2: srcset on <img>
|
| 324 |
+
for img in soup.find_all("img", srcset=True):
|
| 325 |
+
for part in img["srcset"].split(","):
|
| 326 |
+
part = part.strip()
|
| 327 |
+
if part:
|
| 328 |
+
_add_image(part.split(" ")[0], alt=img.get("alt", ""), source_tag="srcset")
|
| 329 |
+
|
| 330 |
+
# Strategy 3: <picture> with <source>
|
| 331 |
+
for picture in soup.find_all("picture"):
|
| 332 |
+
for source in picture.find_all("source"):
|
| 333 |
+
srcset = source.get("srcset", "")
|
| 334 |
+
for part in srcset.split(","):
|
| 335 |
+
part = part.strip()
|
| 336 |
+
if part:
|
| 337 |
+
_add_image(part.split(" ")[0], source_tag="picture/srcset")
|
| 338 |
+
fallback_img = picture.find("img")
|
| 339 |
+
if fallback_img:
|
| 340 |
+
_add_image(
|
| 341 |
+
fallback_img.get("src") or fallback_img.get("data-src"),
|
| 342 |
+
alt=fallback_img.get("alt", ""),
|
| 343 |
+
source_tag="picture/img"
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
# Strategy 4: WordPress CMS patterns
|
| 347 |
+
for img in soup.find_all("img", class_=re.compile(r"wp-image|size-large|size-full|aligncenter")):
|
| 348 |
+
_add_image(img.get("data-src") or img.get("src"),
|
| 349 |
+
alt=img.get("alt", ""), source_tag="wp-image")
|
| 350 |
+
|
| 351 |
+
# Strategy 5: Background images in style attributes
|
| 352 |
+
for tag in soup.find_all(style=re.compile(r"background-image")):
|
| 353 |
+
for m in re.findall(r'url\(["\']?(.*?)["\']?\)', tag.get("style", "")):
|
| 354 |
+
_add_image(m, source_tag="background-style")
|
| 355 |
+
|
| 356 |
+
# Strategy 6: og:image (featured/hero image)
|
| 357 |
+
og_image = soup.find("meta", property="og:image")
|
| 358 |
+
if og_image and og_image.get("content"):
|
| 359 |
+
_add_image(og_image["content"], source_tag="og:image")
|
| 360 |
+
|
| 361 |
+
# Strategy 7: twitter:image
|
| 362 |
+
tw_image = soup.find("meta", attrs={"name": "twitter:image"})
|
| 363 |
+
if tw_image and tw_image.get("content"):
|
| 364 |
+
_add_image(tw_image["content"], source_tag="twitter:image")
|
| 365 |
+
|
| 366 |
+
# Strategy 8: <figure> with <figcaption>
|
| 367 |
+
for figure in soup.find_all("figure"):
|
| 368 |
+
img = figure.find("img")
|
| 369 |
+
if img:
|
| 370 |
+
src = img.get("data-src") or img.get("src")
|
| 371 |
+
figcaption = figure.find("figcaption")
|
| 372 |
+
alt = figcaption.get_text(strip=True) if figcaption else img.get("alt", "")
|
| 373 |
+
_add_image(src, alt=alt, source_tag="figure")
|
| 374 |
+
|
| 375 |
+
# Strategy 9: <a> tags linking to images
|
| 376 |
+
for a in soup.find_all("a", href=True):
|
| 377 |
+
href = a["href"]
|
| 378 |
+
if any(href.lower().endswith(ext) for ext in [".jpg", ".jpeg", ".png", ".webp", ".gif"]):
|
| 379 |
+
_add_image(href, alt=a.get_text(strip=True)[:80], source_tag="link")
|
| 380 |
+
|
| 381 |
+
return images
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
# ===== JINA READER =====
|
| 385 |
+
def _reader_url(target_url: str) -> str:
|
| 386 |
+
safe = quote(target_url, safe=":/?#[]@!$&'()*+,;=%")
|
| 387 |
+
return "https://r.jina.ai/http://" + safe
|
| 388 |
+
|
| 389 |
+
def jina_reader_markdown(url: str) -> str:
|
| 390 |
+
jr = _reader_url(url)
|
| 391 |
+
r = requests.get(jr, headers={"Accept": "text/markdown,text/plain,*/*", "X-Return-Format": "markdown", "User-Agent": "Mozilla/5.0"}, timeout=35)
|
| 392 |
+
r.raise_for_status()
|
| 393 |
+
return r.text or ""
|
| 394 |
+
|
| 395 |
+
def _parse_jina_markdown(md: str, url: str):
|
| 396 |
+
lines = [x.rstrip() for x in (md or "").splitlines()]
|
| 397 |
+
title = ""; first_image = ""; all_images = []; content_lines = []; in_content = False
|
| 398 |
+
for ln in lines:
|
| 399 |
+
if ln.startswith("Title:") and not title:
|
| 400 |
+
title = _clean_text(ln.replace("Title:", "", 1)); continue
|
| 401 |
+
if ln.startswith("URL Source:"):
|
| 402 |
+
continue
|
| 403 |
+
if ln.startswith("Markdown Content:"):
|
| 404 |
+
in_content = True; continue
|
| 405 |
+
# Extract ALL images from markdown 
|
| 406 |
+
for mimg in re.finditer(r'!\[[^\]]*\]\((https?://[^)]+)\)', ln):
|
| 407 |
+
img_url = mimg.group(1)
|
| 408 |
+
if img_url not in all_images:
|
| 409 |
+
all_images.append(img_url)
|
| 410 |
+
if not first_image:
|
| 411 |
+
first_image = img_url
|
| 412 |
+
if in_content or (title and not ln.startswith("Title:")):
|
| 413 |
+
if ln.strip():
|
| 414 |
+
content_lines.append(ln)
|
| 415 |
+
text = "\n".join(content_lines)
|
| 416 |
+
text = re.sub(r'!\[[^\]]*\]\([^)]+\)', '', text)
|
| 417 |
+
paras = []
|
| 418 |
+
for part in re.split(r'\n{2,}|\n(?=#{1,3}\s)', text):
|
| 419 |
+
t = _clean_text(re.sub(r'^#{1,6}\s*', '', part))
|
| 420 |
+
if len(t) >= 40:
|
| 421 |
+
paras.append(t)
|
| 422 |
+
if len(paras) >= 35:
|
| 423 |
+
break
|
| 424 |
+
if not title and paras:
|
| 425 |
+
title = paras[0][:90]
|
| 426 |
+
return {"url": url, "title": title or url, "summary": paras[0] if paras else "",
|
| 427 |
+
"text": "\n".join(paras), "image": first_image,
|
| 428 |
+
"images": all_images, "via": "jina"}
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
# ===== WEB SCRAPE (with full image extraction) =====
|
| 432 |
+
def _best_content_block(soup):
|
| 433 |
+
best, best_score = None, 0
|
| 434 |
+
for el in soup.find_all(["article", "main", "section", "div"]):
|
| 435 |
+
ps = el.find_all("p")
|
| 436 |
+
txt = " ".join(p.get_text(" ", strip=True) for p in ps)
|
| 437 |
+
score = len(ps) * 100 + len(txt)
|
| 438 |
+
cls = " ".join(el.get("class", []))
|
| 439 |
+
if any(k in cls.lower() for k in ["content", "article", "detail", "body", "post", "entry"]):
|
| 440 |
+
score += 800
|
| 441 |
+
if score > best_score:
|
| 442 |
+
best, best_score = el, score
|
| 443 |
+
return best
|
| 444 |
+
|
| 445 |
+
def scrape_any_url_direct(url: str):
|
| 446 |
+
r = requests.get(url, headers=HEADERS, timeout=18)
|
| 447 |
+
if r.status_code in {401, 403, 406, 409, 429, 451, 503}:
|
| 448 |
+
raise RuntimeError(f"blocked status {r.status_code}")
|
| 449 |
+
r.encoding = "utf-8"
|
| 450 |
+
soup = BeautifulSoup(r.text, "lxml")
|
| 451 |
+
for tag in soup.find_all(["script", "style", "nav", "footer", "aside", "form", "noscript"]):
|
| 452 |
+
tag.decompose()
|
| 453 |
+
|
| 454 |
+
# Title
|
| 455 |
+
title = soup.find("h1").get_text(" ", strip=True) if soup.find("h1") else ""
|
| 456 |
+
if not title:
|
| 457 |
+
ogt = soup.find("meta", property="og:title") or soup.find("meta", attrs={"name": "title"})
|
| 458 |
+
title = ogt.get("content", "") if ogt else (soup.title.get_text(strip=True) if soup.title else "")
|
| 459 |
+
|
| 460 |
+
# Summary
|
| 461 |
+
desc_tag = soup.find("meta", property="og:description") or soup.find("meta", attrs={"name": "description"})
|
| 462 |
+
summary = desc_tag.get("content", "") if desc_tag else ""
|
| 463 |
+
|
| 464 |
+
# Featured image (og:image)
|
| 465 |
+
img_tag = soup.find("meta", property="og:image") or soup.find("meta", attrs={"name": "twitter:image"})
|
| 466 |
+
image = img_tag.get("content", "") if img_tag else ""
|
| 467 |
+
if image and image.startswith("//"):
|
| 468 |
+
image = "https:" + image
|
| 469 |
+
|
| 470 |
+
# Extract ALL images from the article
|
| 471 |
+
all_images = _extract_all_images(soup, url)
|
| 472 |
+
image_urls = [img["url"] for img in all_images]
|
| 473 |
+
|
| 474 |
+
# Ensure featured image is first
|
| 475 |
+
if image and image not in image_urls:
|
| 476 |
+
image_urls.insert(0, image)
|
| 477 |
+
elif image in image_urls:
|
| 478 |
+
image_urls.remove(image)
|
| 479 |
+
image_urls.insert(0, image)
|
| 480 |
+
|
| 481 |
+
# Content paragraphs
|
| 482 |
+
block = _best_content_block(soup) or soup
|
| 483 |
+
paras, seen_p = [], set()
|
| 484 |
+
for p in block.find_all("p"):
|
| 485 |
+
t = _clean_text(p.get_text(" ", strip=True))
|
| 486 |
+
if len(t) >= 40 and t not in seen_p:
|
| 487 |
+
seen_p.add(t)
|
| 488 |
+
paras.append(t)
|
| 489 |
+
if len(paras) >= 35:
|
| 490 |
+
break
|
| 491 |
+
|
| 492 |
+
if not title and paras:
|
| 493 |
+
title = paras[0][:90]
|
| 494 |
|
|
|
|
|
|
|
| 495 |
return {
|
| 496 |
+
"url": url, "title": title or url, "summary": paras[0] if paras else "",
|
| 497 |
+
"text": "\n".join(paras), "image": image_urls[0] if image_urls else "",
|
| 498 |
+
"images": image_urls, "via": _domain(url)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 499 |
}
|
| 500 |
|
| 501 |
+
def scrape_any_url(url: str):
|
| 502 |
+
"""Try direct scrape first, fall back to Jina Reader."""
|
| 503 |
+
data = scrape_any_url_direct(url)
|
| 504 |
+
raw_text = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
|
| 505 |
+
if len(raw_text) >= 120:
|
| 506 |
+
return data
|
| 507 |
+
try:
|
| 508 |
+
md = jina_reader_markdown(url)
|
| 509 |
+
if md:
|
| 510 |
+
jr = _parse_jina_markdown(md, url)
|
| 511 |
+
if jr.get("text"):
|
| 512 |
+
if data.get("title") and data["title"] != url:
|
| 513 |
+
jr["title"] = data["title"]
|
| 514 |
+
if data.get("image"):
|
| 515 |
+
jr["image"] = data["image"]
|
| 516 |
+
if data.get("images"):
|
| 517 |
+
jr["images"] = data["images"]
|
| 518 |
+
jr["via"] = data.get("via", _domain(url)) + " + jina"
|
| 519 |
+
return jr
|
| 520 |
+
except Exception:
|
| 521 |
+
pass
|
| 522 |
+
return data
|
| 523 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 524 |
|
| 525 |
+
# ===== POLLINATIONS IMAGE =====
|
| 526 |
+
def pollinations_image_url(topic: str) -> str:
|
| 527 |
+
prompt = "editorial illustration, Vietnamese news, " + topic
|
| 528 |
+
return "https://image.pollinations.ai/prompt/" + quote(prompt, safe="") + "?width=1024&height=576&nologo=true"
|
| 529 |
|
| 530 |
+
|
| 531 |
+
@app.get("/api/ai/probe")
|
| 532 |
+
async def api_ai_probe():
|
| 533 |
+
"""Diagnostic: test which chat models actually work on this token + their latency."""
|
| 534 |
+
import time as _t
|
| 535 |
+
tok = _hf_token()
|
| 536 |
+
out = []
|
| 537 |
+
extra = os.getenv("PROBE_MODELS", "").split(",")
|
| 538 |
+
cand = [m.strip() for m in extra if m.strip()] + [
|
| 539 |
+
"Qwen/Qwen2.5-VL-7B-Instruct", "Qwen/Qwen2.5-VL-3B-Instruct", "Qwen/Qwen2-VL-7B-Instruct",
|
| 540 |
+
"Qwen/Qwen2.5-VL-72B-Instruct", "Qwen/Qwen3-8B", "Qwen/Qwen3-4B", "Qwen/Qwen3-32B",
|
| 541 |
+
"Qwen/Qwen2.5-7B-Instruct-1M", "meta-llama/Llama-3.2-3B-Instruct"]
|
| 542 |
+
seen = set()
|
| 543 |
+
for m in cand:
|
| 544 |
+
if not m or m in seen:
|
| 545 |
+
continue
|
| 546 |
+
seen.add(m)
|
| 547 |
+
t0 = _t.time()
|
| 548 |
+
try:
|
| 549 |
+
c = AsyncInferenceClient(provider="auto", api_key=tok, timeout=40)
|
| 550 |
+
r = await c.chat_completion(model=m, messages=[{"role": "user", "content": "Trả lời đúng 1 từ: xin chào"}], max_tokens=10)
|
| 551 |
+
out.append({"model": m, "ok": True, "sec": round(_t.time() - t0, 1), "txt": (r.choices[0].message.content or "")[:30]})
|
| 552 |
+
except Exception as e:
|
| 553 |
+
out.append({"model": m, "ok": False, "sec": round(_t.time() - t0, 1), "err": (type(e).__name__ + ": " + str(e))[-300:]})
|
| 554 |
+
return JSONResponse({"results": out})
|
| 555 |
+
|
| 556 |
+
|
| 557 |
+
# ===== QWEN AI (strict, concise) =====
|
| 558 |
+
async def qwen_generate(prompt: str, image_url: Optional[str] = None, max_tokens: int = 500, image_urls: Optional[List[str]] = None):
|
| 559 |
+
global LAST_QWEN_ERROR, HF_TOKEN
|
| 560 |
+
HF_TOKEN = _hf_token()
|
| 561 |
+
if not HF_TOKEN:
|
| 562 |
+
LAST_QWEN_ERROR = "Không tìm thấy token"
|
| 563 |
+
return None
|
| 564 |
+
if not AsyncInferenceClient:
|
| 565 |
+
LAST_QWEN_ERROR = "Thiếu huggingface_hub"
|
| 566 |
+
return None
|
| 567 |
+
errors = []; models = []
|
| 568 |
+
has_images = bool(image_urls) or bool(image_url)
|
| 569 |
+
if has_images:
|
| 570 |
+
# Vision needed -> VL models
|
| 571 |
+
candidate = [QWEN_VL_MODEL, "Qwen/Qwen2.5-VL-7B-Instruct", "Qwen/Qwen2.5-VL-3B-Instruct"]
|
| 572 |
+
else:
|
| 573 |
+
# Text-only summary -> FAST text models first, VL only as last resort.
|
| 574 |
+
candidate = QWEN_TEXT_MODELS + [QWEN_VL_MODEL]
|
| 575 |
+
# Use the last-known-working model first to avoid wasting time on unavailable models.
|
| 576 |
+
global _WORKING_MODEL_TEXT, _WORKING_MODEL_VL
|
| 577 |
+
cached_ok = _WORKING_MODEL_VL if has_images else _WORKING_MODEL_TEXT
|
| 578 |
+
if cached_ok and cached_ok in candidate:
|
| 579 |
+
candidate = [cached_ok] + [m for m in candidate if m != cached_ok]
|
| 580 |
+
for m in candidate:
|
| 581 |
+
if m and m not in models:
|
| 582 |
+
models.append(m)
|
| 583 |
+
for model in models:
|
| 584 |
+
try:
|
| 585 |
+
client = AsyncInferenceClient(provider="auto", api_key=HF_TOKEN, timeout=60)
|
| 586 |
+
content = []
|
| 587 |
+
# Collect all images: image_urls list takes priority, fall back to single image_url
|
| 588 |
+
all_img_urls = []
|
| 589 |
+
if image_urls:
|
| 590 |
+
all_img_urls = image_urls[:6] # max 6 images to avoid context overflow
|
| 591 |
+
elif image_url:
|
| 592 |
+
all_img_urls = [image_url]
|
| 593 |
+
for img_u in all_img_urls:
|
| 594 |
+
if img_u and img_u.startswith("http"):
|
| 595 |
+
content.append({"type": "image_url", "image_url": {"url": img_u}})
|
| 596 |
+
content.append({"type": "text", "text": prompt})
|
| 597 |
+
messages = [
|
| 598 |
+
{"role": "system", "content": (
|
| 599 |
+
"Bạn là biên tập viên báo điện tử tiếng Việt. "
|
| 600 |
+
"NHIỆM VỤ: Chỉ TÓM TẮT nội dung, KHÔNG viết lại bài đầy đủ. "
|
| 601 |
+
"QUY TẮC CỨNG: "
|
| 602 |
+
"(1) KHÔNG lặp lại bất kỳ nội dung nào — mỗi ý chỉ xuất hiện ĐÚNG 1 LẦN. "
|
| 603 |
+
"(2) Nếu 2 câu diễn đạt cùng 1 ý → bỏ cây thứ 2. "
|
| 604 |
+
"(3) KHÔNG dùng Markdown (##, **, ---, *). "
|
| 605 |
+
"(4) KHÔNG viết 'Dưới đây là', 'Tôi sẽ', 'Theo yêu cầu', 'Nhiệm vụ', 'Vai trò', 'Đây là bài tóm tắt'. "
|
| 606 |
+
"(5) KHÔNG bịa thông tin ngoài nguồn. "
|
| 607 |
+
"(6) Chỉ viết ĐOẠN VĂN THUẦN, không bullet points. "
|
| 608 |
+
"(7) Tối đa 200 từ. Ngắn gọn, súc tích."
|
| 609 |
+
)},
|
| 610 |
+
{"role": "user", "content": content}
|
| 611 |
+
]
|
| 612 |
+
resp = await client.chat_completion(model=model, messages=messages, max_tokens=max_tokens, temperature=0.3, top_p=0.8)
|
| 613 |
+
txt = (resp.choices[0].message.content or "").strip()
|
| 614 |
+
if txt:
|
| 615 |
+
LAST_QWEN_ERROR = ""
|
| 616 |
+
if has_images: _WORKING_MODEL_VL = model
|
| 617 |
+
else: _WORKING_MODEL_TEXT = model
|
| 618 |
+
return txt
|
| 619 |
+
except Exception as e:
|
| 620 |
+
errors.append(f"{model}: {type(e).__name__}: {str(e)[:220]}")
|
| 621 |
+
LAST_QWEN_ERROR = " | ".join(errors) or "Qwen không trả nội dung."
|
| 622 |
+
print("[qwen errors]", LAST_QWEN_ERROR)
|
| 623 |
+
return None
|
| 624 |
+
|
| 625 |
+
|
| 626 |
+
# ===== TTS GENERATION =====
|
| 627 |
+
async def _generate_tts_edge(text: str, voice_id: str, speed: float, out_path: str, emotion: str = None):
|
| 628 |
+
"""Generate TTS using edge-tts (or gTTS if engine=gtts) with voice, speed & emotion control.
|
| 629 |
+
|
| 630 |
+
Emotion (cảm xúc) is simulated via pitch + rate + volume (edge-tts has no express-as).
|
| 631 |
"""
|
| 632 |
+
vcfg = TTS_VOICES.get(voice_id, TTS_VOICES[TTS_DEFAULT_VOICE])
|
| 633 |
+
# gTTS engine (no voice/speed/emotion control)
|
| 634 |
+
if vcfg.get("engine") == "gtts":
|
| 635 |
+
_generate_tts_gtts(text, out_path)
|
| 636 |
+
return
|
| 637 |
+
if edge_tts is None:
|
| 638 |
+
raise RuntimeError("edge-tts chưa cài đặt")
|
| 639 |
+
voice = vcfg["id"]
|
| 640 |
+
emo = EMOTION_PRESETS.get(emotion or EMOTION_DEFAULT, EMOTION_PRESETS[EMOTION_DEFAULT])
|
| 641 |
+
# Apply emotion rate multiplier on top of base speed
|
| 642 |
+
eff_speed = speed * emo.get("rate_mul", 1.0)
|
| 643 |
+
pct = int(round((eff_speed - 1.0) * 100))
|
| 644 |
+
rate = f"+{pct}%" if pct >= 0 else f"{pct}%"
|
| 645 |
+
pitch = emo.get("pitch", "+0Hz")
|
| 646 |
+
volume = emo.get("volume", "+0%")
|
| 647 |
+
communicate = edge_tts.Communicate(text, voice, rate=rate, pitch=pitch, volume=volume)
|
| 648 |
+
await communicate.save(out_path)
|
| 649 |
+
|
| 650 |
+
def _generate_tts_gtts(text: str, out_path: str):
|
| 651 |
+
"""Fallback TTS using gTTS (no voice/speed control)."""
|
| 652 |
+
if gTTS is None:
|
| 653 |
+
raise RuntimeError("gTTS chưa cài đặt")
|
| 654 |
+
gTTS(text, lang="vi").save(out_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 655 |
|
| 656 |
|
| 657 |
+
# ===== SHORT VIDEO GENERATION (multi-segment: each key point with its own image) =====
|
| 658 |
+
def _download_image(url, fallback_topic, out_path):
|
| 659 |
+
"""Download an image (un-proxying our own /api/proxy/img). Falls back to generated image."""
|
| 660 |
+
if url:
|
| 661 |
+
u = url
|
| 662 |
+
m = re.search(r'/api/proxy/img\?url=(.+)$', u)
|
| 663 |
+
if m:
|
| 664 |
+
from urllib.parse import unquote
|
| 665 |
+
u = unquote(m.group(1))
|
| 666 |
+
try:
|
| 667 |
+
r = requests.get(u, headers={**HEADERS, "Referer": "https://dantri.com.vn/"}, timeout=15)
|
| 668 |
+
if r.status_code == 200 and len(r.content) > 1000:
|
| 669 |
+
with open(out_path, "wb") as f:
|
| 670 |
+
f.write(r.content)
|
| 671 |
+
if Image:
|
| 672 |
+
Image.open(out_path).verify()
|
| 673 |
+
return out_path
|
| 674 |
+
except Exception:
|
| 675 |
+
pass
|
| 676 |
+
gen = pollinations_image_url(fallback_topic)
|
| 677 |
try:
|
| 678 |
+
r = requests.get(gen, headers=HEADERS, timeout=25)
|
| 679 |
+
if r.status_code == 200 and len(r.content) > 1000:
|
| 680 |
+
with open(out_path, "wb") as f:
|
| 681 |
+
f.write(r.content)
|
| 682 |
+
return out_path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 683 |
except Exception:
|
| 684 |
pass
|
| 685 |
+
if Image:
|
| 686 |
+
Image.new("RGB", (1080, 980), (30, 55, 42)).save(out_path)
|
| 687 |
+
return out_path
|
| 688 |
+
raise RuntimeError("Không tạo được ảnh")
|
| 689 |
|
| 690 |
|
| 691 |
+
def _split_keypoint_sentences(text, max_points=6):
|
| 692 |
+
"""Split summary text into key points: prefer bullet markers, else sentences."""
|
| 693 |
+
text = _clean_text(text)
|
| 694 |
+
parts = re.split(r'\s*•\s*', text)
|
| 695 |
+
good = [p.strip() for p in parts if len(p.strip()) > 20]
|
| 696 |
+
if len(good) >= 2:
|
| 697 |
+
# Explicit bullet points: keep each one as-is (never merge).
|
| 698 |
+
return good[:max_points]
|
| 699 |
+
# Fallback: split into sentences and merge orphan short fragments.
|
| 700 |
+
pts = [p.strip() for p in re.split(r'(?<=[.!?])\s+', text) if len(p.strip()) > 20]
|
| 701 |
+
out = []
|
| 702 |
+
for p in pts:
|
| 703 |
+
if out and len(p) < 40:
|
| 704 |
+
out[-1] = (out[-1] + " " + p).strip()
|
| 705 |
+
else:
|
| 706 |
+
out.append(p)
|
| 707 |
+
return out[:max_points] if out else ([text] if text else [])
|
| 708 |
+
|
| 709 |
+
|
| 710 |
+
def _build_keypoints(post, max_points=6):
|
| 711 |
+
"""Return [{text, image}] pairing each key point with its own image."""
|
| 712 |
+
slides = post.get("slides") or []
|
| 713 |
+
images = post.get("images") or ([post.get("img")] if post.get("img") else [])
|
| 714 |
+
images = [i for i in images if i]
|
| 715 |
+
if slides:
|
| 716 |
+
kps = []
|
| 717 |
+
for i, s in enumerate(slides[:max_points]):
|
| 718 |
+
t = _clean_text(s.get("text", ""))
|
| 719 |
+
img = s.get("image") or (images[i] if i < len(images) else (images[-1] if images else ""))
|
| 720 |
+
if t:
|
| 721 |
+
kps.append({"text": t, "image": img})
|
| 722 |
+
if kps:
|
| 723 |
+
return kps
|
| 724 |
+
points = _split_keypoint_sentences(post.get("text", ""), max_points)
|
| 725 |
+
kps = []
|
| 726 |
+
for i, t in enumerate(points):
|
| 727 |
+
img = images[i] if i < len(images) else (images[-1] if images else "")
|
| 728 |
+
kps.append({"text": t, "image": img})
|
| 729 |
+
if not kps:
|
| 730 |
+
kps = [{"text": _clean_text(post.get("title", "")) or "VNEWS", "image": images[0] if images else ""}]
|
| 731 |
+
return kps
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
def _wrap_text(draw, text, font, max_w):
|
| 735 |
+
words = text.split()
|
| 736 |
+
lines, cur = [], ""
|
| 737 |
+
for w in words:
|
| 738 |
+
test = (cur + " " + w).strip()
|
| 739 |
+
if draw.textlength(test, font=font) <= max_w:
|
| 740 |
+
cur = test
|
| 741 |
+
else:
|
| 742 |
+
if cur:
|
| 743 |
+
lines.append(cur)
|
| 744 |
+
cur = w
|
| 745 |
+
if cur:
|
| 746 |
+
lines.append(cur)
|
| 747 |
+
return lines
|
| 748 |
+
|
| 749 |
+
|
| 750 |
+
def _make_segment_frame(title, point_text, img_path, idx, total, out_path):
|
| 751 |
+
"""Render a 1080x1920 vertical frame: image on top, key point text below."""
|
| 752 |
if Image is None:
|
| 753 |
+
raise RuntimeError("Pillow chưa sẵn sàng")
|
|
|
|
|
|
|
|
|
|
| 754 |
W, H = 1080, 1920
|
| 755 |
+
IMG_H = 980
|
| 756 |
+
bg = Image.new("RGB", (W, H), (12, 14, 18))
|
| 757 |
try:
|
| 758 |
im = Image.open(img_path).convert("RGB")
|
| 759 |
+
tr = W / IMG_H
|
| 760 |
+
ir = im.width / im.height
|
| 761 |
+
if ir > tr:
|
| 762 |
+
nh = IMG_H; nw = int(nh * ir)
|
|
|
|
|
|
|
|
|
|
| 763 |
else:
|
| 764 |
+
nw = W; nh = int(nw / ir)
|
| 765 |
+
im = im.resize((nw, nh))
|
| 766 |
+
left = (nw - W) // 2; top = (nh - IMG_H) // 2
|
| 767 |
+
im = im.crop((left, top, left + W, top + IMG_H))
|
|
|
|
|
|
|
|
|
|
| 768 |
bg.paste(im, (0, 0))
|
| 769 |
except Exception:
|
| 770 |
pass
|
|
|
|
| 771 |
draw = ImageDraw.Draw(bg)
|
| 772 |
+
draw.rectangle((0, IMG_H, W, H), fill=(12, 14, 18))
|
| 773 |
try:
|
| 774 |
+
f_label = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 34)
|
| 775 |
+
f_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 46)
|
| 776 |
+
f_point = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 52)
|
| 777 |
except Exception:
|
| 778 |
+
f_label = f_title = f_point = ImageFont.load_default()
|
| 779 |
+
draw.text((54, IMG_H + 24), "VNEWS · Tường AI", fill=(92, 184, 122), font=f_label)
|
| 780 |
+
cnt = f"{idx + 1}/{total}"
|
| 781 |
+
draw.text((W - 54 - draw.textlength(cnt, font=f_label), IMG_H + 24), cnt, fill=(240, 192, 64), font=f_label)
|
| 782 |
+
y = IMG_H + 86
|
| 783 |
+
for ln in _wrap_text(draw, _clean_text(title), f_title, W - 108)[:2]:
|
| 784 |
+
draw.text((54, y), ln, fill=(255, 255, 255), font=f_title)
|
| 785 |
+
y += 56
|
| 786 |
+
y += 16
|
| 787 |
+
for ln in _wrap_text(draw, _clean_text(point_text), f_point, W - 108)[:11]:
|
| 788 |
+
draw.text((54, y), ln, fill=(225, 230, 235), font=f_point)
|
| 789 |
+
y += 64
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 790 |
bg.save(out_path, quality=92)
|
| 791 |
+
return out_path
|
| 792 |
|
| 793 |
|
| 794 |
+
def _ffmpeg_bin():
|
| 795 |
+
return os.environ.get("FFMPEG_BIN", "ffmpeg")
|
| 796 |
+
|
| 797 |
+
|
| 798 |
+
def _audio_duration(path):
|
| 799 |
+
try:
|
| 800 |
+
out = subprocess.run([_ffmpeg_bin(), "-i", path], capture_output=True, text=True, timeout=30).stderr
|
| 801 |
+
m = re.search(r"Duration:\s*(\d+):(\d+):(\d+\.\d+)", out)
|
| 802 |
+
if m:
|
| 803 |
+
h, mi, s = m.groups()
|
| 804 |
+
return int(h) * 3600 + int(mi) * 60 + float(s)
|
| 805 |
+
except Exception:
|
| 806 |
+
pass
|
| 807 |
+
return 0.0
|
| 808 |
+
|
| 809 |
+
|
| 810 |
+
async def _generate_short_video(post, post_id: str, voice_id: str = None, speed: float = None, emotion: str = None) -> str:
|
| 811 |
+
"""Generate a multi-segment MP4 short: each key point shown with its OWN image + narration."""
|
| 812 |
+
try:
|
| 813 |
+
os.makedirs(SHORTS_DIR, exist_ok=True)
|
| 814 |
+
out_mp4 = os.path.join(SHORTS_DIR, _safe_name(post_id) + ".mp4")
|
| 815 |
+
if os.path.exists(out_mp4) and voice_id is None and speed is None and emotion is None:
|
| 816 |
+
return "/api/ai/short-file/" + post_id
|
| 817 |
+
|
| 818 |
+
work = os.path.join(SHORTS_DIR, _safe_name(post_id) + "_work")
|
| 819 |
+
os.makedirs(work, exist_ok=True)
|
| 820 |
+
|
| 821 |
+
title = _clean_text(post.get("title", "")) or "VNEWS"
|
| 822 |
+
kps = _build_keypoints(post)
|
| 823 |
+
|
| 824 |
+
# Resolve voice + emotion (auto from topic, or from post, or explicit args)
|
| 825 |
+
auto_voice, auto_emotion = _detect_voice_emotion(post.get("title", ""), post.get("text", ""))
|
| 826 |
+
if voice_id is None:
|
| 827 |
+
voice_id = post.get("voice") or auto_voice
|
| 828 |
+
if emotion is None:
|
| 829 |
+
emotion = post.get("emotion") or auto_emotion
|
| 830 |
+
if speed is None:
|
| 831 |
+
speed = TTS_DEFAULT_SPEED
|
| 832 |
+
vcfg = TTS_VOICES.get(voice_id, TTS_VOICES[TTS_DEFAULT_VOICE])
|
| 833 |
+
|
| 834 |
+
seg_files = []
|
| 835 |
+
ff = _ffmpeg_bin()
|
| 836 |
+
for i, kp in enumerate(kps):
|
| 837 |
+
img_path = os.path.join(work, f"img{i}.jpg")
|
| 838 |
+
frame_path = os.path.join(work, f"frame{i}.jpg")
|
| 839 |
+
audio_path = os.path.join(work, f"voice{i}.mp3")
|
| 840 |
+
seg_mp4 = os.path.join(work, f"seg{i}.mp4")
|
| 841 |
+
_download_image(kp.get("image", ""), title, img_path)
|
| 842 |
+
_make_segment_frame(title, kp["text"], img_path, i, len(kps), frame_path)
|
| 843 |
+
narration = (title + ". " + kp["text"]) if i == 0 else kp["text"]
|
| 844 |
+
try:
|
| 845 |
+
await _generate_tts_edge(narration, voice_id, speed, audio_path, emotion=emotion)
|
| 846 |
+
except Exception as e:
|
| 847 |
+
print(f"[TTS edge-tts error] {e}, falling back to gTTS")
|
| 848 |
+
if gTTS:
|
| 849 |
+
_generate_tts_gtts(narration, audio_path)
|
| 850 |
+
else:
|
| 851 |
+
return ""
|
| 852 |
+
dur = _audio_duration(audio_path)
|
| 853 |
+
if dur < 1.0:
|
| 854 |
+
dur = 2.0
|
| 855 |
+
cmd = [ff, "-y", "-loop", "1", "-i", frame_path, "-i", audio_path,
|
| 856 |
+
"-c:v", "libx264", "-tune", "stillimage", "-pix_fmt", "yuv420p",
|
| 857 |
+
"-t", f"{dur + 0.4:.2f}", "-c:a", "aac", "-b:a", "128k", "-ar", "44100",
|
| 858 |
+
"-vf", "scale=1080:1920", "-r", "25", seg_mp4]
|
| 859 |
+
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
|
| 860 |
+
seg_files.append(seg_mp4)
|
| 861 |
+
|
| 862 |
+
if not seg_files:
|
| 863 |
+
return ""
|
| 864 |
+
if len(seg_files) == 1:
|
| 865 |
+
os.replace(seg_files[0], out_mp4)
|
| 866 |
+
return "/api/ai/short-file/" + post_id
|
| 867 |
+
|
| 868 |
+
listfile = os.path.join(work, "concat.txt")
|
| 869 |
+
with open(listfile, "w", encoding="utf-8") as f:
|
| 870 |
+
f.write("\n".join(f"file '{s}'" for s in seg_files))
|
| 871 |
+
cmd = [ff, "-y", "-f", "concat", "-safe", "0", "-i", listfile,
|
| 872 |
+
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k", out_mp4]
|
| 873 |
+
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=300)
|
| 874 |
+
return "/api/ai/short-file/" + post_id
|
| 875 |
+
except Exception as e:
|
| 876 |
+
print(f"[short video error] {e}")
|
| 877 |
+
return ""
|
| 878 |
+
|
| 879 |
+
import threading as _threading
|
| 880 |
+
def _spawn_background_video(post):
|
| 881 |
+
"""Generate the short video in a BACKGROUND thread so the rewrite/topic endpoint
|
| 882 |
+
can return immediately. When done, persist the video URL onto the wall post.
|
| 883 |
+
This is the main fix for 'rewrite tu cac nguon tin qua lau' — the user gets the
|
| 884 |
+
text post instantly; the video appears shortly after (or via the 'Tao Video' button)."""
|
| 885 |
+
pid = post.get("id")
|
| 886 |
+
if not pid:
|
| 887 |
+
return
|
| 888 |
+
def _run():
|
| 889 |
try:
|
| 890 |
+
loop = asyncio.new_event_loop()
|
| 891 |
+
asyncio.set_event_loop(loop)
|
| 892 |
+
video_url = loop.run_until_complete(_generate_short_video(post, pid))
|
| 893 |
+
loop.close()
|
| 894 |
+
if video_url:
|
| 895 |
+
posts = _load_wall()
|
| 896 |
+
for i, p in enumerate(posts):
|
| 897 |
+
if str(p.get("id")) == str(pid):
|
| 898 |
+
posts[i]["video"] = video_url
|
| 899 |
+
break
|
| 900 |
+
_save_wall(posts)
|
| 901 |
+
print(f"[bg-video] done {pid} -> {video_url}")
|
| 902 |
+
except Exception as e:
|
| 903 |
+
print(f"[bg-video] error {pid}: {e}")
|
| 904 |
+
_threading.Thread(target=_run, daemon=True).start()
|
| 905 |
+
|
| 906 |
+
# ===== MAKE POST =====
|
| 907 |
+
def make_post(title, text, image, source_url, kind, sources=None, images=None, voice=None, emotion=None):
|
| 908 |
+
# Auto-pick voice + emotion from the article topic when not provided
|
| 909 |
+
auto_voice, auto_emotion = _detect_voice_emotion(title or "", text or "")
|
| 910 |
+
return {
|
| 911 |
+
"id": str(int(time.time() * 1000)) + str(random.randint(100, 999)),
|
| 912 |
+
"title": title, "text": text, "img": image, "url": source_url,
|
| 913 |
+
"kind": kind, "sources": sources or [], "video": "",
|
| 914 |
+
"images": images or [], "ts": int(time.time()),
|
| 915 |
+
"voice": voice or auto_voice,
|
| 916 |
+
"emotion": emotion or auto_emotion,
|
| 917 |
+
}
|
| 918 |
+
|
| 919 |
+
|
| 920 |
+
# ===== SHARED PROMPT BUILDER =====
|
| 921 |
+
def _build_rewrite_prompt(title: str, raw: str, images: List[str] = None) -> str:
|
| 922 |
+
image_info = ""
|
| 923 |
+
if images:
|
| 924 |
+
num = len(images)
|
| 925 |
+
if num == 1:
|
| 926 |
+
image_info = "\n\nBài viết có 1 ảnh minh họa. Hãy tham khảo ảnh để hiểu ngữ cảnh (nếu phù hợp)."
|
| 927 |
else:
|
| 928 |
+
image_info = f"\n\nBài viết có {num} ảnh minh họa. Hãy tham khảo tất cả ảnh để hiểu ngữ cảnh và bổ sung thông tin cho bài viết (nếu phù hợp)."
|
| 929 |
+
|
| 930 |
+
return f"""Tóm tắt bài viết sau thành bài TÓM TẮT đăng Tường AI.
|
| 931 |
+
|
| 932 |
+
QUY TẮC BẮT BUỘC:
|
| 933 |
+
1. Chỉ viết TÓM TẮT các ý chính. KHÔNG sao chép nguyên văn từ bài gốc.
|
| 934 |
+
2. KHÔNG lặp lại bất kỳ nội dung nào. Mỗi thông tin chỉ xuất hiện ĐÚNG 1 LẦN.
|
| 935 |
+
3. Nếu 2 câu nói cùng 1 ý → chỉ giữ 1 câu, bỏ cây còn lại.
|
| 936 |
+
4. KHÔNG dùng Markdown (##, **, ---, *).
|
| 937 |
+
5. KHÔNG viết "Dưới đây là", "Tôi sẽ", "Theo yêu cầu", "Nhiệm vụ", "Vai trò", "Đây là bài tóm tắt".
|
| 938 |
+
6. Viết thành ĐOẠN VĂN THUẦN, mạch lạc, dễ đọc. Không dùng bullet points.
|
| 939 |
+
7. Giữ sự thật, KHÔNG bịa thông tin.
|
| 940 |
+
8. Tối đa 200 từ. Ngắn gọn, đủ ý.{image_info}
|
| 941 |
+
|
| 942 |
+
Tiêu đề gốc: {title}
|
| 943 |
+
|
| 944 |
+
Nội dung gốc:
|
| 945 |
+
{raw[:14000]}"""
|
| 946 |
+
|
| 947 |
+
|
| 948 |
+
def _build_topic_prompt(topic: str, ctx: str) -> str:
|
| 949 |
+
return f"""Viết bài TÓM TẮT NGẮN GỌN về chủ đề: "{topic}".
|
| 950 |
+
|
| 951 |
+
QUY TẮC BẮT BUỘC:
|
| 952 |
+
1. Chỉ viết TÓM TẮT các ý chính từ nguồn. KHÔNG sao chép nguyên văn.
|
| 953 |
+
2. KHÔNG lặp lại bất kỳ nội dung nào. Mỗi thông tin chỉ xuất hiện ĐÚNG 1 LẦN.
|
| 954 |
+
3. Nếu 2 câu nói cùng 1 ý → chỉ giữ 1 câu.
|
| 955 |
+
4. KHÔNG dùng Markdown (##, **, ---, *).
|
| 956 |
+
5. KHÔNG viết "Dưới đây là", "Tôi sẽ", "Theo yêu cầu", "Nhiệm vụ", "Vai trò".
|
| 957 |
+
6. Viết thành ĐOẠN VĂN THU���N, mạch lạc. Không dùng bullet points.
|
| 958 |
+
7. Giữ sự thật, KHÔNG bịa.
|
| 959 |
+
8. Tối đa 200 từ. Ngắn gọn, đủ ý.
|
| 960 |
+
|
| 961 |
+
Nguồn thực tế:
|
| 962 |
+
{ctx[:12000]}"""
|
| 963 |
+
|
| 964 |
+
|
| 965 |
+
# ===== WRITE ENDPOINTS =====
|
| 966 |
+
@app.post("/api/rewrite_share")
|
| 967 |
+
async def api_rewrite_share(request: Request):
|
| 968 |
+
body = await request.json()
|
| 969 |
+
url = _clean_text(body.get("url", ""))
|
| 970 |
+
if not url.startswith("http"):
|
| 971 |
+
return JSONResponse({"error": "missing url"}, status_code=400)
|
| 972 |
+
try:
|
| 973 |
+
data = scrape_any_url(url)
|
| 974 |
+
except Exception as e:
|
| 975 |
+
return JSONResponse({"error": "Không đọc được bài viết: " + str(e)[:180]}, status_code=422)
|
| 976 |
+
raw = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
|
| 977 |
+
if len(raw) < 60:
|
| 978 |
+
return JSONResponse({"error": "Bài viết quá ngắn để tóm tắt"}, status_code=422)
|
| 979 |
+
|
| 980 |
+
images = data.get("images", [])
|
| 981 |
+
prompt = _build_rewrite_prompt(data.get("title", ""), raw, images)
|
| 982 |
+
# Text-only summary for SPEED (images are kept on the post for display + short video).
|
| 983 |
+
text = await qwen_generate(prompt, max_tokens=500)
|
| 984 |
+
if not text:
|
| 985 |
+
return JSONResponse({"error": "Qwen2.5-VL chưa sẵn sàng: " + LAST_QWEN_ERROR}, status_code=503)
|
| 986 |
+
text = _clean_ai_output(text)
|
| 987 |
+
post = make_post(data.get("title") or "Bài viết", text,
|
| 988 |
+
images[0] if images else data.get("image", ""),
|
| 989 |
+
url, "rewrite", images=images)
|
| 990 |
+
|
| 991 |
+
# Save post and return IMMEDIATELY; generate the short video in the background
|
| 992 |
+
# (so rewrite is fast). Video appears on the wall when ready / via 'Tao Video' button.
|
| 993 |
+
posts = _load_wall()
|
| 994 |
+
posts.insert(0, post)
|
| 995 |
+
_save_wall(posts)
|
| 996 |
+
_spawn_background_video(post)
|
| 997 |
+
return JSONResponse({"post": post})
|
| 998 |
+
|
| 999 |
+
|
| 1000 |
+
@app.post("/api/url_wall")
|
| 1001 |
+
async def api_url_wall(request: Request):
|
| 1002 |
+
body = await request.json()
|
| 1003 |
+
url = _clean_text(body.get("url", ""))
|
| 1004 |
+
if not url.startswith("http"):
|
| 1005 |
+
return JSONResponse({"error": "missing url"}, status_code=400)
|
| 1006 |
+
try:
|
| 1007 |
+
data = scrape_any_url(url)
|
| 1008 |
+
except Exception as e:
|
| 1009 |
+
return JSONResponse({"error": "Không scrape được URL: " + str(e)[:180]}, status_code=422)
|
| 1010 |
+
raw = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
|
| 1011 |
+
if len(raw) < 60:
|
| 1012 |
+
return JSONResponse({"error": "URL không có đủ nội dung"}, status_code=422)
|
| 1013 |
+
|
| 1014 |
+
images = data.get("images", [])
|
| 1015 |
+
prompt = _build_rewrite_prompt(data.get("title", ""), raw, images)
|
| 1016 |
+
# Text-only summary for SPEED (images are kept on the post for display + short video).
|
| 1017 |
+
text = await qwen_generate(prompt, max_tokens=500)
|
| 1018 |
+
if not text:
|
| 1019 |
+
return JSONResponse({"error": "Qwen2.5-VL chưa sẵn sàng: " + LAST_QWEN_ERROR}, status_code=503)
|
| 1020 |
+
text = _clean_ai_output(text)
|
| 1021 |
+
post = make_post(data.get("title") or "Bài viết", text,
|
| 1022 |
+
images[0] if images else data.get("image", ""),
|
| 1023 |
+
url, "url", images=images)
|
| 1024 |
+
|
| 1025 |
+
posts = _load_wall()
|
| 1026 |
+
posts.insert(0, post)
|
| 1027 |
+
_save_wall(posts)
|
| 1028 |
+
_spawn_background_video(post)
|
| 1029 |
+
return JSONResponse({"post": post})
|
| 1030 |
+
|
| 1031 |
+
|
| 1032 |
+
@app.post("/api/topic_post")
|
| 1033 |
+
async def api_topic_post(request: Request):
|
| 1034 |
+
body = await request.json()
|
| 1035 |
+
topic = _clean_text(body.get("topic", ""))
|
| 1036 |
+
if not topic:
|
| 1037 |
+
return JSONResponse({"error": "missing topic"}, status_code=400)
|
| 1038 |
+
|
| 1039 |
+
ctx = _web_context(topic)
|
| 1040 |
+
if not ctx:
|
| 1041 |
+
return JSONResponse({"error": "Không lấy được dữ liệu cho chủ đề này"}, status_code=422)
|
| 1042 |
+
|
| 1043 |
+
image = pollinations_image_url(topic)
|
| 1044 |
+
prompt = _build_topic_prompt(topic, ctx)
|
| 1045 |
+
# NOTE: do NOT pass the decorative pollinations image to the VL model — feeding an
|
| 1046 |
+
# image makes Qwen2.5-VL much slower (it must download+process it) with no benefit for
|
| 1047 |
+
# a text summary. We keep the image only for display on the post. This is a major
|
| 1048 |
+
# speed-up for 'rewrite tong hop'. (Text-only inference is several times faster.)
|
| 1049 |
+
text = await qwen_generate(prompt, max_tokens=500)
|
| 1050 |
+
if not text:
|
| 1051 |
+
return JSONResponse({"error": "Qwen2.5-VL chưa sẵn sàng: " + LAST_QWEN_ERROR}, status_code=503)
|
| 1052 |
+
text = _clean_ai_output(text)
|
| 1053 |
+
post = make_post(topic, text, image, "", "topic")
|
| 1054 |
+
|
| 1055 |
+
posts = _load_wall()
|
| 1056 |
+
posts.insert(0, post)
|
| 1057 |
+
_save_wall(posts)
|
| 1058 |
+
_spawn_background_video(post)
|
| 1059 |
+
return JSONResponse({"post": post})
|
| 1060 |
+
|
| 1061 |
+
|
| 1062 |
+
# ===== WALL ENDPOINTS =====
|
| 1063 |
+
@app.get("/api/ai_wall")
|
| 1064 |
+
def api_ai_wall():
|
| 1065 |
+
return JSONResponse({"posts": _load_wall()[:80]})
|
| 1066 |
+
|
| 1067 |
+
@app.get("/api/wall")
|
| 1068 |
+
def api_wall():
|
| 1069 |
+
return JSONResponse({"posts": _load_wall()[:80]})
|
| 1070 |
+
|
| 1071 |
+
|
| 1072 |
+
# ===== SHORT VIDEO ENDPOINT (with voice + speed params) =====
|
| 1073 |
+
@app.post("/api/ai/short/{post_id}")
|
| 1074 |
+
async def api_ai_short(post_id: str, voice: str = Query(default=None), speed: float = Query(default=None), emotion: str = Query(default=None)):
|
| 1075 |
+
"""Generate (or retrieve cached) short video for a wall post.
|
| 1076 |
+
|
| 1077 |
+
Query params:
|
| 1078 |
+
- voice: 'hoaimy' (female) | 'namminh' (male) | auto-detect if not specified
|
| 1079 |
+
- speed: float (default 1.2), e.g. 1.0=normal, 1.2=fast, 0.8=slow
|
| 1080 |
+
- emotion: 'vui'|'hao_hung'|'nghiem'|'tram'|'buon'|'trung_tinh' | auto by topic
|
| 1081 |
+
"""
|
| 1082 |
+
posts = _load_wall()
|
| 1083 |
+
post = next((p for p in posts if str(p.get("id")) == str(post_id)), None)
|
| 1084 |
+
if not post:
|
| 1085 |
+
return JSONResponse({"error": "post not found"}, status_code=404)
|
| 1086 |
+
|
| 1087 |
+
os.makedirs(SHORTS_DIR, exist_ok=True)
|
| 1088 |
+
out_mp4 = os.path.join(SHORTS_DIR, _safe_name(post_id) + ".mp4")
|
| 1089 |
+
|
| 1090 |
+
# If cached and no custom voice/speed/emotion requested, return cached
|
| 1091 |
+
if os.path.exists(out_mp4) and voice is None and speed is None and emotion is None:
|
| 1092 |
+
video_url = "/api/ai/short-file/" + post_id
|
| 1093 |
+
for i, p in enumerate(posts):
|
| 1094 |
+
if str(p.get("id")) == str(post_id):
|
| 1095 |
+
posts[i]["video"] = video_url
|
| 1096 |
+
break
|
| 1097 |
+
_save_wall(posts)
|
| 1098 |
+
return JSONResponse({"video": video_url})
|
| 1099 |
+
|
| 1100 |
+
# Validate params
|
| 1101 |
+
if voice is not None and voice not in TTS_VOICES:
|
| 1102 |
+
return JSONResponse({"error": f"voice không hợp lệ. Chọn: {list(TTS_VOICES.keys())}"}, status_code=400)
|
| 1103 |
+
if emotion is not None and emotion not in EMOTION_PRESETS:
|
| 1104 |
+
return JSONResponse({"error": f"emotion không hợp lệ. Chọn: {list(EMOTION_PRESETS.keys())}"}, status_code=400)
|
| 1105 |
+
|
| 1106 |
+
video_url = await _generate_short_video(post, post_id, voice_id=voice, speed=speed, emotion=emotion)
|
| 1107 |
+
if video_url:
|
| 1108 |
+
for i, p in enumerate(posts):
|
| 1109 |
+
if str(p.get("id")) == str(post_id):
|
| 1110 |
+
posts[i]["video"] = video_url
|
| 1111 |
+
if voice:
|
| 1112 |
+
posts[i]["voice"] = voice
|
| 1113 |
+
if emotion:
|
| 1114 |
+
posts[i]["emotion"] = emotion
|
| 1115 |
+
break
|
| 1116 |
+
_save_wall(posts)
|
| 1117 |
+
return JSONResponse({"video": video_url})
|
| 1118 |
+
return JSONResponse({"error": "Không tạo được shorts"}, status_code=500)
|
| 1119 |
+
|
| 1120 |
+
|
| 1121 |
+
@app.get("/api/ai/short-file/{post_id}")
|
| 1122 |
+
def api_ai_short_file(post_id: str):
|
| 1123 |
+
path = os.path.join(SHORTS_DIR, _safe_name(post_id) + ".mp4")
|
| 1124 |
+
if not os.path.exists(path):
|
| 1125 |
+
return JSONResponse({"error": "not found"}, status_code=404)
|
| 1126 |
+
return FileResponse(path, media_type="video/mp4", filename=f"vnews-ai-{post_id}.mp4")
|
| 1127 |
+
|
| 1128 |
+
|
| 1129 |
+
@app.get("/api/ai/status")
|
| 1130 |
+
def api_ai_status():
|
| 1131 |
+
return JSONResponse({
|
| 1132 |
+
"has_token": bool(_hf_token()),
|
| 1133 |
+
"client_imported": AsyncInferenceClient is not None,
|
| 1134 |
+
"model": QWEN_VL_MODEL,
|
| 1135 |
+
"last_error": LAST_QWEN_ERROR,
|
| 1136 |
+
"tts_ready": gTTS is not None or edge_tts is not None,
|
| 1137 |
+
"tts_engine": "edge-tts" if edge_tts else ("gtts" if gTTS else "none"),
|
| 1138 |
+
"tts_voices": {k: v["flag"] + " " + v["name"] for k, v in TTS_VOICES.items()},
|
| 1139 |
+
"tts_voice_count": len(TTS_VOICES),
|
| 1140 |
+
"tts_default_speed": TTS_DEFAULT_SPEED,
|
| 1141 |
+
})
|
| 1142 |
+
|
| 1143 |
+
|
| 1144 |
+
@app.get("/api/ai/voices")
|
| 1145 |
+
def api_ai_voices():
|
| 1146 |
+
"""Return available TTS voices with country/group info."""
|
| 1147 |
+
voices_out = {}
|
| 1148 |
+
for k, v in TTS_VOICES.items():
|
| 1149 |
+
voices_out[k] = {
|
| 1150 |
+
"name": v["name"],
|
| 1151 |
+
"gender": v["gender"],
|
| 1152 |
+
"country": v["country"],
|
| 1153 |
+
"lang": v["lang"],
|
| 1154 |
+
"flag": v["flag"],
|
| 1155 |
+
"label": f"{v['flag']} {v['name']} ({v['gender']})",
|
| 1156 |
+
}
|
| 1157 |
+
return JSONResponse({
|
| 1158 |
+
"voices": voices_out,
|
| 1159 |
+
"default_voice": TTS_DEFAULT_VOICE,
|
| 1160 |
+
"default_speed": TTS_DEFAULT_SPEED,
|
| 1161 |
+
"topic_voice_map": TOPIC_VOICE_MAP,
|
| 1162 |
+
"emotions": {k: {"label": v["label"], "emoji": v["emoji"]} for k, v in EMOTION_PRESETS.items()},
|
| 1163 |
+
"default_emotion": EMOTION_DEFAULT,
|
| 1164 |
+
})
|
ai_fix2.py
CHANGED
|
@@ -321,7 +321,7 @@ async def ai_short_full(post_id: str, request: Request):
|
|
| 321 |
subprocess.run(['ffmpeg','-y','-i',audio,'-filter:a',f'atempo={speed}','-vn',audio_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=220)
|
| 322 |
duration = 45.0
|
| 323 |
try:
|
| 324 |
-
pr = subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:
|
| 325 |
duration = float((pr.stdout or b'45').decode().strip() or 45)
|
| 326 |
except Exception:
|
| 327 |
pass
|
|
|
|
| 321 |
subprocess.run(['ffmpeg','-y','-i',audio,'-filter:a',f'atempo={speed}','-vn',audio_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=220)
|
| 322 |
duration = 45.0
|
| 323 |
try:
|
| 324 |
+
pr = subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:nokey=1',audio_fast], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
|
| 325 |
duration = float((pr.stdout or b'45').decode().strip() or 45)
|
| 326 |
except Exception:
|
| 327 |
pass
|
ai_patch.py
CHANGED
|
@@ -6,7 +6,6 @@ import json
|
|
| 6 |
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
|
|
@@ -42,17 +41,17 @@ def _similar(a, b):
|
|
| 42 |
return len(ta & tb) / max(1, min(len(ta), len(tb))) >= 0.72
|
| 43 |
|
| 44 |
|
| 45 |
-
def _dedupe_units(units, max_units=
|
| 46 |
-
"""Deduplicate units - only skip exact matches to ensure all bullet points are read."""
|
| 47 |
out, seen = [], set()
|
| 48 |
for u in units:
|
| 49 |
u = _clean(re.sub(r"^[-•*\d\.\)\s]+", "", u))
|
| 50 |
if len(u) < 18:
|
| 51 |
continue
|
| 52 |
nu = _norm(u)
|
| 53 |
-
# Only skip exact matches, NOT similar content (to avoid skipping valid bullet points)
|
| 54 |
if nu in seen:
|
| 55 |
continue
|
|
|
|
|
|
|
| 56 |
seen.add(nu)
|
| 57 |
out.append(u)
|
| 58 |
if len(out) >= max_units:
|
|
@@ -60,7 +59,7 @@ def _dedupe_units(units, max_units=25):
|
|
| 60 |
return out
|
| 61 |
|
| 62 |
|
| 63 |
-
def _postprocess_ai_text(text, max_units=
|
| 64 |
text = _clean(text)
|
| 65 |
if not text:
|
| 66 |
return text
|
|
@@ -79,9 +78,10 @@ def _postprocess_ai_text(text, max_units=20):
|
|
| 79 |
raw_lines.append(line)
|
| 80 |
units = []
|
| 81 |
for line in raw_lines:
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
|
|
|
| 85 |
units = _dedupe_units(units, max_units=max_units)
|
| 86 |
if not units:
|
| 87 |
return text[:900]
|
|
@@ -268,7 +268,7 @@ async def qwen_generate_resilient(prompt: str, image_url=None, max_tokens: int =
|
|
| 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=
|
| 272 |
|
| 273 |
|
| 274 |
if not hasattr(base, "_original_qwen_generate"):
|
|
@@ -321,8 +321,8 @@ Yêu cầu bắt buộ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=
|
| 325 |
-
text = _postprocess_ai_text(text, max_units=
|
| 326 |
src = [art['source']]
|
| 327 |
if 'Nguồn tham khảo:' not in text:
|
| 328 |
text += "\n\n" + _source_line(src)
|
|
@@ -347,8 +347,8 @@ async def compat_url_wall(request: Request):
|
|
| 347 |
if len(raw) < 120:
|
| 348 |
return JSONResponse({'error': 'URL không có đủ nội dung để tóm tắt'}, status_code=422)
|
| 349 |
prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
|
| 350 |
-
text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=
|
| 351 |
-
text = _postprocess_ai_text(text, max_units=
|
| 352 |
src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
|
| 353 |
if 'Nguồn tham khảo:' not in text:
|
| 354 |
text += "\n\n" + _source_line(src)
|
|
@@ -357,116 +357,6 @@ async def compat_url_wall(request: Request):
|
|
| 357 |
return JSONResponse({'post': post})
|
| 358 |
|
| 359 |
|
| 360 |
-
def _is_relevant_image(img_url, title, text):
|
| 361 |
-
"""Check if an image is relevant to the article content."""
|
| 362 |
-
if not img_url:
|
| 363 |
-
return False
|
| 364 |
-
skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif',
|
| 365 |
-
'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite',
|
| 366 |
-
'advertisement', 'ad-banner', 'sponsored', 'banner-ads']
|
| 367 |
-
img_lower = img_url.lower()
|
| 368 |
-
for p in skip_patterns:
|
| 369 |
-
if p in img_lower:
|
| 370 |
-
return False
|
| 371 |
-
if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']):
|
| 372 |
-
return False
|
| 373 |
-
return True
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
def _filter_relevant_images(images, title, text, max_images=8):
|
| 377 |
-
"""Filter and rank images by relevance to article content."""
|
| 378 |
-
if not images:
|
| 379 |
-
return []
|
| 380 |
-
seen = set()
|
| 381 |
-
relevant = []
|
| 382 |
-
for img in images:
|
| 383 |
-
if img in seen:
|
| 384 |
-
continue
|
| 385 |
-
seen.add(img)
|
| 386 |
-
if _is_relevant_image(img, title, text):
|
| 387 |
-
relevant.append(img)
|
| 388 |
-
return relevant[:max_images]
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
def _extract_key_points_for_slides(paragraphs, max_points=12):
|
| 392 |
-
"""Extract key points from paragraphs for slides - extracts ALL sentences, not just first one."""
|
| 393 |
-
points = []
|
| 394 |
-
for p in paragraphs:
|
| 395 |
-
if len(points) >= max_points:
|
| 396 |
-
break
|
| 397 |
-
p = _clean(p)
|
| 398 |
-
if not p:
|
| 399 |
-
continue
|
| 400 |
-
# Split paragraph into sentences using Vietnamese + English punctuation - GET ALL SENTENCES
|
| 401 |
-
sentences = re.split(r'(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])', p)
|
| 402 |
-
sentences = [s.strip() for s in sentences if s.strip()]
|
| 403 |
-
|
| 404 |
-
for sentence in sentences:
|
| 405 |
-
if len(points) >= max_points:
|
| 406 |
-
break
|
| 407 |
-
sentence = _clean(sentence)
|
| 408 |
-
if len(sentence) < 30:
|
| 409 |
-
continue
|
| 410 |
-
if any(sentence[:60] in existing for existing in points):
|
| 411 |
-
continue
|
| 412 |
-
if not sentence.endswith(('.', '!', '?')):
|
| 413 |
-
sentence = sentence + '.'
|
| 414 |
-
points.append(sentence)
|
| 415 |
-
return points
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
def _scrape_article_images(url):
|
| 419 |
-
"""Scrape article page and return only relevant images."""
|
| 420 |
-
try:
|
| 421 |
-
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
| 422 |
-
"Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"}
|
| 423 |
-
r = requests.get(url, headers=headers, timeout=15, allow_redirects=True)
|
| 424 |
-
r.encoding = 'utf-8'
|
| 425 |
-
soup = BeautifulSoup(r.text, 'lxml')
|
| 426 |
-
for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
|
| 427 |
-
tag.decompose()
|
| 428 |
-
h1 = soup.find('h1')
|
| 429 |
-
ogt = soup.find('meta', property='og:title')
|
| 430 |
-
title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '')
|
| 431 |
-
ogi = soup.find('meta', property='og:image')
|
| 432 |
-
og_img = ogi.get('content', '') if ogi else ''
|
| 433 |
-
if og_img and og_img.startswith('//'):
|
| 434 |
-
og_img = 'https:' + og_img
|
| 435 |
-
block = None
|
| 436 |
-
for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
|
| 437 |
-
el = soup.select_one(sel)
|
| 438 |
-
if el and len(el.find_all('p')) >= 2:
|
| 439 |
-
block = el
|
| 440 |
-
break
|
| 441 |
-
if not block:
|
| 442 |
-
block = soup.body or soup
|
| 443 |
-
paragraphs = []
|
| 444 |
-
all_images = []
|
| 445 |
-
seen_imgs = set()
|
| 446 |
-
if og_img and og_img not in seen_imgs:
|
| 447 |
-
all_images.append(og_img)
|
| 448 |
-
seen_imgs.add(og_img)
|
| 449 |
-
for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
|
| 450 |
-
if el.name == 'p':
|
| 451 |
-
t = _clean(el.get_text(strip=True))
|
| 452 |
-
if t and len(t) > 40:
|
| 453 |
-
paragraphs.append(t)
|
| 454 |
-
elif el.name in ('figure', 'img'):
|
| 455 |
-
im = el if el.name == 'img' else el.find('img')
|
| 456 |
-
if im:
|
| 457 |
-
src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
|
| 458 |
-
if src and 'base64' not in src:
|
| 459 |
-
if src.startswith('//'):
|
| 460 |
-
src = 'https:' + src
|
| 461 |
-
if src not in seen_imgs:
|
| 462 |
-
all_images.append(src)
|
| 463 |
-
seen_imgs.add(src)
|
| 464 |
-
relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5]))
|
| 465 |
-
return {'title': _clean(title), 'paragraphs': paragraphs, 'images': relevant_images, 'og_img': og_img}
|
| 466 |
-
except Exception:
|
| 467 |
-
return None
|
| 468 |
-
|
| 469 |
-
|
| 470 |
@app.post('/api/rewrite_share')
|
| 471 |
async def compat_rewrite_share(request: Request):
|
| 472 |
body = await request.json()
|
|
@@ -481,53 +371,40 @@ async def compat_rewrite_share(request: Request):
|
|
| 481 |
if len(raw) < 120:
|
| 482 |
return JSONResponse({'error': 'Bài viết không đủ nội dung để tóm tắt'}, status_code=422)
|
| 483 |
prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
|
| 484 |
-
text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=
|
| 485 |
-
text = _postprocess_ai_text(text, max_units=
|
| 486 |
src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
|
| 487 |
if 'Nguồn tham khảo:' not in text:
|
| 488 |
text += "\n\n" + _source_line(src)
|
| 489 |
post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'summary', sources=src)
|
| 490 |
posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
|
| 491 |
-
|
| 492 |
-
# Generate slides with relevant images only
|
| 493 |
-
slides = []
|
| 494 |
-
page_data = _scrape_article_images(url)
|
| 495 |
-
if page_data and page_data.get('paragraphs'):
|
| 496 |
-
key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12)
|
| 497 |
-
if key_points:
|
| 498 |
-
relevant_imgs = page_data.get('images', [])
|
| 499 |
-
if not relevant_imgs and page_data.get('og_img'):
|
| 500 |
-
relevant_imgs = [page_data['og_img']]
|
| 501 |
-
for i, point in enumerate(key_points):
|
| 502 |
-
img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
|
| 503 |
-
slides.append({'text': point, 'image': img, 'index': i + 1})
|
| 504 |
-
|
| 505 |
-
return JSONResponse({'post': post, 'slides': slides})
|
| 506 |
|
| 507 |
|
| 508 |
def _emotion_script(text, emotion):
|
| 509 |
-
"""Prepend emotion-appropriate prefix to text based on emotion type.
|
| 510 |
-
|
| 511 |
-
NOTE: Prefix is NOT added to avoid cluttering Short AI speech.
|
| 512 |
-
The emotion is still used for voice selection but content is read cleanly.
|
| 513 |
-
"""
|
| 514 |
text = _clean(text)
|
| 515 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 516 |
return text
|
| 517 |
|
| 518 |
|
| 519 |
def _tts_script_smart(post, emotion):
|
| 520 |
-
raw = base._short_script(post)
|
| 521 |
raw = re.sub(r"^[•\-\*]\s*", "", raw, flags=re.M)
|
| 522 |
raw = re.sub(r"\s*\n\s*", ". ", raw)
|
| 523 |
raw = re.sub(r"([\.\!\?])\s*", r"\1\n", raw)
|
| 524 |
raw = re.sub(r"\n{2,}", "\n", raw).strip()
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
raw = raw[:3000]
|
| 529 |
cut = max(raw.rfind("."), raw.rfind("!"), raw.rfind("?"))
|
| 530 |
-
if cut >
|
| 531 |
raw = raw[:cut + 1]
|
| 532 |
return raw
|
| 533 |
|
|
@@ -642,7 +519,7 @@ def _make_short_frame_full(post, img_path, out_path):
|
|
| 642 |
|
| 643 |
|
| 644 |
|
| 645 |
-
def _summary_segments_from_post(post, max_segments=
|
| 646 |
raw = _clean(post.get('text') or post.get('title') or '')
|
| 647 |
raw = re.sub(r'^Bản tin AI viết lại:\s*', '', raw, flags=re.I)
|
| 648 |
raw = re.sub(r'Nguồn tham khảo:.*$', '', raw, flags=re.I|re.S).strip()
|
|
@@ -653,7 +530,7 @@ def _summary_segments_from_post(post, max_segments=25):
|
|
| 653 |
low=ln.lower()
|
| 654 |
if low.startswith(('điểm chính','tiêu đề','sapo','nguồn tham khảo')): continue
|
| 655 |
if len(ln)>=18: lines.append(ln)
|
| 656 |
-
if len(lines)<
|
| 657 |
lines=[]
|
| 658 |
for s in re.split(r'(?<=[\.\!\?])\s+', raw):
|
| 659 |
s=_clean(s)
|
|
@@ -705,8 +582,7 @@ def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='ne
|
|
| 705 |
draw.rounded_rectangle((48,834,260,880), radius=20, fill=(28,70,45))
|
| 706 |
draw.text((66,842),f'Đoạn {idx+1}/{total}',fill=(235,235,235),font=font_small)
|
| 707 |
y=940; maxw=W-96
|
| 708 |
-
|
| 709 |
-
for ln in _wrap_text_px(draw, segment, font_seg, maxw, 18):
|
| 710 |
draw.text((48,y),ln,fill=(255,255,255),font=font_seg)
|
| 711 |
y+=74
|
| 712 |
if y>1500: break
|
|
@@ -718,11 +594,10 @@ def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='ne
|
|
| 718 |
bg.save(out_path, quality=92)
|
| 719 |
|
| 720 |
|
| 721 |
-
def _estimate_audio_duration(path, fallback=
|
| 722 |
-
"""Estimate audio duration with 15s minimum per segment for complete bullet reading."""
|
| 723 |
try:
|
| 724 |
-
pr=subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:
|
| 725 |
-
return max(
|
| 726 |
except Exception:
|
| 727 |
return fallback
|
| 728 |
|
|
@@ -735,7 +610,7 @@ async def patched_ai_short(post_id: str, request: Request):
|
|
| 735 |
body = {}
|
| 736 |
voice = str(body.get('voice', 'nu')).strip().lower()
|
| 737 |
emotion = str(body.get('emotion', 'neutral')).strip().lower()
|
| 738 |
-
speed = float(body.get('speed', 1.
|
| 739 |
speed = max(0.85, min(1.35, speed))
|
| 740 |
|
| 741 |
posts = base._load_ai_wall()
|
|
@@ -743,7 +618,7 @@ async def patched_ai_short(post_id: str, request: Request):
|
|
| 743 |
if not post:
|
| 744 |
return JSONResponse({'error': 'post not found'}, status_code=404)
|
| 745 |
|
| 746 |
-
segments = _summary_segments_from_post(post, max_segments=
|
| 747 |
seg_hash = hashlib.md5(('|'.join(segments)+voice+emotion+str(speed)).encode('utf-8')).hexdigest()[:8]
|
| 748 |
os.makedirs(base.SHORTS_DIR, exist_ok=True)
|
| 749 |
suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_{seg_hash}_scenes_nosub"
|
|
@@ -766,63 +641,11 @@ async def patched_ai_short(post_id: str, request: Request):
|
|
| 766 |
try:
|
| 767 |
base._download_image(post.get('img'), post.get('title', 'AI news'), img)
|
| 768 |
edge_voice = {
|
| 769 |
-
# Vietnamese
|
| 770 |
-
'vi-vn-hoaimyneural': 'vi-VN-HoaiMyNeural',
|
| 771 |
-
'vi-vn-namminhneural': 'vi-VN-NamMinhNeural',
|
| 772 |
-
'hoaimy': 'vi-VN-HoaiMyNeural',
|
| 773 |
-
'namminh': 'vi-VN-NamMinhNeural',
|
| 774 |
'nam': 'vi-VN-NamMinhNeural',
|
| 775 |
'male': 'vi-VN-NamMinhNeural',
|
| 776 |
'nu': 'vi-VN-HoaiMyNeural',
|
| 777 |
'female': 'vi-VN-HoaiMyNeural',
|
| 778 |
'mien-nam': 'vi-VN-HoaiMyNeural',
|
| 779 |
-
# English - Multilingual
|
| 780 |
-
'en-us-andrewmultilingualneural': 'en-US-AndrewMultilingualNeural',
|
| 781 |
-
'en-au-williammultilingualneural': 'en-AU-WilliamMultilingualNeural',
|
| 782 |
-
'andrew': 'en-US-AndrewMultilingualNeural',
|
| 783 |
-
'en_andrew': 'en-US-AndrewMultilingualNeural',
|
| 784 |
-
'jenny': 'en-US-AndrewMultilingualNeural',
|
| 785 |
-
'en_jenny': 'en-US-AndrewMultilingualNeural',
|
| 786 |
-
# Portuguese - Multilingual (ONLY Thalita)
|
| 787 |
-
'pt-br-thalitamultilingualneural': 'pt-BR-ThalitaMultilingualNeural',
|
| 788 |
-
'thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 789 |
-
'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 790 |
-
'pt_br_thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 791 |
-
'pt': 'pt-BR-ThalitaMultilingualNeural',
|
| 792 |
-
'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
|
| 793 |
-
# French - Multilingual
|
| 794 |
-
'fr-fr-viviennemultilingualneural': 'fr-FR-VivienneMultilingualNeural',
|
| 795 |
-
'fr-fr-remymultilingualneural': 'fr-FR-RemyMultilingualNeural',
|
| 796 |
-
'denise': 'fr-FR-VivienneMultilingualNeural',
|
| 797 |
-
'fr': 'fr-FR-VivienneMultilingualNeural',
|
| 798 |
-
'fr_denise': 'fr-FR-VivienneMultilingualNeural',
|
| 799 |
-
# German - Multilingual
|
| 800 |
-
'de-de-seraphinamultilingualneural': 'de-DE-SeraphinaMultilingualNeural',
|
| 801 |
-
'de-de-florianmultilingualneural': 'de-DE-FlorianMultilingualNeural',
|
| 802 |
-
'katja': 'de-DE-SeraphinaMultilingualNeural',
|
| 803 |
-
'de': 'de-DE-SeraphinaMultilingualNeural',
|
| 804 |
-
'de_katja': 'de-DE-SeraphinaMultilingualNeural',
|
| 805 |
-
# Korean - Multilingual (Hyunsu, NOT SunHee)
|
| 806 |
-
'ko-kr-hyusumultilingualneural': 'ko-KR-HyunsuMultilingualNeural',
|
| 807 |
-
'ko-kr-hyunsuneural': 'ko-KR-HyunsuMultilingualNeural',
|
| 808 |
-
'sunhee': 'ko-KR-HyunsuMultilingualNeural',
|
| 809 |
-
'ko': 'ko-KR-HyunsuMultilingualNeural',
|
| 810 |
-
'ko_sunhee': 'ko-KR-HyunsuMultilingualNeural',
|
| 811 |
-
# Italian - Multilingual
|
| 812 |
-
'it-it-giuseppemultilingualneural': 'it-IT-GiuseppeMultilingualNeural',
|
| 813 |
-
# Spanish (keep for backward compat)
|
| 814 |
-
'ela': 'en-US-AndrewMultilingualNeural',
|
| 815 |
-
'es_ela': 'en-US-AndrewMultilingualNeural',
|
| 816 |
-
'es': 'en-US-AndrewMultilingualNeural',
|
| 817 |
-
'es_carlos': 'en-US-AndrewMultilingualNeural',
|
| 818 |
-
# Japanese (keep for backward compat)
|
| 819 |
-
'nanami': 'en-US-AndrewMultilingualNeural',
|
| 820 |
-
'ja': 'en-US-AndrewMultilingualNeural',
|
| 821 |
-
'ja_nanami': 'en-US-AndrewMultilingualNeural',
|
| 822 |
-
# Chinese (keep for backward compat)
|
| 823 |
-
'xiaochen': 'en-US-AndrewMultilingualNeural',
|
| 824 |
-
'zh': 'en-US-AndrewMultilingualNeural',
|
| 825 |
-
'zh_xiaochen': 'en-US-AndrewMultilingualNeural',
|
| 826 |
}.get(voice, 'vi-VN-HoaiMyNeural')
|
| 827 |
part_files=[]
|
| 828 |
for idx, seg in enumerate(segments):
|
|
@@ -835,13 +658,13 @@ async def patched_ai_short(post_id: str, request: Request):
|
|
| 835 |
try:
|
| 836 |
subprocess.run(['python','-m','edge_tts','--voice',edge_voice,'--text',spoken,'--write-media',aud], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
| 837 |
except Exception:
|
| 838 |
-
tld='com.vn' if voice in ('nu','female','mien-nam'
|
| 839 |
try:
|
| 840 |
base.gTTS(spoken, lang='vi', tld=tld, slow=False).save(aud)
|
| 841 |
except TypeError:
|
| 842 |
base.gTTS(spoken, lang='vi', slow=False).save(aud)
|
| 843 |
subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
|
| 844 |
-
dur=_estimate_audio_duration(aud_fast, fallback=
|
| 845 |
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)
|
| 846 |
part_files.append(part)
|
| 847 |
concat=os.path.join(work,'concat.txt')
|
|
@@ -876,3 +699,53 @@ def api_ai_shorts():
|
|
| 876 |
|
| 877 |
|
| 878 |
app.router.routes = [r for r in app.router.routes if not (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set()))]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
import html as html_lib
|
| 7 |
import subprocess
|
| 8 |
import requests
|
|
|
|
| 9 |
import ai_ext as base
|
| 10 |
from ai_ext import app
|
| 11 |
from fastapi import Request
|
|
|
|
| 41 |
return len(ta & tb) / max(1, min(len(ta), len(tb))) >= 0.72
|
| 42 |
|
| 43 |
|
| 44 |
+
def _dedupe_units(units, max_units=7):
|
|
|
|
| 45 |
out, seen = [], set()
|
| 46 |
for u in units:
|
| 47 |
u = _clean(re.sub(r"^[-•*\d\.\)\s]+", "", u))
|
| 48 |
if len(u) < 18:
|
| 49 |
continue
|
| 50 |
nu = _norm(u)
|
|
|
|
| 51 |
if nu in seen:
|
| 52 |
continue
|
| 53 |
+
if any(_similar(u, old) for old in out):
|
| 54 |
+
continue
|
| 55 |
seen.add(nu)
|
| 56 |
out.append(u)
|
| 57 |
if len(out) >= max_units:
|
|
|
|
| 59 |
return out
|
| 60 |
|
| 61 |
|
| 62 |
+
def _postprocess_ai_text(text, max_units=7):
|
| 63 |
text = _clean(text)
|
| 64 |
if not text:
|
| 65 |
return text
|
|
|
|
| 78 |
raw_lines.append(line)
|
| 79 |
units = []
|
| 80 |
for line in raw_lines:
|
| 81 |
+
if len(line) > 260:
|
| 82 |
+
units.extend(re.split(r"(?<=[\.\!\?])\s+(?=[A-ZÀ-Ỹ0-9])", line))
|
| 83 |
+
else:
|
| 84 |
+
units.append(line)
|
| 85 |
units = _dedupe_units(units, max_units=max_units)
|
| 86 |
if not units:
|
| 87 |
return text[:900]
|
|
|
|
| 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=6)
|
| 272 |
|
| 273 |
|
| 274 |
if not hasattr(base, "_original_qwen_generate"):
|
|
|
|
| 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=900)
|
| 325 |
+
text = _postprocess_ai_text(text, max_units=6)
|
| 326 |
src = [art['source']]
|
| 327 |
if 'Nguồn tham khảo:' not in text:
|
| 328 |
text += "\n\n" + _source_line(src)
|
|
|
|
| 347 |
if len(raw) < 120:
|
| 348 |
return JSONResponse({'error': 'URL không có đủ nội dung để tóm tắt'}, status_code=422)
|
| 349 |
prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
|
| 350 |
+
text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=850)
|
| 351 |
+
text = _postprocess_ai_text(text, max_units=6)
|
| 352 |
src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
|
| 353 |
if 'Nguồn tham khảo:' not in text:
|
| 354 |
text += "\n\n" + _source_line(src)
|
|
|
|
| 357 |
return JSONResponse({'post': post})
|
| 358 |
|
| 359 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
@app.post('/api/rewrite_share')
|
| 361 |
async def compat_rewrite_share(request: Request):
|
| 362 |
body = await request.json()
|
|
|
|
| 371 |
if len(raw) < 120:
|
| 372 |
return JSONResponse({'error': 'Bài viết không đủ nội dung để tóm tắt'}, status_code=422)
|
| 373 |
prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
|
| 374 |
+
text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=850)
|
| 375 |
+
text = _postprocess_ai_text(text, max_units=6)
|
| 376 |
src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
|
| 377 |
if 'Nguồn tham khảo:' not in text:
|
| 378 |
text += "\n\n" + _source_line(src)
|
| 379 |
post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'summary', sources=src)
|
| 380 |
posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
|
| 381 |
+
return JSONResponse({'post': post})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
|
| 383 |
|
| 384 |
def _emotion_script(text, emotion):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
text = _clean(text)
|
| 386 |
+
if emotion == 'urgent':
|
| 387 |
+
return 'Tin nhanh. ' + text
|
| 388 |
+
if emotion == 'warm':
|
| 389 |
+
return 'Câu chuyện đáng chú ý. ' + text
|
| 390 |
+
if emotion == 'serious':
|
| 391 |
+
return 'Bản tin nghiêm túc. ' + text
|
| 392 |
+
if emotion == 'energetic':
|
| 393 |
+
return 'Cập nhật nổi bật. ' + text
|
| 394 |
return text
|
| 395 |
|
| 396 |
|
| 397 |
def _tts_script_smart(post, emotion):
|
| 398 |
+
raw = base._short_script(post)
|
| 399 |
raw = re.sub(r"^[•\-\*]\s*", "", raw, flags=re.M)
|
| 400 |
raw = re.sub(r"\s*\n\s*", ". ", raw)
|
| 401 |
raw = re.sub(r"([\.\!\?])\s*", r"\1\n", raw)
|
| 402 |
raw = re.sub(r"\n{2,}", "\n", raw).strip()
|
| 403 |
+
raw = _emotion_script(raw, emotion)
|
| 404 |
+
if len(raw) > 1000:
|
| 405 |
+
raw = raw[:1000]
|
|
|
|
| 406 |
cut = max(raw.rfind("."), raw.rfind("!"), raw.rfind("?"))
|
| 407 |
+
if cut > 350:
|
| 408 |
raw = raw[:cut + 1]
|
| 409 |
return raw
|
| 410 |
|
|
|
|
| 519 |
|
| 520 |
|
| 521 |
|
| 522 |
+
def _summary_segments_from_post(post, max_segments=7):
|
| 523 |
raw = _clean(post.get('text') or post.get('title') or '')
|
| 524 |
raw = re.sub(r'^Bản tin AI viết lại:\s*', '', raw, flags=re.I)
|
| 525 |
raw = re.sub(r'Nguồn tham khảo:.*$', '', raw, flags=re.I|re.S).strip()
|
|
|
|
| 530 |
low=ln.lower()
|
| 531 |
if low.startswith(('điểm chính','tiêu đề','sapo','nguồn tham khảo')): continue
|
| 532 |
if len(ln)>=18: lines.append(ln)
|
| 533 |
+
if len(lines)<2:
|
| 534 |
lines=[]
|
| 535 |
for s in re.split(r'(?<=[\.\!\?])\s+', raw):
|
| 536 |
s=_clean(s)
|
|
|
|
| 582 |
draw.rounded_rectangle((48,834,260,880), radius=20, fill=(28,70,45))
|
| 583 |
draw.text((66,842),f'Đoạn {idx+1}/{total}',fill=(235,235,235),font=font_small)
|
| 584 |
y=940; maxw=W-96
|
| 585 |
+
for ln in _wrap_text_px(draw, segment, font_seg, maxw, 8):
|
|
|
|
| 586 |
draw.text((48,y),ln,fill=(255,255,255),font=font_seg)
|
| 587 |
y+=74
|
| 588 |
if y>1500: break
|
|
|
|
| 594 |
bg.save(out_path, quality=92)
|
| 595 |
|
| 596 |
|
| 597 |
+
def _estimate_audio_duration(path, fallback=4.0):
|
|
|
|
| 598 |
try:
|
| 599 |
+
pr=subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:nokey=1',path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
|
| 600 |
+
return max(1.5, float((pr.stdout or b'').decode().strip() or fallback))
|
| 601 |
except Exception:
|
| 602 |
return fallback
|
| 603 |
|
|
|
|
| 610 |
body = {}
|
| 611 |
voice = str(body.get('voice', 'nu')).strip().lower()
|
| 612 |
emotion = str(body.get('emotion', 'neutral')).strip().lower()
|
| 613 |
+
speed = float(body.get('speed', 1.2) or 1.2)
|
| 614 |
speed = max(0.85, min(1.35, speed))
|
| 615 |
|
| 616 |
posts = base._load_ai_wall()
|
|
|
|
| 618 |
if not post:
|
| 619 |
return JSONResponse({'error': 'post not found'}, status_code=404)
|
| 620 |
|
| 621 |
+
segments = _summary_segments_from_post(post, max_segments=7)
|
| 622 |
seg_hash = hashlib.md5(('|'.join(segments)+voice+emotion+str(speed)).encode('utf-8')).hexdigest()[:8]
|
| 623 |
os.makedirs(base.SHORTS_DIR, exist_ok=True)
|
| 624 |
suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_{seg_hash}_scenes_nosub"
|
|
|
|
| 641 |
try:
|
| 642 |
base._download_image(post.get('img'), post.get('title', 'AI news'), img)
|
| 643 |
edge_voice = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 644 |
'nam': 'vi-VN-NamMinhNeural',
|
| 645 |
'male': 'vi-VN-NamMinhNeural',
|
| 646 |
'nu': 'vi-VN-HoaiMyNeural',
|
| 647 |
'female': 'vi-VN-HoaiMyNeural',
|
| 648 |
'mien-nam': 'vi-VN-HoaiMyNeural',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 649 |
}.get(voice, 'vi-VN-HoaiMyNeural')
|
| 650 |
part_files=[]
|
| 651 |
for idx, seg in enumerate(segments):
|
|
|
|
| 658 |
try:
|
| 659 |
subprocess.run(['python','-m','edge_tts','--voice',edge_voice,'--text',spoken,'--write-media',aud], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
| 660 |
except Exception:
|
| 661 |
+
tld='com.vn' if voice in ('nu','female','mien-nam') else 'com'
|
| 662 |
try:
|
| 663 |
base.gTTS(spoken, lang='vi', tld=tld, slow=False).save(aud)
|
| 664 |
except TypeError:
|
| 665 |
base.gTTS(spoken, lang='vi', slow=False).save(aud)
|
| 666 |
subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
|
| 667 |
+
dur=_estimate_audio_duration(aud_fast, fallback=4.0)+0.35
|
| 668 |
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)
|
| 669 |
part_files.append(part)
|
| 670 |
concat=os.path.join(work,'concat.txt')
|
|
|
|
| 699 |
|
| 700 |
|
| 701 |
app.router.routes = [r for r in app.router.routes if not (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set()))]
|
| 702 |
+
|
| 703 |
+
PATCH_INJECT = r'''
|
| 704 |
+
<style>
|
| 705 |
+
.ai-wall-patched{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}
|
| 706 |
+
.ai-wall-card{flex:0 0 250px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:8px}
|
| 707 |
+
.ai-wall-img{width:100%;aspect-ratio:16/9;background:#222;border-radius:8px;overflow:hidden;margin-bottom:6px}
|
| 708 |
+
.ai-wall-img img{width:100%;height:100%;object-fit:cover}
|
| 709 |
+
.ai-wall-title{font-size:12px;color:#5cb87a;font-weight:800;line-height:1.3;margin-bottom:4px}
|
| 710 |
+
.ai-wall-text{font-size:11px;color:#bbb;line-height:1.45;white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden}
|
| 711 |
+
.ai-wall-actions{display:flex;gap:6px;margin-top:8px}
|
| 712 |
+
.ai-wall-actions button,.ai-wall-actions select{flex:1;border:1px solid #333;background:#222;color:#ddd;border-radius:14px;padding:6px 8px;font-size:10px;min-width:0}
|
| 713 |
+
.ai-wall-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}
|
| 714 |
+
.ai-short-card{flex:0 0 145px}
|
| 715 |
+
.ai-short-video{width:100%;aspect-ratio:9/16;background:#000;border-radius:8px;overflow:hidden}
|
| 716 |
+
.ai-short-video video{width:100%;height:100%;object-fit:cover}
|
| 717 |
+
.ai-short-progress{position:fixed;inset:0;background:rgba(0,0,0,.78);z-index:99999;display:none;align-items:center;justify-content:center;padding:20px}
|
| 718 |
+
.ai-short-progress.active{display:flex}
|
| 719 |
+
.ai-short-box{max-width:420px;width:100%;background:#141414;border:2px solid #2d8659;border-radius:14px;padding:18px;color:#eee;box-shadow:0 0 30px rgba(45,134,89,.35)}
|
| 720 |
+
.ai-short-box h3{color:#5cb87a;margin-bottom:10px}
|
| 721 |
+
.ai-short-step{font-size:13px;line-height:1.55;color:#ccc}
|
| 722 |
+
.ai-short-spinner{width:34px;height:34px;border:4px solid #333;border-top-color:#5cb87a;border-radius:50%;animation:spin 1s linear infinite;margin:10px auto}
|
| 723 |
+
@keyframes spin{to{transform:rotate(360deg)}}
|
| 724 |
+
</style>
|
| 725 |
+
<div id="ai-short-progress" class="ai-short-progress"><div class="ai-short-box"><h3>🎬 Đang tạo Short AI</h3><div class="ai-short-spinner"></div><div class="ai-short-step" id="ai-short-step">Đang chuẩn bị...</div></div></div>
|
| 726 |
+
<script>
|
| 727 |
+
(function(){
|
| 728 |
+
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}
|
| 729 |
+
let patchedWall=[];let aiShorts=[];
|
| 730 |
+
function showProgress(msg){let box=document.getElementById('ai-short-progress');let st=document.getElementById('ai-short-step');if(st)st.innerHTML=msg;if(box)box.classList.add('active');}
|
| 731 |
+
function hideProgress(){document.getElementById('ai-short-progress')?.classList.remove('active');}
|
| 732 |
+
function updateAiLabels(){document.querySelectorAll('.ai-compose-title').forEach(e=>e.textContent='🤖 Tường AI: lọc từng bài theo chủ đề, tóm tắt nội dung bài');document.querySelectorAll('button').forEach(b=>{if((b.textContent||'').includes('AI viết lại'))b.textContent='🤖 Tóm tắt AI & đăng tường';});}
|
| 733 |
+
async function loadPatchedWall(){try{const r=await fetch('/api/ai_wall');const j=await r.json();patchedWall=j.posts||[];renderPatchedWall();updateAiLabels();}catch(e){}try{const r2=await fetch('/api/ai_shorts');const j2=await r2.json();aiShorts=j2.posts||[];renderAiShorts();}catch(e){}}
|
| 734 |
+
function renderAiShorts(){const home=document.getElementById('view-home');if(!home)return;document.getElementById('ai-shorts-patched')?.remove();if(!aiShorts.length)return;let wrap=document.createElement('div');wrap.id='ai-shorts-patched';wrap.className='ai-wall-patched';let h='<div class="slider-header"><span class="slider-label">🎬 Short AI</span><span class="slider-note">Video đã tạo</span></div><div class="slider-track">';aiShorts.slice(0,30).forEach((p,i)=>{h+=`<div class="ai-short-card" onclick="aiReadShortPatched(${i})"><div class="ai-short-video"><video src="${p.video}" muted playsinline preload="metadata"></video></div><div class="slider-title">${esc(p.title)}</div></div>`});h+='</div>';wrap.innerHTML=h;let wall=document.getElementById('ai-wall-patched');if(wall)wall.after(wrap);else home.prepend(wrap);}
|
| 735 |
+
function renderPatchedWall(){const home=document.getElementById('view-home');if(!home)return;document.getElementById('ai-wall-patched')?.remove();if(!patchedWall.length)return;let wrap=document.createElement('div');wrap.id='ai-wall-patched';wrap.className='ai-wall-patched';let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI</span><span class="slider-note">Mỗi nguồn = một bài tóm tắt</span></div><div class="slider-track">';patchedWall.slice(0,30).forEach((p,i)=>{h+=`<div class="ai-wall-card"><div class="ai-wall-img">${p.img?`<img src="${p.img}">`:''}</div><div class="ai-wall-title">${esc(p.title)}</div><div class="ai-wall-text">${esc(p.text)}</div><div class="ai-wall-actions"><button onclick="aiReadWallPatched(${i})">Xem</button><button class="primary" onclick="aiMakeShortPatched(${i})">Shorts</button></div></div>`});h+='</div>';wrap.innerHTML=h;let after=document.querySelector('.ai-compose');if(after)after.after(wrap);else home.prepend(wrap);}
|
| 736 |
+
window.aiReadShortPatched=function(i){const p=aiShorts[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">Short AI</span><h1 class="article-title">${esc(p.title)}</h1><video class="article-img" src="${p.video}" controls playsinline autoplay></video><p class="article-p" style="white-space:pre-wrap">${esc(p.text||'')}</p><div class="article-actions"><button onclick="window.open('${p.video}','_blank')">⬇ Mở video</button>${p.url?`<button onclick="window.open('${p.url}','_blank')">🔗 Nguồn</button>`:''}</div></div>`;document.getElementById('view-article').innerHTML=h;window.scrollTo(0,0)};
|
| 737 |
+
window.aiReadWallPatched=function(i){const p=patchedWall[i];if(!p)return;showView('view-article');let sources='';if(p.sources&&p.sources.length){sources='<div class="article-summary"><b>Nguồn tham khảo:</b><br>'+p.sources.slice(0,5).map(s=>`• ${esc(s.title||s.url||'Nguồn')} ${s.url?`(${esc(new URL(s.url).hostname.replace('www.',''))})`:''}`).join('<br>')+'</div>'}let voiceBox=`<div class="article-actions"><select id="ai-short-voice"><option value="nu">Giọng nữ Việt</option><option value="nam">Giọng nam Việt</option><option value="mien-nam">Giọng miền Nam</option></select><select id="ai-short-emotion"><option value="neutral">Trung tính</option><option value="urgent">Tin nhanh</option><option value="warm">Ấm áp</option><option value="serious">Nghiêm túc</option><option value="energetic">Sôi nổi</option></select><button onclick="aiMakeShortPatched(${i})">🎬 Tạo video shorts</button></div>`;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}">`:''}${sources}<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>${p.video?`<video class="article-img" src="${p.video}" controls playsinline></video>`:''}<div class="article-actions">${p.url?`<button onclick="window.open('${p.url}','_blank')">🔗 Nguồn</button>`:''}</div>${voiceBox}</div>`;document.getElementById('view-article').innerHTML=h;window.scrollTo(0,0)};
|
| 738 |
+
window.aiMakeShortPatched=async function(i){const p=patchedWall[i];if(!p)return;let voice=document.getElementById('ai-short-voice')?.value||'nu';let emotion=document.getElementById('ai-short-emotion')?.value||'neutral';let voiceName={nu:'Giọng nữ Việt',nam:'Giọng nam Việt','mien-nam':'Giọng miền Nam'}[voice]||voice;let emotionName={neutral:'Trung tính',urgent:'Tin nhanh',warm:'Ấm áp',serious:'Nghiêm túc',energetic:'Sôi nổi'}[emotion]||emotion;let ok=confirm(`Quy trình tạo short AI:\n\n1) Dùng ảnh đại diện của bài hoặc tạo ảnh minh họa nếu thiếu.\n2) Rút gọn nội dung tóm tắt thành kịch bản đọc ngắn.\n3) Tự ngắt câu theo dấu câu và xuống dòng hợp lý.\n4) Tạo giọng đọc tiếng Việt: ${voiceName}.\n5) Áp dụng cảm xúc/kịch bản: ${emotionName}.\n6) Tăng tốc giọng đọc 1.2 lần.\n7) Mỗi đoạn tóm tắt sẽ là một cảnh riêng theo thời lượng đọc.\n8) Không thêm phụ đề; video chỉ có chữ cảnh và giọng đọc.\n9) Sau khi xong, video xuất hiện ở slide "Short AI".\n\nQuá trình có thể mất 1-3 phút. Bạn muốn bắt đầu?`);if(!ok)return;try{showProgress(`Bước 1/5: Chuẩn bị ảnh và căn chữ full width...<br>Bước 2/5: Tạo kịch bản, tự ngắt câu/xuống dòng...<br>Bước 3/5: Tạo giọng đọc ${voiceName}, cảm xúc ${emotionName}.<br>Bước 4/5: Tăng tốc 1.2x và ghép từng cảnh riêng, không phụ đề.<br>Bước 5/5: Lưu vào slide "Short AI".`);const r=await fetch('/api/ai/short/'+p.id,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice,emotion,speed:1.2})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');p.video=j.video;hideProgress();alert('Hoàn tất: video shorts đã được tạo và thêm vào slide "Short AI".');aiReadWallPatched(i);loadPatchedWall();}catch(e){hideProgress();alert('Không tạo được shorts: '+e.message)}};
|
| 739 |
+
window.createTopicPost=function(){let inp=document.getElementById('ai-topic-input');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&(j.posts||j.post)){let arr=j.posts||[j.post];patchedWall=arr.concat(patchedWall.filter(x=>!arr.find(y=>y.id===x.id)));renderPatchedWall();if(inp)inp.value='';alert(`Đã lọc và tóm tắt ${arr.length} bài viết theo chủ đề lên Tường AI`);}else alert(j.error||'Lỗi tạo bài')}).catch(e=>alert(e.message||'Lỗi tạo bài'));};
|
| 740 |
+
window.createUrlPost=function(){let inp=document.getElementById('ai-url-input');let url=(inp&&inp.value||'').trim();if(!url)return alert('Dán URL trước');if(!/^https?:\/\//i.test(url))return alert('URL cần bắt đầu bằng http:// hoặc https://');fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&j.post){patchedWall=[j.post].concat(patchedWall.filter(x=>x.id!==j.post.id));renderPatchedWall();if(inp)inp.value='';alert('Đã tóm tắt URL và đăng lên Tường AI');}else alert(j.error||'Lỗi URL')}).catch(e=>alert(e.message||'Lỗi URL'));};
|
| 741 |
+
window.rewriteCurrentArticle=function(){if(!window._currentArticle&&typeof _currentArticle!=='undefined')window._currentArticle=_currentArticle;let cur=window._currentArticle||_currentArticle;if(!cur)return;let btn=document.querySelector('.article-actions button.primary');if(btn){btn.textContent='Đang tóm tắt...';btn.disabled=true}fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:cur.url})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&j.post){document.getElementById('rewrite-result').innerHTML=`<div class="rewrite-box"><div class="rewrite-title">Đã tóm tắt và đăng Tường AI</div><div class="rewrite-text">${esc(j.post.text||'')}</div></div>`;patchedWall=[j.post].concat(patchedWall.filter(x=>x.id!==j.post.id));renderPatchedWall();alert('Đã tóm tắt lên Tường AI');}else alert(j.error||'Không tóm tắt được')}).catch(e=>alert(e.message||'Lỗi tóm tắt')).finally(()=>{if(btn){btn.textContent='🤖 Tóm tắt AI & đăng tường';btn.disabled=false}})};
|
| 742 |
+
setTimeout(loadPatchedWall,1500);setInterval(updateAiLabels,2000);
|
| 743 |
+
})();
|
| 744 |
+
</script>
|
| 745 |
+
'''
|
| 746 |
+
|
| 747 |
+
@app.get('/')
|
| 748 |
+
async def index_patched():
|
| 749 |
+
with open('/app/static/index.html','r',encoding='utf-8') as f:
|
| 750 |
+
html=f.read()
|
| 751 |
+
return HTMLResponse(html.replace('</body>', PATCH_INJECT+'\n</body>'))
|
ai_runtime_final6.py
CHANGED
|
@@ -840,10 +840,486 @@ FINAL6E_INJECT = """
|
|
| 840 |
function escE(s){return String(s||'').replace(/[&<>\"']/g,m=>({'&':'&','<':'<','>':'>','\"':'"',"'":'''}[m]));}
|
| 841 |
window.__topicWallE=[];
|
| 842 |
function sourceDetailsHtml(p){let arr=p.source_details||[];if(!arr.length)return '';let h='<div class="source-detail-box"><h3>📚 Nội dung từng nguồn đã dùng</h3>';arr.forEach((s,i)=>{h+=`<div class="source-detail-item"><div class="source-detail-title">${i+1}. ${escE(s.title)}</div><div class="source-detail-meta">${escE(s.via||'Nguồn')}</div><div class="source-detail-content">${escE(s.content||'')}</div>${s.url?`<a href="${escE(s.url)}" target="_blank">Mở nguồn gốc</a>`:''}</div>`});h+='</div>';return h;}
|
| 843 |
-
function renderTopicWallE(){let home=document.getElementById('view-home');if(!home||!window.__topicWallE.length)return;document.getElementById('ai-wall-topic-live')?.remove();let wrap=document.createElement('div');wrap.id='ai-wall-topic-live';wrap.className='ai-wall-topic-live';let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI mới</span><span class="slider-note">
|
| 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)}">`:'');
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 840 |
function escE(s){return String(s||'').replace(/[&<>\"']/g,m=>({'&':'&','<':'<','>':'>','\"':'"',"'":'''}[m]));}
|
| 841 |
window.__topicWallE=[];
|
| 842 |
function sourceDetailsHtml(p){let arr=p.source_details||[];if(!arr.length)return '';let h='<div class="source-detail-box"><h3>📚 Nội dung từng nguồn đã dùng</h3>';arr.forEach((s,i)=>{h+=`<div class="source-detail-item"><div class="source-detail-title">${i+1}. ${escE(s.title)}</div><div class="source-detail-meta">${escE(s.via||'Nguồn')}</div><div class="source-detail-content">${escE(s.content||'')}</div>${s.url?`<a href="${escE(s.url)}" target="_blank">Mở nguồn gốc</a>`:''}</div>`});h+='</div>';return h;}
|
| 843 |
+
function renderTopicWallE(){let home=document.getElementById('view-home');if(!home||!window.__topicWallE.length)return;document.getElementById('ai-wall-topic-live')?.remove();let wrap=document.createElement('div');wrap.id='ai-wall-topic-live';wrap.className='ai-wall-topic-live';let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI mới</span><span class="slider-note">Có nội dung nguồn</span></div><div class="slider-track">';window.__topicWallE.slice(0,20).forEach((p,i)=>{h+=`<div class="wall-item"><div class="wall-thumb">${p.img?`<img src="${escE(p.img)}">`:''}</div><div class="wall-title">${escE(p.title)}</div><div class="wall-text">${escE(p.text)}</div><div class="wall-actions"><button class="primary" onclick="readTopicWallE(${i})">Xem</button></div></div>`});h+='</div>';wrap.innerHTML=h;let comp=document.querySelector('.ai-compose');if(comp)comp.after(wrap);else home.prepend(wrap);}
|
| 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)}">`:'');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>${sourceDetailsHtml(p)}<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 lấy nguồn...'}try{let src=await fetch('/api/topic_sources?topic='+encodeURIComponent(topic)).then(r=>r.json()).catch(()=>null);if(btn&&src)btn.textContent='Đã tìm '+(src.count||0)+' nguồn, đang viết...';let r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');window.__topicWallE.unshift(j.post);if(inp)inp.value='';renderTopicWallE();readTopicWallE(0);}catch(e){alert(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='✨ Tạo bài tổng hợp từ web bằng Qwen'}}};
|
|
|
|
| 846 |
})();
|
| 847 |
</script>
|
| 848 |
+
"""
|
| 849 |
+
|
| 850 |
+
# Override root one last time to append source-details UI.
|
| 851 |
+
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
|
| 852 |
+
@app.get('/')
|
| 853 |
+
async def index_final6_source_details():
|
| 854 |
+
html=f5.f4.f3.f2.f1._load_index_html()
|
| 855 |
+
body=getattr(rt.old,'PATCH_INJECT','')+f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT+FINAL6_INJECT+globals().get('FINAL6_FAST_HOME_INJECT','')+FINAL6E_INJECT
|
| 856 |
+
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|
| 857 |
+
|
| 858 |
+
|
| 859 |
+
# ===== FINAL6F: CLEAN TOPIC OUTPUT + IN-APP SOURCE READER =====
|
| 860 |
+
def _clean_generated_article(text, topic=''):
|
| 861 |
+
"""Remove prompt/instruction leakage from generated topic articles."""
|
| 862 |
+
text=str(text or '').strip()
|
| 863 |
+
bad_patterns=[
|
| 864 |
+
r'^\s*[•\-]*\s*Hãy viết .*',
|
| 865 |
+
r'^\s*[•\-]*\s*Dưới đây là .*',
|
| 866 |
+
r'^\s*[•\-]*\s*Dữ liệu .*',
|
| 867 |
+
r'^\s*[•\-]*\s*NỘI DUNG NGUỒN.*',
|
| 868 |
+
r'^\s*[•\-]*\s*Yêu cầu\s*:.*',
|
| 869 |
+
r'^\s*[•\-]*\s*Tiêu đề mới.*',
|
| 870 |
+
r'^\s*[•\-]*\s*Sapo\s*2.*',
|
| 871 |
+
r'^\s*[•\-]*\s*5\s*[-–]\s*8\s*đoạn.*',
|
| 872 |
+
r'^\s*[•\-]*\s*Không dùng câu.*',
|
| 873 |
+
r'^\s*[•\-]*\s*Cuối bài.*',
|
| 874 |
+
r'^\s*[•\-]*\s*Không liệt kê.*',
|
| 875 |
+
r'^\s*[•\-]*\s*Tổng hợp thành.*',
|
| 876 |
+
r'^\s*[•\-]*\s*Diễn đạt lại.*',
|
| 877 |
+
r'^\s*[•\-]*\s*Tuyệt đối.*',
|
| 878 |
+
]
|
| 879 |
+
out=[]
|
| 880 |
+
for ln in text.splitlines():
|
| 881 |
+
s=ln.strip()
|
| 882 |
+
if not s:
|
| 883 |
+
out.append(ln);continue
|
| 884 |
+
if any(re.search(p,s,re.I) for p in bad_patterns):
|
| 885 |
+
continue
|
| 886 |
+
out.append(ln)
|
| 887 |
+
cleaned='\n'.join(out).strip()
|
| 888 |
+
# If model returned a markdown code/prompt-like block, keep content after first plausible title line.
|
| 889 |
+
cleaned=re.sub(r'^(?:Bài viết|Nội dung bài viết)\s*[::]\s*','',cleaned,flags=re.I).strip()
|
| 890 |
+
# Remove duplicated leading topic instruction if it appears inline.
|
| 891 |
+
cleaned=re.sub(r'Hãy viết MỘT BÀI VIẾT HOÀN CHỈNH[^\n\.]*[\.\n]*','',cleaned,flags=re.I).strip()
|
| 892 |
+
return cleaned or text
|
| 893 |
+
|
| 894 |
+
def _source_article_data(url):
|
| 895 |
+
try:
|
| 896 |
+
r=requests.get(url,headers=UA,timeout=14);r.encoding='utf-8'
|
| 897 |
+
soup=BeautifulSoup(r.text,'lxml')
|
| 898 |
+
h1=soup.find('h1')
|
| 899 |
+
ogt=soup.find('meta',property='og:title')
|
| 900 |
+
ogd=soup.find('meta',property='og:description')
|
| 901 |
+
ogi=soup.find('meta',property='og:image')
|
| 902 |
+
title=clean(h1.get_text(' ',strip=True) if h1 else (ogt.get('content','') if ogt else ''))
|
| 903 |
+
summary=clean(ogd.get('content','') if ogd else '')
|
| 904 |
+
img=ogi.get('content','') if ogi else ''
|
| 905 |
+
except Exception:
|
| 906 |
+
title='';summary='';img=''
|
| 907 |
+
text=_scrape_article_text(url,12000)
|
| 908 |
+
body=[]
|
| 909 |
+
for para in re.split(r'\n+',text or ''):
|
| 910 |
+
para=clean(para)
|
| 911 |
+
if len(para)>35:
|
| 912 |
+
body.append({'type':'p','text':para})
|
| 913 |
+
if len(body)>=80:break
|
| 914 |
+
if not title:title=url
|
| 915 |
+
if not body and summary:body=[{'type':'p','text':summary}]
|
| 916 |
+
return {'title':title,'summary':summary,'og_image':img,'body':body,'source':'topic-source','url':url}
|
| 917 |
+
|
| 918 |
+
@app.get('/api/topic_source_article')
|
| 919 |
+
def api_topic_source_article(url:str=Query(...)):
|
| 920 |
+
if not url.startswith('http'):
|
| 921 |
+
return JSONResponse({'error':'bad url'},status_code=400)
|
| 922 |
+
return JSONResponse(_source_article_data(url))
|
| 923 |
+
|
| 924 |
+
# Override topic generation one last time with output cleaning and source details.
|
| 925 |
+
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/api/topic_post' and 'POST' in getattr(r,'methods',set()))]
|
| 926 |
+
|
| 927 |
+
@app.post('/api/topic_post')
|
| 928 |
+
async def topic_post_clean_final(request:Request):
|
| 929 |
+
body=await request.json();topic=clean(body.get('topic',''))
|
| 930 |
+
if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
|
| 931 |
+
img=_topic_image(topic)
|
| 932 |
+
research=_fast_context(topic) if '_fast_context' in globals() else _web_research_context(topic)
|
| 933 |
+
context=research.get('context','');sources=research.get('sources',[])
|
| 934 |
+
details=_extract_source_details_from_context(context,sources) if '_extract_source_details_from_context' in globals() else []
|
| 935 |
+
if not details:
|
| 936 |
+
# Build details directly from sources/snippets if helper unavailable or empty.
|
| 937 |
+
for s in sources[:8]:
|
| 938 |
+
details.append({'title':s.get('title',''),'url':s.get('url',''),'via':s.get('via',''),'content':s.get('excerpt','') or s.get('snippet','') or ''})
|
| 939 |
+
if not context and not details:
|
| 940 |
+
return JSONResponse({'error':'Không tìm/crawl được đủ nội dung về chủ đề này. Hãy thử chủ đề cụ thể hơn hoặc dùng hashtag gợi ý.'},status_code=422)
|
| 941 |
+
source_brief='\n\n'.join([f"[{i+1}] {d.get('title','')} ({d.get('via','')})\n{d.get('content','')[:1400]}" for i,d in enumerate(details[:8])])
|
| 942 |
+
prompt=f"""Vai trò: biên tập viên VNEWS.
|
| 943 |
+
Nhiệm vụ: viết một bài báo tiếng Việt hoàn chỉnh về chủ đề "{topic}" dựa trên các nguồn bên dưới.
|
| 944 |
+
|
| 945 |
+
Nguồn thu thập:
|
| 946 |
+
{source_brief[:18000]}
|
| 947 |
+
|
| 948 |
+
Quy tắc biên tập:
|
| 949 |
+
1. Chỉ xuất bản bài viết cuối cùng, không nhắc lại yêu cầu, không liệt kê chỉ dẫn.
|
| 950 |
+
2. Không sao chép nguyên văn; hãy tổng hợp và diễn đạt lại.
|
| 951 |
+
3. Bài có tiêu đề, sapo, các đoạn phân tích/bối cảnh/tác động, và mục Nguồn tham khảo ngắn.
|
| 952 |
+
4. Không dùng các câu như "Dưới đây là", "Tôi sẽ", "Yêu cầu".
|
| 953 |
+
"""
|
| 954 |
+
text=None
|
| 955 |
+
try:
|
| 956 |
+
import asyncio
|
| 957 |
+
text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1700),timeout=35)
|
| 958 |
+
except Exception:
|
| 959 |
+
text=None
|
| 960 |
+
if not text or len(text)<350:
|
| 961 |
+
bullets='\n'.join([f"• {d.get('title','')}: {d.get('content','')[:320]}" for d in details[:6]])
|
| 962 |
+
vias=', '.join(sorted({d.get('via','') for d in details if d.get('via')}))
|
| 963 |
+
text=(f"{topic}: tổng hợp những điểm đáng chú ý\n\n"
|
| 964 |
+
f"{topic} đang được nhiều nguồn tin đề cập với các góc nhìn khác nhau. Dựa trên nội dung đã thu thập, có thể rút ra một số điểm chính để người đọc nắm nhanh bối cảnh.\n\n"
|
| 965 |
+
f"{bullets}\n\n"
|
| 966 |
+
f"Nhìn chung, chủ đề này cần được theo dõi thêm ở các khía cạnh: bối cảnh, tác động thực tế, phản ứng của các bên liên quan và các diễn biến mới trong thời gian tới.\n\n"
|
| 967 |
+
f"Nguồn tham khảo: {vias}")
|
| 968 |
+
text=_clean_generated_article(text,topic)
|
| 969 |
+
post=f5.base.make_post(topic,text,img,'','topic_clean_with_sources',sources=[s for s in sources if s.get('url')])
|
| 970 |
+
post['images']=[img]
|
| 971 |
+
post['source_details']=details[:8]
|
| 972 |
+
posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts)
|
| 973 |
+
return JSONResponse({'post':post,'mode':'clean_with_source_details','sources_count':len(details)})
|
| 974 |
+
|
| 975 |
+
FINAL6F_INJECT = """
|
| 976 |
+
<script>
|
| 977 |
+
(function(){
|
| 978 |
+
function escF(s){return String(s||'').replace(/[&<>\\"']/g,m=>({'&':'&','<':'<','>':'>','\\"':'"',"'":'''}[m]));}
|
| 979 |
+
window.readTopicSourceE=async function(url){
|
| 980 |
+
showView('view-article');
|
| 981 |
+
const el=document.getElementById('view-article');
|
| 982 |
+
el.innerHTML='<div class="loading">Đang tải nguồn...</div>';
|
| 983 |
+
try{
|
| 984 |
+
let data=await fetch('/api/topic_source_article?url='+encodeURIComponent(url)).then(r=>r.json());
|
| 985 |
+
if(!data||data.error||!data.body||!data.body.length){throw new Error('Không đọc được nguồn')}
|
| 986 |
+
let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><h1 class="article-title">${escF(data.title)}</h1>`;
|
| 987 |
+
if(data.summary)h+=`<div class="article-summary">${escF(data.summary)}</div>`;
|
| 988 |
+
if(data.og_image)h+=`<img class="article-img" src="${escF(data.og_image)}">`;
|
| 989 |
+
data.body.forEach(b=>{if(b.type==='p')h+=`<p class="article-p">${escF(b.text)}</p>`;else if(b.type==='heading')h+=`<h2 class="article-h2">${escF(b.text)}</h2>`;});
|
| 990 |
+
h+=`<div class="article-actions"><button onclick="window.open('${escF(url)}','_blank')">🔗 Mở gốc</button></div></div>`;
|
| 991 |
+
el.innerHTML=h;window.scrollTo(0,0);
|
| 992 |
+
}catch(e){el.innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="loading">Không đọc được nguồn.<br><a href="${escF(url)}" target="_blank" style="color:#5cb87a">Mở link gốc</a></div>`}
|
| 993 |
+
};
|
| 994 |
+
// Upgrade existing source detail boxes: replace external open behavior by in-app button.
|
| 995 |
+
function enhanceSourceButtons(){document.querySelectorAll('.source-detail-item a[href]').forEach(a=>{let u=a.getAttribute('href');if(!u||a.dataset.vnews)return;a.dataset.vnews='1';a.textContent='Xem trực tiếp trên VNEWS';a.setAttribute('href','javascript:void(0)');a.onclick=function(){readTopicSourceE(u);return false;};});}
|
| 996 |
+
setInterval(enhanceSourceButtons,1000);setTimeout(enhanceSourceButtons,500);
|
| 997 |
+
})();
|
| 998 |
+
</script>
|
| 999 |
+
"""
|
| 1000 |
+
|
| 1001 |
+
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
|
| 1002 |
+
@app.get('/')
|
| 1003 |
+
async def index_final6_clean_links():
|
| 1004 |
+
html=f5.f4.f3.f2.f1._load_index_html()
|
| 1005 |
+
body=getattr(rt.old,'PATCH_INJECT','')+f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT+FINAL6_INJECT+globals().get('FINAL6_FAST_HOME_INJECT','')+globals().get('FINAL6E_INJECT','')+FINAL6F_INJECT
|
| 1006 |
+
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|
| 1007 |
+
|
| 1008 |
+
|
| 1009 |
+
# ===== FINAL6G: SELECTED FAST SOURCES =====
|
| 1010 |
+
# Restrict hot hashtags + topic articles to the requested sources only:
|
| 1011 |
+
# Thethaovanhoa, Dantri, VTV, VnExpress, Vatvostudio, GenK, VNReview.
|
| 1012 |
+
SELECTED_SOURCE_FEEDS=[
|
| 1013 |
+
('VnExpress','https://vnexpress.net/rss/tin-moi-nhat.rss'),
|
| 1014 |
+
('VnExpress Thời sự','https://vnexpress.net/rss/thoi-su.rss'),
|
| 1015 |
+
('VnExpress Thế giới','https://vnexpress.net/rss/the-gioi.rss'),
|
| 1016 |
+
('VnExpress Kinh doanh','https://vnexpress.net/rss/kinh-doanh.rss'),
|
| 1017 |
+
('VnExpress Công nghệ','https://vnexpress.net/rss/so-hoa.rss'),
|
| 1018 |
+
('VnExpress Thể thao','https://vnexpress.net/rss/the-thao.rss'),
|
| 1019 |
+
('Dân trí','https://dantri.com.vn/rss/home.rss'),
|
| 1020 |
+
('Dân trí Xã hội','https://dantri.com.vn/rss/xa-hoi.rss'),
|
| 1021 |
+
('Dân trí Kinh doanh','https://dantri.com.vn/rss/kinh-doanh.rss'),
|
| 1022 |
+
('Dân trí Thể thao','https://dantri.com.vn/rss/the-thao.rss'),
|
| 1023 |
+
('Dân trí Công nghệ','https://dantri.com.vn/rss/suc-manh-so.rss'),
|
| 1024 |
+
('VTV','https://vtv.vn/rss/trang-chu.rss'),
|
| 1025 |
+
('VTV Thời sự','https://vtv.vn/rss/thoi-su.rss'),
|
| 1026 |
+
('VTV Công nghệ','https://vtv.vn/rss/cong-nghe.rss'),
|
| 1027 |
+
('Thể thao văn hóa','https://thethaovanhoa.vn/rss/home.rss'),
|
| 1028 |
+
('Thể thao văn hóa Bóng đá','https://thethaovanhoa.vn/rss/bong-da.rss'),
|
| 1029 |
+
('GenK','https://genk.vn/home.rss'),
|
| 1030 |
+
('GenK AI','https://genk.vn/ai.rss'),
|
| 1031 |
+
('VNReview','https://vnreview.vn/rss/home.rss'),
|
| 1032 |
+
('VNReview Công nghệ','https://vnreview.vn/rss/cong-nghe.rss'),
|
| 1033 |
+
]
|
| 1034 |
+
SELECTED_HOMEPAGES=[
|
| 1035 |
+
('VTV','https://vtv.vn/'),
|
| 1036 |
+
('Thể thao văn hóa','https://thethaovanhoa.vn/'),
|
| 1037 |
+
('GenK','https://genk.vn/'),
|
| 1038 |
+
('VNReview','https://vnreview.vn/'),
|
| 1039 |
+
('Vatvostudio','https://vatvostudio.vn/'),
|
| 1040 |
+
]
|
| 1041 |
+
SELECTED_DOMAINS=['vnexpress.net','dantri.com.vn','vtv.vn','thethaovanhoa.vn','genk.vn','vnreview.vn','vatvostudio.vn']
|
| 1042 |
+
|
| 1043 |
+
def _selected_fetch_rss(feed_name, feed_url, max_items=10):
|
| 1044 |
+
items=[]
|
| 1045 |
+
try:
|
| 1046 |
+
r=requests.get(feed_url,headers=UA,timeout=4);r.encoding='utf-8'
|
| 1047 |
+
if r.status_code>=400:return []
|
| 1048 |
+
soup=BeautifulSoup(r.text,'xml')
|
| 1049 |
+
for it in soup.find_all('item')[:max_items]:
|
| 1050 |
+
title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
|
| 1051 |
+
link=clean(it.find('link').get_text(strip=True) if it.find('link') else '')
|
| 1052 |
+
desc_raw=it.find('description').get_text(' ',strip=True) if it.find('description') else ''
|
| 1053 |
+
ds=BeautifulSoup(desc_raw,'lxml')
|
| 1054 |
+
desc=clean(ds.get_text(' ',strip=True))
|
| 1055 |
+
if title and link:
|
| 1056 |
+
items.append({'title':title,'url':link,'source':feed_name,'snippet':desc})
|
| 1057 |
+
except Exception:pass
|
| 1058 |
+
return items
|
| 1059 |
+
|
| 1060 |
+
def _selected_scrape_homepage(name, url, max_items=10):
|
| 1061 |
+
items=[];seen=set()
|
| 1062 |
+
try:
|
| 1063 |
+
r=requests.get(url,headers=UA,timeout=4);r.encoding='utf-8'
|
| 1064 |
+
if r.status_code>=400:return []
|
| 1065 |
+
soup=BeautifulSoup(r.text,'lxml')
|
| 1066 |
+
base=url.rstrip('/')
|
| 1067 |
+
for a in soup.find_all('a',href=True):
|
| 1068 |
+
href=a.get('href','').strip();title=clean(a.get('title','') or a.get_text(' ',strip=True))
|
| 1069 |
+
if not href or not title or len(title)<18:continue
|
| 1070 |
+
if href.startswith('/'):
|
| 1071 |
+
p=urlparse(url); href=f'{p.scheme}://{p.netloc}{href}'
|
| 1072 |
+
if not href.startswith('http') or href in seen:continue
|
| 1073 |
+
dom=_domain(href)
|
| 1074 |
+
if not any(d in dom for d in SELECTED_DOMAINS):continue
|
| 1075 |
+
if any(x in href.lower() for x in ['#','javascript:','facebook','youtube','tiktok']):continue
|
| 1076 |
+
seen.add(href)
|
| 1077 |
+
items.append({'title':title,'url':href,'source':name,'snippet':''})
|
| 1078 |
+
if len(items)>=max_items:break
|
| 1079 |
+
except Exception:pass
|
| 1080 |
+
return items
|
| 1081 |
+
|
| 1082 |
+
def _fast_rss_pool():
|
| 1083 |
+
now=time.time();key='selected_fast_pool'
|
| 1084 |
+
if key in _FAST_TOPIC_CACHE and now-_FAST_TOPIC_CACHE[key]['t']<600:return _FAST_TOPIC_CACHE[key]['d']
|
| 1085 |
+
pool=[];seen=set()
|
| 1086 |
+
# RSS first: fast and reliable.
|
| 1087 |
+
for name,url in SELECTED_SOURCE_FEEDS:
|
| 1088 |
+
for it in _selected_fetch_rss(name,url,10):
|
| 1089 |
+
if it['url'] not in seen:
|
| 1090 |
+
seen.add(it['url']);pool.append(it)
|
| 1091 |
+
# Homepage fallback for sources with weak/no RSS, especially Vatvostudio.
|
| 1092 |
+
for name,url in SELECTED_HOMEPAGES:
|
| 1093 |
+
for it in _selected_scrape_homepage(name,url,10):
|
| 1094 |
+
if it['url'] not in seen:
|
| 1095 |
+
seen.add(it['url']);pool.append(it)
|
| 1096 |
+
_FAST_TOPIC_CACHE[key]={'t':now,'d':pool}
|
| 1097 |
+
return pool
|
| 1098 |
+
|
| 1099 |
+
def _hot_topics():
|
| 1100 |
+
now=time.time()
|
| 1101 |
+
if _HOT_CACHE['d'] and now-_HOT_CACHE['t']<600:return _HOT_CACHE['d']
|
| 1102 |
+
pool=_fast_rss_pool()
|
| 1103 |
+
freq={};display={}
|
| 1104 |
+
for it in pool[:220]:
|
| 1105 |
+
title=re.sub(r'\s+-\s+.*$','',it.get('title',''))
|
| 1106 |
+
kws=[]
|
| 1107 |
+
for m in re.findall(r'([A-ZĐÀ-Ỹ][A-Za-zÀ-ỹ0-9]+(?:\s+[A-ZĐÀ-ỸA-Za-zÀ-ỹ0-9][A-Za-zÀ-ỹ0-9]+){1,4})',title):
|
| 1108 |
+
if len(m)>=6:kws.append(m)
|
| 1109 |
+
kws+=_keywords_from_title(title)
|
| 1110 |
+
for kw in kws[:5]:
|
| 1111 |
+
words=[w for w in clean(kw).split() if w.lower() not in STOP_WORDS]
|
| 1112 |
+
if len(words)<2:continue
|
| 1113 |
+
kw=' '.join(words[:5])
|
| 1114 |
+
if len(kw)<6 or len(kw)>55:continue
|
| 1115 |
+
key=kw.lower();freq[key]=freq.get(key,0)+1;display[key]=kw
|
| 1116 |
+
topics=[];seen=set()
|
| 1117 |
+
for key,_ in sorted(freq.items(),key=lambda x:x[1],reverse=True):
|
| 1118 |
+
kw=display[key]
|
| 1119 |
+
if key in seen:continue
|
| 1120 |
+
seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
|
| 1121 |
+
if len(topics)>=24:break
|
| 1122 |
+
for kw in ['AI tại Việt Nam','Công nghệ Việt Nam','VTV thời sự','VnExpress kinh doanh','Dân trí xã hội','GenK AI','VNReview công nghệ','Vatvostudio smartphone','Thể thao văn hóa World Cup']:
|
| 1123 |
+
if kw.lower() not in seen:topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
|
| 1124 |
+
_HOT_CACHE.update({'t':now,'d':topics[:24]})
|
| 1125 |
+
return _HOT_CACHE['d']
|
| 1126 |
+
|
| 1127 |
+
def _candidate_urls(topic):
|
| 1128 |
+
seen=set();items=[]
|
| 1129 |
+
scored=[]
|
| 1130 |
+
for it in _fast_rss_pool():
|
| 1131 |
+
sc=_fast_score(topic,it)
|
| 1132 |
+
if sc>0:scored.append((sc,it))
|
| 1133 |
+
for sc,it in sorted(scored,key=lambda x:(x[0],len(x[1].get('snippet',''))),reverse=True)[:14]:
|
| 1134 |
+
if it['url'] not in seen:
|
| 1135 |
+
seen.add(it['url']);items.append(it)
|
| 1136 |
+
# Search only selected sources when RSS lacks matches.
|
| 1137 |
+
if len(items)<6:
|
| 1138 |
+
for dom in SELECTED_DOMAINS:
|
| 1139 |
+
for it in _ddg_search(f'{topic} site:{dom}',4):
|
| 1140 |
+
if it['url'] not in seen:
|
| 1141 |
+
seen.add(it['url']);items.append(it)
|
| 1142 |
+
if len(items)>=12:break
|
| 1143 |
+
return items[:20]
|
| 1144 |
+
|
| 1145 |
+
|
| 1146 |
+
# ===== FINAL6G: SOURCE-LIMITED FAST TOPICS AND FAST HOME =====
|
| 1147 |
+
# Limit hot hashtags/topic context to requested sources and make homepage APIs return quickly.
|
| 1148 |
+
_SOURCE_FEEDS = [
|
| 1149 |
+
('VnExpress','https://vnexpress.net/rss/tin-moi-nhat.rss','vnexpress.net'),
|
| 1150 |
+
('VnExpress Thời sự','https://vnexpress.net/rss/thoi-su.rss','vnexpress.net'),
|
| 1151 |
+
('VnExpress Thế giới','https://vnexpress.net/rss/the-gioi.rss','vnexpress.net'),
|
| 1152 |
+
('VnExpress Kinh doanh','https://vnexpress.net/rss/kinh-doanh.rss','vnexpress.net'),
|
| 1153 |
+
('VnExpress Công nghệ','https://vnexpress.net/rss/so-hoa.rss','vnexpress.net'),
|
| 1154 |
+
('VnExpress Thể thao','https://vnexpress.net/rss/the-thao.rss','vnexpress.net'),
|
| 1155 |
+
('Dân trí','https://dantri.com.vn/rss/home.rss','dantri.com.vn'),
|
| 1156 |
+
('Dân trí Xã hội','https://dantri.com.vn/rss/xa-hoi.rss','dantri.com.vn'),
|
| 1157 |
+
('Dân trí Kinh doanh','https://dantri.com.vn/rss/kinh-doanh.rss','dantri.com.vn'),
|
| 1158 |
+
('Dân trí Thể thao','https://dantri.com.vn/rss/the-thao.rss','dantri.com.vn'),
|
| 1159 |
+
('Dân trí Công nghệ','https://dantri.com.vn/rss/suc-manh-so.rss','dantri.com.vn'),
|
| 1160 |
+
('VTV','https://vtv.vn/rss/trang-chu.rss','vtv.vn'),
|
| 1161 |
+
('VTV Thời sự','https://vtv.vn/rss/thoi-su.rss','vtv.vn'),
|
| 1162 |
+
('GenK','https://genk.vn/rss/home.rss','genk.vn'),
|
| 1163 |
+
('GenK AI','https://genk.vn/ai.rss','genk.vn'),
|
| 1164 |
+
('VnReview','https://vnreview.vn/rss/tin-moi-nhat.rss','vnreview.vn'),
|
| 1165 |
+
('VnReview Công nghệ','https://vnreview.vn/rss/cong-nghe.rss','vnreview.vn'),
|
| 1166 |
+
('Vật Vờ Studio','https://vatvostudio.vn/feed/','vatvostudio.vn'),
|
| 1167 |
+
('Thể thao văn hóa','https://thethaovanhoa.vn/rss/home.rss','thethaovanhoa.vn'),
|
| 1168 |
+
('Thể thao văn hóa World Cup','https://thethaovanhoa.vn/rss/world-cup-2026.rss','thethaovanhoa.vn'),
|
| 1169 |
+
]
|
| 1170 |
+
_SOURCE_CACHE={'t':0,'items':[]}
|
| 1171 |
+
_FAST_ROUTE_CACHE={}
|
| 1172 |
+
|
| 1173 |
+
def _feed_items_source_limited(max_per_feed=10):
|
| 1174 |
+
now=time.time()
|
| 1175 |
+
if _SOURCE_CACHE['items'] and now-_SOURCE_CACHE['t']<600:return _SOURCE_CACHE['items']
|
| 1176 |
+
items=[];seen=set()
|
| 1177 |
+
def one(feed):
|
| 1178 |
+
name,url,dom=feed;out=[]
|
| 1179 |
+
try:
|
| 1180 |
+
r=requests.get(url,headers=UA,timeout=3.5);r.encoding='utf-8'
|
| 1181 |
+
soup=BeautifulSoup(r.text,'xml')
|
| 1182 |
+
for it in soup.find_all('item')[:max_per_feed*2]:
|
| 1183 |
+
title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
|
| 1184 |
+
link=clean(it.find('link').get_text(strip=True) if it.find('link') else '')
|
| 1185 |
+
desc_raw=it.find('description').get_text(' ',strip=True) if it.find('description') else ''
|
| 1186 |
+
ds=BeautifulSoup(desc_raw,'lxml')
|
| 1187 |
+
img='';im=ds.find('img')
|
| 1188 |
+
if im:img=im.get('src','') or im.get('data-src','')
|
| 1189 |
+
desc=clean(ds.get_text(' ',strip=True))[:700]
|
| 1190 |
+
if title and link:
|
| 1191 |
+
out.append({'title':title,'url':link,'link':link,'source':name,'via':name,'domain':dom,'snippet':desc,'img':img})
|
| 1192 |
+
if len(out)>=max_per_feed:break
|
| 1193 |
+
except Exception:pass
|
| 1194 |
+
return out
|
| 1195 |
+
try:
|
| 1196 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 1197 |
+
with ThreadPoolExecutor(max_workers=8) as ex:
|
| 1198 |
+
futs=[ex.submit(one,f) for f in _SOURCE_FEEDS]
|
| 1199 |
+
for f in as_completed(futs,timeout=5.5):
|
| 1200 |
+
try:
|
| 1201 |
+
for it in f.result() or []:
|
| 1202 |
+
if it['url'] not in seen:
|
| 1203 |
+
seen.add(it['url']);items.append(it)
|
| 1204 |
+
except Exception:pass
|
| 1205 |
+
except Exception:
|
| 1206 |
+
for f in _SOURCE_FEEDS[:8]:
|
| 1207 |
+
for it in one(f):
|
| 1208 |
+
if it['url'] not in seen:
|
| 1209 |
+
seen.add(it['url']);items.append(it)
|
| 1210 |
+
_SOURCE_CACHE.update({'t':now,'items':items})
|
| 1211 |
+
return items
|
| 1212 |
+
|
| 1213 |
+
def _score_topic_source(topic,it):
|
| 1214 |
+
toks=[w.lower() for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',topic or '') if len(w)>1 and w.lower() not in STOP_WORDS]
|
| 1215 |
+
hay=(it.get('title','')+' '+it.get('snippet','')+' '+it.get('source','')).lower()
|
| 1216 |
+
if not toks:return 0
|
| 1217 |
+
score=sum((3 if len(t)>3 else 1) for t in toks if t in hay)
|
| 1218 |
+
if topic.lower().strip() and topic.lower().strip() in hay:score+=12
|
| 1219 |
+
return score
|
| 1220 |
+
|
| 1221 |
+
def _hot_topics():
|
| 1222 |
+
now=time.time()
|
| 1223 |
+
if _HOT_CACHE['d'] and now-_HOT_CACHE['t']<600:return _HOT_CACHE['d']
|
| 1224 |
+
freq={};display={}
|
| 1225 |
+
for it in _feed_items_source_limited(8)[:180]:
|
| 1226 |
+
title=re.sub(r'\s+-\s+.*$','',it.get('title',''))
|
| 1227 |
+
kws=[]
|
| 1228 |
+
for m in re.findall(r'([A-ZĐÀ-Ỹ][A-Za-zÀ-ỹ0-9]+(?:\s+[A-ZĐÀ-ỸA-Za-zÀ-ỹ0-9][A-Za-zÀ-ỹ0-9]+){1,4})',title):
|
| 1229 |
+
if len(m)>=6:kws.append(m)
|
| 1230 |
+
kws += _keywords_from_title(title)
|
| 1231 |
+
for kw in kws[:4]:
|
| 1232 |
+
words=[w for w in clean(kw).split() if w.lower() not in STOP_WORDS]
|
| 1233 |
+
if len(words)<2:continue
|
| 1234 |
+
kw=' '.join(words[:5])
|
| 1235 |
+
if 6<=len(kw)<=55:
|
| 1236 |
+
key=kw.lower();freq[key]=freq.get(key,0)+1;display[key]=kw
|
| 1237 |
+
topics=[]
|
| 1238 |
+
for key,_ in sorted(freq.items(),key=lambda x:x[1],reverse=True):
|
| 1239 |
+
kw=display[key];topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
|
| 1240 |
+
if len(topics)>=24:break
|
| 1241 |
+
for kw in ['Giá vàng trong nước','AI tại Việt Nam','Bóng đá Việt Nam','Kinh tế Việt Nam','Công nghệ AI','Vật Vờ Studio','World Cup 2026','Sức khỏe cộng đồng']:
|
| 1242 |
+
if not any(t['topic'].lower()==kw.lower() for t in topics):topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
|
| 1243 |
+
_HOT_CACHE.update({'t':now,'d':topics[:24]})
|
| 1244 |
+
return _HOT_CACHE['d']
|
| 1245 |
+
|
| 1246 |
+
def _fast_context(topic):
|
| 1247 |
+
now=time.time();key='source_limited_ctx:'+topic.lower().strip()
|
| 1248 |
+
if key in _FAST_TOPIC_CACHE and now-_FAST_TOPIC_CACHE[key]['t']<600:return _FAST_TOPIC_CACHE[key]['d']
|
| 1249 |
+
pool=_feed_items_source_limited(12)
|
| 1250 |
+
scored=[]
|
| 1251 |
+
for it in pool:
|
| 1252 |
+
sc=_score_topic_source(topic,it)
|
| 1253 |
+
if sc>0:scored.append((sc,it))
|
| 1254 |
+
if not scored:
|
| 1255 |
+
# Try broader matching by first token only before giving up.
|
| 1256 |
+
first=(_fast_topic_tokens(topic) or [''])[0]
|
| 1257 |
+
if first:
|
| 1258 |
+
for it in pool:
|
| 1259 |
+
if first in (it.get('title','')+' '+it.get('snippet','')).lower():scored.append((1,it))
|
| 1260 |
+
picked=[it for sc,it in sorted(scored,key=lambda x:(x[0],len(x[1].get('snippet',''))),reverse=True)[:8]]
|
| 1261 |
+
if not picked:picked=pool[:6]
|
| 1262 |
+
blocks=[];src=[]
|
| 1263 |
+
for it in picked:
|
| 1264 |
+
content=it.get('snippet','') or it.get('title','')
|
| 1265 |
+
blocks.append(f"NGUỒN: {it.get('source','')}\nTIÊU ĐỀ: {it.get('title','')}\nTÓM TẮT RSS:\n{content}")
|
| 1266 |
+
src.append({'title':it.get('title',''),'url':it.get('url',''),'via':it.get('source',''),'snippet':content})
|
| 1267 |
+
data={'context':'\n\n---\n\n'.join(blocks),'sources':src,'count':len(blocks)}
|
| 1268 |
+
_FAST_TOPIC_CACHE[key]={'t':now,'d':data}
|
| 1269 |
+
return data
|
| 1270 |
+
|
| 1271 |
+
# Override slow search functions to never crawl open web during topic generation.
|
| 1272 |
+
def _web_research_context(topic):
|
| 1273 |
+
return _fast_context(topic)
|
| 1274 |
+
|
| 1275 |
+
def _candidate_urls(topic):
|
| 1276 |
+
return _fast_context(topic).get('sources',[])
|
| 1277 |
+
|
| 1278 |
+
# Fast homepage endpoints from requested source RSS; no slow HTML scrapers.
|
| 1279 |
+
def _fast_homepage_sources():
|
| 1280 |
+
now=time.time();key='home_sources'
|
| 1281 |
+
if key in _FAST_ROUTE_CACHE and now-_FAST_ROUTE_CACHE[key]['t']<600:return _FAST_ROUTE_CACHE[key]['d']
|
| 1282 |
+
groups=[];seen=set()
|
| 1283 |
+
group_map=[('Tin mới','https://vnexpress.net/rss/tin-moi-nhat.rss','vne'),('Thời Sự','https://vnexpress.net/rss/thoi-su.rss','vne'),('Kinh Doanh','https://vnexpress.net/rss/kinh-doanh.rss','vne'),('Công Nghệ','https://vnexpress.net/rss/so-hoa.rss','vne'),('Dân Trí','https://dantri.com.vn/rss/home.rss','dantri'),('GenK','https://genk.vn/rss/home.rss','genk'),('VnReview','https://vnreview.vn/rss/tin-moi-nhat.rss','vnreview')]
|
| 1284 |
+
for g,u,s in group_map:
|
| 1285 |
+
for it in _rss_articles_fast(u,g,s,6) if '_rss_articles_fast' in globals() else []:
|
| 1286 |
+
if it['link'] not in seen:
|
| 1287 |
+
seen.add(it['link']);groups.append(it)
|
| 1288 |
+
_FAST_ROUTE_CACHE[key]={'t':now,'d':groups}
|
| 1289 |
+
return groups
|
| 1290 |
+
|
| 1291 |
+
for _p in ['/api/homepage','/api/dantri_hot','/api/vne_video','/api/highlights','/api/hot_topics','/api/topic_sources']:
|
| 1292 |
+
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)==_p and 'GET' in getattr(r,'methods',set()))]
|
| 1293 |
+
@app.get('/api/homepage')
|
| 1294 |
+
def api_homepage_source_fast():return JSONResponse(_fast_homepage_sources())
|
| 1295 |
+
@app.get('/api/dantri_hot')
|
| 1296 |
+
def api_dantri_hot_source_fast():
|
| 1297 |
+
data=[{**it,'source':'dantri','link':it.get('url') or it.get('link')} for it in _feed_items_source_limited(8) if it.get('domain')=='dantri.com.vn'][:12]
|
| 1298 |
+
return JSONResponse(data)
|
| 1299 |
+
@app.get('/api/vne_video')
|
| 1300 |
+
def api_vne_video_source_fast():
|
| 1301 |
+
return JSONResponse([]) # do not block homepage if VnEgo is slow
|
| 1302 |
+
@app.get('/api/highlights')
|
| 1303 |
+
def api_highlights_source_fast():return JSONResponse([])
|
| 1304 |
+
@app.get('/api/hot_topics')
|
| 1305 |
+
def api_hot_topics_source_fast():return JSONResponse({'topics':_hot_topics(),'sources':'vn_only'})
|
| 1306 |
+
@app.get('/api/topic_sources')
|
| 1307 |
+
def api_topic_sources_source_fast(topic:str=Query(...)):
|
| 1308 |
+
data=_fast_context(clean(topic));return JSONResponse({'count':data.get('count',0),'sources':data.get('sources',[]),'has_context':bool(data.get('context')),'mode':'source_limited_rss'})
|
| 1309 |
+
|
| 1310 |
+
# Override root: include all existing UI but add a script that prevents forced shorts refresh on initial load.
|
| 1311 |
+
ROOT_FAST_INJECT="""
|
| 1312 |
+
<script>
|
| 1313 |
+
(function(){
|
| 1314 |
+
const oldFetch=window.fetch;window.__allowShortRefresh=false;
|
| 1315 |
+
window.fetch=function(url,opts){try{let u=String(url||'');if(u.includes('/api/shorts?refresh=1')&&!window.__allowShortRefresh)url='/api/shorts';}catch(e){}return oldFetch.call(this,url,opts)};
|
| 1316 |
+
setTimeout(()=>{window.__allowShortRefresh=true;},8000);
|
| 1317 |
+
})();
|
| 1318 |
+
</script>
|
| 1319 |
+
"""
|
| 1320 |
+
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
|
| 1321 |
+
@app.get('/')
|
| 1322 |
+
async def index_final_fast_sources():
|
| 1323 |
+
html=f5.f4.f3.f2.f1._load_index_html()
|
| 1324 |
+
body=getattr(rt.old,'PATCH_INJECT','')+f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT+FINAL6_INJECT+globals().get('FINAL6_FAST_HOME_INJECT','')+globals().get('FINAL6E_INJECT','')+globals().get('FINAL6F_INJECT','')+ROOT_FAST_INJECT
|
| 1325 |
+
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|
app_v2_entry.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""VNEWS v2 Entry Point - with fast bongda proxy
|
| 2 |
import sys, os
|
| 3 |
from main import app, HEADERS, BONGDA_HEADERS, fetch_bongda_api, HL_LEAGUES
|
| 4 |
|
|
@@ -7,11 +7,6 @@ try:
|
|
| 7 |
except Exception as e:
|
| 8 |
print(f"[WARN] ai_ext import failed: {e}")
|
| 9 |
|
| 10 |
-
try:
|
| 11 |
-
import ai_patch
|
| 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
|
|
@@ -21,12 +16,10 @@ from bs4 import BeautifulSoup
|
|
| 21 |
import re, html as html_lib, json, threading, time, uuid
|
| 22 |
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 23 |
from urllib.parse import quote
|
| 24 |
-
import asyncio
|
| 25 |
|
| 26 |
HL_LEAGUES['friendly'] = {"path": "giai-khac/friendly", "name": "Giao hữu", "emoji": "🤝"}
|
| 27 |
|
| 28 |
STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
|
| 29 |
-
SPACE = "https://bep40-vnews.hf.space" # SEO URL base for share links
|
| 30 |
app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)=='/' and hasattr(r,'methods') and 'GET' in getattr(r,'methods',set()))]
|
| 31 |
app.routes[:]=[r for r in app.routes if not isinstance(r, Mount)]
|
| 32 |
app.router.routes=[r for r in app.router.routes if not isinstance(r, Mount)]
|
|
@@ -329,133 +322,43 @@ def _search_all(topic,limit=36):
|
|
| 329 |
if i<len(s) and s[i].get('url') and s[i]['url'] not in seen:seen.add(s[i]['url']);out.append(s[i])
|
| 330 |
return out[:limit]
|
| 331 |
|
| 332 |
-
for
|
| 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 |
-
_article_cache = {}
|
| 336 |
-
_article_cache_ttl = 1800
|
| 337 |
-
|
| 338 |
-
_art_session = None
|
| 339 |
-
_art_lock = threading.Lock()
|
| 340 |
-
def _get_art_session():
|
| 341 |
-
global _art_session
|
| 342 |
-
if _art_session is None:
|
| 343 |
-
with _art_lock:
|
| 344 |
-
if _art_session is None:
|
| 345 |
-
_art_session = req.Session()
|
| 346 |
-
_art_session.headers.update({
|
| 347 |
-
"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",
|
| 348 |
-
"Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8",
|
| 349 |
-
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
| 350 |
-
})
|
| 351 |
-
return _art_session
|
| 352 |
-
|
| 353 |
-
def _scrape_article_fast(url):
|
| 354 |
-
from urllib.parse import urlparse
|
| 355 |
-
domain = urlparse(url).netloc
|
| 356 |
-
sess = _get_art_session()
|
| 357 |
-
uas = [
|
| 358 |
-
{"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"},
|
| 359 |
-
{"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"},
|
| 360 |
-
]
|
| 361 |
-
for ua in uas:
|
| 362 |
-
try:
|
| 363 |
-
r = sess.get(url, headers=ua, timeout=6, allow_redirects=True)
|
| 364 |
-
if not r or r.status_code != 200:
|
| 365 |
-
continue
|
| 366 |
-
r.encoding = 'utf-8'
|
| 367 |
-
soup = BeautifulSoup(r.text, 'lxml')
|
| 368 |
-
for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe','.ads','.ad','.banner-ads','.fb-comments','.fb-root','.social-share','.related-news','.tag','.breadcrumb']):
|
| 369 |
-
tag.decompose()
|
| 370 |
-
title = summary = og_img = ""
|
| 371 |
-
ogt = soup.find('meta', property='og:title')
|
| 372 |
-
if ogt: title = ogt.get('content', '')
|
| 373 |
-
ogd = soup.find('meta', property='og:description') or soup.find('meta', attrs={'name': 'description'})
|
| 374 |
-
if ogd: summary = ogd.get('content', '')[:500]
|
| 375 |
-
ogi = soup.find('meta', property='og:image')
|
| 376 |
-
if ogi:
|
| 377 |
-
og_img = ogi.get('content', '')
|
| 378 |
-
if og_img.startswith('//'): og_img = 'https:' + og_img
|
| 379 |
-
h1 = soup.find('h1')
|
| 380 |
-
if not title and h1: title = h1.get_text(strip=True)[:200]
|
| 381 |
-
body = []
|
| 382 |
-
selectors = [
|
| 383 |
-
'.fck_detail', '.sidebar-1',
|
| 384 |
-
'.singular-content', '.dt__content', '.article-content', '.content-detail', '#divNewsContent',
|
| 385 |
-
'.content-detail', '.main-content-detail', '.box-content',
|
| 386 |
-
'.knc-content', '.article-body', '.detail-body',
|
| 387 |
-
'.article-detail', '.detail-content',
|
| 388 |
-
'article', 'main', '.cms-body', '.article__body', '.post-content',
|
| 389 |
-
'.entry-content', '#content', '.article-text', '.story-body',
|
| 390 |
-
]
|
| 391 |
-
for sel in selectors:
|
| 392 |
-
el = soup.select_one(sel)
|
| 393 |
-
if el and len(el.find_all('p')) >= 2:
|
| 394 |
-
seen_imgs = set()
|
| 395 |
-
for child in el.find_all(['p','h2','h3','figure','img'], recursive=True):
|
| 396 |
-
if child.name == 'p':
|
| 397 |
-
t = child.get_text(strip=True)
|
| 398 |
-
if t and len(t) > 15:
|
| 399 |
-
body.append({'type': 'p', 'text': t})
|
| 400 |
-
elif child.name in ('h2','h3'):
|
| 401 |
-
t = child.get_text(strip=True)
|
| 402 |
-
if t:
|
| 403 |
-
body.append({'type': 'heading', 'text': t})
|
| 404 |
-
elif child.name in ('figure','img'):
|
| 405 |
-
im = child if child.name == 'img' else child.find('img')
|
| 406 |
-
if im:
|
| 407 |
-
src = im.get('data-src') or im.get('src') or im.get('data-lazy') or ''
|
| 408 |
-
if src and 'base64' not in src and src not in seen_imgs:
|
| 409 |
-
seen_imgs.add(src)
|
| 410 |
-
if src.startswith('//'): src = 'https:' + src
|
| 411 |
-
body.append({'type': 'img', 'src': src})
|
| 412 |
-
if child.name == 'figure':
|
| 413 |
-
cap = child.find('figcaption')
|
| 414 |
-
if cap:
|
| 415 |
-
ct = cap.get_text(strip=True)
|
| 416 |
-
if ct: body.append({'type': 'p', 'text': ct})
|
| 417 |
-
if len(body) >= 2:
|
| 418 |
-
return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img,
|
| 419 |
-
'body': body[:50], 'source': domain, 'url': url}
|
| 420 |
-
if title and (summary or og_img):
|
| 421 |
-
fallback = []
|
| 422 |
-
if og_img: fallback.append({'type': 'img', 'src': og_img})
|
| 423 |
-
if summary: fallback.append({'type': 'p', 'text': summary})
|
| 424 |
-
if fallback:
|
| 425 |
-
return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img,
|
| 426 |
-
'body': fallback, 'source': domain, 'url': url, 'fallback': True}
|
| 427 |
-
if title:
|
| 428 |
-
return {'title': _clean(title), 'summary': '', 'og_image': '',
|
| 429 |
-
'body': [{'type': 'p', 'text': 'Nội dung đang được tải...'}],
|
| 430 |
-
'source': domain, 'url': url, 'fallback': True}
|
| 431 |
-
break
|
| 432 |
-
except Exception:
|
| 433 |
-
continue
|
| 434 |
-
return None
|
| 435 |
|
| 436 |
-
|
| 437 |
-
def api_article_v2(url: str = Query(...)):
|
| 438 |
-
from urllib.parse import unquote
|
| 439 |
-
safe_url = unquote(url)
|
| 440 |
try:
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
if
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
|
| 460 |
_hot_cache={'t':0,'d':[]}
|
| 461 |
def _get_hot_topics():
|
|
@@ -488,10 +391,7 @@ def _get_hot_topics():
|
|
| 488 |
_hot_cache.update({'t':now,'d':topics[:24]});return topics[:24]
|
| 489 |
|
| 490 |
@app.get('/api/hot_topics')
|
| 491 |
-
def api_hot_topics():
|
| 492 |
-
resp = JSONResponse({'topics':_get_hot_topics()})
|
| 493 |
-
resp.headers["Cache-Control"] = "public, max-age=120"
|
| 494 |
-
return resp
|
| 495 |
@app.get('/')
|
| 496 |
async def serve_index():
|
| 497 |
p=os.path.join(STATIC_DIR,'index_v2.html')
|
|
@@ -505,47 +405,12 @@ def _ht(topic:str=Query(...),page:int=Query(default=0)):
|
|
| 505 |
def _cat():return JSONResponse([])
|
| 506 |
@app.get('/api/storage_status')
|
| 507 |
def _st():return JSONResponse({'persistent':os.path.isdir('/data') and os.access('/data',os.W_OK)})
|
| 508 |
-
@app.get('/s/{slug}')
|
| 509 |
-
async def _sh_slug(slug: str, url: str = '', title: str = '', img: str = ''):
|
| 510 |
-
"""SEO-friendly share endpoint with slug in URL path."""
|
| 511 |
-
safe_title = _clean(title) if title else 'VNEWS - Tin tức'
|
| 512 |
-
safe_img = _clean(img) if img else ''
|
| 513 |
-
safe_url = _clean(url) if url else '/'
|
| 514 |
-
return HTMLResponse(f'''<!DOCTYPE html>
|
| 515 |
-
<html lang="vi">
|
| 516 |
-
<head>
|
| 517 |
-
<meta charset="utf-8">
|
| 518 |
-
<meta name="viewport" content="width=device-width,initial-scale=1">
|
| 519 |
-
<title>{safe_title}</title>
|
| 520 |
-
<meta property="og:title" content="{safe_title}">
|
| 521 |
-
<meta property="og:image" content="{safe_img}">
|
| 522 |
-
<meta property="og:description" content="Tin tức tóm tắt, AI rewrite, World Cup 2026">
|
| 523 |
-
<meta property="og:url" content="{SPACE}/s/{slug}">
|
| 524 |
-
<link rel="canonical" href="{SPACE}/s/{slug}">
|
| 525 |
-
<meta http-equiv="refresh" content="0;url={safe_url}">
|
| 526 |
-
</head><body></body></html>''')
|
| 527 |
-
|
| 528 |
@app.get('/s')
|
| 529 |
-
async def _sh(url:str='',title:str='',img:str=''):
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
safe_url = _clean(url) if url else '/'
|
| 533 |
-
return HTMLResponse(f'''<!DOCTYPE html>
|
| 534 |
-
<html lang="vi">
|
| 535 |
-
<head>
|
| 536 |
-
<meta charset="utf-8">
|
| 537 |
-
<meta name="viewport" content="width=device-width,initial-scale=1">
|
| 538 |
-
<title>{safe_title}</title>
|
| 539 |
-
<meta property="og:title" content="{safe_title}">
|
| 540 |
-
<meta property="og:image" content="{safe_img}">
|
| 541 |
-
<meta property="og:description" content="Tin tức tóm tắt, AI rewrite, World Cup 2026">
|
| 542 |
-
<meta property="og:url" content="{SPACE}/s">
|
| 543 |
-
<link rel="canonical" href="{SPACE}/s">
|
| 544 |
-
<meta http-equiv="refresh" content="0;url={safe_url}">
|
| 545 |
-
</head><body></body></html>''')
|
| 546 |
-
|
| 547 |
-
from wc2026_scraper import scrape_summary,scrape_fixtures,scrape_standings,scrape_stats,scrape_wc_news,scrape_road_to_wc,get_wc2026_all,scrape_history,scrape_h2h,scrape_lineups,scrape_match_detail
|
| 548 |
|
|
|
|
| 549 |
_xlb_cache = {}
|
| 550 |
_xlb_lock = threading.Lock()
|
| 551 |
|
|
@@ -683,36 +548,54 @@ async def _pc(request:Request):
|
|
| 683 |
with _il:idb=_lj(IF);idb.setdefault(v,{'views':0,'likes':0,'comments':0});idb[v]['comments']=len(cms);_sj(IF,idb)
|
| 684 |
return JSONResponse({'comments':cms})
|
| 685 |
|
|
|
|
|
|
|
| 686 |
def _load_wall_posts():
|
|
|
|
| 687 |
with _wl_lock:
|
| 688 |
return _lj(WALL_FILE)
|
| 689 |
|
| 690 |
def _save_wall_posts(posts):
|
|
|
|
| 691 |
with _wl_lock:
|
| 692 |
_sj(WALL_FILE, posts)
|
| 693 |
|
| 694 |
@app.get('/api/wall')
|
| 695 |
def api_wall():
|
|
|
|
| 696 |
posts = _load_wall_posts()
|
| 697 |
if not posts:
|
|
|
|
| 698 |
return JSONResponse({"posts": []})
|
| 699 |
return JSONResponse({"posts": posts})
|
| 700 |
|
| 701 |
@app.post('/api/wall')
|
| 702 |
async def api_wall_post(request: Request):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 703 |
content_type = request.headers.get('content-type', '')
|
|
|
|
|
|
|
| 704 |
if 'multipart/form-data' in content_type:
|
| 705 |
try:
|
| 706 |
form = await request.form()
|
| 707 |
except Exception as e:
|
| 708 |
return JSONResponse({"error": f"Form parse error: {str(e)}"}, status_code=400)
|
|
|
|
| 709 |
title = form.get('title', 'Video mới') or 'Video mới'
|
| 710 |
text = form.get('text', '') or ''
|
| 711 |
source = form.get('source', 'vtv_recorder') or 'vtv_recorder'
|
| 712 |
video_file = form.get('video')
|
|
|
|
| 713 |
post_id = str(uuid.uuid4())[:12]
|
| 714 |
video_url = None
|
|
|
|
|
|
|
| 715 |
if video_file and hasattr(video_file, 'filename') and video_file.filename:
|
|
|
|
| 716 |
fname = video_file.filename.lower()
|
| 717 |
if fname.endswith('.mp4'):
|
| 718 |
ext = '.mp4'
|
|
@@ -720,21 +603,31 @@ async def api_wall_post(request: Request):
|
|
| 720 |
ext = '.webm'
|
| 721 |
else:
|
| 722 |
ext = '.webm'
|
|
|
|
| 723 |
video_filename = f"wall_{post_id}{ext}"
|
| 724 |
video_path = os.path.join(WALL_VIDEO_DIR, video_filename)
|
|
|
|
| 725 |
try:
|
|
|
|
| 726 |
content = await video_file.read()
|
| 727 |
if not content:
|
| 728 |
return JSONResponse({"error": "Empty video file"}, status_code=400)
|
|
|
|
|
|
|
| 729 |
with open(video_path, 'wb') as f:
|
| 730 |
f.write(content)
|
|
|
|
| 731 |
file_size_mb = len(content) / 1024 / 1024
|
| 732 |
if file_size_mb > 50:
|
| 733 |
os.remove(video_path)
|
| 734 |
return JSONResponse({"error": f"Video quá lớn ({file_size_mb:.1f}MB). Tối đa 50MB."}, status_code=400)
|
|
|
|
|
|
|
| 735 |
video_url = f"/api/wall/video/{video_filename}"
|
| 736 |
except Exception as e:
|
| 737 |
return JSONResponse({"error": f"Lỗi lưu video: {str(e)}"}, status_code=500)
|
|
|
|
|
|
|
| 738 |
post = {
|
| 739 |
"id": post_id,
|
| 740 |
"title": title[:200],
|
|
@@ -746,21 +639,29 @@ async def api_wall_post(request: Request):
|
|
| 746 |
"created": int(time.time()),
|
| 747 |
"created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
|
| 748 |
}
|
|
|
|
|
|
|
| 749 |
posts = _load_wall_posts()
|
| 750 |
if not isinstance(posts, list):
|
| 751 |
posts = []
|
| 752 |
posts.insert(0, post)
|
|
|
|
| 753 |
posts = posts[:200]
|
| 754 |
_save_wall_posts(posts)
|
|
|
|
| 755 |
return JSONResponse({"post": post, "ok": True})
|
|
|
|
|
|
|
| 756 |
try:
|
| 757 |
body = await request.json()
|
| 758 |
except:
|
| 759 |
body = {}
|
|
|
|
| 760 |
title = body.get('title', 'Bài mới') or 'Bài mới'
|
| 761 |
text = body.get('text', '') or ''
|
| 762 |
img = body.get('img', None)
|
| 763 |
source = body.get('source', 'user') or 'user'
|
|
|
|
| 764 |
post_id = str(uuid.uuid4())[:12]
|
| 765 |
post = {
|
| 766 |
"id": post_id,
|
|
@@ -773,16 +674,20 @@ async def api_wall_post(request: Request):
|
|
| 773 |
"created": int(time.time()),
|
| 774 |
"created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
|
| 775 |
}
|
|
|
|
| 776 |
posts = _load_wall_posts()
|
| 777 |
if not isinstance(posts, list):
|
| 778 |
posts = []
|
| 779 |
posts.insert(0, post)
|
| 780 |
posts = posts[:200]
|
| 781 |
_save_wall_posts(posts)
|
|
|
|
| 782 |
return JSONResponse({"post": post, "ok": True})
|
| 783 |
|
| 784 |
@app.get('/api/wall/video/{filename}')
|
| 785 |
def api_wall_video(filename: str):
|
|
|
|
|
|
|
| 786 |
if '..' in filename or '/' in filename:
|
| 787 |
return Response(status_code=403)
|
| 788 |
video_path = os.path.join(WALL_VIDEO_DIR, filename)
|
|
@@ -794,11 +699,14 @@ def api_wall_video(filename: str):
|
|
| 794 |
|
| 795 |
@app.delete('/api/wall/{post_id}')
|
| 796 |
def api_wall_delete(post_id: str):
|
|
|
|
| 797 |
posts = _load_wall_posts()
|
| 798 |
if not isinstance(posts, list):
|
| 799 |
return JSONResponse({"error": "No posts"}, status_code=404)
|
|
|
|
| 800 |
for i, p in enumerate(posts):
|
| 801 |
if p.get('id') == post_id:
|
|
|
|
| 802 |
if p.get('video'):
|
| 803 |
video_name = p['video'].split('/')[-1]
|
| 804 |
video_path = os.path.join(WALL_VIDEO_DIR, video_name)
|
|
@@ -807,510 +715,8 @@ def api_wall_delete(post_id: str):
|
|
| 807 |
posts.pop(i)
|
| 808 |
_save_wall_posts(posts)
|
| 809 |
return JSONResponse({"ok": True})
|
| 810 |
-
return JSONResponse({"error": "Post not found"}, status_code=404)
|
| 811 |
-
|
| 812 |
-
# ===== LANGUAGE & EMOTION DETECTION =====
|
| 813 |
-
import random as _random2
|
| 814 |
-
from urllib.parse import quote as _quote2
|
| 815 |
-
|
| 816 |
-
_UA_RW = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept-Language': 'vi-VN,vi;q=0.9'}
|
| 817 |
-
|
| 818 |
-
# Unique character markers for language detection
|
| 819 |
-
_UNIQUE_CHARS = {
|
| 820 |
-
'vietnamese': set('đăâêôơưàảãạáằẳẵặắầẩẫậấèẻẽẹéềễểệếìỉĩịíòỏõọóồổỗộốờởỡợớùủũụúừửữựứỳỷỹỵý'),
|
| 821 |
-
'spanish': set('ñáéíóúü¿¡'),
|
| 822 |
-
'portuguese': set('ãõçáéíóúâêôà'),
|
| 823 |
-
}
|
| 824 |
-
|
| 825 |
-
_STOPWORDS = {
|
| 826 |
-
'english': {'the', 'is', 'at', 'which', 'on', 'a', 'an', 'and', 'or', 'but', 'in', 'with', 'to', 'for', 'of', 'not', 'no', 'can', 'had', 'have', 'has', 'was', 'were', 'are', 'be', 'been', 'this', 'that', 'it', 'he', 'she', 'they', 'his', 'her', 'my', 'your', 'our', 'we', 'you', 'i'},
|
| 827 |
-
'vietnamese': {'là', 'của', 'và', 'có', 'được', 'cho', 'không', 'với', 'này', 'đó', 'từ', 'trong', 'đã', 'sẽ', 'một', 'các', 'những', 'về', 'tại', 'người', 'năm', 'đến', 'ra', 'lại', 'như', 'khi', 'để', 'rất', 'cũng', 'mà', 'nếu', 'sau', 'trên', 'theo', 'vì', 'do', 'nên', 'thì', 'mình', 'tôi', 'bạn', 'anh', 'chị', 'em'},
|
| 828 |
-
'portuguese': {'de', 'um', 'que', 'e', 'do', 'da', 'em', 'para', 'com', 'não', 'uma', 'os', 'no', 'se', 'na', 'por', 'mais', 'as', 'dos', 'como', 'mas', 'ao', 'ele', 'das', 'tem', 'seu', 'sua', 'ou', 'quando', 'muito', 'nos', 'já', 'eu', 'também', 'só', 'pelo', 'pela', 'até', 'isso', 'ela', 'entre', 'depois', 'sem', 'mesmo', 'aos', 'são', 'está', 'ter', 'ser', 'foi', 'era', 'há', 'estão', 'você', 'nós', 'eles', 'elas'},
|
| 829 |
-
'spanish': {'de', 'que', 'el', 'en', 'y', 'a', 'los', 'del', 'se', 'las', 'por', 'un', 'para', 'con', 'no', 'una', 'su', 'al', 'es', 'lo', 'como', 'más', 'pero', 'sus', 'le', 'ya', 'o', 'fue', 'este', 'ha', 'si', 'porque', 'esta', 'son', 'entre', 'está', 'cuando', 'muy', 'sin', 'sobre', 'ser', 'también', 'me', 'hasta', 'hay', 'donde', 'han', 'quien', 'están', 'desde', 'todo', 'nos', 'durante', 'todos', 'uno', 'les', 'ni', 'contra', 'otros', 'fueron', 'ese', 'eso', 'ante', 'ellos', 'yo', 'tú', 'él', 'ella', 'nosotros', 'usted', 'ustedes'},
|
| 830 |
-
}
|
| 831 |
-
|
| 832 |
-
def detect_language(text):
|
| 833 |
-
"""Detect language from text content using stopword + character analysis."""
|
| 834 |
-
if not text:
|
| 835 |
-
return 'vietnamese'
|
| 836 |
-
text_lower = text.lower()
|
| 837 |
-
text_chars = set(text_lower)
|
| 838 |
-
|
| 839 |
-
# Strong signal: Vietnamese unique characters
|
| 840 |
-
vn_chars = len(text_chars & _UNIQUE_CHARS['vietnamese'])
|
| 841 |
-
if vn_chars >= 2:
|
| 842 |
-
return 'vietnamese'
|
| 843 |
-
|
| 844 |
-
# Spanish unique chars (ñ, ¿, ¡)
|
| 845 |
-
es_chars = len(text_chars & _UNIQUE_CHARS['spanish'])
|
| 846 |
-
pt_chars = len(text_chars & _UNIQUE_CHARS['portuguese'])
|
| 847 |
-
|
| 848 |
-
# Stopword scoring
|
| 849 |
-
words = set(re.findall(r'\b\w+\b', text_lower))
|
| 850 |
-
scores = {}
|
| 851 |
-
for lang, stops in _STOPWORDS.items():
|
| 852 |
-
scores[lang] = len(words & stops) / max(len(stops), 1)
|
| 853 |
-
|
| 854 |
-
# Disambiguate Portuguese vs Spanish
|
| 855 |
-
pt_markers = {'não', 'pelo', 'pela', 'isso', 'há', 'estão', 'num', 'numa', 'tenho', 'posso', 'você', 'nós', 'eles', 'elas', 'também', 'muito', 'já', 'só', 'até', 'entre', 'depois', 'sem', 'mesmo', 'aos', 'serão'}
|
| 856 |
-
es_markers = {'pero', 'está', 'están', 'porque', 'también', 'hasta', 'donde', 'quien', 'fue', 'son', 'fueron', 'ese', 'eso', 'ante', 'ellos', 'ella', 'nosotros', 'usted', 'ustedes', 'tú', 'él', 'desde', 'todo', 'durante', 'todos', 'uno', 'les', 'ni', 'contra', 'otros', 'fueron'}
|
| 857 |
-
|
| 858 |
-
pt_overlap = len(words & pt_markers)
|
| 859 |
-
es_overlap = len(words & es_markers)
|
| 860 |
-
|
| 861 |
-
if scores.get('portuguese', 0) > 0 and pt_overlap > es_overlap:
|
| 862 |
-
return 'portuguese'
|
| 863 |
-
if scores.get('spanish', 0) > 0 and es_overlap > pt_overlap:
|
| 864 |
-
return 'spanish'
|
| 865 |
-
if scores.get('english', 0) > 0.15:
|
| 866 |
-
return 'english'
|
| 867 |
-
|
| 868 |
-
best = max(scores, key=scores.get)
|
| 869 |
-
return best if scores[best] > 0.05 else 'vietnamese'
|
| 870 |
-
|
| 871 |
-
# Emotion keyword-based detection
|
| 872 |
-
_EMOTION_KEYWORDS = {
|
| 873 |
-
'happy': {
|
| 874 |
-
'en': ['happy', 'joy', 'wonderful', 'great', 'amazing', 'fantastic', 'love', 'excellent', 'beautiful', 'glad', 'delighted', 'pleased', 'cheerful', 'celebrate', 'victory', 'win', 'success'],
|
| 875 |
-
'pt': ['feliz', 'alegria', 'maravilhoso', 'ótimo', 'incrível', 'fantástico', 'amor', 'excelente', 'lindo', 'contente', 'encantado', 'vitória', 'sucesso'],
|
| 876 |
-
'es': ['feliz', 'alegria', 'maravilloso', 'genial', 'increíble', 'fantástico', 'amor', 'excelente', 'hermoso', 'contento', 'encantado', 'victoria', 'éxito'],
|
| 877 |
-
'vi': ['vui', 'hạnh phúc', 'tuyệt vời', 'tuyệt', 'ý nghĩa', 'đẹp', 'thích', 'yêu', 'vui vẻ', 'hân hoan', 'phấn khích', 'chiến thắng', 'thành công'],
|
| 878 |
-
},
|
| 879 |
-
'sad': {
|
| 880 |
-
'en': ['sad', 'unhappy', 'terrible', 'awful', 'horrible', 'miserable', 'depressed', 'grief', 'sorrow', 'tragic', 'unfortunate', 'painful', 'death', 'die', 'kill'],
|
| 881 |
-
'pt': ['triste', 'infeliz', 'terrível', 'horrível', 'miserável', 'deprimido', 'dor', 'trágico', 'infelizmente', 'penoso', 'morte', 'morrer'],
|
| 882 |
-
'es': ['triste', 'infeliz', 'terrible', 'horrible', 'miserable', 'deprimido', 'dolor', 'trágico', 'desafortunado', 'penoso', 'muerte', 'morir'],
|
| 883 |
-
'vi': ['buồn', 'không vui', 'tồi tệ', 'kinh khủng', 'đau khổ', 'đau buồn', 'bi thương', 'khốn nạn', 'đau đớn', 'thảm họa', 'chết', 'mất'],
|
| 884 |
-
},
|
| 885 |
-
'excited': {
|
| 886 |
-
'en': ['excited', 'thrilling', 'amazing', 'wow', 'incredible', 'unbelievable', 'awesome', 'exhilarating', 'electrifying', 'breathtaking', 'breakthrough', 'record'],
|
| 887 |
-
'pt': ['animado', 'emocionante', 'incrível', 'impressionante', 'sensacional', 'eletrizante', 'empolgante', 'recorde'],
|
| 888 |
-
'es': ['emocionante', 'increíble', 'impresionante', 'sensacional', 'electrizante', 'emocionado', 'entusiasmado', 'récord'],
|
| 889 |
-
'vi': ['hào hứng', 'phấn khích', 'thú vị', 'tuyệt cú mèo', 'đỉnh cao', 'ngoạn mục', 'sục sôi', 'kỷ lục', 'đột phá'],
|
| 890 |
-
},
|
| 891 |
-
'humorous': {
|
| 892 |
-
'en': ['funny', 'hilarious', 'joke', 'laugh', 'comedy', 'humor', 'amusing', 'witty', 'sarcastic', 'ironic', 'ridiculous', 'absurd', 'lol', 'haha'],
|
| 893 |
-
'pt': ['engraçado', 'hilário', 'piada', 'rir', 'comédia', 'humor', 'divertido', 'irônico', 'ridículo', 'absurdo', 'kkk'],
|
| 894 |
-
'es': ['gracioso', 'hilarante', 'broma', 'risa', 'comedia', 'humor', 'divertido', 'irónico', 'ridículo', 'absurdo', 'jaja'],
|
| 895 |
-
'vi': ['hài hước', 'buồn cười', 'đùa', 'cười', 'hài', 'vui nhộn', 'hóm hỉnh', 'mỉa mai', 'lố bịch', 'vô lý', 'haha'],
|
| 896 |
-
},
|
| 897 |
-
'serious': {
|
| 898 |
-
'en': ['serious', 'critical', 'important', 'urgent', 'severe', 'grave', 'significant', 'crucial', 'vital', 'essential', 'alarming', 'concerning', 'crisis', 'war', 'conflict'],
|
| 899 |
-
'pt': ['sério', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'essencial', 'preocupante', 'crise', 'guerra', 'conflito'],
|
| 900 |
-
'es': ['serio', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'esencial', 'preocupante', 'crisis', 'guerra', 'conflicto'],
|
| 901 |
-
'vi': ['nghiêm trọng', 'quan trọng', 'khẩn cấp', 'nghiêm túc', 'đáng kể', 'thiết yếu', 'cần thiết', 'báo động', 'lo ngại', 'khủng hoảng', 'chiến tranh', 'xung đột'],
|
| 902 |
-
},
|
| 903 |
-
}
|
| 904 |
-
|
| 905 |
-
def detect_emotion(text, language='vietnamese'):
|
| 906 |
-
"""Detect emotion from text using keyword matching."""
|
| 907 |
-
if not text:
|
| 908 |
-
return 'neutral'
|
| 909 |
-
text_lower = text.lower()
|
| 910 |
-
|
| 911 |
-
scores = {}
|
| 912 |
-
for emotion, lang_keywords in _EMOTION_KEYWORDS.items():
|
| 913 |
-
keywords = lang_keywords.get(language, lang_keywords.get('en', []))
|
| 914 |
-
score = sum(1 for kw in keywords if kw in text_lower)
|
| 915 |
-
scores[emotion] = score
|
| 916 |
-
|
| 917 |
-
if max(scores.values()) == 0:
|
| 918 |
-
return 'neutral'
|
| 919 |
-
|
| 920 |
-
return max(scores, key=scores.get)
|
| 921 |
-
|
| 922 |
-
def detect_language_and_emotion(title, text):
|
| 923 |
-
"""Detect both language and emotion from article content."""
|
| 924 |
-
combined = f"{title} {text}"
|
| 925 |
-
lang = detect_language(combined)
|
| 926 |
-
emotion = detect_emotion(combined, lang)
|
| 927 |
-
return lang, emotion
|
| 928 |
-
|
| 929 |
-
# Voice selection based on language and emotion (using MultilingualNeural voices)
|
| 930 |
-
VOICE_BY_LANG_EMOTION = {
|
| 931 |
-
'vietnamese': {
|
| 932 |
-
'happy': ('vi-VN-HoaiMyNeural', 'vui'),
|
| 933 |
-
'sad': ('vi-VN-NamMinhNeural', 'buồn'),
|
| 934 |
-
'excited': ('vi-VN-HoaiMyNeural', 'hào hứng'),
|
| 935 |
-
'humorous': ('vi-VN-HoaiMyNeural', 'vui'),
|
| 936 |
-
'serious': ('vi-VN-NamMinhNeural', 'nghiêm túc'),
|
| 937 |
-
'neutral': ('vi-VN-HoaiMyNeural', 'trung_tinh'),
|
| 938 |
-
},
|
| 939 |
-
'portuguese': {
|
| 940 |
-
'happy': ('pt-BR-ThalitaMultilingualNeural', 'feliz'),
|
| 941 |
-
'sad': ('pt-BR-ThalitaMultilingualNeural', 'triste'),
|
| 942 |
-
'excited': ('pt-BR-ThalitaMultilingualNeural', 'animado'),
|
| 943 |
-
'humorous': ('pt-BR-ThalitaMultilingualNeural', 'engraçado'),
|
| 944 |
-
'serious': ('pt-BR-ThalitaMultilingualNeural', 'sério'),
|
| 945 |
-
'neutral': ('pt-BR-ThalitaMultilingualNeural', 'neutro'),
|
| 946 |
-
},
|
| 947 |
-
'english': {
|
| 948 |
-
'happy': ('en-US-AndrewMultilingualNeural', 'happy'),
|
| 949 |
-
'sad': ('en-AU-WilliamMultilingualNeural', 'sad'),
|
| 950 |
-
'excited': ('en-US-AndrewMultilingualNeural', 'excited'),
|
| 951 |
-
'humorous': ('en-US-AndrewMultilingualNeural', 'funny'),
|
| 952 |
-
'serious': ('en-AU-WilliamMultilingualNeural', 'serious'),
|
| 953 |
-
'neutral': ('en-US-AndrewMultilingualNeural', 'neutral'),
|
| 954 |
-
},
|
| 955 |
-
'french': {
|
| 956 |
-
'happy': ('fr-FR-VivienneMultilingualNeural', 'heureux'),
|
| 957 |
-
'sad': ('fr-FR-RemyMultilingualNeural', 'triste'),
|
| 958 |
-
'excited': ('fr-FR-VivienneMultilingualNeural', 'excité'),
|
| 959 |
-
'humorous': ('fr-FR-VivienneMultilingualNeural', 'drôle'),
|
| 960 |
-
'serious': ('fr-FR-RemyMultilingualNeural', 'sérieux'),
|
| 961 |
-
'neutral': ('fr-FR-VivienneMultilingualNeural', 'neutre'),
|
| 962 |
-
},
|
| 963 |
-
'german': {
|
| 964 |
-
'happy': ('de-DE-SeraphinaMultilingualNeural', 'glücklich'),
|
| 965 |
-
'sad': ('de-DE-FlorianMultilingualNeural', 'traurig'),
|
| 966 |
-
'excited': ('de-DE-SeraphinaMultilingualNeural', 'aufgeregt'),
|
| 967 |
-
'humorous': ('de-DE-SeraphinaMultilingualNeural', 'lustig'),
|
| 968 |
-
'serious': ('de-DE-FlorianMultilingualNeural', 'ernst'),
|
| 969 |
-
'neutral': ('de-DE-SeraphinaMultilingualNeural', 'neutral'),
|
| 970 |
-
},
|
| 971 |
-
'korean': {
|
| 972 |
-
'happy': ('ko-KR-HyunsuMultilingualNeural', '행복'),
|
| 973 |
-
'sad': ('ko-KR-HyunsuMultilingualNeural', '슬픔'),
|
| 974 |
-
'excited': ('ko-KR-HyunsuMultilingualNeural', '흥분'),
|
| 975 |
-
'humorous': ('ko-KR-HyunsuMultilingualNeural', '유쾌'),
|
| 976 |
-
'serious': ('ko-KR-HyunsuMultilingualNeural', '진지'),
|
| 977 |
-
'neutral': ('ko-KR-HyunsuMultilingualNeural', '중립'),
|
| 978 |
-
},
|
| 979 |
-
'italian': {
|
| 980 |
-
'happy': ('it-IT-GiuseppeMultilingualNeural', 'felice'),
|
| 981 |
-
'sad': ('it-IT-GiuseppeMultilingualNeural', 'triste'),
|
| 982 |
-
'excited': ('it-IT-GiuseppeMultilingualNeural', 'emozionato'),
|
| 983 |
-
'humorous': ('it-IT-GiuseppeMultilingualNeural', 'divertente'),
|
| 984 |
-
'serious': ('it-IT-GiuseppeMultilingualNeural', 'serio'),
|
| 985 |
-
'neutral': ('it-IT-GiuseppeMultilingualNeural', 'neutro'),
|
| 986 |
-
},
|
| 987 |
-
}
|
| 988 |
-
|
| 989 |
-
# All valid voice IDs (new MultilingualNeural format)
|
| 990 |
-
VALID_VOICES = {
|
| 991 |
-
'vi-VN-HoaiMyNeural', 'vi-VN-NamMinhNeural',
|
| 992 |
-
'en-US-AndrewMultilingualNeural', 'en-AU-WilliamMultilingualNeural',
|
| 993 |
-
'pt-BR-ThalitaMultilingualNeural',
|
| 994 |
-
'fr-FR-VivienneMultilingualNeural', 'fr-FR-RemyMultilingualNeural',
|
| 995 |
-
'de-DE-SeraphinaMultilingualNeural', 'de-DE-FlorianMultilingualNeural',
|
| 996 |
-
'ko-KR-HyunsuMultilingualNeural',
|
| 997 |
-
'it-IT-GiuseppeMultilingualNeural',
|
| 998 |
-
}
|
| 999 |
-
|
| 1000 |
-
def get_voice_for_content(title, text, preferred_voice=None):
|
| 1001 |
-
"""Get appropriate voice based on content language and emotion."""
|
| 1002 |
-
# Accept the new MultilingualNeural voices directly
|
| 1003 |
-
if preferred_voice and preferred_voice in VALID_VOICES:
|
| 1004 |
-
return preferred_voice
|
| 1005 |
-
|
| 1006 |
-
# Also accept old shorthand voice IDs and map them to new format
|
| 1007 |
-
old_voice_map = {
|
| 1008 |
-
'hoaimy': 'vi-VN-HoaiMyNeural',
|
| 1009 |
-
'namminh': 'vi-VN-NamMinhNeural',
|
| 1010 |
-
'andrew': 'en-US-AndrewMultilingualNeural',
|
| 1011 |
-
'jenny': 'en-US-AndrewMultilingualNeural',
|
| 1012 |
-
'thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 1013 |
-
'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 1014 |
-
'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
|
| 1015 |
-
'ela': 'en-US-AndrewMultilingualNeural',
|
| 1016 |
-
'es_carlos': 'en-US-AndrewMultilingualNeural',
|
| 1017 |
-
'denise': 'fr-FR-VivienneMultilingualNeural',
|
| 1018 |
-
'katja': 'de-DE-SeraphinaMultilingualNeural',
|
| 1019 |
-
'nanami': 'en-US-AndrewMultilingualNeural',
|
| 1020 |
-
'sunhee': 'ko-KR-HyunsuMultilingualNeural',
|
| 1021 |
-
'xiaochen': 'en-US-AndrewMultilingualNeural',
|
| 1022 |
-
}
|
| 1023 |
-
if preferred_voice and preferred_voice in old_voice_map:
|
| 1024 |
-
return old_voice_map[preferred_voice]
|
| 1025 |
-
|
| 1026 |
-
lang, emotion = detect_language_and_emotion(title, text)
|
| 1027 |
-
lang_map = VOICE_BY_LANG_EMOTION.get(lang, VOICE_BY_LANG_EMOTION['vietnamese'])
|
| 1028 |
-
voice, _ = lang_map.get(emotion, lang_map['neutral'])
|
| 1029 |
-
return voice
|
| 1030 |
-
|
| 1031 |
-
|
| 1032 |
-
def _is_relevant_image(img_url, title, text):
|
| 1033 |
-
"""Check if an image is relevant to the article content."""
|
| 1034 |
-
if not img_url:
|
| 1035 |
-
return False
|
| 1036 |
-
skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif',
|
| 1037 |
-
'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite',
|
| 1038 |
-
'advertisement', 'ad-banner', 'sponsored', 'banner-ads']
|
| 1039 |
-
img_lower = img_url.lower()
|
| 1040 |
-
for p in skip_patterns:
|
| 1041 |
-
if p in img_lower:
|
| 1042 |
-
return False
|
| 1043 |
-
if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']):
|
| 1044 |
-
return False
|
| 1045 |
-
return True
|
| 1046 |
-
|
| 1047 |
-
|
| 1048 |
-
def _filter_relevant_images(images, title, text, max_images=8):
|
| 1049 |
-
"""Filter and rank images by relevance to article content."""
|
| 1050 |
-
if not images:
|
| 1051 |
-
return []
|
| 1052 |
-
seen = set()
|
| 1053 |
-
relevant = []
|
| 1054 |
-
for img in images:
|
| 1055 |
-
if img in seen:
|
| 1056 |
-
continue
|
| 1057 |
-
seen.add(img)
|
| 1058 |
-
if _is_relevant_image(img, title, text):
|
| 1059 |
-
relevant.append(img)
|
| 1060 |
-
return relevant[:max_images]
|
| 1061 |
-
|
| 1062 |
-
|
| 1063 |
-
def _scrape_article_for_rewrite(url):
|
| 1064 |
-
"""Scrape article: extract title, paragraphs, RELEVANT images, OG image."""
|
| 1065 |
-
try:
|
| 1066 |
-
r = req.get(url, headers=_UA_RW, timeout=15, allow_redirects=True)
|
| 1067 |
-
r.encoding = 'utf-8'
|
| 1068 |
-
soup = BeautifulSoup(r.text, 'lxml')
|
| 1069 |
-
for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
|
| 1070 |
-
tag.decompose()
|
| 1071 |
-
h1 = soup.find('h1')
|
| 1072 |
-
ogt = soup.find('meta', property='og:title')
|
| 1073 |
-
title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '')
|
| 1074 |
-
ogi = soup.find('meta', property='og:image')
|
| 1075 |
-
og_img = ogi.get('content', '') if ogi else ''
|
| 1076 |
-
if og_img and og_img.startswith('//'):
|
| 1077 |
-
og_img = 'https:' + og_img
|
| 1078 |
-
block = None
|
| 1079 |
-
for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
|
| 1080 |
-
el = soup.select_one(sel)
|
| 1081 |
-
if el and len(el.find_all('p')) >= 2:
|
| 1082 |
-
block = el
|
| 1083 |
-
break
|
| 1084 |
-
if not block:
|
| 1085 |
-
block = soup.body or soup
|
| 1086 |
-
paragraphs = []
|
| 1087 |
-
all_images = []
|
| 1088 |
-
seen_imgs = set()
|
| 1089 |
-
if og_img and og_img not in seen_imgs:
|
| 1090 |
-
all_images.append(og_img)
|
| 1091 |
-
seen_imgs.add(og_img)
|
| 1092 |
-
for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
|
| 1093 |
-
if el.name == 'p':
|
| 1094 |
-
t = _clean(el.get_text(strip=True))
|
| 1095 |
-
if t and len(t) > 40:
|
| 1096 |
-
paragraphs.append(t)
|
| 1097 |
-
elif el.name in ('figure', 'img'):
|
| 1098 |
-
im = el if el.name == 'img' else el.find('img')
|
| 1099 |
-
if im:
|
| 1100 |
-
src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
|
| 1101 |
-
if src and 'base64' not in src:
|
| 1102 |
-
if src.startswith('//'):
|
| 1103 |
-
src = 'https:' + src
|
| 1104 |
-
if src not in seen_imgs:
|
| 1105 |
-
all_images.append(src)
|
| 1106 |
-
seen_imgs.add(src)
|
| 1107 |
-
# Filter to relevant images only
|
| 1108 |
-
relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5]))
|
| 1109 |
-
return {'title': _clean(title), 'paragraphs': paragraphs, 'images': relevant_images, 'og_img': og_img}
|
| 1110 |
-
except Exception:
|
| 1111 |
-
return None
|
| 1112 |
-
|
| 1113 |
-
|
| 1114 |
-
def _extract_key_points_rw(paragraphs, max_points=5):
|
| 1115 |
-
r"""Extract key points from paragraphs - extracts ALL sentences, not just first one.
|
| 1116 |
-
|
| 1117 |
-
Fixes: Original regex `^(.+?[.!?])\s` only captured first sentence per paragraph.
|
| 1118 |
-
Now splits on all sentence boundaries and takes valid sentences until max_points.
|
| 1119 |
-
"""
|
| 1120 |
-
points = []
|
| 1121 |
-
|
| 1122 |
-
for p in paragraphs:
|
| 1123 |
-
if len(points) >= max_points:
|
| 1124 |
-
break
|
| 1125 |
-
|
| 1126 |
-
p = _clean(p)
|
| 1127 |
-
if not p:
|
| 1128 |
-
continue
|
| 1129 |
-
|
| 1130 |
-
# Split paragraph into sentences using Vietnamese + English punctuation
|
| 1131 |
-
sentences = re.split(r'(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])', p)
|
| 1132 |
-
sentences = [s.strip() for s in sentences if s.strip()]
|
| 1133 |
-
|
| 1134 |
-
for sentence in sentences:
|
| 1135 |
-
if len(points) >= max_points:
|
| 1136 |
-
break
|
| 1137 |
-
|
| 1138 |
-
# Clean sentence - remove extra whitespace
|
| 1139 |
-
sentence = _clean(sentence)
|
| 1140 |
-
|
| 1141 |
-
if len(sentence) < 30:
|
| 1142 |
-
continue
|
| 1143 |
-
|
| 1144 |
-
# Check for duplicates
|
| 1145 |
-
if any(sentence[:60] in existing for existing in points):
|
| 1146 |
-
continue
|
| 1147 |
-
|
| 1148 |
-
# Ensure sentence ends with punctuation
|
| 1149 |
-
if not sentence.endswith(('.', '!', '?')):
|
| 1150 |
-
sentence = sentence + '.'
|
| 1151 |
-
|
| 1152 |
-
points.append(sentence)
|
| 1153 |
-
|
| 1154 |
-
# If no valid sentences found, take chunks from raw text
|
| 1155 |
-
if not points:
|
| 1156 |
-
raw = '\n'.join(paragraphs)
|
| 1157 |
-
for i in range(0, min(len(raw), max_points * 300), 280):
|
| 1158 |
-
chunk = _clean(raw[i:i+280])
|
| 1159 |
-
if len(chunk) >= 30 and chunk not in points:
|
| 1160 |
-
points.append(chunk + ('.' if not chunk.endswith('.') else ''))
|
| 1161 |
-
if len(points) >= max_points:
|
| 1162 |
-
break
|
| 1163 |
-
|
| 1164 |
-
return points
|
| 1165 |
-
|
| 1166 |
-
|
| 1167 |
-
@app.post("/api/rewrite_slide")
|
| 1168 |
-
async def api_rewrite_slide(request: Request):
|
| 1169 |
-
"""Fast rewrite as SLIDES - no AI needed, instant response."""
|
| 1170 |
-
body = await request.json()
|
| 1171 |
-
url = _clean(body.get("url", ""))
|
| 1172 |
-
context = body.get("context", "")
|
| 1173 |
-
preferred_voice = body.get("voice", "") # Accept custom voice selection
|
| 1174 |
-
if not url and not context:
|
| 1175 |
-
return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
|
| 1176 |
-
data = None
|
| 1177 |
-
if url and url.startswith("http"):
|
| 1178 |
-
data = _scrape_article_for_rewrite(url)
|
| 1179 |
-
if not data and context:
|
| 1180 |
-
paragraphs = [_clean(p) for p in context.split('\n') if len(_clean(p)) > 40]
|
| 1181 |
-
data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
|
| 1182 |
-
if not data or not data.get('paragraphs'):
|
| 1183 |
-
return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
|
| 1184 |
-
points = _extract_key_points_rw(data['paragraphs'], max_points=12)
|
| 1185 |
-
if not points:
|
| 1186 |
-
return JSONResponse({"error": "Không tìm được ý chính"}, status_code=422)
|
| 1187 |
-
images = data.get('images', [])
|
| 1188 |
-
slides = []
|
| 1189 |
-
for i, point in enumerate(points):
|
| 1190 |
-
img = images[i] if i < len(images) else (images[-1] if images else '')
|
| 1191 |
-
if img and 'cdnphoto.dantri' in img:
|
| 1192 |
-
img = '/api/proxy/img?url=' + _quote2(img, safe='')
|
| 1193 |
-
slides.append({'text': point, 'image': img, 'index': i + 1})
|
| 1194 |
-
summary_text = '\n\n'.join([f"• {s['text']}" for s in slides])
|
| 1195 |
-
|
| 1196 |
-
# Auto-detect language and emotion
|
| 1197 |
-
lang, emotion = detect_language_and_emotion(data['title'], summary_text)
|
| 1198 |
-
# Use preferred voice if provided, otherwise auto-detect
|
| 1199 |
-
voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], summary_text)
|
| 1200 |
-
|
| 1201 |
-
post = {
|
| 1202 |
-
"id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
|
| 1203 |
-
"title": data['title'],
|
| 1204 |
-
"text": summary_text,
|
| 1205 |
-
"img": images[0] if images else '',
|
| 1206 |
-
"url": url,
|
| 1207 |
-
"kind": "slide_summary",
|
| 1208 |
-
"slides": slides,
|
| 1209 |
-
"images": images[:10],
|
| 1210 |
-
"video": "",
|
| 1211 |
-
"voice": voice,
|
| 1212 |
-
"emotion": emotion,
|
| 1213 |
-
"language": lang,
|
| 1214 |
-
"ts": int(time.time())
|
| 1215 |
-
}
|
| 1216 |
-
posts = _load_wall_posts()
|
| 1217 |
-
posts.insert(0, post)
|
| 1218 |
-
_save_wall_posts(posts)
|
| 1219 |
-
return JSONResponse({"post": post, "slides": slides})
|
| 1220 |
-
|
| 1221 |
-
|
| 1222 |
-
@app.post("/api/rewrite_share")
|
| 1223 |
-
async def api_rewrite_share(request: Request):
|
| 1224 |
-
"""Rewrite article and post to Tường AI with SLIDES + AI text."""
|
| 1225 |
-
body = await request.json()
|
| 1226 |
-
url = _clean(body.get("url", ""))
|
| 1227 |
-
ctx = _clean(body.get("context", ""))
|
| 1228 |
-
preferred_voice = body.get("voice", "") # Accept custom voice selection
|
| 1229 |
-
if not url and not ctx:
|
| 1230 |
-
return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
|
| 1231 |
-
data = None
|
| 1232 |
-
if url and url.startswith("http"):
|
| 1233 |
-
data = _scrape_article_for_rewrite(url)
|
| 1234 |
-
if not data and ctx:
|
| 1235 |
-
paragraphs = [_clean(p) for p in ctx.split('\n') if len(_clean(p)) > 40]
|
| 1236 |
-
data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
|
| 1237 |
-
if not data or not data.get('paragraphs'):
|
| 1238 |
-
return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
|
| 1239 |
-
raw_text = '\n'.join(data['paragraphs'])
|
| 1240 |
-
if len(raw_text) < 50:
|
| 1241 |
-
raw_text = ctx[:14000]
|
| 1242 |
-
if len(raw_text) < 50:
|
| 1243 |
-
return JSONResponse({"error": "Bài viết quá ngắn"}, status_code=422)
|
| 1244 |
-
domain = ''
|
| 1245 |
-
try:
|
| 1246 |
-
from urllib.parse import urlparse
|
| 1247 |
-
domain = urlparse(url).netloc.replace('www.', '')
|
| 1248 |
-
except:
|
| 1249 |
-
pass
|
| 1250 |
-
|
| 1251 |
-
# Generate AI summary text
|
| 1252 |
-
ai_text = None
|
| 1253 |
-
try:
|
| 1254 |
-
import ai_ext
|
| 1255 |
-
if hasattr(ai_ext, 'qwen_generate'):
|
| 1256 |
-
prompt = f'Tóm tắt đăng Tường AI:\nTiêu đề: {data["title"]}\n{raw_text[:14000]}\n\n4-6 ý chính. Cuối ghi nguồn.'
|
| 1257 |
-
ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000)
|
| 1258 |
-
except Exception:
|
| 1259 |
-
pass
|
| 1260 |
-
if not ai_text or len(ai_text) < 80:
|
| 1261 |
-
key_pts = _extract_key_points_rw(data['paragraphs'], max_points=12)
|
| 1262 |
-
if key_pts:
|
| 1263 |
-
ai_text = '\n\n'.join([f"• {p}" for p in key_pts])
|
| 1264 |
-
else:
|
| 1265 |
-
ai_text = f"Tóm tắt: {data['title']}\n\n{raw_text[:1200]}\n\nNguồn: {domain}"
|
| 1266 |
-
|
| 1267 |
-
# Build slides from key points (FIX: include slides in rewrite_share too!)
|
| 1268 |
-
points = _extract_key_points_rw(data['paragraphs'], max_points=12)
|
| 1269 |
-
images = data.get('images', [])
|
| 1270 |
-
slides = []
|
| 1271 |
-
for i, point in enumerate(points):
|
| 1272 |
-
img = images[i] if i < len(images) else (images[-1] if images else '')
|
| 1273 |
-
if img and 'cdnphoto.dantri' in img:
|
| 1274 |
-
img = '/api/proxy/img?url=' + _quote2(img, safe='')
|
| 1275 |
-
slides.append({'text': point, 'image': img, 'index': i + 1})
|
| 1276 |
-
|
| 1277 |
-
# Auto-detect language and emotion
|
| 1278 |
-
lang, emotion = detect_language_and_emotion(data['title'], ai_text)
|
| 1279 |
-
# Use preferred voice if provided, otherwise auto-detect
|
| 1280 |
-
voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], ai_text)
|
| 1281 |
-
|
| 1282 |
-
post = {
|
| 1283 |
-
"id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
|
| 1284 |
-
"title": data['title'],
|
| 1285 |
-
"text": ai_text,
|
| 1286 |
-
"img": images[0] if images else '',
|
| 1287 |
-
"url": url,
|
| 1288 |
-
"kind": "rewrite",
|
| 1289 |
-
"slides": slides,
|
| 1290 |
-
"images": images[:10],
|
| 1291 |
-
"video": "",
|
| 1292 |
-
"voice": voice,
|
| 1293 |
-
"emotion": emotion,
|
| 1294 |
-
"language": lang,
|
| 1295 |
-
"ts": int(time.time())
|
| 1296 |
-
}
|
| 1297 |
-
posts = _load_wall_posts()
|
| 1298 |
-
posts.insert(0, post)
|
| 1299 |
-
_save_wall_posts(posts)
|
| 1300 |
-
return JSONResponse({"post": post, "slides": slides})
|
| 1301 |
-
|
| 1302 |
-
|
| 1303 |
-
@app.post("/api/url_wall")
|
| 1304 |
-
async def api_url_wall(request: Request):
|
| 1305 |
-
"""Submit URL to add to Tường AI."""
|
| 1306 |
-
body = await request.json()
|
| 1307 |
-
url = _clean(body.get("url", ""))
|
| 1308 |
-
if not url or not url.startswith('http'):
|
| 1309 |
-
return JSONResponse({"error": "URL không hợp lệ"}, status_code=400)
|
| 1310 |
-
# Reuse rewrite_share logic
|
| 1311 |
-
req._body = json.dumps({"url": url}).encode()
|
| 1312 |
-
return await api_rewrite_share(request)
|
| 1313 |
|
|
|
|
| 1314 |
|
| 1315 |
def _bg():
|
| 1316 |
time.sleep(15)
|
|
@@ -1320,280 +726,4 @@ def _bg():
|
|
| 1320 |
time.sleep(90)
|
| 1321 |
threading.Thread(target=_bg,daemon=True).start()
|
| 1322 |
|
| 1323 |
-
|
| 1324 |
-
_AUTO_SCHEDULE_TIMES = [(7, '07:00'), (13, '13:00'), (19, '19:00')]
|
| 1325 |
-
_AUTO_LOG = os.path.join(DATA_DIR, 'auto_rewrite_log.json')
|
| 1326 |
-
|
| 1327 |
-
def _load_auto_log():
|
| 1328 |
-
try:
|
| 1329 |
-
if os.path.exists(_AUTO_LOG):
|
| 1330 |
-
with open(_AUTO_LOG, 'r') as f:
|
| 1331 |
-
return json.load(f)
|
| 1332 |
-
except: pass
|
| 1333 |
-
return {}
|
| 1334 |
-
|
| 1335 |
-
def _save_auto_log(log):
|
| 1336 |
-
try:
|
| 1337 |
-
tmp = _AUTO_LOG + '.tmp'
|
| 1338 |
-
with open(tmp, 'w') as f:
|
| 1339 |
-
json.dump(log, f)
|
| 1340 |
-
os.replace(tmp, _AUTO_LOG)
|
| 1341 |
-
except: pass
|
| 1342 |
-
|
| 1343 |
-
async def _auto_fetch_short(post_id):
|
| 1344 |
-
"""Try to auto-generate a short for a post."""
|
| 1345 |
-
try:
|
| 1346 |
-
import httpx
|
| 1347 |
-
async with httpx.AsyncClient(timeout=180) as cl:
|
| 1348 |
-
r = await cl.post(
|
| 1349 |
-
f"http://localhost:7860/api/ai/short/{post_id}",
|
| 1350 |
-
json={"voice":"vi-VN-HoaiMyNeural","emotion":"neutral","speed":1.2},
|
| 1351 |
-
headers={"Content-Type":"application/json"}
|
| 1352 |
-
)
|
| 1353 |
-
if r.status_code < 300:
|
| 1354 |
-
sj = r.json()
|
| 1355 |
-
if sj.get('video'):
|
| 1356 |
-
posts = _load_wall_posts()
|
| 1357 |
-
for p in posts:
|
| 1358 |
-
if p.get('id') == post_id:
|
| 1359 |
-
p['video'] = sj['video']
|
| 1360 |
-
break
|
| 1361 |
-
_save_wall_posts(posts)
|
| 1362 |
-
return True
|
| 1363 |
-
except: pass
|
| 1364 |
-
return False
|
| 1365 |
-
|
| 1366 |
-
async def _auto_rewrite_one(topic, slot_label, used_urls=None, post_index=0):
|
| 1367 |
-
"""Rewrite one topic: find articles, summarize, post to wall, trigger short.
|
| 1368 |
-
used_urls: shared set to avoid duplicate articles across topics.
|
| 1369 |
-
post_index: 0-based index to create multiple posts per topic (0,1,2 = up to 3 posts)."""
|
| 1370 |
-
from urllib.parse import quote as _q
|
| 1371 |
-
# Get MORE items to support 1-3 posts per topic
|
| 1372 |
-
items = _search_all(topic, limit=12)
|
| 1373 |
-
# Skip URLs already used by another topic
|
| 1374 |
-
if used_urls is not None:
|
| 1375 |
-
filtered = [it for it in items if it.get('url') not in used_urls]
|
| 1376 |
-
if filtered:
|
| 1377 |
-
items = filtered
|
| 1378 |
-
if not items or post_index >= len(items):
|
| 1379 |
-
return False
|
| 1380 |
-
|
| 1381 |
-
# Get article at post_index (0,1,2 for multiple posts)
|
| 1382 |
-
item = items[post_index] # post_index allows multiple articles per topic
|
| 1383 |
-
url = item.get('url', '')
|
| 1384 |
-
title = item.get('title', topic)
|
| 1385 |
-
if url and used_urls is not None:
|
| 1386 |
-
used_urls.add(url)
|
| 1387 |
-
if not url.startswith('http'):
|
| 1388 |
-
return False
|
| 1389 |
-
|
| 1390 |
-
data = _scrape_article_for_rewrite(url)
|
| 1391 |
-
if not data or not data.get('paragraphs'):
|
| 1392 |
-
return False
|
| 1393 |
-
|
| 1394 |
-
raw_text = '\n'.join(data['paragraphs'])
|
| 1395 |
-
ai_text = None
|
| 1396 |
-
|
| 1397 |
-
# Try AI generation
|
| 1398 |
-
try:
|
| 1399 |
-
import ai_ext
|
| 1400 |
-
prompt = f"Tóm tắt tin tức (tự động {slot_label}):\nTiêu đề: {data['title']}\n{raw_text[:10000]}\n\n4-6 ý chính dạng bullet. Cuối ghi nguồn."
|
| 1401 |
-
ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000)
|
| 1402 |
-
except: pass
|
| 1403 |
-
|
| 1404 |
-
if not ai_text or len(ai_text) < 80:
|
| 1405 |
-
pts = data['paragraphs'][:6]
|
| 1406 |
-
ai_text = '\n\n'.join([f"• {p[:300]}" for p in pts])
|
| 1407 |
-
via = item.get('via', '') or urlparse(url).netloc.replace('www.', '')
|
| 1408 |
-
ai_text += f"\n\nNguồn tham khảo: {via}"
|
| 1409 |
-
|
| 1410 |
-
# Build slides
|
| 1411 |
-
images = data.get('images', [])
|
| 1412 |
-
pts = data['paragraphs'][:10]
|
| 1413 |
-
slides = []
|
| 1414 |
-
for i, p in enumerate(pts[:8]):
|
| 1415 |
-
img = images[i] if i < len(images) else (images[-1] if images else data.get('og_img', ''))
|
| 1416 |
-
slides.append({'text': p[:300], 'image': img, 'index': i + 1})
|
| 1417 |
-
|
| 1418 |
-
post_id = str(int(time.time() * 1000)) + str(_random2.randint(100, 999))
|
| 1419 |
-
post = {
|
| 1420 |
-
"id": post_id, "title": data.get('title', title)[:200],
|
| 1421 |
-
"text": ai_text, "img": images[0] if images else data.get('og_img', ''),
|
| 1422 |
-
"url": url, "kind": "auto_rewrite", "slides": slides,
|
| 1423 |
-
"images": images[:10], "video": "",
|
| 1424 |
-
"voice": "vi-VN-HoaiMyNeural", "emotion": "neutral",
|
| 1425 |
-
"language": "vietnamese", "ts": int(time.time()),
|
| 1426 |
-
"auto_scheduled": True, "slot": slot_label,
|
| 1427 |
-
}
|
| 1428 |
-
|
| 1429 |
-
posts = _load_wall_posts()
|
| 1430 |
-
posts.insert(0, post)
|
| 1431 |
-
_save_wall_posts(posts)
|
| 1432 |
-
|
| 1433 |
-
# Trigger short generation async
|
| 1434 |
-
threading.Thread(target=lambda: asyncio.run(_auto_fetch_short(post_id)), daemon=True).start()
|
| 1435 |
-
return True
|
| 1436 |
-
|
| 1437 |
-
async def _do_scheduled_run(slot_label):
|
| 1438 |
-
"""Main scheduled run: 1-3 posts from 3 different HOT topics (3-9 total), no duplicates."""
|
| 1439 |
-
print(f"[auto] Starting scheduled rewrite for {slot_label}")
|
| 1440 |
-
|
| 1441 |
-
# Get top hot topics, skip duplicates
|
| 1442 |
-
all_topics = _get_hot_topics()
|
| 1443 |
-
seen_topics = set()
|
| 1444 |
-
unique_topics = []
|
| 1445 |
-
for t in all_topics:
|
| 1446 |
-
kw = t.get('topic', '').lower().strip()
|
| 1447 |
-
if kw and len(kw) > 5 and kw not in seen_topics:
|
| 1448 |
-
is_dup = False
|
| 1449 |
-
for s in seen_topics:
|
| 1450 |
-
# Check if one topic is substring of another
|
| 1451 |
-
if kw in s or s in kw:
|
| 1452 |
-
is_dup = True
|
| 1453 |
-
break
|
| 1454 |
-
if not is_dup:
|
| 1455 |
-
seen_topics.add(kw)
|
| 1456 |
-
unique_topics.append(t)
|
| 1457 |
-
if len(unique_topics) >= 3:
|
| 1458 |
-
break
|
| 1459 |
-
|
| 1460 |
-
job_topics = [t['topic'] for t in unique_topics[:3] if t.get('topic')]
|
| 1461 |
-
if not job_topics:
|
| 1462 |
-
print(f"[auto] No hot topics found, skipping")
|
| 1463 |
-
return
|
| 1464 |
-
|
| 1465 |
-
print(f"[auto] Running 3 topics: {job_topics}")
|
| 1466 |
-
|
| 1467 |
-
# Track used URLs to avoid cross-topic duplicates
|
| 1468 |
-
_used_urls = set()
|
| 1469 |
-
results = []
|
| 1470 |
-
|
| 1471 |
-
# Process each topic, create 1-3 posts per topic
|
| 1472 |
-
for jt in job_topics:
|
| 1473 |
-
for post_idx in range(3): # Try up to 3 posts per topic
|
| 1474 |
-
try:
|
| 1475 |
-
ok = await asyncio.wait_for(_auto_rewrite_one(jt, slot_label, _used_urls, post_idx), timeout=120)
|
| 1476 |
-
if ok:
|
| 1477 |
-
results.append((jt, post_idx, True))
|
| 1478 |
-
print(f"[auto] Created post {post_idx+1} for '{jt}'")
|
| 1479 |
-
else:
|
| 1480 |
-
# No more articles for this topic
|
| 1481 |
-
break
|
| 1482 |
-
except Exception as e:
|
| 1483 |
-
print(f"[auto] Error on '{jt}' post {post_idx}: {e}")
|
| 1484 |
-
results.append((jt, post_idx, False))
|
| 1485 |
-
await asyncio.sleep(1) # Small delay between posts
|
| 1486 |
-
|
| 1487 |
-
# Ensure at least 3 posts total (fallback if needed)
|
| 1488 |
-
successful_posts = sum(1 for _, _, ok in results if ok)
|
| 1489 |
-
print(f"[auto] Done {slot_label}: {successful_posts} posts created")
|
| 1490 |
-
|
| 1491 |
-
# Log
|
| 1492 |
-
from datetime import datetime, timezone, timedelta
|
| 1493 |
-
VN_TZ_SCHED = timezone(timedelta(hours=7))
|
| 1494 |
-
today_str = datetime.now(VN_TZ_SCHED).strftime('%Y-%m-%d')
|
| 1495 |
-
log = _load_auto_log()
|
| 1496 |
-
if today_str not in log: log[today_str] = {}
|
| 1497 |
-
log[today_str][slot_label] = {
|
| 1498 |
-
'time': datetime.now(VN_TZ_SCHED).strftime('%H:%M:%S'),
|
| 1499 |
-
'count': successful_posts,
|
| 1500 |
-
'total': len(job_topics),
|
| 1501 |
-
}
|
| 1502 |
-
_save_auto_log(log)
|
| 1503 |
-
|
| 1504 |
-
def _scheduler_loop():
|
| 1505 |
-
"""Check every 60s; trigger at 7:00, 13:00, 19:00 VN time.
|
| 1506 |
-
On startup, check for any missed slots today and run them immediately."""
|
| 1507 |
-
time.sleep(35)
|
| 1508 |
-
from datetime import datetime, timezone, timedelta
|
| 1509 |
-
VN_TZ_SCHED = timezone(timedelta(hours=7))
|
| 1510 |
-
|
| 1511 |
-
_last_run_date = ""
|
| 1512 |
-
_last_run_slots = set()
|
| 1513 |
-
|
| 1514 |
-
# On startup: check log for missed slots today
|
| 1515 |
-
try:
|
| 1516 |
-
start_now = datetime.now(VN_TZ_SCHED)
|
| 1517 |
-
today_str = start_now.strftime('%Y-%m-%d')
|
| 1518 |
-
current_hour = start_now.hour
|
| 1519 |
-
current_minute = start_now.minute
|
| 1520 |
-
log = _load_auto_log()
|
| 1521 |
-
today_log = log.get(today_str, {})
|
| 1522 |
-
for h, label in _AUTO_SCHEDULE_TIMES:
|
| 1523 |
-
# Run if slot is past (either strictly earlier hour, or same hour but window has passed)
|
| 1524 |
-
should_run = False
|
| 1525 |
-
if h < current_hour:
|
| 1526 |
-
should_run = True
|
| 1527 |
-
elif h == current_hour and current_minute > 10:
|
| 1528 |
-
should_run = True
|
| 1529 |
-
if should_run and label not in today_log:
|
| 1530 |
-
print(f"[auto] Detected missed slot {label} (h={h} < now={current_hour}:{current_minute}), running catch-up now")
|
| 1531 |
-
_run_scheduled_sync(label)
|
| 1532 |
-
_last_run_slots.add(label)
|
| 1533 |
-
except Exception as e:
|
| 1534 |
-
print(f"[auto] Catch-up check error: {e}")
|
| 1535 |
-
|
| 1536 |
-
while True:
|
| 1537 |
-
try:
|
| 1538 |
-
now = datetime.now(VN_TZ_SCHED)
|
| 1539 |
-
today = now.strftime('%Y-%m-%d')
|
| 1540 |
-
hour = now.hour
|
| 1541 |
-
minute = now.minute
|
| 1542 |
-
|
| 1543 |
-
if today != _last_run_date:
|
| 1544 |
-
_last_run_date = today
|
| 1545 |
-
_last_run_slots = set()
|
| 1546 |
-
|
| 1547 |
-
slot = None
|
| 1548 |
-
for h, label in _AUTO_SCHEDULE_TIMES:
|
| 1549 |
-
if hour == h and 0 <= minute < 5:
|
| 1550 |
-
slot = label
|
| 1551 |
-
break
|
| 1552 |
-
|
| 1553 |
-
if slot and slot not in _last_run_slots:
|
| 1554 |
-
_last_run_slots.add(slot)
|
| 1555 |
-
_run_scheduled_sync(slot)
|
| 1556 |
-
except Exception as e:
|
| 1557 |
-
print(f"[auto] Loop error: {e}")
|
| 1558 |
-
|
| 1559 |
-
time.sleep(60)
|
| 1560 |
-
|
| 1561 |
-
threading.Thread(target=_scheduler_loop, daemon=True, name='auto-rewrite-scheduler').start()
|
| 1562 |
-
|
| 1563 |
-
@app.get('/api/debug/auto_schedule')
|
| 1564 |
-
async def debug_auto_schedule(slot: str = '07:00'):
|
| 1565 |
-
"""Manually trigger auto scheduler for debugging."""
|
| 1566 |
-
try:
|
| 1567 |
-
# Check if we can access the data directory
|
| 1568 |
-
log = _load_auto_log()
|
| 1569 |
-
topics = _get_hot_topics()[:3]
|
| 1570 |
-
job_topics = [t['topic'] for t in topics if t.get('topic')]
|
| 1571 |
-
return JSONResponse({
|
| 1572 |
-
"slot": slot,
|
| 1573 |
-
"log": log,
|
| 1574 |
-
"hot_topics": job_topics,
|
| 1575 |
-
"wall_posts_count": len(_load_wall_posts()),
|
| 1576 |
-
"data_dir_writable": os.access(DATA_DIR, os.W_OK) if os.path.isdir(DATA_DIR) else False,
|
| 1577 |
-
"data_dir_exists": os.path.isdir(DATA_DIR),
|
| 1578 |
-
})
|
| 1579 |
-
except Exception as e:
|
| 1580 |
-
return JSONResponse({"error": str(e)}, status_code=500)
|
| 1581 |
-
|
| 1582 |
-
def _run_scheduled_sync(slot):
|
| 1583 |
-
"""Run _do_scheduled_run in a separate event loop (for background thread)."""
|
| 1584 |
-
loop = asyncio.new_event_loop()
|
| 1585 |
-
asyncio.set_event_loop(loop)
|
| 1586 |
-
try:
|
| 1587 |
-
loop.run_until_complete(_do_scheduled_run(slot))
|
| 1588 |
-
except Exception as e:
|
| 1589 |
-
print(f"[auto] Background run error: {e}")
|
| 1590 |
-
finally:
|
| 1591 |
-
loop.close()
|
| 1592 |
-
|
| 1593 |
-
@app.get('/api/debug/trigger_auto')
|
| 1594 |
-
async def debug_trigger_auto(slot: str = '19:00'):
|
| 1595 |
-
"""Trigger _do_scheduled_run in background thread (non-blocking)."""
|
| 1596 |
-
threading.Thread(target=_run_scheduled_sync, args=(slot,), daemon=True).start()
|
| 1597 |
-
return JSONResponse({"status": "started", "slot": slot})
|
| 1598 |
-
|
| 1599 |
-
app.mount('/static',StaticFiles(directory=STATIC_DIR),name='vnews_static')
|
|
|
|
| 1 |
+
"""VNEWS v2 Entry Point - with fast bongda proxy"""
|
| 2 |
import sys, os
|
| 3 |
from main import app, HEADERS, BONGDA_HEADERS, fetch_bongda_api, HL_LEAGUES
|
| 4 |
|
|
|
|
| 7 |
except Exception as e:
|
| 8 |
print(f"[WARN] ai_ext import failed: {e}")
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response
|
| 11 |
from fastapi.staticfiles import StaticFiles
|
| 12 |
from starlette.routing import Mount
|
|
|
|
| 16 |
import re, html as html_lib, json, threading, time, uuid
|
| 17 |
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 18 |
from urllib.parse import quote
|
|
|
|
| 19 |
|
| 20 |
HL_LEAGUES['friendly'] = {"path": "giai-khac/friendly", "name": "Giao hữu", "emoji": "🤝"}
|
| 21 |
|
| 22 |
STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
|
|
|
|
| 23 |
app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)=='/' and hasattr(r,'methods') and 'GET' in getattr(r,'methods',set()))]
|
| 24 |
app.routes[:]=[r for r in app.routes if not isinstance(r, Mount)]
|
| 25 |
app.router.routes=[r for r in app.router.routes if not isinstance(r, Mount)]
|
|
|
|
| 322 |
if i<len(s) and s[i].get('url') and s[i]['url'] not in seen:seen.add(s[i]['url']);out.append(s[i])
|
| 323 |
return out[:limit]
|
| 324 |
|
| 325 |
+
app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)=='/api/article' and 'GET' in getattr(r,'methods',set()))]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
|
| 327 |
+
def _scrape_generic(url):
|
|
|
|
|
|
|
|
|
|
| 328 |
try:
|
| 329 |
+
r=req.get(url,headers={'User-Agent':'Mozilla/5.0','Accept-Language':'vi-VN,vi;q=0.9'},timeout=15,allow_redirects=True);r.encoding='utf-8';soup=BeautifulSoup(r.text,'lxml')
|
| 330 |
+
for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript']):tag.decompose()
|
| 331 |
+
h1=soup.find('h1');ogt=soup.find('meta',property='og:title');title=(h1.get_text(strip=True) if h1 else '')or(ogt.get('content','') if ogt else '')
|
| 332 |
+
ogd=soup.find('meta',property='og:description');summary=ogd.get('content','') if ogd else ''
|
| 333 |
+
ogi=soup.find('meta',property='og:image');og_img=ogi.get('content','') if ogi else ''
|
| 334 |
+
if og_img and og_img.startswith('//'):og_img='https:'+og_img
|
| 335 |
+
block=None
|
| 336 |
+
for sel in['article','.singular-content','.detail-content','.fck_detail','.content-detail','.knc-content','main','.cms-body','.article__body']:
|
| 337 |
+
el=soup.select_one(sel)
|
| 338 |
+
if el and len(el.find_all('p'))>=2:block=el;break
|
| 339 |
+
if not block:block=soup.body or soup
|
| 340 |
+
body=[]
|
| 341 |
+
for el in block.find_all(['p','h2','h3','figure','img'],recursive=True):
|
| 342 |
+
if el.name=='p':t=el.get_text(strip=True);(body.append({'type':'p','text':t}) if t and len(t)>30 else None)
|
| 343 |
+
elif el.name in('h2','h3'):t=el.get_text(strip=True);(body.append({'type':'heading','text':t}) if t else None)
|
| 344 |
+
elif el.name in('figure','img'):
|
| 345 |
+
im=el if el.name=='img' else el.find('img')
|
| 346 |
+
if im:src=im.get('data-src') or im.get('src') or'';(body.append({'type':'img','src':'https:'+src if src.startswith('//') else src}) if src and'base64' not in src else None)
|
| 347 |
+
if not body and summary:body=[{'type':'p','text':summary}]
|
| 348 |
+
return{'title':_clean(title),'summary':_clean(summary),'og_image':og_img,'body':body[:50],'source':'generic','url':url}
|
| 349 |
+
except:return None
|
| 350 |
+
|
| 351 |
+
@app.get('/api/article')
|
| 352 |
+
def api_article_v2(url:str=Query(...)):
|
| 353 |
+
from main import scrape_vne_article,scrape_bbc_article,scrape_dantri_article,scrape_genk_article,scrape_ttvh_article
|
| 354 |
+
if 'vnexpress.net' in url:data=scrape_vne_article(url)
|
| 355 |
+
elif 'bbc.com' in url:data=scrape_bbc_article(url)
|
| 356 |
+
elif 'dantri.com.vn' in url:data=scrape_dantri_article(url)
|
| 357 |
+
elif 'genk.vn' in url:data=scrape_genk_article(url)
|
| 358 |
+
elif 'thethaovanhoa.vn' in url:data=scrape_ttvh_article(url)
|
| 359 |
+
else:data=_scrape_generic(url)
|
| 360 |
+
if data and data.get('body'):return JSONResponse(data)
|
| 361 |
+
return JSONResponse(data if data else{'error':'Không đọc được','url':url})
|
| 362 |
|
| 363 |
_hot_cache={'t':0,'d':[]}
|
| 364 |
def _get_hot_topics():
|
|
|
|
| 391 |
_hot_cache.update({'t':now,'d':topics[:24]});return topics[:24]
|
| 392 |
|
| 393 |
@app.get('/api/hot_topics')
|
| 394 |
+
def api_hot_topics():return JSONResponse({'topics':_get_hot_topics()})
|
|
|
|
|
|
|
|
|
|
| 395 |
@app.get('/')
|
| 396 |
async def serve_index():
|
| 397 |
p=os.path.join(STATIC_DIR,'index_v2.html')
|
|
|
|
| 405 |
def _cat():return JSONResponse([])
|
| 406 |
@app.get('/api/storage_status')
|
| 407 |
def _st():return JSONResponse({'persistent':os.path.isdir('/data') and os.access('/data',os.W_OK)})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
@app.get('/s')
|
| 409 |
+
async def _sh(url:str='',title:str='',img:str=''):return HTMLResponse(f'<!DOCTYPE html><html><head><meta property="og:title" content="{_clean(title)}"><meta property="og:image" content="{_clean(img)}"><meta http-equiv="refresh" content="0;url={_clean(url) or "/"}"></head><body></body></html>')
|
| 410 |
+
|
| 411 |
+
from wc2026_scraper import(scrape_summary,scrape_fixtures,scrape_standings,scrape_stats,scrape_wc_news,scrape_road_to_wc,get_wc2026_all,scrape_history,scrape_h2h,scrape_lineups,scrape_match_detail)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
|
| 413 |
+
# === XEMLAIBONGDA.PROXY (CORS workaround for WC highlights) ===
|
| 414 |
_xlb_cache = {}
|
| 415 |
_xlb_lock = threading.Lock()
|
| 416 |
|
|
|
|
| 548 |
with _il:idb=_lj(IF);idb.setdefault(v,{'views':0,'likes':0,'comments':0});idb[v]['comments']=len(cms);_sj(IF,idb)
|
| 549 |
return JSONResponse({'comments':cms})
|
| 550 |
|
| 551 |
+
# ===== WALL / SHORT AI ENDPOINTS =====
|
| 552 |
+
|
| 553 |
def _load_wall_posts():
|
| 554 |
+
"""Load wall posts from JSON file."""
|
| 555 |
with _wl_lock:
|
| 556 |
return _lj(WALL_FILE)
|
| 557 |
|
| 558 |
def _save_wall_posts(posts):
|
| 559 |
+
"""Save wall posts to JSON file."""
|
| 560 |
with _wl_lock:
|
| 561 |
_sj(WALL_FILE, posts)
|
| 562 |
|
| 563 |
@app.get('/api/wall')
|
| 564 |
def api_wall():
|
| 565 |
+
"""Get all wall posts."""
|
| 566 |
posts = _load_wall_posts()
|
| 567 |
if not posts:
|
| 568 |
+
# Return empty list, not error
|
| 569 |
return JSONResponse({"posts": []})
|
| 570 |
return JSONResponse({"posts": posts})
|
| 571 |
|
| 572 |
@app.post('/api/wall')
|
| 573 |
async def api_wall_post(request: Request):
|
| 574 |
+
"""
|
| 575 |
+
Create a wall post. Supports:
|
| 576 |
+
- JSON body: {title, text, img, source}
|
| 577 |
+
- Multipart form: title, text, source + video file upload
|
| 578 |
+
"""
|
| 579 |
content_type = request.headers.get('content-type', '')
|
| 580 |
+
|
| 581 |
+
# Handle multipart upload (video file)
|
| 582 |
if 'multipart/form-data' in content_type:
|
| 583 |
try:
|
| 584 |
form = await request.form()
|
| 585 |
except Exception as e:
|
| 586 |
return JSONResponse({"error": f"Form parse error: {str(e)}"}, status_code=400)
|
| 587 |
+
|
| 588 |
title = form.get('title', 'Video mới') or 'Video mới'
|
| 589 |
text = form.get('text', '') or ''
|
| 590 |
source = form.get('source', 'vtv_recorder') or 'vtv_recorder'
|
| 591 |
video_file = form.get('video')
|
| 592 |
+
|
| 593 |
post_id = str(uuid.uuid4())[:12]
|
| 594 |
video_url = None
|
| 595 |
+
|
| 596 |
+
# Save video file if provided
|
| 597 |
if video_file and hasattr(video_file, 'filename') and video_file.filename:
|
| 598 |
+
# Determine extension
|
| 599 |
fname = video_file.filename.lower()
|
| 600 |
if fname.endswith('.mp4'):
|
| 601 |
ext = '.mp4'
|
|
|
|
| 603 |
ext = '.webm'
|
| 604 |
else:
|
| 605 |
ext = '.webm'
|
| 606 |
+
|
| 607 |
video_filename = f"wall_{post_id}{ext}"
|
| 608 |
video_path = os.path.join(WALL_VIDEO_DIR, video_filename)
|
| 609 |
+
|
| 610 |
try:
|
| 611 |
+
# Read file content
|
| 612 |
content = await video_file.read()
|
| 613 |
if not content:
|
| 614 |
return JSONResponse({"error": "Empty video file"}, status_code=400)
|
| 615 |
+
|
| 616 |
+
# Save to disk
|
| 617 |
with open(video_path, 'wb') as f:
|
| 618 |
f.write(content)
|
| 619 |
+
|
| 620 |
file_size_mb = len(content) / 1024 / 1024
|
| 621 |
if file_size_mb > 50:
|
| 622 |
os.remove(video_path)
|
| 623 |
return JSONResponse({"error": f"Video quá lớn ({file_size_mb:.1f}MB). Tối đa 50MB."}, status_code=400)
|
| 624 |
+
|
| 625 |
+
# URL to access the video
|
| 626 |
video_url = f"/api/wall/video/{video_filename}"
|
| 627 |
except Exception as e:
|
| 628 |
return JSONResponse({"error": f"Lỗi lưu video: {str(e)}"}, status_code=500)
|
| 629 |
+
|
| 630 |
+
# Create post
|
| 631 |
post = {
|
| 632 |
"id": post_id,
|
| 633 |
"title": title[:200],
|
|
|
|
| 639 |
"created": int(time.time()),
|
| 640 |
"created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
|
| 641 |
}
|
| 642 |
+
|
| 643 |
+
# Save to wall
|
| 644 |
posts = _load_wall_posts()
|
| 645 |
if not isinstance(posts, list):
|
| 646 |
posts = []
|
| 647 |
posts.insert(0, post)
|
| 648 |
+
# Keep max 200 posts
|
| 649 |
posts = posts[:200]
|
| 650 |
_save_wall_posts(posts)
|
| 651 |
+
|
| 652 |
return JSONResponse({"post": post, "ok": True})
|
| 653 |
+
|
| 654 |
+
# Handle JSON body (text-only post)
|
| 655 |
try:
|
| 656 |
body = await request.json()
|
| 657 |
except:
|
| 658 |
body = {}
|
| 659 |
+
|
| 660 |
title = body.get('title', 'Bài mới') or 'Bài mới'
|
| 661 |
text = body.get('text', '') or ''
|
| 662 |
img = body.get('img', None)
|
| 663 |
source = body.get('source', 'user') or 'user'
|
| 664 |
+
|
| 665 |
post_id = str(uuid.uuid4())[:12]
|
| 666 |
post = {
|
| 667 |
"id": post_id,
|
|
|
|
| 674 |
"created": int(time.time()),
|
| 675 |
"created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
|
| 676 |
}
|
| 677 |
+
|
| 678 |
posts = _load_wall_posts()
|
| 679 |
if not isinstance(posts, list):
|
| 680 |
posts = []
|
| 681 |
posts.insert(0, post)
|
| 682 |
posts = posts[:200]
|
| 683 |
_save_wall_posts(posts)
|
| 684 |
+
|
| 685 |
return JSONResponse({"post": post, "ok": True})
|
| 686 |
|
| 687 |
@app.get('/api/wall/video/{filename}')
|
| 688 |
def api_wall_video(filename: str):
|
| 689 |
+
"""Serve a wall video file."""
|
| 690 |
+
# Security: prevent path traversal
|
| 691 |
if '..' in filename or '/' in filename:
|
| 692 |
return Response(status_code=403)
|
| 693 |
video_path = os.path.join(WALL_VIDEO_DIR, filename)
|
|
|
|
| 699 |
|
| 700 |
@app.delete('/api/wall/{post_id}')
|
| 701 |
def api_wall_delete(post_id: str):
|
| 702 |
+
"""Delete a wall post and its video."""
|
| 703 |
posts = _load_wall_posts()
|
| 704 |
if not isinstance(posts, list):
|
| 705 |
return JSONResponse({"error": "No posts"}, status_code=404)
|
| 706 |
+
|
| 707 |
for i, p in enumerate(posts):
|
| 708 |
if p.get('id') == post_id:
|
| 709 |
+
# Delete video file if exists
|
| 710 |
if p.get('video'):
|
| 711 |
video_name = p['video'].split('/')[-1]
|
| 712 |
video_path = os.path.join(WALL_VIDEO_DIR, video_name)
|
|
|
|
| 715 |
posts.pop(i)
|
| 716 |
_save_wall_posts(posts)
|
| 717 |
return JSONResponse({"ok": True})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 718 |
|
| 719 |
+
return JSONResponse({"error": "Post not found"}, status_code=404)
|
| 720 |
|
| 721 |
def _bg():
|
| 722 |
time.sleep(15)
|
|
|
|
| 726 |
time.sleep(90)
|
| 727 |
threading.Thread(target=_bg,daemon=True).start()
|
| 728 |
|
| 729 |
+
app.mount('/static',StaticFiles(directory=STATIC_DIR),name='vnews_static')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
bongda_proxy.py
CHANGED
|
@@ -4,6 +4,7 @@ from bs4 import BeautifulSoup
|
|
| 4 |
import re
|
| 5 |
import json
|
| 6 |
|
|
|
|
| 7 |
def _cl(s):
|
| 8 |
return re.sub(r'\s+', ' ', str(s or '')).strip()
|
| 9 |
|
|
@@ -14,18 +15,23 @@ def _normalize_time(raw):
|
|
| 14 |
return t
|
| 15 |
|
| 16 |
def scrape_match_html(event_id, url=None):
|
|
|
|
| 17 |
result = {"event_id": event_id, "found": False, "sections": []}
|
|
|
|
|
|
|
| 18 |
headers = {
|
| 19 |
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
| 20 |
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
| 21 |
"Referer": "https://bongda.com.vn/",
|
| 22 |
}
|
|
|
|
| 23 |
html = None
|
| 24 |
urls_to_try = [url] if url else []
|
| 25 |
urls_to_try += [
|
| 26 |
f"https://bongda.com.vn/tran-dau/{event_id}/centre/",
|
| 27 |
f"https://bongda.com.vn/tran-dau/{event_id}/preview/",
|
| 28 |
]
|
|
|
|
| 29 |
for u in urls_to_try:
|
| 30 |
if not u:
|
| 31 |
continue
|
|
@@ -36,78 +42,203 @@ def scrape_match_html(event_id, url=None):
|
|
| 36 |
break
|
| 37 |
except Exception:
|
| 38 |
continue
|
|
|
|
| 39 |
if not html:
|
| 40 |
return result
|
|
|
|
| 41 |
try:
|
| 42 |
soup = BeautifulSoup(html, 'html.parser')
|
| 43 |
info = {}
|
|
|
|
|
|
|
| 44 |
tel = soup.select_one('.teams')
|
| 45 |
if tel:
|
| 46 |
he = tel.select_one('.team.home')
|
| 47 |
if he:
|
| 48 |
ne = he.select_one('p:not(.logo)') or he.find('p')
|
| 49 |
-
if ne:
|
|
|
|
| 50 |
lo = he.select_one('img')
|
| 51 |
-
if lo:
|
|
|
|
|
|
|
| 52 |
ae = tel.select_one('.team.away')
|
| 53 |
if ae:
|
| 54 |
ne = ae.select_one('p:not(.logo)') or ae.find('p')
|
| 55 |
-
if ne:
|
|
|
|
| 56 |
lo = ae.select_one('img')
|
| 57 |
-
if lo:
|
|
|
|
|
|
|
| 58 |
sc = tel.select_one('.score')
|
| 59 |
if sc:
|
| 60 |
parts = [_cl(p.get_text()) for p in sc.select('p')]
|
| 61 |
-
if len(parts) >= 2:
|
|
|
|
| 62 |
lb = sc.select_one('.label')
|
| 63 |
-
if lb:
|
|
|
|
|
|
|
| 64 |
if info.get('home_team') and info.get('away_team'):
|
| 65 |
result['info'] = info
|
| 66 |
result['found'] = True
|
| 67 |
result['sections'].append('info')
|
| 68 |
else:
|
| 69 |
return result
|
|
|
|
|
|
|
| 70 |
events = []
|
| 71 |
events_div = soup.select_one('.events')
|
| 72 |
if events_div:
|
| 73 |
period = ''
|
| 74 |
for child in events_div.children:
|
| 75 |
-
if not hasattr(child, 'name') or not child.name:
|
|
|
|
| 76 |
cls = ' '.join(child.get('class', []))
|
| 77 |
if 'period' in cls:
|
| 78 |
h2 = child.find('h2')
|
| 79 |
-
if h2:
|
|
|
|
| 80 |
for ev in child.children:
|
| 81 |
-
if not hasattr(ev, 'name') or not ev.name:
|
|
|
|
| 82 |
ev_cls = ' '.join(ev.get('class', []))
|
| 83 |
-
if 'event' not in ev_cls:
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
type_el = ev.select_one('.event-type')
|
| 86 |
if type_el:
|
| 87 |
-
if type_el.select_one('[class*="redcard"]'):
|
| 88 |
-
|
| 89 |
-
elif type_el.select_one('[class*="
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
players_el = ev.select_one('.players')
|
| 92 |
if players_el:
|
| 93 |
time_el = players_el.select_one('.event-time')
|
| 94 |
-
if time_el:
|
|
|
|
|
|
|
|
|
|
| 95 |
text = _cl(players_el.get_text(' ', strip=True).replace(ev_data['time'], '').strip())
|
| 96 |
ev_data['players'] = text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
events.append(ev_data)
|
|
|
|
| 98 |
if events:
|
| 99 |
result['events'] = events
|
| 100 |
result['sections'].append('events')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
except Exception as e:
|
| 102 |
result['error'] = str(e)
|
|
|
|
| 103 |
return result
|
| 104 |
|
|
|
|
|
|
|
| 105 |
from fastapi import Query
|
| 106 |
from fastapi.responses import JSONResponse
|
| 107 |
|
| 108 |
def add_bongda_proxy_endpoint(app):
|
| 109 |
@app.get('/api/proxy/bongda')
|
| 110 |
def proxy_bongda(event_id: int = Query(default=None), url: str = Query(default=None)):
|
|
|
|
| 111 |
if event_id is None:
|
| 112 |
return JSONResponse({'error': 'event_id required'}, status_code=400)
|
| 113 |
-
return JSONResponse(scrape_match_html(event_id, url))
|
|
|
|
| 4 |
import re
|
| 5 |
import json
|
| 6 |
|
| 7 |
+
# Import-safe parsing functions
|
| 8 |
def _cl(s):
|
| 9 |
return re.sub(r'\s+', ' ', str(s or '')).strip()
|
| 10 |
|
|
|
|
| 15 |
return t
|
| 16 |
|
| 17 |
def scrape_match_html(event_id, url=None):
|
| 18 |
+
"""Fast scrape bongda.com.vn for match detail - no external CORS needed."""
|
| 19 |
result = {"event_id": event_id, "found": False, "sections": []}
|
| 20 |
+
|
| 21 |
+
# Fetch HTML
|
| 22 |
headers = {
|
| 23 |
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
| 24 |
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
| 25 |
"Referer": "https://bongda.com.vn/",
|
| 26 |
}
|
| 27 |
+
|
| 28 |
html = None
|
| 29 |
urls_to_try = [url] if url else []
|
| 30 |
urls_to_try += [
|
| 31 |
f"https://bongda.com.vn/tran-dau/{event_id}/centre/",
|
| 32 |
f"https://bongda.com.vn/tran-dau/{event_id}/preview/",
|
| 33 |
]
|
| 34 |
+
|
| 35 |
for u in urls_to_try:
|
| 36 |
if not u:
|
| 37 |
continue
|
|
|
|
| 42 |
break
|
| 43 |
except Exception:
|
| 44 |
continue
|
| 45 |
+
|
| 46 |
if not html:
|
| 47 |
return result
|
| 48 |
+
|
| 49 |
try:
|
| 50 |
soup = BeautifulSoup(html, 'html.parser')
|
| 51 |
info = {}
|
| 52 |
+
|
| 53 |
+
# Teams + score
|
| 54 |
tel = soup.select_one('.teams')
|
| 55 |
if tel:
|
| 56 |
he = tel.select_one('.team.home')
|
| 57 |
if he:
|
| 58 |
ne = he.select_one('p:not(.logo)') or he.find('p')
|
| 59 |
+
if ne:
|
| 60 |
+
info['home_team'] = _cl(ne.get_text())
|
| 61 |
lo = he.select_one('img')
|
| 62 |
+
if lo:
|
| 63 |
+
info['home_logo'] = lo.get('src', '')
|
| 64 |
+
|
| 65 |
ae = tel.select_one('.team.away')
|
| 66 |
if ae:
|
| 67 |
ne = ae.select_one('p:not(.logo)') or ae.find('p')
|
| 68 |
+
if ne:
|
| 69 |
+
info['away_team'] = _cl(ne.get_text())
|
| 70 |
lo = ae.select_one('img')
|
| 71 |
+
if lo:
|
| 72 |
+
info['away_logo'] = lo.get('src', '')
|
| 73 |
+
|
| 74 |
sc = tel.select_one('.score')
|
| 75 |
if sc:
|
| 76 |
parts = [_cl(p.get_text()) for p in sc.select('p')]
|
| 77 |
+
if len(parts) >= 2:
|
| 78 |
+
info['score'] = f"{parts[0]} - {parts[1]}"
|
| 79 |
lb = sc.select_one('.label')
|
| 80 |
+
if lb:
|
| 81 |
+
info['status_label'] = _cl(lb.get_text())
|
| 82 |
+
|
| 83 |
if info.get('home_team') and info.get('away_team'):
|
| 84 |
result['info'] = info
|
| 85 |
result['found'] = True
|
| 86 |
result['sections'].append('info')
|
| 87 |
else:
|
| 88 |
return result
|
| 89 |
+
|
| 90 |
+
# Events parsing
|
| 91 |
events = []
|
| 92 |
events_div = soup.select_one('.events')
|
| 93 |
if events_div:
|
| 94 |
period = ''
|
| 95 |
for child in events_div.children:
|
| 96 |
+
if not hasattr(child, 'name') or not child.name:
|
| 97 |
+
continue
|
| 98 |
cls = ' '.join(child.get('class', []))
|
| 99 |
if 'period' in cls:
|
| 100 |
h2 = child.find('h2')
|
| 101 |
+
if h2:
|
| 102 |
+
period = _cl(h2.get_text())
|
| 103 |
for ev in child.children:
|
| 104 |
+
if not hasattr(ev, 'name') or not ev.name:
|
| 105 |
+
continue
|
| 106 |
ev_cls = ' '.join(ev.get('class', []))
|
| 107 |
+
if 'event' not in ev_cls:
|
| 108 |
+
continue
|
| 109 |
+
|
| 110 |
+
ev_data = {
|
| 111 |
+
'team': 'home' if 'home' in ev_cls else 'away',
|
| 112 |
+
'period': period,
|
| 113 |
+
'type': 'unknown',
|
| 114 |
+
'time': '',
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
# Type detection
|
| 118 |
type_el = ev.select_one('.event-type')
|
| 119 |
if type_el:
|
| 120 |
+
if type_el.select_one('[class*="redcard"]'):
|
| 121 |
+
ev_data['type'] = 'redcard'
|
| 122 |
+
elif type_el.select_one('[class*="yellowcard"]'):
|
| 123 |
+
ev_data['type'] = 'yellowcard'
|
| 124 |
+
elif type_el.select_one('[class*="goal"]'):
|
| 125 |
+
ev_data['type'] = 'goal'
|
| 126 |
+
elif type_el.select_one('[class*="substitution"]'):
|
| 127 |
+
ev_data['type'] = 'substitution'
|
| 128 |
+
|
| 129 |
players_el = ev.select_one('.players')
|
| 130 |
if players_el:
|
| 131 |
time_el = players_el.select_one('.event-time')
|
| 132 |
+
if time_el:
|
| 133 |
+
ev_data['time'] = _normalize_time(time_el.get_text())
|
| 134 |
+
|
| 135 |
+
# Parse player names
|
| 136 |
text = _cl(players_el.get_text(' ', strip=True).replace(ev_data['time'], '').strip())
|
| 137 |
ev_data['players'] = text
|
| 138 |
+
|
| 139 |
+
if ev_data['type'] == 'goal':
|
| 140 |
+
words = text.split()
|
| 141 |
+
if len(words) >= 2:
|
| 142 |
+
ev_data['scorer'] = ' '.join(words[:2])
|
| 143 |
+
elif len(words) == 1:
|
| 144 |
+
ev_data['scorer'] = words[0]
|
| 145 |
+
elif ev_data['type'] == 'substitution':
|
| 146 |
+
words = text.split()
|
| 147 |
+
if len(words) >= 4:
|
| 148 |
+
ev_data['player_out'] = ' '.join(words[:len(words)//2])
|
| 149 |
+
ev_data['player_in'] = ' '.join(words[len(words)//2:])
|
| 150 |
+
elif ev_data['type'] in ('redcard', 'yellowcard'):
|
| 151 |
+
ev_data['player'] = text
|
| 152 |
+
|
| 153 |
events.append(ev_data)
|
| 154 |
+
|
| 155 |
if events:
|
| 156 |
result['events'] = events
|
| 157 |
result['sections'].append('events')
|
| 158 |
+
|
| 159 |
+
# Prediction
|
| 160 |
+
pred = soup.select_one('.prediction-card')
|
| 161 |
+
if pred:
|
| 162 |
+
pred_data = {}
|
| 163 |
+
team_info = pred.select_one('.team-info')
|
| 164 |
+
if team_info:
|
| 165 |
+
teams = team_info.select('.team')
|
| 166 |
+
if len(teams) >= 2:
|
| 167 |
+
pred_data['home_name'] = _cl(teams[0].select_one('.team-name').get_text() or '') if teams[0].select_one('.team-name') else ''
|
| 168 |
+
pred_data['away_name'] = _cl(teams[1].select_one('.team-name').get_text() or '') if teams[1].select_one('.team-name') else ''
|
| 169 |
+
divider = team_info.select_one('.divider')
|
| 170 |
+
if divider:
|
| 171 |
+
pred_data['result'] = _cl(divider.get_text())
|
| 172 |
+
vc = pred.select_one('.vote-count')
|
| 173 |
+
if vc:
|
| 174 |
+
pred_data['vote_count'] = _cl(vc.get_text())
|
| 175 |
+
result['prediction'] = pred_data
|
| 176 |
+
|
| 177 |
+
# Recent matches
|
| 178 |
+
recent = []
|
| 179 |
+
ml = soup.select_one('.matches-list')
|
| 180 |
+
if ml:
|
| 181 |
+
for item in ml.select('.match-detail, .match-item, li'):
|
| 182 |
+
de = item.select_one('.date, .time')
|
| 183 |
+
le = item.select_one('.league')
|
| 184 |
+
he = item.select_one('.home, .team-home')
|
| 185 |
+
ae = item.select_one('.away, .team-away')
|
| 186 |
+
se = item.select_one('.score, .result')
|
| 187 |
+
if he or ae:
|
| 188 |
+
recent.append({
|
| 189 |
+
'date': _cl(de.get_text()) if de else '',
|
| 190 |
+
'league': _cl(le.get_text()) if le else '',
|
| 191 |
+
'home': _cl(he.get_text()) if he else '',
|
| 192 |
+
'away': _cl(ae.get_text()) if ae else '',
|
| 193 |
+
'score': _cl(se.get_text()) if se else 'vs',
|
| 194 |
+
})
|
| 195 |
+
if recent:
|
| 196 |
+
result['recent_matches'] = recent
|
| 197 |
+
result['sections'].append('recent')
|
| 198 |
+
|
| 199 |
+
# H2H stats API
|
| 200 |
+
try:
|
| 201 |
+
api_headers = {
|
| 202 |
+
"User-Agent": "Mozilla/5.0",
|
| 203 |
+
"Accept": "application/json",
|
| 204 |
+
"X-Requested-With": "XMLHttpRequest",
|
| 205 |
+
"Referer": "https://bongda.com.vn/",
|
| 206 |
+
}
|
| 207 |
+
r = requests.get(
|
| 208 |
+
f"https://bongda.com.vn/api/fixtures/h2h-stats?event_id={event_id}",
|
| 209 |
+
headers=api_headers, timeout=10
|
| 210 |
+
)
|
| 211 |
+
if r.status_code == 200:
|
| 212 |
+
ad = r.json()
|
| 213 |
+
if ad.get('status') == 'success' and ad.get('html'):
|
| 214 |
+
asp = BeautifulSoup(ad['html'], 'html.parser')
|
| 215 |
+
ast = {}
|
| 216 |
+
for row in asp.select('li, tr'):
|
| 217 |
+
cells = row.select('td, span, p')
|
| 218 |
+
if len(cells) >= 3:
|
| 219 |
+
lb = _cl(cells[0].get_text())
|
| 220 |
+
if lb:
|
| 221 |
+
ast[lb] = {'home': _cl(cells[1].get_text()), 'away': _cl(cells[2].get_text())}
|
| 222 |
+
if ast:
|
| 223 |
+
result['h2h_stats_parsed'] = ast
|
| 224 |
+
result['sections'].append('h2h_stats')
|
| 225 |
+
except Exception:
|
| 226 |
+
pass
|
| 227 |
+
|
| 228 |
except Exception as e:
|
| 229 |
result['error'] = str(e)
|
| 230 |
+
|
| 231 |
return result
|
| 232 |
|
| 233 |
+
|
| 234 |
+
# FastAPI endpoint (to be added to app_v2_entry.py)
|
| 235 |
from fastapi import Query
|
| 236 |
from fastapi.responses import JSONResponse
|
| 237 |
|
| 238 |
def add_bongda_proxy_endpoint(app):
|
| 239 |
@app.get('/api/proxy/bongda')
|
| 240 |
def proxy_bongda(event_id: int = Query(default=None), url: str = Query(default=None)):
|
| 241 |
+
"""Proxy bongda.com.vn match data - fast server-side scraping."""
|
| 242 |
if event_id is None:
|
| 243 |
return JSONResponse({'error': 'event_id required'}, status_code=400)
|
| 244 |
+
return JSONResponse(scrape_match_html(event_id, url))
|
main.py
CHANGED
|
@@ -1,8 +1,7 @@
|
|
| 1 |
-
"""VNEWS - FastAPI backend with livescore + xemlaibongda highlights + VTV
|
| 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
|
|
@@ -10,34 +9,12 @@ 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 =====
|
| 18 |
-
from wc2026_scraper import get_wc2026_all, scrape_fixtures, scrape_standings, scrape_stats, scrape_wc_news
|
| 19 |
-
|
| 20 |
-
# ===== RATE LIMITING =====
|
| 21 |
-
_rate_limit_data = defaultdict(list)
|
| 22 |
-
_rate_limit_lock = threading.Lock()
|
| 23 |
-
RATE_LIMIT_MAX = 60
|
| 24 |
-
RATE_LIMIT_WINDOW = 60
|
| 25 |
-
|
| 26 |
-
def _check_rate_limit(ip: str) -> bool:
|
| 27 |
-
with _rate_limit_lock:
|
| 28 |
-
now = time.time()
|
| 29 |
-
_rate_limit_data[ip] = [t for t in _rate_limit_data[ip] if now - t < RATE_LIMIT_WINDOW]
|
| 30 |
-
if len(_rate_limit_data[ip]) >= RATE_LIMIT_MAX: return False
|
| 31 |
-
_rate_limit_data[ip].append(now)
|
| 32 |
-
return True
|
| 33 |
-
|
| 34 |
-
@app.middleware("http")
|
| 35 |
-
async def rate_limit_middleware(request: Request, call_next):
|
| 36 |
-
if request.url.path.startswith("/api/"):
|
| 37 |
-
ip = request.client.host
|
| 38 |
-
if not _check_rate_limit(ip): return JSONResponse({"error": "rate limit exceeded"}, status_code=429)
|
| 39 |
-
return await call_next(request)
|
| 40 |
-
|
| 41 |
# ===== VTV CHANNELS API =====
|
| 42 |
from vtv_api import router as vtv_router
|
| 43 |
app.include_router(vtv_router)
|
|
@@ -50,6 +27,63 @@ _cache_ttl = 300
|
|
| 50 |
_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":27110,"laliga":27233,"seriea":27044,"bundesliga":26891,"ligue1":27212}
|
| 55 |
HL_LEAGUES = {
|
|
@@ -102,7 +136,12 @@ def _parse_match_from_li(li, status_type="live"):
|
|
| 102 |
@app.get("/api/proxy/m3u8")
|
| 103 |
def proxy_m3u8(url: str = Query(...)):
|
| 104 |
try:
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
if r.status_code != 200: return Response(status_code=502, content="upstream error")
|
| 107 |
lines = r.text.strip().split('\n'); rewritten = []
|
| 108 |
for line in lines:
|
|
@@ -114,9 +153,29 @@ def proxy_m3u8(url: str = Query(...)):
|
|
| 114 |
@app.get("/api/proxy/seg")
|
| 115 |
def proxy_segment(url: str = Query(...)):
|
| 116 |
try:
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
if r.status_code != 200: return Response(status_code=502, content="upstream error")
|
| 119 |
data = r.content
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
if len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47: data = data[188:]
|
| 121 |
return Response(content=data, media_type="video/mp2t", headers={"Access-Control-Allow-Origin":"*","Cache-Control":"public, max-age=3600"})
|
| 122 |
except: return Response(status_code=502, content="proxy error")
|
|
@@ -136,15 +195,10 @@ def proxy_video(url: str = Query(...), request: Request = None):
|
|
| 136 |
@app.get("/api/proxy/img")
|
| 137 |
def proxy_img(url: str = Query(...)):
|
| 138 |
try:
|
| 139 |
-
|
| 140 |
-
_u = urlparse(url); _host = _u.netloc.lower()
|
| 141 |
-
_referer = "https://dantri.com.vn/"
|
| 142 |
-
if "refooty" in _host or "xemlaibongda" in _host: _referer = "https://xemlaibongda.top/"
|
| 143 |
-
elif "ytimg" in _host or "youtube" in _host: _referer = "https://www.youtube.com/"
|
| 144 |
-
elif "vncecdn" in _host or "vnexpress" in _host: _referer = "https://vnexpress.net/"
|
| 145 |
-
r = requests.get(url, headers={**HEADERS, "Referer": _referer}, timeout=10)
|
| 146 |
if r.status_code != 200: return Response(status_code=502)
|
| 147 |
-
|
|
|
|
| 148 |
except: return Response(status_code=502)
|
| 149 |
|
| 150 |
# ===== XEMLAIBONGDA HIGHLIGHTS =====
|
|
@@ -153,67 +207,50 @@ def _scrape_xemlaibongda_page(page_path, limit=20):
|
|
| 153 |
url = f"https://xemlaibongda.top/{page_path}" if page_path else "https://xemlaibongda.top/"
|
| 154 |
r = requests.get(url, headers=HEADERS, timeout=15)
|
| 155 |
if r.status_code != 200: return []
|
| 156 |
-
r.encoding = "utf-8"
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
for
|
|
|
|
| 160 |
href = a.get("href", "")
|
| 161 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
if not href.startswith("http"): href = "https://xemlaibongda.top" + href
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
seen.add(clean_href)
|
| 166 |
-
img_src = ""
|
| 167 |
img = a.find("img")
|
| 168 |
if not img and a.parent: img = a.parent.find("img")
|
| 169 |
if not img:
|
| 170 |
p = a.parent
|
| 171 |
-
for _ in range(
|
| 172 |
if p and p.find("img"): img = p.find("img"); break
|
| 173 |
p = p.parent if p else None
|
|
|
|
| 174 |
if img:
|
| 175 |
-
img_src =
|
| 176 |
if img_src.startswith("//"): img_src = "https:" + img_src
|
| 177 |
elif img_src.startswith("/"): img_src = "https://xemlaibongda.top" + img_src
|
| 178 |
-
if not img_src:
|
| 179 |
-
p = a.parent
|
| 180 |
-
for _ in range(5):
|
| 181 |
-
if p is None: break
|
| 182 |
-
style = p.get("style", "")
|
| 183 |
-
bg_match = re.search(r'url\(["\']?(.*?)["\']?\)', style)
|
| 184 |
-
if bg_match:
|
| 185 |
-
img_src = bg_match.group(1)
|
| 186 |
-
if img_src.startswith("//"): img_src = "https:" + img_src
|
| 187 |
-
elif img_src.startswith("/"): img_src = "https://xemlaibongda.top" + img_src
|
| 188 |
-
break
|
| 189 |
-
p = p.parent if p else None
|
| 190 |
title = ""
|
| 191 |
-
for
|
| 192 |
-
|
| 193 |
-
if
|
|
|
|
|
|
|
| 194 |
if not title:
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
el = a.select_one(selector)
|
| 198 |
-
if el: t = el.get_text(strip=True)
|
| 199 |
-
if t and len(t) >= 5: title = t; break
|
| 200 |
-
except: pass
|
| 201 |
if not title:
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
slug = clean_href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
|
| 206 |
-
title = slug.replace("-", " ").replace("_", " ").title()
|
| 207 |
-
title = re.sub(r'\d{4}-\d{2}-\d{2}', '', title).strip()
|
| 208 |
if not title or len(title) < 3: continue
|
| 209 |
-
|
| 210 |
-
slug = clean_href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
|
| 211 |
-
img_src = f"https://xemlaibongda.top/uploads/thumb/{slug}.jpg"
|
| 212 |
-
videos.append({"title": title[:100], "link": clean_href, "img": img_src, "source": "xemlaibongda"})
|
| 213 |
if len(videos) >= limit: break
|
| 214 |
return videos
|
| 215 |
-
except
|
| 216 |
-
print(f"[xemlaibongda] Error: {e}"); return []
|
| 217 |
|
| 218 |
def scrape_xemlaibongda(): return _scrape_xemlaibongda_page("", 20)
|
| 219 |
def scrape_highlights_by_league(league_key):
|
|
@@ -224,35 +261,96 @@ def scrape_all_league_highlights():
|
|
| 224 |
def _fetch(key): return key, scrape_highlights_by_league(key)
|
| 225 |
with ThreadPoolExecutor(8) as ex:
|
| 226 |
futs = [ex.submit(_fetch, k) for k in HL_LEAGUES]
|
| 227 |
-
for f in as_completed(futs
|
| 228 |
-
try:
|
| 229 |
-
|
| 230 |
-
|
|
|
|
| 231 |
return results
|
| 232 |
|
| 233 |
def extract_xemlaibongda_video(url):
|
| 234 |
try:
|
| 235 |
r=requests.get(url, headers=HEADERS, timeout=15)
|
| 236 |
if r.status_code!=200: return None
|
| 237 |
-
r.encoding="utf-8"; soup=BeautifulSoup(r.text,"lxml")
|
| 238 |
-
og=soup.find("meta",property="og:image")
|
| 239 |
-
og_poster=og.get("content","") if og else ""
|
| 240 |
-
if og_poster.startswith("//"): og_poster="https:"+og_poster
|
| 241 |
-
video=soup.find("video")
|
| 242 |
if video:
|
| 243 |
src=video.get("src",""); poster=video.get("poster","")
|
| 244 |
if not src:
|
| 245 |
source=video.find("source")
|
| 246 |
if source: src=source.get("src","")
|
| 247 |
-
if not poster: poster=og_poster
|
| 248 |
if src: return{"src":src,"poster":poster,"type":"hls" if".m3u8" in src else"video"}
|
| 249 |
m3u8s=re.findall(r'(https?://[^\s"\'<>]+\.m3u8)',r.text)
|
| 250 |
-
if m3u8s:
|
| 251 |
-
|
| 252 |
-
|
| 253 |
return None
|
| 254 |
except: return None
|
| 255 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
# ===== LIVESCORE =====
|
| 257 |
@app.get("/api/livescore/live")
|
| 258 |
def api_livescore_live(): return JSONResponse({"html":_cached("ls_live",lambda:fetch_bongda_api("/api/fixtures/live"),ttl=_cache_ttl_live)})
|
|
@@ -305,6 +403,41 @@ def api_livescore_featured():
|
|
| 305 |
return None
|
| 306 |
return JSONResponse(_cached("ls_featured",_f,ttl=30))
|
| 307 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
@app.get("/api/highlights")
|
| 309 |
def api_highlights(): return JSONResponse(_cached("xemlaibongda_hl",scrape_xemlaibongda,ttl=_cache_ttl))
|
| 310 |
@app.get("/api/highlights/leagues")
|
|
@@ -315,7 +448,7 @@ def api_highlights_league(league:str):
|
|
| 315 |
return JSONResponse(_cached(f"hl_{league}",lambda:scrape_highlights_by_league(league),ttl=_cache_ttl))
|
| 316 |
|
| 317 |
@app.get("/api/video_url")
|
| 318 |
-
def api_video_url(url:str=Query(...)
|
| 319 |
if "youtube.com" in url or "youtu.be" in url:
|
| 320 |
m=re.search(r'(?:v=|shorts/|youtu\.be/)([a-zA-Z0-9_-]{11})',url)
|
| 321 |
if m: vid=m.group(1); return JSONResponse({"src":f"https://www.youtube.com/embed/{vid}?autoplay=1&rel=0&enablejsapi=1","poster":f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg","type":"youtube"})
|
|
@@ -323,39 +456,9 @@ def api_video_url(url:str=Query(...), img:str=Query(default="")):
|
|
| 323 |
v=extract_xemlaibongda_video(url)
|
| 324 |
if v:
|
| 325 |
if v["type"]=="hls": v["src"]="/api/proxy/m3u8?url="+quote(v["src"],safe="")
|
| 326 |
-
if not v.get("poster") and img: v["poster"] = img
|
| 327 |
return JSONResponse(v)
|
| 328 |
return JSONResponse({"error":"not found"})
|
| 329 |
|
| 330 |
-
# ===== WORLD CUP 2026 API =====
|
| 331 |
-
_wc_request_times = []; _wc_rate_limit_lock = threading.Lock()
|
| 332 |
-
_WC_RATE_LIMIT = 10
|
| 333 |
-
def _wc_rate_limit():
|
| 334 |
-
global _wc_request_times
|
| 335 |
-
with _wc_rate_limit_lock:
|
| 336 |
-
now = time.time()
|
| 337 |
-
_wc_request_times = [t for t in _wc_request_times if now - t < 60]
|
| 338 |
-
if len(_wc_request_times) >= _WC_RATE_LIMIT: return False
|
| 339 |
-
_wc_request_times.append(now)
|
| 340 |
-
return True
|
| 341 |
-
|
| 342 |
-
@app.get("/api/wc2026")
|
| 343 |
-
def api_wc2026():
|
| 344 |
-
return JSONResponse(_cached("wc2026", get_wc2026_all, ttl=_cache_ttl))
|
| 345 |
-
|
| 346 |
-
@app.get("/api/wc2026/{tab}")
|
| 347 |
-
def api_wc2026_tab(tab: str):
|
| 348 |
-
valid_tabs = ["news", "fixtures", "standings", "stats", "highlights"]
|
| 349 |
-
if tab not in valid_tabs: return JSONResponse({"error": "invalid tab"}, status_code=400)
|
| 350 |
-
def _fetch_tab():
|
| 351 |
-
if tab == "highlights": return scrape_highlights_by_league("world-cup")
|
| 352 |
-
elif tab == "news": return scrape_wc_news()
|
| 353 |
-
elif tab == "fixtures": return scrape_fixtures()
|
| 354 |
-
elif tab == "standings": return scrape_standings()
|
| 355 |
-
elif tab == "stats": return scrape_stats()
|
| 356 |
-
return []
|
| 357 |
-
return JSONResponse(_cached(f"wc2026_{tab}", _fetch_tab, ttl=_cache_ttl))
|
| 358 |
-
|
| 359 |
@app.get("/api/bdp_videos")
|
| 360 |
def api_bdp_videos():
|
| 361 |
def _f():
|
|
@@ -376,7 +479,7 @@ def api_bdp_videos():
|
|
| 376 |
return JSONResponse(_cached("bdp_videos",_f))
|
| 377 |
|
| 378 |
# ===== NEWS =====
|
| 379 |
-
VNE_CATS={"thoi-su":("https://vnexpress.net/thoi-su","Thời Sự"),"the-gioi":("https://vnexpress.net/the-gioi","Thế Giới"),"kinh-doanh":("https://vnexpress.net/kinh-doanh","Kinh Doanh"),"the-thao":("https://vnexpress.net/the-thao","Thể Thao"),"giai-tri":("https://vnexpress.net/giai-
|
| 380 |
|
| 381 |
def scrape_vne(cat_url):
|
| 382 |
try:
|
|
@@ -412,7 +515,8 @@ def scrape_genk_ai():
|
|
| 412 |
for img in container.find_all("img"):
|
| 413 |
s=img.get("data-src","") or img.get("src","")
|
| 414 |
if s and "mediacdn" in s and "avatar" not in s and "logo" not in s: img_src=s; break
|
| 415 |
-
if img_src: break
|
|
|
|
| 416 |
seen.add(href)
|
| 417 |
if not img_src:
|
| 418 |
try:
|
|
@@ -455,57 +559,9 @@ def api_categories():
|
|
| 455 |
for k,(u,n) in VNE_CATS.items(): cats.append({"id":k,"name":n,"source":"vne"})
|
| 456 |
return JSONResponse(cats)
|
| 457 |
|
| 458 |
-
@app.get("/api/proxy/xlb")
|
| 459 |
-
def api_xlb(path: str = Query(default=""), limit: int = Query(default=20)):
|
| 460 |
-
try:
|
| 461 |
-
url = f"https://xemlaibongda.top/{path}" if path else "https://xemlaibongda.top/"
|
| 462 |
-
r = requests.get(url, headers=HEADERS, timeout=15)
|
| 463 |
-
if r.status_code != 200: return JSONResponse({"videos": []})
|
| 464 |
-
r.encoding = "utf-8"
|
| 465 |
-
soup = BeautifulSoup(r.text, "lxml")
|
| 466 |
-
videos, seen = [], set()
|
| 467 |
-
for a in soup.find_all("a", href=True):
|
| 468 |
-
href = a.get("href", "")
|
| 469 |
-
if "/video/" not in href and "/xem-lai/" not in href: continue
|
| 470 |
-
if not href.startswith("http"): href = "https://xemlaibongda.top" + href
|
| 471 |
-
clean = href.split("?")[0].split("#")[0]
|
| 472 |
-
if clean in seen: continue
|
| 473 |
-
seen.add(clean)
|
| 474 |
-
img_src = ""
|
| 475 |
-
img = a.find("img") or (a.parent.find("img") if a.parent else None)
|
| 476 |
-
if not img:
|
| 477 |
-
p = a.parent
|
| 478 |
-
for _ in range(5):
|
| 479 |
-
if p and p.find("img"): img = p.find("img"); break
|
| 480 |
-
p = p.parent if p else None
|
| 481 |
-
if img:
|
| 482 |
-
img_src = (img.get("data-src", "") or img.get("src", "") or img.get("data-lazy", "") or img.get("data-original", ""))
|
| 483 |
-
if img_src.startswith("//"): img_src = "https:" + img_src
|
| 484 |
-
elif img_src.startswith("/"): img_src = "https://xemlaibongda.top" + img_src
|
| 485 |
-
title = a.find("h3")
|
| 486 |
-
if not title: title = a.find("h2")
|
| 487 |
-
if not title: title = a.find("strong")
|
| 488 |
-
t = title.get_text(strip=True) if title else ""
|
| 489 |
-
if not t:
|
| 490 |
-
slug = clean.split("/video/")[-1].rstrip("/")
|
| 491 |
-
t = slug.replace("-", " ").title()
|
| 492 |
-
videos.append({"title": t[:100], "link": clean, "img": img_src, "source": "xemlaibongda"})
|
| 493 |
-
if len(videos) >= limit: break
|
| 494 |
-
return JSONResponse({"videos": videos})
|
| 495 |
-
except Exception as e:
|
| 496 |
-
return JSONResponse({"videos": [], "error": str(e)})
|
| 497 |
-
|
| 498 |
@app.get("/api/article")
|
| 499 |
def api_article(url:str=Query(...)):
|
| 500 |
-
|
| 501 |
-
r2 = requests.get(url, headers=HEADERS, timeout=10)
|
| 502 |
-
if r2.status_code == 200:
|
| 503 |
-
r2.encoding = "utf-8"
|
| 504 |
-
soup = BeautifulSoup(r2.text, "lxml")
|
| 505 |
-
og = soup.find("meta", property="og:image")
|
| 506 |
-
return JSONResponse({"og_image": og.get("content", "") if og else ""})
|
| 507 |
-
except: pass
|
| 508 |
-
return JSONResponse({"og_image": ""})
|
| 509 |
|
| 510 |
@app.get("/api/storage_status")
|
| 511 |
def api_storage_status():
|
|
@@ -517,4 +573,4 @@ def api_hot_topics():
|
|
| 517 |
|
| 518 |
@app.get("/", response_class=HTMLResponse)
|
| 519 |
async def root():
|
| 520 |
-
return HTMLResponse("<h1>VNEWS
|
|
|
|
| 1 |
+
"""VNEWS - FastAPI backend with livescore + xemlaibongda highlights + YouTube VTV shorts"""
|
| 2 |
import re, time, subprocess, json, os, threading
|
| 3 |
import html as html_lib
|
| 4 |
from datetime import datetime, timezone, timedelta
|
|
|
|
| 5 |
|
| 6 |
VN_TZ = timezone(timedelta(hours=7))
|
| 7 |
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
|
|
| 9 |
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, Response
|
| 10 |
from urllib.parse import quote
|
| 11 |
import requests
|
| 12 |
+
import urllib3
|
| 13 |
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
| 14 |
from bs4 import BeautifulSoup
|
| 15 |
|
| 16 |
app = FastAPI()
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
# ===== VTV CHANNELS API =====
|
| 19 |
from vtv_api import router as vtv_router
|
| 20 |
app.include_router(vtv_router)
|
|
|
|
| 27 |
_cache_ttl_live = 60
|
| 28 |
_cache_ttl_yt = 1800
|
| 29 |
|
| 30 |
+
# ===== VTV NAM BO SHORTS FALLBACK =====
|
| 31 |
+
SHORTS_FALLBACK = [
|
| 32 |
+
{"id":"nqlLH6chLRo","title":"Tin nóng VTV Nam Bộ | #shorts","channel":"vtvnambo"},
|
| 33 |
+
{"id":"E7Kq0v3hG6w","title":"VTV Nam Bộ - Tin tức miền Nam | #shorts","channel":"vtvnambo"},
|
| 34 |
+
{"id":"Lu_iCQ5YwNM","title":"Công an lập hồ sơ xử lý người phụ nữ chửi bới tát nam tài xế ô tô ở Hà Nội","channel":"baodantri7941"},
|
| 35 |
+
{"id":"CwWvijF8BOA","title":"Chú rể Ninh Bình bật khóc nhận món quà bí mật người cha quá cố gửi 26 năm trước","channel":"baodantri7941"},
|
| 36 |
+
{"id":"tvPewsc2ph4","title":"Tính năng ẩn trên iPhone giúp giảm mỏi mắt","channel":"baodantri7941"},
|
| 37 |
+
{"id":"b1Nxzv9ixlU","title":"Y án 3 năm tù với nữ tài xế uống 8 lon bia lái xe tông chủ tịch xã tử vong","channel":"baodantri7941"},
|
| 38 |
+
{"id":"Xp5eTwAZAis","title":"Người đánh hàng xóm tại chung cư ở Hà Nội bị tuyên hơn 4 tháng tù","channel":"baodantri7941"},
|
| 39 |
+
{"id":"Htzvwg6iOBM","title":"Xe điện Audi S6 Sportback e-tron có gì đặc biệt?","channel":"baodantri7941"},
|
| 40 |
+
{"id":"iMdFmWvYdlo","title":"Cô gái người Nga yêu thời trang và đất nước Việt Nam","channel":"baodantri7941"},
|
| 41 |
+
{"id":"IVaRc6moEv8","title":"Người nông dân Trung Quốc đột quỵ bệnh viện giúp bán sạch 4 tấn táo","channel":"baodantri7941"},
|
| 42 |
+
{"id":"uVxqPxToItU","title":"Công an vào cuộc vụ người phụ nữ chửi bới hành hung tài xế ô tô ở Hà Nội","channel":"baodantri7941"},
|
| 43 |
+
{"id":"VAfgNNgZDRs","title":"Khởi tố 4 đối tượng ném bom xăng vào nhà dân ở Đồng Nai","channel":"baodantri7941"},
|
| 44 |
+
{"id":"sBH_-zGh0Xw","title":"Vì sao Times New Roman vẫn nổi tiếng sau hàng chục năm?","channel":"baodantri7941"},
|
| 45 |
+
{"id":"woKn5f2bLHM","title":"Quảng Ninh ngập sâu diện rộng sau đợt mưa lớn","channel":"baodantri7941"},
|
| 46 |
+
{"id":"bcpgRoxbLPw","title":"Giông lốc quật bay mái tôn ở TP.HCM","channel":"baodantri7941"},
|
| 47 |
+
{"id":"ZIIC5osy544","title":"Bé trai Trung Quốc rơi từ tầng 11 vẫn sống sót kỳ diệu","channel":"baodantri7941"},
|
| 48 |
+
{"id":"uTMJ49NQpyc","title":"Sau lớp mascot 40kg Câu chuyện mưu sinh của người trẻ ở TPHCM","channel":"baodantri7941"},
|
| 49 |
+
{"id":"7Pd6vZ2Lz1M","title":"Hành động ấm lòng của người đàn ông tìm kiếm 5 học sinh tử vong ở sông Lô","channel":"baosuckhoedoisongboyte"},
|
| 50 |
+
{"id":"SlHLt_ZyPiE","title":"Xử phạt người đàn ông xóa số điện thoại cứu hộ trên cao tốc Bắc Nam","channel":"baosuckhoedoisongboyte"},
|
| 51 |
+
{"id":"IUOprcJyYr4","title":"Phụ nữ táo bón có phải do lười ăn rau?","channel":"baosuckhoedoisongboyte"},
|
| 52 |
+
{"id":"YY8ojFNE-AU","title":"Quái xế tự quay clip nẹt pô đánh võng đăng TikTok bị xử lý","channel":"baosuckhoedoisongboyte"},
|
| 53 |
+
{"id":"OV7_oGdQGII","title":"Bố cô dâu khóc sụt sùi rồi quẩy cực sung gây bão mạng","channel":"baosuckhoedoisongboyte"},
|
| 54 |
+
{"id":"FoxhFyz2skY","title":"Người đàn ông nư��c ngoài đập phá ô tô bẻ cần gạt nước ở Đà Nẵng","channel":"baosuckhoedoisongboyte"},
|
| 55 |
+
{"id":"R1oC_I8dFPU","title":"Thanh niên buông tay lái đứng trên xe máy khi đổ đèo ở Đắk Lắk","channel":"baosuckhoedoisongboyte"},
|
| 56 |
+
{"id":"U0Ft6ChWAIo","title":"Cô giáo kể phút tháo chạy khỏi xe khách trước khi bị lũ vò nát ở Cao Bằng","channel":"baosuckhoedoisongboyte"},
|
| 57 |
+
{"id":"hH0ANeze_4E","title":"Liên tiếp hàng chục con bò bị sét đánh chết trong ngày mưa dông","channel":"baosuckhoedoisongboyte"},
|
| 58 |
+
{"id":"pXWt0QbAzRQ","title":"Va chạm giao thông người phụ nữ lăng mạ tài xế ô tô","channel":"baosuckhoedoisongboyte"},
|
| 59 |
+
{"id":"UWWLPY1OYt4","title":"CSGT chặn xe khách khống chế đối tượng cướp dây chuyền tại Gia Lai","channel":"baosuckhoedoisongboyte"},
|
| 60 |
+
{"id":"AxhVTQutsuo","title":"Xuất tinh sớm và những hiểu lầm thường gặp","channel":"baosuckhoedoisongboyte"},
|
| 61 |
+
{"id":"cNy6FgaNxYM","title":"Cô dâu khóc sưng mắt vì 6 chỉ vàng không cánh mà bay trong ngày cưới","channel":"baosuckhoedoisongboyte"},
|
| 62 |
+
{"id":"IDt_S6q59Ro","title":"Chở bạn gái không đội mũ bảo hiểm thanh niên đấm CSGT","channel":"baosuckhoedoisongboyte"},
|
| 63 |
+
{"id":"LFxJ9Ik6W0A","title":"Mệnh lệnh từ trái tim CSGT Hà Nội mở đường đưa bé 5 tháng tuổi đi cấp cứu","channel":"baosuckhoedoisongboyte"},
|
| 64 |
+
]
|
| 65 |
+
for _v in SHORTS_FALLBACK:
|
| 66 |
+
_v.setdefault("link", "https://www.youtube.com/watch?v="+_v["id"])
|
| 67 |
+
_v.setdefault("img", "https://i.ytimg.com/vi/"+_v["id"]+"/hqdefault.jpg")
|
| 68 |
+
_v.setdefault("source", "yt")
|
| 69 |
+
|
| 70 |
+
SHORT_STATS_FILE = "/data/short_stats.json" if os.path.isdir("/data") else "/app/short_stats.json"
|
| 71 |
+
_short_lock = threading.Lock()
|
| 72 |
+
def _load_short_db():
|
| 73 |
+
try:
|
| 74 |
+
if os.path.exists(SHORT_STATS_FILE):
|
| 75 |
+
with open(SHORT_STATS_FILE,"r",encoding="utf-8") as f: return json.load(f)
|
| 76 |
+
except: pass
|
| 77 |
+
return {}
|
| 78 |
+
def _save_short_db(db):
|
| 79 |
+
try:
|
| 80 |
+
os.makedirs(os.path.dirname(SHORT_STATS_FILE), exist_ok=True)
|
| 81 |
+
tmp = SHORT_STATS_FILE + ".tmp"
|
| 82 |
+
with open(tmp,"w",encoding="utf-8") as f: json.dump(db, f, ensure_ascii=False)
|
| 83 |
+
os.replace(tmp, SHORT_STATS_FILE)
|
| 84 |
+
except: pass
|
| 85 |
+
def _short_default(): return {"views":0,"likes":0,"shares":0,"comments":[]}
|
| 86 |
+
|
| 87 |
PRIORITY_LEAGUES = ["Ngoại Hạng Anh","FA Cup","Champions League","LaLiga","Copa del Rey","Serie A","Bundesliga","Ligue 1","V-League"]
|
| 88 |
LEAGUE_IDS = {"nha":27110,"laliga":27233,"seriea":27044,"bundesliga":26891,"ligue1":27212}
|
| 89 |
HL_LEAGUES = {
|
|
|
|
| 136 |
@app.get("/api/proxy/m3u8")
|
| 137 |
def proxy_m3u8(url: str = Query(...)):
|
| 138 |
try:
|
| 139 |
+
headers = dict(HEADERS)
|
| 140 |
+
if "vtvdigital" in url:
|
| 141 |
+
headers["Referer"] = "https://vtv.vn/"
|
| 142 |
+
headers["Origin"] = "https://vtv.vn"
|
| 143 |
+
verify_ssl = "vtvdigital" not in url
|
| 144 |
+
r = requests.get(url, headers=headers, timeout=15, verify=verify_ssl)
|
| 145 |
if r.status_code != 200: return Response(status_code=502, content="upstream error")
|
| 146 |
lines = r.text.strip().split('\n'); rewritten = []
|
| 147 |
for line in lines:
|
|
|
|
| 153 |
@app.get("/api/proxy/seg")
|
| 154 |
def proxy_segment(url: str = Query(...)):
|
| 155 |
try:
|
| 156 |
+
headers = dict(HEADERS)
|
| 157 |
+
if "vtvdigital" in url:
|
| 158 |
+
headers["Referer"] = "https://vtv.vn/"
|
| 159 |
+
headers["Origin"] = "https://vtv.vn"
|
| 160 |
+
verify_ssl = "vtvdigital" not in url
|
| 161 |
+
r = requests.get(url, headers=headers, timeout=30, verify=verify_ssl)
|
| 162 |
if r.status_code != 200: return Response(status_code=502, content="upstream error")
|
| 163 |
data = r.content
|
| 164 |
+
# If response is an m3u8 sub-playlist, rewrite segment URLs
|
| 165 |
+
if b"#EXTM3U" in data[:500] or ".m3u8" in url:
|
| 166 |
+
lines = data.decode("utf-8", errors="replace").split('\n')
|
| 167 |
+
rewritten = []
|
| 168 |
+
base_url = url.rsplit('/', 1)[0] + '/'
|
| 169 |
+
for line in lines:
|
| 170 |
+
line = line.strip()
|
| 171 |
+
if not line or line.startswith('#'):
|
| 172 |
+
rewritten.append(line)
|
| 173 |
+
else:
|
| 174 |
+
seg_url = line
|
| 175 |
+
if not seg_url.startswith('http'):
|
| 176 |
+
seg_url = base_url + seg_url
|
| 177 |
+
rewritten.append("/api/proxy/seg?url=" + quote(seg_url, safe=""))
|
| 178 |
+
return Response(content='\n'.join(rewritten).encode("utf-8"), media_type="application/vnd.apple.mpegurl", headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "no-cache"})
|
| 179 |
if len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47: data = data[188:]
|
| 180 |
return Response(content=data, media_type="video/mp2t", headers={"Access-Control-Allow-Origin":"*","Cache-Control":"public, max-age=3600"})
|
| 181 |
except: return Response(status_code=502, content="proxy error")
|
|
|
|
| 195 |
@app.get("/api/proxy/img")
|
| 196 |
def proxy_img(url: str = Query(...)):
|
| 197 |
try:
|
| 198 |
+
r = requests.get(url, headers={**HEADERS, "Referer": "https://dantri.com.vn/"}, timeout=10)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
if r.status_code != 200: return Response(status_code=502)
|
| 200 |
+
ct = r.headers.get("Content-Type", "image/jpeg")
|
| 201 |
+
return Response(content=r.content, media_type=ct, headers={"Cache-Control": "public, max-age=86400", "Access-Control-Allow-Origin": "*"})
|
| 202 |
except: return Response(status_code=502)
|
| 203 |
|
| 204 |
# ===== XEMLAIBONGDA HIGHLIGHTS =====
|
|
|
|
| 207 |
url = f"https://xemlaibongda.top/{page_path}" if page_path else "https://xemlaibongda.top/"
|
| 208 |
r = requests.get(url, headers=HEADERS, timeout=15)
|
| 209 |
if r.status_code != 200: return []
|
| 210 |
+
r.encoding = "utf-8"; soup = BeautifulSoup(r.text, "lxml"); videos = []; seen = set()
|
| 211 |
+
selectors = ['a[href*="/video/"]','a[href*="/xem-lai/"]','.video-item a','.post-item a','.item a','article a']
|
| 212 |
+
links = []
|
| 213 |
+
for sel in selectors: links.extend(soup.select(sel))
|
| 214 |
+
for a in links:
|
| 215 |
href = a.get("href", "")
|
| 216 |
+
if not href: continue
|
| 217 |
+
is_video = "/video/" in href or "/xem-lai/" in href
|
| 218 |
+
if not is_video:
|
| 219 |
+
parent = a.parent
|
| 220 |
+
if not (parent and parent.find(class_=re.compile(r'vid|play|highlight'))): continue
|
| 221 |
if not href.startswith("http"): href = "https://xemlaibongda.top" + href
|
| 222 |
+
if href in seen: continue
|
| 223 |
+
seen.add(href)
|
|
|
|
|
|
|
| 224 |
img = a.find("img")
|
| 225 |
if not img and a.parent: img = a.parent.find("img")
|
| 226 |
if not img:
|
| 227 |
p = a.parent
|
| 228 |
+
for _ in range(3):
|
| 229 |
if p and p.find("img"): img = p.find("img"); break
|
| 230 |
p = p.parent if p else None
|
| 231 |
+
img_src = ""
|
| 232 |
if img:
|
| 233 |
+
img_src = img.get("data-src","") or img.get("src","") or img.get("data-lazy","") or img.get("data-original","")
|
| 234 |
if img_src.startswith("//"): img_src = "https:" + img_src
|
| 235 |
elif img_src.startswith("/"): img_src = "https://xemlaibongda.top" + img_src
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
title = ""
|
| 237 |
+
for tag in ["h3","h2","h4",".title",".post-title",".entry-title",".video-title"]:
|
| 238 |
+
t = a.select_one(tag) if hasattr(a,'select_one') else None
|
| 239 |
+
if t: title = t.get_text(" ",strip=True); break
|
| 240 |
+
if not title: title = a.get("title","")
|
| 241 |
+
if not title: title = a.get("aria-label","")
|
| 242 |
if not title:
|
| 243 |
+
img_for_alt = a.find("img")
|
| 244 |
+
if img_for_alt: title = img_for_alt.get("alt","")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
if not title:
|
| 246 |
+
slug = href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
|
| 247 |
+
title = slug.replace("-"," ").replace("_"," ")
|
| 248 |
+
title = re.sub(r'\d{4}-\d{2}-\d{2}','',title).strip().title()
|
|
|
|
|
|
|
|
|
|
| 249 |
if not title or len(title) < 3: continue
|
| 250 |
+
videos.append({"title":title,"link":href,"img":img_src,"source":"xemlaibongda"})
|
|
|
|
|
|
|
|
|
|
| 251 |
if len(videos) >= limit: break
|
| 252 |
return videos
|
| 253 |
+
except: return []
|
|
|
|
| 254 |
|
| 255 |
def scrape_xemlaibongda(): return _scrape_xemlaibongda_page("", 20)
|
| 256 |
def scrape_highlights_by_league(league_key):
|
|
|
|
| 261 |
def _fetch(key): return key, scrape_highlights_by_league(key)
|
| 262 |
with ThreadPoolExecutor(8) as ex:
|
| 263 |
futs = [ex.submit(_fetch, k) for k in HL_LEAGUES]
|
| 264 |
+
for f in as_completed(futs):
|
| 265 |
+
try:
|
| 266 |
+
key, vids = f.result()
|
| 267 |
+
if vids: results[key] = vids
|
| 268 |
+
except: pass
|
| 269 |
return results
|
| 270 |
|
| 271 |
def extract_xemlaibongda_video(url):
|
| 272 |
try:
|
| 273 |
r=requests.get(url, headers=HEADERS, timeout=15)
|
| 274 |
if r.status_code!=200: return None
|
| 275 |
+
r.encoding="utf-8"; soup=BeautifulSoup(r.text,"lxml"); video=soup.find("video")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
if video:
|
| 277 |
src=video.get("src",""); poster=video.get("poster","")
|
| 278 |
if not src:
|
| 279 |
source=video.find("source")
|
| 280 |
if source: src=source.get("src","")
|
|
|
|
| 281 |
if src: return{"src":src,"poster":poster,"type":"hls" if".m3u8" in src else"video"}
|
| 282 |
m3u8s=re.findall(r'(https?://[^\s"\'<>]+\.m3u8)',r.text)
|
| 283 |
+
if m3u8s:
|
| 284 |
+
og=soup.find("meta",property="og:image"); poster=og.get("content","") if og else ""
|
| 285 |
+
return{"src":m3u8s[0],"poster":poster,"type":"hls"}
|
| 286 |
return None
|
| 287 |
except: return None
|
| 288 |
|
| 289 |
+
# ===== YOUTUBE SHORTS SCRAPING =====
|
| 290 |
+
def _yt_channel_shorts_requests(channel, count=15):
|
| 291 |
+
try:
|
| 292 |
+
url=f"https://www.youtube.com/@{channel}/shorts"
|
| 293 |
+
r=requests.get(url, headers={**HEADERS,"Accept-Language":"vi,en;q=0.8"}, timeout=15)
|
| 294 |
+
if r.status_code!=200: return []
|
| 295 |
+
html=r.text; ids=[]; items=[]
|
| 296 |
+
for m in re.finditer(r'"videoId":"([A-Za-z0-9_-]{11})"',html):
|
| 297 |
+
vid=m.group(1)
|
| 298 |
+
if vid in ids: continue
|
| 299 |
+
ids.append(vid)
|
| 300 |
+
snip=html[max(0,m.start()-900):m.start()+1600]
|
| 301 |
+
title=""
|
| 302 |
+
mt=re.search(r'"title":\{"runs":\[\{"text":"([^"]+)"',snip)
|
| 303 |
+
if not mt: mt=re.search(r'"accessibilityText":"([^"]+)"',snip)
|
| 304 |
+
if mt: title=html_lib.unescape(mt.group(1)).replace('\n',' ').strip()
|
| 305 |
+
if not title: title="YouTube Short"
|
| 306 |
+
items.append({"title":title,"link":f"https://www.youtube.com/watch?v={vid}","img":f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg","source":"yt","id":vid,"channel":channel})
|
| 307 |
+
if len(items)>=count: break
|
| 308 |
+
return items
|
| 309 |
+
except: return []
|
| 310 |
+
|
| 311 |
+
def scrape_shorts():
|
| 312 |
+
vids=[]
|
| 313 |
+
with ThreadPoolExecutor(3) as ex:
|
| 314 |
+
futs=[ex.submit(_yt_channel_shorts_requests,ch,24) for ch in ["baodantri7941","baosuckhoedoisongboyte","vtvnambo"]]
|
| 315 |
+
for f in as_completed(futs):
|
| 316 |
+
try:
|
| 317 |
+
r=f.result()
|
| 318 |
+
if r: vids.extend(r)
|
| 319 |
+
except: pass
|
| 320 |
+
merged=[]; seen=set()
|
| 321 |
+
for v in vids:
|
| 322 |
+
vid=v.get("id")
|
| 323 |
+
if not vid or vid in seen: continue
|
| 324 |
+
seen.add(vid); merged.append(v)
|
| 325 |
+
for v in SHORTS_FALLBACK:
|
| 326 |
+
vid=v.get("id")
|
| 327 |
+
if not vid or vid in seen: continue
|
| 328 |
+
seen.add(vid); merged.append(v)
|
| 329 |
+
return merged[:60]
|
| 330 |
+
|
| 331 |
+
# ===== VTV NAM BO & WC SHORTS - using yt-dlp =====
|
| 332 |
+
from yt_scraper import get_vtvnambo_shorts, get_wc_related_shorts
|
| 333 |
+
|
| 334 |
+
@app.get("/api/shorts/vtvnamo")
|
| 335 |
+
def api_shorts_vtvnamo(count: int = Query(default=50, le=100)):
|
| 336 |
+
items = get_vtvnambo_shorts(count)
|
| 337 |
+
if not items:
|
| 338 |
+
items = [v for v in SHORTS_FALLBACK if v.get("channel") == "vtvnambo"]
|
| 339 |
+
nql = [v for v in items if v.get("id") == "nqlLH6chLRo"]
|
| 340 |
+
rest = [v for v in items if v.get("id") != "nqlLH6chLRo"]
|
| 341 |
+
items = nql + rest
|
| 342 |
+
return JSONResponse(items)
|
| 343 |
+
|
| 344 |
+
@app.get("/api/shorts/wc")
|
| 345 |
+
def api_shorts_wc(count: int = Query(default=50, le=100)):
|
| 346 |
+
items = get_wc_related_shorts(count)
|
| 347 |
+
if not items:
|
| 348 |
+
items = [v for v in SHORTS_FALLBACK if v.get("channel") == "vtvnambo"]
|
| 349 |
+
nql = [v for v in items if v.get("id") == "nqlLH6chLRo"]
|
| 350 |
+
rest = [v for v in items if v.get("id") != "nqlLH6chLRo"]
|
| 351 |
+
items = nql + rest
|
| 352 |
+
return JSONResponse(items)
|
| 353 |
+
|
| 354 |
# ===== LIVESCORE =====
|
| 355 |
@app.get("/api/livescore/live")
|
| 356 |
def api_livescore_live(): return JSONResponse({"html":_cached("ls_live",lambda:fetch_bongda_api("/api/fixtures/live"),ttl=_cache_ttl_live)})
|
|
|
|
| 403 |
return None
|
| 404 |
return JSONResponse(_cached("ls_featured",_f,ttl=30))
|
| 405 |
|
| 406 |
+
@app.get("/api/shorts")
|
| 407 |
+
def api_shorts(channel: str = Query(default="")):
|
| 408 |
+
if channel == "vtvnambo": return api_shorts_vtvnamo()
|
| 409 |
+
if channel == "wc": return api_shorts_wc()
|
| 410 |
+
return JSONResponse(_cached("yt_shorts_v3",scrape_shorts,ttl=_cache_ttl_yt))
|
| 411 |
+
|
| 412 |
+
@app.get("/api/short-stats")
|
| 413 |
+
def api_short_stats(ids:str=Query(default="")):
|
| 414 |
+
arr=[x for x in ids.split(",") if x]
|
| 415 |
+
with _short_lock:
|
| 416 |
+
db=_load_short_db();out={}
|
| 417 |
+
for vid in arr:
|
| 418 |
+
st=db.get(vid) or _short_default()
|
| 419 |
+
out[vid]={"views":int(st.get("views",0)),"likes":int(st.get("likes",0)),"shares":int(st.get("shares",0)),"comments":st.get("comments",[])[:80]}
|
| 420 |
+
return JSONResponse({"stats":out})
|
| 421 |
+
|
| 422 |
+
@app.post("/api/short-action")
|
| 423 |
+
async def api_short_action(request:Request):
|
| 424 |
+
try: body=await request.json()
|
| 425 |
+
except: body={}
|
| 426 |
+
vid=str(body.get("id","")).strip(); action=str(body.get("action","")).strip(); txt=str(body.get("text","")).strip()
|
| 427 |
+
if not vid: return JSONResponse({"error":"missing id"},status_code=400)
|
| 428 |
+
with _short_lock:
|
| 429 |
+
db=_load_short_db(); st=db.get(vid) or _short_default()
|
| 430 |
+
if action=="view": st["views"]=int(st.get("views",0))+1
|
| 431 |
+
elif action=="like": st["likes"]=int(st.get("likes",0))+1
|
| 432 |
+
elif action=="share": st["shares"]=int(st.get("shares",0))+1
|
| 433 |
+
elif action=="comment" and txt:
|
| 434 |
+
comments=st.get("comments",[])
|
| 435 |
+
comments.insert(0,{"text":txt[:180],"ts":int(time.time())})
|
| 436 |
+
st["comments"]=comments[:80]
|
| 437 |
+
st["updated"]=int(time.time()); db[vid]=st; _save_short_db(db)
|
| 438 |
+
out={"views":int(st.get("views",0)),"likes":int(st.get("likes",0)),"shares":int(st.get("shares",0)),"comments":st.get("comments",[])[:80]}
|
| 439 |
+
return JSONResponse({"stats":out})
|
| 440 |
+
|
| 441 |
@app.get("/api/highlights")
|
| 442 |
def api_highlights(): return JSONResponse(_cached("xemlaibongda_hl",scrape_xemlaibongda,ttl=_cache_ttl))
|
| 443 |
@app.get("/api/highlights/leagues")
|
|
|
|
| 448 |
return JSONResponse(_cached(f"hl_{league}",lambda:scrape_highlights_by_league(league),ttl=_cache_ttl))
|
| 449 |
|
| 450 |
@app.get("/api/video_url")
|
| 451 |
+
def api_video_url(url:str=Query(...)):
|
| 452 |
if "youtube.com" in url or "youtu.be" in url:
|
| 453 |
m=re.search(r'(?:v=|shorts/|youtu\.be/)([a-zA-Z0-9_-]{11})',url)
|
| 454 |
if m: vid=m.group(1); return JSONResponse({"src":f"https://www.youtube.com/embed/{vid}?autoplay=1&rel=0&enablejsapi=1","poster":f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg","type":"youtube"})
|
|
|
|
| 456 |
v=extract_xemlaibongda_video(url)
|
| 457 |
if v:
|
| 458 |
if v["type"]=="hls": v["src"]="/api/proxy/m3u8?url="+quote(v["src"],safe="")
|
|
|
|
| 459 |
return JSONResponse(v)
|
| 460 |
return JSONResponse({"error":"not found"})
|
| 461 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 462 |
@app.get("/api/bdp_videos")
|
| 463 |
def api_bdp_videos():
|
| 464 |
def _f():
|
|
|
|
| 479 |
return JSONResponse(_cached("bdp_videos",_f))
|
| 480 |
|
| 481 |
# ===== NEWS =====
|
| 482 |
+
VNE_CATS={"thoi-su":("https://vnexpress.net/thoi-su","Thời Sự"),"the-gioi":("https://vnexpress.net/the-gioi","Thế Giới"),"kinh-doanh":("https://vnexpress.net/kinh-doanh","Kinh Doanh"),"the-thao":("https://vnexpress.net/the-thao","Thể Thao"),"giai-tri":("https://vnexpress.net/giai-trí","Giải Trí"),"suc-khoe":("https://vnexpress.net/suc-khoe","Sức Khỏe"),"phap-luat":("https://vnexpress.net/phap-luat","Pháp Luật"),"giao-duc":("https://vnexpress.net/giao-duc","Giáo Dục"),"du-lich":("https://vnexpress.net/du-lich","Du Lịch"),"doi-song":("https://vnexpress.net/doi-song","Đời Sống")}
|
| 483 |
|
| 484 |
def scrape_vne(cat_url):
|
| 485 |
try:
|
|
|
|
| 515 |
for img in container.find_all("img"):
|
| 516 |
s=img.get("data-src","") or img.get("src","")
|
| 517 |
if s and "mediacdn" in s and "avatar" not in s and "logo" not in s: img_src=s; break
|
| 518 |
+
if img_src: break
|
| 519 |
+
container=container.parent
|
| 520 |
seen.add(href)
|
| 521 |
if not img_src:
|
| 522 |
try:
|
|
|
|
| 559 |
for k,(u,n) in VNE_CATS.items(): cats.append({"id":k,"name":n,"source":"vne"})
|
| 560 |
return JSONResponse(cats)
|
| 561 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 562 |
@app.get("/api/article")
|
| 563 |
def api_article(url:str=Query(...)):
|
| 564 |
+
return JSONResponse({"error":"not supported"})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 565 |
|
| 566 |
@app.get("/api/storage_status")
|
| 567 |
def api_storage_status():
|
|
|
|
| 573 |
|
| 574 |
@app.get("/", response_class=HTMLResponse)
|
| 575 |
async def root():
|
| 576 |
+
return HTMLResponse("<h1>VNEWS</h1><p>Running</p>")
|
rewrite_fix_v2.js
DELETED
|
@@ -1,2 +0,0 @@
|
|
| 1 |
-
// No-op - all functionality built into app_v2.js
|
| 2 |
-
(function(){})();
|
|
|
|
|
|
|
|
|
runtime.txt
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
python-3.12.x
|
|
|
|
|
|
shorts_cache.py
DELETED
|
@@ -1,86 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
VNEWS Shorts Runtime Cache - External Updater Module
|
| 3 |
-
GitHub Actions fetches YouTube shorts via yt-dlp -> POST to /api/shorts/update
|
| 4 |
-
Space saves to RAM cache + persistent file if /data available
|
| 5 |
-
"""
|
| 6 |
-
import os
|
| 7 |
-
import json
|
| 8 |
-
import time
|
| 9 |
-
import threading
|
| 10 |
-
|
| 11 |
-
# Runtime cache (RAM)
|
| 12 |
-
_shorts_runtime_cache = None
|
| 13 |
-
_shorts_cache_ts = 0
|
| 14 |
-
_shorts_cache_lock = threading.Lock()
|
| 15 |
-
|
| 16 |
-
# Secret for authenticating update requests
|
| 17 |
-
SHORTS_UPDATE_SECRET = os.environ.get("SHORTS_UPDATE_SECRET", "vnews-shorts-2026")
|
| 18 |
-
|
| 19 |
-
# Paths
|
| 20 |
-
SHORTS_CACHE_FILE = "/data/shorts_runtime_cache.json" if os.path.isdir("/data") else "/app/shorts_runtime_cache.json"
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
def get_runtime_cache():
|
| 24 |
-
"""Get cached shorts (from RAM or file fallback)"""
|
| 25 |
-
global _shorts_runtime_cache, _shorts_cache_ts
|
| 26 |
-
with _shorts_cache_lock:
|
| 27 |
-
if _shorts_runtime_cache is not None:
|
| 28 |
-
age = time.time() - _shorts_cache_ts
|
| 29 |
-
if age < 7200: # 2h fresh
|
| 30 |
-
return _shorts_runtime_cache
|
| 31 |
-
|
| 32 |
-
# Try file fallback
|
| 33 |
-
try:
|
| 34 |
-
if os.path.exists(SHORTS_CACHE_FILE):
|
| 35 |
-
with open(SHORTS_CACHE_FILE, "r", encoding="utf-8") as f:
|
| 36 |
-
data = json.load(f)
|
| 37 |
-
age = time.time() - data.get("ts", 0)
|
| 38 |
-
if age < 86400: # 24h stale limit
|
| 39 |
-
items = data.get("items", [])
|
| 40 |
-
with _shorts_cache_lock:
|
| 41 |
-
_shorts_runtime_cache = items
|
| 42 |
-
_shorts_cache_ts = data.get("ts", time.time())
|
| 43 |
-
return items
|
| 44 |
-
except Exception as e:
|
| 45 |
-
print(f"[cache] read error: {e}")
|
| 46 |
-
|
| 47 |
-
return None
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
def set_runtime_cache(items):
|
| 51 |
-
"""Update runtime cache from external data"""
|
| 52 |
-
global _shorts_runtime_cache, _shorts_cache_ts
|
| 53 |
-
ts = time.time()
|
| 54 |
-
with _shorts_cache_lock:
|
| 55 |
-
_shorts_runtime_cache = items
|
| 56 |
-
_shorts_cache_ts = ts
|
| 57 |
-
|
| 58 |
-
# Also write to file (persistent if /data mounted)
|
| 59 |
-
try:
|
| 60 |
-
os.makedirs(os.path.dirname(SHORTS_CACHE_FILE), exist_ok=True)
|
| 61 |
-
payload = {"items": items, "ts": ts, "count": len(items)}
|
| 62 |
-
with open(SHORTS_CACHE_FILE, "w", encoding="utf-8") as f:
|
| 63 |
-
json.dump(payload, f, ensure_ascii=False, indent=2)
|
| 64 |
-
print(f"[cache] saved {len(items)} shorts to {SHORTS_CACHE_FILE}")
|
| 65 |
-
except Exception as e:
|
| 66 |
-
print(f"[cache] write skipped: {e}")
|
| 67 |
-
|
| 68 |
-
return len(items)
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
def get_cache_status():
|
| 72 |
-
"""Return status dict for the cache"""
|
| 73 |
-
cache = None
|
| 74 |
-
with _shorts_cache_lock:
|
| 75 |
-
if _shorts_runtime_cache is not None:
|
| 76 |
-
cache = _shorts_runtime_cache
|
| 77 |
-
age = int(time.time() - _shorts_cache_ts)
|
| 78 |
-
else:
|
| 79 |
-
age = -1
|
| 80 |
-
return {
|
| 81 |
-
"cached": cache is not None,
|
| 82 |
-
"count": len(cache) if cache else 0,
|
| 83 |
-
"age_seconds": age,
|
| 84 |
-
"has_persistent": os.path.isdir("/data"),
|
| 85 |
-
"cache_file_exists": os.path.exists(SHORTS_CACHE_FILE),
|
| 86 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
shorts_rss_proxy.py
DELETED
|
@@ -1,114 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
YouTube RSS Proxy - Fetches YouTube channel RSS feeds server-side
|
| 3 |
-
Avoids CORS issues when client tries to fetch YouTube directly
|
| 4 |
-
"""
|
| 5 |
-
import requests as req
|
| 6 |
-
from fastapi import Query
|
| 7 |
-
from fastapi.responses import Response
|
| 8 |
-
|
| 9 |
-
HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
|
| 10 |
-
|
| 11 |
-
YOUTUBE_CHANNELS = {
|
| 12 |
-
"baodantri7941": "UC_x5TKhOgd6GhYvv5z4I3jg",
|
| 13 |
-
"baosuckhoedoisongboyte": "UCBsY5fXTQLkF_JnH9kLkL4g",
|
| 14 |
-
}
|
| 15 |
-
|
| 16 |
-
def setup_rss_proxy(app):
|
| 17 |
-
"""Add RSS proxy endpoints to the FastAPI app"""
|
| 18 |
-
|
| 19 |
-
@app.get("/api/proxy/rss")
|
| 20 |
-
def proxy_rss(url: str = Query(...)):
|
| 21 |
-
"""Proxy YouTube RSS feed to avoid CORS"""
|
| 22 |
-
try:
|
| 23 |
-
r = req.get(url, headers=HEADERS, timeout=15)
|
| 24 |
-
if r.status_code == 200:
|
| 25 |
-
return Response(
|
| 26 |
-
content=r.content,
|
| 27 |
-
media_type="application/xml",
|
| 28 |
-
headers={"Access-Control-Allow-Origin": "*"}
|
| 29 |
-
)
|
| 30 |
-
return Response(status_code=r.status_code)
|
| 31 |
-
except Exception as e:
|
| 32 |
-
return Response(status_code=502, content=str(e))
|
| 33 |
-
|
| 34 |
-
@app.get("/api/shorts/rss")
|
| 35 |
-
def shorts_via_rss():
|
| 36 |
-
"""Get shorts from YouTube RSS feeds server-side"""
|
| 37 |
-
import xml.etree.ElementTree as ET
|
| 38 |
-
import html as html_lib
|
| 39 |
-
import re
|
| 40 |
-
|
| 41 |
-
shorts = []
|
| 42 |
-
seen = set()
|
| 43 |
-
|
| 44 |
-
for handle, channel_id in YOUTUBE_CHANNELS.items():
|
| 45 |
-
try:
|
| 46 |
-
rss_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
|
| 47 |
-
r = req.get(rss_url, headers=HEADERS, timeout=15)
|
| 48 |
-
if r.status_code != 200:
|
| 49 |
-
continue
|
| 50 |
-
|
| 51 |
-
root = ET.fromstring(r.text)
|
| 52 |
-
ns = {
|
| 53 |
-
'atom': 'http://www.w3.org/2005/Atom',
|
| 54 |
-
'yt': 'http://www.youtube.com/xml/schemas/2015',
|
| 55 |
-
'media': 'http://search.yahoo.com/mrss/'
|
| 56 |
-
}
|
| 57 |
-
|
| 58 |
-
for entry in root.findall('atom:entry', ns)[:30]:
|
| 59 |
-
title_el = entry.find('atom:title', ns)
|
| 60 |
-
title = html_lib.unescape(title_el.text) if title_el is not None and title_el.text else ''
|
| 61 |
-
|
| 62 |
-
link_el = entry.find('atom:link', ns)
|
| 63 |
-
link = link_el.get('href', '') if link_el is not None else ''
|
| 64 |
-
|
| 65 |
-
vid_el = entry.find('yt:videoId', ns)
|
| 66 |
-
vid = vid_el.text if vid_el is not None else ''
|
| 67 |
-
|
| 68 |
-
if not vid:
|
| 69 |
-
m = re.search(r'(?:v=|shorts/)([A-Za-z0-9_-]{11})', link)
|
| 70 |
-
if m:
|
| 71 |
-
vid = m.group(1)
|
| 72 |
-
|
| 73 |
-
if not vid or vid in seen:
|
| 74 |
-
continue
|
| 75 |
-
|
| 76 |
-
# Check if it's a short
|
| 77 |
-
is_short = '#shorts' in title.lower() or '#short' in title.lower() or '/shorts/' in link
|
| 78 |
-
|
| 79 |
-
if not is_short:
|
| 80 |
-
desc_el = entry.find('media:description', ns)
|
| 81 |
-
if desc_el is not None and desc_el.text:
|
| 82 |
-
if '#shorts' in desc_el.text.lower():
|
| 83 |
-
is_short = True
|
| 84 |
-
|
| 85 |
-
if not is_short:
|
| 86 |
-
continue
|
| 87 |
-
|
| 88 |
-
seen.add(vid)
|
| 89 |
-
|
| 90 |
-
# Get thumbnail
|
| 91 |
-
thumb = f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg"
|
| 92 |
-
media_group = entry.find('media:group', ns)
|
| 93 |
-
if media_group is not None:
|
| 94 |
-
thumb_el = media_group.find('media:thumbnail', ns)
|
| 95 |
-
if thumb_el is not None:
|
| 96 |
-
thumb = thumb_el.get('url', thumb)
|
| 97 |
-
|
| 98 |
-
shorts.append({
|
| 99 |
-
'id': vid,
|
| 100 |
-
'title': title.replace('#shorts', '').replace('#short', '').strip()[:120],
|
| 101 |
-
'img': thumb,
|
| 102 |
-
'link': f'https://www.youtube.com/shorts/{vid}',
|
| 103 |
-
'channel': handle,
|
| 104 |
-
'source': 'yt'
|
| 105 |
-
})
|
| 106 |
-
|
| 107 |
-
if len(shorts) >= 40:
|
| 108 |
-
break
|
| 109 |
-
|
| 110 |
-
except Exception as e:
|
| 111 |
-
print(f"RSS error for {handle}: {e}")
|
| 112 |
-
continue
|
| 113 |
-
|
| 114 |
-
return {"shorts": shorts, "count": len(shorts)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/app_v2.js
CHANGED
|
@@ -1,17 +1,4 @@
|
|
| 1 |
-
/
|
| 2 |
-
* VNEWS Frontend v2 - Shorts Dantri/SKDS removed, VTV Digital CDN
|
| 3 |
-
* v2.5 - Added 'Tạo lại' button on wall cards with video
|
| 4 |
-
* v2.4 - Fixed: prependWallPost detached-element bug, makeShortVideo UI update, slide viewer for rewrite posts
|
| 5 |
-
*/
|
| 6 |
-
function _proxyImg(url){
|
| 7 |
-
if(!url || typeof url !== 'string') return '';
|
| 8 |
-
if(url.startsWith('http') && !url.includes(location.host)){
|
| 9 |
-
return '/api/proxy/img?url='+encodeURIComponent(url);
|
| 10 |
-
}
|
| 11 |
-
return url;
|
| 12 |
-
}
|
| 13 |
-
|
| 14 |
-
var _ttsSelections = {};
|
| 15 |
|
| 16 |
function _fetchWithTimeout(url, ms){
|
| 17 |
return new Promise((resolve,reject)=>{
|
|
@@ -32,7 +19,6 @@ async function loadHome(){
|
|
| 32 |
homeEl.innerHTML =
|
| 33 |
'<div id="home-featured-area"></div>'
|
| 34 |
+'<div class="ai-compose"><div class="ai-compose-title">🤖 AI viết bài</div><div class="ai-compose-row"><input id="topic-input" placeholder="Nhập chủ đề..."><button onclick="searchTopic()">Tìm nguồn</button></div><div class="ai-compose-row"><input id="url-input" placeholder="Dán URL bài viết..."><button class="secondary" onclick="rewriteUrl()">Rewrite</button></div><div id="hot-topics" class="hot-topic-row"></div></div>'
|
| 35 |
-
+'<div id="ai-wall-under-compose"></div>'
|
| 36 |
+'<div id="hashtag-box"></div>'
|
| 37 |
+'<div class="ls-section"><div class="ls-header"><h3>⚽ Livescore</h3></div><div class="ls-tabs"><span class="ls-tab active" data-tab="today" onclick="loadLivescore(\'today\')">📅 Hôm nay</span><span class="ls-tab" data-tab="live" onclick="loadLivescore(\'live\')">🔴 Live</span><span class="ls-tab" data-tab="incoming" onclick="loadLivescore(\'incoming\')">⏰ Sắp tới</span><span class="ls-tab" data-tab="results" onclick="loadLivescore(\'results\')">✅ Kết quả</span><span class="ls-tab" data-tab="bxh_nha" onclick="loadLivescore(\'bxh_nha\')">🏆 NHA</span><span class="ls-tab" data-tab="bxh_laliga" onclick="loadLivescore(\'bxh_laliga\')">🏆 La Liga</span></div><div class="ls-content" id="ls-content"><div class="loading">Đang tải...</div></div></div>'
|
| 38 |
+'<div id="wc2026-live-section" class="wc2026-section"><div class="wc-header"><h2>🏆 World Cup 2026</h2><span class="wc-live-badge">● LIVE</span></div><div class="wc-tabs"><span class="wc-tab active" onclick="switchWCTab(\'news\')">📰 Tin tức</span><span class="wc-tab" onclick="switchWCTab(\'fixtures\')">📅 Lịch thi đấu</span><span class="wc-tab" onclick="switchWCTab(\'standings\')">🏆 BXH</span><span class="wc-tab" onclick="switchWCTab(\'highlights\')">🎬 Highlight</span><span class="wc-tab" onclick="switchWCTab(\'stats\')">📊 Thống kê</span></div><div class="wc-content" id="wc-content"><div class="loading">Đang tải World Cup 2026...</div></div></div>'
|
|
@@ -43,28 +29,35 @@ async function loadHome(){
|
|
| 43 |
loadLivescore('today');
|
| 44 |
loadHotTopics();
|
| 45 |
|
| 46 |
-
const [featuredData, wallData, hlLeagues, wcData] = await Promise.allSettled([
|
| 47 |
_fetchWithTimeout('/api/livescore/featured', 5000),
|
|
|
|
| 48 |
_fetchWithTimeout('/api/wall', 5000),
|
| 49 |
_fetchWithTimeout('/api/highlights/leagues', 10000),
|
|
|
|
| 50 |
_fetchWithTimeout('/api/wc2026', 8000),
|
|
|
|
| 51 |
]).then(results => results.map(r => r.status === 'fulfilled' ? r.value : null));
|
| 52 |
|
| 53 |
if(featuredData && featuredData.home){
|
| 54 |
const sc=featuredData.status==='live'?'':'upcoming';
|
| 55 |
const st=featuredData.status==='live'?`🔴 ${featuredData.minute||'LIVE'}`:`⏰ ${featuredData.time}`;
|
| 56 |
const area=document.getElementById('home-featured-area');
|
| 57 |
-
if(area) area.innerHTML=`<div class="featured-match" onclick="openMatch('${featuredData.event_id}')"><div class="fm-league">${featuredData.league}</div><div class="fm-teams"><div class="fm-team"><img src="${
|
| 58 |
}
|
| 59 |
|
|
|
|
| 60 |
_wallPosts = (wallData && wallData.posts) || [];
|
| 61 |
_hlLeagueData = hlLeagues || {};
|
| 62 |
_wc2026Data = wcData;
|
|
|
|
| 63 |
|
| 64 |
if(wcData) switchWCTab('news');
|
| 65 |
|
|
|
|
| 66 |
_renderWallIn(afterEl);
|
| 67 |
_renderHLIn(afterEl);
|
|
|
|
| 68 |
}
|
| 69 |
|
| 70 |
function _renderSlidesIn(key, label, emoji, vids, afterEl){
|
|
@@ -75,9 +68,9 @@ function _renderSlidesIn(key, label, emoji, vids, afterEl){
|
|
| 75 |
const isHL = key==='world-cup'||key==='premier-league'||key==='champions-league'||key==='la-liga'||key==='serie-a'||key==='bundesliga'||key==='friendly';
|
| 76 |
vids.slice(0,isHL?8:12).forEach((a,i)=>{
|
| 77 |
if(isHL){
|
| 78 |
-
h+=`<div class="slider-item" onclick="openHighlightFeed('${key}',${i})"><div class="slider-thumb">${a.img?`<img src="${
|
| 79 |
} else {
|
| 80 |
-
h+=`<div class="slider-item" onclick="readArticle('${esc(a.link)}')"><div class="slider-thumb">${a.img?`<img src="${
|
| 81 |
}
|
| 82 |
});
|
| 83 |
h+='</div>';
|
|
@@ -85,17 +78,42 @@ function _renderSlidesIn(key, label, emoji, vids, afterEl){
|
|
| 85 |
afterEl.parentNode.insertBefore(wrap, afterEl);
|
| 86 |
}
|
| 87 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
function _renderWallIn(afterEl){
|
| 89 |
-
if(!_wallPosts||!_wallPosts.length) return;
|
| 90 |
const posts=_wallPosts;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
const wrap=document.createElement('div');
|
| 92 |
wrap.className='slider-wrap';wrap.id='ai-wall-wrap';
|
| 93 |
let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track" id="ai-wall-track">';
|
| 94 |
posts.slice(0,20).forEach((p,i)=>{h+=makeWallItem(p,i);});
|
| 95 |
h+='</div>';wrap.innerHTML=h;
|
| 96 |
-
|
| 97 |
-
if(target) target.appendChild(wrap);
|
| 98 |
-
else if(afterEl) afterEl.parentNode.insertBefore(wrap,afterEl);
|
| 99 |
}
|
| 100 |
|
| 101 |
function _renderHLIn(afterEl){
|
|
@@ -108,295 +126,149 @@ function _renderHLIn(afterEl){
|
|
| 108 |
}
|
| 109 |
}
|
| 110 |
|
| 111 |
-
// === WALL POST HELPERS ===
|
| 112 |
function makeWallItem(p,i){
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
var vid = p.id||i;
|
| 119 |
-
var lang = p.language || detectLanguage(p.title + ' ' + (p.text||''));
|
| 120 |
-
var curVoice = p.voice || getAutoVoice(lang);
|
| 121 |
-
var curEmotion = p.emotion || detectEmotion(p.title + ' ' + (p.text||''));
|
| 122 |
-
var selKey = 'inline-' + vid;
|
| 123 |
-
if(!_ttsSelections[selKey]) _ttsSelections[selKey] = {voice: curVoice, emotion: curEmotion};
|
| 124 |
-
var voiceOpts = '';
|
| 125 |
-
VOICE_LIST.forEach(function(v){
|
| 126 |
-
voiceOpts += '<option value="'+v.id+'"'+(v.id===_ttsSelections[selKey].voice?' selected':'')+'>'+v.label+'</option>';
|
| 127 |
-
});
|
| 128 |
-
var emotOpts = '';
|
| 129 |
-
EMOTION_LIST.forEach(function(e){
|
| 130 |
-
emotOpts += '<option value="'+e.id+'"'+(e.id===_ttsSelections[selKey].emotion?' selected':'')+'>'+e.label+'</option>';
|
| 131 |
-
});
|
| 132 |
-
var spd = p.short_speed || '1.2';
|
| 133 |
-
var voiceBar = '<div class="wall-tts-bar" style="margin:6px 0 4px;display:grid;grid-template-columns:1fr auto auto;gap:3px">'
|
| 134 |
-
+'<select class="wvs" data-selkey="'+selKey+'" style="background:#1a1a1a;border:1px solid #333;color:#ccc;padding:3px;border-radius:6px;font-size:9px;min-width:0" onchange="_ttsSelections[this.dataset.selkey].voice=this.value">'
|
| 135 |
-
+voiceOpts+'</select>'
|
| 136 |
-
+'<select class="wes" data-selkey="'+selKey+'" style="background:#1a1a1a;border:1px solid #333;color:#ccc;padding:3px;border-radius:6px;font-size:9px;min-width:0" onchange="_ttsSelections[this.dataset.selkey].emotion=this.value">'
|
| 137 |
-
+emotOpts+'</select>'
|
| 138 |
-
+'<select class="wss" data-selkey="'+selKey+'" style="background:#1a1a1a;border:1px solid #333;color:#ccc;padding:3px;border-radius:6px;font-size:9px;min-width:0" onchange="_ttsSelections[this.dataset.selkey].speed=parseFloat(this.value)">'
|
| 139 |
-
+'<option value="0.85"'+(spd==='0.85'?' selected':'')+'>0.85x</option>'
|
| 140 |
-
+'<option value="1.0"'+(spd==='1.0'?' selected':'')+'>1.0x</option>'
|
| 141 |
-
+'<option value="1.2"'+(spd==='1.2'?' selected':'')+'>1.2x</option>'
|
| 142 |
-
+'<option value="1.35"'+(spd==='1.35'?' selected':'')+'>1.35x</option>'
|
| 143 |
-
+'</select>'
|
| 144 |
-
+'</div>';
|
| 145 |
-
var makeBtn = hasVideo
|
| 146 |
-
? '<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed('+i+')">▶ Xem Short</button><button class="wall-btn-make" style="margin-left:4px" onclick="event.stopPropagation();makeShortVideo(\''+esc(vid)+'\',this,_ttsSelections[\'inline-'+esc(vid)+'\'].voice,parseFloat(document.querySelector(\'.wss[data-selkey=inline-'+esc(vid)+']\').value)||1.2,_ttsSelections[\'inline-'+esc(vid)+'\'].emotion)">🎬 Tạo lại</button>'
|
| 147 |
-
: '<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo(\''+esc(vid)+'\',this,_ttsSelections[\'inline-'+esc(vid)+'\'].voice,parseFloat(document.querySelector(\'.wss[data-selkey=inline-'+esc(vid)+']\').value)||1.2,_ttsSelections[\'inline-'+esc(vid)+'\'].emotion)">🎬 Tạo Short</button>';
|
| 148 |
-
return '<div class="wall-item" id="wall-item-'+esc(vid)+'"><div class="wall-thumb">'+thumbContent+videoBadge+'</div><div class="wall-title">'+esc(p.title)+'</div><div class="wall-text">'+esc((p.text||'').slice(0,180))+'</div>'+voiceBar+'<div class="wall-actions"><button class="primary" onclick="readWallPost('+i+')">Xem</button>'+makeBtn+'</div></div>';
|
| 149 |
}
|
| 150 |
|
| 151 |
-
async function makeShortVideo(postId, btn, voice, speed
|
| 152 |
if(!postId)return;
|
| 153 |
const origText = btn ? btn.textContent : '🎬 Tạo Video';
|
| 154 |
if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo...';}
|
| 155 |
toast('⏳ Đang tạo video shorts...');
|
| 156 |
-
if(!voice || !emotion){
|
| 157 |
-
const selKey = 'inline-'+postId;
|
| 158 |
-
const container = document.querySelector('.tts-selector[data-post-id="'+postId+'"]');
|
| 159 |
-
if(container){
|
| 160 |
-
if(_ttsSelections[selKey]){
|
| 161 |
-
voice = voice || _ttsSelections[selKey].voice;
|
| 162 |
-
emotion = emotion || _ttsSelections[selKey].emotion;
|
| 163 |
-
}
|
| 164 |
-
if(!voice){
|
| 165 |
-
const selectedVoiceBtn = container.querySelector('.tts-voice-btn.selected') || container.querySelector('.tts-voice-btn[style*="5cb87a"]') || container.querySelector('.tts-voice-btn');
|
| 166 |
-
voice = selectedVoiceBtn ? selectedVoiceBtn.dataset.voice : 'vi-VN-HoaiMyNeural';
|
| 167 |
-
}
|
| 168 |
-
if(!emotion){
|
| 169 |
-
const selectedEmotionBtn = container.querySelector('.tts-emotion-btn.selected') || container.querySelector('.tts-emotion-btn[style*="5cb87a"]') || container.querySelector('.tts-emotion-btn');
|
| 170 |
-
emotion = selectedEmotionBtn ? selectedEmotionBtn.dataset.emotion : 'neutral';
|
| 171 |
-
}
|
| 172 |
-
const speedSelect = container.querySelector('.tts-speed');
|
| 173 |
-
speed = speedSelect ? parseFloat(speedSelect.value) || 1.2 : (speed || 1.2);
|
| 174 |
-
} else {
|
| 175 |
-
voice = voice || 'vi-VN-HoaiMyNeural';
|
| 176 |
-
emotion = emotion || 'neutral';
|
| 177 |
-
speed = speed || 1.2;
|
| 178 |
-
}
|
| 179 |
-
}
|
| 180 |
try{
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
const j = await r.json();
|
| 183 |
if(!r.ok || j.error) throw new Error(j.error||'Lỗi tạo video');
|
| 184 |
toast('✅ Đã tạo video shorts!');
|
| 185 |
const p = _wallPosts.find(x => String(x.id) === String(postId));
|
| 186 |
if(p){
|
| 187 |
p.video = j.video;
|
| 188 |
-
p.voice = j.voice;
|
| 189 |
-
p.emotion = j.emotion;
|
| 190 |
const itemId = 'wall-item-'+postId;
|
| 191 |
const el = document.getElementById(itemId);
|
| 192 |
if(el){
|
| 193 |
const idx = _wallPosts.indexOf(p);
|
| 194 |
-
el.
|
| 195 |
-
|
|
|
|
| 196 |
}
|
| 197 |
}
|
|
|
|
| 198 |
}catch(e){
|
| 199 |
toast('❌ '+e.message);
|
| 200 |
if(btn){btn.disabled=false;btn.textContent=origText;}
|
| 201 |
}
|
| 202 |
}
|
| 203 |
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
{id:'ko-KR-HyunsuMultilingualNeural', label:'🎙️ Hyunsu (KO)', lang:'ko'},
|
| 215 |
-
{id:'it-IT-GiuseppeMultilingualNeural', label:'🎙️ Giuseppe (IT)', lang:'it'},
|
| 216 |
-
];
|
| 217 |
-
var EMOTION_LIST = [
|
| 218 |
-
{id:'neutral', label:'😐 Trung tính'},
|
| 219 |
-
{id:'happy', label:'😊 Vui vẻ'},
|
| 220 |
-
{id:'excited', label:'🔥 Hào hứng'},
|
| 221 |
-
{id:'sad', label:'😢 Buồn'},
|
| 222 |
-
{id:'humorous', label:'😂 Hài hước'},
|
| 223 |
-
{id:'serious', label:'⚠️ Nghiêm túc'},
|
| 224 |
-
{id:'urgent', label:'🚨 Khẩn cấp'},
|
| 225 |
-
{id:'warm', label:'💖 Ấm áp'},
|
| 226 |
-
];
|
| 227 |
-
|
| 228 |
-
document.addEventListener('click',function(e){
|
| 229 |
-
var btn = e.target.closest('.tts-voice-btn');
|
| 230 |
-
if(btn){
|
| 231 |
-
var container = btn.closest('.tts-selector');
|
| 232 |
-
if(container){
|
| 233 |
-
var selKey = 'inline-'+container.dataset.postId;
|
| 234 |
-
if(!_ttsSelections[selKey]) _ttsSelections[selKey]={voice:btn.dataset.voice,emotion:'neutral'};
|
| 235 |
-
var allBtns = container.querySelectorAll('.tts-voice-btn');
|
| 236 |
-
for(var i=0;i<allBtns.length;i++){allBtns[i].style.borderColor='#333';allBtns[i].style.background='#222';allBtns[i].classList.remove('selected');}
|
| 237 |
-
btn.style.borderColor='#5cb87a';btn.style.background='#1a2a1f';btn.classList.add('selected');
|
| 238 |
-
_ttsSelections[selKey].voice = btn.dataset.voice;
|
| 239 |
}
|
| 240 |
-
return;
|
| 241 |
-
}
|
| 242 |
-
var ebtn = e.target.closest('.tts-emotion-btn');
|
| 243 |
-
if(ebtn){
|
| 244 |
-
var container = ebtn.closest('.tts-selector');
|
| 245 |
-
if(container){
|
| 246 |
-
var selKey = 'inline-'+container.dataset.postId;
|
| 247 |
-
if(!_ttsSelections[selKey]) _ttsSelections[selKey]={voice:'vi-VN-HoaiMyNeural',emotion:ebtn.dataset.emotion};
|
| 248 |
-
var allBtns = container.querySelectorAll('.tts-emotion-btn');
|
| 249 |
-
for(var i=0;i<allBtns.length;i++){allBtns[i].style.borderColor='#333';allBtns[i].style.background='#222';allBtns[i].classList.remove('selected');}
|
| 250 |
-
ebtn.style.borderColor='#5cb87a';ebtn.style.background='#1a2a1f';ebtn.classList.add('selected');
|
| 251 |
-
_ttsSelections[selKey].emotion = ebtn.dataset.emotion;
|
| 252 |
-
}
|
| 253 |
-
return;
|
| 254 |
-
}
|
| 255 |
-
var cbtn = e.target.closest('.tts-create-btn');
|
| 256 |
-
if(cbtn){
|
| 257 |
-
var container = cbtn.closest('.tts-selector');
|
| 258 |
-
if(container){
|
| 259 |
-
var selKey = 'inline-'+container.dataset.postId;
|
| 260 |
-
var selVoice = _ttsSelections[selKey] ? _ttsSelections[selKey].voice : 'vi-VN-HoaiMyNeural';
|
| 261 |
-
var selEmotion = _ttsSelections[selKey] ? _ttsSelections[selKey].emotion : 'neutral';
|
| 262 |
-
var speedSel = container.querySelector('.tts-speed');
|
| 263 |
-
var speed = speedSel ? parseFloat(speedSel.value)||1.2 : 1.2;
|
| 264 |
-
window.makeShortVideo(container.dataset.postId, cbtn, selVoice, speed, selEmotion);
|
| 265 |
-
}
|
| 266 |
-
return;
|
| 267 |
}
|
| 268 |
-
});
|
| 269 |
-
function detectLanguage(text){
|
| 270 |
-
if(!text) return 'vi';
|
| 271 |
-
var t=text.toLowerCase(), chars=new Set(t);
|
| 272 |
-
var vnChars='đăâêôơưàảãạáằẳẵặắầẩẫậấèẻẽẹéềễểệếìỉĩịíòỏõọóồổỗộốờởỡợớùủũụúừửữựứỳỷỹỵý';
|
| 273 |
-
var vnCount=0; for(var c of vnChars){if(chars.has(c)) vnCount++;}
|
| 274 |
-
if(vnCount>=2) return 'vi';
|
| 275 |
-
if(chars.has('ñ')||chars.has('¿')||chars.has('¡')) return 'es';
|
| 276 |
-
if(chars.has('ã')||chars.has('õ')) return 'pt';
|
| 277 |
-
var words=t.split(/\s+/);
|
| 278 |
-
var enWords=['the','is','at','which','on','and','or','but','this','that','with','from','have','been'];
|
| 279 |
-
var enCount=words.filter(function(w){return enWords.indexOf(w)>=0;}).length;
|
| 280 |
-
if(enCount>=2) return 'en';
|
| 281 |
-
return 'vi';
|
| 282 |
}
|
| 283 |
-
function detectEmotion(text){
|
| 284 |
-
if(!text) return 'neutral';
|
| 285 |
-
var t=text.toLowerCase();
|
| 286 |
-
var kws={
|
| 287 |
-
happy:['vui','hạnh phúc','tuyệt','thành công','chiến thắng','feliz','maravilloso','happy','joy','wonderful','great','amazing','love','excellent'],
|
| 288 |
-
excited:['hào hứng','phấn khích','đột phá','kỷ lục','đỉnh cao','emocionante','increíble','excited','thrilling','unbelievable','awesome','breakthrough'],
|
| 289 |
-
sad:['buồn','đau','mất','thảm họa','khủng hoảng','triste','terrible','sad','unhappy','tragic','painful','death'],
|
| 290 |
-
humorous:['hài hước','buồn cười','haha','đùa','engraçado','gracioso','funny','hilarious','joke','lol'],
|
| 291 |
-
serious:['nghiêm trọng','khẩn cấp','quan trọng','lo ngại','sério','crítico','serious','critical','urgent','severe','crisis'],
|
| 292 |
-
urgent:['khẩn cấp','báo động','ngay lập tức','urgent','breaking','alert','emergency'],
|
| 293 |
-
warm:['ấm áp','tình cảm','yêu thương','warm','love','heart','touching']
|
| 294 |
-
};
|
| 295 |
-
var bestScore=0, bestEmotion='neutral';
|
| 296 |
-
for(var em in kws){var score=0; for(var kw of kws[em]){if(t.indexOf(kw)>=0) score++;} if(score>bestScore){bestScore=score;bestEmotion=em;}}
|
| 297 |
-
return bestEmotion;
|
| 298 |
-
}
|
| 299 |
-
function getAutoVoice(lang){var map={vi:'vi-VN-HoaiMyNeural',pt:'pt-BR-ThalitaMultilingualNeural',en:'en-US-AndrewMultilingualNeural',fr:'fr-FR-VivienneMultilingualNeural',de:'de-DE-SeraphinaMultilingualNeural',ko:'ko-KR-HyunsuMultilingualNeural',it:'it-IT-GiuseppeMultilingualNeural'};return map[lang]||'vi-VN-HoaiMyNeural';}
|
| 300 |
-
function buildVoiceEmotionSelector(post){
|
| 301 |
-
var lang=post.language||detectLanguage(post.title+' '+(post.text||''));
|
| 302 |
-
var _oldVoiceMap = {'hoaimy':'vi-VN-HoaiMyNeural','namminh':'vi-VN-NamMinhNeural','andrew':'en-US-AndrewMultilingualNeural','jenny':'en-US-AndrewMultilingualNeural','thalita':'pt-BR-ThalitaMultilingualNeural','pt_thalita':'pt-BR-ThalitaMultilingualNeural','vivienne':'fr-FR-VivienneMultilingualNeural','remy':'fr-FR-RemyMultilingualNeural','seraphina':'de-DE-SeraphinaMultilingualNeural','florian':'de-DE-FlorianMultilingualNeural','sunhee':'ko-KR-HyunsuMultilingualNeural','hyunsu':'ko-KR-HyunsuMultilingualNeural','giuseppe':'it-IT-GiuseppeMultilingualNeural','ela':'en-US-AndrewMultilingualNeural','denise':'fr-FR-VivienneMultilingualNeural','katja':'de-DE-SeraphinaMultilingualNeural','nanami':'en-US-AndrewMultilingualNeural','xiaochen':'en-US-AndrewMultilingualNeural','es_carlos':'en-US-AndrewMultilingualNeural','pt_francisco':'pt-BR-ThalitaMultilingualNeural'};
|
| 303 |
-
var _postVoice = post.voice ? (_oldVoiceMap[post.voice] || post.voice) : '';
|
| 304 |
-
var autoVoice= _postVoice || getAutoVoice(lang);
|
| 305 |
-
var autoEmotion=post.emotion||detectEmotion(post.title+' '+(post.text||''));
|
| 306 |
-
var selKey = 'inline-'+post.id;
|
| 307 |
-
if(!_ttsSelections[selKey]){_ttsSelections[selKey] = {voice: autoVoice, emotion: autoEmotion};}
|
| 308 |
-
var h='<div class="tts-selector" data-post-id="'+post.id+'" style="margin-top:10px;padding:10px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:10px">';
|
| 309 |
-
h+='<div style="font-size:11px;color:#888;margin-bottom:6px">🎙️ Giọng đọc (ngôn ngữ: '+lang.toUpperCase()+'):</div><div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px">';
|
| 310 |
-
VOICE_LIST.forEach(function(v){var sel=v.id===_ttsSelections[selKey].voice?'border-color:#5cb87a;background:#1a2a1f':'border-color:#333;background:#222';h+='<button class="tts-voice-btn '+(v.id===_ttsSelections[selKey].voice?'selected':'')+'" data-voice="'+v.id+'" data-post-id="'+post.id+'" style="'+sel+';border:1px solid;color:#ccc;padding:4px 8px;border-radius:10px;font-size:10px;cursor:pointer">'+v.label+'</button>';});
|
| 311 |
-
h+='</div><div style="font-size:11px;color:#888;margin-bottom:6px">😊 Cảm xúc:</div><div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px">';
|
| 312 |
-
EMOTION_LIST.forEach(function(e){var sel=e.id===_ttsSelections[selKey].emotion?'border-color:#5cb87a;background:#1a2a1f':'border-color:#333;background:#222';h+='<button class="tts-emotion-btn '+(e.id===_ttsSelections[selKey].emotion?'selected':'')+'" data-emotion="'+e.id+'" data-post-id="'+post.id+'" style="'+sel+';border:1px solid;color:#ccc;padding:4px 8px;border-radius:10px;font-size:10px;cursor:pointer">'+e.label+'</button>';});
|
| 313 |
-
h+='</div><div style="display:flex;align-items:center;gap:6px;margin-bottom:8px"><span style="font-size:11px;color:#888">⚡ Tốc độ:</span>';
|
| 314 |
-
h+='<select class="tts-speed" style="background:#222;border:1px solid #333;color:#ccc;padding:3px 8px;border-radius:8px;font-size:10px"><option value="0.85">0.85x Chậm</option><option value="1.0">1.0x Bình thường</option><option value="1.2" selected>1.2x Nhanh</option><option value="1.35">1.35x Rất nhanh</option></select></div>';
|
| 315 |
-
h+='<button class="tts-create-btn" style="width:100%;background:#2d8659;border:0;color:#fff;padding:8px;border-radius:10px;font-size:11px;font-weight:700;cursor:pointer">🎬 Tạo Short AI</button></div>';
|
| 316 |
-
return h;
|
| 317 |
-
}
|
| 318 |
-
window.showVoiceEmotionSelector=function(postId,title,text){
|
| 319 |
-
var overlay=document.createElement('div');
|
| 320 |
-
overlay.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:99999;display:flex;align-items:center;justify-content:center;padding:16px';
|
| 321 |
-
var box=document.createElement('div');box.style.cssText='background:#1a1a1a;border:2px solid #2d8659;border-radius:16px;padding:20px;max-width:400px;width:100%;max-height:80vh;overflow-y:auto';
|
| 322 |
-
var lang=detectLanguage(title+' '+text);var autoEmotion=detectEmotion(title+' '+text);
|
| 323 |
-
var h='<h3 style="color:#5cb87a;margin-bottom:12px;font-size:16px">🎬 Tạo Short AI (ngôn ngữ: '+lang.toUpperCase()+')</h3>';
|
| 324 |
-
h+='<div style="margin-bottom:12px"><div style="color:#aaa;font-size:11px;margin-bottom:6px">🎙️ Chọn giọng đọc:</div>';
|
| 325 |
-
VOICE_LIST.forEach(function(v){var sel=v.id===getAutoVoice(lang)?'border-color:#5cb87a;background:#1a2a1f':'border-color:#333;background:#222';h+='<button class="ve-voice-btn" data-voice="'+v.id+'" style="display:inline-block;'+sel+';border:1px solid;color:#ccc;padding:5px 10px;border-radius:12px;font-size:10px;margin:2px;cursor:pointer">'+v.label+'</button>';});
|
| 326 |
-
h+='</div><div style="margin-bottom:12px"><div style="color:#aaa;font-size:11px;margin-bottom:6px">😊 Chọn cảm xúc:</div>';
|
| 327 |
-
EMOTION_LIST.forEach(function(e){var sel=e.id===autoEmotion?'border-color:#5cb87a;background:#1a2a1f':'border-color:#333;background:#222';h+='<button class="ve-emotion-btn" data-emotion="'+e.id+'" style="display:inline-block;'+sel+';border:1px solid;color:#ccc;padding:5px 10px;border-radius:12px;font-size:10px;margin:2px;cursor:pointer">'+e.label+'</button>';});
|
| 328 |
-
h+='</div><div style="margin-bottom:12px"><div style="color:#aaa;font-size:11px;margin-bottom:6px">⚡ Tốc độ:</div>';
|
| 329 |
-
h+='<select id="ve-speed" style="background:#222;border:1px solid #333;color:#ccc;padding:6px 12px;border-radius:10px;font-size:11px"><option value="0.85">0.85x Chậm</option><option value="1.0">1.0x Bình thường</option><option value="1.2" selected>1.2x Nhanh</option><option value="1.35">1.35x Rất nhanh</option></select></div>';
|
| 330 |
-
h+='<div style="display:flex;gap:8px"><button id="ve-create-btn" style="flex:1;background:#2d8659;border:0;color:#fff;padding:10px;border-radius:12px;font-size:12px;font-weight:700;cursor:pointer">🎬 Tạo Short</button>';
|
| 331 |
-
h+='<button id="ve-cancel-btn" style="background:#333;border:0;color:#ccc;padding:10px 16px;border-radius:12px;font-size:11px;cursor:pointer">✕</button></div>';
|
| 332 |
-
h+='<div id="ve-status" style="color:#888;font-size:10px;margin-top:8px;display:none"></div>';
|
| 333 |
-
box.innerHTML=h;overlay.appendChild(box);document.body.appendChild(overlay);
|
| 334 |
-
var selectedVoice=getAutoVoice(lang),selectedEmotion=autoEmotion;
|
| 335 |
-
box.querySelectorAll('.ve-voice-btn').forEach(function(btn){btn.addEventListener('click',function(){box.querySelectorAll('.ve-voice-btn').forEach(function(b){b.style.borderColor='#333';b.style.background='#222';});this.style.borderColor='#5cb87a';this.style.background='#1a2a1f';selectedVoice=this.dataset.voice;});});
|
| 336 |
-
box.querySelectorAll('.ve-emotion-btn').forEach(function(btn){btn.addEventListener('click',function(){box.querySelectorAll('.ve-emotion-btn').forEach(function(b){b.style.borderColor='#333';b.style.background='#222';});this.style.borderColor='#5cb87a';this.style.background='#1a2a1f';selectedEmotion=this.dataset.emotion;});});
|
| 337 |
-
box.querySelector('#ve-cancel-btn').addEventListener('click',function(){overlay.remove();});
|
| 338 |
-
box.querySelector('#ve-create-btn').addEventListener('click',async function(){
|
| 339 |
-
this.disabled=true;this.textContent='⏳ Đang tạo...';
|
| 340 |
-
box.querySelector('#ve-status').style.display='block';box.querySelector('#ve-status').textContent='Đang tạo video shorts...';
|
| 341 |
-
try{
|
| 342 |
-
var speed=parseFloat(box.querySelector('#ve-speed').value)||1.2;
|
| 343 |
-
if(!_ttsSelections["inline-"+postId]) _ttsSelections["inline-"+postId]={voice:"vi-VN-HoaiMyNeural",emotion:"neutral"};
|
| 344 |
-
_ttsSelections["inline-"+postId].voice=selectedVoice;_ttsSelections["inline-"+postId].emotion=selectedEmotion;
|
| 345 |
-
var r=await fetch('/api/ai/short/'+encodeURIComponent(postId),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:selectedVoice,emotion:selectedEmotion,speed:speed})});
|
| 346 |
-
var j=await r.json();
|
| 347 |
-
if(!r.ok||j.error) throw new Error(j.error||'Lỗi tạo video');
|
| 348 |
-
toast('✅ Đã tạo Short AI!');overlay.remove();
|
| 349 |
-
var p=_wallPosts.find(function(x){return String(x.id)===String(postId);});
|
| 350 |
-
if(p){p.video=j.video;p.voice=j.voice;p.emotion=j.emotion;}
|
| 351 |
-
}catch(e){this.disabled=false;this.textContent='🎬 Tạo Short';box.querySelector('#ve-status').textContent='❌ '+e.message;}
|
| 352 |
-
});
|
| 353 |
-
};
|
| 354 |
|
| 355 |
function prependWallPost(post){
|
| 356 |
_wallPosts.unshift(post);
|
| 357 |
const track=document.getElementById('ai-wall-track');
|
| 358 |
const wrap=document.getElementById('ai-wall-wrap');
|
| 359 |
-
const
|
| 360 |
-
if(
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 367 |
return;
|
| 368 |
}
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
}
|
| 374 |
|
|
|
|
| 375 |
let _wallPosts=[];
|
|
|
|
| 376 |
let _currentView='home';
|
| 377 |
let _currentEventId=null;
|
| 378 |
let _currentMatchUrl=null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
let _htPage=0,_htTopic='';
|
| 380 |
-
async function loadHotTopics(){const j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));const el=document.getElementById('hot-topics');if(!el)return;el.innerHTML=(j.topics||[]).slice(0,18).map(t=>{const topicText=t.topic||t.label.replace(/^#/,'');return`<button class="hot-chip" onclick="searchTopic('${topicText.replace(/'/g,"\\'")}')">${esc(t.label)}</button>`;}).join('');}
|
| 381 |
function searchTopic(topic){if(!topic){topic=document.getElementById('topic-input')?.value.trim();if(!topic){alert('Nhập chủ đề');return;}}document.getElementById('topic-input').value='';_htTopic=topic;_htPage=0;showHashtagSources(topic,0);}
|
| 382 |
-
async function showHashtagSources(topic,page){const box=document.getElementById('hashtag-box');if(!box)return;if(page===0)box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div class="hashtag-loading"><div class="hashtag-spinner"></div>Đang tìm...</div></div>`;try{const r=await fetch(`/api/hashtag/sources?topic=${encodeURIComponent(topic)}&page=${page}`);const j=await r.json();const sources=j.sources||[];if(!sources.length&&page===0){box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div style="color:#888;padding:8px">Không tìm được bài viết liên quan</div></div>`;return;}let h='';if(page===0)h=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)} <span style="font-size:10px;color:#888">(${j.total} bài từ 8 nguồn)</span></h3><div id="ht-list">`;sources.forEach((s,i)=>{const idx=page*8+i;h+=`<div class="hashtag-src-item" onclick="readArticle('${esc(s.url)}')"><div class="hashtag-src-img" id="ht-img-${idx}"></div><div class="hashtag-src-text"><div class="hashtag-src-title">${esc(s.title)}</div><div class="hashtag-src-via">${esc(s.via||'')}</div></div></div>`;});if(page===0){h+=`</div><button class="hashtag-rewrite-btn" onclick="rewriteHashtag('${esc(topic).replace(/'/g,"\\'")}')">🤖 Rewrite AI tổng hợp & đăng tường</button>`;if(j.has_more)h+=`<button class="hashtag-load-more" id="ht-more" onclick="loadMoreHashtag()">Tải thêm ▼</button>`;h+=`</div>`;box.innerHTML=h;}else{document.getElementById('ht-list')?.insertAdjacentHTML('beforeend',h);const btn=document.getElementById('ht-more');if(btn){if(!j.has_more)btn.remove();else{btn.disabled=false;btn.textContent='Tải thêm ▼';}}}sources.forEach((s,i)=>{const idx=page*8+i;if(!s.url)return;
|
| 383 |
function loadMoreHashtag(){_htPage++;const btn=document.getElementById('ht-more');if(btn){btn.disabled=true;btn.textContent='Đang tải...';}showHashtagSources(_htTopic,_htPage);}
|
| 384 |
async function rewriteHashtag(topic){const btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang tổng hợp...';}try{const r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');toast('✅ Đã đăng Tường AI!');if(btn)btn.textContent='✅ Đăng thành công!';if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message);if(btn){btn.disabled=false;btn.textContent='🤖 Rewrite AI';}}}
|
| 385 |
async function loadLivescore(tab){document.querySelectorAll('.ls-tab').forEach(t=>t.classList.remove('active'));document.querySelector(`.ls-tab[data-tab="${tab}"]`)?.classList.add('active');const el=document.getElementById('ls-content');if(!el)return;el.innerHTML='<div class="loading">Đang tải...</div>';let ep='/api/livescore/'+tab;if(tab.startsWith('bxh_'))ep='/api/livescore/standings/'+tab.replace('bxh_','');try{const r=await fetch(ep);const d=await r.json();el.innerHTML=d.html&&d.html.length>50?d.html:'<div class="loading">Không có dữ liệu</div>';bindMatchClicks(el);}catch(e){el.innerHTML='<div class="loading">Lỗi</div>';}}
|
| 386 |
-
function bindMatchClicks(el){
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
function openMatch(id,url){if(!id)return;_currentEventId=id;if(url)_currentMatchUrl=url;document.getElementById('match-overlay').classList.add('active');document.body.style.overflow='hidden';loadMatchTab('detail')}
|
| 388 |
function closeMatch(){document.getElementById('match-overlay').classList.remove('active');document.body.style.overflow=''}
|
| 389 |
async function loadMatchTab(tab){document.querySelectorAll('.mo-tab').forEach(t=>t.classList.remove('active'));document.querySelectorAll('.mo-tab').forEach(t=>{if((tab==='comm'&&t.textContent==='Diễn biến')||(tab==='stats'&&t.textContent==='Thống kê')||(tab==='detail'&&t.textContent.includes('Chi tiết')))t.classList.add('active')});const el=document.getElementById('mo-body');if(!el)return;el.innerHTML='<div class="loading">Đang tải...</div>';try{let apiUrl;if(tab==='stats')apiUrl=`/api/match/${_currentEventId}/stats`;else if(tab==='comm')apiUrl=`/api/match/${_currentEventId}/commentaries`;else{apiUrl=`/api/match/${_currentEventId}/detail`;if(_currentMatchUrl)apiUrl+='?url='+encodeURIComponent(_currentMatchUrl)}const r=await fetch(apiUrl);if(!r.ok){el.innerHTML='<div class="loading">Lỗi máy chủ ('+r.status+')</div>';return}const d=await r.json();if(d.error){el.innerHTML='<div class="loading">'+esc(d.error)+'</div>';return}if(tab==='detail'&&typeof renderMatchDetail==='function'){renderMatchDetail(el,d);return}el.innerHTML=d.html||'<div class="loading">Không có dữ liệu</div>'}catch(e){el.innerHTML='<div class="loading">Lỗi</div>'}}
|
| 390 |
-
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]))}
|
| 391 |
-
function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')}
|
| 392 |
-
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)}}
|
| 393 |
-
function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}}
|
| 394 |
-
function doShare(title,url,img){const shareUrl=SPACE+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||'');if(navigator.share)navigator.share({title,url:shareUrl}).catch(()=>{});else navigator.clipboard.writeText(shareUrl).then(()=>toast('Đã sao chép!')).catch(()=>{})}
|
| 395 |
async function doInteract(videoId,type){try{const r=await fetch('/api/v2/interact',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:videoId,type})});return await r.json();}catch(e){return null;}}
|
| 396 |
async function getInteractions(videoId){try{return await fetch('/api/v2/interactions?id='+encodeURIComponent(videoId)).then(r=>r.json());}catch(e){return{views:0,likes:0,comments:0};}}
|
| 397 |
async function getComments(videoId){try{const j=await fetch('/api/v2/comments?id='+encodeURIComponent(videoId)).then(r=>r.json());return j.comments||[];}catch(e){return[];}}
|
| 398 |
async function postComment(videoId,text){try{const j=await fetch('/api/v2/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:videoId,text})}).then(r=>r.json());return j.comments||[];}catch(e){return[];}}
|
| 399 |
-
function buildTikTokSlide(opts){return`<div class="tiktok-slide" data-vid="${esc(opts.videoId)}">${opts.vtag}<div class="tiktok-bottom"><span class="badge ${opts.badgeClass||'badge-fpt'}">${opts.badge||''}</span><p class="tiktok-title">${esc(opts.title)}</p></div><div class="tiktok-right"><button class="tiktok-right-btn" onclick="event.stopPropagation();doView('${esc(opts.videoId)}',this)"><div class="icon">👁</div><div class="count" id="vc-${opts.idx}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();doLike('${esc(opts.videoId)}',this)"><div class="icon">❤️</div><div class="count" id="lc-${opts.idx}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();toggleComments('${esc(opts.videoId)}',${opts.idx})"><div class="icon">💬</div><div class="count" id="cc-${opts.idx}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();doShare('${esc(opts.title)}','${esc(opts.shareUrl||'')}','')"><div class="icon">📤</div></button>
|
| 400 |
async function doView(videoId,btn){const j=await doInteract(videoId,'view');if(j){const c=btn.querySelector('.count');if(c)c.textContent=fmtNum(j.views);}}
|
| 401 |
async function doLike(videoId,btn){const j=await doInteract(videoId,'like');if(j){const c=btn.querySelector('.count');if(c)c.textContent=fmtNum(j.likes);}}
|
| 402 |
function fmtNum(n){if(!n)return'0';if(n>=1000000)return(n/1000000).toFixed(1)+'M';if(n>=1000)return(n/1000).toFixed(1)+'K';return String(n);}
|
|
@@ -404,69 +276,65 @@ async function loadCounters(videoIds){for(let i=0;i<videoIds.length;i++){const i
|
|
| 404 |
async function toggleComments(videoId,idx){const panel=document.getElementById('cmt-inline-'+idx);if(!panel)return;if(panel.style.display!=='none'){panel.style.display='none';return;}panel.style.display='block';panel.innerHTML='<div style="padding:8px;color:#888;font-size:11px">Đang tải...</div>';const cmts=await getComments(videoId);renderInlineComments(panel,videoId,idx,cmts);}
|
| 405 |
function renderInlineComments(panel,videoId,idx,cmts){let h='<div class="inline-cmt-header"><span>💬 Bình luận</span><button onclick="document.getElementById(\'cmt-inline-'+idx+'\').style.display=\'none\'">✕</button></div><div class="inline-cmt-list">';if(cmts.length){cmts.slice(-30).forEach(c=>{h+=`<div class="inline-cmt-item"><span class="inline-cmt-time">${c.time||''}</span>${esc(c.text)}</div>`;});}else{h+='<div style="color:#777;font-size:11px;padding:4px">Chưa có bình luận</div>';}h+=`</div><div class="inline-cmt-input"><input id="cmt-input-${idx}" placeholder="Viết bình luận..." onkeydown="if(event.key==='Enter')submitInlineCmt('${esc(videoId)}',${idx})"><button onclick="submitInlineCmt('${esc(videoId)}',${idx})">Gửi</button></div>`;panel.innerHTML=h;const list=panel.querySelector('.inline-cmt-list');if(list)list.scrollTop=list.scrollHeight;}
|
| 406 |
async function submitInlineCmt(videoId,idx){const inp=document.getElementById('cmt-input-'+idx);if(!inp)return;const text=inp.value.trim();if(!text)return;inp.value='';inp.disabled=true;const cmts=await postComment(videoId,text);inp.disabled=false;const panel=document.getElementById('cmt-inline-'+idx);if(panel)renderInlineComments(panel,videoId,idx,cmts);const cc=document.getElementById('cc-'+idx);if(cc)cc.textContent=fmtNum(cmts.length);}
|
| 407 |
-
function
|
| 408 |
-
function
|
| 409 |
-
async function
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
_wallPollTimer = setInterval(_pollWall, 30000);
|
| 433 |
-
// Also do an immediate check after 5s
|
| 434 |
-
setTimeout(_pollWall, 5000);
|
| 435 |
-
}
|
| 436 |
-
|
| 437 |
-
async function _pollWall(){
|
| 438 |
-
try{
|
| 439 |
-
var r = await fetch('/api/wall', {signal: AbortSignal.timeout(8000)});
|
| 440 |
-
var j = await r.json();
|
| 441 |
-
var posts = j.posts || [];
|
| 442 |
-
var newPosts = [];
|
| 443 |
-
for(var i = 0; i < posts.length; i++){
|
| 444 |
-
var p = posts[i];
|
| 445 |
-
if(p.id && !_wallPollKnownIds.has(p.id)){
|
| 446 |
-
_wallPollKnownIds.add(p.id);
|
| 447 |
-
if(p.auto_scheduled || p.kind === 'auto_rewrite'){
|
| 448 |
-
newPosts.push(p);
|
| 449 |
-
}
|
| 450 |
-
}
|
| 451 |
-
}
|
| 452 |
-
for(var ni = 0; ni < newPosts.length; ni++){
|
| 453 |
-
prependWallPost(newPosts[ni]);
|
| 454 |
-
if(!newPosts[ni].video && newPosts[ni].id){
|
| 455 |
-
setTimeout(function(id){ makeShortVideo(id, null, 'vi-VN-HoaiMyNeural', 1.2, 'neutral'); }, 1000 + ni * 2000, newPosts[ni].id);
|
| 456 |
-
}
|
| 457 |
-
}
|
| 458 |
-
if(newPosts.length > 0){
|
| 459 |
-
toast('📰 ' + newPosts.length + ' bài AI mới vừa được đăng!');
|
| 460 |
-
}
|
| 461 |
-
}catch(e){}
|
| 462 |
}
|
| 463 |
-
|
| 464 |
-
//
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 470 |
}
|
| 471 |
-
})();
|
| 472 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// === VNEWS Frontend v2 - Optimized for speed ===
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
function _fetchWithTimeout(url, ms){
|
| 4 |
return new Promise((resolve,reject)=>{
|
|
|
|
| 19 |
homeEl.innerHTML =
|
| 20 |
'<div id="home-featured-area"></div>'
|
| 21 |
+'<div class="ai-compose"><div class="ai-compose-title">🤖 AI viết bài</div><div class="ai-compose-row"><input id="topic-input" placeholder="Nhập chủ đề..."><button onclick="searchTopic()">Tìm nguồn</button></div><div class="ai-compose-row"><input id="url-input" placeholder="Dán URL bài viết..."><button class="secondary" onclick="rewriteUrl()">Rewrite</button></div><div id="hot-topics" class="hot-topic-row"></div></div>'
|
|
|
|
| 22 |
+'<div id="hashtag-box"></div>'
|
| 23 |
+'<div class="ls-section"><div class="ls-header"><h3>⚽ Livescore</h3></div><div class="ls-tabs"><span class="ls-tab active" data-tab="today" onclick="loadLivescore(\'today\')">📅 Hôm nay</span><span class="ls-tab" data-tab="live" onclick="loadLivescore(\'live\')">🔴 Live</span><span class="ls-tab" data-tab="incoming" onclick="loadLivescore(\'incoming\')">⏰ Sắp tới</span><span class="ls-tab" data-tab="results" onclick="loadLivescore(\'results\')">✅ Kết quả</span><span class="ls-tab" data-tab="bxh_nha" onclick="loadLivescore(\'bxh_nha\')">🏆 NHA</span><span class="ls-tab" data-tab="bxh_laliga" onclick="loadLivescore(\'bxh_laliga\')">🏆 La Liga</span></div><div class="ls-content" id="ls-content"><div class="loading">Đang tải...</div></div></div>'
|
| 24 |
+'<div id="wc2026-live-section" class="wc2026-section"><div class="wc-header"><h2>🏆 World Cup 2026</h2><span class="wc-live-badge">● LIVE</span></div><div class="wc-tabs"><span class="wc-tab active" onclick="switchWCTab(\'news\')">📰 Tin tức</span><span class="wc-tab" onclick="switchWCTab(\'fixtures\')">📅 Lịch thi đấu</span><span class="wc-tab" onclick="switchWCTab(\'standings\')">🏆 BXH</span><span class="wc-tab" onclick="switchWCTab(\'highlights\')">🎬 Highlight</span><span class="wc-tab" onclick="switchWCTab(\'stats\')">📊 Thống kê</span></div><div class="wc-content" id="wc-content"><div class="loading">Đang tải World Cup 2026...</div></div></div>'
|
|
|
|
| 29 |
loadLivescore('today');
|
| 30 |
loadHotTopics();
|
| 31 |
|
| 32 |
+
const [featuredData, shortsData, wallData, hlLeagues, aiData, wcData, vtvShortsData] = await Promise.allSettled([
|
| 33 |
_fetchWithTimeout('/api/livescore/featured', 5000),
|
| 34 |
+
_fetchWithTimeout('/api/shorts', 8000),
|
| 35 |
_fetchWithTimeout('/api/wall', 5000),
|
| 36 |
_fetchWithTimeout('/api/highlights/leagues', 10000),
|
| 37 |
+
_fetchWithTimeout('/api/genk_ai', 8000),
|
| 38 |
_fetchWithTimeout('/api/wc2026', 8000),
|
| 39 |
+
_fetchWithTimeout('/api/shorts/vtvnamo?count=50', 12000),
|
| 40 |
]).then(results => results.map(r => r.status === 'fulfilled' ? r.value : null));
|
| 41 |
|
| 42 |
if(featuredData && featuredData.home){
|
| 43 |
const sc=featuredData.status==='live'?'':'upcoming';
|
| 44 |
const st=featuredData.status==='live'?`🔴 ${featuredData.minute||'LIVE'}`:`⏰ ${featuredData.time}`;
|
| 45 |
const area=document.getElementById('home-featured-area');
|
| 46 |
+
if(area) area.innerHTML=`<div class="featured-match" onclick="openMatch('${featuredData.event_id}')"><div class="fm-league">${featuredData.league}</div><div class="fm-teams"><div class="fm-team"><img src="${featuredData.home_logo}" onerror="this.style.display='none'"><span>${featuredData.home}</span></div><div class="fm-score">${featuredData.score||'VS'}</div><div class="fm-team"><img src="${featuredData.away_logo}" onerror="this.style.display='none'"><span>${featuredData.away}</span></div></div><div class="fm-status ${sc}">${st}</div></div>`;
|
| 47 |
}
|
| 48 |
|
| 49 |
+
_shortsData = shortsData || [];
|
| 50 |
_wallPosts = (wallData && wallData.posts) || [];
|
| 51 |
_hlLeagueData = hlLeagues || {};
|
| 52 |
_wc2026Data = wcData;
|
| 53 |
+
_wcShortsData = vtvShortsData || [];
|
| 54 |
|
| 55 |
if(wcData) switchWCTab('news');
|
| 56 |
|
| 57 |
+
_renderShortsIn(afterEl);
|
| 58 |
_renderWallIn(afterEl);
|
| 59 |
_renderHLIn(afterEl);
|
| 60 |
+
if(aiData && aiData.length) _renderSlidesIn('ai-articles','Ứng dụng AI','🤖',aiData,afterEl);
|
| 61 |
}
|
| 62 |
|
| 63 |
function _renderSlidesIn(key, label, emoji, vids, afterEl){
|
|
|
|
| 68 |
const isHL = key==='world-cup'||key==='premier-league'||key==='champions-league'||key==='la-liga'||key==='serie-a'||key==='bundesliga'||key==='friendly';
|
| 69 |
vids.slice(0,isHL?8:12).forEach((a,i)=>{
|
| 70 |
if(isHL){
|
| 71 |
+
h+=`<div class="slider-item" onclick="openHighlightFeed('${key}',${i})"><div class="slider-thumb">${a.img?`<img src="${a.img}" loading="lazy">`:''}<div class="card-play">▶</div></div><div class="slider-title">${esc(a.title)}</div></div>`;
|
| 72 |
} else {
|
| 73 |
+
h+=`<div class="slider-item" onclick="readArticle('${esc(a.link)}')"><div class="slider-thumb">${a.img?`<img src="${a.img}" loading="lazy">`:''}</div><div class="slider-title">${esc(a.title)}</div></div>`;
|
| 74 |
}
|
| 75 |
});
|
| 76 |
h+='</div>';
|
|
|
|
| 78 |
afterEl.parentNode.insertBefore(wrap, afterEl);
|
| 79 |
}
|
| 80 |
|
| 81 |
+
function _renderShortsIn(afterEl){
|
| 82 |
+
if(!_shortsData||!_shortsData.length||!afterEl) return;
|
| 83 |
+
const mixed=interleaveShorts(_shortsData);
|
| 84 |
+
if(!mixed.length) return;
|
| 85 |
+
const wrap=document.createElement('div');
|
| 86 |
+
wrap.className='slider-wrap';
|
| 87 |
+
let h=`<div class="slider-header"><span class="slider-label">📱 Shorts Dân trí · SKĐS · VTV Nam Bộ</span><span class="slider-note">Mới nhất · xen kẽ · cập nhật thời gian thực</span></div><div class="slider-track">`;
|
| 88 |
+
mixed.slice(0,36).forEach((a,i)=>{
|
| 89 |
+
let badge, badgeColor;
|
| 90 |
+
if(a.channel==='baosuckhoedoisongboyte'){ badge='SKĐS'; badgeColor='#e67e22'; }
|
| 91 |
+
else if(a.channel==='vtvnambo'){ badge='VTV Nam Bộ'; badgeColor='#0b6bcb'; }
|
| 92 |
+
else { badge='Dân trí'; badgeColor='#27ae60'; }
|
| 93 |
+
h+=`<div class="slider-item shorts-item" onclick="openYTShortsFeed(${i})"><div class="slider-thumb shorts-thumb">${a.img?`<img src="${a.img}" loading="lazy">`:''}<div class="card-play">▶</div><span class="shorts-badge" style="background:${badgeColor}">${badge}</span></div><div class="slider-title">${esc(a.title)}</div></div>`;
|
| 94 |
+
});
|
| 95 |
+
h+='</div>';wrap.innerHTML=h;
|
| 96 |
+
afterEl.parentNode.insertBefore(wrap,afterEl);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
function _renderWallIn(afterEl){
|
| 100 |
+
if(!_wallPosts||!_wallPosts.length||!afterEl) return;
|
| 101 |
const posts=_wallPosts;
|
| 102 |
+
const aiShorts=posts.filter(p=>p.video);
|
| 103 |
+
if(aiShorts.length){
|
| 104 |
+
const wrap=document.createElement('div');
|
| 105 |
+
wrap.className='slider-wrap';
|
| 106 |
+
let h='<div class="slider-header"><span class="slider-label">🎬 Short AI</span></div><div class="slider-track">';
|
| 107 |
+
aiShorts.slice(0,20).forEach((p,i)=>{h+=`<div class="slider-item shorts-item" onclick="openShortAIFeed(${i})"><div class="slider-thumb shorts-thumb"><video src="${p.video}" muted preload="metadata"></video><div class="card-play">▶</div></div><div class="slider-title">${esc(p.title)}</div></div>`;});
|
| 108 |
+
h+='</div>';wrap.innerHTML=h;
|
| 109 |
+
afterEl.parentNode.insertBefore(wrap,afterEl);
|
| 110 |
+
}
|
| 111 |
const wrap=document.createElement('div');
|
| 112 |
wrap.className='slider-wrap';wrap.id='ai-wall-wrap';
|
| 113 |
let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track" id="ai-wall-track">';
|
| 114 |
posts.slice(0,20).forEach((p,i)=>{h+=makeWallItem(p,i);});
|
| 115 |
h+='</div>';wrap.innerHTML=h;
|
| 116 |
+
afterEl.parentNode.insertBefore(wrap,afterEl);
|
|
|
|
|
|
|
| 117 |
}
|
| 118 |
|
| 119 |
function _renderHLIn(afterEl){
|
|
|
|
| 126 |
}
|
| 127 |
}
|
| 128 |
|
|
|
|
| 129 |
function makeWallItem(p,i){
|
| 130 |
+
const hasVideo = p.video && p.video.length > 0;
|
| 131 |
+
const thumbContent = p.img ? `<img src="${esc(p.img)}" loading="lazy" onerror="this.style.display='none'">` : (hasVideo ? `<video src="${esc(p.video)}" muted></video>` : '');
|
| 132 |
+
const videoBadge = hasVideo ? `<div class="wall-video-badge">🎬</div>` : '';
|
| 133 |
+
const videoBtn = hasVideo ? `<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed(${i})">▶ Xem Short</button>` : `<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo('${esc(p.id||i)}',this)">🎬 Tạo Video</button>`;
|
| 134 |
+
return `<div class="wall-item" id="wall-item-${esc(p.id||i)}"><div class="wall-thumb">${thumbContent}${videoBadge}</div><div class="wall-title">${esc(p.title)}</div><div class="wall-text">${esc((p.text||'').slice(0,180))}</div><div class="wall-actions"><button class="primary" onclick="readWallPost(${i})">Xem</button>${videoBtn}</div></div>`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
}
|
| 136 |
|
| 137 |
+
async function makeShortVideo(postId, btn, voice, speed){
|
| 138 |
if(!postId)return;
|
| 139 |
const origText = btn ? btn.textContent : '🎬 Tạo Video';
|
| 140 |
if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo...';}
|
| 141 |
toast('⏳ Đang tạo video shorts...');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
try{
|
| 143 |
+
let url = '/api/ai/short/'+encodeURIComponent(postId);
|
| 144 |
+
const params = [];
|
| 145 |
+
if(voice) params.push('voice='+encodeURIComponent(voice));
|
| 146 |
+
if(speed) params.push('speed='+encodeURIComponent(speed));
|
| 147 |
+
if(params.length) url += '?' + params.join('&');
|
| 148 |
+
const r = await fetch(url, {method:'POST'});
|
| 149 |
const j = await r.json();
|
| 150 |
if(!r.ok || j.error) throw new Error(j.error||'Lỗi tạo video');
|
| 151 |
toast('✅ Đã tạo video shorts!');
|
| 152 |
const p = _wallPosts.find(x => String(x.id) === String(postId));
|
| 153 |
if(p){
|
| 154 |
p.video = j.video;
|
|
|
|
|
|
|
| 155 |
const itemId = 'wall-item-'+postId;
|
| 156 |
const el = document.getElementById(itemId);
|
| 157 |
if(el){
|
| 158 |
const idx = _wallPosts.indexOf(p);
|
| 159 |
+
el.outerHTML = makeWallItem(p, idx);
|
| 160 |
+
const newEl = document.getElementById(itemId);
|
| 161 |
+
if(newEl) newEl.className = 'wall-item wall-item-new';
|
| 162 |
}
|
| 163 |
}
|
| 164 |
+
refreshShortAISlider();
|
| 165 |
}catch(e){
|
| 166 |
toast('❌ '+e.message);
|
| 167 |
if(btn){btn.disabled=false;btn.textContent=origText;}
|
| 168 |
}
|
| 169 |
}
|
| 170 |
|
| 171 |
+
function refreshShortAISlider(){
|
| 172 |
+
const aiShorts = _wallPosts.filter(p=>p.video);
|
| 173 |
+
let shortAISection = document.getElementById('short-ai-section');
|
| 174 |
+
if(aiShorts.length === 0){ if(shortAISection) shortAISection.remove(); return; }
|
| 175 |
+
if(shortAISection){
|
| 176 |
+
const track = shortAISection.querySelector('.slider-track');
|
| 177 |
+
if(track){
|
| 178 |
+
let h = '';
|
| 179 |
+
aiShorts.slice(0,20).forEach((p,i)=>{h+=`<div class="slider-item shorts-item" onclick="openShortAIFeed(${i})"><div class="slider-thumb shorts-thumb"><video src="${esc(p.video)}" muted preload="metadata"></video><div class="card-play">▶</div></div><div class="slider-title">${esc(p.title)}</div></div>`;});
|
| 180 |
+
track.innerHTML = h;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
|
| 185 |
function prependWallPost(post){
|
| 186 |
_wallPosts.unshift(post);
|
| 187 |
const track=document.getElementById('ai-wall-track');
|
| 188 |
const wrap=document.getElementById('ai-wall-wrap');
|
| 189 |
+
const homeEl=document.getElementById('view-home');
|
| 190 |
+
if(!track||!wrap){
|
| 191 |
+
if(homeEl){
|
| 192 |
+
let insertBefore=homeEl.querySelector('.slider-wrap');
|
| 193 |
+
const newWrap=document.createElement('div');
|
| 194 |
+
newWrap.className='slider-wrap';
|
| 195 |
+
newWrap.id='ai-wall-wrap';
|
| 196 |
+
newWrap.innerHTML=`<div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track" id="ai-wall-track">${makeWallItem(post,0)}</div>`;
|
| 197 |
+
if(insertBefore) homeEl.insertBefore(newWrap,insertBefore);
|
| 198 |
+
else homeEl.appendChild(newWrap);
|
| 199 |
+
const firstItem=newWrap.querySelector('.wall-item');
|
| 200 |
+
if(firstItem)firstItem.className='wall-item wall-item-new';
|
| 201 |
+
}
|
| 202 |
return;
|
| 203 |
}
|
| 204 |
+
const div=document.createElement('div');
|
| 205 |
+
div.className='wall-item wall-item-new';
|
| 206 |
+
div.id='wall-item-'+(post.id||'new-'+Date.now());
|
| 207 |
+
const hasVideo = post.video && post.video.length > 0;
|
| 208 |
+
const thumbContent = post.img ? `<img src="${esc(post.img)}" loading="lazy" onerror="this.style.display='none'">` : (hasVideo ? `<video src="${esc(post.video)}" muted></video>` : '');
|
| 209 |
+
const videoBadge = hasVideo ? `<div class="wall-video-badge">🎬</div>` : '';
|
| 210 |
+
const videoBtn = hasVideo ? `<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed(0)">▶ Xem Short</button>` : `<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo('${esc(post.id)}',this)">🎬 Tạo Video</button>`;
|
| 211 |
+
div.innerHTML=`<div class="wall-thumb">${thumbContent}${videoBadge}</div><div class="wall-title">${esc(post.title)}</div><div class="wall-text">${esc((post.text||'').slice(0,180))}</div><div class="wall-actions"><button class="primary" onclick="readWallPost(0)">Xem</button>${videoBtn}</div>`;
|
| 212 |
+
track.prepend(div);
|
| 213 |
+
track.scrollTo({left:0,behavior:'smooth'});
|
| 214 |
+
if(hasVideo) refreshShortAISlider();
|
| 215 |
}
|
| 216 |
|
| 217 |
+
let _shortsData=[];
|
| 218 |
let _wallPosts=[];
|
| 219 |
+
let _wcShortsData=[];
|
| 220 |
let _currentView='home';
|
| 221 |
let _currentEventId=null;
|
| 222 |
let _currentMatchUrl=null;
|
| 223 |
+
function interleaveShorts(shorts){
|
| 224 |
+
const dt=shorts.filter(s=>s.channel==='baodantri7941');
|
| 225 |
+
const sk=shorts.filter(s=>s.channel==='baosuckhoedoisongboyte');
|
| 226 |
+
const vt=shorts.filter(s=>s.channel==='vtvnambo');
|
| 227 |
+
const result=[];
|
| 228 |
+
let i=0,j=0,k=0;
|
| 229 |
+
const maxLen=Math.max(dt.length, sk.length, vt.length);
|
| 230 |
+
for(let x=0;x<maxLen;x++){
|
| 231 |
+
if(x<dt.length)result.push(dt[x]);
|
| 232 |
+
if(x<sk.length)result.push(sk[x]);
|
| 233 |
+
if(x<vt.length)result.push(vt[x]);
|
| 234 |
+
}
|
| 235 |
+
return result;
|
| 236 |
+
}
|
| 237 |
let _htPage=0,_htTopic='';
|
| 238 |
+
async function loadHotTopics(){const j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));const el=document.getElementById('hot-topics');if(!el)return;el.innerHTML=(j.topics||[]).slice(0,18).map(t=>{const topicText=t.topic||t.label.replace(/^#/,'');return`<button class="hot-chip" onclick="searchTopic('${topicText.replace(/'/g,"\\'")}')">${esc(t.label)}</button>`;}).join('');if(j.topics&&j.topics[0]){const firstTopic=j.topics[0].topic||j.topics[0].label.replace(/^#/,'');setTimeout(()=>searchTopic(firstTopic),800);}}
|
| 239 |
function searchTopic(topic){if(!topic){topic=document.getElementById('topic-input')?.value.trim();if(!topic){alert('Nhập chủ đề');return;}}document.getElementById('topic-input').value='';_htTopic=topic;_htPage=0;showHashtagSources(topic,0);}
|
| 240 |
+
async function showHashtagSources(topic,page){const box=document.getElementById('hashtag-box');if(!box)return;if(page===0)box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div class="hashtag-loading"><div class="hashtag-spinner"></div>Đang tìm...</div></div>`;try{const r=await fetch(`/api/hashtag/sources?topic=${encodeURIComponent(topic)}&page=${page}`);const j=await r.json();const sources=j.sources||[];if(!sources.length&&page===0){box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div style="color:#888;padding:8px">Không tìm được bài viết liên quan</div></div>`;return;}let h='';if(page===0)h=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)} <span style="font-size:10px;color:#888">(${j.total} bài từ 8 nguồn)</span></h3><div id="ht-list">`;sources.forEach((s,i)=>{const idx=page*8+i;h+=`<div class="hashtag-src-item" onclick="readArticle('${esc(s.url)}')"><div class="hashtag-src-img" id="ht-img-${idx}"></div><div class="hashtag-src-text"><div class="hashtag-src-title">${esc(s.title)}</div><div class="hashtag-src-via">${esc(s.via||'')}</div></div></div>`;});if(page===0){h+=`</div><button class="hashtag-rewrite-btn" onclick="rewriteHashtag('${esc(topic).replace(/'/g,"\\'")}')">🤖 Rewrite AI tổng hợp & đăng tường</button>`;if(j.has_more)h+=`<button class="hashtag-load-more" id="ht-more" onclick="loadMoreHashtag()">Tải thêm ▼</button>`;h+=`</div>`;box.innerHTML=h;}else{document.getElementById('ht-list')?.insertAdjacentHTML('beforeend',h);const btn=document.getElementById('ht-more');if(btn){if(!j.has_more)btn.remove();else{btn.disabled=false;btn.textContent='Tải thêm ▼';}}}sources.forEach((s,i)=>{const idx=page*8+i;if(!s.url)return;fetch('/api/article?url='+encodeURIComponent(s.url)).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){const el=document.getElementById('ht-img-'+idx);if(el)el.innerHTML=`<img src="${esc(d.og_image||d.img)}" onerror="this.style.display='none'">`;}}).catch(()=>{});});}catch(e){box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div style="color:#e74c3c;padding:8px">Lỗi: ${esc(e.message)}</div></div>`;}}
|
| 241 |
function loadMoreHashtag(){_htPage++;const btn=document.getElementById('ht-more');if(btn){btn.disabled=true;btn.textContent='Đang tải...';}showHashtagSources(_htTopic,_htPage);}
|
| 242 |
async function rewriteHashtag(topic){const btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang tổng hợp...';}try{const r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');toast('✅ Đã đăng Tường AI!');if(btn)btn.textContent='✅ Đăng thành công!';if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message);if(btn){btn.disabled=false;btn.textContent='🤖 Rewrite AI';}}}
|
| 243 |
async function loadLivescore(tab){document.querySelectorAll('.ls-tab').forEach(t=>t.classList.remove('active'));document.querySelector(`.ls-tab[data-tab="${tab}"]`)?.classList.add('active');const el=document.getElementById('ls-content');if(!el)return;el.innerHTML='<div class="loading">Đang tải...</div>';let ep='/api/livescore/'+tab;if(tab.startsWith('bxh_'))ep='/api/livescore/standings/'+tab.replace('bxh_','');try{const r=await fetch(ep);const d=await r.json();el.innerHTML=d.html&&d.html.length>50?d.html:'<div class="loading">Không có dữ liệu</div>';bindMatchClicks(el);}catch(e){el.innerHTML='<div class="loading">Lỗi</div>';}}
|
| 244 |
+
function bindMatchClicks(el){
|
| 245 |
+
el.querySelectorAll('.match-detail').forEach(md=>{
|
| 246 |
+
md.style.cursor='pointer';
|
| 247 |
+
md.addEventListener('click',function(e){
|
| 248 |
+
const statusA=this.querySelector('.status a');
|
| 249 |
+
const teamA=this.querySelector('.teams a[href*="/tran-dau/"]');
|
| 250 |
+
const a = statusA || teamA;
|
| 251 |
+
if(a){
|
| 252 |
+
e.preventDefault();e.stopPropagation();
|
| 253 |
+
const href=a.getAttribute('href')||'';
|
| 254 |
+
const m=href.match(/\/tran-dau\/(\d+)\//);
|
| 255 |
+
if(m){
|
| 256 |
+
const fullUrl=href.startsWith('http')?href:'https://bongda.com.vn'+href;
|
| 257 |
+
openMatch(m[1],fullUrl);
|
| 258 |
+
}
|
| 259 |
+
}
|
| 260 |
+
});
|
| 261 |
+
});
|
| 262 |
+
el.querySelectorAll('a').forEach(a=>{a.addEventListener('click',e=>{e.preventDefault();e.stopPropagation()});});
|
| 263 |
+
}
|
| 264 |
function openMatch(id,url){if(!id)return;_currentEventId=id;if(url)_currentMatchUrl=url;document.getElementById('match-overlay').classList.add('active');document.body.style.overflow='hidden';loadMatchTab('detail')}
|
| 265 |
function closeMatch(){document.getElementById('match-overlay').classList.remove('active');document.body.style.overflow=''}
|
| 266 |
async function loadMatchTab(tab){document.querySelectorAll('.mo-tab').forEach(t=>t.classList.remove('active'));document.querySelectorAll('.mo-tab').forEach(t=>{if((tab==='comm'&&t.textContent==='Diễn biến')||(tab==='stats'&&t.textContent==='Thống kê')||(tab==='detail'&&t.textContent.includes('Chi tiết')))t.classList.add('active')});const el=document.getElementById('mo-body');if(!el)return;el.innerHTML='<div class="loading">Đang tải...</div>';try{let apiUrl;if(tab==='stats')apiUrl=`/api/match/${_currentEventId}/stats`;else if(tab==='comm')apiUrl=`/api/match/${_currentEventId}/commentaries`;else{apiUrl=`/api/match/${_currentEventId}/detail`;if(_currentMatchUrl)apiUrl+='?url='+encodeURIComponent(_currentMatchUrl)}const r=await fetch(apiUrl);if(!r.ok){el.innerHTML='<div class="loading">Lỗi máy chủ ('+r.status+')</div>';return}const d=await r.json();if(d.error){el.innerHTML='<div class="loading">'+esc(d.error)+'</div>';return}if(tab==='detail'&&typeof renderMatchDetail==='function'){renderMatchDetail(el,d);return}el.innerHTML=d.html||'<div class="loading">Không có dữ liệu</div>'}catch(e){el.innerHTML='<div class="loading">Lỗi</div>'}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
async function doInteract(videoId,type){try{const r=await fetch('/api/v2/interact',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:videoId,type})});return await r.json();}catch(e){return null;}}
|
| 268 |
async function getInteractions(videoId){try{return await fetch('/api/v2/interactions?id='+encodeURIComponent(videoId)).then(r=>r.json());}catch(e){return{views:0,likes:0,comments:0};}}
|
| 269 |
async function getComments(videoId){try{const j=await fetch('/api/v2/comments?id='+encodeURIComponent(videoId)).then(r=>r.json());return j.comments||[];}catch(e){return[];}}
|
| 270 |
async function postComment(videoId,text){try{const j=await fetch('/api/v2/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:videoId,text})}).then(r=>r.json());return j.comments||[];}catch(e){return[];}}
|
| 271 |
+
function buildTikTokSlide(opts){return`<div class="tiktok-slide" data-vid="${esc(opts.videoId)}">${opts.vtag}<div class="tiktok-bottom"><span class="badge ${opts.badgeClass||'badge-fpt'}">${opts.badge||''}</span><p class="tiktok-title">${esc(opts.title)}</p></div><div class="tiktok-right"><button class="tiktok-right-btn" onclick="event.stopPropagation();doView('${esc(opts.videoId)}',this)"><div class="icon">👁</div><div class="count" id="vc-${opts.idx}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();doLike('${esc(opts.videoId)}',this)"><div class="icon">❤️</div><div class="count" id="lc-${opts.idx}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();toggleComments('${esc(opts.videoId)}',${opts.idx})"><div class="icon">💬</div><div class="count" id="cc-${opts.idx}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();doShare('${esc(opts.title)}','${esc(opts.shareUrl||'')}','')"><div class="icon">📤</div></button>${opts.extraBtn||''}</div><span class="tiktok-counter">${opts.idx+1}/${opts.total}</span><div class="inline-comments" id="cmt-inline-${opts.idx}" style="display:none"></div></div>`;}
|
| 272 |
async function doView(videoId,btn){const j=await doInteract(videoId,'view');if(j){const c=btn.querySelector('.count');if(c)c.textContent=fmtNum(j.views);}}
|
| 273 |
async function doLike(videoId,btn){const j=await doInteract(videoId,'like');if(j){const c=btn.querySelector('.count');if(c)c.textContent=fmtNum(j.likes);}}
|
| 274 |
function fmtNum(n){if(!n)return'0';if(n>=1000000)return(n/1000000).toFixed(1)+'M';if(n>=1000)return(n/1000).toFixed(1)+'K';return String(n);}
|
|
|
|
| 276 |
async function toggleComments(videoId,idx){const panel=document.getElementById('cmt-inline-'+idx);if(!panel)return;if(panel.style.display!=='none'){panel.style.display='none';return;}panel.style.display='block';panel.innerHTML='<div style="padding:8px;color:#888;font-size:11px">Đang tải...</div>';const cmts=await getComments(videoId);renderInlineComments(panel,videoId,idx,cmts);}
|
| 277 |
function renderInlineComments(panel,videoId,idx,cmts){let h='<div class="inline-cmt-header"><span>💬 Bình luận</span><button onclick="document.getElementById(\'cmt-inline-'+idx+'\').style.display=\'none\'">✕</button></div><div class="inline-cmt-list">';if(cmts.length){cmts.slice(-30).forEach(c=>{h+=`<div class="inline-cmt-item"><span class="inline-cmt-time">${c.time||''}</span>${esc(c.text)}</div>`;});}else{h+='<div style="color:#777;font-size:11px;padding:4px">Chưa có bình luận</div>';}h+=`</div><div class="inline-cmt-input"><input id="cmt-input-${idx}" placeholder="Viết bình luận..." onkeydown="if(event.key==='Enter')submitInlineCmt('${esc(videoId)}',${idx})"><button onclick="submitInlineCmt('${esc(videoId)}',${idx})">Gửi</button></div>`;panel.innerHTML=h;const list=panel.querySelector('.inline-cmt-list');if(list)list.scrollTop=list.scrollHeight;}
|
| 278 |
async function submitInlineCmt(videoId,idx){const inp=document.getElementById('cmt-input-'+idx);if(!inp)return;const text=inp.value.trim();if(!text)return;inp.value='';inp.disabled=true;const cmts=await postComment(videoId,text);inp.disabled=false;const panel=document.getElementById('cmt-inline-'+idx);if(panel)renderInlineComments(panel,videoId,idx,cmts);const cc=document.getElementById('cc-'+idx);if(cc)cc.textContent=fmtNum(cmts.length);}
|
| 279 |
+
function initTikTokFeed(){const feed=document.getElementById('tiktok-feed');if(!feed)return;const slides=feed.querySelectorAll('.tiktok-slide');let cur=-1;function act(i){if(i===cur)return;slides.forEach((sl,idx)=>{const v=sl.querySelector('video');const fr=sl.querySelector('iframe');if(idx===i){if(v&&v.dataset.hls&&!v._hls&&typeof Hls!=='undefined'&&Hls.isSupported()){const hls=new Hls();hls.loadSource(v.dataset.hls);hls.attachMedia(v);hls.on(Hls.Events.MANIFEST_PARSED,()=>v.play().catch(()=>{}));v._hls=hls}else if(v)v.play().catch(()=>{});if(fr&&!fr.src&&fr.dataset.ytSrc)fr.src=fr.dataset.ytSrc;const vid=sl.dataset.vid;if(vid&&!sl._viewed){sl._viewed=true;doInteract(vid,'view').then(j=>{if(j){const vc=document.getElementById('vc-'+idx);if(vc)vc.textContent=fmtNum(j.views);}});}}else{if(v){v.pause();if(v._hls){v._hls.destroy();v._hls=null}}if(fr&&fr.src){try{fr.contentWindow.postMessage('{"event":"command","func":"pauseVideo","args":""}','*');}catch(e){}}}});cur=i}let sT;feed.addEventListener('scroll',()=>{clearTimeout(sT);sT=setTimeout(()=>{const rect=feed.getBoundingClientRect(),ctr=rect.top+rect.height/2;let best=-1,bestD=1e9;slides.forEach((sl,i)=>{const d=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(d<bestD){bestD=d;best=i}});if(best>=0)act(best)},150)});setTimeout(()=>act(0),400);slides.forEach(sl=>{const v=sl.querySelector('video');if(v)v.addEventListener('click',e=>{e.preventDefault();v.paused?v.play().catch(()=>{}):v.pause()})});const ids=[...slides].map(sl=>sl.dataset.vid||'');loadCounters(ids)}
|
| 280 |
+
async function openHighlightFeed(league,idx,forceUrl){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải...</div>';let articles=(_hlLeagueData||{})[league]||[];if(!articles.length){try{articles=await fetch('/api/highlights/'+league).then(r=>r.json())}catch(e){articles=[]}}if(!articles.length){el.innerHTML='<div class="loading">Không có video</div>';return}const vids=[];const results=await Promise.all(articles.map(async(a,i)=>{try{const r=await fetch('/api/video_url?url='+encodeURIComponent(a.link));const v=await r.json();if(v&&v.src)return{...a,...v,_idx:i}}catch(e){}return null}));results.forEach(r=>{if(r)vids.push(r)});vids.sort((a,b)=>a._idx-b._idx);if(!vids.length){el.innerHTML='<div class="loading">Không tìm thấy video</div>';return}let ti=vids.findIndex(v=>v._idx===idx);if(ti<0)ti=0;const ordered=ti>0?[...vids.slice(ti),...vids.slice(0,ti)]:vids;let h=`<button class="back-btn" onclick="switchCat('home')">← Highlight</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">`;ordered.forEach((v,i)=>{const isYT=v.type==='youtube',isHLS=!isYT&&v.src?.includes('.m3u8'),poster=v.poster?` poster="${v.poster}"`:'';const vtag=isYT?`<iframe data-yt-src="${v.src}" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture"></iframe>`:isHLS?`<video playsinline preload="none"${poster} data-hls="${v.src}" loop controls></video>`:`<video playsinline preload="none"${poster} loop controls><source src="${v.src}" type="video/mp4"></video>`;const videoId='hl-'+league+'-'+(v.id||v._idx);h+=buildTikTokSlide({vtag,title:v.title,badge:'HL',badgeClass:'badge-fpt',videoId,idx:i,total:ordered.length,shareUrl:v.link||'',extraBtn:`<button class="tiktok-right-btn" onclick="event.stopPropagation();this.closest('.tiktok-slide').classList.toggle('ratio-wide')"><div class="icon">⬜</div><div class="count">16:9</div></button>`});});h+='</div></div>';el.innerHTML=h;initTikTokFeed();}
|
| 281 |
+
async function openYTShortsFeed(startIdx){
|
| 282 |
+
showView('view-tiktok');
|
| 283 |
+
const el=document.getElementById('view-tiktok');
|
| 284 |
+
el.innerHTML='<div class="loading">Đang tải shorts...</div>';
|
| 285 |
+
let arts = _shortsData.length ? _shortsData : [];
|
| 286 |
+
if(!arts.length){try{arts=await fetch('/api/shorts').then(r=>r.json()).catch(()=>[]);}catch(e){arts=[];}}
|
| 287 |
+
if(!arts.length){el.innerHTML='<div class="loading">Không có shorts</div>';return;}
|
| 288 |
+
const ordered=startIdx>0?[...arts.slice(startIdx),...arts.slice(0,startIdx)]:arts;
|
| 289 |
+
let h=`<button class="back-btn" onclick="switchCat('home')">← Shorts</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">`;
|
| 290 |
+
ordered.forEach((v,i)=>{
|
| 291 |
+
const id=v.id||'';
|
| 292 |
+
const src=`https://www.youtube.com/embed/${id}?autoplay=0&rel=0&playsinline=1&mute=1&loop=1&playlist=${id}&enablejsapi=1`;
|
| 293 |
+
const vtag=`<iframe data-yt-src="${src}" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture"></iframe>`;
|
| 294 |
+
let badge,badgeClass;
|
| 295 |
+
if(v.channel==='baosuckhoedoisongboyte'){badge='SKĐS';badgeClass='badge-fpt';}
|
| 296 |
+
else if(v.channel==='vtvnambo'){badge='VTV Nam Bộ';badgeClass='badge-wc';}
|
| 297 |
+
else{badge='Dân trí';badgeClass='badge-vne';}
|
| 298 |
+
const videoId='yt-'+id;
|
| 299 |
+
h+=buildTikTokSlide({vtag,title:v.title,badge,badgeClass,videoId,idx:i,total:ordered.length,shareUrl:'https://youtube.com/shorts/'+id});
|
| 300 |
+
});
|
| 301 |
+
h+='</div></div>';
|
| 302 |
+
el.innerHTML=h;
|
| 303 |
+
initTikTokFeed();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
}
|
| 305 |
+
async function openShortAIFeed(startIdx){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải...</div>';const wall=(await fetch('/api/wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];const vids=wall.filter(p=>p.video);if(!vids.length){el.innerHTML='<div class="loading">Chưa có Short AI</div>';return}const ordered=startIdx>0?[...vids.slice(startIdx),...vids.slice(0,startIdx)]:vids;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)=>{const vtag=`<video src="${p.video}" playsinline loop controls></video>`;const videoId='ai-'+(p.id||i);h+=buildTikTokSlide({vtag,title:p.title,badge:'AI',badgeClass:'badge-ai',videoId,idx:i,total:ordered.length,shareUrl:SPACE});});h+='</div></div>';el.innerHTML=h;initTikTokFeed();}
|
| 306 |
+
async function readArticle(url){showView('view-article');const el=document.getElementById('view-article');el.innerHTML='<div class="loading">Đang tải...</div>';try{const r=await fetch('/api/article?url='+encodeURIComponent(url));const data=await r.json();if(data&&!data.error&&data.body&&data.body.length){_currentArticle={url,data};let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><h1 class="article-title">${esc(data.title)}</h1>`;if(data.summary)h+=`<div class="article-summary">${esc(data.summary)}</div>`;const seen={};data.body.forEach(b=>{if(b.type==='p')h+=`<p class="article-p">${b.text}</p>`;else if(b.type==='img'&&b.src&&!seen[b.src]){seen[b.src]=1;h+=`<img class="article-img" src="${esc(b.src)}" onerror="this.style.display='none'">`}else if(b.type==='heading')h+=`<h2 class="article-h2">${esc(b.text)}</h2>`});h+=`<div class="article-actions"><button class="primary" onclick="rewriteArticle()">🤖 Rewrite AI đăng tường</button><button onclick="doShare('${esc(data.title)}','${esc(url)}','${esc(data.og_image||'')}')">📤</button><button onclick="window.open('${esc(url)}','_blank')">🔗 Gốc</button></div><div class="article-ai-ask"><h3 style="font-size:14px;color:#5cb87a">🤖 Hỏi AI</h3><textarea id="ask-q" placeholder="Hỏi về bài viết..."></textarea><button onclick="askAI()">Hỏi</button><div id="ask-a" class="article-ai-answer"></div></div></div>`;el.innerHTML=h;window.scrollTo(0,0);return;}}catch(e){}el.innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="loading"><p>Không đọc được.</p><a href="${esc(url)}" target="_blank" style="color:#5cb87a">Mở gốc →</a></div>`;}
|
| 307 |
+
async function rewriteArticle(){const url=_currentArticle?.url;if(!url)return;toast('⏳ Đang rewrite...');try{const r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,context:document.querySelector('.article-view')?.innerText?.slice(0,14000)||''})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error);toast('✅ Đã đăng Tường AI!');if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message)}}
|
| 308 |
+
async function rewriteUrl(){const url=document.getElementById('url-input')?.value.trim();if(!url)return alert('Dán URL');toast('⏳ Đang rewrite...');try{const r=await fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error);toast('✅ Đã đăng!');document.getElementById('url-input').value='';if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message)}}
|
| 309 |
+
async function askAI(){const q=document.getElementById('ask-q')?.value.trim();if(!q)return alert('Nhập câu hỏi');const a=document.getElementById('ask-a');a.textContent='Đang hỏi...';try{const r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:_currentArticle?.url||'',question:q,context:document.querySelector('.article-view')?.innerText?.slice(0,12000)||''})});const j=await r.json();a.textContent=j.answer||'Không trả lời được';}catch(e){a.textContent='Lỗi: '+e.message}}
|
| 310 |
+
async function readWallPost(i){const p=_wallPosts[i];if(!p)return;showView('view-article');
|
| 311 |
+
const images = p.images || [];
|
| 312 |
+
let imgGallery = '';
|
| 313 |
+
if(images.length > 0){
|
| 314 |
+
imgGallery = '<div class="article-image-gallery">';
|
| 315 |
+
images.forEach((imgUrl, imgIdx) => {
|
| 316 |
+
if(imgIdx === 0){imgGallery += `<img class="article-img article-hero-img" src="${esc(imgUrl)}" onerror="this.style.display='none" loading="eager">`;}
|
| 317 |
+
else{if(imgIdx === 1) imgGallery += '<div class="gallery-thumbs">';imgGallery += `<div class="gallery-thumb"><img src="${esc(imgUrl)}" onerror="this.parentElement.style.display='none'" loading="lazy"></div>`;}
|
| 318 |
+
});
|
| 319 |
+
if(images.length > 1) imgGallery += '</div>';
|
| 320 |
+
imgGallery += '</div>';
|
| 321 |
+
}
|
| 322 |
+
const hasVideo = p.video && p.video.length > 0;
|
| 323 |
+
const voiceOptions = [{id:'hoaimy',label:'🎙️ Nữ — Hoài My'},{id:'namminh',label:'🎙️ Nam — Nam Minh'}];
|
| 324 |
+
let voiceSelector = '';
|
| 325 |
+
if(!hasVideo){
|
| 326 |
+
voiceSelector = `<div class="tts-selector"><div class="tts-selector-label">🎙️ Chọn giọng đọc:</div><div class="tts-voice-btns">`;
|
| 327 |
+
voiceOptions.forEach(v=>{voiceSelector+=`<button class="tts-voice-btn" onclick="document.querySelectorAll('.tts-voice-btn').forEach(b=>b.classList.remove('active'));this.classList.add('active');document.getElementById('selected-voice').value='${v.id}'">${v.label}</button>`;});
|
| 328 |
+
voiceSelector+=`</div><div class="tts-speed-row"><span>Tốc độ:</span><select id="selected-speed"><option value="1.0">1.0x</option><option value="1.2" selected>1.2x</option><option value="1.5">1.5x</option><option value="0.8">0.8x</option></select></div>`;
|
| 329 |
+
voiceSelector+=`<input type="hidden" id="selected-voice" value="hoaimy"></div>`;
|
| 330 |
}
|
| 331 |
+
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">${esc(p.title)}</h1>${imgGallery}<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>${hasVideo?`<video class="article-img" src="${esc(p.video)}" controls playsinline style="max-height:400px"></video>`:''}<div class="article-actions">${hasVideo?`<button onclick="openShortAIFeed(${i})">🎬 Xem Short</button>${voiceSelector}<button class="primary" onclick="makeShortVideo('${esc(p.id)}',this,document.getElementById('selected-voice')?.value,parseFloat(document.getElementById('selected-speed')?.value)||1.2)">🔄 Tạo lại Short</button>`:`${voiceSelector}<button class="primary" onclick="makeShortVideo('${esc(p.id)}',this,document.getElementById('selected-voice')?.value,parseFloat(document.getElementById('selected-speed')?.value)||1.2)">🎬 Tạo Video Shorts</button>`}<button onclick="doShare('${esc(p.title)}','${SPACE}','${esc(p.img||'')}')">📤</button></div></div>`;
|
| 332 |
+
const firstVoiceBtn = document.querySelector('.tts-voice-btn');
|
| 333 |
+
if(firstVoiceBtn) firstVoiceBtn.classList.add('active');
|
| 334 |
+
window.scrollTo(0,0)}
|
| 335 |
+
async function loadNewsTab(){const el=document.getElementById('view-cat');el.innerHTML='<div class="loading">Đang tải...</div>';try{const r=await fetch('/api/homepage');const news=await r.json();if(!news.length){el.innerHTML='<div class="loading">Không có tin</div>';return}const groups={};news.forEach(a=>{if(!groups[a.group])groups[a.group]=[];groups[a.group].push(a)});let h='';for(const[g,arts]of Object.entries(groups)){h+=`<div class="section-title">${g}</div><div class="grid">`;arts.slice(0,6).forEach(a=>{h+=`<div class="card" onclick="readArticle('${esc(a.link)}')"><div class="card-img">${a.img?`<img src="${a.img}" loading="lazy">`:''}</div><div class="card-body"><span class="badge badge-vne">${esc(a.source||'VnE')}</span><div class="card-title">${esc(a.title)}</div></div></div>`});h+='</div>'}el.innerHTML=h}catch(e){el.innerHTML='<div class="loading">Lỗi</div>'}}
|
| 336 |
+
async function loadCat(id){const el=document.getElementById('view-cat');el.innerHTML='<div class="loading">Đang tải...</div>';const arts=await fetch('/api/category/'+id).then(r=>r.json()).catch(()=>[]);if(!arts.length){el.innerHTML='<div class="loading">Không có tin</div>';return}let h='<div class="grid">';arts.forEach(a=>{h+=`<div class="card" onclick="readArticle('${esc(a.link)}')"><div class="card-img">${a.img?`<img src="${a.img}" loading="lazy">`:''}</div><div class="card-body"><span class="badge badge-vne">${esc(a.source||'')}</span><div class="card-title">${esc(a.title)}</div></div></div>`});h+='</div>';el.innerHTML=h}
|
| 337 |
+
fetch('/api/storage_status').then(r=>r.json()).then(j=>{if(!j.persistent){const home=document.getElementById('view-home');if(home){const w=document.createElement('div');w.className='storage-warn';w.innerHTML='⚠️ Persistent Storage chưa bật.';home.prepend(w)}}}).catch(()=>{});
|
| 338 |
+
(function(){try{var hash=window.location.hash;if(hash&&hash.length>1){var articleUrl=decodeURIComponent(hash.substring(1));if(articleUrl.startsWith('http')){history.replaceState(null,'',window.location.pathname);setTimeout(function(){if(typeof readArticle==='function')readArticle(articleUrl);},1500);}}}catch(e){}})();
|
| 339 |
+
(function(){try{const pa=localStorage.getItem('pending_article');const pv=localStorage.getItem('pending_video');if(pa){localStorage.removeItem('pending_article');setTimeout(()=>{if(typeof readArticle==='function')readArticle(pa);},1500);}if(pv){localStorage.removeItem('pending_video');try{const v=JSON.parse(pv);if(v&&v.url)setTimeout(()=>{window.open(v.url,'_blank')},1500);}catch(e){}}}catch(e){}})();
|
| 340 |
+
if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',function(){if(typeof loadHome==='function')loadHome();});}else{if(typeof loadHome==='function')loadHome();}
|
static/app_v2_shorts_fix.js
DELETED
|
@@ -1,2 +0,0 @@
|
|
| 1 |
-
// No-op - all functionality built into app_v2.js
|
| 2 |
-
(function(){})();
|
|
|
|
|
|
|
|
|
static/fm_fix.css
CHANGED
|
@@ -31,76 +31,6 @@
|
|
| 31 |
@keyframes ht-spin{to{transform:rotate(360deg)}}
|
| 32 |
.wall-img-count{position:absolute;bottom:4px;left:4px;background:rgba(0,0,0,.7);color:#fff;font-size:9px;padding:1px 5px;border-radius:4px}
|
| 33 |
#progress-toast{position:fixed!important;bottom:70px!important;left:50%!important;transform:translateX(-50%)!important;background:#2d8659!important;color:#fff!important;padding:10px 20px!important;border-radius:20px!important;font-size:12px!important;z-index:99998!important;box-shadow:0 4px 12px rgba(0,0,0,.4)!important;display:none;white-space:nowrap!important}
|
| 34 |
-
/* === ARTICLE / REWRITE VIEW FIX === *//* === WORLD CUP 2026 SECTION === */
|
| 35 |
-
.wc-section{margin:8px 4px!important;background:#1a1a1a!important;border:1px solid #2a2a2a!important;border-radius:10px!important;overflow:hidden!important}
|
| 36 |
-
.wc-header{padding:10px 12px!important;background:linear-gradient(135deg,#0d1b2b,#1a3a2a 50%,#0b4b8b)!important;display:flex!important;align-items:center!important;justify-content:space-between!important}
|
| 37 |
-
.wc-title{font-size:14px!important;font-weight:800!important;color:#fff!important}
|
| 38 |
-
.wc-tabs{display:flex!important;gap:4px!important;padding:8px 10px!important;background:#1a1a1a!important;overflow-x:auto!important;scrollbar-width:none!important;border-bottom:1px solid #2a2a2a!important}
|
| 39 |
-
.wc-tabs::-webkit-scrollbar{display:none!important}
|
| 40 |
-
.wc-tab{padding:6px 12px!important;background:#222!important;border:1px solid #333!important;border-radius:12px!important;color:#999!important;font-size:10px!important;white-space:nowrap!important;cursor:pointer!important;flex-shrink:0!important}
|
| 41 |
-
.wc-tab.active{background:#0b6bcb!important;border-color:#0b6bcb!important;color:#fff!important;font-weight:700!important}
|
| 42 |
-
.wc-tab-content{padding:10px!important;max-height:500px!important;overflow-y:auto!important}
|
| 43 |
-
.wc-news-list{display:flex!important;flex-direction:column!important;gap:8px!important}
|
| 44 |
-
.wc-news-item{display:flex!important;gap:10px!important;padding:8px!important;background:#202020!important;border-radius:8px!important;cursor:pointer!important}
|
| 45 |
-
.wc-news-item:hover{background:#2a2a2a!important}
|
| 46 |
-
.wc-news-img{flex:0 0 90px!important;aspect-ratio:16/9!important;background:#333!important;border-radius:6px!important;overflow:hidden!important}
|
| 47 |
-
.wc-news-img img{width:100%!important;height:100%!important;object-fit:cover!important}
|
| 48 |
-
.wc-news-text{flex:1!important;min-width:0!important;display:flex!important;flex-direction:column!important;justify-content:space-between!important}
|
| 49 |
-
.wc-news-title{font-size:12px!important;font-weight:700!important;color:#eee!important;display:-webkit-box!important;-webkit-line-clamp:2!important;-webkit-box-orient:vertical!important;overflow:hidden!important}
|
| 50 |
-
.wc-news-source{font-size:10px!important;color:#888!important;margin-top:4px!important}
|
| 51 |
-
|
| 52 |
-
/* World Cup Fixtures */
|
| 53 |
-
.wc-fixtures-list{display:flex!important;flex-direction:column!important;gap:6px!important}
|
| 54 |
-
.wc-match-item{padding:10px!important;background:#202020!important;border-radius:8px!important;border-left:3px solid #333!important}
|
| 55 |
-
.wc-match-item:nth-child(odd){background:#1e1e1e!important}
|
| 56 |
-
.wc-match-date{font-size:10px!important;color:#888!important;margin-bottom:4px!important}
|
| 57 |
-
.wc-match-teams{display:flex!important;align-items:center!important;justify-content:center!important;gap:12px!important}
|
| 58 |
-
.wc-match-teams .wc-team{flex:1!important;font-size:12px!important;color:#ddd!important;text-align:center!important}
|
| 59 |
-
.wc-match-teams .wc-score{font-size:16px!important;font-weight:900!important;color:#f0c040!important;min-width:60px!important;text-align:center!important}
|
| 60 |
-
.wc-score.wc-live{color:#e74c3c!important}
|
| 61 |
-
.wc-score.wc-finished{color:#888!important}
|
| 62 |
-
.wc-score.wc-upcoming{color:#5cb87a!important}
|
| 63 |
-
.wc-match-location{font-size:9px!important;color:#777!important;text-align:center!important;margin-top:4px!important}
|
| 64 |
-
|
| 65 |
-
/* World Cup Standings & Stats Tables */
|
| 66 |
-
.wc-standings-table,.wc-stats-table{font-size:11px!important;color:#ccc!important;overflow-x:auto!important;width:100%!important}
|
| 67 |
-
.wc-standings-table table,.wc-stats-table table{width:100%!important;border-collapse:collapse!important;table-layout:fixed!important}
|
| 68 |
-
.wc-standings-table th,.wc-stats-table th{background:#222!important;color:#999!important;padding:6px 4px!important;font-size:10px!important;border-bottom:1px solid #333!important;text-align:center!important}
|
| 69 |
-
.wc-standings-table th:first-child,.wc-stats-table th:first-child{text-align:left!important}
|
| 70 |
-
.wc-standings-table td,.wc-stats-table td{padding:5px 4px!important;border-bottom:1px solid #1a1a1a!important;text-align:center!important;font-size:11px!important}
|
| 71 |
-
.wc-standings-table td:first-child,.wc-stats-table td:first-child{text-align:left!important}
|
| 72 |
-
.wc-standings-table .pts,.wc-stats-table .pts{font-weight:800!important;color:#f0c040!important}
|
| 73 |
-
.wc-standings-table .team-name,.wc-stats-table .team-name{display:flex!important;align-items:center!important;gap:4px!important}
|
| 74 |
-
.wc-standings-table .team-name img,.wc-stats-table .team-name img{width:16px!important;height:16px!important;object-fit:contain!important}
|
| 75 |
-
|
| 76 |
-
/* World Cup Highlights */
|
| 77 |
-
.wc-highlights-grid{display:grid!important;grid-template-columns:repeat(2,1fr)!important;gap:8px!important}/* World Cup Stats */
|
| 78 |
-
.wc-stats{padding:10px!important}
|
| 79 |
-
.wc-stat-group{margin-bottom:16px!important;padding:10px!important;background:#202020!important;border-radius:8px!important}
|
| 80 |
-
.wc-stat-group h4{font-size:12px!important;color:#5cb87a!important;margin-bottom:8px!important;font-weight:700!important}
|
| 81 |
-
.wc-stat-group .wc-empty{color:#888!important;font-size:11px!important;font-style:italic!important}
|
| 82 |
-
.wc-stat-group table{width:100%!important;border-collapse:collapse!important}
|
| 83 |
-
.wc-stat-group th{background:#2a2a2a!important;color:#999!important;padding:6px 4px!important;font-size:10px!important;text-align:left!important;border-bottom:1px solid #333!important}
|
| 84 |
-
.wc-stat-group td{padding:5px 4px!important;border-bottom:1px solid #1a1a1a!important;font-size:11px!important;color:#ccc!important}
|
| 85 |
-
|
| 86 |
-
/* World Cup Group Standings */
|
| 87 |
-
.wc-group{margin-bottom:16px!important}
|
| 88 |
-
.wc-group h4{font-size:12px!important;color:#f0c040!important;margin-bottom:6px!important;padding:6px 8px!important;background:#222!important;border-radius:6px!important;font-weight:700!important}
|
| 89 |
-
.wc-table{width:100%!important;border-collapse:collapse!important;font-size:11px!important}
|
| 90 |
-
.wc-table th{background:#2a2a2a!important;color:#999!important;padding:6px 4px!important;font-size:10px!important;text-align:center!important;border-bottom:1px solid #333!important}
|
| 91 |
-
.wc-table th:first-child{text-align:left!important}
|
| 92 |
-
.wc-table td{padding:5px 4px!important;border-bottom:1px solid #1a1a1a!important;text-align:center!important;color:#ccc!important}
|
| 93 |
-
.wc-table td:first-child{text-align:left!important;font-weight:600!important}
|
| 94 |
-
.wc-table .pts{font-weight:800!important;color:#f0c040!important}
|
| 95 |
-
|
| 96 |
-
/* World Cup Highlights */
|
| 97 |
-
.wc-highlights-grid{display:grid!important;grid-template-columns:repeat(2,1fr)!important;gap:8px!important}
|
| 98 |
-
.wc-hl-item{cursor:pointer!important}
|
| 99 |
-
.wc-hl-thumb{position:relative!important;aspect-ratio:16/9!important;background:#333!important;border-radius:6px!important;overflow:hidden!important}
|
| 100 |
-
.wc-hl-thumb img{width:100%!important;height:100%!important;object-fit:cover!important}
|
| 101 |
-
.wc-hl-thumb .card-play{position:absolute!important;left:50%!important;top:50%!important;transform:translate(-50%,-50%)!important;width:30px!important;height:30px!important;border-radius:50%!important;background:rgba(0,0,0,.55)!important;display:flex!important;align-items:center!important;justify-content:center!important;color:#fff!important;font-size:12px!important}
|
| 102 |
-
.wc-hl-title{font-size:10px!important;color:#ccc!important;margin-top:4px!important;line-height:1.2!important;display:-webkit-box!important;-webkit-line-clamp:2!important;-webkit-box-orient:vertical!important;overflow:hidden!important}
|
| 103 |
-
|
| 104 |
/* === ARTICLE / REWRITE VIEW FIX === */
|
| 105 |
.article-view{padding:12px 8px 40px!important;max-width:760px!important;margin:0 auto!important}
|
| 106 |
.article-title{font-size:18px!important;font-weight:800!important;line-height:1.3!important;margin-bottom:8px!important;color:#fff!important}
|
|
|
|
| 31 |
@keyframes ht-spin{to{transform:rotate(360deg)}}
|
| 32 |
.wall-img-count{position:absolute;bottom:4px;left:4px;background:rgba(0,0,0,.7);color:#fff;font-size:9px;padding:1px 5px;border-radius:4px}
|
| 33 |
#progress-toast{position:fixed!important;bottom:70px!important;left:50%!important;transform:translateX(-50%)!important;background:#2d8659!important;color:#fff!important;padding:10px 20px!important;border-radius:20px!important;font-size:12px!important;z-index:99998!important;box-shadow:0 4px 12px rgba(0,0,0,.4)!important;display:none;white-space:nowrap!important}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
/* === ARTICLE / REWRITE VIEW FIX === */
|
| 35 |
.article-view{padding:12px 8px 40px!important;max-width:760px!important;margin:0 auto!important}
|
| 36 |
.article-title{font-size:18px!important;font-weight:800!important;line-height:1.3!important;margin-bottom:8px!important;color:#fff!important}
|
static/index_v2.html
CHANGED
|
@@ -4,18 +4,16 @@
|
|
| 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/
|
| 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:50%;background:rgba(18,18,18,.95);border-radius:14px 14px 0 0;z-index:10;overflow:hidden;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}.inline-cmt-header button{background:none;border:0;color:#fff;font-size:16px;cursor:pointer}.inline-cmt-list{flex:1;overflow-y:auto;padding:6px 10px;max-height:180px}.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}.inline-cmt-input input{flex:1;background:#222;border:1px solid #444;color:#eee;border-radius:16px;padding:7px 12px;font-size:11px}.inline-cmt-input button{background:#2d8659;border:0;color:#fff;border-radius:16px;padding:7px 12px;font-size:11px;cursor:pointer}.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 ·
|
| 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>
|
|
@@ -32,28 +30,19 @@
|
|
| 32 |
var _cats=[],_hlLeagueData={},_currentArticle=null;window._currentEventId='';
|
| 33 |
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]))}
|
| 34 |
function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')}
|
| 35 |
-
function switchCat(id){document.querySelectorAll('.cat').forEach(c=>c.classList.remove('active'));document.querySelector(
|
| 36 |
function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}}
|
| 37 |
-
function doShare(title,url,img,
|
| 38 |
-
|
| 39 |
-
const slug = title.toLowerCase()
|
| 40 |
-
.normalize('NFKD').replace(/[\u0300-\u036f]/g,'')
|
| 41 |
-
.replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'')
|
| 42 |
-
.substring(0,60) || 'article';
|
| 43 |
-
const shareUrl = SPACE + '/s/' + slug + '?url=' + encodeURIComponent(url) + '&img=' + encodeURIComponent(img||'');
|
| 44 |
-
if(navigator.share) navigator.share({title,url:shareUrl}).catch(()=>{});
|
| 45 |
-
else{ navigator.clipboard.writeText(shareUrl).then(()=>alert('Đã sao chép!\n'+shareUrl)).catch(()=>{}) }
|
| 46 |
-
}
|
| 47 |
-
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)});}
|
| 48 |
var SPACE=location.origin;
|
| 49 |
</script>
|
| 50 |
-
<script src="/static/app_v2.js
|
| 51 |
<script src="/static/yt_live.js"></script>
|
| 52 |
-
<script src="/static/
|
| 53 |
-
<script src="/static/hot_multi.js?v=1"></script>
|
| 54 |
<script src="/static/wc2026_v2.js"></script>
|
| 55 |
<script src="/static/live_mode.js"></script>
|
| 56 |
<script src="/static/match_detail_v6.js"></script>
|
| 57 |
-
|
|
|
|
| 58 |
</body>
|
| 59 |
-
</html>
|
|
|
|
| 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, YouTube shorts, 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/fm_fix.css">
|
| 12 |
<script src="https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js"></script>
|
| 13 |
+
<style>*{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}.shorts-item{flex:0 0 110px!important}.slider-thumb{position:relative;width:100%;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#333}.shorts-thumb{aspect-ratio:3/4!important;border-radius:8px!important}.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}.shorts-badge{position:absolute;bottom:4px;left:4px;font-size:7px;font-weight:700;padding:1px 4px;border-radius:3px;color:#fff;z-index:2}.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-bbc{background:#b80000}.badge-dt{background:#1565c0}.badge-genk{background:#6a1b9a}.badge-fpt{background:#f26522}.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:50%;background:rgba(18,18,18,.95);border-radius:14px 14px 0 0;z-index:10;overflow:hidden;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}.inline-cmt-header button{background:none;border:0;color:#fff;font-size:16px;cursor:pointer}.inline-cmt-list{flex:1;overflow-y:auto;padding:6px 10px;max-height:180px}.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}.inline-cmt-input input{flex:1;background:#222;border:1px solid #444;color:#eee;border-radius:16px;padding:7px 12px;font-size:11px}.inline-cmt-input button{background:#2d8659;border:0;color:#fff;border-radius:16px;padding:7px 12px;font-size:11px;cursor:pointer}.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 12px;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}</style>
|
|
|
|
|
|
|
| 14 |
</head>
|
| 15 |
<body>
|
| 16 |
+
<div class="header"><h1>📰 VNEWS</h1><p>Tin tức · Bóng đá LIVE · Video · AI · World Cup 2026</p></div>
|
| 17 |
<div class="cats" id="cat-bar"></div>
|
| 18 |
<div id="view-home" class="view active"><div class="loading">Đang tải...</div></div>
|
| 19 |
<div id="view-cat" class="view"></div>
|
|
|
|
| 30 |
var _cats=[],_hlLeagueData={},_currentArticle=null;window._currentEventId='';
|
| 31 |
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]))}
|
| 32 |
function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')}
|
| 33 |
+
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)}}
|
| 34 |
function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}}
|
| 35 |
+
function doShare(title,url,img){const shareUrl=SPACE+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||'');if(navigator.share)navigator.share({title,url:shareUrl}).catch(()=>{});else navigator.clipboard.writeText(shareUrl).then(()=>alert('Đã sao chép!')).catch(()=>{})}
|
| 36 |
+
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)});}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
var SPACE=location.origin;
|
| 38 |
</script>
|
| 39 |
+
<script src="/static/app_v2.js"></script>
|
| 40 |
<script src="/static/yt_live.js"></script>
|
| 41 |
+
<script src="/static/hot_multi.js"></script>
|
|
|
|
| 42 |
<script src="/static/wc2026_v2.js"></script>
|
| 43 |
<script src="/static/live_mode.js"></script>
|
| 44 |
<script src="/static/match_detail_v6.js"></script>
|
| 45 |
+
|
| 46 |
+
<script>init();</script>
|
| 47 |
</body>
|
| 48 |
+
</html>
|
static/restart_trigger.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
REBUILD_TRIGGER v5: processRatio fix - 1781425341.6507528
|
static/rewrite_fix.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Fix rewriteArticle - call correct endpoint
|
| 2 |
+
// This file patches the rewriteArticle function to use /api/rewrite_slide instead of /api/rewrite_share
|
| 3 |
+
|
| 4 |
+
(function(){
|
| 5 |
+
// Override rewriteArticle to call /api/rewrite_slide
|
| 6 |
+
const origRewrite = window.rewriteArticle;
|
| 7 |
+
window.rewriteArticle = async function(){
|
| 8 |
+
const url = _currentArticle?.url;
|
| 9 |
+
if(!url) return;
|
| 10 |
+
toast('⏳ Đang tạo slide tóm tắt...');
|
| 11 |
+
try {
|
| 12 |
+
const r = await fetch('/api/rewrite_slide', {
|
| 13 |
+
method: 'POST',
|
| 14 |
+
headers: {'Content-Type': 'application/json'},
|
| 15 |
+
body: JSON.stringify({url, context: document.querySelector('.article-view')?.innerText?.slice(0,14000) || ''})
|
| 16 |
+
});
|
| 17 |
+
const j = await r.json();
|
| 18 |
+
if (!r.ok || j.error) throw new Error(j.error);
|
| 19 |
+
toast('✅ Đã đăng Tường AI!');
|
| 20 |
+
if (j.post) prependWallPost(j.post);
|
| 21 |
+
// Navigate to the new post on Tường AI (home). Slide overlay (if any) stays on top.
|
| 22 |
+
if (j.post && typeof goToWallPost === 'function') goToWallPost(j.post.id);
|
| 23 |
+
// Show slides preview
|
| 24 |
+
if (j.slides && j.slides.length) {
|
| 25 |
+
showSlidePreview(j.slides, j.post?.title || '');
|
| 26 |
+
}
|
| 27 |
+
} catch(e) {
|
| 28 |
+
// Fallback: try /api/rewrite_share (old endpoint from ai_ext)
|
| 29 |
+
try {
|
| 30 |
+
const r2 = await fetch('/api/rewrite_share', {
|
| 31 |
+
method: 'POST',
|
| 32 |
+
headers: {'Content-Type': 'application/json'},
|
| 33 |
+
body: JSON.stringify({url, context: document.querySelector('.article-view')?.innerText?.slice(0,14000) || ''})
|
| 34 |
+
});
|
| 35 |
+
const j2 = await r2.json();
|
| 36 |
+
if (r2.ok && !j2.error) {
|
| 37 |
+
toast('✅ Đã đăng Tường AI!');
|
| 38 |
+
if (j2.post) prependWallPost(j2.post);
|
| 39 |
+
if (j2.post && typeof goToWallPost === 'function') goToWallPost(j2.post.id);
|
| 40 |
+
return;
|
| 41 |
+
}
|
| 42 |
+
} catch(e2) {}
|
| 43 |
+
toast('❌ ' + e.message);
|
| 44 |
+
}
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
// Show slides as fullscreen overlay
|
| 48 |
+
window.showSlidePreview = function(slides, title) {
|
| 49 |
+
if (!slides || !slides.length) return;
|
| 50 |
+
const overlay = document.createElement('div');
|
| 51 |
+
overlay.id = 'slide-preview';
|
| 52 |
+
overlay.style.cssText = 'position:fixed;inset:0;background:#000;z-index:99999;display:flex;flex-direction:column;overflow:hidden';
|
| 53 |
+
|
| 54 |
+
let currentSlide = 0;
|
| 55 |
+
function renderSlide(idx) {
|
| 56 |
+
const s = slides[idx];
|
| 57 |
+
overlay.innerHTML = `
|
| 58 |
+
<div style="position:absolute;top:10px;left:10px;right:10px;display:flex;justify-content:space-between;align-items:center;z-index:2">
|
| 59 |
+
<button onclick="document.getElementById('slide-preview').remove()" style="background:rgba(0,0,0,.6);border:0;color:#fff;padding:8px 14px;border-radius:20px;font-size:12px;cursor:pointer">✕ Đóng</button>
|
| 60 |
+
<span style="color:#fff;font-size:11px;background:rgba(0,0,0,.6);padding:4px 10px;border-radius:10px">${idx+1}/${slides.length}</span>
|
| 61 |
+
</div>
|
| 62 |
+
<div style="flex:1;display:flex;align-items:center;justify-content:center;padding:20px">
|
| 63 |
+
${s.image ? `<img src="${esc(s.image)}" style="max-width:100%;max-height:60vh;border-radius:10px;object-fit:contain" onerror="this.style.display='none'">` : ''}
|
| 64 |
+
</div>
|
| 65 |
+
<div style="padding:16px 20px;background:linear-gradient(transparent,rgba(0,0,0,.9));min-height:100px">
|
| 66 |
+
<p style="color:#fff;font-size:14px;line-height:1.6">${esc(s.text)}</p>
|
| 67 |
+
</div>
|
| 68 |
+
<div style="display:flex;gap:10px;padding:10px 20px 20px;justify-content:center">
|
| 69 |
+
<button onclick="prevSlide()" style="background:#333;border:0;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;cursor:pointer" ${idx===0?'disabled style="opacity:.3"':''}>← Trước</button>
|
| 70 |
+
<button onclick="nextSlide()" style="background:#2d8659;border:0;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;cursor:pointer" ${idx===slides.length-1?'disabled style="opacity:.3"':''}>Tiếp →</button>
|
| 71 |
+
</div>
|
| 72 |
+
`;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
window.nextSlide = function() { if (currentSlide < slides.length - 1) { currentSlide++; renderSlide(currentSlide); } };
|
| 76 |
+
window.prevSlide = function() { if (currentSlide > 0) { currentSlide--; renderSlide(currentSlide); } };
|
| 77 |
+
|
| 78 |
+
renderSlide(0);
|
| 79 |
+
document.body.appendChild(overlay);
|
| 80 |
+
|
| 81 |
+
// Swipe support
|
| 82 |
+
let startX = 0;
|
| 83 |
+
overlay.addEventListener('touchstart', e => { startX = e.touches[0].clientX; });
|
| 84 |
+
overlay.addEventListener('touchend', e => {
|
| 85 |
+
const diff = e.changedTouches[0].clientX - startX;
|
| 86 |
+
if (diff < -50) nextSlide();
|
| 87 |
+
else if (diff > 50) prevSlide();
|
| 88 |
+
});
|
| 89 |
+
};
|
| 90 |
+
})();
|
static/rewrite_fix_v2.js
DELETED
|
@@ -1,164 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* VNEWS Rewrite Fix v2 — OVERRIDES loadHome() with fast non-blocking rendering
|
| 3 |
-
*
|
| 4 |
-
* FIXES:
|
| 5 |
-
* 1. News grid loads FIRST (homepage API returns in ~100ms)
|
| 6 |
-
* 2. All other API calls are non-blocking — render what's available immediately
|
| 7 |
-
* 3. WC2026 (14s) / highlights (5s) / livescore (2s) load in background
|
| 8 |
-
* 4. rewriteUrl() → rewriteSlide() (auto create slides + Short AI)
|
| 9 |
-
* 5. No page reload needed after Short AI creation
|
| 10 |
-
*/
|
| 11 |
-
|
| 12 |
-
// ===== OVERRIDE: fast non-blocking loadHome =====
|
| 13 |
-
async function loadHome(){
|
| 14 |
-
const homeEl = document.getElementById('view-home');
|
| 15 |
-
if(!homeEl) return;
|
| 16 |
-
|
| 17 |
-
// Render structure immediately
|
| 18 |
-
homeEl.innerHTML =
|
| 19 |
-
'<div id="home-featured-area"></div>'
|
| 20 |
-
+'<div class="ai-compose"><div class="ai-compose-title">🤖 AI viết bài</div><div class="ai-compose-row"><input id="topic-input" placeholder="Nhập chủ đề..."><button onclick="searchTopic()">Tìm nguồn</button></div><div class="ai-compose-row"><input id="url-input" placeholder="Dán URL bài viết..."><button class="secondary" onclick="rewriteUrl()">Rewrite</button></div><div id="hot-topics" class="hot-topic-row"></div></div>'
|
| 21 |
-
+'<div id="hashtag-box"></div>'
|
| 22 |
-
+'<div class="ls-section"><div class="ls-header"><h3>⚽ Livescore</h3></div><div class="ls-tabs"><span class="ls-tab active" data-tab="today" onclick="loadLivescore(\'today\')">📅 Hôm nay</span><span class="ls-tab" data-tab="live" onclick="loadLivescore(\'live\')">🔴 Live</span><span class="ls-tab" data-tab="incoming" onclick="loadLivescore(\'incoming\')">⏰ Sắp tới</span><span class="ls-tab" data-tab="results" onclick="loadLivescore(\'results\')">✅ Kết quả</span><span class="ls-tab" data-tab="bxh_nha" onclick="loadLivescore(\'bxh_nha\')">🏆 NHA</span><span class="ls-tab" data-tab="bxh_laliga" onclick="loadLivescore(\'bxh_laliga\')">🏆 La Liga</span></div><div class="ls-content" id="ls-content"><div class="loading">Đang tải...</div></div></div>'
|
| 23 |
-
// News grid on homepage!
|
| 24 |
-
+'<div id="home-news-grid"><div class="loading" style="padding:8px;font-size:11px">📰 Đang tải tin tức...</div></div>'
|
| 25 |
-
+'<div id="wc2026-live-section" class="wc2026-section" style="display:none"><div class="wc-header"><h2>🏆 World Cup 2026</h2><span class="wc-live-badge">● LIVE</span></div><div class="wc-tabs"><span class="wc-tab active" onclick="switchWCTab(\'news\')">📰 Tin tức</span><span class="wc-tab" onclick="switchWCTab(\'fixtures\')">📅 Lịch thi đấu</span><span class="wc-tab" onclick="switchWCTab(\'standings\')">🏆 BXH</span><span class="wc-tab" onclick="switchWCTab(\'highlights\')">🎬 Highlight</span><span class="wc-tab" onclick="switchWCTab(\'stats\')">📊 Thống kê</span></div><div class="wc-content" id="wc-content"><div class="loading">Đang tải World Cup 2026...</div></div></div>'
|
| 26 |
-
+'<div id="home-after-wc"></div>';
|
| 27 |
-
|
| 28 |
-
const afterEl = homeEl.querySelector('#home-after-wc');
|
| 29 |
-
const newsEl = homeEl.querySelector('#home-news-grid');
|
| 30 |
-
|
| 31 |
-
// 1. FAST: load livescore + hot topics (both < 1s)
|
| 32 |
-
loadLivescore('today');
|
| 33 |
-
loadHotTopics();
|
| 34 |
-
|
| 35 |
-
// 2. FAST: load news grid (homepage API ~108ms) — renders immediately
|
| 36 |
-
(async ()=>{
|
| 37 |
-
try{
|
| 38 |
-
const r = await fetch('/api/homepage');
|
| 39 |
-
const articles = await r.json();
|
| 40 |
-
if(articles && articles.length){
|
| 41 |
-
let h = '<div class="section-title">📰 Tin tức mới nhất</div><div class="grid">';
|
| 42 |
-
articles.slice(0, 20).forEach(a=>{
|
| 43 |
-
const src = a.source||'vne';
|
| 44 |
-
const badge = a.group||a.source||'';
|
| 45 |
-
h+=`<div class="card" onclick="readArticle('${esc(a.link)}')"><div class="card-img">${a.img?`<img src="${_proxyImg(a.img)}" loading="lazy" onerror="this.style.display=\'none\'">`:''}</div><div class="card-body"><span class="badge badge-${src}">${esc(badge)}</span><div class="card-title">${esc(a.title)}</div></div></div>`;
|
| 46 |
-
});
|
| 47 |
-
h+='</div>';
|
| 48 |
-
if(newsEl) newsEl.innerHTML = h;
|
| 49 |
-
} else if(newsEl) newsEl.style.display='none';
|
| 50 |
-
} catch(e){
|
| 51 |
-
if(newsEl) newsEl.innerHTML = '<div style="padding:12px;color:#888;font-size:11px">📰 Nhấn "📰 Tin tức" để xem tin mới</div>';
|
| 52 |
-
}
|
| 53 |
-
})();
|
| 54 |
-
|
| 55 |
-
// 3. BACKGROUND: featured match, wall, highlights, WC2026
|
| 56 |
-
Promise.allSettled([
|
| 57 |
-
_fetchWithTimeout('/api/livescore/featured', 5000),
|
| 58 |
-
_fetchWithTimeout('/api/wall', 5000),
|
| 59 |
-
_fetchWithTimeout('/api/highlights/leagues', 10000),
|
| 60 |
-
_fetchWithTimeout('/api/wc2026', 8000),
|
| 61 |
-
]).then(results => {
|
| 62 |
-
const resultsArr = results.map(r => r.status === 'fulfilled' ? r.value : null);
|
| 63 |
-
const [featuredData, wallData, hlLeagues, wcData] = resultsArr;
|
| 64 |
-
|
| 65 |
-
// Featured match
|
| 66 |
-
if(featuredData && featuredData.home){
|
| 67 |
-
const sc=featuredData.status==='live'?'':'upcoming';
|
| 68 |
-
const st=featuredData.status==='live'?`🔴 ${featuredData.minute||'LIVE'}`:`⏰ ${featuredData.time}`;
|
| 69 |
-
const area=document.getElementById('home-featured-area');
|
| 70 |
-
if(area) area.innerHTML=`<div class="featured-match" onclick="openMatch('${featuredData.event_id}')"><div class="fm-league">${featuredData.league}</div><div class="fm-teams"><div class="fm-team"><img src="${_proxyImg(featuredData.home_logo)}" onerror="this.style.display='none'"><span>${featuredData.home}</span></div><div class="fm-score">${featuredData.score||'VS'}</div><div class="fm-team"><img src="${_proxyImg(featuredData.away_logo)}" onerror="this.style.display='none'"><span>${featuredData.away}</span></div></div><div class="fm-status ${sc}">${st}</div></div>`;
|
| 71 |
-
}
|
| 72 |
-
|
| 73 |
-
_wallPosts = (wallData && wallData.posts) || [];
|
| 74 |
-
_hlLeagueData = hlLeagues || {};
|
| 75 |
-
_wc2026Data = wcData;
|
| 76 |
-
|
| 77 |
-
// Show WC2026 section
|
| 78 |
-
const wcSection = document.getElementById('wc2026-live-section');
|
| 79 |
-
if(wcSection) wcSection.style.display = '';
|
| 80 |
-
if(wcData) switchWCTab('news');
|
| 81 |
-
|
| 82 |
-
_renderWallIn(afterEl);
|
| 83 |
-
_renderHLIn(afterEl);
|
| 84 |
-
});
|
| 85 |
-
}
|
| 86 |
-
|
| 87 |
-
// ===== OVERRIDE rewriteUrl() =====
|
| 88 |
-
async function rewriteUrl(){
|
| 89 |
-
const url = document.getElementById('url-input')?.value.trim();
|
| 90 |
-
if(!url){ alert('Nhập URL bài viết'); return; }
|
| 91 |
-
rewriteSlide(url);
|
| 92 |
-
}
|
| 93 |
-
|
| 94 |
-
// ===== AUTO Short AI + show slides (replaces app_v2.js rewriteSlide) =====
|
| 95 |
-
async function rewriteSlide(url){
|
| 96 |
-
if(!url) return;
|
| 97 |
-
const btn = document.querySelector('.article-actions .primary') || event?.target;
|
| 98 |
-
if(btn){ btn.disabled=true; btn.textContent='⏳ Đang tạo slides...'; }
|
| 99 |
-
toast('⏳ Đang tạo slide rewrite...');
|
| 100 |
-
try{
|
| 101 |
-
const r = await fetch('/api/rewrite_slide',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});
|
| 102 |
-
const j = await r.json();
|
| 103 |
-
if(!r.ok||j.error) throw new Error(j.error||'Lỗi');
|
| 104 |
-
toast('✅ Đã tạo slide! Đang tự động tạo Short AI...');
|
| 105 |
-
if(btn) btn.textContent='✅ Đang tạo Short AI...';
|
| 106 |
-
|
| 107 |
-
// Show slides immediately
|
| 108 |
-
if(j.post && j.post.slides && j.post.slides.length){
|
| 109 |
-
showView('view-article');
|
| 110 |
-
const el = document.getElementById('view-article');
|
| 111 |
-
let h = `<button class="back-btn" onclick="switchCat('home')">← Tường AI</button><div class="slide-viewer" style="padding:12px;max-width:600px;margin:0 auto">`;
|
| 112 |
-
j.post.slides.forEach(s=>{
|
| 113 |
-
h += `<div class="slide-card" style="background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:16px;margin-bottom:12px"><div class="slide-num" style="color:#5cb87a;font-size:12px;font-weight:700;margin-bottom:6px">Slide ${s.index}/${j.post.slides.length}</div>${
|
| 114 |
-
s.image ? `<img src="${s.image.startsWith('/api/')?s.image:'/api/proxy/img?url='+encodeURIComponent(s.image)}" style="width:100%;max-height:300px;object-fit:cover;border-radius:8px;margin-bottom:8px" loading="lazy" onerror="this.style.display=\'none\'">` : ''}<p style="color:#ddd;font-size:14px;line-height:1.6;margin:0">${esc(s.text)}</p></div>`;
|
| 115 |
-
});
|
| 116 |
-
h += `</div><div style="text-align:center;padding:12px"><div id="short-ai-status" style="color:#888;font-size:12px">⏳ Đang tạo video Short AI...</div></div>`;
|
| 117 |
-
el.innerHTML = h;
|
| 118 |
-
}
|
| 119 |
-
|
| 120 |
-
// Add to wall
|
| 121 |
-
if(j.post) prependWallPost(j.post);
|
| 122 |
-
|
| 123 |
-
// Auto-create Short AI
|
| 124 |
-
const postId = j.post && j.post.id;
|
| 125 |
-
if(postId){
|
| 126 |
-
const voice = j.post.voice || 'hoaimy';
|
| 127 |
-
const emotion = j.post.emotion || 'trung_tinh';
|
| 128 |
-
try{
|
| 129 |
-
const sr = await fetch('/api/ai/short/'+encodeURIComponent(postId),{
|
| 130 |
-
method:'POST',
|
| 131 |
-
headers:{'Content-Type':'application/json'},
|
| 132 |
-
body:JSON.stringify({voice,emotion,speed:1.2})
|
| 133 |
-
});
|
| 134 |
-
const sj = await sr.json();
|
| 135 |
-
if(!sr.ok||sj.error) throw new Error(sj.error||'Lỗi tạo Short AI');
|
| 136 |
-
toast('✅ Short AI đã tạo xong! Xem trong Tường AI.');
|
| 137 |
-
const statusEl = document.getElementById('short-ai-status');
|
| 138 |
-
if(statusEl) statusEl.innerHTML = '✅ Short AI đã sẵn sàng! <button class="primary" onclick="openShortAIFeed(0)" style="background:#2d8659;border:0;color:#fff;padding:6px 12px;border-radius:10px;font-size:11px;cursor:pointer;margin-left:8px">▶ Xem ngay</button>';
|
| 139 |
-
// Update wall card
|
| 140 |
-
if(sj.video && typeof _wallPosts!=='undefined'){
|
| 141 |
-
const p = _wallPosts.find(x => String(x.id) === String(postId));
|
| 142 |
-
if(p){
|
| 143 |
-
p.video = sj.video;
|
| 144 |
-
const itemId = 'wall-item-'+postId;
|
| 145 |
-
const el2 = document.getElementById(itemId);
|
| 146 |
-
if(el2){
|
| 147 |
-
const idx = _wallPosts.indexOf(p);
|
| 148 |
-
el2.insertAdjacentHTML('afterend', makeWallItem(p, idx));
|
| 149 |
-
el2.remove();
|
| 150 |
-
}
|
| 151 |
-
}
|
| 152 |
-
}
|
| 153 |
-
} catch(e2){
|
| 154 |
-
console.log('Auto Short AI failed:', e2.message);
|
| 155 |
-
const statusEl = document.getElementById('short-ai-status');
|
| 156 |
-
if(statusEl) statusEl.innerHTML = '❌ '+e2.message;
|
| 157 |
-
}
|
| 158 |
-
}
|
| 159 |
-
if(btn) btn.textContent='✅ Hoàn tất';
|
| 160 |
-
} catch(e){
|
| 161 |
-
toast('❌ '+e.message);
|
| 162 |
-
if(btn){ btn.disabled=false; btn.textContent='🤖 Slide Rewrite AI'; }
|
| 163 |
-
}
|
| 164 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/shorts_fresh.js
DELETED
|
@@ -1,5 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* VNEWS Shorts — REMOVED per user request
|
| 3 |
-
* Dantri/SKDS slides removed from homepage entirely
|
| 4 |
-
*/
|
| 5 |
-
(function(){'use strict';console.log('[Shorts] Disabled - Dantri/SKDS removed');})();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/vtv_init.js
DELETED
|
@@ -1,370 +0,0 @@
|
|
| 1 |
-
// ===== VNEWS VTV Player v11 — SV2 iframe + HLS.js native player toggle =====
|
| 2 |
-
(function() {
|
| 3 |
-
if (window._vtvInitLoaded) return;
|
| 4 |
-
window._vtvInitLoaded = true;
|
| 5 |
-
|
| 6 |
-
var CHANNELS = [
|
| 7 |
-
{id:'vtv1',name:'VTV1',badge:'Tin tức'},{id:'vtv2',name:'VTV2',badge:'Khoa học'},
|
| 8 |
-
{id:'vtv3',name:'VTV3',badge:'Giải trí'},{id:'vtv4',name:'VTV4',badge:'Quốc Tế'},
|
| 9 |
-
{id:'vtv5',name:'VTV5',badge:'Miền Nam'},{id:'vtv6',name:'VTV6',badge:'Thanh Niên'},
|
| 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 =====
|
| 24 |
-
(function injectCSS() {
|
| 25 |
-
if (document.getElementById('vtv-pro-css')) return;
|
| 26 |
-
var s = document.createElement('style');
|
| 27 |
-
s.id = 'vtv-pro-css';
|
| 28 |
-
s.textContent = [
|
| 29 |
-
'#vtv-player-section{display:block!important;margin:8px 0 10px;background:linear-gradient(180deg,#0a0e17,#111622);border:1px solid rgba(0,150,255,.15);border-radius:14px;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}',
|
| 30 |
-
'#vtv-player-section .vtv-pro-head{display:flex;align-items:center;gap:10px;padding:10px 14px;background:linear-gradient(90deg,#0a1628,rgba(0,102,204,.08))}',
|
| 31 |
-
'#vtv-player-section .vtv-pro-logo{width:22px;height:22px;border-radius:50%;background:linear-gradient(135deg,#0066cc,#00ccff);display:flex;align-items:center;justify-content:center;font-size:11px;color:#fff;font-weight:800;flex-shrink:0}',
|
| 32 |
-
'#vtv-player-section .vtv-pro-title{font-size:13px;font-weight:700;color:#e8eaed;letter-spacing:.3px}',
|
| 33 |
-
'#vtv-player-section .vtv-pro-live{font-size:9px;font-weight:700;color:#00cc88;background:rgba(0,204,136,.12);padding:2px 8px;border-radius:10px;display:flex;align-items:center;gap:4px;margin-left:auto}',
|
| 34 |
-
'#vtv-player-section .vtv-pro-live-dot{width:5px;height:5px;border-radius:50%;background:#00cc88;animation:vtv-pro-pulse 1.2s infinite}',
|
| 35 |
-
'@keyframes vtv-pro-pulse{0%,100%{opacity:1}50%{opacity:.3}}',
|
| 36 |
-
'#vtv-player-section .vtv-pro-tabs{display:flex;flex-wrap:wrap;gap:2px;padding:6px 10px 8px;overflow-x:auto;scrollbar-width:none}',
|
| 37 |
-
'#vtv-player-section .vtv-pro-tabs::-webkit-scrollbar{display:none}',
|
| 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}',
|
| 44 |
-
'#vtv-player-section .vtv-pro-frame video{position:absolute;inset:0;width:100%;height:100%;background:#000}',
|
| 45 |
-
'#vtv-player-section .vtv-pro-load{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;min-height:200px;gap:12px;color:#9aa0a6;font-size:12px}',
|
| 46 |
-
'#vtv-player-section .vtv-pro-spinner{width:28px;height:28px;border:2px solid rgba(255,255,255,.06);border-top-color:#0066cc;border-radius:50%;animation:vtv-pro-spin .7s linear infinite}',
|
| 47 |
-
'@keyframes vtv-pro-spin{to{transform:rotate(360deg)}}',
|
| 48 |
-
'#vtv-player-section .vtv-pro-err{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;min-height:200px;gap:10px;color:#9aa0a6;font-size:12px;text-align:center;padding:20px}',
|
| 49 |
-
'#vtv-player-section .vtv-pro-err .icon{font-size:28px;opacity:.5}',
|
| 50 |
-
'#vtv-player-section .vtv-pro-err .msg{color:#9aa0a6}',
|
| 51 |
-
'#vtv-player-section .vtv-pro-err button{background:rgba(0,102,204,.15);border:1px solid rgba(0,102,204,.3);color:#8ab4f8;padding:7px 16px;border-radius:8px;font-size:11px;cursor:pointer;transition:all .15s}',
|
| 52 |
-
'#vtv-player-section .vtv-pro-err button:hover{background:rgba(0,102,204,.25)}',
|
| 53 |
-
'#vtv-player-section .vtv-pro-controls{display:flex;align-items:center;gap:6px;padding:6px 12px;background:rgba(0,0,0,.3);border-top:1px solid rgba(255,255,255,.04)}',
|
| 54 |
-
'#vtv-player-section .vtv-pro-btn{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.08);color:#9aa0a6;font-size:10px;padding:4px 10px;border-radius:6px;cursor:pointer;transition:all .15s;display:flex;align-items:center;gap:4px}',
|
| 55 |
-
'#vtv-player-section .vtv-pro-btn:hover{background:rgba(0,102,204,.12);color:#e8eaed}',
|
| 56 |
-
'#vtv-player-section .vtv-pro-switch{font-size:9px;padding:3px 8px;background:rgba(0,150,255,.08);border:1px solid rgba(0,150,255,.2);color:#8ab4f8;border-radius:6px;cursor:pointer;transition:all .15s;display:flex;align-items:center;gap:3px;white-space:nowrap}',
|
| 57 |
-
'#vtv-player-section .vtv-pro-switch:hover{background:rgba(0,150,255,.2)}',
|
| 58 |
-
'#vtv-player-section .vtv-pro-switch.active{background:rgba(0,204,136,.12);border-color:rgba(0,204,136,.3);color:#00cc88}',
|
| 59 |
-
'#vtv-player-section .vtv-pro-epg{border-top:1px solid rgba(255,255,255,.04);background:rgba(0,0,0,.15)}',
|
| 60 |
-
'#vtv-player-section .vtv-pro-epg-hdr{display:flex;align-items:center;justify-content:space-between;padding:8px 12px 4px}',
|
| 61 |
-
'#vtv-player-section .vtv-pro-epg-title{font-size:10px;font-weight:600;color:#9aa0a6;letter-spacing:.5px;text-transform:uppercase}',
|
| 62 |
-
'#vtv-player-section .vtv-pro-epg-time{font-size:9px;color:#5f6368;font-variant-numeric:tabular-nums}',
|
| 63 |
-
'#vtv-player-section .vtv-pro-epg-list{padding:2px 8px 10px;display:flex;flex-direction:column;gap:2px;max-height:200px;overflow-y:auto;scrollbar-width:thin}',
|
| 64 |
-
'#vtv-player-section .vtv-pro-epg-list::-webkit-scrollbar{width:3px}',
|
| 65 |
-
'#vtv-player-section .vtv-pro-epg-list::-webkit-scrollbar-thumb{background:rgba(255,255,255,.08);border-radius:3px}',
|
| 66 |
-
'#vtv-player-section .vtv-pro-epg-row{display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;transition:all .2s;border-left:2px solid transparent;font-size:11px}',
|
| 67 |
-
'#vtv-player-section .vtv-pro-epg-row:hover{background:rgba(255,255,255,.03)}',
|
| 68 |
-
'#vtv-player-section .vtv-pro-epg-row.now{border-left-color:#00cc88;background:rgba(0,204,136,.06)}',
|
| 69 |
-
'#vtv-player-section .vtv-pro-epg-row.passed{opacity:.35}',
|
| 70 |
-
'#vtv-player-section .vtv-pro-epg-row .t{font-size:10px;color:#5f6368;min-width:38px;font-variant-numeric:tabular-nums}',
|
| 71 |
-
'#vtv-player-section .vtv-pro-epg-row.now .t{color:#00cc88;font-weight:600}',
|
| 72 |
-
'#vtv-player-section .vtv-pro-epg-row .n{color:#e8eaed;line-height:1.3}',
|
| 73 |
-
'#vtv-player-section .vtv-pro-epg-row.now .n{color:#fff;font-weight:500}',
|
| 74 |
-
'#vtv-player-section .vtv-pro-epg-row .bar{flex:0 0 2px;height:12px;border-radius:1px;background:rgba(255,255,255,.08)}',
|
| 75 |
-
'#vtv-player-section .vtv-pro-epg-row.now .bar{background:#00cc88}',
|
| 76 |
-
'#vtv-player-section .vtv-pro-epg-row .d{font-size:8px;color:#5f6368;min-width:30px;text-align:right}',
|
| 77 |
-
'#vtv-player-section .vtv-pro-epg-empty{color:#5f6368;font-size:11px;padding:12px 14px;text-align:center}',
|
| 78 |
-
'#vtv-player-section .vtv-pro-epg-load{color:#5f6368;font-size:11px;padding:12px 14px;text-align:center;display:flex;align-items:center;justify-content:center;gap:6px}',
|
| 79 |
-
].join('');
|
| 80 |
-
document.head.appendChild(s);
|
| 81 |
-
})();
|
| 82 |
-
|
| 83 |
-
function formatTime(d) {
|
| 84 |
-
var h = d.getHours(), m = d.getMinutes();
|
| 85 |
-
return (h < 10 ? '0' : '') + h + ':' + (m < 10 ? '0' : '') + m;
|
| 86 |
-
}
|
| 87 |
-
|
| 88 |
-
function getVNTime() {
|
| 89 |
-
var now = new Date();
|
| 90 |
-
try {
|
| 91 |
-
var opts = { timeZone: 'Asia/Ho_Chi_Minh', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, year: 'numeric', month: '2-digit', day: '2-digit' };
|
| 92 |
-
var parts = new Intl.DateTimeFormat('en-CA', opts).formatToParts(now);
|
| 93 |
-
var hh=0,mm=0,ss=0,yy=2026,mo=1,dd=1;
|
| 94 |
-
for (var i = 0; i < parts.length; i++) {
|
| 95 |
-
var p = parts[i];
|
| 96 |
-
if (p.type === 'hour') hh = parseInt(p.value);
|
| 97 |
-
else if (p.type === 'minute') mm = parseInt(p.value);
|
| 98 |
-
else if (p.type === 'second') ss = parseInt(p.value);
|
| 99 |
-
else if (p.type === 'year') yy = parseInt(p.value);
|
| 100 |
-
else if (p.type === 'month') mo = parseInt(p.value);
|
| 101 |
-
else if (p.type === 'day') dd = parseInt(p.value);
|
| 102 |
-
}
|
| 103 |
-
return new Date(yy, mo - 1, dd, hh, mm, ss);
|
| 104 |
-
} catch(e) {
|
| 105 |
-
var utc = new Date(now.getTime() + now.getTimezoneOffset() * 60000);
|
| 106 |
-
return new Date(utc.getTime() + 7 * 3600000);
|
| 107 |
-
}
|
| 108 |
-
}
|
| 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>';
|
| 115 |
-
var xhr = new XMLHttpRequest();
|
| 116 |
-
xhr.open('GET', '/api/vtv/epg/' + chId, true);
|
| 117 |
-
xhr.timeout = 15000;
|
| 118 |
-
xhr.onload = function() {
|
| 119 |
-
try {
|
| 120 |
-
var data = JSON.parse(xhr.responseText);
|
| 121 |
-
_serverDate = data.date || '';
|
| 122 |
-
_epgData = data.programs || [];
|
| 123 |
-
renderEPG();
|
| 124 |
-
} catch(e) { epgEl.innerHTML = '<div class="vtv-pro-epg-empty">Không có lịch phát sóng</div>'; }
|
| 125 |
-
};
|
| 126 |
-
xhr.onerror = xhr.ontimeout = function() {
|
| 127 |
-
epgEl.innerHTML = '<div class="vtv-pro-epg-empty">Không tải được lịch phát sóng</div>';
|
| 128 |
-
};
|
| 129 |
-
xhr.send();
|
| 130 |
-
}
|
| 131 |
-
|
| 132 |
-
function renderEPG() {
|
| 133 |
-
var epgEl = document.getElementById('vtv-pro-epg-body');
|
| 134 |
-
if (!epgEl || !_epgData || !_epgData.length) {
|
| 135 |
-
if (epgEl) epgEl.innerHTML = '<div class="vtv-pro-epg-empty">Chưa có lịch phát sóng</div>';
|
| 136 |
-
return;
|
| 137 |
-
}
|
| 138 |
-
var vnNow = getVNTime();
|
| 139 |
-
var nowStr = formatTime(vnNow);
|
| 140 |
-
_epgData.sort(function(a, b) { return a.time.localeCompare(b.time); });
|
| 141 |
-
for (var i = 0; i < _epgData.length; i++) {
|
| 142 |
-
_epgData[i].now = false;
|
| 143 |
-
var p = _epgData[i], nxt = _epgData[i + 1];
|
| 144 |
-
if (p.time <= nowStr && (!nxt || nxt.time > nowStr)) { p.now = true; p.end_time = nxt ? nxt.time : ''; }
|
| 145 |
-
}
|
| 146 |
-
var html = '', prevDate = '';
|
| 147 |
-
for (var i = 0; i < _epgData.length; i++) {
|
| 148 |
-
var p = _epgData[i];
|
| 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>';
|
| 156 |
-
}
|
| 157 |
-
epgEl.innerHTML = html;
|
| 158 |
-
var nowEl = epgEl.querySelector('.now');
|
| 159 |
-
if (nowEl) nowEl.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
| 160 |
-
}
|
| 161 |
-
|
| 162 |
-
function startEpgRefresh() {
|
| 163 |
-
if (_epgTimer) clearInterval(_epgTimer);
|
| 164 |
-
_epgTimer = setInterval(function() { if (_currentCh) loadEPG(_currentCh); }, 60000);
|
| 165 |
-
}
|
| 166 |
-
|
| 167 |
-
function updateClock() {
|
| 168 |
-
var timeEl = document.getElementById('vtv-pro-time');
|
| 169 |
-
if (timeEl) {
|
| 170 |
-
var vn = getVNTime();
|
| 171 |
-
timeEl.textContent = formatTime(vn) + ' GMT+7' + (_serverDate ? ' - ' + _serverDate : '');
|
| 172 |
-
}
|
| 173 |
-
}
|
| 174 |
-
|
| 175 |
-
// ===== Destroy HLS player =====
|
| 176 |
-
function destroyHLS() {
|
| 177 |
-
if (_hls) {
|
| 178 |
-
try { _hls.destroy(); } catch(e) {}
|
| 179 |
-
_hls = null;
|
| 180 |
-
}
|
| 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 =====
|
| 320 |
-
function buildHTML() {
|
| 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">' +
|
| 328 |
-
'<div class="vtv-pro-head">' +
|
| 329 |
-
'<div class="vtv-pro-logo">V</div>' +
|
| 330 |
-
'<span class="vtv-pro-title">VTV Player</span>' +
|
| 331 |
-
'<span class="vtv-pro-live"><span class="vtv-pro-live-dot"></span>TRỰC TIẾP</span>' +
|
| 332 |
-
'</div>' +
|
| 333 |
-
'<div class="vtv-pro-tabs">'+tabs+'</div>' +
|
| 334 |
-
'<div class="vtv-pro-frame" id="vtv-pro-frame">' +
|
| 335 |
-
'<div class="vtv-pro-load"><div class="vtv-pro-spinner"></div><span>Chọn kênh để xem trực tiếp</span></div>' +
|
| 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>';
|
| 346 |
-
}
|
| 347 |
-
|
| 348 |
-
function inject() {
|
| 349 |
-
var homeEl = document.getElementById('view-home');
|
| 350 |
-
if (!homeEl || document.getElementById('vtv-player-section')) return;
|
| 351 |
-
var featured = document.getElementById('home-featured-area') || homeEl.querySelector('.featured-match, .fm-section, .slider-wrap');
|
| 352 |
-
if (featured && featured.parentNode) featured.insertAdjacentHTML('afterend', buildHTML());
|
| 353 |
-
else if (homeEl.firstChild) homeEl.insertAdjacentHTML('afterbegin', buildHTML());
|
| 354 |
-
setTimeout(function() { if (window._vtvProSwitch) window._vtvProSwitch('vtv6'); }, 500);
|
| 355 |
-
}
|
| 356 |
-
|
| 357 |
-
var orig = window.loadHome;
|
| 358 |
-
if (typeof orig === 'function') {
|
| 359 |
-
window.loadHome = function() {
|
| 360 |
-
var r = orig.apply(this, arguments);
|
| 361 |
-
if (r && typeof r.then === 'function') return r.then(function(v) { setTimeout(inject, 2000); return v; });
|
| 362 |
-
else { setTimeout(inject, 2000); return r; }
|
| 363 |
-
};
|
| 364 |
-
} else {
|
| 365 |
-
(function waitAndInject() {
|
| 366 |
-
if (document.getElementById('view-home') && !document.getElementById('vtv-player-section')) inject();
|
| 367 |
-
else if (!document.getElementById('vtv-player-section')) setTimeout(waitAndInject, 1000);
|
| 368 |
-
})();
|
| 369 |
-
}
|
| 370 |
-
})();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/vtv_init_loader.html
DELETED
|
@@ -1,2 +0,0 @@
|
|
| 1 |
-
<script src="/static/yt_live.js"></script>
|
| 2 |
-
<script src="/static/vtv_init.js"></script>
|
|
|
|
|
|
|
|
|
static/vtv_test2.html
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html>
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>VTV Stream Debug</title>
|
| 7 |
+
<style>
|
| 8 |
+
body{background:#111;color:#eee;font-family:monospace;padding:16px;margin:0}
|
| 9 |
+
h2{color:#5cb87a;font-size:16px}
|
| 10 |
+
video{width:100%;max-width:600px;background:#000;margin:10px 0;display:block}
|
| 11 |
+
#log{background:#1a1a1a;border:1px solid #333;padding:10px;max-height:300px;overflow-y:auto;white-space:pre-wrap;font-size:11px;line-height:1.5}
|
| 12 |
+
.ok{color:#5cb87a}.err{color:#e74c3c}.warn{color:#f0c040}.info{color:#888}
|
| 13 |
+
button{background:#2d8659;border:0;color:#fff;padding:8px 16px;border-radius:8px;cursor:pointer;margin:4px;font-size:12px}
|
| 14 |
+
select{background:#222;color:#eee;border:1px solid #444;padding:6px;border-radius:6px;font-size:12px}
|
| 15 |
+
</style>
|
| 16 |
+
</head>
|
| 17 |
+
<body>
|
| 18 |
+
<h2>📺 VTV Stream Debug Test</h2>
|
| 19 |
+
<div>
|
| 20 |
+
<select id="ch">
|
| 21 |
+
<option value="vtv1">VTV1</option><option value="vtv2">VTV2</option><option value="vtv3">VTV3</option>
|
| 22 |
+
<option value="vtv4">VTV4</option><option value="vtv5">VTV5</option><option value="vtv6" selected>VTV6</option>
|
| 23 |
+
<option value="vtv7">VTV7</option><option value="vtv8">VTV8</option><option value="vtv9">VTV9</option>
|
| 24 |
+
</select>
|
| 25 |
+
<button onclick="startPlay()">▶ Play</button>
|
| 26 |
+
<button onclick="location.reload()">🔄 Reload</button>
|
| 27 |
+
</div>
|
| 28 |
+
<video id="v" controls autoplay muted playsinline></video>
|
| 29 |
+
<div id="log"></div>
|
| 30 |
+
|
| 31 |
+
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.13/dist/hls.min.js"></script>
|
| 32 |
+
<script>
|
| 33 |
+
const LOG=(msg,type)=>{const el=document.getElementById('log');const c=type||'info';el.innerHTML+='<span class="'+c+'">['+new Date().toISOString().substr(11,12)+'] '+msg+'\n</span>';el.scrollTop=el.scrollHeight;};
|
| 34 |
+
const VTVGO={"vtv1":"https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8","vtv2":"https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv2-manifest.m3u8","vtv3":"https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv3-manifest.m3u8","vtv4":"https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv4-manifest.m3u8","vtv5":"https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv5-manifest.m3u8","vtv6":"https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv6-manifest.m3u8","vtv7":"https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv7-manifest.m3u8","vtv8":"https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv8-manifest.m3u8","vtv9":"https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv9-manifest.m3u8"};
|
| 35 |
+
const BASE="https://bep40-vnews.hf.space";
|
| 36 |
+
const video=document.getElementById('v');
|
| 37 |
+
let hls=null;
|
| 38 |
+
|
| 39 |
+
LOG('Page loaded','ok');
|
| 40 |
+
LOG('HLS.js: '+(Hls.version||'unknown')+' | supported: '+Hls.isSupported(),'info');
|
| 41 |
+
LOG('Native HLS: '+video.canPlayType('application/vnd.apple.mpegurl'),'info');
|
| 42 |
+
|
| 43 |
+
function startPlay(){
|
| 44 |
+
const ch=document.getElementById('ch').value;
|
| 45 |
+
const directUrl=VTVGO[ch];
|
| 46 |
+
const proxyUrl=BASE+'/api/proxy/m3u8/vtv?url='+encodeURIComponent(directUrl);
|
| 47 |
+
LOG('\n--- Playing '+ch+' ---','info');
|
| 48 |
+
LOG('Proxy: '+proxyUrl,'info');
|
| 49 |
+
if(hls){hls.destroy();hls=null;}
|
| 50 |
+
video.pause();video.src='';
|
| 51 |
+
|
| 52 |
+
if(!Hls.isSupported()){
|
| 53 |
+
LOG('Using native HLS','warn');
|
| 54 |
+
video.src=proxyUrl;
|
| 55 |
+
video.play().then(()=>LOG('Native play OK','ok')).catch(e=>LOG('Native play: '+e,'err'));
|
| 56 |
+
return;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
hls=new Hls({enableWorker:false,lowLatencyMode:true,startLevel:-1,capLevelToPlayerSize:true,maxBufferLength:6,debug:true});
|
| 60 |
+
hls.attachMedia(video);
|
| 61 |
+
LOG('attachMedia done','info');
|
| 62 |
+
|
| 63 |
+
hls.on(Hls.Events.MEDIA_ATTACHED,()=>{
|
| 64 |
+
LOG('MEDIA_ATTACHED','ok');
|
| 65 |
+
hls.loadSource(proxyUrl);
|
| 66 |
+
LOG('loadSource: '+proxyUrl.substring(0,60),'info');
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
hls.on(Hls.Events.MANIFEST_LOADING,()=>LOG('MANIFEST_LOADING','info'));
|
| 70 |
+
hls.on(Hls.Events.MANIFEST_LOADED,()=>LOG('MANIFEST_LOADED','ok'));
|
| 71 |
+
hls.on(Hls.Events.MANIFEST_PARSED,(ev,d)=>{
|
| 72 |
+
LOG('MANIFEST_PARSED: '+d.levels.length+' levels, '+(d.levels[0]?d.levels[0].width+'x'+d.levels[0].height:'?'),'ok');
|
| 73 |
+
const res=d.levels.map(l=>l.width+'x'+l.height).join(', ');
|
| 74 |
+
LOG('Available: '+res,'info');
|
| 75 |
+
});
|
| 76 |
+
|
| 77 |
+
hls.on(Hls.Events.FRAG_LOADED,(ev,d)=>{
|
| 78 |
+
const sz=d.frag&&d.frag.data&&d.frag.data.length;
|
| 79 |
+
LOG('FRAG_LOADED #'+d.frag.sn+' size='+sz,'ok');
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
hls.on(Hls.Events.BUFFER_APPENDED,()=>{
|
| 83 |
+
const buf=video.buffered;
|
| 84 |
+
let bufInfo='';
|
| 85 |
+
if(buf.length>0)bufInfo='buf[0]='+buf.start(0).toFixed(1)+'-'+buf.end(0).toFixed(1);
|
| 86 |
+
const dur=video.duration;
|
| 87 |
+
const ct=video.currentTime;
|
| 88 |
+
LOG('BUFFER_APPENDED '+bufInfo+' dur='+(isFinite(dur)?dur.toFixed(1):'live')+' ct='+ct.toFixed(1),'ok');
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
hls.on(Hls.Events.ERROR,(ev,data)=>{
|
| 92 |
+
LOG('ERROR '+data.type+' '+data.details+' fatal='+data.fatal,'err');
|
| 93 |
+
if(data.url)LOG(' URL: '+data.url.substring(0,80),'err');
|
| 94 |
+
if(data.fatal){
|
| 95 |
+
LOG('Fatal error - destroying HLS','err');
|
| 96 |
+
hls.destroy();hls=null;
|
| 97 |
+
}
|
| 98 |
+
});
|
| 99 |
+
|
| 100 |
+
video.addEventListener('playing',()=>LOG('🎬 VIDEO PLAYING!','ok'),{once:true});
|
| 101 |
+
video.addEventListener('loadeddata',()=>LOG('LOADED DATA','ok'),{once:true});
|
| 102 |
+
video.addEventListener('error',(e)=>LOG('VIDEO ERROR: '+video.error.code+' '+video.error.message,'err'),{once:true});
|
| 103 |
+
|
| 104 |
+
let checkCount=0;
|
| 105 |
+
const checkInterval=setInterval(()=>{
|
| 106 |
+
checkCount++;
|
| 107 |
+
if(checkCount>20){clearInterval(checkInterval);return;}
|
| 108 |
+
const state={ready:video.readyState,networkState:video.networkState,w:video.videoWidth,h:video.videoHeight,paused:video.paused,muted:video.muted};
|
| 109 |
+
LOG('State #'+checkCount+': '+JSON.stringify(state),state.w>0?'ok':'warn');
|
| 110 |
+
if(state.w>0){LOG('🎉 VIDEO HAS FRAMES '+state.w+'x'+state.h,'ok');clearInterval(checkInterval);}
|
| 111 |
+
if(video.error){LOG('Error: '+video.error.message,'err');clearInterval(checkInterval);}
|
| 112 |
+
},1000);
|
| 113 |
+
}
|
| 114 |
+
</script>
|
| 115 |
+
</body>
|
| 116 |
+
</html>
|
static/wc2026_v2.js
CHANGED
|
@@ -1,54 +1,13 @@
|
|
| 1 |
-
// === WORLD CUP 2026 - Highlights
|
| 2 |
let _wc2026Data=null;let _wcRefreshInterval=null;
|
| 3 |
-
let _wcShortsData=[]; // YouTube shorts: VTV Nam Bộ + WC moments
|
| 4 |
const WC_TEAMS={'Mexico':'🇲🇽 Mexico','Colombia':'🇨🇴 Colombia','USA':'🇺🇸 Mỹ','United States':'🇺🇸 Mỹ','Brazil':'🇧🇷 Brazil','Argentina':'🇦🇷 Argentina','Germany':'🇩🇪 Đức','France':'🇫🇷 Pháp','Spain':'🇪🇸 Tây Ban Nha','England':'🏴 Anh','Portugal':'🇵🇹 Bồ Đào Nha','Netherlands':'🇳🇱 Hà Lan','Italy':'🇮🇹 Ý','Belgium':'🇧🇪 Bỉ','Croatia':'🇭🇷 Croatia','Uruguay':'🇺🇾 Uruguay','Japan':'🇯🇵 Nhật Bản','South Korea':'🇰🇷 Hàn Quốc','Korea Republic':'🇰🇷 Hàn Quốc','Australia':'🇦🇺 Úc','Saudi Arabia':'🇸🇦 Ả Rập Xê Út','Iran':'🇮🇷 Iran','Qatar':'🇶🇦 Qatar','Canada':'🇨🇦 Canada','Morocco':'🇲🇦 Morocco','Senegal':'🇸🇳 Senegal','Ecuador':'🇪🇨 Ecuador','Serbia':'🇷🇸 Serbia','Switzerland':'🇨🇭 Thụy Sĩ','Denmark':'🇩🇰 Đan Mạch','Poland':'🇵🇱 Ba Lan','Turkey':'🇹🇷 Thổ Nhĩ Kỳ','Türkiye':'🇹🇷 Thổ Nhĩ Kỳ','Ukraine':'🇺🇦 Ukraine','Egypt':'🇪🇬 Ai Cập','Nigeria':'🇳🇬 Nigeria','Cameroon':'🇨🇲 Cameroon','Ghana':'🇬🇭 Ghana','Tunisia':'🇹🇳 Tunisia','Algeria':'🇩🇿 Algeria','South Africa':'🇿🇦 Nam Phi','Indonesia':'🇮🇩 Indonesia','Vietnam':'🇻🇳 Việt Nam','China PR':'🇨🇳 Trung Quốc','New Zealand':'🇳🇿 New Zealand','Panama':'🇵🇦 Panama','Costa Rica':'🇨🇷 Costa Rica','Jamaica':'🇯🇲 Jamaica','Honduras':'🇭🇳 Honduras','Paraguay':'🇵🇾 Paraguay','Bolivia':'🇧🇴 Bolivia','Chile':'🇨🇱 Chile','Peru':'🇵🇪 Peru','Venezuela':'🇻🇪 Venezuela','Norway':'🇳🇴 Na Uy','Sweden':'🇸🇪 Thụy Điển','Austria':'🇦🇹 Áo','Scotland':'🏴 Scotland','Wales':'🏴 Xứ Wales','Ireland':'🇮🇪 Ireland','Romania':'🇷🇴 Romania','Hungary':'🇭🇺 Hungary','Greece':'🇬🇷 Hy Lạp','Uzbekistan':'🇺🇿 Uzbekistan','Iraq':'🇮🇶 Iraq','TBD':'🏳️ TBD'};
|
| 5 |
function _t(n){return WC_TEAMS[n]||WC_TEAMS[(n||'').trim()]||'🏴 '+n;}
|
| 6 |
|
| 7 |
-
const WC_CSS=`<style>
|
| 8 |
-
.wc-bxh{max-height:460px;overflow-y:auto;padding:2px;display:block}
|
| 9 |
-
.wc-bxh,.wc-bxh *{box-sizing:border-box!important;float:none!important;max-width:100%!important}
|
| 10 |
-
.wc-bxh script,.wc-bxh style,.wc-bxh ins,.wc-bxh iframe,.wc-bxh .info,.wc-bxh .btns,.wc-bxh .form{display:none!important}
|
| 11 |
-
.wc-bxh .title-table,.wc-bxh h6{display:block!important;background:#0b1a2a!important;color:#f0c040!important;font-size:11px!important;font-weight:800!important;padding:6px 8px!important;margin:8px 0 0!important;border-radius:5px 5px 0 0!important;border-bottom:1px solid #1a3a5a!important}
|
| 12 |
-
.wc-bxh .bxh-head{display:flex!important;align-items:center!important;padding:4px 6px!important;background:#0a1320!important;color:#6a9fca!important;font-size:9px!important;font-weight:700!important;border-bottom:1px solid #1a3a5a!important}
|
| 13 |
-
.wc-bxh .bxh-head .h-team{flex:1!important;text-align:left!important}
|
| 14 |
-
.wc-bxh .bxh-head .h-stat{flex:0 0 26px!important;text-align:center!important}
|
| 15 |
-
.wc-bxh .bxh-head .h-pts{flex:0 0 30px!important;text-align:center!important;color:#f0c040!important}
|
| 16 |
-
.wc-bxh .item{display:flex!important;align-items:center!important;padding:5px 6px!important;border-bottom:1px solid #10202f!important;background:transparent!important}
|
| 17 |
-
.wc-bxh .item:nth-child(even){background:#0a1320!important}
|
| 18 |
-
.wc-bxh .item .team{flex:1!important;display:flex!important;align-items:center!important;gap:5px!important;min-width:0!important;text-align:left!important}
|
| 19 |
-
.wc-bxh .item .team>span{flex:0 0 16px!important;color:#6a9fca!important;font-size:10px!important;text-align:center!important}
|
| 20 |
-
.wc-bxh .item .team a{flex:1!important;display:flex!important;align-items:center!important;gap:5px!important;min-width:0!important;color:#eee!important;text-decoration:none!important}
|
| 21 |
-
.wc-bxh .item .team a p{font-size:11px!important;color:#eee!important;white-space:nowrap!important;overflow:hidden!important;text-overflow:ellipsis!important;margin:0!important}
|
| 22 |
-
.wc-bxh .item .copy{display:flex!important;align-items:center!important;flex:0 0 auto!important}
|
| 23 |
-
.wc-bxh .item .copy>p{flex:0 0 26px!important;text-align:center!important;font-size:10px!important;color:#ccc!important;margin:0!important}
|
| 24 |
-
.wc-bxh .item .copy>p:last-of-type,.wc-bxh .item .copy>p:nth-last-child(2){flex:0 0 30px!important}
|
| 25 |
-
.wc-bxh .item .copy>p strong{color:#f0c040!important;font-weight:800!important}
|
| 26 |
-
.wc-bxh img{width:18px!important;height:18px!important;object-fit:contain!important;vertical-align:middle!important;display:inline-block!important;flex:0 0 18px!important}
|
| 27 |
-
.wc-bxh strong{color:#f0c040!important}
|
| 28 |
-
.wc-bxh a{color:#8ab4d8!important;text-decoration:none!important}
|
| 29 |
-
.wc-bxh table{width:100%!important;border-collapse:collapse!important;font-size:11px!important;display:table!important}
|
| 30 |
-
.wc-bxh th{background:#0b1a2a!important;color:#6a9fca!important;padding:5px 3px!important;font-size:9px!important;text-align:center!important}
|
| 31 |
-
.wc-bxh td{padding:4px 3px!important;border-bottom:1px solid #0d1a2a!important;font-size:10px!important;color:#ddd!important}
|
| 32 |
-
/* WC Shorts slider */
|
| 33 |
-
.wc-shorts-slider{margin:8px 0}
|
| 34 |
-
.wc-shorts-track{display:flex;overflow-x:auto;gap:8px;padding:4px 0 10px;scrollbar-width:none}
|
| 35 |
-
.wc-shorts-track::-webkit-scrollbar{display:none}
|
| 36 |
-
.wc-shorts-item{flex:0 0 130px;cursor:pointer;border-radius:8px;overflow:hidden;background:#0a1520;border:1px solid #1a3a5a;transition:border-color .2s}
|
| 37 |
-
.wc-shorts-item:hover{border-color:#f0c040}
|
| 38 |
-
.wc-shorts-thumb{position:relative;width:100%;aspect-ratio:9/16;background:#1a2a3a;overflow:hidden}
|
| 39 |
-
.wc-shorts-thumb img{width:100%;height:100%;object-fit:cover}
|
| 40 |
-
.wc-shorts-thumb .card-play{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:32px;height:32px;border-radius:50%;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;color:#fff;font-size:14px}
|
| 41 |
-
.wc-shorts-info{padding:4px 6px}
|
| 42 |
-
.wc-shorts-badge{font-size:8px;font-weight:700;padding:1px 4px;border-radius:3px;display:inline-block;margin-bottom:2px}
|
| 43 |
-
.wc-shorts-title{font-size:10px;color:#ccc;line-height:1.2;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
| 44 |
-
.wc-shorts-viewall{flex:0 0 100px;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#0b2e4a,#1a3a5a);border-radius:8px;cursor:pointer;border:1px dashed #2a5a8a;color:#8ab4d8;font-size:11px;font-weight:700;text-align:center;padding:8px}
|
| 45 |
-
</style>`;
|
| 46 |
-
|
| 47 |
function switchWCTab(tab){
|
| 48 |
document.querySelectorAll('.wc-tab').forEach(t=>t.classList.remove('active'));
|
| 49 |
-
|
|
|
|
| 50 |
const el=document.getElementById('wc-content');if(!el)return;
|
| 51 |
-
if(!_wc2026Data&&tab!=='highlights'){el.innerHTML='<div class="loading">Đang tải...</div>';return;}
|
| 52 |
if(tab==='news')renderWCNews(el);
|
| 53 |
else if(tab==='fixtures')renderWCFixtures(el);
|
| 54 |
else if(tab==='standings')renderWCStandings(el);
|
|
@@ -56,8 +15,8 @@ function switchWCTab(tab){
|
|
| 56 |
else if(tab==='stats')renderWCStats(el);
|
| 57 |
}
|
| 58 |
|
| 59 |
-
// === NEWS ===
|
| 60 |
function renderWCNews(el){
|
|
|
|
| 61 |
const news=_wc2026Data.news||[];const road=_wc2026Data.road||[];
|
| 62 |
const combined=[];let ni=0,ri=0;
|
| 63 |
while(ni<news.length||ri<road.length){if(ni<news.length)combined.push({...news[ni++],_type:'news'});if(ri<road.length)combined.push({...road[ri++],_type:'road'});}
|
|
@@ -71,8 +30,8 @@ function renderWCNews(el){
|
|
| 71 |
combined.slice(0,20).forEach((a,i)=>{if(a.img)return;fetch('/api/article?url='+encodeURIComponent(a.link)).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){const x=document.getElementById('wcimg-'+i);if(x)x.innerHTML=`<img src="${esc(d.og_image||d.img)}" loading="lazy" onerror="this.parentElement.innerHTML=''">`;}}).catch(()=>{});});
|
| 72 |
}
|
| 73 |
|
| 74 |
-
// === FIXTURES ===
|
| 75 |
function renderWCFixtures(el){
|
|
|
|
| 76 |
const d=_wc2026Data.fixtures||{};const matches=d.matches||[];
|
| 77 |
if(!matches.length){el.innerHTML='<div class="loading">Đang tải lịch thi đấu...</div>';return;}
|
| 78 |
const sorted=[...matches].sort((a,b)=>(a.date_utc||'').localeCompare(b.date_utc||''));
|
|
@@ -87,231 +46,64 @@ function renderWCFixtures(el){
|
|
| 87 |
h+='</div>';el.innerHTML=h;
|
| 88 |
}
|
| 89 |
|
| 90 |
-
// === BXH ===
|
| 91 |
function renderWCStandings(el){
|
|
|
|
| 92 |
const d=_wc2026Data.standings||{};
|
| 93 |
if(d.html&&d.html.length>100){
|
| 94 |
-
el.innerHTML=
|
| 95 |
-
const box=el.querySelector('.wc-bxh');
|
| 96 |
-
if(box){
|
| 97 |
-
box.querySelectorAll('.title-table, h6').forEach(t=>{
|
| 98 |
-
if(t.nextElementSibling && t.nextElementSibling.classList.contains('bxh-head'))return;
|
| 99 |
-
const head=document.createElement('div');
|
| 100 |
-
head.className='bxh-head';
|
| 101 |
-
head.innerHTML='<span class="h-team">Đội</span><span class="h-stat">Tr</span><span class="h-stat">T</span><span class="h-stat">H</span><span class="h-stat">B</span><span class="h-stat">BT</span><span class="h-stat">BB</span><span class="h-pts">HS</span><span class="h-pts">Đ</span>';
|
| 102 |
-
t.insertAdjacentElement('afterend',head);
|
| 103 |
-
});
|
| 104 |
-
}
|
| 105 |
el.querySelectorAll('a').forEach(a=>a.addEventListener('click',e=>e.preventDefault()));
|
| 106 |
return;
|
| 107 |
}
|
| 108 |
-
|
| 109 |
-
const gm=matches.filter(m=>m.group&&m.group.toLowerCase().includes('group'));
|
| 110 |
-
if(!gm.length){el.innerHTML='<div class="loading">BXH cập nhật khi giải bắt đầu 11/06/2026<br><small style="color:#6a9fca">🔴 Livescore tự động</small></div>';return;}
|
| 111 |
-
const groups={};gm.forEach(m=>{const g=m.group;if(!groups[g])groups[g]={};[m.home,m.away].forEach(t=>{if(!groups[g][t])groups[g][t]={p:0,w:0,d:0,l:0,gf:0,ga:0,pts:0};});if(m.home_score!=null&&m.away_score!=null){const hs=+m.home_score,as=+m.away_score;if(!isNaN(hs)&&!isNaN(as)){groups[g][m.home].p++;groups[g][m.away].p++;groups[g][m.home].gf+=hs;groups[g][m.home].ga+=as;groups[g][m.away].gf+=as;groups[g][m.away].ga+=hs;if(hs>as){groups[g][m.home].w++;groups[g][m.home].pts+=3;groups[g][m.away].l++;}else if(hs<as){groups[g][m.away].w++;groups[g][m.away].pts+=3;groups[g][m.home].l++;}else{groups[g][m.home].d++;groups[g][m.home].pts++;groups[g][m.away].d++;groups[g][m.away].pts++;}}}});
|
| 112 |
-
let h='<div style="max-height:450px;overflow-y:auto;padding:2px"><div style="font-size:10px;color:#6a9fca;text-align:center;padding:3px">🏆 BXH World Cup 2026 · 🔴 Livescore</div>';
|
| 113 |
-
Object.keys(groups).sort().forEach(g=>{const teams=Object.entries(groups[g]).map(([n,s])=>({name:n,...s,gd:s.gf-s.ga})).sort((a,b)=>b.pts-a.pts||b.gd-a.gd);h+=`<div style="font-size:11px;font-weight:800;color:#8ab4d8;padding:6px 4px 3px;margin-top:4px;border-bottom:1px solid #1a3a5a">${esc(g)}</div><table style="width:100%;border-collapse:collapse;font-size:10px;margin:2px 0"><tr style="background:#0b1a2a"><th style="text-align:left;padding:4px;color:#6a9fca">Đội</th><th style="color:#6a9fca;padding:3px">Tr</th><th style="color:#6a9fca;padding:3px">T</th><th style="color:#6a9fca;padding:3px">H</th><th style="color:#6a9fca;padding:3px">B</th><th style="color:#6a9fca;padding:3px">HS</th><th style="color:#f0c040;padding:3px;font-weight:800">Đ</th></tr>`;teams.forEach((t,i)=>{h+=`<tr style="background:${i<2?'#0a1a10':'transparent'}"><td style="padding:3px;color:#eee;white-space:nowrap">${_t(t.name)}</td><td style="text-align:center;padding:3px;color:#999">${t.p}</td><td style="text-align:center;color:#4ade80">${t.w}</td><td style="text-align:center;color:#fbbf24">${t.d}</td><td style="text-align:center;color:#f87171">${t.l}</td><td style="text-align:center;color:${t.gd>=0?'#4ade80':'#f87171'}">${t.gd>0?'+':''}${t.gd}</td><td style="text-align:center;color:#f0c040;font-weight:800">${t.pts}</td></tr>`;});h+='</table>';});
|
| 114 |
-
h+='</div>';el.innerHTML=h;
|
| 115 |
}
|
| 116 |
|
| 117 |
-
// === STATS ===
|
| 118 |
function renderWCStats(el){
|
|
|
|
| 119 |
const d=_wc2026Data.stats||{};
|
| 120 |
-
if(d.html&&d.html.length>100){el.innerHTML=
|
| 121 |
else el.innerHTML='<div class="loading">Thống kê cập nhật khi giải bắt đầu<br><small style="color:#6a9fca">Vua phá lưới · Kiến tạo · Thẻ phạt</small></div>';
|
| 122 |
}
|
| 123 |
|
| 124 |
-
// === HIGHLIGHTS - WC match highlights from xemlaibongda.top ===
|
| 125 |
async function renderWCHighlights(el){
|
| 126 |
el.innerHTML='<div class="loading">Đang tải highlight World Cup...</div>';
|
| 127 |
try{
|
| 128 |
const wcRes = await fetch('/api/proxy/xlb?path=the-gioi/world-cup', {signal: AbortSignal.timeout(15000)}).then(r=>r.json()).catch(()=>({videos:[]}));
|
| 129 |
let wcVids = (wcRes.videos||[]);
|
| 130 |
-
// Deduplicate by link
|
| 131 |
const seen = new Map();
|
| 132 |
wcVids.forEach((v,i)=>{if(!seen.has(v.link)) seen.set(v.link, {...v, _li: seen.size});});
|
| 133 |
wcVids = Array.from(seen.values());
|
| 134 |
-
|
| 135 |
-
if(!wcVids.length){
|
| 136 |
-
el.innerHTML='<div class="loading">Chưa có highlight World Cup<br><small style="color:#6a9fca">Highlight sẽ xuất hiện khi giải diễn ra</small></div>';
|
| 137 |
-
return;
|
| 138 |
-
}
|
| 139 |
-
|
| 140 |
let h='<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:6px;max-height:450px;overflow-y:auto;padding:4px">';
|
| 141 |
wcVids.forEach((v,i)=>{
|
| 142 |
h+=`<div style="cursor:pointer;border-radius:6px;overflow:hidden;background:#0a1520" onclick="openHighlightFeed('world-cup',${v._li},'${esc(v.link)}')">`;
|
| 143 |
h+=`<div style="position:relative;aspect-ratio:16/9;background:#1a2a3a">${v.img?`<img src="${esc(v.img)}" loading="lazy" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none'">`:''}<div class="card-play">▶</div></div>`;
|
| 144 |
-
h+=`<div style="padding:4px 6px"><span style="font-size:8px;color:#f0c040">🌍 World Cup</span><div style="font-size:10px;color:#ccc;line-height:1.2;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden">${esc(v.title)}</div></div>`;
|
| 145 |
-
h+='</div>';
|
| 146 |
});
|
| 147 |
h+='</div>';el.innerHTML=h;
|
| 148 |
-
}catch(e){
|
| 149 |
-
el.innerHTML='<div class="loading">Lỗi tải highlight: '+esc(e.message)+'</div>';
|
| 150 |
-
}
|
| 151 |
-
}
|
| 152 |
-
|
| 153 |
-
// === WC SHORTS SLIDE — shown inside WC section ===
|
| 154 |
-
function renderWCShortsSlide(container){
|
| 155 |
-
if(!_wcShortsData || !_wcShortsData.length) return;
|
| 156 |
-
const wrap = document.createElement('div');
|
| 157 |
-
wrap.className = 'slider-wrap wc-shorts-slider';
|
| 158 |
-
let h = '<div class="slider-header"><span class="slider-label">📱 Shorts World Cup</span><span style="font-size:10px;color:#8ab4d8;cursor:pointer" onclick="openWCShortsFeed(0)">Xem tất cả →</span></div>';
|
| 159 |
-
h += '<div class="wc-shorts-track">';
|
| 160 |
-
_wcShortsData.slice(0,10).forEach((s,i)=>{
|
| 161 |
-
const badge = s.channel==='vtvnambo' ? 'VTV Nam Bộ' : '⚽ WC';
|
| 162 |
-
const badgeColor = s.channel==='vtvnambo' ? '#0b6bcb' : '#f26522';
|
| 163 |
-
h += `<div class="wc-shorts-item" onclick="openWCShortsFeed(${i})">`;
|
| 164 |
-
h += `<div class="wc-shorts-thumb">${s.img?`<img src="${esc(s.img)}" loading="lazy" onerror="this.style.display='none'">`:''}<div class="card-play">▶</div></div>`;
|
| 165 |
-
h += `<div class="wc-shorts-info"><span class="wc-shorts-badge" style="background:${badgeColor};color:#fff">${badge}</span><div class="wc-shorts-title">${esc(s.title)}</div></div>`;
|
| 166 |
-
h += '</div>';
|
| 167 |
-
});
|
| 168 |
-
// "View all" card
|
| 169 |
-
h += `<div class="wc-shorts-viewall" onclick="openWCShortsFeed(0)">📱 Xem tất cả<br><small style="font-size:9px;color:#6a9fca">${_wcShortsData.length} shorts</small></div>`;
|
| 170 |
-
h += '</div>';
|
| 171 |
-
wrap.innerHTML = h;
|
| 172 |
-
container.appendChild(wrap);
|
| 173 |
-
}
|
| 174 |
-
|
| 175 |
-
// === WC SHORTS TIKTOK FEED — full-screen vertical scroll, continuous autoplay ===
|
| 176 |
-
async function openWCShortsFeed(startIdx){
|
| 177 |
-
showView('view-tiktok');
|
| 178 |
-
const el = document.getElementById('view-tiktok');
|
| 179 |
-
el.innerHTML = '<div class="loading">Đang tải shorts...</div>';
|
| 180 |
-
|
| 181 |
-
// Fetch shorts if not loaded
|
| 182 |
-
if(!_wcShortsData || !_wcShortsData.length){
|
| 183 |
-
try{
|
| 184 |
-
const r = await fetch('/api/shorts?channel=vtvnambo&count=30');
|
| 185 |
-
_wcShortsData = await r.json() || [];
|
| 186 |
-
}catch(e){
|
| 187 |
-
_wcShortsData = [];
|
| 188 |
-
}
|
| 189 |
-
}
|
| 190 |
-
|
| 191 |
-
if(!_wcShortsData.length){
|
| 192 |
-
el.innerHTML = '<div class="loading">Chưa có shorts</div>';
|
| 193 |
-
return;
|
| 194 |
-
}
|
| 195 |
-
|
| 196 |
-
// Rotate so startIdx is first
|
| 197 |
-
const ordered = startIdx > 0 ? [..._wcShortsData.slice(startIdx), ..._wcShortsData.slice(0,startIdx)] : _wcShortsData;
|
| 198 |
-
|
| 199 |
-
let h = `<button class="back-btn" onclick="switchCat('home')">← World Cup Shorts</button>`;
|
| 200 |
-
h += '<div class="tiktok-container"><div class="tiktok-feed" id="wc-shorts-feed">';
|
| 201 |
-
|
| 202 |
-
ordered.forEach((s,i)=>{
|
| 203 |
-
const id = s.id || '';
|
| 204 |
-
const src = `https://www.youtube.com/embed/${id}?autoplay=0&rel=0&playsinline=1&mute=1`;
|
| 205 |
-
const vtag = `<iframe data-yt-src="${src}" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture"></iframe>`;
|
| 206 |
-
const badge = s.channel==='vtvnambo' ? 'VTV Nam Bộ' : '⚽ WC';
|
| 207 |
-
const badgeClass = s.channel==='vtvnambo' ? 'badge-wc' : 'badge-fpt';
|
| 208 |
-
const videoId = 'wcshort-' + id;
|
| 209 |
-
h += buildTikTokSlide({vtag,title:s.title,badge,badgeClass,videoId,idx:i,total:ordered.length,shareUrl:'https://youtube.com/shorts/'+id});
|
| 210 |
-
});
|
| 211 |
-
|
| 212 |
-
h += '</div></div>';
|
| 213 |
-
el.innerHTML = h;
|
| 214 |
-
|
| 215 |
-
// Init continuous autoplay feed
|
| 216 |
-
initWCShortsFeed();
|
| 217 |
-
}
|
| 218 |
-
|
| 219 |
-
function initWCShortsFeed(){
|
| 220 |
-
const feed = document.getElementById('wc-shorts-feed');
|
| 221 |
-
if(!feed) return;
|
| 222 |
-
const slides = feed.querySelectorAll('.tiktok-slide');
|
| 223 |
-
let cur = -1;
|
| 224 |
-
let isManualScroll = false;
|
| 225 |
-
let manualTimer = null;
|
| 226 |
-
|
| 227 |
-
function activate(i){
|
| 228 |
-
if(i === cur) return;
|
| 229 |
-
slides.forEach((sl,idx)=>{
|
| 230 |
-
const fr = sl.querySelector('iframe');
|
| 231 |
-
if(idx === i){
|
| 232 |
-
// Load and play
|
| 233 |
-
if(fr && !fr.src && fr.dataset.ytSrc) fr.src = fr.dataset.ytSrc;
|
| 234 |
-
// For continuous feel: set autoplay=1 on activate
|
| 235 |
-
if(fr && fr.src && !fr.src.includes('autoplay=1')){
|
| 236 |
-
fr.src = fr.src.replace('autoplay=0','autoplay=1');
|
| 237 |
-
}
|
| 238 |
-
const vid = sl.dataset.vid;
|
| 239 |
-
if(vid && !sl._viewed){ sl._viewed = true; doInteract(vid,'view'); }
|
| 240 |
-
} else {
|
| 241 |
-
// Pause: reload with autoplay=0 to stop
|
| 242 |
-
if(fr && fr.src){
|
| 243 |
-
fr.src = fr.src.replace('autoplay=1','autoplay=0');
|
| 244 |
-
}
|
| 245 |
-
}
|
| 246 |
-
});
|
| 247 |
-
cur = i;
|
| 248 |
-
}
|
| 249 |
-
|
| 250 |
-
// Scroll-based activation with snap
|
| 251 |
-
let scrollTimer;
|
| 252 |
-
feed.addEventListener('scroll', ()=>{
|
| 253 |
-
isManualScroll = true;
|
| 254 |
-
clearTimeout(manualTimer);
|
| 255 |
-
manualTimer = setTimeout(()=>{ isManualScroll = false; }, 2000);
|
| 256 |
-
clearTimeout(scrollTimer);
|
| 257 |
-
scrollTimer = setTimeout(()=>{
|
| 258 |
-
const rect = feed.getBoundingClientRect();
|
| 259 |
-
const ctr = rect.top + rect.height / 2;
|
| 260 |
-
let best = -1, bestD = 1e9;
|
| 261 |
-
slides.forEach((sl,i)=>{
|
| 262 |
-
const d = Math.abs(sl.getBoundingClientRect().top + sl.getBoundingClientRect().height/2 - ctr);
|
| 263 |
-
if(d < bestD){ bestD = d; best = i; }
|
| 264 |
-
});
|
| 265 |
-
if(best >= 0) activate(best);
|
| 266 |
-
}, 100);
|
| 267 |
-
});
|
| 268 |
-
|
| 269 |
-
// Auto-advance every 8 seconds for continuous playback
|
| 270 |
-
let autoTimer = setInterval(()=>{
|
| 271 |
-
if(isManualScroll) return;
|
| 272 |
-
if(cur < slides.length - 1){
|
| 273 |
-
const nextSl = slides[cur + 1];
|
| 274 |
-
if(nextSl){
|
| 275 |
-
nextSl.scrollIntoView({behavior:'smooth', block:'start'});
|
| 276 |
-
// activate will be called by scroll event
|
| 277 |
-
}
|
| 278 |
-
} else {
|
| 279 |
-
// Loop back to first
|
| 280 |
-
slides[0].scrollIntoView({behavior:'smooth', block:'start'});
|
| 281 |
-
}
|
| 282 |
-
}, 8000);
|
| 283 |
-
|
| 284 |
-
// Store timer for cleanup
|
| 285 |
-
feed._autoTimer = autoTimer;
|
| 286 |
-
|
| 287 |
-
// Activate first after short delay
|
| 288 |
-
setTimeout(() => {
|
| 289 |
-
if(slides.length > 0){
|
| 290 |
-
slides[0].scrollIntoView({behavior:'start'});
|
| 291 |
-
activate(0);
|
| 292 |
-
}
|
| 293 |
-
}, 300);
|
| 294 |
-
|
| 295 |
-
// Cleanup on leave
|
| 296 |
-
const origSwitchCat = window.switchCat;
|
| 297 |
-
if(origSwitchCat && !origSwitchCat.__wcCleaned){
|
| 298 |
-
window.switchCat = function(id){
|
| 299 |
-
if(feed._autoTimer) clearInterval(feed._autoTimer);
|
| 300 |
-
return origSwitchCat.apply(this, arguments);
|
| 301 |
-
};
|
| 302 |
-
window.switchCat.__wcCleaned = true;
|
| 303 |
-
}
|
| 304 |
}
|
| 305 |
|
| 306 |
-
// === LIVE AUTO-REFRESH (90s) ===
|
| 307 |
function startWCLiveRefresh(){
|
| 308 |
if(_wcRefreshInterval)clearInterval(_wcRefreshInterval);
|
| 309 |
_wcRefreshInterval=setInterval(async()=>{
|
| 310 |
if(!document.getElementById('view-home')?.classList.contains('active'))return;
|
| 311 |
try{
|
| 312 |
const data=await fetch('/api/wc2026').then(r=>r.json()).catch(()=>null);
|
| 313 |
-
if(data){
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
}catch(e){}
|
| 315 |
},90000);
|
| 316 |
}
|
| 317 |
-
setTimeout(startWCLiveRefresh,5000);
|
|
|
|
| 1 |
+
// === WORLD CUP 2026 - Highlights (Shorts now in separate slide above) ===
|
| 2 |
let _wc2026Data=null;let _wcRefreshInterval=null;
|
|
|
|
| 3 |
const WC_TEAMS={'Mexico':'🇲🇽 Mexico','Colombia':'🇨🇴 Colombia','USA':'🇺🇸 Mỹ','United States':'🇺🇸 Mỹ','Brazil':'🇧🇷 Brazil','Argentina':'🇦🇷 Argentina','Germany':'🇩🇪 Đức','France':'🇫🇷 Pháp','Spain':'🇪🇸 Tây Ban Nha','England':'🏴 Anh','Portugal':'🇵🇹 Bồ Đào Nha','Netherlands':'🇳🇱 Hà Lan','Italy':'🇮🇹 Ý','Belgium':'🇧🇪 Bỉ','Croatia':'🇭🇷 Croatia','Uruguay':'🇺🇾 Uruguay','Japan':'🇯🇵 Nhật Bản','South Korea':'🇰🇷 Hàn Quốc','Korea Republic':'🇰🇷 Hàn Quốc','Australia':'🇦🇺 Úc','Saudi Arabia':'🇸🇦 Ả Rập Xê Út','Iran':'🇮🇷 Iran','Qatar':'🇶🇦 Qatar','Canada':'🇨🇦 Canada','Morocco':'🇲🇦 Morocco','Senegal':'🇸🇳 Senegal','Ecuador':'🇪🇨 Ecuador','Serbia':'🇷🇸 Serbia','Switzerland':'🇨🇭 Thụy Sĩ','Denmark':'🇩🇰 Đan Mạch','Poland':'🇵🇱 Ba Lan','Turkey':'🇹🇷 Thổ Nhĩ Kỳ','Türkiye':'🇹🇷 Thổ Nhĩ Kỳ','Ukraine':'🇺🇦 Ukraine','Egypt':'🇪🇬 Ai Cập','Nigeria':'🇳🇬 Nigeria','Cameroon':'🇨🇲 Cameroon','Ghana':'🇬🇭 Ghana','Tunisia':'🇹🇳 Tunisia','Algeria':'🇩🇿 Algeria','South Africa':'🇿🇦 Nam Phi','Indonesia':'🇮🇩 Indonesia','Vietnam':'🇻🇳 Việt Nam','China PR':'🇨🇳 Trung Quốc','New Zealand':'🇳🇿 New Zealand','Panama':'🇵🇦 Panama','Costa Rica':'🇨🇷 Costa Rica','Jamaica':'🇯🇲 Jamaica','Honduras':'🇭🇳 Honduras','Paraguay':'🇵🇾 Paraguay','Bolivia':'🇧🇴 Bolivia','Chile':'🇨🇱 Chile','Peru':'🇵🇪 Peru','Venezuela':'🇻🇪 Venezuela','Norway':'🇳🇴 Na Uy','Sweden':'🇸🇪 Thụy Điển','Austria':'🇦🇹 Áo','Scotland':'🏴 Scotland','Wales':'🏴 Xứ Wales','Ireland':'🇮🇪 Ireland','Romania':'🇷🇴 Romania','Hungary':'🇭🇺 Hungary','Greece':'🇬🇷 Hy Lạp','Uzbekistan':'🇺🇿 Uzbekistan','Iraq':'🇮🇶 Iraq','TBD':'🏳️ TBD'};
|
| 4 |
function _t(n){return WC_TEAMS[n]||WC_TEAMS[(n||'').trim()]||'🏴 '+n;}
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
function switchWCTab(tab){
|
| 7 |
document.querySelectorAll('.wc-tab').forEach(t=>t.classList.remove('active'));
|
| 8 |
+
const tabMap = {'news':'Tin','fixtures':'Lịch','standings':'BXH','highlights':'Highlight','stats':'Thống'};
|
| 9 |
+
document.querySelectorAll('.wc-tab').forEach(t=>{if(tabMap[tab]&&t.textContent.includes(tabMap[tab]))t.classList.add('active');});
|
| 10 |
const el=document.getElementById('wc-content');if(!el)return;
|
|
|
|
| 11 |
if(tab==='news')renderWCNews(el);
|
| 12 |
else if(tab==='fixtures')renderWCFixtures(el);
|
| 13 |
else if(tab==='standings')renderWCStandings(el);
|
|
|
|
| 15 |
else if(tab==='stats')renderWCStats(el);
|
| 16 |
}
|
| 17 |
|
|
|
|
| 18 |
function renderWCNews(el){
|
| 19 |
+
if(!_wc2026Data){el.innerHTML='<div class="loading">Đang tải...</div>';return;}
|
| 20 |
const news=_wc2026Data.news||[];const road=_wc2026Data.road||[];
|
| 21 |
const combined=[];let ni=0,ri=0;
|
| 22 |
while(ni<news.length||ri<road.length){if(ni<news.length)combined.push({...news[ni++],_type:'news'});if(ri<road.length)combined.push({...road[ri++],_type:'road'});}
|
|
|
|
| 30 |
combined.slice(0,20).forEach((a,i)=>{if(a.img)return;fetch('/api/article?url='+encodeURIComponent(a.link)).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){const x=document.getElementById('wcimg-'+i);if(x)x.innerHTML=`<img src="${esc(d.og_image||d.img)}" loading="lazy" onerror="this.parentElement.innerHTML=''">`;}}).catch(()=>{});});
|
| 31 |
}
|
| 32 |
|
|
|
|
| 33 |
function renderWCFixtures(el){
|
| 34 |
+
if(!_wc2026Data){el.innerHTML='<div class="loading">Đang tải...</div>';return;}
|
| 35 |
const d=_wc2026Data.fixtures||{};const matches=d.matches||[];
|
| 36 |
if(!matches.length){el.innerHTML='<div class="loading">Đang tải lịch thi đấu...</div>';return;}
|
| 37 |
const sorted=[...matches].sort((a,b)=>(a.date_utc||'').localeCompare(b.date_utc||''));
|
|
|
|
| 46 |
h+='</div>';el.innerHTML=h;
|
| 47 |
}
|
| 48 |
|
|
|
|
| 49 |
function renderWCStandings(el){
|
| 50 |
+
if(!_wc2026Data){el.innerHTML='<div class="loading">Đang tải...</div>';return;}
|
| 51 |
const d=_wc2026Data.standings||{};
|
| 52 |
if(d.html&&d.html.length>100){
|
| 53 |
+
el.innerHTML='<div class="wc-bxh">'+d.html+'</div>';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
el.querySelectorAll('a').forEach(a=>a.addEventListener('click',e=>e.preventDefault()));
|
| 55 |
return;
|
| 56 |
}
|
| 57 |
+
el.innerHTML='<div class="loading">BXH cập nhật khi giải bắt đầu 11/06/2026<br><small style="color:#6a9fca">🔴 Livescore tự động</small></div>';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
}
|
| 59 |
|
|
|
|
| 60 |
function renderWCStats(el){
|
| 61 |
+
if(!_wc2026Data){el.innerHTML='<div class="loading">Đang tải...</div>';return;}
|
| 62 |
const d=_wc2026Data.stats||{};
|
| 63 |
+
if(d.html&&d.html.length>100){el.innerHTML='<div class="wc-bxh">'+d.html+'</div>';el.querySelectorAll('a').forEach(a=>a.addEventListener('click',e=>e.preventDefault()));}
|
| 64 |
else el.innerHTML='<div class="loading">Thống kê cập nhật khi giải bắt đầu<br><small style="color:#6a9fca">Vua phá lưới · Kiến tạo · Thẻ phạt</small></div>';
|
| 65 |
}
|
| 66 |
|
|
|
|
| 67 |
async function renderWCHighlights(el){
|
| 68 |
el.innerHTML='<div class="loading">Đang tải highlight World Cup...</div>';
|
| 69 |
try{
|
| 70 |
const wcRes = await fetch('/api/proxy/xlb?path=the-gioi/world-cup', {signal: AbortSignal.timeout(15000)}).then(r=>r.json()).catch(()=>({videos:[]}));
|
| 71 |
let wcVids = (wcRes.videos||[]);
|
|
|
|
| 72 |
const seen = new Map();
|
| 73 |
wcVids.forEach((v,i)=>{if(!seen.has(v.link)) seen.set(v.link, {...v, _li: seen.size});});
|
| 74 |
wcVids = Array.from(seen.values());
|
| 75 |
+
if(!wcVids.length){el.innerHTML='<div class="loading">Chưa có highlight World Cup</div>';return;}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
let h='<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:6px;max-height:450px;overflow-y:auto;padding:4px">';
|
| 77 |
wcVids.forEach((v,i)=>{
|
| 78 |
h+=`<div style="cursor:pointer;border-radius:6px;overflow:hidden;background:#0a1520" onclick="openHighlightFeed('world-cup',${v._li},'${esc(v.link)}')">`;
|
| 79 |
h+=`<div style="position:relative;aspect-ratio:16/9;background:#1a2a3a">${v.img?`<img src="${esc(v.img)}" loading="lazy" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none'">`:''}<div class="card-play">▶</div></div>`;
|
| 80 |
+
h+=`<div style="padding:4px 6px"><span style="font-size:8px;color:#f0c040">🌍 World Cup</span><div style="font-size:10px;color:#ccc;line-height:1.2;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden">${esc(v.title)}</div></div></div>`;
|
|
|
|
| 81 |
});
|
| 82 |
h+='</div>';el.innerHTML=h;
|
| 83 |
+
}catch(e){el.innerHTML='<div class="loading">Lỗi tải highlight</div>';}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
}
|
| 85 |
|
|
|
|
| 86 |
function startWCLiveRefresh(){
|
| 87 |
if(_wcRefreshInterval)clearInterval(_wcRefreshInterval);
|
| 88 |
_wcRefreshInterval=setInterval(async()=>{
|
| 89 |
if(!document.getElementById('view-home')?.classList.contains('active'))return;
|
| 90 |
try{
|
| 91 |
const data=await fetch('/api/wc2026').then(r=>r.json()).catch(()=>null);
|
| 92 |
+
if(data){
|
| 93 |
+
_wc2026Data=data;
|
| 94 |
+
const wcEl=document.getElementById('wc-content');
|
| 95 |
+
if(wcEl){
|
| 96 |
+
const tab=document.querySelector('.wc-tab.active');
|
| 97 |
+
if(tab){
|
| 98 |
+
const t=tab.textContent;
|
| 99 |
+
if(t.includes('Tin'))renderWCNews(wcEl);
|
| 100 |
+
else if(t.includes('Lịch'))renderWCFixtures(wcEl);
|
| 101 |
+
else if(t.includes('BXH'))renderWCStandings(wcEl);
|
| 102 |
+
else if(t.includes('Thống'))renderWCStats(wcEl);
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
}catch(e){}
|
| 107 |
},90000);
|
| 108 |
}
|
| 109 |
+
setTimeout(startWCLiveRefresh,5000);
|
static/wc_shorts_inject.js
DELETED
|
@@ -1,76 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* WC Shorts Inject — Chạy SAU app_v2.js
|
| 3 |
-
* Tự tạo anchor DIV rồi render WC Shorts slide TRÊN World Cup section
|
| 4 |
-
*/
|
| 5 |
-
(function(){
|
| 6 |
-
'use strict';
|
| 7 |
-
|
| 8 |
-
function inject(){
|
| 9 |
-
// Tìm WC section
|
| 10 |
-
const wcSection = document.getElementById('wc2026-live-section');
|
| 11 |
-
const homeEl = document.getElementById('view-home');
|
| 12 |
-
if(!homeEl) return;
|
| 13 |
-
|
| 14 |
-
// Đã inject?
|
| 15 |
-
if(document.getElementById('wc-shorts-slide-section')) return;
|
| 16 |
-
|
| 17 |
-
// Đảm bảo renderWCSSlideSection đã load
|
| 18 |
-
if(typeof renderWCSSlideSection !== 'function'){
|
| 19 |
-
// Chờ rồi thử lại
|
| 20 |
-
setTimeout(inject, 500);
|
| 21 |
-
return;
|
| 22 |
-
}
|
| 23 |
-
|
| 24 |
-
// Tạo container rồi render vào
|
| 25 |
-
const div = document.createElement('div');
|
| 26 |
-
div.id = 'wc-shorts-slide-container-tmp';
|
| 27 |
-
div.style.display = 'none';
|
| 28 |
-
|
| 29 |
-
// Insert TRƯỚC WC section, hoặc vào đầu home
|
| 30 |
-
if(wcSection && wcSection.parentNode){
|
| 31 |
-
wcSection.parentNode.insertBefore(div, wcSection);
|
| 32 |
-
} else {
|
| 33 |
-
homeEl.insertBefore(div, homeEl.firstChild);
|
| 34 |
-
}
|
| 35 |
-
|
| 36 |
-
// Render slide section vào div
|
| 37 |
-
renderWCSSlideSection(div);
|
| 38 |
-
|
| 39 |
-
// Bỏ wrapper thừa, di chuyển section ra đúng vị trí
|
| 40 |
-
const section = div.querySelector('.wc-shorts-slide-section');
|
| 41 |
-
if(section){
|
| 42 |
-
section.id = 'wc-shorts-slide-section';
|
| 43 |
-
if(wcSection && wcSection.parentNode){
|
| 44 |
-
wcSection.parentNode.insertBefore(section, wcSection);
|
| 45 |
-
}
|
| 46 |
-
div.remove();
|
| 47 |
-
}
|
| 48 |
-
}
|
| 49 |
-
|
| 50 |
-
// Chạy khi DOM sẵn sàng
|
| 51 |
-
if(document.readyState === 'loading'){
|
| 52 |
-
document.addEventListener('DOMContentLoaded', ()=> setTimeout(inject, 800));
|
| 53 |
-
} else {
|
| 54 |
-
setTimeout(inject, 800);
|
| 55 |
-
}
|
| 56 |
-
|
| 57 |
-
// Cũng chạy sau khi loadHome xong
|
| 58 |
-
const origLoadHome = window.loadHome;
|
| 59 |
-
if(origLoadHome){
|
| 60 |
-
window.loadHome = function(){
|
| 61 |
-
const result = origLoadHome.apply(this, arguments);
|
| 62 |
-
setTimeout(inject, 200);
|
| 63 |
-
setTimeout(inject, 800);
|
| 64 |
-
setTimeout(inject, 2000);
|
| 65 |
-
return result;
|
| 66 |
-
};
|
| 67 |
-
}
|
| 68 |
-
|
| 69 |
-
// Fallback: retry nhiều lần
|
| 70 |
-
let retries = 0;
|
| 71 |
-
const retryIv = setInterval(()=>{
|
| 72 |
-
retries++;
|
| 73 |
-
inject();
|
| 74 |
-
if(retries > 20) clearInterval(retryIv);
|
| 75 |
-
}, 1000);
|
| 76 |
-
})();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/yt_live.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
-
// === VNEWS — VTV LIVE + Inline Recorder
|
| 2 |
-
//
|
| 3 |
-
//
|
| 4 |
-
//
|
| 5 |
|
| 6 |
(function(){
|
| 7 |
if(window._ytLiveLoaded) return;
|
|
@@ -15,26 +15,10 @@
|
|
| 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
|
| 38 |
|
| 39 |
// ===== RECORDER STATE =====
|
| 40 |
const _rec = {
|
|
@@ -51,14 +35,14 @@
|
|
| 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;
|
| 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}
|
|
@@ -148,7 +132,7 @@
|
|
| 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}
|
|
@@ -159,17 +143,398 @@
|
|
| 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);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// === VNEWS — VTV LIVE + Inline Recorder v8 ===
|
| 2 |
+
// KEY CHANGE: Try DIRECT stream URLs first (client fetches through VPN), fall back to proxy only on CORS error
|
| 3 |
+
// Fixes: FPTPlay URLs updated, VTVGO failover, canthotv fallback, AES-128 key URI rewriting
|
| 4 |
+
// Features: PiP, mini-player, INLINE RECORDER with ratio crop + AI title + Short AI upload
|
| 5 |
|
| 6 |
(function(){
|
| 7 |
if(window._ytLiveLoaded) return;
|
|
|
|
| 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 |
+
const DEFAULT_CHANNEL = 'vtv6';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
const STREAMS = {};
|
| 20 |
let _currentCh = null, _hls = null, _loading = false, _blockInserted = false;
|
| 21 |
+
let _streamsLoaded = false, _pipActive = false, _miniActive = false, _vtvPinned = false;
|
| 22 |
|
| 23 |
// ===== RECORDER STATE =====
|
| 24 |
const _rec = {
|
|
|
|
| 35 |
.vtv-title{font-size:13px;font-weight:800;color:#00ccff}
|
| 36 |
.vtv-badge{font-size:10px;font-weight:800;color:#00ccff;animation:vtvp 1.3s infinite}
|
| 37 |
@keyframes vtvp{0%,100%{opacity:1}50%{opacity:.3}}
|
| 38 |
+
.vtv-tabs{display:flex;gap:3px;padding:6px 8px;overflow-x:auto;scrollbar-width:none;background:#0d1a2a}
|
| 39 |
.vtv-tabs::-webkit-scrollbar{display:none}
|
| 40 |
.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}
|
| 41 |
.vtv-tab:hover{background:#0b4a7a;color:#fff}
|
| 42 |
.vtv-tab.on{background:#0066cc;border-color:#00ccff;color:#fff;font-weight:700}
|
| 43 |
+
.vtv-tab.off{opacity:.35;pointer-events:none}
|
| 44 |
+
.vtv-frame{position:relative;width:100%;aspect-ratio:16/9;background:#000;min-height:180px;overflow:hidden}
|
| 45 |
+
.vtv-frame video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;background:#000}
|
| 46 |
.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}
|
| 47 |
.vtv-err button{background:#0066cc;border:none;color:#fff;padding:6px 14px;border-radius:8px;font-size:11px;cursor:pointer}
|
| 48 |
.vtv-load{display:flex;align-items:center;justify-content:center;height:180px;color:#00ccff;font-size:12px;flex-direction:column;gap:8px}
|
|
|
|
| 132 |
.vtv-mini.show{display:block}
|
| 133 |
.vtv-mini.hidden{transform:translateY(-88%)}
|
| 134 |
.vtv-mini-frame{position:relative;width:100%;aspect-ratio:16/9;background:#000;max-height:180px}
|
| 135 |
+
.vtv-mini-frame video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;background:#000}
|
| 136 |
.vtv-mini-bar{display:flex;align-items:center;justify-content:space-between;padding:3px 8px;background:linear-gradient(90deg,#003366,#1a1a1a)}
|
| 137 |
.vtv-mini-ch{font-size:10px;font-weight:700;color:#00ccff}
|
| 138 |
.vtv-mini-epg{font-size:9px;color:#8ab4d8;max-width:180px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-left:8px}
|
|
|
|
| 143 |
.vtv-mini-btn.x:hover{background:#900;color:#fff}
|
| 144 |
.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}
|
| 145 |
.vtv-mini.hidden .vtv-mini-peek{display:block}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
`;
|
| 147 |
+
document.head.appendChild(style);
|
| 148 |
+
|
| 149 |
+
// ===== HELPERS =====
|
| 150 |
+
function fmtSec(s){ return String(Math.floor(s/60)).padStart(2,'0')+':'+String(Math.floor(s%60)).padStart(2,'0'); }
|
| 151 |
+
function escH(s){ return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
| 152 |
+
|
| 153 |
+
function recStatus(msg, type) {
|
| 154 |
+
const el = document.getElementById('vtv-rec-status');
|
| 155 |
+
if (el) { el.textContent = msg; el.className = 'rec-status' + (type ? ' ' + type : ''); }
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
function downloadBlob(blob, filename) {
|
| 159 |
+
const a = document.createElement('a');
|
| 160 |
+
a.href = URL.createObjectURL(blob); a.download = filename; a.click();
|
| 161 |
+
URL.revokeObjectURL(a.href);
|
| 162 |
+
recStatus('✅ Đã tải: ' + filename, 'ok');
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
function showProc(text) {
|
| 166 |
+
const el = document.getElementById('vtv-rec-proc');
|
| 167 |
+
if (el) { el.style.display = 'block'; document.getElementById('vtv-rec-proc-text').textContent = text; }
|
| 168 |
+
}
|
| 169 |
+
function hideProc() {
|
| 170 |
+
const el = document.getElementById('vtv-rec-proc');
|
| 171 |
+
if (el) el.style.display = 'none';
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
function uploadToWall(blob, title, source) {
|
| 175 |
+
const formData = new FormData();
|
| 176 |
+
formData.append('video', blob, `vtv-${_currentCh}-${Date.now()}.webm`);
|
| 177 |
+
formData.append('title', title || `Record LIVE ${_currentCh?.toUpperCase()} - ${new Date().toLocaleString('vi-VN')}`);
|
| 178 |
+
formData.append('source', source || 'vtv_inline_recorder');
|
| 179 |
+
showProc('⏳ Đang upload lên Short AI...');
|
| 180 |
+
return fetch('/api/wall', { method: 'POST', body: formData })
|
| 181 |
+
.then(r => r.json())
|
| 182 |
+
.then(data => {
|
| 183 |
+
hideProc();
|
| 184 |
+
if (data.ok) {
|
| 185 |
+
recStatus('✅ Đã tạo Short AI! Xem trong mục Tường AI.', 'ok');
|
| 186 |
+
return data;
|
| 187 |
+
} else {
|
| 188 |
+
throw new Error(data.error || 'Upload lỗi');
|
| 189 |
+
}
|
| 190 |
+
})
|
| 191 |
+
.catch(e => {
|
| 192 |
+
hideProc();
|
| 193 |
+
recStatus('⚠️ ' + e.message + '. Đang tải về...', 'err');
|
| 194 |
+
setTimeout(() => downloadBlob(blob, `vtv-${_currentCh}-${Date.now()}.webm`), 800);
|
| 195 |
+
throw e;
|
| 196 |
+
});
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
async function generateAITitle(blob, channelName) {
|
| 200 |
+
return new Promise((resolve) => {
|
| 201 |
+
const video = document.createElement('video');
|
| 202 |
+
video.src = URL.createObjectURL(blob);
|
| 203 |
+
video.muted = true;
|
| 204 |
+
video.onloadedmetadata = function() {
|
| 205 |
+
URL.revokeObjectURL(video.src);
|
| 206 |
+
const dur = Math.round(video.duration || 0);
|
| 207 |
+
const now = new Date();
|
| 208 |
+
const timeStr = now.toLocaleTimeString('vi-VN',{hour:'2-digit',minute:'2-digit'});
|
| 209 |
+
const dateStr = now.toLocaleDateString('vi-VN',{day:'2-digit',month:'2-digit'});
|
| 210 |
+
const titles = [
|
| 211 |
+
`🔴 LIVE ${channelName} - ${timeStr} ${dateStr}`,
|
| 212 |
+
`📺 ${channelName} - Video ngắn ${dur}s`,
|
| 213 |
+
`🎬 ${channelName} Record - ${timeStr}`,
|
| 214 |
+
`⚡ ${channelName} Highlights - ${dateStr}`,
|
| 215 |
+
`📱 ${channelName} Short - ${timeStr} ${dateStr}`,
|
| 216 |
+
];
|
| 217 |
+
const idx = dur < 15 ? 4 : dur < 30 ? 1 : dur < 45 ? 2 : 0;
|
| 218 |
+
resolve(titles[idx % titles.length]);
|
| 219 |
+
};
|
| 220 |
+
video.onerror = function() {
|
| 221 |
+
URL.revokeObjectURL(video.src);
|
| 222 |
+
resolve(`📺 Record ${channelName} - ${new Date().toLocaleTimeString('vi-VN',{hour:'2-digit',minute:'2-digit'})}`);
|
| 223 |
+
};
|
| 224 |
+
});
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
// ===== BUILD INLINE RECORDER =====
|
| 228 |
+
function buildInlineRecorder() {
|
| 229 |
+
const controls = document.querySelector('.vtv-controls');
|
| 230 |
+
if (!controls || document.getElementById('vtv-rec-btn')) return;
|
| 231 |
+
const recBtn = document.createElement('button');
|
| 232 |
+
recBtn.id = 'vtv-rec-btn'; recBtn.className = 'vtv-rec-btn';
|
| 233 |
+
recBtn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record';
|
| 234 |
+
recBtn.title = 'Record video ngắn từ LIVE (max 60s)';
|
| 235 |
+
recBtn.onclick = toggleRecPanel;
|
| 236 |
+
controls.appendChild(recBtn);
|
| 237 |
+
const inlineRec = document.createElement('div');
|
| 238 |
+
inlineRec.id = 'vtv-inline-rec'; inlineRec.className = 'vtv-inline-rec';
|
| 239 |
+
inlineRec.innerHTML = `<div class="rec-time"><span id="vtv-rec-time-start">00:00</span><span id="vtv-rec-time-dur">Duration: 0s</span><span id="vtv-rec-time-end">01:00</span></div><div class="rec-bar" id="vtv-rec-bar"><div class="rec-progress" id="vtv-rec-progress"></div><div class="rec-marker s" id="vtv-rec-mk-start" style="display:none"></div><div class="rec-marker e" id="vtv-rec-mk-end" style="display:none"></div></div><div class="rec-controls"><button class="rec-set-start" id="vtv-rec-set-start" onclick="window._vtvRecSetStart()">🔵 Điểm đầu</button><button class="rec-set-end" id="vtv-rec-set-end" onclick="window._vtvRecSetEnd()" disabled>🔴 Điểm cuối</button><button class="rec-go" id="vtv-rec-go" onclick="window._vtvRecGo()" disabled>⏺ Record!</button><button class="rec-reset" onclick="window._vtvRecReset()">↺ Reset</button></div><div class="rec-hint" id="vtv-rec-hint">Chọn điểm bắt đầu → kết thúc (tối đa 60s)</div><div class="rec-status" id="vtv-rec-status">Sẵn sàng</div>`;
|
| 240 |
+
controls.parentNode.insertBefore(inlineRec, controls.nextSibling);
|
| 241 |
+
const recPanel = document.createElement('div');
|
| 242 |
+
recPanel.id = 'vtv-rec-panel'; recPanel.className = 'vtv-rec-panel';
|
| 243 |
+
recPanel.innerHTML = `<div class="rec-panel-title">🎬 Đã record xong!</div><div class="rec-preview-wrap" id="vtv-rec-preview-wrap" style="display:none"><video id="vtv-rec-preview-vid" controls style="max-width:100%;max-height:180px;border-radius:6px;background:#000"></video></div><input type="text" class="rec-title-input" id="vtv-rec-title-input" placeholder="Tiêu đề video (để trống = AI tự tạo)"><div class="rec-ai-title-row"><button onclick="window._vtvRecGenAITitle()" id="vtv-rec-ai-btn">🤖 AI tạo tiêu đề</button></div><div class="rec-ratio-row"><button class="active" data-ratio="original" onclick="window._vtvRecSetRatio('original')">📺 Gốc</button><button data-ratio="1:1" onclick="window._vtvRecSetRatio('1:1')">⬜ 1:1</button><button data-ratio="9:16" onclick="window._vtvRecSetRatio('9:16')">📱 9:16</button></div><div class="rec-actions"><button class="rec-download" onclick="window._vtvRecDownload()">💾 Lưu về máy</button><button class="rec-share" onclick="window._vtvRecShare()">🎬 Tạo Short AI</button></div><div class="rec-proc" id="vtv-rec-proc"><div class="vtv-spinner" style="width:20px;height:20px;margin:0 auto 6px"></div><span id="vtv-rec-proc-text">Đang xử lý...</span></div>`;
|
| 244 |
+
inlineRec.parentNode.insertBefore(recPanel, inlineRec.nextSibling);
|
| 245 |
+
document.getElementById('vtv-rec-bar').addEventListener('click', function(e){ if(!_rec.isRecording) handleRecBarClick(e); });
|
| 246 |
+
document.getElementById('vtv-rec-mk-start').addEventListener('mousedown', function(e){ e.preventDefault(); e.stopPropagation(); _rec._dragMarker = 'start'; });
|
| 247 |
+
document.getElementById('vtv-rec-mk-start').addEventListener('touchstart', function(e){ e.preventDefault(); e.stopPropagation(); _rec._dragMarker = 'start'; }, {passive:false});
|
| 248 |
+
document.getElementById('vtv-rec-mk-end').addEventListener('mousedown', function(e){ e.preventDefault(); e.stopPropagation(); _rec._dragMarker = 'end'; });
|
| 249 |
+
document.getElementById('vtv-rec-mk-end').addEventListener('touchstart', function(e){ e.preventDefault(); e.stopPropagation(); _rec._dragMarker = 'end'; }, {passive:false});
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
document.addEventListener('mousemove', function(e){ if(_rec._dragMarker) dragMarker(e.clientX); });
|
| 253 |
+
document.addEventListener('touchmove', function(e){ if(_rec._dragMarker) dragMarker(e.touches[0].clientX); }, {passive:false});
|
| 254 |
+
document.addEventListener('mouseup', function(){ _rec._dragMarker = null; });
|
| 255 |
+
document.addEventListener('touchend', function(){ _rec._dragMarker = null; });
|
| 256 |
+
|
| 257 |
+
function dragMarker(clientX){const bar=document.getElementById('vtv-rec-bar');if(!bar)return;const rect=bar.getBoundingClientRect();const pct=Math.max(0,Math.min(1,(clientX-rect.left)/rect.width));const time=pct*60;if(_rec._dragMarker==='start'){if(_rec.endTime!==null&&time>=_rec.endTime-1)return;_rec.startTime=time;const mk=document.getElementById('vtv-rec-mk-start');mk.style.display='block';mk.style.left=(pct*100)+'%';document.getElementById('vtv-rec-time-start').textContent=fmtSec(time);}else{if(_rec.startTime!==null&&time<=_rec.startTime+1)return;_rec.endTime=time;const mk=document.getElementById('vtv-rec-mk-end');mk.style.display='block';mk.style.left=(pct*100)+'%';document.getElementById('vtv-rec-time-end').textContent=fmtSec(time);}updateRecBar();}
|
| 258 |
+
function handleRecBarClick(e){const video=document.getElementById('vtv-player');if(!video||!video.src){recStatus('⚠️ Chưa có stream!');return;}const bar=document.getElementById('vtv-rec-bar');const rect=bar.getBoundingClientRect();const pct=Math.max(0,Math.min(1,(e.clientX-rect.left)/rect.width));const time=pct*60;if(_rec.startTime===null){_rec.startTime=time;const mk=document.getElementById('vtv-rec-mk-start');mk.style.display='block';mk.style.left=(pct*100)+'%';document.getElementById('vtv-rec-time-start').textContent=fmtSec(time);document.getElementById('vtv-rec-set-end').disabled=false;recStatus('Điểm đầu: '+fmtSec(time)+'. Chọn điểm cuối.','');}else if(_rec.endTime===null){if(time<=_rec.startTime){recStatus('⚠️ Điểm cuối phải sau điểm đầu!','err');return;}const dur=time-_rec.startTime;if(dur>60){recStatus('⚠️ Tối đa 60s!','err');return;}_rec.endTime=time;const mk=document.getElementById('vtv-rec-mk-end');mk.style.display='block';mk.style.left=(pct*100)+'%';document.getElementById('vtv-rec-time-end').textContent=fmtSec(time);updateRecBar();recStatus('✅ '+Math.round(dur)+'s. Nhấn Record!','ok');document.getElementById('vtv-rec-go').disabled=false;}}
|
| 259 |
+
function updateRecBar(){const s=_rec.startTime||0,e=_rec.endTime||60;const dur=e-s;document.getElementById('vtv-rec-progress').style.left=(s/60*100)+'%';document.getElementById('vtv-rec-progress').style.width=(dur/60*100)+'%';document.getElementById('vtv-rec-time-dur').textContent='Duration: '+Math.round(dur)+'s';}
|
| 260 |
+
window._vtvRecSetStart=function(){const video=document.getElementById('vtv-player');if(!video||!video.src){recStatus('⚠️ Chưa có stream!','err');return;}_rec.startTime=video.currentTime||0;const pct=_rec.startTime/60*100;const mk=document.getElementById('vtv-rec-mk-start');mk.style.display='block';mk.style.left=pct+'%';document.getElementById('vtv-rec-time-start').textContent=fmtSec(_rec.startTime);document.getElementById('vtv-rec-set-end').disabled=false;recStatus('Điểm đầu: '+fmtSec(_rec.startTime),'');};
|
| 261 |
+
window._vtvRecSetEnd=function(){if(_rec.startTime===null){recStatus('⚠️ Chọn điểm đầu trước!','err');return;}const video=document.getElementById('vtv-player');if(!video)return;const cur=video.currentTime||0;if(cur<=_rec.startTime){recStatus('⚠️ Điểm cuối phải sau điểm đầu!','err');return;}const dur=cur-_rec.startTime;if(dur>60){recStatus('⚠️ Tối đa 60s!','err');return;}_rec.endTime=cur;const pct=cur/60*100;const mk=document.getElementById('vtv-rec-mk-end');mk.style.display='block';mk.style.left=pct+'%';document.getElementById('vtv-rec-time-end').textContent=fmtSec(cur);updateRecBar();recStatus('✅ '+Math.round(dur)+'s. Nhấn Record!','ok');document.getElementById('vtv-rec-go').disabled=false;};
|
| 262 |
+
window._vtvRecReset=function(){_rec.startTime=null;_rec.endTime=null;_rec.isRecording=false;_rec.blob=null;if(_rec.recorder&&_rec.recorder.state!=='inactive')_rec.recorder.stop();clearTimeout(_rec._recTimer);document.getElementById('vtv-rec-mk-start').style.display='none';document.getElementById('vtv-rec-mk-end').style.display='none';document.getElementById('vtv-rec-progress').style.width='0%';document.getElementById('vtv-rec-time-start').textContent='00:00';document.getElementById('vtv-rec-time-end').textContent='01:00';document.getElementById('vtv-rec-time-dur').textContent='Duration: 0s';document.getElementById('vtv-rec-set-start').disabled=false;document.getElementById('vtv-rec-set-end').disabled=true;document.getElementById('vtv-rec-go').disabled=true;document.getElementById('vtv-rec-panel').classList.remove('show');document.getElementById('vtv-rec-title-input').value='';recStatus('Đã reset.','');const btn=document.getElementById('vtv-rec-btn');if(btn){btn.classList.remove('recording');btn.innerHTML='<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record';}};
|
| 263 |
+
window._vtvRecSetRatio=function(ratio){_rec.ratio=ratio;document.querySelectorAll('#vtv-rec-panel .rec-ratio-row button').forEach(b=>b.classList.toggle('active',b.dataset.ratio===ratio));};
|
| 264 |
+
window._vtvRecGo=function(){if(_rec.startTime===null||_rec.endTime===null)return;if(_rec.isRecording){if(_rec.recorder&&_rec.recorder.state!=='inactive')_rec.recorder.stop();_rec.isRecording=false;clearTimeout(_rec._recTimer);const btn=document.getElementById('vtv-rec-btn');if(btn){btn.classList.remove('recording');btn.innerHTML='<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record';}return;}const video=document.getElementById('vtv-player');if(!video){recStatus('⚠️ Không tìm thấy video!','err');return;}_rec.chunks=[];_rec.isRecording=true;const wasMuted=video.muted;video.muted=false;const wasVolume=video.volume;video.volume=0.3;let stream;try{stream=video.captureStream?video.captureStream():video.mozCaptureStream();if(!stream)throw new Error('captureStream not supported');}catch(e){video.muted=wasMuted;video.volume=wasVolume;recStatus('❌ '+e.message,'err');_rec.isRecording=false;return;}const audioTracks=stream.getAudioTracks();console.log('[VTV Rec] Audio tracks:',audioTracks.length,'Video tracks:',stream.getVideoTracks().length);let mimeType='video/webm;codecs=vp9,opus';if(!MediaRecorder.isTypeSupported(mimeType))mimeType='video/webm;codecs=vp8,opus';if(!MediaRecorder.isTypeSupported(mimeType))mimeType='video/webm';try{_rec.recorder=new MediaRecorder(stream,{mimeType});}catch(e){video.muted=wasMuted;video.volume=wasVolume;recStatus('❌ Lỗi MediaRecorder: '+e.message,'err');_rec.isRecording=false;return;}_rec.recorder.ondataavailable=e=>{if(e.data&&e.data.size>0)_rec.chunks.push(e.data);};_rec.recorder.onstop=function(){_rec.isRecording=false;video.muted=wasMuted;video.volume=wasVolume;_rec.blob=new Blob(_rec.chunks,{type:'video/webm'});const btn=document.getElementById('vtv-rec-btn');if(btn){btn.classList.remove('recording');btn.innerHTML='<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record';}document.getElementById('vtv-rec-panel').classList.add('show');const pv=document.getElementById('vtv-rec-preview-vid');pv.src=URL.createObjectURL(_rec.blob);document.getElementById('vtv-rec-preview-wrap').style.display='block';const sizeMB=(_rec.blob.size/1024/1024).toFixed(1);recStatus('✅ Record xong! '+sizeMB+'MB'+(audioTracks.length>0?' (có audio)':' (video only)'),'ok');};_rec.recorder.onerror=e=>{_rec.isRecording=false;video.muted=wasMuted;video.volume=wasVolume;recStatus('❌ Lỗi: '+(e.error?.message||'?'),'err');};_rec.recorder.start(500);video.currentTime=_rec.startTime;const btn=document.getElementById('vtv-rec-btn');if(btn){btn.classList.add('recording');btn.innerHTML='<svg viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/></svg> Dừng';}recStatus('🔴 ĐANG RECORD...'+(audioTracks.length>0?' (có audio)':''),'recording');const dur=(_rec.endTime-_rec.startTime)*1000;_rec._recTimer=setTimeout(()=>{if(_rec.isRecording&&_rec.recorder&&_rec.recorder.state!=='inactive')_rec.recorder.stop();},dur+500);};
|
| 265 |
+
window._vtvRecGenAITitle=async function(){if(!_rec.blob){recStatus('⚠️ Chưa record!','err');return;}const btn=document.getElementById('vtv-rec-ai-btn');const input=document.getElementById('vtv-rec-title-input');if(!btn||!input)return;btn.disabled=true;btn.textContent='⏳ Đang tạo...';try{const ch=CHANNELS.find(c=>c.id===_currentCh);const channelName=ch?ch.name:(_currentCh||'VTV');const title=await generateAITitle(_rec.blob,channelName);input.value=title;recStatus('✅ Đã tạo tiêu đề AI: '+title,'ok');}catch(e){recStatus('⚠️ Lỗi AI: '+e.message,'err');}finally{btn.disabled=false;btn.textContent='🤖 AI tạo tiêu đề';}};
|
| 266 |
+
window._vtvRecDownload=function(){if(!_rec.blob)return;const title=document.getElementById('vtv-rec-title-input')?.value||'';if(_rec.ratio==='original'){downloadBlob(_rec.blob,`vtv-${_currentCh}-${Date.now()}.webm`);}else{processRatio(_rec.blob,_rec.ratio,'download',title);}};
|
| 267 |
+
window._vtvRecShare=function(){if(!_rec.blob)return;const title=document.getElementById('vtv-rec-title-input')?.value||'';if(_rec.ratio!=='original'){processRatio(_rec.blob,_rec.ratio,'upload',title);}else{uploadToWall(_rec.blob,title);}};
|
| 268 |
+
|
| 269 |
+
function processRatio(blob,ratio,action,title){
|
| 270 |
+
showProc(`🔄 Đang xử lý ${ratio}...`);
|
| 271 |
+
const video=document.createElement('video');
|
| 272 |
+
video.src=URL.createObjectURL(blob);video.volume=0;video.preload='auto';video.playsInline=true;
|
| 273 |
+
function onReady(){
|
| 274 |
+
video.removeEventListener('loadedmetadata',onReady);video.removeEventListener('canplay',onReady);
|
| 275 |
+
const vw=video.videoWidth,vh=video.videoHeight;
|
| 276 |
+
if(!vw||!vh){hideProc();recStatus('❌ Video không có kích thước hợp lệ.','err');URL.revokeObjectURL(video.src);return;}
|
| 277 |
+
let cw,ch,sx,sy;
|
| 278 |
+
if(ratio==='1:1'){const size=Math.min(vw,vh);cw=ch=size;sx=Math.floor((vw-size)/2);sy=Math.floor((vh-size)/2);}
|
| 279 |
+
else if(ratio==='9:16'){ch=vh;cw=Math.min(vw,Math.round(vh*9/16));sx=Math.floor((vw-cw)/2);sy=0;}
|
| 280 |
+
else{cw=vw;ch=vh;sx=sy=0;}
|
| 281 |
+
showProc(`🔄 Render ${cw}x${ch}...`);
|
| 282 |
+
const canvas=document.createElement('canvas');canvas.width=cw;canvas.height=ch;
|
| 283 |
+
const ctx=canvas.getContext('2d');
|
| 284 |
+
const canvasStream=canvas.captureStream(30);
|
| 285 |
+
let recordStream=canvasStream;
|
| 286 |
+
const audioVideo=document.createElement('video');
|
| 287 |
+
audioVideo.src=video.src;audioVideo.muted=true;audioVideo.volume=0;audioVideo.preload='auto';audioVideo.playsInline=true;
|
| 288 |
+
audioVideo.onloadedmetadata=function(){try{const aStr=audioVideo.captureStream?audioVideo.captureStream():null;if(aStr){const tracks=aStr.getAudioTracks();if(tracks.length>0){recordStream=new MediaStream([...canvasStream.getVideoTracks(),...tracks]);}}catch(e){console.warn('[VTV Crop] Audio merge failed:',e);}};
|
| 289 |
+
const mime=MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus')?'video/webm;codecs=vp9,opus':MediaRecorder.isTypeSupported('video/webm;codecs=vp8,opus')?'video/webm;codecs=vp8,opus':'video/webm';
|
| 290 |
+
let rec;try{rec=new MediaRecorder(recordStream,{mimeType:mime});}catch(e){hideProc();recStatus('❌ Lỗi MediaRecorder: '+e.message,'err');URL.revokeObjectURL(video.src);return;}
|
| 291 |
+
const chunks=[];rec.ondataavailable=e=>{if(e.data&&e.data.size>0)chunks.push(e.data);};
|
| 292 |
+
rec.onstop=()=>{hideProc();URL.revokeObjectURL(video.src);if(chunks.length===0){recStatus('❌ Render thất bại. Thử lại hoặc chọn "Gốc".','err');return;}const out=new Blob(chunks,{type:'video/webm'});if(action==='download'){downloadBlob(out,`vtv-${_currentCh}-${ratio.replace(':','x')}-${Date.now()}.webm`);}else{uploadToWall(out,title);}};
|
| 293 |
+
rec.onerror=e=>{hideProc();recStatus('❌ Lỗi render: '+(e.error?.message||'?'),'err');URL.revokeObjectURL(video.src);};
|
| 294 |
+
rec.start(200);audioVideo.play().catch(()=>{});
|
| 295 |
+
video.play().then(()=>{let finished=false;function finish(){if(finished)return;finished=true;try{rec.requestData();}catch(e){}setTimeout(()=>{if(rec.state!=='inactive')rec.stop();try{audioVideo.pause();}catch(e){}},300);}video.onended=finish;audioVideo.onended=finish;function draw(){if(video.paused||video.ended)return;ctx.drawImage(video,sx,sy,cw,ch,0,0,cw,ch);requestAnimationFrame(draw);}draw();}).catch(e=>{hideProc();recStatus('❌ Lỗi phát video: '+e.message,'err');URL.revokeObjectURL(video.src);});
|
| 296 |
+
}
|
| 297 |
+
video.onerror=function(){hideProc();recStatus('❌ Không đọc được video.','err');URL.revokeObjectURL(video.src);};
|
| 298 |
+
video.addEventListener('loadedmetadata',onReady);video.addEventListener('canplay',onReady);
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
function toggleRecPanel(){
|
| 302 |
+
const panel=document.getElementById('vtv-inline-rec');
|
| 303 |
+
if(!panel){buildInlineRecorder();setTimeout(toggleRecPanel,200);return;}
|
| 304 |
+
_rec.active=!_rec.active;panel.classList.toggle('show',_rec.active);
|
| 305 |
+
const btn=document.getElementById('vtv-rec-btn');
|
| 306 |
+
if(btn){if(_rec.active){btn.style.background='#3a1a1a';btn.innerHTML='<svg viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/></svg> Đóng';}else{btn.style.background='';btn.innerHTML='<svg viewBox="0 0 24"><circle cx="12" cy="12" r="8"/></svg> Record';document.getElementById('vtv-rec-panel').classList.remove('show');}}
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
function setupPinButton(){
|
| 310 |
+
const block=document.getElementById('vtv-block');if(!block)return;
|
| 311 |
+
const header=block.querySelector('.vtv-head');if(!header||document.getElementById('vtv-pin-btn'))return;
|
| 312 |
+
const btn=document.createElement('button');btn.id='vtv-pin-btn';btn.className='vtv-pin-btn';btn.innerHTML='📌 Ghim';btn.title='Ghim VTV cố định trên đầu trang';
|
| 313 |
+
btn.onclick=function(e){e.stopPropagation();_vtvPinned=!_vtvPinned;if(_vtvPinned){block.classList.add('vtv-sticky');btn.classList.add('pinned');btn.innerHTML='📌 Đã ghim';}else{block.classList.remove('vtv-sticky');btn.classList.remove('pinned');btn.innerHTML='📌 Ghim';}};
|
| 314 |
+
header.style.position='relative';header.appendChild(btn);
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
function createMiniPlayer(){
|
| 318 |
+
if(document.getElementById('vtv-mini'))return;
|
| 319 |
+
const mini=document.createElement('div');mini.className='vtv-mini';mini.id='vtv-mini';
|
| 320 |
+
mini.innerHTML=`<div class="vtv-mini-frame"><video id="vtv-mini-vid" playsinline muted preload="auto"></video></div><div class="vtv-mini-bar"><div style="display:flex;align-items:center;flex:1;min-width:0"><span class="vtv-mini-ch" id="vtv-mini-ch">VTV</span><span class="vtv-mini-epg" id="vtv-mini-epg"></span></div><div class="vtv-mini-btns"><button class="vtv-mini-btn" id="vtv-mini-pip">📺</button><button class="vtv-mini-btn" id="vtv-mini-expand">⬆️</button><button class="vtv-mini-btn" id="vtv-mini-hide">⬇️</button><button class="vtv-mini-btn x" id="vtv-mini-close">✕</button></div></div><div class="vtv-mini-peek" id="vtv-mini-peek">📺 Xem</div>`;
|
| 321 |
+
document.body.appendChild(mini);
|
| 322 |
+
document.getElementById('vtv-mini-pip').onclick=async function(){const v=document.getElementById('vtv-mini-vid');try{if(document.pictureInPictureElement===v)await document.exitPictureInPicture();else await v.requestPictureInPicture();}catch(e){}};
|
| 323 |
+
document.getElementById('vtv-mini-expand').onclick=function(){closeMiniPlayer();if(typeof switchCat==='function')switchCat('home');};
|
| 324 |
+
document.getElementById('vtv-mini-hide').onclick=()=>{mini.classList.add('hidden');};
|
| 325 |
+
document.getElementById('vtv-mini-close').onclick=closeMiniPlayer;
|
| 326 |
+
document.getElementById('vtv-mini-peek').onclick=()=>{mini.classList.remove('hidden');};
|
| 327 |
+
}
|
| 328 |
+
function closeMiniPlayer(){const m=document.getElementById('vtv-mini');if(m){const v=document.getElementById('vtv-mini-vid');if(v){v.pause();v.src='';}m.remove();}_miniActive=false;}
|
| 329 |
+
|
| 330 |
+
function activateMiniPlayer(){
|
| 331 |
+
if(!_currentCh)return;createMiniPlayer();_miniActive=true;
|
| 332 |
+
const mini=document.getElementById('vtv-mini');const mv=document.getElementById('vtv-mini-vid');const v=document.getElementById('vtv-player');
|
| 333 |
+
if(_hls){const h=new Hls({enableWorker:true,lowLatencyMode:true,startLevel:0,capLevelToPlayerSize:true,maxBufferLength:30});h.loadSource(STREAMS[_currentCh][0]);h.attachMedia(mv);mv.play().catch(()=>{});}
|
| 334 |
+
else if(v&&v.src){mv.src=v.src;mv.play().catch(()=>{});}
|
| 335 |
+
const ch=CHANNELS.find(c=>c.id===_currentCh);if(ch)document.getElementById('vtv-mini-ch').textContent=ch.name;
|
| 336 |
+
fetch('/api/vtv/epg/'+_currentCh).then(r=>r.json()).then(d=>{const p=d.programs||[];const n=p.find(x=>x.now);document.getElementById('vtv-mini-epg').textContent=n?n.time+' '+n.title:(p[0]?p[0].time+' '+p[0].title:'');}).catch(()=>{});
|
| 337 |
+
mini.classList.add('show');mini.classList.remove('hidden');
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
// ===== URL HELPERS =====
|
| 341 |
+
// v8: Try DIRECT URL first, fall back to proxy only on CORS/network error
|
| 342 |
+
function isExternalUrl(url){try{const u=new URL(url);return u.origin!==location.origin;}catch(e){return false;}}
|
| 343 |
+
function proxyUrl(url){if(!url)return url;if(!isExternalUrl(url))return url;if(url.startsWith('/api/proxy/'))return url;return '/api/proxy/m3u8/vtv?url='+encodeURIComponent(url);}
|
| 344 |
+
|
| 345 |
+
// ===== MAIN PLAYER =====
|
| 346 |
+
async function loadAllStreams(){
|
| 347 |
+
if(_loading)return;_loading=true;
|
| 348 |
+
const el=document.getElementById('vtv-load');if(el)el.innerHTML='<div class="vtv-spinner"></div>Đang tải kênh...';
|
| 349 |
+
try{
|
| 350 |
+
const r=await fetch('/api/vtv/streams',{signal:AbortSignal.timeout(10000)});
|
| 351 |
+
if(r.ok){
|
| 352 |
+
const d=await r.json();
|
| 353 |
+
CHANNELS.forEach(ch=>{
|
| 354 |
+
const i=d[ch.id];
|
| 355 |
+
if(i&&i.stream_url){
|
| 356 |
+
// Store BOTH direct and proxied URLs - try direct first
|
| 357 |
+
STREAMS[ch.id]=[i.stream_url, proxyUrl(i.stream_url)];
|
| 358 |
+
} else STREAMS[ch.id]=[];
|
| 359 |
+
});
|
| 360 |
+
}
|
| 361 |
+
}catch(e){console.warn('loadAllStreams error:',e);}
|
| 362 |
+
CHANNELS.forEach(ch=>{
|
| 363 |
+
const t=document.getElementById('vtvt-'+ch.id);
|
| 364 |
+
if(t){
|
| 365 |
+
if(STREAMS[ch.id]&&STREAMS[ch.id].length>0){t.classList.remove('off');t.textContent=ch.name;}
|
| 366 |
+
else{t.style.opacity='0.35';t.textContent=ch.name+' ✕';}
|
| 367 |
+
}
|
| 368 |
+
});
|
| 369 |
+
_loading=false;_streamsLoaded=true;
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
function buildBlock(){
|
| 373 |
+
const w=document.createElement('div');w.className='vtv-wrap';w.id='vtv-block';
|
| 374 |
+
let tabs='';CHANNELS.forEach(ch=>{tabs+='<button class="vtv-tab off" id="vtvt-'+ch.id+'" onclick="window._vtvPlay(\''+ch.id+'\')">'+ch.name+'</button>';});
|
| 375 |
+
w.innerHTML='<div class="vtv-head"><span class="vtv-title">📺 VTV Trực Tuyến</span><span class="vtv-badge">● LIVE</span></div>'+
|
| 376 |
+
'<div class="vtv-tabs">'+tabs+'</div>'+
|
| 377 |
+
'<div class="vtv-frame" id="vtv-frame">'+
|
| 378 |
+
'<div class="vtv-load" id="vtv-load"><div class="vtv-spinner"></div>Đang tải kênh...</div>'+
|
| 379 |
+
'<video id="vtv-player" playsinline webkit-playsinline muted preload="auto" style="display:none"></video>'+
|
| 380 |
+
'<div class="vtv-err" id="vtv-err" style="display:none"><span id="vtv-err-msg">Không thể tải kênh</span><button onclick="window._vtvRetry()">Thử lại</button></div>'+
|
| 381 |
+
'</div>'+
|
| 382 |
+
'<div class="vtv-controls"><button class="vtv-pip-btn" id="vtv-pip-btn" onclick="window._vtvTogglePiP()"><svg viewBox="0 0 24 24"><rect x="2" y="4" width="20" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><rect x="12" y="10" width="8" height="6" rx="1" fill="currentColor"/></svg>PiP</button><span style="flex:1"></span><span style="font-size:9px;color:#666" id="vtv-status"></span></div>'+
|
| 383 |
+
'<div class="vtv-epg" id="vtv-epg"><div class="vtv-epg-header"><span class="vtv-epg-title">📋 Lịch phát sóng</span><button class="vtv-epg-toggle" id="vtv-epg-toggle" onclick="window._vtvToggleEpg()">Ẩn</button></div><div class="vtv-epg-list" id="vtv-epg-list"><div class="vtv-epg-loading"><div class="vtv-epg-sp"></div>Đang tải...</div></div></div>';
|
| 384 |
+
return w;
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
async function loadEpg(chId){
|
| 388 |
+
const el=document.getElementById('vtv-epg-list');if(!el)return;
|
| 389 |
+
el.innerHTML='<div class="vtv-epg-loading"><div class="vtv-epg-sp"></div>Đang tải lịch...</div>';
|
| 390 |
+
try{const r=await fetch('/api/vtv/epg/'+chId,{signal:AbortSignal.timeout(10000)});if(!r.ok)throw new Error();const d=await r.json();const p=d.programs||[];if(!p.length){el.innerHTML='<div class="vtv-epg-empty">Chưa có lịch</div>';return;}let h='';p.forEach(x=>{const n=x.now?' now':'';h+='<div class="vtv-epg-item'+n+'"><span class="t">'+(x.time||'')+(x.end_time?'-'+x.end_time:'')+'</span> <span class="n">'+(x.title||'')+'</span></div>';});el.innerHTML=h;const ni=el.querySelector('.vtv-epg-item.now');if(ni)ni.scrollIntoView({behavior:'smooth',inline:'center',block:'nearest'});}catch(e){el.innerHTML='<div class="vtv-epg-empty">Không tải được lịch</div>';}
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
window._vtvToggleEpg=function(){const l=document.getElementById('vtv-epg-list'),b=document.getElementById('vtv-epg-toggle');if(!l||!b)return;if(l.style.display==='none'){l.style.display='flex';b.textContent='Ẩn';}else{l.style.display='none';b.textContent='Hiện';}};
|
| 394 |
+
window._vtvTogglePiP=async function(){const v=document.getElementById('vtv-player');const b=document.getElementById('vtv-pip-btn');if(!v)return;try{if(document.pictureInPictureElement===v){await document.exitPictureInPicture();_pipActive=false;if(b)b.classList.remove('on');}else{await v.requestPictureInPicture();_pipActive=true;if(b)b.classList.add('on');}}catch(e){}};
|
| 395 |
+
|
| 396 |
+
function pinBlock(){
|
| 397 |
+
const h=document.getElementById('view-home');if(!h||document.getElementById('vtv-block'))return;
|
| 398 |
+
h.insertBefore(buildBlock(),h.firstChild);_blockInserted=true;
|
| 399 |
+
setTimeout(()=>{setupPinButton();buildInlineRecorder();},500);
|
| 400 |
+
if(!_streamsLoaded){loadAllStreams().then(()=>{setTimeout(()=>window._vtvPlay(DEFAULT_CHANNEL),300);});}
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
window._vtvRetry=function(){if(_currentCh)window._vtvPlay(_currentCh);else loadAllStreams().then(()=>{if(DEFAULT_CHANNEL)window._vtvPlay(DEFAULT_CHANNEL);});};
|
| 404 |
+
|
| 405 |
+
function ensureVideoReady(video){
|
| 406 |
+
video.setAttribute('playsinline','');video.setAttribute('webkit-playsinline','');
|
| 407 |
+
video.setAttribute('controlslist','nodownload');video.style.backgroundColor='#000';
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
window._vtvPlay=function(chId){
|
| 411 |
+
const ch=CHANNELS.find(c=>c.id===chId);if(!ch)return;_currentCh=chId;
|
| 412 |
+
document.querySelectorAll('.vtv-tab').forEach(t=>t.classList.remove('on'));
|
| 413 |
+
const tab=document.getElementById('vtvt-'+chId);if(tab)tab.classList.add('on');
|
| 414 |
+
const video=document.getElementById('vtv-player');const errEl=document.getElementById('vtv-err');const loadEl=document.getElementById('vtv-load');const errMsg=document.getElementById('vtv-err-msg');
|
| 415 |
+
ensureVideoReady(video);
|
| 416 |
+
video.style.display='block';video.style.visibility='hidden';video.style.opacity='0';
|
| 417 |
+
errEl.style.display='none';loadEl.style.display='flex';
|
| 418 |
+
loadEl.innerHTML='<div class="vtv-spinner"></div>Đang kết nối '+ch.name+'...';
|
| 419 |
+
if(_hls){_hls.destroy();_hls=null;}
|
| 420 |
+
const urls=STREAMS[chId]||[];
|
| 421 |
+
if(!urls.length){loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent=chId==='vtvprime'?'VTVPrime: Kênh trả phí.':ch.name+': Không có luồng.';return;}
|
| 422 |
+
_tryPlay(video,urls,0,ch.name,loadEl,errEl,errMsg);
|
| 423 |
+
loadEpg(chId);
|
| 424 |
+
if(_miniActive){document.getElementById('vtv-mini-ch').textContent=ch.name;fetch('/api/vtv/epg/'+_currentCh).then(r=>r.json()).then(d=>{const p=d.programs||[];const n=p.find(x=>x.now);document.getElementById('vtv-mini-epg').textContent=n?n.time+' '+n.title:(p[0]?p[0].time+' '+p[0].title:'');}).catch(()=>{});}
|
| 425 |
+
};
|
| 426 |
+
|
| 427 |
+
function _tryPlay(video,urls,idx,name,loadEl,errEl,errMsg){
|
| 428 |
+
if(idx>=urls.length){loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent=name+': Tất cả nguồn lỗi.';return;}
|
| 429 |
+
const src=urls[idx];
|
| 430 |
+
const isDirect=!src.startsWith('/api/proxy/');
|
| 431 |
+
loadEl.innerHTML='<div class="vtv-spinner"></div>Kết nối '+name+(isDirect?' (trực tiếp)':' (proxy)')+' ('+(idx+1)+'/'+urls.length+')...';
|
| 432 |
+
|
| 433 |
+
if(typeof Hls!=='undefined'&&Hls.isSupported()){
|
| 434 |
+
const hls=new Hls({
|
| 435 |
+
enableWorker:true,
|
| 436 |
+
lowLatencyMode:false,
|
| 437 |
+
startLevel:0,
|
| 438 |
+
capLevelToPlayerSize:true,
|
| 439 |
+
maxBufferLength:30,
|
| 440 |
+
maxMaxBufferLength:120,
|
| 441 |
+
backBufferLength:90,
|
| 442 |
+
// For direct URLs, set withCredentials to include cookies if needed
|
| 443 |
+
xhrSetup:function(xhr,url){
|
| 444 |
+
xhr.open('GET',url,true);
|
| 445 |
+
// Set referer header for FPTPlay
|
| 446 |
+
if(url.includes('fptplay')){
|
| 447 |
+
xhr.setRequestHeader('Referer','https://fptplay.vn/');
|
| 448 |
+
}
|
| 449 |
+
},
|
| 450 |
+
});
|
| 451 |
+
_hls=hls;
|
| 452 |
+
hls.loadSource(src);
|
| 453 |
+
hls.attachMedia(video);
|
| 454 |
+
|
| 455 |
+
hls.on(Hls.Events.MANIFEST_PARSED,()=>{
|
| 456 |
+
loadEl.style.display='none';
|
| 457 |
+
video.style.visibility='visible';
|
| 458 |
+
video.style.opacity='1';
|
| 459 |
+
video.play().then(()=>{console.log('[VTV] Playing:',name);}).catch(e=>{
|
| 460 |
+
console.warn('[VTV] Play failed:',e.message);
|
| 461 |
+
video.muted=true;video.play().catch(()=>{});
|
| 462 |
+
});
|
| 463 |
+
});
|
| 464 |
+
|
| 465 |
+
hls.on(Hls.Events.FRAG_LOADED,()=>{
|
| 466 |
+
if(video.style.visibility==='hidden'){
|
| 467 |
+
video.style.visibility='visible';video.style.opacity='1';loadEl.style.display='none';
|
| 468 |
+
}
|
| 469 |
+
});
|
| 470 |
+
|
| 471 |
+
let retryCount=0;
|
| 472 |
+
hls.on(Hls.Events.ERROR,(ev,data)=>{
|
| 473 |
+
if(data.fatal){
|
| 474 |
+
if(data.type===Hls.ErrorTypes.NETWORK_ERROR){
|
| 475 |
+
retryCount++;
|
| 476 |
+
if(retryCount<=3){
|
| 477 |
+
loadEl.innerHTML='<div class="vtv-spinner"></div>Kết nối lại '+name+' ('+retryCount+'/3)...';
|
| 478 |
+
setTimeout(()=>{hls.startLoad();},1500*retryCount);
|
| 479 |
+
}else{
|
| 480 |
+
// Try next URL (proxy if direct failed, or vice versa)
|
| 481 |
+
hls.destroy();_hls=null;
|
| 482 |
+
_tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);
|
| 483 |
+
}
|
| 484 |
+
}else if(data.type===Hls.ErrorTypes.MEDIA_ERROR){
|
| 485 |
+
try{hls.recoverMediaError();}catch(e){}
|
| 486 |
+
}else{
|
| 487 |
+
hls.destroy();_hls=null;
|
| 488 |
+
_tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);
|
| 489 |
+
}
|
| 490 |
+
}else{
|
| 491 |
+
console.warn('HLS non-fatal:',data.type,data.details);
|
| 492 |
+
}
|
| 493 |
+
});
|
| 494 |
+
}else if(video.canPlayType('application/vnd.apple.mpegurl')){
|
| 495 |
+
video.src=src;
|
| 496 |
+
video.addEventListener('loadedmetadata',()=>{
|
| 497 |
+
loadEl.style.display='none';video.style.visibility='visible';video.style.opacity='1';video.play().catch(()=>{});
|
| 498 |
+
},{once:true});
|
| 499 |
+
video.addEventListener('playing',()=>{
|
| 500 |
+
loadEl.style.display='none';video.style.visibility='visible';video.style.opacity='1';
|
| 501 |
+
},{once:true});
|
| 502 |
+
video.addEventListener('error',()=>{_tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);},{once:true});
|
| 503 |
+
}else{
|
| 504 |
+
loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent='Không hỗ trợ HLS';
|
| 505 |
+
}
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
// ===== NAV HOOKS =====
|
| 509 |
+
const _origShowView=window.showView;
|
| 510 |
+
window.showView=function(id){
|
| 511 |
+
if(id==='view-home'&&_miniActive)closeMiniPlayer();
|
| 512 |
+
else if(id!=='view-home'&&_currentCh&&STREAMS[_currentCh]){
|
| 513 |
+
const block=document.getElementById('vtv-block');
|
| 514 |
+
if(block&&!document.getElementById('view-home').contains(block)){
|
| 515 |
+
activateMiniPlayer();
|
| 516 |
+
}
|
| 517 |
+
}
|
| 518 |
+
if(_origShowView)_origShowView(id);
|
| 519 |
+
};
|
| 520 |
+
|
| 521 |
+
const _origSwitchCat=window.switchCat;
|
| 522 |
+
window.switchCat=function(cat){
|
| 523 |
+
if(cat==='home'||_cat==='home'){
|
| 524 |
+
if(!_blockInserted&&!document.getElementById('vtv-block'))pinBlock();
|
| 525 |
+
else if(!document.getElementById('vtv-block'))pinBlock();
|
| 526 |
+
}
|
| 527 |
+
if(_origSwitchCat)_origSwitchCat(cat);
|
| 528 |
+
};
|
| 529 |
+
|
| 530 |
+
// Auto-insert on home
|
| 531 |
+
if(document.getElementById('view-home')&&!document.getElementById('vtv-block')){
|
| 532 |
+
pinBlock();
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
// Expose for manual init
|
| 536 |
+
window._vtvPinBlock=pinBlock;
|
| 537 |
+
window._vtvLoadStreams=loadAllStreams;
|
| 538 |
+
window._vtvPlayChannel=window._vtvPlay;
|
| 539 |
+
|
| 540 |
+
})();
|
vtv_api.py
CHANGED
|
@@ -1,227 +1,543 @@
|
|
| 1 |
-
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
from fastapi import APIRouter, Query
|
| 4 |
-
from fastapi.responses import JSONResponse, Response
|
| 5 |
from bs4 import BeautifulSoup
|
| 6 |
from datetime import datetime, timedelta, timezone
|
| 7 |
from urllib.parse import quote
|
| 8 |
|
| 9 |
VN_TZ = timezone(timedelta(hours=7))
|
|
|
|
| 10 |
router = APIRouter()
|
| 11 |
|
| 12 |
UA = {
|
| 13 |
"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",
|
| 14 |
"Accept-Language": "vi-VN,vi;q=0.9",
|
|
|
|
| 15 |
}
|
| 16 |
|
| 17 |
-
|
| 18 |
-
"
|
| 19 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
}
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
"
|
| 28 |
-
"
|
| 29 |
-
"
|
| 30 |
-
"
|
| 31 |
-
"
|
| 32 |
-
"
|
| 33 |
-
|
| 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 |
-
|
| 41 |
-
|
| 42 |
-
"https://
|
| 43 |
-
"https://
|
| 44 |
-
"https://
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
@router.get("/api/vtv/streams")
|
| 99 |
def api_vtv_streams():
|
| 100 |
-
urls = dict(CHANNEL_URLS)
|
| 101 |
result = {}
|
| 102 |
for ch_id in CHANNEL_NAMES:
|
| 103 |
-
stream_url =
|
| 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 |
-
"
|
| 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 |
-
|
| 120 |
-
if
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
"
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
"
|
| 135 |
-
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 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 |
-
|
| 151 |
-
def _parse_time(time_str, reference_date=None):
|
| 152 |
-
if not time_str: return None
|
| 153 |
-
time_str = time_str.strip().replace("h",":").replace("H",":")
|
| 154 |
-
m = re.search(r'(\d{1,2}):(\d{2})', time_str)
|
| 155 |
-
if m:
|
| 156 |
-
try:
|
| 157 |
-
hour, minute = int(m.group(1)), int(m.group(2))
|
| 158 |
-
base = reference_date or datetime.now(VN_TZ)
|
| 159 |
-
if hour < 5: base = base - timedelta(days=1) if base.hour >= 5 else base
|
| 160 |
-
dt = base.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
| 161 |
-
if dt.tzinfo is None: dt = dt.replace(tzinfo=VN_TZ)
|
| 162 |
-
return dt
|
| 163 |
-
except: pass
|
| 164 |
-
return None
|
| 165 |
|
| 166 |
def _fetch_epg_from_vtv():
|
| 167 |
global _epg_cache, _epg_cache_time
|
| 168 |
now_ts = time.time()
|
| 169 |
-
if _epg_cache and now_ts - _epg_cache_time < _EPG_CACHE_TTL:
|
|
|
|
| 170 |
epg_data = {}
|
| 171 |
-
now_vn = datetime.now(VN_TZ)
|
| 172 |
try:
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
r.encoding = "utf-8"
|
| 177 |
soup = BeautifulSoup(r.text, "lxml")
|
| 178 |
channel_order = []
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
for li in container.find_all('li', class_=re.compile(r'\bprogram\b')):
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
time_str =
|
| 193 |
-
title =
|
| 194 |
-
if
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
return epg_data
|
| 203 |
|
| 204 |
-
|
| 205 |
-
def
|
| 206 |
-
|
| 207 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
epg_data = _fetch_epg_from_vtv()
|
| 209 |
programmes = epg_data.get(channel_id, [])
|
|
|
|
|
|
|
| 210 |
now = datetime.now(VN_TZ)
|
| 211 |
-
today = now.date()
|
| 212 |
result = []
|
| 213 |
-
|
| 214 |
-
for i, p in enumerate(today_progs):
|
| 215 |
start_dt = p.get("start_dt")
|
| 216 |
-
stop_dt =
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
|
| 222 |
@router.get("/api/vtv/epg")
|
| 223 |
def api_vtv_epg_refresh():
|
| 224 |
global _epg_cache, _epg_cache_time
|
| 225 |
-
_epg_cache = {}
|
|
|
|
| 226 |
epg_data = _fetch_epg_from_vtv()
|
| 227 |
-
return JSONResponse({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
VTV Channels API - Backend endpoints for VTV1-VTV10 + VTVPrime
|
| 3 |
+
Fetches stream URLs from multiple sources with fallback chain:
|
| 4 |
+
1. FPTPlay CDN - primary (direct URLs, client fetches directly)
|
| 5 |
+
2. VTVGO failover (vtvdigital.vn) - secondary
|
| 6 |
+
3. canthotv.vn - fallback for VTV6/VTV10
|
| 7 |
+
4. xemtv.net PHP endpoints - last resort (often blocked by Cloudflare)
|
| 8 |
+
|
| 9 |
+
KEY CHANGE: Stream URLs are returned DIRECTLY to client (not proxied through server).
|
| 10 |
+
This avoids server-side 403/404 issues since the client browser (with VPN) fetches directly.
|
| 11 |
+
CORS is handled by the client-side player using hls.js with corsConfig.
|
| 12 |
+
"""
|
| 13 |
+
import re, time, threading
|
| 14 |
+
import requests
|
| 15 |
+
import urllib3
|
| 16 |
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
| 17 |
from fastapi import APIRouter, Query
|
| 18 |
+
from fastapi.responses import JSONResponse, Response
|
| 19 |
from bs4 import BeautifulSoup
|
| 20 |
from datetime import datetime, timedelta, timezone
|
| 21 |
from urllib.parse import quote
|
| 22 |
|
| 23 |
VN_TZ = timezone(timedelta(hours=7))
|
| 24 |
+
|
| 25 |
router = APIRouter()
|
| 26 |
|
| 27 |
UA = {
|
| 28 |
"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",
|
| 29 |
"Accept-Language": "vi-VN,vi;q=0.9",
|
| 30 |
+
"Referer": "https://fptplay.vn/",
|
| 31 |
}
|
| 32 |
|
| 33 |
+
CHANNEL_NAMES = {
|
| 34 |
+
"vtv1": "VTV1",
|
| 35 |
+
"vtv2": "VTV2",
|
| 36 |
+
"vtv3": "VTV3",
|
| 37 |
+
"vtv4": "VTV4",
|
| 38 |
+
"vtv5": "VTV5",
|
| 39 |
+
"vtv6": "VTV6",
|
| 40 |
+
"vtv7": "VTV7",
|
| 41 |
+
"vtv8": "VTV8",
|
| 42 |
+
"vtv9": "VTV9",
|
| 43 |
+
"vtv10": "VTV10",
|
| 44 |
+
"vtvprime": "VTVPrime",
|
| 45 |
}
|
| 46 |
+
|
| 47 |
+
# ===== FPTPlay CDN - PRIMARY source =====
|
| 48 |
+
FPTPLAY_URLS = {
|
| 49 |
+
"vtv1": "https://live.fptplay53.net/fnxch2/vtv1hd_abr.smil/chunklist.m3u8",
|
| 50 |
+
"vtv2": "https://live.fptplay53.net/fnxch2/vtv2hd_abr.smil/chunklist.m3u8",
|
| 51 |
+
"vtv3": "https://live.fptplay53.net/fnxch2/vtv3hd_abr.smil/chunklist.m3u8",
|
| 52 |
+
"vtv4": "https://live.fptplay53.net/fnxch2/vtv4hd_abr.smil/chunklist.m3u8",
|
| 53 |
+
"vtv5": "https://live.fptplay53.net/fnxhd1/vtv5hd_vhls.smil/chunklist.m3u8",
|
| 54 |
+
"vtv6": "https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/vtv6-avc1_5600000=10000-mp4a_131600=20000.m3u8",
|
| 55 |
+
"vtv7": "https://live.fptplay53.net/fnxhd1/vtv7hd_vhls.smil/chunklist.m3u8",
|
| 56 |
+
"vtv8": "https://live.fptplay53.net/epzhd1/vtv8hd_vhls.smil/chunklist.m3u8",
|
| 57 |
+
"vtv9": "https://live.fptplay53.net/fnxhd1/vtv9hd_vhls.smil/chunklist.m3u8",
|
| 58 |
+
"vtv10": "https://live.fptplay53.net/fnxch2/vtvcantho_abr.smil/chunklist.m3u8",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
}
|
| 60 |
|
| 61 |
+
# ===== VTVGO failover CDN - SECONDARY source =====
|
| 62 |
+
VTVGO_FAILOVER = {
|
| 63 |
+
"vtv1": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8",
|
| 64 |
+
"vtv2": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv2-manifest.m3u8",
|
| 65 |
+
"vtv3": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv3-manifest.m3u8",
|
| 66 |
+
"vtv4": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv4-manifest.m3u8",
|
| 67 |
+
"vtv5": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv5-manifest.m3u8",
|
| 68 |
+
"vtv6": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv6-manifest.m3u8",
|
| 69 |
+
"vtv7": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv7-manifest.m3u8",
|
| 70 |
+
"vtv8": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv8-manifest.m3u8",
|
| 71 |
+
"vtv9": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv9-manifest.m3u8",
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
# ===== canthotv.vn - FALLBACK =====
|
| 75 |
+
CANTHOTV_URLS = {
|
| 76 |
+
"vtv6": "https://live.canthotv.vn/live/tv/chunklist.m3u8",
|
| 77 |
+
"vtv10": "https://live.canthotv.vn/live/tv/chunklist.m3u8",
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
# ===== xemtv.net PHP endpoints - LAST RESORT =====
|
| 81 |
+
XEMTV_PHP_ENDPOINTS = {
|
| 82 |
+
"vtv1": "https://hd.xemtv.net/kenh/vtv1.php",
|
| 83 |
+
"vtv2": "https://hd.xemtv.net/kenh/vtv2.php",
|
| 84 |
+
"vtv3": "https://hd.xemtv.net/kenh/vtv3.php",
|
| 85 |
+
"vtv4": "https://hd.xemtv.net/kenh/vtv4.php",
|
| 86 |
+
"vtv5": "https://hd.xemtv.net/kenh/vtv5.php",
|
| 87 |
+
"vtv6": "https://hd.xemtv.net/kenh/vtv6.php",
|
| 88 |
+
"vtv7": "https://hd.xemtv.net/kenh/vtv7.php",
|
| 89 |
+
"vtv8": "https://hd.xemtv.net/kenh/vtv8.php",
|
| 90 |
+
"vtv9": "https://hd.xemtv.net/kenh/vtv9.php",
|
| 91 |
+
"vtv10": "https://hd.xemtv.net/kenh/vtv10.php",
|
| 92 |
+
"vtvprime": "https://hd.xemtv.net/kenh/vtvprime.php",
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
_vtv_cache = {}
|
| 96 |
+
_vtv_lock = threading.Lock()
|
| 97 |
+
_CACHE_TTL = 120
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _cached(key):
|
| 101 |
+
with _vtv_lock:
|
| 102 |
+
if key in _vtv_cache and time.time() - _vtv_cache[key]['t'] < _CACHE_TTL:
|
| 103 |
+
return _vtv_cache[key]['d']
|
| 104 |
+
return None
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _set_cache(key, data):
|
| 108 |
+
with _vtv_lock:
|
| 109 |
+
_vtv_cache[key] = {'t': time.time(), 'd': data}
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def extract_m3u8_from_html(html):
|
| 113 |
+
if not html:
|
| 114 |
+
return None
|
| 115 |
+
m = re.search(r"file\s*:\s*['\"]([^'\"]*\.m3u8[^'\"]*)['\"]", html, re.IGNORECASE)
|
| 116 |
+
if m:
|
| 117 |
+
url = m.group(1).strip()
|
| 118 |
+
if len(url) > 20:
|
| 119 |
+
return url
|
| 120 |
+
m = re.search(r"(https?://[^\s\"'<>\\]+\.m3u8[^\s\"'<>\\]*)", html, re.IGNORECASE)
|
| 121 |
+
if m:
|
| 122 |
+
url = m.group(1).strip()
|
| 123 |
+
if len(url) > 20:
|
| 124 |
+
return url
|
| 125 |
+
return None
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def fetch_xemtv_stream(channel_id):
|
| 129 |
+
php_url = XEMTV_PHP_ENDPOINTS.get(channel_id)
|
| 130 |
+
if not php_url:
|
| 131 |
+
return None
|
| 132 |
+
try:
|
| 133 |
+
headers = {
|
| 134 |
+
"User-Agent": UA["User-Agent"],
|
| 135 |
+
"Accept-Language": "vi-VN,vi;q=0.9",
|
| 136 |
+
"Referer": "https://hd.xemtv.net/",
|
| 137 |
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
| 138 |
+
}
|
| 139 |
+
r = requests.get(php_url, headers=headers, timeout=15, allow_redirects=True)
|
| 140 |
+
if r.status_code == 200 and "cloudflare" not in r.text.lower() and "just a moment" not in r.text.lower():
|
| 141 |
+
m3u8 = extract_m3u8_from_html(r.text)
|
| 142 |
+
if m3u8:
|
| 143 |
+
return m3u8
|
| 144 |
+
except:
|
| 145 |
+
pass
|
| 146 |
+
return None
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _verify_stream_url(url, timeout=8):
|
| 150 |
+
if not url:
|
| 151 |
+
return False
|
| 152 |
+
try:
|
| 153 |
+
headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
|
| 154 |
+
if "vtvdigital" in url:
|
| 155 |
+
headers["Referer"] = "https://vtv.vn/"
|
| 156 |
+
elif "fptplay" in url:
|
| 157 |
+
headers["Referer"] = "https://fptplay.vn/"
|
| 158 |
+
headers["Origin"] = "https://fptplay.vn"
|
| 159 |
+
elif "canthotv" in url:
|
| 160 |
+
headers["Referer"] = "https://canthotv.vn/"
|
| 161 |
+
verify_ssl = "vtvdigital" not in url
|
| 162 |
+
r = requests.get(url, headers=headers, timeout=timeout, allow_redirects=True, verify=verify_ssl)
|
| 163 |
+
if r.status_code == 200 and b"#EXTM3U" in r.content[:500]:
|
| 164 |
+
lines = r.text.strip().split('\n')
|
| 165 |
+
has_ts = any('.ts' in line.lower() for line in lines if not line.startswith('#'))
|
| 166 |
+
has_sub = any('.m3u8' in line.lower() for line in lines if not line.startswith('#'))
|
| 167 |
+
if has_ts:
|
| 168 |
+
return True
|
| 169 |
+
if has_sub:
|
| 170 |
+
for line in lines:
|
| 171 |
+
line = line.strip()
|
| 172 |
+
if line and not line.startswith('#') and '.m3u8' in line:
|
| 173 |
+
sub_url = line if line.startswith('http') else url.rsplit('/', 1)[0] + '/' + line
|
| 174 |
+
try:
|
| 175 |
+
sub_r = requests.get(sub_url, headers=headers, timeout=timeout, allow_redirects=True, verify=verify_ssl)
|
| 176 |
+
if sub_r.status_code == 200 and b"#EXTM3U" in sub_r.content[:500]:
|
| 177 |
+
sub_lines = sub_r.text.strip().split('\n')
|
| 178 |
+
has_real_ts = any('.ts' in l.lower() for l in sub_lines if not l.startswith('#'))
|
| 179 |
+
if has_real_ts:
|
| 180 |
+
return True
|
| 181 |
+
except:
|
| 182 |
+
pass
|
| 183 |
+
break
|
| 184 |
+
return False
|
| 185 |
+
except:
|
| 186 |
+
return False
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def fetch_vtv_stream(channel_id):
|
| 190 |
+
channel_id = channel_id.lower().strip()
|
| 191 |
+
name_map = {
|
| 192 |
+
'vtvct': 'vtv10', 'vtv-can-tho': 'vtv10', 'vtv can tho': 'vtv10',
|
| 193 |
+
'vtv_can_tho': 'vtv10', 'cantho': 'vtv10',
|
| 194 |
+
'vietnam_vtv1': 'vtv1', 'vietnam_vtv2': 'vtv2', 'vietnam_vtv3': 'vtv3',
|
| 195 |
+
'vietnam_vtv4': 'vtv4', 'vietnam_vtv5': 'vtv5', 'vietnam_vtv6': 'vtv6',
|
| 196 |
+
'vietnam_vtv7': 'vtv7', 'vietnam_vtv8': 'vtv8', 'vietnam_vtv9': 'vtv9',
|
| 197 |
+
}
|
| 198 |
+
channel_id = name_map.get(channel_id, channel_id)
|
| 199 |
+
cached = _cached(channel_id)
|
| 200 |
+
if cached is not None:
|
| 201 |
+
return cached
|
| 202 |
+
result = None
|
| 203 |
+
if channel_id == 'vtvprime':
|
| 204 |
+
xemtv_url = fetch_xemtv_stream('vtvprime')
|
| 205 |
+
if xemtv_url:
|
| 206 |
+
result = xemtv_url
|
| 207 |
+
_set_cache(channel_id, result)
|
| 208 |
+
return result
|
| 209 |
+
_set_cache(channel_id, None)
|
| 210 |
+
return None
|
| 211 |
+
sources = []
|
| 212 |
+
fpt_url = FPTPLAY_URLS.get(channel_id)
|
| 213 |
+
if fpt_url:
|
| 214 |
+
sources.append(fpt_url)
|
| 215 |
+
vtvgo_url = VTVGO_FAILOVER.get(channel_id)
|
| 216 |
+
if vtvgo_url:
|
| 217 |
+
sources.append(vtvgo_url)
|
| 218 |
+
cantho_url = CANTHOTV_URLS.get(channel_id)
|
| 219 |
+
if cantho_url:
|
| 220 |
+
sources.append(cantho_url)
|
| 221 |
+
xemtv_url = fetch_xemtv_stream(channel_id)
|
| 222 |
+
if xemtv_url:
|
| 223 |
+
sources.append(xemtv_url)
|
| 224 |
+
for src in sources:
|
| 225 |
+
if _verify_stream_url(src):
|
| 226 |
+
result = src
|
| 227 |
+
break
|
| 228 |
+
if result is None and sources:
|
| 229 |
+
result = sources[0]
|
| 230 |
+
_set_cache(channel_id, result)
|
| 231 |
+
return result
|
| 232 |
+
|
| 233 |
|
| 234 |
@router.get("/api/vtv/streams")
|
| 235 |
def api_vtv_streams():
|
|
|
|
| 236 |
result = {}
|
| 237 |
for ch_id in CHANNEL_NAMES:
|
| 238 |
+
stream_url = fetch_vtv_stream(ch_id)
|
|
|
|
|
|
|
| 239 |
result[ch_id] = {
|
| 240 |
"name": CHANNEL_NAMES[ch_id],
|
| 241 |
"stream_url": stream_url,
|
| 242 |
+
"status": "ok" if stream_url else "offline"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
}
|
| 244 |
return JSONResponse(result)
|
| 245 |
|
| 246 |
+
|
| 247 |
@router.get("/api/vtv/stream/{channel_id}")
|
| 248 |
def api_vtv_stream(channel_id: str):
|
| 249 |
+
stream_url = fetch_vtv_stream(channel_id)
|
| 250 |
+
if stream_url:
|
| 251 |
+
return JSONResponse({"stream_url": stream_url, "status": "ok"})
|
| 252 |
+
return JSONResponse({"error": "stream not found", "status": "offline"}, status_code=404)
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
@router.get("/api/proxy/page")
|
| 256 |
+
def proxy_page(url: str = Query(...)):
|
| 257 |
+
try:
|
| 258 |
+
headers = {**UA}
|
| 259 |
+
if "xemtv.net" in url:
|
| 260 |
+
headers["Referer"] = "https://hd.xemtv.net/"
|
| 261 |
+
r = requests.get(url, headers=headers, timeout=15, allow_redirects=True)
|
| 262 |
+
if r.status_code != 200:
|
| 263 |
+
return Response(status_code=502, content="upstream error")
|
| 264 |
+
return Response(content=r.text.encode("utf-8"), media_type="text/html; charset=utf-8",
|
| 265 |
+
headers={"Access-Control-Allow-Origin": "*"})
|
| 266 |
+
except:
|
| 267 |
+
return Response(status_code=502, content="proxy error")
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
@router.get("/api/proxy/m3u8/vtv")
|
| 271 |
+
def proxy_vtv_m3u8(url: str = Query(...)):
|
| 272 |
+
try:
|
| 273 |
+
headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
|
| 274 |
+
if "fptplay" in url:
|
| 275 |
+
headers["Referer"] = "https://fptplay.vn/"
|
| 276 |
+
headers["Origin"] = "https://fptplay.vn"
|
| 277 |
+
elif "xemtv" in url:
|
| 278 |
+
headers["Referer"] = "https://hd.xemtv.net/"
|
| 279 |
+
elif "vtvdigital" in url:
|
| 280 |
+
headers["Referer"] = "https://vtv.vn/"
|
| 281 |
+
headers["Origin"] = "https://vtv.vn"
|
| 282 |
+
elif "canthotv" in url:
|
| 283 |
+
headers["Referer"] = "https://canthotv.vn/"
|
| 284 |
+
verify_ssl = "vtvdigital" not in url
|
| 285 |
+
r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=verify_ssl)
|
| 286 |
+
if r.status_code != 200:
|
| 287 |
+
return Response(status_code=502, content="upstream error")
|
| 288 |
+
content = r.text
|
| 289 |
+
lines = content.split('\n')
|
| 290 |
+
rewritten = []
|
| 291 |
+
base_url = url.rsplit('/', 1)[0] + '/'
|
| 292 |
+
for line in lines:
|
| 293 |
+
line = line.strip()
|
| 294 |
+
if not line:
|
| 295 |
+
rewritten.append(line)
|
| 296 |
+
continue
|
| 297 |
+
if line.startswith('#'):
|
| 298 |
+
if 'URI=' in line:
|
| 299 |
+
line = re.sub(
|
| 300 |
+
r'URI="([^"]+)"',
|
| 301 |
+
lambda m: 'URI="' + ("/api/proxy/seg/vtv?url=" + quote(m.group(1), safe="")
|
| 302 |
+
if not m.group(1).startswith('http')
|
| 303 |
+
else "/api/proxy/seg/vtv?url=" + quote(m.group(1), safe="")) + '"',
|
| 304 |
+
line
|
| 305 |
+
)
|
| 306 |
+
rewritten.append(line)
|
| 307 |
+
continue
|
| 308 |
+
seg_url = line
|
| 309 |
+
if not seg_url.startswith('http'):
|
| 310 |
+
seg_url = base_url + seg_url
|
| 311 |
+
proxy_seg = "/api/proxy/seg/vtv?url=" + quote(seg_url, safe="")
|
| 312 |
+
rewritten.append(proxy_seg)
|
| 313 |
+
return Response(
|
| 314 |
+
content='\n'.join(rewritten).encode("utf-8"),
|
| 315 |
+
media_type="application/vnd.apple.mpegurl",
|
| 316 |
+
headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "no-cache"}
|
| 317 |
+
)
|
| 318 |
+
except Exception as e:
|
| 319 |
+
return Response(status_code=502, content="proxy error: " + str(e))
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
@router.get("/api/proxy/seg/vtv")
|
| 323 |
+
def proxy_vtv_segment(url: str = Query(...)):
|
| 324 |
+
try:
|
| 325 |
+
headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
|
| 326 |
+
if "fptplay" in url:
|
| 327 |
+
headers["Referer"] = "https://fptplay.vn/"
|
| 328 |
+
headers["Origin"] = "https://fptplay.vn"
|
| 329 |
+
elif "vtvdigital" in url:
|
| 330 |
+
headers["Referer"] = "https://vtv.vn/"
|
| 331 |
+
headers["Origin"] = "https://vtv.vn"
|
| 332 |
+
elif "canthotv" in url:
|
| 333 |
+
headers["Referer"] = "https://canthotv.vn/"
|
| 334 |
+
verify_ssl = "vtvdigital" not in url
|
| 335 |
+
r = requests.get(url, headers=headers, timeout=30, allow_redirects=True, verify=verify_ssl)
|
| 336 |
+
if r.status_code != 200:
|
| 337 |
+
return Response(status_code=502, content="upstream error")
|
| 338 |
+
data = r.content
|
| 339 |
+
if b"#EXTM3U" in data[:500] or ".m3u8" in url:
|
| 340 |
+
lines = data.decode("utf-8", errors="replace").split('\n')
|
| 341 |
+
rewritten = []
|
| 342 |
+
base_url = url.rsplit('/', 1)[0] + '/'
|
| 343 |
+
for line in lines:
|
| 344 |
+
line_s = line.strip()
|
| 345 |
+
if not line_s or line_s.startswith('#'):
|
| 346 |
+
if 'URI=' in line_s:
|
| 347 |
+
line_s = re.sub(
|
| 348 |
+
r'URI="([^"]+)"',
|
| 349 |
+
lambda m: 'URI="' + ("/api/proxy/seg/vtv?url=" + quote(m.group(1), safe="")
|
| 350 |
+
if not m.group(1).startswith('http')
|
| 351 |
+
else "/api/proxy/seg/vtv?url=" + quote(m.group(1), safe="")) + '"',
|
| 352 |
+
line_s
|
| 353 |
+
)
|
| 354 |
+
rewritten.append(line_s)
|
| 355 |
+
else:
|
| 356 |
+
seg_url = line_s
|
| 357 |
+
if not seg_url.startswith('http'):
|
| 358 |
+
seg_url = base_url + seg_url
|
| 359 |
+
rewritten.append("/api/proxy/seg/vtv?url=" + quote(seg_url, safe=""))
|
| 360 |
+
return Response(
|
| 361 |
+
content='\n'.join(rewritten).encode("utf-8"),
|
| 362 |
+
media_type="application/vnd.apple.mpegurl",
|
| 363 |
+
headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "no-cache"}
|
| 364 |
+
)
|
| 365 |
+
if len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47:
|
| 366 |
+
data = data[188:]
|
| 367 |
+
return Response(
|
| 368 |
+
content=data,
|
| 369 |
+
media_type="video/mp2t",
|
| 370 |
+
headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600"}
|
| 371 |
+
)
|
| 372 |
+
except:
|
| 373 |
+
return Response(status_code=502, content="proxy error")
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
@router.get("/api/cors-proxy")
|
| 377 |
+
def cors_proxy(url: str = Query(...)):
|
| 378 |
+
try:
|
| 379 |
+
headers = {"User-Agent": UA["User-Agent"]}
|
| 380 |
+
if "fptplay" in url:
|
| 381 |
+
headers["Referer"] = "https://fptplay.vn/"
|
| 382 |
+
headers["Origin"] = "https://fptplay.vn"
|
| 383 |
+
elif "vtvdigital" in url:
|
| 384 |
+
headers["Referer"] = "https://vtv.vn/"
|
| 385 |
+
elif "canthotv" in url:
|
| 386 |
+
headers["Referer"] = "https://canthotv.vn/"
|
| 387 |
+
verify_ssl = "vtvdigital" not in url
|
| 388 |
+
r = requests.get(url, headers=headers, timeout=30, allow_redirects=True, verify=verify_ssl)
|
| 389 |
+
resp_headers = {"Access-Control-Allow-Origin": "*"}
|
| 390 |
+
if "Content-Type" in r.headers:
|
| 391 |
+
resp_headers["Content-Type"] = r.headers["Content-Type"]
|
| 392 |
+
return Response(content=r.content, status_code=r.status_code, headers=resp_headers)
|
| 393 |
+
except Exception as e:
|
| 394 |
+
return Response(status_code=502, content=str(e))
|
| 395 |
|
| 396 |
+
|
| 397 |
+
# ===== EPG =====
|
| 398 |
+
_epg_cache = {}
|
| 399 |
+
_epg_cache_time = 0
|
| 400 |
+
_EPG_CACHE_TTL = 1800
|
| 401 |
+
|
| 402 |
+
VTV_CHANNEL_MAP = {
|
| 403 |
+
"vtv1": "vtv1", "vtv2": "vtv2", "vtv3": "vtv3", "vtv4": "vtv4",
|
| 404 |
+
"vtv5": "vtv5", "vtv5-tay-nam-bo": "vtv5", "vtv5-tay-nguyen": "vtv5",
|
| 405 |
+
"vtv7": "vtv7", "vtv8": "vtv8", "vtv9": "vtv9", "vtv-can-tho": "vtv10",
|
| 406 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
|
| 408 |
def _fetch_epg_from_vtv():
|
| 409 |
global _epg_cache, _epg_cache_time
|
| 410 |
now_ts = time.time()
|
| 411 |
+
if _epg_cache and now_ts - _epg_cache_time < _EPG_CACHE_TTL:
|
| 412 |
+
return _epg_cache
|
| 413 |
epg_data = {}
|
|
|
|
| 414 |
try:
|
| 415 |
+
headers = {
|
| 416 |
+
"User-Agent": UA["User-Agent"],
|
| 417 |
+
"Accept-Language": "vi-VN,vi;q=0.9",
|
| 418 |
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
| 419 |
+
"Referer": "https://vtv.vn/",
|
| 420 |
+
}
|
| 421 |
+
r = requests.get("https://vtv.vn/lich-phat-song.htm", headers=headers, timeout=20)
|
| 422 |
+
if r.status_code != 200:
|
| 423 |
+
return epg_data
|
| 424 |
r.encoding = "utf-8"
|
| 425 |
soup = BeautifulSoup(r.text, "lxml")
|
| 426 |
channel_order = []
|
| 427 |
+
list_channel = soup.find(class_=re.compile(r'list-channel'))
|
| 428 |
+
if list_channel:
|
| 429 |
+
for link in list_channel.find_all('a', href=re.compile(r'truyen-hinh-truc-tuyen/([^.]+)\.htm')):
|
| 430 |
+
ch_id = re.search(r'truyen-hinh-truc-tuyen/([^.]+)\.htm', link.get('href', ''))
|
| 431 |
+
if ch_id:
|
| 432 |
+
channel_order.append(ch_id.group(1))
|
| 433 |
+
if not channel_order:
|
| 434 |
+
for link in soup.find_all('a', href=re.compile(r'truyen-hinh-truc-tuyen/([^.]+)\.htm')):
|
| 435 |
+
ch_id = re.search(r'truyen-hinh-truc-tuyen/([^.]+)\.htm', link.get('href', ''))
|
| 436 |
+
if ch_id and ch_id.group(1) not in channel_order:
|
| 437 |
+
channel_order.append(ch_id.group(1))
|
| 438 |
+
prog_containers = soup.find_all('ul', class_=re.compile(r'\bprograms\b'))
|
| 439 |
+
for i, container in enumerate(prog_containers):
|
| 440 |
+
if i >= len(channel_order):
|
| 441 |
+
break
|
| 442 |
+
vtv_ch_id = channel_order[i]
|
| 443 |
+
our_ch_id = VTV_CHANNEL_MAP.get(vtv_ch_id, vtv_ch_id)
|
| 444 |
+
if our_ch_id not in epg_data:
|
| 445 |
+
epg_data[our_ch_id] = []
|
| 446 |
for li in container.find_all('li', class_=re.compile(r'\bprogram\b')):
|
| 447 |
+
time_span = li.find('span', class_=re.compile(r'\btime\b'))
|
| 448 |
+
title_span = li.find('span', class_=re.compile(r'\btitle\b'))
|
| 449 |
+
genre_a = li.find('a', class_=re.compile(r'\bgenre\b'))
|
| 450 |
+
time_str = time_span.get_text(strip=True) if time_span else ""
|
| 451 |
+
title = ""
|
| 452 |
+
if genre_a:
|
| 453 |
+
title = genre_a.get_text(strip=True)
|
| 454 |
+
if not title and title_span:
|
| 455 |
+
title = title_span.get_text(strip=True)
|
| 456 |
+
if not time_str or not title:
|
| 457 |
+
continue
|
| 458 |
+
start_dt = _parse_time(time_str)
|
| 459 |
+
if not start_dt:
|
| 460 |
+
continue
|
| 461 |
+
epg_data[our_ch_id].append({"time": time_str[:5], "title": title[:80], "start_dt": start_dt})
|
| 462 |
+
for ch_id in epg_data:
|
| 463 |
+
epg_data[ch_id].sort(key=lambda x: x.get("start_dt") or datetime.min)
|
| 464 |
+
seen = set()
|
| 465 |
+
unique = []
|
| 466 |
+
for p in epg_data[ch_id]:
|
| 467 |
+
key = (p["time"], p["title"])
|
| 468 |
+
if key not in seen:
|
| 469 |
+
seen.add(key)
|
| 470 |
+
unique.append(p)
|
| 471 |
+
epg_data[ch_id] = unique
|
| 472 |
+
except Exception as e:
|
| 473 |
+
print(f"EPG error: {e}")
|
| 474 |
+
_epg_cache = epg_data
|
| 475 |
+
_epg_cache_time = now_ts
|
| 476 |
return epg_data
|
| 477 |
|
| 478 |
+
|
| 479 |
+
def _parse_time(time_str):
|
| 480 |
+
if not time_str:
|
| 481 |
+
return None
|
| 482 |
+
time_str = time_str.strip().replace("h", ":").replace("H", ":")
|
| 483 |
+
m = re.search(r'(\d{1,2}):(\d{2})', time_str)
|
| 484 |
+
if m:
|
| 485 |
+
try:
|
| 486 |
+
hour, minute = int(m.group(1)), int(m.group(2))
|
| 487 |
+
now = datetime.now(VN_TZ)
|
| 488 |
+
return now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
| 489 |
+
except:
|
| 490 |
+
pass
|
| 491 |
+
return None
|
| 492 |
+
|
| 493 |
+
|
| 494 |
+
def _get_epg_for_channel(channel_id):
|
| 495 |
epg_data = _fetch_epg_from_vtv()
|
| 496 |
programmes = epg_data.get(channel_id, [])
|
| 497 |
+
if not programmes:
|
| 498 |
+
return []
|
| 499 |
now = datetime.now(VN_TZ)
|
|
|
|
| 500 |
result = []
|
| 501 |
+
for i, p in enumerate(programmes):
|
|
|
|
| 502 |
start_dt = p.get("start_dt")
|
| 503 |
+
stop_dt = None
|
| 504 |
+
if i + 1 < len(programmes):
|
| 505 |
+
stop_dt = programmes[i + 1].get("start_dt")
|
| 506 |
+
is_now = False
|
| 507 |
+
if start_dt:
|
| 508 |
+
if stop_dt:
|
| 509 |
+
is_now = start_dt <= now < stop_dt
|
| 510 |
+
else:
|
| 511 |
+
is_now = start_dt <= now
|
| 512 |
+
end_time = ""
|
| 513 |
+
if stop_dt:
|
| 514 |
+
end_time = stop_dt.strftime("%H:%M")
|
| 515 |
+
result.append({"time": p["time"], "title": p["title"], "end_time": end_time, "now": is_now})
|
| 516 |
+
return result
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
@router.get("/api/vtv/epg/{channel_id}")
|
| 520 |
+
def api_vtv_epg(channel_id: str):
|
| 521 |
+
channel_id = channel_id.lower().strip()
|
| 522 |
+
if channel_id not in CHANNEL_NAMES:
|
| 523 |
+
return JSONResponse({"error": "channel not found"}, status_code=404)
|
| 524 |
+
programs = _get_epg_for_channel(channel_id)
|
| 525 |
+
return JSONResponse({
|
| 526 |
+
"channel": channel_id,
|
| 527 |
+
"channel_name": CHANNEL_NAMES.get(channel_id, channel_id),
|
| 528 |
+
"date": datetime.now(VN_TZ).strftime("%Y-%m-%d"),
|
| 529 |
+
"programs": programs,
|
| 530 |
+
})
|
| 531 |
+
|
| 532 |
|
| 533 |
@router.get("/api/vtv/epg")
|
| 534 |
def api_vtv_epg_refresh():
|
| 535 |
global _epg_cache, _epg_cache_time
|
| 536 |
+
_epg_cache = {}
|
| 537 |
+
_epg_cache_time = 0
|
| 538 |
epg_data = _fetch_epg_from_vtv()
|
| 539 |
+
return JSONResponse({
|
| 540 |
+
"status": "refreshed",
|
| 541 |
+
"channels": len(epg_data),
|
| 542 |
+
"total_programmes": sum(len(v) for v in epg_data),
|
| 543 |
+
})
|
vtv_epg_data.json
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
{"vtv1":[{"time":"00:00","title":"PHIM TRUYỆN: MẸ BIỂN - TẬP 24"},{"time":"00:00","title":"MẸ BIỂN - TẬP 26"},{"time":"00:00","title":"PHIM TRUYỆN: MẸ BIỂN - TẬP 25"},{"time":"00:30","title":"ÁNH SÁNG TRI THỨC: KHƠI NGUỒN SÁNG TẠO"},{"time":"00:30","title":"HỌC VÀ LÀM THEO BÁC: CÓ CHÍ THÌ NÊN"},{"time":"00:30","title":"CHỐNG GIAN LẬN-BẢO VỆ NGƯỜI DÙNG"},{"time":"00:45","title":"VĂN HỌC NGHỆ THUẬT: NSND DƯƠNG MINH ĐỨC"},{"time":"00:45","title":"SỰ LỰA CHỌN"},{"time":"00:45","title":"SỰ KIỆN VÀ BÌNH LUẬN"},{"time":"01:10","title":"PHIM TÀI LIỆU: KHI VỸ HÁT"},{"time":"01:10","title":"TƯƠNG LAI XANH: CHẤN CHỈNH KHAI THÁC KHOÁNG SẢN"},{"time":"01:10","title":"GIAI ĐIỆU KẾT NỐI"},{"time":"01:40","title":"KHÁM PHÁ VIỆT NAM: DẤU ẤN ĐÀO XÁ"},{"time":"01:40","title":"KHÁM PHÁ VIỆT NAM: NGƯỜI BAHNAR BÊN DÒNG ĐẮK BLA"},{"time":"02:00","title":"THỂ THAO: KẾT NỐI THỂ THAO"},{"time":"02:00","title":"THỂ THAO: GIỜ VÀNG THỂ THAO"},{"time":"02:00","title":"THỂ THAO"},{"time":"02:30","title":"TỪ NHỮNG MIỀN QUÊ: BÌNH YÊN BẢO LỘC"},{"time":"02:30","title":"TỪ NHỮNG MIỀN QUÊ: SẮC MÀU TRÊN ĐẤT MƯỜNG VÀ"},{"time":"02:30","title":"TỪ NHỮNG MIỀN QUÊ - VÙNG ĐẤT CẨM NAM"},{"time":"02:45","title":"VTV SỐNG KHỎE: KHI TRÁI TIM LOẠN NHỊP"},{"time":"02:45","title":"VTV SỐNG KHỎE: VIÊM TAI GIỮA, NHỮNG NGUY CƠ NGÀY HÈ"},{"time":"02:45","title":"VTV SỐNG KHỎE"},{"time":"03:30","title":"PHIM TRUYỆN: GIA ĐÌNH MÌNH VUI BẤT THÌNH LÌNH - TẬP 39"},{"time":"03:30","title":"PHIM TRUYỆN: GIA ĐÌNH MÌNH VUI BẤT THÌNH LÌNH - TẬP 41"},{"time":"03:30","title":"GIA ĐÌNH MÌNH VUI BẤT THÌNH LÌNH - TẬP 43"},{"time":"04:15","title":"PHIM TRUYỆN: GIA ĐÌNH MÌNH VUI BẤT THÌNH LÌNH - TẬP 40"},{"time":"04:15","title":"PHIM TRUYỆN: GIA ĐÌNH MÌNH VUI BẤT THÌNH LÌNH - TẬP 42"},{"time":"04:15","title":"GIA ĐÌNH MÌNH VUI BẤT THÌNH LÌNH - TẬP 44"},{"time":"05:05","title":"S - VIỆT NAM: BÀ NÀ TINH HOA ẨM THỰC CHÂU ÂU"},{"time":"05:05","title":"S - VIỆT NAM: GIA LAI - MÙA LỄ HỘI CẦU NGƯ"},{"time":"05:05","title":"S - VIỆT NAM"},{"time":"05:10","title":"KINH TẾ BẠC: NGÔI NHÀ THỨ 2"},{"time":"05:10","title":"VÌ CỘNG ĐỒNG: DỆT HOA SẮC MÀU TRUYỀN THỐNG"},{"time":"05:10","title":"HÀNH TRÌNH HY VỌNG"},{"time":"05:25","title":"HẢI QUAN VIỆT NAM"},{"time":"05:30","title":"CHÀO BUỔI SÁNG"},{"time":"07:00","title":"TIÊU ĐIỂM CHÍNH SÁCH"},{"time":"07:00","title":"BÁO CHÍ TOÀN CẢNH"},{"time":"07:00","title":"TÀI CHÍNH - KINH DOANH"},{"time":"07:15","title":"KHÔNG GIAN VĂN HÓA NGHỆ THUẬT"},{"time":"07:25","title":"VIỆT NAM ĐA SẮC"},{"time":"07:30","title":"PHÓNG SỰ: CHUYỆN LÀNG TRONG VẬN NƯỚC"},{"time":"07:30","title":"NẺO VỀ NGUỒN CỘI - ÂM VANG TRỐNG ĐỒNG"},{"time":"07:45","title":"DÁM SỐNG: RỰC RỠ GIỮA BÓNG TỐI"},{"time":"07:45","title":"KHÁM PHÁ VIỆT NAM - NGHỀ XƯA GIỮA NHỊP SỐNG MỚI"},{"time":"08:00","title":"VTV KẾT NỐI"},{"time":"08:00","title":"SỐNG MỚI"},{"time":"08:00","title":"HÀNH TRÌNH HY VỌNG"},{"time":"08:15","title":"SÁCH HAY THAY ĐỔI CUỘC ĐỜI"},{"time":"08:15","title":"SỐNG XANH"},{"time":"08:30","title":"TẠP CHÍ KINH TẾ CUỐI TUẦN"},{"time":"08:40","title":"ĐI CÙNG CHÚNG TÔI"},{"time":"08:45","title":"CHỐNG GIAN LẬN-BẢO VỆ NGƯỜI DÙNG"},{"time":"09:00","title":"THỜI SỰ"},{"time":"09:05","title":"DU LỊCH VIỆT NAM"},{"time":"09:15","title":"SỰ KIỆN VÀ BÌNH LUẬN"},{"time":"09:15","title":"TOÀN CẢNH THẾ GIỚI"},{"time":"09:15","title":"TẠP CHÍ KINH TẾ CUỐI TUẦN"},{"time":"09:45","title":"DÁM SỐNG: KIẾN TRÚC SƯ MAI HƯNG TRUNG"},{"time":"09:45","title":"VIETNAM 360: QUY HOẠCH HÀ NỘI TẦM NHÌN 100 NĂM"},{"time":"09:45","title":"VTV KẾT NỐI"},{"time":"10:00","title":"ĐIỂM TỰA CUỘC SỐNG: BẢO VỆ TRẺ EM TRÊN KHÔNG GIAN MẠNG"},{"time":"10:00","title":"THỂ THAO"},{"time":"10:15","title":"VTV KẾT NỐI"},{"time":"10:25","title":"TOÀN CẢNH THẾ GIỚI"},{"time":"10:30","title":"TƯƠNG LAI XANH: CHẤN CHỈNH KHAI THÁC KHOÁNG SẢN"},{"time":"10:30","title":"CẶP LÁ YÊU THƯƠNG"},{"time":"11:00","title":"SỐNG AN TOÀN"},{"time":"11:00","title":"SỐNG XANH"},{"time":"11:00","title":"TÀI CHÍNH - KINH DOANH"},{"time":"11:30","title":"CUỘC SỐNG SỐ"},{"time":"11:45","title":"GÓC NHÌN VĂN HÓA"},{"time":"12:00","title":"THỜI SỰ"},{"time":"12:40","title":"SỰ LỰA CHỌN"},{"time":"12:40","title":"NÔNG NGHIỆP XANH: CANH TÁC SỐ"},{"time":"12:45","title":"NHỊP SỐNG TUỔI BẠC"},{"time":"12:55","title":"CẶP LÁ YÊU THƯƠNG"},{"time":"13:00","title":"VTV SỐNG KHỎE - DINH DƯỠNG CHO NGƯỜI VIỆT: ĐẢM BẢO DINH DƯỠNG CHO TRẺ TRONG THỜI BÃO GIÁ"},{"time":"13:00","title":"VTV SỐNG KHỎE: ĐIỀU TRỊ HIỆU QUẢ UNG THƯ VÚ"},{"time":"13:00","title":"VTV SỐNG KHỎE"},{"time":"13:45","title":"PHỤ NỮ VÀ CUỘC SỐNG: NỮ ĐẠI SỨ DU LỊCH LÀNG NGHỀ"},{"time":"13:45","title":"80 NĂM QUỐC HỘI VIỆT NAM"},{"time":"13:45","title":"TỪ NHỮNG MIỀN QUÊ - VÙNG ĐẤT CẨM NAM"},{"time":"14:00","title":"GÓC NHÌN VĂN HÓA"},{"time":"14:15","title":"HỌC VÀ LÀM THEO BÁC: CÓ CHÍ THÌ NÊN"},{"time":"14:15","title":"HÀNH TRÌNH DI SẢN: SỨC SỐNG BÊN DÒNG SUỐI MƯỜNG HOA"},{"time":"14:15","title":"SỐNG MỚI"},{"time":"14:30","title":"VĂN HỌC NGHỆ THUẬT: NHỮNG CÂY CẦU CỦA VĂN CHƯƠNG"},{"time":"14:55","title":"ÁNH SÁNG TRI THỨC - AI TRONG GIÁO DỤC"},{"time":"15:00","title":"DOANH NGHIỆP - DOANH NHÂN: CHUYỂN DỊCH"},{"time":"15:00","title":"GIAI ĐIỆU KẾT NỐI"},{"time":"15:10","title":"DOANH NGHIỆP - DOANH NHÂN - CHUYỂN DỊCH"},{"time":"15:25","title":"VTV KẾT NỐI"},{"time":"15:30","title":"TRÁI TIM CHO EM: ƯỚC MƠ TỪ NHỊP TIM NHỎ"},{"time":"15:30","title":"CẶP LÁ YÊU THƯƠNG"},{"time":"15:40","title":"THƯƠNG HIỆU QUỐC GIA VIỆT NAM: THƯƠNG HIỆU QUỐC GIA VÀ SỞ HỮU TRÍ TUỆ"},{"time":"15:45","title":"KHUYẾN HỌC - HÀNH TRÌNH TRI THỨC: DÒNG HỌ KHOA BẢNG NGUYỄN VŨ"},{"time":"15:55","title":"VỀ QUÊ: LIÊN KẾT BỀN VỮNG NƠI VÙNG CAO"},{"time":"15:55","title":"CÙNG EM ĐẾN TRƯỜNG: NHỮNG MÓN QUÀ TIẾP SỨC"},{"time":"16:00","title":"THỜI SỰ"},{"time":"16:15","title":"VĂN HOÁ CAND: BẢN LĨNH VÀ KHÁT VỌNG"},{"time":"16:15","title":"VĂN HOÁ QĐND"},{"time":"16:15","title":"NHÂN ĐẠO - ĐIỂM TỰA AN SINH Xà HỘI"},{"time":"16:30","title":"SỰ KIỆN VÀ BÌNH LUẬN"},{"time":"16:45","title":"VÌ CỘNG ĐỒNG: DỆT HOA SẮC MÀU TRUYỀN THỐNG"},{"time":"16:45","title":"KINH TẾ BẠC: NHỮNG NGƯỜI TIÊN PHONG"},{"time":"17:00","title":"NHẬT KÝ NGƯỜI VIỆT: LÚA LAI HAI DÒNG Ở VIỆT NAM"},{"time":"17:00","title":"NHẬT KÝ NGƯỜI VIỆT: GIỮ HỒN NHẠC CỤ ĐÀO XÁ"},{"time":"17:00","title":"VIỆT NAM ĐA SẮC"},{"time":"17:05","title":"KHÁM PHÁ VIỆT NAM: NGƯỜI BAHNAR BÊN DÒNG ĐẮK BLA"},{"time":"17:05","title":"KHÁM PHÁ VIỆT NAM: THẠNH HÓA -BẢN HÒA CA TỪ BIỂN"},{"time":"17:10","title":"CẢI CÁCH HÀNH CHÍNH"},{"time":"17:20","title":"HÀNH TRÌNH VẺ ĐẸP: ĐỜN CA TÀI TỬ MIỀN TÂY XỨ DỪA"},{"time":"17:20","title":"HÀNH TRÌNH VẺ ĐẸP"},{"time":"17:30","title":"CHUYỂN ĐỘNG 24H"},{"time":"18:00","title":"VIỆT NAM HÔM NAY"},{"time":"18:25","title":"VÌ TẦM VÓC VIỆT"},{"time":"19:00","title":"THỜI SỰ"},{"time":"19:40","title":"THỜI TIẾT + THỂ THAO 24/7"},{"time":"19:40","title":"THỂ THAO 24/7"},{"time":"19:55","title":"ĐIỂM TIN"},{"time":"20:00","title":"S - VIỆT NAM: GIA LAI - MÙA LỄ HỘI CẦU NGƯ"},{"time":"20:00","title":"S - VIỆT NAM: DẤU ẤN ĐÔNG DƯƠNG GIỮA LÒNG HẢI PHÒNG"},{"time":"20:00","title":"VIỆT NAM - ĐIỂM HẸN"},{"time":"20:05","title":"VIỆT NAM VUI KHỎE"},{"time":"20:10","title":"TRUYỀN HÌNH TRỰC TIẾP: KHAI MẠC LỄ HỘI VÌ HÒA BÌNH NĂM 2026"},{"time":"20:10","title":"TRUYỀN HÌNH QUÂN ĐỘI NHÂN DÂN"},{"time":"20:10","title":"PHÓNG SỰ"},{"time":"20:30","title":"QUỐC DÂN HIỂU THUẾ"},{"time":"20:30","title":"PHIM TÀI LIỆU"},{"time":"20:45","title":"TIÊU ĐIỂM"},{"time":"20:55","title":"THUẾ VÀ ĐỜI SỐNG"},{"time":"21:00","title":"PHIM TÀI LIỆU: HÀNH TRÌNH CỦA NHỮNG GIÁ TRỊ SỐNG"},{"time":"21:00","title":"PHÍA BÊN KIA THÀNH PHỐ - TẬP 27"},{"time":"21:30","title":"ÁNH SÁNG TRI THỨC: AI TRONG GIÁO DỤC"},{"time":"21:30","title":"TÀI CHÍNH - KINH DOANH"},{"time":"21:45","title":"CÂU CHUYỆN QUỐC TẾ: RANH GIỚI MÀN HÌNH"},{"time":"21:45","title":"DÁM SỐNG: KIẾN TRÚC SƯ MAI HƯNG TRUNG"},{"time":"21:55","title":"VIỆC TỬ TẾ"},{"time":"22:00","title":"CHUYỂN ĐỘNG CUỐI NGÀY"},{"time":"22:30","title":"NHẬT KÝ FIFA WORLD CUP 2026"},{"time":"22:45","title":"TÁC PHẨM MỚI: NGÀY ĐẤT NƯỚC CHUYỂN MÌNH"},{"time":"22:45","title":"HÒA NHẠC THÍNH PHÒNG: HÒA NHẠC VIỄN PHƯƠNG - PHẦN 2"},{"time":"22:45","title":"VTV KẾT NỐI"},{"time":"23:00","title":"GIỜ VÀNG THỂ THAO"},{"time":"23:00","title":"SỐNG MỚI"},{"time":"23:15","title":"TỔ QUỐC TRONG TIM"},{"time":"23:15","title":"GIỜ VÀNG THỂ THAO"},{"time":"23:45","title":"KHÁM PHÁ VIỆT NAM: NGƯỜI BAHNAR BÊN DÒNG ĐẮK BLA"},{"time":"23:45","title":"KHÁM PHÁ VIỆT NAM: THẠNH HÓA -BẢN HÒA CA TỪ BIỂN"}],"vtv2":[{"time":"00:00","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 39"},{"time":"00:00","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 40"},{"time":"00:00","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 41"},{"time":"00:45","title":"KHÁM PHÁ VIỆT NAM: DẤU ẤN THIÊNG HAI BÀ TRƯNG"},{"time":"00:45","title":"KHÁM PHÁ VIỆT NAM: NHỊP SỐNG MIỀN CHIÊM TRŨNG"},{"time":"00:45","title":"KHÁM PHÁ VIỆT NAM: HƯƠNG VỊ GIỮ HỒN QUÊ"},{"time":"01:00","title":"BẠN CỦA NHÀ NÔNG"},{"time":"01:45","title":"DÁM SỐNG: HOÀNG VẼ CUỘC ĐỜI MÌNH"},{"time":"01:45","title":"DÁM SỐNG: KÌNH NGƯ"},{"time":"01:45","title":"DÁM SỐNG: CHÀNG ĐAM - SAN THỜI HIỆN ĐẠI"},{"time":"02:00","title":"KHÁM PHÁ THẾ GIỚI: SỰ SỐNG TRONG ĐẠI DƯƠNG - TẬP 5"},{"time":"02:00","title":"KHÁM PHÁ THẾ GIỚI: SỰ SỐNG TRONG ĐẠI DƯƠNG - TẬP 6"},{"time":"02:00","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 1"},{"time":"02:30","title":"HIỂU SÂU - SỐNG CHẤT: KHI MÂU THUẪN VƯỢT NGƯỠNG"},{"time":"02:30","title":"HIỂU SÂU - SỐNG CHẤT: ÔNG BÀ THỜI CHUYỂN ĐỔI SỐ"},{"time":"03:00","title":"S - TECH"},{"time":"03:30","title":"CHUYỆN NHÀ THỜI NAY"},{"time":"04:00","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 40"},{"time":"04:00","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 41"},{"time":"04:00","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 42"},{"time":"04:45","title":"DÁM SỐNG: KÌNH NGƯ"},{"time":"04:45","title":"DÁM SỐNG: CHÀNG ĐAM - SAN THỜI HIỆN ĐẠI"},{"time":"04:45","title":"DÁM SỐNG: ĐỂ MẠCH NGUỒN TUỒNG CHẢY MÃI"},{"time":"05:00","title":"EDUTALK - BÀN LUẬN GIÁO DỤC: TRƯỜNG CHUYÊN TIỆM CẬN CHUẨN QUỐC TẾ"},{"time":"05:00","title":"BÍ ẨN TỰ NHIÊN: BÍ ẨN NÚI LỬA ĐĂK NÔNG"},{"time":"05:00","title":"ĐƯỜNG TỚI NÔNG TRẠI: DU LỊCH NÔNG NGHIỆP TRẢI NGHIỆM"},{"time":"05:30","title":"BẠN CỦA NHÀ NÔNG"},{"time":"06:15","title":"VTV SỐNG KHỎE: SỐNG CHUNG VỚI TRÁI TIM SUY"},{"time":"06:15","title":"VTV SỐNG KHỎE: YOGA VỚI SỨC KHỎE NGƯỜI CAO TUỔI"},{"time":"06:15","title":"VTV SỐNG KHỎE: RỐI LOẠN TIC - HIỂU ĐỂ CHỮA LÀNH"},{"time":"06:45","title":"KHÁM PHÁ VIỆT NAM: NHỊP SỐNG MIỀN CHIÊM TRŨNG"},{"time":"06:45","title":"VTV KẾT NỐI"},{"time":"06:45","title":"KHÁM PHÁ VIỆT NAM: DẤU ẤN THIÊNG HAI BÀ TRƯNG"},{"time":"07:00","title":"CHUYỆN NHÀ THỜI NAY"},{"time":"07:30","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 1"},{"time":"07:30","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 2"},{"time":"07:30","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 3"},{"time":"08:00","title":"VTV SỐNG KHỎE: VIÊM TAI GIỮA, NHỮNG NGUY CƠ NGÀY HÈ"},{"time":"08:00","title":"TƯ VẤN TUYỂN SINH"},{"time":"08:00","title":"VTV SỐNG KHỎE: UNG THƯ TRỰC TRÀNG"},{"time":"08:45","title":"DÁM SỐNG: CHÀNG ĐAM - SAN THỜI HIỆN ĐẠI"},{"time":"08:45","title":"DÁM SỐNG: HÀNH TRÌNH TỎA SÁNG THẾ GIỚI"},{"time":"09:00","title":"BÍ ẨN TỰ NHIÊN: BÍ ẨN NÚI LỬA ĐĂK NÔNG"},{"time":"09:00","title":"ĐƯỜNG TỚI NÔNG TRẠI: DU LỊCH NÔNG NGHIỆP TRẢI NGHIỆM"},{"time":"09:25","title":"TRÁI TIM CHO EM: ƯỚC MƠ TỪ NHỊP TIM NHỎ"},{"time":"09:30","title":"S - TECH"},{"time":"10:00","title":"KHÁM PHÁ VIỆT NAM: NHỊP SỐNG MIỀN CHIÊM TRŨNG"},{"time":"10:00","title":"KHỞI NGHIỆP KIẾN QUỐC: CHỮ TÍN - NỀN MÓNG CỦA CÔNG TRÌNH"},{"time":"10:00","title":"KHÁM PHÁ VIỆT NAM: DẤU ẤN THIÊNG HAI BÀ TRƯNG"},{"time":"10:15","title":"VTV SỐNG KHỎE: RỐI LOẠN TIC - HIỂU ĐỂ CHỮA LÀNH"},{"time":"10:15","title":"VTV SỐNG KHỎE: SỐNG CHUNG VỚI TRÁI TIM SUY"},{"time":"10:15","title":"VTV SỐNG KHỎE: YOGA VỚI SỨC KHỎE NGƯỜI CAO TUỔI"},{"time":"10:45","title":"KHÁT VỌNG XANH: THÀNH PHỐ TÍCH NHIỆT"},{"time":"10:45","title":"KHÁT VỌNG XANH: HỆ LỤY TỪ MỘT THÚ CHƠI"},{"time":"10:45","title":"KHÁM PHÁ THẾ GIỚI: KHÁM PHÁ THÁI BÌNH DƯƠNG - TẬP 2"},{"time":"11:00","title":"PHIM HOẠT HÌNH: POKÉMON CHÂN TRỜI MỚI - TẬP 11"},{"time":"11:00","title":"PHIM HOẠT HÌNH: POKÉMON CHÂN TRỜI MỚI - TẬP 12"},{"time":"11:30","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 41"},{"time":"11:30","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 42"},{"time":"11:30","title":"PHIM TRUYỆN: GIÓ NGANG KHOẢNG TRỜI XANH - TẬP 43"},{"time":"12:15","title":"TỪ NHỮNG MIỀN QUÊ: NGƯỜI THÁI Ở SƠN LA"},{"time":"12:15","title":"TỪ NHỮNG MIỀN QUÊ: NGHỀ NGÓI ÂM DƯƠNG BẮC SƠN"},{"time":"12:15","title":"TỪ NHỮNG MIỀN QUÊ: SẮC MÀU TRÊN ĐẤT MƯỜNG VÀ"},{"time":"12:30","title":"HIỂU SÂU - SỐNG CHẤT: VỈA HÈ CHUNG - LỢI ÍCH RIÊNG"},{"time":"12:30","title":"HIỂU SÂU - SỐNG CHẤT: HỘI NHÓM VÀ BÓC PHỐT TRÊN MẠNG"},{"time":"12:30","title":"HIỂU SÂU - SỐNG CHẤT: KHI MÂU THUẪN VƯỢT NGƯỠNG"},{"time":"13:00","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 1"},{"time":"13:00","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 2"},{"time":"13:00","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 3"},{"time":"13:30","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 2"},{"time":"13:30","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 3"},{"time":"13:30","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 4"},{"time":"14:00","title":"SẮC MÀU CÁC DÂN TỘC: CHUYỆN TỪ BẢN VẶT"},{"time":"14:00","title":"PHỤ NỮ LÀ ĐỂ YÊU THƯƠNG: BẠN ĐƯỢC QUYỀN HẠNH PHÚC"},{"time":"14:30","title":"CHUYỆN NHÀ THỜI NAY"},{"time":"15:00","title":"TƯ VẤN TUYỂN SINH"},{"time":"15:00","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 4"},{"time":"15:00","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG: BỆNH DA HIẾM GẶP - TẬP 1"},{"time":"15:30","title":"EDUTALK - BÀN LUẬN GIÁO DỤC: CHIẾN THUẬT CHỌN NGUYỆN VỌNG"},{"time":"15:30","title":"ĐƯỜNG TỚI NÔNG TRẠI: DU LỊCH NÔNG NGHIỆP TRẢI NGHIỆM"},{"time":"16:00","title":"CẢNH GIÁC 247: NGUY CƠ RỬA TIỀN TỪ VÍ ĐIỆN TỬ"},{"time":"16:15","title":"VTV KẾT NỐI"},{"time":"16:20","title":"CHECK IN VIỆT NAM: CÓ HẸN VỚI XỨ MƯỜNG"},{"time":"16:30","title":"KHÁM PHÁ VIỆT NAM: HƯƠNG VỊ GIỮ HỒN QUÊ"},{"time":"16:30","title":"KHÁM PHÁ VIỆT NAM: DẤU ẤN THIÊNG HAI BÀ TRƯNG"},{"time":"16:30","title":"KHÁM PHÁ VIỆT NAM: NHỊP SỐNG MIỀN CHIÊM TRŨNG"},{"time":"16:43","title":"THÔNG BÁO - GHI ƠN"},{"time":"16:45","title":"BẠN CỦA NHÀ NÔNG"},{"time":"17:25","title":"NHỊP ĐẬP VIỆT NAM: LÀNG CHÀI XUÂN HẢI"},{"time":"17:25","title":"NHỊP ĐẬP VIỆT NAM: NÉT ĐẸP CỔ KÍNH CHÙA TRÔNG"},{"time":"17:25","title":"NHỊP ĐẬP VIỆT NAM: CUỘC SỐNG LÀNG CHÀI CỬA NHƯỢNG"},{"time":"17:30","title":"ĐƯỜNG TỚI NÔNG TRẠI: DU LỊCH NÔNG NGHIỆP TRẢI NGHIỆM"},{"time":"17:30","title":"BÍ ẨN TỰ NHIÊN: BÍ ẨN NÚI LỬA ĐĂK NÔNG"},{"time":"17:30","title":"EDUTALK - BÀN LUẬN GIÁO DỤC: CHIẾN THUẬT CHỌN NGUYỆN VỌNG"},{"time":"18:00","title":"KIẾN THỨC CỘNG ĐỒNG: LIVESTREAM VÀ QUYỀN RIÊNG TƯ CÁ NHÂN"},{"time":"18:00","title":"KIẾN THỨC CỘNG ĐỒNG: HIỂU LUẬT MỖI NGÀY"},{"time":"18:00","title":"KIẾN THỨC CỘNG ĐỒNG: MIỄN GIẤY PHÉP XÂY DỰNG ÁP DỤNG RA SAO?"},{"time":"18:15","title":"NHỮNG BÔNG HOA NHỎ"},{"time":"18:30","title":"CHUYỆN NHÀ THỜI NAY"},{"time":"18:30","title":"CHUYỆN NHÀ THỜI NAY: CHUYỆN NHÀ THỜI NAY - TẬP 33"},{"time":"19:00","title":"CẬN CẢNH FIFA WORLD CUP 2026"},{"time":"19:30","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 1"},{"time":"19:30","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 2"},{"time":"19:30","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 3"},{"time":"20:00","title":"LÁT CẮT FIFA WORLD CUP 2026"},{"time":"20:05","title":"HIỂU SÂU - SỐNG CHẤT: VỈA HÈ CHUNG - LỢI ÍCH RIÊNG"},{"time":"20:10","title":"CẢNH GIÁC 247: NGUY CƠ RỬA TIỀN TỪ VÍ ĐIỆN TỬ"},{"time":"20:10","title":"EDUTALK - BÀN LUẬN GIÁO DỤC: CHIẾN THUẬT CHỌN NGUYỆN VỌNG"},{"time":"20:30","title":"SÁCH HAY THAY ĐỔI CUỘC ĐỜI"},{"time":"20:30","title":"S - TECH"},{"time":"20:35","title":"PHỤ NỮ LÀ ĐỂ YÊU THƯƠNG: BẠN ĐƯỢC QUYỀN HẠNH PHÚC"},{"time":"20:40","title":"BÍ ẨN TỰ NHIÊN: BÃO - CỖ MÁY CUỒNG PHONG - TẬP 1"},{"time":"21:00","title":"CHECK IN VIỆT NAM: CÓ HẸN VỚI XỨ MƯỜNG"},{"time":"21:00","title":"PHIM TÀI LIỆU NƯỚC NGOÀI: NHỮNG BÍ MẬT CHƯA KHÉP LẠI CỦA THỜI APARTHEID - TẬP 2"},{"time":"21:00","title":"KHÁT VỌNG XANH: GIA TĂNG LŨ CỰC ĐOAN"},{"time":"21:15","title":"VTV SỐNG KHỎE: RỐI LOẠN TIC - HIỂU ĐỂ CHỮA LÀNH"},{"time":"21:15","title":"VTV SỐNG KHỎE: SỐNG CHUNG VỚI TRÁI TIM SUY"},{"time":"21:45","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 4"},{"time":"21:45","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG: BỆNH DA HIẾM GẶP - TẬP 1"},{"time":"21:45","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 5"},{"time":"22:15","title":"CHUYỆN NHÀ THỜI NAY"},{"time":"22:15","title":"CHUYỆN NHÀ THỜI NAY: CHUYỆN NHÀ THỜI NAY - TẬP 33"},{"time":"22:45","title":"KHÁM PHÁ VIỆT NAM: HƯƠNG VỊ GIỮ HỒN QUÊ"},{"time":"22:45","title":"KHÁM PHÁ VIỆT NAM: DẤU ẤN THIÊNG HAI BÀ TRƯNG"},{"time":"22:45","title":"KHÁM PHÁ VIỆT NAM: NHỊP SỐNG MIỀN CHIÊM TRŨNG"},{"time":"23:00","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 1"},{"time":"23:00","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 2"},{"time":"23:00","title":"KHÁM PHÁ THẾ GIỚI: CUỘC SỐNG 2.0 - TẬP 3"},{"time":"23:30","title":"BÍ ẨN TỰ NHIÊN: BÃO - CỖ MÁY CUỒNG PHONG - TẬP 1"},{"time":"23:30","title":"S - TECH"}],"vtv3":[{"time":"00:00","title":"THANH ÂM TỪ ĐẤT VIỆT: ĐẤT HÁT"},{"time":"00:00","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 VÒNG 16 ĐỘI: CANADA - MA RỐC"},{"time":"00:10","title":"PHIM TRUYỆN: TÌNH YÊU CỦA ĐỜI TÔI - TẬP 39"},{"time":"00:20","title":"BÌNH LUẬN THỂ THAO"},{"time":"01:00","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 VÒNG 32 ĐỘI: AUSTRALIA - AI CẬP"},{"time":"01:00","title":"ĐƯỜNG LÊN ĐỈNH OLYMPIA"},{"time":"01:50","title":"SAO CHECK: CA SĨ VŨ THẢO MY"},{"time":"02:20","title":"BÌNH LUẬN THỂ THAO"},{"time":"03:00","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 VÒNG 16 ĐỘI: BRAZIL - NA UY"},{"time":"03:20","title":"BÌNH LUẬN THỂ THAO"},{"time":"04:00","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 VÒNG 16 ĐỘI: PARAGUAY - PHÁP"},{"time":"04:20","title":"BÌNH LUẬN THỂ THAO"},{"time":"05:00","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 VÒNG 32 ĐỘI: ARGENTINA - CAPE VERDE"},{"time":"06:20","title":"BÌNH LUẬN THỂ THAO"},{"time":"07:00","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 VÒNG 16 ĐỘI: MEXICO - ANH"},{"time":"07:20","title":"BÌNH LUẬN THỂ THAO"},{"time":"07:30","title":"CHUYỆN NHÀ THỜI NAY"},{"time":"07:55","title":"VIỆT NAM XANH: NGÀY HỘI ĐỔI RÁC LẤY QUÀ"},{"time":"08:00","title":"VTV KẾT NỐI"},{"time":"08:10","title":"THỂ THAO"},{"time":"08:30","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 VÒNG 32 ĐỘI: COLOMBIA - GHANA"},{"time":"08:40","title":"CẨM NANG VÀNG CHO SỨC KHỎE"},{"time":"09:00","title":"BỘ BA TRANH TÀI: HỒNG LUYẾN, THANH HUYỀN, ĐỨC THÀNH"},{"time":"10:00","title":"PHỤ NỮ SỐ"},{"time":"10:30","title":"NHẬT KÝ TRÊN KHÓA SOL: CA SĨ HOÀNG ANH"},{"time":"10:30","title":"HIỂU SÂU - SỐNG CHẤT: CHUYỆN ĐỌC SÁCH THỜI ĐẠI SỐ"},{"time":"10:55","title":"THANH ÂM TỪ ĐẤT VIỆT: NHỊP ĐIỆU ĐÔNG HỒ"},{"time":"11:00","title":"NHÀ MÌNH QUÁ ĐỈNH"},{"time":"11:10","title":"THÔNG TIN 260"},{"time":"11:15","title":"ĐIỀU NHỎ BÉ KỲ DIỆU"},{"time":"11:30","title":"GÌ THẾ NHỈ"},{"time":"11:45","title":"GIA ĐÌNH VUI VẺ"},{"time":"11:50","title":"QUÀ TẶNG CUỘC SỐNG: SỰ TÍCH CÁI DÂY LƯNG"},{"time":"12:00","title":"ÚM BA LA RA CHỮ GÌ?"},{"time":"12:00","title":"KHÁCH SẠN 5 SAO"},{"time":"12:00","title":"PHIM TRUYỆN: SỚM TỐI CÓ NHAU - TẬP 7"},{"time":"12:45","title":"ALO! ĐÂY LÀ..."},{"time":"12:50","title":"HẠNH PHÚC LÀ GÌ?: VÕ CAO ĐỈNH"},{"time":"12:50","title":"HẠNH PHÚC LÀ GÌ?: TỐNG THỊ LÂM"},{"time":"13:00","title":"VUA TIẾNG VIỆT"},{"time":"13:00","title":"ĐƯỜNG LÊN ĐỈNH OLYMPIA"},{"time":"13:00","title":"PHIM TRUYỆN: CÂU CHUYỆN HOA HỒNG - TẬP 22"},{"time":"13:50","title":"ĐƯỜNG LÊN ĐỈNH OLYMPIA"},{"time":"14:00","title":"PHIM TRUYỆN: DỊU DÀNG MÀU NẮNG - TẬP 11"},{"time":"14:00","title":"PHIM TRUYỆN: DỊU DÀNG MÀU NẮNG - TẬP 12"},{"time":"14:30","title":"ÚM BA LA RA CHỮ GÌ?"},{"time":"14:50","title":"CẢ NHÀ CÙNG VUI"},{"time":"15:00","title":"BẬT MÍ BÍ MẬT"},{"time":"15:20","title":"THỂ THAO: GIỜ VÀNG THỂ THAO"},{"time":"15:40","title":"ALO! ĐÂY LÀ..."},{"time":"15:50","title":"ALO! ĐÂY LÀ..."},{"time":"15:50","title":"GÌ THẾ NHỈ"},{"time":"16:00","title":"SÂN CỎ FIFA WORLD CUP 2026"},{"time":"16:10","title":"TALK VIETNAM: HẦU ĐỒNG - DI SẢN KHÔNG BIÊN GIỚI"},{"time":"16:10","title":"PHIM TRUYỆN: CUỘC ĐỜI VẪN ĐẸP SAO - TẬP 41"},{"time":"16:30","title":"SAO CHECK: DIỄN VIÊN NGỌC THANH TÂM"},{"time":"16:55","title":"THỜI TIẾT"},{"time":"16:55","title":"PHÚT GIÂY THƯ GIÃN: THẦN DƯỢC CHANH MUỐI"},{"time":"17:00","title":"S - VIỆT NAM (15)"},{"time":"17:00","title":"ĐIỀU NHỎ BÉ KỲ DIỆU"},{"time":"17:14","title":"THỜI TIẾT"},{"time":"17:15","title":"SÂN CỎ FIFA WORLD CUP 2026"},{"time":"17:15","title":"TALK VIETNAM: HẦU ĐỒNG - DI SẢN KHÔNG BIÊN GIỚI"},{"time":"17:20","title":"THÔNG TIN 260"},{"time":"17:29","title":"THỜI TIẾT"},{"time":"17:30","title":"SÂN CỎ FIFA WORLD CUP 2026"},{"time":"17:45","title":"VTV KẾT NỐI"},{"time":"18:00","title":"PHIM TRUYỆN: TRÁI TIM KHÔNG THỂ NGỪNG YÊU - TẬP 10"},{"time":"18:00","title":"PHIM TRUYỆN: TRÁI TIM KHÔNG THỂ NGỪNG YÊU - TẬP 11"},{"time":"18:00","title":"PHIM TRUYỆN: TRÁI TIM KHÔNG THỂ NGỪNG YÊU - TẬP 12"},{"time":"18:35","title":"CẢM HỨNG FIFA WORL CUP 2026"},{"time":"19:00","title":"THỜI SỰ"},{"time":"19:55","title":"SAO 24H"},{"time":"20:00","title":"ANH TRAI VƯỢT NGÀN CHÔNG GAI"},{"time":"20:00","title":"STUDIO 3"},{"time":"20:00","title":"PHIM TRUYỆN (20H00): DƯỚI Ô CỬA SÁNG ĐÈN - TẬP 22"},{"time":"20:50","title":"ĐIỀU NHỎ BÉ KỲ DIỆU"},{"time":"20:55","title":"FANZONE FIFA WORLD CUP 2026"},{"time":"21:00","title":"GAMEBOX - HỘP TRÒ CHƠI"},{"time":"21:00","title":"FANZONE FIFA WORLD CUP 2026"},{"time":"21:05","title":"ĐẦU BẾP THƯỢNG ĐỈNH"},{"time":"21:25","title":"FANZONE FIFA WORLD CUP 2026"},{"time":"21:30","title":"PHIM TRUYỆN: TIỂU TAM KHÔNG CÓ LỖI? - TẬP 18"},{"time":"21:55","title":"QUÀ TẶNG CUỘC SỐNG: CON VOI TRẮNG"},{"time":"22:00","title":"NÓNG CÙNG FIFA WORLD CUP 2026"},{"time":"22:35","title":"VTV KẾT NỐI"},{"time":"22:40","title":"PHIM TRUYỆN: NƠI TUYẾN LỬA - TẬP 9"},{"time":"22:50","title":"NÓNG CÙNG FIFA WORLD CUP 2026"},{"time":"23:00","title":"NÓNG CÙNG FIFA WORLD CUP 2026"},{"time":"23:20","title":"BÌNH LUẬN THỂ THAO"},{"time":"23:30","title":"THỂ THAO"}],"vtv4":[{"time":"00:00","title":"BẢN TIN THỜI SỰ"},{"time":"00:20","title":"SỨC SỐNG THỂ THAO"},{"time":"00:25","title":"THỜI TIẾT DU LỊCH"},{"time":"00:30","title":"PHIM TRUYỆN: HOA HỒNG TRÊN NGỰC TRÁI - TẬP 46"},{"time":"00:30","title":"PHIM TRUYỆN: HƯỚNG DƯƠNG NGƯỢC NẮNG - TẬP 1 - PHẦN 1"},{"time":"00:30","title":"PHIM TRUYỆN: HƯỚNG DƯƠNG NGƯỢC NẮNG - TẬP 2 - PHẦN 1"},{"time":"01:15","title":"NGƯỜI VIỆT BỐN PHƯƠNG"},{"time":"01:15","title":"S - VIỆT NAM"},{"time":"01:15","title":"CHECK IN VIỆT NAM: HƠI THỞ CỦA BIỂN"},{"time":"01:30","title":"NHÀ HÁT TRUYỀN HÌNH: VỞ CẢI LƯƠNG: HÀO KIỆT VỚI GIANG SƠN"},{"time":"01:30","title":"STUDIO 3"},{"time":"01:30","title":"PHIM TRUYỆN: PHỐ TRONG LÀNG - TẬP 31"},{"time":"02:00","title":"GIAI ĐIỆU CUỘC SỐNG: NEO ĐẬU BẾN QUÊ"},{"time":"02:15","title":"MIỀN ĐẤT VÕ: VOVINAM - NGHỆ THUẬT CẬN CHIẾN"},{"time":"02:30","title":"THẾ GIỚI TUỔI THƠ: VẦNG TRĂNG CỦA EM"},{"time":"02:55","title":"XIN CHÀO VIỆT NAM: ĐÀ NẴNG - MỘT GÓC BÌNH YÊN TRÊN ĐỈNH BÀ NÀ"},{"time":"03:00","title":"TỌA ĐÀM: CHÍNH PHỦ KIẾN TẠO PHÁT TRIỂN - SỐ 1"},{"time":"03:00","title":"CUỘC SỐNG VẪN TƯƠI ĐẸP: NHỮNG KHUNG HÌNH HẠNH PHÚC"},{"time":"03:00","title":"NÚI SÔNG BỜ CÕI: VIỆT NAM TRÚNG CỬ THẨM PHÁN ITLOS"},{"time":"03:30","title":"NÚI SÔNG BỜ CÕI: VIỆT NAM TRÚNG CỬ THẨM PHÁN ITLOS"},{"time":"03:30","title":"VĂN HỌC NGHỆ THUẬT: NSND HÀ THỦY - GỌI TÊN NHỮNG THANH ÂM"},{"time":"03:30","title":"TIẾNG VIỆT DIỆU KÌ: CÂU ĐỐ VỀ CON VẬT"},{"time":"03:45","title":"GÓC ĐỒNG HÀNH: ĐIỂM TỰA Y TẾ TẠI HÀN QUỐC"},{"time":"03:55","title":"XIN CHÀO VIỆT NAM: ĐÀ NẴNG - MỘT GÓC BÌNH YÊN TRÊN ĐỈNH BÀ NÀ"},{"time":"04:00","title":"GIAI ĐIỆU CUỘC SỐNG: NHẠT NẮNG"},{"time":"04:00","title":"PHIM CUỐI TUẦN: TRẢ GIÁ"},{"time":"04:00","title":"GIAI ĐIỆU CUỘC SỐNG: CỎ VÀ MƯA"},{"time":"04:15","title":"MIỀN ĐẤT VÕ: VOVINAM - DĨ NHU CHẾ CƯƠNG"},{"time":"04:15","title":"PHÓNG SỰ: PHÁT TRIỂN DU LỊCH SỨC KHOẺ TỪ DI SẢN"},{"time":"04:30","title":"ÁNH SÁNG TRI THỨC: TINH HOA KIẾN TRÚC"},{"time":"04:30","title":"GIAI ĐIỆU CUỘC SỐNG: LỜI MẸ RU"},{"time":"04:45","title":"TÁC PHẨM MỚI: NẾP NHÀ MIỀN XANH"},{"time":"05:00","title":"PHIM TRUYỆN: BIỆT DƯỢC ĐEN - TẬP 24"},{"time":"05:00","title":"PHIM TRUYỆN: BIỆT DƯỢC ĐEN - TẬP 25"},{"time":"05:45","title":"THỜI TIẾT DU LỊCH"},{"time":"05:50","title":"TRÁI TIM CHO EM: ƯỚC MƠ TỪ NHỊP TIM NHỎ"},{"time":"06:00","title":"ĐƯỜNG LÊN ĐỈNH OLYMPIA"},{"time":"06:00","title":"NHÀ MÌNH QUÁ ĐỈNH"},{"time":"06:45","title":"PHIM TRUYỆN: HOA HỒNG TRÊN NGỰC TRÁI - TẬP 46"},{"time":"06:45","title":"PHIM TRUYỆN: HƯỚNG DƯƠNG NGƯỢC NẮNG - TẬP 1 - PHẦN 1"},{"time":"06:45","title":"PHIM TRUYỆN: HƯỚNG DƯƠNG NGƯỢC NẮNG - TẬP 2 - PHẦN 1"},{"time":"07:30","title":"NGƯỜI VIỆT BỐN PHƯƠNG"},{"time":"07:30","title":"HƯỚNG VỀ TỔ QUỐC"},{"time":"07:30","title":"CUỘC SỐNG PHƯƠNG XA: LAN TỎA NGHỆ THUẬT MÚA VIỆT TẠI PHÁP"},{"time":"07:45","title":"GIAI ĐIỆU CUỘC SỐNG: NHẠT NẮNG"},{"time":"07:45","title":"TÁC PHẨM MỚI: NẾP NHÀ MIỀN XANH"},{"time":"07:55","title":"XIN CHÀO VIỆT NAM: ĐÀ NẴNG - MỘT GÓC BÌNH YÊN TRÊN ĐỈNH BÀ NÀ"},{"time":"08:00","title":"TỌA ĐÀM: CHÍNH PHỦ KIẾN TẠO PHÁT TRIỂN - SỐ 1"},{"time":"08:00","title":"SAO CHECK: CA SĨ VƯƠNG BÌNH"},{"time":"08:00","title":"TIẾNG VIỆT DIỆU KÌ: CÂU ĐỐ VỀ CON VẬT"},{"time":"08:15","title":"TALK VIETNAM: HẦU ĐỒNG - DI SẢN KHÔNG BIÊN GIỚI"},{"time":"08:30","title":"WHEN IN VIETNAM: NHỮNG NGƯỜI YÊU ĐỘNG VẬT"},{"time":"08:30","title":"VIETNAM DISCOVERY: KHÁM PHÁ DI SẢN THẾ GIỚI TẠI QUẢNG NINH"},{"time":"09:00","title":"NÚI SÔNG BỜ CÕI: VIỆT NAM TRÚNG CỬ THẨM PHÁN ITLOS"},{"time":"09:00","title":"VĂN HỌC NGHỆ THUẬT: NSND HÀ THỦY - GỌI TÊN NHỮNG THANH ÂM"},{"time":"09:00","title":"DU LỊCH VÀ ẨM THỰC: VĨNH LONG - NHỮNG CÂU CHUYỆN BÊN DÒNG PHÙ SA"},{"time":"09:30","title":"NHỊP SỐNG CỘNG ĐỒNG"},{"time":"09:30","title":"PHIM TÀI LIỆU: NHỮNG ĐỨA TRẺ HẠNH PHÚC - TẬP 3: KHI VỸ HÁT"},{"time":"09:30","title":"TỔ QUỐC TRONG TIM"},{"time":"09:35","title":"THẾ GIỚI TUỔI THƠ: ĐI ĐỂ LỚN LÊN"},{"time":"10:00","title":"NHÀ HÁT TRUYỀN HÌNH: VỞ CẢI LƯƠNG: HÀO KI���T VỚI GIANG SƠN"},{"time":"10:00","title":"PHIM CUỐI TUẦN: TRẢ GIÁ"},{"time":"10:00","title":"KHÁM PHÁ VIỆT NAM: SÔNG THU BỒN VÀ LỄ HỘI BÀ MẸ SỨ XỞ"},{"time":"10:10","title":"S - VIỆT NAM"},{"time":"10:25","title":"XIN CHÀO VIỆT NAM: ĐÀ NẴNG - MỘT GÓC BÌNH YÊN TRÊN ĐỈNH BÀ NÀ"},{"time":"10:30","title":"GIAI ĐIỆU CUỘC SỐNG: LỜI MẸ RU"},{"time":"11:00","title":"ĐIỂM HẸN NGƯỜI VIỆT"},{"time":"11:15","title":"PHIM TRUYỆN: PHỐ TRONG LÀNG - TẬP 31"},{"time":"11:35","title":"ĐI ĐỂ BIẾT: MỘT NGÀY Ở ĐẢO NGỌC VỪNG"},{"time":"11:45","title":"DÁM SỐNG: HÀNH TRÌNH KỲ DIỆU"},{"time":"11:45","title":"GIAI ĐIỆU CUỘC SỐNG: MỖI KHI ANH NHÌN EM"},{"time":"12:00","title":"BẢN TIN THỜI SỰ"},{"time":"12:25","title":"SỨC SỐNG THỂ THAO"},{"time":"12:30","title":"NHÀ MÌNH QUÁ ĐỈNH"},{"time":"12:30","title":"STUDIO 3"},{"time":"12:30","title":"SẮC MÀU CÁC DÂN TỘC: CHUYỆN KỂ TỪ THANH ÂM"},{"time":"13:00","title":"PHÓNG SỰ: PHÁT TRIỂN DU LỊCH SỨC KHOẺ TỪ DI SẢN"},{"time":"13:15","title":"PHIM TRUYỆN: BIỆT DƯỢC ĐEN - TẬP 24"},{"time":"13:15","title":"PHIM TRUYỆN: BIỆT DƯỢC ĐEN - TẬP 25"},{"time":"14:00","title":"GÓC ĐỒNG HÀNH: ĐIỂM TỰA Y TẾ TẠI HÀN QUỐC"},{"time":"14:00","title":"HƯỚNG VỀ TỔ QUỐC"},{"time":"14:00","title":"ĐIỂM HẸN NGƯỜI VIỆT"},{"time":"14:15","title":"PHIM CA NHẠC: HÀ NỘI NGÀY TRỞ VỀ"},{"time":"14:15","title":"VUI - KHỎE - CÓ ÍCH"},{"time":"14:30","title":"NET ZERO - GỬI TƯƠNG LAI"},{"time":"15:00","title":"BẢN TIN THỜI SỰ"},{"time":"15:25","title":"NHỊP SỐNG CỘNG ĐỒNG"},{"time":"15:30","title":"PHIM TÀI LIỆU: THIÊN NHIÊN TÀ KÓU - PHẦN 2"},{"time":"15:30","title":"SẮC MÀU CÁC DÂN TỘC: BẢO TỒN QUẦN THỂ DI TÍCH CỐ ĐÔ HUẾ"},{"time":"15:30","title":"GÓC NHÌN CỘNG ĐỒNG: ỨNG XỬ NƠI CÔNG CỘNG - CHUYỆN KHÔNG NHỎ"},{"time":"15:55","title":"XIN CHÀO VIỆT NAM: ĐÀ NẴNG - MỘT GÓC BÌNH YÊN TRÊN ĐỈNH BÀ NÀ"},{"time":"16:00","title":"PHIM CUỐI TUẦN: TRẢ GIÁ"},{"time":"16:00","title":"CHUYẾN XE ÂM NHẠC XANH"},{"time":"16:00","title":"PHIM TRUYỆN: PHỐ TRONG LÀNG - TẬP 31"},{"time":"16:30","title":"GIAI ĐIỆU CUỘC SỐNG: TÌNH EM"},{"time":"16:45","title":"KHÁM PHÁ VIỆT NAM: SÔNG THU BỒN VÀ LỄ HỘI BÀ MẸ SỨ XỞ"},{"time":"17:00","title":"ĐƯỜNG LÊN ĐỈNH OLYMPIA"},{"time":"17:15","title":"ÁNH SÁNG TRI THỨC: TINH HOA KIẾN TRÚC"},{"time":"17:30","title":"KHÁM PHÁ VIỆT NAM: SẮC MÀU VĂN HÓA LAI CHÂU"},{"time":"17:30","title":"KHÁM PHÁ VIỆT NAM: CÁT BÀ - BẢN GIAO HƯỞNG CỦA BIỂN VÀ RỪNG"},{"time":"17:45","title":"TỪ NHỮNG MIỀN QUÊ: LÀNG RAU DƯỚI CHÂN NÚI TAM ĐẢO"},{"time":"17:45","title":"TỪ NHỮNG MIỀN QUÊ: ĐỘC ĐÁO VŨ ĐIỆU CỦA NGƯỜI SÁN CHỈ"},{"time":"17:45","title":"ÁNH SÁNG TRI THỨC: TRỞ VỀ ĐỂ KIẾN TẠO"},{"time":"18:00","title":"GIA ĐÌNH VUI VẺ"},{"time":"18:00","title":"NGƯỜI VIỆT BỐN PHƯƠNG"},{"time":"18:15","title":"VIỆT NAM KẾT NỐI THIÊN NHIÊN"},{"time":"19:00","title":"VIỆT NAM - ĐIỂM HẸN: THẮP SÁNG BẦU TRỜI"},{"time":"19:00","title":"TOÀN CẢNH THẾ GIỚI"},{"time":"19:00","title":"S - TECH"},{"time":"19:05","title":"HITECH CÔNG NGHỆ TƯƠNG LAI"},{"time":"19:30","title":"GÓC NHÌN CỘNG ĐỒNG: ỨNG XỬ NƠI CÔNG CỘNG - CHUYỆN KHÔNG NHỎ"},{"time":"19:30","title":"TIẾNG VIỆT KHÔNG KHÓ: DẠO QUANH THẢO CẦM VIÊN"},{"time":"19:30","title":"KẾT NỐI THỂ THAO"},{"time":"19:45","title":"NHÌN TỪ HÀ NỘI: KHÁT VỌNG TĂNG TRƯỞNG VÀ NIỀM TIN PHÁT TRIỂN"},{"time":"19:55","title":"RẠNG RỠ VIỆT NAM"},{"time":"20:00","title":"DU LỊCH VÀ ẨM THỰC: VĨNH LONG - NHỮNG CÂU CHUYỆN BÊN DÒNG PHÙ SA"},{"time":"20:00","title":"NET ZERO - GỬI TƯƠNG LAI"},{"time":"20:00","title":"MÔI TRƯỜNG - GÓC NHÌN TỪ QUỐC TẾ: KHI TRÁI ĐẤT NÓNG LÊN"},{"time":"20:30","title":"NÚI SÔNG BỜ CÕI: VIỆT NAM TRÚNG CỬ THẨM PHÁN ITLOS"},{"time":"20:30","title":"PHIM TÀI LIỆU: NHỮNG ĐỨA TRẺ HẠNH PHÚC - TẬP 3: KHI VỸ HÁT"},{"time":"20:30","title":"MIỀN ĐẤT VÕ: VOVINAM - NGHỆ THUẬT CẬN CHIẾN"},{"time":"20:45","title":"VIỆT NAM QUA GÓC NHÌN QUỐC TẾ"},{"time":"21:00","title":"BẢN TIN THỜI SỰ"},{"time":"21:30","title":"SỨC SỐNG THỂ THAO"},{"time":"21:35","title":"THỜI TIẾT DU LỊCH"},{"time":"21:40","title":"TIỂU PHẨM HÀI: HẺM 168 - TẬP 132"},{"time":"21:40","title":"VĂN HỌC NGHỆ THUẬT: NSND HÀ THỦY - GỌI TÊN NHỮNG THANH ÂM"},{"time":"21:40","title":"TIỂU PHẨM HÀI: HẺM 168 - TẬP 133"},{"time":"21:55","title":"GÓC ĐỒNG HÀNH: ĐIỂM TỰA Y TẾ TẠI HÀN QUỐC"},{"time":"21:55","title":"ĐIỂM HẸN NGƯỜI VIỆT"},{"time":"22:10","title":"NHỊP ĐẬP VIỆT NAM: TẾT KHU CÙ TÊ CỦA NGƯỜI LA CHÍ"},{"time":"22:10","title":"NHỊP ĐẬP VIỆT NAM: LÀNG NGHỀ MỘC CÚC BỒ"},{"time":"22:10","title":"NHỊP ĐẬP VIỆT NAM: HƯƠNG THU TRÊN BẢN TÀY"},{"time":"22:15","title":"PHIM TRUYỆN: BIỆT DƯỢC ĐEN - TẬP 24"},{"time":"22:15","title":"TALK VIETNAM: HẦU ĐỒNG - DI SẢN KHÔNG BIÊN GIỚI"},{"time":"22:15","title":"PHIM TRUYỆN: BIỆT DƯỢC ĐEN - TẬP 25"},{"time":"23:00","title":"GIAI ĐIỆU CUỘC SỐNG: CHIẾC LÁ VÔ TÌNH"},{"time":"23:00","title":"GIAI ĐIỆU CUỘC SỐNG: HOA NẮNG TÔI"},{"time":"23:00","title":"GIAI ĐIỆU CUỘC SỐNG: CỎ VÀ MƯA"},{"time":"23:15","title":"NHỊP SỐNG CỘNG ĐỒNG"},{"time":"23:15","title":"VIỆT NAM - ĐIỂM HẸN: THẮP SÁNG BẦU TRỜI"},{"time":"23:15","title":"S - VIỆT NAM: MÙA XANH Ở CÁT BÀ"},{"time":"23:20","title":"THẾ GIỚI TUỔI THƠ: ĐI ĐỂ LỚN LÊN"},{"time":"23:20","title":"NET ZERO - GỬI TƯƠNG LAI"},{"time":"23:20","title":"TỔ QUỐC TRONG TIM"},{"time":"23:45","title":"QUYẾN RŨ VIỆT NAM: THANH ÂM XƯA NINH BÌNH"},{"time":"23:45","title":"KHÁM PHÁ VIỆT NAM: HÀNH TRÌNH XANH TRÊN ĐẤT QUẢNG NGÃI"}],"vtv5":[{"time":"00:00","title":"PHIM TRUYỆN: THIÊN LONG BÁT BỘ - TẬP 9"},{"time":"00:05","title":"PHIM TRUYỆN: MỸ NHÂN TẦNG 22- TẬP 9"},{"time":"00:05","title":"PHIM TRUYỆN: MỸ NHÂN TẦNG 22 - TẬP 10"},{"time":"00:05","title":"PHIM TRUYỆN: MỸ NHÂN TẦNG 22 - TẬP 11"},{"time":"00:45","title":"PHÓNG SỰ: BƯỚC TRÊN CON ĐƯỜNG TRI THỨC"},{"time":"00:45","title":"NHỊP SỐNG HÔM NAY"},{"time":"00:45","title":"PHÓNG SỰ: NGHỊ QUYẾT 14- ĐÒN BẨY ĐỂ VÙNG CAO BỨT PHÁ BẰNG CÔNG NGHỆ SỐ"},{"time":"01:00","title":"VTV5 KẾT NỐI"},{"time":"01:00","title":"VTV SỐNG KHỎE: KHI HUYẾT ÁP ÂM THẦM TĂNG CAO"},{"time":"01:15","title":"NÔNG NGHIỆP XANH: THÚC ĐẨY NÔNG NGHIỆP HÀNG HOÁ"},{"time":"01:15","title":"VTV5 KẾT NỐI"},{"time":"01:30","title":"SỔ TAY CÔNG NGHỆ: SÁNG TẠO NỘI DUNG SỐ VỀ LÀNG QUÊ"},{"time":"01:30","title":"KINH TẾ NÔNG THÔN: LÀNG NGHỀ TRƯỚC SỨC ÉP CỦA THỊ TRƯỜNG"},{"time":"01:45","title":"CHÍNH SÁCH VÀ CUỘC SỐNG: PHÁT TRIỂN LÀNG DU LỊCH CỘNG ĐỒNG VEN BIỂN"},{"time":"01:45","title":"THANH ÂM VIỆT: EM GÁI NƠI BẢN DAO"},{"time":"01:45","title":"THƯƠNG NHỚ MIỀN TÂY"},{"time":"02:00","title":"PHIM TRUYỆN: HOA SỮA VỀ TRONG GIÓ - TẬP 32"},{"time":"02:00","title":"PHIM TRUYỆN: HOA SỮA VỀ TRONG GIÓ - TẬP 33"},{"time":"02:00","title":"PHIM TRUYỆN: HOA SỮA VỀ TRONG GIÓ - TẬP 34"},{"time":"02:45","title":"THÔNG TIN CHÍNH SÁCH PHÁP LUẬT"},{"time":"02:45","title":"KIẾN THỨC VÀ CUỘC SỐNG"},{"time":"02:45","title":"KHÁM PHÁ VIỆT NAM: SÔNG THU BỒN VÀ LỄ HỘI BÀ MẸ XỨ SỞ"},{"time":"03:00","title":"PHIM HOẠT HÌNH: 100% SÓI - TẬP 46"},{"time":"03:00","title":"PHIM HOẠT HÌNH: 100% SÓI - TẬP 47"},{"time":"03:00","title":"PHIM HOẠT HÌNH: 100% SÓI - TẬP 48"},{"time":"03:15","title":"NHỮNG NGƯỜI CON ĐẤT VIỆT: NƠI SÁNG KIẾN NẢY MẦM"},{"time":"03:15","title":"CHECK IN VN: CHÍN TẦNG MÂY GIỮA ĐẠI NGÀN"},{"time":"03:15","title":"VTV5 KẾT NỐI"},{"time":"03:30","title":"AN TOÀN GIAO THÔNG"},{"time":"03:30","title":"DÂN TỘC TÔN GIÁO: GIEO DUYÊN LÀNH CHO ĐỜI"},{"time":"03:30","title":"KHÁM PHÁ VIỆT NAM"},{"time":"03:45","title":"PHỤ NỮ SỐ: HÀNH TRÌNH NÂNG TẦM TRÀ VIỆT"},{"time":"03:45","title":"SẮC MÀU CÁC DÂN TỘC: GIỮA MIỀN SỚN CƯỚC NẶM ĐẶM"},{"time":"03:45","title":"DÂN TỘC PHÁT TRIỂN: LAI CHÂU PHÁT TRIỂN CÔNG NGHIỆP NĂNG LƯỢNG"},{"time":"04:15","title":"PHIM TRUYỆN: GIÂY PHÚT GẶP EM - TẬP 3"},{"time":"04:15","title":"PHIM TRUYỆN: GIÂY PHÚT GẶP EM - TẬP 4"},{"time":"04:15","title":"PHIM TRUYỆN: GIÂY PHÚT GẶP EM - TẬP 5"},{"time":"05:00","title":"CHƯƠNG TRÌNH TIẾNG MƯỜNG"},{"time":"05:00","title":"CHƯƠNG TRÌNH TIẾNG SÁN CHÍ"},{"time":"05:30","title":"CHƯƠNG TRÌNH TIẾNG TÀY"},{"time":"06:00","title":"CHƯƠNG TRÌNH TIẾNG MÔNG"},{"time":"06:30","title":"CHƯƠNG TRÌNH TIẾNG DAO"},{"time":"07:00","title":"CHƯƠNG TRÌNH TIẾNG THÁI"},{"time":"07:00","title":"CHƯƠNG TRÌNH TIẾNG DAO"},{"time":"07:30","title":"CHƯƠNG TRÌNH TIẾNG MÔNG"},{"time":"08:00","title":"CHƯƠNG TRÌNH TIẾNG DAO"},{"time":"08:30","title":"CHƯƠNG TRÌNH TIẾNG THÁI"},{"time":"08:30","title":"CHÀO TUẦN MỚI"},{"time":"09:00","title":"CHÍNH SÁCH VÀ CUỘC SỐNG: SÁT CÁNH CÙNG HỘ NGHÈO"},{"time":"09:00","title":"VTV SỐNG KHỎE: NGUY CƠ NẮNG NÓNG KÉO DÀI"},{"time":"09:00","title":"NÔNG THÔN MỚI: GIỮ CHUẨN NÔNG THÔN MỚI SAU SÁP NHẬP"},{"time":"09:15","title":"KHÁM PHÁ VIỆT NAM: SÔNG THU BỒN VÀ LỄ HỘI BÀ MẸ XỨ SỞ"},{"time":"09:15","title":"VTV5 KẾT NỐI"},{"time":"09:30","title":"VTV5 KẾT NỐI"},{"time":"09:30","title":"PHÁT HUY VAI TRÒ CỦA MẶT TRẬN"},{"time":"09:35","title":"QUÀ TẶNG CUỘC SỐNG: TÌNH YÊU CỦA VỊT MÁI"},{"time":"09:45","title":"ĐƯỜNG LÊN ĐỈNH OLYMPIA"},{"time":"09:45","title":"VTV5 KẾT NỐI"},{"time":"09:45","title":"VUA TIẾNG VIỆT"},{"time":"10:00","title":"SAO CHECK: DIỄN VIÊN NGỌC THANH TÂM"},{"time":"10:30","title":"PHIM TRUYỆN: HOA SỮA VỀ TRONG GIÓ - TẬP 33"},{"time":"10:30","title":"PHIM TRUYỆN: HOA SỮA VỀ TRONG GIÓ - TẬP 34"},{"time":"10:30","title":"PHIM TRUYỆN: HOA SỮA VỀ TRONG GIÓ - TẬP 35"},{"time":"11:00","title":"VĂN HÓA TÂY NGUYÊN: ĐỂ TIẾNG CHIÊNG MÃI NGÂN VANG"},{"time":"11:00","title":"SẮC MÀU VIỆT NAM: GIỮ HỒN MẠNH NGUỒN VĂN HOÁ SƠN LA"},{"time":"11:00","title":"GÌ THẾ NHỈ?"},{"time":"11:15","title":"PHIM HOẠT HÌNH: 100% SÓI - TẬP 47"},{"time":"11:15","title":"PHIM HOẠT HÌNH: 100% SÓI - TẬP 48"},{"time":"11:15","title":"PHIM HOẠT HÌNH: 100% SÓI - TẬP 49"},{"time":"11:30","title":"THỜI SỰ"},{"time":"11:50","title":"BẢN TIN THỂ THAO"},{"time":"11:55","title":"BẢN TIN THỊ TRƯỜNG"},{"time":"11:55","title":"SỨC SỐNG NGHỊ QUYẾT"},{"time":"12:00","title":"PHIM TRUYỆN: GIA ĐÌNH LÀ TẤT CẢ - TẬP 55"},{"time":"12:00","title":"PHIM TRUYỆN: GIA ĐÌNH LÀ TẤT CẢ - TẬP 57"},{"time":"12:00","title":"PHIM TRUYỆN: GIA ĐÌNH LÀ TẤT CẢ - TẬP 59"},{"time":"12:45","title":"PHIM TRUYỆN: GIA ĐÌNH LÀ TẤT CẢ - TẬP 56"},{"time":"12:45","title":"PHIM TRUYỆN: GIA ĐÌNH LÀ TẤT CẢ - TẬP 58"},{"time":"12:45","title":"PHIM TRUYỆN: GIA ĐÌNH LÀ TẤT CẢ - TẬP 60"},{"time":"13:30","title":"CHƯƠNG TRÌNH TIẾNG CAO LAN"},{"time":"13:30","title":"CHƯƠNG TRÌNH TIẾNG HÀ NHÌ"},{"time":"13:30","title":"CHƯƠNG TRÌNH TIẾNG MƯỜNG"},{"time":"14:00","title":"CHƯƠNG TRÌNH TIẾNG MÔNG"},{"time":"14:30","title":"CHƯƠNG TRÌNH TIẾNG DAO"},{"time":"15:00","title":"CHƯƠNG TRÌNH TIẾNG THÁI"},{"time":"15:30","title":"CHƯƠNG TRÌNH TIẾNG MÔNG"},{"time":"16:00","title":"CHƯƠNG TRÌNH TIẾNG TÀY"},{"time":"16:30","title":"PHIM TRUYỆN: KHOẢNG CÁCH - TẬP 4"},{"time":"16:30","title":"PHIM TRUYỆN: KHOẢNG CÁCH - TẬP 5"},{"time":"16:30","title":"PHIM TRUYỆN: KHOẢNG CÁCH - TẬP 6"},{"time":"17:15","title":"KIẾN THỨC VÀ CUỘC SỐNG"},{"time":"17:15","title":"HÀNH TRÌNH MỞ LỐI"},{"time":"17:15","title":"THÔNG TIN CHÍNH SÁCH PHÁP LUẬT"},{"time":"17:30","title":"NHỊP SỐNG HÔM NAY"},{"time":"17:30","title":"TRẠM YÊU THƯƠNG: KHI NGHỀ XƯA THẮP LÊN HY VỌNG"},{"time":"17:45","title":"PHÓNG SỰ: HỢP NHẤT BA CHƯƠNG TRÌNH MỤC TIÊU QUỐC GIA"},{"time":"18:00","title":"THỜI SỰ"},{"time":"18:30","title":"THỜI TIẾT"},{"time":"18:35","title":"SẮC MÀU THỂ THAO"},{"time":"18:35","title":"BẢN TIN THỊ TRƯỜNG"},{"time":"18:35","title":"CHÀO TUẦN MỚI"},{"time":"18:40","title":"THIẾU NHI"},{"time":"18:40","title":"CẬN CẢNH THỂ THAO"},{"time":"19:00","title":"PHIM TRUYỆN: VÒNG TRÒN ĐỊNH MỆNH - TẬP 73"},{"time":"19:00","title":"PHIM TRUYỆN: VÒNG TRÒN ĐỊNH MỆNH - TẬP 75"},{"time":"19:00","title":"PHIM TRUYỆN: VÒNG TRÒN ĐỊNH MỆNH - TẬP 77"},{"time":"19:45","title":"PHIM TRUYỆN: VÒNG TRÒN ĐỊNH MỆNH - TẬP 74"},{"time":"19:45","title":"PHIM TRUYỆN: VÒNG TRÒN ĐỊNH MỆNH - TẬP 76"},{"time":"19:45","title":"PHIM TRUYỆN: VÒNG TRÒN ĐỊNH MỆNH - TẬP 78"},{"time":"20:30","title":"VUA TIẾNG VIỆT"},{"time":"20:30","title":"KHÁCH SẠN 5 SAO: CA SĨ ĐÔNG HÙNG - VÕ HẠ TRÂM"},{"time":"20:30","title":"PHIM TÀI LIỆU: HỒ TÙNG MẬU- TRỌN ĐỜI VÌ ĐẢNG, VÌ DÂN"},{"time":"21:00","title":"LÀM GIÀU TRÊN QUÊ HƯƠNG: HIỆU QUẢ MÔ HÌNH NUÔI CÁ GIỐNG"},{"time":"21:15","title":"VTV5 KẾT NỐI"},{"time":"21:15","title":"ĐIỂM HẸN BẢN SẮC: VANG XA VÓ NGỰA BẮC HÀ"},{"time":"21:15","title":"TRANG VĂN HÓA"},{"time":"21:30","title":"PHIM TRUYỆN: GIÂY PHÚT GẶP EM - TẬP 4"},{"time":"21:30","title":"PHIM TRUYỆN: GIÂY PHÚT GẶP EM - TẬP 5"},{"time":"21:30","title":"PHIM TRUYỆN: GIÂY PHÚT GẶP EM - TẬP 6"},{"time":"22:15","title":"BẠN KỂ TÔI NGHE"},{"time":"22:15","title":"VTV5 KẾT NỐI"},{"time":"22:15","title":"NHÌN RA THẾ GIỚI"},{"time":"22:30","title":"VĂN HỌC NGHỆ THUẬT: NGƯỜI GÓI MƯA MIỀN TRUNG"},{"time":"22:45","title":"XEM VÀ NGHĨ"},{"time":"22:45","title":"VTV5 KẾT NỐI"},{"time":"23:00","title":"PHIM TRUYỆN: THIÊN LONG BÁT BỘ - TẬP 8"},{"time":"23:00","title":"PHIM TRUYỆN: THIÊN LONG BÁT BỘ - TẬP 9"},{"time":"23:00","title":"PHIM TRUYỆN: THIÊN LONG BÁT BỘ - TẬP 10"}],"vtv6":[{"time":"00:00","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 32: COLOMBIA VS GHANA"},{"time":"00:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 32: ÚC - AI CẬP"},{"time":"00:20","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 1/8: PARAGUAY VS PHÁP"},{"time":"02:20","title":"TRUYỀN HÌNH TRỰC TIẾP FIFA WORLD CUP 2026 - VÒNG 1/8: BRAZIL VS NA UY"},{"time":"02:30","title":"360° THỂ THAO"},{"time":"03:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 1/8: PARAGUAY - PHÁP"},{"time":"03:30","title":"360° THỂ THAO"},{"time":"04:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 32: ARGENTINA - CABO VERDE"},{"time":"06:20","title":"TRUYỀN HÌNH TRỰC TIẾP FIFA WORLD CUP 2026 - VÒNG 1/8: MEXICO VS ANH"},{"time":"07:30","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 1/8: CANADA - MAROC"},{"time":"07:50","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 32: COLOMBIA - GHANA"},{"time":"09:20","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 1/8: PARAGUAY - PHÁP"},{"time":"10:15","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 1/8: BRAZIL VS NA UY"},{"time":"11:00","title":"CẬN CẢNH FIFA WORLD CUP 2026"},{"time":"11:30","title":"CẢM HỨNG FIFA WORL CUP 2026"},{"time":"11:45","title":"VTV SPORTS NEWS"},{"time":"12:00","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 32: ÚC - AI CẬP"},{"time":"12:00","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 32: TÂY BAN NHA - ÁO"},{"time":"12:00","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 1/8: MEXICO VS ANH"},{"time":"14:00","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 32: ARGENTINA - CABO VERDE"},{"time":"14:00","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 32: ÚC - AI CẬP"},{"time":"14:00","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 1/8: PARAGUAY VS PHÁP"},{"time":"15:50","title":"CẢM HỨNG FIFA WORL CUP 2026"},{"time":"16:00","title":"CẬN CẢNH FIFA WORLD CUP 2026"},{"time":"16:05","title":"SÂN CỎ FIFA WORLD CUP 2026"},{"time":"16:30","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 1/8: CANADA - MAROC"},{"time":"16:35","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 32: COLOMBIA - GHANA"},{"time":"16:35","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 1/8: CANADA - MAROC"},{"time":"18:30","title":"VTV SPORTS NEWS"},{"time":"18:45","title":"CẢM HỨNG FIFA WORL CUP 2026"},{"time":"18:45","title":"CẢM HỨNG FIFA WORLD CUP 2026"},{"time":"19:00","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 32: ÚC - AI CẬP"},{"time":"19:00","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 1/8: PARAGUAY - PHÁP"},{"time":"19:00","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 1/8: BRAZIL VS NA UY"},{"time":"21:00","title":"CẬN CẢNH FIFA WORLD CUP 2026"},{"time":"21:30","title":"SÂN CỎ FIFA WORLD CUP 2026"},{"time":"22:00","title":"360° THỂ THAO"},{"time":"22:30","title":"GIỜ VÀNG THỂ THAO"},{"time":"22:30","title":"TƯỜNG THUẬT: FIFA WORLD CUP 2026 - VÒNG 1/8: CANADA - MAROC"},{"time":"22:30","title":"TƯỜNG THUẬT FIFA WORLD CUP 2026 - VÒNG 1/8: MEXICO VS ANH"},{"time":"22:50","title":"NÓNG CÙNG FIFA WORLD CUP 2026"},{"time":"23:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 1/8: CANADA - MAROC"}],"vtv7":[{"time":"06:03","title":"7 PHÚT CHO BỮA SÁNG: CÀ TÍM NHÚNG TRỨNG CHIÊN"},{"time":"06:03","title":"7 PHÚT CHO BỮA SÁNG: BÁNH MÌ NƯỚNG MUỐI ỚT"},{"time":"06:03","title":"7 PHÚT CHO BỮA SÁNG: CƠM HỘP BENTO CHO BÉ"},{"time":"06:13","title":"ĐẸP HƠN MỖI NGÀY: SỐ 7 - CHĂM SÓC MÙI HƯƠNG CƠ THỂ"},{"time":"06:13","title":"ĐẸP HƠN MỖI NGÀY: SỐ 8 - DỌN TỦ ĐỒ THEO PHONG CÁCH NHẬT BẢN"},{"time":"06:13","title":"ĐẸP HƠN MỖI NGÀY: SỐ 9 - CÁCH CHỌN ÁO SƠ MI CHO NAM"},{"time":"06:20","title":"CÙNG NHAU TA VẬN ĐỘNG: SỐ 8 - NGÔI NHÀ BIỂN XANH"},{"time":"06:20","title":"CÙNG NHAU TA VẬN ĐỘNG: SỐ 9 - TRÒ CHƠI GIẢI CỨU"},{"time":"06:20","title":"CÙNG NHAU TA VẬN ĐỘNG: SỐ 10 - CÙNG LẮC LƯ"},{"time":"06:30","title":"NHỮNG NGƯỜI BẠN DIỆU KỲ: TẬP 3"},{"time":"06:30","title":"BẠN LÀ HÌNH GÌ: VŨ ĐIỆU NGÔI SAO"},{"time":"06:30","title":"BẠN LÀ HÌNH GÌ: HÒN ĐẢO KỲ LẠ"},{"time":"06:35","title":"ĐỘI CỨU HỘ BIỂN XANH: TẬP 1"},{"time":"06:35","title":"ĐỘI CỨU HỘ BIỂN XANH - TẬP 2"},{"time":"06:45","title":"Ú ÒA: SỐ 14 - CHƠI VỚI BÓNG THỔI"},{"time":"06:45","title":"Ú ÒA: SỐ 15 - HAI BÀN TAY"},{"time":"06:45","title":"Ú ÒA: SỐ 16 - CẢM ƠN - XIN LỖI"},{"time":"07:00","title":"XỨ SỞ CẦU VỒNG: SỐ 18"},{"time":"07:00","title":"XỨ SỞ CẦU VỒNG: SỐ 19"},{"time":"07:00","title":"XỨ SỞ CẦU VỒNG - SỐ 20"},{"time":"07:30","title":"CRACK EM UP: SỐ 5"},{"time":"07:30","title":"CRACK EM UP: SỐ 6"},{"time":"07:30","title":"IELTS FACE OFF: SỐ 6 - GLOBETROTTER"},{"time":"08:00","title":"KHÁM PHÁ KHOA HỌC: SỐ 39 - TOÀ THÁP LỚN LÊN"},{"time":"08:00","title":"KHÁM PHÁ KHOA HỌC: SỐ 40 - DẠO BƯỚC TRÊN CẦU VỒNG"},{"time":"08:00","title":"KHÁM PHÁ KHOA HỌC: SỐ 41 - TỰ LÀM Ô TÔ ĐỘNG CƠ KHÍ NÉN"},{"time":"08:30","title":"BIỆT ĐỘI BICHILI: SỐ 2 - CÔNG CHÚA HOÁ HỌC"},{"time":"08:30","title":"CHUYỆN HỌC TRÒ: SỐ 7 - CÂU CHUYỆN VỚI HÌNH XĂM"},{"time":"08:30","title":"VĂN VUI VẺ: SỐ 1 - BỨC THƯ CỦA THỦ LĨNH DA ĐỎ"},{"time":"09:00","title":"HỌC SAO CHO TỐT: SỐ 3 - BƯỚC NGOẶT"},{"time":"09:00","title":"HỌC SAO CHO TỐT: SỐ 4 - MÔN TOÁN ĐÁNG SỢ"},{"time":"09:00","title":"HEO ĐẤT: SỐ 1 - TIỀN LÀ GÌ"},{"time":"09:20","title":"SÁNG TẠO 102: SỐ 10 - SA MẠC"},{"time":"09:35","title":"MỘT VÒNG TIẾNG VIỆT - SỐ 9"},{"time":"09:45","title":"ĐƯỜNG ĐẾN TRƯỜNG: TẤM VÉ TỚI TRƯỜNG"},{"time":"09:45","title":"ĐƯỜNG ĐẾN TRƯỜNG: NHỮNG CÂU CHUYỆN TRÊN ĐẢO"},{"time":"09:50","title":"MATH DORM: SỐ 5 - PHÉP NHÂN"},{"time":"10:00","title":"HÔM NAY CHƠI GÌ?: ĐỘNG VẬT - PHẦN 1"},{"time":"10:00","title":"HÔM NAY CHƠI GÌ?: ĐỘNG VẬT - PHẦN 2"},{"time":"10:05","title":"NHỮNG NGƯỜI BẠN CẦU VỒNG: HOA - TẬP 1"},{"time":"10:30","title":"CHUYẾN XE HẠT VỪNG: SỐ 16"},{"time":"10:30","title":"CHUYẾN XE HẠT VỪNG: SỐ 17"},{"time":"10:30","title":"CHUYẾN XE HẠT VỪNG - SỐ 18"},{"time":"11:00","title":"CHA MẸ THAY ĐỔI: SỐ 1 - ÂM THANH CỦA NHỮNG BẢN NHẠC BUỒN"},{"time":"11:00","title":"TRƯỜNG TEEN"},{"time":"11:00","title":"CÙNG LĂN VÀO BẾP - SỐ 23"},{"time":"11:15","title":"ĐẸP HƠN MỖI NGÀY: SỐ 8 - DỌN TỦ ĐỒ THEO PHONG CÁCH NHẬT BẢN"},{"time":"11:20","title":"5 KÝ HIỆU NGÔN NGỮ MỖI NGÀY: CẢM XÚC - PHẦN 2"},{"time":"11:30","title":"MẸ ƠI TẠI SAO: SỐ 3 - TẬP TRUNG"},{"time":"11:45","title":"TRƯỜNG HỌC HẠNH PHÚC: ĐỪNG ĐỂ NHỮNG ĐỨA TRẺ CÔ ĐƠN - TẬP 11"},{"time":"12:00","title":"XỨ SỞ CẦU VỒNG: SỐ 17"},{"time":"12:00","title":"XỨ SỞ CẦU VỒNG: SỐ 18"},{"time":"12:00","title":"XỨ SỞ CẦU VỒNG - SỐ 19"},{"time":"12:30","title":"ENGLISH BY STORIES: SỐ 10 - BẢY ĐIỀU ƯỚC"},{"time":"12:30","title":"ENGLISH BY STORIES: SỐ 1 - SỰ TÍCH SỌ DỪA"},{"time":"12:30","title":"VĂN VUI VẺ: SỐ 1 - BỨC THƯ CỦA THỦ LĨNH DA ĐỎ"},{"time":"12:55","title":"5 TỪ MỚI TIẾNG ANH MỖI NGÀY: MÀU SẮC"},{"time":"12:55","title":"5 TỪ MỚI TIẾNG ANH MỖI NGÀY: GIA VỊ"},{"time":"13:00","title":"JUMPING WITH TOEIC: SỐ 53"},{"time":"13:00","title":"JUMPING WITH TOEIC: SỐ 54"},{"time":"13:00","title":"JUMPING WITH TOEIC - SỐ 55"},{"time":"13:45","title":"FOLLOW US: SỐ 24 - LET ME GO HOME"},{"time":"13:45","title":"FOLLOW US: SỐ 25 - SKIN CARE"},{"time":"13:45","title":"FOLLOW US: SỐ 26 - SHOPAHOLICS"},{"time":"14:00","title":"CRACK EM UP: SỐ 5"},{"time":"14:00","title":"CRACK EM UP: SỐ 6"},{"time":"14:00","title":"IELTS FACE OFF: SỐ 6 - GLOBETROTTER"},{"time":"14:30","title":"KHÁM PHÁ KHOA HỌC: SỐ 39 - TOÀ THÁP LỚN LÊN"},{"time":"14:30","title":"KHÁM PHÁ KHOA HỌC: SỐ 40 - DẠO BƯỚC TRÊN CẦU VỒNG"},{"time":"14:30","title":"KHÁM PHÁ KHOA HỌC: SỐ 41 - TỰ LÀM Ô TÔ ĐỘNG CƠ KHÍ NÉN"},{"time":"15:00","title":"GÕ CỬA NGHỀ NGHIỆP: SỐ 3"},{"time":"15:00","title":"GÕ CỬA NGHỀ NGHIỆP: SỐ 5"},{"time":"15:00","title":"HEO ĐẤT: SỐ 1 - TIỀN LÀ GÌ"},{"time":"15:20","title":"SÁNG TẠO 102: SỐ 10 - SA MẠC"},{"time":"15:35","title":"MỘT VÒNG TIẾNG VIỆT - SỐ 9"},{"time":"15:45","title":"CON ĐƯỜNG NGHỀ NGHIỆP: SỐ 27 - CHĂM SÓC SẮC ĐẸP"},{"time":"15:45","title":"CON ĐƯỜNG NGHỀ NGHIỆP: SỐ 33 - KỸ SƯ CƠ KHÍ"},{"time":"15:50","title":"MATH DORM: SỐ 5 - PHÉP NHÂN"},{"time":"16:00","title":"CHA MẸ THAY ĐỔI: SỐ 1 - ÂM THANH CỦA NHỮNG BẢN NHẠC BUỒN"},{"time":"16:00","title":"TRƯỜNG TEEN"},{"time":"16:05","title":"CÙNG LĂN Vào Bếp - SỐ 23"},{"time":"16:20","title":"CUỐN SÁCH CỦA TÔI: SỐ 8 - RICO VÀ OSKAR"},{"time":"16:30","title":"MẸ ƠI TẠI SAO: SỐ 3 - TẬP TRUNG"},{"time":"16:45","title":"TRƯỜNG HỌC HẠNH PHÚC: ĐỪNG ĐỂ NHỮNG ĐỨA TRẺ CÔ ĐƠN - TẬP 11"},{"time":"17:00","title":"XỨ SỞ CẦU VỒNG: SỐ 18"},{"time":"17:00","title":"XỨ SỞ CẦU VỒNG: SỐ 19"},{"time":"17:00","title":"XỨ SỞ CẦU VỒNG - SỐ 20"},{"time":"17:30","title":"TRƯỜNG HỌC HẠNH PHÚC: ĐỪNG ĐỂ NHỮNG ĐỨA TRẺ CÔ ĐƠN - TẬP 9"},{"time":"17:30","title":"TRƯỜNG HỌC HẠNH PHÚC: ĐỪNG ĐỂ NHỮNG ĐỨA TRẺ CÔ ĐƠN - TẬP 10"},{"time":"17:30","title":"VĂN VUI VẺ: SỐ 1 - BỨC THƯ CỦA THỦ LĨNH DA ĐỎ"},{"time":"17:45","title":"EM YÊU VIỆT NAM: SỐ 6"},{"time":"17:45","title":"EM YÊU VIỆT NAM: SỐ 2 - VÀO MÙA"},{"time":"18:00","title":"THỬ THÁCH KHOA HỌC: SỐ 7 - HÓA HỌC ỨNG DỤNG"},{"time":"18:00","title":"THỬ THÁCH KHOA HỌC: SỐ 8 - KHÁM PHÁ CHẤT CHỈ THỊ"},{"time":"18:00","title":"THỬ THÁCH KHOA HỌC: SỐ 9 - ÁP SUẤT RẤT THÚ VỊ"},{"time":"18:15","title":"STREAM TOÁN HỌC: SỐ 8"},{"time":"18:15","title":"STREAM TOÁN HỌC: SỐ 9"},{"time":"18:15","title":"STREAM TOÁN HỌC - SỐ 10"},{"time":"18:30","title":"ENGLISH BY STORIES: SỐ 10 - BẢY ĐIỀU Ư��C"},{"time":"18:30","title":"ENGLISH BY STORIES: SỐ 1 - SỰ TÍCH SỌ DỪA"},{"time":"18:30","title":"CUỐN SÁCH CỦA EM: SỐ 3 - PIPPY TẤT DÀI"},{"time":"18:45","title":"LÀ LA LÁ: SỐ 4 - CUỘC THI CAO ĐỘ"},{"time":"18:55","title":"5 TỪ MỚI TIẾNG ANH MỖI NGÀY: MÀU SẮC"},{"time":"18:55","title":"5 TỪ MỚI TIẾNG ANH MỖI NGÀY: GIA VỊ"},{"time":"19:00","title":"CHUYẾN XE HẠT VỪNG: SỐ 16"},{"time":"19:00","title":"CHUYẾN XE HẠT VỪNG: SỐ 17"},{"time":"19:00","title":"CHUYẾN XE HẠT VỪNG - SỐ 18"},{"time":"19:30","title":"HÔM NAY CHƠI GÌ?: ĐỘNG VẬT - PHẦN 1"},{"time":"19:30","title":"HÔM NAY CHƠI GÌ?: ĐỘNG VẬT - PHẦN 2"},{"time":"19:30","title":"NHỮNG NGƯỜI BẠN CẦU VỒNG: HOA - TẬP 1"},{"time":"19:50","title":"CHÔM CHÔM VÀ NHỮNG NGƯỜI BẠN: GIẢI CỨU CHÔM CHÔM"},{"time":"20:00","title":"123 TA CÙNG ĐẾM: SỐ 0"},{"time":"20:00","title":"123 TA CÙNG ĐẾM: SỐ 1"},{"time":"20:00","title":"KỸ NĂNG AN TOÀN CHO BÉ - SỐ 6"},{"time":"20:10","title":"HỌC VẼ CÙNG ẾCH CỐM: SỐ 39"},{"time":"20:10","title":"HỌC VẼ CÙNG ẾCH CỐM: SỐ 40"},{"time":"20:10","title":"NHỮNG NGƯỜI BẠN DIỆU KỲ - TẬP 4"},{"time":"20:20","title":"KIDS VOCAB: SỐ 12 - HEALTH PROBLEMS"},{"time":"20:20","title":"KIDS VOCAB: SỐ 13 - POSITIONS"},{"time":"20:20","title":"LÀ LA LÁ: SỐ 5 - CƯỜNG ĐỘ TO NHỎ CỦA ÂM THANH"},{"time":"20:35","title":"NGÀY XƯA CỔ TÍCH: SỐ 4 - QUẠ VÀ CÔNG"},{"time":"20:50","title":"CHUYỆN KỂ CỦA NHỮNG CHÚ CỪU: SỐ 20 - SỰ ĐOÀN KẾT CỦA BẦY CHIM"},{"time":"20:50","title":"CHUYỆN KỂ CỦA NHỮNG CHÚ CỪU: SỐ 21 - ĐÀN KIẾN TRẢ ƠN"},{"time":"20:50","title":"CHUYỆN KỂ CỦA NHỮNG CHÚ CỪU: SỐ 22 - CHÚ RÙA TẬP BAY"},{"time":"21:00","title":"GÕ CỬA NGHỀ NGHIỆP: SỐ 4"},{"time":"21:00","title":"GÕ CỬA NGHỀ NGHIỆP: SỐ 6"},{"time":"21:00","title":"GÕ CỬA NGHỀ NGHIỆP - SỐ 7"},{"time":"21:45","title":"HỌC TIẾNG ANH QUA BÀI HÁT: SỐ 23 - GIRL ON FIRE"},{"time":"21:45","title":"HỌC TIẾNG ANH QUA BÀI HÁT: SỐ 24 - WE DONT TALK ANYMORE"},{"time":"21:45","title":"HỌC TIẾNG ANH QUA BÀI HÁT: SỐ 25 - LA LA LA"},{"time":"22:00","title":"JUMPING WITH TOEIC: SỐ 53"},{"time":"22:00","title":"JUMPING WITH TOEIC: SỐ 54"},{"time":"22:00","title":"JUMPING WITH TOEIC - SỐ 55"},{"time":"22:45","title":"FOLLOW US: SỐ 24 - LET ME GO HOME"},{"time":"22:45","title":"FOLLOW US: SỐ 25 - SKIN CARE"},{"time":"22:45","title":"FOLLOW US: SỐ 26 - SHOPAHOLICS"},{"time":"23:00","title":"CUỐN SÁCH CỦA TÔI: SỐ 5 - NHÀ GIẢ KIM"},{"time":"23:00","title":"CUỐN SÁCH CỦA TÔI: SỐ 7 - DỐC HẾT TRÁI TIM"},{"time":"23:00","title":"CUỐN SÁCH CỦA TÔI: SỐ 8 - RICO VÀ OSKAR"},{"time":"23:15","title":"CHUYỆN HỌC TRÒ: SỐ 6 - ƯỚC MƠ TRỞ THÀNH GAME THỦ"},{"time":"23:15","title":"CHUYỆN HỌC TRÒ: SỐ 8 - NÓI DỐI"},{"time":"23:15","title":"CHUYỆN HỌC TRÒ: SỐ 9 - NHÀ BÁO TẬP SỰ"},{"time":"23:40","title":"KHÔNG THÌ THẦM: SỐ 11 - ẢO TƯỞNG"},{"time":"23:40","title":"KHÔNG THÌ THẦM: SỐ 12 - XA NHÀ"},{"time":"23:40","title":"KHÔNG THÌ THẦM: SỐ 13 - HẬU LẦN ĐẦU LÀM CHUYỆN ẤY"}],"vtv8":[{"time":"00:00","title":"KÝ SỰ: VỀ MIỀN DI SẢN: NGHỆ NHÂN - NGƯỜI GIỮ HỒN DI SẢN"},{"time":"00:00","title":"KÝ SỰ: PHÁT HUY GIÁ TRỊ CÁC DI SẢN VĂN HÓA MIỀN KINH BẮC"},{"time":"00:00","title":"KÝ SỰ"},{"time":"00:15","title":"TRƯỜNG SƠN VẠN DẶM: TINH HOA THỔ CẨM GIỮA ĐẠI NGÀN TRƯỜNG SƠN"},{"time":"00:15","title":"QUYẾN RŨ VIỆT NAM: ÂM SẮC LÂM BÌNH"},{"time":"00:15","title":"ĐIỂM TỰA BÌNH YÊN"},{"time":"00:30","title":"PHIM TRUYỆN: MỘNG HOA LỤC - TẬP 22"},{"time":"00:30","title":"PHIM TRUYỆN: MỘNG HOA LỤC - TẬP 23"},{"time":"00:30","title":"PHIM TRUYỆN"},{"time":"01:00","title":"PHÓNG SỰ: ĐẮK LẮK - MỞ CỬA TIỀM NĂNG, ĐÓN SÓNG ĐẦU TƯ"},{"time":"01:00","title":"GIAI ĐIỆU KẾT NỐI: MÙA HÈ YÊU THƯƠNG"},{"time":"01:00","title":"ĐI ĐỂ BIẾT"},{"time":"01:15","title":"KẾT NỐI VTV8"},{"time":"01:15","title":"PHÓNG SỰ: NÂNG CAO CHẤT LƯỢNG PHỤC VỤ TRẢI NGHIỆM DI SẢN HUẾ"},{"time":"01:30","title":"CÀ PHÊ TÁM: CHÂN DUNG HẠNH PHÚC"},{"time":"01:30","title":"ĐI ĐỂ BIẾT: NHỮNG NGÀY Ở ĐẢO TRẦN - ĐẢO TIỀN TIÊU CỦA TỔ QUỐC"},{"time":"01:30","title":"VĂN HỌC - NGHỆ THUẬT"},{"time":"01:45","title":"MIỀN ĐẤT VÕ: VOVINAM - DĨ NHU CHẾ CƯƠNG"},{"time":"01:55","title":"ẨM THỰC ĐỘC ĐÁO"},{"time":"02:00","title":"PHIM SITCOM: MỘT NHÀ TRĂM CHUYỆN - TẬP 21"},{"time":"02:00","title":"PHIM SITCOM: MỘT NHÀ TRĂM CHUYỆN - TẬP 22"},{"time":"02:00","title":"PHIM SITCOM"},{"time":"02:15","title":"KHÁM PHÁ THẾ GIỚI: BẢO TỒN CÁC LOÀI VẬT - TẬP 4"},{"time":"02:15","title":"KHÁM PHÁ THẾ GIỚI: BẢO TỒN CÁC LOÀI VẬT - TẬP 5"},{"time":"02:15","title":"KHÁM PHÁ THẾ GIỚI"},{"time":"02:45","title":"PHỐ TÀI CHÍNH: KỲ VỌNG THỊ TRƯỜNG TRONG TRUNG VÀ DÀI HẠN"},{"time":"02:45","title":"CHÉM GIÓ - GIÓ CHÉM: NGHỆ THUẬT GÓP Ý"},{"time":"02:45","title":"GIẢI Mà CUỘC SỐNG"},{"time":"03:00","title":"ĐI ĐỂ BIẾT: TRẢI NGHIỆM CHÀI LƯỚI CÙNG NGƯ DÂN ĐẢO THANH LÂN"},{"time":"03:00","title":"GIAO LƯU - TỌA ĐÀM: TỪ TINH GỌN BỘ MÁY ĐẾN HIỆU QUẢ VẬN HÀNH"},{"time":"03:00","title":"ATLAS"},{"time":"03:25","title":"PHÓNG SỰ"},{"time":"03:30","title":"TỪ NHỮNG MIỀN QUÊ: NGƯỜI MÔNG NƠI ĐỈNH TRỜI ĐÁ XÁM"},{"time":"03:30","title":"PHÓNG SỰ: BẮC NINH - CÁC TÔN GIÁO ĐỒNG LÒNG CHUNG TAY XÂY DỰNG QUÊ HƯƠNG"},{"time":"03:35","title":"TỪ NHỮNG MIỀN QUÊ"},{"time":"03:45","title":"PHIM TRUYỆN: KIẾM CHỒNG CHO MẸ CHỒNG - TẬP 62"},{"time":"03:45","title":"PHIM TRUYỆN: KIẾM CHỒNG CHO MẸ CHỒNG - TẬP 63"},{"time":"03:45","title":"PHIM TRUYỆN"},{"time":"04:15","title":"PHIM TÀI LIỆU: DẤU ẤN PHẬT HOÀNG - DI SẢN NGÀN NĂM"},{"time":"04:15","title":"PHIM TÀI LIỆU: THIÊN NHIÊN HOANG Dà PHONG NHA - KẺ BÀNG - PHẦN 1"},{"time":"04:15","title":"PHIM TÀI LIỆU"},{"time":"04:45","title":"PHÓNG SỰ: CHÍNH QUYỀN CẤP Xà - BIẾN ÁP LỰC THÀNH ĐỘNG LỰC PHÁT TRIỂN"},{"time":"04:45","title":"TRƯỜNG SƠN VẠN DẶM: HÙNG SƠN - NƠI RỪNG KỂ CHUYỆN"},{"time":"04:45","title":"QUYẾN RŨ VIỆT NAM"},{"time":"05:00","title":"KHẾ ƯỚC THỜI GIAN: CỒNG CHIÊNG, TỪ ĐÁ ĐẾN ĐỒNG"},{"time":"05:00","title":"ATLAS: NGA SƠN - MIỀN QUÊ HUYỀN THOẠI"},{"time":"05:00","title":"DẤU ẤN LỊCH SỬ"},{"time":"05:30","title":"NẺO VỀ NGUỒN CỘI: DẤU ẤN VĂN HÓA CỔ ĐỒNG NAI"},{"time":"05:30","title":"GIẢI Mà CUỘC SỐNG: NGHỀ VẼ TRUYỀN THẦN VÀ NHỮNG ĐIỀU CHƯA BIẾT"},{"time":"05:30","title":"NẺO VỀ NGUỒN CỘI"},{"time":"05:45","title":"KÝ SỰ: PHÁT HUY GIÁ TRỊ CÁC DI SẢN VĂN HÓA MIỀN KINH BẮC"},{"time":"05:45","title":"KÝ SỰ: CHÙA THIÊNG XỨ KINH BẮC: CHÙA DÂU"},{"time":"05:45","title":"KÝ SỰ"},{"time":"06:00","title":"CÂU CHUYỆN TỪ NHỮNG BÀI CA: LÁ THƯ TRUÔNG BỒN"},{"time":"06:00","title":"GIAI ĐIỆU KẾT NỐI: ƯỚC GÌ"},{"time":"06:00","title":"GIAI ĐIỆU KẾT NỐI"},{"time":"06:15","title":"NÔNG SẢN KỂ CHUYỆN: HƯƠNG QUẾ GIỮ RỪNG"},{"time":"06:20","title":"SẮC MÀU CÁC DÂN TỘC"},{"time":"06:25","title":"NẺO VỀ NGUỒN CỘI: DẤU ẤN VĂN HÓA CỔ ĐỒNG NAI"},{"time":"06:40","title":"THÔNG TIN - DỊCH VỤ"},{"time":"06:45","title":"HÀNH TRÌNH DI SẢN: MƯỜNG VANG TIẾNG ĐẤT"},{"time":"06:45","title":"HÀNH TRÌNH DI SẢN: HÀO KHÍ LAM SƠN"},{"time":"06:45","title":"GIẢI Mà SỨC KHỎE"},{"time":"07:30","title":"NẺO VỀ NGUỒN CỘI: TRỐNG SÀNH CỦA NGƯỜI CAO LAN"},{"time":"07:30","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG: HÌNH TƯỢNG NGỰA TRIỀU NGUYỄN"},{"time":"07:30","title":"NẺO VỀ NGUỒN CỘI"},{"time":"07:45","title":"GIẢI Mà SỨC KHỎE: CẢI THIỆN ĐAU NHỨC XƯƠNG KHỚP THƯỜNG XUYÊN TÁI PHÁT Ở NGƯỜI LỚN TUỔI"},{"time":"07:45","title":"GIẢI Mà SỨC KHỎE: BÍ QUYẾT NÂNG CAO SỨC KHỎE TIM MẠCH, CHỐNG LÃO HÓA Ở NGƯỜI CAO TUỔI"},{"time":"07:45","title":"GIẢI Mà SỨC KHỎE"},{"time":"08:45","title":"CÂU CHUYỆN TỪ NHỮNG BÀI CA: THỜI HOA ĐỎ"},{"time":"08:45","title":"SỰ SỐNG DIỆU KỲ: YJEK NIÊ KDĂM - NGƯỜI SƯU TẦM SỬ THI TÂY NGUYÊN"},{"time":"08:45","title":"SỰ SỐNG DIỆU KỲ"},{"time":"09:00","title":"HIỂU ĐÚNG - SỐNG KHỎE: ĐÔNG TRÙNG HẠ THẢO VỚI SỨC KHỎE TIM MẠCH PHÒNG NGỪA ĐỘT QUỴ"},{"time":"09:00","title":"HIỂU ĐÚNG - SỐNG KHỎE: ĐIỀU HÒA HUYẾT ÁP, BẢO VỆ SỨC KHỎE TIM MẠCH NHỜ SÂM DÂN GIAN"},{"time":"09:00","title":"HIỂU ĐÚNG - SỐNG KHỎE"},{"time":"10:00","title":"KẾT NỐI VTV8"},{"time":"10:00","title":"TỪ NHỮNG MIỀN QUÊ: NGƯỜI LÀO Ở NÚA NGAM"},{"time":"10:00","title":"TỪ NHỮNG MIỀN QUÊ"},{"time":"10:15","title":"PHIM TÀI LIỆU: GIẢM KÉP, ÁP LỰC GẤP BA"},{"time":"10:15","title":"PHIM CA NHẠC: VỌNG NGUYỆT"},{"time":"10:15","title":"CHẤT LƯỢNG CUỘC SỐNG"},{"time":"11:00","title":"NÓNG CÙNG V8: NÓNG CÙNG V8 (20)"},{"time":"11:00","title":"NÓNG CÙNG V8"},{"time":"11:15","title":"KINH TẾ KẾT NỐI"},{"time":"11:30","title":"24H ONLINE"},{"time":"11:45","title":"DỰ BÁO THỜI TIẾT: DỰ BÁO THỜI TIẾT (V8)"},{"time":"11:45","title":"DỰ BÁO THỜI TIẾT"},{"time":"11:50","title":"PHIM SITCOM: MỘT NHÀ TRĂM CHUYỆN - TẬP 22"},{"time":"11:50","title":"PHIM SITCOM: MỘT NHÀ TRĂM CHUYỆN - TẬP 23"},{"time":"11:50","title":"PHIM SITCOM"},{"time":"12:05","title":"PHIM TRUYỆN: MỘNG HOA LỤC - TẬP 23"},{"time":"12:05","title":"PHIM TRUYỆN: MỘNG HOA LỤC - TẬP 24"},{"time":"12:05","title":"PHIM TRUYỆN"},{"time":"12:35","title":"DỰ BÁO THỜI TIẾT: DỰ BÁO THỜI TIẾT (V8)"},{"time":"12:35","title":"DỰ BÁO THỜI TIẾT"},{"time":"12:40","title":"THỂ THAO"},{"time":"12:45","title":"PHIM TRUYỆN: SỨ MỆNH VẪY GỌI - TẬP 52"},{"time":"12:45","title":"PHIM TRUYỆN: SỨ MỆNH VẪY GỌI - TẬP 53"},{"time":"12:45","title":"PHIM TRUYỆN"},{"time":"13:15","title":"QUYẾN RŨ VIỆT NAM: ÂM SẮC LÂM BÌNH"},{"time":"13:15","title":"PHIM TÀI LIỆU: TRÚC LÂM YÊN TỬ - DÒNG THIỀN THUẦN VIỆT"},{"time":"13:15","title":"NƠI ĐÂU CŨNG LÀ NHÀ"},{"time":"13:30","title":"GIẢI Mà SỨC KHỎE: BÍ QUYẾT NÂNG CAO SỨC KHỎE TIM MẠCH, CHỐNG LÃO HÓA Ở NGƯỜI CAO TUỔI"},{"time":"13:30","title":"GIẢI Mà SỨC KHỎE: SUY GIẢM SINH LÝ NAM - XU HƯỚNG ĐIỀU TRỊ TỪ THẢO DƯỢC"},{"time":"13:30","title":"GIẢI Mà SỨC KHỎE"},{"time":"14:30","title":"MIỀN ĐẤT VÕ: VOVINAM - DĨ NHU CHẾ CƯƠNG"},{"time":"14:30","title":"CHECK IN VIỆT NAM: VIÊN NGỌC BIỂN KHƠI"},{"time":"14:30","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG"},{"time":"14:50","title":"HIỂU ĐÚNG - SỐNG KHỎE: ĐIỀU HÒA HUYẾT ÁP, BẢO VỆ SỨC KHỎE TIM MẠCH NHỜ SÂM DÂN GIAN"},{"time":"14:50","title":"HIỂU ĐÚNG - SỐNG KHỎE: TĂNG CƯỜNG SỨC KHỎE TIM MẠCH, ỔN ĐỊNH HUYẾT ÁP"},{"time":"14:50","title":"HIỂU ĐÚNG - SỐNG KHỎE"},{"time":"15:50","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG: GỐM VIỆT - TẬP 3: GỐM BÀU TRÚC"},{"time":"15:50","title":"KINH TẾ KẾT NỐI"},{"time":"15:50","title":"PHỐ TÀI CHÍNH"},{"time":"16:05","title":"MUÔN MÀU CUỘC SỐNG: MÙA SEN HUẾ"},{"time":"16:05","title":"MUÔN MÀU CUỘC SỐNG"},{"time":"16:15","title":"PHIM TÀI LIỆU: THIÊN NHIÊN HOANG Dà PHONG NHA - KẺ BÀNG - PHẦN 1"},{"time":"16:15","title":"CHẤT LƯỢNG CUỘC SỐNG: CHỦ ĐỘNG PHÒNG NGỪA SỐT XUẤT HUYẾT TRONG MÙA CAO ĐIỂM"},{"time":"16:15","title":"PHIM TÀI LIỆU"},{"time":"16:45","title":"KINH TẾ KẾT NỐI"},{"time":"17:00","title":"SỐNG KHỎE: SUY GIẢM SINH LÝ NAM - PHƯƠNG PHÁP ĐIỀU TRỊ TỪ GỐC"},{"time":"17:00","title":"PHIM TÀI LIỆU: DÒNG NƯỚC KHÔNG LỐI VỀ"},{"time":"17:00","title":"HÀNH TRÌNH DI SẢN"},{"time":"17:45","title":"THẾ GIỚI THỂ THAO"},{"time":"18:00","title":"TRẠM 18H"},{"time":"18:30","title":"CHUYỂN ĐỘNG HÔM NAY"},{"time":"18:45","title":"THỂ THAO"},{"time":"18:50","title":"NHÂN TÀI ĐẠI VIỆT: LŨ CHIM QUÁI ÁC"},{"time":"18:50","title":"NHÂN TÀI ĐẠI VIỆT: ĐỘI QUÂN BÙ NHÌN"},{"time":"18:50","title":"NHÂN TÀI ĐẠI VIỆT"},{"time":"19:00","title":"PHIM TRUYỆN: KIẾM CHỒNG CHO MẸ CHỒNG - TẬP 63"},{"time":"19:00","title":"PHIM TRUYỆN: KIẾM CHỒNG CHO MẸ CHỒNG - TẬP 64"},{"time":"19:00","title":"PHIM TRUYỆN"},{"time":"19:30","title":"CA NHẠC: LỜI TRÁI TIM: KỂ CHUYỆN ĐÊM MƯA"},{"time":"19:30","title":"ĐI ĐỂ BIẾT: CHUYỆN Ở ĐẢO NHỎ THANH LÂN"},{"time":"19:30","title":"ATLAS"},{"time":"20:00","title":"TRUYỀN HÌNH TRỰC TIẾP: BẾ MẠC LIÊN HOAN PHIM CHÂU Á ĐÀ NẴNG LẦN THỨ IV"},{"time":"20:00","title":"DỰ BÁO THỜI TIẾT: DỰ BÁO THỜI TIẾT (V8)"},{"time":"20:00","title":"DỰ BÁO THỜI TIẾT"},{"time":"20:05","title":"CHÉM GIÓ - GIÓ CHÉM: TÌNH YÊU TƯƠNG ĐỒNG"},{"time":"20:05","title":"KẾT NỐI VTV8"},{"time":"20:20","title":"ẨM THỰC ĐỘC ĐÁO: CÀ MUỐI HÀ TĨNH"},{"time":"20:20","title":"ẨM THỰC ĐỘC ĐÁO"},{"time":"20:25","title":"TÌNH CA BẤT HỦ: TÌNH KHÚC PHÚ QUANG"},{"time":"20:25","title":"PHIM TRUYỆN"},{"time":"21:05","title":"PHIM TÀI LIỆU: MỘT NĂM SẮP XẾP LẠI GIANG SƠN"},{"time":"21:10","title":"PHIM TRUYỆN"},{"time":"21:35","title":"GIẢI Mà SỨC KHỎE: XU HƯỚNG SỬ DỤNG THẢO DƯỢC TRONG HỖ TRỢ TĂNG CƯỜNG SINH LÝ NAM"},{"time":"21:35","title":"DẤU ẤN LỊCH SỬ"},{"time":"22:00","title":"DỰ BÁO THỜI TIẾT: DỰ BÁO THỜI TIẾT (V8)"},{"time":"22:05","title":"DẤU ẤN LỊCH SỬ: CHUYỆN LÀNG TRONG VẬN NƯỚC"},{"time":"22:05","title":"VĂN HỌC - NGHỆ THUẬT"},{"time":"22:35","title":"MUÔN MÀU CUỘC SỐNG"},{"time":"22:40","title":"TRẠM 18H"},{"time":"23:10","title":"CHUYỂN ĐỘNG HÔM NAY"},{"time":"23:30","title":"KHÁM PHÁ THẾ GIỚI: BẢO TỒN CÁC LOÀI VẬT - TẬP 5"},{"time":"23:30","title":"KHÁM PHÁ THẾ GIỚI: NHỮNG ANH HÙNG ĐỜI THƯỜNG - TẬP 1"},{"time":"23:30","title":"KHÁM PHÁ THẾ GIỚI"}],"vtv9":[{"time":"00:00","title":"ĐƯỜNG DÂY NÓNG VTV9"},{"time":"00:00","title":"PHIM TRUYỆN"},{"time":"00:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 32 ĐỘI - AUSTRALIA – AI CẬP"},{"time":"00:45","title":"PHIM TRUYỆN"},{"time":"01:30","title":"NẺO VỀ NGUỒN CỘI"},{"time":"01:45","title":"TIÊU ĐIỂM CHÍNH SÁCH"},{"time":"02:00","title":"VÌ NHÂN DÂN QUÊN MÌNH"},{"time":"02:15","title":"PHIM TÀI LIỆU"},{"time":"02:45","title":"THTT FIFA WORLD CUP 2026 - VÒNG 16 ĐỘI: BRAZIL VS NA UY"},{"time":"03:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 1/8 - PARAGUAY - PHÁP"},{"time":"04:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 32 ĐỘI - ARGENTINA - CAPE VERDE"},{"time":"05:25","title":"TÁM CÔNG SỞ"},{"time":"05:35","title":"CHUYỆN PHỐ PHƯỜNG"},{"time":"05:40","title":"THỊ TRƯỜNG 360 ĐỘ"},{"time":"05:45","title":"VÕ THUẬT TỔNG HỢP MMA"},{"time":"06:15","title":"CÂU CHUYỆN TỪ CUỘC SỐNG"},{"time":"06:25","title":"THÀNH PHỐ ẤM ÁP TÌNH NGƯỜI"},{"time":"06:30","title":"THTT FIFA WORLD CUP 2026 - VÒNG 16 ĐỘI: MEXICO VS ANH"},{"time":"07:35","title":"TIẾU LÂM DU KÝ"},{"time":"07:50","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 32 ĐỘI - COLOMBIA - GHANA"},{"time":"07:50","title":"KÍNH ĐA CHIỀU"},{"time":"08:00","title":"NỘI THẤT KHÔNG GIỚI HẠN: NỘI THẤT TỐI GIẢN - GIÁ TRỊ CỦA KHÔNG GIAN"},{"time":"08:15","title":"CHỮA BỆNH CÙNG CHUYÊN GIA"},{"time":"09:00","title":"PHIM TÀI LIỆU: MỘT NĂM SẮP XẾP LẠI GIANG SƠN – TINH GỌN, GẦN DÂN, HIỆU QUẢ"},{"time":"09:20","title":"CÂU CHUYỆN TỪ CUỘC SỐNG"},{"time":"09:30","title":"CÂU CHUYỆN NHÂN VẬT"},{"time":"09:30","title":"ALO DOCTOR CUỐI TUẦN"},{"time":"10:00","title":"CUỐI TUẦN KỂ CHUYỆN"},{"time":"10:00","title":"VÕ THUẬT TỔNG HỢP MMA"},{"time":"10:30","title":"DẠO QUANH THỊ TRƯỜNG"},{"time":"10:45","title":"THỊ TRƯỜNG 360 ĐỘ"},{"time":"10:50","title":"GÓC NHÌN NGƯỜI TIÊU DÙNG"},{"time":"10:50","title":"THÀNH PHỐ ẤM ÁP TÌNH NGƯỜI"},{"time":"10:55","title":"CHUYỆN PHỐ PHƯỜNG"},{"time":"11:00","title":"VIỆT NAM ƠI - MÌNH CÙNG ĐI: NHỮNG ĐIỂM ĐẾN ẤN TƯỢNG TẠI VĨNH LONG"},{"time":"11:00","title":"PHIM TRUYỆN"},{"time":"11:20","title":"THUẬN VỢ THUẬN CHỒNG"},{"time":"11:30","title":"QUẢ CẦU THÔNG THÁI"},{"time":"11:50","title":"ALO DOCTOR"},{"time":"12:00","title":"PHƯƠNG NAM HÔM NAY"},{"time":"12:30","title":"PHIM TRUYỆN: NỮ BÁC SĨ TÂM LÝ - TẬP 32"},{"time":"12:30","title":"PHIM TRUYỆN: NỮ BÁC SĨ TÂM LÝ - TẬP 33"},{"time":"12:30","title":"PHIM TRUYỆN"},{"time":"13:15","title":"PHIM TRUYỆN: MỘT CUỘC TẤN CÔNG - TẬP 24"},{"time":"13:15","title":"PHIM TRUYỆN: MỘT CUỘC TẤN CÔNG - TẬP 25"},{"time":"13:15","title":"PHIM TRUYỆN"},{"time":"14:00","title":"ĐẤU TRƯỜNG ẨM THỰC NHÍ"},{"time":"14:00","title":"CHECK IN VIỆT NAM: VỀ VÙNG ĐẤT CỔ Ô DIÊN"},{"time":"14:00","title":"KỶ NIỆM THANH XUÂN"},{"time":"14:15","title":"CUỐI TUẦN KỂ CHUYỆN"},{"time":"14:15","title":"GAMESHOW NGƯỜI ĐỨNG THẲNG"},{"time":"14:20","title":"CHỊ EM GỠ RỐI"},{"time":"14:40","title":"GIA ĐÌNH HẾT SẢY"},{"time":"15:00","title":"NẺO VỀ NGUỒN CỘI: THANH ÂM CUNG ĐÌNH HUẾ"},{"time":"15:00","title":"NẺO VỀ NGUỒN CỘI"},{"time":"15:15","title":"BÍ MẬT THẾ KỶ"},{"time":"15:15","title":"BÍ MẬT THẾ KỶ: VƯỢT THÁI BÌNH DƯƠNG - PHẦN 4"},{"time":"15:15","title":"CHỮA BỆNH CÙNG CHUYÊN GIA"},{"time":"15:45","title":"ĐƯỜNG DÂY NÓNG VTV9"},{"time":"15:45","title":"PHÓNG SỰ: ỨNG DỤNG CÔNG NGHỆ HƯỚNG ĐẾN CHÍNH QUYỀN ĐÔ THỊ THÔNG MINH"},{"time":"16:00","title":"PHIM TRUYỆN: GẠO NẾP GẠO TẺ - PHẦN 2 - TẬP 10"},{"time":"16:00","title":"PHIM TRUYỆN: GẠO NẾP GẠO TẺ - PHẦN 2 - TẬP 11"},{"time":"16:00","title":"PHIM TRUYỆN"},{"time":"16:30","title":"VIỆT NAM ƠI - MÌNH CÙNG ĐI: CHUYỆN BÊN HỒ"},{"time":"16:30","title":"VIỆT NAM ƠI - MÌNH CÙNG ĐI: ĐẾN THĂM VÙNG ĐẤT ĐỒNG THÁP"},{"time":"16:30","title":"VIỆT NAM - 365 NGÀY THÚ VỊ"},{"time":"16:45","title":"VÌ NHÂN DÂN QUÊN MÌNH"},{"time":"16:45","title":"DẠO QUANH THỊ TRƯỜNG"},{"time":"16:45","title":"HÀNH TRÌNH NET ZERO"},{"time":"17:00","title":"NỘI THẤT KHÔNG GIỚI HẠN: XU HƯỚNG THIẾT KẾ NỘI THẤT KHÔNG GIAN BẾP HIỆN ĐẠI"},{"time":"17:00","title":"CANH TÁC THÔNG MINH"},{"time":"17:00","title":"VÌ NHÂN DÂN QUÊN MÌNH"},{"time":"17:15","title":"THỊ TRƯỜNG 360 ĐỘ"},{"time":"17:20","title":"ALO DOCTOR CUỐI TUẦN"},{"time":"17:20","title":"CÂU CHUYỆN NHÂN VẬT"},{"time":"17:20","title":"TRAI ĐẸP VÀO BẾP"},{"time":"17:50","title":"TÁM CÔNG SỞ: HỌP KÍN GIỜ HÀNH CHÍNH - TẬP 167"},{"time":"17:50","title":"TÁM CÔNG SỞ: HỌP KÍN GIỜ HÀNH CHÍNH - TẬP 168"},{"time":"17:50","title":"TÁM CÔNG SỞ"},{"time":"18:00","title":"TOÀN CẢNH 24H"},{"time":"18:30","title":"NHẬT KÝ FIFA WORLD CUP 2026"},{"time":"18:30","title":"TẦM NHÌN BẤT ĐỘNG SẢN"},{"time":"18:45","title":"PHIM TRUYỆN: ƯỚC MÌNH CÙNG BAY - TẬP 72"},{"time":"18:45","title":"PHIM TRUYỆN: ƯỚC MÌNH CÙNG BAY - TẬP 73"},{"time":"18:50","title":"KÍNH ĐA CHIỀU"},{"time":"19:00","title":"PHIM TRUYỆN"},{"time":"19:15","title":"ĐỜI RẤT ĐẸP"},{"time":"19:15","title":"KỶ NIỆM THANH XUÂN"},{"time":"19:30","title":"ĐỜI NGHỆ SĨ"},{"time":"19:30","title":"VÒNG XOAY LỐC XOÁY"},{"time":"19:35","title":"CHỊ EM GỠ RỐI"},{"time":"20:00","title":"TIẾU LÂM DU KÝ"},{"time":"20:00","title":"THỨC TỈNH TÂM HỒN: HIỂU LẦM CON"},{"time":"20:00","title":"CÂU CHUYỆN TỪ CUỘC SỐNG"},{"time":"20:10","title":"PHIM TRUYỆN"},{"time":"20:15","title":"PHIM TRUYỆN: MỘT CUỘC TẤN CÔNG - TẬP 26"},{"time":"20:15","title":"PHIM TRUYỆN: ÂN OÁN TÌNH THÙ - TẬP 29"},{"time":"21:00","title":"PHIM TRUYỆN: MỘT CUỘC TẤN CÔNG - TẬP 27"},{"time":"21:00","title":"PHIM TRUYỆN: ÂN OÁN TÌNH THÙ - TẬP 30"},{"time":"21:00","title":"ĐƯỜNG DÂY NÓNG VTV9"},{"time":"21:15","title":"PHIM TRUYỆN"},{"time":"21:45","title":"GAMESHOW NGƯỜI ĐỨNG THẲNG"},{"time":"21:45","title":"ĐỜI NGHỆ SỸ"},{"time":"22:00","title":"VIỆT NAM ƠI - MÌNH CÙNG ĐI"},{"time":"22:15","title":"ĐIỀU CON MUỐN NÓI"},{"time":"22:25","title":"CHECK IN VIỆT NAM: CỬU THÁC GIỮA ĐẠI NGÀN"},{"time":"22:30","title":"VIỆT NAM - 365 NGÀY THÚ VỊ"},{"time":"22:35","title":"MẢNH GHÉP HOÀN HẢO"},{"time":"22:40","title":"VÒNG XOAY LỐC XOÁY"},{"time":"22:45","title":"TRAI ĐẸP VÀO BẾP"},{"time":"23:00","title":"TIẾU LÂM DU KÝ"},{"time":"23:10","title":"BƯỚC CHÂN KHÁM PHÁ"},{"time":"23:15","title":"GIẢI Mà CUỘC SỐNG"},{"time":"23:20","title":"TRUYỀN HÌNH TRỰC TIẾP: FIFA WORLD CUP 2026 - VÒNG 1/8 - CANADA - MOROCO"},{"time":"23:30","title":"ALO DOCTOR CUỐI TUẦN"},{"time":"23:30","title":"VÕ THUẬT TỔNG HỢP MMA"}],"vtv10":[{"time":"00:00","title":"TÌNH KHÚC VƯỢT THỜI GIAN - ĐÊM TÂM SỰ"},{"time":"00:20","title":"TRUYỀN HÌNH TRỰC TIẾP: WORLD CUP 2026: AUSTRALIA - AI CẬP"},{"time":"00:45","title":"DÂN CA NHẠC CỔ: DUYÊN DÁNG ĐỒNG BẰNG"},{"time":"00:45","title":"MẢNH GHÉP HOÀN HẢO - SỐ 102"},{"time":"01:15","title":"LẬP TRÌNH TRÁI TIM: NGƯỜI EM BẤT HẢO - PHẦN 1"},{"time":"01:15","title":"LẬP TRÌNH TRÁI TIM - NGƯỜI EM BẤT HẢO - PHẦN 2"},{"time":"01:30","title":"THỂ THAO: WORLD CUP 2026: TÂY BAN NHA - ÁO"},{"time":"01:30","title":"BẠN CỦA NHÀ NÔNG - SỐ 7"},{"time":"02:20","title":"TRUYỀN HÌNH TRỰC TIẾP - WORLD CUP 2026: BRAZIL - NA UY"},{"time":"03:00","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG: VƯỜN QUỐC GIA CÔN ĐẢO - TẬP 1"},{"time":"03:30","title":"KÝ SỰ: VỀ LẠI CỨ ĐỊA XƯA - TẬP 5"},{"time":"03:45","title":"KÝ ỨC MIỀN TÂY: CỎ BÀNG BẤT TẬN"},{"time":"04:00","title":"PHIM TRUYỆN: CHÚNG TA PHẢI HẠNH PHÚC - TẬP 34"},{"time":"04:20","title":"LIÊN KẾT VÀ HỘI NHẬP: TÁI ĐỊNH VỊ DU LỊCH ĐỒNG BẰNG"},{"time":"04:45","title":"THỂ THAO"},{"time":"04:45","title":"THỂ THAO: KẾT NỐI THỂ THAO"},{"time":"05:15","title":"KÝ SỰ: VỀ LẠI CỨ ĐỊA XƯA - TẬP 5"},{"time":"05:15","title":"KÝ ỨC MIỀN TÂY: HỒI ĐÓ LẤM LEM"},{"time":"05:30","title":"CA NHẠC: CHƠI VƠI"},{"time":"05:30","title":"CA NHẠC: NỖI NHỚ DỊU ÊM"},{"time":"06:00","title":"BẢN TIN NÔNG NGHIỆP"},{"time":"06:10","title":"BẢN TIN THỂ THAO"},{"time":"06:15","title":"KÝ ỨC MIỀN TÂY: CỎ BÀNG BẤT TẬN"},{"time":"06:15","title":"NÔNG DÂN SỐ: SỐ 1"},{"time":"06:30","title":"PHIM HOẠT HÌNH: CHUYỆN CỔ TÍCH - PHẦN 3"},{"time":"06:30","title":"PHIM HOẠT HÌNH: CHUYỆN CỔ TÍCH - PHẦN 4"},{"time":"06:30","title":"THƯƠNG NHỚ MIỀN TÂY - NGHỀ SÔNG NƯỚC"},{"time":"06:45","title":"THẾ GIỚI QUANH TA - SỐ 27"},{"time":"07:00","title":"THỂ THAO: WORLD CUP 2026: TÂY BAN NHA - ÁO"},{"time":"07:00","title":"THỂ THAO: WORLD CUP 2026: ARGENTINA - CAPE VERDE"},{"time":"07:00","title":"THỂ THAO - WORLD CUP 2026: PARAGUAY - PHÁP"},{"time":"08:30","title":"PHIM TRUYỆN: NỮ HOÀNG XU HƯỚNG - TẬP 41"},{"time":"08:30","title":"PHIM TRUYỆN: NỮ HOÀNG XU HƯỚNG - TẬP 42"},{"time":"08:30","title":"NỮ HOÀNG XU HƯỚNG - TẬP 43"},{"time":"09:15","title":"SỐNG KHỎE MỖI NGÀY: ỔN ĐỊNH ĐƯỜNG HUYẾT"},{"time":"09:15","title":"SỐNG KHỎE MỖI NGÀY: BẢO VỆ TIM MẠCH Ở NGƯỜI CAO TUỔI"},{"time":"09:15","title":"SỐNG KHỎE MỖI NGÀY - PHÌ ĐẠI TUYẾN TIỀN LIỆT"},{"time":"10:15","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG: VƯỜN QUỐC GIA CÔN ĐẢO - TẬP 1"},{"time":"10:15","title":"TẠP CHÍ KINH TẾ CUỐI TUẦN"},{"time":"10:15","title":"NHỮNG MẢNH GHÉP CỦA CUỘC SỐNG - VƯỜN QUỐC GIA CÔN ĐẢO - TẬP 1"},{"time":"10:45","title":"PHÓNG SỰ: BÀI TOÁN CHO Y TẾ CƠ SỞ"},{"time":"10:45","title":"KÝ ỨC MIỀN TÂY: CỎ BÀNG BẤT TẬN"},{"time":"10:45","title":"MIỀN TÂY NĂNG ĐỘNG - VĨNH LONG - SỐ 5"},{"time":"11:00","title":"MIỀN TÂY HÔM NAY"},{"time":"11:30","title":"SÂN KHẤU - CẢI LƯƠNG: MỘT THỜI ĐỂ NHỚ"},{"time":"11:35","title":"PHIM TRUYỆN: CHÚNG TA PHẢI HẠNH PHÚC - TẬP 34"},{"time":"11:35","title":"ĐỪNG NÓI KHI YÊU - TẬP 1"},{"time":"12:15","title":"PHIM TRUYỆN: ANH TRAI NHÀ ĐỐI DIỆN - TẬP 14"},{"time":"12:15","title":"ANH TRAI NHÀ ĐỐI DIỆN - TẬP 15"},{"time":"13:00","title":"CHƯƠNG TRÌNH VỀ SỨC KHỎE: DƯỠNG TIM, BỔ NÃO"},{"time":"13:00","title":"TỌA ĐÀM: SỐT XUẤT HUYẾT NHẬN DIỆN SỚM, XỬ TRÍ ĐÚNG"},{"time":"13:00","title":"CHƯƠNG TRÌNH VỀ SỨC KHỎE"},{"time":"14:00","title":"THỂ THAO: KẾT NỐI THỂ THAO"},{"time":"14:00","title":"BẠN CỦA NHÀ NÔNG: SỐ 7"},{"time":"14:00","title":"THỂ THAO - GIỜ VÀNG THỂ THAO"},{"time":"14:30","title":"DÂN CA NHẠC CỔ: DUYÊN DÁNG ĐỒNG BẰNG"},{"time":"14:30","title":"DÂN CA NHẠC CỔ - MIỀN TÂY BAO THƯƠNG NHỚ"},{"time":"14:45","title":"PHÓNG SỰ: BÀI TOÁN CHO Y TẾ CƠ SỞ"},{"time":"15:00","title":"ĐẤT KHỎE – CÂY TRỒNG KHỎE: GIẢM CHI PHÍ, TĂNG NĂNG SUẤT, CHẤT LƯỢNG LÚA HÈ THU"},{"time":"15:00","title":"KHỎE CÙNG CHUYÊN GIA: PHÒNG NGỪA ĐỘT QUỴ"},{"time":"15:00","title":"KHỎE CÙNG CHUYÊN GIA - PHÒNG NGỪA ĐỘT QUỴ NÃO"},{"time":"16:00","title":"BẢN TIN NÔNG NGHIỆP"},{"time":"16:10","title":"XỔ SỐ KIẾN THIẾT: HẬU GIANG - LONG AN"},{"time":"16:10","title":"XỔ SỐ KIẾN THIẾT: KIÊN GIANG - TIỀN GIANG"},{"time":"16:10","title":"XỔ SỐ KIẾN THIẾT - ĐỒNG THÁP - CÀ MAU"},{"time":"16:40","title":"NHÀ NÔNG VÀ CÔNG NGHỆ: SỐ 27"},{"time":"16:45","title":"LẬP TRÌNH TRÁI TIM: NGƯỜI EM BẤT HẢO - PHẦN 1"},{"time":"16:45","title":"LẬP TRÌNH TRÁI TIM: NGƯỜI EM BẤT HẢO - PHẦN 2"},{"time":"16:45","title":"LẬP TRÌNH TRÁI TIM - NGƯỜI EM BẤT HẢO - PHẦN 3"},{"time":"17:00","title":"PHIM TRUYỆN: BA ƠI MẸ CÓ VỀ KHÔNG - TẬP 20"},{"time":"17:00","title":"PHIM TRUYỆN: BA ƠI MẸ CÓ VỀ KHÔNG - TẬP 21"},{"time":"17:00","title":"BA ƠI MẸ CÓ VỀ KHÔNG - TẬP 22"},{"time":"17:45","title":"CHƯƠNG TRÌNH THIẾU NHI: TRƯỞNG THÀNH CÙNG TIAN TIAN - TẬP 22"},{"time":"17:45","title":"CHƯƠNG TRÌNH THIẾU NHI: TRƯỞNG THÀNH CÙNG TIAN TIAN - TẬP 23"},{"time":"17:45","title":"KÝ SỰ - VỀ LẠI CỨ ĐỊA XƯA - TẬP 6"},{"time":"18:00","title":"MIỀN TÂY HÔM NAY"},{"time":"18:30","title":"THẾ GIỚI QUANH TA: SỐ 27"},{"time":"18:30","title":"360 ĐỘ MIỀN TÂY: SỐ 25"},{"time":"18:30","title":"BẢN TIN THỂ THAO"},{"time":"18:45","title":"THƯƠNG NHỚ MIỀN TÂY: NGHỀ SÔNG NƯỚC"},{"time":"18:45","title":"QUỐC PHÒNG TOÀN DÂN QK9: SỐ 27"},{"time":"18:45","title":"BIẾN ĐỔI KHÍ HẬU - TRẢ LẠI HƠI THỞ CHO TRÀM CHIM"},{"time":"19:00","title":"THỜI SỰ"},{"time":"19:45","title":"PHIM TRUYỆN: KẾ HOẠCH TRÁI TIM - TẬP 24"},{"time":"19:45","title":"TỌA ĐÀM: BẢO HIỂM Y TẾ - LÁ CHẮN SỨC KHỎE CHO MỌI NGƯỜI"},{"time":"19:45","title":"KẾ HOẠCH TRÁI TIM - TẬP 25"},{"time":"20:30","title":"PHIM HOẠT HÌNH: CHUYỆN CỔ TÍCH - PHẦN 4"},{"time":"20:30","title":"PHIM HOẠT HÌNH"},{"time":"20:45","title":"KÝ ỨC MIỀN TÂY: HỒI ĐÓ LẤM LEM"},{"time":"21:00","title":"NHẬT KÝ WORLD CUP 2026"},{"time":"21:15","title":"SỐNG KHỎE - ĐẸP: SỐ 40"},{"time":"21:15","title":"PHIM TÀI LIỆU: TƯỚNG VỀ HƯU VÀ 1.000 CĂN NHÀ ĐỒNG ĐỘI"},{"time":"21:15","title":"CẢNH GIÁC 247"},{"time":"21:45","title":"PHIM TRUYỆN: NHỮNG NẺO ĐƯỜNG GẦN XA - TẬP 23"},{"time":"21:45","title":"PHIM TRUYỆN: NHỮNG NẺO ĐƯỜNG GẦN XA - TẬP 24"},{"time":"21:45","title":"NHỮNG NẺO ĐƯỜNG GẦN XA - TẬP 25"},{"time":"22:30","title":"CHUYỆN CUỐI TUẦN: NSND KIM XUÂN"},{"time":"22:30","title":"MẢNH GHÉP HOÀN HẢO: SỐ 102"},{"time":"22:30","title":"PHIM TÀI LIỆU - TƯỚNG VỀ HƯU VÀ 1.000 CĂN NHÀ ĐỒNG ĐỘI"},{"time":"23:00","title":"TÌNH KHÚC VƯỢT THỜI GIAN: ĐÊM TÂM SỰ"},{"time":"23:00","title":"ĐỜI NGHỆ SỸ: NHẠC SỸ ĐÀI PHƯƠNG TRANG"},{"time":"23:00","title":"SẮC MÀU CÁC DÂN TỘC - CHUYỆN KỂ TỪ THANH ÂM"},{"time":"23:30","title":"THỂ THAO - KẾT NỐI THỂ THAO"},{"time":"23:45","title":"KÝ ỨC MIỀN TÂY: HỒI ĐÓ LẤM LEM"},{"time":"23:45","title":"THƯƠNG NHỚ MIỀN TÂY: DU LỊCH XUYÊN RỪNG XUYÊN ĐÊM"}]}
|
|
|
|
|
|
vtv_scraper.py
CHANGED
|
@@ -1,11 +1,9 @@
|
|
| 1 |
"""
|
| 2 |
-
VTV Channels Scraper
|
| 3 |
-
Fetches stream URLs from
|
|
|
|
| 4 |
"""
|
| 5 |
import requests, re, time, threading
|
| 6 |
-
from datetime import datetime, timedelta, timezone
|
| 7 |
-
|
| 8 |
-
VN_TZ = timezone(timedelta(hours=17))
|
| 9 |
|
| 10 |
UA = {
|
| 11 |
"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",
|
|
@@ -13,9 +11,8 @@ UA = {
|
|
| 13 |
"Referer": "https://hd.xemtv.net/",
|
| 14 |
}
|
| 15 |
|
| 16 |
-
#
|
| 17 |
-
#
|
| 18 |
-
|
| 19 |
XEMTV_PHP_ENDPOINTS = {
|
| 20 |
"vtv1": "https://hd.xemtv.net/kenh/vtv1.php",
|
| 21 |
"vtv2": "https://hd.xemtv.net/kenh/vtv2.php",
|
|
@@ -26,45 +23,42 @@ XEMTV_PHP_ENDPOINTS = {
|
|
| 26 |
"vtv7": "https://hd.xemtv.net/kenh/vtv7.php",
|
| 27 |
"vtv8": "https://hd.xemtv.net/kenh/vtv8.php",
|
| 28 |
"vtv9": "https://hd.xemtv.net/kenh/vtv9.php",
|
| 29 |
-
"vtv10": "https://hd.xemtv.net/kenh/vtv10.php",
|
| 30 |
}
|
| 31 |
|
|
|
|
| 32 |
CHANNEL_NAMES = {
|
| 33 |
-
"vtv1": "VTV1",
|
| 34 |
-
"
|
| 35 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
}
|
| 37 |
|
| 38 |
-
#
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
"
|
| 42 |
-
"
|
| 43 |
-
"
|
| 44 |
-
"
|
| 45 |
-
"
|
| 46 |
-
"
|
| 47 |
-
"
|
| 48 |
-
"
|
| 49 |
-
|
| 50 |
-
"
|
| 51 |
-
|
| 52 |
-
# VTVGo Failover
|
| 53 |
-
"vtv1_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8",
|
| 54 |
-
"vtv2_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv2-manifest.m3u8",
|
| 55 |
-
"vtv3_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv3-manifest.m3u8",
|
| 56 |
-
"vtv4_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv4-manifest.m3u8",
|
| 57 |
-
"vtv5_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv5-manifest.m3u8",
|
| 58 |
-
"vtv6_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv6-manifest.m3u8",
|
| 59 |
-
"vtv7_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv7-manifest.m3u8",
|
| 60 |
-
"vtv8_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv8-manifest.m3u8",
|
| 61 |
-
"vtv9_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv9-manifest.m3u8",
|
| 62 |
-
"vtv10_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv10-manifest.m3u8",
|
| 63 |
}
|
| 64 |
|
| 65 |
_vtv_cache = {}
|
| 66 |
_vtv_lock = threading.Lock()
|
| 67 |
-
_CACHE_TTL = 180
|
|
|
|
| 68 |
|
| 69 |
def _cached(key):
|
| 70 |
with _vtv_lock:
|
|
@@ -72,39 +66,36 @@ def _cached(key):
|
|
| 72 |
return _vtv_cache[key]['d']
|
| 73 |
return None
|
| 74 |
|
|
|
|
| 75 |
def _set_cache(key, data):
|
| 76 |
with _vtv_lock:
|
| 77 |
_vtv_cache[key] = {'t': time.time(), 'd': data}
|
| 78 |
|
|
|
|
| 79 |
def extract_m3u8_from_html(html):
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
| 82 |
m = re.search(r"file\s*:\s*['\"]([^'\"]*\.m3u8[^'\"]*)['\"]", html, re.IGNORECASE)
|
| 83 |
if m:
|
| 84 |
url = m.group(1).strip()
|
| 85 |
-
if len(url) > 20:
|
| 86 |
-
|
|
|
|
| 87 |
m = re.search(r"(https?://[^\s\"'<>\\]+\.m3u8[^\s\"'<>\\]*)", html, re.IGNORECASE)
|
| 88 |
if m:
|
| 89 |
url = m.group(1).strip()
|
| 90 |
-
if len(url) > 20:
|
| 91 |
-
return None
|
| 92 |
-
|
| 93 |
-
def verify_cdn(url, referer="", timeout=8):
|
| 94 |
-
"""Quick verify CDN is working"""
|
| 95 |
-
if not url: return None
|
| 96 |
-
try:
|
| 97 |
-
r = requests.get(url, headers={"User-Agent": UA["User-Agent"], "Referer": referer}, timeout=timeout, allow_redirects=True, verify=False)
|
| 98 |
-
if r.status_code == 200 and '#EXTM3U' in r.text[:500]:
|
| 99 |
return url
|
| 100 |
-
except: pass
|
| 101 |
return None
|
| 102 |
|
|
|
|
| 103 |
def fetch_vtv_stream(channel_id):
|
| 104 |
-
"""Fetch
|
| 105 |
channel_id = channel_id.lower().strip()
|
| 106 |
-
|
| 107 |
-
# Normalize
|
| 108 |
name_map = {
|
| 109 |
'vtvct': 'vtv10', 'vtv-can-tho': 'vtv10', 'vtv can tho': 'vtv10',
|
| 110 |
'vtv_can_tho': 'vtv10', 'cantho': 'vtv10', 'cần thơ': 'vtv10',
|
|
@@ -113,35 +104,53 @@ def fetch_vtv_stream(channel_id):
|
|
| 113 |
'vietnam_vtv7': 'vtv7', 'vietnam_vtv8': 'vtv8', 'vietnam_vtv9': 'vtv9',
|
| 114 |
}
|
| 115 |
channel_id = name_map.get(channel_id, channel_id)
|
| 116 |
-
|
| 117 |
-
#
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
php_url = XEMTV_PHP_ENDPOINTS.get(channel_id)
|
| 123 |
-
if php_url:
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
return None
|
| 137 |
|
|
|
|
| 138 |
def get_all_vtv_streams():
|
|
|
|
| 139 |
channels = []
|
| 140 |
-
for ch_id in
|
| 141 |
stream_url = fetch_vtv_stream(ch_id)
|
| 142 |
channels.append({
|
| 143 |
'id': ch_id,
|
| 144 |
'name': CHANNEL_NAMES.get(ch_id, ch_id.upper()),
|
| 145 |
'stream_url': stream_url,
|
| 146 |
})
|
| 147 |
-
return channels
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
VTV Channels Scraper
|
| 3 |
+
Fetches stream URLs from hd.xemtv.net PHP endpoints for VTV1-VTV10 + VTV Cần Thơ
|
| 4 |
+
The PHP endpoints return jwplayer config with fresh stream URLs (important for VTV6 which has expiring signatures)
|
| 5 |
"""
|
| 6 |
import requests, re, time, threading
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
UA = {
|
| 9 |
"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",
|
|
|
|
| 11 |
"Referer": "https://hd.xemtv.net/",
|
| 12 |
}
|
| 13 |
|
| 14 |
+
# Channel ID -> xemtv.net PHP endpoint mapping
|
| 15 |
+
# These PHP pages return jwplayer config with fresh stream URLs
|
|
|
|
| 16 |
XEMTV_PHP_ENDPOINTS = {
|
| 17 |
"vtv1": "https://hd.xemtv.net/kenh/vtv1.php",
|
| 18 |
"vtv2": "https://hd.xemtv.net/kenh/vtv2.php",
|
|
|
|
| 23 |
"vtv7": "https://hd.xemtv.net/kenh/vtv7.php",
|
| 24 |
"vtv8": "https://hd.xemtv.net/kenh/vtv8.php",
|
| 25 |
"vtv9": "https://hd.xemtv.net/kenh/vtv9.php",
|
| 26 |
+
"vtv10": "https://hd.xemtv.net/kenh/vtv10.php", # VTV Cần Thơ
|
| 27 |
}
|
| 28 |
|
| 29 |
+
# Channel display names
|
| 30 |
CHANNEL_NAMES = {
|
| 31 |
+
"vtv1": "VTV1",
|
| 32 |
+
"vtv2": "VTV2",
|
| 33 |
+
"vtv3": "VTV3",
|
| 34 |
+
"vtv4": "VTV4",
|
| 35 |
+
"vtv5": "VTV5",
|
| 36 |
+
"vtv6": "VTV6",
|
| 37 |
+
"vtv7": "VTV7",
|
| 38 |
+
"vtv8": "VTV8",
|
| 39 |
+
"vtv9": "VTV9",
|
| 40 |
+
"vtv10": "VTV Cần Thơ",
|
| 41 |
}
|
| 42 |
|
| 43 |
+
# Fallback CDN streams (fptplay) — used when xemtv scraping fails
|
| 44 |
+
CDN_FALLBACK = {
|
| 45 |
+
"vtv1": "https://live.fptplay53.net/fnxch2/vtv1hd_abr.smil/chunklist.m3u8",
|
| 46 |
+
"vtv2": "https://live.fptplay53.net/fnxch2/vtv2hd_abr.smil/chunklist.m3u8",
|
| 47 |
+
"vtv3": "https://live.fptplay53.net/fnxch2/vtv3hd_abr.smil/chunklist.m3u8",
|
| 48 |
+
"vtv4": "https://live.fptplay53.net/fnxch2/vtv4hd_abr.smil/chunklist.m3u8",
|
| 49 |
+
"vtv5": "https://live-a.fptplay53.net/live/media/VTV5HD/live_hls_avc/index.m3u8",
|
| 50 |
+
"vtv7": "https://live.fptplay53.net/fnxhd1/vtv7hd_vhls.smil/chunklist_b5000000.m3u8",
|
| 51 |
+
"vtv8": "https://live.fptplay53.net/epzhd1/vtv8hd_vhls.smil/chunklist.m3u8",
|
| 52 |
+
"vtv9": "https://live.fptplay53.net/fnxhd1/vtv9hd_vhls.smil/chunklist.m3u8",
|
| 53 |
+
"vtv10": "https://live.fptplay53.net/fnxch2/vtvcantho_abr.smil/chunklist.m3u8",
|
| 54 |
+
# VTV6 fallback: try canthotv as alternative
|
| 55 |
+
"vtv6": "https://live.canthotv.vn/live/tv/chunklist.m3u8",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
}
|
| 57 |
|
| 58 |
_vtv_cache = {}
|
| 59 |
_vtv_lock = threading.Lock()
|
| 60 |
+
_CACHE_TTL = 180 # 3 minutes — refresh frequently for VTV6 sign URLs
|
| 61 |
+
|
| 62 |
|
| 63 |
def _cached(key):
|
| 64 |
with _vtv_lock:
|
|
|
|
| 66 |
return _vtv_cache[key]['d']
|
| 67 |
return None
|
| 68 |
|
| 69 |
+
|
| 70 |
def _set_cache(key, data):
|
| 71 |
with _vtv_lock:
|
| 72 |
_vtv_cache[key] = {'t': time.time(), 'd': data}
|
| 73 |
|
| 74 |
+
|
| 75 |
def extract_m3u8_from_html(html):
|
| 76 |
+
"""Extract m3u8 URL from xemtv PHP page (jwplayer config)."""
|
| 77 |
+
if not html:
|
| 78 |
+
return None
|
| 79 |
+
# jwplayer config: file: 'URL'
|
| 80 |
m = re.search(r"file\s*:\s*['\"]([^'\"]*\.m3u8[^'\"]*)['\"]", html, re.IGNORECASE)
|
| 81 |
if m:
|
| 82 |
url = m.group(1).strip()
|
| 83 |
+
if len(url) > 20:
|
| 84 |
+
return url
|
| 85 |
+
# Generic m3u8 pattern
|
| 86 |
m = re.search(r"(https?://[^\s\"'<>\\]+\.m3u8[^\s\"'<>\\]*)", html, re.IGNORECASE)
|
| 87 |
if m:
|
| 88 |
url = m.group(1).strip()
|
| 89 |
+
if len(url) > 20:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
return url
|
|
|
|
| 91 |
return None
|
| 92 |
|
| 93 |
+
|
| 94 |
def fetch_vtv_stream(channel_id):
|
| 95 |
+
"""Fetch m3u8 stream URL for a VTV channel by scraping xemtv.net PHP endpoint."""
|
| 96 |
channel_id = channel_id.lower().strip()
|
| 97 |
+
|
| 98 |
+
# Normalize name
|
| 99 |
name_map = {
|
| 100 |
'vtvct': 'vtv10', 'vtv-can-tho': 'vtv10', 'vtv can tho': 'vtv10',
|
| 101 |
'vtv_can_tho': 'vtv10', 'cantho': 'vtv10', 'cần thơ': 'vtv10',
|
|
|
|
| 104 |
'vietnam_vtv7': 'vtv7', 'vietnam_vtv8': 'vtv8', 'vietnam_vtv9': 'vtv9',
|
| 105 |
}
|
| 106 |
channel_id = name_map.get(channel_id, channel_id)
|
| 107 |
+
|
| 108 |
+
# Check cache
|
| 109 |
+
cached = _cached(channel_id)
|
| 110 |
+
if cached:
|
| 111 |
+
return cached
|
| 112 |
+
|
| 113 |
php_url = XEMTV_PHP_ENDPOINTS.get(channel_id)
|
| 114 |
+
if not php_url:
|
| 115 |
+
# Fallback
|
| 116 |
+
fallback = CDN_FALLBACK.get(channel_id)
|
| 117 |
+
if fallback:
|
| 118 |
+
_set_cache(channel_id, fallback)
|
| 119 |
+
return fallback
|
| 120 |
+
return None
|
| 121 |
+
|
| 122 |
+
try:
|
| 123 |
+
r = requests.get(php_url, headers=UA, timeout=15, allow_redirects=True)
|
| 124 |
+
if r.status_code == 200:
|
| 125 |
+
m3u8 = extract_m3u8_from_html(r.text)
|
| 126 |
+
if m3u8:
|
| 127 |
+
_set_cache(channel_id, m3u8)
|
| 128 |
+
return m3u8
|
| 129 |
+
except:
|
| 130 |
+
pass
|
| 131 |
+
|
| 132 |
+
# Fallback to CDN
|
| 133 |
+
fallback = CDN_FALLBACK.get(channel_id)
|
| 134 |
+
if fallback:
|
| 135 |
+
_set_cache(channel_id, fallback)
|
| 136 |
+
return fallback
|
| 137 |
+
|
| 138 |
return None
|
| 139 |
|
| 140 |
+
|
| 141 |
def get_all_vtv_streams():
|
| 142 |
+
"""Fetch all VTV channel streams. Returns list of {id, name, stream_url}."""
|
| 143 |
channels = []
|
| 144 |
+
for ch_id, php_url in XEMTV_PHP_ENDPOINTS.items():
|
| 145 |
stream_url = fetch_vtv_stream(ch_id)
|
| 146 |
channels.append({
|
| 147 |
'id': ch_id,
|
| 148 |
'name': CHANNEL_NAMES.get(ch_id, ch_id.upper()),
|
| 149 |
'stream_url': stream_url,
|
| 150 |
})
|
| 151 |
+
return channels
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
# Legacy compatibility
|
| 155 |
+
XEMTV_CHANNELS = {v: k for k, v in CHANNEL_NAMES.items()}
|
| 156 |
+
CDN_STREAMS = {v: k for k, v in CDN_FALLBACK.items()}
|
vtv_shorts.py
CHANGED
|
@@ -1,5 +1,10 @@
|
|
| 1 |
"""
|
| 2 |
VTV Nam Bộ YouTube Shorts Scraper
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
import requests
|
| 5 |
import re
|
|
@@ -12,7 +17,7 @@ from xml.etree import ElementTree as ET
|
|
| 12 |
|
| 13 |
_cache = {}
|
| 14 |
_lock = threading.Lock()
|
| 15 |
-
CACHE_TTL = 1800
|
| 16 |
|
| 17 |
UA = {
|
| 18 |
"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",
|
|
@@ -29,10 +34,13 @@ def _set_cache(key, data):
|
|
| 29 |
with _lock:
|
| 30 |
_cache[key] = {'t': time.time(), 'd': data}
|
| 31 |
|
|
|
|
| 32 |
def get_channel_id(username):
|
|
|
|
| 33 |
cached = _cached(f'ch_id_{username}')
|
| 34 |
if cached:
|
| 35 |
return cached
|
|
|
|
| 36 |
channel_id = None
|
| 37 |
try:
|
| 38 |
url = f"https://www.youtube.com/@{username}"
|
|
@@ -41,49 +49,76 @@ def get_channel_id(username):
|
|
| 41 |
m = re.search(r'<meta\s+property="og:url"\s+content="https://www.youtube.com/channel/(UC[^"]+)"', r.text)
|
| 42 |
if m:
|
| 43 |
channel_id = m.group(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
if not channel_id:
|
| 45 |
m = re.search(r'"channelId":"(UC[^"]+)"', r.text)
|
| 46 |
if m:
|
| 47 |
channel_id = m.group(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
except Exception as e:
|
| 49 |
print(f"Error getting channel ID for @{username}: {e}")
|
|
|
|
| 50 |
if channel_id:
|
| 51 |
_set_cache(f'ch_id_{username}', channel_id)
|
| 52 |
return channel_id
|
| 53 |
|
|
|
|
| 54 |
def scrape_via_rss(channel_id, max_count=100):
|
|
|
|
| 55 |
shorts = []
|
| 56 |
try:
|
| 57 |
url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
|
| 58 |
r = requests.get(url, headers=UA, timeout=15)
|
| 59 |
if r.status_code != 200:
|
| 60 |
return shorts
|
|
|
|
| 61 |
root = ET.fromstring(r.text)
|
| 62 |
ns = {'atom': 'http://www.w3.org/2005/Atom', 'yt': 'http://www.youtube.com/xml/schemas/2015'}
|
|
|
|
| 63 |
for entry in root.findall('atom:entry', ns)[:max_count]:
|
| 64 |
title_el = entry.find('atom:title', ns)
|
| 65 |
title = html_lib.unescape(title_el.text) if title_el is not None and title_el.text else ''
|
|
|
|
| 66 |
vid_el = entry.find('yt:videoId', ns)
|
| 67 |
vid = vid_el.text if vid_el is not None else ''
|
| 68 |
if not vid:
|
| 69 |
continue
|
|
|
|
| 70 |
is_short = '#shorts' in title.lower() or '#short' in title.lower()
|
|
|
|
| 71 |
link_el = entry.find('atom:link', ns)
|
| 72 |
link = link_el.get('href', '') if link_el is not None else ''
|
| 73 |
if '/shorts/' in link:
|
| 74 |
is_short = True
|
|
|
|
| 75 |
if is_short:
|
| 76 |
-
shorts.append({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
except Exception as e:
|
| 78 |
print(f"RSS scrape error: {e}")
|
|
|
|
| 79 |
return shorts
|
| 80 |
|
|
|
|
| 81 |
def scrape_via_ydlp(username, count=50):
|
|
|
|
| 82 |
shorts = []
|
| 83 |
try:
|
| 84 |
url = f"https://www.youtube.com/@{username}/shorts"
|
| 85 |
result = subprocess.run(
|
| 86 |
-
["yt-dlp", "--dump-json", "--flat-playlist", "--no-download",
|
|
|
|
|
|
|
| 87 |
capture_output=True, text=True, timeout=90
|
| 88 |
)
|
| 89 |
if result.returncode == 0 and result.stdout.strip():
|
|
@@ -99,19 +134,71 @@ def scrape_via_ydlp(username, count=50):
|
|
| 99 |
continue
|
| 100 |
seen_ids.add(vid)
|
| 101 |
title = entry.get('title', 'VTV Nam Bộ Short')
|
| 102 |
-
shorts.append({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
except json.JSONDecodeError:
|
| 104 |
continue
|
| 105 |
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
|
| 106 |
print(f"yt-dlp error: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
return shorts
|
| 108 |
|
|
|
|
| 109 |
def get_vtvnambo_shorts(max_count=50):
|
|
|
|
| 110 |
cached = _cached('vtvnambo_shorts_v2')
|
| 111 |
if cached is not None:
|
| 112 |
return cached
|
|
|
|
| 113 |
all_shorts = []
|
| 114 |
seen_ids = set()
|
|
|
|
|
|
|
| 115 |
channel_id = get_channel_id('vtvnambo')
|
| 116 |
if channel_id:
|
| 117 |
rss_shorts = scrape_via_rss(channel_id, max_count * 2)
|
|
@@ -119,22 +206,89 @@ def get_vtvnambo_shorts(max_count=50):
|
|
| 119 |
if s['id'] not in seen_ids:
|
| 120 |
seen_ids.add(s['id'])
|
| 121 |
all_shorts.append(s)
|
|
|
|
|
|
|
| 122 |
if len(all_shorts) < 3:
|
| 123 |
ydlp_shorts = scrape_via_ydlp('vtvnambo', max_count)
|
| 124 |
for s in ydlp_shorts:
|
| 125 |
if s['id'] not in seen_ids:
|
| 126 |
seen_ids.add(s['id'])
|
| 127 |
all_shorts.append(s)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
result = all_shorts[:max_count]
|
| 129 |
_set_cache('vtvnambo_shorts_v2', result)
|
| 130 |
return result
|
| 131 |
|
|
|
|
|
|
|
| 132 |
get_vtvnamo_shorts = get_vtvnambo_shorts
|
| 133 |
|
|
|
|
| 134 |
def get_wc_related_shorts(max_count=30):
|
|
|
|
| 135 |
all_shorts = get_vtvnambo_shorts(max_count * 3)
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
if not wc_shorts:
|
| 139 |
wc_shorts = all_shorts
|
|
|
|
| 140 |
return wc_shorts[:max_count]
|
|
|
|
| 1 |
"""
|
| 2 |
VTV Nam Bộ YouTube Shorts Scraper
|
| 3 |
+
- Fetches channel ID from YouTube page
|
| 4 |
+
- Uses YouTube RSS feed to get all videos
|
| 5 |
+
- Filters for shorts (#shorts in title or /shorts/ in link)
|
| 6 |
+
- Falls back to yt-dlp if RSS fails
|
| 7 |
+
- Returns list of shorts with id, title, thumbnail
|
| 8 |
"""
|
| 9 |
import requests
|
| 10 |
import re
|
|
|
|
| 17 |
|
| 18 |
_cache = {}
|
| 19 |
_lock = threading.Lock()
|
| 20 |
+
CACHE_TTL = 1800 # 30 min
|
| 21 |
|
| 22 |
UA = {
|
| 23 |
"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",
|
|
|
|
| 34 |
with _lock:
|
| 35 |
_cache[key] = {'t': time.time(), 'd': data}
|
| 36 |
|
| 37 |
+
|
| 38 |
def get_channel_id(username):
|
| 39 |
+
"""Get YouTube channel ID from username by parsing the channel page."""
|
| 40 |
cached = _cached(f'ch_id_{username}')
|
| 41 |
if cached:
|
| 42 |
return cached
|
| 43 |
+
|
| 44 |
channel_id = None
|
| 45 |
try:
|
| 46 |
url = f"https://www.youtube.com/@{username}"
|
|
|
|
| 49 |
m = re.search(r'<meta\s+property="og:url"\s+content="https://www.youtube.com/channel/(UC[^"]+)"', r.text)
|
| 50 |
if m:
|
| 51 |
channel_id = m.group(1)
|
| 52 |
+
if not channel_id:
|
| 53 |
+
m = re.search(r'<link\s+rel="canonical"\s+href="https://www.youtube.com/channel/(UC[^"]+)"', r.text)
|
| 54 |
+
if m:
|
| 55 |
+
channel_id = m.group(1)
|
| 56 |
if not channel_id:
|
| 57 |
m = re.search(r'"channelId":"(UC[^"]+)"', r.text)
|
| 58 |
if m:
|
| 59 |
channel_id = m.group(1)
|
| 60 |
+
if not channel_id:
|
| 61 |
+
m = re.search(r'"externalId":"(UC[^"]+)"', r.text)
|
| 62 |
+
if m:
|
| 63 |
+
channel_id = m.group(1)
|
| 64 |
except Exception as e:
|
| 65 |
print(f"Error getting channel ID for @{username}: {e}")
|
| 66 |
+
|
| 67 |
if channel_id:
|
| 68 |
_set_cache(f'ch_id_{username}', channel_id)
|
| 69 |
return channel_id
|
| 70 |
|
| 71 |
+
|
| 72 |
def scrape_via_rss(channel_id, max_count=100):
|
| 73 |
+
"""Scrape videos from YouTube RSS feed."""
|
| 74 |
shorts = []
|
| 75 |
try:
|
| 76 |
url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
|
| 77 |
r = requests.get(url, headers=UA, timeout=15)
|
| 78 |
if r.status_code != 200:
|
| 79 |
return shorts
|
| 80 |
+
|
| 81 |
root = ET.fromstring(r.text)
|
| 82 |
ns = {'atom': 'http://www.w3.org/2005/Atom', 'yt': 'http://www.youtube.com/xml/schemas/2015'}
|
| 83 |
+
|
| 84 |
for entry in root.findall('atom:entry', ns)[:max_count]:
|
| 85 |
title_el = entry.find('atom:title', ns)
|
| 86 |
title = html_lib.unescape(title_el.text) if title_el is not None and title_el.text else ''
|
| 87 |
+
|
| 88 |
vid_el = entry.find('yt:videoId', ns)
|
| 89 |
vid = vid_el.text if vid_el is not None else ''
|
| 90 |
if not vid:
|
| 91 |
continue
|
| 92 |
+
|
| 93 |
is_short = '#shorts' in title.lower() or '#short' in title.lower()
|
| 94 |
+
|
| 95 |
link_el = entry.find('atom:link', ns)
|
| 96 |
link = link_el.get('href', '') if link_el is not None else ''
|
| 97 |
if '/shorts/' in link:
|
| 98 |
is_short = True
|
| 99 |
+
|
| 100 |
if is_short:
|
| 101 |
+
shorts.append({
|
| 102 |
+
'id': vid,
|
| 103 |
+
'title': title,
|
| 104 |
+
'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg",
|
| 105 |
+
'channel': 'vtvnambo',
|
| 106 |
+
})
|
| 107 |
except Exception as e:
|
| 108 |
print(f"RSS scrape error: {e}")
|
| 109 |
+
|
| 110 |
return shorts
|
| 111 |
|
| 112 |
+
|
| 113 |
def scrape_via_ydlp(username, count=50):
|
| 114 |
+
"""Scrape shorts using yt-dlp."""
|
| 115 |
shorts = []
|
| 116 |
try:
|
| 117 |
url = f"https://www.youtube.com/@{username}/shorts"
|
| 118 |
result = subprocess.run(
|
| 119 |
+
["yt-dlp", "--dump-json", "--flat-playlist", "--no-download",
|
| 120 |
+
"--playlist-end", str(count),
|
| 121 |
+
url],
|
| 122 |
capture_output=True, text=True, timeout=90
|
| 123 |
)
|
| 124 |
if result.returncode == 0 and result.stdout.strip():
|
|
|
|
| 134 |
continue
|
| 135 |
seen_ids.add(vid)
|
| 136 |
title = entry.get('title', 'VTV Nam Bộ Short')
|
| 137 |
+
shorts.append({
|
| 138 |
+
'id': vid,
|
| 139 |
+
'title': title,
|
| 140 |
+
'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg",
|
| 141 |
+
'channel': 'vtvnambo',
|
| 142 |
+
})
|
| 143 |
except json.JSONDecodeError:
|
| 144 |
continue
|
| 145 |
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
|
| 146 |
print(f"yt-dlp error: {e}")
|
| 147 |
+
except Exception as e:
|
| 148 |
+
print(f"yt-dlp error: {e}")
|
| 149 |
+
|
| 150 |
+
return shorts
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def scrape_via_ydlp_videos_filter(username, count=100):
|
| 154 |
+
"""Scrape all videos and filter for shorts by duration."""
|
| 155 |
+
shorts = []
|
| 156 |
+
try:
|
| 157 |
+
url = f"https://www.youtube.com/@{username}/videos"
|
| 158 |
+
result = subprocess.run(
|
| 159 |
+
["yt-dlp", "--dump-json", "--flat-playlist", "--no-download",
|
| 160 |
+
"--playlist-end", str(count),
|
| 161 |
+
"--match-filter", "duration < 65",
|
| 162 |
+
url],
|
| 163 |
+
capture_output=True, text=True, timeout=90
|
| 164 |
+
)
|
| 165 |
+
if result.returncode == 0 and result.stdout.strip():
|
| 166 |
+
seen_ids = set()
|
| 167 |
+
for line in result.stdout.strip().split('\n'):
|
| 168 |
+
line = line.strip()
|
| 169 |
+
if not line:
|
| 170 |
+
continue
|
| 171 |
+
try:
|
| 172 |
+
entry = json.loads(line)
|
| 173 |
+
vid = entry.get('id', '')
|
| 174 |
+
if not vid or vid in seen_ids:
|
| 175 |
+
continue
|
| 176 |
+
seen_ids.add(vid)
|
| 177 |
+
title = entry.get('title', 'VTV Nam Bộ Short')
|
| 178 |
+
shorts.append({
|
| 179 |
+
'id': vid,
|
| 180 |
+
'title': title,
|
| 181 |
+
'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg",
|
| 182 |
+
'channel': 'vtvnambo',
|
| 183 |
+
})
|
| 184 |
+
except json.JSONDecodeError:
|
| 185 |
+
continue
|
| 186 |
+
except Exception as e:
|
| 187 |
+
print(f"yt-dlp videos filter error: {e}")
|
| 188 |
+
|
| 189 |
return shorts
|
| 190 |
|
| 191 |
+
|
| 192 |
def get_vtvnambo_shorts(max_count=50):
|
| 193 |
+
"""Get all shorts from VTV Nam Bộ channel. Tries multiple methods."""
|
| 194 |
cached = _cached('vtvnambo_shorts_v2')
|
| 195 |
if cached is not None:
|
| 196 |
return cached
|
| 197 |
+
|
| 198 |
all_shorts = []
|
| 199 |
seen_ids = set()
|
| 200 |
+
|
| 201 |
+
# Method 1: RSS feed (most reliable, no JS needed)
|
| 202 |
channel_id = get_channel_id('vtvnambo')
|
| 203 |
if channel_id:
|
| 204 |
rss_shorts = scrape_via_rss(channel_id, max_count * 2)
|
|
|
|
| 206 |
if s['id'] not in seen_ids:
|
| 207 |
seen_ids.add(s['id'])
|
| 208 |
all_shorts.append(s)
|
| 209 |
+
|
| 210 |
+
# Method 2: yt-dlp on shorts page
|
| 211 |
if len(all_shorts) < 3:
|
| 212 |
ydlp_shorts = scrape_via_ydlp('vtvnambo', max_count)
|
| 213 |
for s in ydlp_shorts:
|
| 214 |
if s['id'] not in seen_ids:
|
| 215 |
seen_ids.add(s['id'])
|
| 216 |
all_shorts.append(s)
|
| 217 |
+
|
| 218 |
+
# Method 3: yt-dlp on videos page with duration filter
|
| 219 |
+
if len(all_shorts) < 3:
|
| 220 |
+
ydlp_vids = scrape_via_ydlp_videos_filter('vtvnambo', max_count * 2)
|
| 221 |
+
for s in ydlp_vids:
|
| 222 |
+
if s['id'] not in seen_ids:
|
| 223 |
+
seen_ids.add(s['id'])
|
| 224 |
+
all_shorts.append(s)
|
| 225 |
+
|
| 226 |
+
# Method 4: requests + regex on shorts page
|
| 227 |
+
if len(all_shorts) < 3:
|
| 228 |
+
try:
|
| 229 |
+
url = "https://www.youtube.com/@vtvnambo/shorts"
|
| 230 |
+
r = requests.get(url, headers=UA, timeout=15)
|
| 231 |
+
if r.status_code == 200:
|
| 232 |
+
for m in re.finditer(r'"videoId":"([A-Za-z0-9_-]{11})"', r.text):
|
| 233 |
+
vid = m.group(1)
|
| 234 |
+
if vid in seen_ids:
|
| 235 |
+
continue
|
| 236 |
+
seen_ids.add(vid)
|
| 237 |
+
snip = r.text[max(0, m.start() - 900):m.start() + 1600]
|
| 238 |
+
title = ""
|
| 239 |
+
mt = re.search(r'"title":\{"runs":\[\{"text":"([^"]+)"', snip)
|
| 240 |
+
if not mt:
|
| 241 |
+
mt = re.search(r'"accessibilityText":"([^"]+)"', snip)
|
| 242 |
+
if mt:
|
| 243 |
+
title = html_lib.unescape(mt.group(1)).replace('\n', ' ').strip()
|
| 244 |
+
if not title:
|
| 245 |
+
title = "VTV Nam Bộ Short"
|
| 246 |
+
all_shorts.append({
|
| 247 |
+
'id': vid,
|
| 248 |
+
'title': title,
|
| 249 |
+
'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg",
|
| 250 |
+
'channel': 'vtvnambo',
|
| 251 |
+
})
|
| 252 |
+
except Exception as e:
|
| 253 |
+
print(f"Requests regex error: {e}")
|
| 254 |
+
|
| 255 |
result = all_shorts[:max_count]
|
| 256 |
_set_cache('vtvnambo_shorts_v2', result)
|
| 257 |
return result
|
| 258 |
|
| 259 |
+
|
| 260 |
+
# Alias for backward compatibility (fix the typo import in main.py)
|
| 261 |
get_vtvnamo_shorts = get_vtvnambo_shorts
|
| 262 |
|
| 263 |
+
|
| 264 |
def get_wc_related_shorts(max_count=30):
|
| 265 |
+
"""Get World Cup / football related shorts from VTV Nam Bộ."""
|
| 266 |
all_shorts = get_vtvnambo_shorts(max_count * 3)
|
| 267 |
+
|
| 268 |
+
wc_kws = [
|
| 269 |
+
'world cup', 'wc 2026', 'worldcup', 'fifa', 'bóng đá',
|
| 270 |
+
'trận đấu', 'đội tuyển', 'tuyển', 'vòng loại',
|
| 271 |
+
'khoảnh khắc', 'highlights', 'bàn thắng', 'goal',
|
| 272 |
+
'kết quả', 'tỉ số', 'việt nam', 'vn',
|
| 273 |
+
'ngoại hạng', 'premier league', 'champions league',
|
| 274 |
+
'laliga', 'serie a', 'bundesliga', 'ligue 1',
|
| 275 |
+
'copa', 'europa', 'c1', 'c2',
|
| 276 |
+
'messi', 'ronaldo', 'neymar', 'mbappe', 'haaland',
|
| 277 |
+
'v-league', 'vleague', 'bóng đá việt',
|
| 278 |
+
'đội bóng', 'hlv', 'huấn luyện viên',
|
| 279 |
+
'chuyển nhượng', 'transfer',
|
| 280 |
+
'asian cup', 'aff cup', 'sea games',
|
| 281 |
+
'olympic', 'u23', 'u20', 'u17',
|
| 282 |
+
]
|
| 283 |
+
|
| 284 |
+
wc_shorts = []
|
| 285 |
+
for s in all_shorts:
|
| 286 |
+
tl = s.get('title', '').lower()
|
| 287 |
+
if any(k in tl for k in wc_kws):
|
| 288 |
+
wc_shorts.append(s)
|
| 289 |
+
|
| 290 |
+
# If no WC-specific shorts found, return all VTV shorts
|
| 291 |
if not wc_shorts:
|
| 292 |
wc_shorts = all_shorts
|
| 293 |
+
|
| 294 |
return wc_shorts[:max_count]
|
vtv_test.html
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html>
|
| 3 |
+
<head><meta charset="utf-8"><title>VTV Stream Test</title></head>
|
| 4 |
+
<body style="background:#000;color:#fff;font-family:monospace;padding:20px">
|
| 5 |
+
<h2>VTV Stream Test</h2>
|
| 6 |
+
<div id="log" style="white-space:pre-wrap;font-size:12px;max-height:400px;overflow-y:auto;background:#111;padding:10px;margin:10px 0"></div>
|
| 7 |
+
<video id="v" controls autoplay muted style="width:100%;max-width:640px;display:block;background:#000"></video>
|
| 8 |
+
|
| 9 |
+
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.13/dist/hls.min.js"></script>
|
| 10 |
+
<script>
|
| 11 |
+
const log = msg => { const el = document.getElementById('log'); el.textContent += new Date().toISOString().substr(11,12) + ' ' + msg + '\n'; el.scrollTop = el.scrollHeight; console.log(msg); };
|
| 12 |
+
const video = document.getElementById('v');
|
| 13 |
+
|
| 14 |
+
const streamUrl = 'https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8';
|
| 15 |
+
const proxyUrl = 'https://bep40-vnews.hf.space/api/proxy/m3u8/vtv?url=' + encodeURIComponent(streamUrl);
|
| 16 |
+
|
| 17 |
+
log('HLS.js version: ' + (Hls.version || 'unknown'));
|
| 18 |
+
log('Hls.isSupported: ' + Hls.isSupported());
|
| 19 |
+
log('video.canPlayType m3u8: ' + video.canPlayType('application/vnd.apple.mpegurl'));
|
| 20 |
+
|
| 21 |
+
function tryLoad(label, url) {
|
| 22 |
+
log('\n--- Trying ' + label + ' ---');
|
| 23 |
+
log('URL: ' + url);
|
| 24 |
+
|
| 25 |
+
if (!Hls.isSupported()) {
|
| 26 |
+
video.src = url;
|
| 27 |
+
video.addEventListener('loadedmetadata', () => { log('Native: metadata loaded'); video.play(); });
|
| 28 |
+
return;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
const hls = new Hls({
|
| 32 |
+
enableWorker: false,
|
| 33 |
+
lowLatencyMode: true,
|
| 34 |
+
startLevel: 0,
|
| 35 |
+
capLevelToPlayerSize: true,
|
| 36 |
+
maxBufferLength: 8,
|
| 37 |
+
debug: false,
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
hls.on(Hls.Events.MEDIA_ATTACHING, () => log('HLS: MEDIA_ATTACHING'));
|
| 41 |
+
hls.on(Hls.Events.MEDIA_ATTACHED, () => log('HLS: MEDIA_ATTACHED'));
|
| 42 |
+
hls.on(Hls.Events.MANIFEST_LOADING, () => log('HLS: MANIFEST_LOADING'));
|
| 43 |
+
hls.on(Hls.Events.MANIFEST_LOADED, () => log('HLS: MANIFEST_LOADED'));
|
| 44 |
+
hls.on(Hls.Events.MANIFEST_PARSED, (ev,d) => {
|
| 45 |
+
log('HLS: MANIFEST_PARSED levels=' + d.levels.length + ' video=' + !!d.video + ' audio=' + !!d.audio);
|
| 46 |
+
if (d.levels[0]) log('HLS: First level: ' + d.levels[0].width + 'x' + d.levels[0].height + ' @' + d.levels[0].bitrate + 'bps');
|
| 47 |
+
video.play().then(() => log('HLS: PLAY OK')).catch(e => log('HLS: PLAY ERROR: ' + e));
|
| 48 |
+
});
|
| 49 |
+
hls.on(Hls.Events.LEVEL_LOADED, (ev,d) => log('HLS: LEVEL_LOADED fragments=' + d.details.fragments.length));
|
| 50 |
+
hls.on(Hls.Events.FRAG_LOADING, (ev,d) => log('HLS: FRAG_LOADING ' + (d.frag && d.frag.url && d.frag.url.substring(0,60))));
|
| 51 |
+
hls.on(Hls.Events.FRAG_LOADED, (ev,d) => log('HLS: FRAG_LOADED size=' + (d.frag && d.frag.data && d.frag.data.length)));
|
| 52 |
+
hls.on(Hls.Events.FRAG_BUFFERED, (ev,d) => log('HLS: FRAG_BUFFERED ' + (d.frag && d.frag.url && d.frag.url.substring(0,60))));
|
| 53 |
+
hls.on(Hls.Events.BUFFER_CREATED, () => log('HLS: BUFFER_CREATED'));
|
| 54 |
+
hls.on(Hls.Events.BUFFER_APPENDING, () => log('HLS: BUFFER_APPENDING'));
|
| 55 |
+
hls.on(Hls.Events.BUFFER_APPENDED, () => log('HLS: BUFFER_APPENDED'));
|
| 56 |
+
hls.on(Hls.Events.ERROR, (ev,data) => {
|
| 57 |
+
log('HLS: ERROR type=' + data.type + ' details=' + data.details + ' fatal=' + data.fatal + ' reason=' + data.reason);
|
| 58 |
+
if (data.url) log(' URL: ' + data.url.substring(0,100));
|
| 59 |
+
if (data.response) log(' Resp: ' + JSON.stringify(data.response).substring(0,100));
|
| 60 |
+
if (data.fatal) {
|
| 61 |
+
hls.destroy();
|
| 62 |
+
if (label === 'DIRECT') {
|
| 63 |
+
log('\nDIRECT failed, trying PROXY...');
|
| 64 |
+
tryLoad('PROXY', proxyUrl);
|
| 65 |
+
} else {
|
| 66 |
+
log('\nBOTH FAILED');
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
});
|
| 70 |
+
|
| 71 |
+
hls.loadSource(url);
|
| 72 |
+
hls.attachMedia(video);
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
tryLoad('DIRECT', streamUrl);
|
| 76 |
+
</script>
|
| 77 |
+
</body>
|
| 78 |
+
</html>
|
yt_scraper_fixed.py
DELETED
|
@@ -1,162 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
YouTube Shorts Scraper using yt-dlp (already installed on Space)
|
| 3 |
-
Optimized for fast load with long cache + fallback
|
| 4 |
-
"""
|
| 5 |
-
import subprocess
|
| 6 |
-
import json
|
| 7 |
-
import time
|
| 8 |
-
import threading
|
| 9 |
-
import os
|
| 10 |
-
import re as re_mod
|
| 11 |
-
|
| 12 |
-
_cache = {}
|
| 13 |
-
_lock = threading.Lock()
|
| 14 |
-
CACHE_TTL = 1800 # 30 min cache - longer to reduce timeout issues
|
| 15 |
-
|
| 16 |
-
def _get_cached(key):
|
| 17 |
-
"""Get cached data if still valid"""
|
| 18 |
-
with _lock:
|
| 19 |
-
if key in _cache:
|
| 20 |
-
entry = _cache[key]
|
| 21 |
-
if time.time() - entry['t'] < CACHE_TTL:
|
| 22 |
-
return entry['d']
|
| 23 |
-
return None
|
| 24 |
-
|
| 25 |
-
def _set_cached(key, data):
|
| 26 |
-
"""Set cache with timestamp"""
|
| 27 |
-
with _lock:
|
| 28 |
-
_cache[key] = {'t': time.time(), 'd': data}
|
| 29 |
-
|
| 30 |
-
def run_yt_dlp(args, timeout=45):
|
| 31 |
-
"""Run yt-dlp and return parsed JSON lines - with shorter timeout"""
|
| 32 |
-
try:
|
| 33 |
-
result = subprocess.run(
|
| 34 |
-
["yt-dlp"] + args,
|
| 35 |
-
capture_output=True, text=True, timeout=timeout
|
| 36 |
-
)
|
| 37 |
-
if result.returncode != 0 and not result.stdout.strip():
|
| 38 |
-
return []
|
| 39 |
-
lines = result.stdout.strip().split('\n')
|
| 40 |
-
items = []
|
| 41 |
-
for line in lines:
|
| 42 |
-
line = line.strip()
|
| 43 |
-
if not line:
|
| 44 |
-
continue
|
| 45 |
-
try:
|
| 46 |
-
items.append(json.loads(line))
|
| 47 |
-
except json.JSONDecodeError:
|
| 48 |
-
continue
|
| 49 |
-
return items
|
| 50 |
-
except subprocess.TimeoutExpired:
|
| 51 |
-
print("yt-dlp timeout (this is OK - using fallback)")
|
| 52 |
-
return []
|
| 53 |
-
except FileNotFoundError:
|
| 54 |
-
print("yt-dlp not found - using fallback")
|
| 55 |
-
return []
|
| 56 |
-
except Exception as e:
|
| 57 |
-
print(f"yt-dlp exception: {e}")
|
| 58 |
-
return []
|
| 59 |
-
|
| 60 |
-
def get_channel_shorts_fast(channel_username, max_count=25):
|
| 61 |
-
"""Get shorts fast - prioritize /shorts page only to avoid timeout"""
|
| 62 |
-
shorts = []
|
| 63 |
-
|
| 64 |
-
url = f"https://www.youtube.com/@{channel_username}/shorts"
|
| 65 |
-
items = run_yt_dlp([
|
| 66 |
-
"--dump-json",
|
| 67 |
-
"--flat-playlist",
|
| 68 |
-
"--no-download",
|
| 69 |
-
"--playlist-end", str(max_count),
|
| 70 |
-
"--no-check-certificates",
|
| 71 |
-
"--quiet", # Reduce output for speed
|
| 72 |
-
"--no-warnings",
|
| 73 |
-
url
|
| 74 |
-
], timeout=35) # Increased timeout but still reasonable
|
| 75 |
-
|
| 76 |
-
seen_ids = set()
|
| 77 |
-
for item in items:
|
| 78 |
-
vid = item.get('id', '')
|
| 79 |
-
if not vid or vid in seen_ids:
|
| 80 |
-
continue
|
| 81 |
-
seen_ids.add(vid)
|
| 82 |
-
|
| 83 |
-
title = item.get('title', f'{channel_username} Short')
|
| 84 |
-
|
| 85 |
-
shorts.append({
|
| 86 |
-
'id': vid,
|
| 87 |
-
'title': title,
|
| 88 |
-
'channel': channel_username,
|
| 89 |
-
'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg",
|
| 90 |
-
})
|
| 91 |
-
|
| 92 |
-
if len(shorts) >= max_count:
|
| 93 |
-
break
|
| 94 |
-
|
| 95 |
-
return shorts
|
| 96 |
-
|
| 97 |
-
def get_dantri_shorts(max_count=25):
|
| 98 |
-
"""Get Dantri shorts - fast, no fallback needed, cache for 30min"""
|
| 99 |
-
cached = _get_cached('dantri_shorts_yt')
|
| 100 |
-
if cached is not None:
|
| 101 |
-
return cached
|
| 102 |
-
|
| 103 |
-
shorts = get_channel_shorts_fast('baodantri7941', max_count)
|
| 104 |
-
|
| 105 |
-
if shorts:
|
| 106 |
-
_set_cached('dantri_shorts_yt', shorts)
|
| 107 |
-
return shorts
|
| 108 |
-
|
| 109 |
-
# Fallback to static list if scrape fails
|
| 110 |
-
return [
|
| 111 |
-
{"id":"Lu_iCQ5YwNM","title":"Công an lập hồ sơ xử lý người phụ nữ chửi bới tát nam tài xế ô tô ở Hà Nội","channel":"baodantri7941"},
|
| 112 |
-
{"id":"CwWvijF8BOA","title":"Chú rể Ninh Bình bật khóc nhận món quà bí mật người cha","channel":"baodantri7941"},
|
| 113 |
-
]
|
| 114 |
-
|
| 115 |
-
def get_skds_shorts(max_count=25):
|
| 116 |
-
"""Get SKĐS shorts - fast, no fallback needed, cache for 30min"""
|
| 117 |
-
cached = _get_cached('skds_shorts_yt')
|
| 118 |
-
if cached is not None:
|
| 119 |
-
return cached
|
| 120 |
-
|
| 121 |
-
shorts = get_channel_shorts_fast('baosuckhoedoisongboyte', max_count)
|
| 122 |
-
|
| 123 |
-
if shorts:
|
| 124 |
-
_set_cached('skds_shorts_yt', shorts)
|
| 125 |
-
return shorts
|
| 126 |
-
|
| 127 |
-
# Fallback to static list if scrape fails
|
| 128 |
-
return [
|
| 129 |
-
{"id":"7Pd6vZ2Lz1M","title":"Hành động ấm lòng của người đàn ông tìm kiếm 5 học sinh tử vong","channel":"baosuckhoedoisongboyte"},
|
| 130 |
-
{"id":"SlHLt_ZyPiE","title":"Xử phạt người đàn ông xóa số điện thoại cứu hộ trên cao tốc Bắc Nam","channel":"baosuckhoedoisongboyte"},
|
| 131 |
-
]
|
| 132 |
-
|
| 133 |
-
def get_dantri_skds_shorts(max_count=50):
|
| 134 |
-
"""Get interleaved Dantri + SKĐS shorts - optimized with separate caching"""
|
| 135 |
-
# Get each channel's shorts separately (allows partial fallback)
|
| 136 |
-
dantri = get_dantri_shorts(max_count // 2 + 10)
|
| 137 |
-
skds = get_skds_shorts(max_count // 2 + 10)
|
| 138 |
-
|
| 139 |
-
# Interleave them
|
| 140 |
-
result = []
|
| 141 |
-
seen = set()
|
| 142 |
-
i, j = 0, 0
|
| 143 |
-
|
| 144 |
-
while (i < len(dantri) or j < len(skds)) and len(result) < max_count:
|
| 145 |
-
if i < len(dantri):
|
| 146 |
-
item = dantri[i]
|
| 147 |
-
if item.get('id') not in seen:
|
| 148 |
-
seen.add(item.get('id'))
|
| 149 |
-
result.append(item)
|
| 150 |
-
i += 1
|
| 151 |
-
if j < len(skds):
|
| 152 |
-
item = skds[j]
|
| 153 |
-
if item.get('id') not in seen:
|
| 154 |
-
seen.add(item.get('id'))
|
| 155 |
-
result.append(item)
|
| 156 |
-
j += 1
|
| 157 |
-
|
| 158 |
-
return result
|
| 159 |
-
|
| 160 |
-
# For backward compatibility
|
| 161 |
-
get_vtvnambo_shorts = get_dantri_skds_shorts
|
| 162 |
-
get_wc_related_shorts = get_dantri_skds_shorts
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|