Spaces:
Running
Running
Fix: No-op rewrite_fix_v2.js — eliminates JS conflict with app_v2.js
#29
by bep40 - opened
- .docker_rebuild +0 -1
- .huggingface/rebuild +1 -1
- .rebuild +1 -1
- .rebuild_flag +0 -1
- .rebuild_v2 +0 -1
- .restart_trigger +1 -1
- CHANGELOG.md +0 -44
- Dockerfile +1 -2
- README.md +14 -22
- _run.py +1 -1
- ai_ext.py +34 -466
- ai_patch.py +77 -209
- app_v2_entry.py +48 -451
- main.py +409 -78
- rewrite_fix_v2.js +240 -2
- shorts_cache.py +0 -86
- shorts_rss_proxy.py +0 -114
- static/app_v2.js +213 -312
- static/app_v2_shorts_fix.js +2 -1
- static/index_v2.html +10 -11
- 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/wc2026_v2.js +1 -1
- static/yt_live.js +850 -10
- vtv_api.py +494 -175
- vtv_epg_data.json +0 -1
- vtv_scraper.py +51 -67
- yt_scraper_fixed.py +0 -162
.docker_rebuild
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
trigger
|
|
|
|
|
|
.huggingface/rebuild
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
rebuild
|
|
|
|
| 1 |
+
trigger rebuild
|
.rebuild
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
1782552642
|
.rebuild_flag
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
rebuild
|
|
|
|
|
|
.rebuild_v2
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
rebuild
|
|
|
|
|
|
.restart_trigger
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
# restart trigger - v5.1 rewrite fix 2026-06-27
|
CHANGELOG.md
CHANGED
|
@@ -1,47 +1,3 @@
|
|
| 1 |
-
# VNEWS v6.5 - Resilient Shorts Auto-Updater
|
| 2 |
-
|
| 3 |
-
## Changes
|
| 4 |
-
|
| 5 |
-
### Critical Fix: Shorts timeout and homepage load stability
|
| 6 |
-
**Root cause**: YouTube shorts fetching in `main.py` using `scrape_shorts()` and `_yt_channel_shorts_requests()` could hang indefinitely when YouTube blocks requests or yt-dlp times out, causing:
|
| 7 |
-
- Homepage `/api/shorts` endpoint to time out (30s limit)
|
| 8 |
-
- Space to appear unresponsive on first load
|
| 9 |
-
- No fallback when sources fail
|
| 10 |
-
|
| 11 |
-
**Fix applied**:
|
| 12 |
-
1. **shorts_updater.py** (NEW) — Resilient background updater:
|
| 13 |
-
- Hard timeout (25s) per channel using subprocess isolation
|
| 14 |
-
- Stale-while-revalidate pattern: returns cached data immediately, updates in background
|
| 15 |
-
- Automatic fallback to hardcoded short URLs when all sources fail
|
| 16 |
-
- Persistent storage in `/data/shorts_cache.json` for cache across restarts
|
| 17 |
-
- Background scheduler runs every 10 minutes automatically
|
| 18 |
-
- No blocking on first homepage load
|
| 19 |
-
|
| 20 |
-
2. **_run.py** — Integrated resilient shorts endpoint:
|
| 21 |
-
- Overrides `/api/shorts` with non-blocking version
|
| 22 |
-
- Returns cached/fallback data in <100ms guaranteed
|
| 23 |
-
- Triggers background update if cache is stale or empty
|
| 24 |
-
- Never hangs - always returns valid JSON response
|
| 25 |
-
|
| 26 |
-
3. **FALLBACK_SHORTS** — 6 hardcoded viral shorts as emergency fallback:
|
| 27 |
-
- baodantri7941 (Dân trí) headlines
|
| 28 |
-
- baosuckhoedoisongboyte (Sức khỏe & đời sống) stories
|
| 29 |
-
- vtvnambo (VTV Nam Bộ) news
|
| 30 |
-
|
| 31 |
-
### Benefits
|
| 32 |
-
- Homepage loads in <2 seconds always
|
| 33 |
-
- Shorts data auto-updates every 10 minutes
|
| 34 |
-
- Never times out - graceful degradation to fallback
|
| 35 |
-
- Persistent cache survives Space restarts
|
| 36 |
-
- Uses bucket `bep40/VNEWS-storage` for cache storage
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# VNEWS v5.1 - Rewrite Fix
|
| 2 |
|
| 3 |
## Changes
|
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 -->
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_run.py
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
from app_v2_entry import app # v5-stable inline bongda proxy
|
|
|
|
| 1 |
+
from app_v2_entry import app # v5-stable inline bongda proxy
|
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:
|
|
@@ -79,136 +66,6 @@ def _hf_token():
|
|
| 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(
|
|
@@ -225,82 +82,57 @@ HEADERS = {
|
|
| 225 |
}
|
| 226 |
LAST_QWEN_ERROR = ""
|
| 227 |
|
| 228 |
-
|
| 229 |
# ===== MULTILINGUAL VOICES FOR TTS =====
|
| 230 |
-
# Maps voice IDs to edge-tts voice names (only MultilingualNeural voices)
|
| 231 |
MULTILINGUAL_VOICES = {
|
| 232 |
-
# Vietnamese
|
| 233 |
-
"
|
| 234 |
-
"vi-vn-namminhneural": "vi-VN-NamMinhNeural",
|
| 235 |
-
"hoaimy": "vi-VN-HoaiMyNeural",
|
| 236 |
-
"namminh": "vi-VN-NamMinhNeural",
|
| 237 |
-
"vi_female": "vi-VN-HoaiMyNeural",
|
| 238 |
"vi_male": "vi-VN-NamMinhNeural",
|
| 239 |
-
"
|
| 240 |
-
"
|
| 241 |
-
|
| 242 |
-
"
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
"
|
| 246 |
-
"
|
| 247 |
-
|
| 248 |
-
"en_jenny": "en-US-AndrewMultilingualNeural",
|
| 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-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
"
|
| 258 |
-
"
|
| 259 |
-
|
| 260 |
-
"
|
| 261 |
-
"
|
| 262 |
-
# German
|
| 263 |
-
"
|
| 264 |
-
"
|
| 265 |
-
|
| 266 |
-
"
|
| 267 |
-
"
|
| 268 |
-
# Korean
|
| 269 |
-
"
|
| 270 |
-
"
|
| 271 |
-
|
| 272 |
-
"
|
| 273 |
-
|
| 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 _detect_voice_emotion(title, text):
|
| 292 |
"""Detect appropriate voice and emotion based on content for multilingual TTS."""
|
| 293 |
content = ((title or "") + " " + (text or "")).lower()
|
| 294 |
|
| 295 |
# World Cup / Football content - use Andrew multilingual
|
| 296 |
if any(kw in content for kw in ["world cup", "wc 2026", "fifa", "bóng đá", "trận đấu", "bóng bóng", "đội tuyển", "cầu thủ"]):
|
| 297 |
-
return ("andrew", "excited")
|
| 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"]):
|
|
@@ -308,267 +140,3 @@ def _detect_voice_emotion(title, text):
|
|
| 308 |
|
| 309 |
# Default Vietnamese
|
| 310 |
return ("hoaimy", "trung_tinh")
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
def _safe_name(s: str) -> str:
|
| 314 |
-
"""Create safe filename from string."""
|
| 315 |
-
s = re.sub(r"[^\w\-.]", "_", s)
|
| 316 |
-
return s[:100] if len(s) > 100 else s
|
| 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 |
-
def _load_ai_wall():
|
| 344 |
-
"""Load AI wall posts from JSON file (uses wall_posts.json for consistency with app_v2_entry)."""
|
| 345 |
-
try:
|
| 346 |
-
if os.path.exists(WALL_FILE):
|
| 347 |
-
with open(WALL_FILE, "r", encoding="utf-8") as f:
|
| 348 |
-
return json.load(f)
|
| 349 |
-
except Exception:
|
| 350 |
-
pass
|
| 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 |
-
# Helper functions for wall operations
|
| 367 |
-
def _load_wall_posts():
|
| 368 |
-
"""Alias for _load_ai_wall for consistency with app_v2_entry.py."""
|
| 369 |
-
return _load_ai_wall()
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
def _save_wall_posts(posts):
|
| 373 |
-
"""Alias for _save_ai_wall for consistency with app_v2_entry.py."""
|
| 374 |
-
return _save_ai_wall(posts)
|
| 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 |
-
"id": str(int(time.time() * 1000)),
|
| 381 |
-
"title": title,
|
| 382 |
-
"text": text,
|
| 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 |
-
# ===== SCRAPER FUNCTIONS (required by ai_patch.py) =====
|
| 400 |
-
def scrape_any_url(url: str) -> dict:
|
| 401 |
-
"""Scrape any URL and extract article content.
|
| 402 |
-
|
| 403 |
-
Returns dict with: title, summary, text, image, og_image, via (domain)
|
| 404 |
-
"""
|
| 405 |
-
try:
|
| 406 |
-
r = requests.get(url, headers=HEADERS, timeout=15, allow_redirects=True)
|
| 407 |
-
r.encoding = 'utf-8'
|
| 408 |
-
soup = BeautifulSoup(r.text, 'lxml')
|
| 409 |
-
|
| 410 |
-
# Remove scripts, styles, nav, footer
|
| 411 |
-
for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
|
| 412 |
-
tag.decompose()
|
| 413 |
-
|
| 414 |
-
# Extract title
|
| 415 |
-
h1 = soup.find('h1')
|
| 416 |
-
ogt = soup.find('meta', property='og:title')
|
| 417 |
-
title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else url)
|
| 418 |
-
|
| 419 |
-
# Extract OG image
|
| 420 |
-
ogi = soup.find('meta', property='og:image')
|
| 421 |
-
og_image = ogi.get('content', '') if ogi else ''
|
| 422 |
-
|
| 423 |
-
# Extract article body
|
| 424 |
-
block = None
|
| 425 |
-
for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
|
| 426 |
-
el = soup.select_one(sel)
|
| 427 |
-
if el and len(el.find_all('p')) >= 2:
|
| 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 |
-
def web_context(topic: str, limit: int = 5) -> tuple:
|
| 468 |
-
"""Get web context for a topic. Returns (context_text, sources_list)."""
|
| 469 |
-
sources = []
|
| 470 |
-
try:
|
| 471 |
-
# Try Google News RSS
|
| 472 |
-
rss_url = f"https://news.google.com/rss/search?q={quote_plus(topic)}&hl=vi&gl=VN&ceid=VN:vi"
|
| 473 |
-
r = requests.get(rss_url, headers=HEADERS, timeout=15)
|
| 474 |
-
r.encoding = 'utf-8'
|
| 475 |
-
soup = BeautifulSoup(r.text, 'xml')
|
| 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 |
-
context = f'Trên mạng có nhiều bài viết về "{topic}". Một số nguồn: ' + ', '.join([s.get('title', '') for s in sources[:3]])
|
| 485 |
-
return context, sources
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
# ===== SHORT FRAME FUNCTION (required by ai_patch.py) =====
|
| 489 |
-
def _make_short_frame(post, img_path, out_path):
|
| 490 |
-
"""Create a short video frame from post and image.
|
| 491 |
-
|
| 492 |
-
Called by ai_patch.py _make_short_frame_full when Image is available.
|
| 493 |
-
"""
|
| 494 |
-
if Image is None:
|
| 495 |
-
# Create a minimal frame without PIL - just return success
|
| 496 |
-
# The caller should handle this case
|
| 497 |
-
return False
|
| 498 |
-
|
| 499 |
-
W, H = 1080, 1920
|
| 500 |
-
bg = Image.new("RGB", (W, H), (14, 14, 14))
|
| 501 |
-
|
| 502 |
-
try:
|
| 503 |
-
im = Image.open(img_path).convert("RGB")
|
| 504 |
-
target = (1080, 760)
|
| 505 |
-
im_ratio = im.width / max(1, im.height)
|
| 506 |
-
target_ratio = target[0] / target[1]
|
| 507 |
-
|
| 508 |
-
if im_ratio > target_ratio:
|
| 509 |
-
new_h = target[1]
|
| 510 |
-
new_w = int(new_h * im_ratio)
|
| 511 |
-
else:
|
| 512 |
-
new_w = target[0]
|
| 513 |
-
new_h = int(new_w / im_ratio)
|
| 514 |
-
|
| 515 |
-
im = im.resize((new_w, new_h))
|
| 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 |
-
font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 54)
|
| 527 |
-
font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 38)
|
| 528 |
-
except Exception:
|
| 529 |
-
font_title = font_body = None
|
| 530 |
-
|
| 531 |
-
draw.rectangle((0, 720, W, H), fill=(14, 14, 14))
|
| 532 |
-
margin = 48
|
| 533 |
-
maxw = W - margin * 2
|
| 534 |
-
|
| 535 |
-
y = 830
|
| 536 |
-
for ln in _wrap_text(draw, post.get("title", ""), font_title, maxw, 4):
|
| 537 |
-
draw.text((margin, y), ln, fill=(255, 255, 255), font=font_title)
|
| 538 |
-
y += 66
|
| 539 |
-
|
| 540 |
-
y += 18
|
| 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 True
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
def _wrap_text(draw, text, font, max_width, max_lines):
|
| 555 |
-
"""Helper for wrapping text in frames."""
|
| 556 |
-
words = _clean_text(text).split()
|
| 557 |
-
lines, cur = [], ""
|
| 558 |
-
for w in words:
|
| 559 |
-
test = (cur + " " + w).strip()
|
| 560 |
-
try:
|
| 561 |
-
width = draw.textbbox((0, 0), test, font=font)[2]
|
| 562 |
-
except Exception:
|
| 563 |
-
width = len(test) * 20
|
| 564 |
-
if width <= max_width:
|
| 565 |
-
cur = test
|
| 566 |
-
else:
|
| 567 |
-
if cur:
|
| 568 |
-
lines.append(cur)
|
| 569 |
-
cur = w
|
| 570 |
-
if len(lines) >= max_lines:
|
| 571 |
-
break
|
| 572 |
-
if cur and len(lines) < max_lines:
|
| 573 |
-
lines.append(cur)
|
| 574 |
-
return lines
|
|
|
|
| 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:
|
|
|
|
| 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 |
QWEN_TEXT_MODELS = [m.strip() for m in os.getenv(
|
|
|
|
| 82 |
}
|
| 83 |
LAST_QWEN_ERROR = ""
|
| 84 |
|
|
|
|
| 85 |
# ===== MULTILINGUAL VOICES FOR TTS =====
|
|
|
|
| 86 |
MULTILINGUAL_VOICES = {
|
| 87 |
+
# Vietnamese
|
| 88 |
+
"vi_female": "vi-VN-HoaMyNeural",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
"vi_male": "vi-VN-NamMinhNeural",
|
| 90 |
+
"hoaimy": "vi-VN-HoaMyNeural",
|
| 91 |
+
"namminh": "vi-VN-NamMinhNeural",
|
| 92 |
+
# Multilingual - Andrew (hỗ trợ tiếng Việt)
|
| 93 |
+
"en_andrew": "en-US-AndrewNeural",
|
| 94 |
+
"andrew": "en-US-AndrewNeural",
|
| 95 |
+
# Multilingual - Jenny
|
| 96 |
+
"en_jenny": "en-US-JennyNeural",
|
| 97 |
+
"jenny": "en-US-JennyNeural",
|
| 98 |
+
# Portuguese - Thalita Multilingual
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
"pt_thalita": "pt-BR-ThalitaMultilingualNeural",
|
| 100 |
"thalita": "pt-BR-ThalitaMultilingualNeural",
|
| 101 |
+
"pt_francisco": "pt-BR-FranciscoNeural",
|
| 102 |
+
# Spanish
|
| 103 |
+
"es_ela": "es-ES-ElaNeural",
|
| 104 |
+
"ela": "es-ES-ElaNeural",
|
| 105 |
+
"es_carlos": "es-ES-CarlosNeural",
|
| 106 |
+
# French
|
| 107 |
+
"fr_denise": "fr-FR-DeniseNeural",
|
| 108 |
+
"denise": "fr-FR-DeniseNeural",
|
| 109 |
+
# German
|
| 110 |
+
"de_katja": "de-DE-KatjaNeural",
|
| 111 |
+
"katja": "de-DE-KatjaNeural",
|
| 112 |
+
# Japanese
|
| 113 |
+
"ja_nanami": "ja-JP-NanamiNeural",
|
| 114 |
+
"nanami": "ja-JP-NanamiNeural",
|
| 115 |
+
# Korean
|
| 116 |
+
"ko_sunhee": "ko-KR-SunHeeNeural",
|
| 117 |
+
"sunhee": "ko-KR-SunHeeNeural",
|
| 118 |
+
# Chinese
|
| 119 |
+
"zh_xiaochen": "zh-CN-XiaochenNeural",
|
| 120 |
+
"xiaochen": "zh-CN-XiaochenNeural",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
}
|
| 122 |
|
|
|
|
| 123 |
def _detect_voice_emotion(title, text):
|
| 124 |
"""Detect appropriate voice and emotion based on content for multilingual TTS."""
|
| 125 |
content = ((title or "") + " " + (text or "")).lower()
|
| 126 |
|
| 127 |
# World Cup / Football content - use Andrew multilingual
|
| 128 |
if any(kw in content for kw in ["world cup", "wc 2026", "fifa", "bóng đá", "trận đấu", "bóng bóng", "đội tuyển", "cầu thủ"]):
|
| 129 |
+
return ("andrew", "excited") # Andrew multilingual hỗ trợ tiếng Việt
|
| 130 |
|
| 131 |
# News categories - choose appropriate voice
|
| 132 |
if any(kw in content for kw in ["kinh tế", "tài chính", "thị trường", "economics", "finance"]):
|
| 133 |
return ("jenny", "calm")
|
| 134 |
if any(kw in content for kw in ["thiên tai", "bão", "lũ lụt", "cháy nổ", "tai nạn", "disaster", "accident"]):
|
| 135 |
+
return ("thalita", "serious") # Portuguese multilingual
|
| 136 |
if any(kw in content for kw in ["giải trí", "showbiz", "entertainment", "hài hước"]):
|
| 137 |
return ("ela", "happy")
|
| 138 |
if any(kw in content for kw in ["công nghệ", "tech", "technology", "ai", "trí tuệ nhân tạo"]):
|
|
|
|
| 140 |
|
| 141 |
# Default Vietnamese
|
| 142 |
return ("hoaimy", "trung_tinh")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ai_patch.py
CHANGED
|
@@ -42,17 +42,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 +60,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 +79,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 +269,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 +322,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 +348,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 +358,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,39 +372,31 @@ 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 517 |
|
| 518 |
|
| 519 |
def _tts_script_smart(post, emotion):
|
|
@@ -522,12 +405,11 @@ def _tts_script_smart(post, emotion):
|
|
| 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 +524,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 +535,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 +587,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 +599,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:no_key=1',path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
|
| 725 |
-
return max(
|
| 726 |
except Exception:
|
| 727 |
return fallback
|
| 728 |
|
|
@@ -735,7 +615,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 +623,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"
|
|
@@ -767,62 +647,50 @@ async def patched_ai_short(post_id: str, request: Request):
|
|
| 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 |
-
|
| 780 |
-
'
|
| 781 |
-
|
| 782 |
-
'andrew': 'en-US-
|
| 783 |
-
'en_andrew': 'en-US-
|
| 784 |
-
|
| 785 |
-
'
|
| 786 |
-
|
| 787 |
-
|
| 788 |
'thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 789 |
'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 790 |
'pt_br_thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 791 |
-
'pt': 'pt-BR-
|
| 792 |
-
'pt_francisco': 'pt-BR-
|
| 793 |
-
#
|
| 794 |
-
'
|
| 795 |
-
'
|
| 796 |
-
'
|
| 797 |
-
'
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
'
|
| 801 |
-
'
|
| 802 |
-
|
| 803 |
-
'
|
| 804 |
-
'
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
'
|
| 808 |
-
'
|
| 809 |
-
'
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
'
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
'
|
| 816 |
-
'
|
| 817 |
-
'
|
| 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):
|
|
@@ -841,7 +709,7 @@ async def patched_ai_short(post_id: str, request: Request):
|
|
| 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')
|
|
|
|
| 42 |
return len(ta & tb) / max(1, min(len(ta), len(tb))) >= 0.72
|
| 43 |
|
| 44 |
|
| 45 |
+
def _dedupe_units(units, max_units=7):
|
|
|
|
| 46 |
out, seen = [], set()
|
| 47 |
for u in units:
|
| 48 |
u = _clean(re.sub(r"^[-•*\d\.\)\s]+", "", u))
|
| 49 |
if len(u) < 18:
|
| 50 |
continue
|
| 51 |
nu = _norm(u)
|
|
|
|
| 52 |
if nu in seen:
|
| 53 |
continue
|
| 54 |
+
if any(_similar(u, old) for old in out):
|
| 55 |
+
continue
|
| 56 |
seen.add(nu)
|
| 57 |
out.append(u)
|
| 58 |
if len(out) >= max_units:
|
|
|
|
| 60 |
return out
|
| 61 |
|
| 62 |
|
| 63 |
+
def _postprocess_ai_text(text, max_units=7):
|
| 64 |
text = _clean(text)
|
| 65 |
if not text:
|
| 66 |
return text
|
|
|
|
| 79 |
raw_lines.append(line)
|
| 80 |
units = []
|
| 81 |
for line in raw_lines:
|
| 82 |
+
if len(line) > 260:
|
| 83 |
+
units.extend(re.split(r"(?<=[\.\!\?])\s+(?=[A-ZÀ-Ỹ0-9])", line))
|
| 84 |
+
else:
|
| 85 |
+
units.append(line)
|
| 86 |
units = _dedupe_units(units, max_units=max_units)
|
| 87 |
if not units:
|
| 88 |
return text[:900]
|
|
|
|
| 269 |
errors.append("missing HF_TOKEN")
|
| 270 |
base.LAST_QWEN_ERROR = " | ".join(errors[-6:]) or "Qwen unavailable; used extractive fallback"
|
| 271 |
print("[qwen resilient fallback]", base.LAST_QWEN_ERROR)
|
| 272 |
+
return _fallback_summary_from_prompt(prompt, max_units=6)
|
| 273 |
|
| 274 |
|
| 275 |
if not hasattr(base, "_original_qwen_generate"):
|
|
|
|
| 322 |
|
| 323 |
Nội dung bài:
|
| 324 |
{art['raw'][:14000]}"""
|
| 325 |
+
text = await base.qwen_generate(prompt, image_url=art.get('image') or None, max_tokens=900)
|
| 326 |
+
text = _postprocess_ai_text(text, max_units=6)
|
| 327 |
src = [art['source']]
|
| 328 |
if 'Nguồn tham khảo:' not in text:
|
| 329 |
text += "\n\n" + _source_line(src)
|
|
|
|
| 348 |
if len(raw) < 120:
|
| 349 |
return JSONResponse({'error': 'URL không có đủ nội dung để tóm tắt'}, status_code=422)
|
| 350 |
prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
|
| 351 |
+
text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=850)
|
| 352 |
+
text = _postprocess_ai_text(text, max_units=6)
|
| 353 |
src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
|
| 354 |
if 'Nguồn tham khảo:' not in text:
|
| 355 |
text += "\n\n" + _source_line(src)
|
|
|
|
| 358 |
return JSONResponse({'post': post})
|
| 359 |
|
| 360 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
@app.post('/api/rewrite_share')
|
| 362 |
async def compat_rewrite_share(request: Request):
|
| 363 |
body = await request.json()
|
|
|
|
| 372 |
if len(raw) < 120:
|
| 373 |
return JSONResponse({'error': 'Bài viết không đủ nội dung để tóm tắt'}, status_code=422)
|
| 374 |
prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
|
| 375 |
+
text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=850)
|
| 376 |
+
text = _postprocess_ai_text(text, max_units=6)
|
| 377 |
src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
|
| 378 |
if 'Nguồn tham khảo:' not in text:
|
| 379 |
text += "\n\n" + _source_line(src)
|
| 380 |
post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'summary', sources=src)
|
| 381 |
posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
|
| 382 |
+
return JSONResponse({'post': post})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
|
| 384 |
|
| 385 |
def _emotion_script(text, emotion):
|
| 386 |
+
"""Prepend emotion-appropriate prefix to text based on emotion type."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
text = _clean(text)
|
| 388 |
+
prefixes = {
|
| 389 |
+
'urgent': 'Tin nhanh. ',
|
| 390 |
+
'warm': 'Câu chuyện đáng chú ý. ',
|
| 391 |
+
'serious': 'Bản tin nghiêm túc. ',
|
| 392 |
+
'energetic': 'Cập nhật nổi bật. ',
|
| 393 |
+
'happy': 'Tin vui! ',
|
| 394 |
+
'sad': 'Tin buồn. ',
|
| 395 |
+
'humorous': 'Chút hài hước. ',
|
| 396 |
+
'neutral': '',
|
| 397 |
+
}
|
| 398 |
+
prefix = prefixes.get(emotion, '')
|
| 399 |
+
return prefix + text
|
| 400 |
|
| 401 |
|
| 402 |
def _tts_script_smart(post, emotion):
|
|
|
|
| 405 |
raw = re.sub(r"\s*\n\s*", ". ", raw)
|
| 406 |
raw = re.sub(r"([\.\!\?])\s*", r"\1\n", raw)
|
| 407 |
raw = re.sub(r"\n{2,}", "\n", raw).strip()
|
| 408 |
+
raw = _emotion_script(raw, emotion)
|
| 409 |
+
if len(raw) > 1000:
|
| 410 |
+
raw = raw[:1000]
|
|
|
|
| 411 |
cut = max(raw.rfind("."), raw.rfind("!"), raw.rfind("?"))
|
| 412 |
+
if cut > 350:
|
| 413 |
raw = raw[:cut + 1]
|
| 414 |
return raw
|
| 415 |
|
|
|
|
| 524 |
|
| 525 |
|
| 526 |
|
| 527 |
+
def _summary_segments_from_post(post, max_segments=7):
|
| 528 |
raw = _clean(post.get('text') or post.get('title') or '')
|
| 529 |
raw = re.sub(r'^Bản tin AI viết lại:\s*', '', raw, flags=re.I)
|
| 530 |
raw = re.sub(r'Nguồn tham khảo:.*$', '', raw, flags=re.I|re.S).strip()
|
|
|
|
| 535 |
low=ln.lower()
|
| 536 |
if low.startswith(('điểm chính','tiêu đề','sapo','nguồn tham khảo')): continue
|
| 537 |
if len(ln)>=18: lines.append(ln)
|
| 538 |
+
if len(lines)<2:
|
| 539 |
lines=[]
|
| 540 |
for s in re.split(r'(?<=[\.\!\?])\s+', raw):
|
| 541 |
s=_clean(s)
|
|
|
|
| 587 |
draw.rounded_rectangle((48,834,260,880), radius=20, fill=(28,70,45))
|
| 588 |
draw.text((66,842),f'Đoạn {idx+1}/{total}',fill=(235,235,235),font=font_small)
|
| 589 |
y=940; maxw=W-96
|
| 590 |
+
for ln in _wrap_text_px(draw, segment, font_seg, maxw, 8):
|
|
|
|
| 591 |
draw.text((48,y),ln,fill=(255,255,255),font=font_seg)
|
| 592 |
y+=74
|
| 593 |
if y>1500: break
|
|
|
|
| 599 |
bg.save(out_path, quality=92)
|
| 600 |
|
| 601 |
|
| 602 |
+
def _estimate_audio_duration(path, fallback=4.0):
|
|
|
|
| 603 |
try:
|
| 604 |
pr=subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:no_key=1',path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
|
| 605 |
+
return max(1.5, float((pr.stdout or b'').decode().strip() or fallback))
|
| 606 |
except Exception:
|
| 607 |
return fallback
|
| 608 |
|
|
|
|
| 615 |
body = {}
|
| 616 |
voice = str(body.get('voice', 'nu')).strip().lower()
|
| 617 |
emotion = str(body.get('emotion', 'neutral')).strip().lower()
|
| 618 |
+
speed = float(body.get('speed', 1.2) or 1.2)
|
| 619 |
speed = max(0.85, min(1.35, speed))
|
| 620 |
|
| 621 |
posts = base._load_ai_wall()
|
|
|
|
| 623 |
if not post:
|
| 624 |
return JSONResponse({'error': 'post not found'}, status_code=404)
|
| 625 |
|
| 626 |
+
segments = _summary_segments_from_post(post, max_segments=7)
|
| 627 |
seg_hash = hashlib.md5(('|'.join(segments)+voice+emotion+str(speed)).encode('utf-8')).hexdigest()[:8]
|
| 628 |
os.makedirs(base.SHORTS_DIR, exist_ok=True)
|
| 629 |
suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_{seg_hash}_scenes_nosub"
|
|
|
|
| 647 |
base._download_image(post.get('img'), post.get('title', 'AI news'), img)
|
| 648 |
edge_voice = {
|
| 649 |
# Vietnamese
|
|
|
|
|
|
|
|
|
|
|
|
|
| 650 |
'nam': 'vi-VN-NamMinhNeural',
|
| 651 |
'male': 'vi-VN-NamMinhNeural',
|
| 652 |
'nu': 'vi-VN-HoaiMyNeural',
|
| 653 |
'female': 'vi-VN-HoaiMyNeural',
|
| 654 |
'mien-nam': 'vi-VN-HoaiMyNeural',
|
| 655 |
+
'hoaimy': 'vi-VN-HoaiMyNeural',
|
| 656 |
+
'namminh': 'vi-VN-NamMinhNeural',
|
| 657 |
+
# Multilingual - Andrew
|
| 658 |
+
'andrew': 'en-US-AndrewNeural',
|
| 659 |
+
'en_andrew': 'en-US-AndrewNeural',
|
| 660 |
+
# Multilingual - Jenny
|
| 661 |
+
'jenny': 'en-US-JennyNeural',
|
| 662 |
+
'en_jenny': 'en-US-JennyNeural',
|
| 663 |
+
# Portuguese - Thalita Multilingual
|
| 664 |
'thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 665 |
'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 666 |
'pt_br_thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 667 |
+
'pt': 'pt-BR-FranciscoNeural',
|
| 668 |
+
'pt_francisco': 'pt-BR-FranciscoNeural',
|
| 669 |
+
# Spanish
|
| 670 |
+
'ela': 'es-ES-ElaNeural',
|
| 671 |
+
'es_ela': 'es-ES-ElaNeural',
|
| 672 |
+
'es': 'es-ES-CarlosNeural',
|
| 673 |
+
'es_carlos': 'es-ES-CarlosNeural',
|
| 674 |
+
# French
|
| 675 |
+
'denise': 'fr-FR-DeniseNeural',
|
| 676 |
+
'fr': 'fr-FR-DeniseNeural',
|
| 677 |
+
'fr_denise': 'fr-FR-DeniseNeural',
|
| 678 |
+
# German
|
| 679 |
+
'katja': 'de-DE-KatjaNeural',
|
| 680 |
+
'de': 'de-DE-KatjaNeural',
|
| 681 |
+
'de_katja': 'de-DE-KatjaNeural',
|
| 682 |
+
# Japanese
|
| 683 |
+
'nanami': 'ja-JP-NanamiNeural',
|
| 684 |
+
'ja': 'ja-JP-NanamiNeural',
|
| 685 |
+
'ja_nanami': 'ja-JP-NanamiNeural',
|
| 686 |
+
# Korean
|
| 687 |
+
'sunhee': 'ko-KR-SunHeeNeural',
|
| 688 |
+
'ko': 'ko-KR-SunHeeNeural',
|
| 689 |
+
'ko_sunhee': 'ko-KR-SunHeeNeural',
|
| 690 |
+
# Chinese
|
| 691 |
+
'xiaochen': 'zh-CN-XiaochenNeural',
|
| 692 |
+
'zh': 'zh-CN-XiaochenNeural',
|
| 693 |
+
'zh_xiaochen': 'zh-CN-XiaochenNeural',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 694 |
}.get(voice, 'vi-VN-HoaiMyNeural')
|
| 695 |
part_files=[]
|
| 696 |
for idx, seg in enumerate(segments):
|
|
|
|
| 709 |
except TypeError:
|
| 710 |
base.gTTS(spoken, lang='vi', slow=False).save(aud)
|
| 711 |
subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
|
| 712 |
+
dur=_estimate_audio_duration(aud_fast, fallback=4.0)+0.35
|
| 713 |
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)
|
| 714 |
part_files.append(part)
|
| 715 |
concat=os.path.join(work,'concat.txt')
|
app_v2_entry.py
CHANGED
|
@@ -21,7 +21,6 @@ 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 |
|
|
@@ -889,142 +888,55 @@ def detect_language_and_emotion(title, text):
|
|
| 889 |
emotion = detect_emotion(combined, lang)
|
| 890 |
return lang, emotion
|
| 891 |
|
| 892 |
-
# Voice selection based on language and emotion
|
| 893 |
VOICE_BY_LANG_EMOTION = {
|
| 894 |
'vietnamese': {
|
| 895 |
-
'happy': ('
|
| 896 |
-
'sad': ('
|
| 897 |
-
'excited': ('
|
| 898 |
-
'humorous': ('
|
| 899 |
-
'serious': ('
|
| 900 |
-
'neutral': ('
|
| 901 |
},
|
| 902 |
'portuguese': {
|
| 903 |
-
'happy': ('
|
| 904 |
-
'sad': ('
|
| 905 |
-
'excited': ('
|
| 906 |
-
'humorous': ('
|
| 907 |
-
'serious': ('
|
| 908 |
-
'neutral': ('
|
| 909 |
},
|
| 910 |
'english': {
|
| 911 |
-
'happy': ('
|
| 912 |
-
'sad': ('
|
| 913 |
-
'excited': ('
|
| 914 |
-
'humorous': ('
|
| 915 |
-
'serious': ('
|
| 916 |
-
'neutral': ('
|
| 917 |
},
|
| 918 |
-
'
|
| 919 |
-
'happy': ('
|
| 920 |
-
'sad': ('
|
| 921 |
-
'excited': ('
|
| 922 |
-
'humorous': ('
|
| 923 |
-
'serious': ('
|
| 924 |
-
'neutral': ('
|
| 925 |
-
},
|
| 926 |
-
'german': {
|
| 927 |
-
'happy': ('de-DE-SeraphinaMultilingualNeural', 'glücklich'),
|
| 928 |
-
'sad': ('de-DE-FlorianMultilingualNeural', 'traurig'),
|
| 929 |
-
'excited': ('de-DE-SeraphinaMultilingualNeural', 'aufgeregt'),
|
| 930 |
-
'humorous': ('de-DE-SeraphinaMultilingualNeural', 'lustig'),
|
| 931 |
-
'serious': ('de-DE-FlorianMultilingualNeural', 'ernst'),
|
| 932 |
-
'neutral': ('de-DE-SeraphinaMultilingualNeural', 'neutral'),
|
| 933 |
-
},
|
| 934 |
-
'korean': {
|
| 935 |
-
'happy': ('ko-KR-HyunsuMultilingualNeural', '행복'),
|
| 936 |
-
'sad': ('ko-KR-HyunsuMultilingualNeural', '슬픔'),
|
| 937 |
-
'excited': ('ko-KR-HyunsuMultilingualNeural', '흥분'),
|
| 938 |
-
'humorous': ('ko-KR-HyunsuMultilingualNeural', '유쾌'),
|
| 939 |
-
'serious': ('ko-KR-HyunsuMultilingualNeural', '진지'),
|
| 940 |
-
'neutral': ('ko-KR-HyunsuMultilingualNeural', '중립'),
|
| 941 |
-
},
|
| 942 |
-
'italian': {
|
| 943 |
-
'happy': ('it-IT-GiuseppeMultilingualNeural', 'felice'),
|
| 944 |
-
'sad': ('it-IT-GiuseppeMultilingualNeural', 'triste'),
|
| 945 |
-
'excited': ('it-IT-GiuseppeMultilingualNeural', 'emozionato'),
|
| 946 |
-
'humorous': ('it-IT-GiuseppeMultilingualNeural', 'divertente'),
|
| 947 |
-
'serious': ('it-IT-GiuseppeMultilingualNeural', 'serio'),
|
| 948 |
-
'neutral': ('it-IT-GiuseppeMultilingualNeural', 'neutro'),
|
| 949 |
},
|
| 950 |
}
|
| 951 |
|
| 952 |
-
# All valid voice IDs (new MultilingualNeural format)
|
| 953 |
-
VALID_VOICES = {
|
| 954 |
-
'vi-VN-HoaiMyNeural', 'vi-VN-NamMinhNeural',
|
| 955 |
-
'en-US-AndrewMultilingualNeural', 'en-AU-WilliamMultilingualNeural',
|
| 956 |
-
'pt-BR-ThalitaMultilingualNeural',
|
| 957 |
-
'fr-FR-VivienneMultilingualNeural', 'fr-FR-RemyMultilingualNeural',
|
| 958 |
-
'de-DE-SeraphinaMultilingualNeural', 'de-DE-FlorianMultilingualNeural',
|
| 959 |
-
'ko-KR-HyunsuMultilingualNeural',
|
| 960 |
-
'it-IT-GiuseppeMultilingualNeural',
|
| 961 |
-
}
|
| 962 |
-
|
| 963 |
def get_voice_for_content(title, text, preferred_voice=None):
|
| 964 |
"""Get appropriate voice based on content language and emotion."""
|
| 965 |
-
|
| 966 |
-
if preferred_voice and preferred_voice in VALID_VOICES:
|
| 967 |
return preferred_voice
|
| 968 |
|
| 969 |
-
# Also accept old shorthand voice IDs and map them to new format
|
| 970 |
-
old_voice_map = {
|
| 971 |
-
'hoaimy': 'vi-VN-HoaiMyNeural',
|
| 972 |
-
'namminh': 'vi-VN-NamMinhNeural',
|
| 973 |
-
'andrew': 'en-US-AndrewMultilingualNeural',
|
| 974 |
-
'jenny': 'en-US-AndrewMultilingualNeural',
|
| 975 |
-
'thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 976 |
-
'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 977 |
-
'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
|
| 978 |
-
'ela': 'en-US-AndrewMultilingualNeural',
|
| 979 |
-
'es_carlos': 'en-US-AndrewMultilingualNeural',
|
| 980 |
-
'denise': 'fr-FR-VivienneMultilingualNeural',
|
| 981 |
-
'katja': 'de-DE-SeraphinaMultilingualNeural',
|
| 982 |
-
'nanami': 'en-US-AndrewMultilingualNeural',
|
| 983 |
-
'sunhee': 'ko-KR-HyunsuMultilingualNeural',
|
| 984 |
-
'xiaochen': 'en-US-AndrewMultilingualNeural',
|
| 985 |
-
}
|
| 986 |
-
if preferred_voice and preferred_voice in old_voice_map:
|
| 987 |
-
return old_voice_map[preferred_voice]
|
| 988 |
-
|
| 989 |
lang, emotion = detect_language_and_emotion(title, text)
|
| 990 |
lang_map = VOICE_BY_LANG_EMOTION.get(lang, VOICE_BY_LANG_EMOTION['vietnamese'])
|
| 991 |
voice, _ = lang_map.get(emotion, lang_map['neutral'])
|
| 992 |
return voice
|
| 993 |
|
| 994 |
|
| 995 |
-
def _is_relevant_image(img_url, title, text):
|
| 996 |
-
"""Check if an image is relevant to the article content."""
|
| 997 |
-
if not img_url:
|
| 998 |
-
return False
|
| 999 |
-
skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif',
|
| 1000 |
-
'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite',
|
| 1001 |
-
'advertisement', 'ad-banner', 'sponsored', 'banner-ads']
|
| 1002 |
-
img_lower = img_url.lower()
|
| 1003 |
-
for p in skip_patterns:
|
| 1004 |
-
if p in img_lower:
|
| 1005 |
-
return False
|
| 1006 |
-
if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']):
|
| 1007 |
-
return False
|
| 1008 |
-
return True
|
| 1009 |
-
|
| 1010 |
-
|
| 1011 |
-
def _filter_relevant_images(images, title, text, max_images=8):
|
| 1012 |
-
"""Filter and rank images by relevance to article content."""
|
| 1013 |
-
if not images:
|
| 1014 |
-
return []
|
| 1015 |
-
seen = set()
|
| 1016 |
-
relevant = []
|
| 1017 |
-
for img in images:
|
| 1018 |
-
if img in seen:
|
| 1019 |
-
continue
|
| 1020 |
-
seen.add(img)
|
| 1021 |
-
if _is_relevant_image(img, title, text):
|
| 1022 |
-
relevant.append(img)
|
| 1023 |
-
return relevant[:max_images]
|
| 1024 |
-
|
| 1025 |
-
|
| 1026 |
def _scrape_article_for_rewrite(url):
|
| 1027 |
-
"""Scrape article: extract title, paragraphs,
|
| 1028 |
try:
|
| 1029 |
r = req.get(url, headers=_UA_RW, timeout=15, allow_redirects=True)
|
| 1030 |
r.encoding = 'utf-8'
|
|
@@ -1047,10 +959,10 @@ def _scrape_article_for_rewrite(url):
|
|
| 1047 |
if not block:
|
| 1048 |
block = soup.body or soup
|
| 1049 |
paragraphs = []
|
| 1050 |
-
|
| 1051 |
seen_imgs = set()
|
| 1052 |
if og_img and og_img not in seen_imgs:
|
| 1053 |
-
|
| 1054 |
seen_imgs.add(og_img)
|
| 1055 |
for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
|
| 1056 |
if el.name == 'p':
|
|
@@ -1065,65 +977,29 @@ def _scrape_article_for_rewrite(url):
|
|
| 1065 |
if src.startswith('//'):
|
| 1066 |
src = 'https:' + src
|
| 1067 |
if src not in seen_imgs:
|
| 1068 |
-
|
| 1069 |
seen_imgs.add(src)
|
| 1070 |
-
|
| 1071 |
-
relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5]))
|
| 1072 |
-
return {'title': _clean(title), 'paragraphs': paragraphs, 'images': relevant_images, 'og_img': og_img}
|
| 1073 |
except Exception:
|
| 1074 |
return None
|
| 1075 |
|
| 1076 |
|
| 1077 |
def _extract_key_points_rw(paragraphs, max_points=5):
|
| 1078 |
-
"""Extract key points from paragraphs
|
| 1079 |
-
|
| 1080 |
-
Fixes: Original regex `^(.+?[.!?])\s` only captured first sentence per paragraph.
|
| 1081 |
-
Now splits on all sentence boundaries and takes valid sentences until max_points.
|
| 1082 |
-
"""
|
| 1083 |
points = []
|
| 1084 |
-
|
| 1085 |
for p in paragraphs:
|
| 1086 |
if len(points) >= max_points:
|
| 1087 |
break
|
| 1088 |
-
|
| 1089 |
-
|
| 1090 |
-
|
|
|
|
|
|
|
|
|
|
| 1091 |
continue
|
| 1092 |
-
|
| 1093 |
-
|
| 1094 |
-
|
| 1095 |
-
sentences = [s.strip() for s in sentences if s.strip()]
|
| 1096 |
-
|
| 1097 |
-
for sentence in sentences:
|
| 1098 |
-
if len(points) >= max_points:
|
| 1099 |
-
break
|
| 1100 |
-
|
| 1101 |
-
# Clean sentence - remove extra whitespace
|
| 1102 |
-
sentence = _clean(sentence)
|
| 1103 |
-
|
| 1104 |
-
if len(sentence) < 30:
|
| 1105 |
-
continue
|
| 1106 |
-
|
| 1107 |
-
# Check for duplicates
|
| 1108 |
-
if any(sentence[:60] in existing for existing in points):
|
| 1109 |
-
continue
|
| 1110 |
-
|
| 1111 |
-
# Ensure sentence ends with punctuation
|
| 1112 |
-
if not sentence.endswith(('.', '!', '?')):
|
| 1113 |
-
sentence = sentence + '.'
|
| 1114 |
-
|
| 1115 |
-
points.append(sentence)
|
| 1116 |
-
|
| 1117 |
-
# If no valid sentences found, take chunks from raw text
|
| 1118 |
-
if not points:
|
| 1119 |
-
raw = '\n'.join(paragraphs)
|
| 1120 |
-
for i in range(0, min(len(raw), max_points * 300), 280):
|
| 1121 |
-
chunk = _clean(raw[i:i+280])
|
| 1122 |
-
if len(chunk) >= 30 and chunk not in points:
|
| 1123 |
-
points.append(chunk + ('.' if not chunk.endswith('.') else ''))
|
| 1124 |
-
if len(points) >= max_points:
|
| 1125 |
-
break
|
| 1126 |
-
|
| 1127 |
return points
|
| 1128 |
|
| 1129 |
|
|
@@ -1133,7 +1009,6 @@ async def api_rewrite_slide(request: Request):
|
|
| 1133 |
body = await request.json()
|
| 1134 |
url = _clean(body.get("url", ""))
|
| 1135 |
context = body.get("context", "")
|
| 1136 |
-
preferred_voice = body.get("voice", "") # Accept custom voice selection
|
| 1137 |
if not url and not context:
|
| 1138 |
return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
|
| 1139 |
data = None
|
|
@@ -1144,7 +1019,7 @@ async def api_rewrite_slide(request: Request):
|
|
| 1144 |
data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
|
| 1145 |
if not data or not data.get('paragraphs'):
|
| 1146 |
return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
|
| 1147 |
-
points = _extract_key_points_rw(data['paragraphs'], max_points=
|
| 1148 |
if not points:
|
| 1149 |
return JSONResponse({"error": "Không tìm được ý chính"}, status_code=422)
|
| 1150 |
images = data.get('images', [])
|
|
@@ -1158,8 +1033,7 @@ async def api_rewrite_slide(request: Request):
|
|
| 1158 |
|
| 1159 |
# Auto-detect language and emotion
|
| 1160 |
lang, emotion = detect_language_and_emotion(data['title'], summary_text)
|
| 1161 |
-
|
| 1162 |
-
voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], summary_text)
|
| 1163 |
|
| 1164 |
post = {
|
| 1165 |
"id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
|
|
@@ -1188,7 +1062,6 @@ async def api_rewrite_share(request: Request):
|
|
| 1188 |
body = await request.json()
|
| 1189 |
url = _clean(body.get("url", ""))
|
| 1190 |
ctx = _clean(body.get("context", ""))
|
| 1191 |
-
preferred_voice = body.get("voice", "") # Accept custom voice selection
|
| 1192 |
if not url and not ctx:
|
| 1193 |
return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
|
| 1194 |
data = None
|
|
@@ -1221,14 +1094,14 @@ async def api_rewrite_share(request: Request):
|
|
| 1221 |
except Exception:
|
| 1222 |
pass
|
| 1223 |
if not ai_text or len(ai_text) < 80:
|
| 1224 |
-
key_pts = _extract_key_points_rw(data['paragraphs'], max_points=
|
| 1225 |
if key_pts:
|
| 1226 |
ai_text = '\n\n'.join([f"• {p}" for p in key_pts])
|
| 1227 |
else:
|
| 1228 |
ai_text = f"Tóm tắt: {data['title']}\n\n{raw_text[:1200]}\n\nNguồn: {domain}"
|
| 1229 |
|
| 1230 |
# Build slides from key points (FIX: include slides in rewrite_share too!)
|
| 1231 |
-
points = _extract_key_points_rw(data['paragraphs'], max_points=
|
| 1232 |
images = data.get('images', [])
|
| 1233 |
slides = []
|
| 1234 |
for i, point in enumerate(points):
|
|
@@ -1239,8 +1112,7 @@ async def api_rewrite_share(request: Request):
|
|
| 1239 |
|
| 1240 |
# Auto-detect language and emotion
|
| 1241 |
lang, emotion = detect_language_and_emotion(data['title'], ai_text)
|
| 1242 |
-
|
| 1243 |
-
voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], ai_text)
|
| 1244 |
|
| 1245 |
post = {
|
| 1246 |
"id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
|
|
@@ -1283,279 +1155,4 @@ def _bg():
|
|
| 1283 |
time.sleep(90)
|
| 1284 |
threading.Thread(target=_bg,daemon=True).start()
|
| 1285 |
|
| 1286 |
-
|
| 1287 |
-
_AUTO_SCHEDULE_TIMES = [(7, '07:00'), (13, '13:00'), (19, '19:00')]
|
| 1288 |
-
_AUTO_LOG = os.path.join(DATA_DIR, 'auto_rewrite_log.json')
|
| 1289 |
-
|
| 1290 |
-
def _load_auto_log():
|
| 1291 |
-
try:
|
| 1292 |
-
if os.path.exists(_AUTO_LOG):
|
| 1293 |
-
with open(_AUTO_LOG, 'r') as f:
|
| 1294 |
-
return json.load(f)
|
| 1295 |
-
except: pass
|
| 1296 |
-
return {}
|
| 1297 |
-
|
| 1298 |
-
def _save_auto_log(log):
|
| 1299 |
-
try:
|
| 1300 |
-
tmp = _AUTO_LOG + '.tmp'
|
| 1301 |
-
with open(tmp, 'w') as f:
|
| 1302 |
-
json.dump(log, f)
|
| 1303 |
-
os.replace(tmp, _AUTO_LOG)
|
| 1304 |
-
except: pass
|
| 1305 |
-
|
| 1306 |
-
async def _auto_fetch_short(post_id):
|
| 1307 |
-
"""Try to auto-generate a short for a post."""
|
| 1308 |
-
try:
|
| 1309 |
-
import httpx
|
| 1310 |
-
async with httpx.AsyncClient(timeout=180) as cl:
|
| 1311 |
-
r = await cl.post(
|
| 1312 |
-
f"http://localhost:7860/api/ai/short/{post_id}",
|
| 1313 |
-
json={"voice":"vi-VN-HoaiMyNeural","emotion":"neutral","speed":1.2},
|
| 1314 |
-
headers={"Content-Type":"application/json"}
|
| 1315 |
-
)
|
| 1316 |
-
if r.status_code < 300:
|
| 1317 |
-
sj = r.json()
|
| 1318 |
-
if sj.get('video'):
|
| 1319 |
-
posts = _load_wall_posts()
|
| 1320 |
-
for p in posts:
|
| 1321 |
-
if p.get('id') == post_id:
|
| 1322 |
-
p['video'] = sj['video']
|
| 1323 |
-
break
|
| 1324 |
-
_save_wall_posts(posts)
|
| 1325 |
-
return True
|
| 1326 |
-
except: pass
|
| 1327 |
-
return False
|
| 1328 |
-
|
| 1329 |
-
async def _auto_rewrite_one(topic, slot_label, used_urls=None):
|
| 1330 |
-
"""Rewrite one topic: find articles, summarize, post to wall, trigger short.
|
| 1331 |
-
used_urls: shared set to avoid duplicate articles across topics."""
|
| 1332 |
-
from urllib.parse import quote as _q
|
| 1333 |
-
items = _search_all(topic, limit=6)
|
| 1334 |
-
# Skip URLs already used by another topic
|
| 1335 |
-
if used_urls is not None:
|
| 1336 |
-
filtered = [it for it in items if it.get('url') not in used_urls]
|
| 1337 |
-
if filtered:
|
| 1338 |
-
items = filtered
|
| 1339 |
-
if not items:
|
| 1340 |
-
# fallback: Google RSS
|
| 1341 |
-
try:
|
| 1342 |
-
r = req.get(f"https://news.google.com/rss/search?q={_q(topic)}&hl=vi&gl=VN&ceid=VN:vi",
|
| 1343 |
-
headers={'User-Agent':'Mozilla/5.0'}, timeout=8)
|
| 1344 |
-
r.encoding = 'utf-8'
|
| 1345 |
-
soup = BeautifulSoup(r.text, 'xml')
|
| 1346 |
-
for it in soup.find_all('item')[:5]:
|
| 1347 |
-
t = _clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
|
| 1348 |
-
lk = it.find('link').get_text(strip=True) if it.find('link') else ''
|
| 1349 |
-
if t and lk: items.append({'title':t,'url':lk,'via':'Google News'})
|
| 1350 |
-
except: pass
|
| 1351 |
-
if not items:
|
| 1352 |
-
return False
|
| 1353 |
-
|
| 1354 |
-
item = items[0] # best match
|
| 1355 |
-
url = item.get('url', '')
|
| 1356 |
-
title = item.get('title', topic)
|
| 1357 |
-
if url and used_urls is not None:
|
| 1358 |
-
used_urls.add(url)
|
| 1359 |
-
if not url.startswith('http'):
|
| 1360 |
-
return False
|
| 1361 |
-
|
| 1362 |
-
data = _scrape_article_for_rewrite(url)
|
| 1363 |
-
if not data or not data.get('paragraphs'):
|
| 1364 |
-
return False
|
| 1365 |
-
|
| 1366 |
-
raw_text = '\n'.join(data['paragraphs'])
|
| 1367 |
-
ai_text = None
|
| 1368 |
-
|
| 1369 |
-
# Try AI generation
|
| 1370 |
-
try:
|
| 1371 |
-
import ai_ext
|
| 1372 |
-
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."
|
| 1373 |
-
ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000)
|
| 1374 |
-
except: pass
|
| 1375 |
-
|
| 1376 |
-
if not ai_text or len(ai_text) < 80:
|
| 1377 |
-
pts = data['paragraphs'][:6]
|
| 1378 |
-
ai_text = '\n\n'.join([f"• {p[:300]}" for p in pts])
|
| 1379 |
-
via = item.get('via', '') or urlparse(url).netloc.replace('www.', '')
|
| 1380 |
-
ai_text += f"\n\nNguồn tham khảo: {via}"
|
| 1381 |
-
|
| 1382 |
-
# Build slides
|
| 1383 |
-
images = data.get('images', [])
|
| 1384 |
-
pts = data['paragraphs'][:10]
|
| 1385 |
-
slides = []
|
| 1386 |
-
for i, p in enumerate(pts[:8]):
|
| 1387 |
-
img = images[i] if i < len(images) else (images[-1] if images else data.get('og_img', ''))
|
| 1388 |
-
slides.append({'text': p[:300], 'image': img, 'index': i + 1})
|
| 1389 |
-
|
| 1390 |
-
post_id = str(int(time.time() * 1000)) + str(_random2.randint(100, 999))
|
| 1391 |
-
post = {
|
| 1392 |
-
"id": post_id, "title": data.get('title', title)[:200],
|
| 1393 |
-
"text": ai_text, "img": images[0] if images else data.get('og_img', ''),
|
| 1394 |
-
"url": url, "kind": "auto_rewrite", "slides": slides,
|
| 1395 |
-
"images": images[:10], "video": "",
|
| 1396 |
-
"voice": "vi-VN-HoaiMyNeural", "emotion": "neutral",
|
| 1397 |
-
"language": "vietnamese", "ts": int(time.time()),
|
| 1398 |
-
"auto_scheduled": True, "slot": slot_label,
|
| 1399 |
-
}
|
| 1400 |
-
|
| 1401 |
-
posts = _load_wall_posts()
|
| 1402 |
-
posts.insert(0, post)
|
| 1403 |
-
_save_wall_posts(posts)
|
| 1404 |
-
|
| 1405 |
-
# Trigger short generation async
|
| 1406 |
-
threading.Thread(target=lambda: asyncio.run(_auto_fetch_short(post_id)), daemon=True).start()
|
| 1407 |
-
return True
|
| 1408 |
-
|
| 1409 |
-
async def _do_scheduled_run(slot_label):
|
| 1410 |
-
"""Main scheduled run: exactly 3 posts from 3 different HOT topics, no duplicates."""
|
| 1411 |
-
print(f"[auto] Starting scheduled rewrite for {slot_label}")
|
| 1412 |
-
|
| 1413 |
-
# Get top hot topics, skip duplicates
|
| 1414 |
-
all_topics = _get_hot_topics()
|
| 1415 |
-
seen_topics = set()
|
| 1416 |
-
unique_topics = []
|
| 1417 |
-
for t in all_topics:
|
| 1418 |
-
kw = t.get('topic', '').lower().strip()
|
| 1419 |
-
if kw and len(kw) > 5 and kw not in seen_topics:
|
| 1420 |
-
is_dup = False
|
| 1421 |
-
for s in seen_topics:
|
| 1422 |
-
# Check if one topic is substring of another
|
| 1423 |
-
if kw in s or s in kw:
|
| 1424 |
-
is_dup = True
|
| 1425 |
-
break
|
| 1426 |
-
if not is_dup:
|
| 1427 |
-
seen_topics.add(kw)
|
| 1428 |
-
unique_topics.append(t)
|
| 1429 |
-
if len(unique_topics) >= 3:
|
| 1430 |
-
break
|
| 1431 |
-
|
| 1432 |
-
job_topics = [t['topic'] for t in unique_topics[:3] if t.get('topic')]
|
| 1433 |
-
if not job_topics:
|
| 1434 |
-
print(f"[auto] No hot topics found, skipping")
|
| 1435 |
-
return
|
| 1436 |
-
|
| 1437 |
-
print(f"[auto] Running 3 topics: {job_topics}")
|
| 1438 |
-
|
| 1439 |
-
# Track used URLs to avoid cross-topic duplicates
|
| 1440 |
-
_used_urls = set()
|
| 1441 |
-
results = []
|
| 1442 |
-
|
| 1443 |
-
for jt in job_topics:
|
| 1444 |
-
try:
|
| 1445 |
-
ok = await asyncio.wait_for(_auto_rewrite_one(jt, slot_label, _used_urls), timeout=120)
|
| 1446 |
-
results.append((jt, ok))
|
| 1447 |
-
except Exception as e:
|
| 1448 |
-
print(f"[auto] Error on '{jt}': {e}")
|
| 1449 |
-
results.append((jt, False))
|
| 1450 |
-
await asyncio.sleep(2)
|
| 1451 |
-
|
| 1452 |
-
# Log
|
| 1453 |
-
from datetime import datetime, timezone, timedelta
|
| 1454 |
-
VN_TZ_SCHED = timezone(timedelta(hours=7))
|
| 1455 |
-
today_str = datetime.now(VN_TZ_SCHED).strftime('%Y-%m-%d')
|
| 1456 |
-
log = _load_auto_log()
|
| 1457 |
-
if today_str not in log: log[today_str] = {}
|
| 1458 |
-
log[today_str][slot_label] = {
|
| 1459 |
-
'time': datetime.now(VN_TZ_SCHED).strftime('%H:%M:%S'),
|
| 1460 |
-
'count': sum(1 for _, ok in results if ok),
|
| 1461 |
-
'total': len(results),
|
| 1462 |
-
}
|
| 1463 |
-
_save_auto_log(log)
|
| 1464 |
-
print(f"[auto] Done {slot_label}: {sum(1 for _, ok in results if ok)}/{len(results)} posts")
|
| 1465 |
-
|
| 1466 |
-
def _scheduler_loop():
|
| 1467 |
-
"""Check every 60s; trigger at 7:00, 13:00, 19:00 VN time.
|
| 1468 |
-
On startup, check for any missed slots today and run them immediately."""
|
| 1469 |
-
time.sleep(35)
|
| 1470 |
-
from datetime import datetime, timezone, timedelta
|
| 1471 |
-
VN_TZ_SCHED = timezone(timedelta(hours=7))
|
| 1472 |
-
|
| 1473 |
-
_last_run_date = ""
|
| 1474 |
-
_last_run_slots = set()
|
| 1475 |
-
|
| 1476 |
-
# On startup: check log for missed slots today
|
| 1477 |
-
try:
|
| 1478 |
-
start_now = datetime.now(VN_TZ_SCHED)
|
| 1479 |
-
today_str = start_now.strftime('%Y-%m-%d')
|
| 1480 |
-
current_hour = start_now.hour
|
| 1481 |
-
current_minute = start_now.minute
|
| 1482 |
-
log = _load_auto_log()
|
| 1483 |
-
today_log = log.get(today_str, {})
|
| 1484 |
-
for h, label in _AUTO_SCHEDULE_TIMES:
|
| 1485 |
-
# Run if slot is past (either strictly earlier hour, or same hour but window has passed)
|
| 1486 |
-
should_run = False
|
| 1487 |
-
if h < current_hour:
|
| 1488 |
-
should_run = True
|
| 1489 |
-
elif h == current_hour and current_minute > 10:
|
| 1490 |
-
should_run = True
|
| 1491 |
-
if should_run and label not in today_log:
|
| 1492 |
-
print(f"[auto] Detected missed slot {label} (h={h} < now={current_hour}:{current_minute}), running catch-up now")
|
| 1493 |
-
_run_scheduled_sync(label)
|
| 1494 |
-
_last_run_slots.add(label)
|
| 1495 |
-
except Exception as e:
|
| 1496 |
-
print(f"[auto] Catch-up check error: {e}")
|
| 1497 |
-
|
| 1498 |
-
while True:
|
| 1499 |
-
try:
|
| 1500 |
-
now = datetime.now(VN_TZ_SCHED)
|
| 1501 |
-
today = now.strftime('%Y-%m-%d')
|
| 1502 |
-
hour = now.hour
|
| 1503 |
-
minute = now.minute
|
| 1504 |
-
|
| 1505 |
-
if today != _last_run_date:
|
| 1506 |
-
_last_run_date = today
|
| 1507 |
-
_last_run_slots = set()
|
| 1508 |
-
|
| 1509 |
-
slot = None
|
| 1510 |
-
for h, label in _AUTO_SCHEDULE_TIMES:
|
| 1511 |
-
if hour == h and 0 <= minute < 5:
|
| 1512 |
-
slot = label
|
| 1513 |
-
break
|
| 1514 |
-
|
| 1515 |
-
if slot and slot not in _last_run_slots:
|
| 1516 |
-
_last_run_slots.add(slot)
|
| 1517 |
-
_run_scheduled_sync(slot)
|
| 1518 |
-
except Exception as e:
|
| 1519 |
-
print(f"[auto] Loop error: {e}")
|
| 1520 |
-
|
| 1521 |
-
time.sleep(60)
|
| 1522 |
-
|
| 1523 |
-
threading.Thread(target=_scheduler_loop, daemon=True, name='auto-rewrite-scheduler').start()
|
| 1524 |
-
|
| 1525 |
-
@app.get('/api/debug/auto_schedule')
|
| 1526 |
-
async def debug_auto_schedule(slot: str = '07:00'):
|
| 1527 |
-
"""Manually trigger auto scheduler for debugging."""
|
| 1528 |
-
try:
|
| 1529 |
-
# Check if we can access the data directory
|
| 1530 |
-
log = _load_auto_log()
|
| 1531 |
-
topics = _get_hot_topics()[:3]
|
| 1532 |
-
job_topics = [t['topic'] for t in topics if t.get('topic')]
|
| 1533 |
-
return JSONResponse({
|
| 1534 |
-
"slot": slot,
|
| 1535 |
-
"log": log,
|
| 1536 |
-
"hot_topics": job_topics,
|
| 1537 |
-
"wall_posts_count": len(_load_wall_posts()),
|
| 1538 |
-
"data_dir_writable": os.access(DATA_DIR, os.W_OK) if os.path.isdir(DATA_DIR) else False,
|
| 1539 |
-
"data_dir_exists": os.path.isdir(DATA_DIR),
|
| 1540 |
-
})
|
| 1541 |
-
except Exception as e:
|
| 1542 |
-
return JSONResponse({"error": str(e)}, status_code=500)
|
| 1543 |
-
|
| 1544 |
-
def _run_scheduled_sync(slot):
|
| 1545 |
-
"""Run _do_scheduled_run in a separate event loop (for background thread)."""
|
| 1546 |
-
loop = asyncio.new_event_loop()
|
| 1547 |
-
asyncio.set_event_loop(loop)
|
| 1548 |
-
try:
|
| 1549 |
-
loop.run_until_complete(_do_scheduled_run(slot))
|
| 1550 |
-
except Exception as e:
|
| 1551 |
-
print(f"[auto] Background run error: {e}")
|
| 1552 |
-
finally:
|
| 1553 |
-
loop.close()
|
| 1554 |
-
|
| 1555 |
-
@app.get('/api/debug/trigger_auto')
|
| 1556 |
-
async def debug_trigger_auto(slot: str = '19:00'):
|
| 1557 |
-
"""Trigger _do_scheduled_run in background thread (non-blocking)."""
|
| 1558 |
-
threading.Thread(target=_run_scheduled_sync, args=(slot,), daemon=True).start()
|
| 1559 |
-
return JSONResponse({"status": "started", "slot": slot})
|
| 1560 |
-
|
| 1561 |
-
app.mount('/static',StaticFiles(directory=STATIC_DIR),name='vnews_static')
|
|
|
|
| 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 |
|
| 25 |
HL_LEAGUES['friendly'] = {"path": "giai-khac/friendly", "name": "Giao hữu", "emoji": "🤝"}
|
| 26 |
|
|
|
|
| 888 |
emotion = detect_emotion(combined, lang)
|
| 889 |
return lang, emotion
|
| 890 |
|
| 891 |
+
# Voice selection based on language and emotion
|
| 892 |
VOICE_BY_LANG_EMOTION = {
|
| 893 |
'vietnamese': {
|
| 894 |
+
'happy': ('hoaimy', 'vui'),
|
| 895 |
+
'sad': ('namminh', 'buồn'),
|
| 896 |
+
'excited': ('hoaimy', 'hào hứng'),
|
| 897 |
+
'humorous': ('hoaimy', 'vui'),
|
| 898 |
+
'serious': ('namminh', 'nghiêm túc'),
|
| 899 |
+
'neutral': ('hoaimy', 'trung_tinh'),
|
| 900 |
},
|
| 901 |
'portuguese': {
|
| 902 |
+
'happy': ('pt_thalita', 'feliz'),
|
| 903 |
+
'sad': ('thalita', 'triste'),
|
| 904 |
+
'excited': ('pt_francisco', 'animado'),
|
| 905 |
+
'humorous': ('pt_thalita', 'engraçado'),
|
| 906 |
+
'serious': ('thalita', 'sério'),
|
| 907 |
+
'neutral': ('pt_thalita', 'neutro'),
|
| 908 |
},
|
| 909 |
'english': {
|
| 910 |
+
'happy': ('jenny', 'happy'),
|
| 911 |
+
'sad': ('jenny', 'sad'),
|
| 912 |
+
'excited': ('andrew', 'excited'),
|
| 913 |
+
'humorous': ('jenny', 'funny'),
|
| 914 |
+
'serious': ('andrew', 'serious'),
|
| 915 |
+
'neutral': ('jenny', 'neutral'),
|
| 916 |
},
|
| 917 |
+
'spanish': {
|
| 918 |
+
'happy': ('ela', 'feliz'),
|
| 919 |
+
'sad': ('es_carlos', 'triste'),
|
| 920 |
+
'excited': ('ela', 'emocionado'),
|
| 921 |
+
'humorous': ('ela', 'gracioso'),
|
| 922 |
+
'serious': ('es_carlos', 'serio'),
|
| 923 |
+
'neutral': ('ela', 'neutro'),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 924 |
},
|
| 925 |
}
|
| 926 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 927 |
def get_voice_for_content(title, text, preferred_voice=None):
|
| 928 |
"""Get appropriate voice based on content language and emotion."""
|
| 929 |
+
if preferred_voice and preferred_voice in ('hoaimy', 'namminh', 'andrew', 'jenny', 'thalita', 'pt_thalita', 'pt_francisco', 'ela', 'es_carlos', 'denise', 'katja', 'nanami', 'sunhee', 'xiaochen'):
|
|
|
|
| 930 |
return preferred_voice
|
| 931 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 932 |
lang, emotion = detect_language_and_emotion(title, text)
|
| 933 |
lang_map = VOICE_BY_LANG_EMOTION.get(lang, VOICE_BY_LANG_EMOTION['vietnamese'])
|
| 934 |
voice, _ = lang_map.get(emotion, lang_map['neutral'])
|
| 935 |
return voice
|
| 936 |
|
| 937 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 938 |
def _scrape_article_for_rewrite(url):
|
| 939 |
+
"""Scrape article: extract title, paragraphs, images, OG image."""
|
| 940 |
try:
|
| 941 |
r = req.get(url, headers=_UA_RW, timeout=15, allow_redirects=True)
|
| 942 |
r.encoding = 'utf-8'
|
|
|
|
| 959 |
if not block:
|
| 960 |
block = soup.body or soup
|
| 961 |
paragraphs = []
|
| 962 |
+
images = []
|
| 963 |
seen_imgs = set()
|
| 964 |
if og_img and og_img not in seen_imgs:
|
| 965 |
+
images.append(og_img)
|
| 966 |
seen_imgs.add(og_img)
|
| 967 |
for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
|
| 968 |
if el.name == 'p':
|
|
|
|
| 977 |
if src.startswith('//'):
|
| 978 |
src = 'https:' + src
|
| 979 |
if src not in seen_imgs:
|
| 980 |
+
images.append(src)
|
| 981 |
seen_imgs.add(src)
|
| 982 |
+
return {'title': _clean(title), 'paragraphs': paragraphs, 'images': images, 'og_img': og_img}
|
|
|
|
|
|
|
| 983 |
except Exception:
|
| 984 |
return None
|
| 985 |
|
| 986 |
|
| 987 |
def _extract_key_points_rw(paragraphs, max_points=5):
|
| 988 |
+
"""Extract key points from paragraphs."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 989 |
points = []
|
|
|
|
| 990 |
for p in paragraphs:
|
| 991 |
if len(points) >= max_points:
|
| 992 |
break
|
| 993 |
+
m = re.match(r'^(.+?[.!?])\s', p)
|
| 994 |
+
if m:
|
| 995 |
+
sentence = m.group(1)
|
| 996 |
+
else:
|
| 997 |
+
sentence = p[:150] + ('.' if not p.endswith('.') else '')
|
| 998 |
+
if len(sentence) < 30:
|
| 999 |
continue
|
| 1000 |
+
if any(sentence[:50] in existing for existing in points):
|
| 1001 |
+
continue
|
| 1002 |
+
points.append(sentence)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1003 |
return points
|
| 1004 |
|
| 1005 |
|
|
|
|
| 1009 |
body = await request.json()
|
| 1010 |
url = _clean(body.get("url", ""))
|
| 1011 |
context = body.get("context", "")
|
|
|
|
| 1012 |
if not url and not context:
|
| 1013 |
return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
|
| 1014 |
data = None
|
|
|
|
| 1019 |
data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
|
| 1020 |
if not data or not data.get('paragraphs'):
|
| 1021 |
return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
|
| 1022 |
+
points = _extract_key_points_rw(data['paragraphs'], max_points=6)
|
| 1023 |
if not points:
|
| 1024 |
return JSONResponse({"error": "Không tìm được ý chính"}, status_code=422)
|
| 1025 |
images = data.get('images', [])
|
|
|
|
| 1033 |
|
| 1034 |
# Auto-detect language and emotion
|
| 1035 |
lang, emotion = detect_language_and_emotion(data['title'], summary_text)
|
| 1036 |
+
voice = get_voice_for_content(data['title'], summary_text)
|
|
|
|
| 1037 |
|
| 1038 |
post = {
|
| 1039 |
"id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
|
|
|
|
| 1062 |
body = await request.json()
|
| 1063 |
url = _clean(body.get("url", ""))
|
| 1064 |
ctx = _clean(body.get("context", ""))
|
|
|
|
| 1065 |
if not url and not ctx:
|
| 1066 |
return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
|
| 1067 |
data = None
|
|
|
|
| 1094 |
except Exception:
|
| 1095 |
pass
|
| 1096 |
if not ai_text or len(ai_text) < 80:
|
| 1097 |
+
key_pts = _extract_key_points_rw(data['paragraphs'], max_points=6)
|
| 1098 |
if key_pts:
|
| 1099 |
ai_text = '\n\n'.join([f"• {p}" for p in key_pts])
|
| 1100 |
else:
|
| 1101 |
ai_text = f"Tóm tắt: {data['title']}\n\n{raw_text[:1200]}\n\nNguồn: {domain}"
|
| 1102 |
|
| 1103 |
# Build slides from key points (FIX: include slides in rewrite_share too!)
|
| 1104 |
+
points = _extract_key_points_rw(data['paragraphs'], max_points=6)
|
| 1105 |
images = data.get('images', [])
|
| 1106 |
slides = []
|
| 1107 |
for i, point in enumerate(points):
|
|
|
|
| 1112 |
|
| 1113 |
# Auto-detect language and emotion
|
| 1114 |
lang, emotion = detect_language_and_emotion(data['title'], ai_text)
|
| 1115 |
+
voice = get_voice_for_content(data['title'], ai_text)
|
|
|
|
| 1116 |
|
| 1117 |
post = {
|
| 1118 |
"id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
|
|
|
|
| 1155 |
time.sleep(90)
|
| 1156 |
threading.Thread(target=_bg,daemon=True).start()
|
| 1157 |
|
| 1158 |
+
app.mount('/static',StaticFiles(directory=STATIC_DIR),name='vnews_static')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
main.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 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
|
|
@@ -14,29 +14,38 @@ 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:
|
|
|
|
| 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):
|
| 39 |
-
|
|
|
|
|
|
|
| 40 |
|
| 41 |
# ===== VTV CHANNELS API =====
|
| 42 |
from vtv_api import router as vtv_router
|
|
@@ -50,6 +59,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 = {
|
|
@@ -136,84 +202,215 @@ 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 =====
|
| 151 |
def _scrape_xemlaibongda_page(page_path, limit=20):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
try:
|
| 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:
|
|
|
|
| 156 |
r.encoding = "utf-8"
|
| 157 |
soup = BeautifulSoup(r.text, "lxml")
|
| 158 |
-
videos = []
|
|
|
|
|
|
|
| 159 |
for a in soup.find_all("a", href=True):
|
| 160 |
href = a.get("href", "")
|
| 161 |
-
if "/video/" not in href and "/xem-lai/" not in href:
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
clean_href = href.split("?")[0].split("#")[0]
|
| 164 |
-
if clean_href in seen:
|
|
|
|
| 165 |
seen.add(clean_href)
|
|
|
|
|
|
|
| 166 |
img_src = ""
|
| 167 |
img = a.find("img")
|
| 168 |
-
if not img and a.parent:
|
|
|
|
| 169 |
if not img:
|
| 170 |
p = a.parent
|
| 171 |
for _ in range(4):
|
| 172 |
-
if p and p.find("img"):
|
| 173 |
-
|
| 174 |
-
if img:
|
| 175 |
-
img_src = (img.get("data-src", "") or img.get("src", "") or img.get("data-lazy", "") or img.get("data-original", "") or img.get("data-thumb", "") or img.get("data-image", ""))
|
| 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 attr in ["title", "aria-label"]:
|
| 192 |
val = a.get(attr, "")
|
| 193 |
-
if val and len(val) >= 5:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
if not title:
|
| 195 |
for selector in ["h3", "h2", "h4", ".title", ".video-title", "strong"]:
|
| 196 |
try:
|
| 197 |
el = a.select_one(selector)
|
| 198 |
-
if el:
|
| 199 |
-
|
| 200 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
if not title:
|
| 202 |
text = a.get_text(strip=True)
|
| 203 |
-
if text and len(text) >= 5:
|
|
|
|
|
|
|
|
|
|
| 204 |
if not title or len(title) < 3:
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
if not img_src:
|
| 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 |
-
|
| 213 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
return videos
|
| 215 |
except Exception as e:
|
| 216 |
-
print(f"[xemlaibongda] Error: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
|
| 218 |
def scrape_xemlaibongda(): return _scrape_xemlaibongda_page("", 20)
|
| 219 |
def scrape_highlights_by_league(league_key):
|
|
@@ -225,34 +422,95 @@ def scrape_all_league_highlights():
|
|
| 225 |
with ThreadPoolExecutor(8) as ex:
|
| 226 |
futs = [ex.submit(_fetch, k) for k in HL_LEAGUES]
|
| 227 |
for f in as_completed(futs, timeout=25):
|
| 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 +563,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 +608,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 +616,69 @@ 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 |
-
|
| 332 |
-
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
| 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:
|
|
|
|
|
|
|
| 350 |
def _fetch_tab():
|
| 351 |
-
if tab == "highlights":
|
| 352 |
-
|
| 353 |
-
elif tab == "
|
| 354 |
-
|
| 355 |
-
elif tab == "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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():
|
|
@@ -412,7 +735,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,19 +779,23 @@ 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:
|
|
|
|
| 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:
|
| 470 |
-
|
|
|
|
|
|
|
| 471 |
clean = href.split("?")[0].split("#")[0]
|
| 472 |
if clean in seen: continue
|
| 473 |
seen.add(clean)
|
|
@@ -476,10 +804,13 @@ def api_xlb(path: str = Query(default=""), limit: int = Query(default=20)):
|
|
| 476 |
if not img:
|
| 477 |
p = a.parent
|
| 478 |
for _ in range(5):
|
| 479 |
-
if p and p.find("img"):
|
|
|
|
|
|
|
| 480 |
p = p.parent if p else None
|
| 481 |
if img:
|
| 482 |
-
img_src = (img.get("data-src", "") or img.get("src", "") or
|
|
|
|
| 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")
|
|
@@ -494,7 +825,6 @@ def api_xlb(path: str = Query(default=""), limit: int = Query(default=20)):
|
|
| 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 |
try:
|
|
@@ -517,4 +847,5 @@ 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
|
|
|
|
| 14 |
|
| 15 |
app = FastAPI()
|
| 16 |
|
| 17 |
+
# ===== RATE LIMITING =====app = FastAPI()
|
| 18 |
+
|
| 19 |
# ===== WORLD CUP 2026 SCRAPER =====
|
| 20 |
from wc2026_scraper import get_wc2026_all, scrape_fixtures, scrape_standings, scrape_stats, scrape_wc_news
|
| 21 |
|
| 22 |
# ===== RATE LIMITING =====
|
| 23 |
+
_rate_limit_data = defaultdict(list) # {ip: [timestamp1, timestamp2, ...]}
|
| 24 |
_rate_limit_lock = threading.Lock()
|
| 25 |
+
RATE_LIMIT_MAX = 60 # Max requests per minute per IP
|
| 26 |
+
RATE_LIMIT_WINDOW = 60 # seconds
|
| 27 |
|
| 28 |
def _check_rate_limit(ip: str) -> bool:
|
| 29 |
+
"""Kiểm tra rate limit, return True nếu OK, False nếu bị limit"""
|
| 30 |
with _rate_limit_lock:
|
| 31 |
now = time.time()
|
| 32 |
+
# Xóa các request cũ
|
| 33 |
_rate_limit_data[ip] = [t for t in _rate_limit_data[ip] if now - t < RATE_LIMIT_WINDOW]
|
| 34 |
+
if len(_rate_limit_data[ip]) >= RATE_LIMIT_MAX:
|
| 35 |
+
return False
|
| 36 |
_rate_limit_data[ip].append(now)
|
| 37 |
return True
|
| 38 |
|
| 39 |
@app.middleware("http")
|
| 40 |
async def rate_limit_middleware(request: Request, call_next):
|
| 41 |
+
"""Middleware để kiểm tra rate limit"""
|
| 42 |
+
# Chỉ rate limit API endpoints
|
| 43 |
if request.url.path.startswith("/api/"):
|
| 44 |
ip = request.client.host
|
| 45 |
+
if not _check_rate_limit(ip):
|
| 46 |
+
return JSONResponse({"error": "rate limit exceeded"}, status_code=429)
|
| 47 |
+
response = await call_next(request)
|
| 48 |
+
return response
|
| 49 |
|
| 50 |
# ===== VTV CHANNELS API =====
|
| 51 |
from vtv_api import router as vtv_router
|
|
|
|
| 59 |
_cache_ttl_live = 60
|
| 60 |
_cache_ttl_yt = 1800
|
| 61 |
|
| 62 |
+
# ===== VTV NAM BO SHORTS FALLBACK =====
|
| 63 |
+
SHORTS_FALLBACK = [
|
| 64 |
+
{"id":"nqlLH6chLRo","title":"Tin nóng VTV Nam Bộ | #shorts","channel":"vtvnambo"},
|
| 65 |
+
{"id":"E7Kq0v3hG6w","title":"VTV Nam Bộ - Tin tức miền Nam | #shorts","channel":"vtvnambo"},
|
| 66 |
+
{"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"},
|
| 67 |
+
{"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"},
|
| 68 |
+
{"id":"tvPewsc2ph4","title":"Tính năng ẩn trên iPhone giúp giảm mỏi mắt","channel":"baodantri7941"},
|
| 69 |
+
{"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"},
|
| 70 |
+
{"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"},
|
| 71 |
+
{"id":"Htzvwg6iOBM","title":"Xe điện Audi S6 Sportback e-tron có gì đặc biệt?","channel":"baodantri7941"},
|
| 72 |
+
{"id":"iMdFmWvYdlo","title":"Cô gái người Nga yêu thời trang và đất nước Việt Nam","channel":"baodantri7941"},
|
| 73 |
+
{"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"},
|
| 74 |
+
{"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"},
|
| 75 |
+
{"id":"VAfgNNgZDRs","title":"Khởi tố 4 đối tượng ném bom xăng vào nhà dân ở Đồng Nai","channel":"baodantri7941"},
|
| 76 |
+
{"id":"sBH_-zGh0Xw","title":"Vì sao Times New Roman vẫn nổi tiếng sau hàng chục năm?","channel":"baodantri7941"},
|
| 77 |
+
{"id":"woKn5f2bLHM","title":"Quảng Ninh ngập sâu diện rộng sau đợt mưa lớn","channel":"baodantri7941"},
|
| 78 |
+
{"id":"bcpgRoxbLPw","title":"Giông lốc quật bay mái tôn ở TP.HCM","channel":"baodantri7941"},
|
| 79 |
+
{"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"},
|
| 80 |
+
{"id":"uTMJ49NQpyc","title":"Sau lớp mascot 40kg Câu chuyện mưu sinh của người trẻ ở TPHCM","channel":"baodantri7941"},
|
| 81 |
+
{"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"},
|
| 82 |
+
{"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"},
|
| 83 |
+
{"id":"IUOprcJyYr4","title":"Phụ nữ táo bón có phải do lười ăn rau?","channel":"baosuckhoedoisongboyte"},
|
| 84 |
+
{"id":"YY8ojFNE-AU","title":"Quái xế tự quay clip nẹt pô đánh võng đăng TikTok bị xử lý","channel":"baosuckhoedoisongboyte"},
|
| 85 |
+
{"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"},
|
| 86 |
+
{"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"},
|
| 87 |
+
{"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"},
|
| 88 |
+
{"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"},
|
| 89 |
+
{"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"},
|
| 90 |
+
{"id":"pXWt0QbAzRQ","title":"Va chạm giao thông người phụ nữ lăng mạ tài xế ô tô","channel":"baosuckhoedoisongboyte"},
|
| 91 |
+
{"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"},
|
| 92 |
+
{"id":"AxhVTQutsuo","title":"Xuất tinh sớm và những hiểu lầm thường gặp","channel":"baosuckhoedoisongboyte"},
|
| 93 |
+
{"id":"cNy6FgaNxYM","title":"Cô dâu khóc sưng mắt vì 6 chỉ vàng không cánh mày bay trong ngày cưới","channel":"baosuckhoedoisongboyte"},
|
| 94 |
+
{"id":"IDt_S6q59Ro","title":"Chở bạn gái không đội mũ bảo hiểm thanh niên đấm CSGT","channel":"baosuckhoedoisongboyte"},
|
| 95 |
+
{"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"},
|
| 96 |
+
]
|
| 97 |
+
for _v in SHORTS_FALLBACK:
|
| 98 |
+
_v.setdefault("link", "https://www.youtube.com/watch?v="+_v["id"])
|
| 99 |
+
_v.setdefault("img", "https://i.ytimg.com/vi/"+_v["id"]+"/hqdefault.jpg")
|
| 100 |
+
_v.setdefault("source", "yt")
|
| 101 |
+
|
| 102 |
+
SHORT_STATS_FILE = "/data/short_stats.json" if os.path.isdir("/data") else "/app/short_stats.json"
|
| 103 |
+
_short_lock = threading.Lock()
|
| 104 |
+
def _load_short_db():
|
| 105 |
+
try:
|
| 106 |
+
if os.path.exists(SHORT_STATS_FILE):
|
| 107 |
+
with open(SHORT_STATS_FILE,"r",encoding="utf-8") as f: return json.load(f)
|
| 108 |
+
except: pass
|
| 109 |
+
return {}
|
| 110 |
+
def _save_short_db(db):
|
| 111 |
+
try:
|
| 112 |
+
os.makedirs(os.path.dirname(SHORT_STATS_FILE), exist_ok=True)
|
| 113 |
+
tmp = SHORT_STATS_FILE + ".tmp"
|
| 114 |
+
with open(tmp,"w",encoding="utf-8") as f: json.dump(db, f, ensure_ascii=False)
|
| 115 |
+
os.replace(tmp, SHORT_STATS_FILE)
|
| 116 |
+
except: pass
|
| 117 |
+
def _short_default(): return {"views":0,"likes":0,"shares":0,"comments":[]}
|
| 118 |
+
|
| 119 |
PRIORITY_LEAGUES = ["Ngoại Hạng Anh","FA Cup","Champions League","LaLiga","Copa del Rey","Serie A","Bundesliga","Ligue 1","V-League"]
|
| 120 |
LEAGUE_IDS = {"nha":27110,"laliga":27233,"seriea":27044,"bundesliga":26891,"ligue1":27212}
|
| 121 |
HL_LEAGUES = {
|
|
|
|
| 202 |
@app.get("/api/proxy/img")
|
| 203 |
def proxy_img(url: str = Query(...)):
|
| 204 |
try:
|
| 205 |
+
r = requests.get(url, headers={**HEADERS, "Referer": "https://dantri.com.vn/"}, timeout=10)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
if r.status_code != 200: return Response(status_code=502)
|
| 207 |
+
ct = r.headers.get("Content-Type", "image/jpeg")
|
| 208 |
+
return Response(content=r.content, media_type=ct, headers={"Cache-Control": "public, max-age=86400", "Access-Control-Allow-Origin": "*"})
|
| 209 |
except: return Response(status_code=502)
|
| 210 |
|
| 211 |
# ===== XEMLAIBONGDA HIGHLIGHTS =====
|
| 212 |
def _scrape_xemlaibongda_page(page_path, limit=20):
|
| 213 |
+
"""
|
| 214 |
+
Scrape video từ xemlaibongda.top - Simple & Reliable
|
| 215 |
+
Dùng logic cũ đã test, không fetch từng trang (tránh timeout)
|
| 216 |
+
"""
|
| 217 |
try:
|
| 218 |
url = f"https://xemlaibongda.top/{page_path}" if page_path else "https://xemlaibongda.top/"
|
| 219 |
r = requests.get(url, headers=HEADERS, timeout=15)
|
| 220 |
+
if r.status_code != 200:
|
| 221 |
+
return []
|
| 222 |
r.encoding = "utf-8"
|
| 223 |
soup = BeautifulSoup(r.text, "lxml")
|
| 224 |
+
videos = []
|
| 225 |
+
seen = set()
|
| 226 |
+
|
| 227 |
for a in soup.find_all("a", href=True):
|
| 228 |
href = a.get("href", "")
|
| 229 |
+
if "/video/" not in href and "/xem-lai/" not in href:
|
| 230 |
+
continue
|
| 231 |
+
|
| 232 |
+
if not href.startswith("http"):
|
| 233 |
+
href = "https://xemlaibongda.top" + href
|
| 234 |
+
|
| 235 |
+
# Bỏ query params
|
| 236 |
clean_href = href.split("?")[0].split("#")[0]
|
| 237 |
+
if clean_href in seen:
|
| 238 |
+
continue
|
| 239 |
seen.add(clean_href)
|
| 240 |
+
|
| 241 |
+
# ===== Lấy THUMBNAIL =====
|
| 242 |
img_src = ""
|
| 243 |
img = a.find("img")
|
| 244 |
+
if not img and a.parent:
|
| 245 |
+
img = a.parent.find("img")
|
| 246 |
if not img:
|
| 247 |
p = a.parent
|
| 248 |
for _ in range(4):
|
| 249 |
+
if p and p.find("img"):
|
| 250 |
+
img = p.find("img")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
break
|
| 252 |
p = p.parent if p else None
|
| 253 |
+
|
| 254 |
+
if img:
|
| 255 |
+
img_src = (img.get("data-src", "") or img.get("src", "") or
|
| 256 |
+
img.get("data-lazy", "") or img.get("data-original", ""))
|
| 257 |
+
if img_src.startswith("//"):
|
| 258 |
+
img_src = "https:" + img_src
|
| 259 |
+
elif img_src.startswith("/"):
|
| 260 |
+
img_src = "https://xemlaibongda.top" + img_src
|
| 261 |
+
|
| 262 |
+
# ===== Lấy TITLE =====
|
| 263 |
title = ""
|
| 264 |
+
# Thử attribute
|
| 265 |
for attr in ["title", "aria-label"]:
|
| 266 |
val = a.get(attr, "")
|
| 267 |
+
if val and len(val) >= 5:
|
| 268 |
+
title = val
|
| 269 |
+
break
|
| 270 |
+
|
| 271 |
+
# Thử các selector
|
| 272 |
if not title:
|
| 273 |
for selector in ["h3", "h2", "h4", ".title", ".video-title", "strong"]:
|
| 274 |
try:
|
| 275 |
el = a.select_one(selector)
|
| 276 |
+
if el:
|
| 277 |
+
t = el.get_text(strip=True)
|
| 278 |
+
if len(t) >= 5:
|
| 279 |
+
title = t
|
| 280 |
+
break
|
| 281 |
+
except:
|
| 282 |
+
pass
|
| 283 |
+
|
| 284 |
+
# Thử text content
|
| 285 |
if not title:
|
| 286 |
text = a.get_text(strip=True)
|
| 287 |
+
if text and len(text) >= 5:
|
| 288 |
+
title = text[:100]
|
| 289 |
+
|
| 290 |
+
# Fallback: tạo title từ slug
|
| 291 |
if not title or len(title) < 3:
|
| 292 |
slug = clean_href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
|
| 293 |
title = slug.replace("-", " ").replace("_", " ").title()
|
| 294 |
title = re.sub(r'\d{4}-\d{2}-\d{2}', '', title).strip()
|
| 295 |
+
|
| 296 |
+
if not title or len(title) < 3:
|
| 297 |
+
continue
|
| 298 |
+
|
| 299 |
+
# Fallback thumbnail từ slug
|
| 300 |
if not img_src:
|
| 301 |
slug = clean_href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
|
| 302 |
img_src = f"https://xemlaibongda.top/uploads/thumb/{slug}.jpg"
|
| 303 |
+
|
| 304 |
+
videos.append({
|
| 305 |
+
"title": title[:100],
|
| 306 |
+
"link": clean_href,
|
| 307 |
+
"img": img_src,
|
| 308 |
+
"source": "xemlaibongda"
|
| 309 |
+
})
|
| 310 |
+
|
| 311 |
+
if len(videos) >= limit:
|
| 312 |
+
break
|
| 313 |
+
|
| 314 |
return videos
|
| 315 |
except Exception as e:
|
| 316 |
+
print(f"[xemlaibongda] Error: {e}")
|
| 317 |
+
return []
|
| 318 |
+
|
| 319 |
+
def _extract_img_src(a_tag):
|
| 320 |
+
"""Extract image URL từ thẻ <a> và parent elements"""
|
| 321 |
+
img = a_tag.find("img")
|
| 322 |
+
if not img and a_tag.parent:
|
| 323 |
+
img = a_tag.parent.find("img")
|
| 324 |
+
if not img:
|
| 325 |
+
p = a_tag.parent
|
| 326 |
+
for _ in range(5): # Tìm sâu hơn
|
| 327 |
+
if p and p.find("img"):
|
| 328 |
+
img = p.find("img")
|
| 329 |
+
break
|
| 330 |
+
p = p.parent if p else None
|
| 331 |
+
|
| 332 |
+
if not img:
|
| 333 |
+
return ""
|
| 334 |
+
|
| 335 |
+
# Thử tất cả các attribute có thể chứa img URL
|
| 336 |
+
attrs = ["data-src", "src", "data-lazy", "data-original", "data-srcset", "data-thumb", "data-image"]
|
| 337 |
+
for attr in attrs:
|
| 338 |
+
val = img.get(attr, "")
|
| 339 |
+
if val:
|
| 340 |
+
if attr == "data-srcset":
|
| 341 |
+
val = val.split(",")[0].strip().split(" ")[0]
|
| 342 |
+
break
|
| 343 |
+
else:
|
| 344 |
+
val = ""
|
| 345 |
+
|
| 346 |
+
# Thử background-image từ style
|
| 347 |
+
if not val:
|
| 348 |
+
style = img.get("style", "") or img.get("data-bg", "")
|
| 349 |
+
bg_match = re.search(r'url\(["\']?(.*?)["\']?\)', style)
|
| 350 |
+
if bg_match:
|
| 351 |
+
val = bg_match.group(1)
|
| 352 |
+
|
| 353 |
+
# Normalize URL
|
| 354 |
+
if val.startswith("//"):
|
| 355 |
+
val = "https:" + val
|
| 356 |
+
elif val.startswith("/"):
|
| 357 |
+
val = "https://xemlaibongda.top" + val
|
| 358 |
+
|
| 359 |
+
return val
|
| 360 |
+
|
| 361 |
+
def _extract_title(a_tag, href):
|
| 362 |
+
"""Extract title từ thẻ <a> và child/parent elements"""
|
| 363 |
+
title = ""
|
| 364 |
+
|
| 365 |
+
# 1. Thử các selector phổ biến cho title
|
| 366 |
+
title_selectors = [
|
| 367 |
+
"h3", "h2", "h4", "h5",
|
| 368 |
+
".title", ".post-title", ".entry-title", ".video-title",
|
| 369 |
+
".card-title", ".item-title", ".news-title",
|
| 370 |
+
"span.title", "strong", "b",
|
| 371 |
+
".name", ".caption"
|
| 372 |
+
]
|
| 373 |
+
for tag in title_selectors:
|
| 374 |
+
try:
|
| 375 |
+
t = a_tag.select_one(tag) if hasattr(a_tag, 'select_one') else None
|
| 376 |
+
if t:
|
| 377 |
+
title = t.get_text(" ", strip=True)
|
| 378 |
+
if len(title) >= 3:
|
| 379 |
+
return title
|
| 380 |
+
except:
|
| 381 |
+
pass
|
| 382 |
+
|
| 383 |
+
# 2. Thử attribute của <a>
|
| 384 |
+
for attr in ["title", "aria-label", "data-title"]:
|
| 385 |
+
val = a_tag.get(attr, "")
|
| 386 |
+
if val and len(val) >= 3:
|
| 387 |
+
return val
|
| 388 |
+
|
| 389 |
+
# 3. Thử alt text của img
|
| 390 |
+
img = a_tag.find("img")
|
| 391 |
+
if img:
|
| 392 |
+
alt = img.get("alt", "")
|
| 393 |
+
if alt and len(alt) >= 3:
|
| 394 |
+
return alt
|
| 395 |
+
|
| 396 |
+
# 4. Thử text content của <a> (loại bỏ quá dài)
|
| 397 |
+
text = a_tag.get_text(" ", strip=True)
|
| 398 |
+
if text and len(text) >= 3:
|
| 399 |
+
# Lấy dòng đầu tiên nếu có nhiều dòng
|
| 400 |
+
lines = [l.strip() for l in text.split("\n") if l.strip()]
|
| 401 |
+
if lines:
|
| 402 |
+
first_line = lines[0]
|
| 403 |
+
if len(first_line) >= 3:
|
| 404 |
+
return first_line[:100]
|
| 405 |
+
|
| 406 |
+
# 5. Fallback: tạo title từ slug
|
| 407 |
+
slug = href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
|
| 408 |
+
title = slug.replace("-", " ").replace("_", " ")
|
| 409 |
+
title = re.sub(r'\d{4}-\d{2}-\d{2}', '', title).strip()
|
| 410 |
+
if title:
|
| 411 |
+
return title.title()
|
| 412 |
+
|
| 413 |
+
return ""
|
| 414 |
|
| 415 |
def scrape_xemlaibongda(): return _scrape_xemlaibongda_page("", 20)
|
| 416 |
def scrape_highlights_by_league(league_key):
|
|
|
|
| 422 |
with ThreadPoolExecutor(8) as ex:
|
| 423 |
futs = [ex.submit(_fetch, k) for k in HL_LEAGUES]
|
| 424 |
for f in as_completed(futs, timeout=25):
|
| 425 |
+
try:
|
| 426 |
+
key, vids = f.result()
|
| 427 |
+
if vids: results[key] = vids
|
| 428 |
+
except: pass
|
| 429 |
return results
|
| 430 |
|
| 431 |
def extract_xemlaibongda_video(url):
|
| 432 |
try:
|
| 433 |
r=requests.get(url, headers=HEADERS, timeout=15)
|
| 434 |
if r.status_code!=200: return None
|
| 435 |
+
r.encoding="utf-8"; soup=BeautifulSoup(r.text,"lxml"); video=soup.find("video")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 436 |
if video:
|
| 437 |
src=video.get("src",""); poster=video.get("poster","")
|
| 438 |
if not src:
|
| 439 |
source=video.find("source")
|
| 440 |
if source: src=source.get("src","")
|
|
|
|
| 441 |
if src: return{"src":src,"poster":poster,"type":"hls" if".m3u8" in src else"video"}
|
| 442 |
m3u8s=re.findall(r'(https?://[^\s"\'<>]+\.m3u8)',r.text)
|
| 443 |
+
if m3u8s:
|
| 444 |
+
og=soup.find("meta",property="og:image"); poster=og.get("content","") if og else ""
|
| 445 |
+
return{"src":m3u8s[0],"poster":poster,"type":"hls"}
|
| 446 |
return None
|
| 447 |
except: return None
|
| 448 |
|
| 449 |
+
# ===== YOUTUBE SHORTS SCRAPING =====
|
| 450 |
+
def _yt_channel_shorts_requests(channel, count=15):
|
| 451 |
+
try:
|
| 452 |
+
url=f"https://www.youtube.com/@{channel}/shorts"
|
| 453 |
+
r=requests.get(url, headers={**HEADERS,"Accept-Language":"vi,en;q=0.8"}, timeout=15)
|
| 454 |
+
if r.status_code!=200: return []
|
| 455 |
+
html=r.text; ids=[]; items=[]
|
| 456 |
+
for m in re.finditer(r'"videoId":"([A-Za-z0-9_-]{11})"',html):
|
| 457 |
+
vid=m.group(1)
|
| 458 |
+
if vid in ids: continue
|
| 459 |
+
ids.append(vid)
|
| 460 |
+
snip=html[max(0,m.start()-900):m.start()+1600]
|
| 461 |
+
title=""
|
| 462 |
+
mt=re.search(r'"title":\{"runs":\[\{"text":"([^"]+)"',snip)
|
| 463 |
+
if not mt: mt=re.search(r'"accessibilityText":"([^"]+)"',snip)
|
| 464 |
+
if mt: title=html_lib.unescape(mt.group(1)).replace('\n',' ').strip()
|
| 465 |
+
if not title: title="YouTube Short"
|
| 466 |
+
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})
|
| 467 |
+
if len(items)>=count: break
|
| 468 |
+
return items
|
| 469 |
+
except: return []
|
| 470 |
+
|
| 471 |
+
def scrape_shorts():
|
| 472 |
+
vids=[]
|
| 473 |
+
with ThreadPoolExecutor(3) as ex:
|
| 474 |
+
futs=[ex.submit(_yt_channel_shorts_requests,ch,24) for ch in ["baodantri7941","baosuckhoedoisongboyte","vtvnambo"]]
|
| 475 |
+
for f in as_completed(futs):
|
| 476 |
+
try:
|
| 477 |
+
r=f.result()
|
| 478 |
+
if r: vids.extend(r)
|
| 479 |
+
except: pass
|
| 480 |
+
merged=[]; seen=set()
|
| 481 |
+
for v in vids:
|
| 482 |
+
vid=v.get("id")
|
| 483 |
+
if not vid or vid in seen: continue
|
| 484 |
+
seen.add(vid); merged.append(v)
|
| 485 |
+
for v in SHORTS_FALLBACK:
|
| 486 |
+
vid=v.get("id")
|
| 487 |
+
if not vid or vid in seen: continue
|
| 488 |
+
seen.add(vid); merged.append(v)
|
| 489 |
+
return merged[:60]
|
| 490 |
+
|
| 491 |
+
# ===== VTV NAM BO & WC SHORTS - using yt-dlp =====
|
| 492 |
+
from yt_scraper import get_vtvnambo_shorts, get_wc_related_shorts
|
| 493 |
+
|
| 494 |
+
@app.get("/api/shorts/vtvnamo")
|
| 495 |
+
def api_shorts_vtvnamo(count: int = Query(default=50, le=100)):
|
| 496 |
+
items = get_vtvnambo_shorts(count)
|
| 497 |
+
if not items:
|
| 498 |
+
items = [v for v in SHORTS_FALLBACK if v.get("channel") == "vtvnambo"]
|
| 499 |
+
nql = [v for v in items if v.get("id") == "nqlLH6chLRo"]
|
| 500 |
+
rest = [v for v in items if v.get("id") != "nqlLH6chLRo"]
|
| 501 |
+
items = nql + rest
|
| 502 |
+
return JSONResponse(items)
|
| 503 |
+
|
| 504 |
+
@app.get("/api/shorts/wc")
|
| 505 |
+
def api_shorts_wc(count: int = Query(default=50, le=100)):
|
| 506 |
+
items = get_wc_related_shorts(count)
|
| 507 |
+
if not items:
|
| 508 |
+
items = [v for v in SHORTS_FALLBACK if v.get("channel") == "vtvnambo"]
|
| 509 |
+
nql = [v for v in items if v.get("id") == "nqlLH6chLRo"]
|
| 510 |
+
rest = [v for v in items if v.get("id") != "nqlLH6chLRo"]
|
| 511 |
+
items = nql + rest
|
| 512 |
+
return JSONResponse(items)
|
| 513 |
+
|
| 514 |
# ===== LIVESCORE =====
|
| 515 |
@app.get("/api/livescore/live")
|
| 516 |
def api_livescore_live(): return JSONResponse({"html":_cached("ls_live",lambda:fetch_bongda_api("/api/fixtures/live"),ttl=_cache_ttl_live)})
|
|
|
|
| 563 |
return None
|
| 564 |
return JSONResponse(_cached("ls_featured",_f,ttl=30))
|
| 565 |
|
| 566 |
+
@app.get("/api/shorts")
|
| 567 |
+
def api_shorts(channel: str = Query(default="")):
|
| 568 |
+
if channel == "vtvnambo": return api_shorts_vtvnamo()
|
| 569 |
+
if channel == "wc": return api_shorts_wc()
|
| 570 |
+
return JSONResponse(_cached("yt_shorts_v3",scrape_shorts,ttl=_cache_ttl_yt))
|
| 571 |
+
|
| 572 |
+
@app.get("/api/short-stats")
|
| 573 |
+
def api_short_stats(ids:str=Query(default="")):
|
| 574 |
+
arr=[x for x in ids.split(",") if x]
|
| 575 |
+
with _short_lock:
|
| 576 |
+
db=_load_short_db();out={}
|
| 577 |
+
for vid in arr:
|
| 578 |
+
st=db.get(vid) or _short_default()
|
| 579 |
+
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]}
|
| 580 |
+
return JSONResponse({"stats":out})
|
| 581 |
+
|
| 582 |
+
@app.post("/api/short-action")
|
| 583 |
+
async def api_short_action(request:Request):
|
| 584 |
+
try: body=await request.json()
|
| 585 |
+
except: body={}
|
| 586 |
+
vid=str(body.get("id","")).strip(); action=str(body.get("action","")).strip(); txt=str(body.get("text","")).strip()
|
| 587 |
+
if not vid: return JSONResponse({"error":"missing id"},status_code=400)
|
| 588 |
+
with _short_lock:
|
| 589 |
+
db=_load_short_db(); st=db.get(vid) or _short_default()
|
| 590 |
+
if action=="view": st["views"]=int(st.get("views",0))+1
|
| 591 |
+
elif action=="like": st["likes"]=int(st.get("likes",0))+1
|
| 592 |
+
elif action=="share": st["shares"]=int(st.get("shares",0))+1
|
| 593 |
+
elif action=="comment" and txt:
|
| 594 |
+
comments=st.get("comments",[])
|
| 595 |
+
comments.insert(0,{"text":txt[:180],"ts":int(time.time())})
|
| 596 |
+
st["comments"]=comments[:80]
|
| 597 |
+
st["updated"]=int(time.time()); db[vid]=st; _save_short_db(db)
|
| 598 |
+
out={"views":int(st.get("views",0)),"likes":int(st.get("likes",0)),"shares":int(st.get("shares",0)),"comments":st.get("comments",[])[:80]}
|
| 599 |
+
return JSONResponse({"stats":out})
|
| 600 |
+
|
| 601 |
@app.get("/api/highlights")
|
| 602 |
def api_highlights(): return JSONResponse(_cached("xemlaibongda_hl",scrape_xemlaibongda,ttl=_cache_ttl))
|
| 603 |
@app.get("/api/highlights/leagues")
|
|
|
|
| 608 |
return JSONResponse(_cached(f"hl_{league}",lambda:scrape_highlights_by_league(league),ttl=_cache_ttl))
|
| 609 |
|
| 610 |
@app.get("/api/video_url")
|
| 611 |
+
def api_video_url(url:str=Query(...)):
|
| 612 |
if "youtube.com" in url or "youtu.be" in url:
|
| 613 |
m=re.search(r'(?:v=|shorts/|youtu\.be/)([a-zA-Z0-9_-]{11})',url)
|
| 614 |
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"})
|
|
|
|
| 616 |
v=extract_xemlaibongda_video(url)
|
| 617 |
if v:
|
| 618 |
if v["type"]=="hls": v["src"]="/api/proxy/m3u8?url="+quote(v["src"],safe="")
|
|
|
|
| 619 |
return JSONResponse(v)
|
| 620 |
return JSONResponse({"error":"not found"})
|
| 621 |
|
| 622 |
# ===== WORLD CUP 2026 API =====
|
| 623 |
+
# Rate limiting cho WC API
|
| 624 |
+
_wc_request_times = []
|
| 625 |
+
_wc_rate_limit_lock = threading.Lock()
|
| 626 |
+
_WC_RATE_LIMIT = 10 # Max 10 requests per minute
|
| 627 |
+
|
| 628 |
def _wc_rate_limit():
|
| 629 |
+
"""Kiểm tra rate limit cho WC API"""
|
| 630 |
global _wc_request_times
|
| 631 |
with _wc_rate_limit_lock:
|
| 632 |
now = time.time()
|
| 633 |
+
# Xóa các request cũ hơn 60 giây
|
| 634 |
_wc_request_times = [t for t in _wc_request_times if now - t < 60]
|
| 635 |
+
if len(_wc_request_times) >= _WC_RATE_LIMIT:
|
| 636 |
+
return False
|
| 637 |
_wc_request_times.append(now)
|
| 638 |
return True
|
| 639 |
|
| 640 |
@app.get("/api/wc2026")
|
| 641 |
def api_wc2026():
|
| 642 |
+
"""Trả về tất cả dữ liệu World Cup 2026"""
|
| 643 |
return JSONResponse(_cached("wc2026", get_wc2026_all, ttl=_cache_ttl))
|
| 644 |
|
| 645 |
@app.get("/api/wc2026/{tab}")
|
| 646 |
def api_wc2026_tab(tab: str):
|
| 647 |
+
"""Trả về từng tab của World Cup"""
|
| 648 |
valid_tabs = ["news", "fixtures", "standings", "stats", "highlights"]
|
| 649 |
+
if tab not in valid_tabs:
|
| 650 |
+
return JSONResponse({"error": "invalid tab"}, status_code=400)
|
| 651 |
+
|
| 652 |
def _fetch_tab():
|
| 653 |
+
if tab == "highlights":
|
| 654 |
+
return scrape_highlights_by_league("world-cup")
|
| 655 |
+
elif tab == "news":
|
| 656 |
+
return scrape_wc_news()
|
| 657 |
+
elif tab == "fixtures":
|
| 658 |
+
return scrape_fixtures()
|
| 659 |
+
elif tab == "standings":
|
| 660 |
+
return scrape_standings()
|
| 661 |
+
elif tab == "stats":
|
| 662 |
+
return scrape_stats()
|
| 663 |
return []
|
| 664 |
+
|
| 665 |
return JSONResponse(_cached(f"wc2026_{tab}", _fetch_tab, ttl=_cache_ttl))
|
| 666 |
|
| 667 |
+
# Note: WC functions (scrape_wc_news, scrape_fixtures, scrape_stats, scrape_standings)
|
| 668 |
+
# are imported from wc2026_scraper.py at the top of this file
|
| 669 |
+
|
| 670 |
+
@app.get("/api/video_url")
|
| 671 |
+
def api_video_url(url:str=Query(...)):
|
| 672 |
+
if "youtube.com" in url or "youtu.be" in url:
|
| 673 |
+
m=re.search(r'(?:v=|shorts/|youtu\.be/)([a-zA-Z0-9_-]{11})',url)
|
| 674 |
+
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"})
|
| 675 |
+
if "xemlaibongda.top" in url:
|
| 676 |
+
v=extract_xemlaibongda_video(url)
|
| 677 |
+
if v:
|
| 678 |
+
if v["type"]=="hls": v["src"]="/api/proxy/m3u8?url="+quote(v["src"],safe="")
|
| 679 |
+
return JSONResponse(v)
|
| 680 |
+
return JSONResponse({"error":"not found"})
|
| 681 |
+
|
| 682 |
@app.get("/api/bdp_videos")
|
| 683 |
def api_bdp_videos():
|
| 684 |
def _f():
|
|
|
|
| 735 |
for img in container.find_all("img"):
|
| 736 |
s=img.get("data-src","") or img.get("src","")
|
| 737 |
if s and "mediacdn" in s and "avatar" not in s and "logo" not in s: img_src=s; break
|
| 738 |
+
if img_src: break
|
| 739 |
+
container=container.parent
|
| 740 |
seen.add(href)
|
| 741 |
if not img_src:
|
| 742 |
try:
|
|
|
|
| 779 |
for k,(u,n) in VNE_CATS.items(): cats.append({"id":k,"name":n,"source":"vne"})
|
| 780 |
return JSONResponse(cats)
|
| 781 |
|
| 782 |
+
|
| 783 |
@app.get("/api/proxy/xlb")
|
| 784 |
+
def api_xlb(path: str = Query(default="", description="Path after xemlaibongda.top"), limit: int = Query(default=20)):
|
| 785 |
try:
|
| 786 |
url = f"https://xemlaibongda.top/{path}" if path else "https://xemlaibongda.top/"
|
| 787 |
r = requests.get(url, headers=HEADERS, timeout=15)
|
| 788 |
+
if r.status_code != 200:
|
| 789 |
+
return JSONResponse({"videos": []})
|
| 790 |
r.encoding = "utf-8"
|
| 791 |
soup = BeautifulSoup(r.text, "lxml")
|
| 792 |
videos, seen = [], set()
|
| 793 |
for a in soup.find_all("a", href=True):
|
| 794 |
href = a.get("href", "")
|
| 795 |
+
if "/video/" not in href and "/xem-lai/" not in href:
|
| 796 |
+
continue
|
| 797 |
+
if not href.startswith("http"):
|
| 798 |
+
href = "https://xemlaibongda.top" + href
|
| 799 |
clean = href.split("?")[0].split("#")[0]
|
| 800 |
if clean in seen: continue
|
| 801 |
seen.add(clean)
|
|
|
|
| 804 |
if not img:
|
| 805 |
p = a.parent
|
| 806 |
for _ in range(5):
|
| 807 |
+
if p and p.find("img"):
|
| 808 |
+
img = p.find("img")
|
| 809 |
+
break
|
| 810 |
p = p.parent if p else None
|
| 811 |
if img:
|
| 812 |
+
img_src = (img.get("data-src", "") or img.get("src", "") or
|
| 813 |
+
img.get("data-lazy", "") or img.get("data-original", ""))
|
| 814 |
if img_src.startswith("//"): img_src = "https:" + img_src
|
| 815 |
elif img_src.startswith("/"): img_src = "https://xemlaibongda.top" + img_src
|
| 816 |
title = a.find("h3")
|
|
|
|
| 825 |
return JSONResponse({"videos": videos})
|
| 826 |
except Exception as e:
|
| 827 |
return JSONResponse({"videos": [], "error": str(e)})
|
|
|
|
| 828 |
@app.get("/api/article")
|
| 829 |
def api_article(url:str=Query(...)):
|
| 830 |
try:
|
|
|
|
| 847 |
|
| 848 |
@app.get("/", response_class=HTMLResponse)
|
| 849 |
async def root():
|
| 850 |
+
return HTMLResponse("<h1>VNEWS</h1><p>Running</p>")
|
| 851 |
+
# v14 rebuild 2026-06-18T12:21:41.503230
|
rewrite_fix_v2.js
CHANGED
|
@@ -1,2 +1,240 @@
|
|
| 1 |
-
//
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Fix rewriteArticle - call correct endpoint for VNEWS
|
| 2 |
+
// Uses /api/rewrite_share which now returns {post, slides}
|
| 3 |
+
// Includes full multilingual voice + emotion UI
|
| 4 |
+
|
| 5 |
+
(function(){
|
| 6 |
+
// Full voice list with languages and emotions
|
| 7 |
+
const VOICE_OPTIONS = [
|
| 8 |
+
{id:'hoaimy', label:'🎙️ Hoài My (VI)', lang:'vi'},
|
| 9 |
+
{id:'namminh', label:'🎙️ Nam Minh (VI)', lang:'vi'},
|
| 10 |
+
{id:'andrew', label:'🎙️ Andrew (Multilingual)', lang:'en'},
|
| 11 |
+
{id:'jenny', label:'🎙️ Jenny (EN)', lang:'en'},
|
| 12 |
+
{id:'thalita', label:'🎙️ Thalita (PT)', lang:'pt'},
|
| 13 |
+
{id:'pt_francisco', label:'🎙️ Francisco (PT)', lang:'pt'},
|
| 14 |
+
{id:'ela', label:'🎙️ Ela (ES)', lang:'es'},
|
| 15 |
+
{id:'es_carlos', label:'🎙️ Carlos (ES)', lang:'es'},
|
| 16 |
+
{id:'denise', label:'🎙️ Denise (FR)', lang:'fr'},
|
| 17 |
+
{id:'katja', label:'🎙️ Katja (DE)', lang:'de'},
|
| 18 |
+
{id:'nanami', label:'🎙️ Nanami (JA)', lang:'ja'},
|
| 19 |
+
{id:'sunhee', label:'🎙️ SunHee (KO)', lang:'ko'},
|
| 20 |
+
{id:'xiaochen', label:'🎙️ XiaoChen (ZH)', lang:'zh'},
|
| 21 |
+
];
|
| 22 |
+
|
| 23 |
+
const EMOTION_OPTIONS = [
|
| 24 |
+
{id:'neutral', label:'😐 Trung tính', emoji:'😐'},
|
| 25 |
+
{id:'happy', label:'😊 Vui vẻ', emoji:'😊'},
|
| 26 |
+
{id='excited', label:'🔥 Hào hứng', emoji:'🔥'},
|
| 27 |
+
{id:'sad', label:'😢 Buồn', emoji:'😢'},
|
| 28 |
+
{id:'humorous', label:'😂 Hài hước', emoji:'😂'},
|
| 29 |
+
{id:'serious', label:'⚠️ Nghiêm túc', emoji:'⚠️'},
|
| 30 |
+
{id:'urgent', label:'🚨 Khẩn cấp', emoji:'🚨'},
|
| 31 |
+
{id:'warm', label:'💖 Ấm áp', emoji:'💖'},
|
| 32 |
+
];
|
| 33 |
+
|
| 34 |
+
window.rewriteArticle = async function(){
|
| 35 |
+
var url = window._currentArticle && window._currentArticle.url;
|
| 36 |
+
if(!url) return;
|
| 37 |
+
toast('⏳ Đang tạo bài đăng Tường AI...');
|
| 38 |
+
try {
|
| 39 |
+
var r = await fetch('/api/rewrite_share', {
|
| 40 |
+
method: 'POST',
|
| 41 |
+
headers: {'Content-Type': 'application/json'},
|
| 42 |
+
body: JSON.stringify({url: url, context: document.querySelector('.article-view') ? document.querySelector('.article-view').innerText.slice(0,14000) : ''})
|
| 43 |
+
});
|
| 44 |
+
var j = await r.json();
|
| 45 |
+
if (!r.ok || j.error) throw new Error(j.error);
|
| 46 |
+
toast('✅ Đã đăng Tường AI!');
|
| 47 |
+
if (j.post) prependWallPost(j.post);
|
| 48 |
+
if (j.post && typeof goToWallPost === 'function') goToWallPost(j.post.id);
|
| 49 |
+
// Show slides if available
|
| 50 |
+
if (j.slides && j.slides.length) {
|
| 51 |
+
setTimeout(function(){ showSlidePreview(j.slides, j.post ? j.post.title : ''); }, 500);
|
| 52 |
+
}
|
| 53 |
+
// Show voice + emotion selector
|
| 54 |
+
if (j.post && !j.post.video) {
|
| 55 |
+
setTimeout(function(){ showVoiceEmotionSelector(j.post.id, j.post.title, j.post.text); }, 1000);
|
| 56 |
+
}
|
| 57 |
+
} catch(e) {
|
| 58 |
+
toast('❌ ' + e.message);
|
| 59 |
+
}
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
// Full voice + emotion selector popup
|
| 63 |
+
window.showVoiceEmotionSelector = function(postId, title, text) {
|
| 64 |
+
var overlay = document.createElement('div');
|
| 65 |
+
overlay.id = 'voice-emotion-selector';
|
| 66 |
+
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';
|
| 67 |
+
|
| 68 |
+
var box = document.createElement('div');
|
| 69 |
+
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';
|
| 70 |
+
|
| 71 |
+
var h = '<h3 style="color:#5cb87a;margin-bottom:12px;font-size:16px">🎬 Tạo Short AI</h3>';
|
| 72 |
+
|
| 73 |
+
// Voice selection
|
| 74 |
+
h += '<div style="margin-bottom:12px"><div style="color:#aaa;font-size:11px;margin-bottom:6px">🎙️ Chọn giọng đọc:</div>';
|
| 75 |
+
VOICE_OPTIONS.forEach(function(v){
|
| 76 |
+
h += '<button class="ve-voice-btn" data-voice="'+v.id+'" style="display:inline-block;background:#222;border:1px solid #333;color:#ccc;padding:5px 10px;border-radius:12px;font-size:10px;margin:2px;cursor:pointer" onmouseover="this.style.background=\'#333\'" onmouseout="this.style.background=\'#222\'">'+esc(v.label)+'</button>';
|
| 77 |
+
});
|
| 78 |
+
h += '</div>';
|
| 79 |
+
|
| 80 |
+
// Emotion selection
|
| 81 |
+
h += '<div style="margin-bottom:12px"><div style="color:#aaa;font-size:11px;margin-bottom:6px">😊 Chọn cảm xúc:</div>';
|
| 82 |
+
EMOTION_OPTIONS.forEach(function(e){
|
| 83 |
+
h += '<button class="ve-emotion-btn" data-emotion="'+e.id+'" style="display:inline-block;background:#222;border:1px solid #333;color:#ccc;padding:5px 10px;border-radius:12px;font-size:10px;margin:2px;cursor:pointer" onmouseover="this.style.background=\'#333\'" onmouseout="this.style.background=\'#222\'">'+esc(e.label)+'</button>';
|
| 84 |
+
});
|
| 85 |
+
h += '</div>';
|
| 86 |
+
|
| 87 |
+
// Speed
|
| 88 |
+
h += '<div style="margin-bottom:12px"><div style="color:#aaa;font-size:11px;margin-bottom:6px">⚡ Tốc độ:</div>';
|
| 89 |
+
h += '<select id="ve-speed" style="background:#222;border:1px solid #333;color:#ccc;padding:6px 12px;border-radius:10px;font-size:11px">';
|
| 90 |
+
h += '<option value="0.85">0.85x — Chậm</option>';
|
| 91 |
+
h += '<option value="1.0">1.0x — Bình thường</option>';
|
| 92 |
+
h += '<option value="1.2" selected>1.2x — Nhanh</option>';
|
| 93 |
+
h += '<option value="1.35">1.35x — Rất nhanh</option>';
|
| 94 |
+
h += '</select></div>';
|
| 95 |
+
|
| 96 |
+
// Actions
|
| 97 |
+
h += '<div style="display:flex;gap:8px;margin-top:12px">';
|
| 98 |
+
h += '<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>';
|
| 99 |
+
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>';
|
| 100 |
+
h += '</div>';
|
| 101 |
+
|
| 102 |
+
// Status
|
| 103 |
+
h += '<div id="ve-status" style="color:#888;font-size:10px;margin-top:8px;display:none"></div>';
|
| 104 |
+
|
| 105 |
+
box.innerHTML = h;
|
| 106 |
+
overlay.appendChild(box);
|
| 107 |
+
document.body.appendChild(overlay);
|
| 108 |
+
|
| 109 |
+
var selectedVoice = 'hoaimy';
|
| 110 |
+
var selectedEmotion = 'neutral';
|
| 111 |
+
|
| 112 |
+
// Voice click handlers
|
| 113 |
+
box.querySelectorAll('.ve-voice-btn').forEach(function(btn){
|
| 114 |
+
btn.addEventListener('click', function(){
|
| 115 |
+
box.querySelectorAll('.ve-voice-btn').forEach(function(b){b.style.borderColor='#333';b.style.background='#222';});
|
| 116 |
+
this.style.borderColor='#5cb87a';
|
| 117 |
+
this.style.background='#1a2a1f';
|
| 118 |
+
selectedVoice = this.dataset.voice;
|
| 119 |
+
});
|
| 120 |
+
});
|
| 121 |
+
// Select first voice by default
|
| 122 |
+
box.querySelector('.ve-voice-btn').style.borderColor = '#5cb87a';
|
| 123 |
+
box.querySelector('.ve-voice-btn').style.background = '#1a2a1f';
|
| 124 |
+
|
| 125 |
+
// Emotion click handlers
|
| 126 |
+
box.querySelectorAll('.ve-emotion-btn').forEach(function(btn){
|
| 127 |
+
btn.addEventListener('click', function(){
|
| 128 |
+
box.querySelectorAll('.ve-emotion-btn').forEach(function(b){b.style.borderColor='#333';b.style.background='#222';});
|
| 129 |
+
this.style.borderColor='#5cb87a';
|
| 130 |
+
this.style.background='#1a2a1f';
|
| 131 |
+
selectedEmotion = this.dataset.emotion;
|
| 132 |
+
});
|
| 133 |
+
});
|
| 134 |
+
// Select first emotion by default
|
| 135 |
+
box.querySelector('.ve-emotion-btn').style.borderColor = '#5cb87a';
|
| 136 |
+
box.querySelector('.ve-emotion-btn').style.background = '#1a2a1f';
|
| 137 |
+
|
| 138 |
+
// Cancel
|
| 139 |
+
box.querySelector('#ve-cancel-btn').addEventListener('click', function(){
|
| 140 |
+
overlay.remove();
|
| 141 |
+
});
|
| 142 |
+
|
| 143 |
+
// Create short
|
| 144 |
+
box.querySelector('#ve-create-btn').addEventListener('click', async function(){
|
| 145 |
+
this.disabled = true;
|
| 146 |
+
this.textContent = '⏳ Đang tạo...';
|
| 147 |
+
box.querySelector('#ve-status').style.display = 'block';
|
| 148 |
+
box.querySelector('#ve-status').textContent = 'Đang tạo video shorts (có thể mất 1-3 phút)...';
|
| 149 |
+
|
| 150 |
+
try {
|
| 151 |
+
var speed = parseFloat(box.querySelector('#ve-speed').value) || 1.2;
|
| 152 |
+
var r = await fetch('/api/ai/short/' + encodeURIComponent(postId), {
|
| 153 |
+
method: 'POST',
|
| 154 |
+
headers: {'Content-Type': 'application/json'},
|
| 155 |
+
body: JSON.stringify({voice: selectedVoice, emotion: selectedEmotion, speed: speed})
|
| 156 |
+
});
|
| 157 |
+
var j = await r.json();
|
| 158 |
+
if (!r.ok || j.error) throw new Error(j.error || 'Lỗi tạo video');
|
| 159 |
+
toast('✅ Đã tạo Short AI!');
|
| 160 |
+
overlay.remove();
|
| 161 |
+
// Update wall
|
| 162 |
+
var p = _wallPosts.find(function(x){return String(x.id)===String(postId);});
|
| 163 |
+
if (p) {
|
| 164 |
+
p.video = j.video;
|
| 165 |
+
p.voice = j.voice;
|
| 166 |
+
p.emotion = j.emotion;
|
| 167 |
+
}
|
| 168 |
+
// Refresh wall view
|
| 169 |
+
var track = document.getElementById('ai-wall-track');
|
| 170 |
+
if (track) {
|
| 171 |
+
var idx = _wallPosts.findIndex(function(x){return String(x.id)===String(postId);});
|
| 172 |
+
if (idx >= 0) {
|
| 173 |
+
var el = track.children[idx];
|
| 174 |
+
if (el) el.outerHTML = makeWallItem(p, idx);
|
| 175 |
+
}
|
| 176 |
+
}
|
| 177 |
+
refreshShortAISlider();
|
| 178 |
+
} catch(e) {
|
| 179 |
+
this.disabled = false;
|
| 180 |
+
this.textContent = '🎬 Tạo Short';
|
| 181 |
+
box.querySelector('#ve-status').textContent = '❌ ' + e.message;
|
| 182 |
+
}
|
| 183 |
+
});
|
| 184 |
+
};
|
| 185 |
+
|
| 186 |
+
// Show slides as fullscreen overlay
|
| 187 |
+
window.showSlidePreview = function(slides, title) {
|
| 188 |
+
if (!slides || !slides.length) return;
|
| 189 |
+
var overlay = document.createElement('div');
|
| 190 |
+
overlay.id = 'slide-preview';
|
| 191 |
+
overlay.style.cssText = 'position:fixed;inset:0;background:#000;z-index:99999;display:flex;flex-direction:column;overflow:hidden';
|
| 192 |
+
|
| 193 |
+
var currentSlide = 0;
|
| 194 |
+
function renderSlide(idx) {
|
| 195 |
+
var s = slides[idx];
|
| 196 |
+
overlay.innerHTML = '<div style="position:absolute;top:10px;left:10px;right:10px;display:flex;justify-content:space-between;align-items:center;z-index:2">' +
|
| 197 |
+
'<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>' +
|
| 198 |
+
'<span style="color:#fff;font-size:11px;background:rgba(0,0,0,.6);padding:4px 10px;border-radius:10px">' + (idx+1) + '/' + slides.length + '</span>' +
|
| 199 |
+
'<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">✕</button>' +
|
| 200 |
+
'</div>' +
|
| 201 |
+
'<div style="flex:1;display:flex;align-items:center;justify-content:center;padding:20px">' +
|
| 202 |
+
(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\'">' : '') +
|
| 203 |
+
'</div>' +
|
| 204 |
+
'<div style="padding:16px 20px;background:linear-gradient(transparent,rgba(0,0,0,.9));min-height:100px">' +
|
| 205 |
+
'<p style="color:#fff;font-size:14px;line-height:1.6;text-align:center">' + esc(s.text) + '</p>' +
|
| 206 |
+
'</div>' +
|
| 207 |
+
'<div style="display:flex;gap:10px;padding:10px 20px 20px;justify-content:center">' +
|
| 208 |
+
'<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>' +
|
| 209 |
+
'<button onclick="nextSlideRip(currentSlide)" id="slide-next-btn" 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>' +
|
| 210 |
+
'</div>';
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
window.nextSlideRip = function(currentIdx) { if (currentIdx < slides.length - 1) { currentSlide = currentIdx + 1; renderSlide(currentSlide); } };
|
| 214 |
+
window.prevSlide = function() { if (currentSlide > 0) { currentSlide--; renderSlide(currentSlide); } };
|
| 215 |
+
|
| 216 |
+
// Fix: update next button onclick properly
|
| 217 |
+
var origRender = renderSlide;
|
| 218 |
+
renderSlide = function(idx) {
|
| 219 |
+
currentSlide = idx;
|
| 220 |
+
origRender(idx);
|
| 221 |
+
var nextBtn = document.getElementById('slide-next-btn');
|
| 222 |
+
if (nextBtn) {
|
| 223 |
+
nextBtn.onclick = function(){ if (idx < slides.length - 1) { currentSlide = idx + 1; renderSlide(currentSlide); } };
|
| 224 |
+
nextBtn.disabled = (idx === slides.length - 1);
|
| 225 |
+
nextBtn.style.opacity = (idx === slides.length - 1) ? '.3' : '1';
|
| 226 |
+
}
|
| 227 |
+
};
|
| 228 |
+
|
| 229 |
+
renderSlide(0);
|
| 230 |
+
document.body.appendChild(overlay);
|
| 231 |
+
|
| 232 |
+
var startX = 0;
|
| 233 |
+
overlay.addEventListener('touchstart', function(e) { startX = e.touches[0].clientX; });
|
| 234 |
+
overlay.addEventListener('touchend', function(e) {
|
| 235 |
+
var diff = e.changedTouches[0].clientX - startX;
|
| 236 |
+
if (diff < -50) window.nextSlideRip(currentSlide);
|
| 237 |
+
else if (diff > 50) window.prevSlide();
|
| 238 |
+
});
|
| 239 |
+
};
|
| 240 |
+
})();
|
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,18 +1,6 @@
|
|
| 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)=>{
|
| 18 |
const ctrl=new AbortController();
|
|
@@ -29,10 +17,10 @@ async function loadHome(){
|
|
| 29 |
const homeEl = document.getElementById('view-home');
|
| 30 |
if(!homeEl) return;
|
| 31 |
|
|
|
|
| 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>'
|
|
@@ -40,31 +28,42 @@ async function loadHome(){
|
|
| 40 |
|
| 41 |
const afterEl = homeEl.querySelector('#home-after-wc');
|
| 42 |
|
|
|
|
| 43 |
loadLivescore('today');
|
| 44 |
loadHotTopics();
|
| 45 |
|
| 46 |
-
|
|
|
|
| 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 +74,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 +84,39 @@ 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){
|
|
@@ -110,293 +131,151 @@ function _renderHLIn(afterEl){
|
|
| 110 |
|
| 111 |
// === WALL POST HELPERS ===
|
| 112 |
function makeWallItem(p,i){
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
?
|
| 116 |
-
: (hasVideo ?
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 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 |
-
{id:'pt-BR-ThalitaMultilingualNeural', label:'🎙️ Thalita (PT)', lang:'pt'},
|
| 210 |
-
{id:'fr-FR-VivienneMultilingualNeural', label:'🎙️ Vivienne (FR)', lang:'fr'},
|
| 211 |
-
{id:'fr-FR-RemyMultilingualNeural', label:'🎙️ Rémy (FR)', lang:'fr'},
|
| 212 |
-
{id:'de-DE-SeraphinaMultilingualNeural', label:'🎙️ Seraphina (DE)', lang:'de'},
|
| 213 |
-
{id:'de-DE-FlorianMultilingualNeural', label:'🎙️ Florian (DE)', lang:'de'},
|
| 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 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 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 +283,91 @@ 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 toggle169View(videoId,iconId){const slide=videoId?document.querySelector(`.tiktok-slide[data-vid="${videoId}"]`):null;if(slide){slide.classList.toggle('ratio-wide');const iconEl=iconId?document.getElementById(iconId):null;if(iconEl)iconEl.textContent=slide.classList.contains('ratio-wide')?'📺':'🖥️';else{const btn=slide.querySelector('.tiktok-right-btn .icon');if(btn)btn.textContent=slide.classList.contains('ratio-wide')?'📺':'🖥️';}return}document.querySelectorAll('.tiktok-slide.ratio-wide').forEach(s=>s.classList.remove('ratio-wide'));document.querySelectorAll('.tiktok-slide').forEach(s=>s.classList.add('ratio-wide'));document.querySelectorAll('.tiktok-right-btn .icon').forEach(b=>{if(b.textContent==='🖥️')b.textContent='📺';})}
|
| 408 |
function initTikTokFeed(){const feed=document.getElementById('tiktok-feed');if(!feed)return;const slides=feed.querySelectorAll('.tiktok-slide');let cur=-1;function act(i){if(i===cur)return;slides.forEach((sl,idx)=>{const v=sl.querySelector('video');const fr=sl.querySelector('iframe');if(idx===i){if(v&&v.dataset.hls&&!v._hls&&typeof Hls!=='undefined'&&Hls.isSupported()){const hls=new Hls();hls.loadSource(v.dataset.hls);hls.attachMedia(v);hls.on(Hls.Events.MANIFEST_PARSED,()=>v.play().catch(()=>{}));v._hls=hls}else if(v)v.play().catch(()=>{});if(fr&&!fr.src&&fr.dataset.ytSrc)fr.src=fr.dataset.ytSrc;const vid=sl.dataset.vid;if(vid&&!sl._viewed){sl._viewed=true;doInteract(vid,'view').then(j=>{if(j){const vc=document.getElementById('vc-'+idx);if(vc)vc.textContent=fmtNum(j.views);}});}}else{if(v){v.pause();if(v._hls){v._hls.destroy();v._hls=null}}if(fr&&fr.src)fr.src=''}});cur=i}let sT;feed.addEventListener('scroll',()=>{clearTimeout(sT);sT=setTimeout(()=>{const rect=feed.getBoundingClientRect(),ctr=rect.top+rect.height/2;let best=-1,bestD=1e9;slides.forEach((sl,i)=>{const d=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(d<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)}
|
| 409 |
-
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)
|
| 410 |
-
async function openYTShortsFeed(
|
| 411 |
-
async function openShortAIFeed(
|
| 412 |
-
function
|
| 413 |
-
|
| 414 |
-
function
|
| 415 |
-
function
|
| 416 |
-
function
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 436 |
|
| 437 |
-
|
| 438 |
try{
|
| 439 |
-
var
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
if(p.auto_scheduled || p.kind === 'auto_rewrite'){
|
| 448 |
-
newPosts.push(p);
|
| 449 |
-
}
|
| 450 |
}
|
| 451 |
}
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 457 |
}
|
| 458 |
-
if(
|
| 459 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 460 |
}
|
| 461 |
}catch(e){}
|
| 462 |
-
}
|
| 463 |
-
|
| 464 |
-
// Auto-start polling once wall data is loaded
|
| 465 |
-
(function _initPoll(){
|
| 466 |
-
if(typeof _wallPosts !== 'undefined' && _wallPosts.length > 0){
|
| 467 |
-
setTimeout(_startWallPolling, 2000);
|
| 468 |
-
} else {
|
| 469 |
-
setTimeout(_initPoll, 1500);
|
| 470 |
-
}
|
| 471 |
})();
|
| 472 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// === VNEWS Frontend v2 - Optimized for speed ===
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
+
// === LOAD HOME - Fast: immediate shell + parallel fetch ===
|
| 4 |
function _fetchWithTimeout(url, ms){
|
| 5 |
return new Promise((resolve,reject)=>{
|
| 6 |
const ctrl=new AbortController();
|
|
|
|
| 17 |
const homeEl = document.getElementById('view-home');
|
| 18 |
if(!homeEl) return;
|
| 19 |
|
| 20 |
+
// Build shell IMMEDIATELY — no skeleton, no delay
|
| 21 |
homeEl.innerHTML =
|
| 22 |
'<div id="home-featured-area"></div>'
|
| 23 |
+'<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>'
|
|
|
|
| 24 |
+'<div id="hashtag-box"></div>'
|
| 25 |
+'<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>'
|
| 26 |
+'<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>'
|
|
|
|
| 28 |
|
| 29 |
const afterEl = homeEl.querySelector('#home-after-wc');
|
| 30 |
|
| 31 |
+
// Start critical loads immediately
|
| 32 |
loadLivescore('today');
|
| 33 |
loadHotTopics();
|
| 34 |
|
| 35 |
+
// Fetch all data in parallel with shorter timeouts
|
| 36 |
+
const [featuredData, shortsData, wallData, hlLeagues, aiData, wcData] = await Promise.allSettled([
|
| 37 |
_fetchWithTimeout('/api/livescore/featured', 5000),
|
| 38 |
+
_fetchWithTimeout('/api/shorts', 8000),
|
| 39 |
_fetchWithTimeout('/api/wall', 5000),
|
| 40 |
_fetchWithTimeout('/api/highlights/leagues', 10000),
|
| 41 |
+
_fetchWithTimeout('/api/genk_ai', 8000),
|
| 42 |
_fetchWithTimeout('/api/wc2026', 8000),
|
| 43 |
]).then(results => results.map(r => r.status === 'fulfilled' ? r.value : null));
|
| 44 |
|
| 45 |
+
// Render featured match
|
| 46 |
if(featuredData && featuredData.home){
|
| 47 |
const sc=featuredData.status==='live'?'':'upcoming';
|
| 48 |
const st=featuredData.status==='live'?`🔴 ${featuredData.minute||'LIVE'}`:`⏰ ${featuredData.time}`;
|
| 49 |
const area=document.getElementById('home-featured-area');
|
| 50 |
+
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>`;
|
| 51 |
}
|
| 52 |
|
| 53 |
+
// Store globally
|
| 54 |
+
_shortsData = shortsData || [];
|
| 55 |
_wallPosts = (wallData && wallData.posts) || [];
|
| 56 |
_hlLeagueData = hlLeagues || {};
|
| 57 |
_wc2026Data = wcData;
|
| 58 |
|
| 59 |
+
// Render WC if data arrived
|
| 60 |
if(wcData) switchWCTab('news');
|
| 61 |
|
| 62 |
+
// Render sections into the after-wc area
|
| 63 |
+
_renderShortsIn(afterEl);
|
| 64 |
_renderWallIn(afterEl);
|
| 65 |
_renderHLIn(afterEl);
|
| 66 |
+
if(aiData && aiData.length) _renderSlidesIn('ai-articles','Ứng dụng AI','🤖',aiData,afterEl);
|
| 67 |
}
|
| 68 |
|
| 69 |
function _renderSlidesIn(key, label, emoji, vids, afterEl){
|
|
|
|
| 74 |
const isHL = key==='world-cup'||key==='premier-league'||key==='champions-league'||key==='la-liga'||key==='serie-a'||key==='bundesliga'||key==='friendly';
|
| 75 |
vids.slice(0,isHL?8:12).forEach((a,i)=>{
|
| 76 |
if(isHL){
|
| 77 |
+
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>`;
|
| 78 |
} else {
|
| 79 |
+
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>`;
|
| 80 |
}
|
| 81 |
});
|
| 82 |
h+='</div>';
|
|
|
|
| 84 |
afterEl.parentNode.insertBefore(wrap, afterEl);
|
| 85 |
}
|
| 86 |
|
| 87 |
+
function _renderShortsIn(afterEl){
|
| 88 |
+
if(!_shortsData||!_shortsData.length||!afterEl) return;
|
| 89 |
+
const mixed=interleaveShorts(_shortsData);
|
| 90 |
+
if(!mixed.length) return;
|
| 91 |
+
const wrap=document.createElement('div');
|
| 92 |
+
wrap.className='slider-wrap';
|
| 93 |
+
let h=`<div class="slider-header"><span class="slider-label">📱 Shorts Dân trí & SKĐS</span><span class="slider-note">Mới nhất · xen kẽ</span></div><div class="slider-track">`;
|
| 94 |
+
mixed.slice(0,30).forEach((a,i)=>{
|
| 95 |
+
const badge=a.channel==='baosuckhoedoisongboyte'?'SKĐS':'Dân trí';
|
| 96 |
+
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></div><div class="slider-title"><span style="color:#f0c040;font-size:8px">${badge}</span> ${esc(a.title)}</div></div>`;
|
| 97 |
+
});
|
| 98 |
+
h+='</div>';wrap.innerHTML=h;
|
| 99 |
+
afterEl.parentNode.insertBefore(wrap,afterEl);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
function _renderWallIn(afterEl){
|
| 103 |
+
if(!_wallPosts||!_wallPosts.length||!afterEl) return;
|
| 104 |
const posts=_wallPosts;
|
| 105 |
+
const aiShorts=posts.filter(p=>p.video);
|
| 106 |
+
if(aiShorts.length){
|
| 107 |
+
const wrap=document.createElement('div');
|
| 108 |
+
wrap.className='slider-wrap';
|
| 109 |
+
let h='<div class="slider-header"><span class="slider-label">🎬 Short AI</span></div><div class="slider-track">';
|
| 110 |
+
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>`;});
|
| 111 |
+
h+='</div>';wrap.innerHTML=h;
|
| 112 |
+
afterEl.parentNode.insertBefore(wrap,afterEl);
|
| 113 |
+
}
|
| 114 |
const wrap=document.createElement('div');
|
| 115 |
wrap.className='slider-wrap';wrap.id='ai-wall-wrap';
|
| 116 |
let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track" id="ai-wall-track">';
|
| 117 |
posts.slice(0,20).forEach((p,i)=>{h+=makeWallItem(p,i);});
|
| 118 |
h+='</div>';wrap.innerHTML=h;
|
| 119 |
+
afterEl.parentNode.insertBefore(wrap,afterEl);
|
|
|
|
|
|
|
| 120 |
}
|
| 121 |
|
| 122 |
function _renderHLIn(afterEl){
|
|
|
|
| 131 |
|
| 132 |
// === WALL POST HELPERS ===
|
| 133 |
function makeWallItem(p,i){
|
| 134 |
+
const hasVideo = p.video && p.video.length > 0;
|
| 135 |
+
const thumbContent = p.img
|
| 136 |
+
? `<img src="${esc(p.img)}" loading="lazy" onerror="this.style.display='none'">`
|
| 137 |
+
: (hasVideo ? `<video src="${esc(p.video)}" muted></video>` : '');
|
| 138 |
+
const videoBadge = hasVideo ? `<div class="wall-video-badge">🎬</div>` : '';
|
| 139 |
+
const videoBtn = hasVideo
|
| 140 |
+
? `<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed(${i})">▶ Xem Short</button>`
|
| 141 |
+
: `<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo('${esc(p.id||i)}',this)">🎬 Tạo Video</button>`;
|
| 142 |
+
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>`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
}
|
| 144 |
|
| 145 |
+
async function makeShortVideo(postId, btn, voice, speed){
|
| 146 |
if(!postId)return;
|
| 147 |
const origText = btn ? btn.textContent : '🎬 Tạo Video';
|
| 148 |
if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo...';}
|
| 149 |
toast('⏳ Đang tạo video shorts...');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
try{
|
| 151 |
+
let url = '/api/ai/short/'+encodeURIComponent(postId);
|
| 152 |
+
const params = [];
|
| 153 |
+
if(voice) params.push('voice='+encodeURIComponent(voice));
|
| 154 |
+
if(speed) params.push('speed='+encodeURIComponent(speed));
|
| 155 |
+
if(params.length) url += '?' + params.join('&');
|
| 156 |
+
const r = await fetch(url, {method:'POST'});
|
| 157 |
const j = await r.json();
|
| 158 |
if(!r.ok || j.error) throw new Error(j.error||'Lỗi tạo video');
|
| 159 |
toast('✅ Đã tạo video shorts!');
|
| 160 |
const p = _wallPosts.find(x => String(x.id) === String(postId));
|
| 161 |
if(p){
|
| 162 |
p.video = j.video;
|
|
|
|
|
|
|
| 163 |
const itemId = 'wall-item-'+postId;
|
| 164 |
const el = document.getElementById(itemId);
|
| 165 |
if(el){
|
| 166 |
const idx = _wallPosts.indexOf(p);
|
| 167 |
+
el.outerHTML = makeWallItem(p, idx);
|
| 168 |
+
const newEl = document.getElementById(itemId);
|
| 169 |
+
if(newEl) newEl.className = 'wall-item wall-item-new';
|
| 170 |
}
|
| 171 |
}
|
| 172 |
+
refreshShortAISlider();
|
| 173 |
}catch(e){
|
| 174 |
toast('❌ '+e.message);
|
| 175 |
if(btn){btn.disabled=false;btn.textContent=origText;}
|
| 176 |
}
|
| 177 |
}
|
| 178 |
|
| 179 |
+
function refreshShortAISlider(){
|
| 180 |
+
const aiShorts = _wallPosts.filter(p=>p.video);
|
| 181 |
+
let shortAISection = document.getElementById('short-ai-section');
|
| 182 |
+
if(aiShorts.length === 0){
|
| 183 |
+
if(shortAISection) shortAISection.remove();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
return;
|
| 185 |
}
|
| 186 |
+
if(shortAISection){
|
| 187 |
+
const track = shortAISection.querySelector('.slider-track');
|
| 188 |
+
if(track){
|
| 189 |
+
let h = '';
|
| 190 |
+
aiShorts.slice(0,20).forEach((p,i)=>{
|
| 191 |
+
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>`;
|
| 192 |
+
});
|
| 193 |
+
track.innerHTML = h;
|
|
|
|
|
|
|
| 194 |
}
|
|
|
|
| 195 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
|
| 198 |
function prependWallPost(post){
|
| 199 |
_wallPosts.unshift(post);
|
| 200 |
const track=document.getElementById('ai-wall-track');
|
| 201 |
const wrap=document.getElementById('ai-wall-wrap');
|
| 202 |
+
const homeEl=document.getElementById('view-home');
|
| 203 |
+
if(!track||!wrap){
|
| 204 |
+
if(homeEl){
|
| 205 |
+
let insertBefore=homeEl.querySelector('.slider-wrap');
|
| 206 |
+
const newWrap=document.createElement('div');
|
| 207 |
+
newWrap.className='slider-wrap';
|
| 208 |
+
newWrap.id='ai-wall-wrap';
|
| 209 |
+
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>`;
|
| 210 |
+
if(insertBefore) homeEl.insertBefore(newWrap,insertBefore);
|
| 211 |
+
else homeEl.appendChild(newWrap);
|
| 212 |
+
const firstItem=newWrap.querySelector('.wall-item');
|
| 213 |
+
if(firstItem)firstItem.className='wall-item wall-item-new';
|
| 214 |
+
}
|
| 215 |
return;
|
| 216 |
}
|
| 217 |
+
const div=document.createElement('div');
|
| 218 |
+
div.className='wall-item wall-item-new';
|
| 219 |
+
div.id='wall-item-'+(post.id||'new-'+Date.now());
|
| 220 |
+
const hasVideo = post.video && post.video.length > 0;
|
| 221 |
+
const thumbContent = post.img
|
| 222 |
+
? `<img src="${esc(post.img)}" loading="lazy" onerror="this.style.display='none'">`
|
| 223 |
+
: (hasVideo ? `<video src="${esc(post.video)}" muted></video>` : '');
|
| 224 |
+
const videoBadge = hasVideo ? `<div class="wall-video-badge">🎬</div>` : '';
|
| 225 |
+
const videoBtn = hasVideo
|
| 226 |
+
? `<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed(0)">▶ Xem Short</button>`
|
| 227 |
+
: `<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo('${esc(post.id)}',this)">🎬 Tạo Video</button>`;
|
| 228 |
+
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>`;
|
| 229 |
+
track.prepend(div);
|
| 230 |
+
track.scrollTo({left:0,behavior:'smooth'});
|
| 231 |
+
if(hasVideo) refreshShortAISlider();
|
| 232 |
}
|
| 233 |
|
| 234 |
+
// === REST OF FUNCTIONS ===
|
| 235 |
+
let _shortsData=[];
|
| 236 |
let _wallPosts=[];
|
| 237 |
let _currentView='home';
|
| 238 |
let _currentEventId=null;
|
| 239 |
let _currentMatchUrl=null;
|
| 240 |
+
function interleaveShorts(shorts){const dt=shorts.filter(s=>s.channel==='baodantri7941');const sk=shorts.filter(s=>s.channel==='baosuckhoedoisongboyte');const result=[];let i=0,j=0;while(i<dt.length||j<sk.length){if(i<dt.length)result.push(dt[i++]);if(j<sk.length)result.push(sk[j++]);}return result;}
|
| 241 |
let _htPage=0,_htTopic='';
|
| 242 |
+
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);}}
|
| 243 |
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);}
|
| 244 |
+
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>`;}}
|
| 245 |
function loadMoreHashtag(){_htPage++;const btn=document.getElementById('ht-more');if(btn){btn.disabled=true;btn.textContent='Đang tải...';}showHashtagSources(_htTopic,_htPage);}
|
| 246 |
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';}}}
|
| 247 |
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>';}}
|
| 248 |
+
function bindMatchClicks(el){
|
| 249 |
+
el.querySelectorAll('.match-detail').forEach(md=>{
|
| 250 |
+
md.style.cursor='pointer';
|
| 251 |
+
md.addEventListener('click',function(e){
|
| 252 |
+
const statusA=this.querySelector('.status a');
|
| 253 |
+
const teamA=this.querySelector('.teams a[href*="/tran-dau/"]');
|
| 254 |
+
const a = statusA || teamA;
|
| 255 |
+
if(a){
|
| 256 |
+
e.preventDefault();
|
| 257 |
+
e.stopPropagation();
|
| 258 |
+
const href=a.getAttribute('href')||'';
|
| 259 |
+
const m=href.match(/\/tran-dau\/(\d+)\//);
|
| 260 |
+
if(m){
|
| 261 |
+
const fullUrl=href.startsWith('http')?href:'https://bongda.com.vn'+href;
|
| 262 |
+
openMatch(m[1],fullUrl);
|
| 263 |
+
}
|
| 264 |
+
}
|
| 265 |
+
});
|
| 266 |
+
});
|
| 267 |
+
el.querySelectorAll('a').forEach(a=>{
|
| 268 |
+
a.addEventListener('click',e=>{e.preventDefault();e.stopPropagation()});
|
| 269 |
+
});
|
| 270 |
+
}
|
| 271 |
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')}
|
| 272 |
function closeMatch(){document.getElementById('match-overlay').classList.remove('active');document.body.style.overflow=''}
|
| 273 |
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>'}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
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;}}
|
| 275 |
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};}}
|
| 276 |
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[];}}
|
| 277 |
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[];}}
|
| 278 |
+
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>`;}
|
| 279 |
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);}}
|
| 280 |
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);}}
|
| 281 |
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);}
|
|
|
|
| 283 |
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);}
|
| 284 |
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;}
|
| 285 |
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);}
|
|
|
|
| 286 |
function initTikTokFeed(){const feed=document.getElementById('tiktok-feed');if(!feed)return;const slides=feed.querySelectorAll('.tiktok-slide');let cur=-1;function act(i){if(i===cur)return;slides.forEach((sl,idx)=>{const v=sl.querySelector('video');const fr=sl.querySelector('iframe');if(idx===i){if(v&&v.dataset.hls&&!v._hls&&typeof Hls!=='undefined'&&Hls.isSupported()){const hls=new Hls();hls.loadSource(v.dataset.hls);hls.attachMedia(v);hls.on(Hls.Events.MANIFEST_PARSED,()=>v.play().catch(()=>{}));v._hls=hls}else if(v)v.play().catch(()=>{});if(fr&&!fr.src&&fr.dataset.ytSrc)fr.src=fr.dataset.ytSrc;const vid=sl.dataset.vid;if(vid&&!sl._viewed){sl._viewed=true;doInteract(vid,'view').then(j=>{if(j){const vc=document.getElementById('vc-'+idx);if(vc)vc.textContent=fmtNum(j.views);}});}}else{if(v){v.pause();if(v._hls){v._hls.destroy();v._hls=null}}if(fr&&fr.src)fr.src=''}});cur=i}let sT;feed.addEventListener('scroll',()=>{clearTimeout(sT);sT=setTimeout(()=>{const rect=feed.getBoundingClientRect(),ctr=rect.top+rect.height/2;let best=-1,bestD=1e9;slides.forEach((sl,i)=>{const d=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(d<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)}
|
| 287 |
+
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();}
|
| 288 |
+
async function openYTShortsFeed(startIdx){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải...</div>';const arts=_shortsData.length?_shortsData:await fetch('/api/shorts').then(r=>r.json()).catch(()=>[]);if(!arts.length){el.innerHTML='<div class="loading">Không có shorts</div>';return}const ordered=startIdx>0?[...arts.slice(startIdx),...arts.slice(0,startIdx)]:arts;let h=`<button class="back-btn" onclick="switchCat('home')">← Shorts</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">`;ordered.forEach((v,i)=>{const id=v.id||'';const src=`https://www.youtube.com/embed/${id}?autoplay=1&rel=0&playsinline=1`;const vtag=`<iframe data-yt-src="${src}" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture"></iframe>`;const badge=v.channel==='baosuckhoedoisongboyte'?'SKĐS':'Dân trí';const videoId='yt-'+id;h+=buildTikTokSlide({vtag,title:v.title,badge,badgeClass:'badge-fpt',videoId,idx:i,total:ordered.length,shareUrl:'https://youtube.com/watch?v='+id});});h+='</div></div>';el.innerHTML=h;initTikTokFeed();}
|
| 289 |
+
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();}
|
| 290 |
+
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>`;}
|
| 291 |
+
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)}}
|
| 292 |
+
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)}}
|
| 293 |
+
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}}
|
| 294 |
+
async function readWallPost(i){const p=_wallPosts[i];if(!p)return;showView('view-article');
|
| 295 |
+
const images = p.images || [];
|
| 296 |
+
let imgGallery = '';
|
| 297 |
+
if(images.length > 0){
|
| 298 |
+
imgGallery = '<div class="article-image-gallery">';
|
| 299 |
+
images.forEach((imgUrl, imgIdx) => {
|
| 300 |
+
if(imgIdx === 0){
|
| 301 |
+
imgGallery += `<img class="article-img article-hero-img" src="${esc(imgUrl)}" onerror="this.style.display='none" loading="eager">`;
|
| 302 |
+
} else {
|
| 303 |
+
if(imgIdx === 1) imgGallery += '<div class="gallery-thumbs">';
|
| 304 |
+
imgGallery += `<div class="gallery-thumb"><img src="${esc(imgUrl)}" onerror="this.parentElement.style.display='none'" loading="lazy"></div>`;
|
| 305 |
+
}
|
| 306 |
+
});
|
| 307 |
+
if(images.length > 1) imgGallery += '</div>';
|
| 308 |
+
imgGallery += '</div>';
|
| 309 |
+
}
|
| 310 |
+
const hasVideo = p.video && p.video.length > 0;
|
| 311 |
+
const voiceOptions = [
|
| 312 |
+
{id:'hoaimy', label:'🎙️ Nữ — Hoài My'},
|
| 313 |
+
{id:'namminh', label:'🎙️ Nam — Nam Minh'},
|
| 314 |
+
];
|
| 315 |
+
let voiceSelector = '';
|
| 316 |
+
if(!hasVideo){
|
| 317 |
+
voiceSelector = `<div class="tts-selector"><div class="tts-selector-label">🎙️ Chọn giọng đọc:</div><div class="tts-voice-btns">`;
|
| 318 |
+
voiceOptions.forEach(v=>{
|
| 319 |
+
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>`;
|
| 320 |
+
});
|
| 321 |
+
voiceSelector += `</div><div class="tts-speed-row"><span>Tốc độ:</span><select id="selected-speed"><option value="1.0">1.0x — Bình thường</option><option value="1.2" selected>1.2x — Nhanh</option><option value="1.5">1.5x — Rất nhanh</option><option value="0.8">0.8x — Chậm</option></select></div>`;
|
| 322 |
+
voiceSelector += `<input type="hidden" id="selected-voice" value="hoaimy"></div>`;
|
| 323 |
+
}
|
| 324 |
+
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>`;
|
| 325 |
+
const firstVoiceBtn = document.querySelector('.tts-voice-btn');
|
| 326 |
+
if(firstVoiceBtn) firstVoiceBtn.classList.add('active');
|
| 327 |
+
window.scrollTo(0,0)}
|
| 328 |
+
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>'}}
|
| 329 |
+
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}
|
| 330 |
+
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(()=>{});
|
| 331 |
|
| 332 |
+
(function(){
|
| 333 |
try{
|
| 334 |
+
var hash = window.location.hash;
|
| 335 |
+
if(hash && hash.length > 1){
|
| 336 |
+
var articleUrl = decodeURIComponent(hash.substring(1));
|
| 337 |
+
if(articleUrl.startsWith('http')){
|
| 338 |
+
history.replaceState(null, '', window.location.pathname);
|
| 339 |
+
setTimeout(function(){
|
| 340 |
+
if(typeof readArticle==='function') readArticle(articleUrl);
|
| 341 |
+
}, 1500);
|
|
|
|
|
|
|
|
|
|
| 342 |
}
|
| 343 |
}
|
| 344 |
+
}catch(e){}
|
| 345 |
+
})();
|
| 346 |
+
|
| 347 |
+
(function(){
|
| 348 |
+
try{
|
| 349 |
+
const pa=localStorage.getItem('pending_article');
|
| 350 |
+
const pv=localStorage.getItem('pending_video');
|
| 351 |
+
if(pa){
|
| 352 |
+
localStorage.removeItem('pending_article');
|
| 353 |
+
setTimeout(()=>{
|
| 354 |
+
if(typeof readArticle==='function') readArticle(pa);
|
| 355 |
+
},1500);
|
| 356 |
}
|
| 357 |
+
if(pv){
|
| 358 |
+
localStorage.removeItem('pending_video');
|
| 359 |
+
try{
|
| 360 |
+
const v=JSON.parse(pv);
|
| 361 |
+
if(v&&v.url) setTimeout(()=>{window.open(v.url,'_blank')},1500);
|
| 362 |
+
}catch(e){}
|
| 363 |
}
|
| 364 |
}catch(e){}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
})();
|
| 366 |
+
|
| 367 |
+
if (document.readyState === 'loading') {
|
| 368 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 369 |
+
if (typeof loadHome === 'function') loadHome();
|
| 370 |
+
});
|
| 371 |
+
} else {
|
| 372 |
+
if (typeof loadHome === 'function') loadHome();
|
| 373 |
+
}
|
static/app_v2_shorts_fix.js
CHANGED
|
@@ -1,2 +1,3 @@
|
|
| 1 |
-
//
|
|
|
|
| 2 |
(function(){})();
|
|
|
|
| 1 |
+
// app_v2_shorts_fix.js - No-op
|
| 2 |
+
// All voice+emotion+slide functionality is now built into app_v2.js directly.
|
| 3 |
(function(){})();
|
static/index_v2.html
CHANGED
|
@@ -10,12 +10,10 @@
|
|
| 10 |
<link rel="canonical" href="https://bep40-vnews.hf.space">
|
| 11 |
<link rel="stylesheet" href="/static/wc2026.css">
|
| 12 |
<script src="https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js"></script>
|
| 13 |
-
<style>
|
| 14 |
-
*{box-sizing:border-box;margin:0;padding:0}body{background:#111;color:#eee;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;overflow-x:hidden}.header{background:linear-gradient(135deg,#0d1117,#1a3a2a 50%,#8b7500);padding:12px;text-align:center}.header h1{font-size:18px;color:#fff}.header p{font-size:10px;color:#aaa}.cats{display:flex;overflow-x:auto;background:#1a1a1a;border-bottom:1px solid #333;padding:0 4px;position:sticky;top:0;z-index:50;scrollbar-width:none}.cats::-webkit-scrollbar{display:none}.cat{padding:9px 11px;color:#888;font-size:11px;white-space:nowrap;border-bottom:2px solid transparent;cursor:pointer;flex-shrink:0}.cat.active{color:#5cb87a;border-bottom-color:#5cb87a;font-weight:700}.view{display:none}.view.active{display:block}.loading{text-align:center;padding:30px;color:#777;font-size:12px}.slider-wrap{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.slider-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.slider-label{color:#f0c040;font-size:13px;font-weight:800}.slider-note{font-size:10px;color:#777}.slider-track{display:flex;overflow-x:auto;gap:8px;padding:4px 10px 10px;scrollbar-width:none}.slider-track::-webkit-scrollbar{display:none}.slider-item{flex:0 0 160px;cursor:pointer}.slider-thumb{position:relative;width:100%;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#333}.slider-thumb img,.slider-thumb video{width:100%;height:100%;object-fit:cover}.slider-title{font-size:10px;color:#ccc;margin-top:3px;line-height:1.2;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.card-play{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:30px;height:30px;border-radius:50%;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;color:#fff;font-size:12px}.grid{display:grid;grid-template-columns:repeat(2,1fr);gap:6px;padding:6px 4px}@media(min-width:650px){.grid{grid-template-columns:repeat(3,1fr)}}.card{background:#1a1a1a;border:1px solid #222;border-radius:8px;overflow:hidden;cursor:pointer}.card-img{position:relative;aspect-ratio:16/9;background:#333}.card-img img{width:100%;height:100%;object-fit:cover}.card-body{padding:6px 8px}.card-title{font-size:11px;line-height:1.35;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.badge{font-size:8px;padding:1px 5px;border-radius:3px;font-weight:700;display:inline-block;margin-bottom:2px;color:#fff}.badge-vne{background:#c0392b}.badge-genk{background:#6a1b9a}.badge-ai{background:#2d8659}.badge-wc{background:#0b6bcb}.section-title{font-size:13px;font-weight:800;color:#5cb87a;margin:8px 0 4px;padding-left:8px;border-left:3px solid #5cb87a}.back-btn{background:#111;color:#fff;border:none;padding:10px;font-size:12px;width:100%;position:sticky;top:0;z-index:60;cursor:pointer}.article-view{padding:12px 8px 40px;max-width:760px;margin:0 auto}.article-title{font-size:18px;font-weight:800;line-height:1.3;margin-bottom:8px}.article-summary{background:#1a2a1f;border-left:3px solid #2d8659;padding:10px;margin-bottom:14px;color:#ccc;font-size:13px}.article-p{font-size:14px;line-height:1.7;color:#ccc;margin-bottom:10px}.article-img{width:100%;border-radius:6px;margin:10px 0}.article-h2{font-size:16px;margin:16px 0 8px;color:#eee}.article-actions{display:flex;gap:8px;flex-wrap:wrap;border-top:1px solid #333;margin-top:16px;padding-top:10px}.article-actions button{background:#1a1a1a;border:1px solid #333;color:#ccc;padding:7px 12px;border-radius:14px;font-size:11px;cursor:pointer}.article-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}.article-ai-ask{margin-top:12px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.article-ai-ask textarea{width:100%;min-height:60px;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px;font-size:12px}.article-ai-ask button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin-top:6px;font-size:11px;cursor:pointer}.article-ai-answer{white-space:pre-wrap;color:#ccc;font-size:13px;line-height:1.55;margin-top:8px}.tiktok-container{width:100%;height:80vh;max-height:680px;min-height:400px;background:#000}.tiktok-feed{height:100%;overflow-y:scroll;scroll-snap-type:y mandatory;scrollbar-width:none}.tiktok-feed::-webkit-scrollbar{display:none}.tiktok-slide{height:80vh;max-height:680px;min-height:400px;scroll-snap-align:start;position:relative;background:#000;display:flex;align-items:center;justify-content:center}.tiktok-slide video,.tiktok-slide iframe{width:100%;height:100%;object-fit:cover;border:none}.tiktok-slide.ratio-wide video,.tiktok-slide.ratio-wide iframe{object-fit:contain}.tiktok-bottom{position:absolute;bottom:0;left:0;right:60px;padding:12px 10px 16px;background:linear-gradient(transparent,rgba(0,0,0,.85));z-index:3}.tiktok-title{font-size:12px;color:#fff}.tiktok-counter{position:absolute;top:8px;left:8px;background:rgba(0,0,0,.5);font-size:9px;padding:2px 7px;border-radius:8px;color:#fff;z-index:4}.tiktok-right{position:absolute;right:8px;bottom:100px;display:flex;flex-direction:column;align-items:center;gap:14px;z-index:5}.tiktok-right-btn{display:flex;flex-direction:column;align-items:center;gap:2px;background:none;border:0;color:#fff;cursor:pointer;font-size:10px}.tiktok-right-btn .icon{width:42px;height:42px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:20px}.tiktok-right-btn .count{font-size:10px;color:#ddd}.inline-comments{position:absolute;bottom:0;left:0;right:0;max-height: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,19 +30,20 @@
|
|
| 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){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(()=>{})}
|
| 38 |
-
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+=
|
| 39 |
var SPACE=location.origin;
|
| 40 |
</script>
|
| 41 |
-
<script src="/static/app_v2.js
|
| 42 |
<script src="/static/yt_live.js"></script>
|
| 43 |
-
<script src="/static/
|
| 44 |
-
<script src="/static/hot_multi.js?v=1"></script>
|
| 45 |
<script src="/static/wc2026_v2.js"></script>
|
| 46 |
<script src="/static/live_mode.js"></script>
|
| 47 |
<script src="/static/match_detail_v6.js"></script>
|
| 48 |
-
<script>
|
|
|
|
|
|
|
| 49 |
</body>
|
| 50 |
-
</html>
|
|
|
|
| 10 |
<link rel="canonical" href="https://bep40-vnews.hf.space">
|
| 11 |
<link rel="stylesheet" href="/static/wc2026.css">
|
| 12 |
<script src="https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js"></script>
|
| 13 |
+
<style>*{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}.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 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}</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 |
+
<script src="/static/rewrite_fix_v2.js"></script>
|
| 46 |
+
<script src="/static/app_v2_shorts_fix.js"></script>
|
| 47 |
+
<script>init();</script>
|
| 48 |
</body>
|
| 49 |
+
</html>
|
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/wc2026_v2.js
CHANGED
|
@@ -314,4 +314,4 @@ function startWCLiveRefresh(){
|
|
| 314 |
}catch(e){}
|
| 315 |
},90000);
|
| 316 |
}
|
| 317 |
-
setTimeout(startWCLiveRefresh,5000);
|
|
|
|
| 314 |
}catch(e){}
|
| 315 |
},90000);
|
| 316 |
}
|
| 317 |
+
setTimeout(startWCLiveRefresh,5000);
|
static/yt_live.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
| 1 |
// === VNEWS — VTV LIVE + Inline Recorder v9 ===
|
| 2 |
// Features: PiP, mini-player, INLINE RECORDER, VTVGO POPUP players for each channel
|
| 3 |
-
// FIX
|
| 4 |
-
// FIX v11: Allow clicking off tabs (always clickable), better stream status handling
|
| 5 |
|
| 6 |
(function(){
|
| 7 |
if(window._ytLiveLoaded) return;
|
|
@@ -16,18 +15,18 @@
|
|
| 16 |
{id:'vtvprime',name:'VTVPrime',badge:'Prime'},
|
| 17 |
];
|
| 18 |
|
| 19 |
-
// VTVGO popup URLs for each channel (FIXED
|
| 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://
|
| 24 |
vtv4: 'https://package.vtvgo.vn/channel/vtv4-1,4.html',
|
| 25 |
-
vtv5: 'https://
|
| 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://
|
| 29 |
-
vtv9: 'https://
|
| 30 |
-
vtv10: 'https://
|
| 31 |
};
|
| 32 |
|
| 33 |
const DEFAULT_CHANNEL = 'vtv3';
|
|
@@ -56,7 +55,7 @@
|
|
| 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}
|
|
@@ -172,4 +171,845 @@
|
|
| 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 v9 ===
|
| 2 |
// Features: PiP, mini-player, INLINE RECORDER, VTVGO POPUP players for each channel
|
| 3 |
+
// FIX v9: Correct VTVGO popup URLs for all channels + World Cup 2026 TS stream links
|
|
|
|
| 4 |
|
| 5 |
(function(){
|
| 6 |
if(window._ytLiveLoaded) return;
|
|
|
|
| 15 |
{id:'vtvprime',name:'VTVPrime',badge:'Prime'},
|
| 16 |
];
|
| 17 |
|
| 18 |
+
// VTVGO popup URLs for each channel (FIXED v9 - verified correct URLs)
|
| 19 |
const VTV_POPUP_URLS = {
|
| 20 |
vtv1: 'https://package.vtvgo.vn/channel/vtv1-1,1.html',
|
| 21 |
vtv2: 'https://package.vtvgo.vn/channel/vtv2-1,2.html',
|
| 22 |
+
vtv3: 'https://vtvgo.vn/channel/vtv3-1,3.html',
|
| 23 |
vtv4: 'https://package.vtvgo.vn/channel/vtv4-1,4.html',
|
| 24 |
+
vtv5: 'https://vtvgo.vn/channel/vtv5-1,5.html',
|
| 25 |
vtv6: 'https://package.vtvgo.vn/channel/vtv6-1,13.html',
|
| 26 |
vtv7: 'https://package.vtvgo.vn/channel/vtv7-1,27.html',
|
| 27 |
+
vtv8: 'https://vtvgo.vn/xem-truc-tuyen-kenh-vtv8-36.html',
|
| 28 |
+
vtv9: 'https://vtvgo.vn/xem-truc-tuyen-kenh-vtv9-11.html',
|
| 29 |
+
vtv10: 'https://vtvgo.vn/xem-truc-tuyen-kenh-vtv10-6.html',
|
| 30 |
};
|
| 31 |
|
| 32 |
const DEFAULT_CHANNEL = 'vtv3';
|
|
|
|
| 55 |
.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}
|
| 56 |
.vtv-tab:hover{background:#0b4a7a;color:#fff}
|
| 57 |
.vtv-tab.on{background:#0066cc;border-color:#00ccff;color:#fff;font-weight:700}
|
| 58 |
+
.vtv-tab.off{opacity:.35;pointer-events:none}
|
| 59 |
.vtv-frame{position:relative;width:100%;aspect-ratio:16/9;background:#000;min-height:180px}
|
| 60 |
.vtv-frame video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
|
| 61 |
.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}
|
|
|
|
| 171 |
.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}
|
| 172 |
.vtv-popup-btn:hover{background:#0b4a7a;color:#fff}
|
| 173 |
`;
|
| 174 |
+
document.head.appendChild(style);
|
| 175 |
+
|
| 176 |
+
// ===== HELPERS =====
|
| 177 |
+
function fmtSec(s){ return String(Math.floor(s/60)).padStart(2,'0')+':'+String(Math.floor(s%60)).padStart(2,'0'); }
|
| 178 |
+
function escH(s){ return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
| 179 |
+
|
| 180 |
+
function recStatus(msg, type) {
|
| 181 |
+
const el = document.getElementById('vtv-rec-status');
|
| 182 |
+
if (el) { el.textContent = msg; el.className = 'rec-status' + (type ? ' ' + type : ''); }
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
function downloadBlob(blob, filename) {
|
| 186 |
+
const a = document.createElement('a');
|
| 187 |
+
a.href = URL.createObjectURL(blob); a.download = filename; a.click();
|
| 188 |
+
URL.revokeObjectURL(a.href);
|
| 189 |
+
recStatus('✅ Đã tải: ' + filename, 'ok');
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
function showProc(text) {
|
| 193 |
+
const el = document.getElementById('vtv-rec-proc');
|
| 194 |
+
if (el) { el.style.display = 'block'; document.getElementById('vtv-rec-proc-text').textContent = text; }
|
| 195 |
+
}
|
| 196 |
+
function hideProc() {
|
| 197 |
+
const el = document.getElementById('vtv-rec-proc');
|
| 198 |
+
if (el) el.style.display = 'none';
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
// ===== UPLOAD TO WALL (Short AI) — v7: robust JSON parse =====
|
| 202 |
+
function uploadToWall(blob, title, source) {
|
| 203 |
+
const formData = new FormData();
|
| 204 |
+
formData.append('video', blob, `vtv-${_currentCh}-${Date.now()}.webm`);
|
| 205 |
+
formData.append('title', title || `Record LIVE ${_currentCh?.toUpperCase()} - ${new Date().toLocaleString('vi-VN')}`);
|
| 206 |
+
formData.append('source', source || 'vtv_inline_recorder');
|
| 207 |
+
showProc('⏳ Đang upload lên Short AI...');
|
| 208 |
+
return fetch('/api/wall', { method: 'POST', body: formData })
|
| 209 |
+
.then(r => {
|
| 210 |
+
console.log('[VTV Upload] HTTP status:', r.status, r.statusText);
|
| 211 |
+
return r.text().then(text => {
|
| 212 |
+
console.log('[VTV Upload] Response text:', text.substring(0, 200));
|
| 213 |
+
let data;
|
| 214 |
+
try { data = JSON.parse(text); } catch(e) {
|
| 215 |
+
console.error('[VTV Upload] JSON parse failed:', e, 'Raw:', text.substring(0, 500));
|
| 216 |
+
throw new Error('Server trả về lỗi (không phải JSON). Status: ' + r.status);
|
| 217 |
+
}
|
| 218 |
+
return data;
|
| 219 |
+
});
|
| 220 |
+
})
|
| 221 |
+
.then(data => {
|
| 222 |
+
hideProc();
|
| 223 |
+
console.log('[VTV Upload] Parsed data:', data);
|
| 224 |
+
if (data && data.ok === true) {
|
| 225 |
+
recStatus('✅ Đã tạo Short AI! Xem trong mục Tường AI.', 'ok');
|
| 226 |
+
return data;
|
| 227 |
+
} else {
|
| 228 |
+
throw new Error(data && data.error ? data.error : 'Upload thất bại (ok=false)');
|
| 229 |
+
}
|
| 230 |
+
})
|
| 231 |
+
.catch(e => {
|
| 232 |
+
hideProc();
|
| 233 |
+
console.error('[VTV Upload] Error:', e);
|
| 234 |
+
recStatus('⚠️ ' + e.message + '. Đang tải về...', 'err');
|
| 235 |
+
setTimeout(() => downloadBlob(blob, `vtv-${_currentCh}-${Date.now()}.webm`), 800);
|
| 236 |
+
throw e;
|
| 237 |
+
});
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
// ===== AI TITLE GENERATION =====
|
| 241 |
+
async function generateAITitle(blob, channelName) {
|
| 242 |
+
return new Promise((resolve) => {
|
| 243 |
+
const video = document.createElement('video');
|
| 244 |
+
video.src = URL.createObjectURL(blob);
|
| 245 |
+
video.muted = true;
|
| 246 |
+
video.onloadedmetadata = function() {
|
| 247 |
+
URL.revokeObjectURL(video.src);
|
| 248 |
+
const dur = Math.round(video.duration || 0);
|
| 249 |
+
const now = new Date();
|
| 250 |
+
const timeStr = now.toLocaleTimeString('vi-VN',{hour:'2-digit',minute:'2-digit'});
|
| 251 |
+
const dateStr = now.toLocaleDateString('vi-VN',{day:'2-digit',month:'2-digit'});
|
| 252 |
+
const titles = [
|
| 253 |
+
`🔴 LIVE ${channelName} - ${timeStr} ${dateStr}`,
|
| 254 |
+
`📺 ${channelName} - Video ngắn ${dur}s`,
|
| 255 |
+
`🎬 ${channelName} Record - ${timeStr}`,
|
| 256 |
+
`⚡ ${channelName} Highlights - ${dateStr}`,
|
| 257 |
+
`📱 ${channelName} Short - ${timeStr} ${dateStr}`,
|
| 258 |
+
];
|
| 259 |
+
const idx = dur < 15 ? 4 : dur < 30 ? 1 : dur < 45 ? 2 : 0;
|
| 260 |
+
resolve(titles[idx % titles.length]);
|
| 261 |
+
};
|
| 262 |
+
video.onerror = function() {
|
| 263 |
+
URL.revokeObjectURL(video.src);
|
| 264 |
+
resolve(`📺 Record ${channelName} - ${new Date().toLocaleTimeString('vi-VN',{hour:'2-digit',minute:'2-digit'})}`);
|
| 265 |
+
};
|
| 266 |
+
});
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
// ===== BUILD INLINE RECORDER =====
|
| 270 |
+
function buildInlineRecorder() {
|
| 271 |
+
const controls = document.querySelector('.vtv-controls');
|
| 272 |
+
if (!controls || document.getElementById('vtv-rec-btn')) return;
|
| 273 |
+
|
| 274 |
+
const recBtn = document.createElement('button');
|
| 275 |
+
recBtn.id = 'vtv-rec-btn';
|
| 276 |
+
recBtn.className = 'vtv-rec-btn';
|
| 277 |
+
recBtn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record';
|
| 278 |
+
recBtn.title = 'Record video ngắn từ LIVE (max 60s)';
|
| 279 |
+
recBtn.onclick = toggleRecPanel;
|
| 280 |
+
controls.appendChild(recBtn);
|
| 281 |
+
|
| 282 |
+
const inlineRec = document.createElement('div');
|
| 283 |
+
inlineRec.id = 'vtv-inline-rec';
|
| 284 |
+
inlineRec.className = 'vtv-inline-rec';
|
| 285 |
+
inlineRec.innerHTML = `
|
| 286 |
+
<div class="rec-time">
|
| 287 |
+
<span id="vtv-rec-time-start">00:00</span>
|
| 288 |
+
<span id="vtv-rec-time-dur">Duration: 0s</span>
|
| 289 |
+
<span id="vtv-rec-time-end">01:00</span>
|
| 290 |
+
</div>
|
| 291 |
+
<div class="rec-bar" id="vtv-rec-bar">
|
| 292 |
+
<div class="rec-progress" id="vtv-rec-progress"></div>
|
| 293 |
+
<div class="rec-marker s" id="vtv-rec-mk-start" style="display:none"></div>
|
| 294 |
+
<div class="rec-marker e" id="vtv-rec-mk-end" style="display:none"></div>
|
| 295 |
+
</div>
|
| 296 |
+
<div class="rec-controls">
|
| 297 |
+
<button class="rec-set-start" id="vtv-rec-set-start" onclick="window._vtvRecSetStart()">🔵 Điểm đầu</button>
|
| 298 |
+
<button class="rec-set-end" id="vtv-rec-set-end" onclick="window._vtvRecSetEnd()" disabled>🔴 Điểm cuối</button>
|
| 299 |
+
<button class="rec-go" id="vtv-rec-go" onclick="window._vtvRecGo()" disabled>⏺ Record!</button>
|
| 300 |
+
<button class="rec-reset" onclick="window._vtvRecReset()">↺ Reset</button>
|
| 301 |
+
</div>
|
| 302 |
+
<div class="rec-hint" id="vtv-rec-hint">Chọn điểm bắt đầu → kết thúc (tối đa 60s)</div>
|
| 303 |
+
<div class="rec-status" id="vtv-rec-status">Sẵn sàng</div>
|
| 304 |
+
`;
|
| 305 |
+
controls.parentNode.insertBefore(inlineRec, controls.nextSibling);
|
| 306 |
+
|
| 307 |
+
const recPanel = document.createElement('div');
|
| 308 |
+
recPanel.id = 'vtv-rec-panel';
|
| 309 |
+
recPanel.className = 'vtv-rec-panel';
|
| 310 |
+
recPanel.innerHTML = `
|
| 311 |
+
<div class="rec-panel-title">🎬 Đã record xong!</div>
|
| 312 |
+
<div class="rec-preview-wrap" id="vtv-rec-preview-wrap" style="display:none">
|
| 313 |
+
<video id="vtv-rec-preview-vid" controls style="max-width:100%;max-height:180px;border-radius:6px;background:#000"></video>
|
| 314 |
+
</div>
|
| 315 |
+
<input type="text" class="rec-title-input" id="vtv-rec-title-input" placeholder="Tiêu đề video (để trống = AI tự tạo)">
|
| 316 |
+
<div class="rec-ai-title-row">
|
| 317 |
+
<button onclick="window._vtvRecGenAITitle()" id="vtv-rec-ai-btn">🤖 AI tạo tiêu đề</button>
|
| 318 |
+
</div>
|
| 319 |
+
<div class="rec-ratio-row">
|
| 320 |
+
<button class="active" data-ratio="original" onclick="window._vtvRecSetRatio('original')">📺 Gốc</button>
|
| 321 |
+
<button data-ratio="1:1" onclick="window._vtvRecSetRatio('1:1')">⬜ 1:1</button>
|
| 322 |
+
<button data-ratio="9:16" onclick="window._vtvRecSetRatio('9:16')">📱 9:16</button>
|
| 323 |
+
</div>
|
| 324 |
+
<div class="rec-actions">
|
| 325 |
+
<button class="rec-download" onclick="window._vtvRecDownload()">💾 Lưu về máy</button>
|
| 326 |
+
<button class="rec-share" onclick="window._vtvRecShare()">��� Tạo Short AI</button>
|
| 327 |
+
</div>
|
| 328 |
+
<div class="rec-proc" id="vtv-rec-proc">
|
| 329 |
+
<div class="vtv-spinner" style="width:20px;height:20px;margin:0 auto 6px"></div>
|
| 330 |
+
<span id="vtv-rec-proc-text">Đang xử lý...</span>
|
| 331 |
+
</div>
|
| 332 |
+
`;
|
| 333 |
+
inlineRec.parentNode.insertBefore(recPanel, inlineRec.nextSibling);
|
| 334 |
+
|
| 335 |
+
document.getElementById('vtv-rec-bar').addEventListener('click', function(e) {
|
| 336 |
+
if (!_rec.isRecording) handleRecBarClick(e);
|
| 337 |
+
});
|
| 338 |
+
|
| 339 |
+
document.getElementById('vtv-rec-mk-start').addEventListener('mousedown', function(e){ e.preventDefault(); e.stopPropagation(); _rec._dragMarker = 'start'; });
|
| 340 |
+
document.getElementById('vtv-rec-mk-start').addEventListener('touchstart', function(e){ e.preventDefault(); e.stopPropagation(); _rec._dragMarker = 'start'; }, {passive:false});
|
| 341 |
+
document.getElementById('vtv-rec-mk-end').addEventListener('mousedown', function(e){ e.preventDefault(); e.stopPropagation(); _rec._dragMarker = 'end'; });
|
| 342 |
+
document.getElementById('vtv-rec-mk-end').addEventListener('touchstart', function(e){ e.preventDefault(); e.stopPropagation(); _rec._dragMarker = 'end'; }, {passive:false});
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
// Global drag
|
| 346 |
+
document.addEventListener('mousemove', function(e) { if (_rec._dragMarker) dragMarker(e.clientX); });
|
| 347 |
+
document.addEventListener('touchmove', function(e) { if (_rec._dragMarker) dragMarker(e.touches[0].clientX); }, {passive:false});
|
| 348 |
+
document.addEventListener('mouseup', function(){ _rec._dragMarker = null; });
|
| 349 |
+
document.addEventListener('touchend', function(){ _rec._dragMarker = null; });
|
| 350 |
+
|
| 351 |
+
function dragMarker(clientX) {
|
| 352 |
+
const bar = document.getElementById('vtv-rec-bar');
|
| 353 |
+
if (!bar) return;
|
| 354 |
+
const rect = bar.getBoundingClientRect();
|
| 355 |
+
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
| 356 |
+
const time = pct * 60;
|
| 357 |
+
if (_rec._dragMarker === 'start') {
|
| 358 |
+
if (_rec.endTime !== null && time >= _rec.endTime - 1) return;
|
| 359 |
+
_rec.startTime = time;
|
| 360 |
+
const mk = document.getElementById('vtv-rec-mk-start');
|
| 361 |
+
mk.style.display = 'block'; mk.style.left = (pct * 100) + '%';
|
| 362 |
+
document.getElementById('vtv-rec-time-start').textContent = fmtSec(time);
|
| 363 |
+
} else {
|
| 364 |
+
if (_rec.startTime !== null && time <= _rec.startTime + 1) return;
|
| 365 |
+
_rec.endTime = time;
|
| 366 |
+
const mk = document.getElementById('vtv-rec-mk-end');
|
| 367 |
+
mk.style.display = 'block'; mk.style.left = (pct * 100) + '%';
|
| 368 |
+
document.getElementById('vtv-rec-time-end').textContent = fmtSec(time);
|
| 369 |
+
}
|
| 370 |
+
updateRecBar();
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
function handleRecBarClick(e) {
|
| 374 |
+
const video = document.getElementById('vtv-player');
|
| 375 |
+
if (!video || !video.src) { recStatus('⚠️ Chưa có stream!'); return; }
|
| 376 |
+
const bar = document.getElementById('vtv-rec-bar');
|
| 377 |
+
const rect = bar.getBoundingClientRect();
|
| 378 |
+
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
| 379 |
+
const time = pct * 60;
|
| 380 |
+
if (_rec.startTime === null) {
|
| 381 |
+
_rec.startTime = time;
|
| 382 |
+
const mk = document.getElementById('vtv-rec-mk-start');
|
| 383 |
+
mk.style.display = 'block'; mk.style.left = (pct * 100) + '%';
|
| 384 |
+
document.getElementById('vtv-rec-time-start').textContent = fmtSec(time);
|
| 385 |
+
document.getElementById('vtv-rec-set-end').disabled = false;
|
| 386 |
+
recStatus('Điểm đầu: ' + fmtSec(time) + '. Chọn điểm cuối.', '');
|
| 387 |
+
} else if (_rec.endTime === null) {
|
| 388 |
+
if (time <= _rec.startTime) { recStatus('⚠️ Điểm cuối phải sau điểm đầu!', 'err'); return; }
|
| 389 |
+
const dur = time - _rec.startTime;
|
| 390 |
+
if (dur > 60) { recStatus('⚠️ Tối đa 60s!', 'err'); return; }
|
| 391 |
+
_rec.endTime = time;
|
| 392 |
+
const mk = document.getElementById('vtv-rec-mk-end');
|
| 393 |
+
mk.style.display = 'block'; mk.style.left = (pct * 100) + '%';
|
| 394 |
+
document.getElementById('vtv-rec-time-end').textContent = fmtSec(time);
|
| 395 |
+
updateRecBar();
|
| 396 |
+
recStatus('✅ ' + Math.round(dur) + 's. Nhấn Record!', 'ok');
|
| 397 |
+
document.getElementById('vtv-rec-go').disabled = false;
|
| 398 |
+
}
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
function updateRecBar() {
|
| 402 |
+
const s = _rec.startTime || 0, e = _rec.endTime || 60;
|
| 403 |
+
const dur = e - s;
|
| 404 |
+
document.getElementById('vtv-rec-progress').style.left = (s/60*100) + '%';
|
| 405 |
+
document.getElementById('vtv-rec-progress').style.width = (dur/60*100) + '%';
|
| 406 |
+
document.getElementById('vtv-rec-time-dur').textContent = 'Duration: ' + Math.round(dur) + 's';
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
// ===== RECORDER CONTROLS =====
|
| 410 |
+
window._vtvRecSetStart = function() {
|
| 411 |
+
const video = document.getElementById('vtv-player');
|
| 412 |
+
if (!video || !video.src) { recStatus('⚠️ Chưa có stream!', 'err'); return; }
|
| 413 |
+
_rec.startTime = video.currentTime || 0;
|
| 414 |
+
const pct = _rec.startTime / 60 * 100;
|
| 415 |
+
const mk = document.getElementById('vtv-rec-mk-start');
|
| 416 |
+
mk.style.display = 'block'; mk.style.left = pct + '%';
|
| 417 |
+
document.getElementById('vtv-rec-time-start').textContent = fmtSec(_rec.startTime);
|
| 418 |
+
document.getElementById('vtv-rec-set-end').disabled = false;
|
| 419 |
+
recStatus('Điểm đầu: ' + fmtSec(_rec.startTime), '');
|
| 420 |
+
};
|
| 421 |
+
|
| 422 |
+
window._vtvRecSetEnd = function() {
|
| 423 |
+
if (_rec.startTime === null) { recStatus('⚠️ Chọn điểm đầu trước!', 'err'); return; }
|
| 424 |
+
const video = document.getElementById('vtv-player');
|
| 425 |
+
if (!video) return;
|
| 426 |
+
const cur = video.currentTime || 0;
|
| 427 |
+
if (cur <= _rec.startTime) { recStatus('⚠️ Điểm cuối phải sau điểm đầu!', 'err'); return; }
|
| 428 |
+
const dur = cur - _rec.startTime;
|
| 429 |
+
if (dur > 60) { recStatus('⚠️ Tối đa 60s!', 'err'); return; }
|
| 430 |
+
_rec.endTime = cur;
|
| 431 |
+
const pct = cur / 60 * 100;
|
| 432 |
+
const mk = document.getElementById('vtv-rec-mk-end');
|
| 433 |
+
mk.style.display = 'block'; mk.style.left = pct + '%';
|
| 434 |
+
document.getElementById('vtv-rec-time-end').textContent = fmtSec(cur);
|
| 435 |
+
updateRecBar();
|
| 436 |
+
recStatus('✅ ' + Math.round(dur) + 's. Nhấn Record!', 'ok');
|
| 437 |
+
document.getElementById('vtv-rec-go').disabled = false;
|
| 438 |
+
};
|
| 439 |
+
|
| 440 |
+
window._vtvRecReset = function() {
|
| 441 |
+
_rec.startTime = null; _rec.endTime = null; _rec.isRecording = false; _rec.blob = null;
|
| 442 |
+
if (_rec.recorder && _rec.recorder.state !== 'inactive') _rec.recorder.stop();
|
| 443 |
+
clearTimeout(_rec._recTimer);
|
| 444 |
+
document.getElementById('vtv-rec-mk-start').style.display = 'none';
|
| 445 |
+
document.getElementById('vtv-rec-mk-end').style.display = 'none';
|
| 446 |
+
document.getElementById('vtv-rec-progress').style.width = '0%';
|
| 447 |
+
document.getElementById('vtv-rec-time-start').textContent = '00:00';
|
| 448 |
+
document.getElementById('vtv-rec-time-end').textContent = '01:00';
|
| 449 |
+
document.getElementById('vtv-rec-time-dur').textContent = 'Duration: 0s';
|
| 450 |
+
document.getElementById('vtv-rec-set-start').disabled = false;
|
| 451 |
+
document.getElementById('vtv-rec-set-end').disabled = true;
|
| 452 |
+
document.getElementById('vtv-rec-go').disabled = true;
|
| 453 |
+
document.getElementById('vtv-rec-panel').classList.remove('show');
|
| 454 |
+
document.getElementById('vtv-rec-title-input').value = '';
|
| 455 |
+
recStatus('Đã reset.', '');
|
| 456 |
+
const btn = document.getElementById('vtv-rec-btn');
|
| 457 |
+
if (btn) { btn.classList.remove('recording'); btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record'; }
|
| 458 |
+
};
|
| 459 |
+
|
| 460 |
+
window._vtvRecSetRatio = function(ratio) {
|
| 461 |
+
_rec.ratio = ratio;
|
| 462 |
+
document.querySelectorAll('#vtv-rec-panel .rec-ratio-row button').forEach(b => b.classList.toggle('active', b.dataset.ratio === ratio));
|
| 463 |
+
};
|
| 464 |
+
|
| 465 |
+
// ===== RECORD — captureStream with audio (unmute during record) =====
|
| 466 |
+
window._vtvRecGo = function() {
|
| 467 |
+
if (_rec.startTime === null || _rec.endTime === null) return;
|
| 468 |
+
if (_rec.isRecording) {
|
| 469 |
+
if (_rec.recorder && _rec.recorder.state !== 'inactive') _rec.recorder.stop();
|
| 470 |
+
_rec.isRecording = false;
|
| 471 |
+
clearTimeout(_rec._recTimer);
|
| 472 |
+
const btn = document.getElementById('vtv-rec-btn');
|
| 473 |
+
if (btn) { btn.classList.remove('recording'); btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record'; }
|
| 474 |
+
return;
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
const video = document.getElementById('vtv-player');
|
| 478 |
+
if (!video) { recStatus('⚠️ Không tìm thấy video!', 'err'); return; }
|
| 479 |
+
|
| 480 |
+
_rec.chunks = [];
|
| 481 |
+
_rec.isRecording = true;
|
| 482 |
+
|
| 483 |
+
const wasMuted = video.muted;
|
| 484 |
+
video.muted = false;
|
| 485 |
+
const wasVolume = video.volume;
|
| 486 |
+
video.volume = 0.3;
|
| 487 |
+
|
| 488 |
+
let stream;
|
| 489 |
+
try {
|
| 490 |
+
stream = video.captureStream ? video.captureStream() : video.mozCaptureStream();
|
| 491 |
+
if (!stream) throw new Error('captureStream not supported');
|
| 492 |
+
} catch(e) {
|
| 493 |
+
video.muted = wasMuted; video.volume = wasVolume;
|
| 494 |
+
recStatus('❌ ' + e.message, 'err');
|
| 495 |
+
_rec.isRecording = false;
|
| 496 |
+
return;
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
const audioTracks = stream.getAudioTracks();
|
| 500 |
+
console.log('[VTV Rec] Audio tracks:', audioTracks.length, 'Video tracks:', stream.getVideoTracks().length);
|
| 501 |
+
|
| 502 |
+
let mimeType = 'video/webm;codecs=vp9,opus';
|
| 503 |
+
if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = 'video/webm;codecs=vp8,opus';
|
| 504 |
+
if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = 'video/webm';
|
| 505 |
+
|
| 506 |
+
try { _rec.recorder = new MediaRecorder(stream, {mimeType}); }
|
| 507 |
+
catch(e) {
|
| 508 |
+
video.muted = wasMuted; video.volume = wasVolume;
|
| 509 |
+
recStatus('❌ Lỗi MediaRecorder: ' + e.message, 'err');
|
| 510 |
+
_rec.isRecording = false;
|
| 511 |
+
return;
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
_rec.recorder.ondataavailable = e => { if(e.data && e.data.size > 0) _rec.chunks.push(e.data); };
|
| 515 |
+
_rec.recorder.onstop = function() {
|
| 516 |
+
_rec.isRecording = false;
|
| 517 |
+
video.muted = wasMuted;
|
| 518 |
+
video.volume = wasVolume;
|
| 519 |
+
_rec.blob = new Blob(_rec.chunks, {type: 'video/webm'});
|
| 520 |
+
const btn = document.getElementById('vtv-rec-btn');
|
| 521 |
+
if (btn) { btn.classList.remove('recording'); btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record'; }
|
| 522 |
+
document.getElementById('vtv-rec-panel').classList.add('show');
|
| 523 |
+
const pv = document.getElementById('vtv-rec-preview-vid');
|
| 524 |
+
pv.src = URL.createObjectURL(_rec.blob);
|
| 525 |
+
document.getElementById('vtv-rec-preview-wrap').style.display = 'block';
|
| 526 |
+
const sizeMB = (_rec.blob.size/1024/1024).toFixed(1);
|
| 527 |
+
recStatus('✅ Record xong! ' + sizeMB + 'MB' + (audioTracks.length > 0 ? ' (có audio)' : ' (video only)'), 'ok');
|
| 528 |
+
};
|
| 529 |
+
_rec.recorder.onerror = e => {
|
| 530 |
+
_rec.isRecording = false;
|
| 531 |
+
video.muted = wasMuted; video.volume = wasVolume;
|
| 532 |
+
recStatus('❌ Lỗi: ' + (e.error?.message||'?'), 'err');
|
| 533 |
+
};
|
| 534 |
+
|
| 535 |
+
_rec.recorder.start(500);
|
| 536 |
+
video.currentTime = _rec.startTime;
|
| 537 |
+
|
| 538 |
+
const btn = document.getElementById('vtv-rec-btn');
|
| 539 |
+
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'; }
|
| 540 |
+
recStatus('🔴 ĐANG RECORD...' + (audioTracks.length > 0 ? ' (có audio)' : ''), 'recording');
|
| 541 |
+
|
| 542 |
+
const dur = (_rec.endTime - _rec.startTime) * 1000;
|
| 543 |
+
_rec._recTimer = setTimeout(() => {
|
| 544 |
+
if (_rec.isRecording && _rec.recorder && _rec.recorder.state !== 'inactive') _rec.recorder.stop();
|
| 545 |
+
}, dur + 500);
|
| 546 |
+
};
|
| 547 |
+
|
| 548 |
+
// ===== AI TITLE =====
|
| 549 |
+
window._vtvRecGenAITitle = async function() {
|
| 550 |
+
if (!_rec.blob) { recStatus('⚠️ Chưa record!', 'err'); return; }
|
| 551 |
+
const btn = document.getElementById('vtv-rec-ai-btn');
|
| 552 |
+
const input = document.getElementById('vtv-rec-title-input');
|
| 553 |
+
if (!btn || !input) return;
|
| 554 |
+
|
| 555 |
+
btn.disabled = true;
|
| 556 |
+
btn.textContent = '⏳ Đang tạo...';
|
| 557 |
+
|
| 558 |
+
try {
|
| 559 |
+
const ch = CHANNELS.find(c => c.id === _currentCh);
|
| 560 |
+
const channelName = ch ? ch.name : (_currentCh || 'VTV');
|
| 561 |
+
const title = await generateAITitle(_rec.blob, channelName);
|
| 562 |
+
input.value = title;
|
| 563 |
+
recStatus('✅ Đã tạo tiêu đề AI: ' + title, 'ok');
|
| 564 |
+
} catch(e) {
|
| 565 |
+
recStatus('⚠️ Lỗi AI: ' + e.message, 'err');
|
| 566 |
+
} finally {
|
| 567 |
+
btn.disabled = false;
|
| 568 |
+
btn.textContent = '🤖 AI tạo tiêu đề';
|
| 569 |
+
}
|
| 570 |
+
};
|
| 571 |
+
|
| 572 |
+
// ===== DOWNLOAD =====
|
| 573 |
+
window._vtvRecDownload = function() {
|
| 574 |
+
if (!_rec.blob) return;
|
| 575 |
+
const title = document.getElementById('vtv-rec-title-input')?.value || '';
|
| 576 |
+
if (_rec.ratio === 'original') {
|
| 577 |
+
downloadBlob(_rec.blob, `vtv-${_currentCh}-${Date.now()}.webm`);
|
| 578 |
+
} else {
|
| 579 |
+
processRatio(_rec.blob, _rec.ratio, 'download', title);
|
| 580 |
+
}
|
| 581 |
+
};
|
| 582 |
+
|
| 583 |
+
// ===== SHARE TO SHORT AI =====
|
| 584 |
+
window._vtvRecShare = function() {
|
| 585 |
+
if (!_rec.blob) return;
|
| 586 |
+
const title = document.getElementById('vtv-rec-title-input')?.value || '';
|
| 587 |
+
if (_rec.ratio !== 'original') {
|
| 588 |
+
processRatio(_rec.blob, _rec.ratio, 'upload', title);
|
| 589 |
+
} else {
|
| 590 |
+
uploadToWall(_rec.blob, title);
|
| 591 |
+
}
|
| 592 |
+
};
|
| 593 |
+
|
| 594 |
+
// ===== RATIO CROP — v7: reliable render with timeout fallback =====
|
| 595 |
+
function processRatio(blob, ratio, action, title) {
|
| 596 |
+
showProc(`🔄 Đang xử lý ${ratio}...`);
|
| 597 |
+
|
| 598 |
+
const blobUrl = URL.createObjectURL(blob);
|
| 599 |
+
const video = document.createElement('video');
|
| 600 |
+
video.src = blobUrl;
|
| 601 |
+
video.volume = 0;
|
| 602 |
+
video.preload = 'auto';
|
| 603 |
+
video.playsInline = true;
|
| 604 |
+
|
| 605 |
+
function onReady() {
|
| 606 |
+
video.removeEventListener('loadedmetadata', onReady);
|
| 607 |
+
video.removeEventListener('canplay', onReady);
|
| 608 |
+
|
| 609 |
+
const vw = video.videoWidth, vh = video.videoHeight;
|
| 610 |
+
if (!vw || !vh) {
|
| 611 |
+
hideProc(); recStatus('❌ Video không có kích thước hợp lệ.', 'err');
|
| 612 |
+
URL.revokeObjectURL(blobUrl); return;
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
let cw, ch, sx, sy;
|
| 616 |
+
if (ratio === '1:1') {
|
| 617 |
+
const size = Math.min(vw, vh); cw = ch = size;
|
| 618 |
+
sx = Math.floor((vw - size) / 2); sy = Math.floor((vh - size) / 2);
|
| 619 |
+
} else if (ratio === '9:16') {
|
| 620 |
+
ch = vh; cw = Math.min(vw, Math.round(vh * 9 / 16));
|
| 621 |
+
sx = Math.floor((vw - cw) / 2); sy = 0;
|
| 622 |
+
} else { cw = vw; ch = vh; sx = sy = 0; }
|
| 623 |
+
|
| 624 |
+
showProc(`🔄 Render ${cw}x${ch}...`);
|
| 625 |
+
|
| 626 |
+
const canvas = document.createElement('canvas');
|
| 627 |
+
canvas.width = cw; canvas.height = ch;
|
| 628 |
+
const ctx = canvas.getContext('2d');
|
| 629 |
+
const canvasStream = canvas.captureStream(30);
|
| 630 |
+
|
| 631 |
+
let recordStream = canvasStream;
|
| 632 |
+
const audioVideo = document.createElement('video');
|
| 633 |
+
audioVideo.src = blobUrl;
|
| 634 |
+
audioVideo.muted = true;
|
| 635 |
+
audioVideo.volume = 0;
|
| 636 |
+
audioVideo.preload = 'auto';
|
| 637 |
+
audioVideo.playsInline = true;
|
| 638 |
+
|
| 639 |
+
audioVideo.onloadedmetadata = function() {
|
| 640 |
+
try {
|
| 641 |
+
const aStr = audioVideo.captureStream ? audioVideo.captureStream() : null;
|
| 642 |
+
if (aStr) {
|
| 643 |
+
const tracks = aStr.getAudioTracks();
|
| 644 |
+
if (tracks.length > 0) {
|
| 645 |
+
recordStream = new MediaStream([...canvasStream.getVideoTracks(), ...tracks]);
|
| 646 |
+
console.log('[VTV Crop] Audio merged:', tracks.length, 'tracks');
|
| 647 |
+
}
|
| 648 |
+
}
|
| 649 |
+
} catch(e) { console.warn('[VTV Crop] Audio merge failed:', e); }
|
| 650 |
+
};
|
| 651 |
+
|
| 652 |
+
const mime = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus')
|
| 653 |
+
? 'video/webm;codecs=vp9,opus'
|
| 654 |
+
: MediaRecorder.isTypeSupported('video/webm;codecs=vp8,opus')
|
| 655 |
+
? 'video/webm;codecs=vp8,opus' : 'video/webm';
|
| 656 |
+
|
| 657 |
+
let rec;
|
| 658 |
+
try { rec = new MediaRecorder(recordStream, {mimeType: mime}); }
|
| 659 |
+
catch(e) { hideProc(); recStatus('❌ Lỗi MediaRecorder: ' + e.message, 'err'); URL.revokeObjectURL(blobUrl); return; }
|
| 660 |
+
|
| 661 |
+
const chunks = [];
|
| 662 |
+
rec.ondataavailable = e => { if(e.data && e.data.size > 0) chunks.push(e.data); };
|
| 663 |
+
rec.onstop = () => {
|
| 664 |
+
hideProc();
|
| 665 |
+
URL.revokeObjectURL(blobUrl);
|
| 666 |
+
if (chunks.length === 0) {
|
| 667 |
+
recStatus('❌ Render thất bại (không có data). Thử lại hoặc chọn "Gốc".', 'err');
|
| 668 |
+
return;
|
| 669 |
+
}
|
| 670 |
+
const out = new Blob(chunks, {type: 'video/webm'});
|
| 671 |
+
if (action === 'download') {
|
| 672 |
+
downloadBlob(out, `vtv-${_currentCh}-${ratio.replace(':','x')}-${Date.now()}.webm`);
|
| 673 |
+
} else {
|
| 674 |
+
uploadToWall(out, title);
|
| 675 |
+
}
|
| 676 |
+
};
|
| 677 |
+
rec.onerror = e => { hideProc(); recStatus('❌ Lỗi render: ' + (e.error?.message || '?'), 'err'); URL.revokeObjectURL(blobUrl); };
|
| 678 |
+
|
| 679 |
+
rec.start(200);
|
| 680 |
+
|
| 681 |
+
audioVideo.play().catch(() => {});
|
| 682 |
+
|
| 683 |
+
video.play().then(() => {
|
| 684 |
+
let finished = false;
|
| 685 |
+
function finish() {
|
| 686 |
+
if (finished) return;
|
| 687 |
+
finished = true;
|
| 688 |
+
console.log('[VTV Crop] finish() called');
|
| 689 |
+
try { rec.requestData(); } catch(e) {}
|
| 690 |
+
setTimeout(() => {
|
| 691 |
+
if (rec.state !== 'inactive') rec.stop();
|
| 692 |
+
try { audioVideo.pause(); } catch(e) {}
|
| 693 |
+
}, 300);
|
| 694 |
+
}
|
| 695 |
+
video.onended = finish;
|
| 696 |
+
audioVideo.onended = finish;
|
| 697 |
+
|
| 698 |
+
const expectedDur = (video.duration || 0) * 1000;
|
| 699 |
+
if (expectedDur > 0 && isFinite(expectedDur)) {
|
| 700 |
+
setTimeout(() => {
|
| 701 |
+
if (!finished) {
|
| 702 |
+
console.warn('[VTV Crop] Timeout fallback after', expectedDur + 3000, 'ms');
|
| 703 |
+
finish();
|
| 704 |
+
}
|
| 705 |
+
}, expectedDur + 3000);
|
| 706 |
+
} else {
|
| 707 |
+
console.warn('[VTV Crop] Duration unknown, using 60s timeout');
|
| 708 |
+
setTimeout(() => {
|
| 709 |
+
if (!finished) {
|
| 710 |
+
console.warn('[VTV Crop] 60s timeout fallback');
|
| 711 |
+
finish();
|
| 712 |
+
}
|
| 713 |
+
}, 60000);
|
| 714 |
+
}
|
| 715 |
+
|
| 716 |
+
function draw() {
|
| 717 |
+
if (video.paused || video.ended) return;
|
| 718 |
+
ctx.drawImage(video, sx, sy, cw, ch, 0, 0, cw, ch);
|
| 719 |
+
requestAnimationFrame(draw);
|
| 720 |
+
}
|
| 721 |
+
draw();
|
| 722 |
+
}).catch(e => {
|
| 723 |
+
hideProc(); recStatus('❌ Lỗi phát video: ' + e.message, 'err');
|
| 724 |
+
URL.revokeObjectURL(blobUrl);
|
| 725 |
+
});
|
| 726 |
+
}
|
| 727 |
+
|
| 728 |
+
video.onerror = function() {
|
| 729 |
+
hideProc(); recStatus('❌ Không đọc được video.', 'err');
|
| 730 |
+
URL.revokeObjectURL(blobUrl);
|
| 731 |
+
};
|
| 732 |
+
|
| 733 |
+
video.addEventListener('loadedmetadata', onReady);
|
| 734 |
+
video.addEventListener('canplay', onReady);
|
| 735 |
+
}
|
| 736 |
+
|
| 737 |
+
// ===== TOGGLE REC PANEL =====
|
| 738 |
+
function toggleRecPanel() {
|
| 739 |
+
const panel = document.getElementById('vtv-inline-rec');
|
| 740 |
+
if (!panel) { buildInlineRecorder(); setTimeout(toggleRecPanel, 200); return; }
|
| 741 |
+
_rec.active = !_rec.active;
|
| 742 |
+
panel.classList.toggle('show', _rec.active);
|
| 743 |
+
const btn = document.getElementById('vtv-rec-btn');
|
| 744 |
+
if (btn) {
|
| 745 |
+
if (_rec.active) {
|
| 746 |
+
btn.style.background = '#3a1a1a';
|
| 747 |
+
btn.innerHTML = '<svg viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/></svg> Đóng';
|
| 748 |
+
} else {
|
| 749 |
+
btn.style.background = '';
|
| 750 |
+
btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record';
|
| 751 |
+
document.getElementById('vtv-rec-panel').classList.remove('show');
|
| 752 |
+
}
|
| 753 |
+
}
|
| 754 |
+
}
|
| 755 |
+
|
| 756 |
+
// ===== PIN BUTTON =====
|
| 757 |
+
function setupPinButton(){
|
| 758 |
+
const block = document.getElementById('vtv-block');
|
| 759 |
+
if(!block) return;
|
| 760 |
+
const header = block.querySelector('.vtv-head');
|
| 761 |
+
if(!header || document.getElementById('vtv-pin-btn')) return;
|
| 762 |
+
const btn = document.createElement('button');
|
| 763 |
+
btn.id = 'vtv-pin-btn'; btn.className = 'vtv-pin-btn';
|
| 764 |
+
btn.innerHTML = '📌 Ghim';
|
| 765 |
+
btn.title = 'Ghim VTV cố định trên đầu trang';
|
| 766 |
+
btn.onclick = function(e){
|
| 767 |
+
e.stopPropagation(); _vtvPinned = !_vtvPinned;
|
| 768 |
+
if(_vtvPinned){ block.classList.add('vtv-sticky'); btn.classList.add('pinned'); btn.innerHTML = '📌 Đã ghim'; }
|
| 769 |
+
else { block.classList.remove('vtv-sticky'); btn.classList.remove('pinned'); btn.innerHTML = '📌 Ghim'; }
|
| 770 |
+
};
|
| 771 |
+
header.style.position = 'relative';
|
| 772 |
+
header.appendChild(btn);
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
// ===== MINI PLAYER =====
|
| 776 |
+
function createMiniPlayer(){
|
| 777 |
+
if(document.getElementById('vtv-mini')) return;
|
| 778 |
+
const mini = document.createElement('div');
|
| 779 |
+
mini.className = 'vtv-mini'; mini.id = 'vtv-mini';
|
| 780 |
+
mini.innerHTML = `<div class="vtv-mini-frame"><video id="vtv-mini-vid" playsinline muted preload="auto"></video></div>
|
| 781 |
+
<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>
|
| 782 |
+
<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>
|
| 783 |
+
<div class="vtv-mini-peek" id="vtv-mini-peek">📺 Xem</div>`;
|
| 784 |
+
document.body.appendChild(mini);
|
| 785 |
+
document.getElementById('vtv-mini-pip').onclick = async function(){
|
| 786 |
+
const v = document.getElementById('vtv-mini-vid');
|
| 787 |
+
try{ if(document.pictureInPictureElement === v) await document.exitPictureInPicture(); else await v.requestPictureInPicture(); }catch(e){}
|
| 788 |
+
};
|
| 789 |
+
document.getElementById('vtv-mini-expand').onclick = function(){ closeMiniPlayer(); if(typeof switchCat==='function') switchCat('home'); };
|
| 790 |
+
document.getElementById('vtv-mini-hide').onclick = ()=>{ mini.classList.add('hidden'); };
|
| 791 |
+
document.getElementById('vtv-mini-close').onclick = closeMiniPlayer;
|
| 792 |
+
document.getElementById('vtv-mini-peek').onclick = ()=>{ mini.classList.remove('hidden'); };
|
| 793 |
+
}
|
| 794 |
+
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; }
|
| 795 |
+
function activateMiniPlayer(){
|
| 796 |
+
if(!_currentCh) return; createMiniPlayer(); _miniActive=true;
|
| 797 |
+
const mini=document.getElementById('vtv-mini'); const mv=document.getElementById('vtv-mini-vid'); const v=document.getElementById('vtv-player');
|
| 798 |
+
if(_hls){const h=new Hls({enableWorker:true,lowLatencyMode:false,startLevel:0,capLevelToPlayerSize:true,maxBufferLength:4,fragLoadingTimeOut:8000,manifestLoadingTimeOut:8000}); h.loadSource(STREAMS[_currentCh][0]); h.attachMedia(mv); mv.play().catch(()=>{});}
|
| 799 |
+
else if(v&&v.src){mv.src=v.src; mv.play().catch(()=>{});}
|
| 800 |
+
const ch=CHANNELS.find(c=>c.id===_currentCh); if(ch) document.getElementById('vtv-mini-ch').textContent=ch.name;
|
| 801 |
+
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(()=>{});
|
| 802 |
+
mini.classList.add('show'); mini.classList.remove('hidden');
|
| 803 |
+
}
|
| 804 |
+
|
| 805 |
+
// ===== VTVGO POPUP PLAYER =====
|
| 806 |
+
function openVtvPopup(chId) {
|
| 807 |
+
const popupUrl = VTV_POPUP_URLS[chId];
|
| 808 |
+
if (!popupUrl) return;
|
| 809 |
+
const ch = CHANNELS.find(c => c.id === chId);
|
| 810 |
+
const channelName = ch ? ch.name : chId;
|
| 811 |
+
openVtvPopupWithUrl(popupUrl, '📺 ' + channelName);
|
| 812 |
+
}
|
| 813 |
+
|
| 814 |
+
function closeVtvPopup() {
|
| 815 |
+
const popup = document.getElementById('vtv-popup');
|
| 816 |
+
if (popup) {
|
| 817 |
+
popup.classList.remove('show');
|
| 818 |
+
const iframe = popup.querySelector('iframe');
|
| 819 |
+
if (iframe) iframe.src = '';
|
| 820 |
+
}
|
| 821 |
+
_vtvPopupActive = false;
|
| 822 |
+
}
|
| 823 |
+
|
| 824 |
+
// ===== WORLD CUP 2026 REPLAY SECTION =====
|
| 825 |
+
const WC2026_LINKS = [
|
| 826 |
+
{id:'wc-vtv3', name:'⚽ WC2026 - VTV3', popupUrl:'https://vtvgo.vn/channel/vtv3-1,3.html', badge:'VTV3'},
|
| 827 |
+
{id:'wc-vtv6', name:'⚽ WC2026 - VTV6', popupUrl:'https://package.vtvgo.vn/channel/vtv6-1,13.html', badge:'VTV6'},
|
| 828 |
+
];
|
| 829 |
+
|
| 830 |
+
// TS token links for World Cup replay (user can add more)
|
| 831 |
+
const WC2026_TS_LINKS = [
|
| 832 |
+
{id:'wc-ts-1', name:'⚽ WC2026 - Xem lại (TS Token)', tsUrl:'https://package.vtvgo.vn/ts/62FPb0qUU6-20260626-328751', badge:'Replay'},
|
| 833 |
+
];
|
| 834 |
+
|
| 835 |
+
function buildWc2026Section(){
|
| 836 |
+
const w=document.createElement('div'); w.className='vtv-wrap'; w.id='vtv-wc2026';
|
| 837 |
+
w.style.borderColor='#e6b800';
|
| 838 |
+
let tabs='';
|
| 839 |
+
WC2026_LINKS.forEach(ch=>{
|
| 840 |
+
tabs+='<div style="display:flex;align-items:center"><button class="vtv-tab" id="vtvt-wc-'+ch.id+'" onclick="window._vtvOpenUrlPopup(\''+ch.popupUrl+'\')" style="background:#1a2a1a;border-color:#2d4a2d;color:#8ad88a">'+ch.name+'</button></div>';
|
| 841 |
+
});
|
| 842 |
+
WC2026_TS_LINKS.forEach(ch=>{
|
| 843 |
+
tabs+='<div style="display:flex;align-items:center"><button class="vtv-tab" id="vtvt-wc-ts-'+ch.id+'" onclick="window._vtvOpenTsPopup(\''+ch.tsUrl+'\')" style="background:#2a1a1a;border-color:#4a2d2d;color:#d88a8a">'+ch.name+'</button></div>';
|
| 844 |
+
});
|
| 845 |
+
w.innerHTML='<div class="vtv-head" style="background:linear-gradient(90deg,#4a3a00,#1a1a1a)"><span class="vtv-title" style="color:#ffcc00">⚽ World Cup 2026</span><span class="vtv-badge" style="color:#ffcc00">FIFA</span></div>'+
|
| 846 |
+
'<div class="vtv-tabs" style="flex-wrap:wrap">'+tabs+'</div>'+
|
| 847 |
+
'<div class="vtv-wc-info" style="padding:6px 10px;background:#0a1628;border-top:1px solid #2a2a1a;font-size:9px;color:#888;text-align:center">Nhấn nút để mở player VTVGO xem trực tiếp / xem lại World Cup 2026</div>';
|
| 848 |
+
return w;
|
| 849 |
+
}
|
| 850 |
+
|
| 851 |
+
// Open any URL in popup (for WC2026 direct links)
|
| 852 |
+
window._vtvOpenUrlPopup=function(popupUrl) {
|
| 853 |
+
if (!popupUrl) return;
|
| 854 |
+
openVtvPopupWithUrl(popupUrl, '', true);
|
| 855 |
+
};
|
| 856 |
+
|
| 857 |
+
window._vtvOpenPopup=function(chId) {
|
| 858 |
+
openVtvPopup(chId);
|
| 859 |
+
};
|
| 860 |
+
|
| 861 |
+
function openVtvPopupWithUrl(popupUrl, title, hideHeader) {
|
| 862 |
+
let popup = document.getElementById('vtv-popup');
|
| 863 |
+
if (!popup) {
|
| 864 |
+
popup = document.createElement('div');
|
| 865 |
+
popup.id = 'vtv-popup';
|
| 866 |
+
popup.className = 'vtv-popup';
|
| 867 |
+
popup.innerHTML = `
|
| 868 |
+
<div class="vtv-popup-header" id="vtv-popup-hdr">
|
| 869 |
+
<span class="vtv-popup-title" id="vtv-popup-title">📺 VTV Player</span>
|
| 870 |
+
<button class="vtv-popup-close" id="vtv-popup-close">✕</button>
|
| 871 |
+
</div>
|
| 872 |
+
<div class="vtv-popup-frame" id="vtv-popup-frame"></div>
|
| 873 |
+
`;
|
| 874 |
+
document.body.appendChild(popup);
|
| 875 |
+
document.getElementById('vtv-popup-close').onclick = closeVtvPopup;
|
| 876 |
+
}
|
| 877 |
+
|
| 878 |
+
_vtvPopupActive = true;
|
| 879 |
+
const hdr = document.getElementById('vtv-popup-hdr');
|
| 880 |
+
if (hdr) hdr.style.display = hideHeader ? 'none' : 'flex';
|
| 881 |
+
document.getElementById('vtv-popup-title').textContent = title || '📺 VTV Player';
|
| 882 |
+
const frame = document.getElementById('vtv-popup-frame');
|
| 883 |
+
|
| 884 |
+
// Show loader
|
| 885 |
+
frame.innerHTML = '<div class="vtv-popup-loader" id="vtv-popup-loader"><div class="vtv-spinner"></div>Đang tải player...</div>';
|
| 886 |
+
|
| 887 |
+
// Use iframe but handle blocked embeds gracefully
|
| 888 |
+
const iframe = document.createElement('iframe');
|
| 889 |
+
iframe.id = 'vtv-popup-iframe';
|
| 890 |
+
iframe.setAttribute('allowfullscreen', 'true');
|
| 891 |
+
iframe.setAttribute('webkitallowfullscreen', 'true');
|
| 892 |
+
iframe.setAttribute('mozallowfullscreen', 'true');
|
| 893 |
+
iframe.setAttribute('allow', 'autoplay; encrypted-media; fullscreen');
|
| 894 |
+
iframe.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;border:none;display:none';
|
| 895 |
+
|
| 896 |
+
// Timeout to detect blocked iframe (onload fires even for blocked, so use timeout)
|
| 897 |
+
let loaded = false;
|
| 898 |
+
iframe.onload = () => { loaded = true; const l = document.getElementById('vtv-popup-loader'); if(l) l.style.display='none'; iframe.style.display = 'block'; };
|
| 899 |
+
frame.appendChild(iframe);
|
| 900 |
+
popup.classList.add('show');
|
| 901 |
+
|
| 902 |
+
// Set src after append to ensure events fire
|
| 903 |
+
setTimeout(() => { iframe.src = popupUrl; }, 50);
|
| 904 |
+
|
| 905 |
+
// If still not loaded after 4s, fallback to new window
|
| 906 |
+
setTimeout(() => {
|
| 907 |
+
if (!loaded && popup.classList.contains('show')) {
|
| 908 |
+
window.open(popupUrl, '_blank');
|
| 909 |
+
closeVtvPopup();
|
| 910 |
+
}
|
| 911 |
+
}, 4000);
|
| 912 |
+
}
|
| 913 |
+
|
| 914 |
+
// Open TS token URL in popup
|
| 915 |
+
window._vtvOpenTsPopup=function(tsUrl) {
|
| 916 |
+
openVtvPopupWithUrl(tsUrl, '', true);
|
| 917 |
+
};
|
| 918 |
+
|
| 919 |
+
window._vtvClosePopup=function() { closeVtvPopup(); };
|
| 920 |
+
|
| 921 |
+
// ===== MAIN PLAYER =====
|
| 922 |
+
async function loadAllStreams(){
|
| 923 |
+
if(_loading) return; _loading=true;
|
| 924 |
+
const el=document.getElementById('vtv-load'); if(el) el.innerHTML='<div class="vtv-spinner"></div>Đang tải kênh...';
|
| 925 |
+
try{const r=await fetch('/api/vtv/streams'); if(r.ok){const d=await r.json(); CHANNELS.forEach(ch=>{const i=d[ch.id]; if(i&&i.stream_url){let u=i.stream_url; if(NEEDS_PROXY.test(u)) u='/api/proxy/m3u8/vtv?url='+encodeURIComponent(u); STREAMS[ch.id]=[u];} else STREAMS[ch.id]=[];});}}catch(e){}
|
| 926 |
+
CHANNELS.forEach(ch=>{
|
| 927 |
+
const t=document.querySelector('#vtvt-'+ch.id);
|
| 928 |
+
if(t){
|
| 929 |
+
if(STREAMS[ch.id]&&STREAMS[ch.id].length>0){
|
| 930 |
+
t.classList.remove('off');
|
| 931 |
+
}else{
|
| 932 |
+
t.style.opacity='0.35';
|
| 933 |
+
}
|
| 934 |
+
}
|
| 935 |
+
});
|
| 936 |
+
_loading=false; _streamsLoaded=true;
|
| 937 |
+
}
|
| 938 |
+
|
| 939 |
+
function buildBlock(){
|
| 940 |
+
const w=document.createElement('div'); w.className='vtv-wrap'; w.id='vtv-block';
|
| 941 |
+
let tabs=''; CHANNELS.forEach(ch=>{
|
| 942 |
+
tabs+='<div style="display:flex;align-items:center"><button class="vtv-tab off" id="vtvt-'+ch.id+'" onclick="window._vtvPlay(\''+ch.id+'\')">'+ch.name+'</button><button class="vtv-popup-btn" onclick="window._vtvOpenPopup(\''+ch.id+'\')" title="Mở player VTVGO">📺</button></div>';
|
| 943 |
+
});
|
| 944 |
+
w.innerHTML='<div class="vtv-head"><span class="vtv-title">📺 VTV Trực Tuyến</span><span class="vtv-badge">● LIVE</span></div>'+
|
| 945 |
+
'<div class="vtv-tabs" style="flex-wrap:wrap">'+tabs+'</div>'+
|
| 946 |
+
'<div class="vtv-frame"><div class="vtv-load" id="vtv-load"><div class="vtv-spinner"></div>Đang tải kênh...</div><video id="vtv-player" playsinline muted controls preload="auto" style="display:none"></video><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></div>'+
|
| 947 |
+
'<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>'+
|
| 948 |
+
'<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>';
|
| 949 |
+
return w;
|
| 950 |
+
}
|
| 951 |
+
|
| 952 |
+
async function loadEpg(chId){
|
| 953 |
+
const el=document.getElementById('vtv-epg-list'); if(!el) return;
|
| 954 |
+
el.innerHTML='<div class="vtv-epg-loading"><div class="vtv-epg-sp"></div>Đang tải lịch...</div>';
|
| 955 |
+
try{const r=await fetch('/api/vtv/epg/'+chId); 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>';}
|
| 956 |
+
}
|
| 957 |
+
|
| 958 |
+
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';}};
|
| 959 |
+
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){}};
|
| 960 |
+
|
| 961 |
+
function pinBlock(){
|
| 962 |
+
const h=document.getElementById('view-home'); if(!h||document.getElementById('vtv-block')) return;
|
| 963 |
+
h.insertBefore(buildBlock(),h.firstChild); _blockInserted=true;
|
| 964 |
+
// Add World Cup 2026 section below VTV block
|
| 965 |
+
if(!document.getElementById('vtv-wc2026')){
|
| 966 |
+
const wcBlock = buildWc2026Section();
|
| 967 |
+
h.insertBefore(wcBlock, document.getElementById('vtv-block').nextSibling);
|
| 968 |
+
}
|
| 969 |
+
setTimeout(()=>{setupPinButton();buildInlineRecorder();},500);
|
| 970 |
+
if(!_streamsLoaded){loadAllStreams().then(()=>{setTimeout(()=>window._vtvPlay(DEFAULT_CHANNEL),300);});}
|
| 971 |
+
}
|
| 972 |
+
|
| 973 |
+
window._vtvRetry=function(){if(_currentCh)window._vtvPlay(_currentCh);};
|
| 974 |
+
|
| 975 |
+
window._vtvPlay=function(chId){
|
| 976 |
+
const ch=CHANNELS.find(c=>c.id===chId); if(!ch) return; _currentCh=chId;
|
| 977 |
+
document.querySelectorAll('.vtv-tab').forEach(t=>t.classList.remove('on'));
|
| 978 |
+
const tab=document.getElementById('vtvt-'+chId); if(tab) tab.classList.add('on');
|
| 979 |
+
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');
|
| 980 |
+
video.style.display='none'; errEl.style.display='none'; loadEl.style.display='flex';
|
| 981 |
+
loadEl.innerHTML='<div class="vtv-spinner"></div>Đang kết nối '+ch.name+'...';
|
| 982 |
+
if(_hls){_hls.destroy();_hls=null;}
|
| 983 |
+
const urls=STREAMS[chId]||[];
|
| 984 |
+
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;}
|
| 985 |
+
_tryPlay(video,urls,0,ch.name,loadEl,errEl,errMsg);
|
| 986 |
+
loadEpg(chId);
|
| 987 |
+
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(()=>{});}
|
| 988 |
+
};
|
| 989 |
+
|
| 990 |
+
function _tryPlay(video,urls,idx,name,loadEl,errEl,errMsg){
|
| 991 |
+
if(idx>=urls.length){loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent=name+': Tất cả nguồn lỗi.';return;}
|
| 992 |
+
const src=urls[idx]; loadEl.innerHTML='<div class="vtv-spinner"></div>Kết nối '+name+' ('+(idx+1)+'/'+urls.length+')...';
|
| 993 |
+
if(typeof Hls!=='undefined'&&Hls.isSupported()){
|
| 994 |
+
const hls=new Hls({enableWorker:true,lowLatencyMode:false,startLevel:0,capLevelToPlayerSize:true,maxBufferLength:6,maxMaxBufferLength:10,liveSyncDurationCount:2,liveMaxLatencyDurationCount:3,fragLoadingTimeOut:10000,manifestLoadingTimeOut:10000}); _hls=hls; hls.loadSource(src); hls.attachMedia(video);
|
| 995 |
+
hls.on(Hls.Events.MANIFEST_PARSED,()=>{video.play().catch(()=>{});loadEl.style.display='none';video.style.display='block';});
|
| 996 |
+
let rec=0; hls.on(Hls.Events.ERROR,(ev,data)=>{if(data.fatal){if(data.type===Hls.ErrorTypes.NETWORK_ERROR){rec++;if(rec<=3)setTimeout(()=>hls.startLoad(),2000);else{hls.destroy();_hls=null;_tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);}}else if(data.type===Hls.ErrorTypes.MEDIA_ERROR){try{hls.recoverMediaError();}catch(e){}}else{hls.destroy();_hls=null;_tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);}}});
|
| 997 |
+
}else if(video.canPlayType('application/vnd.apple.mpegurl')){
|
| 998 |
+
video.src=src;
|
| 999 |
+
video.addEventListener('loadedmetadata',()=>{video.play().catch(()=>{});loadEl.style.display='none';video.style.display='block';},{once:true});
|
| 1000 |
+
video.addEventListener('error',()=>{_tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);},{once:true});
|
| 1001 |
+
}else{loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent='Không hỗ trợ HLS';}
|
| 1002 |
+
}
|
| 1003 |
+
|
| 1004 |
+
// ===== NAV HOOKS =====
|
| 1005 |
+
const _origShowView=window.showView;
|
| 1006 |
+
window.showView=function(id){if(id==='view-home'&&_miniActive)closeMiniPlayer();else if(id!=='view-home'&&_currentCh&&STREAMS[_currentCh]&&STREAMS[_currentCh].length>0&&!_miniActive)activateMiniPlayer();return _origShowView.apply(this,arguments);};
|
| 1007 |
+
const _origReadArticle=window.readArticle; if(_origReadArticle){window.readArticle=function(url){if(_currentCh&&STREAMS[_currentCh]&&STREAMS[_currentCh].length>0&&!_miniActive)activateMiniPlayer();return _origReadArticle.apply(this,arguments);};}
|
| 1008 |
+
const _origSwitchCat=window.switchCat; if(_origSwitchCat){window.switchCat=function(id){if(id==='home'){if(_miniActive)closeMiniPlayer();}else if(_currentCh&&STREAMS[_currentCh]&&STREAMS[_currentCh].length>0&&!_miniActive)activateMiniPlayer();return _origSwitchCat.apply(this,arguments);};}
|
| 1009 |
+
|
| 1010 |
+
const _origLoadHome=window.loadHome;
|
| 1011 |
+
if(_origLoadHome&&!_origLoadHome.__vtvWrapped){
|
| 1012 |
+
window.loadHome=async function(){const old=document.getElementById('vtv-block');if(old)old.remove();const wc=document.getElementById('vtv-wc2026');if(wc)wc.remove();_blockInserted=false;const r=await _origLoadHome.apply(this,arguments);try{pinBlock();}catch(e){}return r;};
|
| 1013 |
+
window.loadHome.__vtvWrapped=true;
|
| 1014 |
+
}
|
| 1015 |
+
})();
|
vtv_api.py
CHANGED
|
@@ -1,12 +1,18 @@
|
|
| 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 = {
|
|
@@ -14,214 +20,527 @@ UA = {
|
|
| 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 |
-
"
|
| 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 |
-
"
|
| 43 |
-
"
|
| 44 |
-
"
|
| 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 |
-
|
| 105 |
-
is_flv = stream_url and ".flv" in stream_url
|
| 106 |
-
result[ch_id] = {
|
| 107 |
-
"name": CHANNEL_NAMES[ch_id],
|
| 108 |
-
"stream_url": stream_url,
|
| 109 |
-
"proxy_url": f"/api/proxy/flv?url={quote(stream_url, safe='')}" if stream_url and is_flv else "",
|
| 110 |
-
"proxy_url_hls": f"/api/proxy/stream?url={quote(stream_url, safe='')}" if stream_url and not is_flv else "",
|
| 111 |
-
"is_flv": is_flv,
|
| 112 |
-
"iframe_url": iframe_url,
|
| 113 |
-
"status": "ok"
|
| 114 |
-
}
|
| 115 |
return JSONResponse(result)
|
| 116 |
|
| 117 |
@router.get("/api/vtv/stream/{channel_id}")
|
| 118 |
def api_vtv_stream(channel_id: str):
|
| 119 |
-
|
| 120 |
-
if
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
is_flv = stream_url and ".flv" in stream_url
|
| 124 |
-
m3u8_proxy = ""
|
| 125 |
-
if stream_url and not is_flv and stream_url.startswith("http"):
|
| 126 |
-
m3u8_proxy = f"/api/proxy/m3u8?url={quote(stream_url, safe='')}"
|
| 127 |
-
return JSONResponse({
|
| 128 |
-
"name": CHANNEL_NAMES[channel_id],
|
| 129 |
-
"stream_url": stream_url,
|
| 130 |
-
"m3u8_proxy_url": m3u8_proxy,
|
| 131 |
-
"proxy_url": f"/api/proxy/flv?url={quote(stream_url, safe='')}" if stream_url and is_flv else "",
|
| 132 |
-
"proxy_url_hls": f"/api/proxy/stream?url={quote(stream_url, safe='')}" if stream_url and not is_flv else "",
|
| 133 |
-
"is_flv": is_flv,
|
| 134 |
-
"iframe_url": iframe_url,
|
| 135 |
-
"status": "ok"
|
| 136 |
-
})
|
| 137 |
|
| 138 |
-
@router.get("/api/
|
| 139 |
-
def
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
start_dt = _parse_time(time_str, reference_date=now_vn)
|
| 197 |
-
if not start_dt:
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
return epg_data
|
| 203 |
|
| 204 |
-
|
| 205 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 215 |
start_dt = p.get("start_dt")
|
| 216 |
-
|
| 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 xemtv.us PHP endpoints (primary)
|
| 4 |
+
Fallback: FPTPlay CDN → VTVGo CDN → xemtv.net (legacy)
|
| 5 |
+
EPG data scraped from https://vtv.vn/lich-phat-song.htm
|
| 6 |
+
"""
|
| 7 |
+
import re, time, threading
|
| 8 |
+
import requests
|
| 9 |
from fastapi import APIRouter, Query
|
| 10 |
+
from fastapi.responses import JSONResponse, Response
|
| 11 |
from bs4 import BeautifulSoup
|
| 12 |
from datetime import datetime, timedelta, timezone
|
|
|
|
| 13 |
|
| 14 |
VN_TZ = timezone(timedelta(hours=7))
|
| 15 |
+
|
| 16 |
router = APIRouter()
|
| 17 |
|
| 18 |
UA = {
|
|
|
|
| 20 |
"Accept-Language": "vi-VN,vi;q=0.9",
|
| 21 |
}
|
| 22 |
|
| 23 |
+
# ===== PRIMARY: xemtv.us (new domain, works 2025-2026) =====
|
| 24 |
+
XEMTV_US_ENDPOINTS = {
|
| 25 |
+
"vtv1": "https://xemtv.us/tv/vtv1.php",
|
| 26 |
+
"vtv2": "https://xemtv.us/tv/vtv2.php",
|
| 27 |
+
"vtv3": "https://xemtv.us/tv/vtv3.php",
|
| 28 |
+
"vtv4": "https://xemtv.us/tv/vtv4.php",
|
| 29 |
+
"vtv5": "https://xemtv.us/tv/vtv5.php",
|
| 30 |
+
"vtv6": "https://xemtv.us/tv/vtv6.php",
|
| 31 |
+
"vtv7": "https://xemtv.us/tv/vtv7.php",
|
| 32 |
+
"vtv8": "https://xemtv.us/tv/vtv8.php",
|
| 33 |
+
"vtv9": "https://xemtv.us/tv/vtv9.php",
|
| 34 |
+
"vtv10": "https://xemtv.us/tv/vtv10.php",
|
| 35 |
+
"vtvprime": "https://xemtv.us/tv/vtvprime.php",
|
| 36 |
}
|
| 37 |
+
|
| 38 |
+
# ===== LEGACY: xemtv.net (may return 403, keep as last resort) =====
|
| 39 |
+
XEMTV_LEGACY_ENDPOINTS = {
|
| 40 |
+
"vtv1": "https://hd.xemtv.net/kenh/vtv1.php",
|
| 41 |
+
"vtv2": "https://hd.xemtv.net/kenh/vtv2.php",
|
| 42 |
+
"vtv3": "https://hd.xemtv.net/kenh/vtv3.php",
|
| 43 |
+
"vtv4": "https://hd.xemtv.net/kenh/vtv4.php",
|
| 44 |
+
"vtv5": "https://hd.xemtv.net/kenh/vtv5.php",
|
| 45 |
+
"vtv6": "https://hd.xemtv.net/kenh/vtv6.php",
|
| 46 |
+
"vtv7": "https://hd.xemtv.net/kenh/vtv7.php",
|
| 47 |
+
"vtv8": "https://hd.xemtv.net/kenh/vtv8.php",
|
| 48 |
+
"vtv9": "https://hd.xemtv.net/kenh/vtv9.php",
|
| 49 |
+
"vtv10": "https://hd.xemtv.net/kenh/vtv10.php",
|
| 50 |
+
"vtvprime": "https://hd.xemtv.net/kenh/vtvprime.php",
|
|
|
|
|
|
|
|
|
|
| 51 |
}
|
| 52 |
|
| 53 |
+
CHANNEL_NAMES = {
|
| 54 |
+
"vtv1": "VTV1",
|
| 55 |
+
"vtv2": "VTV2",
|
| 56 |
+
"vtv3": "VTV3",
|
| 57 |
+
"vtv4": "VTV4",
|
| 58 |
+
"vtv5": "VTV5",
|
| 59 |
+
"vtv6": "VTV6",
|
| 60 |
+
"vtv7": "VTV7",
|
| 61 |
+
"vtv8": "VTV8",
|
| 62 |
+
"vtv9": "VTV9",
|
| 63 |
+
"vtv10": "VTV10",
|
| 64 |
+
"vtvprime": "VTVPrime",
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
# ===== FALLBACK 1: FPTPlay CDN (new URLs 2025-2026) =====
|
| 68 |
+
FPTPLAY_URLS = {
|
| 69 |
+
"vtv1": "https://live-a.fptplay53.net/live/media/vtv1/live247-hls-avc/index.m3u8",
|
| 70 |
+
"vtv2": "https://live-a.fptplay53.net/live/media/vtv2/live247-hls-avc/index.m3u8",
|
| 71 |
+
"vtv3": "https://live-a.fptplay53.net/live/media/vtv3/live247-hls-avc/index.m3u8",
|
| 72 |
+
"vtv4": "https://live-a.fptplay53.net/live/media/vtv4/live247-hls-avc/index.m3u8",
|
| 73 |
+
"vtv5": "https://live-a.fptplay53.net/live/media/vtv5/live247-hls-avc/index.m3u8",
|
| 74 |
+
"vtv6": "https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/index.m3u8",
|
| 75 |
+
"vtv7": "https://live-a.fptplay53.net/live/media/vtv7/live247-hls-avc/index.m3u8",
|
| 76 |
+
"vtv8": "https://live-a.fptplay53.net/live/media/vtv8/live-hls-avc/index.m3u8",
|
| 77 |
+
"vtv9": "https://live-a.fptplay53.net/live/media/vtv9/live247-hls-avc/index.m3u8",
|
| 78 |
+
"vtv10": "https://live-a.fptplay53.net/live/media/vtv10/live247-hls-avc/index.m3u8",
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
# ===== FALLBACK 2: VTVGo CDN =====
|
| 82 |
+
VTVGO_FAILOVER = {
|
| 83 |
+
"vtv1": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8",
|
| 84 |
+
"vtv2": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv2-manifest.m3u8",
|
| 85 |
+
"vtv3": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv3-manifest.m3u8",
|
| 86 |
+
"vtv4": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv4-manifest.m3u8",
|
| 87 |
+
"vtv5": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv5-manifest.m3u8",
|
| 88 |
+
"vtv6": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv6-manifest.m3u8",
|
| 89 |
+
"vtv7": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv7-manifest.m3u8",
|
| 90 |
+
"vtv8": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv8-manifest.m3u8",
|
| 91 |
+
"vtv9": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv9-manifest.m3u8",
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
_vtv_cache = {}
|
| 95 |
+
_vtv_lock = threading.Lock()
|
| 96 |
+
_CACHE_TTL = 180
|
| 97 |
+
|
| 98 |
+
def _cached(key):
|
| 99 |
+
with _vtv_lock:
|
| 100 |
+
if key in _vtv_cache and time.time() - _vtv_cache[key]['t'] < _CACHE_TTL:
|
| 101 |
+
return _vtv_cache[key]['d']
|
| 102 |
+
return None
|
| 103 |
+
|
| 104 |
+
def _set_cache(key, data):
|
| 105 |
+
with _vtv_lock:
|
| 106 |
+
_vtv_cache[key] = {'t': time.time(), 'd': data}
|
| 107 |
+
|
| 108 |
+
def extract_m3u8_from_html(html):
|
| 109 |
+
if not html:
|
| 110 |
+
return None
|
| 111 |
+
m = re.search(r"file\s*:\s*['\"]([^'\"]*\.m3u8[^'\"]*)['\"]", html, re.IGNORECASE)
|
| 112 |
+
if m:
|
| 113 |
+
url = m.group(1).strip()
|
| 114 |
+
if len(url) > 20:
|
| 115 |
+
return url
|
| 116 |
+
m = re.search(r"(https?://[^\s\"'<>\\]+\.m3u8[^\s\"'<>\\]*)", html, re.IGNORECASE)
|
| 117 |
+
if m:
|
| 118 |
+
url = m.group(1).strip()
|
| 119 |
+
if len(url) > 20:
|
| 120 |
+
return url
|
| 121 |
+
return None
|
| 122 |
+
|
| 123 |
+
def fetch_xemtv_us_stream(channel_id):
|
| 124 |
+
php_url = XEMTV_US_ENDPOINTS.get(channel_id)
|
| 125 |
+
if not php_url:
|
| 126 |
+
return None
|
| 127 |
+
try:
|
| 128 |
+
headers = {**UA, "Referer": "https://xemtv.us/"}
|
| 129 |
+
r = requests.get(php_url, headers=headers, timeout=15, allow_redirects=True, verify=False)
|
| 130 |
+
if r.status_code == 200:
|
| 131 |
+
m3u8 = extract_m3u8_from_html(r.text)
|
| 132 |
+
if m3u8:
|
| 133 |
+
return m3u8
|
| 134 |
+
except:
|
| 135 |
+
pass
|
| 136 |
+
return None
|
| 137 |
+
|
| 138 |
+
def fetch_xemtv_legacy_stream(channel_id):
|
| 139 |
+
php_url = XEMTV_LEGACY_ENDPOINTS.get(channel_id)
|
| 140 |
+
if not php_url:
|
| 141 |
+
return None
|
| 142 |
+
try:
|
| 143 |
+
headers = {**UA, "Referer": "https://hd.xemtv.net/"}
|
| 144 |
+
r = requests.get(php_url, headers=headers, timeout=15, allow_redirects=True, verify=False)
|
| 145 |
+
if r.status_code == 200:
|
| 146 |
+
m3u8 = extract_m3u8_from_html(r.text)
|
| 147 |
+
if m3u8:
|
| 148 |
+
return m3u8
|
| 149 |
+
except:
|
| 150 |
+
pass
|
| 151 |
+
return None
|
| 152 |
+
|
| 153 |
+
def fetch_fptplay_stream(channel_id):
|
| 154 |
+
url = FPTPLAY_URLS.get(channel_id)
|
| 155 |
+
if not url:
|
| 156 |
+
return None
|
| 157 |
+
try:
|
| 158 |
+
headers = {
|
| 159 |
+
"User-Agent": UA["User-Agent"],
|
| 160 |
+
"Referer": "https://fptplay.vn/",
|
| 161 |
+
"Origin": "https://fptplay.vn",
|
| 162 |
+
}
|
| 163 |
+
r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=False)
|
| 164 |
+
if r.status_code == 200 and '#EXTM3U' in r.text[:200]:
|
| 165 |
+
return url
|
| 166 |
+
except:
|
| 167 |
+
pass
|
| 168 |
+
return None
|
| 169 |
+
|
| 170 |
+
def fetch_vtvgo_stream(channel_id):
|
| 171 |
+
url = VTVGO_FAILOVER.get(channel_id)
|
| 172 |
+
if not url:
|
| 173 |
+
return None
|
| 174 |
+
try:
|
| 175 |
+
headers = {**UA, "Referer": "https://vtvgo.vn/"}
|
| 176 |
+
r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=False)
|
| 177 |
+
if r.status_code == 200 and '#EXTM3U' in r.text[:200]:
|
| 178 |
+
return url
|
| 179 |
+
except:
|
| 180 |
+
pass
|
| 181 |
+
return None
|
| 182 |
+
|
| 183 |
+
def normalize_fptplay_url(url):
|
| 184 |
+
"""Replace old/broken FPTPlay URLs with new working ones"""
|
| 185 |
+
if not url:
|
| 186 |
+
return url
|
| 187 |
+
old_to_new = {
|
| 188 |
+
"https://live.fptplay53.net/fnxch2/vtv1hd_abr.smil/chunklist.m3u8":
|
| 189 |
+
"https://live-a.fptplay53.net/live/media/vtv1/live247-hls-avc/index.m3u8",
|
| 190 |
+
"https://live.fptplay53.net/fnxch2/vtv2hd_abr.smil/chunklist.m3u8":
|
| 191 |
+
"https://live-a.fptplay53.net/live/media/vtv2/live247-hls-avc/index.m3u8",
|
| 192 |
+
"https://live.fptplay53.net/fnxch2/vtv3hd_abr.smil/chunklist.m3u8":
|
| 193 |
+
"https://live-a.fptplay53.net/live/media/vtv3/live247-hls-avc/index.m3u8",
|
| 194 |
+
"https://live.fptplay53.net/fnxch2/vtv4hd_abr.smil/chunklist.m3u8":
|
| 195 |
+
"https://live-a.fptplay53.net/live/media/vtv4/live247-hls-avc/index.m3u8",
|
| 196 |
+
"https://live.fptplay53.net/fnxhd1/vtv5hd_vhls.smil/chunklist.m3u8":
|
| 197 |
+
"https://live-a.fptplay53.net/live/media/vtv5/live247-hls-avc/index.m3u8",
|
| 198 |
+
"https://live.fptplay53.net/fnxhd1/vtv6hd_vhls.smil/chunklist.m3u8":
|
| 199 |
+
"https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/index.m3u8",
|
| 200 |
+
"https://live.fptplay53.net/fnxhd1/vtv7hd_vhls.smil/chunklist_b5000000.m3u8":
|
| 201 |
+
"https://live-a.fptplay53.net/live/media/vtv7/live247-hls-avc/index.m3u8",
|
| 202 |
+
"https://live.fptplay53.net/epzhd1/vtv8hd_vhls.smil/c.hunklist.m3u8":
|
| 203 |
+
"https://live-a.fptplay53.net/live/media/vtv8/live-hls-avc/index.m3u8",
|
| 204 |
+
"https://live.fptplay53.net/epzhd1/vtv8hd_vhls.smil/chunklist.m3u8":
|
| 205 |
+
"https://live-a.fptplay53.net/live/media/vtv8/live-hls-avc/index.m3u8",
|
| 206 |
+
"https://live.fptplay53.net/fnxhd1/vtv9hd_vhls.smil/chunklist.m3u8":
|
| 207 |
+
"https://live-a.fptplay53.net/live/media/vtv9/live247-hls-avc/index.m3u8",
|
| 208 |
+
"https://live.fptplay53.net/fnxhd1/vtv10hd_vhls.smil/chunklist.m3u8":
|
| 209 |
+
"https://live-a.fptplay53.net/live/media/vtv10/live247-hls-avc/index.m3u8",
|
| 210 |
+
"https://live-a.fptplay53.net/live/media/VTV5HD/live_hls_avc/index.m3u8":
|
| 211 |
+
"https://live-a.fptplay53.net/live/media/vtv5/live247-hls-avc/index.m3u8",
|
| 212 |
+
}
|
| 213 |
+
return old_to_new.get(url, url)
|
| 214 |
+
|
| 215 |
+
def fetch_vtv_stream(channel_id):
|
| 216 |
+
"""Fetch VTV stream with multi-source fallback chain:
|
| 217 |
+
1. xemtv.us (primary - new domain, most reliable)
|
| 218 |
+
2. FPTPlay CDN (fallback - new URLs)
|
| 219 |
+
3. VTVGo CDN (fallback)
|
| 220 |
+
4. xemtv.net legacy (last resort)
|
| 221 |
+
"""
|
| 222 |
+
channel_id = channel_id.lower().strip()
|
| 223 |
+
name_map = {
|
| 224 |
+
'vtvct': 'vtv10', 'vtv-can-tho': 'vtv10', 'vtv can tho': 'vtv10',
|
| 225 |
+
'vtv_can_tho': 'vtv10', 'cantho': 'vtv10',
|
| 226 |
+
'vietnam_vtv1': 'vtv1', 'vietnam_vtv2': 'vtv2', 'vietnam_vtv3': 'vtv3',
|
| 227 |
+
'vietnam_vtv4': 'vtv4', 'vietnam_vtv5': 'vtv5', 'vietnam_vtv6': 'vtv6',
|
| 228 |
+
'vietnam_vtv7': 'vtv7', 'vietnam_vtv8': 'vtv8', 'vietnam_vtv9': 'vtv9',
|
| 229 |
+
}
|
| 230 |
+
channel_id = name_map.get(channel_id, channel_id)
|
| 231 |
+
cached = _cached(channel_id)
|
| 232 |
+
if cached is not None:
|
| 233 |
+
return cached
|
| 234 |
+
|
| 235 |
+
if channel_id == 'vtvprime':
|
| 236 |
+
url = fetch_xemtv_us_stream('vtvprime') or fetch_xemtv_legacy_stream('vtvprime')
|
| 237 |
+
if url:
|
| 238 |
+
url = normalize_fptplay_url(url)
|
| 239 |
+
_set_cache(channel_id, url)
|
| 240 |
+
return url
|
| 241 |
+
|
| 242 |
+
# Source 1: xemtv.us (primary)
|
| 243 |
+
url = fetch_xemtv_us_stream(channel_id)
|
| 244 |
+
if url:
|
| 245 |
+
url = normalize_fptplay_url(url)
|
| 246 |
+
_set_cache(channel_id, url)
|
| 247 |
+
return url
|
| 248 |
+
|
| 249 |
+
# Source 2: FPTPlay CDN
|
| 250 |
+
url = fetch_fptplay_stream(channel_id)
|
| 251 |
+
if url:
|
| 252 |
+
_set_cache(channel_id, url)
|
| 253 |
+
return url
|
| 254 |
+
|
| 255 |
+
# Source 3: VTVGo CDN
|
| 256 |
+
url = fetch_vtvgo_stream(channel_id)
|
| 257 |
+
if url:
|
| 258 |
+
_set_cache(channel_id, url)
|
| 259 |
+
return url
|
| 260 |
+
|
| 261 |
+
# Source 4: xemtv.net legacy (last resort)
|
| 262 |
+
url = fetch_xemtv_legacy_stream(channel_id)
|
| 263 |
+
if url:
|
| 264 |
+
url = normalize_fptplay_url(url)
|
| 265 |
+
_set_cache(channel_id, url)
|
| 266 |
+
return url
|
| 267 |
+
|
| 268 |
+
_set_cache(channel_id, None)
|
| 269 |
+
return None
|
| 270 |
|
| 271 |
@router.get("/api/vtv/streams")
|
| 272 |
def api_vtv_streams():
|
|
|
|
| 273 |
result = {}
|
| 274 |
for ch_id in CHANNEL_NAMES:
|
| 275 |
+
stream_url = fetch_vtv_stream(ch_id)
|
| 276 |
+
result[ch_id] = {"name": CHANNEL_NAMES[ch_id], "stream_url": stream_url, "status": "ok" if stream_url else "offline"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
return JSONResponse(result)
|
| 278 |
|
| 279 |
@router.get("/api/vtv/stream/{channel_id}")
|
| 280 |
def api_vtv_stream(channel_id: str):
|
| 281 |
+
stream_url = fetch_vtv_stream(channel_id)
|
| 282 |
+
if stream_url:
|
| 283 |
+
return JSONResponse({"stream_url": stream_url, "status": "ok"})
|
| 284 |
+
return JSONResponse({"error": "stream not found", "status": "offline"}, status_code=404)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
|
| 286 |
+
@router.get("/api/proxy/page")
|
| 287 |
+
def proxy_page(url: str = Query(...)):
|
| 288 |
+
try:
|
| 289 |
+
headers = {**UA}
|
| 290 |
+
if "xemtv.us" in url:
|
| 291 |
+
headers["Referer"] = "https://xemtv.us/"
|
| 292 |
+
elif "xemtv.net" in url:
|
| 293 |
+
headers["Referer"] = "https://hd.xemtv.net/"
|
| 294 |
+
r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=False)
|
| 295 |
+
if r.status_code != 200:
|
| 296 |
+
return Response(status_code=502, content="upstream error")
|
| 297 |
+
return Response(content=r.text.encode("utf-8"), media_type="text/html; charset=utf-8", headers={"Access-Control-Allow-Origin": "*"})
|
| 298 |
+
except:
|
| 299 |
+
return Response(status_code=502, content="proxy error")
|
| 300 |
|
| 301 |
+
@router.get("/api/proxy/m3u8/vtv")
|
| 302 |
+
def proxy_vtv_m3u8(url: str = Query(...)):
|
| 303 |
+
try:
|
| 304 |
+
headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
|
| 305 |
+
if "fptplay" in url:
|
| 306 |
+
headers["Referer"] = "https://fptplay.vn/"
|
| 307 |
+
headers["Origin"] = "https://fptplay.vn"
|
| 308 |
+
elif "xemtv" in url:
|
| 309 |
+
headers["Referer"] = "https://xemtv.us/"
|
| 310 |
+
elif "vtvgo" in url or "vtvdigital" in url:
|
| 311 |
+
headers["Referer"] = "https://vtvgo.vn/"
|
| 312 |
+
r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=False)
|
| 313 |
+
if r.status_code != 200:
|
| 314 |
+
return Response(status_code=502, content="upstream error")
|
| 315 |
+
content = r.text
|
| 316 |
+
lines = content.split('\n')
|
| 317 |
+
rewritten = []
|
| 318 |
+
base_url = url.rsplit('/', 1)[0] + '/'
|
| 319 |
+
for line in lines:
|
| 320 |
+
line = line.strip()
|
| 321 |
+
if not line or line.startswith('#'):
|
| 322 |
+
rewritten.append(line)
|
| 323 |
+
else:
|
| 324 |
+
seg_url = line
|
| 325 |
+
if not seg_url.startswith('http'):
|
| 326 |
+
seg_url = base_url + seg_url
|
| 327 |
+
if seg_url.endswith('.m3u8'):
|
| 328 |
+
rewritten.append("/api/proxy/m3u8/vtv?url=" + requests.utils.quote(seg_url, safe=""))
|
| 329 |
+
else:
|
| 330 |
+
rewritten.append("/api/proxy/seg/vtv?url=" + requests.utils.quote(seg_url, safe=""))
|
| 331 |
+
return Response(content='\n'.join(rewritten).encode("utf-8"), media_type="application/vnd.apple.mpegurl", headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "no-cache"})
|
| 332 |
+
except Exception as e:
|
| 333 |
+
return Response(status_code=502, content="proxy error: " + str(e))
|
| 334 |
|
| 335 |
+
@router.get("/api/proxy/seg/vtv")
|
| 336 |
+
def proxy_vtv_segment(url: str = Query(...)):
|
| 337 |
+
try:
|
| 338 |
+
headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
|
| 339 |
+
if "fptplay" in url:
|
| 340 |
+
headers["Referer"] = "https://fptplay.vn/"
|
| 341 |
+
headers["Origin"] = "https://fptplay.vn"
|
| 342 |
+
r = requests.get(url, headers=headers, timeout=30, allow_redirects=True, verify=False)
|
| 343 |
+
if r.status_code != 200:
|
| 344 |
+
return Response(status_code=502, content="upstream error")
|
| 345 |
+
data = r.content
|
| 346 |
+
if len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47:
|
| 347 |
+
data = data[188:]
|
| 348 |
+
return Response(content=data, media_type="video/mp2t", headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600"})
|
| 349 |
+
except:
|
| 350 |
+
return Response(status_code=502, content="proxy error")
|
| 351 |
+
|
| 352 |
+
_epg_cache = {}
|
| 353 |
+
_epg_cache_time = 0
|
| 354 |
+
_EPG_CACHE_TTL = 600 # Giảm từ 30 phút xuống 10 phút, đảm bảo dữ liệu mới
|
| 355 |
+
|
| 356 |
+
VTV_CHANNEL_MAP = {
|
| 357 |
+
"vtv1": "vtv1", "vtv2": "vtv2", "vtv3": "vtv3", "vtv4": "vtv4",
|
| 358 |
+
"vtv5": "vtv5", "vtv5-tay-nam-bo": "vtv5", "vtv5-tay-nguyen": "vtv5",
|
| 359 |
+
"vtv7": "vtv7", "vtv8": "vtv8", "vtv6": "vtv6", "vtv9": "vtv9",
|
| 360 |
+
"vtv-can-tho": "vtv10",
|
| 361 |
+
}
|
| 362 |
|
| 363 |
def _fetch_epg_from_vtv():
|
| 364 |
global _epg_cache, _epg_cache_time
|
| 365 |
now_ts = time.time()
|
| 366 |
+
if _epg_cache and now_ts - _epg_cache_time < _EPG_CACHE_TTL:
|
| 367 |
+
return _epg_cache
|
| 368 |
epg_data = {}
|
| 369 |
+
# Lấy thời gian VN hiện tại để truyền vào parse_time
|
| 370 |
now_vn = datetime.now(VN_TZ)
|
| 371 |
try:
|
| 372 |
+
headers = {
|
| 373 |
+
"User-Agent": UA["User-Agent"], "Accept-Language": "vi-VN,vi;q=0.9",
|
| 374 |
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
| 375 |
+
"Referer": "https://vtv.vn/",
|
| 376 |
+
}
|
| 377 |
+
r = requests.get("https://vtv.vn/lich-phat-song.htm", headers=headers, timeout=20)
|
| 378 |
+
if r.status_code != 200:
|
| 379 |
+
return epg_data
|
| 380 |
r.encoding = "utf-8"
|
| 381 |
soup = BeautifulSoup(r.text, "lxml")
|
| 382 |
channel_order = []
|
| 383 |
+
list_channel = soup.find(class_=re.compile(r'list-channel'))
|
| 384 |
+
if list_channel:
|
| 385 |
+
for link in list_channel.find_all('a', href=re.compile(r'truyen-hinh-truc-tuyen/([^.]+)\.htm')):
|
| 386 |
+
ch_id = re.search(r'truyen-hinh-truc-tuyen/([^.]+)\.htm', link.get('href', ''))
|
| 387 |
+
if ch_id:
|
| 388 |
+
channel_order.append(ch_id.group(1))
|
| 389 |
+
if not channel_order:
|
| 390 |
+
for link in soup.find_all('a', href=re.compile(r'truyen-hinh-truc-tuyen/([^.]+)\.htm')):
|
| 391 |
+
ch_id = re.search(r'truyen-hinh-truc-tuyen/([^.]+)\.htm', link.get('href', ''))
|
| 392 |
+
if ch_id and ch_id.group(1) not in channel_order:
|
| 393 |
+
channel_order.append(ch_id.group(1))
|
| 394 |
+
prog_containers = soup.find_all('ul', class_=re.compile(r'\bprograms\b'))
|
| 395 |
+
for i, container in enumerate(prog_containers):
|
| 396 |
+
if i >= len(channel_order):
|
| 397 |
+
break
|
| 398 |
+
vtv_ch_id = channel_order[i]
|
| 399 |
+
our_ch_id = VTV_CHANNEL_MAP.get(vtv_ch_id, vtv_ch_id)
|
| 400 |
+
if our_ch_id not in epg_data:
|
| 401 |
+
epg_data[our_ch_id] = []
|
| 402 |
for li in container.find_all('li', class_=re.compile(r'\bprogram\b')):
|
| 403 |
+
time_span = li.find('span', class_=re.compile(r'\btime\b'))
|
| 404 |
+
title_span = li.find('span', class_=re.compile(r'\btitle\b'))
|
| 405 |
+
genre_a = li.find('a', class_=re.compile(r'\bgenre\b'))
|
| 406 |
+
time_str = time_span.get_text(strip=True) if time_span else ""
|
| 407 |
+
title = ""
|
| 408 |
+
if genre_a:
|
| 409 |
+
title = genre_a.get_text(strip=True)
|
| 410 |
+
if not title and title_span:
|
| 411 |
+
title = title_span.get_text(strip=True)
|
| 412 |
+
if not time_str or not title:
|
| 413 |
+
continue
|
| 414 |
+
# Truyền reference_date để xử lý quy tắc ngày truyền hình VTV
|
| 415 |
start_dt = _parse_time(time_str, reference_date=now_vn)
|
| 416 |
+
if not start_dt:
|
| 417 |
+
continue
|
| 418 |
+
epg_data[our_ch_id].append({"time": time_str[:5], "title": title[:80], "start_dt": start_dt})
|
| 419 |
+
for ch_id in epg_data:
|
| 420 |
+
epg_data[ch_id].sort(key=lambda x: x.get("start_dt") or datetime.min)
|
| 421 |
+
seen = set()
|
| 422 |
+
unique = []
|
| 423 |
+
for p in epg_data[ch_id]:
|
| 424 |
+
key = (p["time"], p["title"])
|
| 425 |
+
if key not in seen:
|
| 426 |
+
seen.add(key)
|
| 427 |
+
unique.append(p)
|
| 428 |
+
epg_data[ch_id] = unique
|
| 429 |
+
except Exception as e:
|
| 430 |
+
print(f"EPG vtv.vn error: {e}")
|
| 431 |
+
_epg_cache = epg_data
|
| 432 |
+
_epg_cache_time = now_ts
|
| 433 |
return epg_data
|
| 434 |
|
| 435 |
+
def _parse_time(time_str, reference_date=None):
|
| 436 |
+
"""
|
| 437 |
+
Parse giờ từ lịch phát sóng VTV (đã là giờ VN UTC+7) sang datetime có timezone.
|
| 438 |
+
|
| 439 |
+
VTV hiển thị lịch theo ngày dương lịch (không phải ngày truyền hình).
|
| 440 |
+
Ví dụ: Lịch ngày 17/06 sẽ hiển thị tất cả chương trình từ 00:00 đến 23:59 ngày 17/06.
|
| 441 |
+
|
| 442 |
+
Logic:
|
| 443 |
+
- Giờ 00:00-04:59: Có thể là đêm khuya của ngày hôm trước HOẶC sáng sớm của ngày mới
|
| 444 |
+
- Giờ 05:00-23:59: Luôn thuộc ngày hiện tại
|
| 445 |
+
"""
|
| 446 |
+
if not time_str:
|
| 447 |
+
return None
|
| 448 |
+
time_str = time_str.strip().replace("h", ":").replace("H", ":")
|
| 449 |
+
m = re.search(r'(\d{1,2}):(\d{2})', time_str)
|
| 450 |
+
if m:
|
| 451 |
+
try:
|
| 452 |
+
hour, minute = int(m.group(1)), int(m.group(2))
|
| 453 |
+
|
| 454 |
+
if reference_date:
|
| 455 |
+
base_date = reference_date
|
| 456 |
+
else:
|
| 457 |
+
# Lấy ngày hiện tại theo giờ VN (UTC+7)
|
| 458 |
+
now_vn = datetime.now(VN_TZ)
|
| 459 |
+
base_date = now_vn
|
| 460 |
+
|
| 461 |
+
from datetime import timedelta
|
| 462 |
+
|
| 463 |
+
# Xác định ngày cho giờ program
|
| 464 |
+
if hour < 5:
|
| 465 |
+
# Giờ 00:00-04:59: Cần xem giờ hiện tại để quyết định
|
| 466 |
+
if base_date.hour < 5:
|
| 467 |
+
# Nếu hiện tại cũng < 5:00 (đang trong khoảng sáng sớm)
|
| 468 |
+
# → Giờ program thuộc CÙNG NGÀY với giờ hiện tại
|
| 469 |
+
tv_date = base_date
|
| 470 |
+
else:
|
| 471 |
+
# Nếu hiện tại >= 5:00 (đã qua sáng sớm)
|
| 472 |
+
# → Giờ 00:00-04:59 thuộc NGÀY HÔM TRƯỚC
|
| 473 |
+
tv_date = base_date - timedelta(days=1)
|
| 474 |
+
else:
|
| 475 |
+
# Giờ 05:00-23:59: Luôn thuộc ngày hiện tại
|
| 476 |
+
tv_date = base_date
|
| 477 |
+
|
| 478 |
+
# Tạo datetime với giờ từ lịch
|
| 479 |
+
result = tv_date.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
| 480 |
+
|
| 481 |
+
# Đảm bảo result có timezone
|
| 482 |
+
if result.tzinfo is None:
|
| 483 |
+
result = result.replace(tzinfo=VN_TZ)
|
| 484 |
+
return result
|
| 485 |
+
except:
|
| 486 |
+
pass
|
| 487 |
+
return None
|
| 488 |
+
|
| 489 |
+
def _get_epg_for_channel(channel_id):
|
| 490 |
epg_data = _fetch_epg_from_vtv()
|
| 491 |
programmes = epg_data.get(channel_id, [])
|
| 492 |
+
if not programmes:
|
| 493 |
+
return []
|
| 494 |
now = datetime.now(VN_TZ)
|
| 495 |
today = now.date()
|
| 496 |
result = []
|
| 497 |
+
|
| 498 |
+
# Lọc chỉ lấy programs của ngày hôm nay (theo lịch VTV)
|
| 499 |
+
today_programmes = []
|
| 500 |
+
for p in programmes:
|
| 501 |
start_dt = p.get("start_dt")
|
| 502 |
+
if start_dt and start_dt.date() == today:
|
| 503 |
+
today_programmes.append(p)
|
| 504 |
+
|
| 505 |
+
# Nếu không có programs cho hôm nay, dùng tất cả (fallback)
|
| 506 |
+
if not today_programmes:
|
| 507 |
+
today_programmes = programmes
|
| 508 |
+
|
| 509 |
+
for i, p in enumerate(today_programmes):
|
| 510 |
+
start_dt = p.get("start_dt")
|
| 511 |
+
stop_dt = None
|
| 512 |
+
if i + 1 < len(today_programmes):
|
| 513 |
+
stop_dt = today_programmes[i + 1].get("start_dt")
|
| 514 |
+
is_now = False
|
| 515 |
+
if start_dt:
|
| 516 |
+
if stop_dt:
|
| 517 |
+
is_now = start_dt <= now < stop_dt
|
| 518 |
+
else:
|
| 519 |
+
is_now = start_dt <= now
|
| 520 |
+
end_time = ""
|
| 521 |
+
if stop_dt:
|
| 522 |
+
end_time = stop_dt.strftime("%H:%M")
|
| 523 |
+
result.append({"time": p["time"], "title": p["title"], "end_time": end_time, "now": is_now})
|
| 524 |
+
return result
|
| 525 |
+
|
| 526 |
+
@router.get("/api/vtv/epg/{channel_id}")
|
| 527 |
+
def api_vtv_epg(channel_id: str):
|
| 528 |
+
channel_id = channel_id.lower().strip()
|
| 529 |
+
if channel_id not in CHANNEL_NAMES:
|
| 530 |
+
return JSONResponse({"error": "channel not found"}, status_code=404)
|
| 531 |
+
programs = _get_epg_for_channel(channel_id)
|
| 532 |
+
return JSONResponse({
|
| 533 |
+
"channel": channel_id, "channel_name": CHANNEL_NAMES.get(channel_id, channel_id),
|
| 534 |
+
"date": datetime.now(VN_TZ).strftime("%Y-%m-%d"), "programs": programs,
|
| 535 |
+
})
|
| 536 |
|
| 537 |
@router.get("/api/vtv/epg")
|
| 538 |
def api_vtv_epg_refresh():
|
| 539 |
global _epg_cache, _epg_cache_time
|
| 540 |
+
_epg_cache = {}
|
| 541 |
+
_epg_cache_time = 0
|
| 542 |
epg_data = _fetch_epg_from_vtv()
|
| 543 |
+
return JSONResponse({
|
| 544 |
+
"status": "refreshed", "channels": len(epg_data),
|
| 545 |
+
"total_programmes": sum(len(v) for v in epg_data),
|
| 546 |
+
})
|
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,8 @@
|
|
| 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 +10,6 @@ UA = {
|
|
| 13 |
"Referer": "https://hd.xemtv.net/",
|
| 14 |
}
|
| 15 |
|
| 16 |
-
# ===== PRIMARY CDN SOURCES (Optimized for stability) =====
|
| 17 |
-
# Priority order: FPTPlay > VTVGo > MediaCDN
|
| 18 |
-
|
| 19 |
XEMTV_PHP_ENDPOINTS = {
|
| 20 |
"vtv1": "https://hd.xemtv.net/kenh/vtv1.php",
|
| 21 |
"vtv2": "https://hd.xemtv.net/kenh/vtv2.php",
|
|
@@ -32,34 +26,30 @@ XEMTV_PHP_ENDPOINTS = {
|
|
| 32 |
CHANNEL_NAMES = {
|
| 33 |
"vtv1": "VTV1", "vtv2": "VTV2", "vtv3": "VTV3", "vtv4": "VTV4",
|
| 34 |
"vtv5": "VTV5", "vtv6": "VTV6", "vtv7": "VTV7", "vtv8": "VTV8",
|
| 35 |
-
"vtv9": "VTV9", "vtv10": "
|
| 36 |
}
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
"
|
| 42 |
-
"
|
| 43 |
-
"
|
| 44 |
-
"
|
| 45 |
-
"
|
| 46 |
-
"
|
| 47 |
-
"
|
| 48 |
-
"
|
| 49 |
-
"
|
| 50 |
-
"
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
"
|
| 54 |
-
"
|
| 55 |
-
"
|
| 56 |
-
"
|
| 57 |
-
"
|
| 58 |
-
"
|
| 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 = {}
|
|
@@ -77,34 +67,22 @@ def _set_cache(key, data):
|
|
| 77 |
_vtv_cache[key] = {'t': time.time(), 'd': data}
|
| 78 |
|
| 79 |
def extract_m3u8_from_html(html):
|
| 80 |
-
if not html:
|
| 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 VTV stream with priority order for maximum stability"""
|
| 105 |
channel_id = channel_id.lower().strip()
|
| 106 |
-
|
| 107 |
-
# Normalize channel ID
|
| 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 +91,41 @@ 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 |
try:
|
| 125 |
r = requests.get(php_url, headers=UA, timeout=15, allow_redirects=True, verify=False)
|
| 126 |
if r.status_code == 200:
|
| 127 |
m3u8 = extract_m3u8_from_html(r.text)
|
| 128 |
-
if m3u8:
|
| 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 |
"""
|
| 5 |
import requests, re, time, threading
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
UA = {
|
| 8 |
"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",
|
|
|
|
| 10 |
"Referer": "https://hd.xemtv.net/",
|
| 11 |
}
|
| 12 |
|
|
|
|
|
|
|
|
|
|
| 13 |
XEMTV_PHP_ENDPOINTS = {
|
| 14 |
"vtv1": "https://hd.xemtv.net/kenh/vtv1.php",
|
| 15 |
"vtv2": "https://hd.xemtv.net/kenh/vtv2.php",
|
|
|
|
| 26 |
CHANNEL_NAMES = {
|
| 27 |
"vtv1": "VTV1", "vtv2": "VTV2", "vtv3": "VTV3", "vtv4": "VTV4",
|
| 28 |
"vtv5": "VTV5", "vtv6": "VTV6", "vtv7": "VTV7", "vtv8": "VTV8",
|
| 29 |
+
"vtv9": "VTV9", "vtv10": "VTV Cần Thơ",
|
| 30 |
}
|
| 31 |
|
| 32 |
+
CDN_FALLBACK = {
|
| 33 |
+
"vtv1": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8",
|
| 34 |
+
"vtv2": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv2-manifest.m3u8",
|
| 35 |
+
"vtv3": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv3-manifest.m3u8",
|
| 36 |
+
"vtv4": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv4-manifest.m3u8",
|
| 37 |
+
"vtv5": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv5-manifest.m3u8",
|
| 38 |
+
"vtv6": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv6-manifest.m3u8",
|
| 39 |
+
"vtv7": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv7-manifest.m3u8",
|
| 40 |
+
"vtv8": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv8-manifest.m3u8",
|
| 41 |
+
"vtv9": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv9-manifest.m3u8",
|
| 42 |
+
"vtv10": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv10-manifest.m3u8",
|
| 43 |
+
"_fpt_vtv1": "https://live.fptplay53.net/fnxch2/vtv1hd_abr.smil/chunklist.m3u8",
|
| 44 |
+
"_fpt_vtv2": "https://live.fptplay53.net/fnxch2/vtv2hd_abr.smil/chunklist.m3u8",
|
| 45 |
+
"_fpt_vtv3": "https://live.fptplay53.net/fnxch2/vtv3hd_abr.smil/chunklist.m3u8",
|
| 46 |
+
"_fpt_vtv4": "https://live.fptplay53.net/fnxch2/vtv4hd_abr.smil/chunklist.m3u8",
|
| 47 |
+
"_fpt_vtv5": "https://live-a.fptplay53.net/live/media/VTV5HD/live_hls_avc/index.m3u8",
|
| 48 |
+
"_fpt_vtv6": "https://live.fptplay53.net/fnxch2/vtv6hd_abr.smil/chunklist.m3u8",
|
| 49 |
+
"_fpt_vtv7": "https://live.fptplay53.net/fnxhd1/vtv7hd_vhls.smil/chunklist_b5000000.m3u8",
|
| 50 |
+
"_fpt_vtv8": "https://live.fptplay53.net/epzhd1/vtv8hd_vhls.smil/chunklist.m3u8",
|
| 51 |
+
"_fpt_vtv9": "https://live.fptplay53.net/fnxhd1/vtv9hd_vhls.smil/chunklist.m3u8",
|
| 52 |
+
"_fpt_vtv10": "https://live.fptplay53.net/fnxch2/vtvcantho_abr.smil/chunklist.m3u8",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
}
|
| 54 |
|
| 55 |
_vtv_cache = {}
|
|
|
|
| 67 |
_vtv_cache[key] = {'t': time.time(), 'd': data}
|
| 68 |
|
| 69 |
def extract_m3u8_from_html(html):
|
| 70 |
+
if not html:
|
| 71 |
+
return None
|
| 72 |
m = re.search(r"file\s*:\s*['\"]([^'\"]*\.m3u8[^'\"]*)['\"]", html, re.IGNORECASE)
|
| 73 |
if m:
|
| 74 |
url = m.group(1).strip()
|
| 75 |
+
if len(url) > 20:
|
| 76 |
+
return url
|
| 77 |
m = re.search(r"(https?://[^\s\"'<>\\]+\.m3u8[^\s\"'<>\\]*)", html, re.IGNORECASE)
|
| 78 |
if m:
|
| 79 |
url = m.group(1).strip()
|
| 80 |
+
if len(url) > 20:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
return url
|
|
|
|
| 82 |
return None
|
| 83 |
|
| 84 |
def fetch_vtv_stream(channel_id):
|
|
|
|
| 85 |
channel_id = channel_id.lower().strip()
|
|
|
|
|
|
|
| 86 |
name_map = {
|
| 87 |
'vtvct': 'vtv10', 'vtv-can-tho': 'vtv10', 'vtv can tho': 'vtv10',
|
| 88 |
'vtv_can_tho': 'vtv10', 'cantho': 'vtv10', 'cần thơ': 'vtv10',
|
|
|
|
| 91 |
'vietnam_vtv7': 'vtv7', 'vietnam_vtv8': 'vtv8', 'vietnam_vtv9': 'vtv9',
|
| 92 |
}
|
| 93 |
channel_id = name_map.get(channel_id, channel_id)
|
| 94 |
+
cached = _cached(channel_id)
|
| 95 |
+
if cached:
|
| 96 |
+
return cached
|
| 97 |
+
vtvgourl = CDN_FALLBACK.get(channel_id)
|
| 98 |
+
if vtvgourl:
|
| 99 |
+
_set_cache(channel_id, vtvgourl)
|
| 100 |
+
return vtvgourl
|
| 101 |
php_url = XEMTV_PHP_ENDPOINTS.get(channel_id)
|
| 102 |
if php_url:
|
| 103 |
try:
|
| 104 |
r = requests.get(php_url, headers=UA, timeout=15, allow_redirects=True, verify=False)
|
| 105 |
if r.status_code == 200:
|
| 106 |
m3u8 = extract_m3u8_from_html(r.text)
|
| 107 |
+
if m3u8:
|
| 108 |
+
_set_cache(channel_id, m3u8)
|
| 109 |
+
return m3u8
|
| 110 |
+
except:
|
| 111 |
+
pass
|
| 112 |
+
fpt_key = f"_fpt_{channel_id}"
|
| 113 |
+
fpt_url = CDN_FALLBACK.get(fpt_key)
|
| 114 |
+
if fpt_url:
|
| 115 |
+
_set_cache(channel_id, fpt_url)
|
| 116 |
+
return fpt_url
|
| 117 |
return None
|
| 118 |
|
| 119 |
def get_all_vtv_streams():
|
| 120 |
channels = []
|
| 121 |
+
for ch_id, php_url in XEMTV_PHP_ENDPOINTS.items():
|
| 122 |
stream_url = fetch_vtv_stream(ch_id)
|
| 123 |
channels.append({
|
| 124 |
'id': ch_id,
|
| 125 |
'name': CHANNEL_NAMES.get(ch_id, ch_id.upper()),
|
| 126 |
'stream_url': stream_url,
|
| 127 |
})
|
| 128 |
+
return channels
|
| 129 |
+
|
| 130 |
+
XEMTV_CHANNELS = {v: k for k, v in CHANNEL_NAMES.items()}
|
| 131 |
+
CDN_STREAMS = {v: k for k, v in CDN_FALLBACK.items()}
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|