.huggingface/rebuild DELETED
@@ -1 +0,0 @@
1
- rebuild
 
 
.rebuild DELETED
@@ -1 +0,0 @@
1
- Rebuild: vtv_api.py hardcode vtv6 m3u8 + vtv_init.js v8 hls.js native
 
 
.restart_trigger DELETED
@@ -1 +0,0 @@
1
- Trigger rebuild — sv2 iframe embed VTV1-10 + proxy + EPG date
 
 
CHANGELOG.md DELETED
@@ -1,71 +0,0 @@
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
48
-
49
- ### Critical Fix: Rewrite button not creating posts on Tường AI
50
- **Root cause**: `_run.py` imports from `app_v2_entry.py`, but the `/api/rewrite_share` endpoint was only defined in `ai_runtime_patch_fast.py` (loaded through `app_entry.py` which is NOT used). The frontend called a non-existent endpoint → 404 → silent failure.
51
-
52
- **Fix applied**:
53
- 1. **app_v2_entry.py** — Added 3 new endpoints:
54
- - `POST /api/rewrite_slide` — Fast extractive summary (no AI needed), creates slides from article key points + images, saves to wall
55
- - `POST /api/rewrite_share` — AI-powered rewrite with extractive fallback, saves to wall
56
- - `POST /api/url_wall` — URL submission endpoint (alias for rewrite_share)
57
- - All endpoints use the same `_load_wall_posts()` / `_save_wall_posts()` and `WALL_FILE` path as the existing `/api/wall` endpoint
58
-
59
- 2. **static/index_v2.html** — Added `<script src="/static/rewrite_fix_v2.js"></script>` to load the rewrite fix
60
-
61
- 3. **static/rewrite_fix_v2.js** — New file that overrides `rewriteArticle()` to:
62
- - Call `/api/rewrite_slide` first (fast, no AI needed)
63
- - Fallback to `/api/rewrite_share` if slide fails
64
- - Show slide preview overlay after successful post
65
- - Use `prependWallPost()` to add the new post to Tường AI
66
-
67
- ### Previous changes (v5)
68
- - Rewrote match_detail_v2.py with correct event parsing
69
- - 2-tab layout for match detail (stats + timeline)
70
- - Fixed _run.py import
71
- - Dockerfile cache busting
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Dockerfile CHANGED
@@ -1,16 +1,15 @@
1
- FROM python:3.12-slim
2
 
3
  WORKDIR /app
4
 
5
  RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg fonts-dejavu-core && rm -rf /var/lib/apt/lists/*
6
- RUN pip install --no-cache-dir "beautifulsoup4>=4.12" lxml
7
- RUN pip install --no-cache-dir fastapi uvicorn requests beautifulsoup4 jinja2 yt-dlp huggingface_hub gTTS pillow edge-tts python-dateutil httpx
8
-
9
- COPY requirements.txt .
10
- RUN pip install --no-cache-dir -r requirements.txt || true
11
 
12
  COPY . .
 
13
  EXPOSE 7860
14
 
15
- # Khôi phục commit 719a835ce 2026-07-05
16
- CMD ["uvicorn", "_run:app", "--host", "0.0.0.0", "--port", "7860", "--reload"]
 
 
 
1
+ FROM python:3.11-slim
2
 
3
  WORKDIR /app
4
 
5
  RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg fonts-dejavu-core && rm -rf /var/lib/apt/lists/*
6
+ RUN pip install --no-cache-dir fastapi uvicorn requests beautifulsoup4 lxml jinja2 yt-dlp huggingface_hub gTTS pillow edge-tts
 
 
 
 
7
 
8
  COPY . .
9
+
10
  EXPOSE 7860
11
 
12
+ # CRITICAL: Use app_v2_entry.py which imports ONLY main.py (core APIs)
13
+ # This bypasses the entire injection chain (app_run → app_final → ai_runtime_final6)
14
+ # Guarantees index_v2.html is served clean without old JS patches
15
+ CMD ["uvicorn", "app_v2_entry:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -9,25 +9,5 @@ tags:
9
  - ml-intern
10
  ---
11
 
12
- # VNEWS - Tin Tức Việt Nam
13
-
14
- **v18 - FIXED VTV2/VTV3/VTV6/VTV9 stream hanging**
15
-
16
- ## 🔧 Changes in v18 (2026-07-06)
17
- - **VTV2, VTV3, VTV6, VTV9**: Skip expired ssaimh CDN token → immediately fall through to sv2.xemtivitop.com
18
- - **15+ extraction patterns** for m3u8 URL (up from 5), including: file:, src=, source:, player.src(), hls.loadSource(), href=, `<source src>`, url:, window.location, iframe follow (3 levels deep), base64 decode
19
- - **Backup CDN** `tv.mediacdn.vn` for VTV2/VTV3/VTV6/VTV9
20
- - **Fast timeout** 5s for CDN, 12s for PHP endpoints (was 15s each = 60s+ total)
21
- - **sv2.xemtivitop.com** re-prioritized to check BEFORE xemtv.us
22
- - **Iframe chain following**: if a PHP page returns an iframe → follow it up to 3 levels to find the m3u8
23
-
24
- ## Features:
25
- - 📰 News from VnExpress (10 categories) + GenK AI
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 -->
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
_run.py DELETED
@@ -1 +0,0 @@
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
- # Try to import main app, but don't fail if it doesn't exist
17
- try:
18
- from main import app
19
- except ImportError:
20
- # Create a minimal FastAPI app for standalone testing
21
- try:
22
- from fastapi import FastAPI
23
- app = FastAPI()
24
- except Exception:
25
- app = None
26
 
27
  # Import wall store from main.py so we read/write the SAME file
28
  try:
@@ -50,10 +41,6 @@ except ImportError:
50
  def _web_context(topic):
51
  return ""
52
 
53
- # ai_ext alias for backward compatibility
54
- _load_ai_wall = _load_wall
55
- _save_ai_wall = _save_wall
56
-
57
  try:
58
  from huggingface_hub import AsyncInferenceClient
59
  except Exception:
@@ -73,502 +60,994 @@ except Exception:
73
 
74
 
75
  def _hf_token():
76
- for k in ("HF_TOKEN", "HUGGINGFACE_HUB_API_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HF_API_TOKEN"):
77
  v = os.getenv(k, "").strip()
78
  if v:
79
  return v
80
  return ""
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
 
 
83
  def _clean_text(s: str) -> str:
84
- """Clean text for processing."""
85
  s = html_lib.unescape(s or "")
86
- s = re.sub(r"\s+", " ", s)
87
- return s.strip()
88
 
89
-
90
- def _domain(url: str) -> str:
91
- """Extract domain from URL."""
92
  try:
93
- return urlparse(url or "").netloc.replace("www.", "")
94
  except Exception:
95
  return ""
96
 
 
 
97
 
98
- async def qwen_generate(prompt: str, image_url: str = None, max_tokens: int = 1200) -> str:
99
- """Generate text using Qwen models via Hugging Face Inference API.
100
-
101
- This function provides a resilient implementation that:
102
- 1. First tries the SDK-based inference client if available
103
- 2. Falls back to REST API calls to HF router endpoint
104
- 3. Returns a fallback summary if all else fails
105
  """
106
- token = _hf_token()
107
- errors = []
108
-
109
- # Try HF router API with multiple models
110
- if token:
111
- models = [
112
- os.getenv("QWEN_VL_MODEL", ""),
113
- "Qwen/Qwen2.5-VL-7B-Instruct",
114
- "Qwen/Qwen2.5-VL-3B-Instruct",
115
- "Qwen/Qwen2.5-7B-Instruct",
116
- "Qwen/Qwen2.5-3B-Instruct",
117
- "Qwen/Qwen2.5-1.5B-Instruct",
118
- "Qwen/Qwen2.5-72B-Instruct",
119
- "meta-llama/Llama-3.3-70B-Instruct",
120
- ]
121
- # Deduplicate while preserving order
122
- seen = set()
123
- models = [m for m in models if m and m not in seen and not seen.add(m)]
124
-
125
- headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
126
-
127
- for model in models:
128
- try:
129
- is_vl = "VL" in model and image_url
130
- if is_vl:
131
- user_content = [
132
- {"type": "image_url", "image_url": {"url": image_url}},
133
- {"type": "text", "text": prompt}
134
- ]
135
- else:
136
- user_content = prompt
137
-
138
- payload = {
139
- "model": model,
140
- "messages": [
141
- {"role": "system", "content": "Bạn là trợ lý AI tiếng Việt. Trả lời tự nhiên, ngắn gọn, chính xác."},
142
- {"role": "user", "content": user_content},
143
- ],
144
- "max_tokens": min(int(max_tokens or 900), 1400),
145
- "temperature": 0.35,
146
- "top_p": 0.85,
147
- }
148
-
149
- r = requests.post(
150
- "https://router.huggingface.co/v1/chat/completions",
151
- headers=headers,
152
- json=payload,
153
- timeout=95
154
- )
155
-
156
- if r.status_code >= 300:
157
- errors.append(f"{model}: HTTP {r.status_code}")
158
- continue
159
-
160
- j = r.json()
161
- txt = (j.get("choices", [{}])[0].get("message", {}).get("content") or "").strip()
162
-
163
- if txt:
164
- return txt
165
-
166
- errors.append(f"{model}: empty response")
167
-
168
- except Exception as e:
169
- errors.append(f"{model}: {type(e).__name__}")
170
-
171
- # Fallback: extractive summary from prompt
172
- LAST_QWEN_ERROR = errors[-3:] if errors else "unknown error"
173
- return _fallback_summary_from_prompt(prompt, max_units=6)
174
 
 
 
 
 
 
 
175
 
176
- def _fallback_summary_from_prompt(prompt: str, max_units: int = 6) -> str:
177
- """Generate a simple fallback summary when AI is unavailable."""
178
- text = prompt or ""
179
- for marker in ["Nội dung nguồn:", "Nội dung bài:", "Nội dung gốc:", "Nội dung:", "Nguồn/bối cảnh internet:"]:
180
- if marker in text:
181
- text = text.split(marker, 1)[1]
182
- break
183
- text = re.sub(r"https?://\S+", "", text)
184
- text = re.sub(r"\s+", " ", text).strip()
185
-
186
- # Split into sentences - extract ALL valid sentences, not just first few
187
- sentences = re.split(r"(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])", text)
188
- units = []
189
- for s in sentences:
190
- s = _clean_text(s)
191
- if len(s) >= 30: # Lower threshold to capture more content
192
- units.append(s)
193
-
194
- if units:
195
- # Take up to max_units valid sentences
196
- result_units = units[:max_units]
197
- return "\n".join("• " + u for u in result_units)
198
- if text:
199
- # Fallback: take chunks if no sentence boundaries found
200
- chunks = []
201
- for i in range(0, min(len(text), max_units * 300), 280):
202
- chunk = _clean_text(text[i:i+300])
203
- if chunk and chunk not in chunks:
204
- chunks.append(chunk)
205
- if len(chunks) >= max_units:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  break
207
- if chunks:
208
- return "\n".join("• " + c for c in chunks)
209
- return "• Không có đủ nội dung để tóm tắt."
 
 
 
 
 
 
 
210
 
 
 
211
 
212
- HF_TOKEN = _hf_token()
213
- QWEN_VL_MODEL = os.getenv("QWEN_VL_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct")
214
- QWEN_TEXT_MODELS = [m.strip() for m in os.getenv(
215
- "QWEN_TEXT_MODELS",
216
- "Qwen/Qwen2.5-72B-Instruct,meta-llama/Llama-3.3-70B-Instruct,Qwen/Qwen2.5-7B-Instruct"
217
- ).split(",") if m.strip()]
218
- _WORKING_MODEL_TEXT = None
219
- _WORKING_MODEL_VL = None
220
- DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data"
221
- SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts")
222
- HEADERS = {
223
- "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",
224
- "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"
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 - Native voices
233
- "vi-vn-hoaimyneural": "vi-VN-HoaiMyNeural",
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
- "nu": "vi-VN-HoaiMyNeural",
240
- "male": "vi-VN-NamMinhNeural",
241
- "female": "vi-VN-HoaiMyNeural",
242
- "mien-nam": "vi-VN-HoaiMyNeural",
243
- # English - Multilingual
244
- "en-us-andrewmultilingualneural": "en-US-AndrewMultilingualNeural",
245
- "en-au-williammultilingualneural": "en-AU-WilliamMultilingualNeural",
246
- "en_andrew": "en-US-AndrewMultilingualNeural",
247
- "andrew": "en-US-AndrewMultilingualNeural",
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-ThalitaMultilingualNeural",
255
- "pt": "pt-BR-ThalitaMultilingualNeural",
256
- # French - Multilingual
257
- "fr-fr-viviennemultilingualneural": "fr-FR-VivienneMultilingualNeural",
258
- "fr-fr-remymultilingualneural": "fr-FR-RemyMultilingualNeural",
259
- "fr_denise": "fr-FR-VivienneMultilingualNeural",
260
- "denise": "fr-FR-VivienneMultilingualNeural",
261
- "fr": "fr-FR-VivienneMultilingualNeural",
262
- # German - Multilingual
263
- "de-de-seraphinamultilingualneural": "de-DE-SeraphinaMultilingualNeural",
264
- "de-de-florianmultilingualneural": "de-DE-FlorianMultilingualNeural",
265
- "de_katja": "de-DE-SeraphinaMultilingualNeural",
266
- "katja": "de-DE-SeraphinaMultilingualNeural",
267
- "de": "de-DE-SeraphinaMultilingualNeural",
268
- # Korean - Hyunsu Multilingual (NOT SunHee)
269
- "ko-kr-hyunsumultilingualneural": "ko-KR-HyunsuMultilingualNeural",
270
- "ko_sunhee": "ko-KR-HyunsuMultilingualNeural",
271
- "sunhee": "ko-KR-HyunsuMultilingualNeural",
272
- "ko": "ko-KR-HyunsuMultilingualNeural",
273
- # Italian - Multilingual
274
- "it-it-giuseppemultilingualneural": "it-IT-GiuseppeMultilingualNeural",
275
- # Spanish (fallback to English multilingual)
276
- "es_ela": "en-US-AndrewMultilingualNeural",
277
- "ela": "en-US-AndrewMultilingualNeural",
278
- "es_carlos": "en-US-AndrewMultilingualNeural",
279
- "es": "en-US-AndrewMultilingualNeural",
280
- # Japanese (fallback to English multilingual)
281
- "ja_nanami": "en-US-AndrewMultilingualNeural",
282
- "nanami": "en-US-AndrewMultilingualNeural",
283
- "ja": "en-US-AndrewMultilingualNeural",
284
- # Chinese (fallback to English multilingual)
285
- "zh_xiaochen": "en-US-AndrewMultilingualNeural",
286
- "xiaochen": "en-US-AndrewMultilingualNeural",
287
- "zh": "en-US-AndrewMultilingualNeural",
288
- }
289
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
 
291
- def _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"]):
307
- return ("katja", "excited")
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:
 
60
 
61
 
62
  def _hf_token():
63
+ for k in ("HF_TOKEN", "HUGGINGFACEHUB_API_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HF_API_TOKEN"):
64
  v = os.getenv(k, "").strip()
65
  if v:
66
  return v
67
  return ""
68
 
69
+ HF_TOKEN = _hf_token()
70
+ QWEN_VL_MODEL = os.getenv("QWEN_VL_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct")
71
+ DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data"
72
+ SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts")
73
+ HEADERS = {
74
+ "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",
75
+ "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"
76
+ }
77
+ LAST_QWEN_ERROR = ""
78
+
79
+ # ===== TTS VOICE CONFIG =====
80
+ TTS_VOICES = {
81
+ "hoaimy": {"id": "vi-VN-HoaiMyNeural", "gender": "female", "name": "Hoài My", "country": "Việt Nam", "lang": "vi", "flag": "🇻🇳"},
82
+ "namminh": {"id": "vi-VN-NamMinhNeural", "gender": "male", "name": "Nam Minh", "country": "Việt Nam", "lang": "vi", "flag": "🇻🇳"},
83
+ "en_us_f": {"id": "en-US-AvaNeural", "gender": "female", "name": "Ava", "country": "Mỹ", "lang": "en", "flag": "🇺🇸"},
84
+ "en_us_m": {"id": "en-US-BrianNeural", "gender": "male", "name": "Brian", "country": "Mỹ", "lang": "en", "flag": "🇺🇸"},
85
+ "en_gb_f": {"id": "en-GB-SoniaNeural", "gender": "female", "name": "Sonia", "country": "Anh", "lang": "en", "flag": "🇬🇧"},
86
+ "en_gb_m": {"id": "en-GB-RyanNeural", "gender": "male", "name": "Ryan", "country": "Anh", "lang": "en", "flag": "🇬🇧"},
87
+ "zh_cn_f": {"id": "zh-CN-XiaoxiaoNeural", "gender": "female", "name": "Xiaoxiao", "country": "Trung Quốc", "lang": "zh", "flag": "🇨🇳"},
88
+ "zh_cn_m": {"id": "zh-CN-YunyangNeural", "gender": "male", "name": "Yunyang", "country": "Trung Quốc", "lang": "zh", "flag": "🇨🇳"},
89
+ "zh_hk_f": {"id": "zh-HK-HiuGaaiNeural", "gender": "female", "name": "HiuGaai", "country": "Hồng Kông", "lang": "zh", "flag": "🇭🇰"},
90
+ "ja_jp_f": {"id": "ja-JP-NanamiNeural", "gender": "female", "name": "Nanami", "country": "Nhật Bản", "lang": "ja", "flag": "🇯🇵"},
91
+ "ja_jp_m": {"id": "ja-JP-KeitaNeural", "gender": "male", "name": "Keita", "country": "Nhật Bản", "lang": "ja", "flag": "🇯🇵"},
92
+ "ko_kr_f": {"id": "ko-KR-SunHiNeural", "gender": "female", "name": "Sun-Hi", "country": "Hàn Quốc", "lang": "ko", "flag": "🇰🇷"},
93
+ "ko_kr_m": {"id": "ko-KR-InJoonNeural", "gender": "male", "name": "InJoon", "country": "Hàn Quốc", "lang": "ko", "flag": "🇰🇷"},
94
+ "fr_fr_f": {"id": "fr-FR-DeniseNeural", "gender": "female", "name": "Denise", "country": "Pháp", "lang": "fr", "flag": "🇫🇷"},
95
+ "fr_fr_m": {"id": "fr-FR-HenriNeural", "gender": "male", "name": "Henri", "country": "Pháp", "lang": "fr", "flag": "🇫🇷"},
96
+ "de_de_f": {"id": "de-DE-KatjaNeural", "gender": "female", "name": "Katja", "country": "Đức", "lang": "de", "flag": "🇩🇪"},
97
+ "de_de_m": {"id": "de-DE-KillianNeural", "gender": "male", "name": "Killian", "country": "Đức", "lang": "de", "flag": "🇩🇪"},
98
+ "es_es_f": {"id": "es-ES-XimenaNeural", "gender": "female", "name": "Ximena", "country": "Tây Ban Nha", "lang": "es", "flag": "🇪🇸"},
99
+ "es_mx_m": {"id": "es-MX-JorgeNeural", "gender": "male", "name": "Jorge", "country": "Mexico", "lang": "es", "flag": "🇲🇽"},
100
+ "th_th_f": {"id": "th-TH-PremwadeeNeural", "gender": "female", "name": "Premwadee", "country": "Thái Lan", "lang": "th", "flag": "🇹🇭"},
101
+ "th_th_m": {"id": "th-TH-NiwatNeural", "gender": "male", "name": "Niwat", "country": "Thái Lan", "lang": "th", "flag": "🇹🇭"},
102
+ "hi_in_f": {"id": "hi-IN-SwaraNeural", "gender": "female", "name": "Swara", "country": "Ấn Độ", "lang": "hi", "flag": "🇮🇳"},
103
+ "hi_in_m": {"id": "hi-IN-MadhurNeural", "gender": "male", "name": "Madhur", "country": "Ấn Độ", "lang": "hi", "flag": "🇮🇳"},
104
+ }
105
+ TTS_DEFAULT_VOICE = "hoaimy"
106
+ TTS_DEFAULT_SPEED = 1.2
107
+
108
+ TOPIC_VOICE_MAP = {
109
+ "bóng đá": "namminh", "thể thao": "namminh", "world cup": "namminh",
110
+ "premier league": "namminh", "champions league": "namminh", "la liga": "namminh",
111
+ "serie a": "namminh", "bundesliga": "namminh", "v-league": "namminh",
112
+ "tennis": "namminh", "olympic": "namminh", "f1": "namminh", "moto": "namminh",
113
+ "sức khỏe": "hoaimy", "làm đẹp": "hoaimy", "giải trí": "hoaimy",
114
+ "âm nhạc": "hoaimy", "phim": "hoaimy", "thời trang": "hoaimy",
115
+ "ẩm thực": "hoaimy", "du lịch": "hoaimy", "gia đình": "hoaimy",
116
+ "tình yêu": "hoaimy", "hôn nhân": "hoaimy", "mẹ và bé": "hoaimy",
117
+ "công nghệ": "namminh", "ai": "namminh", "robot": "namminh",
118
+ "khoa học": "namminh", "vũ trụ": "namminh", "điện thoại": "namminh",
119
+ "laptop": "namminh", "game": "namminh",
120
+ "chính trị": "namminh", "kinh tế": "namminh", "tài chính": "namminh",
121
+ "chứng khoán": "namminh", "ngân hàng": "namminh", "thị trường": "namminh",
122
+ "xã hội": "namminh", "pháp luật": "namminh", "giáo dục": "namminh",
123
+ }
124
+
125
+
126
+ def _detect_voice_for_topic(title: str, text: str) -> str:
127
+ combined = (title + " " + text[:500]).lower()
128
+ for keyword, voice_id in TOPIC_VOICE_MAP.items():
129
+ if keyword in combined:
130
+ return voice_id
131
+ return TTS_DEFAULT_VOICE
132
+
133
 
134
+ # ===== TEXT HELPERS =====
135
  def _clean_text(s: str) -> str:
 
136
  s = html_lib.unescape(s or "")
137
+ return re.sub(r"\s+", " ", s).strip()
 
138
 
139
+ def _domain(u):
 
 
140
  try:
141
+ return urlparse(u).netloc.replace("www.", "")
142
  except Exception:
143
  return ""
144
 
145
+ def _safe_name(s):
146
+ return re.sub(r"[^a-zA-Z0-9_-]+", "_", str(s))[:80]
147
 
148
+
149
+ # ===== CLEAN AI OUTPUT COMPLETE REWRITE =====
150
+ def _clean_ai_output(text: str) -> str:
 
 
 
 
151
  """
152
+ Aggressively clean AI output:
153
+ 1. Remove markdown artifacts
154
+ 2. Remove instruction leakage / meta-commentary
155
+ 3. Split into sentences, deduplicate (exact + near-match)
156
+ 4. Trim at complete sentence boundary (no mid-sentence cuts)
157
+ 5. Remove consecutive duplicate lines
158
+ """
159
+ if not text:
160
+ return ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
 
162
+ # --- Step 1: Remove markdown ---
163
+ text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
164
+ text = re.sub(r'\*\*([^*]+)\*\*', r'\1', text)
165
+ text = re.sub(r'\*([^*]+)\*', r'\1', text)
166
+ text = re.sub(r'^---+\s*$', '', text, flags=re.MULTILINE)
167
+ text = re.sub(r'^[-*_]{3,}\s*$', '', text, flags=re.MULTILINE)
168
 
169
+ # --- Step 2: Remove instruction leakage (entire lines) ---
170
+ leakage_patterns = [
171
+ r'Dưới đây là', r'Theo yêu cầu', r'Tôi sẽ viết', r'Tôi sẽ tóm tắt',
172
+ r'Đây bài', r'Đây nội dung', r'Bài viết sau đây',
173
+ r'Nội dung (tóm tắt|chính)', r'Nhiệm vụ', r'Vai trò', r'Tôi là',
174
+ r'Dựa trên.*tôi sẽ', r'Hãy', r'Bạn cần', r'Đọc bài viết',
175
+ r'Tôi xin', r'Xin chào', r'Trân trọng', r'Kính thưa',
176
+ r'Dựa trên.*dưới đây', r'Sau đây là', r'Dưới đây là bài',
177
+ r'Dựa trên.*tôi đã', r'Theo nội dung', r'Tóm tắt như sau',
178
+ r'Dưới đây là bài viết', r'Đây là phần', r'Bài tóm tắt',
179
+ r'Nội dung tóm tắt', r'Đoạn văn sau', r'Viết lại.*sau',
180
+ ]
181
+ for phrase in leakage_patterns:
182
+ text = re.sub(r'^[^\n]*' + phrase + r'[^\n]*\n?', '', text, flags=re.MULTILINE | re.IGNORECASE)
183
+
184
+ # Remove lines that are just meta-instructions
185
+ text = re.sub(r'^[^\n]*(?:QUY TẮC|bắt buộc|tối đa|không dùng)[^\n]*\n?', '', text, flags=re.MULTILINE | re.IGNORECASE)
186
+
187
+ text = re.sub(r'\n{3,}', '\n\n', text)
188
+ text = text.strip()
189
+
190
+ if not text:
191
+ return ""
192
+
193
+ # --- Step 3: Split into sentences & deduplicate ---
194
+ # Vietnamese sentences end with . ! ? or end of string
195
+ # We split on sentence-ending punctuation followed by space or end
196
+ raw_parts = re.split(r'(?<=[.!?])\s+', text.strip())
197
+
198
+ def _norm(s):
199
+ """Normalize for comparison: lowercase, collapse whitespace, remove punctuation."""
200
+ s = re.sub(r'\s+', ' ', s.strip().lower())
201
+ return re.sub(r'[^\w\s]', '', s)
202
+
203
+ seen_sentences = set()
204
+ unique_parts = []
205
+
206
+ for part in raw_parts:
207
+ n = _norm(part)
208
+ if not n or len(n) < 5:
209
+ # Very short fragments — keep them if they're not empty
210
+ if part.strip():
211
+ unique_parts.append(part)
212
+ continue
213
+
214
+ # Check exact match
215
+ if n in seen_sentences:
216
+ continue
217
+
218
+ # Check near-duplicate: substring match
219
+ is_dup = False
220
+ n_clean = re.sub(r'\s+', '', n)
221
+ for seen in list(seen_sentences):
222
+ seen_clean = re.sub(r'\s+', '', seen)
223
+ if not n_clean or not seen_clean:
224
+ continue
225
+ # One is substring of the other
226
+ if n_clean in seen_clean or seen_clean in n_clean:
227
+ shorter = min(len(n_clean), len(seen_clean))
228
+ longer = max(len(n_clean), len(seen_clean))
229
+ if shorter > 15 and shorter / longer > 0.7:
230
+ is_dup = True
231
  break
232
+ # High word overlap
233
+ else:
234
+ words_n = set(n.split())
235
+ words_s = set(seen.split())
236
+ if len(words_n) >= 3 and len(words_s) >= 3:
237
+ overlap = len(words_n & words_s)
238
+ ratio = overlap / min(len(words_n), len(words_s))
239
+ if ratio > 0.75:
240
+ is_dup = True
241
+ break
242
 
243
+ if is_dup:
244
+ continue
245
 
246
+ seen_sentences.add(n)
247
+ unique_parts.append(part)
 
 
 
 
 
 
 
 
 
 
 
 
 
248
 
249
+ result = ' '.join(unique_parts).strip()
250
 
251
+ # --- Step 4: Remove consecutive duplicate lines ---
252
+ lines = result.split('\n')
253
+ final_lines = []
254
+ prev_line = ""
255
+ for line in lines:
256
+ stripped = line.strip()
257
+ if stripped and stripped == prev_line:
258
+ continue
259
+ final_lines.append(line)
260
+ prev_line = stripped
261
+ result = '\n'.join(final_lines).strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
 
263
+ # --- Step 5: Trim at complete sentence boundary ---
264
+ # Find the last sentence-ending punctuation and cut there
265
+ # This prevents mid-sentence truncation
266
+ if result:
267
+ # Find all sentence-ending positions
268
+ end_matches = list(re.finditer(r'[.!?]\s*$', result))
269
+ if not end_matches:
270
+ # No ending punctuation — try to find the last complete sentence
271
+ last_end = -1
272
+ for m in re.finditer(r'[.!?](?=\s|$)', result):
273
+ last_end = m.end()
274
+ if last_end > 0:
275
+ result = result[:last_end].strip()
276
+ # else: already ends with punctuation, good
277
 
278
+ # --- Step 6: Final cleanup ---
279
+ # Remove any trailing incomplete sentence fragments after last punctuation
280
+ # e.g. "Đây câu 1. Đây câu 2. một" → cut "Và một"
281
+ last_period = result.rfind('.')
282
+ last_exclaim = result.rfind('!')
283
+ last_question = result.rfind('?')
284
+ last_end = max(last_period, last_exclaim, last_question)
285
+ if last_end > 0:
286
+ after = result[last_end + 1:].strip()
287
+ # If what follows the last sentence end is a short fragment (< 20 chars, no ending punct), cut it
288
+ if after and len(after) < 20 and not re.search(r'[.!?]$', after):
289
+ result = result[:last_end + 1].strip()
 
 
 
 
 
 
 
 
290
 
291
+ return result
292
 
 
 
 
 
293
 
294
+ # ===== EXTRACT ALL IMAGES FROM ARTICLE — ENHANCED =====
295
+ def _extract_all_images(soup, base_url: str) -> List[Dict]:
296
+ """Extract ALL content images from an article page using multi-strategy approach."""
297
+ images = []
298
+ seen_urls = set()
299
+ skip_patterns = [
300
+ "avatar", "icon", "logo", "button", "banner-ad", "tracking",
301
+ "beacon", "pixel", "1x1", "spacer", "emoji", "sprite", "placeholder",
302
+ "advertisement", "ads", "widget", "sidebar", "footer-logo",
303
+ "social", "share", "comment-avatar", "user-avatar",
304
+ ]
305
 
306
+ def _add_image(src: str, alt: str = "", source_tag: str = "img"):
307
+ if not src or src.startswith("data:"):
308
+ return
309
+ # Normalize URL
310
+ src = src.strip()
311
+ if src.startswith("//"):
312
+ src = "https:" + src
313
+ abs_url = urljoin(base_url, src)
314
+ if abs_url in seen_urls:
315
+ return
316
+ # Skip non-content images by URL pattern
317
+ if any(p in abs_url.lower() for p in skip_patterns):
318
+ return
319
+ # Skip very small images (likely icons)
320
+ try:
321
+ parsed = urlparse(abs_url)
322
+ path = parsed.path.lower()
323
+ if any(path.endswith(ext) for ext in ['.svg', '.ico']):
324
+ return
325
+ # Skip if URL contains dimension patterns like 16x16, 32x32
326
+ if re.search(r'/\d{1,3}x\d{1,3}/', path) or re.search(r'-\d{1,3}x\d{1,3}\.', path):
327
+ return
328
+ except Exception:
329
+ pass
330
+ seen_urls.add(abs_url)
331
+ images.append({"url": abs_url, "alt": alt, "source": source_tag})
332
 
333
+ # Strategy 1: Standard <img> tags with all lazy-load attributes
334
+ for img in soup.find_all("img"):
335
+ src = (img.get("src") or img.get("data-src") or img.get("data-lazy-src") or
336
+ img.get("data-original") or img.get("data-srcset", "").split(",")[0].strip().split(" ")[0])
337
+ _add_image(src, alt=img.get("alt", ""), source_tag="img")
338
 
339
+ # Strategy 2: srcset on <img>
340
+ for img in soup.find_all("img", srcset=True):
341
+ for part in img["srcset"].split(","):
342
+ part = part.strip()
343
+ if part:
344
+ _add_image(part.split(" ")[0], alt=img.get("alt", ""), source_tag="srcset")
345
 
346
+ # Strategy 3: <picture> with <source>
347
+ for picture in soup.find_all("picture"):
348
+ for source in picture.find_all("source"):
349
+ srcset = source.get("srcset", "")
350
+ for part in srcset.split(","):
351
+ part = part.strip()
352
+ if part:
353
+ _add_image(part.split(" ")[0], source_tag="picture/srcset")
354
+ fallback_img = picture.find("img")
355
+ if fallback_img:
356
+ _add_image(
357
+ fallback_img.get("src") or fallback_img.get("data-src"),
358
+ alt=fallback_img.get("alt", ""),
359
+ source_tag="picture/img"
360
+ )
361
 
362
+ # Strategy 4: WordPress CMS patterns
363
+ for img in soup.find_all("img", class_=re.compile(r"wp-image|size-large|size-full|aligncenter")):
364
+ _add_image(img.get("data-src") or img.get("src"),
365
+ alt=img.get("alt", ""), source_tag="wp-image")
366
 
367
+ # Strategy 5: Background images in style attributes
368
+ for tag in soup.find_all(style=re.compile(r"background-image")):
369
+ for m in re.findall(r'url\(["\']?(.*?)["\']?\)', tag.get("style", "")):
370
+ _add_image(m, source_tag="background-style")
 
 
 
 
 
371
 
372
+ # Strategy 6: og:image (featured/hero image)
373
+ og_image = soup.find("meta", property="og:image")
374
+ if og_image and og_image.get("content"):
375
+ _add_image(og_image["content"], source_tag="og:image")
376
 
377
+ # Strategy 7: twitter:image
378
+ tw_image = soup.find("meta", attrs={"name": "twitter:image"})
379
+ if tw_image and tw_image.get("content"):
380
+ _add_image(tw_image["content"], source_tag="twitter:image")
 
 
 
 
 
 
381
 
382
+ # Strategy 8: <figure> with <figcaption>
383
+ for figure in soup.find_all("figure"):
384
+ img = figure.find("img")
385
+ if img:
386
+ src = img.get("data-src") or img.get("src")
387
+ figcaption = figure.find("figcaption")
388
+ alt = figcaption.get_text(strip=True) if figcaption else img.get("alt", "")
389
+ _add_image(src, alt=alt, source_tag="figure")
390
 
391
+ # Strategy 9: <a> tags linking to images
392
+ for a in soup.find_all("a", href=True):
393
+ href = a["href"]
394
+ if any(href.lower().endswith(ext) for ext in [".jpg", ".jpeg", ".png", ".webp"]):
395
+ _add_image(href, alt=a.get_text(strip=True)[:80], source_tag="link")
396
 
397
+ # Strategy 10: VnExpress-specific — fig-parent / fig-image containers
398
+ for fig_parent in soup.find_all(class_=re.compile(r"fig-parent|fig-image|image|thumb")):
399
+ img = fig_parent.find("img")
400
+ if img:
401
+ src = img.get("data-src") or img.get("src") or img.get("data-original")
402
+ _add_image(src, alt=img.get("alt", ""), source_tag="fig-parent")
403
+ # Also check for background-image in fig-parent
404
+ style = fig_parent.get("style", "")
405
+ for m in re.findall(r'url\(["\']?(.*?)["\']?\)', style):
406
+ _add_image(m, source_tag="fig-parent-bg")
407
 
408
+ # Strategy 11: Dân Trí / VietnamNet — image in .content-detail, .article-body
409
+ for container in soup.find_all(class_=re.compile(r"content-detail|article-body|article_content|detail-content|singular-content|fck_detail|knc-content|cms-body|article__body")):
410
+ for img in container.find_all("img"):
411
+ src = img.get("data-src") or img.get("src") or img.get("data-original")
412
+ _add_image(src, alt=img.get("alt", ""), source_tag="content-block-img")
413
 
414
+ # Strategy 12: Generic — any <img> inside <article> or <main>
415
+ for scope in soup.find_all(["article", "main"]):
416
+ for img in scope.find_all("img"):
417
+ src = img.get("data-src") or img.get("src") or img.get("data-original")
418
+ _add_image(src, alt=img.get("alt", ""), source_tag="article-scope")
419
+
420
+ return images
421
 
 
 
 
 
 
 
 
 
 
 
 
 
422
 
423
+ # ===== JINA READER =====
424
+ def _reader_url(target_url: str) -> str:
425
+ safe = quote(target_url, safe=":/?#[]@!$&'()*+,;=%")
426
+ return "https://r.jina.ai/http://" + safe
427
 
428
+ def jina_reader_markdown(url: str) -> str:
429
+ jr = _reader_url(url)
430
+ r = requests.get(jr, headers={"Accept": "text/markdown,text/plain,*/*", "X-Return-Format": "markdown", "User-Agent": "Mozilla/5.0"}, timeout=35)
431
+ r.raise_for_status()
432
+ return r.text or ""
 
433
 
434
+ def _parse_jina_markdown(md: str, url: str):
435
+ lines = [x.rstrip() for x in (md or "").splitlines()]
436
+ title = ""; first_image = ""; all_images = []; content_lines = []; in_content = False
437
+ for ln in lines:
438
+ if ln.startswith("Title:") and not title:
439
+ title = _clean_text(ln.replace("Title:", "", 1)); continue
440
+ if ln.startswith("URL Source:"):
441
+ continue
442
+ if ln.startswith("Markdown Content:"):
443
+ in_content = True; continue
444
+ # Extract ALL images from markdown ![alt](url)
445
+ for mimg in re.finditer(r'!\[[^\]]*\]\((https?://[^)]+)\)', ln):
446
+ img_url = mimg.group(1)
447
+ if img_url not in all_images:
448
+ all_images.append(img_url)
449
+ if not first_image:
450
+ first_image = img_url
451
+ if in_content or (title and not ln.startswith("Title:")):
452
+ if ln.strip():
453
+ content_lines.append(ln)
454
+ text = "\n".join(content_lines)
455
+ text = re.sub(r'!\[[^\]]*\]\([^)]+\)', '', text)
456
+ paras = []
457
+ for part in re.split(r'\n{2,}|\n(?=#{1,3}\s)', text):
458
+ t = _clean_text(re.sub(r'^#{1,6}\s*', '', part))
459
+ if len(t) >= 40:
460
+ paras.append(t)
461
+ if len(paras) >= 35:
462
+ break
463
+ if not title and paras:
464
+ title = paras[0][:90]
465
+ return {"url": url, "title": title or url, "summary": paras[0] if paras else "",
466
+ "text": "\n".join(paras), "image": first_image,
467
+ "images": all_images, "via": "jina"}
468
+
469
+
470
+ # ===== WEB SCRAPE (with full image extraction) =====
471
+ def _best_content_block(soup):
472
+ best, best_score = None, 0
473
+ for el in soup.find_all(["article", "main", "section", "div"]):
474
+ ps = el.find_all("p")
475
+ txt = " ".join(p.get_text(" ", strip=True) for p in ps)
476
+ score = len(ps) * 100 + len(txt)
477
+ cls = " ".join(el.get("class", []))
478
+ if any(k in cls.lower() for k in ["content", "article", "detail", "body", "post", "entry"]):
479
+ score += 800
480
+ if score > best_score:
481
+ best, best_score = el, score
482
+ return best
483
+
484
+ def scrape_any_url_direct(url: str):
485
+ r = requests.get(url, headers=HEADERS, timeout=18)
486
+ if r.status_code in {401, 403, 406, 409, 429, 451, 503}:
487
+ raise RuntimeError(f"blocked status {r.status_code}")
488
+ r.encoding = "utf-8"
489
+ soup = BeautifulSoup(r.text, "lxml")
490
+ for tag in soup.find_all(["script", "style", "nav", "footer", "aside", "form", "noscript"]):
491
+ tag.decompose()
492
+
493
+ # Title
494
+ title = soup.find("h1").get_text(" ", strip=True) if soup.find("h1") else ""
495
+ if not title:
496
+ ogt = soup.find("meta", property="og:title") or soup.find("meta", attrs={"name": "title"})
497
+ title = ogt.get("content", "") if ogt else (soup.title.get_text(strip=True) if soup.title else "")
498
+
499
+ # Summary
500
+ desc_tag = soup.find("meta", property="og:description") or soup.find("meta", attrs={"name": "description"})
501
+ summary = desc_tag.get("content", "") if desc_tag else ""
502
+
503
+ # Featured image (og:image)
504
+ img_tag = soup.find("meta", property="og:image") or soup.find("meta", attrs={"name": "twitter:image"})
505
+ image = img_tag.get("content", "") if img_tag else ""
506
+ if image and image.startswith("//"):
507
+ image = "https:" + image
508
+
509
+ # Extract ALL images from the article
510
+ all_images = _extract_all_images(soup, url)
511
+ image_urls = [img["url"] for img in all_images]
512
+
513
+ # Ensure featured image is first
514
+ if image and image not in image_urls:
515
+ image_urls.insert(0, image)
516
+ elif image in image_urls:
517
+ image_urls.remove(image)
518
+ image_urls.insert(0, image)
519
+
520
+ # Content paragraphs
521
+ block = _best_content_block(soup) or soup
522
+ paras, seen_p = [], set()
523
+ for p in block.find_all("p"):
524
+ t = _clean_text(p.get_text(" ", strip=True))
525
+ if len(t) >= 40 and t not in seen_p:
526
+ seen_p.add(t)
527
+ paras.append(t)
528
+ if len(paras) >= 35:
529
+ break
530
+
531
+ if not title and paras:
532
+ title = paras[0][:90]
533
 
534
+ return {
535
+ "url": url, "title": title or url, "summary": paras[0] if paras else "",
536
+ "text": "\n".join(paras), "image": image_urls[0] if image_urls else "",
537
+ "images": image_urls, "via": _domain(url)
538
+ }
539
+
540
+ def scrape_any_url(url: str):
541
+ """Try direct scrape first, fall back to Jina Reader.
542
 
543
+ KEY FIX: When falling back to Jina, MERGE images from both sources
544
+ instead of overwriting. Direct scrape often finds more inline images.
545
  """
546
+ data = scrape_any_url_direct(url)
547
+ raw_text = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
548
+ direct_images = data.get("images", [])
549
+
550
+ if len(raw_text) >= 120:
551
+ # Direct scrape got enough content — but still try Jina for more images
552
+ # Only use Jina if direct scrape found very few images
553
+ if len(direct_images) < 3:
554
+ try:
555
+ md = jina_reader_markdown(url)
556
+ if md:
557
+ jr = _parse_jina_markdown(md, url)
558
+ if jr.get("images"):
559
+ # MERGE: add Jina images that aren't already in direct scrape
560
+ existing = set(direct_images)
561
+ for img_url in jr["images"]:
562
+ if img_url not in existing:
563
+ direct_images.append(img_url)
564
+ existing.add(img_url)
565
+ data["images"] = direct_images
566
+ except Exception:
567
+ pass
568
+ return data
569
+
570
+ # Direct scrape didn't get enough content — fall back to Jina
571
  try:
572
+ md = jina_reader_markdown(url)
573
+ if md:
574
+ jr = _parse_jina_markdown(md, url)
575
+ if jr.get("text"):
576
+ # Keep direct scrape's title/images if Jina's are worse
577
+ if data.get("title") and data["title"] != url:
578
+ jr["title"] = data["title"]
579
+ # MERGE images: direct scrape images + Jina images
580
+ merged_images = list(direct_images) # start with direct scrape images
581
+ existing = set(merged_images)
582
+ for img_url in jr.get("images", []):
583
+ if img_url not in existing:
584
+ merged_images.append(img_url)
585
+ existing.add(img_url)
586
+ jr["images"] = merged_images
587
+ # Use direct scrape's image as first if available
588
+ if data.get("image"):
589
+ jr["image"] = data["image"]
590
+ jr["via"] = data.get("via", _domain(url)) + " + jina"
591
+ return jr
592
+ except Exception:
593
+ pass
594
+ return data
595
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
596
 
597
+ # ===== POLLINATIONS IMAGE =====
598
+ def pollinations_image_url(topic: str) -> str:
599
+ prompt = "editorial illustration, Vietnamese news, " + topic
600
+ return "https://image.pollinations.ai/prompt/" + quote(prompt, safe="") + "?width=1024&height=576&nologo=true"
601
 
602
+
603
+ # ===== QWEN AI (strict, concise) =====
604
+ async def qwen_generate(prompt: str, image_url: Optional[str] = None, max_tokens: int = 700, image_urls: Optional[List[str]] = None):
605
+ global LAST_QWEN_ERROR, HF_TOKEN
606
+ HF_TOKEN = _hf_token()
607
+ if not HF_TOKEN:
608
+ LAST_QWEN_ERROR = "Không tìm thấy token"
609
+ return None
610
+ if not AsyncInferenceClient:
611
+ LAST_QWEN_ERROR = "Thiếu huggingface_hub"
612
+ return None
613
+ errors = []; models = []
614
+ for m in [QWEN_VL_MODEL, "Qwen/Qwen2.5-VL-7B-Instruct", "Qwen/Qwen2.5-VL-3B-Instruct"]:
615
+ if m and m not in models:
616
+ models.append(m)
617
+ for model in models:
618
+ try:
619
+ client = AsyncInferenceClient(provider="auto", api_key=HF_TOKEN, timeout=90)
620
+ content = []
621
+ # Collect all images: image_urls list takes priority, fall back to single image_url
622
+ all_img_urls = []
623
+ if image_urls:
624
+ all_img_urls = image_urls[:6] # max 6 images to avoid context overflow
625
+ elif image_url:
626
+ all_img_urls = [image_url]
627
+ for img_u in all_img_urls:
628
+ if img_u and img_u.startswith("http"):
629
+ content.append({"type": "image_url", "image_url": {"url": img_u}})
630
+ content.append({"type": "text", "text": prompt})
631
+ messages = [
632
+ {"role": "system", "content": (
633
+ "Bạn là biên tập viên báo điện tử tiếng Việt. "
634
+ "NHIỆM VỤ: Chỉ TÓM TẮT nội dung, KHÔNG viết lại bài đầy đủ. "
635
+ "QUY TẮC CỨNG: "
636
+ "(1) KHÔNG lặp lại bất kỳ nội dung nào — mỗi ý chỉ xuất hiện ĐÚNG 1 LẦN. "
637
+ "(2) Nếu 2 câu diễn đạt cùng 1 ý → bỏ cây thứ 2. "
638
+ "(3) KHÔNG dùng Markdown (##, **, ---, *). "
639
+ "(4) KHÔNG viết 'Dưới đây là', 'Tôi sẽ', 'Theo yêu cầu', 'Nhiệm vụ', 'Vai trò', 'Đây là bài tóm tắt'. "
640
+ "(5) KHÔNG bịa thông tin ngoài nguồn. "
641
+ "(6) Chỉ viết ĐOẠN VĂN THUẦN, không bullet points. "
642
+ "(7) Tối đa 200 từ. Ngắn gọn, súc tích. "
643
+ "(8) KHÔNG sao chép nguyên văn từ bài gốc. Viết lại bằng cách diễn đạt khác hoàn toàn."
644
+ )},
645
+ {"role": "user", "content": content}
646
+ ]
647
+ resp = await client.chat_completion(model=model, messages=messages, max_tokens=max_tokens, temperature=0.3, top_p=0.8)
648
+ txt = (resp.choices[0].message.content or "").strip()
649
+ if txt:
650
+ LAST_QWEN_ERROR = ""
651
+ return txt
652
+ except Exception as e:
653
+ errors.append(f"{model}: {type(e).__name__}: {str(e)[:220]}")
654
+ LAST_QWEN_ERROR = " | ".join(errors) or "Qwen không trả nội dung."
655
+ print("[qwen errors]", LAST_QWEN_ERROR)
656
+ return None
657
+
658
+
659
+ # ===== TTS GENERATION =====
660
+ async def _generate_tts_edge(text: str, voice_id: str, speed: float, out_path: str):
661
+ if edge_tts is None:
662
+ raise RuntimeError("edge-tts chưa cài đặt")
663
+ voice = TTS_VOICES.get(voice_id, TTS_VOICES[TTS_DEFAULT_VOICE])["id"]
664
+ pct = int((speed - 1.0) * 100)
665
+ rate = f"+{pct}%" if pct >= 0 else f"{pct}%"
666
+ communicate = edge_tts.Communicate(text, voice, rate=rate)
667
+ await communicate.save(out_path)
668
+
669
+ def _generate_tts_gtts(text: str, out_path: str):
670
+ if gTTS is None:
671
+ raise RuntimeError("gTTS chưa cài đặt")
672
+ gTTS(text, lang="vi").save(out_path)
673
+
674
+
675
+ # ===== SHORT VIDEO GENERATION =====
676
+ def _download_image(url, fallback_topic, out_path):
677
+ if url:
678
+ try:
679
+ r = requests.get(url, headers=HEADERS, timeout=15)
680
+ if r.status_code == 200 and len(r.content) > 1000:
681
+ with open(out_path, "wb") as f:
682
+ f.write(r.content)
683
+ return out_path
684
+ except Exception:
685
+ pass
686
+ gen = pollinations_image_url(fallback_topic)
687
  try:
688
+ r = requests.get(gen, headers=HEADERS, timeout=25)
689
+ if r.status_code == 200 and len(r.content) > 1000:
690
+ with open(out_path, "wb") as f:
691
+ f.write(r.content)
692
+ return out_path
 
 
 
 
 
693
  except Exception:
694
  pass
695
+ if Image:
696
+ Image.new("RGB", (1080, 860), (30, 55, 42)).save(out_path)
697
+ return out_path
698
+ raise RuntimeError("Không tạo được ảnh")
699
 
 
700
  def _make_short_frame(post, img_path, out_path):
 
 
 
 
701
  if Image is None:
702
+ raise RuntimeError("Pillow chưa sẵn sàng")
 
 
 
703
  W, H = 1080, 1920
704
  bg = Image.new("RGB", (W, H), (14, 14, 14))
 
705
  try:
706
  im = Image.open(img_path).convert("RGB")
707
+ target = (1080, 860)
708
+ im_ratio = im.width / im.height
709
  target_ratio = target[0] / target[1]
 
710
  if im_ratio > target_ratio:
711
+ new_h = target[1]; new_w = int(new_h * im_ratio)
 
712
  else:
713
+ new_w = target[0]; new_h = int(new_w / im_ratio)
 
 
714
  im = im.resize((new_w, new_h))
715
+ left = (new_w - target[0]) // 2; top = (new_h - target[1]) // 2
 
716
  im = im.crop((left, top, left + target[0], top + target[1]))
717
  bg.paste(im, (0, 0))
718
  except Exception:
719
  pass
 
720
  draw = ImageDraw.Draw(bg)
 
721
  try:
722
+ font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 52)
723
+ font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 40)
724
+ font_label = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 32)
725
  except Exception:
726
+ font_title = font_body = font_label = None
727
+ draw.rectangle((0, 780, W, H), fill=(14, 14, 14))
728
+ draw.text((54, 830), "VNEWS · Tường AI", fill=(92, 184, 122), font=font_label)
729
+ title = post.get("title", "")
730
+ words = title.split(); lines_t = []; cur = ""
731
+ for w in words:
732
+ if len(cur) + len(w) + 1 <= 24:
733
+ cur = (cur + " " + w).strip()
734
+ else:
735
+ if cur: lines_t.append(cur)
736
+ cur = w
737
+ if cur: lines_t.append(cur)
738
+ draw.multiline_text((54, 900), "\n".join(lines_t[:3]), fill=(255, 255, 255), font=font_title, spacing=10)
739
+ body_text = post.get("text", "")
740
+ words_b = body_text.split(); lines_b = []; cur_b = ""
741
+ for w in words_b:
742
+ if len(cur_b) + len(w) + 1 <= 34:
743
+ cur_b = (cur_b + " " + w).strip()
744
+ else:
745
+ if cur_b: lines_b.append(cur_b)
746
+ cur_b = w
747
+ if len(lines_b) >= 10:
748
  break
749
+ if cur_b and len(lines_b) < 10: lines_b.append(cur_b)
750
+ draw.multiline_text((54, 1120), "\n".join(lines_b), fill=(220, 220, 220), font=font_body, spacing=12)
751
  bg.save(out_path, quality=92)
 
752
 
753
+ def _short_script(post, max_chars=700):
754
+ txt = _clean_text(post.get("text", ""))
755
+ if len(txt) > max_chars:
756
+ # Trim at complete sentence
757
+ trimmed = txt[:max_chars]
758
+ last_end = -1
759
+ for m in re.finditer(r'[.!?](?=\s|$)', trimmed):
760
+ last_end = m.end()
761
+ if last_end > 0:
762
+ txt = trimmed[:last_end]
763
+ else:
764
+ txt = trimmed.rsplit(" ", 1)[0] + "."
765
+ title = _clean_text(post.get("title", ""))
766
+ return f"{title}. {txt}"
767
+
768
+ async def _generate_short_video(post, post_id: str, voice_id: str = None, speed: float = None) -> str:
769
+ try:
770
+ os.makedirs(SHORTS_DIR, exist_ok=True)
771
+ out_mp4 = os.path.join(SHORTS_DIR, _safe_name(post_id) + ".mp4")
772
+ if os.path.exists(out_mp4):
773
+ return "/api/ai/short-file/" + post_id
774
+
775
+ work = os.path.join(SHORTS_DIR, _safe_name(post_id))
776
+ os.makedirs(work, exist_ok=True)
777
+ img_path = os.path.join(work, "image.jpg")
778
+ frame_path = os.path.join(work, "frame.jpg")
779
+ audio_path = os.path.join(work, "voice.mp3")
780
+
781
+ _download_image(post.get("img"), post.get("title", "AI news"), img_path)
782
+ _make_short_frame(post, img_path, frame_path)
783
+ script = _short_script(post)
784
+
785
+ if voice_id is None:
786
+ voice_id = _detect_voice_for_topic(post.get("title", ""), post.get("text", ""))
787
+ if speed is None:
788
+ speed = TTS_DEFAULT_SPEED
789
 
 
 
 
 
 
 
790
  try:
791
+ await _generate_tts_edge(script, voice_id, speed, audio_path)
792
+ except Exception as e:
793
+ print(f"[TTS edge-tts error] {e}, falling back to gTTS")
794
+ if gTTS:
795
+ _generate_tts_gtts(script, audio_path)
796
+ else:
797
+ return ""
798
+
799
+ cmd = ["ffmpeg", "-y", "-loop", "1", "-i", frame_path, "-i", audio_path,
800
+ "-shortest", "-c:v", "libx264", "-tune", "stillimage", "-pix_fmt", "yuv420p",
801
+ "-c:a", "aac", "-b:a", "128k", "-vf", "scale=1080:1920", out_mp4]
802
+ subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
803
+ return "/api/ai/short-file/" + post_id
804
+ except Exception as e:
805
+ print(f"[short video error] {e}")
806
+ return ""
807
+
808
+
809
+ # ===== MAKE POST =====
810
+ def make_post(title, text, image, source_url, kind, sources=None, images=None):
811
+ return {
812
+ "id": str(int(time.time() * 1000)) + str(random.randint(100, 999)),
813
+ "title": title, "text": text, "img": image, "url": source_url,
814
+ "kind": kind, "sources": sources or [], "video": "",
815
+ "images": images or [], "ts": int(time.time())
816
+ }
817
+
818
+
819
+ # ===== SHARED PROMPT BUILDER — STRICT REWRITE =====
820
+ def _build_rewrite_prompt(title: str, raw: str, images: List[str] = None) -> str:
821
+ image_info = ""
822
+ if images:
823
+ num = len(images)
824
+ if num == 1:
825
+ image_info = "\n\nBài viết có 1 ảnh minh họa. Hãy tham khảo ảnh để hiểu ngữ cảnh (nếu phù hợp)."
826
  else:
827
+ image_info = f"\n\nBài viết có {num} ảnh minh họa. Hãy tham khảo tất cả ảnh để hiểu ngữ cảnh và bổ sung thông tin cho bài viết (nếu phù hợp)."
828
+
829
+ return f"""Đọc bài viết dưới đây và viết một bài TÓM TẮT NGẮN trên Tường AI.
830
+
831
+ ⚠️ QUY TẮC CỨNG — VI PHẠM = BỎ QUA:
832
+ 1. KHÔNG sao chép nguyên văn bất kỳ câu nào từ bài gốc. Phải diễn đạt LẠI hoàn toàn bằng ngôn ngữ riêng.
833
+ 2. KHÔNG lặp lại bất kỳ thông tin nào. Mỗi ý chỉ xuất hiện ĐÚNG 1 LẦN.
834
+ 3. Nếu 2 câu diễn đạt cùng 1 ý → chỉ giữ 1 câu, bỏ cây còn lại.
835
+ 4. KHÔNG dùng Markdown (##, **, ---, *).
836
+ 5. KHÔNG viết "Dưới đây là", "Tôi sẽ", "Theo yêu cầu", "Nhiệm vụ", "Vai trò", "Đây là bài tóm tắt", "Sau đây là", "Dựa trên".
837
+ 6. Viết thành ĐOẠN VĂN THUẦN, mạch lạc, dễ đọc. Không dùng bullet points.
838
+ 7. Giữ sự thật, KHÔNG bịa thông tin.
839
+ 8. Tối đa 200 từ. Ngắn gọn, đủ ý.
840
+ 9. Mỗi câu phải trọn vẹn, không bị ngắt giữa chừng.{image_info}
841
+
842
+ Tiêu đề gốc: {title}
843
+
844
+ Nội dung gốc:
845
+ {raw[:14000]}
846
+
847
+ Chỉ viết phần tóm tắt, không thêm gì khác:"""
848
+
849
+
850
+ def _build_topic_prompt(topic: str, ctx: str) -> str:
851
+ return f"""Viết bài TÓM TẮT NGẮN GỌN về chủ đề: "{topic}".
852
+
853
+ ⚠️ QUY TẮC CỨNG:
854
+ 1. KHÔNG sao chép nguyên văn từ nguồn. Phải diễn đạt LẠI hoàn toàn.
855
+ 2. KHÔNG lặp lại bất kỳ nội dung nào. Mỗi thông tin chỉ xuất hiện ĐÚNG 1 LẦN.
856
+ 3. Nếu 2 câu nói cùng 1 ý → chỉ giữ 1 câu.
857
+ 4. KHÔNG dùng Markdown (##, **, ---, *).
858
+ 5. KHÔNG viết "Dưới đây là", "Tôi sẽ", "Theo yêu cầu", "Nhiệm vụ", "Vai trò".
859
+ 6. Viết thành ĐOẠN VĂN THUẦN, mạch lạc. Không dùng bullet points.
860
+ 7. Giữ sự thật, KHÔNG bịa.
861
+ 8. Tối đa 200 từ. Ngắn gọn, đủ ý.
862
+ 9. Mỗi câu phải trọn vẹn.
863
+
864
+ Nguồn thực tế:
865
+ {ctx[:12000]}
866
+
867
+ Chỉ viết phần tóm tắt, không thêm gì khác:"""
868
+
869
+
870
+ # ===== WRITE ENDPOINTS =====
871
+ @app.post("/api/rewrite_share")
872
+ async def api_rewrite_share(request: Request):
873
+ body = await request.json()
874
+ url = _clean_text(body.get("url", ""))
875
+ if not url.startswith("http"):
876
+ return JSONResponse({"error": "missing url"}, status_code=400)
877
+ try:
878
+ data = scrape_any_url(url)
879
+ except Exception as e:
880
+ return JSONResponse({"error": "Không đọc được bài viết: " + str(e)[:180]}, status_code=422)
881
+ raw = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
882
+ if len(raw) < 60:
883
+ return JSONResponse({"error": "Bài viết quá ngắn để tóm tắt"}, status_code=422)
884
+
885
+ images = data.get("images", [])
886
+ prompt = _build_rewrite_prompt(data.get("title", ""), raw, images)
887
+ text = await qwen_generate(prompt, image_urls=images if images else None, max_tokens=700)
888
+ if not text:
889
+ return JSONResponse({"error": "Qwen2.5-VL chưa sẵn sàng: " + LAST_QWEN_ERROR}, status_code=503)
890
+ text = _clean_ai_output(text)
891
+ post = make_post(data.get("title") or "Bài viết", text,
892
+ images[0] if images else data.get("image", ""),
893
+ url, "rewrite", images=images)
894
+
895
+ # Auto-generate short video
896
+ video_url = await _generate_short_video(post, post["id"])
897
+ if video_url:
898
+ post["video"] = video_url
899
+
900
+ posts = _load_wall()
901
+ posts.insert(0, post)
902
+ _save_wall(posts)
903
+ return JSONResponse({"post": post})
904
+
905
+
906
+ @app.post("/api/url_wall")
907
+ async def api_url_wall(request: Request):
908
+ body = await request.json()
909
+ url = _clean_text(body.get("url", ""))
910
+ if not url.startswith("http"):
911
+ return JSONResponse({"error": "missing url"}, status_code=400)
912
+ try:
913
+ data = scrape_any_url(url)
914
+ except Exception as e:
915
+ return JSONResponse({"error": "Không scrape được URL: " + str(e)[:180]}, status_code=422)
916
+ raw = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
917
+ if len(raw) < 60:
918
+ return JSONResponse({"error": "URL không có đủ nội dung"}, status_code=422)
919
+
920
+ images = data.get("images", [])
921
+ prompt = _build_rewrite_prompt(data.get("title", ""), raw, images)
922
+ text = await qwen_generate(prompt, image_urls=images if images else None, max_tokens=700)
923
+ if not text:
924
+ return JSONResponse({"error": "Qwen2.5-VL chưa sẵn sàng: " + LAST_QWEN_ERROR}, status_code=503)
925
+ text = _clean_ai_output(text)
926
+ post = make_post(data.get("title") or "Bài viết", text,
927
+ images[0] if images else data.get("image", ""),
928
+ url, "url", images=images)
929
+
930
+ video_url = await _generate_short_video(post, post["id"])
931
+ if video_url:
932
+ post["video"] = video_url
933
+
934
+ posts = _load_wall()
935
+ posts.insert(0, post)
936
+ _save_wall(posts)
937
+ return JSONResponse({"post": post})
938
+
939
+
940
+ @app.post("/api/topic_post")
941
+ async def api_topic_post(request: Request):
942
+ body = await request.json()
943
+ topic = _clean_text(body.get("topic", ""))
944
+ if not topic:
945
+ return JSONResponse({"error": "missing topic"}, status_code=400)
946
+
947
+ ctx = _web_context(topic)
948
+ if not ctx:
949
+ return JSONResponse({"error": "Không lấy được dữ liệu cho chủ đề này"}, status_code=422)
950
+
951
+ image = pollinations_image_url(topic)
952
+ prompt = _build_topic_prompt(topic, ctx)
953
+ text = await qwen_generate(prompt, image_url=image, max_tokens=700)
954
+ if not text:
955
+ return JSONResponse({"error": "Qwen2.5-VL chưa sẵn sàng: " + LAST_QWEN_ERROR}, status_code=503)
956
+ text = _clean_ai_output(text)
957
+ post = make_post(topic, text, image, "", "topic")
958
+
959
+ video_url = await _generate_short_video(post, post["id"])
960
+ if video_url:
961
+ post["video"] = video_url
962
+
963
+ posts = _load_wall()
964
+ posts.insert(0, post)
965
+ _save_wall(posts)
966
+ return JSONResponse({"post": post})
967
+
968
+
969
+ # ===== WALL ENDPOINTS =====
970
+ @app.get("/api/ai_wall")
971
+ def api_ai_wall():
972
+ return JSONResponse({"posts": _load_wall()[:80]})
973
+
974
+ @app.get("/api/wall")
975
+ def api_wall():
976
+ return JSONResponse({"posts": _load_wall()[:80]})
977
+
978
+
979
+ # ===== SHORT VIDEO ENDPOINT =====
980
+ @app.post("/api/ai/short/{post_id}")
981
+ async def api_ai_short(post_id: str, voice: str = Query(default=None), speed: float = Query(default=None)):
982
+ posts = _load_wall()
983
+ post = next((p for p in posts if str(p.get("id")) == str(post_id)), None)
984
+ if not post:
985
+ return JSONResponse({"error": "post not found"}, status_code=404)
986
+
987
+ os.makedirs(SHORTS_DIR, exist_ok=True)
988
+ out_mp4 = os.path.join(SHORTS_DIR, _safe_name(post_id) + ".mp4")
989
+
990
+ if os.path.exists(out_mp4) and voice is None and speed is None:
991
+ video_url = "/api/ai/short-file/" + post_id
992
+ for i, p in enumerate(posts):
993
+ if str(p.get("id")) == str(post_id):
994
+ posts[i]["video"] = video_url
995
+ break
996
+ _save_wall(posts)
997
+ return JSONResponse({"video": video_url})
998
+
999
+ if voice is not None and voice not in TTS_VOICES:
1000
+ return JSONResponse({"error": f"voice không hợp lệ. Chọn: {list(TTS_VOICES.keys())}"}, status_code=400)
1001
+
1002
+ video_url = await _generate_short_video(post, post_id, voice_id=voice, speed=speed)
1003
+ if video_url:
1004
+ for i, p in enumerate(posts):
1005
+ if str(p.get("id")) == str(post_id):
1006
+ posts[i]["video"] = video_url
1007
+ break
1008
+ _save_wall(posts)
1009
+ return JSONResponse({"video": video_url})
1010
+ return JSONResponse({"error": "Không tạo được shorts"}, status_code=500)
1011
+
1012
+
1013
+ @app.get("/api/ai/short-file/{post_id}")
1014
+ def api_ai_short_file(post_id: str):
1015
+ path = os.path.join(SHORTS_DIR, _safe_name(post_id) + ".mp4")
1016
+ if not os.path.exists(path):
1017
+ return JSONResponse({"error": "not found"}, status_code=404)
1018
+ return FileResponse(path, media_type="video/mp4", filename=f"vnews-ai-{post_id}.mp4")
1019
+
1020
+
1021
+ @app.get("/api/ai/status")
1022
+ def api_ai_status():
1023
+ return JSONResponse({
1024
+ "has_token": bool(_hf_token()),
1025
+ "client_imported": AsyncInferenceClient is not None,
1026
+ "model": QWEN_VL_MODEL,
1027
+ "last_error": LAST_QWEN_ERROR,
1028
+ "tts_ready": gTTS is not None or edge_tts is not None,
1029
+ "tts_engine": "edge-tts" if edge_tts else ("gtts" if gTTS else "none"),
1030
+ "tts_voices": {k: v["flag"] + " " + v["name"] for k, v in TTS_VOICES.items()},
1031
+ "tts_voice_count": len(TTS_VOICES),
1032
+ "tts_default_speed": TTS_DEFAULT_SPEED,
1033
+ })
1034
+
1035
+
1036
+ @app.get("/api/ai/voices")
1037
+ def api_ai_voices():
1038
+ voices_out = {}
1039
+ for k, v in TTS_VOICES.items():
1040
+ voices_out[k] = {
1041
+ "name": v["name"],
1042
+ "gender": v["gender"],
1043
+ "country": v["country"],
1044
+ "lang": v["lang"],
1045
+ "flag": v["flag"],
1046
+ "label": f"{v['flag']} {v['name']} ({v['gender']})",
1047
+ }
1048
+ return JSONResponse({
1049
+ "voices": voices_out,
1050
+ "default_voice": TTS_DEFAULT_VOICE,
1051
+ "default_speed": TTS_DEFAULT_SPEED,
1052
+ "topic_voice_map": TOPIC_VOICE_MAP,
1053
+ })
ai_fix2.py CHANGED
@@ -321,7 +321,7 @@ async def ai_short_full(post_id: str, request: Request):
321
  subprocess.run(['ffmpeg','-y','-i',audio,'-filter:a',f'atempo={speed}','-vn',audio_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=220)
322
  duration = 45.0
323
  try:
324
- pr = subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:no_key=1',audio_fast], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
325
  duration = float((pr.stdout or b'45').decode().strip() or 45)
326
  except Exception:
327
  pass
 
321
  subprocess.run(['ffmpeg','-y','-i',audio,'-filter:a',f'atempo={speed}','-vn',audio_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=220)
322
  duration = 45.0
323
  try:
324
+ pr = subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:nokey=1',audio_fast], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
325
  duration = float((pr.stdout or b'45').decode().strip() or 45)
326
  except Exception:
327
  pass
ai_patch.py CHANGED
@@ -6,7 +6,6 @@ import json
6
  import html as html_lib
7
  import subprocess
8
  import requests
9
- import hashlib
10
  import ai_ext as base
11
  from ai_ext import app
12
  from fastapi import Request
@@ -42,17 +41,17 @@ def _similar(a, b):
42
  return len(ta & tb) / max(1, min(len(ta), len(tb))) >= 0.72
43
 
44
 
45
- def _dedupe_units(units, max_units=25):
46
- """Deduplicate units - only skip exact matches to ensure all bullet points are read."""
47
  out, seen = [], set()
48
  for u in units:
49
  u = _clean(re.sub(r"^[-•*\d\.\)\s]+", "", u))
50
  if len(u) < 18:
51
  continue
52
  nu = _norm(u)
53
- # Only skip exact matches, NOT similar content (to avoid skipping valid bullet points)
54
  if nu in seen:
55
  continue
 
 
56
  seen.add(nu)
57
  out.append(u)
58
  if len(out) >= max_units:
@@ -60,7 +59,7 @@ def _dedupe_units(units, max_units=25):
60
  return out
61
 
62
 
63
- def _postprocess_ai_text(text, max_units=20):
64
  text = _clean(text)
65
  if not text:
66
  return text
@@ -79,9 +78,10 @@ def _postprocess_ai_text(text, max_units=20):
79
  raw_lines.append(line)
80
  units = []
81
  for line in raw_lines:
82
- # KEEP FULL bullet point - don't truncate or split into segments
83
- if len(line) >= 18:
84
- units.append(_clean(re.sub(r"^[-•*\d\.\)\s]+", "", line)))
 
85
  units = _dedupe_units(units, max_units=max_units)
86
  if not units:
87
  return text[:900]
@@ -194,6 +194,7 @@ def _topic_source_articles(topic, limit=5):
194
  break
195
  except Exception:
196
  continue
 
197
  if not out:
198
  for s in (sources or _direct_news_rss(topic, 6))[:limit]:
199
  title = s.get("title") or topic
@@ -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=12)
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=1500)
325
- text = _postprocess_ai_text(text, max_units=20)
326
  src = [art['source']]
327
  if 'Nguồn tham khảo:' not in text:
328
  text += "\n\n" + _source_line(src)
@@ -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=1500)
351
- text = _postprocess_ai_text(text, max_units=20)
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,53 +372,40 @@ async def compat_rewrite_share(request: Request):
481
  if len(raw) < 120:
482
  return JSONResponse({'error': 'Bài viết không đủ nội dung để tóm tắt'}, status_code=422)
483
  prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
484
- text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=1500)
485
- text = _postprocess_ai_text(text, max_units=20)
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
- # REMOVED: No prefix added to keep content clean and natural
 
 
 
 
 
 
 
516
  return text
517
 
518
 
519
  def _tts_script_smart(post, emotion):
520
- raw = base._short_script(post) if hasattr(base, '_short_script') else _clean(post.get('text', '') or post.get('title', ''))
521
  raw = re.sub(r"^[•\-\*]\s*", "", raw, flags=re.M)
522
  raw = re.sub(r"\s*\n\s*", ". ", raw)
523
  raw = re.sub(r"([\.\!\?])\s*", r"\1\n", raw)
524
  raw = re.sub(r"\n{2,}", "\n", raw).strip()
525
- # REMOVED: _emotion_script call - read content cleanly without prefix
526
- # INCREASED to 3000 to read full content of all bullet points
527
- if len(raw) > 3000:
528
- raw = raw[:3000]
529
  cut = max(raw.rfind("."), raw.rfind("!"), raw.rfind("?"))
530
- if cut > 700:
531
  raw = raw[:cut + 1]
532
  return raw
533
 
@@ -642,7 +520,8 @@ def _make_short_frame_full(post, img_path, out_path):
642
 
643
 
644
 
645
- def _summary_segments_from_post(post, max_segments=25):
 
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 +532,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)<3:
657
  lines=[]
658
  for s in re.split(r'(?<=[\.\!\?])\s+', raw):
659
  s=_clean(s)
@@ -663,12 +542,14 @@ def _summary_segments_from_post(post, max_segments=25):
663
 
664
 
665
  def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='neutral'):
 
666
  if Image is None:
667
  return _make_short_frame_full(post, img_path, out_path)
668
  W,H=1080,1920
669
  bg=Image.new('RGB',(W,H),(10,10,10))
670
  try:
671
  im=Image.open(img_path).convert('RGB')
 
672
  ratio=im.width/max(1,im.height); target=W/H
673
  if ratio>target:
674
  nh=H; nw=int(nh*ratio)
@@ -678,6 +559,7 @@ def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='ne
678
  cover=cover.crop((left,top,left+W,top+H))
679
  bg.paste(cover,(0,0))
680
  bg=Image.blend(bg, Image.new('RGB',(W,H),(0,0,0)), 0.50)
 
681
  hero_h=720; target=W/hero_h
682
  if ratio>target:
683
  nh=hero_h; nw=int(nh*ratio)
@@ -697,6 +579,7 @@ def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='ne
697
  except Exception:
698
  font_brand=font_small=font_seg=font_title=None
699
  draw.rectangle((0,680,W,H), fill=(12,12,12))
 
700
  dot_x=48; dot_y=742
701
  for i in range(total):
702
  fill=(92,184,122) if i==idx else (70,70,70)
@@ -705,8 +588,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
- # INCREASED from 12 to 18 for full content display - each key point can span multiple lines
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,24 +600,24 @@ 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=15.0):
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(12.0, float((pr.stdout or b'').decode().strip() or fallback))
726
  except Exception:
727
  return fallback
728
 
729
 
730
  @app.post('/api/ai/short/{post_id}')
731
  async def patched_ai_short(post_id: str, request: Request):
 
732
  try:
733
  body = await request.json()
734
  except Exception:
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.0) or 1.0)
739
  speed = max(0.85, min(1.35, speed))
740
 
741
  posts = base._load_ai_wall()
@@ -743,7 +625,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=25)
747
  seg_hash = hashlib.md5(('|'.join(segments)+voice+emotion+str(speed)).encode('utf-8')).hexdigest()[:8]
748
  os.makedirs(base.SHORTS_DIR, exist_ok=True)
749
  suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_{seg_hash}_scenes_nosub"
@@ -766,63 +648,11 @@ async def patched_ai_short(post_id: str, request: Request):
766
  try:
767
  base._download_image(post.get('img'), post.get('title', 'AI news'), img)
768
  edge_voice = {
769
- # Vietnamese
770
- 'vi-vn-hoaimyneural': 'vi-VN-HoaiMyNeural',
771
- 'vi-vn-namminhneural': 'vi-VN-NamMinhNeural',
772
- 'hoaimy': 'vi-VN-HoaiMyNeural',
773
- 'namminh': 'vi-VN-NamMinhNeural',
774
  'nam': 'vi-VN-NamMinhNeural',
775
  'male': 'vi-VN-NamMinhNeural',
776
  'nu': 'vi-VN-HoaiMyNeural',
777
  'female': 'vi-VN-HoaiMyNeural',
778
  'mien-nam': 'vi-VN-HoaiMyNeural',
779
- # English - Multilingual
780
- 'en-us-andrewmultilingualneural': 'en-US-AndrewMultilingualNeural',
781
- 'en-au-williammultilingualneural': 'en-AU-WilliamMultilingualNeural',
782
- 'andrew': 'en-US-AndrewMultilingualNeural',
783
- 'en_andrew': 'en-US-AndrewMultilingualNeural',
784
- 'jenny': 'en-US-AndrewMultilingualNeural',
785
- 'en_jenny': 'en-US-AndrewMultilingualNeural',
786
- # Portuguese - Multilingual (ONLY Thalita)
787
- 'pt-br-thalitamultilingualneural': 'pt-BR-ThalitaMultilingualNeural',
788
- 'thalita': 'pt-BR-ThalitaMultilingualNeural',
789
- 'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
790
- 'pt_br_thalita': 'pt-BR-ThalitaMultilingualNeural',
791
- 'pt': 'pt-BR-ThalitaMultilingualNeural',
792
- 'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
793
- # French - Multilingual
794
- 'fr-fr-viviennemultilingualneural': 'fr-FR-VivienneMultilingualNeural',
795
- 'fr-fr-remymultilingualneural': 'fr-FR-RemyMultilingualNeural',
796
- 'denise': 'fr-FR-VivienneMultilingualNeural',
797
- 'fr': 'fr-FR-VivienneMultilingualNeural',
798
- 'fr_denise': 'fr-FR-VivienneMultilingualNeural',
799
- # German - Multilingual
800
- 'de-de-seraphinamultilingualneural': 'de-DE-SeraphinaMultilingualNeural',
801
- 'de-de-florianmultilingualneural': 'de-DE-FlorianMultilingualNeural',
802
- 'katja': 'de-DE-SeraphinaMultilingualNeural',
803
- 'de': 'de-DE-SeraphinaMultilingualNeural',
804
- 'de_katja': 'de-DE-SeraphinaMultilingualNeural',
805
- # Korean - Multilingual (Hyunsu, NOT SunHee)
806
- 'ko-kr-hyusumultilingualneural': 'ko-KR-HyunsuMultilingualNeural',
807
- 'ko-kr-hyunsuneural': 'ko-KR-HyunsuMultilingualNeural',
808
- 'sunhee': 'ko-KR-HyunsuMultilingualNeural',
809
- 'ko': 'ko-KR-HyunsuMultilingualNeural',
810
- 'ko_sunhee': 'ko-KR-HyunsuMultilingualNeural',
811
- # Italian - Multilingual
812
- 'it-it-giuseppemultilingualneural': 'it-IT-GiuseppeMultilingualNeural',
813
- # Spanish (keep for backward compat)
814
- 'ela': 'en-US-AndrewMultilingualNeural',
815
- 'es_ela': 'en-US-AndrewMultilingualNeural',
816
- 'es': 'en-US-AndrewMultilingualNeural',
817
- 'es_carlos': 'en-US-AndrewMultilingualNeural',
818
- # Japanese (keep for backward compat)
819
- 'nanami': 'en-US-AndrewMultilingualNeural',
820
- 'ja': 'en-US-AndrewMultilingualNeural',
821
- 'ja_nanami': 'en-US-AndrewMultilingualNeural',
822
- # Chinese (keep for backward compat)
823
- 'xiaochen': 'en-US-AndrewMultilingualNeural',
824
- 'zh': 'en-US-AndrewMultilingualNeural',
825
- 'zh_xiaochen': 'en-US-AndrewMultilingualNeural',
826
  }.get(voice, 'vi-VN-HoaiMyNeural')
827
  part_files=[]
828
  for idx, seg in enumerate(segments):
@@ -835,13 +665,14 @@ async def patched_ai_short(post_id: str, request: Request):
835
  try:
836
  subprocess.run(['python','-m','edge_tts','--voice',edge_voice,'--text',spoken,'--write-media',aud], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
837
  except Exception:
838
- tld='com.vn' if voice in ('nu','female','mien-nam','hoaimy') else 'com'
839
  try:
840
  base.gTTS(spoken, lang='vi', tld=tld, slow=False).save(aud)
841
  except TypeError:
842
  base.gTTS(spoken, lang='vi', slow=False).save(aud)
843
  subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
844
- dur=_estimate_audio_duration(aud_fast, fallback=15.0)+0.35
 
845
  subprocess.run(['ffmpeg','-y','-loop','1','-t',str(dur),'-i',frame,'-i',aud_fast,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k',part], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=150)
846
  part_files.append(part)
847
  concat=os.path.join(work,'concat.txt')
@@ -876,3 +707,33 @@ def api_ai_shorts():
876
 
877
 
878
  app.router.routes = [r for r in app.router.routes if not (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set()))]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  import html as html_lib
7
  import subprocess
8
  import requests
 
9
  import ai_ext as base
10
  from ai_ext import app
11
  from fastapi import Request
 
41
  return len(ta & tb) / max(1, min(len(ta), len(tb))) >= 0.72
42
 
43
 
44
+ def _dedupe_units(units, max_units=7):
 
45
  out, seen = [], set()
46
  for u in units:
47
  u = _clean(re.sub(r"^[-•*\d\.\)\s]+", "", u))
48
  if len(u) < 18:
49
  continue
50
  nu = _norm(u)
 
51
  if nu in seen:
52
  continue
53
+ if any(_similar(u, old) for old in out):
54
+ continue
55
  seen.add(nu)
56
  out.append(u)
57
  if len(out) >= max_units:
 
59
  return out
60
 
61
 
62
+ def _postprocess_ai_text(text, max_units=7):
63
  text = _clean(text)
64
  if not text:
65
  return text
 
78
  raw_lines.append(line)
79
  units = []
80
  for line in raw_lines:
81
+ if len(line) > 260:
82
+ units.extend(re.split(r"(?<=[\.\!\?])\s+(?=[A-ZÀ-Ỹ0-9])", line))
83
+ else:
84
+ units.append(line)
85
  units = _dedupe_units(units, max_units=max_units)
86
  if not units:
87
  return text[:900]
 
194
  break
195
  except Exception:
196
  continue
197
+ # Fallback to headlines/snippets only if no full body works.
198
  if not out:
199
  for s in (sources or _direct_news_rss(topic, 6))[:limit]:
200
  title = s.get("title") or topic
 
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
  text = _clean(text)
387
+ if emotion == 'urgent':
388
+ return 'Tin nhanh. ' + text
389
+ if emotion == 'warm':
390
+ return 'Câu chuyện đáng chú ý. ' + text
391
+ if emotion == 'serious':
392
+ return 'Bản tin nghiêm túc. ' + text
393
+ if emotion == 'energetic':
394
+ return 'Cập nhật nổi bật. ' + text
395
  return text
396
 
397
 
398
  def _tts_script_smart(post, emotion):
399
+ raw = base._short_script(post)
400
  raw = re.sub(r"^[•\-\*]\s*", "", raw, flags=re.M)
401
  raw = re.sub(r"\s*\n\s*", ". ", raw)
402
  raw = re.sub(r"([\.\!\?])\s*", r"\1\n", raw)
403
  raw = re.sub(r"\n{2,}", "\n", raw).strip()
404
+ raw = _emotion_script(raw, emotion)
405
+ if len(raw) > 1000:
406
+ raw = raw[:1000]
 
407
  cut = max(raw.rfind("."), raw.rfind("!"), raw.rfind("?"))
408
+ if cut > 350:
409
  raw = raw[:cut + 1]
410
  return raw
411
 
 
520
 
521
 
522
 
523
+ def _summary_segments_from_post(post, max_segments=7):
524
+ """Only use current post summary. Each bullet/paragraph becomes one short scene."""
525
  raw = _clean(post.get('text') or post.get('title') or '')
526
  raw = re.sub(r'^Bản tin AI viết lại:\s*', '', raw, flags=re.I)
527
  raw = re.sub(r'Nguồn tham khảo:.*$', '', raw, flags=re.I|re.S).strip()
 
532
  low=ln.lower()
533
  if low.startswith(('điểm chính','tiêu đề','sapo','nguồn tham khảo')): continue
534
  if len(ln)>=18: lines.append(ln)
535
+ if len(lines)<2:
536
  lines=[]
537
  for s in re.split(r'(?<=[\.\!\?])\s+', raw):
538
  s=_clean(s)
 
542
 
543
 
544
  def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='neutral'):
545
+ """Create one 9:16 frame for one summary segment. No subtitle layer."""
546
  if Image is None:
547
  return _make_short_frame_full(post, img_path, out_path)
548
  W,H=1080,1920
549
  bg=Image.new('RGB',(W,H),(10,10,10))
550
  try:
551
  im=Image.open(img_path).convert('RGB')
552
+ # background cover
553
  ratio=im.width/max(1,im.height); target=W/H
554
  if ratio>target:
555
  nh=H; nw=int(nh*ratio)
 
559
  cover=cover.crop((left,top,left+W,top+H))
560
  bg.paste(cover,(0,0))
561
  bg=Image.blend(bg, Image.new('RGB',(W,H),(0,0,0)), 0.50)
562
+ # top hero image
563
  hero_h=720; target=W/hero_h
564
  if ratio>target:
565
  nh=hero_h; nw=int(nh*ratio)
 
579
  except Exception:
580
  font_brand=font_small=font_seg=font_title=None
581
  draw.rectangle((0,680,W,H), fill=(12,12,12))
582
+ # progress bars
583
  dot_x=48; dot_y=742
584
  for i in range(total):
585
  fill=(92,184,122) if i==idx else (70,70,70)
 
588
  draw.rounded_rectangle((48,834,260,880), radius=20, fill=(28,70,45))
589
  draw.text((66,842),f'Đoạn {idx+1}/{total}',fill=(235,235,235),font=font_small)
590
  y=940; maxw=W-96
591
+ for ln in _wrap_text_px(draw, segment, font_seg, maxw, 8):
 
592
  draw.text((48,y),ln,fill=(255,255,255),font=font_seg)
593
  y+=74
594
  if y>1500: break
 
600
  bg.save(out_path, quality=92)
601
 
602
 
603
+ def _estimate_audio_duration(path, fallback=4.0):
 
604
  try:
605
+ pr=subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:nokey=1',path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
606
+ return max(1.5, float((pr.stdout or b'').decode().strip() or fallback))
607
  except Exception:
608
  return fallback
609
 
610
 
611
  @app.post('/api/ai/short/{post_id}')
612
  async def patched_ai_short(post_id: str, request: Request):
613
+ """Create 9:16 short from separate timed summary segments; no subtitles."""
614
  try:
615
  body = await request.json()
616
  except Exception:
617
  body = {}
618
  voice = str(body.get('voice', 'nu')).strip().lower()
619
  emotion = str(body.get('emotion', 'neutral')).strip().lower()
620
+ speed = float(body.get('speed', 1.2) or 1.2)
621
  speed = max(0.85, min(1.35, speed))
622
 
623
  posts = base._load_ai_wall()
 
625
  if not post:
626
  return JSONResponse({'error': 'post not found'}, status_code=404)
627
 
628
+ segments = _summary_segments_from_post(post, max_segments=7)
629
  seg_hash = hashlib.md5(('|'.join(segments)+voice+emotion+str(speed)).encode('utf-8')).hexdigest()[:8]
630
  os.makedirs(base.SHORTS_DIR, exist_ok=True)
631
  suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_{seg_hash}_scenes_nosub"
 
648
  try:
649
  base._download_image(post.get('img'), post.get('title', 'AI news'), img)
650
  edge_voice = {
 
 
 
 
 
651
  'nam': 'vi-VN-NamMinhNeural',
652
  'male': 'vi-VN-NamMinhNeural',
653
  'nu': 'vi-VN-HoaiMyNeural',
654
  'female': 'vi-VN-HoaiMyNeural',
655
  'mien-nam': 'vi-VN-HoaiMyNeural',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
656
  }.get(voice, 'vi-VN-HoaiMyNeural')
657
  part_files=[]
658
  for idx, seg in enumerate(segments):
 
665
  try:
666
  subprocess.run(['python','-m','edge_tts','--voice',edge_voice,'--text',spoken,'--write-media',aud], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
667
  except Exception:
668
+ tld='com.vn' if voice in ('nu','female','mien-nam') else 'com'
669
  try:
670
  base.gTTS(spoken, lang='vi', tld=tld, slow=False).save(aud)
671
  except TypeError:
672
  base.gTTS(spoken, lang='vi', slow=False).save(aud)
673
  subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
674
+ dur=_estimate_audio_duration(aud_fast, fallback=4.0)+0.35
675
+ # No subtitles: scene text is part of frame only.
676
  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)
677
  part_files.append(part)
678
  concat=os.path.join(work,'concat.txt')
 
707
 
708
 
709
  app.router.routes = [r for r in app.router.routes if not (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set()))]
710
+
711
+ PATCH_INJECT = r'''
712
+ <style>.ai-wall-patched{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.ai-wall-card{flex:0 0 250px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:8px}.ai-wall-img{width:100%;aspect-ratio:16/9;background:#222;border-radius:8px;overflow:hidden;margin-bottom:6px}.ai-wall-img img{width:100%;height:100%;object-fit:cover}.ai-wall-title{font-size:12px;color:#5cb87a;font-weight:800;line-height:1.3;margin-bottom:4px}.ai-wall-text{font-size:11px;color:#bbb;line-height:1.45;white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden}.ai-wall-actions{display:flex;gap:6px;margin-top:8px}.ai-wall-actions button,.ai-wall-actions select{flex:1;border:1px solid #333;background:#222;color:#ddd;border-radius:14px;padding:6px 8px;font-size:10px;min-width:0}.ai-wall-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}.ai-short-card{flex:0 0 145px}.ai-short-video{width:100%;aspect-ratio:9/16;background:#000;border-radius:8px;overflow:hidden}.ai-short-video video{width:100%;height:100%;object-fit:cover}.ai-short-progress{position:fixed;inset:0;background:rgba(0,0,0,.78);z-index:99999;display:none;align-items:center;justify-content:center;padding:20px}.ai-short-progress.active{display:flex}.ai-short-box{max-width:420px;width:100%;background:#141414;border:2px solid #2d8659;border-radius:14px;padding:18px;color:#eee;box-shadow:0 0 30px rgba(45,134,89,.35)}.ai-short-box h3{color:#5cb87a;margin-bottom:10px}.ai-short-step{font-size:13px;line-height:1.55;color:#ccc}.ai-short-spinner{width:34px;height:34px;border:4px solid #333;border-top-color:#5cb87a;border-radius:50%;animation:spin 1s linear infinite;margin:10px auto}@keyframes spin{to{transform:rotate(360deg)}}</style>
713
+ <div id="ai-short-progress" class="ai-short-progress"><div class="ai-short-box"><h3>🎬 Đang tạo Short AI</h3><div class="ai-short-spinner"></div><div class="ai-short-step" id="ai-short-step">Đang chuẩn bị...</div></div></div>
714
+ <script>
715
+ (function(){
716
+ function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
717
+ let patchedWall=[];let aiShorts=[];
718
+ function showProgress(msg){let box=document.getElementById('ai-short-progress');let st=document.getElementById('ai-short-step');if(st)st.innerHTML=msg;if(box)box.classList.add('active');}
719
+ function hideProgress(){document.getElementById('ai-short-progress')?.classList.remove('active');}
720
+ function updateAiLabels(){document.querySelectorAll('.ai-compose-title').forEach(e=>e.textContent='🤖 Tường AI: lọc từng bài theo chủ đề, tóm tắt nội dung bài');document.querySelectorAll('button').forEach(b=>{if((b.textContent||'').includes('AI viết lại'))b.textContent='🤖 Tóm tắt AI & đăng tường';});}
721
+ async function loadPatchedWall(){try{const r=await fetch('/api/ai_wall');const j=await r.json();patchedWall=j.posts||[];renderPatchedWall();updateAiLabels();}catch(e){}try{const r2=await fetch('/api/ai_shorts');const j2=await r2.json();aiShorts=j2.posts||[];renderAiShorts();}catch(e){}}
722
+ function renderAiShorts(){const home=document.getElementById('view-home');if(!home)return;document.getElementById('ai-shorts-patched')?.remove();if(!aiShorts.length)return;let wrap=document.createElement('div');wrap.id='ai-shorts-patched';wrap.className='ai-wall-patched';let h='<div class="slider-header"><span class="slider-label">🎬 Short AI</span><span class="slider-note">Video đã tạo</span></div><div class="slider-track">';aiShorts.slice(0,30).forEach((p,i)=>{h+=`<div class="ai-short-card" onclick="aiReadShortPatched(${i})"><div class="ai-short-video"><video src="${p.video}" muted playsinline preload="metadata"></video></div><div class="slider-title">${esc(p.title)}</div></div>`});h+='</div>';wrap.innerHTML=h;let wall=document.getElementById('ai-wall-patched');if(wall)wall.after(wrap);else home.prepend(wrap);}
723
+ function renderPatchedWall(){const home=document.getElementById('view-home');if(!home)return;document.getElementById('ai-wall-patched')?.remove();if(!patchedWall.length)return;let wrap=document.createElement('div');wrap.id='ai-wall-patched';wrap.className='ai-wall-patched';let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI</span><span class="slider-note">Mỗi nguồn = một bài tóm tắt</span></div><div class="slider-track">';patchedWall.slice(0,30).forEach((p,i)=>{h+=`<div class="ai-wall-card"><div class="ai-wall-img">${p.img?`<img src="${p.img}">`:''}</div><div class="ai-wall-title">${esc(p.title)}</div><div class="ai-wall-text">${esc(p.text)}</div><div class="ai-wall-actions"><button onclick="aiReadWallPatched(${i})">Xem</button><button class="primary" onclick="aiMakeShortPatched(${i})">Shorts</button></div></div>`});h+='</div>';wrap.innerHTML=h;let after=document.querySelector('.ai-compose');if(after)after.after(wrap);else home.prepend(wrap);}
724
+ window.aiReadShortPatched=function(i){const p=aiShorts[i];if(!p)return;showView('view-article');let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">Short AI</span><h1 class="article-title">${esc(p.title)}</h1><video class="article-img" src="${p.video}" controls playsinline autoplay></video><p class="article-p" style="white-space:pre-wrap">${esc(p.text||'')}</p><div class="article-actions"><button onclick="window.open('${p.video}','_blank')">⬇ Mở video</button>${p.url?`<button onclick="window.open('${p.url}','_blank')">🔗 Nguồn</button>`:''}</div></div>`;document.getElementById('view-article').innerHTML=h;window.scrollTo(0,0)};
725
+ window.aiReadWallPatched=function(i){const p=patchedWall[i];if(!p)return;showView('view-article');let sources='';if(p.sources&&p.sources.length){sources='<div class="article-summary"><b>Nguồn tham khảo:</b><br>'+p.sources.slice(0,5).map(s=>`• ${esc(s.title||s.url||'Nguồn')} ${s.url?`(${esc(new URL(s.url).hostname.replace('www.',''))})`:''}`).join('<br>')+'</div>'}let voiceBox=`<div class="article-actions"><select id="ai-short-voice"><option value="nu">Giọng nữ Việt</option><option value="nam">Giọng nam Việt</option><option value="mien-nam">Giọng miền Nam</option></select><select id="ai-short-emotion"><option value="neutral">Trung tính</option><option value="urgent">Tin nhanh</option><option value="warm">Ấm áp</option><option value="serious">Nghiêm túc</option><option value="energetic">Sôi nổi</option></select><button onclick="aiMakeShortPatched(${i})">🎬 Tạo video shorts</button></div>`;let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${esc(p.title)}</h1>${p.img?`<img class="article-img" src="${p.img}">`:''}${sources}<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>${p.video?`<video class="article-img" src="${p.video}" controls playsinline></video>`:''}<div class="article-actions">${p.url?`<button onclick="window.open('${p.url}','_blank')">🔗 Nguồn</button>`:''}</div>${voiceBox}</div>`;document.getElementById('view-article').innerHTML=h;window.scrollTo(0,0)};
726
+ window.aiMakeShortPatched=async function(i){const p=patchedWall[i];if(!p)return;let voice=document.getElementById('ai-short-voice')?.value||'nu';let emotion=document.getElementById('ai-short-emotion')?.value||'neutral';let voiceName={nu:'Giọng nữ Việt',nam:'Giọng nam Việt','mien-nam':'Giọng miền Nam'}[voice]||voice;let emotionName={neutral:'Trung tính',urgent:'Tin nhanh',warm:'Ấm áp',serious:'Nghiêm túc',energetic:'Sôi nổi'}[emotion]||emotion;let ok=confirm(`Quy trình tạo short AI:\n\n1) Dùng ảnh đại diện của bài hoặc tạo ảnh minh họa nếu thiếu.\n2) Rút gọn nội dung tóm tắt thành kịch bản đọc ngắn.\n3) Tự ngắt câu theo dấu câu và xuống dòng hợp lý.\n4) Tạo giọng đọc tiếng Việt: ${voiceName}.\n5) Áp dụng cảm xúc/kịch bản: ${emotionName}.\n6) Tăng tốc giọng đọc 1.2 lần.\n7) Mỗi đoạn tóm tắt sẽ là một cảnh riêng theo thời lượng đọc.\n8) Không thêm phụ đề; video chỉ có chữ cảnh và giọng đọc.\n9) Sau khi xong, video xuất hiện ở slide “Short AI”.\n\nQuá trình có thể mất 1-3 phút. Bạn muốn bắt đầu?`);if(!ok)return;try{showProgress(`Bước 1/5: Chuẩn bị ảnh và căn chữ full width...<br>Bước 2/5: Tạo kịch bản, tự ngắt câu/xuống dòng...<br>Bước 3/5: Tạo giọng đọc ${voiceName}, cảm xúc ${emotionName}.<br>Bước 4/5: Tăng tốc 1.2x và ghép từng cảnh riêng, không phụ đề.<br>Bước 5/5: Lưu vào slide “Short AI”.`);const r=await fetch('/api/ai/short/'+p.id,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice,emotion,speed:1.2})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');p.video=j.video;hideProgress();alert('Hoàn tất: video shorts đã được tạo và thêm vào slide “Short AI”.');aiReadWallPatched(i);loadPatchedWall();}catch(e){hideProgress();alert('Không tạo được shorts: '+e.message)}};
727
+ window.createTopicPost=function(){let inp=document.getElementById('ai-topic-input');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&(j.posts||j.post)){let arr=j.posts||[j.post];patchedWall=arr.concat(patchedWall.filter(x=>!arr.find(y=>y.id===x.id)));renderPatchedWall();if(inp)inp.value='';alert(`Đã lọc và tóm tắt ${arr.length} bài viết theo chủ đề lên Tường AI`);}else alert(j.error||'Lỗi tạo bài')}).catch(e=>alert(e.message||'Lỗi tạo bài'));};
728
+ window.createUrlPost=function(){let inp=document.getElementById('ai-url-input');let url=(inp&&inp.value||'').trim();if(!url)return alert('Dán URL trước');if(!/^https?:\/\//i.test(url))return alert('URL cần bắt đầu bằng http:// hoặc https://');fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&j.post){patchedWall=[j.post].concat(patchedWall.filter(x=>x.id!==j.post.id));renderPatchedWall();if(inp)inp.value='';alert('Đã tóm tắt URL và đăng lên Tường AI');}else alert(j.error||'Lỗi URL')}).catch(e=>alert(e.message||'Lỗi URL'));};
729
+ window.rewriteCurrentArticle=function(){if(!window._currentArticle&&typeof _currentArticle!=='undefined')window._currentArticle=_currentArticle;let cur=window._currentArticle||_currentArticle;if(!cur)return;let btn=document.querySelector('.article-actions button.primary');if(btn){btn.textContent='Đang tóm tắt...';btn.disabled=true}fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:cur.url})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&j.post){document.getElementById('rewrite-result').innerHTML=`<div class="rewrite-box"><div class="rewrite-title">Đã tóm tắt và đăng Tường AI</div><div class="rewrite-text">${esc(j.post.text||'')}</div></div>`;patchedWall=[j.post].concat(patchedWall.filter(x=>x.id!==j.post.id));renderPatchedWall();alert('Đã tóm tắt lên Tường AI');}else alert(j.error||'Không tóm tắt được')}).catch(e=>alert(e.message||'Lỗi tóm tắt')).finally(()=>{if(btn){btn.textContent='🤖 Tóm tắt AI & đăng tường';btn.disabled=false}})};
730
+ setTimeout(loadPatchedWall,1500);setInterval(updateAiLabels,2000);
731
+ })();
732
+ </script>
733
+ '''
734
+
735
+ @app.get('/')
736
+ async def index_patched():
737
+ with open('/app/static/index.html','r',encoding='utf-8') as f:
738
+ html=f.read()
739
+ return HTMLResponse(html.replace('</body>', PATCH_INJECT+'\n</body>'))
ai_runtime_final.py CHANGED
@@ -297,7 +297,7 @@ async def final_short(post_id:str,request:Request):
297
  clips.append(clip)
298
  lf=os.path.join(work,'list.txt')
299
  with open(lf,'w',encoding='utf-8') as f:
300
- for c in clips:f.write("file '"+c.replace("","'\\''"))+"'\n")
301
  subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',lf,'-c','copy',out],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=240)
302
  post['video']='/api/ai/short-file/'+post_id+suffix;post['short_subtitles']=False;post['short_segments']=segs;post['short_speed']=speed;base._save_ai_wall(posts)
303
  return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False})
@@ -309,7 +309,48 @@ def ai_wall_share(post:str=Query(default=''), short:int=Query(default=0)):
309
  if not p:return HTMLResponse(f'<script>location.href="{SPACE_URL}"</script>')
310
  title=p.get('title') or 'VNEWS AI';img=p.get('img') or DEFAULT_IMG
311
  desc=(p.get('text') or '')[:220]
312
- return HTMLResponse(f'<!doctype html><html><head><meta charset="utf-8"><title>{title}</title><meta property="og:title" content="{title}"><meta property="og:description" content="{desc}"><meta property="og:image" content="{img}"><meta property="og:type" content="article"><meta name="twitter:card" content="summary_large_image"></head><body><script>localStorage.setItem('pending_ai_post','{post}');location.href='{SPACE_URL}'</script></body></html>')
313
 
314
  FINAL_INJECT = r'''
315
  <style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  clips.append(clip)
298
  lf=os.path.join(work,'list.txt')
299
  with open(lf,'w',encoding='utf-8') as f:
300
+ for c in clips:f.write("file '"+c.replace("'","'\\''")+"'\n")
301
  subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',lf,'-c','copy',out],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=240)
302
  post['video']='/api/ai/short-file/'+post_id+suffix;post['short_subtitles']=False;post['short_segments']=segs;post['short_speed']=speed;base._save_ai_wall(posts)
303
  return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False})
 
309
  if not p:return HTMLResponse(f'<script>location.href="{SPACE_URL}"</script>')
310
  title=p.get('title') or 'VNEWS AI';img=p.get('img') or DEFAULT_IMG
311
  desc=(p.get('text') or '')[:220]
312
+ return HTMLResponse(f'''<!doctype html><html><head><meta charset="utf-8"><title>{title}</title><meta property="og:title" content="{title}"><meta property="og:description" content="{desc}"><meta property="og:image" content="{img}"><meta property="og:type" content="article"><meta name="twitter:card" content="summary_large_image"></head><body><script>localStorage.setItem('pending_ai_post','{post}');location.href='{SPACE_URL}'</script></body></html>''')
313
 
314
  FINAL_INJECT = r'''
315
  <style>
316
+ #ai-topic-input{display:none!important}button[onclick*="createTopicPost"],*[onclick*="createTopicPost"]{display:none!important}.ai-topic-row,.topic-row,.ai-compose-topic{display:none!important}.ai-compose{width:calc(100% - 8px)!important}.ai-compose-row:has(#ai-url-input){display:flex!important;flex-direction:column!important;gap:8px!important;width:100%!important}.ai-compose-row:has(#ai-url-input) button{width:100%!important;display:block!important}.ai-compose-row:has(#ai-url-input) input,#ai-url-input{display:block!important;width:100%!important;max-width:none!important;flex:1 1 100%!important;box-sizing:border-box!important}.ai-url-only-note{font-size:11px;color:#888;margin:5px 0 8px;width:100%}.ai-wall-gallery{display:grid;grid-template-columns:repeat(2,1fr);gap:6px;margin:10px 0}.ai-wall-gallery img{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:8px;background:#222}.ai-wall-gallery img:first-child{grid-column:1/-1}.ai-wall-final{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}
317
+ </style>
318
+ <script>
319
+ (function(){
320
+ function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
321
+ let finalWall=[];
322
+ function hideTopicControls(){document.querySelectorAll('#ai-topic-input').forEach(e=>{let row=e.closest('.ai-compose-topic,.topic-row,.ai-topic-row')||e.parentElement;if(row&&!row.querySelector('#ai-url-input'))row.style.display='none';else e.style.display='none';});document.querySelectorAll('button,a').forEach(b=>{let t=(b.textContent||'').toLowerCase();let oc=b.getAttribute('onclick')||'';if(oc.includes('createTopicPost')||t.includes('chủ đề'))b.style.display='none';});let url=document.getElementById('ai-url-input');if(url&&!document.getElementById('ai-url-only-note')){let n=document.createElement('div');n.id='ai-url-only-note';n.className='ai-url-only-note';n.textContent='Dán URL bài viết; ảnh chỉ lấy từ nội dung bài, không lấy bài liên quan/quảng cáo.';url.insertAdjacentElement('afterend',n);}}
323
+ function voiceOptions(){return `<option value="nu">Nữ Việt - Hoài My</option><option value="nam">Nam Việt - Nam Minh</option><option value="nu-truyen-cam">Nữ Việt truyền cảm</option><option value="nu-tin-nhanh">Nữ Việt tin nhanh</option><option value="nam-tram">Nam Việt trầm</option><option value="nam-ban-tin">Nam Việt bản tin</option><option value="nam-nang-dong">Nam Việt năng động</option>`}
324
+ function addVoiceOptions(){document.querySelectorAll('#ai-short-voice, select[id*=voice]').forEach(sel=>{let old=sel.value||'nu';sel.innerHTML=voiceOptions();sel.value=[...sel.options].some(o=>o.value===old)?old:'nu';});}
325
+ function shareAI(p,isShort){let url=location.origin+'/aw?post='+encodeURIComponent(p.id)+(isShort?'&short=1':'');let title=(isShort?'🎬 Short AI: ':'🧱 Tường AI: ')+(p.title||'VNEWS');if(navigator.share)navigator.share({title,url}).catch(()=>{});else navigator.clipboard.writeText(url).then(()=>alert('Đã sao chép link chia sẻ!')).catch(()=>{});}
326
+ function galleryHtml(p){let imgs=(p.images||[]).filter(Boolean);if(!imgs.length&&p.img)imgs=[p.img];if(!imgs.length)return '';return '<div class="ai-wall-gallery">'+imgs.slice(0,12).map(u=>`<img src="${esc(u)}" loading="lazy">`).join('')+'</div>';}
327
+ async function loadWall(){try{finalWall=(await (await fetch('/api/ai_wall')).json()).posts||[];renderWall();let pend=localStorage.getItem('pending_ai_post');if(pend){localStorage.removeItem('pending_ai_post');let idx=finalWall.findIndex(p=>String(p.id)===String(pend));if(idx>=0)setTimeout(()=>readFinalWall(idx),300);}}catch(e){}}
328
+ function renderWall(){let home=document.getElementById('view-home');if(!home||!finalWall.length)return;document.getElementById('ai-wall-final')?.remove();let wrap=document.createElement('div');wrap.id='ai-wall-final';wrap.className='ai-wall-final';let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI</span><span class="slider-note">URL summary</span></div><div class="slider-track">';finalWall.slice(0,30).forEach((p,i)=>{h+=`<div class="wall-item"><div class="wall-thumb">${p.img?`<img src="${esc(p.img)}">`:''}</div><div class="wall-title">${esc(p.title)}</div><div class="wall-text">${esc(p.text)}</div><div class="wall-actions"><button class="primary" onclick="readFinalWall(${i})">Xem</button><button onclick="shareFinalWall(${i})">📤 Chia sẻ</button>${p.video?`<button onclick="readFinalShort(${i})">Short</button>`:''}</div></div>`});h+='</div>';wrap.innerHTML=h;let compose=document.querySelector('.ai-compose');if(compose)compose.after(wrap);else home.prepend(wrap);}
329
+ window.shareFinalWall=function(i){let p=finalWall[i];if(p)shareAI(p,false)};
330
+ window.shareFinalShort=function(i){let p=finalWall[i];if(p)shareAI(p,true)};
331
+ window.readFinalWall=window.aiReadWallPatched=window.aiReadWall=function(i){let p=finalWall[i]||(window.patchedWall||window.aiWall||[])[i];if(!p)return;showView('view-article');let sources='';if(p.sources&&p.sources.length){sources='<div class="article-summary"><b>Nguồn tham khảo:</b><br>'+p.sources.slice(0,5).map(s=>`• ${esc(s.title||s.url||'Nguồn')} ${s.url?`(${esc(new URL(s.url).hostname.replace('www.',''))})`:''}`).join('<br>')+'</div>'}let voiceBox=`<div class="article-actions"><select id="ai-short-voice">${voiceOptions()}</select><select id="ai-short-emotion"><option value="neutral">Trung tính</option><option value="urgent">Tin nhanh</option><option value="warm">Ấm áp</option><option value="serious">Nghiêm túc</option><option value="energetic">Sôi nổi</option></select><button onclick="makeFinalShort(${i})">🎬 Tạo short</button><button onclick="shareFinalWall(${i})">📤 Chia sẻ bài</button>${p.video?`<button onclick="shareFinalShort(${i})">📤 Chia sẻ short</button>`:''}</div>`;let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${esc(p.title)}</h1>${galleryHtml(p)}${sources}<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>${p.video?`<video class="article-img" src="${p.video}" controls playsinline></video>`:''}<div class="article-actions">${p.url?`<button onclick="window.open('${p.url}','_blank')">🔗 Nguồn</button>`:''}</div>${voiceBox}</div>`;document.getElementById('view-article').innerHTML=h;addVoiceOptions();window.scrollTo(0,0)};
332
+ window.readFinalShort=function(i){let p=finalWall[i];if(!p||!p.video)return;showView('view-article');document.getElementById('view-article').innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">Short AI</span><h1 class="article-title">${esc(p.title)}</h1><video class="article-img" src="${p.video}" controls playsinline autoplay></video><div class="article-actions"><button onclick="shareFinalShort(${i})">📤 Chia sẻ short</button><button onclick="readFinalWall(${i})">Xem bài tường</button></div></div>`;window.scrollTo(0,0)};
333
+ window.makeFinalShort=window.aiMakeShortPatched=async function(i){let p=finalWall[i]||(window.patchedWall||window.aiWall||[])[i];if(!p)return;let voice=document.getElementById('ai-short-voice')?.value||'nu';let emotion=document.getElementById('ai-short-emotion')?.value||'neutral';let btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang tạo...'}try{let r=await fetch('/api/ai/short/'+p.id,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice,emotion,speed:1.2})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi tạo short');p.video=j.video;let idx=finalWall.findIndex(x=>x.id===p.id);if(idx<0){finalWall.unshift(p);idx=0}renderWall();readFinalWall(idx);alert('Đã tạo short AI và cập nhật ngay trên tường.');}catch(e){alert('Không tạo được short: '+e.message)}finally{if(btn){btn.disabled=false;btn.textContent='🎬 Tạo short'}}};
334
+ window.createTopicPost=function(){alert('Đã tắt ô nhập chủ đề. Vui lòng dán URL bài viết.');};
335
+ window.createUrlPost=async function(){let inp=document.getElementById('ai-url-input');let url=(inp&&inp.value||'').trim();if(!url)return alert('Dán URL trước');if(!/^https?:\/\//i.test(url))return alert('URL cần bắt đầu bằng http:// hoặc https://');let btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang tóm tắt...'}try{let r=await fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi URL');finalWall.unshift(j.post);if(inp)inp.value='';renderWall();readFinalWall(0);alert('Đã đăng tường AI, không cần tải lại trang.');}catch(e){alert(e.message||'Lỗi URL')}finally{if(btn){btn.disabled=false;btn.textContent='Chèn URL'}}};
336
+ setTimeout(()=>{hideTopicControls();addVoiceOptions();loadWall();},300);setInterval(()=>{hideTopicControls();addVoiceOptions();},1200);
337
+ })();
338
+ </script>
339
+ '''
340
+
341
+ def _load_index_html():
342
+ try:
343
+ with open('/app/static/index.html','r',encoding='utf-8') as f:html=f.read()
344
+ except Exception:html=''
345
+ if '<!DOCTYPE html>' not in html or '<div id="view-home"' not in html:
346
+ try:
347
+ r=requests.get(RESTORE_INDEX_URL,timeout=20)
348
+ if r.status_code==200 and '<!DOCTYPE html>' in r.text:html=r.text
349
+ except Exception:pass
350
+ if '<!DOCTYPE html>' not in html:html='<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>VNEWS</title></head><body><div id="view-home">VNEWS</div></body></html>'
351
+ return html
352
+
353
+ @app.get('/')
354
+ async def index_final():
355
+ html=_load_index_html();body=getattr(rt.old,'PATCH_INJECT','') + FINAL_INJECT
356
+ return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
ai_runtime_final6.py CHANGED
@@ -840,10 +840,486 @@ FINAL6E_INJECT = """
840
  function escE(s){return String(s||'').replace(/[&<>\"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',"'":'&#39;'}[m]));}
841
  window.__topicWallE=[];
842
  function sourceDetailsHtml(p){let arr=p.source_details||[];if(!arr.length)return '';let h='<div class="source-detail-box"><h3>📚 Nội dung từng nguồn đã dùng</h3>';arr.forEach((s,i)=>{h+=`<div class="source-detail-item"><div class="source-detail-title">${i+1}. ${escE(s.title)}</div><div class="source-detail-meta">${escE(s.via||'Nguồn')}</div><div class="source-detail-content">${escE(s.content||'')}</div>${s.url?`<a href="${escE(s.url)}" target="_blank">Mở nguồn gốc</a>`:''}</div>`});h+='</div>';return h;}
843
- function renderTopicWallE(){let home=document.getElementById('view-home');if(!home||!window.__topicWallE.length)return;document.getElementById('ai-wall-topic-live')?.remove();let wrap=document.createElement('div');wrap.id='ai-wall-topic-live';wrap.className='ai-wall-topic-live';let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI mới</span><span class="slider-note">Tổng hợp từ web</span></div><div class="slider-track">';window.__topicWallE.slice(0,20).forEach((p,i)=>{h+=`<div class="wall-item"><div class="wall-thumb">${p.img?`<img src="${escE(p.img)}">`:''}</div><div class="wall-title">${escE(p.title)}</div><div class="wall-text">${escE(p.text)}</div><div class="wall-actions"><button class="primary" onclick="readTopicWallE(${i})">Xem</button></div></div>`});h+='</div>';wrap.innerHTML=h;let comp=document.querySelector('.ai-compose');if(comp)comp.after(wrap);else home.prepend(wrap);}
844
- window.readTopicWallE=function(i){let p=window.__topicWallE[i];if(!p)return;showView('view-article');let imgs=(p.images||[]).filter(Boolean);let gal=imgs.length?'<div class="ai-wall-gallery">'+imgs.slice(0,12).map(u=>`<img src="${escE(u)}" loading="lazy">`).join('')+'</div>':(p.img?`<img class="article-img" src="${escE(p.img)}">`:'');let srcDetails=sourceDetailsHtml(p);document.getElementById('view-article').innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${escE(p.title)}</h1>${gal}<p class="article-p" style="white-space:pre-wrap">${escE(p.text)}</p>${srcDetails}<div class="article-actions"><button onclick="shareAI?shareAI(${JSON.stringify(p).replace(/"/g,'&quot;')},false):navigator.clipboard.writeText(location.href)">📤 Chia sẻ</button></div></div>`;window.scrollTo(0,0)};
845
- window.createTopicPostFinal5=async function(){let inp=document.getElementById('ai-topic-input-final5');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');let btn=document.getElementById('ai-topic-btn-final5');if(btn){btn.disabled=true;btn.textContent='Đang tìm nguồn...'}try{let src=await fetch('/api/topic_sources?topic='+encodeURIComponent(topic)).then(r=>r.json()).catch(()=>null);if(btn&&src)btn.textContent='Đã tìm '+(src.count||0)+' nguồn, đang tổng hợp...';let r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');window.__topicWallE.unshift(j.post);if(inp)inp.value='';renderTopicWallE();readTopicWallE(0);alert('Đã tạo bài tổng hợp từ nội dung web và đăng lên Tường AI.');}catch(e){alert(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='✨ Tạo bài tổng hợp từ web bằng Qwen'}}};
846
- setInterval(()=>{document.querySelectorAll('#ai-topic-input-final3,.topic-final3,#ai-topic-input-final4,.topic-final4').forEach(e=>(e.closest('.topic-final3,.topic-final4,.ai-compose-row')||e).remove());let b=document.getElementById('ai-topic-btn-final5');if(b){b.style.display='block';b.textContent='✨ Tạo bài tổng hợp từ web bằng Qwen';}},1200);
847
  })();
848
  </script>
849
- '''
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
840
  function escE(s){return String(s||'').replace(/[&<>\"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',"'":'&#39;'}[m]));}
841
  window.__topicWallE=[];
842
  function sourceDetailsHtml(p){let arr=p.source_details||[];if(!arr.length)return '';let h='<div class="source-detail-box"><h3>📚 Nội dung từng nguồn đã dùng</h3>';arr.forEach((s,i)=>{h+=`<div class="source-detail-item"><div class="source-detail-title">${i+1}. ${escE(s.title)}</div><div class="source-detail-meta">${escE(s.via||'Nguồn')}</div><div class="source-detail-content">${escE(s.content||'')}</div>${s.url?`<a href="${escE(s.url)}" target="_blank">Mở nguồn gốc</a>`:''}</div>`});h+='</div>';return h;}
843
+ function renderTopicWallE(){let home=document.getElementById('view-home');if(!home||!window.__topicWallE.length)return;document.getElementById('ai-wall-topic-live')?.remove();let wrap=document.createElement('div');wrap.id='ai-wall-topic-live';wrap.className='ai-wall-topic-live';let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI mới</span><span class="slider-note"> nội dung nguồn</span></div><div class="slider-track">';window.__topicWallE.slice(0,20).forEach((p,i)=>{h+=`<div class="wall-item"><div class="wall-thumb">${p.img?`<img src="${escE(p.img)}">`:''}</div><div class="wall-title">${escE(p.title)}</div><div class="wall-text">${escE(p.text)}</div><div class="wall-actions"><button class="primary" onclick="readTopicWallE(${i})">Xem</button></div></div>`});h+='</div>';wrap.innerHTML=h;let comp=document.querySelector('.ai-compose');if(comp)comp.after(wrap);else home.prepend(wrap);}
844
+ window.readTopicWallE=function(i){let p=window.__topicWallE[i];if(!p)return;showView('view-article');let imgs=(p.images||[]).filter(Boolean);let gal=imgs.length?'<div class="ai-wall-gallery">'+imgs.slice(0,12).map(u=>`<img src="${escE(u)}" loading="lazy">`).join('')+'</div>':(p.img?`<img class="article-img" src="${escE(p.img)}">`:'');document.getElementById('view-article').innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${escE(p.title)}</h1>${gal}<p class="article-p" style="white-space:pre-wrap">${escE(p.text)}</p>${sourceDetailsHtml(p)}<div class="article-actions"><button onclick="shareAI?shareAI(${JSON.stringify(p).replace(/\"/g,'&quot;')},false):navigator.clipboard.writeText(location.href)">📤 Chia sẻ</button></div></div>`;window.scrollTo(0,0)};
845
+ window.createTopicPostFinal5=async function(){let inp=document.getElementById('ai-topic-input-final5');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');let btn=document.getElementById('ai-topic-btn-final5');if(btn){btn.disabled=true;btn.textContent='Đang lấy nguồn...'}try{let src=await fetch('/api/topic_sources?topic='+encodeURIComponent(topic)).then(r=>r.json()).catch(()=>null);if(btn&&src)btn.textContent='Đã tìm '+(src.count||0)+' nguồn, đang viết...';let r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');window.__topicWallE.unshift(j.post);if(inp)inp.value='';renderTopicWallE();readTopicWallE(0);}catch(e){alert(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='✨ Tạo bài tổng hợp từ web bằng Qwen'}}};
 
846
  })();
847
  </script>
848
+ """
849
+
850
+ # Override root one last time to append source-details UI.
851
+ app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
852
+ @app.get('/')
853
+ async def index_final6_source_details():
854
+ html=f5.f4.f3.f2.f1._load_index_html()
855
+ body=getattr(rt.old,'PATCH_INJECT','')+f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT+FINAL6_INJECT+globals().get('FINAL6_FAST_HOME_INJECT','')+FINAL6E_INJECT
856
+ return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
857
+
858
+
859
+ # ===== FINAL6F: CLEAN TOPIC OUTPUT + IN-APP SOURCE READER =====
860
+ def _clean_generated_article(text, topic=''):
861
+ """Remove prompt/instruction leakage from generated topic articles."""
862
+ text=str(text or '').strip()
863
+ bad_patterns=[
864
+ r'^\s*[•\-]*\s*Hãy viết .*',
865
+ r'^\s*[•\-]*\s*Dưới đây là .*',
866
+ r'^\s*[•\-]*\s*Dữ liệu .*',
867
+ r'^\s*[•\-]*\s*NỘI DUNG NGUỒN.*',
868
+ r'^\s*[•\-]*\s*Yêu cầu\s*:.*',
869
+ r'^\s*[•\-]*\s*Tiêu đề mới.*',
870
+ r'^\s*[•\-]*\s*Sapo\s*2.*',
871
+ r'^\s*[•\-]*\s*5\s*[-–]\s*8\s*đoạn.*',
872
+ r'^\s*[•\-]*\s*Không dùng câu.*',
873
+ r'^\s*[•\-]*\s*Cuối bài.*',
874
+ r'^\s*[•\-]*\s*Không liệt kê.*',
875
+ r'^\s*[•\-]*\s*Tổng hợp thành.*',
876
+ r'^\s*[•\-]*\s*Diễn đạt lại.*',
877
+ r'^\s*[•\-]*\s*Tuyệt đối.*',
878
+ ]
879
+ out=[]
880
+ for ln in text.splitlines():
881
+ s=ln.strip()
882
+ if not s:
883
+ out.append(ln);continue
884
+ if any(re.search(p,s,re.I) for p in bad_patterns):
885
+ continue
886
+ out.append(ln)
887
+ cleaned='\n'.join(out).strip()
888
+ # If model returned a markdown code/prompt-like block, keep content after first plausible title line.
889
+ cleaned=re.sub(r'^(?:Bài viết|Nội dung bài viết)\s*[::]\s*','',cleaned,flags=re.I).strip()
890
+ # Remove duplicated leading topic instruction if it appears inline.
891
+ cleaned=re.sub(r'Hãy viết MỘT BÀI VIẾT HOÀN CHỈNH[^\n\.]*[\.\n]*','',cleaned,flags=re.I).strip()
892
+ return cleaned or text
893
+
894
+ def _source_article_data(url):
895
+ try:
896
+ r=requests.get(url,headers=UA,timeout=14);r.encoding='utf-8'
897
+ soup=BeautifulSoup(r.text,'lxml')
898
+ h1=soup.find('h1')
899
+ ogt=soup.find('meta',property='og:title')
900
+ ogd=soup.find('meta',property='og:description')
901
+ ogi=soup.find('meta',property='og:image')
902
+ title=clean(h1.get_text(' ',strip=True) if h1 else (ogt.get('content','') if ogt else ''))
903
+ summary=clean(ogd.get('content','') if ogd else '')
904
+ img=ogi.get('content','') if ogi else ''
905
+ except Exception:
906
+ title='';summary='';img=''
907
+ text=_scrape_article_text(url,12000)
908
+ body=[]
909
+ for para in re.split(r'\n+',text or ''):
910
+ para=clean(para)
911
+ if len(para)>35:
912
+ body.append({'type':'p','text':para})
913
+ if len(body)>=80:break
914
+ if not title:title=url
915
+ if not body and summary:body=[{'type':'p','text':summary}]
916
+ return {'title':title,'summary':summary,'og_image':img,'body':body,'source':'topic-source','url':url}
917
+
918
+ @app.get('/api/topic_source_article')
919
+ def api_topic_source_article(url:str=Query(...)):
920
+ if not url.startswith('http'):
921
+ return JSONResponse({'error':'bad url'},status_code=400)
922
+ return JSONResponse(_source_article_data(url))
923
+
924
+ # Override topic generation one last time with output cleaning and source details.
925
+ app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/api/topic_post' and 'POST' in getattr(r,'methods',set()))]
926
+
927
+ @app.post('/api/topic_post')
928
+ async def topic_post_clean_final(request:Request):
929
+ body=await request.json();topic=clean(body.get('topic',''))
930
+ if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
931
+ img=_topic_image(topic)
932
+ research=_fast_context(topic) if '_fast_context' in globals() else _web_research_context(topic)
933
+ context=research.get('context','');sources=research.get('sources',[])
934
+ details=_extract_source_details_from_context(context,sources) if '_extract_source_details_from_context' in globals() else []
935
+ if not details:
936
+ # Build details directly from sources/snippets if helper unavailable or empty.
937
+ for s in sources[:8]:
938
+ details.append({'title':s.get('title',''),'url':s.get('url',''),'via':s.get('via',''),'content':s.get('excerpt','') or s.get('snippet','') or ''})
939
+ if not context and not details:
940
+ return JSONResponse({'error':'Không tìm/crawl được đủ nội dung về chủ đề này. Hãy thử chủ đề cụ thể hơn hoặc dùng hashtag gợi ý.'},status_code=422)
941
+ source_brief='\n\n'.join([f"[{i+1}] {d.get('title','')} ({d.get('via','')})\n{d.get('content','')[:1400]}" for i,d in enumerate(details[:8])])
942
+ prompt=f"""Vai trò: biên tập viên VNEWS.
943
+ Nhiệm vụ: viết một bài báo tiếng Việt hoàn chỉnh về chủ đề "{topic}" dựa trên các nguồn bên dưới.
944
+
945
+ Nguồn thu thập:
946
+ {source_brief[:18000]}
947
+
948
+ Quy tắc biên tập:
949
+ 1. Chỉ xuất bản bài viết cuối cùng, không nhắc lại yêu cầu, không liệt kê chỉ dẫn.
950
+ 2. Không sao chép nguyên văn; hãy tổng hợp và diễn đạt lại.
951
+ 3. Bài có tiêu đề, sapo, các đoạn phân tích/bối cảnh/tác động, và mục Nguồn tham khảo ngắn.
952
+ 4. Không dùng các câu như "Dưới đây là", "Tôi sẽ", "Yêu cầu".
953
+ """
954
+ text=None
955
+ try:
956
+ import asyncio
957
+ text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1700),timeout=35)
958
+ except Exception:
959
+ text=None
960
+ if not text or len(text)<350:
961
+ bullets='\n'.join([f"• {d.get('title','')}: {d.get('content','')[:320]}" for d in details[:6]])
962
+ vias=', '.join(sorted({d.get('via','') for d in details if d.get('via')}))
963
+ text=(f"{topic}: tổng hợp những điểm đáng chú ý\n\n"
964
+ f"{topic} đang được nhiều nguồn tin đề cập với các góc nhìn khác nhau. Dựa trên nội dung đã thu thập, có thể rút ra một số điểm chính để người đọc nắm nhanh bối cảnh.\n\n"
965
+ f"{bullets}\n\n"
966
+ f"Nhìn chung, chủ đề này cần được theo dõi thêm ở các khía cạnh: bối cảnh, tác động thực tế, phản ứng của các bên liên quan và các diễn biến mới trong thời gian tới.\n\n"
967
+ f"Nguồn tham khảo: {vias}")
968
+ text=_clean_generated_article(text,topic)
969
+ post=f5.base.make_post(topic,text,img,'','topic_clean_with_sources',sources=[s for s in sources if s.get('url')])
970
+ post['images']=[img]
971
+ post['source_details']=details[:8]
972
+ posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts)
973
+ return JSONResponse({'post':post,'mode':'clean_with_source_details','sources_count':len(details)})
974
+
975
+ FINAL6F_INJECT = """
976
+ <script>
977
+ (function(){
978
+ function escF(s){return String(s||'').replace(/[&<>\\"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','\\"':'&quot;',"'":'&#39;'}[m]));}
979
+ window.readTopicSourceE=async function(url){
980
+ showView('view-article');
981
+ const el=document.getElementById('view-article');
982
+ el.innerHTML='<div class="loading">Đang tải nguồn...</div>';
983
+ try{
984
+ let data=await fetch('/api/topic_source_article?url='+encodeURIComponent(url)).then(r=>r.json());
985
+ if(!data||data.error||!data.body||!data.body.length){throw new Error('Không đọc được nguồn')}
986
+ let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><h1 class="article-title">${escF(data.title)}</h1>`;
987
+ if(data.summary)h+=`<div class="article-summary">${escF(data.summary)}</div>`;
988
+ if(data.og_image)h+=`<img class="article-img" src="${escF(data.og_image)}">`;
989
+ data.body.forEach(b=>{if(b.type==='p')h+=`<p class="article-p">${escF(b.text)}</p>`;else if(b.type==='heading')h+=`<h2 class="article-h2">${escF(b.text)}</h2>`;});
990
+ h+=`<div class="article-actions"><button onclick="window.open('${escF(url)}','_blank')">🔗 Mở gốc</button></div></div>`;
991
+ el.innerHTML=h;window.scrollTo(0,0);
992
+ }catch(e){el.innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="loading">Không đọc được nguồn.<br><a href="${escF(url)}" target="_blank" style="color:#5cb87a">Mở link gốc</a></div>`}
993
+ };
994
+ // Upgrade existing source detail boxes: replace external open behavior by in-app button.
995
+ function enhanceSourceButtons(){document.querySelectorAll('.source-detail-item a[href]').forEach(a=>{let u=a.getAttribute('href');if(!u||a.dataset.vnews)return;a.dataset.vnews='1';a.textContent='Xem trực tiếp trên VNEWS';a.setAttribute('href','javascript:void(0)');a.onclick=function(){readTopicSourceE(u);return false;};});}
996
+ setInterval(enhanceSourceButtons,1000);setTimeout(enhanceSourceButtons,500);
997
+ })();
998
+ </script>
999
+ """
1000
+
1001
+ app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
1002
+ @app.get('/')
1003
+ async def index_final6_clean_links():
1004
+ html=f5.f4.f3.f2.f1._load_index_html()
1005
+ body=getattr(rt.old,'PATCH_INJECT','')+f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT+FINAL6_INJECT+globals().get('FINAL6_FAST_HOME_INJECT','')+globals().get('FINAL6E_INJECT','')+FINAL6F_INJECT
1006
+ return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
1007
+
1008
+
1009
+ # ===== FINAL6G: SELECTED FAST SOURCES =====
1010
+ # Restrict hot hashtags + topic articles to the requested sources only:
1011
+ # Thethaovanhoa, Dantri, VTV, VnExpress, Vatvostudio, GenK, VNReview.
1012
+ SELECTED_SOURCE_FEEDS=[
1013
+ ('VnExpress','https://vnexpress.net/rss/tin-moi-nhat.rss'),
1014
+ ('VnExpress Thời sự','https://vnexpress.net/rss/thoi-su.rss'),
1015
+ ('VnExpress Thế giới','https://vnexpress.net/rss/the-gioi.rss'),
1016
+ ('VnExpress Kinh doanh','https://vnexpress.net/rss/kinh-doanh.rss'),
1017
+ ('VnExpress Công nghệ','https://vnexpress.net/rss/so-hoa.rss'),
1018
+ ('VnExpress Thể thao','https://vnexpress.net/rss/the-thao.rss'),
1019
+ ('Dân trí','https://dantri.com.vn/rss/home.rss'),
1020
+ ('Dân trí Xã hội','https://dantri.com.vn/rss/xa-hoi.rss'),
1021
+ ('Dân trí Kinh doanh','https://dantri.com.vn/rss/kinh-doanh.rss'),
1022
+ ('Dân trí Thể thao','https://dantri.com.vn/rss/the-thao.rss'),
1023
+ ('Dân trí Công nghệ','https://dantri.com.vn/rss/suc-manh-so.rss'),
1024
+ ('VTV','https://vtv.vn/rss/trang-chu.rss'),
1025
+ ('VTV Thời sự','https://vtv.vn/rss/thoi-su.rss'),
1026
+ ('VTV Công nghệ','https://vtv.vn/rss/cong-nghe.rss'),
1027
+ ('Thể thao văn hóa','https://thethaovanhoa.vn/rss/home.rss'),
1028
+ ('Thể thao văn hóa Bóng đá','https://thethaovanhoa.vn/rss/bong-da.rss'),
1029
+ ('GenK','https://genk.vn/home.rss'),
1030
+ ('GenK AI','https://genk.vn/ai.rss'),
1031
+ ('VNReview','https://vnreview.vn/rss/home.rss'),
1032
+ ('VNReview Công nghệ','https://vnreview.vn/rss/cong-nghe.rss'),
1033
+ ]
1034
+ SELECTED_HOMEPAGES=[
1035
+ ('VTV','https://vtv.vn/'),
1036
+ ('Thể thao văn hóa','https://thethaovanhoa.vn/'),
1037
+ ('GenK','https://genk.vn/'),
1038
+ ('VNReview','https://vnreview.vn/'),
1039
+ ('Vatvostudio','https://vatvostudio.vn/'),
1040
+ ]
1041
+ SELECTED_DOMAINS=['vnexpress.net','dantri.com.vn','vtv.vn','thethaovanhoa.vn','genk.vn','vnreview.vn','vatvostudio.vn']
1042
+
1043
+ def _selected_fetch_rss(feed_name, feed_url, max_items=10):
1044
+ items=[]
1045
+ try:
1046
+ r=requests.get(feed_url,headers=UA,timeout=4);r.encoding='utf-8'
1047
+ if r.status_code>=400:return []
1048
+ soup=BeautifulSoup(r.text,'xml')
1049
+ for it in soup.find_all('item')[:max_items]:
1050
+ title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
1051
+ link=clean(it.find('link').get_text(strip=True) if it.find('link') else '')
1052
+ desc_raw=it.find('description').get_text(' ',strip=True) if it.find('description') else ''
1053
+ ds=BeautifulSoup(desc_raw,'lxml')
1054
+ desc=clean(ds.get_text(' ',strip=True))
1055
+ if title and link:
1056
+ items.append({'title':title,'url':link,'source':feed_name,'snippet':desc})
1057
+ except Exception:pass
1058
+ return items
1059
+
1060
+ def _selected_scrape_homepage(name, url, max_items=10):
1061
+ items=[];seen=set()
1062
+ try:
1063
+ r=requests.get(url,headers=UA,timeout=4);r.encoding='utf-8'
1064
+ if r.status_code>=400:return []
1065
+ soup=BeautifulSoup(r.text,'lxml')
1066
+ base=url.rstrip('/')
1067
+ for a in soup.find_all('a',href=True):
1068
+ href=a.get('href','').strip();title=clean(a.get('title','') or a.get_text(' ',strip=True))
1069
+ if not href or not title or len(title)<18:continue
1070
+ if href.startswith('/'):
1071
+ p=urlparse(url); href=f'{p.scheme}://{p.netloc}{href}'
1072
+ if not href.startswith('http') or href in seen:continue
1073
+ dom=_domain(href)
1074
+ if not any(d in dom for d in SELECTED_DOMAINS):continue
1075
+ if any(x in href.lower() for x in ['#','javascript:','facebook','youtube','tiktok']):continue
1076
+ seen.add(href)
1077
+ items.append({'title':title,'url':href,'source':name,'snippet':''})
1078
+ if len(items)>=max_items:break
1079
+ except Exception:pass
1080
+ return items
1081
+
1082
+ def _fast_rss_pool():
1083
+ now=time.time();key='selected_fast_pool'
1084
+ if key in _FAST_TOPIC_CACHE and now-_FAST_TOPIC_CACHE[key]['t']<600:return _FAST_TOPIC_CACHE[key]['d']
1085
+ pool=[];seen=set()
1086
+ # RSS first: fast and reliable.
1087
+ for name,url in SELECTED_SOURCE_FEEDS:
1088
+ for it in _selected_fetch_rss(name,url,10):
1089
+ if it['url'] not in seen:
1090
+ seen.add(it['url']);pool.append(it)
1091
+ # Homepage fallback for sources with weak/no RSS, especially Vatvostudio.
1092
+ for name,url in SELECTED_HOMEPAGES:
1093
+ for it in _selected_scrape_homepage(name,url,10):
1094
+ if it['url'] not in seen:
1095
+ seen.add(it['url']);pool.append(it)
1096
+ _FAST_TOPIC_CACHE[key]={'t':now,'d':pool}
1097
+ return pool
1098
+
1099
+ def _hot_topics():
1100
+ now=time.time()
1101
+ if _HOT_CACHE['d'] and now-_HOT_CACHE['t']<600:return _HOT_CACHE['d']
1102
+ pool=_fast_rss_pool()
1103
+ freq={};display={}
1104
+ for it in pool[:220]:
1105
+ title=re.sub(r'\s+-\s+.*$','',it.get('title',''))
1106
+ kws=[]
1107
+ for m in re.findall(r'([A-ZĐÀ-Ỹ][A-Za-zÀ-ỹ0-9]+(?:\s+[A-ZĐÀ-ỸA-Za-zÀ-ỹ0-9][A-Za-zÀ-ỹ0-9]+){1,4})',title):
1108
+ if len(m)>=6:kws.append(m)
1109
+ kws+=_keywords_from_title(title)
1110
+ for kw in kws[:5]:
1111
+ words=[w for w in clean(kw).split() if w.lower() not in STOP_WORDS]
1112
+ if len(words)<2:continue
1113
+ kw=' '.join(words[:5])
1114
+ if len(kw)<6 or len(kw)>55:continue
1115
+ key=kw.lower();freq[key]=freq.get(key,0)+1;display[key]=kw
1116
+ topics=[];seen=set()
1117
+ for key,_ in sorted(freq.items(),key=lambda x:x[1],reverse=True):
1118
+ kw=display[key]
1119
+ if key in seen:continue
1120
+ seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
1121
+ if len(topics)>=24:break
1122
+ for kw in ['AI tại Việt Nam','Công nghệ Việt Nam','VTV thời sự','VnExpress kinh doanh','Dân trí xã hội','GenK AI','VNReview công nghệ','Vatvostudio smartphone','Thể thao văn hóa World Cup']:
1123
+ if kw.lower() not in seen:topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
1124
+ _HOT_CACHE.update({'t':now,'d':topics[:24]})
1125
+ return _HOT_CACHE['d']
1126
+
1127
+ def _candidate_urls(topic):
1128
+ seen=set();items=[]
1129
+ scored=[]
1130
+ for it in _fast_rss_pool():
1131
+ sc=_fast_score(topic,it)
1132
+ if sc>0:scored.append((sc,it))
1133
+ for sc,it in sorted(scored,key=lambda x:(x[0],len(x[1].get('snippet',''))),reverse=True)[:14]:
1134
+ if it['url'] not in seen:
1135
+ seen.add(it['url']);items.append(it)
1136
+ # Search only selected sources when RSS lacks matches.
1137
+ if len(items)<6:
1138
+ for dom in SELECTED_DOMAINS:
1139
+ for it in _ddg_search(f'{topic} site:{dom}',4):
1140
+ if it['url'] not in seen:
1141
+ seen.add(it['url']);items.append(it)
1142
+ if len(items)>=12:break
1143
+ return items[:20]
1144
+
1145
+
1146
+ # ===== FINAL6G: SOURCE-LIMITED FAST TOPICS AND FAST HOME =====
1147
+ # Limit hot hashtags/topic context to requested sources and make homepage APIs return quickly.
1148
+ _SOURCE_FEEDS = [
1149
+ ('VnExpress','https://vnexpress.net/rss/tin-moi-nhat.rss','vnexpress.net'),
1150
+ ('VnExpress Thời sự','https://vnexpress.net/rss/thoi-su.rss','vnexpress.net'),
1151
+ ('VnExpress Thế giới','https://vnexpress.net/rss/the-gioi.rss','vnexpress.net'),
1152
+ ('VnExpress Kinh doanh','https://vnexpress.net/rss/kinh-doanh.rss','vnexpress.net'),
1153
+ ('VnExpress Công nghệ','https://vnexpress.net/rss/so-hoa.rss','vnexpress.net'),
1154
+ ('VnExpress Thể thao','https://vnexpress.net/rss/the-thao.rss','vnexpress.net'),
1155
+ ('Dân trí','https://dantri.com.vn/rss/home.rss','dantri.com.vn'),
1156
+ ('Dân trí Xã hội','https://dantri.com.vn/rss/xa-hoi.rss','dantri.com.vn'),
1157
+ ('Dân trí Kinh doanh','https://dantri.com.vn/rss/kinh-doanh.rss','dantri.com.vn'),
1158
+ ('Dân trí Thể thao','https://dantri.com.vn/rss/the-thao.rss','dantri.com.vn'),
1159
+ ('Dân trí Công nghệ','https://dantri.com.vn/rss/suc-manh-so.rss','dantri.com.vn'),
1160
+ ('VTV','https://vtv.vn/rss/trang-chu.rss','vtv.vn'),
1161
+ ('VTV Thời sự','https://vtv.vn/rss/thoi-su.rss','vtv.vn'),
1162
+ ('GenK','https://genk.vn/rss/home.rss','genk.vn'),
1163
+ ('GenK AI','https://genk.vn/ai.rss','genk.vn'),
1164
+ ('VnReview','https://vnreview.vn/rss/tin-moi-nhat.rss','vnreview.vn'),
1165
+ ('VnReview Công nghệ','https://vnreview.vn/rss/cong-nghe.rss','vnreview.vn'),
1166
+ ('Vật Vờ Studio','https://vatvostudio.vn/feed/','vatvostudio.vn'),
1167
+ ('Thể thao văn hóa','https://thethaovanhoa.vn/rss/home.rss','thethaovanhoa.vn'),
1168
+ ('Thể thao văn hóa World Cup','https://thethaovanhoa.vn/rss/world-cup-2026.rss','thethaovanhoa.vn'),
1169
+ ]
1170
+ _SOURCE_CACHE={'t':0,'items':[]}
1171
+ _FAST_ROUTE_CACHE={}
1172
+
1173
+ def _feed_items_source_limited(max_per_feed=10):
1174
+ now=time.time()
1175
+ if _SOURCE_CACHE['items'] and now-_SOURCE_CACHE['t']<600:return _SOURCE_CACHE['items']
1176
+ items=[];seen=set()
1177
+ def one(feed):
1178
+ name,url,dom=feed;out=[]
1179
+ try:
1180
+ r=requests.get(url,headers=UA,timeout=3.5);r.encoding='utf-8'
1181
+ soup=BeautifulSoup(r.text,'xml')
1182
+ for it in soup.find_all('item')[:max_per_feed*2]:
1183
+ title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
1184
+ link=clean(it.find('link').get_text(strip=True) if it.find('link') else '')
1185
+ desc_raw=it.find('description').get_text(' ',strip=True) if it.find('description') else ''
1186
+ ds=BeautifulSoup(desc_raw,'lxml')
1187
+ img='';im=ds.find('img')
1188
+ if im:img=im.get('src','') or im.get('data-src','')
1189
+ desc=clean(ds.get_text(' ',strip=True))[:700]
1190
+ if title and link:
1191
+ out.append({'title':title,'url':link,'link':link,'source':name,'via':name,'domain':dom,'snippet':desc,'img':img})
1192
+ if len(out)>=max_per_feed:break
1193
+ except Exception:pass
1194
+ return out
1195
+ try:
1196
+ from concurrent.futures import ThreadPoolExecutor, as_completed
1197
+ with ThreadPoolExecutor(max_workers=8) as ex:
1198
+ futs=[ex.submit(one,f) for f in _SOURCE_FEEDS]
1199
+ for f in as_completed(futs,timeout=5.5):
1200
+ try:
1201
+ for it in f.result() or []:
1202
+ if it['url'] not in seen:
1203
+ seen.add(it['url']);items.append(it)
1204
+ except Exception:pass
1205
+ except Exception:
1206
+ for f in _SOURCE_FEEDS[:8]:
1207
+ for it in one(f):
1208
+ if it['url'] not in seen:
1209
+ seen.add(it['url']);items.append(it)
1210
+ _SOURCE_CACHE.update({'t':now,'items':items})
1211
+ return items
1212
+
1213
+ def _score_topic_source(topic,it):
1214
+ toks=[w.lower() for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',topic or '') if len(w)>1 and w.lower() not in STOP_WORDS]
1215
+ hay=(it.get('title','')+' '+it.get('snippet','')+' '+it.get('source','')).lower()
1216
+ if not toks:return 0
1217
+ score=sum((3 if len(t)>3 else 1) for t in toks if t in hay)
1218
+ if topic.lower().strip() and topic.lower().strip() in hay:score+=12
1219
+ return score
1220
+
1221
+ def _hot_topics():
1222
+ now=time.time()
1223
+ if _HOT_CACHE['d'] and now-_HOT_CACHE['t']<600:return _HOT_CACHE['d']
1224
+ freq={};display={}
1225
+ for it in _feed_items_source_limited(8)[:180]:
1226
+ title=re.sub(r'\s+-\s+.*$','',it.get('title',''))
1227
+ kws=[]
1228
+ for m in re.findall(r'([A-ZĐÀ-Ỹ][A-Za-zÀ-ỹ0-9]+(?:\s+[A-ZĐÀ-ỸA-Za-zÀ-ỹ0-9][A-Za-zÀ-ỹ0-9]+){1,4})',title):
1229
+ if len(m)>=6:kws.append(m)
1230
+ kws += _keywords_from_title(title)
1231
+ for kw in kws[:4]:
1232
+ words=[w for w in clean(kw).split() if w.lower() not in STOP_WORDS]
1233
+ if len(words)<2:continue
1234
+ kw=' '.join(words[:5])
1235
+ if 6<=len(kw)<=55:
1236
+ key=kw.lower();freq[key]=freq.get(key,0)+1;display[key]=kw
1237
+ topics=[]
1238
+ for key,_ in sorted(freq.items(),key=lambda x:x[1],reverse=True):
1239
+ kw=display[key];topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
1240
+ if len(topics)>=24:break
1241
+ for kw in ['Giá vàng trong nước','AI tại Việt Nam','Bóng đá Việt Nam','Kinh tế Việt Nam','Công nghệ AI','Vật Vờ Studio','World Cup 2026','Sức khỏe cộng đồng']:
1242
+ if not any(t['topic'].lower()==kw.lower() for t in topics):topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
1243
+ _HOT_CACHE.update({'t':now,'d':topics[:24]})
1244
+ return _HOT_CACHE['d']
1245
+
1246
+ def _fast_context(topic):
1247
+ now=time.time();key='source_limited_ctx:'+topic.lower().strip()
1248
+ if key in _FAST_TOPIC_CACHE and now-_FAST_TOPIC_CACHE[key]['t']<600:return _FAST_TOPIC_CACHE[key]['d']
1249
+ pool=_feed_items_source_limited(12)
1250
+ scored=[]
1251
+ for it in pool:
1252
+ sc=_score_topic_source(topic,it)
1253
+ if sc>0:scored.append((sc,it))
1254
+ if not scored:
1255
+ # Try broader matching by first token only before giving up.
1256
+ first=(_fast_topic_tokens(topic) or [''])[0]
1257
+ if first:
1258
+ for it in pool:
1259
+ if first in (it.get('title','')+' '+it.get('snippet','')).lower():scored.append((1,it))
1260
+ picked=[it for sc,it in sorted(scored,key=lambda x:(x[0],len(x[1].get('snippet',''))),reverse=True)[:8]]
1261
+ if not picked:picked=pool[:6]
1262
+ blocks=[];src=[]
1263
+ for it in picked:
1264
+ content=it.get('snippet','') or it.get('title','')
1265
+ blocks.append(f"NGUỒN: {it.get('source','')}\nTIÊU ĐỀ: {it.get('title','')}\nTÓM TẮT RSS:\n{content}")
1266
+ src.append({'title':it.get('title',''),'url':it.get('url',''),'via':it.get('source',''),'snippet':content})
1267
+ data={'context':'\n\n---\n\n'.join(blocks),'sources':src,'count':len(blocks)}
1268
+ _FAST_TOPIC_CACHE[key]={'t':now,'d':data}
1269
+ return data
1270
+
1271
+ # Override slow search functions to never crawl open web during topic generation.
1272
+ def _web_research_context(topic):
1273
+ return _fast_context(topic)
1274
+
1275
+ def _candidate_urls(topic):
1276
+ return _fast_context(topic).get('sources',[])
1277
+
1278
+ # Fast homepage endpoints from requested source RSS; no slow HTML scrapers.
1279
+ def _fast_homepage_sources():
1280
+ now=time.time();key='home_sources'
1281
+ if key in _FAST_ROUTE_CACHE and now-_FAST_ROUTE_CACHE[key]['t']<600:return _FAST_ROUTE_CACHE[key]['d']
1282
+ groups=[];seen=set()
1283
+ group_map=[('Tin mới','https://vnexpress.net/rss/tin-moi-nhat.rss','vne'),('Thời Sự','https://vnexpress.net/rss/thoi-su.rss','vne'),('Kinh Doanh','https://vnexpress.net/rss/kinh-doanh.rss','vne'),('Công Nghệ','https://vnexpress.net/rss/so-hoa.rss','vne'),('Dân Trí','https://dantri.com.vn/rss/home.rss','dantri'),('GenK','https://genk.vn/rss/home.rss','genk'),('VnReview','https://vnreview.vn/rss/tin-moi-nhat.rss','vnreview')]
1284
+ for g,u,s in group_map:
1285
+ for it in _rss_articles_fast(u,g,s,6) if '_rss_articles_fast' in globals() else []:
1286
+ if it['link'] not in seen:
1287
+ seen.add(it['link']);groups.append(it)
1288
+ _FAST_ROUTE_CACHE[key]={'t':now,'d':groups}
1289
+ return groups
1290
+
1291
+ for _p in ['/api/homepage','/api/dantri_hot','/api/vne_video','/api/highlights','/api/hot_topics','/api/topic_sources']:
1292
+ app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)==_p and 'GET' in getattr(r,'methods',set()))]
1293
+ @app.get('/api/homepage')
1294
+ def api_homepage_source_fast():return JSONResponse(_fast_homepage_sources())
1295
+ @app.get('/api/dantri_hot')
1296
+ def api_dantri_hot_source_fast():
1297
+ data=[{**it,'source':'dantri','link':it.get('url') or it.get('link')} for it in _feed_items_source_limited(8) if it.get('domain')=='dantri.com.vn'][:12]
1298
+ return JSONResponse(data)
1299
+ @app.get('/api/vne_video')
1300
+ def api_vne_video_source_fast():
1301
+ return JSONResponse([]) # do not block homepage if VnEgo is slow
1302
+ @app.get('/api/highlights')
1303
+ def api_highlights_source_fast():return JSONResponse([])
1304
+ @app.get('/api/hot_topics')
1305
+ def api_hot_topics_source_fast():return JSONResponse({'topics':_hot_topics(),'sources':'vn_only'})
1306
+ @app.get('/api/topic_sources')
1307
+ def api_topic_sources_source_fast(topic:str=Query(...)):
1308
+ data=_fast_context(clean(topic));return JSONResponse({'count':data.get('count',0),'sources':data.get('sources',[]),'has_context':bool(data.get('context')),'mode':'source_limited_rss'})
1309
+
1310
+ # Override root: include all existing UI but add a script that prevents forced shorts refresh on initial load.
1311
+ ROOT_FAST_INJECT="""
1312
+ <script>
1313
+ (function(){
1314
+ const oldFetch=window.fetch;window.__allowShortRefresh=false;
1315
+ window.fetch=function(url,opts){try{let u=String(url||'');if(u.includes('/api/shorts?refresh=1')&&!window.__allowShortRefresh)url='/api/shorts';}catch(e){}return oldFetch.call(this,url,opts)};
1316
+ setTimeout(()=>{window.__allowShortRefresh=true;},8000);
1317
+ })();
1318
+ </script>
1319
+ """
1320
+ app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
1321
+ @app.get('/')
1322
+ async def index_final_fast_sources():
1323
+ html=f5.f4.f3.f2.f1._load_index_html()
1324
+ body=getattr(rt.old,'PATCH_INJECT','')+f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT+FINAL6_INJECT+globals().get('FINAL6_FAST_HOME_INJECT','')+globals().get('FINAL6E_INJECT','')+globals().get('FINAL6F_INJECT','')+ROOT_FAST_INJECT
1325
+ return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
app/static/app_v2.js ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // === VNEWS Frontend v2 - Full Functions ===
2
+ // Updated: Voice selector + speed control + image gallery + auto voice detect
3
+
4
+ // === LOAD HOME ===
5
+ async function loadHome(){
6
+ const[featured,sh,wall,hlLeagues,ai,wcData]=await Promise.all([
7
+ fetch('/api/livescore/featured').then(r=>r.json()).catch(()=>null),
8
+ fetch('/api/shorts').then(r=>r.json()).catch(()=>[]),
9
+ fetch('/api/wall').then(r=>r.json()).catch(()=>({posts:[]})),
10
+ fetch('/api/highlights/leagues').then(r=>r.json()).catch(()=>({})),
11
+ fetch('/api/genk_ai').then(r=>r.json()).catch(()=>[]),
12
+ fetch('/api/wc2026').then(r=>r.json()).catch(()=>null)
13
+ ]);
14
+ _hlLeagueData=hlLeagues;
15
+ _wc2026Data=wcData;
16
+ _shortsData=interleaveShorts(sh||[]);
17
+ _wallPosts=(wall&&wall.posts)||[];
18
+ let h='';
19
+ if(featured&&featured.home){const sc=featured.status==='live'?'':'upcoming';const st=featured.status==='live'?`🔴 ${featured.minute||'LIVE'}`:`⏰ ${featured.time}`;h+=`<div class="featured-match" onclick="openMatch('${featured.event_id}')"><div class="fm-league">${featured.league}</div><div class="fm-teams"><div class="fm-team"><img src="${featured.home_logo}" onerror="this.style.display='none'"><span>${featured.home}</span></div><div class="fm-score">${featured.score||'VS'}</div><div class="fm-team"><img src="${featured.away_logo}" onerror="this.style.display='none'"><span>${featured.away}</span></div></div><div class="fm-status ${sc}">${st}</div></div>`;}
20
+ h+=`<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
+ h+='<div id="hashtag-box"></div>';
22
+ h+=`<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
+ h+=`<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>`;
24
+ const wallPosts=_wallPosts;
25
+ const aiShorts=wallPosts.filter(p=>p.video);
26
+ if(aiShorts.length){h+='<div class="slider-wrap"><div class="slider-header"><span class="slider-label">🎬 Short AI</span></div><div class="slider-track">';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>`});h+='</div></div>';}
27
+ if(_shortsData.length){h+='<div class="slider-wrap"><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">';_shortsData.slice(0,30).forEach((a,i)=>{const badge=a.channel==='baosuckhoedoisongboyte'?'SKĐS':'Dân trí';h+=`<div class="slider-item shorts-item" onclick="openYTShortsFeed(${i})"><div class="slider-thumb shorts-thumb">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="slider-title"><span style="color:#f0c040;font-size:8px">${badge}</span> ${esc(a.title)}</div></div>`;});h+='</div></div>';}
28
+ if(wallPosts.length){h+=`<div class="slider-wrap" id="ai-wall-wrap"><div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track" id="ai-wall-track">`;wallPosts.slice(0,20).forEach((p,i)=>{h+=makeWallItem(p,i)});h+='</div></div>';}
29
+ const HL_CONFIG={"world-cup":{name:"World Cup 2026",emoji:"🌍"},"premier-league":{name:"Premier League",emoji:"🏴"},"champions-league":{name:"Champions League",emoji:"⭐"},"la-liga":{name:"La Liga",emoji:"🇪🇸"},"serie-a":{name:"Serie A",emoji:"🇮🇹"},"bundesliga":{name:"Bundesliga",emoji:"🇩🇪"},"friendly":{name:"Giao hữu",emoji:"🤝"}};
30
+ for(const[key,cfg] of Object.entries(HL_CONFIG)){const vids=hlLeagues[key];if(!vids||!vids.length)continue;h+=`<div class="slider-wrap"><div class="slider-header"><span class="slider-label">${cfg.emoji} ${cfg.name}</span></div><div class="slider-track">`;vids.slice(0,8).forEach((a,i)=>{h+=`<div class="slider-item" onclick="openHighlightFeed('${key}',${i})"><div class="slider-thumb">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="slider-title">${esc(a.title)}</div></div>`});h+='</div></div>';}
31
+ if(ai&&ai.length){h+='<div class="slider-wrap"><div class="slider-header"><span class="slider-label">🤖 Ứng dụng AI</span></div><div class="slider-track">';ai.slice(0,12).forEach(a=>{h+=`<div class="slider-item" onclick="readArticle('${esc(a.link)}')"><div class="slider-thumb">${a.img?`<img src="${a.img}">`:''}</div><div class="slider-title">${esc(a.title)}</div></div>`});h+='</div></div>';}
32
+ document.getElementById('view-home').innerHTML=h;
33
+ loadLivescore('today');loadHotTopics();
34
+ if(_wc2026Data)switchWCTab('news');
35
+ }
36
+
37
+ // === WALL POST HELPERS ===
38
+ function makeWallItem(p,i){
39
+ const hasVideo = p.video && p.video.length > 0;
40
+ const thumbContent = p.img
41
+ ? `<img src="${esc(p.img)}" onerror="this.style.display='none'">`
42
+ : (hasVideo ? `<video src="${esc(p.video)}" muted></video>` : '');
43
+ const videoBadge = hasVideo
44
+ ? `<div class="wall-video-badge">🎬</div>`
45
+ : '';
46
+ const videoBtn = hasVideo
47
+ ? `<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed(${i})">▶ Xem Short</button>`
48
+ : `<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo('${esc(p.id||i)}',this)">🎬 Tạo Video</button>`;
49
+
50
+ return `<div class="wall-item" id="wall-item-${esc(p.id||i)}">
51
+ <div class="wall-thumb">
52
+ ${thumbContent}
53
+ ${videoBadge}
54
+ </div>
55
+ <div class="wall-title">${esc(p.title)}</div>
56
+ <div class="wall-text">${esc((p.text||'').slice(0,180))}</div>
57
+ <div class="wall-actions">
58
+ <button class="primary" onclick="readWallPost(${i})">Xem</button>
59
+ ${videoBtn}
60
+ </div>
61
+ </div>`;
62
+ }
63
+
64
+ // === GENERATE SHORT VIDEO FOR A WALL POST ===
65
+ async function makeShortVideo(postId, btn, voice, speed){
66
+ if(!postId)return;
67
+ const origText = btn ? btn.textContent : '🎬 Tạo Video';
68
+ if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo...';}
69
+ toast('⏳ Đang tạo video shorts...');
70
+ try{
71
+ let url = '/api/ai/short/'+encodeURIComponent(postId);
72
+ const params = [];
73
+ if(voice) params.push('voice='+encodeURIComponent(voice));
74
+ if(speed) params.push('speed='+encodeURIComponent(speed));
75
+ if(params.length) url += '?' + params.join('&');
76
+ const r = await fetch(url, {method:'POST'});
77
+ const j = await r.json();
78
+ if(!r.ok || j.error) throw new Error(j.error||'Lỗi tạo video');
79
+ toast('✅ Đã tạo video shorts!');
80
+ const p = _wallPosts.find(x => String(x.id) === String(postId));
81
+ if(p){
82
+ p.video = j.video;
83
+ const itemId = 'wall-item-'+postId;
84
+ const el = document.getElementById(itemId);
85
+ if(el){
86
+ const idx = _wallPosts.indexOf(p);
87
+ el.outerHTML = makeWallItem(p, idx);
88
+ const newEl = document.getElementById(itemId);
89
+ if(newEl) newEl.className = 'wall-item wall-item-new';
90
+ }
91
+ }
92
+ refreshShortAISlider();
93
+ }catch(e){
94
+ toast('❌ '+e.message);
95
+ if(btn){btn.disabled=false;btn.textContent=origText;}
96
+ }
97
+ }
98
+
99
+ // Refresh Short AI slider after video generation
100
+ function refreshShortAISlider(){
101
+ const aiShorts = _wallPosts.filter(p=>p.video);
102
+ let shortAISection = document.getElementById('short-ai-section');
103
+ if(aiShorts.length === 0){
104
+ if(shortAISection) shortAISection.remove();
105
+ return;
106
+ }
107
+ if(shortAISection){
108
+ const track = shortAISection.querySelector('.slider-track');
109
+ if(track){
110
+ let h = '';
111
+ aiShorts.slice(0,20).forEach((p,i)=>{
112
+ 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>`;
113
+ });
114
+ track.innerHTML = h;
115
+ }
116
+ }
117
+ }
118
+
119
+ function prependWallPost(post){
120
+ _wallPosts.unshift(post);
121
+ const track=document.getElementById('ai-wall-track');
122
+ const wrap=document.getElementById('ai-wall-wrap');
123
+ const homeEl=document.getElementById('view-home');
124
+ if(!track||!wrap){
125
+ if(homeEl){
126
+ let insertBefore=homeEl.querySelector('.slider-wrap');
127
+ const newWrap=document.createElement('div');
128
+ newWrap.className='slider-wrap';
129
+ newWrap.id='ai-wall-wrap';
130
+ 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>`;
131
+ if(insertBefore){
132
+ homeEl.insertBefore(newWrap,insertBefore);
133
+ }else{
134
+ homeEl.appendChild(newWrap);
135
+ }
136
+ const firstItem=newWrap.querySelector('.wall-item');
137
+ if(firstItem)firstItem.className='wall-item wall-item-new';
138
+ }
139
+ return;
140
+ }
141
+ const div=document.createElement('div');
142
+ div.className='wall-item wall-item-new';
143
+ div.id='wall-item-'+(post.id||'new-'+Date.now());
144
+ const hasVideo = post.video && post.video.length > 0;
145
+ const thumbContent = post.img
146
+ ? `<img src="${esc(post.img)}" onerror="this.style.display='none'">`
147
+ : (hasVideo ? `<video src="${esc(post.video)}" muted></video>` : '');
148
+ const videoBadge = hasVideo ? `<div class="wall-video-badge">🎬</div>` : '';
149
+ const videoBtn = hasVideo
150
+ ? `<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed(0)">▶ Xem Short</button>`
151
+ : `<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo('${esc(post.id)}',this)">🎬 Tạo Video</button>`;
152
+ 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>`;
153
+ track.prepend(div);
154
+ track.scrollTo({left:0,behavior:'smooth'});
155
+ if(hasVideo) refreshShortAISlider();
156
+ }
157
+
158
+ // === REST OF FUNCTIONS ===
159
+ let _shortsData=[];
160
+ let _wallPosts=[];
161
+ let _currentView='home';
162
+ 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;}
163
+ let _htPage=0,_htTopic='';
164
+ 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);}}
165
+ 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);}
166
+ 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>`;}}
167
+ function loadMoreHashtag(){_htPage++;const btn=document.getElementById('ht-more');if(btn){btn.disabled=true;btn.textContent='Đang tải...';}showHashtagSources(_htTopic,_htPage);}
168
+ 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';}}}
169
+ 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>';}}
170
+ function bindMatchClicks(el){el.querySelectorAll('.match-detail').forEach(md=>{md.addEventListener('click',function(e){e.preventDefault();const a=this.querySelector('.status a');if(a){const m=(a.getAttribute('href')||'').match(/\/tran-dau\/(\d+)\//);if(m)openMatch(m[1])}})});el.querySelectorAll('a').forEach(a=>a.addEventListener('click',e=>e.preventDefault()))}
171
+ function openMatch(id){if(!id)return;_currentEventId=id;document.getElementById('match-overlay').classList.add('active');document.body.style.overflow='hidden';loadMatchTab('comm')}
172
+ function closeMatch(){document.getElementById('match-overlay').classList.remove('active');document.body.style.overflow=''}
173
+ 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ê'))t.classList.add('active')});const el=document.getElementById('mo-body');el.innerHTML='<div class="loading">Đang tải...</div>';try{const r=await fetch(tab==='stats'?`/api/match/${_currentEventId}/stats`:`/api/match/${_currentEventId}/commentaries`);const d=await r.json();el.innerHTML=d.html||'<div class="loading">Không có dữ liệu</div>'}catch(e){el.innerHTML='<div class="loading">Lỗi</div>'}}
174
+ 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;}}
175
+ 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};}}
176
+ 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[];}}
177
+ 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[];}}
178
+ 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>`;}
179
+ 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);}}
180
+ 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);}}
181
+ 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);}
182
+ async function loadCounters(videoIds){for(let i=0;i<videoIds.length;i++){const id=videoIds[i];if(!id)continue;const j=await getInteractions(id);const vc=document.getElementById('vc-'+i);if(vc)vc.textContent=fmtNum(j.views);const lc=document.getElementById('lc-'+i);if(lc)lc.textContent=fmtNum(j.likes);const cc=document.getElementById('cc-'+i);if(cc)cc.textContent=fmtNum(j.comments);}}
183
+ 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);}
184
+ 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;}
185
+ 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);}
186
+ 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);}
187
+ async function openHighlightFeed(league,idx){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();}
188
+ 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();}
189
+ 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();}
190
+ 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>`;}
191
+ 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)}}
192
+ 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)}}
193
+ 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}}
194
+ async function readWallPost(i){const p=_wallPosts[i];if(!p)return;showView('view-article');
195
+ // Build image gallery HTML
196
+ const images = p.images || [];
197
+ let imgGallery = '';
198
+ if(images.length > 0){
199
+ imgGallery = '<div class="article-image-gallery">';
200
+ images.forEach((imgUrl, idx) => {
201
+ if(idx === 0){
202
+ imgGallery += `<img class="article-img article-hero-img" src="${esc(imgUrl)}" onerror="this.style.display='none" loading="eager">`;
203
+ } else {
204
+ if(idx === 1) imgGallery += '<div class="gallery-thumbs">';
205
+ imgGallery += `<div class="gallery-thumb"><img src="${esc(imgUrl)}" onerror="this.parentElement.style.display='none'" loading="lazy"></div>`;
206
+ }
207
+ });
208
+ if(images.length > 1) imgGallery += '</div>';
209
+ imgGallery += '</div>';
210
+ }
211
+ const hasVideo = p.video && p.video.length > 0;
212
+ const voiceOptions = [
213
+ {id:'hoaimy', label:'🎙️ Nữ — Hoài My'},
214
+ {id:'namminh', label:'🎙️ Nam — Nam Minh'},
215
+ ];
216
+ let voiceSelector = '';
217
+ if(!hasVideo){
218
+ voiceSelector = `<div class="tts-selector"><div class="tts-selector-label">🎙️ Chọn giọng đọc:</div><div class="tts-voice-btns">`;
219
+ voiceOptions.forEach(v=>{
220
+ 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>`;
221
+ });
222
+ 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>`;
223
+ voiceSelector += `<input type="hidden" id="selected-voice" value="hoaimy"></div>`;
224
+ }
225
+ 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>`;
226
+ const firstVoiceBtn = document.querySelector('.tts-voice-btn');
227
+ if(firstVoiceBtn) firstVoiceBtn.classList.add('active');
228
+ window.scrollTo(0,0)}
229
+ 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}">`:''}</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>'}}
230
+ 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}">`:''}</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}
231
+ 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(()=>{});
app_main.py CHANGED
@@ -1,7 +1,7 @@
1
  """VNEWS v2 - Clean frontend. CRITICAL: removes ALL old routes before registering new ones."""
2
  from app_run import *
3
  from app_run import app, f5, f6, rt, PATCH_INJECT, UNIFIED_INJECT_FIXED, HIGHLIGHT_FULL_OVERRIDE, EXTRA_WALL_FIX, FAST_HASHTAG_JS
4
- from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response
5
  from fastapi.staticfiles import StaticFiles
6
  from fastapi import Query, Request
7
  import requests as req
@@ -67,17 +67,25 @@ def _search_all(topic, limit=40):
67
  if i.get('url') and i['url'] not in seen:seen.add(i['url']);unique.append(i)
68
  return unique[:limit]
69
 
70
- # Remove old routes
 
 
 
 
71
  app.router.routes = [r for r in app.router.routes if not (
72
  (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set())) or
73
  (getattr(r, 'path', None) == '/api/hashtag/sources' and 'GET' in getattr(r, 'methods', set())) or
74
  (getattr(r, 'path', None) in ('/api/short/comments', '/api/short/comment'))
75
  )]
 
76
  app.routes[:] = [r for r in app.routes if not (
77
- hasattr(r, 'path') and getattr(r, 'path', None) == '/' and
78
  hasattr(r, 'methods') and 'GET' in getattr(r, 'methods', set())
79
  )]
80
 
 
 
 
81
  STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
82
 
83
  @app.get('/api/hashtag/sources')
@@ -98,56 +106,9 @@ def _storage():return JSONResponse({'persistent':os.path.isdir('/data') and os.a
98
  async def _share(url:str='',title:str='',img:str=''):
99
  return HTMLResponse(f'<!DOCTYPE html><html><head><meta property="og:title" content="{_clean(title)}"><meta property="og:image" content="{_clean(img)}"><meta http-equiv="refresh" content="0;url={_clean(url) or "/"}"></head><body>Redirecting...</body></html>')
100
 
101
- @app.get('/api/proxy/page')
102
- def proxy_page(url: str = Query(...)):
103
- try:
104
- r = req.get(url, headers={'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36','Accept-Language':'vi-VN,vi;q=0.9','Referer':'https://hd.xemtv.net/'}, timeout=15)
105
- return HTMLResponse(content=r.text)
106
- except:
107
- return HTMLResponse(content='', status_code=502)
108
-
109
- @app.get('/api/proxy/hls')
110
- def proxy_hls(url: str = Query(...)):
111
- try:
112
- headers = {
113
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
114
- 'Accept': '*/*',
115
- 'Accept-Language': 'vi-VN,vi;q=0.9',
116
- 'Referer': 'https://fptplay.vn/',
117
- 'Origin': 'https://fptplay.vn',
118
- }
119
- r = req.get(url, headers=headers, timeout=15)
120
- content_type = r.headers.get('Content-Type', 'application/vnd.apple.mpegurl')
121
- text = r.text
122
- base_url = url.rsplit('/', 1)[0] + '/'
123
- def _rewrite_url(m):
124
- seg_url = m.group(0)
125
- if seg_url.startswith('http'):
126
- return '/api/proxy/seg?url=' + quote(seg_url, safe='')
127
- elif seg_url.startswith('/'):
128
- return '/api/proxy/seg?url=' + quote(base_url.rsplit('/', 2)[0] + seg_url, safe='')
129
- else:
130
- return '/api/proxy/seg?url=' + quote(base_url + seg_url, safe='')
131
- text = re.sub(r'https?://[^\s"\'<>]+\.(ts|m3u8)[^\s"\'<>]*', _rewrite_url, text)
132
- return HTMLResponse(content=text, media_type=content_type)
133
- except:
134
- return HTMLResponse(content='', status_code=502)
135
-
136
- @app.get('/api/proxy/seg')
137
- def proxy_seg(url: str = Query(...)):
138
- try:
139
- headers = {
140
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
141
- 'Referer': 'https://fptplay.vn/',
142
- 'Origin': 'https://fptplay.vn',
143
- }
144
- r = req.get(url, headers=headers, timeout=15)
145
- content_type = r.headers.get('Content-Type', 'video/MP2T')
146
- return Response(content=r.content, media_type=content_type)
147
- except:
148
- return Response(content=b'', status_code=502)
149
-
150
- # Interactions
151
  DATA_DIR = '/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data')
152
  os.makedirs(DATA_DIR, exist_ok=True)
153
  INTERACTIONS_FILE = os.path.join(DATA_DIR, 'interactions_v2.json')
@@ -199,7 +160,9 @@ async def api_post_comment(request:Request):
199
  idb[vid]['comments']=len(comments);_save_json(INTERACTIONS_FILE,idb)
200
  return JSONResponse({'comments':comments})
201
 
202
- # World Cup 2026 API
 
 
203
  from wc2026_scraper import (
204
  scrape_summary, scrape_fixtures, scrape_standings, scrape_stats,
205
  scrape_wc_news, scrape_road_to_wc, get_wc2026_all,
@@ -229,41 +192,6 @@ def api_wc2026_lineups(event_id:int):return JSONResponse(scrape_lineups(event_id
229
  @app.get('/api/wc2026/match/{event_id}')
230
  def api_wc2026_match(event_id:int):return JSONResponse(scrape_match_detail(event_id))
231
 
232
- # Match Detail API (for any match from bongda.com.vn)
233
- from match_detail import fetch_match_detail, fetch_match_detail_by_url, _bongda_api
234
-
235
- @app.get('/api/match/{event_id}/detail')
236
- def api_match_detail(event_id: int, url: str = Query(default=None)):
237
- """Get complete match detail. Optional 'url' param with full bongda URL (with slug) for HTML scraping."""
238
- if url:
239
- return JSONResponse(fetch_match_detail_by_url(url))
240
- return JSONResponse(fetch_match_detail(event_id))
241
-
242
- @app.get('/api/match/{event_id}/commentaries')
243
- def api_match_commentaries(event_id: int):
244
- """Get match commentaries from bongda API."""
245
- comm = _bongda_api("/api/fixtures/commentaries", {"event_id": event_id})
246
- if comm and comm.get("status") == "success":
247
- html = comm.get("html", "")
248
- if html and len(html.strip()) > 10:
249
- return JSONResponse({"html": html})
250
- return JSONResponse({"html": ""})
251
-
252
- @app.get('/api/match/{event_id}/stats')
253
- def api_match_stats(event_id: int):
254
- """Get match player performance stats from bongda API."""
255
- perf = _bongda_api("/api/event-standing/player-performance", {"event_id": event_id})
256
- if perf and perf.get("status") == "success":
257
- html = perf.get("html", "")
258
- if html and len(html.strip()) > 10:
259
- return JSONResponse({"html": html})
260
- return JSONResponse({"html": ""})
261
-
262
- @app.get('/api/match/detail')
263
- def api_match_detail_by_url(url: str = Query(...)):
264
- """Get match detail by full bongda.com.vn URL."""
265
- return JSONResponse(fetch_match_detail_by_url(url))
266
-
267
  def _wc2026_bg_refresh():
268
  time.sleep(10)
269
  while True:
@@ -272,12 +200,18 @@ def _wc2026_bg_refresh():
272
  time.sleep(90)
273
  threading.Thread(target=_wc2026_bg_refresh,daemon=True).start()
274
 
275
- # Serve frontend
 
 
276
  @app.get('/')
277
  async def _index_v2():
 
278
  index_path = os.path.join(STATIC_DIR, 'index_v2.html')
279
  if os.path.exists(index_path):
280
  return FileResponse(index_path, media_type='text/html')
281
  return HTMLResponse('<html><body><h1>VNEWS v2</h1><p>index_v2.html not found</p></body></html>')
282
 
 
 
 
283
  app.mount('/static', StaticFiles(directory=STATIC_DIR), name='vnews_static')
 
1
  """VNEWS v2 - Clean frontend. CRITICAL: removes ALL old routes before registering new ones."""
2
  from app_run import *
3
  from app_run import app, f5, f6, rt, PATCH_INJECT, UNIFIED_INJECT_FIXED, HIGHLIGHT_FULL_OVERRIDE, EXTRA_WALL_FIX, FAST_HASHTAG_JS
4
+ from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
5
  from fastapi.staticfiles import StaticFiles
6
  from fastapi import Query, Request
7
  import requests as req
 
67
  if i.get('url') and i['url'] not in seen:seen.add(i['url']);unique.append(i)
68
  return unique[:limit]
69
 
70
+ # ============================================================
71
+ # AGGRESSIVELY remove ALL old '/' routes from entire chain
72
+ # This ensures our new '/' route is the ONLY one serving
73
+ # ============================================================
74
+ # Remove from app.router.routes (APIRoute objects)
75
  app.router.routes = [r for r in app.router.routes if not (
76
  (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set())) or
77
  (getattr(r, 'path', None) == '/api/hashtag/sources' and 'GET' in getattr(r, 'methods', set())) or
78
  (getattr(r, 'path', None) in ('/api/short/comments', '/api/short/comment'))
79
  )]
80
+ # Also remove from app.routes (includes mounts)
81
  app.routes[:] = [r for r in app.routes if not (
82
+ hasattr(r, 'path') and getattr(r, 'path', None) == '/' and
83
  hasattr(r, 'methods') and 'GET' in getattr(r, 'methods', set())
84
  )]
85
 
86
+ # ============================================================
87
+ # DEFINE ALL NEW ROUTES
88
+ # ============================================================
89
  STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
90
 
91
  @app.get('/api/hashtag/sources')
 
106
  async def _share(url:str='',title:str='',img:str=''):
107
  return HTMLResponse(f'<!DOCTYPE html><html><head><meta property="og:title" content="{_clean(title)}"><meta property="og:image" content="{_clean(img)}"><meta http-equiv="refresh" content="0;url={_clean(url) or "/"}"></head><body>Redirecting...</body></html>')
108
 
109
+ # ============================================================
110
+ # INTERACTIONS + COMMENTS
111
+ # ============================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  DATA_DIR = '/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data')
113
  os.makedirs(DATA_DIR, exist_ok=True)
114
  INTERACTIONS_FILE = os.path.join(DATA_DIR, 'interactions_v2.json')
 
160
  idb[vid]['comments']=len(comments);_save_json(INTERACTIONS_FILE,idb)
161
  return JSONResponse({'comments':comments})
162
 
163
+ # ============================================================
164
+ # WORLD CUP 2026 API
165
+ # ============================================================
166
  from wc2026_scraper import (
167
  scrape_summary, scrape_fixtures, scrape_standings, scrape_stats,
168
  scrape_wc_news, scrape_road_to_wc, get_wc2026_all,
 
192
  @app.get('/api/wc2026/match/{event_id}')
193
  def api_wc2026_match(event_id:int):return JSONResponse(scrape_match_detail(event_id))
194
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  def _wc2026_bg_refresh():
196
  time.sleep(10)
197
  while True:
 
200
  time.sleep(90)
201
  threading.Thread(target=_wc2026_bg_refresh,daemon=True).start()
202
 
203
+ # ============================================================
204
+ # SERVE V2 FRONTEND - THIS MUST BE THE ONLY '/' ROUTE
205
+ # ============================================================
206
  @app.get('/')
207
  async def _index_v2():
208
+ """Serve clean v2 frontend from static/index_v2.html"""
209
  index_path = os.path.join(STATIC_DIR, 'index_v2.html')
210
  if os.path.exists(index_path):
211
  return FileResponse(index_path, media_type='text/html')
212
  return HTMLResponse('<html><body><h1>VNEWS v2</h1><p>index_v2.html not found</p></body></html>')
213
 
214
+ # ============================================================
215
+ # MOUNT STATIC FILES LAST (must be after all route definitions)
216
+ # ============================================================
217
  app.mount('/static', StaticFiles(directory=STATIC_DIR), name='vnews_static')
app_v2_entry.py CHANGED
@@ -1,216 +1,41 @@
1
- """VNEWS v2 Entry Point - with fast bongda proxy + rewrite endpoints + multilingual TTS"""
2
  import sys, os
3
  from main import app, HEADERS, BONGDA_HEADERS, fetch_bongda_api, HL_LEAGUES
4
 
 
5
  try:
6
  import ai_ext
7
  except Exception as e:
8
  print(f"[WARN] ai_ext import failed: {e}")
9
 
10
- try:
11
- import ai_patch
12
- except Exception as e:
13
- print(f"[WARN] ai_patch import failed: {e}")
14
-
15
- from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response
16
  from fastapi.staticfiles import StaticFiles
17
  from starlette.routing import Mount
18
- from fastapi import Query, Request, UploadFile, File, Form
19
  import requests as req
 
20
  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
 
25
  HL_LEAGUES['friendly'] = {"path": "giai-khac/friendly", "name": "Giao hữu", "emoji": "🤝"}
26
 
27
  STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
 
28
  app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)=='/' and hasattr(r,'methods') and 'GET' in getattr(r,'methods',set()))]
 
29
  app.routes[:]=[r for r in app.routes if not isinstance(r, Mount)]
 
30
  app.router.routes=[r for r in app.router.routes if not isinstance(r, Mount)]
31
 
32
- def _clean(s): return re.sub(r"\s+"," ",html_lib.unescape(str(s or""))).strip()
33
-
34
- # Cache for match details (5 min TTL)
35
- _match_cache = {}
36
-
37
- # === FAST BONGDA PROXY ENDPOINT ===
38
- def _get_match_detail(event_id, slug=None):
39
- headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "text/html", "Referer": "https://bongda.com.vn/"}
40
- if slug:
41
- url = f"https://bongda.com.vn/tran-dau/{event_id}/centre/{slug}"
42
- else:
43
- url = f"https://bongda.com.vn/tran-dau/{event_id}"
44
- resp = req.get(url, headers=headers, timeout=15, allow_redirects=True)
45
- if resp.status_code != 200:
46
- return None
47
- soup = BeautifulSoup(resp.text, 'html.parser')
48
- result = {"event_id": event_id, "found": False, "sections": []}
49
- info = {}
50
- tel = soup.select_one('.teams')
51
- if tel:
52
- he = tel.select_one('.team.home')
53
- if he:
54
- p_tags = [p for p in he.select('p') if not p.get('class') or 'logo' not in p.get('class', [])]
55
- if p_tags: info['home_team'] = _clean(p_tags[0].get_text())
56
- lo = he.select_one('img')
57
- if lo: info['home_logo'] = lo.get('src', '')
58
- ae = tel.select_one('.team.away')
59
- if ae:
60
- p_tags = ae.select('p')
61
- team_ps = [p for p in p_tags if not p.get('class') or 'logo' not in p.get('class', [])]
62
- if team_ps: info['away_team'] = _clean(team_ps[-1].get_text())
63
- lo = ae.select_one('img')
64
- if lo: info['away_logo'] = lo.get('src', '')
65
- sc = tel.select_one('.score')
66
- if sc:
67
- parts = [_clean(p.get_text()) for p in sc.select('p')]
68
- if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}"
69
- lb = sc.select_one('.label')
70
- if lb: info['status_label'] = _clean(lb.get_text())
71
- if info.get('home_team') and info.get('away_team'):
72
- result['info'] = info
73
- result['found'] = True
74
- result['sections'].append('info')
75
- events = []
76
- for ev in soup.select('.events .period .event'):
77
- ev_cls = ' '.join(ev.get('class', []))
78
- ev_data = {'team': 'home' if 'home' in ev_cls else 'away', 'period': '', 'type': 'unknown', 'time': '', 'players': ''}
79
- parent = ev.parent
80
- if parent:
81
- h2 = parent.find('h2')
82
- if h2: ev_data['period'] = _clean(h2.get_text())
83
- if ev.select_one('[class*="goal"]'): ev_data['type'] = 'goal'
84
- elif ev.select_one('[class*="redcard"]'): ev_data['type'] = 'redcard'
85
- elif ev.select_one('[class*="yellowcard"]'): ev_data['type'] = 'yellowcard'
86
- elif ev.select_one('[class*="substitution"]'): ev_data['type'] = 'substitution'
87
- players_el = ev.select_one('.players')
88
- if players_el:
89
- pl_text = _clean(players_el.get_text(' ', strip=True))
90
- m = re.match(r"(\d+)'(.*)", pl_text)
91
- if m:
92
- ev_data['time'] = f"{m.group(1)}'"
93
- ev_data['players'] = m.group(2)
94
- else:
95
- ev_data['players'] = pl_text
96
- events.append(ev_data)
97
- if events:
98
- result['events'] = events
99
- result['sections'].append('events')
100
- pred = soup.select_one('.prediction-card')
101
- if pred:
102
- team_info = pred.select_one('.team-info')
103
- if team_info:
104
- teams = team_info.select('.team')
105
- pred_data = {}
106
- if len(teams) >= 2:
107
- pred_data['home_name'] = _clean(teams[0].select_one('.team-name').get_text()) if teams[0].select_one('.team-name') else ''
108
- pred_data['away_name'] = _clean(teams[1].select_one('.team-name').get_text()) if teams[1].select_one('.team-name') else ''
109
- divider = team_info.select_one('.divider')
110
- if divider: pred_data['result'] = _clean(divider.get_text())
111
- vc = pred.select_one('.vote-count')
112
- if vc: pred_data['vote_count'] = _clean(vc.get_text())
113
- result['prediction'] = pred_data
114
- recent = []
115
- ml = soup.select_one('.matches-list')
116
- if ml:
117
- for item in ml.select('.match-detail, .match-item, li'):
118
- de = item.select_one('.date, .time')
119
- le = item.select_one('.league')
120
- he_item = item.select_one('.home, .team-home')
121
- ae_item = item.select_one('.away, .team-away')
122
- se = item.select_one('.score, .result')
123
- if he_item or ae_item:
124
- recent.append({'date': _clean(de.get_text()) if de else '', 'league': _clean(le.get_text()) if le else '', 'home': _clean(he_item.get_text()) if he_item else '', 'away': _clean(ae_item.get_text()) if ae_item else '', 'score': _clean(se.get_text()) if se else 'vs'})
125
- if recent:
126
- result['recent_matches'] = recent
127
- result['sections'].append('recent')
128
- try:
129
- api_h = {"User-Agent": "Mozilla/5.0", "Accept": "application/json", "X-Requested-With": "XMLHttpRequest", "Referer": "https://bongda.com.vn/"}
130
- ar = req.get(f"https://bongda.com.vn/api/fixtures/h2h-stats?event_id={event_id}", headers=api_h, timeout=10)
131
- if ar.status_code == 200:
132
- ad = ar.json()
133
- if ad.get('status') == 'success' and ad.get('html'):
134
- asp = BeautifulSoup(ad['html'], 'html.parser')
135
- ast = {}
136
- for row in asp.select('li, tr'):
137
- cells = row.select('td, span, p')
138
- if len(cells) >= 3:
139
- lb = _clean(cells[0].get_text())
140
- if lb: ast[lb] = {'home': _clean(cells[1].get_text()), 'away': _clean(cells[2].get_text())}
141
- if ast:
142
- result['h2h_stats_parsed'] = ast
143
- result['sections'].append('h2h_stats')
144
- except: pass
145
- return result
146
-
147
- @app.get('/api/proxy/bongda')
148
- def proxy_bongda(event_id: int = Query(default=None), slug: str = Query(default=None)):
149
- if event_id is None:
150
- return JSONResponse({'error': 'event_id required'}, status_code=400)
151
- cache_key = f"{event_id}_{slug}"
152
- now = time.time()
153
- cached = _match_cache.get(cache_key)
154
- if cached and now - cached.get('_ts', 0) < 300:
155
- return JSONResponse(cached)
156
- try:
157
- result = _get_match_detail(event_id, slug)
158
- if result:
159
- result['_ts'] = now
160
- _match_cache[cache_key] = result
161
- return JSONResponse(result)
162
- except Exception as e:
163
- err = {"event_id": event_id, "found": False, "error": str(e), "_ts": now}
164
- _match_cache[cache_key] = err
165
- return JSONResponse(err)
166
- return JSONResponse({"event_id": event_id, "found": False})
167
-
168
- @app.get('/api/match/{event_id}/detail')
169
- def api_match_detail(event_id: int, url: str = Query(default=None)):
170
- slug = None
171
- if url:
172
- m = re.match(r'.+/tran-dau/\d+/(?:centre|preview)/(.+)', url)
173
- if m:
174
- slug = m.group(1)
175
- cache_key = f"{event_id}_{slug or ''}"
176
- now = time.time()
177
- cached = _match_cache.get(cache_key)
178
- if cached and now - cached.get('_ts', 0) < 300:
179
- return JSONResponse(cached)
180
- try:
181
- if not slug:
182
- try:
183
- home_r = req.get("https://bongda.com.vn/", headers={"User-Agent": "Mozilla/5.0"}, timeout=10)
184
- if home_r.status_code == 200:
185
- home_soup = BeautifulSoup(home_r.text, 'html.parser')
186
- for a in home_soup.select(f'a[href*="/tran-dau/{event_id}/"]'):
187
- href = a.get('href', '')
188
- m = re.match(r'/tran-dau/\d+/(?:centre|preview)/(.+)', href)
189
- if m:
190
- slug = m.group(1)
191
- cache_key = f"{event_id}_{slug}"
192
- break
193
- except: pass
194
- result = _get_match_detail(event_id, slug)
195
- if result:
196
- result['_ts'] = now
197
- _match_cache[cache_key] = result
198
- return JSONResponse(result)
199
- except Exception as e:
200
- err = {"event_id": event_id, "found": False, "error": str(e), "_ts": now}
201
- _match_cache[cache_key] = err
202
- return JSONResponse(err)
203
- return JSONResponse({"event_id": event_id, "found": False})
204
-
205
  _STOP=set('và của các những một được trong với cho tại sau trước khi không người việt nam hôm nay mới nhất nóng tin tức cập nhật theo từ đến là có thì này đã để'.split())
206
-
207
  def _has_kw(topic,title):
208
  tl=topic.lower();tt=(title or'').lower()
209
  if tl in tt:return True
210
  words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',tl) if len(w)>2 and w not in _STOP]
211
  if not words:return True
212
  return any(w in tt for w in words)
213
-
214
  def _s_vnexpress(topic,limit=8):
215
  items=[]
216
  try:
@@ -222,7 +47,6 @@ def _s_vnexpress(topic,limit=8):
222
  if _has_kw(topic,t):items.append({'title':t,'url':a['href'],'via':'VnExpress'})
223
  except:pass
224
  return items
225
-
226
  def _s_dantri(topic,limit=8):
227
  items=[]
228
  try:
@@ -235,20 +59,18 @@ def _s_dantri(topic,limit=8):
235
  if len(items)>=limit:break
236
  except:pass
237
  return items
238
-
239
  def _s_vietnamnet(topic,limit=6):
240
  items=[]
241
  try:
242
  r=req.get(f"https://vietnamnet.vn/tim-kiem?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
243
  for a in soup.select('h3 a[href], .vnn-title a')[:limit*2]:
244
- t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
245
  if t and len(t)>15 and _has_kw(topic,t):
246
  if not href.startswith('http'):href='https://vietnamnet.vn'+href
247
  items.append({'title':t,'url':href,'via':'VietNamNet'})
248
  if len(items)>=limit:break
249
  except:pass
250
  return items
251
-
252
  def _s_bongda(topic,limit=5):
253
  items=[]
254
  try:
@@ -261,7 +83,6 @@ def _s_bongda(topic,limit=5):
261
  if len(items)>=limit:break
262
  except:pass
263
  return items
264
-
265
  def _s_genk(topic,limit=5):
266
  items=[]
267
  try:
@@ -274,7 +95,6 @@ def _s_genk(topic,limit=5):
274
  if len(items)>=limit:break
275
  except:pass
276
  return items
277
-
278
  def _s_thanhnien(topic,limit=6):
279
  items=[]
280
  try:
@@ -287,7 +107,6 @@ def _s_thanhnien(topic,limit=6):
287
  if len(items)>=limit:break
288
  except:pass
289
  return items
290
-
291
  def _s_tuoitre(topic,limit=6):
292
  items=[]
293
  try:
@@ -300,7 +119,6 @@ def _s_tuoitre(topic,limit=6):
300
  if len(items)>=limit:break
301
  except:pass
302
  return items
303
-
304
  def _s_thethaovanhoa(topic,limit=5):
305
  items=[]
306
  try:
@@ -313,7 +131,6 @@ def _s_thethaovanhoa(topic,limit=5):
313
  if len(items)>=limit:break
314
  except:pass
315
  return items
316
-
317
  def _search_all(topic,limit=36):
318
  results={}
319
  with ThreadPoolExecutor(8) as ex:
@@ -321,140 +138,47 @@ def _search_all(topic,limit=36):
321
  for f in as_completed(futs,timeout=14):
322
  try:results[futs[f]]=f.result()
323
  except:results[futs[f]]=[]
324
- srcs=list(results.values());out=[];seen=set()
325
- for i in range(max((len(s) for s in srcs),default=0)):
326
  for s in srcs:
327
  if i<len(s) and s[i].get('url') and s[i]['url'] not in seen:seen.add(s[i]['url']);out.append(s[i])
328
  return out[:limit]
329
-
330
- for _path in ['/api/article', '/api/hot_topics', '/api/categories', '/api/storage_status', '/s']:
331
- app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)==_path and 'GET' in getattr(r,'methods',set()))]
332
-
333
- _article_cache = {}
334
- _article_cache_ttl = 1800
335
-
336
- _art_session = None
337
- _art_lock = threading.Lock()
338
- def _get_art_session():
339
- global _art_session
340
- if _art_session is None:
341
- with _art_lock:
342
- if _art_session is None:
343
- _art_session = req.Session()
344
- _art_session.headers.update({
345
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
346
- "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8",
347
- "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
348
- })
349
- return _art_session
350
-
351
- def _scrape_article_fast(url):
352
- from urllib.parse import urlparse
353
- domain = urlparse(url).netloc
354
- sess = _get_art_session()
355
- uas = [
356
- {"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"},
357
- {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"},
358
- ]
359
- for ua in uas:
360
- try:
361
- r = sess.get(url, headers=ua, timeout=6, allow_redirects=True)
362
- if not r or r.status_code != 200:
363
- continue
364
- r.encoding = 'utf-8'
365
- soup = BeautifulSoup(r.text, 'lxml')
366
- for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe','.ads','.ad','.banner-ads','.fb-comments','.fb-root','.social-share','.related-news','.tag','.breadcrumb']):
367
- tag.decompose()
368
- title = summary = og_img = ""
369
- ogt = soup.find('meta', property='og:title')
370
- if ogt: title = ogt.get('content', '')
371
- ogd = soup.find('meta', property='og:description') or soup.find('meta', attrs={'name': 'description'})
372
- if ogd: summary = ogd.get('content', '')[:500]
373
- ogi = soup.find('meta', property='og:image')
374
- if ogi:
375
- og_img = ogi.get('content', '')
376
- if og_img.startswith('//'): og_img = 'https:' + og_img
377
- h1 = soup.find('h1')
378
- if not title and h1: title = h1.get_text(strip=True)[:200]
379
- body = []
380
- selectors = [
381
- '.fck_detail', '.sidebar-1',
382
- '.singular-content', '.dt__content', '.article-content', '.content-detail', '#divNewsContent',
383
- '.content-detail', '.main-content-detail', '.box-content',
384
- '.knc-content', '.article-body', '.detail-body',
385
- '.article-detail', '.detail-content',
386
- 'article', 'main', '.cms-body', '.article__body', '.post-content',
387
- '.entry-content', '#content', '.article-text', '.story-body',
388
- ]
389
- for sel in selectors:
390
- el = soup.select_one(sel)
391
- if el and len(el.find_all('p')) >= 2:
392
- seen_imgs = set()
393
- for child in el.find_all(['p','h2','h3','figure','img'], recursive=True):
394
- if child.name == 'p':
395
- t = child.get_text(strip=True)
396
- if t and len(t) > 15:
397
- body.append({'type': 'p', 'text': t})
398
- elif child.name in ('h2','h3'):
399
- t = child.get_text(strip=True)
400
- if t:
401
- body.append({'type': 'heading', 'text': t})
402
- elif child.name in ('figure','img'):
403
- im = child if child.name == 'img' else child.find('img')
404
- if im:
405
- src = im.get('data-src') or im.get('src') or im.get('data-lazy') or ''
406
- if src and 'base64' not in src and src not in seen_imgs:
407
- seen_imgs.add(src)
408
- if src.startswith('//'): src = 'https:' + src
409
- body.append({'type': 'img', 'src': src})
410
- if child.name == 'figure':
411
- cap = child.find('figcaption')
412
- if cap:
413
- ct = cap.get_text(strip=True)
414
- if ct: body.append({'type': 'p', 'text': ct})
415
- if len(body) >= 2:
416
- return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img,
417
- 'body': body[:50], 'source': domain, 'url': url}
418
- if title and (summary or og_img):
419
- fallback = []
420
- if og_img: fallback.append({'type': 'img', 'src': og_img})
421
- if summary: fallback.append({'type': 'p', 'text': summary})
422
- if fallback:
423
- return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img,
424
- 'body': fallback, 'source': domain, 'url': url, 'fallback': True}
425
- if title:
426
- return {'title': _clean(title), 'summary': '', 'og_image': '',
427
- 'body': [{'type': 'p', 'text': 'Nội dung đang được tải...'}],
428
- 'source': domain, 'url': url, 'fallback': True}
429
- break
430
- except Exception:
431
- continue
432
- return None
433
-
434
- @app.get('/api/article')
435
- def api_article_v2(url: str = Query(...)):
436
- from urllib.parse import unquote
437
- safe_url = unquote(url)
438
  try:
439
- now = time.time()
440
- cached = _article_cache.get(safe_url)
441
- if cached and now - cached['t'] < _article_cache_ttl:
442
- resp = JSONResponse(cached['d'])
443
- resp.headers["Cache-Control"] = "public, max-age=1800"
444
- return resp
445
- data = _scrape_article_fast(safe_url)
446
- if data and data.get('body'):
447
- _article_cache[safe_url] = {'d': data, 't': now}
448
- resp = JSONResponse(data)
449
- resp.headers["Cache-Control"] = "public, max-age=1800"
450
- return resp
451
- result = {'error': 'Không đọc được', 'url': safe_url}
452
- resp = JSONResponse(result)
453
- resp.headers["Cache-Control"] = "public, max-age=60"
454
- return resp
455
- except Exception as e:
456
- return JSONResponse({'error': f'Server error: {str(e)[:100]}', 'url': safe_url}, status_code=200)
457
-
 
 
 
 
 
 
 
 
 
 
 
 
 
458
  _hot_cache={'t':0,'d':[]}
459
  def _get_hot_topics():
460
  now=time.time()
@@ -484,12 +208,8 @@ def _get_hot_topics():
484
  if len(topics)>=24:break
485
  if not any(kw.lower() in s for s in seen):topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw,'count':0})
486
  _hot_cache.update({'t':now,'d':topics[:24]});return topics[:24]
487
-
488
  @app.get('/api/hot_topics')
489
- def api_hot_topics():
490
- resp = JSONResponse({'topics':_get_hot_topics()})
491
- resp.headers["Cache-Control"] = "public, max-age=120"
492
- return resp
493
  @app.get('/')
494
  async def serve_index():
495
  p=os.path.join(STATIC_DIR,'index_v2.html')
@@ -505,114 +225,9 @@ def _cat():return JSONResponse([])
505
  def _st():return JSONResponse({'persistent':os.path.isdir('/data') and os.access('/data',os.W_OK)})
506
  @app.get('/s')
507
  async def _sh(url:str='',title:str='',img:str=''):return HTMLResponse(f'<!DOCTYPE html><html><head><meta property="og:title" content="{_clean(title)}"><meta property="og:image" content="{_clean(img)}"><meta http-equiv="refresh" content="0;url={_clean(url) or "/"}"></head><body></body></html>')
508
-
509
- from wc2026_scraper import scrape_summary,scrape_fixtures,scrape_standings,scrape_stats,scrape_wc_news,scrape_road_to_wc,get_wc2026_all,scrape_history,scrape_h2h,scrape_lineups,scrape_match_detail
510
-
511
- _xlb_cache = {}
512
- _xlb_lock = threading.Lock()
513
-
514
- def _xlb_scrape(path):
515
- url = f"https://xemlaibongda.top/{path}"
516
- r = req.get(url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}, timeout=15, allow_redirects=True)
517
- if r.status_code != 200:
518
- return []
519
- soup = BeautifulSoup(r.text, 'lxml')
520
- vids = []
521
- seen = set()
522
- for a in soup.select('a[href*="/video/"]'):
523
- href = a.get('href', '')
524
- if not href or href in seen:
525
- continue
526
- seen.add(href)
527
- if not href.startswith('http'):
528
- href = 'https://xemlaibongda.top' + href
529
- img = a.select_one('img')
530
- p = a.parent
531
- for _ in range(4):
532
- if img:
533
- break
534
- if p:
535
- img = p.select_one('img')
536
- p = p.parent
537
- img_src = ''
538
- if img:
539
- img_src = img.get('data-src','') or img.get('src','') or img.get('data-lazy','') or img.get('data-original','')
540
- if img_src.startswith('//'):
541
- img_src = 'https:' + img_src
542
- elif img_src.startswith('/'):
543
- img_src = 'https://xemlaibongda.top' + img_src
544
- title = ''
545
- for sel in ['.title', 'h3', 'h2', '.name', '.post-title', '.entry-title', '.video-title']:
546
- t = a.select_one(sel)
547
- if t:
548
- title = _clean(t.get_text())
549
- break
550
- if not title:
551
- title = _clean(a.get('title',''))
552
- if not title:
553
- img_alt = a.select_one('img')
554
- if img_alt:
555
- title = _clean(img_alt.get('alt',''))
556
- if not title:
557
- parent = a.parent
558
- if parent:
559
- pt = _clean(parent.get_text(' ',strip=True))
560
- if 5 < len(pt) < 120:
561
- title = pt
562
- if not title or len(title) < 3:
563
- continue
564
- vids.append({"link": href, "img": img_src, "title": title})
565
- if len(vids) >= 30:
566
- break
567
- return vids
568
-
569
- @app.get('/api/proxy/xlb')
570
- def proxy_xlb(path: str = Query(default="")):
571
- now = time.time()
572
- cache_key = f"xlb:{path}"
573
- with _xlb_lock:
574
- cached = _xlb_cache.get(cache_key)
575
- if cached and now - cached['t'] < 120:
576
- return JSONResponse(cached['d'])
577
- try:
578
- vids = _xlb_scrape(path)
579
- result = {"videos": vids, "count": len(vids)}
580
- with _xlb_lock:
581
- _xlb_cache[cache_key] = {'t': now, 'd': result}
582
- return JSONResponse(result)
583
- except Exception as e:
584
- return JSONResponse({"videos": [], "count": 0, "error": str(e)}, status_code=500)
585
-
586
- @app.get('/api/wc2026')
587
- def _w():return JSONResponse(get_wc2026_all())
588
- @app.get('/api/wc2026/fixtures')
589
- def _wf():return JSONResponse(scrape_fixtures())
590
- @app.get('/api/wc2026/standings')
591
- def _ws():return JSONResponse(scrape_standings())
592
- @app.get('/api/wc2026/stats')
593
- def _wst():return JSONResponse(scrape_stats())
594
- @app.get('/api/wc2026/history')
595
- def _whi():return JSONResponse(scrape_history())
596
- @app.get('/api/wc2026/news')
597
- def _wn():return JSONResponse(scrape_wc_news())
598
- @app.get('/api/wc2026/road')
599
- def _wr():return JSONResponse(scrape_road_to_wc())
600
- @app.get('/api/wc2026/h2h/{eid}')
601
- def _wh2(eid:int):return JSONResponse(scrape_h2h(eid))
602
- @app.get('/api/wc2026/lineups/{eid}')
603
- def _wl(eid:int):return JSONResponse(scrape_lineups(eid))
604
- @app.get('/api/wc2026/match/{eid}')
605
- def _wm(eid:int):return JSONResponse(scrape_match_detail(eid))
606
-
607
  DATA_DIR='/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)),'data')
608
- os.makedirs(DATA_DIR,exist_ok=True)
609
- IF=os.path.join(DATA_DIR,'interactions_v2.json')
610
- CF=os.path.join(DATA_DIR,'comments_v2.json')
611
- WALL_FILE=os.path.join(DATA_DIR,'wall_posts.json')
612
- WALL_VIDEO_DIR=os.path.join(DATA_DIR,'wall_videos')
613
- os.makedirs(WALL_VIDEO_DIR,exist_ok=True)
614
-
615
- _il=threading.Lock();_cl=threading.Lock();_wl_lock=threading.Lock()
616
  def _lj(p):
617
  try:
618
  if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8'))
@@ -621,21 +236,17 @@ def _lj(p):
621
  def _sj(p,d):
622
  try:open(p+'.tmp','w',encoding='utf-8').write(json.dumps(d,ensure_ascii=False));os.replace(p+'.tmp',p)
623
  except:pass
624
-
625
  @app.post('/api/v2/interact')
626
  async def _int(request:Request):
627
  b=await request.json();v=str(b.get('id','')).strip();t=str(b.get('type','')).strip()
628
  if not v or t not in('view','like'):return JSONResponse({'error':'x'},status_code=400)
629
  with _il:db=_lj(IF);db.setdefault(v,{'views':0,'likes':0,'comments':0});db[v][t+'s']+=1;_sj(IF,db);return JSONResponse(db[v])
630
-
631
  @app.get('/api/v2/interactions')
632
  def _gi(id:str=Query(...)):
633
  with _il:return JSONResponse(_lj(IF).get(id.strip(),{'views':0,'likes':0,'comments':0}))
634
-
635
  @app.get('/api/v2/comments')
636
  def _gc(id:str=Query(...)):
637
  with _cl:return JSONResponse({'comments':_lj(CF).get(id.strip(),[])})
638
-
639
  @app.post('/api/v2/comment')
640
  async def _pc(request:Request):
641
  b=await request.json();v=str(b.get('id','')).strip();tx=str(b.get('text','')).strip()[:500]
@@ -644,636 +255,27 @@ async def _pc(request:Request):
644
  with _cl:db=_lj(CF);db.setdefault(v,[]);db[v].append(c);db[v]=db[v][-200:];_sj(CF,db);cms=db[v]
645
  with _il:idb=_lj(IF);idb.setdefault(v,{'views':0,'likes':0,'comments':0});idb[v]['comments']=len(cms);_sj(IF,idb)
646
  return JSONResponse({'comments':cms})
647
-
648
- def _load_wall_posts():
649
- with _wl_lock:
650
- return _lj(WALL_FILE)
651
-
652
- def _save_wall_posts(posts):
653
- with _wl_lock:
654
- _sj(WALL_FILE, posts)
655
-
656
- @app.get('/api/wall')
657
- def api_wall():
658
- posts = _load_wall_posts()
659
- if not posts:
660
- return JSONResponse({"posts": []})
661
- return JSONResponse({"posts": posts})
662
-
663
- @app.post('/api/wall')
664
- async def api_wall_post(request: Request):
665
- content_type = request.headers.get('content-type', '')
666
- if 'multipart/form-data' in content_type:
667
- try:
668
- form = await request.form()
669
- except Exception as e:
670
- return JSONResponse({"error": f"Form parse error: {str(e)}"}, status_code=400)
671
- title = form.get('title', 'Video mới') or 'Video mới'
672
- text = form.get('text', '') or ''
673
- source = form.get('source', 'vtv_recorder') or 'vtv_recorder'
674
- video_file = form.get('video')
675
- post_id = str(uuid.uuid4())[:12]
676
- video_url = None
677
- if video_file and hasattr(video_file, 'filename') and video_file.filename:
678
- fname = video_file.filename.lower()
679
- if fname.endswith('.mp4'):
680
- ext = '.mp4'
681
- elif fname.endswith('.webm'):
682
- ext = '.webm'
683
- else:
684
- ext = '.webm'
685
- video_filename = f"wall_{post_id}{ext}"
686
- video_path = os.path.join(WALL_VIDEO_DIR, video_filename)
687
- try:
688
- content = await video_file.read()
689
- if not content:
690
- return JSONResponse({"error": "Empty video file"}, status_code=400)
691
- with open(video_path, 'wb') as f:
692
- f.write(content)
693
- file_size_mb = len(content) / 1024 / 1024
694
- if file_size_mb > 50:
695
- os.remove(video_path)
696
- return JSONResponse({"error": f"Video quá lớn ({file_size_mb:.1f}MB). Tối đa 50MB."}, status_code=400)
697
- video_url = f"/api/wall/video/{video_filename}"
698
- except Exception as e:
699
- return JSONResponse({"error": f"Lỗi lưu video: {str(e)}"}, status_code=500)
700
- post = {
701
- "id": post_id,
702
- "title": title[:200],
703
- "text": text[:2000],
704
- "source": source,
705
- "video": video_url,
706
- "img": None,
707
- "images": [],
708
- "created": int(time.time()),
709
- "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
710
- }
711
- posts = _load_wall_posts()
712
- if not isinstance(posts, list):
713
- posts = []
714
- posts.insert(0, post)
715
- posts = posts[:200]
716
- _save_wall_posts(posts)
717
- return JSONResponse({"post": post, "ok": True})
718
- try:
719
- body = await request.json()
720
- except:
721
- body = {}
722
- title = body.get('title', 'Bài mới') or 'Bài mới'
723
- text = body.get('text', '') or ''
724
- img = body.get('img', None)
725
- source = body.get('source', 'user') or 'user'
726
- post_id = str(uuid.uuid4())[:12]
727
- post = {
728
- "id": post_id,
729
- "title": title[:200],
730
- "text": text[:2000],
731
- "source": source,
732
- "video": None,
733
- "img": img,
734
- "images": [],
735
- "created": int(time.time()),
736
- "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
737
- }
738
- posts = _load_wall_posts()
739
- if not isinstance(posts, list):
740
- posts = []
741
- posts.insert(0, post)
742
- posts = posts[:200]
743
- _save_wall_posts(posts)
744
- return JSONResponse({"post": post, "ok": True})
745
-
746
- @app.get('/api/wall/video/{filename}')
747
- def api_wall_video(filename: str):
748
- if '..' in filename or '/' in filename:
749
- return Response(status_code=403)
750
- video_path = os.path.join(WALL_VIDEO_DIR, filename)
751
- if not os.path.exists(video_path):
752
- return Response(status_code=404)
753
- ext = os.path.splitext(filename)[1].lower()
754
- media_type = 'video/mp4' if ext == '.mp4' else 'video/webm'
755
- return FileResponse(video_path, media_type=media_type)
756
-
757
- @app.delete('/api/wall/{post_id}')
758
- def api_wall_delete(post_id: str):
759
- posts = _load_wall_posts()
760
- if not isinstance(posts, list):
761
- return JSONResponse({"error": "No posts"}, status_code=404)
762
- for i, p in enumerate(posts):
763
- if p.get('id') == post_id:
764
- if p.get('video'):
765
- video_name = p['video'].split('/')[-1]
766
- video_path = os.path.join(WALL_VIDEO_DIR, video_name)
767
- if os.path.exists(video_path):
768
- os.remove(video_path)
769
- posts.pop(i)
770
- _save_wall_posts(posts)
771
- return JSONResponse({"ok": True})
772
- return JSONResponse({"error": "Post not found"}, status_code=404)
773
-
774
- # ===== LANGUAGE & EMOTION DETECTION =====
775
- import random as _random2
776
- from urllib.parse import quote as _quote2
777
-
778
- _UA_RW = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept-Language': 'vi-VN,vi;q=0.9'}
779
-
780
- # Unique character markers for language detection
781
- _UNIQUE_CHARS = {
782
- 'vietnamese': set('đăâêôơưàảãạáằẳẵặắầẩẫậấèẻẽẹéềễểệếìỉĩịíòỏõọóồổỗộốờởỡợớùủũụúừửữựứỳỷỹỵý'),
783
- 'spanish': set('ñáéíóúü¿¡'),
784
- 'portuguese': set('ãõçáéíóúâêôà'),
785
- }
786
-
787
- _STOPWORDS = {
788
- 'english': {'the', 'is', 'at', 'which', 'on', 'a', 'an', 'and', 'or', 'but', 'in', 'with', 'to', 'for', 'of', 'not', 'no', 'can', 'had', 'have', 'has', 'was', 'were', 'are', 'be', 'been', 'this', 'that', 'it', 'he', 'she', 'they', 'his', 'her', 'my', 'your', 'our', 'we', 'you', 'i'},
789
- 'vietnamese': {'là', 'của', 'và', 'có', 'được', 'cho', 'không', 'với', 'này', 'đó', 'từ', 'trong', 'đã', 'sẽ', 'một', 'các', 'những', 'về', 'tại', 'người', 'năm', 'đến', 'ra', 'lại', 'như', 'khi', 'để', 'rất', 'cũng', 'mà', 'nếu', 'sau', 'trên', 'theo', 'vì', 'do', 'nên', 'thì', 'mình', 'tôi', 'bạn', 'anh', 'chị', 'em'},
790
- 'portuguese': {'de', 'um', 'que', 'e', 'do', 'da', 'em', 'para', 'com', 'não', 'uma', 'os', 'no', 'se', 'na', 'por', 'mais', 'as', 'dos', 'como', 'mas', 'ao', 'ele', 'das', 'tem', 'seu', 'sua', 'ou', 'quando', 'muito', 'nos', 'já', 'eu', 'também', 'só', 'pelo', 'pela', 'até', 'isso', 'ela', 'entre', 'depois', 'sem', 'mesmo', 'aos', 'são', 'está', 'ter', 'ser', 'foi', 'era', 'há', 'estão', 'você', 'nós', 'eles', 'elas'},
791
- 'spanish': {'de', 'que', 'el', 'en', 'y', 'a', 'los', 'del', 'se', 'las', 'por', 'un', 'para', 'con', 'no', 'una', 'su', 'al', 'es', 'lo', 'como', 'más', 'pero', 'sus', 'le', 'ya', 'o', 'fue', 'este', 'ha', 'si', 'porque', 'esta', 'son', 'entre', 'está', 'cuando', 'muy', 'sin', 'sobre', 'ser', 'también', 'me', 'hasta', 'hay', 'donde', 'han', 'quien', 'están', 'desde', 'todo', 'nos', 'durante', 'todos', 'uno', 'les', 'ni', 'contra', 'otros', 'fueron', 'ese', 'eso', 'ante', 'ellos', 'yo', 'tú', 'él', 'ella', 'nosotros', 'usted', 'ustedes'},
792
- }
793
-
794
- def detect_language(text):
795
- """Detect language from text content using stopword + character analysis."""
796
- if not text:
797
- return 'vietnamese'
798
- text_lower = text.lower()
799
- text_chars = set(text_lower)
800
-
801
- # Strong signal: Vietnamese unique characters
802
- vn_chars = len(text_chars & _UNIQUE_CHARS['vietnamese'])
803
- if vn_chars >= 2:
804
- return 'vietnamese'
805
-
806
- # Spanish unique chars (ñ, ¿, ¡)
807
- es_chars = len(text_chars & _UNIQUE_CHARS['spanish'])
808
- pt_chars = len(text_chars & _UNIQUE_CHARS['portuguese'])
809
-
810
- # Stopword scoring
811
- words = set(re.findall(r'\b\w+\b', text_lower))
812
- scores = {}
813
- for lang, stops in _STOPWORDS.items():
814
- scores[lang] = len(words & stops) / max(len(stops), 1)
815
-
816
- # Disambiguate Portuguese vs Spanish
817
- pt_markers = {'não', 'pelo', 'pela', 'isso', 'há', 'estão', 'num', 'numa', 'tenho', 'posso', 'você', 'nós', 'eles', 'elas', 'também', 'muito', 'já', 'só', 'até', 'entre', 'depois', 'sem', 'mesmo', 'aos', 'serão'}
818
- es_markers = {'pero', 'está', 'están', 'porque', 'también', 'hasta', 'donde', 'quien', 'fue', 'son', 'fueron', 'ese', 'eso', 'ante', 'ellos', 'ella', 'nosotros', 'usted', 'ustedes', 'tú', 'él', 'desde', 'todo', 'durante', 'todos', 'uno', 'les', 'ni', 'contra', 'otros', 'fueron'}
819
-
820
- pt_overlap = len(words & pt_markers)
821
- es_overlap = len(words & es_markers)
822
-
823
- if scores.get('portuguese', 0) > 0 and pt_overlap > es_overlap:
824
- return 'portuguese'
825
- if scores.get('spanish', 0) > 0 and es_overlap > pt_overlap:
826
- return 'spanish'
827
- if scores.get('english', 0) > 0.15:
828
- return 'english'
829
-
830
- best = max(scores, key=scores.get)
831
- return best if scores[best] > 0.05 else 'vietnamese'
832
-
833
- # Emotion keyword-based detection
834
- _EMOTION_KEYWORDS = {
835
- 'happy': {
836
- 'en': ['happy', 'joy', 'wonderful', 'great', 'amazing', 'fantastic', 'love', 'excellent', 'beautiful', 'glad', 'delighted', 'pleased', 'cheerful', 'celebrate', 'victory', 'win', 'success'],
837
- 'pt': ['feliz', 'alegria', 'maravilhoso', 'ótimo', 'incrível', 'fantástico', 'amor', 'excelente', 'lindo', 'contente', 'encantado', 'vitória', 'sucesso'],
838
- 'es': ['feliz', 'alegria', 'maravilloso', 'genial', 'increíble', 'fantástico', 'amor', 'excelente', 'hermoso', 'contento', 'encantado', 'victoria', 'éxito'],
839
- 'vi': ['vui', 'hạnh phúc', 'tuyệt vời', 'tuyệt', 'ý nghĩa', 'đẹp', 'thích', 'yêu', 'vui vẻ', 'hân hoan', 'phấn khích', 'chiến thắng', 'thành công'],
840
- },
841
- 'sad': {
842
- 'en': ['sad', 'unhappy', 'terrible', 'awful', 'horrible', 'miserable', 'depressed', 'grief', 'sorrow', 'tragic', 'unfortunate', 'painful', 'death', 'die', 'kill'],
843
- 'pt': ['triste', 'infeliz', 'terrível', 'horrível', 'miserável', 'deprimido', 'dor', 'trágico', 'infelizmente', 'penoso', 'morte', 'morrer'],
844
- 'es': ['triste', 'infeliz', 'terrible', 'horrible', 'miserable', 'deprimido', 'dolor', 'trágico', 'desafortunado', 'penoso', 'muerte', 'morir'],
845
- 'vi': ['buồn', 'không vui', 'tồi tệ', 'kinh khủng', 'đau khổ', 'đau buồn', 'bi thương', 'khốn nạn', 'đau đớn', 'thảm họa', 'chết', 'mất'],
846
- },
847
- 'excited': {
848
- 'en': ['excited', 'thrilling', 'amazing', 'wow', 'incredible', 'unbelievable', 'awesome', 'exhilarating', 'electrifying', 'breathtaking', 'breakthrough', 'record'],
849
- 'pt': ['animado', 'emocionante', 'incrível', 'impressionante', 'sensacional', 'eletrizante', 'empolgante', 'recorde'],
850
- 'es': ['emocionante', 'increíble', 'impresionante', 'sensacional', 'electrizante', 'emocionado', 'entusiasmado', 'récord'],
851
- 'vi': ['hào hứng', 'phấn khích', 'thú vị', 'tuyệt cú mèo', 'đỉnh cao', 'ngoạn mục', 'sục sôi', 'kỷ lục', 'đột phá'],
852
- },
853
- 'humorous': {
854
- 'en': ['funny', 'hilarious', 'joke', 'laugh', 'comedy', 'humor', 'amusing', 'witty', 'sarcastic', 'ironic', 'ridiculous', 'absurd', 'lol', 'haha'],
855
- 'pt': ['engraçado', 'hilário', 'piada', 'rir', 'comédia', 'humor', 'divertido', 'irônico', 'ridículo', 'absurdo', 'kkk'],
856
- 'es': ['gracioso', 'hilarante', 'broma', 'risa', 'comedia', 'humor', 'divertido', 'irónico', 'ridículo', 'absurdo', 'jaja'],
857
- 'vi': ['hài hước', 'buồn cười', 'đùa', 'cười', 'hài', 'vui nhộn', 'hóm hỉnh', 'mỉa mai', 'lố bịch', 'vô lý', 'haha'],
858
- },
859
- 'serious': {
860
- 'en': ['serious', 'critical', 'important', 'urgent', 'severe', 'grave', 'significant', 'crucial', 'vital', 'essential', 'alarming', 'concerning', 'crisis', 'war', 'conflict'],
861
- 'pt': ['sério', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'essencial', 'preocupante', 'crise', 'guerra', 'conflito'],
862
- 'es': ['serio', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'esencial', 'preocupante', 'crisis', 'guerra', 'conflicto'],
863
- 'vi': ['nghiêm trọng', 'quan trọng', 'khẩn cấp', 'nghiêm túc', 'đáng kể', 'thiết yếu', 'cần thiết', 'báo động', 'lo ngại', 'khủng hoảng', 'chiến tranh', 'xung đột'],
864
- },
865
- }
866
-
867
- def detect_emotion(text, language='vietnamese'):
868
- """Detect emotion from text using keyword matching."""
869
- if not text:
870
- return 'neutral'
871
- text_lower = text.lower()
872
-
873
- scores = {}
874
- for emotion, lang_keywords in _EMOTION_KEYWORDS.items():
875
- keywords = lang_keywords.get(language, lang_keywords.get('en', []))
876
- score = sum(1 for kw in keywords if kw in text_lower)
877
- scores[emotion] = score
878
-
879
- if max(scores.values()) == 0:
880
- return 'neutral'
881
-
882
- return max(scores, key=scores.get)
883
-
884
- def detect_language_and_emotion(title, text):
885
- """Detect both language and emotion from article content."""
886
- combined = f"{title} {text}"
887
- lang = detect_language(combined)
888
- emotion = detect_emotion(combined, lang)
889
- return lang, emotion
890
-
891
- # Voice selection based on language and emotion (using MultilingualNeural voices)
892
- VOICE_BY_LANG_EMOTION = {
893
- 'vietnamese': {
894
- 'happy': ('vi-VN-HoaiMyNeural', 'vui'),
895
- 'sad': ('vi-VN-NamMinhNeural', 'buồn'),
896
- 'excited': ('vi-VN-HoaiMyNeural', 'hào hứng'),
897
- 'humorous': ('vi-VN-HoaiMyNeural', 'vui'),
898
- 'serious': ('vi-VN-NamMinhNeural', 'nghiêm túc'),
899
- 'neutral': ('vi-VN-HoaiMyNeural', 'trung_tinh'),
900
- },
901
- 'portuguese': {
902
- 'happy': ('pt-BR-ThalitaMultilingualNeural', 'feliz'),
903
- 'sad': ('pt-BR-ThalitaMultilingualNeural', 'triste'),
904
- 'excited': ('pt-BR-ThalitaMultilingualNeural', 'animado'),
905
- 'humorous': ('pt-BR-ThalitaMultilingualNeural', 'engraçado'),
906
- 'serious': ('pt-BR-ThalitaMultilingualNeural', 'sério'),
907
- 'neutral': ('pt-BR-ThalitaMultilingualNeural', 'neutro'),
908
- },
909
- 'english': {
910
- 'happy': ('en-US-AndrewMultilingualNeural', 'happy'),
911
- 'sad': ('en-AU-WilliamMultilingualNeural', 'sad'),
912
- 'excited': ('en-US-AndrewMultilingualNeural', 'excited'),
913
- 'humorous': ('en-US-AndrewMultilingualNeural', 'funny'),
914
- 'serious': ('en-AU-WilliamMultilingualNeural', 'serious'),
915
- 'neutral': ('en-US-AndrewMultilingualNeural', 'neutral'),
916
- },
917
- 'french': {
918
- 'happy': ('fr-FR-VivienneMultilingualNeural', 'heureux'),
919
- 'sad': ('fr-FR-RemyMultilingualNeural', 'triste'),
920
- 'excited': ('fr-FR-VivienneMultilingualNeural', 'excité'),
921
- 'humorous': ('fr-FR-VivienneMultilingualNeural', 'drôle'),
922
- 'serious': ('fr-FR-RemyMultilingualNeural', 'sérieux'),
923
- 'neutral': ('fr-FR-VivienneMultilingualNeural', 'neutre'),
924
- },
925
- 'german': {
926
- 'happy': ('de-DE-SeraphinaMultilingualNeural', 'glücklich'),
927
- 'sad': ('de-DE-FlorianMultilingualNeural', 'traurig'),
928
- 'excited': ('de-DE-SeraphinaMultilingualNeural', 'aufgeregt'),
929
- 'humorous': ('de-DE-SeraphinaMultilingualNeural', 'lustig'),
930
- 'serious': ('de-DE-FlorianMultilingualNeural', 'ernst'),
931
- 'neutral': ('de-DE-SeraphinaMultilingualNeural', 'neutral'),
932
- },
933
- 'korean': {
934
- 'happy': ('ko-KR-HyunsuMultilingualNeural', '행복'),
935
- 'sad': ('ko-KR-HyunsuMultilingualNeural', '슬픔'),
936
- 'excited': ('ko-KR-HyunsuMultilingualNeural', '흥분'),
937
- 'humorous': ('ko-KR-HyunsuMultilingualNeural', '유쾌'),
938
- 'serious': ('ko-KR-HyunsuMultilingualNeural', '진지'),
939
- 'neutral': ('ko-KR-HyunsuMultilingualNeural', '중립'),
940
- },
941
- 'italian': {
942
- 'happy': ('it-IT-GiuseppeMultilingualNeural', 'felice'),
943
- 'sad': ('it-IT-GiuseppeMultilingualNeural', 'triste'),
944
- 'excited': ('it-IT-GiuseppeMultilingualNeural', 'emozionato'),
945
- 'humorous': ('it-IT-GiuseppeMultilingualNeural', 'divertente'),
946
- 'serious': ('it-IT-GiuseppeMultilingualNeural', 'serio'),
947
- 'neutral': ('it-IT-GiuseppeMultilingualNeural', 'neutro'),
948
- },
949
- }
950
-
951
- # All valid voice IDs (new MultilingualNeural format)
952
- VALID_VOICES = {
953
- 'vi-VN-HoaiMyNeural', 'vi-VN-NamMinhNeural',
954
- 'en-US-AndrewMultilingualNeural', 'en-AU-WilliamMultilingualNeural',
955
- 'pt-BR-ThalitaMultilingualNeural',
956
- 'fr-FR-VivienneMultilingualNeural', 'fr-FR-RemyMultilingualNeural',
957
- 'de-DE-SeraphinaMultilingualNeural', 'de-DE-FlorianMultilingualNeural',
958
- 'ko-KR-HyunsuMultilingualNeural',
959
- 'it-IT-GiuseppeMultilingualNeural',
960
- }
961
-
962
- def get_voice_for_content(title, text, preferred_voice=None):
963
- """Get appropriate voice based on content language and emotion."""
964
- # Accept the new MultilingualNeural voices directly
965
- if preferred_voice and preferred_voice in VALID_VOICES:
966
- return preferred_voice
967
-
968
- # Also accept old shorthand voice IDs and map them to new format
969
- old_voice_map = {
970
- 'hoaimy': 'vi-VN-HoaiMyNeural',
971
- 'namminh': 'vi-VN-NamMinhNeural',
972
- 'andrew': 'en-US-AndrewMultilingualNeural',
973
- 'jenny': 'en-US-AndrewMultilingualNeural',
974
- 'thalita': 'pt-BR-ThalitaMultilingualNeural',
975
- 'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
976
- 'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
977
- 'ela': 'en-US-AndrewMultilingualNeural',
978
- 'es_carlos': 'en-US-AndrewMultilingualNeural',
979
- 'denise': 'fr-FR-VivienneMultilingualNeural',
980
- 'katja': 'de-DE-SeraphinaMultilingualNeural',
981
- 'nanami': 'en-US-AndrewMultilingualNeural',
982
- 'sunhee': 'ko-KR-HyunsuMultilingualNeural',
983
- 'xiaochen': 'en-US-AndrewMultilingualNeural',
984
- }
985
- if preferred_voice and preferred_voice in old_voice_map:
986
- return old_voice_map[preferred_voice]
987
-
988
- lang, emotion = detect_language_and_emotion(title, text)
989
- lang_map = VOICE_BY_LANG_EMOTION.get(lang, VOICE_BY_LANG_EMOTION['vietnamese'])
990
- voice, _ = lang_map.get(emotion, lang_map['neutral'])
991
- return voice
992
-
993
-
994
- def _is_relevant_image(img_url, title, text):
995
- """Check if an image is relevant to the article content."""
996
- if not img_url:
997
- return False
998
- skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif',
999
- 'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite',
1000
- 'advertisement', 'ad-banner', 'sponsored', 'banner-ads']
1001
- img_lower = img_url.lower()
1002
- for p in skip_patterns:
1003
- if p in img_lower:
1004
- return False
1005
- if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']):
1006
- return False
1007
- return True
1008
-
1009
-
1010
- def _filter_relevant_images(images, title, text, max_images=8):
1011
- """Filter and rank images by relevance to article content."""
1012
- if not images:
1013
- return []
1014
- seen = set()
1015
- relevant = []
1016
- for img in images:
1017
- if img in seen:
1018
- continue
1019
- seen.add(img)
1020
- if _is_relevant_image(img, title, text):
1021
- relevant.append(img)
1022
- return relevant[:max_images]
1023
-
1024
-
1025
- def _scrape_article_for_rewrite(url):
1026
- """Scrape article: extract title, paragraphs, RELEVANT images, OG image."""
1027
- try:
1028
- r = req.get(url, headers=_UA_RW, timeout=15, allow_redirects=True)
1029
- r.encoding = 'utf-8'
1030
- soup = BeautifulSoup(r.text, 'lxml')
1031
- for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
1032
- tag.decompose()
1033
- h1 = soup.find('h1')
1034
- ogt = soup.find('meta', property='og:title')
1035
- title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '')
1036
- ogi = soup.find('meta', property='og:image')
1037
- og_img = ogi.get('content', '') if ogi else ''
1038
- if og_img and og_img.startswith('//'):
1039
- og_img = 'https:' + og_img
1040
- block = None
1041
- for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
1042
- el = soup.select_one(sel)
1043
- if el and len(el.find_all('p')) >= 2:
1044
- block = el
1045
- break
1046
- if not block:
1047
- block = soup.body or soup
1048
- paragraphs = []
1049
- all_images = []
1050
- seen_imgs = set()
1051
- if og_img and og_img not in seen_imgs:
1052
- all_images.append(og_img)
1053
- seen_imgs.add(og_img)
1054
- for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
1055
- if el.name == 'p':
1056
- t = _clean(el.get_text(strip=True))
1057
- if t and len(t) > 40:
1058
- paragraphs.append(t)
1059
- elif el.name in ('figure', 'img'):
1060
- im = el if el.name == 'img' else el.find('img')
1061
- if im:
1062
- src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
1063
- if src and 'base64' not in src:
1064
- if src.startswith('//'):
1065
- src = 'https:' + src
1066
- if src not in seen_imgs:
1067
- all_images.append(src)
1068
- seen_imgs.add(src)
1069
- # Filter to relevant images only
1070
- relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5]))
1071
- return {'title': _clean(title), 'paragraphs': paragraphs, 'images': relevant_images, 'og_img': og_img}
1072
- except Exception:
1073
- return None
1074
-
1075
-
1076
- def _extract_key_points_rw(paragraphs, max_points=5):
1077
- """Extract key points from paragraphs - extracts ALL sentences, not just first one.
1078
-
1079
- Fixes: Original regex `^(.+?[.!?])\s` only captured first sentence per paragraph.
1080
- Now splits on all sentence boundaries and takes valid sentences until max_points.
1081
- """
1082
- points = []
1083
-
1084
- for p in paragraphs:
1085
- if len(points) >= max_points:
1086
- break
1087
-
1088
- p = _clean(p)
1089
- if not p:
1090
- continue
1091
-
1092
- # Split paragraph into sentences using Vietnamese + English punctuation
1093
- sentences = re.split(r'(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])', p)
1094
- sentences = [s.strip() for s in sentences if s.strip()]
1095
-
1096
- for sentence in sentences:
1097
- if len(points) >= max_points:
1098
- break
1099
-
1100
- # Clean sentence - remove extra whitespace
1101
- sentence = _clean(sentence)
1102
-
1103
- if len(sentence) < 30:
1104
- continue
1105
-
1106
- # Check for duplicates
1107
- if any(sentence[:60] in existing for existing in points):
1108
- continue
1109
-
1110
- # Ensure sentence ends with punctuation
1111
- if not sentence.endswith(('.', '!', '?')):
1112
- sentence = sentence + '.'
1113
-
1114
- points.append(sentence)
1115
-
1116
- # If no valid sentences found, take chunks from raw text
1117
- if not points:
1118
- raw = '\n'.join(paragraphs)
1119
- for i in range(0, min(len(raw), max_points * 300), 280):
1120
- chunk = _clean(raw[i:i+280])
1121
- if len(chunk) >= 30 and chunk not in points:
1122
- points.append(chunk + ('.' if not chunk.endswith('.') else ''))
1123
- if len(points) >= max_points:
1124
- break
1125
-
1126
- return points
1127
-
1128
-
1129
- @app.post("/api/rewrite_slide")
1130
- async def api_rewrite_slide(request: Request):
1131
- """Fast rewrite as SLIDES - no AI needed, instant response."""
1132
- body = await request.json()
1133
- url = _clean(body.get("url", ""))
1134
- context = body.get("context", "")
1135
- preferred_voice = body.get("voice", "") # Accept custom voice selection
1136
- if not url and not context:
1137
- return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
1138
- data = None
1139
- if url and url.startswith("http"):
1140
- data = _scrape_article_for_rewrite(url)
1141
- if not data and context:
1142
- paragraphs = [_clean(p) for p in context.split('\n') if len(_clean(p)) > 40]
1143
- data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
1144
- if not data or not data.get('paragraphs'):
1145
- return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
1146
- points = _extract_key_points_rw(data['paragraphs'], max_points=12)
1147
- if not points:
1148
- return JSONResponse({"error": "Không tìm được ý chính"}, status_code=422)
1149
- images = data.get('images', [])
1150
- slides = []
1151
- for i, point in enumerate(points):
1152
- img = images[i] if i < len(images) else (images[-1] if images else '')
1153
- if img and 'cdnphoto.dantri' in img:
1154
- img = '/api/proxy/img?url=' + _quote2(img, safe='')
1155
- slides.append({'text': point, 'image': img, 'index': i + 1})
1156
- summary_text = '\n\n'.join([f"• {s['text']}" for s in slides])
1157
-
1158
- # Auto-detect language and emotion
1159
- lang, emotion = detect_language_and_emotion(data['title'], summary_text)
1160
- # Use preferred voice if provided, otherwise auto-detect
1161
- voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], summary_text)
1162
-
1163
- post = {
1164
- "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
1165
- "title": data['title'],
1166
- "text": summary_text,
1167
- "img": images[0] if images else '',
1168
- "url": url,
1169
- "kind": "slide_summary",
1170
- "slides": slides,
1171
- "images": images[:10],
1172
- "video": "",
1173
- "voice": voice,
1174
- "emotion": emotion,
1175
- "language": lang,
1176
- "ts": int(time.time())
1177
- }
1178
- posts = _load_wall_posts()
1179
- posts.insert(0, post)
1180
- _save_wall_posts(posts)
1181
- return JSONResponse({"post": post, "slides": slides})
1182
-
1183
-
1184
- @app.post("/api/rewrite_share")
1185
- async def api_rewrite_share(request: Request):
1186
- """Rewrite article and post to Tường AI with SLIDES + AI text."""
1187
- body = await request.json()
1188
- url = _clean(body.get("url", ""))
1189
- ctx = _clean(body.get("context", ""))
1190
- preferred_voice = body.get("voice", "") # Accept custom voice selection
1191
- if not url and not ctx:
1192
- return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
1193
- data = None
1194
- if url and url.startswith("http"):
1195
- data = _scrape_article_for_rewrite(url)
1196
- if not data and ctx:
1197
- paragraphs = [_clean(p) for p in ctx.split('\n') if len(_clean(p)) > 40]
1198
- data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
1199
- if not data or not data.get('paragraphs'):
1200
- return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
1201
- raw_text = '\n'.join(data['paragraphs'])
1202
- if len(raw_text) < 50:
1203
- raw_text = ctx[:14000]
1204
- if len(raw_text) < 50:
1205
- return JSONResponse({"error": "Bài viết quá ngắn"}, status_code=422)
1206
- domain = ''
1207
- try:
1208
- from urllib.parse import urlparse
1209
- domain = urlparse(url).netloc.replace('www.', '')
1210
- except:
1211
- pass
1212
-
1213
- # Generate AI summary text
1214
- ai_text = None
1215
- try:
1216
- import ai_ext
1217
- if hasattr(ai_ext, 'qwen_generate'):
1218
- prompt = f'Tóm tắt đăng Tường AI:\nTiêu đề: {data["title"]}\n{raw_text[:14000]}\n\n4-6 ý chính. Cuối ghi nguồn.'
1219
- ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000)
1220
- except Exception:
1221
- pass
1222
- if not ai_text or len(ai_text) < 80:
1223
- key_pts = _extract_key_points_rw(data['paragraphs'], max_points=12)
1224
- if key_pts:
1225
- ai_text = '\n\n'.join([f"• {p}" for p in key_pts])
1226
- else:
1227
- ai_text = f"Tóm tắt: {data['title']}\n\n{raw_text[:1200]}\n\nNguồn: {domain}"
1228
-
1229
- # Build slides from key points (FIX: include slides in rewrite_share too!)
1230
- points = _extract_key_points_rw(data['paragraphs'], max_points=12)
1231
- images = data.get('images', [])
1232
- slides = []
1233
- for i, point in enumerate(points):
1234
- img = images[i] if i < len(images) else (images[-1] if images else '')
1235
- if img and 'cdnphoto.dantri' in img:
1236
- img = '/api/proxy/img?url=' + _quote2(img, safe='')
1237
- slides.append({'text': point, 'image': img, 'index': i + 1})
1238
-
1239
- # Auto-detect language and emotion
1240
- lang, emotion = detect_language_and_emotion(data['title'], ai_text)
1241
- # Use preferred voice if provided, otherwise auto-detect
1242
- voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], ai_text)
1243
-
1244
- post = {
1245
- "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
1246
- "title": data['title'],
1247
- "text": ai_text,
1248
- "img": images[0] if images else '',
1249
- "url": url,
1250
- "kind": "rewrite",
1251
- "slides": slides,
1252
- "images": images[:10],
1253
- "video": "",
1254
- "voice": voice,
1255
- "emotion": emotion,
1256
- "language": lang,
1257
- "ts": int(time.time())
1258
- }
1259
- posts = _load_wall_posts()
1260
- posts.insert(0, post)
1261
- _save_wall_posts(posts)
1262
- return JSONResponse({"post": post, "slides": slides})
1263
-
1264
-
1265
- @app.post("/api/url_wall")
1266
- async def api_url_wall(request: Request):
1267
- """Submit URL to add to Tường AI."""
1268
- body = await request.json()
1269
- url = _clean(body.get("url", ""))
1270
- if not url or not url.startswith('http'):
1271
- return JSONResponse({"error": "URL không hợp lệ"}, status_code=400)
1272
- # Reuse rewrite_share logic
1273
- req._body = json.dumps({"url": url}).encode()
1274
- return await api_rewrite_share(request)
1275
-
1276
-
1277
  def _bg():
1278
  time.sleep(15)
1279
  while True:
@@ -1281,5 +283,4 @@ def _bg():
1281
  except:pass
1282
  time.sleep(90)
1283
  threading.Thread(target=_bg,daemon=True).start()
1284
-
1285
  app.mount('/static',StaticFiles(directory=STATIC_DIR),name='vnews_static')
 
1
+ """VNEWS v2 Entry Point - with AI endpoints + friendly highlights"""
2
  import sys, os
3
  from main import app, HEADERS, BONGDA_HEADERS, fetch_bongda_api, HL_LEAGUES
4
 
5
+ # Import AI extension to get /api/rewrite_share, /api/topic_post, /api/ai_wall endpoints
6
  try:
7
  import ai_ext
8
  except Exception as e:
9
  print(f"[WARN] ai_ext import failed: {e}")
10
 
11
+ from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
 
 
 
 
 
12
  from fastapi.staticfiles import StaticFiles
13
  from starlette.routing import Mount
14
+ from fastapi import Query, Request
15
  import requests as req
16
+ from urllib.parse import quote
17
  from bs4 import BeautifulSoup
18
+ import re, html as html_lib, json, threading, time
19
  from concurrent.futures import ThreadPoolExecutor, as_completed
 
20
 
21
  HL_LEAGUES['friendly'] = {"path": "giai-khac/friendly", "name": "Giao hữu", "emoji": "🤝"}
22
 
23
  STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
24
+ # Only remove '/' route, keep all other routes (including AI endpoints)
25
  app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)=='/' and hasattr(r,'methods') and 'GET' in getattr(r,'methods',set()))]
26
+ # Remove only Mount objects (old static mounts), NOT API routes
27
  app.routes[:]=[r for r in app.routes if not isinstance(r, Mount)]
28
+ # Clean mounts from router too
29
  app.router.routes=[r for r in app.router.routes if not isinstance(r, Mount)]
30
 
31
+ def _clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or""))).strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  _STOP=set('và của các những một được trong với cho tại sau trước khi không người việt nam hôm nay mới nhất nóng tin tức cập nhật theo từ đến là có thì này đã để'.split())
 
33
  def _has_kw(topic,title):
34
  tl=topic.lower();tt=(title or'').lower()
35
  if tl in tt:return True
36
  words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',tl) if len(w)>2 and w not in _STOP]
37
  if not words:return True
38
  return any(w in tt for w in words)
 
39
  def _s_vnexpress(topic,limit=8):
40
  items=[]
41
  try:
 
47
  if _has_kw(topic,t):items.append({'title':t,'url':a['href'],'via':'VnExpress'})
48
  except:pass
49
  return items
 
50
  def _s_dantri(topic,limit=8):
51
  items=[]
52
  try:
 
59
  if len(items)>=limit:break
60
  except:pass
61
  return items
 
62
  def _s_vietnamnet(topic,limit=6):
63
  items=[]
64
  try:
65
  r=req.get(f"https://vietnamnet.vn/tim-kiem?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
66
  for a in soup.select('h3 a[href], .vnn-title a')[:limit*2]:
67
+ t=_clean(a.get_text(strip=True));href=a.get('href','')
68
  if t and len(t)>15 and _has_kw(topic,t):
69
  if not href.startswith('http'):href='https://vietnamnet.vn'+href
70
  items.append({'title':t,'url':href,'via':'VietNamNet'})
71
  if len(items)>=limit:break
72
  except:pass
73
  return items
 
74
  def _s_bongda(topic,limit=5):
75
  items=[]
76
  try:
 
83
  if len(items)>=limit:break
84
  except:pass
85
  return items
 
86
  def _s_genk(topic,limit=5):
87
  items=[]
88
  try:
 
95
  if len(items)>=limit:break
96
  except:pass
97
  return items
 
98
  def _s_thanhnien(topic,limit=6):
99
  items=[]
100
  try:
 
107
  if len(items)>=limit:break
108
  except:pass
109
  return items
 
110
  def _s_tuoitre(topic,limit=6):
111
  items=[]
112
  try:
 
119
  if len(items)>=limit:break
120
  except:pass
121
  return items
 
122
  def _s_thethaovanhoa(topic,limit=5):
123
  items=[]
124
  try:
 
131
  if len(items)>=limit:break
132
  except:pass
133
  return items
 
134
  def _search_all(topic,limit=36):
135
  results={}
136
  with ThreadPoolExecutor(8) as ex:
 
138
  for f in as_completed(futs,timeout=14):
139
  try:results[futs[f]]=f.result()
140
  except:results[futs[f]]=[]
141
+ srcs=list(results.values());out=[];seen=set();mx=max((len(s) for s in srcs),default=0)
142
+ for i in range(mx):
143
  for s in srcs:
144
  if i<len(s) and s[i].get('url') and s[i]['url'] not in seen:seen.add(s[i]['url']);out.append(s[i])
145
  return out[:limit]
146
+ # Override article endpoint
147
+ app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)=='/api/article' and 'GET' in getattr(r,'methods',set()))]
148
+ def _scrape_generic(url):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  try:
150
+ r=req.get(url,headers={'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36','Accept-Language':'vi-VN,vi;q=0.9'},timeout=15,allow_redirects=True);r.encoding='utf-8';soup=BeautifulSoup(r.text,'lxml')
151
+ for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript']):tag.decompose()
152
+ h1=soup.find('h1');ogt=soup.find('meta',property='og:title');title=(h1.get_text(strip=True) if h1 else '')or(ogt.get('content','') if ogt else '')
153
+ ogd=soup.find('meta',property='og:description');summary=ogd.get('content','') if ogd else ''
154
+ ogi=soup.find('meta',property='og:image');og_img=ogi.get('content','') if ogi else ''
155
+ if og_img and og_img.startswith('//'):og_img='https:'+og_img
156
+ block=None
157
+ for sel in['article','.singular-content','.detail-content','.fck_detail','.content-detail','.knc-content','main','.cms-body','.article__body']:
158
+ el=soup.select_one(sel)
159
+ if el and len(el.find_all('p'))>=2:block=el;break
160
+ if not block:block=soup.body or soup
161
+ body=[]
162
+ for el in block.find_all(['p','h2','h3','figure','img'],recursive=True):
163
+ if el.name=='p':t=el.get_text(strip=True);(body.append({'type':'p','text':t}) if t and len(t)>30 else None)
164
+ elif el.name in('h2','h3'):t=el.get_text(strip=True);(body.append({'type':'heading','text':t}) if t else None)
165
+ elif el.name in('figure','img'):
166
+ im=el if el.name=='img' else el.find('img')
167
+ if im:src=im.get('data-src') or im.get('src') or'';(body.append({'type':'img','src':'https:'+src if src.startswith('//') else src}) if src and'base64' not in src else None)
168
+ if not body and summary:body=[{'type':'p','text':summary}]
169
+ return{'title':_clean(title),'summary':_clean(summary),'og_image':og_img,'body':body[:50],'source':'generic','url':url}
170
+ except:return None
171
+ @app.get('/api/article')
172
+ def api_article_v2(url:str=Query(...)):
173
+ from main import scrape_vne_article,scrape_bbc_article,scrape_dantri_article,scrape_genk_article,scrape_ttvh_article
174
+ if 'vnexpress.net' in url:data=scrape_vne_article(url)
175
+ elif 'bbc.com' in url:data=scrape_bbc_article(url)
176
+ elif 'dantri.com.vn' in url:data=scrape_dantri_article(url)
177
+ elif 'genk.vn' in url:data=scrape_genk_article(url)
178
+ elif 'thethaovanhoa.vn' in url:data=scrape_ttvh_article(url)
179
+ else:data=_scrape_generic(url)
180
+ if data and data.get('body'):return JSONResponse(data)
181
+ data=_scrape_generic(url);return JSONResponse(data if data else{'error':'Không đọc được','url':url})
182
  _hot_cache={'t':0,'d':[]}
183
  def _get_hot_topics():
184
  now=time.time()
 
208
  if len(topics)>=24:break
209
  if not any(kw.lower() in s for s in seen):topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw,'count':0})
210
  _hot_cache.update({'t':now,'d':topics[:24]});return topics[:24]
 
211
  @app.get('/api/hot_topics')
212
+ def api_hot_topics():return JSONResponse({'topics':_get_hot_topics()})
 
 
 
213
  @app.get('/')
214
  async def serve_index():
215
  p=os.path.join(STATIC_DIR,'index_v2.html')
 
225
  def _st():return JSONResponse({'persistent':os.path.isdir('/data') and os.access('/data',os.W_OK)})
226
  @app.get('/s')
227
  async def _sh(url:str='',title:str='',img:str=''):return HTMLResponse(f'<!DOCTYPE html><html><head><meta property="og:title" content="{_clean(title)}"><meta property="og:image" content="{_clean(img)}"><meta http-equiv="refresh" content="0;url={_clean(url) or "/"}"></head><body></body></html>')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  DATA_DIR='/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)),'data')
229
+ os.makedirs(DATA_DIR,exist_ok=True);IF=os.path.join(DATA_DIR,'interactions_v2.json');CF=os.path.join(DATA_DIR,'comments_v2.json')
230
+ _il=threading.Lock();_cl=threading.Lock()
 
 
 
 
 
 
231
  def _lj(p):
232
  try:
233
  if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8'))
 
236
  def _sj(p,d):
237
  try:open(p+'.tmp','w',encoding='utf-8').write(json.dumps(d,ensure_ascii=False));os.replace(p+'.tmp',p)
238
  except:pass
 
239
  @app.post('/api/v2/interact')
240
  async def _int(request:Request):
241
  b=await request.json();v=str(b.get('id','')).strip();t=str(b.get('type','')).strip()
242
  if not v or t not in('view','like'):return JSONResponse({'error':'x'},status_code=400)
243
  with _il:db=_lj(IF);db.setdefault(v,{'views':0,'likes':0,'comments':0});db[v][t+'s']+=1;_sj(IF,db);return JSONResponse(db[v])
 
244
  @app.get('/api/v2/interactions')
245
  def _gi(id:str=Query(...)):
246
  with _il:return JSONResponse(_lj(IF).get(id.strip(),{'views':0,'likes':0,'comments':0}))
 
247
  @app.get('/api/v2/comments')
248
  def _gc(id:str=Query(...)):
249
  with _cl:return JSONResponse({'comments':_lj(CF).get(id.strip(),[])})
 
250
  @app.post('/api/v2/comment')
251
  async def _pc(request:Request):
252
  b=await request.json();v=str(b.get('id','')).strip();tx=str(b.get('text','')).strip()[:500]
 
255
  with _cl:db=_lj(CF);db.setdefault(v,[]);db[v].append(c);db[v]=db[v][-200:];_sj(CF,db);cms=db[v]
256
  with _il:idb=_lj(IF);idb.setdefault(v,{'views':0,'likes':0,'comments':0});idb[v]['comments']=len(cms);_sj(IF,idb)
257
  return JSONResponse({'comments':cms})
258
+ from wc2026_scraper import(scrape_summary,scrape_fixtures,scrape_standings,scrape_stats,scrape_wc_news,scrape_road_to_wc,get_wc2026_all,scrape_history,scrape_h2h,scrape_lineups,scrape_match_detail)
259
+ @app.get('/api/wc2026')
260
+ def _w():return JSONResponse(get_wc2026_all())
261
+ @app.get('/api/wc2026/fixtures')
262
+ def _wf():return JSONResponse(scrape_fixtures())
263
+ @app.get('/api/wc2026/standings')
264
+ def _ws():return JSONResponse(scrape_standings())
265
+ @app.get('/api/wc2026/stats')
266
+ def _wst():return JSONResponse(scrape_stats())
267
+ @app.get('/api/wc2026/history')
268
+ def _whi():return JSONResponse(scrape_history())
269
+ @app.get('/api/wc2026/news')
270
+ def _wn():return JSONResponse(scrape_wc_news())
271
+ @app.get('/api/wc2026/road')
272
+ def _wr():return JSONResponse(scrape_road_to_wc())
273
+ @app.get('/api/wc2026/h2h/{eid}')
274
+ def _wh2(eid:int):return JSONResponse(scrape_h2h(eid))
275
+ @app.get('/api/wc2026/lineups/{eid}')
276
+ def _wl(eid:int):return JSONResponse(scrape_lineups(eid))
277
+ @app.get('/api/wc2026/match/{eid}')
278
+ def _wm(eid:int):return JSONResponse(scrape_match_detail(eid))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  def _bg():
280
  time.sleep(15)
281
  while True:
 
283
  except:pass
284
  time.sleep(90)
285
  threading.Thread(target=_bg,daemon=True).start()
 
286
  app.mount('/static',StaticFiles(directory=STATIC_DIR),name='vnews_static')
app_v2_entry_test.py DELETED
@@ -1,16 +0,0 @@
1
- """
2
- VNEWS App v2 - Main application with match detail API
3
- """
4
- import os, json, re, time, asyncio, hashlib, logging, threading, importlib, sys
5
- from datetime import datetime, timezone, timedelta
6
- from pathlib import Path
7
- from typing import Optional
8
-
9
- import httpx
10
- import requests
11
- from fastapi import FastAPI, HTTPException, Query
12
- from fastapi.responses import JSONResponse, FileResponse, HTMLResponse
13
- from fastapi.staticfiles import StaticFiles
14
- from fastapi.templating import Jinja2Templates
15
-
16
- # ... (rest of app_v2_entry.py content)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app_v2_entry_v2.py DELETED
@@ -1,16 +0,0 @@
1
- """
2
- VNEWS App v2 - Main application with match detail API
3
- """
4
- import os, json, re, time, asyncio, hashlib, logging, threading, importlib
5
- from datetime import datetime, timezone, timedelta
6
- from pathlib import Path
7
- from typing import Optional
8
-
9
- import httpx
10
- import requests
11
- from fastapi import FastAPI, HTTPException, Query
12
- from fastapi.responses import JSONResponse, FileResponse, HTMLResponse
13
- from fastapi.staticfiles import StaticFiles
14
- from fastapi.templating import Jinja2Templates
15
-
16
- # ... (rest of app_v2_entry.py content)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bongda_proxy.py DELETED
@@ -1,113 +0,0 @@
1
- """VNEWS — Bongda Proxy Endpoint (for fast match detail loading)"""
2
- import requests
3
- from bs4 import BeautifulSoup
4
- import re
5
- import json
6
-
7
- def _cl(s):
8
- return re.sub(r'\s+', ' ', str(s or '')).strip()
9
-
10
- def _normalize_time(raw):
11
- t = _cl(raw)
12
- t = re.sub(r"(\d+)'\s*\+(\d+)", r"\1+\2'", t)
13
- t = t.replace("''", "'")
14
- return t
15
-
16
- def scrape_match_html(event_id, url=None):
17
- result = {"event_id": event_id, "found": False, "sections": []}
18
- headers = {
19
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
20
- "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
21
- "Referer": "https://bongda.com.vn/",
22
- }
23
- html = None
24
- urls_to_try = [url] if url else []
25
- urls_to_try += [
26
- f"https://bongda.com.vn/tran-dau/{event_id}/centre/",
27
- f"https://bongda.com.vn/tran-dau/{event_id}/preview/",
28
- ]
29
- for u in urls_to_try:
30
- if not u:
31
- continue
32
- try:
33
- resp = requests.get(u, headers=headers, timeout=15, allow_redirects=True)
34
- if resp.status_code == 200 and len(resp.text) > 1000:
35
- html = resp.text
36
- break
37
- except Exception:
38
- continue
39
- if not html:
40
- return result
41
- try:
42
- soup = BeautifulSoup(html, 'html.parser')
43
- info = {}
44
- tel = soup.select_one('.teams')
45
- if tel:
46
- he = tel.select_one('.team.home')
47
- if he:
48
- ne = he.select_one('p:not(.logo)') or he.find('p')
49
- if ne: info['home_team'] = _cl(ne.get_text())
50
- lo = he.select_one('img')
51
- if lo: info['home_logo'] = lo.get('src', '')
52
- ae = tel.select_one('.team.away')
53
- if ae:
54
- ne = ae.select_one('p:not(.logo)') or ae.find('p')
55
- if ne: info['away_team'] = _cl(ne.get_text())
56
- lo = ae.select_one('img')
57
- if lo: info['away_logo'] = lo.get('src', '')
58
- sc = tel.select_one('.score')
59
- if sc:
60
- parts = [_cl(p.get_text()) for p in sc.select('p')]
61
- if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}"
62
- lb = sc.select_one('.label')
63
- if lb: info['status_label'] = _cl(lb.get_text())
64
- if info.get('home_team') and info.get('away_team'):
65
- result['info'] = info
66
- result['found'] = True
67
- result['sections'].append('info')
68
- else:
69
- return result
70
- events = []
71
- events_div = soup.select_one('.events')
72
- if events_div:
73
- period = ''
74
- for child in events_div.children:
75
- if not hasattr(child, 'name') or not child.name: continue
76
- cls = ' '.join(child.get('class', []))
77
- if 'period' in cls:
78
- h2 = child.find('h2')
79
- if h2: period = _cl(h2.get_text())
80
- for ev in child.children:
81
- if not hasattr(ev, 'name') or not ev.name: continue
82
- ev_cls = ' '.join(ev.get('class', []))
83
- if 'event' not in ev_cls: continue
84
- ev_data = {'team': 'home' if 'home' in ev_cls else 'away', 'period': period, 'type': 'unknown', 'time': ''}
85
- type_el = ev.select_one('.event-type')
86
- if type_el:
87
- if type_el.select_one('[class*="redcard"]'): ev_data['type'] = 'redcard'
88
- elif type_el.select_one('[class*="yellowcard"]'): ev_data['type'] = 'yellowcard'
89
- elif type_el.select_one('[class*="goal"]'): ev_data['type'] = 'goal'
90
- elif type_el.select_one('[class*="substitution"]'): ev_data['type'] = 'substitution'
91
- players_el = ev.select_one('.players')
92
- if players_el:
93
- time_el = players_el.select_one('.event-time')
94
- if time_el: ev_data['time'] = _normalize_time(time_el.get_text())
95
- text = _cl(players_el.get_text(' ', strip=True).replace(ev_data['time'], '').strip())
96
- ev_data['players'] = text
97
- events.append(ev_data)
98
- if events:
99
- result['events'] = events
100
- result['sections'].append('events')
101
- except Exception as e:
102
- result['error'] = str(e)
103
- return result
104
-
105
- from fastapi import Query
106
- from fastapi.responses import JSONResponse
107
-
108
- def add_bongda_proxy_endpoint(app):
109
- @app.get('/api/proxy/bongda')
110
- def proxy_bongda(event_id: int = Query(default=None), url: str = Query(default=None)):
111
- if event_id is None:
112
- return JSONResponse({'error': 'event_id required'}, status_code=400)
113
- return JSONResponse(scrape_match_html(event_id, url))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
main.py CHANGED
@@ -1,269 +1,285 @@
1
- """VNEWS - FastAPI backend with livescore + xemlaibongda highlights + VTV channels"""
2
- import re, time, subprocess, json, os, threading
3
  import html as html_lib
4
- from datetime import datetime, timezone, timedelta
5
- from collections import defaultdict
6
-
7
- VN_TZ = timezone(timedelta(hours=7))
8
  from concurrent.futures import ThreadPoolExecutor, as_completed
9
  from fastapi import FastAPI, Query, Request
10
  from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, Response
11
- from urllib.parse import quote
 
12
  import requests
13
  from bs4 import BeautifulSoup
14
 
15
  app = FastAPI()
16
-
17
- # ===== WORLD CUP 2026 SCRAPER =====
18
- from wc2026_scraper import get_wc2026_all, scrape_fixtures, scrape_standings, scrape_stats, scrape_wc_news
19
-
20
- # ===== RATE LIMITING =====
21
- _rate_limit_data = defaultdict(list)
22
- _rate_limit_lock = threading.Lock()
23
- RATE_LIMIT_MAX = 60
24
- RATE_LIMIT_WINDOW = 60
25
-
26
- def _check_rate_limit(ip: str) -> bool:
27
- with _rate_limit_lock:
28
- now = time.time()
29
- _rate_limit_data[ip] = [t for t in _rate_limit_data[ip] if now - t < RATE_LIMIT_WINDOW]
30
- if len(_rate_limit_data[ip]) >= RATE_LIMIT_MAX: return False
31
- _rate_limit_data[ip].append(now)
32
- return True
33
-
34
- @app.middleware("http")
35
- async def rate_limit_middleware(request: Request, call_next):
36
- if request.url.path.startswith("/api/"):
37
- ip = request.client.host
38
- if not _check_rate_limit(ip): return JSONResponse({"error": "rate limit exceeded"}, status_code=429)
39
- return await call_next(request)
40
-
41
- # ===== VTV CHANNELS API =====
42
- from vtv_api import router as vtv_router
43
- app.include_router(vtv_router)
44
-
45
  HEADERS = {"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36","Accept-Language":"vi-VN,vi;q=0.9,en;q=0.8"}
46
  BONGDA_HEADERS = {"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36","Accept-Language":"vi-VN,vi;q=0.9","Referer":"https://bongda.com.vn/lich-thi-dau","X-Requested-With":"XMLHttpRequest"}
47
  BASE_BDP = "https://bongdaplus.vn"
 
48
  _cache = {}
49
  _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 = {
56
- "premier-league":{"path":"anh/premier-league","name":"Premier League","emoji":"🏴󠁧󠁢󠁥󠁮󠁧󠁿"},
57
- "fa-cup":{"path":"anh/fa-cup","name":"FA Cup","emoji":"🏆"},
58
- "bundesliga":{"path":"duc/bundesliga","name":"Bundesliga","emoji":"🇩🇪"},
59
- "serie-a":{"path":"italy/serie-a","name":"Serie A","emoji":"🇮🇹"},
60
- "la-liga":{"path":"tay-ban-nha/la-liga","name":"La Liga","emoji":"🇪🇸"},
61
- "champions-league":{"path":"cup-chau-au/uefa-champions-league","name":"Champions League","emoji":"⭐"},
62
- "europa-league":{"path":"cup-chau-au/uefa-europa-league","name":"Europa League","emoji":"🟠"},
63
- "world-cup":{"path":"the-gioi/world-cup","name":"World Cup 2026","emoji":"🌍"},
64
- }
65
  def _cached(key, fn, ttl=None):
66
- now=time.time(); t=ttl or _cache_ttl
67
- if key in _cache and now-_cache[key]["t"]<t: return _cache[key]["d"]
68
- try: data=fn()
69
- except: data=_cache.get(key,{}).get("d",[])
70
- _cache[key]={"d":data,"t":now}; return data
71
- def _get(url, headers=None):
72
- h=headers or HEADERS; r=requests.get(url, headers=h, timeout=15); r.encoding="utf-8"
73
  return BeautifulSoup(r.text,"lxml")
74
  def fetch_bongda_api(endpoint):
75
  try:
76
- r=requests.get(f"https://bongda.com.vn{endpoint}", headers=BONGDA_HEADERS, timeout=10)
77
  if r.status_code==200:
78
  data=r.json()
79
- if data.get("status")=="success": return data.get("html","")
80
  return ""
81
- except: return ""
82
-
83
  def _parse_match_from_li(li, status_type="live"):
84
  match_div=li.select_one("div.match")
85
- if not match_div: return None
86
- home_el=match_div.select_one(".home-team .name"); away_el=match_div.select_one(".away-team .name")
87
- if not home_el or not away_el: return None
88
- status_el=match_div.select_one(".status a"); league_el=li.find_previous("strong"); time_el=match_div.select_one(".match-time")
89
- home_logo=match_div.select_one(".home-team .logo img"); away_logo=match_div.select_one(".away-team .logo img")
90
  event_id=""
91
  if status_el:
92
- href=status_el.get("href",""); m=re.search(r'/tran-dau/(\d+)/',href)
93
- if m: event_id=m.group(1)
94
- spans=status_el.find_all("span") if status_el else []; score=""; minute=""
95
- if len(spans)>=3: score=f"{spans[0].get_text(strip=True)} - {spans[2].get_text(strip=True)}"
96
- if len(spans)>=4: minute=spans[3].get_text(strip=True)
97
- if not score and status_el and status_el.select_one(".vs"): score="VS"
98
  league=league_el.get_text(strip=True) if league_el else ""
99
- return {"home":home_el.get_text(strip=True),"away":away_el.get_text(strip=True),"score":score or"VS","minute":minute,"league":league,"time":time_el.get_text(strip=True) if time_el else "","event_id":event_id,"home_logo":home_logo.get("src","") if home_logo else "","away_logo":away_logo.get("src","") if away_logo else "","status":status_type}
100
 
101
  # ===== VIDEO PROXY =====
102
  @app.get("/api/proxy/m3u8")
103
  def proxy_m3u8(url: str = Query(...)):
104
  try:
105
  r = requests.get(url, headers=HEADERS, timeout=15)
106
- if r.status_code != 200: return Response(status_code=502, content="upstream error")
107
- lines = r.text.strip().split('\n'); rewritten = []
108
  for line in lines:
109
- if line.startswith('#') or not line.strip(): rewritten.append(line)
110
- else: rewritten.append("/api/proxy/seg?url=" + quote(line.strip(), safe=""))
111
- return Response(content='\n'.join(rewritten).encode('utf-8'), media_type="application/vnd.apple.mpegurl", headers={"Access-Control-Allow-Origin":"*","Cache-Control":"public, max-age=300"})
112
- except: return Response(status_code=502, content="proxy error")
113
 
114
  @app.get("/api/proxy/seg")
115
  def proxy_segment(url: str = Query(...)):
116
  try:
117
  r = requests.get(url, headers=HEADERS, timeout=30)
118
- if r.status_code != 200: return Response(status_code=502, content="upstream error")
119
  data = r.content
120
- if len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47: data = data[188:]
121
- return Response(content=data, media_type="video/mp2t", headers={"Access-Control-Allow-Origin":"*","Cache-Control":"public, max-age=3600"})
122
- except: return Response(status_code=502, content="proxy error")
123
 
124
  @app.get("/api/proxy/video")
125
  def proxy_video(url: str = Query(...), request: Request = None):
126
  try:
127
  req_headers = dict(HEADERS)
128
- if request and request.headers.get("range"): req_headers["Range"] = request.headers["range"]
129
  r = requests.get(url, headers=req_headers, timeout=30, stream=True)
130
  resp_headers = {"Access-Control-Allow-Origin":"*","Accept-Ranges":"bytes","Content-Type":r.headers.get("Content-Type","video/mp4")}
131
- if "Content-Range" in r.headers: resp_headers["Content-Range"] = r.headers["Content-Range"]
132
- if "Content-Length" in r.headers: resp_headers["Content-Length"] = r.headers["Content-Length"]
133
- return StreamingResponse(r.iter_content(chunk_size=256*1024), status_code=r.status_code, headers=resp_headers)
134
- except: return Response(status_code=502, content="proxy error")
135
 
136
  @app.get("/api/proxy/img")
137
  def proxy_img(url: str = Query(...)):
 
138
  try:
139
- from urllib.parse import urlparse
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
- return Response(content=r.content, media_type=r.headers.get("Content-Type", "image/jpeg"), headers={"Cache-Control": "public, max-age=86400", "Access-Control-Allow-Origin": "*"})
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: return []
156
- r.encoding = "utf-8"
157
- soup = BeautifulSoup(r.text, "lxml")
158
- videos = []; seen = set()
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: continue
162
- if not href.startswith("http"): href = "https://xemlaibongda.top" + href
163
- clean_href = href.split("?")[0].split("#")[0]
164
- if clean_href in seen: continue
165
- seen.add(clean_href)
166
- img_src = ""
167
- img = a.find("img")
168
- if not img and a.parent: img = a.parent.find("img")
169
- if not img:
170
- p = a.parent
171
- for _ in range(4):
172
- if p and p.find("img"): img = p.find("img"); break
173
- p = p.parent if p else None
174
- if img:
175
- img_src = (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: title = val; break
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: t = el.get_text(strip=True)
199
- if t and len(t) >= 5: title = t; break
200
- except: pass
201
- if not title:
202
- text = a.get_text(strip=True)
203
- if text and len(text) >= 5: title = text[:100]
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
- if not title or len(title) < 3: continue
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
- videos.append({"title": title[:100], "link": clean_href, "img": img_src, "source": "xemlaibongda"})
213
- if len(videos) >= limit: break
214
  return videos
215
- except Exception as e:
216
- print(f"[xemlaibongda] Error: {e}"); return []
217
 
218
- def scrape_xemlaibongda(): return _scrape_xemlaibongda_page("", 20)
219
  def scrape_highlights_by_league(league_key):
220
- if league_key not in HL_LEAGUES: return []
221
- return _scrape_xemlaibongda_page(HL_LEAGUES[league_key]["path"], 20)
 
222
  def scrape_all_league_highlights():
223
  results = {}
224
- def _fetch(key): return key, scrape_highlights_by_league(key)
225
  with ThreadPoolExecutor(8) as ex:
226
  futs = [ex.submit(_fetch, k) for k in HL_LEAGUES]
227
- for f in as_completed(futs, timeout=25):
228
- try: key, vids = f.result()
229
- except: continue
230
- if vids: results[key] = vids
 
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: return{"src":m3u8s[0],"poster":og_poster,"type":"hls"}
251
- yt_iframe = soup.find("iframe", src=re.compile(r"youtube\.com/embed|youtube-nocookie\.com/embed"))
252
- if yt_iframe: return{"src":yt_iframe.get("src",""),"poster":og_poster,"type":"youtube"}
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)})
259
  @app.get("/api/livescore/incoming")
260
- def api_livescore_incoming(): return JSONResponse({"html":_cached("ls_incoming",lambda:fetch_bongda_api("/api/fixtures/incoming"),ttl=_cache_ttl_live)})
261
  @app.get("/api/livescore/today")
262
  def api_livescore_today():
263
- today=datetime.now(VN_TZ).strftime("%Y-%m-%d");return JSONResponse({"html":_cached("ls_today",lambda:fetch_bongda_api(f"/api/fixtures/get-by-date?date={today}"),ttl=_cache_ttl)})
264
  @app.get("/api/livescore/results")
265
  def api_livescore_results():
266
- today=datetime.now(VN_TZ).strftime("%Y-%m-%d");return JSONResponse({"html":_cached("ls_results",lambda:fetch_bongda_api(f"/api/fixtures/get-by-date?date={today}&status=finished"),ttl=_cache_ttl)})
267
  @app.get("/api/livescore/standings/{league}")
268
  def api_livescore_standings(league:str):
269
  tid=LEAGUE_IDS.get(league,27110);return JSONResponse({"html":_cached(f"ls_bxh_{league}",lambda:fetch_bongda_api(f"/api/league-table/home?tournament_id={tid}&is_detail=True"),ttl=_cache_ttl)})
@@ -273,21 +289,10 @@ def api_livescore_date(date:str):return JSONResponse({"html":fetch_bongda_api(f"
273
  def api_match_commentaries(event_id:int):return JSONResponse({"html":fetch_bongda_api(f"/api/fixtures/commentaries?event_id={event_id}")})
274
  @app.get("/api/match/{event_id}/stats")
275
  def api_match_stats(event_id:int):return JSONResponse({"html":fetch_bongda_api(f"/api/event-standing/player-performance?event_id={event_id}")})
276
-
277
- from match_detail_v2 import fetch_match_detail, fetch_match_detail_by_url
278
-
279
- @app.get("/api/match/{event_id}/detail")
280
- def api_match_detail(event_id: int, url: str = Query(default="")):
281
- try:
282
- if url: data = fetch_match_detail_by_url(url)
283
- else: data = fetch_match_detail(event_id)
284
- return JSONResponse(data)
285
- except Exception as e: return JSONResponse({"event_id": event_id, "found": False, "error": str(e)})
286
-
287
  @app.get("/api/livescore/featured")
288
  def api_livescore_featured():
289
  def _f():
290
- sources=[("/api/fixtures/live","live"),("/api/fixtures/get-by-date?date="+datetime.now(VN_TZ).strftime("%Y-%m-%d"),"today"),("/api/fixtures/incoming","upcoming")]
291
  for endpoint, stype in sources:
292
  html=fetch_bongda_api(endpoint)
293
  if not html or len(html)<100:continue
@@ -305,216 +310,616 @@ 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")
311
- def api_highlights_leagues(): return JSONResponse(_cached("hl_leagues",scrape_all_league_highlights,ttl=_cache_ttl))
312
  @app.get("/api/highlights/{league}")
313
  def api_highlights_league(league:str):
314
- if league not in HL_LEAGUES: return JSONResponse({"error":"league not found"})
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(...), img:str=Query(default="")):
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"})
322
  if "xemlaibongda.top" in url:
323
  v=extract_xemlaibongda_video(url)
324
  if v:
325
- if v["type"]=="hls": v["src"]="/api/proxy/m3u8?url="+quote(v["src"],safe="")
326
- if not v.get("poster") and img: v["poster"] = img
327
  return JSONResponse(v)
 
 
 
 
 
 
 
 
 
 
328
  return JSONResponse({"error":"not found"})
329
-
330
- # ===== WORLD CUP 2026 API =====
331
- _wc_request_times = []; _wc_rate_limit_lock = threading.Lock()
332
- _WC_RATE_LIMIT = 10
333
- def _wc_rate_limit():
334
- global _wc_request_times
335
- with _wc_rate_limit_lock:
336
- now = time.time()
337
- _wc_request_times = [t for t in _wc_request_times if now - t < 60]
338
- if len(_wc_request_times) >= _WC_RATE_LIMIT: return False
339
- _wc_request_times.append(now)
340
- return True
341
-
342
- @app.get("/api/wc2026")
343
- def api_wc2026():
344
- return JSONResponse(_cached("wc2026", get_wc2026_all, ttl=_cache_ttl))
345
-
346
- @app.get("/api/wc2026/{tab}")
347
- def api_wc2026_tab(tab: str):
348
- valid_tabs = ["news", "fixtures", "standings", "stats", "highlights"]
349
- if tab not in valid_tabs: return JSONResponse({"error": "invalid tab"}, status_code=400)
350
- def _fetch_tab():
351
- if tab == "highlights": return scrape_highlights_by_league("world-cup")
352
- elif tab == "news": return scrape_wc_news()
353
- elif tab == "fixtures": return scrape_fixtures()
354
- elif tab == "standings": return scrape_standings()
355
- elif tab == "stats": return scrape_stats()
356
- return []
357
- return JSONResponse(_cached(f"wc2026_{tab}", _fetch_tab, ttl=_cache_ttl))
358
-
359
  @app.get("/api/bdp_videos")
360
  def api_bdp_videos():
361
  def _f():
362
  try:
363
- soup=_get(f"{BASE_BDP}/video"); arts=[]; seen=set()
364
  for a in soup.find_all("a",href=True):
365
  href=a.get("href","")
366
  if"/video/" not in href or href in("/video/","/video/ban-thang-dep","/video/highlight"):continue
367
- if not href.startswith("http"): href=BASE_BDP+href
368
- if href in seen: continue
369
  title=re.sub(r'^\d{2}:\d{2}','',a.get_text(strip=True)).strip()
370
- if not title or len(title)<5: continue
371
  img_tag=a.find("img") or(a.parent.find("img") if a.parent else None)
372
  img=(img_tag.get("data-src") or img_tag.get("src","")) if img_tag else ""
373
- seen.add(href); arts.append({"title":title,"link":href,"img":img,"source":"bdp"})
374
  return arts[:20]
375
- except: return []
376
  return JSONResponse(_cached("bdp_videos",_f))
377
-
378
  # ===== NEWS =====
379
- VNE_CATS={"thoi-su":("https://vnexpress.net/thoi-su","Thời Sự"),"the-gioi":("https://vnexpress.net/the-gioi","Thế Giới"),"kinh-doanh":("https://vnexpress.net/kinh-doanh","Kinh Doanh"),"the-thao":("https://vnexpress.net/the-thao","Thể Thao"),"giai-tri":("https://vnexpress.net/giai-tri","Giải Trí"),"suc-khoe":("https://vnexpress.net/suc-khoe","Sức Khỏe"),"phap-luat":("https://vnexpress.net/phap-luat","Pháp Luật"),"giao-duc":("https://vnexpress.net/giao-duc","Giáo Dục"),"du-lich":("https://vnexpress.net/du-lich","Du Lịch"),"doi-song":("https://vnexpress.net/doi-song","Đời Sống")}
380
-
381
  def scrape_vne(cat_url):
382
  try:
383
- soup=_get(cat_url); arts=[]
384
  for it in soup.select("article.item-news")[:15]:
385
  a=it.select_one("h2.title-news a") or it.select_one("h3.title-news a")
386
- if not a: continue
387
- t=a.get("title","") or a.get_text(strip=True); lk=a.get("href","")
388
- if not t or not lk: continue
389
- im=it.find("img"); img=(im.get("data-src") or im.get("src","")) if im else ""
390
- if img and 'blank' in img:
391
  src=it.find("source")
392
- if src: img=src.get("srcset","").split(",")[0].strip().split(" ")[0]
393
  arts.append({"title":t,"link":lk,"img":img,"source":"vne"})
394
  return arts
395
- except: return []
396
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
397
  def scrape_genk_ai():
 
398
  try:
399
  r=requests.get("https://genk.vn/ai.chn",headers=HEADERS,timeout=15)
400
- if r.status_code!=200: return []
401
- r.encoding="utf-8"; soup=BeautifulSoup(r.text,"lxml"); articles=[]; seen=set()
 
402
  for a in soup.find_all("a",href=True):
403
  href=a.get("href","")
404
- if not href.endswith(".chn") or href=="/ai.chn": continue
405
- if href.startswith("/"): href="https://genk.vn"+href
406
- if href in seen or "genk.vn" not in href: continue
407
  title=a.get("title","") or a.get_text(strip=True)
408
- if not title or len(title)<20: continue
409
- container=a.parent; img_src=""
410
  for _ in range(6):
411
- if container is None: break
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; container=container.parent
 
 
416
  seen.add(href)
417
  if not img_src:
418
  try:
419
- og_r=requests.get(href,headers=HEADERS,timeout=8); og_r.encoding="utf-8"
420
- og_soup=BeautifulSoup(og_r.text,"lxml"); og_tag=og_soup.find("meta",property="og:image")
421
- if og_tag: img_src=og_tag.get("content","")
422
- except: pass
423
  articles.append({"title":title,"link":href,"img":img_src,"source":"genk"})
424
- if len(articles)>=30: break
425
  return articles
426
- except: return []
427
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428
  @app.get("/api/homepage")
429
  def api_homepage():
430
  def _f():
431
  articles=[]
432
  with ThreadPoolExecutor(12) as ex:
433
  futs={ex.submit(scrape_vne,VNE_CATS[k][0]):VNE_CATS[k][1] for k in["thoi-su","the-gioi","kinh-doanh","the-thao","giai-tri","phap-luat","giao-duc","du-lich","doi-song"]}
 
434
  for f in as_completed(futs):
435
  try:
436
- for a in f.result(): a["group"]=futs[f]; articles.append(a)
437
- except: pass
438
  return articles
439
  return JSONResponse(_cached("homepage",_f))
440
-
441
  @app.get("/api/category/{cat_id}")
442
  def api_category(cat_id:str):
443
  def _f():
444
- if cat_id=="cong-nghe": return scrape_genk_ai()
445
- if cat_id in VNE_CATS:
446
- arts=scrape_vne(VNE_CATS[cat_id][0])
447
- [a.update({"group":VNE_CATS[cat_id][1]}) for a in arts]
448
- return arts
449
- return []
450
  return JSONResponse(_cached(f"cat_{cat_id}",_f))
451
-
452
  @app.get("/api/categories")
453
  def api_categories():
454
- cats=[{"id":"cong-nghe","name":"Công Nghệ","source":"genk"}]
455
- for k,(u,n) in VNE_CATS.items(): cats.append({"id":k,"name":n,"source":"vne"})
456
  return JSONResponse(cats)
457
-
458
- @app.get("/api/proxy/xlb")
459
- def api_xlb(path: str = Query(default=""), limit: int = Query(default=20)):
 
 
 
 
460
  try:
461
- url = f"https://xemlaibongda.top/{path}" if path else "https://xemlaibongda.top/"
462
- r = requests.get(url, headers=HEADERS, timeout=15)
463
- if r.status_code != 200: return JSONResponse({"videos": []})
464
- r.encoding = "utf-8"
465
- soup = BeautifulSoup(r.text, "lxml")
466
- videos, seen = [], set()
467
- for a in soup.find_all("a", href=True):
468
- href = a.get("href", "")
469
- if "/video/" not in href and "/xem-lai/" not in href: continue
470
- if not href.startswith("http"): href = "https://xemlaibongda.top" + href
471
- clean = href.split("?")[0].split("#")[0]
472
- if clean in seen: continue
473
- seen.add(clean)
474
- img_src = ""
475
- img = a.find("img") or (a.parent.find("img") if a.parent else None)
476
- if not img:
477
- p = a.parent
478
- for _ in range(5):
479
- if p and p.find("img"): img = p.find("img"); break
480
- p = p.parent if p else None
481
- if img:
482
- img_src = (img.get("data-src", "") or img.get("src", "") or img.get("data-lazy", "") or img.get("data-original", ""))
483
- if img_src.startswith("//"): img_src = "https:" + img_src
484
- elif img_src.startswith("/"): img_src = "https://xemlaibongda.top" + img_src
485
- title = a.find("h3")
486
- if not title: title = a.find("h2")
487
- if not title: title = a.find("strong")
488
- t = title.get_text(strip=True) if title else ""
489
- if not t:
490
- slug = clean.split("/video/")[-1].rstrip("/")
491
- t = slug.replace("-", " ").title()
492
- videos.append({"title": t[:100], "link": clean, "img": img_src, "source": "xemlaibongda"})
493
- if len(videos) >= limit: break
494
- return JSONResponse({"videos": videos})
495
- except Exception as e:
496
- return JSONResponse({"videos": [], "error": str(e)})
497
 
498
  @app.get("/api/article")
499
  def api_article(url:str=Query(...)):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
500
  try:
501
- r2 = requests.get(url, headers=HEADERS, timeout=10)
502
- if r2.status_code == 200:
503
- r2.encoding = "utf-8"
504
- soup = BeautifulSoup(r2.text, "lxml")
505
- og = soup.find("meta", property="og:image")
506
- return JSONResponse({"og_image": og.get("content", "") if og else ""})
507
- except: pass
508
- return JSONResponse({"og_image": ""})
509
-
510
- @app.get("/api/storage_status")
511
- def api_storage_status():
512
- return JSONResponse({"persistent":os.path.isdir("/data")})
513
-
514
- @app.get("/api/hot_topics")
515
- def api_hot_topics():
516
- return JSONResponse({"topics":[]})
517
-
518
- @app.get("/", response_class=HTMLResponse)
519
- async def root():
520
- return HTMLResponse("<h1>VNEWS v17</h1><p>VTV Digital CDN ssaimh · No shorts Dantri/SKDS · Homepage full content</p>")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """VNEWS - FastAPI backend with livescore + xemlaibongda highlights + YouTube FPT shorts"""
2
+ import hashlib, re, time, subprocess, json, os, threading
3
  import html as html_lib
4
+ from datetime import datetime
 
 
 
5
  from concurrent.futures import ThreadPoolExecutor, as_completed
6
  from fastapi import FastAPI, Query, Request
7
  from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, Response
8
+ from fastapi.staticfiles import StaticFiles
9
+ from urllib.parse import unquote, quote, urlencode
10
  import requests
11
  from bs4 import BeautifulSoup
12
 
13
  app = FastAPI()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  HEADERS = {"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36","Accept-Language":"vi-VN,vi;q=0.9,en;q=0.8"}
15
  BONGDA_HEADERS = {"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36","Accept-Language":"vi-VN,vi;q=0.9","Referer":"https://bongda.com.vn/lich-thi-dau","X-Requested-With":"XMLHttpRequest"}
16
  BASE_BDP = "https://bongdaplus.vn"
17
+ SPACE_URL = "https://bep40-vnews.hf.space"
18
  _cache = {}
19
  _cache_ttl = 300
20
  _cache_ttl_live = 60
21
  _cache_ttl_yt = 1800
22
+ SHORTS_FALLBACK = [
23
+ {"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 | #shorts","channel":"baodantri7941"},
24
+ {"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 | #shorts","channel":"baodantri7941"},
25
+ {"id":"tvPewsc2ph4","title":"Tính năng ẩn trên iPhone giúp giảm mỏi mắt | #shorts","channel":"baodantri7941"},
26
+ {"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 | #shorts","channel":"baodantri7941"},
27
+ {"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ù | #shorts","channel":"baodantri7941"},
28
+ {"id":"Htzvwg6iOBM","title":"Xe điện Audi S6 Sportback e-tron có gì đặc biệt? | #shorts","channel":"baodantri7941"},
29
+ {"id":"iMdFmWvYdlo","title":"Cô gái người Nga yêu thời trang và đất nước Việt Nam | #shorts","channel":"baodantri7941"},
30
+ {"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 | #shorts","channel":"baodantri7941"},
31
+ {"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 | #shorts","channel":"baodantri7941"},
32
+ {"id":"VAfgNNgZDRs","title":"Khởi tố 4 đối tượng ném bom xăng vào nhà dân ở Đồng Nai | #shorts","channel":"baodantri7941"},
33
+ {"id":"sBH_-zGh0Xw","title":"Vì sao Times New Roman vẫn nổi tiếng sau hàng chục năm? | #shorts","channel":"baodantri7941"},
34
+ {"id":"woKn5f2bLHM","title":"Quảng Ninh ngập sâu diện rộng sau đợt mưa lớn | #shorts","channel":"baodantri7941"},
35
+ {"id":"bcpgRoxbLPw","title":"Giông lốc quật bay mái tôn ở TP.HCM | #shorts","channel":"baodantri7941"},
36
+ {"id":"ZIIC5osy544","title":"Bé trai Trung Quốc rơi từ tầng 11 vẫn sống sót kỳ diệu | #shorts","channel":"baodantri7941"},
37
+ {"id":"uTMJ49NQpyc","title":"Sau lớp mascot 40kg: Câu chuyện mưu sinh của người trẻ ở TPHCM | #shorts","channel":"baodantri7941"},
38
+ {"id":"7Pd6vZ2Lz1M","title":"Hành động ấm lòng của người đàn ông tham gia tìm kiếm 5 học sinh tử vong ở sông Lô | SKĐS","channel":"baosuckhoedoisongboyte"},
39
+ {"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 | SKĐS","channel":"baosuckhoedoisongboyte"},
40
+ {"id":"IUOprcJyYr4","title":"Phụ nữ táo bón có phải do lười ăn rau? | SKĐS #shorts","channel":"baosuckhoedoisongboyte"},
41
+ {"id":"YY8ojFNE-AU","title":"Quái xế tự quay clip nẹt pô, đánh võng đăng TikTok bị xử lý | SKĐS","channel":"baosuckhoedoisongboyte"},
42
+ {"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 | SKĐS","channel":"baosuckhoedoisongboyte"},
43
+ {"id":"FoxhFyz2skY","title":"Người đàn ông nước ngoài đập phá ô tô, bẻ cần gạt nước ở Đà Nẵng | SKĐS","channel":"baosuckhoedoisongboyte"},
44
+ {"id":"R1oC_I8dFPU","title":"Thanh niên buông tay lái, đứng trên xe máy khi đổ đèo ở Đắk Lắk | SKĐS","channel":"baosuckhoedoisongboyte"},
45
+ {"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 | SKĐS","channel":"baosuckhoedoisongboyte"},
46
+ {"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 | SKĐS","channel":"baosuckhoedoisongboyte"},
47
+ {"id":"pXWt0QbAzRQ","title":"Va chạm giao thông, người phụ nữ lăng mạ tài xế ô tô | SKĐS","channel":"baosuckhoedoisongboyte"},
48
+ {"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 | SKĐS","channel":"baosuckhoedoisongboyte"},
49
+ {"id":"AxhVTQutsuo","title":"Xuất tinh sớm và những hiểu lầm thường gặp | SKĐS #shorts","channel":"baosuckhoedoisongboyte"},
50
+ {"id":"cNy6FgaNxYM","title":"Cô dâu khóc sưng mắt vì 6 chỉ vàng không cánh mà bay trong ngày cưới | SKĐS","channel":"baosuckhoedoisongboyte"},
51
+ {"id":"IDt_S6q59Ro","title":"Chở bạn gái không đội mũ bảo hiểm, thanh niên đấm CSGT | SKĐS","channel":"baosuckhoedoisongboyte"},
52
+ {"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 | SKĐS","channel":"baosuckhoedoisongboyte"}
53
+ ]
54
+ for _v in SHORTS_FALLBACK:
55
+ _v["link"]="https://www.youtube.com/watch?v="+_v["id"]
56
+ _v["img"]="https://i.ytimg.com/vi/"+_v["id"]+"/hqdefault.jpg"
57
+ _v["source"]="yt"
58
+ SHORT_STATS_FILE = "/data/short_stats.json" if os.path.isdir("/data") else "/app/short_stats.json"
59
+ _short_lock = threading.Lock()
60
+ def _load_short_db():
61
+ try:
62
+ if os.path.exists(SHORT_STATS_FILE):
63
+ with open(SHORT_STATS_FILE,"r",encoding="utf-8") as f:return json.load(f)
64
+ except:pass
65
+ return {}
66
+ def _save_short_db(db):
67
+ try:
68
+ os.makedirs(os.path.dirname(SHORT_STATS_FILE),exist_ok=True)
69
+ tmp=SHORT_STATS_FILE+".tmp"
70
+ with open(tmp,"w",encoding="utf-8") as f:json.dump(db,f,ensure_ascii=False)
71
+ os.replace(tmp,SHORT_STATS_FILE)
72
+ except:pass
73
+
74
+ def _short_default():return {"views":0,"likes":0,"shares":0,"comments":[]}
75
+ WALL_FILE = "/data/wall_posts.json" if os.path.isdir("/data") else "/app/wall_posts.json"
76
+ def _load_wall():
77
+ try:
78
+ if os.path.exists(WALL_FILE):
79
+ with open(WALL_FILE,"r",encoding="utf-8") as f:return json.load(f)
80
+ except:pass
81
+ return []
82
+ def _save_wall(posts):
83
+ try:
84
+ os.makedirs(os.path.dirname(WALL_FILE),exist_ok=True)
85
+ tmp=WALL_FILE+".tmp"
86
+ with open(tmp,"w",encoding="utf-8") as f:json.dump(posts[:100],f,ensure_ascii=False)
87
+ os.replace(tmp,WALL_FILE)
88
+ except:pass
89
  PRIORITY_LEAGUES = ["Ngoại Hạng Anh","FA Cup","Champions League","LaLiga","Copa del Rey","Serie A","Bundesliga","Ligue 1","V-League"]
90
  LEAGUE_IDS = {"nha":27110,"laliga":27233,"seriea":27044,"bundesliga":26891,"ligue1":27212}
91
+ HL_LEAGUES = {"premier-league":{"path":"anh/premier-league","name":"Premier League","emoji":"🏴󠁧󠁢󠁥󠁮󠁧󠁿"},"fa-cup":{"path":"anh/fa-cup","name":"FA Cup","emoji":"🏆"},"bundesliga":{"path":"duc/bundesliga","name":"Bundesliga","emoji":"🇩🇪"},"serie-a":{"path":"italy/serie-a","name":"Serie A","emoji":"🇮🇹"},"la-liga":{"path":"tay-ban-nha/la-liga","name":"La Liga","emoji":"🇪🇸"},"champions-league":{"path":"cup-chau-au/uefa-champions-league","name":"Champions League","emoji":"⭐"},"europa-league":{"path":"cup-chau-au/uefa-europa-league","name":"Europa League","emoji":"🟠"},"world-cup":{"path":"the-gioi/world-cup-qualifiers","name":"World Cup 2026","emoji":"🌍"}}
 
 
 
 
 
 
 
 
 
92
  def _cached(key, fn, ttl=None):
93
+ now=time.time();t=ttl or _cache_ttl
94
+ if key in _cache and now-_cache[key]["t"]<t:return _cache[key]["d"]
95
+ try:data=fn()
96
+ except:data=_cache.get(key,{}).get("d",[])
97
+ _cache[key]={"d":data,"t":now};return data
98
+ def _get(url,headers=None):
99
+ h=headers or HEADERS;r=requests.get(url,headers=h,timeout=15);r.encoding="utf-8"
100
  return BeautifulSoup(r.text,"lxml")
101
  def fetch_bongda_api(endpoint):
102
  try:
103
+ r=requests.get(f"https://bongda.com.vn{endpoint}",headers=BONGDA_HEADERS,timeout=10)
104
  if r.status_code==200:
105
  data=r.json()
106
+ if data.get("status")=="success":return data.get("html","")
107
  return ""
108
+ except:return ""
 
109
  def _parse_match_from_li(li, status_type="live"):
110
  match_div=li.select_one("div.match")
111
+ if not match_div:return None
112
+ home_el=match_div.select_one(".home-team .name");away_el=match_div.select_one(".away-team .name")
113
+ if not home_el or not away_el:return None
114
+ status_el=match_div.select_one(".status a");league_el=li.find_previous("strong");time_el=match_div.select_one(".match-time")
115
+ home_logo=match_div.select_one(".home-team .logo img");away_logo=match_div.select_one(".away-team .logo img")
116
  event_id=""
117
  if status_el:
118
+ href=status_el.get("href","");m=re.search(r'/tran-dau/(\d+)/',href)
119
+ if m:event_id=m.group(1)
120
+ spans=status_el.find_all("span") if status_el else [];score="";minute=""
121
+ if len(spans)>=3:score=f"{spans[0].get_text(strip=True)} - {spans[2].get_text(strip=True)}"
122
+ if len(spans)>=4:minute=spans[3].get_text(strip=True)
123
+ if not score and status_el and status_el.select_one(".vs"):score="VS"
124
  league=league_el.get_text(strip=True) if league_el else ""
125
+ return{"home":home_el.get_text(strip=True),"away":away_el.get_text(strip=True),"score":score or"VS","minute":minute,"league":league,"time":time_el.get_text(strip=True) if time_el else "","event_id":event_id,"home_logo":home_logo.get("src","") if home_logo else "","away_logo":away_logo.get("src","") if away_logo else "","status":status_type}
126
 
127
  # ===== VIDEO PROXY =====
128
  @app.get("/api/proxy/m3u8")
129
  def proxy_m3u8(url: str = Query(...)):
130
  try:
131
  r = requests.get(url, headers=HEADERS, timeout=15)
132
+ if r.status_code != 200:return Response(status_code=502, content="upstream error")
133
+ lines = r.text.strip().split('\n');rewritten = []
134
  for line in lines:
135
+ if line.startswith('#') or not line.strip():rewritten.append(line)
136
+ else:rewritten.append("/api/proxy/seg?url=" + quote(line.strip(), safe=""))
137
+ return Response(content='\n'.join(rewritten).encode('utf-8'),media_type="application/vnd.apple.mpegurl",headers={"Access-Control-Allow-Origin":"*","Cache-Control":"public, max-age=300"})
138
+ except:return Response(status_code=502, content="proxy error")
139
 
140
  @app.get("/api/proxy/seg")
141
  def proxy_segment(url: str = Query(...)):
142
  try:
143
  r = requests.get(url, headers=HEADERS, timeout=30)
144
+ if r.status_code != 200:return Response(status_code=502, content="upstream error")
145
  data = r.content
146
+ if len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47:data = data[188:]
147
+ return Response(content=data,media_type="video/mp2t",headers={"Access-Control-Allow-Origin":"*","Cache-Control":"public, max-age=3600"})
148
+ except:return Response(status_code=502, content="proxy error")
149
 
150
  @app.get("/api/proxy/video")
151
  def proxy_video(url: str = Query(...), request: Request = None):
152
  try:
153
  req_headers = dict(HEADERS)
154
+ if request and request.headers.get("range"):req_headers["Range"] = request.headers["range"]
155
  r = requests.get(url, headers=req_headers, timeout=30, stream=True)
156
  resp_headers = {"Access-Control-Allow-Origin":"*","Accept-Ranges":"bytes","Content-Type":r.headers.get("Content-Type","video/mp4")}
157
+ if "Content-Range" in r.headers:resp_headers["Content-Range"] = r.headers["Content-Range"]
158
+ if "Content-Length" in r.headers:resp_headers["Content-Length"] = r.headers["Content-Length"]
159
+ return StreamingResponse(r.iter_content(chunk_size=256*1024),status_code=r.status_code,headers=resp_headers)
160
+ except:return Response(status_code=502, content="proxy error")
161
 
162
  @app.get("/api/proxy/img")
163
  def proxy_img(url: str = Query(...)):
164
+ """Proxy images from sources that block hotlinking (DanTri CDN)."""
165
  try:
166
+ r = requests.get(url, headers={**HEADERS, "Referer": "https://dantri.com.vn/"}, timeout=10)
167
+ if r.status_code != 200:return Response(status_code=502)
168
+ ct = r.headers.get("Content-Type", "image/jpeg")
169
+ return Response(content=r.content, media_type=ct, headers={"Cache-Control": "public, max-age=86400", "Access-Control-Allow-Origin": "*"})
170
+ except:return Response(status_code=502)
 
 
 
 
 
171
 
172
  # ===== XEMLAIBONGDA HIGHLIGHTS =====
173
  def _scrape_xemlaibongda_page(page_path, limit=20):
174
  try:
175
  url = f"https://xemlaibongda.top/{page_path}" if page_path else "https://xemlaibongda.top/"
176
+ r=requests.get(url,headers=HEADERS,timeout=15)
177
+ if r.status_code!=200:return[]
178
+ r.encoding="utf-8";soup=BeautifulSoup(r.text,"lxml");videos=[];seen=set()
179
+ for a in soup.find_all("a",href=True):
180
+ href=a.get("href","")
181
+ if"/video/" not in href:continue
182
+ if not href.startswith("http"):href="https://xemlaibongda.top"+href
183
+ if href in seen:continue
184
+ seen.add(href);slug=href.split("/video/")[-1].rstrip("/")
185
+ title=slug.replace("-"," ").title()
186
+ title=re.sub(r'\d{4}\s*\d{2}\s*\d{2}$','',title).strip()
187
+ title=re.sub(r'\s+V\s+',' vs ',title);title=re.sub(r'\s+Vs\s+',' vs ',title)
188
+ img=a.find("img") or (a.parent.find("img") if a.parent else None)
189
+ img_src=""
190
+ if img:img_src=img.get("data-src","") or img.get("src","") or img.get("data-lazy","")
191
+ if not img_src:img_src=f"https://img.refooty.com/thumbnail/{slug}.webp"
192
+ videos.append({"title":title,"link":href,"img":img_src,"source":"xemlaibongda"})
193
+ if len(videos)>=limit:break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  return videos
195
+ except:return[]
 
196
 
197
+ def scrape_xemlaibongda():return _scrape_xemlaibongda_page("",20)
198
  def scrape_highlights_by_league(league_key):
199
+ if league_key not in HL_LEAGUES:return[]
200
+ return _scrape_xemlaibongda_page(HL_LEAGUES[league_key]["path"],20)
201
+
202
  def scrape_all_league_highlights():
203
  results = {}
204
+ def _fetch(key):return key, scrape_highlights_by_league(key)
205
  with ThreadPoolExecutor(8) as ex:
206
  futs = [ex.submit(_fetch, k) for k in HL_LEAGUES]
207
+ for f in as_completed(futs):
208
+ try:
209
+ key, vids = f.result()
210
+ if vids:results[key] = vids
211
+ except:pass
212
  return results
213
 
214
  def extract_xemlaibongda_video(url):
215
  try:
216
+ r=requests.get(url,headers=HEADERS,timeout=15)
217
+ if r.status_code!=200:return None
218
+ r.encoding="utf-8";soup=BeautifulSoup(r.text,"lxml");video=soup.find("video")
 
 
 
 
219
  if video:
220
+ src=video.get("src","");poster=video.get("poster","")
221
  if not src:
222
  source=video.find("source")
223
+ if source:src=source.get("src","")
224
+ if src:return{"src":src,"poster":poster,"type":"hls" if".m3u8" in src else"video"}
 
225
  m3u8s=re.findall(r'(https?://[^\s"\'<>]+\.m3u8)',r.text)
226
+ if m3u8s:
227
+ og=soup.find("meta",property="og:image");poster=og.get("content","") if og else ""
228
+ return{"src":m3u8s[0],"poster":poster,"type":"hls"}
229
  return None
230
+ except:return None
231
+
232
+ # ===== YOUTUBE SHORTS =====
233
+ def _yt_channel_shorts(channel, count=15):
234
+ """Fast scrape YouTube shorts tab without yt-dlp. Returns newest-first IDs/titles."""
235
+ try:
236
+ url=f"https://www.youtube.com/@{channel}/shorts"
237
+ r=requests.get(url,headers={**HEADERS,"Accept-Language":"vi,en;q=0.8"},timeout=15)
238
+ if r.status_code!=200:return[]
239
+ html=r.text
240
+ ids=[];items=[]
241
+ for m in re.finditer(r'"videoId":"([A-Za-z0-9_-]{11})"',html):
242
+ vid=m.group(1)
243
+ if vid in ids:continue
244
+ ids.append(vid)
245
+ snip=html[max(0,m.start()-900):m.start()+1600]
246
+ title=""
247
+ mt=re.search(r'"title":\{"runs":\[\{"text":"([^"]+)"',snip)
248
+ if not mt:mt=re.search(r'"accessibilityText":"([^"]+)"',snip)
249
+ if mt:title=html_lib.unescape(mt.group(1)).replace('\n',' ').strip()
250
+ if not title:title="YouTube Short"
251
+ 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})
252
+ if len(items)>=count:break
253
+ return items
254
+ except:return[]
255
+ def scrape_shorts():
256
+ """Stable shorts feed: fast HTML scrape + static fallback so slide never disappears."""
257
+ vids=[]
258
+ with ThreadPoolExecutor(2) as ex:
259
+ futs=[ex.submit(_yt_channel_shorts,ch,24) for ch in ["baodantri7941","baosuckhoedoisongboyte"]]
260
+ for f in as_completed(futs):
261
+ try:
262
+ r=f.result()
263
+ if r:vids.extend(r)
264
+ except:pass
265
+ merged=[];seen=set()
266
+ for v in vids+SHORTS_FALLBACK:
267
+ vid=v.get("id")
268
+ if not vid or vid in seen:continue
269
+ seen.add(vid);merged.append(v)
270
+ return merged[:40]
271
 
272
  # ===== LIVESCORE =====
273
  @app.get("/api/livescore/live")
274
+ def api_livescore_live():return JSONResponse({"html":_cached("ls_live",lambda:fetch_bongda_api("/api/fixtures/live"),ttl=_cache_ttl_live)})
275
  @app.get("/api/livescore/incoming")
276
+ def api_livescore_incoming():return JSONResponse({"html":_cached("ls_incoming",lambda:fetch_bongda_api("/api/fixtures/incoming"),ttl=_cache_ttl_live)})
277
  @app.get("/api/livescore/today")
278
  def api_livescore_today():
279
+ today=datetime.now().strftime("%Y-%m-%d");return JSONResponse({"html":_cached("ls_today",lambda:fetch_bongda_api(f"/api/fixtures/get-by-date?date={today}"),ttl=_cache_ttl)})
280
  @app.get("/api/livescore/results")
281
  def api_livescore_results():
282
+ today=datetime.now().strftime("%Y-%m-%d");return JSONResponse({"html":_cached("ls_results",lambda:fetch_bongda_api(f"/api/fixtures/get-by-date?date={today}&status=finished"),ttl=_cache_ttl)})
283
  @app.get("/api/livescore/standings/{league}")
284
  def api_livescore_standings(league:str):
285
  tid=LEAGUE_IDS.get(league,27110);return JSONResponse({"html":_cached(f"ls_bxh_{league}",lambda:fetch_bongda_api(f"/api/league-table/home?tournament_id={tid}&is_detail=True"),ttl=_cache_ttl)})
 
289
  def api_match_commentaries(event_id:int):return JSONResponse({"html":fetch_bongda_api(f"/api/fixtures/commentaries?event_id={event_id}")})
290
  @app.get("/api/match/{event_id}/stats")
291
  def api_match_stats(event_id:int):return JSONResponse({"html":fetch_bongda_api(f"/api/event-standing/player-performance?event_id={event_id}")})
 
 
 
 
 
 
 
 
 
 
 
292
  @app.get("/api/livescore/featured")
293
  def api_livescore_featured():
294
  def _f():
295
+ sources=[("/api/fixtures/live","live"),("/api/fixtures/get-by-date?date="+datetime.now().strftime("%Y-%m-%d"),"today"),("/api/fixtures/incoming","upcoming")]
296
  for endpoint, stype in sources:
297
  html=fetch_bongda_api(endpoint)
298
  if not html or len(html)<100:continue
 
310
  return None
311
  return JSONResponse(_cached("ls_featured",_f,ttl=30))
312
 
313
+ # ===== VIDEO APIs =====
314
+ @app.get("/api/shorts")
315
+ def api_shorts():return JSONResponse(_cached("yt_shorts_v3",scrape_shorts,ttl=_cache_ttl_yt))
316
+ @app.get("/api/short-stats")
317
+ def api_short_stats(ids:str=Query(default="")):
318
+ arr=[x for x in ids.split(",") if x]
319
+ with _short_lock:
320
+ db=_load_short_db();out={}
321
+ for vid in arr:
322
+ st=db.get(vid) or _short_default()
323
+ 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]}
324
+ return JSONResponse({"stats":out})
325
+
326
+ @app.post("/api/short-action")
327
+ async def api_short_action(request:Request):
328
+ try:body=await request.json()
329
+ except:body={}
330
+ vid=str(body.get("id","")).strip();action=str(body.get("action","")).strip();txt=str(body.get("text","")).strip()
331
+ if not vid:return JSONResponse({"error":"missing id"},status_code=400)
332
+ with _short_lock:
333
+ db=_load_short_db();st=db.get(vid) or _short_default()
334
+ if action=="view":st["views"]=int(st.get("views",0))+1
335
+ elif action=="like":st["likes"]=int(st.get("likes",0))+1
336
+ elif action=="share":st["shares"]=int(st.get("shares",0))+1
337
+ elif action=="comment" and txt:
338
+ comments=st.get("comments",[])
339
+ comments.insert(0,{"text":txt[:180],"ts":int(time.time())})
340
+ st["comments"]=comments[:80]
341
+ st["updated"]=int(time.time());db[vid]=st;_save_short_db(db)
342
+ out={"views":int(st.get("views",0)),"likes":int(st.get("likes",0)),"shares":int(st.get("shares",0)),"comments":st.get("comments",[])[:80]}
343
+ return JSONResponse({"stats":out})
344
+
345
  @app.get("/api/highlights")
346
+ def api_highlights():return JSONResponse(_cached("xemlaibongda_hl",scrape_xemlaibongda,ttl=_cache_ttl))
347
  @app.get("/api/highlights/leagues")
348
+ def api_highlights_leagues():return JSONResponse(_cached("hl_leagues",scrape_all_league_highlights,ttl=_cache_ttl))
349
  @app.get("/api/highlights/{league}")
350
  def api_highlights_league(league:str):
351
+ if league not in HL_LEAGUES:return JSONResponse({"error":"league not found"})
352
  return JSONResponse(_cached(f"hl_{league}",lambda:scrape_highlights_by_league(league),ttl=_cache_ttl))
353
+ @app.get("/api/highlights_config")
354
+ def api_highlights_config():return JSONResponse(HL_LEAGUES)
355
  @app.get("/api/video_url")
356
+ def api_video_url(url:str=Query(...)):
357
  if "youtube.com" in url or "youtu.be" in url:
358
  m=re.search(r'(?:v=|shorts/|youtu\.be/)([a-zA-Z0-9_-]{11})',url)
359
+ 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"})
360
  if "xemlaibongda.top" in url:
361
  v=extract_xemlaibongda_video(url)
362
  if v:
363
+ if v["type"]=="hls":v["src"]="/api/proxy/m3u8?url="+quote(v["src"],safe="")
 
364
  return JSONResponse(v)
365
+ if "bongdaplus.vn" in url:
366
+ try:
367
+ m=re.search(r'-(\d{6,})\.html',url)
368
+ if m:
369
+ r=requests.get(f"{BASE_BDP}/video-embed/{m.group(1)}.html",headers=HEADERS,timeout=10);r.encoding="utf-8"
370
+ soup=BeautifulSoup(r.text,"lxml");video=soup.select_one("video#videoPlayer")
371
+ if video:
372
+ source=video.find("source");src=source.get("src","") if source else "";poster=video.get("poster","")
373
+ if src:return JSONResponse({"src":"/api/proxy/video?url="+quote(src,safe=""),"poster":poster,"type":"video"})
374
+ except:pass
375
  return JSONResponse({"error":"not found"})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
  @app.get("/api/bdp_videos")
377
  def api_bdp_videos():
378
  def _f():
379
  try:
380
+ soup=_get(f"{BASE_BDP}/video");arts=[];seen=set()
381
  for a in soup.find_all("a",href=True):
382
  href=a.get("href","")
383
  if"/video/" not in href or href in("/video/","/video/ban-thang-dep","/video/highlight"):continue
384
+ if not href.startswith("http"):href=BASE_BDP+href
385
+ if href in seen:continue
386
  title=re.sub(r'^\d{2}:\d{2}','',a.get_text(strip=True)).strip()
387
+ if not title or len(title)<5:continue
388
  img_tag=a.find("img") or(a.parent.find("img") if a.parent else None)
389
  img=(img_tag.get("data-src") or img_tag.get("src","")) if img_tag else ""
390
+ seen.add(href);arts.append({"title":title,"link":href,"img":img,"source":"bdp"})
391
  return arts[:20]
392
+ except:return[]
393
  return JSONResponse(_cached("bdp_videos",_f))
 
394
  # ===== NEWS =====
 
 
395
  def scrape_vne(cat_url):
396
  try:
397
+ soup=_get(cat_url);arts=[]
398
  for it in soup.select("article.item-news")[:15]:
399
  a=it.select_one("h2.title-news a") or it.select_one("h3.title-news a")
400
+ if not a:continue
401
+ t=a.get("title","") or a.get_text(strip=True);lk=a.get("href","")
402
+ if not t or not lk:continue
403
+ im=it.find("img");img=(im.get("data-src") or im.get("src","")) if im else ""
404
+ if img and'blank'in img:
405
  src=it.find("source")
406
+ if src:img=src.get("srcset","").split(",")[0].strip().split(" ")[0]
407
  arts.append({"title":t,"link":lk,"img":img,"source":"vne"})
408
  return arts
409
+ except:return[]
410
+ def scrape_vne_article(url):
411
+ try:
412
+ soup=_get(url);h1=soup.select_one("h1.title-detail");desc=soup.select_one("p.description")
413
+ og=soup.find("meta",property="og:image");og_img=og.get("content","") if og else ""
414
+ cd=soup.select_one("article.fck_detail");body=[]
415
+ if cd:
416
+ for ch in cd.children:
417
+ if not hasattr(ch,'name') or not ch.name:continue
418
+ if ch.name=="p":t=ch.get_text(strip=True);(body.append({"type":"p","text":t}) if t else None)
419
+ elif ch.name=="figure":
420
+ im=ch.find("img")
421
+ if im:s=im.get("data-src") or im.get("src","");body.append({"type":"img","src":s})
422
+ elif ch.name in("h2","h3"):body.append({"type":"heading","text":ch.get_text(strip=True)})
423
+ return{"title":h1.get_text(strip=True) if h1 else "","summary":desc.get_text(strip=True) if desc else "","og_image":og_img,"body":body,"source":"vne","url":url}
424
+ except:return None
425
+ def _scrape_dantri_homepage(cat_filter=None):
426
+ try:
427
+ soup=_get("https://dantri.com.vn/");arts=[];seen=set()
428
+ for a in soup.find_all("a",href=True):
429
+ href=a.get("href","");title=a.get("title","") or a.get_text(strip=True)
430
+ if not title or len(title)<15 or"javascript:" in href:continue
431
+ if not href.startswith("http"):href="https://dantri.com.vn"+href
432
+ if href in seen or not href.endswith(".htm"):continue
433
+ if cat_filter and f"/{cat_filter}/" not in href:continue
434
+ img_tag=a.find("img")
435
+ if not img_tag and a.parent:img_tag=a.parent.find("img")
436
+ img_src=""
437
+ if img_tag:img_src=img_tag.get("data-src","") or img_tag.get("src","")
438
+ if not img_src or "cdn" not in img_src:continue
439
+ proxied_img="/api/proxy/img?url="+quote(img_src,safe="")
440
+ seen.add(href);arts.append({"title":title,"link":href,"img":proxied_img,"source":"dantri"})
441
+ if len(arts)>=15:break
442
+ return arts
443
+ except:return[]
444
+ def scrape_dantri_hot():return _scrape_dantri_homepage()
445
+ def scrape_dantri_congnghe():
446
+ try:
447
+ soup=_get("https://dantri.com.vn/");arts=[];seen=set()
448
+ for a in soup.find_all("a",href=True):
449
+ href=a.get("href","");title=a.get("title","") or a.get_text(strip=True)
450
+ if not title or len(title)<15 or"javascript:" in href:continue
451
+ if not href.startswith("http"):href="https://dantri.com.vn"+href
452
+ if href in seen or not href.endswith(".htm"):continue
453
+ if"/cong-nghe/" not in href:continue
454
+ img_tag=a.find("img")
455
+ if not img_tag and a.parent:img_tag=a.parent.find("img")
456
+ img_src=""
457
+ if img_tag:img_src=img_tag.get("data-src","") or img_tag.get("src","")
458
+ if img_src and "cdn" in img_src:img_src="/api/proxy/img?url="+quote(img_src,safe="")
459
+ else:img_src=""
460
+ seen.add(href);arts.append({"title":title,"link":href,"img":img_src,"source":"dantri"})
461
+ if len(arts)>=15:break
462
+ return arts
463
+ except:return[]
464
  def scrape_genk_ai():
465
+ """Scrape AI articles from genk.vn - readable in-app"""
466
  try:
467
  r=requests.get("https://genk.vn/ai.chn",headers=HEADERS,timeout=15)
468
+ if r.status_code!=200:return[]
469
+ r.encoding="utf-8";soup=BeautifulSoup(r.text,"lxml")
470
+ articles=[];seen=set()
471
  for a in soup.find_all("a",href=True):
472
  href=a.get("href","")
473
+ if not href.endswith(".chn") or href=="/ai.chn":continue
474
+ if href.startswith("/"):href="https://genk.vn"+href
475
+ if href in seen or "genk.vn" not in href:continue
476
  title=a.get("title","") or a.get_text(strip=True)
477
+ if not title or len(title)<20:continue
478
+ container=a.parent;img_src=""
479
  for _ in range(6):
480
+ if container is None:break
481
  for img in container.find_all("img"):
482
  s=img.get("data-src","") or img.get("src","")
483
+ if s and "mediacdn" in s and "avatar" not in s and "logo" not in s:
484
+ img_src=s;break
485
+ if img_src:break
486
+ container=container.parent
487
  seen.add(href)
488
  if not img_src:
489
  try:
490
+ og_r=requests.get(href,headers=HEADERS,timeout=8);og_r.encoding="utf-8"
491
+ og_soup=BeautifulSoup(og_r.text,"lxml");og_tag=og_soup.find("meta",property="og:image")
492
+ if og_tag:img_src=og_tag.get("content","")
493
+ except:pass
494
  articles.append({"title":title,"link":href,"img":img_src,"source":"genk"})
495
+ if len(articles)>=30:break
496
  return articles
497
+ except:return[]
498
 
499
+ def scrape_dantri_article(url):
500
+ try:
501
+ r=requests.get(url,headers=HEADERS,timeout=15);r.encoding="utf-8";soup=BeautifulSoup(r.text,"lxml")
502
+ for tag in soup.find_all(["script","style","nav","footer","aside"]):tag.decompose()
503
+ h1=soup.find("h1");og=soup.find("meta",property="og:image");og_img=og.get("content","") if og else ""
504
+ if og_img and "cdnphoto.dantri" in og_img:og_img="/api/proxy/img?url="+quote(og_img,safe="")
505
+ content=soup.select_one("main") or soup.select_one("div.singular-content") or soup.select_one("article");body=[]
506
+ if content:
507
+ for el in content.find_all(["p","h2","h3","figure","img"],recursive=True):
508
+ if el.name=="p":t=el.get_text(strip=True);(body.append({"type":"p","text":t}) if t and len(t)>15 else None)
509
+ elif el.name in("h2","h3"):t=el.get_text(strip=True);(body.append({"type":"heading","text":t}) if t else None)
510
+ elif el.name in("figure","img"):
511
+ im=el if el.name=="img" else el.find("img")
512
+ if im:
513
+ s=im.get("data-src") or im.get("src","")
514
+ if s and"base64" not in s:
515
+ if "cdnphoto.dantri" in s:s="/api/proxy/img?url="+quote(s,safe="")
516
+ body.append({"type":"img","src":s})
517
+ desc="";sapo=soup.select_one("h2.singular-sapo") or soup.select_one("h2[class*=sapo]")
518
+ if not sapo:
519
+ og_desc=soup.find("meta",property="og:description")
520
+ if og_desc:desc=og_desc.get("content","")
521
+ else:desc=sapo.get_text(strip=True)
522
+ return{"title":h1.get_text(strip=True) if h1 else "","summary":desc,"og_image":og_img,"body":body,"source":"dantri","url":url}
523
+ except:return None
524
+ def scrape_bbc_vietnamese():
525
+ try:
526
+ r=requests.get("https://www.bbc.com/vietnamese",headers={"User-Agent":"Mozilla/5.0","Accept-Language":"en-GB"},timeout=15);r.encoding="utf-8"
527
+ soup=BeautifulSoup(r.text,"lxml");arts=[];seen=set()
528
+ for a in soup.select("a[href*='/vietnamese/']"):
529
+ href=a.get("href","")
530
+ if not href or href=="/vietnamese" or href.count("/")<3:continue
531
+ if not href.startswith("http"):href="https://www.bbc.com"+href
532
+ if href in seen:continue
533
+ title=a.get_text(strip=True)
534
+ if not title or len(title)<15 or any(x in title.lower() for x in["đăng nhập","trang chủ","bbc news"]):continue
535
+ img="";container=a.parent
536
+ for _ in range(3):
537
+ if container:
538
+ im=container.find("img")
539
+ if im:img=im.get("src","") or im.get("data-src","");break
540
+ container=container.parent
541
+ seen.add(href);arts.append({"title":title,"link":href,"img":img,"source":"bbc"})
542
+ if len(arts)>=15:break
543
+ return arts
544
+ except:return[]
545
+ def scrape_bbc_article(url):
546
+ try:
547
+ r=requests.get(url,headers={"User-Agent":"Mozilla/5.0","Accept-Language":"en-GB"},timeout=15);r.encoding="utf-8"
548
+ soup=BeautifulSoup(r.text,"lxml");h1=soup.find("h1")
549
+ og=soup.find("meta",property="og:image");og_img=og.get("content","") if og else ""
550
+ body=[]
551
+ for p in soup.select("[data-component='text-block'] p, article p, main p"):
552
+ t=p.get_text(strip=True)
553
+ if t and len(t)>20:body.append({"type":"p","text":t})
554
+ return{"title":h1.get_text(strip=True) if h1 else "","summary":"","og_image":og_img,"body":body,"source":"bbc","url":url}
555
+ except:return None
556
+
557
+ def scrape_ttvh_worldcup():
558
+ """Scrape all World Cup 2026 articles from The Thao Van Hoa RSS."""
559
+ try:
560
+ r=requests.get("https://thethaovanhoa.vn/rss/world-cup-2026.rss",headers=HEADERS,timeout=15);r.encoding="utf-8"
561
+ soup=BeautifulSoup(r.text,"xml");arts=[];seen=set()
562
+ for it in soup.find_all("item"):
563
+ title=(it.find("title").get_text(strip=True) if it.find("title") else "")
564
+ link=(it.find("link").get_text(strip=True) if it.find("link") else "")
565
+ desc=(it.find("description").get_text(" ",strip=True) if it.find("description") else "")
566
+ img="";ds=BeautifulSoup(desc,"lxml");im=ds.find("img")
567
+ if im:img=im.get("src","") or im.get("data-src","")
568
+ if title and link and link not in seen:
569
+ seen.add(link);arts.append({"title":title,"link":link,"img":img,"source":"ttvh"})
570
+ if arts:return arts
571
+ except:pass
572
+ try:
573
+ soup=_get("https://thethaovanhoa.vn/world-cup-2026.htm");arts=[];seen=set()
574
+ for a in soup.find_all("a",href=True):
575
+ href=a.get("href","")
576
+ if not href.startswith("http"):href="https://thethaovanhoa.vn"+href
577
+ if href in seen or "thethaovanhoa.vn" not in href:continue
578
+ if not re.search(r"/[^/]+-\d{8,}\.htm",href):continue
579
+ title=a.get("title","") or a.get_text(" ",strip=True)
580
+ img=None;p=a
581
+ for _ in range(5):
582
+ if p is None:break
583
+ img=p.find("img")
584
+ if img:break
585
+ p=p.parent
586
+ img_src=""
587
+ if img:
588
+ img_src=img.get("data-src","") or img.get("src","") or img.get("data-original","") or img.get("data-thumb","")
589
+ if len(title)<15:title=img.get("alt","") or img.get("title","") or title
590
+ if not title or len(title)<15:continue
591
+ seen.add(href);arts.append({"title":title,"link":href,"img":img_src,"source":"ttvh"})
592
+ if len(arts)>=24:break
593
+ return arts
594
+ except:return[]
595
+
596
+ def scrape_ttvh_article(url):
597
+ try:
598
+ soup=_get(url);h1=soup.find("h1");og=soup.find("meta",property="og:image");og_img=og.get("content","") if og else ""
599
+ og_title=soup.find("meta",property="og:title");fallback_title=og_title.get("content","") if og_title else ""
600
+ desc_el=soup.find("meta",property="og:description");desc=desc_el.get("content","") if desc_el else ""
601
+ cd=soup.select_one(".detail-content") or soup.select_one(".content-detail") or soup.select_one("article") or soup.select_one("main")
602
+ body=[]
603
+ if cd:
604
+ for el in cd.find_all(["p","h2","h3","figure","img"],recursive=True):
605
+ if el.name=="p":
606
+ t=el.get_text(strip=True)
607
+ if t and len(t)>20 and "Theo dõi" not in t:body.append({"type":"p","text":t})
608
+ elif el.name in ("h2","h3"):
609
+ t=el.get_text(strip=True)
610
+ if t:body.append({"type":"heading","text":t})
611
+ elif el.name in ("figure","img"):
612
+ im=el if el.name=="img" else el.find("img")
613
+ if im:
614
+ src=im.get("data-src") or im.get("src","") or im.get("data-original","")
615
+ if src and "base64" not in src:body.append({"type":"img","src":src})
616
+ if not body and desc:body=[{"type":"p","text":desc}]
617
+ return {"title":h1.get_text(strip=True) if h1 else fallback_title,"summary":desc,"og_image":og_img,"body":body,"source":"ttvh","url":url}
618
+ except:return None
619
+
620
+ VNE_CATS={"thoi-su":("https://vnexpress.net/thoi-su","Thời Sự"),"the-gioi":("https://vnexpress.net/the-gioi","Thế Giới"),"kinh-doanh":("https://vnexpress.net/kinh-doanh","Kinh Doanh"),"the-thao":("https://vnexpress.net/the-thao","Thể Thao"),"giai-tri":("https://vnexpress.net/giai-tri","Giải Trí"),"suc-khoe":("https://vnexpress.net/suc-khoe","Sức Khỏe"),"phap-luat":("https://vnexpress.net/phap-luat","Pháp Luật"),"giao-duc":("https://vnexpress.net/giao-duc","Giáo Dục"),"du-lich":("https://vnexpress.net/du-lich","Du Lịch"),"doi-song":("https://vnexpress.net/doi-song","Đời Sống")}
621
  @app.get("/api/homepage")
622
  def api_homepage():
623
  def _f():
624
  articles=[]
625
  with ThreadPoolExecutor(12) as ex:
626
  futs={ex.submit(scrape_vne,VNE_CATS[k][0]):VNE_CATS[k][1] for k in["thoi-su","the-gioi","kinh-doanh","the-thao","giai-tri","phap-luat","giao-duc","du-lich","doi-song"]}
627
+ futs[ex.submit(scrape_bbc_vietnamese)]="BBC"
628
  for f in as_completed(futs):
629
  try:
630
+ for a in f.result():a["group"]=futs[f];articles.append(a)
631
+ except:pass
632
  return articles
633
  return JSONResponse(_cached("homepage",_f))
 
634
  @app.get("/api/category/{cat_id}")
635
  def api_category(cat_id:str):
636
  def _f():
637
+ if cat_id=="bbc":return scrape_bbc_vietnamese()
638
+ if cat_id=="cong-nghe":return scrape_genk_ai()
639
+ if cat_id in VNE_CATS:arts=scrape_vne(VNE_CATS[cat_id][0]);[a.update({"group":VNE_CATS[cat_id][1]}) for a in arts];return arts
640
+ return[]
 
 
641
  return JSONResponse(_cached(f"cat_{cat_id}",_f))
 
642
  @app.get("/api/categories")
643
  def api_categories():
644
+ cats=[{"id":"bbc","name":"BBC Tiếng Việt","source":"bbc"},{"id":"cong-nghe","name":"Công Nghệ","source":"genk"}]
645
+ for k,(u,n) in VNE_CATS.items():cats.append({"id":k,"name":n,"source":"vne"})
646
  return JSONResponse(cats)
647
+ @app.get("/api/dantri_hot")
648
+ def api_dantri_hot():return JSONResponse(_cached("dantri_hot",scrape_dantri_hot))
649
+ @app.get("/api/genk_ai")
650
+ def api_genk_ai():return JSONResponse(_cached("genk_ai",scrape_genk_ai,ttl=_cache_ttl))
651
+ @app.get("/api/worldcup2026")
652
+ def api_worldcup2026():return JSONResponse(_cached("ttvh_worldcup",scrape_ttvh_worldcup,ttl=_cache_ttl))
653
+ def scrape_genk_article(url):
654
  try:
655
+ r=requests.get(url,headers=HEADERS,timeout=15);r.encoding="utf-8";soup=BeautifulSoup(r.text,"lxml")
656
+ h1=soup.find("h1");og=soup.find("meta",property="og:image");og_img=og.get("content","") if og else ""
657
+ og_title=soup.find("meta",property="og:title");fallback_title=og_title.get("content","") if og_title else ""
658
+ desc_el=soup.find("meta",property="og:description");desc=desc_el.get("content","") if desc_el else ""
659
+ cd=soup.select_one(".knc-content");body=[]
660
+ if cd:
661
+ for el in cd.find_all(["p","h2","h3","figure","img"],recursive=True):
662
+ if el.name=="p":t=el.get_text(strip=True);(body.append({"type":"p","text":t}) if t and len(t)>15 else None)
663
+ elif el.name in("h2","h3"):t=el.get_text(strip=True);(body.append({"type":"heading","text":t}) if t else None)
664
+ elif el.name in("figure","img"):
665
+ im=el if el.name=="img" else el.find("img")
666
+ if im:s=im.get("data-src") or im.get("src","");(body.append({"type":"img","src":s}) if s and"base64" not in s else None)
667
+ return{"title":h1.get_text(strip=True) if h1 else "","summary":desc,"og_image":og_img,"body":body,"source":"genk","url":url}
668
+ except:return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
669
 
670
  @app.get("/api/article")
671
  def api_article(url:str=Query(...)):
672
+ if"vnexpress.net" in url:data=scrape_vne_article(url)
673
+ elif"bbc.com" in url:data=scrape_bbc_article(url)
674
+ elif"dantri.com.vn" in url:data=scrape_dantri_article(url)
675
+ elif"genk.vn" in url:data=scrape_genk_article(url)
676
+ elif"thethaovanhoa.vn" in url:data=scrape_ttvh_article(url)
677
+ else:data=None
678
+ return JSONResponse(data if data else{"error":"not supported"})
679
+ def _web_context(topic):
680
+ """Collect real web/news context for a topic."""
681
+ bits=[]
682
+ try:
683
+ rss="https://news.google.com/rss/search?q="+quote(topic)+"&hl=vi&gl=VN&ceid=VN:vi"
684
+ r=requests.get(rss,headers=HEADERS,timeout=12);r.encoding="utf-8"
685
+ soup=BeautifulSoup(r.text,"xml")
686
+ for it in soup.find_all("item")[:8]:
687
+ title=it.find("title").get_text(" ",strip=True) if it.find("title") else ""
688
+ src=it.find("source").get_text(" ",strip=True) if it.find("source") else ""
689
+ if title:bits.append((title+(" — "+src if src else ""))[:280])
690
+ except:pass
691
+ if bits:return "\n".join(bits)
692
+ try:
693
+ r=requests.get("https://html.duckduckgo.com/html/?q="+quote(topic),headers=HEADERS,timeout=12);r.encoding="utf-8"
694
+ soup=BeautifulSoup(r.text,"lxml")
695
+ for res in soup.select(".result")[:6]:
696
+ t=res.select_one(".result__title");sn=res.select_one(".result__snippet")
697
+ line=((t.get_text(" ",strip=True) if t else "")+" — "+(sn.get_text(" ",strip=True) if sn else "")).strip(" —")
698
+ if line:bits.append(line[:280])
699
+ except:pass
700
+ return "\n".join(bits)
701
+
702
+ def _jina_read(url):
703
+ try:
704
+ ju="https://r.jina.ai/http://"+url
705
+ r=requests.get(ju,headers=HEADERS,timeout=25);r.encoding="utf-8"
706
+ if r.status_code!=200 or not r.text:return None
707
+ lines=[x.rstrip() for x in r.text.splitlines()]
708
+ title="";img="";body=[];summary=""
709
+ for ln in lines[:40]:
710
+ if ln.startswith("Title:"):title=ln.replace("Title:","",1).strip()
711
+ elif ln.startswith("Image:"):img=ln.replace("Image:","",1).strip()
712
+ elif ln.startswith("Description:"):summary=ln.replace("Description:","",1).strip()
713
+ for ln in lines:
714
+ t=ln.strip()
715
+ if not t or t.startswith(("Title:","URL Source:","Published Time:","Markdown Content:","Image:","Description:")):continue
716
+ if len(t)>40:body.append({"type":"p","text":t})
717
+ if not body and summary:body=[{"type":"p","text":summary}]
718
+ return {"title":title or url,"summary":summary,"og_image":img,"body":body[:80],"source":"jina","url":url}
719
+ except:return None
720
+
721
+ def _scrape_generic_article(url):
722
+ try:
723
+ hdr={**HEADERS,"Accept":"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"}
724
+ r=requests.get(url,headers=hdr,timeout=15);r.encoding="utf-8"
725
+ ct=r.headers.get("content-type","").lower()
726
+ if r.status_code>=400 or "text/html" not in ct:
727
+ jr=_jina_read(url)
728
+ if jr:return jr
729
+ soup=BeautifulSoup(r.text,"lxml")
730
+ for tag in soup.find_all(["script","style","nav","footer","aside","form"]):tag.decompose()
731
+ h1=soup.find("h1")
732
+ ogt=soup.find("meta",property="og:title");title=h1.get_text(strip=True) if h1 else (ogt.get("content","") if ogt else "")
733
+ ogd=soup.find("meta",property="og:description");desc=ogd.get("content","") if ogd else ""
734
+ ogi=soup.find("meta",property="og:image");img=ogi.get("content","") if ogi else ""
735
+ main=soup.find("article") or soup.find("main") or soup.body
736
+ body=[]
737
+ if main:
738
+ for el in main.find_all(["p","h2","h3","figure","img"],recursive=True):
739
+ if el.name=="p":
740
+ t=el.get_text(" ",strip=True)
741
+ if t and len(t)>35:body.append({"type":"p","text":t})
742
+ elif el.name in ("h2","h3"):
743
+ t=el.get_text(" ",strip=True)
744
+ if t:body.append({"type":"heading","text":t})
745
+ elif el.name in ("figure","img"):
746
+ im=el if el.name=="img" else el.find("img")
747
+ if im:
748
+ src=im.get("data-src") or im.get("src","") or im.get("data-original","")
749
+ if src and "base64" not in src:body.append({"type":"img","src":src})
750
+ if not body:
751
+ jr=_jina_read(url)
752
+ if jr and jr.get("body"):return jr
753
+ if not body and desc:body=[{"type":"p","text":desc}]
754
+ return {"title":title or url,"summary":desc,"og_image":img,"body":body,"source":"generic","url":url}
755
+ except:
756
+ return _jina_read(url)
757
+
758
+ def _article_by_url(url):
759
+ if "vnexpress.net" in url:return scrape_vne_article(url)
760
+ if "bbc.com" in url:return scrape_bbc_article(url)
761
+ if "dantri.com.vn" in url:return scrape_dantri_article(url)
762
+ if "genk.vn" in url:return scrape_genk_article(url)
763
+ if "thethaovanhoa.vn" in url:return scrape_ttvh_article(url)
764
+ return _scrape_generic_article(url)
765
+
766
+ def _call_qwen(prompt, max_tokens=1800):
767
+ """Try Qwen2.5-VL via HF router; return None if unavailable."""
768
+ try:
769
+ token=os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACEHUB_API_TOKEN") or os.environ.get("VAISTUDIO")
770
+ if not token:return None
771
+ headers={"Authorization":"Bearer "+token,"Content-Type":"application/json"}
772
+ payload={"model":"Qwen/Qwen2.5-VL-7B-Instruct","messages":[{"role":"user","content":prompt}],"max_tokens":max_tokens,"temperature":0.7}
773
+ r=requests.post("https://router.huggingface.co/v1/chat/completions",headers=headers,json=payload,timeout=75)
774
+ if r.status_code>=300:return None
775
+ j=r.json();return j.get("choices",[{}])[0].get("message",{}).get("content")
776
+ except:return None
777
+
778
+ def _collect_article_text(data, limit=28000):
779
+ title=(data or {}).get("title","");summary=(data or {}).get("summary","")
780
+ parts=[]
781
+ if summary:parts.append(summary)
782
+ for b in (data or {}).get("body",[]):
783
+ if b.get("type")=="heading":parts.append("## "+b.get("text","") )
784
+ elif b.get("type")=="p":parts.append(b.get("text","") )
785
+ text="\n".join([p.strip() for p in parts if p and p.strip()])
786
+ return title,text[:limit]
787
+
788
+ def _ai_rewrite_article(data,tone="tu-nhien"):
789
+ title,text=_collect_article_text(data)
790
+ prompt=("Bạn là biên tập viên báo điện tử tiếng Việt. Hãy viết lại bài dưới đây bằng ngôn ngữ tự nhiên, mạch lạc, không cắt khúc, không bỏ ý quan trọng. "
791
+ "Giữ đúng sự thật, không bịa, không thêm thông tin ngoài bài. Văn phong: "+tone+". "
792
+ "Đầu ra gồm: tiêu đề hấp dẫn, đoạn sapo 2-3 câu, các đoạn nội dung ngắn dễ đọc, và 3 gạch đầu dòng điểm chính.\n\n"
793
+ "TIÊU ĐỀ GỐC: "+title+"\n\nNỘI DUNG GỐC:\n"+text)
794
+ out=_call_qwen(prompt,2200)
795
+ if out and len(out)>300:return out.strip()
796
+ # Fallback: complete non-truncated rewrite using full collected text chunks
797
+ paras=[p.strip() for p in text.split("\n") if len(p.strip())>30]
798
+ body="\n\n".join(paras[:18])
799
+ bullets="\n".join(["• "+p[:220]+("..." if len(p)>220 else "") for p in paras[:5]])
800
+ return ("Bản tin AI viết lại: "+title+"\n\n"+
801
+ (paras[0] if paras else "")+"\n\n"+body+"\n\nĐiểm chính:\n"+bullets).strip()
802
+
803
+ def _image_for_topic(topic):
804
+ return "https://image.pollinations.ai/prompt/"+quote("editorial illustration, Vietnamese news, "+topic,safe="")+"?width=1024&height=576&nologo=true"
805
+
806
+ def _topic_articles(topic,limit=5):
807
+ items=[];seen=set()
808
  try:
809
+ rss="https://news.google.com/rss/search?q="+quote(topic)+"&hl=vi&gl=VN&ceid=VN:vi"
810
+ r=requests.get(rss,headers=HEADERS,timeout=12);r.encoding="utf-8"
811
+ soup=BeautifulSoup(r.text,"xml")
812
+ for it in soup.find_all("item")[:limit*3]:
813
+ title=it.find("title").get_text(" ",strip=True) if it.find("title") else ""
814
+ link=it.find("link").get_text(strip=True) if it.find("link") else ""
815
+ src=it.find("source").get_text(" ",strip=True) if it.find("source") else ""
816
+ if not title or not link or link in seen:continue
817
+ seen.add(link);items.append({"title":title,"link":link,"source":src})
818
+ if len(items)>=limit:break
819
+ except:pass
820
+ return items
821
+
822
+ def _topic_article_context(topic):
823
+ """Filter readable article sources by topic, then summarize actual article bodies."""
824
+ raw_keys=[k.lower() for k in re.findall(r"[\wÀ-ỹ]+",topic) if len(k)>2]
825
+ # Drop ultra-generic tokens; keep domain words such as giáo/dục, bóng/đá, world/cup.
826
+ stop={"trong","năm","the","and","của","cho","với","một","các","những","hiện","nay"}
827
+ keys=[k for k in raw_keys if k not in stop]
828
+ candidates=[];seen=set()
829
+ def add_items(items):
830
+ for a in items or []:
831
+ link=a.get("link","");title=a.get("title","")
832
+ if not link or link in seen:continue
833
+ seen.add(link);candidates.append(a)
834
+ try:add_items(scrape_genk_ai())
835
+ except:pass
836
+ try:add_items(scrape_dantri_congnghe())
837
+ except:pass
838
+ try:add_items(scrape_ttvh_worldcup())
839
+ except:pass
840
+ scored=[];img=""
841
+ for a in candidates[:40]:
842
+ data=_article_by_url(a.get("link",""))
843
+ if not data or not data.get("body"):continue
844
+ title=data.get("title") or a.get("title","")
845
+ ps=[b.get("text","") for b in data.get("body",[]) if b.get("type")=="p" and len(b.get("text",""))>40]
846
+ excerpt=" ".join(ps)[:1800] or data.get("summary","")
847
+ hay=(title+" "+excerpt).lower()
848
+ score=sum(1 for k in keys if k in hay)
849
+ # Require topic relevance when we have meaningful keys.
850
+ if keys and score==0:continue
851
+ if len(keys)>=2 and score<2 and not any(" ".join(keys[i:i+2]) in hay for i in range(len(keys)-1)):continue
852
+ scored.append((score,title,a.get("link",""),excerpt,data.get("og_image") or a.get("img","") or ""))
853
+ scored=sorted(scored,key=lambda x:x[0],reverse=True)[:5]
854
+ chunks=[]
855
+ for score,title,link,excerpt,im in scored:
856
+ if not img and im:img=im
857
+ chunks.append("BÀI: "+title+"\nURL: "+link+"\nNỘI DUNG LỌC: "+excerpt)
858
+ if chunks:return "\n\n".join(chunks),img
859
+ return _web_context(topic),""
860
+
861
+ def _topic_post_text(topic):
862
+ ctx,img=_topic_article_context(topic)
863
+ prompt=("Bạn là cây bút báo điện tử tiếng Việt. Hãy lọc các thông tin thực tế trong những nguồn dưới đây để viết một bài tóm tắt theo chủ đề: "+topic+
864
+ ". Không viết chung chung. Chỉ dùng dữ kiện có trong nguồn; nếu nguồn khác nhau thì tổng hợp khách quan. "
865
+ "Đầu ra gồm: tiêu đề, sapo, các ý chính theo bullet, phần phân tích ngắn và kết luận.\n\nNGUỒN THỰC TẾ:\n"+ctx)
866
+ out=_call_qwen(prompt,1800)
867
+ if out and len(out)>300:return out.strip()
868
+ if ctx:
869
+ return "Bài tóm tắt theo chủ đề: "+topic+"\n\nDữ liệu thực tế đã lọc:\n"+ctx[:3500]+"\n\nTóm tắt: Các nguồn trên cho thấy chủ đề này đang có nhiều diễn biến đáng chú ý. Khi viết bài, nên nêu rõ bối cảnh, các điểm mới, tác động thực tế và những điều còn cần kiểm chứng."
870
+ return "Chưa thu thập được dữ liệu đủ rõ cho chủ đề: "+topic
871
+
872
+ @app.get("/api/wall")
873
+ def api_wall():return JSONResponse({"posts":_load_wall()[:50]})
874
+
875
+ @app.post("/api/rewrite_share")
876
+ async def api_rewrite_share(request:Request):
877
+ try:body=await request.json()
878
+ except:body={}
879
+ url=str(body.get("url","")).strip();tone=str(body.get("tone","tu-nhien")).strip()
880
+ if not url:return JSONResponse({"error":"missing url"},status_code=400)
881
+ data=_article_by_url(url)
882
+ if not data or not data.get("title") or (not data.get("body") and not data.get("summary")):
883
+ return JSONResponse({"error":"Không đọc được bài viết"},status_code=422)
884
+ post={"id":hashlib.md5((url+str(time.time())).encode()).hexdigest()[:12],"url":url,"title":data.get("title",""),"img":data.get("og_image","") or "","text":_ai_rewrite_article(data,tone),"ts":int(time.time()),"source":data.get("source","")}
885
+ posts=_load_wall();posts.insert(0,post);_save_wall(posts)
886
+ return JSONResponse({"post":post})
887
+
888
+ @app.post("/api/topic_post")
889
+ async def api_topic_post(request:Request):
890
+ try:body=await request.json()
891
+ except:body={}
892
+ topic=str(body.get("topic","")).strip()
893
+ if not topic:return JSONResponse({"error":"missing topic"},status_code=400)
894
+ ctx_img=_topic_article_context(topic)[1]
895
+ post={"id":hashlib.md5((topic+str(time.time())).encode()).hexdigest()[:12],"url":"","title":topic,"img":ctx_img or _image_for_topic(topic),"text":_topic_post_text(topic),"ts":int(time.time()),"source":"ai-topic"}
896
+ posts=_load_wall();posts.insert(0,post);_save_wall(posts)
897
+ return JSONResponse({"post":post})
898
+
899
+ @app.post("/api/url_wall")
900
+ async def api_url_wall(request:Request):
901
+ try:body=await request.json()
902
+ except:body={}
903
+ url=str(body.get("url","")).strip()
904
+ if not url:return JSONResponse({"error":"missing url"},status_code=400)
905
+ data=_article_by_url(url)
906
+ if not data or not data.get("title"):
907
+ return JSONResponse({"error":"Không đọc được URL"},status_code=422)
908
+ post={"id":hashlib.md5((url+str(time.time())).encode()).hexdigest()[:12],"url":url,"title":data.get("title",""),"img":data.get("og_image","") or "","text":_ai_rewrite_article(data,"ngan-gon-tu-nhien"),"ts":int(time.time()),"source":data.get("source","")}
909
+ posts=_load_wall();posts.insert(0,post);_save_wall(posts)
910
+ return JSONResponse({"post":post})
911
+
912
+ @app.get("/v")
913
+ async def video_share(url:str=Query(default=""),title:str=Query(default="VNEWS Video"),img:str=Query(default=""),type:str=Query(default="highlights")):
914
+ decoded_url=unquote(url);decoded_title=unquote(title)
915
+ redirect_script=f'<script>localStorage.setItem("pending_video",JSON.stringify({{"url":"{decoded_url}","type":"{type}"}}));location.href="{SPACE_URL}";</script>' if decoded_url else f'<script>location.href="{SPACE_URL}";</script>'
916
+ return HTMLResponse(f'<!DOCTYPE html><html><head><meta charset="utf-8"><title>{decoded_title}</title></head><body style="background:#111;color:#fff;text-align:center;padding:40px"><p>⏳</p>{redirect_script}</body></html>')
917
+ @app.get("/s")
918
+ async def share_redirect(url:str=Query(default=""),title:str=Query(default="VNEWS"),img:str=Query(default="")):
919
+ decoded_url=unquote(url)
920
+ redirect_script=f'<script>localStorage.setItem("pending_article","{decoded_url}");location.href="{SPACE_URL}";</script>' if decoded_url else f'<script>location.href="{SPACE_URL}";</script>'
921
+ return HTMLResponse(f'<!DOCTYPE html><html><head><meta charset="utf-8"><title>{unquote(title)}</title></head><body>{redirect_script}</body></html>')
922
+ @app.get("/")
923
+ async def index():
924
+ with open("/app/static/index.html","r",encoding="utf-8") as f:return HTMLResponse(content=f.read())
925
+ app.mount("/static",StaticFiles(directory="/app/static"),name="static")
main_patch.py DELETED
@@ -1,8 +0,0 @@
1
- # PATCH: Add these 2 lines to main.py right after "app = FastAPI()"
2
- # Line 1: from vtv_api import router as vtv_router
3
- # Line 2: app.include_router(vtv_router)
4
- #
5
- # This enables the VTV1-VTV10 + VTVPrime stream endpoints:
6
- # GET /api/vtv/streams - Get all channel streams
7
- # GET /api/vtv/stream/{id} - Get specific channel stream
8
- # GET /api/proxy/page?url=... - Proxy web pages (for xemtv PHP scraping)
 
 
 
 
 
 
 
 
 
match_detail.py DELETED
@@ -1,309 +0,0 @@
1
- """
2
- Match Detail Scraper for bongda.com.vn
3
- """
4
- import requests, re, json, time, threading
5
- from bs4 import BeautifulSoup
6
-
7
- def _sp(html):
8
- try:
9
- return BeautifulSoup(html, 'lxml')
10
- except:
11
- return BeautifulSoup(html, 'html.parser')
12
-
13
- BH = {
14
- "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",
15
- "Accept": "application/json, text/javascript, */*; q=0.01",
16
- "Referer": "https://bongda.com.vn/",
17
- "X-Requested-With": "XMLHttpRequest",
18
- }
19
- HH = {
20
- "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",
21
- "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
22
- "Referer": "https://bongda.com.vn/",
23
- }
24
-
25
- def _cl(s):
26
- return re.sub(r'\s+', ' ', str(s or '')).strip()
27
-
28
- def _api(ep, params=None):
29
- try:
30
- url = f"https://bongda.com.vn{ep}"
31
- if params:
32
- url += "?" + "&".join(f"{k}={v}" for k, v in params.items())
33
- r = requests.get(url, headers=BH, timeout=15)
34
- if r.status_code == 200:
35
- try: return r.json()
36
- except: pass
37
- except: pass
38
- return None
39
-
40
- def _get_teams(soup):
41
- info = {}
42
- tel = soup.select_one('.teams')
43
- if not tel:
44
- return info
45
- he = tel.select_one('.team.home, .home-team')
46
- if he:
47
- ne = he.select_one('p:not(.logo)') or he.find('p')
48
- if ne: info['home_team'] = _cl(ne.get_text())
49
- lo = he.select_one('img')
50
- if lo: info['home_logo'] = lo.get('src', '')
51
- le = he if he.name == 'a' else he.find('a')
52
- if le and le.get('href'):
53
- m = re.search(r'/doi-bong/(\d+)/', le['href'])
54
- if m: info['home_team_id'] = m.group(1)
55
- ae = tel.select_one('.team.away, .away-team')
56
- if ae:
57
- ne = ae.select_one('p:not(.logo)') or ae.find('p')
58
- if ne: info['away_team'] = _cl(ne.get_text())
59
- lo = ae.select_one('img')
60
- if lo: info['away_logo'] = lo.get('src', '')
61
- le = ae if ae.name == 'a' else ae.find('a')
62
- if le and le.get('href'):
63
- m = re.search(r'/doi-bong/(\d+)/', le['href'])
64
- if m: info['away_team_id'] = m.group(1)
65
- sc = tel.select_one('.score')
66
- if sc:
67
- parts = [_cl(p.get_text()) for p in sc.select('p')]
68
- if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}"
69
- lb = sc.select_one('.label')
70
- if lb: info['status_label'] = _cl(lb.get_text())
71
- return info
72
-
73
- def _get_timeline(soup):
74
- tl = []
75
- el = soup.select_one('.timeline')
76
- if not el: return tl
77
- half = ''
78
- for c in el.children:
79
- if not hasattr(c, 'name') or not c.name: continue
80
- t = _cl(c.get_text())
81
- if not t: continue
82
- if t in ['H1','H2','Hiệp 1','Hiệp 2']:
83
- half = t; continue
84
- m = re.match(r"(\d+'\+?\d*)", t)
85
- if m:
86
- tl.append({'time': m.group(1), 'text': t[m.end():].strip(), 'half': half})
87
- elif len(t) > 5:
88
- tl.append({'time': '', 'text': t, 'half': half})
89
- return tl
90
-
91
- def _get_events(soup):
92
- evts = []
93
- for el in soup.select('.event'):
94
- e = {}
95
- cl = ' '.join(el.get('class', []))
96
- e['team'] = 'home' if 'home' in cl else ('away' if 'away' in cl else '')
97
- ps = [_cl(p.get_text()) for p in el.select('p')]
98
- ps = [p for p in ps if p]
99
- if ps: e['players'] = ps
100
- tl = el.select_one('.time, .minute, span')
101
- if tl: e['time'] = _cl(tl.get_text())
102
- evts.append(e)
103
- return evts
104
-
105
- def _get_stats(soup):
106
- st = {}
107
- for sel in ['.match-stats','[class*="stats"]']:
108
- el = soup.select_one(sel)
109
- if el and len(str(el)) > 50:
110
- for row in el.select('li,tr,.stat-row'):
111
- cells = row.select('td,span,p')
112
- if len(cells) >= 3:
113
- lb = _cl(cells[0].get_text())
114
- if lb: st[lb] = {'home': _cl(cells[1].get_text()), 'away': _cl(cells[2].get_text())}
115
- if st: break
116
- return st
117
-
118
- def _get_h2h(soup):
119
- h2h = {'matches': [], 'stats': {}}
120
- for sel in ['.head-to-head','[class*="h2h"]']:
121
- el = soup.select_one(sel)
122
- if el and len(str(el)) > 50:
123
- for it in el.select('li,tr,.match-item'):
124
- m = {}
125
- cells = it.select('td,span,p')
126
- if len(cells) >= 3:
127
- m['date'] = _cl(cells[0].get_text())
128
- m['home'] = _cl(cells[1].get_text())
129
- m['score'] = _cl(cells[2].get_text())
130
- if m.get('home'):
131
- if len(cells) > 3: m['away'] = _cl(cells[3].get_text())
132
- h2h['matches'].append(m)
133
- if h2h['matches']: break
134
- return h2h
135
-
136
- def _get_form(soup):
137
- f = {'home': [], 'away': []}
138
- for sel in ['.form-guide','[class*="form"]']:
139
- el = soup.select_one(sel)
140
- if el and len(str(el)) > 50:
141
- items = el.select('li,.form-item,tr')
142
- for it in items[:10]:
143
- t = _cl(it.get_text())
144
- if t: f['home'].append({'text': t})
145
- for it in items[10:20]:
146
- t = _cl(it.get_text())
147
- if t: f['away'].append({'text': t})
148
- break
149
- return f
150
-
151
- def _get_info(soup):
152
- info = {}
153
- mi = soup.select_one('.match-info')
154
- if mi:
155
- te = mi.select_one('.times,li')
156
- if te: info['datetime'] = _cl(te.get_text())
157
- le = soup.select_one('.league,.tournament,[class*="league"]')
158
- if le: info['league'] = _cl(le.get_text())
159
- return info
160
-
161
- def _scrape(url):
162
- print(f"[DEBUG] _scrape: {url[:80]}", flush=True)
163
- try:
164
- r = requests.get(url, headers=HH, timeout=15, allow_redirects=True)
165
- print(f"[DEBUG] HTTP={r.status_code}", flush=True)
166
- if r.status_code != 200:
167
- return False, {}
168
- sp = _sp(r.text)
169
- d = {}
170
-
171
- teams = _get_teams(sp)
172
- print(f"[DEBUG] teams={teams}", flush=True)
173
- if teams: d['info'] = teams
174
-
175
- mi = _get_info(sp)
176
- if mi:
177
- d.setdefault('info', {}).update(mi)
178
-
179
- tl = _get_timeline(sp)
180
- if tl:
181
- d['timeline'] = tl
182
- d['commentaries_html'] = '\n'.join([f"{t.get('time','')} {t.get('text','')}" for t in tl])
183
-
184
- ev = _get_events(sp)
185
- if ev: d['events'] = ev
186
-
187
- st = _get_stats(sp)
188
- if st:
189
- d['stats_parsed'] = st
190
- d['stats_html'] = str(st)
191
-
192
- h2h = _get_h2h(sp)
193
- if h2h.get('matches'): d['h2h_matches'] = h2h['matches']
194
- if h2h.get('stats'): d['h2h_stats'] = h2h['stats']
195
-
196
- if '/preview/' in url:
197
- fm = _get_form(sp)
198
- if fm.get('home'): d['home_form'] = fm['home']
199
- if fm.get('away'): d['away_form'] = fm['away']
200
-
201
- print(f"[DEBUG] success keys={list(d.keys())}", flush=True)
202
- return True, d
203
- except Exception as e:
204
- import traceback
205
- print(f"[DEBUG] error: {e}", flush=True)
206
- traceback.print_exc()
207
- return False, {}
208
-
209
- def fetch_match_detail_by_url(url):
210
- m = re.search(r'/tran-dau/(\d+)/', url)
211
- if not m: return {"error": "Could not extract event_id", "found": False}
212
- event_id = int(m.group(1))
213
- res = {"event_id": event_id, "found": False, "sections": []}
214
- _fetch_api(event_id, res)
215
- ok, d = _scrape(url)
216
- print(f"[DEBUG] by_url: ok={ok} d_keys={list(d.keys())}", flush=True)
217
- if ok: _merge(res, d)
218
- return res
219
-
220
- def fetch_match_detail(event_id):
221
- print(f"[DEBUG] fetch_match_detail({event_id})", flush=True)
222
- res = {"event_id": event_id, "found": False, "sections": []}
223
- _fetch_api(event_id, res)
224
-
225
- for pt in ["centre", "preview"]:
226
- url = f"https://bongda.com.vn/tran-dau/{event_id}/{pt}/"
227
- ok, d = _scrape(url)
228
- print(f"[DEBUG] {pt}: ok={ok}", flush=True)
229
- if ok:
230
- _merge(res, d)
231
- if res.get("found"): break
232
-
233
- print(f"[DEBUG] final: found={res['found']} sections={res['sections']}", flush=True)
234
- return res
235
-
236
- def _fetch_api(eid, res):
237
- pm = _api("/api/event-standing/pre-match", {"event_id": eid})
238
- res["pre_match"] = pm
239
- res["pre_match_html"] = pm.get("html","") if pm and pm.get("status")=="success" and len(pm.get("html","").strip())>10 else ""
240
-
241
- hm = _api("/api/fixtures/h2h-match", {"event_id": eid})
242
- res["h2h_match"] = hm
243
- if hm and hm.get("status")=="success":
244
- h = hm.get("html","")
245
- if len(h.strip())>10:
246
- res["h2h_html"] = h
247
- res["sections"].append("h2h")
248
- else: res["h2h_html"] = ""
249
-
250
- hs = _api("/api/fixtures/h2h-stats", {"event_id": eid})
251
- res["h2h_stats"] = hs
252
- if hs and hs.get("status")=="success":
253
- h = hs.get("html","")
254
- if len(h.strip())>10:
255
- res["h2h_stats_html"] = h
256
- res["sections"].append("h2h_stats")
257
- try:
258
- sp = _sp(h)
259
- stats = {}
260
- for row in sp.select('li,tr,.stat-row'):
261
- cells = row.select('td,span,p')
262
- if len(cells)>=3:
263
- lb = _cl(cells[0].get_text())
264
- if lb: stats[lb] = {'home': _cl(cells[1].get_text()), 'away': _cl(cells[2].get_text())}
265
- if stats: res["h2h_stats_parsed"] = stats
266
- except: pass
267
- else: res["h2h_stats_html"] = ""
268
-
269
- pf = _api("/api/event-standing/player-performance", {"event_id": eid})
270
- res["performance"] = pf
271
- if pf and pf.get("status")=="success" and len(pf.get("html","").strip())>10:
272
- res["stats_html"] = pf["html"]
273
- res["sections"].append("stats")
274
- else: res["stats_html"] = ""
275
-
276
- cm = _api("/api/fixtures/commentaries", {"event_id": eid})
277
- if cm and cm.get("status")=="success" and len(cm.get("html","").strip())>10:
278
- res["commentaries_html"] = cm["html"]
279
- res["sections"].append("commentaries")
280
- elif not res.get("commentaries_html"): res["commentaries_html"] = ""
281
-
282
- def _merge(res, d):
283
- if d.get("info"):
284
- res.setdefault("info", {}).update(d["info"])
285
- res["found"] = True
286
- if "info" not in res["sections"]: res["sections"].append("info")
287
- if d.get("timeline"):
288
- res["timeline"] = d["timeline"]
289
- if not res.get("commentaries_html"): res["commentaries_html"] = d.get("commentaries_html","")
290
- res["sections"].append("commentaries")
291
- if d.get("events"):
292
- res["events"] = d["events"]
293
- res["sections"].append("events")
294
- if d.get("stats_parsed"):
295
- res["stats_parsed"] = d["stats_parsed"]
296
- if not res.get("stats_html"): res["stats_html"] = d.get("stats_html","")
297
- res["sections"].append("stats")
298
- if d.get("h2h_matches"):
299
- res["h2h"] = d["h2h_matches"]
300
- res["sections"].append("h2h")
301
- if d.get("h2h_stats"):
302
- res["h2h_stats_parsed"] = d["h2h_stats"]
303
- res["sections"].append("h2h_stats")
304
- if d.get("home_form"):
305
- res["home_form"] = d["home_form"]
306
- res["sections"].append("home_form")
307
- if d.get("away_form"):
308
- res["away_form"] = d["away_form"]
309
- res["sections"].append("away_form")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
match_detail_v2.py DELETED
@@ -1,418 +0,0 @@
1
- """VNEWS — Match Detail Parser v2 (html.parser only, no lxml dependency)"""
2
- import re
3
- import requests
4
- from bs4 import BeautifulSoup
5
-
6
- HEADERS = {
7
- "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",
8
- "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
9
- "Accept-Language": "vi-VN,vi;q=0.9",
10
- "Referer": "https://bongda.com.vn/",
11
- }
12
-
13
- API_HEADERS = {
14
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
15
- "Accept": "application/json, text/javascript, */*; q=0.01",
16
- "X-Requested-With": "XMLHttpRequest",
17
- "Referer": "https://bongda.com.vn/",
18
- }
19
-
20
-
21
- def _cl(s):
22
- return re.sub(r'\s+', ' ', str(s or '')).strip()
23
-
24
-
25
- def _normalize_time(raw):
26
- t = _cl(raw)
27
- if not t:
28
- return t
29
- t = re.sub(r"(\d+)'\s*\+(\d+)", r"\1+\2'", t)
30
- t = t.replace("''", "'")
31
- return t
32
-
33
-
34
- def _mk(html):
35
- """Parse HTML using html.parser (lxml may not be available)."""
36
- return BeautifulSoup(html, 'html.parser')
37
-
38
-
39
- def fetch_html(url, timeout=8):
40
- resp = requests.get(url, headers=HEADERS, timeout=timeout, allow_redirects=True)
41
- resp.raise_for_status()
42
- return resp.text
43
-
44
-
45
- def parse_events(sp):
46
- """Parse .events > .period > .event structure."""
47
- events = []
48
- events_div = sp.select_one('.events')
49
- if not events_div:
50
- return events
51
-
52
- current_period = ''
53
- for child in events_div.children:
54
- if not hasattr(child, 'name') or not child.name:
55
- continue
56
- cls_str = ' '.join(child.get('class', []) if child.get('class') else [])
57
-
58
- if 'period' in cls_str:
59
- h2 = child.find('h2')
60
- if h2:
61
- current_period = _cl(h2.get_text())
62
-
63
- for ev in child.children:
64
- if not hasattr(ev, 'name') or not ev.name:
65
- continue
66
- ev_cls_str = ' '.join(ev.get('class', []) if ev.get('class') else [])
67
- if 'event' not in ev_cls_str:
68
- continue
69
-
70
- team = 'home' if 'home' in ev_cls_str else 'away'
71
- ev_data = {
72
- 'team': team, 'period': current_period, 'type': 'unknown',
73
- 'time': '', 'players': '', 'player_in': '', 'player_out': '',
74
- 'scorer': '', 'assist': '', 'card_type': '', 'player': '',
75
- }
76
-
77
- type_el = ev.select_one('.event-type')
78
- if type_el:
79
- if type_el.select_one('[class*="redcard"]'):
80
- ev_data['type'] = 'redcard'; ev_data['card_type'] = 'red'
81
- elif type_el.select_one('[class*="yellowcard"]'):
82
- ev_data['type'] = 'yellowcard'; ev_data['card_type'] = 'yellow'
83
- elif type_el.select_one('[class*="goal"]'):
84
- ev_data['type'] = 'goal'
85
- elif type_el.select_one('[class*="substitution"]'):
86
- ev_data['type'] = 'substitution'
87
- else:
88
- for rect in type_el.select('svg rect'):
89
- if rect.get('fill') == '#E20007':
90
- ev_data['type'] = 'redcard'; ev_data['card_type'] = 'red'; break
91
- if ev_data['type'] == 'unknown':
92
- for circle in type_el.select('svg circle'):
93
- if circle.get('fill') == 'white' and circle.get('r') == '8':
94
- ev_data['type'] = 'goal'; break
95
- if ev_data['type'] == 'unknown' and ev.select_one('.players.subst'):
96
- ev_data['type'] = 'substitution'
97
-
98
- players_el = ev.select_one('.players')
99
- if players_el and ev_data['type'] == 'unknown':
100
- pcls = ' '.join(players_el.get('class', []) if players_el.get('class') else [])
101
- if 'goal' in pcls: ev_data['type'] = 'goal'
102
- elif 'card' in pcls: ev_data['type'] = 'redcard'; ev_data['card_type'] = 'red'
103
- elif 'subst' in pcls: ev_data['type'] = 'substitution'
104
-
105
- if players_el:
106
- time_el = players_el.select_one('.event-time')
107
- if time_el:
108
- ev_data['time'] = _normalize_time(time_el.get_text())
109
- ev_data['players'] = _cl(players_el.get_text(' ', strip=True))
110
-
111
- texts = []
112
- for d in players_el.find_all('div', recursive=False):
113
- t = _cl(d.get_text())
114
- if t and t != ev_data['time']:
115
- texts.append(t)
116
- for p in players_el.find_all('p', recursive=False):
117
- t = _cl(p.get_text())
118
- if t and t not in texts:
119
- texts.append(t)
120
-
121
- if ev_data['type'] == 'substitution':
122
- if len(texts) >= 2:
123
- ev_data['player_out'] = texts[0]; ev_data['player_in'] = texts[1]
124
- elif len(texts) == 1:
125
- ev_data['player_in'] = texts[0]
126
- elif ev_data['type'] == 'goal':
127
- if len(texts) >= 1: ev_data['scorer'] = texts[0]
128
- if len(texts) >= 2: ev_data['assist'] = texts[1]
129
- elif ev_data['type'] in ('redcard', 'yellowcard'):
130
- if texts: ev_data['player'] = ' '.join(texts)
131
-
132
- events.append(ev_data)
133
- return events
134
-
135
-
136
- def fetch_match_detail(event_id: int) -> dict:
137
- import concurrent.futures
138
- result = {"event_id": event_id, "found": False, "sections": []}
139
-
140
- html = None
141
- base = f"https://bongda.com.vn/tran-dau/{event_id}"
142
- urls = [base + suffix for suffix in ['/centre/', '/preview/', '/bao-cao-nhanh/']]
143
-
144
- # Try all URLs in parallel, take first success
145
- with concurrent.futures.ThreadPoolExecutor(max_workers=3) as ex:
146
- futures = {ex.submit(requests.get, url, headers=HEADERS, timeout=8, allow_redirects=True): url for url in urls}
147
- for future in concurrent.futures.as_completed(futures, timeout=12):
148
- try:
149
- resp = future.result()
150
- if resp.status_code == 200 and len(resp.text) > 1000:
151
- html = resp.text
152
- for f in futures:
153
- f.cancel()
154
- break
155
- except Exception:
156
- continue
157
-
158
- if not html:
159
- return result
160
-
161
- sp = _mk(html)
162
- info = {}
163
-
164
- tel = sp.select_one('.teams')
165
- if tel:
166
- he = tel.select_one('.team.home') or tel.select_one('[class*="home"]')
167
- if he:
168
- ne = he.select_one('p:not(.logo)') or he.find('p')
169
- if ne: info['home_team'] = _cl(ne.get_text())
170
- lo = he.select_one('img')
171
- if lo: info['home_logo'] = lo.get('src', '')
172
-
173
- ae = tel.select_one('.team.away') or tel.select_one('[class*="away"]')
174
- if ae:
175
- ne = ae.select_one('p:not(.logo)') or ae.find('p')
176
- if ne: info['away_team'] = _cl(ne.get_text())
177
- lo = ae.select_one('img')
178
- if lo: info['away_logo'] = lo.get('src', '')
179
-
180
- sc = tel.select_one('.score')
181
- if sc:
182
- parts = [_cl(p.get_text()) for p in sc.select('p')]
183
- if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}"
184
- lb = sc.select_one('.label')
185
- if lb: info['status_label'] = _cl(lb.get_text())
186
-
187
- if info.get('home_team') and info.get('away_team'):
188
- result['info'] = info
189
- result['found'] = True
190
- result['sections'].append('info')
191
- else:
192
- return result
193
-
194
- mi = sp.select_one('.match-info')
195
- if mi:
196
- for sel in ['.times', 'li']:
197
- el = mi.select_one(sel)
198
- if el:
199
- t = _cl(el.get_text())
200
- if t: info.setdefault('datetime', t); break
201
-
202
- events = parse_events(sp)
203
- if events:
204
- result['events'] = events
205
- result['sections'].append('events')
206
-
207
- pred = sp.select_one('.prediction-card')
208
- if pred:
209
- pred_data = {}
210
- team_info = pred.select_one('.team-info')
211
- if team_info:
212
- teams = team_info.select('.team')
213
- if len(teams) >= 2:
214
- pred_data['home_name'] = _cl(teams[0].select_one('.team-name').get_text()) if teams[0].select_one('.team-name') else ''
215
- pred_data['away_name'] = _cl(teams[1].select_one('.team-name').get_text()) if teams[1].select_one('.team-name') else ''
216
- divider = team_info.select_one('.divider')
217
- if divider: pred_data['result'] = _cl(divider.get_text())
218
- vote_count = pred.select_one('.vote-count')
219
- if vote_count: pred_data['vote_count'] = _cl(vote_count.get_text())
220
- result['prediction'] = pred_data
221
-
222
- try:
223
- ar = requests.get(
224
- f"https://bongda.com.vn/api/fixtures/h2h-stats?event_id={event_id}",
225
- headers=API_HEADERS, timeout=6
226
- )
227
- if ar.status_code == 200:
228
- ad = ar.json()
229
- if ad.get('status') == 'success' and ad.get('html'):
230
- asp = _mk(ad['html'])
231
- ast = {}
232
- for row in asp.select('li, tr, .stat-row'):
233
- cells = row.select('td, span, p')
234
- if len(cells) >= 3:
235
- lb = _cl(cells[0].get_text())
236
- if lb: ast[lb] = {'home': _cl(cells[1].get_text()), 'away': _cl(cells[2].get_text())}
237
- if ast: result['h2h_stats_parsed'] = ast; result['sections'].append('h2h_stats')
238
- except Exception:
239
- pass
240
-
241
- h2h_data = []
242
- h2h_el = sp.select_one('.h2h-standings')
243
- if h2h_el:
244
- rows = h2h_el.select('.ranking-table tbody tr, .leaderboard tr')
245
- for row in rows:
246
- cells = row.select('td')
247
- if len(cells) >= 4:
248
- logo = row.select_one('img')
249
- name_el = row.select_one('.team-name, p.link, .name')
250
- h2h_data.append({
251
- 'pos': _cl(cells[0].get_text()),
252
- 'logo': logo.get('src', '') if logo else '',
253
- 'name': _cl(name_el.get_text()) if name_el else '',
254
- 'played': _cl(cells[1].get_text()) if len(cells) > 1 else '',
255
- 'wins': _cl(cells[2].get_text()) if len(cells) > 2 else '',
256
- 'draws': _cl(cells[3].get_text()) if len(cells) > 3 else '',
257
- 'losses': _cl(cells[4].get_text()) if len(cells) > 4 else '',
258
- 'gf': _cl(cells[5].get_text()) if len(cells) > 5 else '',
259
- 'ga': _cl(cells[6].get_text()) if len(cells) > 6 else '',
260
- 'points': _cl(cells[8].get_text()) if len(cells) > 8 else '',
261
- })
262
- if h2h_data: result['h2h_standings'] = h2h_data; result['sections'].append('h2h_standings')
263
-
264
- recent_matches = []
265
- matches_list = sp.select_one('.matches-list')
266
- if matches_list:
267
- for item in matches_list.select('.match-detail, .match-item, li'):
268
- date_el = item.select_one('.date, .time, .match-time')
269
- league_el = item.select_one('.league')
270
- home_el = item.select_one('.home, .team-home')
271
- away_el = item.select_one('.away, .team-away')
272
- score_el = item.select_one('.score, .result')
273
- if home_el or away_el:
274
- recent_matches.append({
275
- 'date': _cl(date_el.get_text()) if date_el else '',
276
- 'league': _cl(league_el.get_text()) if league_el else '',
277
- 'home': _cl(home_el.get_text()) if home_el else '',
278
- 'away': _cl(away_el.get_text()) if away_el else '',
279
- 'score': _cl(score_el.get_text()) if score_el else 'vs',
280
- })
281
- if recent_matches: result['recent_matches'] = recent_matches; result['sections'].append('recent')
282
-
283
- return result
284
-
285
-
286
- def fetch_match_detail_by_url(url: str) -> dict:
287
- import concurrent.futures
288
- eid_match = re.search(r'/tran-dau/(\d+)/', url)
289
- if not eid_match:
290
- return {"event_id": 0, "found": False, "error": "Cannot extract event_id from URL"}
291
- event_id = int(eid_match.group(1))
292
- result = {"event_id": event_id, "found": False, "sections": []}
293
-
294
- html = None
295
- try:
296
- resp = requests.get(url, headers=HEADERS, timeout=8, allow_redirects=True)
297
- if resp.status_code == 200 and len(resp.text) > 1000:
298
- html = resp.text
299
- except Exception:
300
- pass
301
-
302
- if not html:
303
- return fetch_match_detail(event_id)
304
-
305
- sp = _mk(html)
306
- info = {}
307
-
308
- tel = sp.select_one('.teams')
309
- if tel:
310
- he = tel.select_one('.team.home') or tel.select_one('[class*="home"]')
311
- if he:
312
- ne = he.select_one('p:not(.logo)') or he.find('p')
313
- if ne: info['home_team'] = _cl(ne.get_text())
314
- lo = he.select_one('img')
315
- if lo: info['home_logo'] = lo.get('src', '')
316
- ae = tel.select_one('.team.away') or tel.select_one('[class*="away"]')
317
- if ae:
318
- ne = ae.select_one('p:not(.logo)') or ae.find('p')
319
- if ne: info['away_team'] = _cl(ne.get_text())
320
- lo = ae.select_one('img')
321
- if lo: info['away_logo'] = lo.get('src', '')
322
- sc = tel.select_one('.score')
323
- if sc:
324
- parts = [_cl(p.get_text()) for p in sc.select('p')]
325
- if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}"
326
- lb = sc.select_one('.label')
327
- if lb: info['status_label'] = _cl(lb.get_text())
328
-
329
- if info.get('home_team') and info.get('away_team'):
330
- result['info'] = info; result['found'] = True; result['sections'].append('info')
331
- else:
332
- return fetch_match_detail(event_id)
333
-
334
- mi = sp.select_one('.match-info')
335
- if mi:
336
- te = mi.select_one('.times, li')
337
- if te: info.setdefault('datetime', _cl(te.get_text()))
338
-
339
- events = parse_events(sp)
340
- if events: result['events'] = events; result['sections'].append('events')
341
-
342
- pred = sp.select_one('.prediction-card')
343
- if pred:
344
- pred_data = {}
345
- team_info = pred.select_one('.team-info')
346
- if team_info:
347
- teams = team_info.select('.team')
348
- if len(teams) >= 2:
349
- pred_data['home_name'] = _cl(teams[0].select_one('.team-name').get_text()) if teams[0].select_one('.team-name') else ''
350
- pred_data['away_name'] = _cl(teams[1].select_one('.team-name').get_text()) if teams[1].select_one('.team-name') else ''
351
- divider = team_info.select_one('.divider')
352
- if divider: pred_data['result'] = _cl(divider.get_text())
353
- vote_count = pred.select_one('.vote-count')
354
- if vote_count: pred_data['vote_count'] = _cl(vote_count.get_text())
355
- result['prediction'] = pred_data
356
-
357
- try:
358
- ar = requests.get(
359
- f"https://bongda.com.vn/api/fixtures/h2h-stats?event_id={event_id}",
360
- headers=API_HEADERS, timeout=6
361
- )
362
- if ar.status_code == 200:
363
- ad = ar.json()
364
- if ad.get('status') == 'success' and ad.get('html'):
365
- asp = _mk(ad['html'])
366
- ast = {}
367
- for row in asp.select('li, tr, .stat-row'):
368
- cells = row.select('td, span, p')
369
- if len(cells) >= 3:
370
- lb = _cl(cells[0].get_text())
371
- if lb: ast[lb] = {'home': _cl(cells[1].get_text()), 'away': _cl(cells[2].get_text())}
372
- if ast: result['h2h_stats_parsed'] = ast; result['sections'].append('h2h_stats')
373
- except Exception:
374
- pass
375
-
376
- h2h_data = []
377
- h2h_el = sp.select_one('.h2h-standings')
378
- if h2h_el:
379
- rows = h2h_el.select('.ranking-table tbody tr, .leaderboard tr')
380
- for row in rows:
381
- cells = row.select('td')
382
- if len(cells) >= 4:
383
- logo = row.select_one('img')
384
- name_el = row.select_one('.team-name, p.link, .name')
385
- h2h_data.append({
386
- 'pos': _cl(cells[0].get_text()),
387
- 'logo': logo.get('src', '') if logo else '',
388
- 'name': _cl(name_el.get_text()) if name_el else '',
389
- 'played': _cl(cells[1].get_text()) if len(cells) > 1 else '',
390
- 'wins': _cl(cells[2].get_text()) if len(cells) > 2 else '',
391
- 'draws': _cl(cells[3].get_text()) if len(cells) > 3 else '',
392
- 'losses': _cl(cells[4].get_text()) if len(cells) > 4 else '',
393
- 'gf': _cl(cells[5].get_text()) if len(cells) > 5 else '',
394
- 'ga': _cl(cells[6].get_text()) if len(cells) > 6 else '',
395
- 'points': _cl(cells[8].get_text()) if len(cells) > 8 else '',
396
- })
397
- if h2h_data: result['h2h_standings'] = h2h_data; result['sections'].append('h2h_standings')
398
-
399
- recent_matches = []
400
- matches_list = sp.select_one('.matches-list')
401
- if matches_list:
402
- for item in matches_list.select('.match-detail, .match-item, li'):
403
- date_el = item.select_one('.date, .time, .match-time')
404
- league_el = item.select_one('.league')
405
- home_el = item.select_one('.home, .team-home')
406
- away_el = item.select_one('.away, .team-away')
407
- score_el = item.select_one('.score, .result')
408
- if home_el or away_el:
409
- recent_matches.append({
410
- 'date': _cl(date_el.get_text()) if date_el else '',
411
- 'league': _cl(league_el.get_text()) if league_el else '',
412
- 'home': _cl(home_el.get_text()) if home_el else '',
413
- 'away': _cl(away_el.get_text()) if away_el else '',
414
- 'score': _cl(score_el.get_text()) if score_el else 'vs',
415
- })
416
- if recent_matches: result['recent_matches'] = recent_matches; result['sections'].append('recent')
417
-
418
- return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_extra.py CHANGED
@@ -35,7 +35,7 @@ window.makeShortFromPost=async function(pid,btn){
35
  var r=await fetch('/api/ai/short/'+pid,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:'nu',emotion:'neutral',speed:1.2})});
36
  var j=await r.json();
37
  if(!r.ok||j.error)throw new Error(j.error||'Lỗi');
38
- showShortProgress('✅ Đ�ã tạo Short AI!');
39
  setTimeout(hideShortProgress,3000);
40
  if(typeof renderShortAISlide==='function')renderShortAISlide();
41
  }catch(e){
 
35
  var r=await fetch('/api/ai/short/'+pid,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:'nu',emotion:'neutral',speed:1.2})});
36
  var j=await r.json();
37
  if(!r.ok||j.error)throw new Error(j.error||'Lỗi');
38
+ showShortProgress('✅ Đã tạo Short AI!');
39
  setTimeout(hideShortProgress,3000);
40
  if(typeof renderShortAISlide==='function')renderShortAISlide();
41
  }catch(e){
piped_client.py DELETED
@@ -1,258 +0,0 @@
1
- """
2
- YouTube Shorts Scraper using Piped API
3
- Piped is a privacy-friendly YouTube proxy that works without JS
4
- """
5
- import requests
6
- import json
7
- import time
8
- import threading
9
-
10
- _cache = {}
11
- _lock = threading.Lock()
12
- CACHE_TTL = 900 # 15 min
13
-
14
- # Piped API instances (public)
15
- PIPED_INSTANCES = [
16
- "https://pipedapi.kavin.rocks",
17
- "https://pipedapi.adminforge.de",
18
- "https://api.piped.projectsegfau.lt",
19
- ]
20
-
21
- UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
22
-
23
- def _cached(key):
24
- with _lock:
25
- if key in _cache and time.time() - _cache[key]['t'] < CACHE_TTL:
26
- return _cache[key]['d']
27
- return None
28
-
29
- def _set_cache(key, data):
30
- with _lock:
31
- _cache[key] = {'t': time.time(), 'd': data}
32
-
33
- def _piped_request(path, params=None):
34
- """Try multiple Piped instances"""
35
- last_err = None
36
- for base in PIPED_INSTANCES:
37
- try:
38
- url = f"{base}{path}"
39
- r = requests.get(url, params=params, headers=UA, timeout=15)
40
- if r.status_code == 200:
41
- return r.json()
42
- except Exception as e:
43
- last_err = e
44
- continue
45
- raise Exception(f"All Piped instances failed: {last_err}")
46
-
47
- def get_channel_videos(channel_id, max_videos=200):
48
- """Get all videos from a channel using Piped API with pagination"""
49
- cached = _cached(f'ch_vids_{channel_id}')
50
- if cached is not None:
51
- return cached
52
-
53
- all_videos = []
54
- page = None
55
-
56
- while len(all_videos) < max_videos:
57
- try:
58
- if page:
59
- data = _piped_request(f"/channels/{channel_id}/videos", {"nextpage": page})
60
- else:
61
- data = _piped_request(f"/channels/{channel_id}/videos")
62
-
63
- videos = data.get('relatedStreams', [])
64
- if not videos:
65
- break
66
-
67
- all_videos.extend(videos)
68
-
69
- # Check for next page
70
- next_page = data.get('nextpage')
71
- if not next_page or next_page == page:
72
- break
73
- page = next_page
74
-
75
- # Small delay to be polite
76
- time.sleep(0.3)
77
-
78
- if len(all_videos) >= max_videos:
79
- break
80
-
81
- except Exception as e:
82
- print(f"Piped pagination error: {e}")
83
- break
84
-
85
- result = all_videos[:max_videos]
86
- _set_cache(f'ch_vids_{channel_id}', result)
87
- return result
88
-
89
- def get_vtvnambo_shorts_piped(max_count=50):
90
- """Get shorts from VTV Nam Bộ using Piped API"""
91
- # VTV Nam Bộ channel ID
92
- channel_id = "UCJ0btJV8qh7J7R2aXb9GmGA"
93
-
94
- try:
95
- videos = get_channel_videos(channel_id, 200)
96
-
97
- shorts = []
98
- for v in videos:
99
- title = v.get('title', '')
100
- vid = v.get('url', '').replace('/watch?v=', '')
101
- if not vid:
102
- continue
103
-
104
- # Filter for shorts: title has #shorts, or duration <= 60s
105
- duration = v.get('duration', 0)
106
- is_short = (
107
- '#shorts' in title.lower() or
108
- '#short' in title.lower() or
109
- (duration > 0 and duration <= 60)
110
- )
111
-
112
- if is_short:
113
- shorts.append({
114
- 'id': vid,
115
- 'title': title,
116
- 'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg",
117
- 'channel': 'vtvnambo',
118
- })
119
-
120
- if shorts:
121
- return shorts[:max_count]
122
- except Exception as e:
123
- print(f"Piped shorts error: {e}")
124
-
125
- return []
126
-
127
- def get_vtvnambo_shorts_rss(max_count=50):
128
- """Get shorts from YouTube RSS feed"""
129
- cached = _cached('vtvnambo_rss')
130
- if cached is not None:
131
- return cached
132
-
133
- from xml.etree import ElementTree as ET
134
-
135
- # First get channel ID from page
136
- channel_id = None
137
- try:
138
- r = requests.get("https://www.youtube.com/@vtvnambo", headers=UA, timeout=15)
139
- if r.status_code == 200:
140
- m = re.search(r'"channelId":"(UC[^"]+)"', r.text)
141
- if m:
142
- channel_id = m.group(1)
143
- except:
144
- pass
145
-
146
- if not channel_id:
147
- channel_id = "UCJ0btJV8qh7J7R2aXb9GmGA" # fallback
148
-
149
- try:
150
- url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
151
- r = requests.get(url, headers=UA, timeout=15)
152
- if r.status_code != 200:
153
- return []
154
-
155
- root = ET.fromstring(r.text)
156
- ns = {'atom': 'http://www.w3.org/2005/Atom', 'yt': 'http://www.youtube.com/xml/schemas/2015'}
157
-
158
- shorts = []
159
- for entry in root.findall('atom:entry', ns)[:max_count * 2]:
160
- title_el = entry.find('atom:title', ns)
161
- title = title_el.text if title_el is not None and title_el.text else ''
162
-
163
- vid_el = entry.find('yt:videoId', ns)
164
- vid = vid_el.text if vid_el is not None else ''
165
- if not vid:
166
- continue
167
-
168
- is_short = '#shorts' in title.lower() or '#short' in title.lower()
169
- link_el = entry.find('atom:link', ns)
170
- link = link_el.get('href', '') if link_el is not None else ''
171
- if '/shorts/' in link:
172
- is_short = True
173
-
174
- if is_short:
175
- shorts.append({
176
- 'id': vid,
177
- 'title': title,
178
- 'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg",
179
- 'channel': 'vtvnambo',
180
- })
181
-
182
- _set_cache('vtvnambo_rss', shorts[:max_count])
183
- return shorts[:max_count]
184
- except Exception as e:
185
- print(f"RSS error: {e}")
186
-
187
- return []
188
-
189
- def get_vtvnambo_shorts(max_count=50):
190
- """Get all shorts from VTV Nam Bộ. Tries Piped API first, then RSS."""
191
- cached = _cached('vtvnambo_shorts_v3')
192
- if cached is not None:
193
- return cached
194
-
195
- all_shorts = []
196
- seen_ids = set()
197
-
198
- # Method 1: Piped API (most reliable)
199
- try:
200
- piped_shorts = get_vtvnambo_shorts_piped(max_count)
201
- for s in piped_shorts:
202
- if s['id'] not in seen_ids:
203
- seen_ids.add(s['id'])
204
- all_shorts.append(s)
205
- print(f"Piped API found {len(piped_shorts)} shorts")
206
- except Exception as e:
207
- print(f"Piped method failed: {e}")
208
-
209
- # Method 2: RSS feed
210
- if len(all_shorts) < 3:
211
- try:
212
- rss_shorts = get_vtvnambo_shorts_rss(max_count)
213
- for s in rss_shorts:
214
- if s['id'] not in seen_ids:
215
- seen_ids.add(s['id'])
216
- all_shorts.append(s)
217
- print(f"RSS found {len(rss_shorts)} shorts")
218
- except Exception as e:
219
- print(f"RSS method failed: {e}")
220
-
221
- result = all_shorts[:max_count]
222
- _set_cache('vtvnambo_shorts_v3', result)
223
- return result
224
-
225
- def get_wc_related_shorts(max_count=30):
226
- """Get World Cup / football related shorts."""
227
- all_shorts = get_vtvnambo_shorts(max_count * 3)
228
-
229
- wc_kws = [
230
- 'world cup', 'wc 2026', 'worldcup', 'fifa', 'bóng đá',
231
- 'trận đấu', 'đội tuyển', 'tuyển', 'vòng loại',
232
- 'khoảnh khắc', 'highlights', 'bàn thắng', 'goal',
233
- 'kết quả', 'tỉ số', 'việt nam', 'vn',
234
- 'ngoại hạng', 'premier league', 'champions league',
235
- 'laliga', 'serie a', 'bundesliga', 'ligue 1',
236
- 'copa', 'europa', 'c1', 'c2',
237
- 'messi', 'ronaldo', 'neymar', 'mbappe', 'haaland',
238
- 'v-league', 'vleague', 'bóng đá việt',
239
- 'đội bóng', 'hlv', 'huấn luyện viên',
240
- 'chuyển nhượng', 'transfer',
241
- 'asian cup', 'aff cup', 'sea games',
242
- 'olympic', 'u23', 'u20', 'u17',
243
- ]
244
-
245
- wc_shorts = []
246
- for s in all_shorts:
247
- tl = s.get('title', '').lower()
248
- if any(k in tl for k in wc_kws):
249
- wc_shorts.append(s)
250
-
251
- if not wc_shorts:
252
- wc_shorts = all_shorts
253
-
254
- return wc_shorts[:max_count]
255
-
256
- import re
257
- # Alias for backward compatibility
258
- get_vtvnamo_shorts = get_vtvnambo_shorts
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt DELETED
@@ -1,13 +0,0 @@
1
- fastapi
2
- uvicorn
3
- requests
4
- beautifulsoup4>=4.12.0
5
- lxml
6
- jinja2
7
- yt-dlp
8
- huggingface_hub
9
- gTTS
10
- pillow
11
- edge-tts
12
- python-dateutil
13
- httpx
 
 
 
 
 
 
 
 
 
 
 
 
 
 
rewrite_fix_v2.js DELETED
@@ -1,2 +0,0 @@
1
- // No-op - all functionality built into app_v2.js
2
- (function(){})();
 
 
 
rewrite_slide.py DELETED
@@ -1,185 +0,0 @@
1
- """Fast rewrite as slides - no AI needed, extracts key points + images from article."""
2
- from main import app
3
- from fastapi import Request
4
- from fastapi.responses import JSONResponse
5
- import requests, re, time, random, json, os
6
- from bs4 import BeautifulSoup
7
- from urllib.parse import quote
8
-
9
- UA = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept-Language': 'vi-VN,vi;q=0.9'}
10
-
11
- try:
12
- from main import _load_wall, _save_wall
13
- except:
14
- _data_dir = "/data" if os.path.isdir("/data") else "/app/data"
15
- _wall_file = os.path.join(_data_dir, "wall_posts.json")
16
- def _load_wall():
17
- try:
18
- if os.path.exists(_wall_file):
19
- with open(_wall_file, 'r', encoding='utf-8') as f: return json.load(f)
20
- except: pass
21
- return []
22
- def _save_wall(posts):
23
- try:
24
- os.makedirs(os.path.dirname(_wall_file), exist_ok=True)
25
- with open(_wall_file+'.tmp', 'w', encoding='utf-8') as f: json.dump(posts[:100], f, ensure_ascii=False)
26
- os.replace(_wall_file+'.tmp', _wall_file)
27
- except: pass
28
-
29
-
30
- def _clean(s): return re.sub(r'\s+', ' ', str(s or '')).strip()
31
-
32
-
33
- def _scrape_article_full(url):
34
- """Scrape article: extract paragraphs + ALL images."""
35
- try:
36
- r = requests.get(url, headers=UA, timeout=15, allow_redirects=True)
37
- r.encoding = 'utf-8'
38
- soup = BeautifulSoup(r.text, 'lxml')
39
- for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']): tag.decompose()
40
-
41
- # Title
42
- h1 = soup.find('h1')
43
- ogt = soup.find('meta', property='og:title')
44
- title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '')
45
-
46
- # OG image
47
- ogi = soup.find('meta', property='og:image')
48
- og_img = ogi.get('content', '') if ogi else ''
49
- if og_img and og_img.startswith('//'): og_img = 'https:' + og_img
50
-
51
- # Find content block
52
- block = None
53
- for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
54
- el = soup.select_one(sel)
55
- if el and len(el.find_all('p')) >= 2: block = el; break
56
- if not block: block = soup.body or soup
57
-
58
- # Extract paragraphs and images IN ORDER
59
- paragraphs = []
60
- images = []
61
- seen_imgs = set()
62
-
63
- if og_img and og_img not in seen_imgs:
64
- images.append(og_img)
65
- seen_imgs.add(og_img)
66
-
67
- for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
68
- if el.name == 'p':
69
- t = _clean(el.get_text(strip=True))
70
- if t and len(t) > 40:
71
- paragraphs.append(t)
72
- elif el.name in ('figure', 'img'):
73
- im = el if el.name == 'img' else el.find('img')
74
- if im:
75
- src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
76
- if src and 'base64' not in src:
77
- if src.startswith('//'): src = 'https:' + src
78
- if src not in seen_imgs:
79
- images.append(src)
80
- seen_imgs.add(src)
81
-
82
- return {'title': _clean(title), 'paragraphs': paragraphs, 'images': images, 'og_img': og_img}
83
- except Exception as e:
84
- return None
85
-
86
-
87
- def _extract_key_points(paragraphs, max_points=5):
88
- """Extract key points: take first sentence of each significant paragraph."""
89
- points = []
90
- for p in paragraphs:
91
- if len(points) >= max_points: break
92
- # Take first complete sentence (ends with . ! ?)
93
- m = re.match(r'^(.+?[.!?])\s', p)
94
- if m:
95
- sentence = m.group(1)
96
- else:
97
- sentence = p[:150] + ('.' if not p.endswith('.') else '')
98
-
99
- # Skip if too short or duplicate
100
- if len(sentence) < 30: continue
101
- if any(sentence[:50] in existing for existing in points): continue
102
-
103
- points.append(sentence)
104
-
105
- return points
106
-
107
-
108
- @app.post("/api/rewrite_slide")
109
- async def api_rewrite_slide(request: Request):
110
- """
111
- Fast rewrite as SLIDES:
112
- - Extract key points from article (1 sentence each, full and complete)
113
- - Pair each point with an image from the article
114
- - Return as slides array for frontend to display
115
- - Save to Tường AI
116
- NO AI NEEDED - instant response.
117
- """
118
- body = await request.json()
119
- url = _clean(body.get("url", ""))
120
- context = body.get("context", "")
121
-
122
- if not url and not context:
123
- return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
124
-
125
- # Scrape article
126
- data = None
127
- if url and url.startswith("http"):
128
- data = _scrape_article_full(url)
129
-
130
- if not data and context:
131
- # Use context passed from frontend
132
- paragraphs = [_clean(p) for p in context.split('\n') if len(_clean(p)) > 40]
133
- data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
134
-
135
- if not data or not data.get('paragraphs'):
136
- return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
137
-
138
- # Extract key points
139
- points = _extract_key_points(data['paragraphs'], max_points=6)
140
- if not points:
141
- return JSONResponse({"error": "Không tìm được ý chính"}, status_code=422)
142
-
143
- # Build slides: pair each point with an image
144
- images = data.get('images', [])
145
- slides = []
146
- for i, point in enumerate(points):
147
- img = images[i] if i < len(images) else (images[-1] if images else '')
148
- # Proxy dantri images
149
- if img and 'cdnphoto.dantri' in img:
150
- img = '/api/proxy/img?url=' + quote(img, safe='')
151
- slides.append({
152
- 'text': point,
153
- 'image': img,
154
- 'index': i + 1
155
- })
156
-
157
- # Create post for Tường AI
158
- summary_text = '\n\n'.join([f"• {s['text']}" for s in slides])
159
- # Auto voice + emotion based on topic (reuse ai_ext detector if available)
160
- try:
161
- from ai_ext import _detect_voice_emotion
162
- _voice, _emotion = _detect_voice_emotion(data['title'], summary_text)
163
- except Exception:
164
- _voice, _emotion = "hoaimy", "trung_tinh"
165
- post = {
166
- "id": str(int(time.time() * 1000)) + str(random.randint(100, 999)),
167
- "title": data['title'],
168
- "text": summary_text,
169
- "img": images[0] if images else '',
170
- "url": url,
171
- "kind": "slide_summary",
172
- "slides": slides,
173
- "images": images[:10],
174
- "video": "",
175
- "voice": _voice,
176
- "emotion": _emotion,
177
- "ts": int(time.time())
178
- }
179
-
180
- # Save to wall
181
- posts = _load_wall()
182
- posts.insert(0, post)
183
- _save_wall(posts)
184
-
185
- return JSONResponse({"post": post, "slides": slides})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
runtime.txt DELETED
@@ -1 +0,0 @@
1
- python-3.12.x
 
 
shorts_cache.py DELETED
@@ -1,86 +0,0 @@
1
- """
2
- VNEWS Shorts Runtime Cache - External Updater Module
3
- GitHub Actions fetches YouTube shorts via yt-dlp -> POST to /api/shorts/update
4
- Space saves to RAM cache + persistent file if /data available
5
- """
6
- import os
7
- import json
8
- import time
9
- import threading
10
-
11
- # Runtime cache (RAM)
12
- _shorts_runtime_cache = None
13
- _shorts_cache_ts = 0
14
- _shorts_cache_lock = threading.Lock()
15
-
16
- # Secret for authenticating update requests
17
- SHORTS_UPDATE_SECRET = os.environ.get("SHORTS_UPDATE_SECRET", "vnews-shorts-2026")
18
-
19
- # Paths
20
- SHORTS_CACHE_FILE = "/data/shorts_runtime_cache.json" if os.path.isdir("/data") else "/app/shorts_runtime_cache.json"
21
-
22
-
23
- def get_runtime_cache():
24
- """Get cached shorts (from RAM or file fallback)"""
25
- global _shorts_runtime_cache, _shorts_cache_ts
26
- with _shorts_cache_lock:
27
- if _shorts_runtime_cache is not None:
28
- age = time.time() - _shorts_cache_ts
29
- if age < 7200: # 2h fresh
30
- return _shorts_runtime_cache
31
-
32
- # Try file fallback
33
- try:
34
- if os.path.exists(SHORTS_CACHE_FILE):
35
- with open(SHORTS_CACHE_FILE, "r", encoding="utf-8") as f:
36
- data = json.load(f)
37
- age = time.time() - data.get("ts", 0)
38
- if age < 86400: # 24h stale limit
39
- items = data.get("items", [])
40
- with _shorts_cache_lock:
41
- _shorts_runtime_cache = items
42
- _shorts_cache_ts = data.get("ts", time.time())
43
- return items
44
- except Exception as e:
45
- print(f"[cache] read error: {e}")
46
-
47
- return None
48
-
49
-
50
- def set_runtime_cache(items):
51
- """Update runtime cache from external data"""
52
- global _shorts_runtime_cache, _shorts_cache_ts
53
- ts = time.time()
54
- with _shorts_cache_lock:
55
- _shorts_runtime_cache = items
56
- _shorts_cache_ts = ts
57
-
58
- # Also write to file (persistent if /data mounted)
59
- try:
60
- os.makedirs(os.path.dirname(SHORTS_CACHE_FILE), exist_ok=True)
61
- payload = {"items": items, "ts": ts, "count": len(items)}
62
- with open(SHORTS_CACHE_FILE, "w", encoding="utf-8") as f:
63
- json.dump(payload, f, ensure_ascii=False, indent=2)
64
- print(f"[cache] saved {len(items)} shorts to {SHORTS_CACHE_FILE}")
65
- except Exception as e:
66
- print(f"[cache] write skipped: {e}")
67
-
68
- return len(items)
69
-
70
-
71
- def get_cache_status():
72
- """Return status dict for the cache"""
73
- cache = None
74
- with _shorts_cache_lock:
75
- if _shorts_runtime_cache is not None:
76
- cache = _shorts_runtime_cache
77
- age = int(time.time() - _shorts_cache_ts)
78
- else:
79
- age = -1
80
- return {
81
- "cached": cache is not None,
82
- "count": len(cache) if cache else 0,
83
- "age_seconds": age,
84
- "has_persistent": os.path.isdir("/data"),
85
- "cache_file_exists": os.path.exists(SHORTS_CACHE_FILE),
86
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
shorts_rss_proxy.py DELETED
@@ -1,114 +0,0 @@
1
- """
2
- YouTube RSS Proxy - Fetches YouTube channel RSS feeds server-side
3
- Avoids CORS issues when client tries to fetch YouTube directly
4
- """
5
- import requests as req
6
- from fastapi import Query
7
- from fastapi.responses import Response
8
-
9
- HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
10
-
11
- YOUTUBE_CHANNELS = {
12
- "baodantri7941": "UC_x5TKhOgd6GhYvv5z4I3jg",
13
- "baosuckhoedoisongboyte": "UCBsY5fXTQLkF_JnH9kLkL4g",
14
- }
15
-
16
- def setup_rss_proxy(app):
17
- """Add RSS proxy endpoints to the FastAPI app"""
18
-
19
- @app.get("/api/proxy/rss")
20
- def proxy_rss(url: str = Query(...)):
21
- """Proxy YouTube RSS feed to avoid CORS"""
22
- try:
23
- r = req.get(url, headers=HEADERS, timeout=15)
24
- if r.status_code == 200:
25
- return Response(
26
- content=r.content,
27
- media_type="application/xml",
28
- headers={"Access-Control-Allow-Origin": "*"}
29
- )
30
- return Response(status_code=r.status_code)
31
- except Exception as e:
32
- return Response(status_code=502, content=str(e))
33
-
34
- @app.get("/api/shorts/rss")
35
- def shorts_via_rss():
36
- """Get shorts from YouTube RSS feeds server-side"""
37
- import xml.etree.ElementTree as ET
38
- import html as html_lib
39
- import re
40
-
41
- shorts = []
42
- seen = set()
43
-
44
- for handle, channel_id in YOUTUBE_CHANNELS.items():
45
- try:
46
- rss_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
47
- r = req.get(rss_url, headers=HEADERS, timeout=15)
48
- if r.status_code != 200:
49
- continue
50
-
51
- root = ET.fromstring(r.text)
52
- ns = {
53
- 'atom': 'http://www.w3.org/2005/Atom',
54
- 'yt': 'http://www.youtube.com/xml/schemas/2015',
55
- 'media': 'http://search.yahoo.com/mrss/'
56
- }
57
-
58
- for entry in root.findall('atom:entry', ns)[:30]:
59
- title_el = entry.find('atom:title', ns)
60
- title = html_lib.unescape(title_el.text) if title_el is not None and title_el.text else ''
61
-
62
- link_el = entry.find('atom:link', ns)
63
- link = link_el.get('href', '') if link_el is not None else ''
64
-
65
- vid_el = entry.find('yt:videoId', ns)
66
- vid = vid_el.text if vid_el is not None else ''
67
-
68
- if not vid:
69
- m = re.search(r'(?:v=|shorts/)([A-Za-z0-9_-]{11})', link)
70
- if m:
71
- vid = m.group(1)
72
-
73
- if not vid or vid in seen:
74
- continue
75
-
76
- # Check if it's a short
77
- is_short = '#shorts' in title.lower() or '#short' in title.lower() or '/shorts/' in link
78
-
79
- if not is_short:
80
- desc_el = entry.find('media:description', ns)
81
- if desc_el is not None and desc_el.text:
82
- if '#shorts' in desc_el.text.lower():
83
- is_short = True
84
-
85
- if not is_short:
86
- continue
87
-
88
- seen.add(vid)
89
-
90
- # Get thumbnail
91
- thumb = f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg"
92
- media_group = entry.find('media:group', ns)
93
- if media_group is not None:
94
- thumb_el = media_group.find('media:thumbnail', ns)
95
- if thumb_el is not None:
96
- thumb = thumb_el.get('url', thumb)
97
-
98
- shorts.append({
99
- 'id': vid,
100
- 'title': title.replace('#shorts', '').replace('#short', '').strip()[:120],
101
- 'img': thumb,
102
- 'link': f'https://www.youtube.com/shorts/{vid}',
103
- 'channel': handle,
104
- 'source': 'yt'
105
- })
106
-
107
- if len(shorts) >= 40:
108
- break
109
-
110
- except Exception as e:
111
- print(f"RSS error for {handle}: {e}")
112
- continue
113
-
114
- return {"shorts": shorts, "count": len(shorts)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
static/app_v2.js CHANGED
@@ -1,352 +1,130 @@
1
- /**
2
- * VNEWS Frontend v2 - Shorts Dantri/SKDS removed, VTV Digital CDN
3
- * v2.4 - Fixed: prependWallPost detached-element bug, makeShortVideo UI update, slide viewer for rewrite posts
4
- */
5
- function _proxyImg(url){
6
- if(!url || typeof url !== 'string') return '';
7
- if(url.startsWith('http') && !url.includes(location.host)){
8
- return '/api/proxy/img?url='+encodeURIComponent(url);
9
- }
10
- return url;
11
- }
12
-
13
- var _ttsSelections = {};
14
-
15
- function _fetchWithTimeout(url, ms){
16
- return new Promise((resolve,reject)=>{
17
- const ctrl=new AbortController();
18
- const tid=setTimeout(()=>ctrl.abort(),ms);
19
- fetch(url,{signal:ctrl.signal}).then(r=>{
20
- clearTimeout(tid);
21
- if(!r.ok) return reject(new Error('HTTP '+r.status));
22
- return r.json();
23
- }).then(d=>resolve(d)).catch(e=>{clearTimeout(tid);reject(e);});
24
- });
25
- }
26
 
 
27
  async function loadHome(){
28
- const homeEl = document.getElementById('view-home');
29
- if(!homeEl) return;
30
-
31
- homeEl.innerHTML =
32
- '<div id="home-featured-area"></div>'
33
- +'<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>'
34
- +'<div id="hashtag-box"></div>'
35
- +'<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>'
36
- +'<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>'
37
- +'<div id="home-after-wc"></div>';
38
-
39
- const afterEl = homeEl.querySelector('#home-after-wc');
40
-
41
- loadLivescore('today');
42
- loadHotTopics();
43
-
44
- const [featuredData, wallData, hlLeagues, wcData] = await Promise.allSettled([
45
- _fetchWithTimeout('/api/livescore/featured', 5000),
46
- _fetchWithTimeout('/api/wall', 5000),
47
- _fetchWithTimeout('/api/highlights/leagues', 10000),
48
- _fetchWithTimeout('/api/wc2026', 8000),
49
- ]).then(results => results.map(r => r.status === 'fulfilled' ? r.value : null));
50
-
51
- if(featuredData && featuredData.home){
52
- const sc=featuredData.status==='live'?'':'upcoming';
53
- const st=featuredData.status==='live'?`🔴 ${featuredData.minute||'LIVE'}`:`⏰ ${featuredData.time}`;
54
- const area=document.getElementById('home-featured-area');
55
- 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>`;
56
- }
57
-
58
- _wallPosts = (wallData && wallData.posts) || [];
59
- _hlLeagueData = hlLeagues || {};
60
- _wc2026Data = wcData;
61
-
62
- if(wcData) switchWCTab('news');
63
-
64
- _renderWallIn(afterEl);
65
- _renderHLIn(afterEl);
66
- }
67
-
68
- function _renderSlidesIn(key, label, emoji, vids, afterEl){
69
- if(!vids||!vids.length||!afterEl) return;
70
- const wrap=document.createElement('div');
71
- wrap.className='slider-wrap';
72
- let h=`<div class="slider-header"><span class="slider-label">${emoji} ${label}</span></div><div class="slider-track">`;
73
- const isHL = key==='world-cup'||key==='premier-league'||key==='champions-league'||key==='la-liga'||key==='serie-a'||key==='bundesliga'||key==='friendly';
74
- vids.slice(0,isHL?8:12).forEach((a,i)=>{
75
- if(isHL){
76
- h+=`<div class="slider-item" onclick="openHighlightFeed('${key}',${i})"><div class="slider-thumb">${a.img?`<img src="${_proxyImg(a.img)}" loading="lazy">`:''}<div class="card-play">▶</div></div><div class="slider-title">${esc(a.title)}</div></div>`;
77
- } else {
78
- h+=`<div class="slider-item" onclick="readArticle('${esc(a.link)}')"><div class="slider-thumb">${a.img?`<img src="${_proxyImg(a.img)}" loading="lazy">`:''}</div><div class="slider-title">${esc(a.title)}</div></div>`;
79
- }
80
- });
81
- h+='</div>';
82
- wrap.innerHTML=h;
83
- afterEl.parentNode.insertBefore(wrap, afterEl);
84
- }
85
-
86
- function _renderWallIn(afterEl){
87
- if(!_wallPosts||!_wallPosts.length||!afterEl) return;
88
- const posts=_wallPosts;
89
- const wrap=document.createElement('div');
90
- wrap.className='slider-wrap';wrap.id='ai-wall-wrap';
91
- let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track" id="ai-wall-track">';
92
- posts.slice(0,20).forEach((p,i)=>{h+=makeWallItem(p,i);});
93
- h+='</div>';wrap.innerHTML=h;
94
- afterEl.parentNode.insertBefore(wrap,afterEl);
95
- }
96
-
97
- function _renderHLIn(afterEl){
98
- if(!_hlLeagueData||Object.keys(_hlLeagueData).length===0||!afterEl) return;
99
  const HL_CONFIG={"world-cup":{name:"World Cup 2026",emoji:"🌍"},"premier-league":{name:"Premier League",emoji:"🏴"},"champions-league":{name:"Champions League",emoji:"⭐"},"la-liga":{name:"La Liga",emoji:"🇪🇸"},"serie-a":{name:"Serie A",emoji:"🇮🇹"},"bundesliga":{name:"Bundesliga",emoji:"🇩🇪"},"friendly":{name:"Giao hữu",emoji:"🤝"}};
100
- for(const[key,cfg] of Object.entries(HL_CONFIG)){
101
- const vids=_hlLeagueData[key];
102
- if(!vids||!vids.length) continue;
103
- _renderSlidesIn(key,cfg.name,cfg.emoji,vids,afterEl);
104
- }
105
  }
106
 
107
  // === WALL POST HELPERS ===
108
  function makeWallItem(p,i){
109
- var hasVideo = p.video && p.video.length > 0;
110
- var thumbContent = p.img
111
- ? '<img src="/api/proxy/img?url='+encodeURIComponent(p.img)+'" loading="lazy" onerror="this.style.display=\'none\'">'
112
- : (hasVideo ? '<video src="'+esc(p.video)+'" muted></video>' : '');
113
- var videoBadge = hasVideo ? '<div class="wall-video-badge">🎬</div>' : '';
114
- var vid = p.id||i;
115
- var lang = p.language || detectLanguage(p.title + ' ' + (p.text||''));
116
- var curVoice = p.voice || getAutoVoice(lang);
117
- var curEmotion = p.emotion || detectEmotion(p.title + ' ' + (p.text||''));
118
- var selKey = 'inline-' + vid;
119
- if(!_ttsSelections[selKey]) _ttsSelections[selKey] = {voice: curVoice, emotion: curEmotion};
120
- var voiceOpts = '';
121
- VOICE_LIST.forEach(function(v){
122
- voiceOpts += '<option value="'+v.id+'"'+(v.id===_ttsSelections[selKey].voice?' selected':'')+'>'+v.label+'</option>';
123
- });
124
- var emotOpts = '';
125
- EMOTION_LIST.forEach(function(e){
126
- emotOpts += '<option value="'+e.id+'"'+(e.id===_ttsSelections[selKey].emotion?' selected':'')+'>'+e.label+'</option>';
127
- });
128
- var spd = p.short_speed || '1.2';
129
- var voiceBar = '<div class="wall-tts-bar" style="margin:6px 0 4px;display:grid;grid-template-columns:1fr auto auto;gap:3px">'
130
- +'<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">'
131
- +voiceOpts+'</select>'
132
- +'<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">'
133
- +emotOpts+'</select>'
134
- +'<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)">'
135
- +'<option value="0.85"'+(spd==='0.85'?' selected':'')+'>0.85x</option>'
136
- +'<option value="1.0"'+(spd==='1.0'?' selected':'')+'>1.0x</option>'
137
- +'<option value="1.2"'+(spd==='1.2'?' selected':'')+'>1.2x</option>'
138
- +'<option value="1.35"'+(spd==='1.35'?' selected':'')+'>1.35x</option>'
139
- +'</select>'
140
- +'</div>';
141
- var makeBtn = hasVideo
142
- ? '<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed('+i+')">▶ Xem Short</button>'
143
- : '<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>';
144
- 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>';
145
  }
146
 
147
- async function makeShortVideo(postId, btn, voice, speed, emotion){
 
148
  if(!postId)return;
149
  const origText = btn ? btn.textContent : '🎬 Tạo Video';
150
  if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo...';}
151
  toast('⏳ Đang tạo video shorts...');
152
- if(!voice || !emotion){
153
- const selKey = 'inline-'+postId;
154
- const container = document.querySelector('.tts-selector[data-post-id="'+postId+'"]');
155
- if(container){
156
- if(_ttsSelections[selKey]){
157
- voice = voice || _ttsSelections[selKey].voice;
158
- emotion = emotion || _ttsSelections[selKey].emotion;
159
- }
160
- if(!voice){
161
- const selectedVoiceBtn = container.querySelector('.tts-voice-btn.selected') || container.querySelector('.tts-voice-btn[style*="5cb87a"]') || container.querySelector('.tts-voice-btn');
162
- voice = selectedVoiceBtn ? selectedVoiceBtn.dataset.voice : 'vi-VN-HoaiMyNeural';
163
- }
164
- if(!emotion){
165
- const selectedEmotionBtn = container.querySelector('.tts-emotion-btn.selected') || container.querySelector('.tts-emotion-btn[style*="5cb87a"]') || container.querySelector('.tts-emotion-btn');
166
- emotion = selectedEmotionBtn ? selectedEmotionBtn.dataset.emotion : 'neutral';
167
- }
168
- const speedSelect = container.querySelector('.tts-speed');
169
- speed = speedSelect ? parseFloat(speedSelect.value) || 1.2 : (speed || 1.2);
170
- } else {
171
- voice = voice || 'vi-VN-HoaiMyNeural';
172
- emotion = emotion || 'neutral';
173
- speed = speed || 1.2;
174
- }
175
- }
176
  try{
177
- const r = await fetch('/api/ai/short/' + encodeURIComponent(postId), {method:'POST',headers:{'Content-Type':'application/json'},body: JSON.stringify({voice:voice, emotion:emotion, speed:speed})});
 
 
 
 
 
178
  const j = await r.json();
179
  if(!r.ok || j.error) throw new Error(j.error||'Lỗi tạo video');
180
  toast('✅ Đã tạo video shorts!');
181
  const p = _wallPosts.find(x => String(x.id) === String(postId));
182
  if(p){
183
  p.video = j.video;
184
- p.voice = j.voice;
185
- p.emotion = j.emotion;
186
  const itemId = 'wall-item-'+postId;
187
  const el = document.getElementById(itemId);
188
  if(el){
189
  const idx = _wallPosts.indexOf(p);
190
- el.insertAdjacentHTML('afterend', makeWallItem(p, idx));
191
- el.remove();
 
192
  }
193
  }
 
194
  }catch(e){
195
  toast('❌ '+e.message);
196
  if(btn){btn.disabled=false;btn.textContent=origText;}
197
  }
198
  }
199
 
200
- var VOICE_LIST = [
201
- {id:'vi-VN-HoaiMyNeural', label:'🎙️ Hoài My (VI)', lang:'vi'},
202
- {id:'vi-VN-NamMinhNeural', label:'🎙️ Nam Minh (VI)', lang:'vi'},
203
- {id:'en-US-AndrewMultilingualNeural', label:'🎙️ Andrew (EN)', lang:'en'},
204
- {id:'en-AU-WilliamMultilingualNeural', label:'🎙️ William (EN)', lang:'en'},
205
- {id:'pt-BR-ThalitaMultilingualNeural', label:'🎙️ Thalita (PT)', lang:'pt'},
206
- {id:'fr-FR-VivienneMultilingualNeural', label:'🎙️ Vivienne (FR)', lang:'fr'},
207
- {id:'fr-FR-RemyMultilingualNeural', label:'🎙️ Rémy (FR)', lang:'fr'},
208
- {id:'de-DE-SeraphinaMultilingualNeural', label:'🎙️ Seraphina (DE)', lang:'de'},
209
- {id:'de-DE-FlorianMultilingualNeural', label:'🎙️ Florian (DE)', lang:'de'},
210
- {id:'ko-KR-HyunsuMultilingualNeural', label:'🎙️ Hyunsu (KO)', lang:'ko'},
211
- {id:'it-IT-GiuseppeMultilingualNeural', label:'🎙️ Giuseppe (IT)', lang:'it'},
212
- ];
213
- var EMOTION_LIST = [
214
- {id:'neutral', label:'😐 Trung tính'},
215
- {id:'happy', label:'😊 Vui vẻ'},
216
- {id:'excited', label:'🔥 Hào hứng'},
217
- {id:'sad', label:'😢 Buồn'},
218
- {id:'humorous', label:'😂 Hài hước'},
219
- {id:'serious', label:'⚠️ Nghiêm túc'},
220
- {id:'urgent', label:'🚨 Khẩn cấp'},
221
- {id:'warm', label:'💖 Ấm áp'},
222
- ];
223
 
224
- document.addEventListener('click',function(e){
225
- var btn = e.target.closest('.tts-voice-btn');
226
- if(btn){
227
- var container = btn.closest('.tts-selector');
228
- if(container){
229
- var selKey = 'inline-'+container.dataset.postId;
230
- if(!_ttsSelections[selKey]) _ttsSelections[selKey]={voice:btn.dataset.voice,emotion:'neutral'};
231
- var allBtns = container.querySelectorAll('.tts-voice-btn');
232
- for(var i=0;i<allBtns.length;i++){allBtns[i].style.borderColor='#333';allBtns[i].style.background='#222';allBtns[i].classList.remove('selected');}
233
- btn.style.borderColor='#5cb87a';btn.style.background='#1a2a1f';btn.classList.add('selected');
234
- _ttsSelections[selKey].voice = btn.dataset.voice;
235
- }
236
  return;
237
  }
238
- var ebtn = e.target.closest('.tts-emotion-btn');
239
- if(ebtn){
240
- var container = ebtn.closest('.tts-selector');
241
- if(container){
242
- var selKey = 'inline-'+container.dataset.postId;
243
- if(!_ttsSelections[selKey]) _ttsSelections[selKey]={voice:'vi-VN-HoaiMyNeural',emotion:ebtn.dataset.emotion};
244
- var allBtns = container.querySelectorAll('.tts-emotion-btn');
245
- for(var i=0;i<allBtns.length;i++){allBtns[i].style.borderColor='#333';allBtns[i].style.background='#222';allBtns[i].classList.remove('selected');}
246
- ebtn.style.borderColor='#5cb87a';ebtn.style.background='#1a2a1f';ebtn.classList.add('selected');
247
- _ttsSelections[selKey].emotion = ebtn.dataset.emotion;
248
  }
249
- return;
250
- }
251
- var cbtn = e.target.closest('.tts-create-btn');
252
- if(cbtn){
253
- var container = cbtn.closest('.tts-selector');
254
- if(container){
255
- var selKey = 'inline-'+container.dataset.postId;
256
- var selVoice = _ttsSelections[selKey] ? _ttsSelections[selKey].voice : 'vi-VN-HoaiMyNeural';
257
- var selEmotion = _ttsSelections[selKey] ? _ttsSelections[selKey].emotion : 'neutral';
258
- var speedSel = container.querySelector('.tts-speed');
259
- var speed = speedSel ? parseFloat(speedSel.value)||1.2 : 1.2;
260
- window.makeShortVideo(container.dataset.postId, cbtn, selVoice, speed, selEmotion);
261
- }
262
- return;
263
  }
264
- });
265
- function detectLanguage(text){
266
- if(!text) return 'vi';
267
- var t=text.toLowerCase(), chars=new Set(t);
268
- var vnChars='đăâêôơưàảãạáằẳẵặắầẩẫậấèẻẽẹéềễểệếìỉĩịíòỏõọóồổỗộốờởỡợớùủũụúừửữựứỳỷỹỵý';
269
- var vnCount=0; for(var c of vnChars){if(chars.has(c)) vnCount++;}
270
- if(vnCount>=2) return 'vi';
271
- if(chars.has('ñ')||chars.has('¿')||chars.has('¡')) return 'es';
272
- if(chars.has('ã')||chars.has('õ')) return 'pt';
273
- var words=t.split(/\s+/);
274
- var enWords=['the','is','at','which','on','and','or','but','this','that','with','from','have','been'];
275
- var enCount=words.filter(function(w){return enWords.indexOf(w)>=0;}).length;
276
- if(enCount>=2) return 'en';
277
- return 'vi';
278
  }
279
- function detectEmotion(text){
280
- if(!text) return 'neutral';
281
- var t=text.toLowerCase();
282
- var kws={
283
- 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'],
284
- excited:['hào hứng','phấn khích','đột phá','kỷ lục','đỉnh cao','emocionante','increíble','excited','thrilling','unbelievable','awesome','breakthrough'],
285
- sad:['buồn','đau','mất','thảm họa','khủng hoảng','triste','terrible','sad','unhappy','tragic','painful','death'],
286
- humorous:['hài hước','buồn cười','haha','đùa','engraçado','gracioso','funny','hilarious','joke','lol'],
287
- serious:['nghiêm trọng','khẩn cấp','quan trọng','lo ngại','sério','crítico','serious','critical','urgent','severe','crisis'],
288
- urgent:['khẩn cấp','báo động','ngay lập tức','urgent','breaking','alert','emergency'],
289
- warm:['ấm áp','tình cảm','yêu thương','warm','love','heart','touching']
290
- };
291
- var bestScore=0, bestEmotion='neutral';
292
- 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;}}
293
- return bestEmotion;
294
- }
295
- 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';}
296
- function buildVoiceEmotionSelector(post){
297
- var lang=post.language||detectLanguage(post.title+' '+(post.text||''));
298
- 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'};
299
- var _postVoice = post.voice ? (_oldVoiceMap[post.voice] || post.voice) : '';
300
- var autoVoice= _postVoice || getAutoVoice(lang);
301
- var autoEmotion=post.emotion||detectEmotion(post.title+' '+(post.text||''));
302
- var selKey = 'inline-'+post.id;
303
- if(!_ttsSelections[selKey]){_ttsSelections[selKey] = {voice: autoVoice, emotion: autoEmotion};}
304
- 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">';
305
- 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">';
306
- 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>';});
307
- 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">';
308
- 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>';});
309
- h+='</div><div style="display:flex;align-items:center;gap:6px;margin-bottom:8px"><span style="font-size:11px;color:#888">⚡ Tốc độ:</span>';
310
- 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>';
311
- 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>';
312
- return h;
313
- }
314
- window.showVoiceEmotionSelector=function(postId,title,text){
315
- var overlay=document.createElement('div');
316
- 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';
317
- 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';
318
- var lang=detectLanguage(title+' '+text);var autoEmotion=detectEmotion(title+' '+text);
319
- var h='<h3 style="color:#5cb87a;margin-bottom:12px;font-size:16px">🎬 Tạo Short AI (ngôn ngữ: '+lang.toUpperCase()+')</h3>';
320
- h+='<div style="margin-bottom:12px"><div style="color:#aaa;font-size:11px;margin-bottom:6px">🎙️ Chọn giọng đọc:</div>';
321
- 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>';});
322
- h+='</div><div style="margin-bottom:12px"><div style="color:#aaa;font-size:11px;margin-bottom:6px">😊 Chọn cảm xúc:</div>';
323
- 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>';});
324
- h+='</div><div style="margin-bottom:12px"><div style="color:#aaa;font-size:11px;margin-bottom:6px">⚡ Tốc độ:</div>';
325
- 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>';
326
- 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>';
327
- 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>';
328
- h+='<div id="ve-status" style="color:#888;font-size:10px;margin-top:8px;display:none"></div>';
329
- box.innerHTML=h;overlay.appendChild(box);document.body.appendChild(overlay);
330
- var selectedVoice=getAutoVoice(lang),selectedEmotion=autoEmotion;
331
- 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;});});
332
- 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;});});
333
- box.querySelector('#ve-cancel-btn').addEventListener('click',function(){overlay.remove();});
334
- box.querySelector('#ve-create-btn').addEventListener('click',async function(){
335
- this.disabled=true;this.textContent='⏳ Đang tạo...';
336
- box.querySelector('#ve-status').style.display='block';box.querySelector('#ve-status').textContent='Đang tạo video shorts...';
337
- try{
338
- var speed=parseFloat(box.querySelector('#ve-speed').value)||1.2;
339
- if(!_ttsSelections["inline-"+postId]) _ttsSelections["inline-"+postId]={voice:"vi-VN-HoaiMyNeural",emotion:"neutral"};
340
- _ttsSelections["inline-"+postId].voice=selectedVoice;_ttsSelections["inline-"+postId].emotion=selectedEmotion;
341
- 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})});
342
- var j=await r.json();
343
- if(!r.ok||j.error) throw new Error(j.error||'Lỗi tạo video');
344
- toast('✅ Đã tạo Short AI!');overlay.remove();
345
- var p=_wallPosts.find(function(x){return String(x.id)===String(postId);});
346
- if(p){p.video=j.video;p.voice=j.voice;p.emotion=j.emotion;}
347
- }catch(e){this.disabled=false;this.textContent='🎬 Tạo Short';box.querySelector('#ve-status').textContent='❌ '+e.message;}
348
- });
349
- };
350
 
351
  function prependWallPost(post){
352
  _wallPosts.unshift(post);
@@ -357,44 +135,58 @@ function prependWallPost(post){
357
  if(homeEl){
358
  let insertBefore=homeEl.querySelector('.slider-wrap');
359
  const newWrap=document.createElement('div');
360
- newWrap.className='slider-wrap';newWrap.id='ai-wall-wrap';
 
361
  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>`;
362
- if(insertBefore) homeEl.insertBefore(newWrap,insertBefore);
363
- else homeEl.appendChild(newWrap);
 
 
 
364
  const firstItem=newWrap.querySelector('.wall-item');
365
  if(firstItem)firstItem.className='wall-item wall-item-new';
366
  }
367
  return;
368
  }
369
- track.insertAdjacentHTML('afterbegin', makeWallItem(post, 0));
 
 
 
 
 
 
 
 
 
 
 
 
 
370
  track.scrollTo({left:0,behavior:'smooth'});
 
371
  }
372
 
 
 
373
  let _wallPosts=[];
374
  let _currentView='home';
375
- let _currentEventId=null;
376
- let _currentMatchUrl=null;
377
  let _htPage=0,_htTopic='';
378
- 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('');}
379
  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);}
380
- 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;const ctrl=new AbortController();setTimeout(()=>ctrl.abort(),4000);fetch('/api/article?url='+encodeURIComponent(s.url),{signal:ctrl.signal}).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){const el=document.getElementById('ht-img-'+idx);if(el)el.innerHTML=`<img src="${_proxyImg(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>`;}}
381
  function loadMoreHashtag(){_htPage++;const btn=document.getElementById('ht-more');if(btn){btn.disabled=true;btn.textContent='Đang tải...';}showHashtagSources(_htTopic,_htPage);}
382
  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';}}}
383
  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>';}}
384
- function bindMatchClicks(el){el.querySelectorAll('.match-detail').forEach(md=>{md.style.cursor='pointer';md.addEventListener('click',function(e){const statusA=this.querySelector('.status a');const teamA=this.querySelector('.teams a[href*="/tran-dau/"]');const a = statusA || teamA;if(a){e.preventDefault();e.stopPropagation();const href=a.getAttribute('href')||'';const m=href.match(/\/tran-dau\/(\d+)\//);if(m){const fullUrl=href.startsWith('http')?href:'https://bongda.com.vn'+href;openMatch(m[1],fullUrl);}}});});el.querySelectorAll('a').forEach(a=>{a.addEventListener('click',e=>{e.preventDefault();e.stopPropagation()});});}
385
- 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')}
386
  function closeMatch(){document.getElementById('match-overlay').classList.remove('active');document.body.style.overflow=''}
387
- 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>'}}
388
- function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]))}
389
- function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')}
390
- 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)}}
391
- function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}}
392
- 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(()=>{})}
393
  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;}}
394
  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};}}
395
  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[];}}
396
  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[];}}
397
- 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><button class="tiktok-right-btn" onclick="event.stopPropagation();toggle169View('${esc(opts.videoId)}','169-toggle-${opts.idx}')"><div class="icon" id="169-toggle-${opts.idx}">🖥️</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>`;}
398
  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);}}
399
  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);}}
400
  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);}
@@ -402,24 +194,123 @@ async function loadCounters(videoIds){for(let i=0;i<videoIds.length;i++){const i
402
  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);}
403
  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;}
404
  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);}
405
- 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='📺';})}
406
- 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)}
407
- 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)+'&img='+encodeURIComponent(a.img||''));const v=await r.json();if(v&&v.src){return{_idx:i,title:a.title||v.title||'',link:a.link||'',img:a.img||v.poster||'',src:v.src,type:v.type||'',poster:v.poster||a.img||''}}return null}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="${esc(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._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();openHighlightFeed('${league}',${(i+1)%ordered.length})"><div class="icon">⏭️</div></button>`})});h+='</div></div>';el.innerHTML=h;setTimeout(()=>initTikTokFeed(),200);}
408
- async function openYTShortsFeed(idx){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đã xóa Shorts Dân trí/SKĐS</div>';}
409
- async function openShortAIFeed(idx){showView('view-tiktok');const el=document.getElementById('view-tiktok');if(!_wallPosts||!_wallPosts.length){el.innerHTML='<div class="loading">Không có Short AI</div>';return}const aiPosts=_wallPosts.filter(p=>p.video);if(!aiPosts.length||idx>=aiPosts.length){el.innerHTML='<div class="loading">Không có Short AI</div>';return}const ordered=aiPosts.slice(idx).concat(aiPosts.slice(0,idx));let h=`<button class="back-btn" onclick="switchCat('home')">← Tường AI</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">`;ordered.forEach((p,i)=>{const baseIdx=_wallPosts.indexOf(p);const vtag=p.video?`<video playsinline preload="none" src="${esc(p.video)}" loop controls></video>`:'';h+=buildTikTokSlide({vtag,title:p.title,badge:'Short AI',badgeClass:'badge-ai',videoId:p.id||'ai-'+i,idx:i,total:ordered.length,shareUrl:'',extraBtn:`<button class="tiktok-right-btn" onclick="event.stopPropagation();showVoiceEmotionSelector('${esc(p.id)}','${esc(p.title)}','${esc((p.text||'').slice(0,200))}')"><div class="icon">🎤</div></button>`})});h+='</div></div>';el.innerHTML=h;setTimeout(()=>initTikTokFeed(),200);}
410
- function makeShortVideo(postId, btn, voice, speed, emotion){
411
- if(!postId)return;
412
- if(!voice||!emotion){voice='vi-VN-HoaiMyNeural';emotion='neutral';}
413
- toast('⏳ Đang tạo...');
414
- fetch('/api/ai/short/'+encodeURIComponent(postId),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice,emotion,speed:speed||1.2})}).then(r=>r.json()).then(j=>{if(j.error)throw new Error(j.error);toast('✅ OK!');}).catch(e=>toast('❌ '+e.message));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
415
  }
416
- function readWallPost(idx){const p=_wallPosts&&_wallPosts[idx];if(!p)return;if(p.slides&&p.slides.length){readSlidePost(idx);return}readArticle(p.url||'','','',p.title,p.text);}
417
- /** Show rewrite slide viewer - vertical slides with text+image */
418
- function readSlidePost(idx){const p=_wallPosts[idx];if(!p||!p.slides)return;showView('view-article');const el=document.getElementById('view-article');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">`;p.slides.forEach((s,i)=>{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||i+1}/${p.slides.length}</div>${s.image?`<img src="${_proxyImg(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>`;});h+=`</div>`;el.innerHTML=h;}
419
- function readNewsTab(tab){loadNewsTab();}
420
- function loadNewsTab(){const el=document.getElementById('view-cat');if(!el)return;el.innerHTML='<div class="loading">Đang tải tin tức...</div>';fetch('/api/homepage').then(r=>r.json()).then(articles=>{if(!articles||!articles.length){el.innerHTML='<div class="loading">Không có tin</div>';return}let h='<div class="grid">';articles.forEach(a=>{const src=a.source||'vne';const badge=a.group||a.source||'';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>`;});h+='</div>';el.innerHTML=h;}).catch(()=>{el.innerHTML='<div class="loading">Lỗi tải</div>';});}
421
 
422
- function readArticle(url,title,img,presetTitle,presetText){showView('view-article');const el=document.getElementById('view-article');el.innerHTML='<div class="loading">Đang tải...</div><button class="back-btn" onclick="switchCat(\'home\')">← Quay lại</button>';if(presetTitle){el.innerHTML=`<button class="back-btn" onclick="switchCat('home')"> Quay lại</button><div class="article-view"><h1 class="article-title">${esc(presetTitle)}</h1>${presetText?`<div class="article-summary">${esc(presetText)}</div>`:''}</div>`;return;}if(!url)return;fetch('/api/article?url='+encodeURIComponent(url)).then(r=>r.json()).then(d=>{let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view">`;if(d.title)h+=`<h1 class="article-title">${esc(d.title)}</h1>`;if(d.summary)h+=`<div class="article-summary">${esc(d.summary)}</div>`;if(d.body)d.body.forEach(b=>{if(b.type==='p')h+=`<p class="article-p">${esc(b.text)}</p>`;else if(b.type==='heading')h+=`<h2 class="article-h2">${esc(b.text)}</h2>`;else if(b.type==='img'&&b.src)h+=`<img class="article-img" src="${_proxyImg(b.src)}" loading="lazy" onerror="this.style.display=\'none\'">`;});h+=`</div><div class="article-actions"><button onclick="doShare('${esc(d.title||'')}','${esc(url)}','${esc(d.og_image||'')}')">📤 Chia sẻ</button><button class="primary" onclick="rewriteSlide('${esc(url)}')">🤖 Slide Rewrite AI</button></div>`;el.innerHTML=h;}).catch(()=>{el.innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><p>Không thể tải bài viết</p></div>`;});}
423
- async function rewriteSlide(url){if(!url)return;const btn=document.querySelector('.article-actions .primary')||event?.target;if(btn){btn.disabled=true;btn.textContent=' Đang tạo slides...';}toast('⏳ Đang tạo slide rewrite...');try{const r=await fetch('/api/rewrite_slide',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');toast('✅ Đã tạo slide! Xem trong Tường AI.');if(btn)btn.textContent='✅ Hoàn tất';if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message);if(btn){btn.disabled=false;btn.textContent='🤖 Slide Rewrite AI';}}}
424
- function rewriteUrl(){const url=document.getElementById('url-input')?.value.trim();if(!url){alert('Nhập URL');return;}readArticle(url);}
425
- function loadCat(id){const el=document.getElementById('view-cat');el.innerHTML='<div class="loading">Đang tải...</div>';fetch('/api/category/'+id).then(r=>r.json()).then(articles=>{if(!articles||!articles.length){el.innerHTML='<div class="loading">Không có bài viết</div>';return}let h='<div class="grid">';articles.forEach(a=>{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-${a.source||'vne'}">${esc(a.source||'')}</span><div class="card-title">${esc(a.title)}</div></div></div>`;});h+='</div>';el.innerHTML=h;}).catch(()=>{el.innerHTML='<div class="loading">Lỗi tải</div>';});}
 
1
+ // === VNEWS Frontend v2 - Full Functions ===
2
+ // Updated: Rewrite fixes better gallery, voice selector always shown, no-reload wall update
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
+ // === LOAD HOME ===
5
  async function loadHome(){
6
+ const[featured,sh,wall,hlLeagues,ai,wcData]=await Promise.all([
7
+ fetch('/api/livescore/featured').then(r=>r.json()).catch(()=>null),
8
+ fetch('/api/shorts').then(r=>r.json()).catch(()=>[]),
9
+ fetch('/api/wall').then(r=>r.json()).catch(()=>({posts:[]})),
10
+ fetch('/api/highlights/leagues').then(r=>r.json()).catch(()=>({})),
11
+ fetch('/api/genk_ai').then(r=>r.json()).catch(()=>[]),
12
+ fetch('/api/wc2026').then(r=>r.json()).catch(()=>null)
13
+ ]);
14
+ _hlLeagueData=hlLeagues;
15
+ _wc2026Data=wcData;
16
+ _shortsData=interleaveShorts(sh||[]);
17
+ _wallPosts=(wall&&wall.posts)||[];
18
+ let h='';
19
+ if(featured&&featured.home){const sc=featured.status==='live'?'':'upcoming';const st=featured.status==='live'?`🔴 ${featured.minute||'LIVE'}`:`⏰ ${featured.time}`;h+=`<div class="featured-match" onclick="openMatch('${featured.event_id}')"><div class="fm-league">${featured.league}</div><div class="fm-teams"><div class="fm-team"><img src="${featured.home_logo}" onerror="this.style.display='none'"><span>${featured.home}</span></div><div class="fm-score">${featured.score||'VS'}</div><div class="fm-team"><img src="${featured.away_logo}" onerror="this.style.display='none'"><span>${featured.away}</span></div></div><div class="fm-status ${sc}">${st}</div></div>`;}
20
+ h+=`<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
+ h+='<div id="hashtag-box"></div>';
22
+ h+=`<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
+ h+=`<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>`;
24
+ const wallPosts=_wallPosts;
25
+ const aiShorts=wallPosts.filter(p=>p.video);
26
+ if(aiShorts.length){h+='<div class="slider-wrap"><div class="slider-header"><span class="slider-label">🎬 Short AI</span></div><div class="slider-track">';aiShorts.slice(0,20).forEach((p,i)=>{h+=`<div class="slider-item shorts-item" onclick="openShortAIFeed(${i})"><div class="slider-thumb shorts-thumb"><video src="${esc(p.video)}" muted preload="metadata"></video><div class="card-play">▶</div></div><div class="slider-title">${esc(p.title)}</div></div>`});h+='</div></div>';}
27
+ if(_shortsData.length){h+='<div class="slider-wrap"><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">';_shortsData.slice(0,30).forEach((a,i)=>{const badge=a.channel==='baosuckhoedoisongboyte'?'SKĐS':'Dân trí';h+=`<div class="slider-item shorts-item" onclick="openYTShortsFeed(${i})"><div class="slider-thumb shorts-thumb">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="slider-title"><span style="color:#f0c040;font-size:8px">${badge}</span> ${esc(a.title)}</div></div>`;});h+='</div></div>';}
28
+ if(wallPosts.length){h+=`<div class="slider-wrap" id="ai-wall-wrap"><div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track" id="ai-wall-track">`;wallPosts.slice(0,20).forEach((p,i)=>{h+=makeWallItem(p,i)});h+='</div></div>';}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  const HL_CONFIG={"world-cup":{name:"World Cup 2026",emoji:"🌍"},"premier-league":{name:"Premier League",emoji:"🏴"},"champions-league":{name:"Champions League",emoji:"⭐"},"la-liga":{name:"La Liga",emoji:"🇪🇸"},"serie-a":{name:"Serie A",emoji:"🇮🇹"},"bundesliga":{name:"Bundesliga",emoji:"🇩🇪"},"friendly":{name:"Giao hữu",emoji:"🤝"}};
30
+ for(const[key,cfg] of Object.entries(HL_CONFIG)){const vids=hlLeagues[key];if(!vids||!vids.length)continue;h+=`<div class="slider-wrap"><div class="slider-header"><span class="slider-label">${cfg.emoji} ${cfg.name}</span></div><div class="slider-track">`;vids.slice(0,8).forEach((a,i)=>{h+=`<div class="slider-item" onclick="openHighlightFeed('${key}',${i})"><div class="slider-thumb">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="slider-title">${esc(a.title)}</div></div>`});h+='</div></div>';}
31
+ if(ai&&ai.length){h+='<div class="slider-wrap"><div class="slider-header"><span class="slider-label">🤖 Ứng dụng AI</span></div><div class="slider-track">';ai.slice(0,12).forEach(a=>{h+=`<div class="slider-item" onclick="readArticle('${esc(a.link)}')"><div class="slider-thumb">${a.img?`<img src="${a.img}">`:''}</div><div class="slider-title">${esc(a.title)}</div></div>`});h+='</div></div>';}
32
+ document.getElementById('view-home').innerHTML=h;
33
+ loadLivescore('today');loadHotTopics();
34
+ if(_wc2026Data)switchWCTab('news');
35
  }
36
 
37
  // === WALL POST HELPERS ===
38
  function makeWallItem(p,i){
39
+ const hasVideo = p.video && p.video.length > 0;
40
+ const thumbContent = p.img
41
+ ? `<img src="${esc(p.img)}" onerror="this.style.display='none'">`
42
+ : (hasVideo ? `<video src="${esc(p.video)}" muted></video>` : '');
43
+ const videoBadge = hasVideo
44
+ ? `<div class="wall-video-badge">🎬</div>`
45
+ : '';
46
+ const videoBtn = hasVideo
47
+ ? `<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed(${i})">▶ Xem Short</button>`
48
+ : `<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo('${esc(p.id||i)}',this)">🎬 Tạo Video</button>`;
49
+
50
+ return `<div class="wall-item" id="wall-item-${esc(p.id||i)}">
51
+ <div class="wall-thumb">
52
+ ${thumbContent}
53
+ ${videoBadge}
54
+ </div>
55
+ <div class="wall-title">${esc(p.title)}</div>
56
+ <div class="wall-text">${esc((p.text||'').slice(0,180))}</div>
57
+ <div class="wall-actions">
58
+ <button class="primary" onclick="readWallPost(${i})">Xem</button>
59
+ ${videoBtn}
60
+ </div>
61
+ </div>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  }
63
 
64
+ // === GENERATE SHORT VIDEO FOR A WALL POST ===
65
+ async function makeShortVideo(postId, btn, voice, speed){
66
  if(!postId)return;
67
  const origText = btn ? btn.textContent : '🎬 Tạo Video';
68
  if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo...';}
69
  toast('⏳ Đang tạo video shorts...');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  try{
71
+ let url = '/api/ai/short/'+encodeURIComponent(postId);
72
+ const params = [];
73
+ if(voice) params.push('voice='+encodeURIComponent(voice));
74
+ if(speed) params.push('speed='+encodeURIComponent(speed));
75
+ if(params.length) url += '?' + params.join('&');
76
+ const r = await fetch(url, {method:'POST'});
77
  const j = await r.json();
78
  if(!r.ok || j.error) throw new Error(j.error||'Lỗi tạo video');
79
  toast('✅ Đã tạo video shorts!');
80
  const p = _wallPosts.find(x => String(x.id) === String(postId));
81
  if(p){
82
  p.video = j.video;
 
 
83
  const itemId = 'wall-item-'+postId;
84
  const el = document.getElementById(itemId);
85
  if(el){
86
  const idx = _wallPosts.indexOf(p);
87
+ el.outerHTML = makeWallItem(p, idx);
88
+ const newEl = document.getElementById(itemId);
89
+ if(newEl) newEl.className = 'wall-item wall-item-new';
90
  }
91
  }
92
+ refreshShortAISlider();
93
  }catch(e){
94
  toast('❌ '+e.message);
95
  if(btn){btn.disabled=false;btn.textContent=origText;}
96
  }
97
  }
98
 
99
+ // ===== VOICE SELECTOR =====
100
+ function selectVoice(voiceKey){
101
+ document.querySelectorAll('.tts-voice-btn').forEach(b=>{
102
+ b.classList.toggle('active', b.dataset.voice === voiceKey);
103
+ });
104
+ const inp = document.getElementById('selected-voice');
105
+ if(inp) inp.value = voiceKey;
106
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
 
108
+ // Refresh Short AI slider after video generation
109
+ function refreshShortAISlider(){
110
+ const aiShorts = _wallPosts.filter(p=>p.video);
111
+ let shortAISection = document.getElementById('short-ai-section');
112
+ if(aiShorts.length === 0){
113
+ if(shortAISection) shortAISection.remove();
 
 
 
 
 
 
114
  return;
115
  }
116
+ const existing = document.querySelectorAll('#view-home .slider-wrap');
117
+ if(shortAISection){
118
+ const track = shortAISection.querySelector('.slider-track');
119
+ if(track){
120
+ let h = '';
121
+ aiShorts.slice(0,20).forEach((p,i)=>{
122
+ 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>`;
123
+ });
124
+ track.innerHTML = h;
 
125
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
 
129
  function prependWallPost(post){
130
  _wallPosts.unshift(post);
 
135
  if(homeEl){
136
  let insertBefore=homeEl.querySelector('.slider-wrap');
137
  const newWrap=document.createElement('div');
138
+ newWrap.className='slider-wrap';
139
+ newWrap.id='ai-wall-wrap';
140
  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>`;
141
+ if(insertBefore){
142
+ homeEl.insertBefore(newWrap,insertBefore);
143
+ }else{
144
+ homeEl.appendChild(newWrap);
145
+ }
146
  const firstItem=newWrap.querySelector('.wall-item');
147
  if(firstItem)firstItem.className='wall-item wall-item-new';
148
  }
149
  return;
150
  }
151
+ // Track exists — prepend with animation
152
+ const div=document.createElement('div');
153
+ div.className='wall-item wall-item-new';
154
+ div.id='wall-item-'+(post.id||'new-'+Date.now());
155
+ const hasVideo = post.video && post.video.length > 0;
156
+ const thumbContent = post.img
157
+ ? `<img src="${esc(post.img)}" onerror="this.style.display='none'">`
158
+ : (hasVideo ? `<video src="${esc(post.video)}" muted></video>` : '');
159
+ const videoBadge = hasVideo ? `<div class="wall-video-badge">🎬</div>` : '';
160
+ const videoBtn = hasVideo
161
+ ? `<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed(0)">▶ Xem Short</button>`
162
+ : `<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo('${esc(post.id)}',this)">🎬 Tạo Video</button>`;
163
+ 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>`;
164
+ track.prepend(div);
165
  track.scrollTo({left:0,behavior:'smooth'});
166
+ if(hasVideo) refreshShortAISlider();
167
  }
168
 
169
+ // === REST OF FUNCTIONS ===
170
+ let _shortsData=[];
171
  let _wallPosts=[];
172
  let _currentView='home';
173
+ 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;}
 
174
  let _htPage=0,_htTopic='';
175
+ 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);}}
176
  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);}
177
+ 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>`;}}
178
  function loadMoreHashtag(){_htPage++;const btn=document.getElementById('ht-more');if(btn){btn.disabled=true;btn.textContent='Đang tải...';}showHashtagSources(_htTopic,_htPage);}
179
  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';}}}
180
  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>';}}
181
+ function bindMatchClicks(el){el.querySelectorAll('.match-detail').forEach(md=>{md.addEventListener('click',function(e){e.preventDefault();const a=this.querySelector('.status a');if(a){const m=(a.getAttribute('href')||'').match(/\/tran-dau\/(\d+)\//);if(m)openMatch(m[1])}})});el.querySelectorAll('a').forEach(a=>a.addEventListener('click',e=>e.preventDefault()))}
182
+ function openMatch(id){if(!id)return;_currentEventId=id;document.getElementById('match-overlay').classList.add('active');document.body.style.overflow='hidden';loadMatchTab('comm')}
183
  function closeMatch(){document.getElementById('match-overlay').classList.remove('active');document.body.style.overflow=''}
184
+ 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ê'))t.classList.add('active')});const el=document.getElementById('mo-body');el.innerHTML='<div class="loading">Đang tải...</div>';try{const r=await fetch(tab==='stats'?`/api/match/${_currentEventId}/stats`:`/api/match/${_currentEventId}/commentaries`);const d=await r.json();el.innerHTML=d.html||'<div class="loading">Không có dữ liệu</div>'}catch(e){el.innerHTML='<div class="loading">Lỗi</div>'}}
 
 
 
 
 
185
  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;}}
186
  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};}}
187
  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[];}}
188
  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[];}}
189
+ 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>`;}
190
  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);}}
191
  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);}}
192
  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);}
 
194
  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);}
195
  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;}
196
  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);}
197
+ 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);}
198
+ async function openHighlightFeed(league,idx){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();}
199
+ 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 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();}
200
+ 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();}
201
+ 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>`;}
202
+ 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)}}
203
+ 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)}}
204
+ 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}}
205
+
206
+ // ===== READ WALL POST — FIXED: gallery + voice selector =====
207
+ async function readWallPost(i){
208
+ const p=_wallPosts[i];
209
+ if(!p)return;
210
+ showView('view-article');
211
+
212
+ // Build image gallery
213
+ const images = p.images || [];
214
+ let imgGallery = '';
215
+ if(images.length > 0){
216
+ imgGallery = '<div class="article-image-gallery">';
217
+ // Hero image (first)
218
+ imgGallery += `<img class="article-img article-hero-img" src="${esc(images[0])}" onerror="this.style.display='none'" loading="eager">`;
219
+ // Thumbnail strip (remaining)
220
+ if(images.length > 1){
221
+ imgGallery += '<div class="gallery-thumbs">';
222
+ for(let idx = 1; idx < images.length; idx++){
223
+ imgGallery += `<div class="gallery-thumb" onclick="swapGalleryHero('${esc(images[idx])}',this)"><img src="${esc(images[idx])}" onerror="this.parentElement.style.display='none'" loading="lazy"></div>`;
224
+ }
225
+ imgGallery += '</div>';
226
+ }
227
+ imgGallery += '</div>';
228
+ }
229
+
230
+ const hasVideo = p.video && p.video.length > 0;
231
+
232
+ // Voice selector — always shown (for create or recreate)
233
+ const allVoices = [
234
+ {key:'hoaimy', flag:'🇻🇳', country:'Việt Nam', name:'Hoài My', gender:'Nữ'},
235
+ {key:'namminh', flag:'🇻🇳', country:'Việt Nam', name:'Nam Minh', gender:'Nam'},
236
+ {key:'en_us_f', flag:'🇺🇸', country:'Mỹ', name:'Ava', gender:'Nữ'},
237
+ {key:'en_us_m', flag:'🇺🇸', country:'Mỹ', name:'Brian', gender:'Nam'},
238
+ {key:'en_gb_f', flag:'🇬🇧', country:'Anh', name:'Sonia', gender:'Nữ'},
239
+ {key:'en_gb_m', flag:'🇬🇧', country:'Anh', name:'Ryan', gender:'Nam'},
240
+ {key:'zh_cn_f', flag:'🇨🇳', country:'TQ', name:'Xiaoxiao', gender:'Nữ'},
241
+ {key:'zh_cn_m', flag:'🇨🇳', country:'TQ', name:'Yunyang', gender:'Nam'},
242
+ {key:'zh_hk_f', flag:'🇭🇰', country:'Hồng Kông',name:'HiuGaai', gender:'Nữ'},
243
+ {key:'ja_jp_f', flag:'🇯🇵', country:'Nhật', name:'Nanami', gender:'Nữ'},
244
+ {key:'ja_jp_m', flag:'🇯🇵', country:'Nhật', name:'Keita', gender:'Nam'},
245
+ {key:'ko_kr_f', flag:'🇰🇷', country:'Hàn', name:'Sun-Hi', gender:'Nữ'},
246
+ {key:'ko_kr_m', flag:'🇰🇷', country:'Hàn', name:'InJoon', gender:'Nam'},
247
+ {key:'fr_fr_f', flag:'🇫🇷', country:'Pháp', name:'Denise', gender:'Nữ'},
248
+ {key:'fr_fr_m', flag:'🇫🇷', country:'Pháp', name:'Henri', gender:'Nam'},
249
+ {key:'de_de_f', flag:'🇩🇪', country:'Đức', name:'Katja', gender:'Nữ'},
250
+ {key:'de_de_m', flag:'🇩🇪', country:'Đức', name:'Killian', gender:'Nam'},
251
+ {key:'es_es_f', flag:'🇪🇸', country:'TBN', name:'Ximena', gender:'Nữ'},
252
+ {key:'es_mx_m', flag:'🇲🇽', country:'Mexico', name:'Jorge', gender:'Nam'},
253
+ {key:'th_th_f', flag:'🇹🇭', country:'Thái', name:'Premwadee', gender:'Nữ'},
254
+ {key:'th_th_m', flag:'🇹🇭', country:'Thái', name:'Niwat', gender:'Nam'},
255
+ {key:'hi_in_f', flag:'🇮🇳', country:'Ấn Độ', name:'Swara', gender:'Nữ'},
256
+ {key:'hi_in_m', flag:'🇮🇳', country:'Ấn Độ', name:'Madhur', gender:'Nam'},
257
+ ];
258
+
259
+ const voiceGroups = {};
260
+ allVoices.forEach(v=>{
261
+ if(!voiceGroups[v.country]) voiceGroups[v.country] = [];
262
+ voiceGroups[v.country].push(v);
263
+ });
264
+
265
+ let voiceSelector = '';
266
+ voiceSelector = `<div class="tts-selector"><div class="tts-selector-label">🎙️ Chọn giọng đọc (đa ngôn ngữ):</div><div class="tts-voice-groups">`;
267
+ for(const [country, voices] of Object.entries(voiceGroups)){
268
+ voiceSelector += `<div class="tts-voice-group"><div class="tts-voice-group-label">${voices[0].flag} ${country}</div><div class="tts-voice-btns">`;
269
+ voices.forEach(v=>{
270
+ const label = `${v.flag} ${v.gender==='Nữ'?'👩':'👨'} ${v.name}`;
271
+ voiceSelector += `<button class="tts-voice-btn" data-voice="${v.key}" onclick="selectVoice('${v.key}')">${label}</button>`;
272
+ });
273
+ voiceSelector += `</div></div>`;
274
+ }
275
+ 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>`;
276
+ voiceSelector += `<input type="hidden" id="selected-voice" value="hoaimy"></div>`;
277
+
278
+ // Build action buttons
279
+ let actionButtons = '';
280
+ if(hasVideo){
281
+ actionButtons += `<button onclick="openShortAIFeed(${i})">🎬 Xem Short</button>`;
282
+ actionButtons += `<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>`;
283
+ } else {
284
+ actionButtons += `<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>`;
285
+ }
286
+
287
+ document.getElementById('view-article').innerHTML=
288
+ `<button class="back-btn" onclick="switchCat('home')">← Quay lại</button>
289
+ <div class="article-view">
290
+ <span class="badge badge-ai">AI</span>
291
+ <h1 class="article-title">${esc(p.title)}</h1>
292
+ ${imgGallery}
293
+ <p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>
294
+ ${hasVideo?`<video class="article-img" src="${esc(p.video)}" controls playsinline style="max-height:400px"></video>`:''}
295
+ <div class="article-actions">
296
+ ${voiceSelector}
297
+ ${actionButtons}
298
+ <button onclick="doShare('${esc(p.title)}','${SPACE}','${esc(p.img||'')}')">📤</button>
299
+ </div>
300
+ </div>`;
301
+
302
+ selectVoice('hoaimy');
303
+ window.scrollTo(0,0);
304
+ }
305
+
306
+ // Gallery thumbnail click — swap hero image
307
+ function swapGalleryHero(imgUrl, thumbEl){
308
+ const gallery = thumbEl.closest('.article-image-gallery');
309
+ if(!gallery) return;
310
+ const hero = gallery.querySelector('.article-hero-img');
311
+ if(hero) hero.src = imgUrl;
312
  }
 
 
 
 
 
313
 
314
+ 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 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}">`:''}</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>'}}
315
+ 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 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}">`:''}</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}
316
+ 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(()=>{});
 
static/app_v2_shorts_fix.js DELETED
@@ -1,2 +0,0 @@
1
- // No-op - all functionality built into app_v2.js
2
- (function(){})();
 
 
 
static/fm_fix.css DELETED
@@ -1,170 +0,0 @@
1
- /* v2 - Force override broken inline CSS (.fm-league missing } + -wall-img-count bad selector) */
2
- .featured-match{margin:6px 4px!important;background:linear-gradient(135deg,#1a2a1f,#0d1117)!important;border:1px solid #2d8659!important;border-radius:10px!important;padding:12px!important;cursor:pointer!important}
3
- .fm-league{text-align:center!important;color:#5cb87a!important;font-size:9px!important;font-weight:700!important;text-transform:uppercase!important;display:block!important}
4
- .fm-teams{display:flex!important;align-items:center!important;justify-content:center!important;gap:10px!important;margin-top:6px!important}
5
- .fm-team{flex:1!important;display:flex!important;flex-direction:column!important;align-items:center!important;gap:4px!important}
6
- .fm-team img{width:32px!important;height:32px!important;object-fit:contain!important}
7
- .fm-team span{font-size:10px!important;color:#ccc!important;text-align:center!important}
8
- .fm-score{font-size:22px!important;font-weight:900!important;min-width:60px!important;text-align:center!important;color:#fff!important}
9
- .fm-status{text-align:center!important;margin-top:6px!important;font-size:9px!important;color:#e74c3c!important;font-weight:700!important}
10
- .fm-status.upcoming{color:#f0c040!important}
11
- .ai-compose{margin:6px 4px!important;background:#141414!important;border:1px solid #2a2a2a!important;border-radius:10px!important;padding:10px!important}
12
- .ai-compose-title{font-size:13px!important;font-weight:800!important;color:#5cb87a!important;margin-bottom:8px!important}
13
- .ai-compose-row{display:flex!important;gap:6px!important;margin-top:6px!important}
14
- .ai-compose input{flex:1!important;background:#222!important;border:1px solid #333!important;color:#eee!important;border-radius:18px!important;padding:9px 12px!important;font-size:12px!important;min-width:0!important}
15
- .ai-compose button{background:#2d8659!important;border:0!important;color:#fff!important;border-radius:18px!important;padding:9px 12px!important;font-size:11px!important;font-weight:700!important;cursor:pointer!important;white-space:nowrap!important}
16
- .ai-compose button.secondary{background:#333!important}
17
- .hot-topic-row{display:flex!important;gap:6px!important;overflow-x:auto!important;padding:4px 0!important}
18
- .hot-chip{flex:0 0 auto!important;background:#222!important;border:1px solid #333!important;color:#ddd!important;border-radius:16px!important;padding:5px 10px!important;font-size:11px!important;cursor:pointer!important;white-space:nowrap!important}
19
- .hashtag-sources{margin:8px 4px!important;background:#1a1a1a!important;border:1px solid #2a2a2a!important;border-radius:10px!important;padding:10px!important}
20
- .hashtag-sources h3{font-size:13px!important;color:#5cb87a!important;margin-bottom:8px!important}
21
- .hashtag-src-item{display:flex!important;gap:8px!important;padding:8px!important;background:#202020!important;border-radius:8px!important;margin:6px 0!important;cursor:pointer!important}
22
- .hashtag-src-img{flex:0 0 80px!important;aspect-ratio:16/9!important;background:#333!important;border-radius:6px!important;overflow:hidden!important}
23
- .hashtag-src-img img{width:100%!important;height:100%!important;object-fit:cover!important}
24
- .hashtag-src-text{flex:1!important;min-width:0!important}
25
- .hashtag-src-title{font-size:12px!important;font-weight:700!important;color:#eee!important;display:-webkit-box!important;-webkit-line-clamp:2!important;-webkit-box-orient:vertical!important;overflow:hidden!important}
26
- .hashtag-src-via{font-size:10px!important;color:#888!important;margin-top:2px!important}
27
- .hashtag-rewrite-btn{width:100%!important;margin-top:8px!important;background:#2d8659!important;border:0!important;color:#fff!important;padding:9px!important;border-radius:10px!important;font-size:12px!important;font-weight:700!important;cursor:pointer!important}
28
- .hashtag-load-more{width:100%!important;margin-top:8px!important;background:#222!important;border:1px solid #333!important;color:#ccc!important;padding:9px!important;border-radius:10px!important;font-size:12px!important;cursor:pointer!important}
29
- .hashtag-loading{display:flex!important;align-items:center!important;gap:8px!important;padding:12px!important;color:#888!important;font-size:12px!important}
30
- .hashtag-spinner{width:16px!important;height:16px!important;border:2px solid #333!important;border-top-color:#5cb87a!important;border-radius:50%!important;animation:ht-spin .8s linear infinite!important}
31
- @keyframes ht-spin{to{transform:rotate(360deg)}}
32
- .wall-img-count{position:absolute;bottom:4px;left:4px;background:rgba(0,0,0,.7);color:#fff;font-size:9px;padding:1px 5px;border-radius:4px}
33
- #progress-toast{position:fixed!important;bottom:70px!important;left:50%!important;transform:translateX(-50%)!important;background:#2d8659!important;color:#fff!important;padding:10px 20px!important;border-radius:20px!important;font-size:12px!important;z-index:99998!important;box-shadow:0 4px 12px rgba(0,0,0,.4)!important;display:none;white-space:nowrap!important}
34
- /* === ARTICLE / REWRITE VIEW FIX === *//* === WORLD CUP 2026 SECTION === */
35
- .wc-section{margin:8px 4px!important;background:#1a1a1a!important;border:1px solid #2a2a2a!important;border-radius:10px!important;overflow:hidden!important}
36
- .wc-header{padding:10px 12px!important;background:linear-gradient(135deg,#0d1b2b,#1a3a2a 50%,#0b4b8b)!important;display:flex!important;align-items:center!important;justify-content:space-between!important}
37
- .wc-title{font-size:14px!important;font-weight:800!important;color:#fff!important}
38
- .wc-tabs{display:flex!important;gap:4px!important;padding:8px 10px!important;background:#1a1a1a!important;overflow-x:auto!important;scrollbar-width:none!important;border-bottom:1px solid #2a2a2a!important}
39
- .wc-tabs::-webkit-scrollbar{display:none!important}
40
- .wc-tab{padding:6px 12px!important;background:#222!important;border:1px solid #333!important;border-radius:12px!important;color:#999!important;font-size:10px!important;white-space:nowrap!important;cursor:pointer!important;flex-shrink:0!important}
41
- .wc-tab.active{background:#0b6bcb!important;border-color:#0b6bcb!important;color:#fff!important;font-weight:700!important}
42
- .wc-tab-content{padding:10px!important;max-height:500px!important;overflow-y:auto!important}
43
- .wc-news-list{display:flex!important;flex-direction:column!important;gap:8px!important}
44
- .wc-news-item{display:flex!important;gap:10px!important;padding:8px!important;background:#202020!important;border-radius:8px!important;cursor:pointer!important}
45
- .wc-news-item:hover{background:#2a2a2a!important}
46
- .wc-news-img{flex:0 0 90px!important;aspect-ratio:16/9!important;background:#333!important;border-radius:6px!important;overflow:hidden!important}
47
- .wc-news-img img{width:100%!important;height:100%!important;object-fit:cover!important}
48
- .wc-news-text{flex:1!important;min-width:0!important;display:flex!important;flex-direction:column!important;justify-content:space-between!important}
49
- .wc-news-title{font-size:12px!important;font-weight:700!important;color:#eee!important;display:-webkit-box!important;-webkit-line-clamp:2!important;-webkit-box-orient:vertical!important;overflow:hidden!important}
50
- .wc-news-source{font-size:10px!important;color:#888!important;margin-top:4px!important}
51
-
52
- /* World Cup Fixtures */
53
- .wc-fixtures-list{display:flex!important;flex-direction:column!important;gap:6px!important}
54
- .wc-match-item{padding:10px!important;background:#202020!important;border-radius:8px!important;border-left:3px solid #333!important}
55
- .wc-match-item:nth-child(odd){background:#1e1e1e!important}
56
- .wc-match-date{font-size:10px!important;color:#888!important;margin-bottom:4px!important}
57
- .wc-match-teams{display:flex!important;align-items:center!important;justify-content:center!important;gap:12px!important}
58
- .wc-match-teams .wc-team{flex:1!important;font-size:12px!important;color:#ddd!important;text-align:center!important}
59
- .wc-match-teams .wc-score{font-size:16px!important;font-weight:900!important;color:#f0c040!important;min-width:60px!important;text-align:center!important}
60
- .wc-score.wc-live{color:#e74c3c!important}
61
- .wc-score.wc-finished{color:#888!important}
62
- .wc-score.wc-upcoming{color:#5cb87a!important}
63
- .wc-match-location{font-size:9px!important;color:#777!important;text-align:center!important;margin-top:4px!important}
64
-
65
- /* World Cup Standings & Stats Tables */
66
- .wc-standings-table,.wc-stats-table{font-size:11px!important;color:#ccc!important;overflow-x:auto!important;width:100%!important}
67
- .wc-standings-table table,.wc-stats-table table{width:100%!important;border-collapse:collapse!important;table-layout:fixed!important}
68
- .wc-standings-table th,.wc-stats-table th{background:#222!important;color:#999!important;padding:6px 4px!important;font-size:10px!important;border-bottom:1px solid #333!important;text-align:center!important}
69
- .wc-standings-table th:first-child,.wc-stats-table th:first-child{text-align:left!important}
70
- .wc-standings-table td,.wc-stats-table td{padding:5px 4px!important;border-bottom:1px solid #1a1a1a!important;text-align:center!important;font-size:11px!important}
71
- .wc-standings-table td:first-child,.wc-stats-table td:first-child{text-align:left!important}
72
- .wc-standings-table .pts,.wc-stats-table .pts{font-weight:800!important;color:#f0c040!important}
73
- .wc-standings-table .team-name,.wc-stats-table .team-name{display:flex!important;align-items:center!important;gap:4px!important}
74
- .wc-standings-table .team-name img,.wc-stats-table .team-name img{width:16px!important;height:16px!important;object-fit:contain!important}
75
-
76
- /* World Cup Highlights */
77
- .wc-highlights-grid{display:grid!important;grid-template-columns:repeat(2,1fr)!important;gap:8px!important}/* World Cup Stats */
78
- .wc-stats{padding:10px!important}
79
- .wc-stat-group{margin-bottom:16px!important;padding:10px!important;background:#202020!important;border-radius:8px!important}
80
- .wc-stat-group h4{font-size:12px!important;color:#5cb87a!important;margin-bottom:8px!important;font-weight:700!important}
81
- .wc-stat-group .wc-empty{color:#888!important;font-size:11px!important;font-style:italic!important}
82
- .wc-stat-group table{width:100%!important;border-collapse:collapse!important}
83
- .wc-stat-group th{background:#2a2a2a!important;color:#999!important;padding:6px 4px!important;font-size:10px!important;text-align:left!important;border-bottom:1px solid #333!important}
84
- .wc-stat-group td{padding:5px 4px!important;border-bottom:1px solid #1a1a1a!important;font-size:11px!important;color:#ccc!important}
85
-
86
- /* World Cup Group Standings */
87
- .wc-group{margin-bottom:16px!important}
88
- .wc-group h4{font-size:12px!important;color:#f0c040!important;margin-bottom:6px!important;padding:6px 8px!important;background:#222!important;border-radius:6px!important;font-weight:700!important}
89
- .wc-table{width:100%!important;border-collapse:collapse!important;font-size:11px!important}
90
- .wc-table th{background:#2a2a2a!important;color:#999!important;padding:6px 4px!important;font-size:10px!important;text-align:center!important;border-bottom:1px solid #333!important}
91
- .wc-table th:first-child{text-align:left!important}
92
- .wc-table td{padding:5px 4px!important;border-bottom:1px solid #1a1a1a!important;text-align:center!important;color:#ccc!important}
93
- .wc-table td:first-child{text-align:left!important;font-weight:600!important}
94
- .wc-table .pts{font-weight:800!important;color:#f0c040!important}
95
-
96
- /* World Cup Highlights */
97
- .wc-highlights-grid{display:grid!important;grid-template-columns:repeat(2,1fr)!important;gap:8px!important}
98
- .wc-hl-item{cursor:pointer!important}
99
- .wc-hl-thumb{position:relative!important;aspect-ratio:16/9!important;background:#333!important;border-radius:6px!important;overflow:hidden!important}
100
- .wc-hl-thumb img{width:100%!important;height:100%!important;object-fit:cover!important}
101
- .wc-hl-thumb .card-play{position:absolute!important;left:50%!important;top:50%!important;transform:translate(-50%,-50%)!important;width:30px!important;height:30px!important;border-radius:50%!important;background:rgba(0,0,0,.55)!important;display:flex!important;align-items:center!important;justify-content:center!important;color:#fff!important;font-size:12px!important}
102
- .wc-hl-title{font-size:10px!important;color:#ccc!important;margin-top:4px!important;line-height:1.2!important;display:-webkit-box!important;-webkit-line-clamp:2!important;-webkit-box-orient:vertical!important;overflow:hidden!important}
103
-
104
- /* === ARTICLE / REWRITE VIEW FIX === */
105
- .article-view{padding:12px 8px 40px!important;max-width:760px!important;margin:0 auto!important}
106
- .article-title{font-size:18px!important;font-weight:800!important;line-height:1.3!important;margin-bottom:8px!important;color:#fff!important}
107
- .article-p{font-size:14px!important;line-height:1.7!important;color:#ccc!important;margin-bottom:10px!important}
108
- .article-img{width:100%!important;border-radius:6px!important;margin:10px 0!important}
109
- .article-actions{display:flex!important;gap:8px!important;flex-wrap:wrap!important;border-top:1px solid #333!important;margin-top:16px!important;padding-top:10px!important}
110
- .article-actions button{background:#1a1a1a!important;border:1px solid #333!important;color:#ccc!important;padding:7px 12px!important;border-radius:14px!important;font-size:11px!important;cursor:pointer!important}
111
- .article-actions button.primary{background:#2d8659!important;border-color:#2d8659!important;color:#fff!important}
112
- .badge-ai{background:#2d8659!important;color:#fff!important;font-size:8px!important;padding:1px 5px!important;border-radius:3px!important;font-weight:700!important;display:inline-block!important}
113
- /* TTS voice selector */
114
- .tts-selector{margin:12px 0!important;padding:10px!important;background:#1a1a1a!important;border:1px solid #2a2a2a!important;border-radius:10px!important;width:100%!important}
115
- .tts-selector-label{font-size:12px!important;font-weight:700!important;color:#5cb87a!important;margin-bottom:8px!important}
116
- .tts-voice-groups{display:flex!important;flex-direction:column!important;gap:8px!important;max-height:280px!important;overflow-y:auto!important}
117
- .tts-voice-group{background:#1e1e1e!important;border:1px solid #2a2a2a!important;border-radius:8px!important;padding:6px 8px!important}
118
- .tts-voice-group-label{font-size:10px!important;font-weight:700!important;color:#888!important;margin-bottom:4px!important;text-transform:uppercase!important}
119
- .tts-voice-btns{display:flex!important;gap:6px!important;flex-wrap:wrap!important}
120
- .tts-voice-btn{background:#222!important;border:1px solid #333!important;color:#ddd!important;border-radius:12px!important;padding:6px 10px!important;font-size:10px!important;cursor:pointer!important;white-space:nowrap!important}
121
- .tts-voice-btn.active{background:#2d8659!important;border-color:#2d8659!important;color:#fff!important;font-weight:700!important}
122
- .tts-speed-row{display:flex!important;align-items:center!important;gap:8px!important;font-size:12px!important;color:#aaa!important;margin-top:10px!important}
123
- .tts-speed-row select{flex:1!important;background:#222!important;border:1px solid #333!important;color:#eee!important;border-radius:14px!important;padding:6px 10px!important;font-size:11px!important}
124
- /* Image gallery */
125
- .article-image-gallery{margin:10px 0!important}
126
- .article-hero-img{width:100%!important;border-radius:8px!important;margin-bottom:6px!important}
127
- .gallery-thumbs{display:flex!important;gap:6px!important;overflow-x:auto!important;padding-bottom:4px!important}
128
- .gallery-thumb{flex:0 0 100px!important;aspect-ratio:16/9!important;border-radius:6px!important;overflow:hidden!important;background:#222!important;cursor:pointer!important}
129
- .gallery-thumb img{width:100%!important;height:100%!important;object-fit:cover!important}
130
- /* === BADGE FIX (article AI badge) === */
131
- .badge{display:inline-block!important;font-size:8px!important;padding:1px 5px!important;border-radius:3px!important;font-weight:700!important;vertical-align:middle!important}
132
- .badge.badge-ai,.badge-ai{background:#2d8659!important;color:#fff!important}
133
- /* article-view top spacing so badge+title not clipped */
134
- .article-view .badge{margin-bottom:6px!important}
135
- .article-view .tts-selector{width:auto!important;display:block!important}
136
-
137
- /* ============================================================
138
- 1:1 ("ratio-square") view for normal horizontal Dan tri / SKDS videos.
139
- Spec: fill 100% of the slide HEIGHT, crop the excess WIDTH (cut left/right).
140
- A YouTube <iframe> ignores object-fit, so instead of letterboxing we size the
141
- iframe to FULL slide height and 16:9 width (177.78vh -> wider than a phone),
142
- center it, and let the slide's overflow:hidden clip the sides.
143
- The #tiktok-feed ancestor raises specificity above app_v2.js's injected
144
- `.tiktok-slide.ratio-square>iframe` rule, so this wins regardless of CSS
145
- load order (not just !important + source order).
146
- ============================================================ */
147
- #tiktok-feed .tiktok-slide.ratio-square{position:relative!important;overflow:hidden!important;display:block!important;background:#000!important}
148
- #tiktok-feed .tiktok-slide.ratio-square>iframe,
149
- #tiktok-feed .tiktok-slide.ratio-square>video{
150
- position:absolute!important;
151
- top:50%!important;
152
- left:50%!important;
153
- transform:translate(-50%,-50%)!important;
154
- height:100%!important; /* 100% full height */
155
- width:177.78vh!important; /* 16/9 of the height -> overflow on the sides */
156
- min-width:100%!important; /* never narrower than the screen */
157
- max-width:none!important;
158
- max-height:none!important;
159
- object-fit:cover!important; /* affects <video>; harmless for <iframe> */
160
- }
161
- /* Fallback copy at plain specificity too (in case the feed id ever differs) */
162
- .tiktok-slide.ratio-square{position:relative!important;overflow:hidden!important;display:block!important;background:#000!important}
163
- .tiktok-slide.ratio-square>iframe,
164
- .tiktok-slide.ratio-square>video{
165
- position:absolute!important;top:50%!important;left:50%!important;
166
- transform:translate(-50%,-50%)!important;
167
- height:100%!important;width:177.78vh!important;
168
- min-width:100%!important;max-width:none!important;max-height:none!important;
169
- object-fit:cover!important;
170
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
static/index_v2.html CHANGED
@@ -10,41 +10,38 @@
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 · Highlight · AI · World Cup 2026</p></div>
19
  <div class="cats" id="cat-bar"></div>
20
  <div id="view-home" class="view active"><div class="loading">Đang tải...</div></div>
21
  <div id="view-cat" class="view"></div>
22
  <div id="view-video" class="view"></div>
23
  <div id="view-tiktok" class="view"></div>
24
  <div id="view-article" class="view"></div>
25
- <div class="match-overlay" id="match-overlay">
26
- <div class="mo-header"><h3 id="mo-title">Chi tiết trận đấu</h3><button class="mo-close" onclick="closeMatch()">✕</button></div>
27
- <div class="mo-tabs"><span class="mo-tab active" onclick="loadMatchTab('detail')">📋 Chi tiết</span><span class="mo-tab" onclick="loadMatchTab('comm')">Diễn biến</span><span class="mo-tab" onclick="loadMatchTab('stats')">Thống kê</span></div>
28
- <div class="mo-body" id="mo-body"><div class="loading">Đang tải...</div></div>
29
- </div>
30
  <div id="progress-toast"></div>
31
  <script>
32
- var _cats=[],_hlLeagueData={},_currentArticle=null;window._currentEventId='';
 
33
  function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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('[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)}}
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+='<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)});}
39
- var SPACE=location.origin;
40
  </script>
41
- <script src="/static/app_v2.js?v=20260701"></script>
42
- <script src="/static/yt_live.js"></script>
43
- <script src="/static/vtv_init.js"></script>
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>init();loadHome();</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;transition:border-color .6s ease,box-shadow .6s ease;position:relative}.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}
14
+ /* TTS Voice Selector — Multi-country grouped */
15
+ .tts-selector{margin:12px 0;padding:10px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:10px;width:100%}.tts-selector-label{font-size:12px;font-weight:700;color:#5cb87a;margin-bottom:8px}.tts-voice-groups{display:flex;flex-direction:column;gap:8px;max-height:280px;overflow-y:auto;scrollbar-width:thin}.tts-voice-group{background:#1e1e1e;border:1px solid #2a2a2a;border-radius:8px;padding:6px 8px}.tts-voice-group-label{font-size:10px;font-weight:700;color:#888;margin-bottom:4px;text-transform:uppercase;letter-spacing:.5px}.tts-voice-btns{display:flex;gap:6px;flex-wrap:wrap}.tts-voice-btn{background:#222;border:1px solid #333;color:#ddd;border-radius:12px;padding:6px 10px;font-size:10px;cursor:pointer;transition:all .2s;white-space:nowrap}.tts-voice-btn:hover{border-color:#5cb87a;color:#5cb87a}.tts-voice-btn.active{background:#2d8659;border-color:#2d8659;color:#fff;font-weight:700}.tts-speed-row{display:flex;align-items:center;gap:8px;font-size:12px;color:#aaa;margin-top:10px}.tts-speed-row select{flex:1;background:#222;border:1px solid #333;color:#eee;border-radius:14px;padding:6px 10px;font-size:11px;cursor:pointer}
16
+ /* Article Image Gallery */
17
+ .article-image-gallery{margin:10px 0}.article-hero-img{width:100%!important;border-radius:8px;margin-bottom:6px;transition:opacity .3s}.gallery-thumbs{display:flex;gap:6px;overflow-x:auto;padding-bottom:4px;scrollbar-width:none}.gallery-thumbs::-webkit-scrollbar{display:none}.gallery-thumb{flex:0 0 100px;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#222;cursor:pointer;border:2px solid transparent;transition:border-color .2s}.gallery-thumb img{width:100%;height:100%;object-fit:cover;transition:transform .2s}.gallery-thumb:hover img{transform:scale(1.05)}.gallery-thumb.active{border-color:#5cb87a}
18
+ /* Wall video badge with image count */
19
+ -wall-img-count{position:absolute;bottom:4px;left:4px;background:rgba(0,0,0,.7);color:#fff;font-size:9px;padding:1px 5px;border-radius:4px}#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>
20
  </head>
21
  <body>
22
+ <div class="header"><h1>📰 VNEWS</h1><p>Tin tức · Bóng đá LIVE · Video · AI · World Cup 2026</p></div>
23
  <div class="cats" id="cat-bar"></div>
24
  <div id="view-home" class="view active"><div class="loading">Đang tải...</div></div>
25
  <div id="view-cat" class="view"></div>
26
  <div id="view-video" class="view"></div>
27
  <div id="view-tiktok" class="view"></div>
28
  <div id="view-article" class="view"></div>
29
+ <div class="match-overlay" id="match-overlay"><div class="mo-header"><h3 id="mo-title">Chi tiết trận đấu</h3><button class="mo-close" onclick="closeMatch()">✕</button></div><div class="mo-tabs"><span class="mo-tab active" onclick="loadMatchTab('comm')">Diễn biến</span><span class="mo-tab" onclick="loadMatchTab('stats')">Thống kê</span></div><div class="mo-body" id="mo-body"><div class="loading">Đang tải...</div></div></div>
 
 
 
 
30
  <div id="progress-toast"></div>
31
  <script>
32
+ const SPACE=location.origin;
33
+ let _cats=[],_hlLeagueData={},_currentArticle=null,_currentEventId='';
34
  function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]))}
35
  function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')}
36
+ 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)}}
37
  function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}}
38
  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(()=>{})}
39
+ 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)});await loadHome()}
 
40
  </script>
41
+ <script src="/static/app_v2.js"></script>
42
+ <script src="/static/hot_multi.js"></script>
 
 
43
  <script src="/static/wc2026_v2.js"></script>
44
  <script src="/static/live_mode.js"></script>
45
+ <script>init();</script>
 
46
  </body>
47
+ </html>
static/live_mode.js CHANGED
@@ -1,47 +1,25 @@
1
- // === INJECT CSS FIX with cache-bust + inline backup ===
2
- (function(){
3
- // Method 1: link with cache-bust
4
- const link = document.createElement('link');
5
- link.rel = 'stylesheet';
6
- link.href = '/static/fm_fix.css?v=' + Date.now();
7
- document.head.appendChild(link);
8
-
9
- // Method 2: inline critical CSS directly (guaranteed, no cache) — highest priority
10
- const style = document.createElement('style');
11
- style.textContent = `
12
- .featured-match{margin:6px 4px!important;background:linear-gradient(135deg,#1a2a1f,#0d1117)!important;border:1px solid #2d8659!important;border-radius:10px!important;padding:12px!important;cursor:pointer!important}
13
- .fm-league{text-align:center!important;color:#5cb87a!important;font-size:9px!important;font-weight:700!important;text-transform:uppercase!important;display:block!important}
14
- .fm-teams{display:flex!important;align-items:center!important;justify-content:center!important;gap:10px!important;margin-top:6px!important}
15
- .fm-team{flex:1!important;display:flex!important;flex-direction:column!important;align-items:center!important;gap:4px!important}
16
- .fm-team img{width:32px!important;height:32px!important;object-fit:contain!important}
17
- .fm-team span{font-size:10px!important;color:#ccc!important;text-align:center!important}
18
- .fm-score{font-size:22px!important;font-weight:900!important;min-width:60px!important;text-align:center!important;color:#fff!important}
19
- .fm-status{text-align:center!important;margin-top:6px!important;font-size:9px!important;color:#e74c3c!important;font-weight:700!important}
20
- .fm-status.upcoming{color:#f0c040!important}
21
- .hashtag-src-item{display:flex!important;gap:8px!important;padding:8px!important;background:#202020!important;border-radius:8px!important;margin:6px 0!important;cursor:pointer!important}
22
- .hashtag-src-img{flex:0 0 80px!important;aspect-ratio:16/9!important;background:#333!important;border-radius:6px!important;overflow:hidden!important}
23
- .hashtag-src-img img{width:100%!important;height:100%!important;object-fit:cover!important}
24
- .hashtag-src-text{flex:1!important;min-width:0!important}
25
- .hashtag-src-title{font-size:12px!important;font-weight:700!important;color:#eee!important;display:-webkit-box!important;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden!important}
26
- .hashtag-src-via{font-size:10px!important;color:#888!important;margin-top:2px!important}`;
27
- document.head.appendChild(style);
28
-
29
- // Load rewrite fix
30
- const script = document.createElement('script');
31
- script.src = '/static/rewrite_fix.js?v=' + Date.now();
32
- document.body.appendChild(script);
33
- })();
34
 
35
  let _liveInterval = null;
36
  let _liveTick = 0;
 
37
  function startHomepageLive() {
38
  if (_liveInterval) clearInterval(_liveInterval);
 
 
39
  setTimeout(addLiveBadges, 2000);
 
40
  _liveInterval = setInterval(async () => {
41
  if (!document.getElementById('view-home')?.classList.contains('active')) return;
42
  _liveTick++;
 
 
43
  const lsTab = document.querySelector('.ls-tab.active');
44
  if (lsTab && lsTab.dataset.tab) loadLivescore(lsTab.dataset.tab);
 
 
45
  try {
46
  const f = await fetch('/api/livescore/featured').then(r => r.json()).catch(() => null);
47
  if (f && f.home) {
@@ -53,12 +31,25 @@ function startHomepageLive() {
53
  }
54
  }
55
  } catch(e) {}
56
- if (_liveTick % 3 === 0) refreshHashtag();
57
- if (_liveTick % 5 === 0) loadHotTopics();
 
 
 
 
 
 
 
 
 
 
58
  pulseLiveBadges();
59
- }, 60000);
 
60
  }
 
61
  function addLiveBadges() {
 
62
  document.querySelectorAll('.ls-header h3, .slider-header .slider-label').forEach(el => {
63
  if (!el.querySelector('.live-dot')) {
64
  const dot = document.createElement('span');
@@ -69,17 +60,22 @@ function addLiveBadges() {
69
  }
70
  });
71
  }
 
72
  function pulseLiveBadges() {
 
73
  document.querySelectorAll('.live-dot').forEach(d => {
74
  d.style.opacity = '1';
75
  setTimeout(() => { d.style.opacity = '0.4'; }, 500);
76
  setTimeout(() => { d.style.opacity = '1'; }, 1000);
77
  });
78
  }
 
79
  function refreshHashtag() {
 
80
  if (typeof _htTopic !== 'undefined' && _htTopic) {
81
  const box = document.getElementById('hashtag-box');
82
  if (box && box.innerHTML.length > 50) {
 
83
  fetch(`/api/hashtag/sources?topic=${encodeURIComponent(_htTopic)}&page=0`)
84
  .then(r => r.json())
85
  .then(j => {
@@ -87,11 +83,13 @@ function refreshHashtag() {
87
  if (!sources.length) return;
88
  const list = document.getElementById('ht-list');
89
  if (!list) return;
 
90
  let h = '';
91
  sources.forEach((s, i) => {
92
  h += `<div class="hashtag-src-item" onclick="readArticle('${esc(s.url)}')"><div class="hashtag-src-img" id="ht-img-${i}"></div><div class="hashtag-src-text"><div class="hashtag-src-title">${esc(s.title)}</div><div class="hashtag-src-via">${esc(s.via || '')}</div></div></div>`;
93
  });
94
  list.innerHTML = h;
 
95
  sources.forEach((s, i) => {
96
  if (!s.url) return;
97
  fetch('/api/article?url=' + encodeURIComponent(s.url)).then(r => r.json()).then(d => {
@@ -105,4 +103,5 @@ function refreshHashtag() {
105
  }
106
  }
107
  }
 
108
  setTimeout(startHomepageLive, 6000);
 
1
+ // === HOMEPAGE LIVE MODE ===
2
+ // ALL sections show ● LIVE badge and auto-refresh without page reload
3
+ // Livescore: 60s, Featured: 60s, Shorts: 5min, Hashtag: 3min, Highlights: 5min
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  let _liveInterval = null;
6
  let _liveTick = 0;
7
+
8
  function startHomepageLive() {
9
  if (_liveInterval) clearInterval(_liveInterval);
10
+
11
+ // Add LIVE badges to all section headers
12
  setTimeout(addLiveBadges, 2000);
13
+
14
  _liveInterval = setInterval(async () => {
15
  if (!document.getElementById('view-home')?.classList.contains('active')) return;
16
  _liveTick++;
17
+
18
+ // === Livescore: every 60s ===
19
  const lsTab = document.querySelector('.ls-tab.active');
20
  if (lsTab && lsTab.dataset.tab) loadLivescore(lsTab.dataset.tab);
21
+
22
+ // === Featured match: every 60s ===
23
  try {
24
  const f = await fetch('/api/livescore/featured').then(r => r.json()).catch(() => null);
25
  if (f && f.home) {
 
31
  }
32
  }
33
  } catch(e) {}
34
+
35
+ // === Hashtag: every 3 min (tick 3) ===
36
+ if (_liveTick % 3 === 0) {
37
+ refreshHashtag();
38
+ }
39
+
40
+ // === Hot topics: every 5 min (tick 5) ===
41
+ if (_liveTick % 5 === 0) {
42
+ loadHotTopics();
43
+ }
44
+
45
+ // Pulse LIVE badges
46
  pulseLiveBadges();
47
+
48
+ }, 60000); // 60s base
49
  }
50
+
51
  function addLiveBadges() {
52
+ // Add ● LIVE to section headers that don't have it yet
53
  document.querySelectorAll('.ls-header h3, .slider-header .slider-label').forEach(el => {
54
  if (!el.querySelector('.live-dot')) {
55
  const dot = document.createElement('span');
 
60
  }
61
  });
62
  }
63
+
64
  function pulseLiveBadges() {
65
+ // Quick visual pulse to show data is fresh
66
  document.querySelectorAll('.live-dot').forEach(d => {
67
  d.style.opacity = '1';
68
  setTimeout(() => { d.style.opacity = '0.4'; }, 500);
69
  setTimeout(() => { d.style.opacity = '1'; }, 1000);
70
  });
71
  }
72
+
73
  function refreshHashtag() {
74
+ // Re-search current hashtag topic for fresh results
75
  if (typeof _htTopic !== 'undefined' && _htTopic) {
76
  const box = document.getElementById('hashtag-box');
77
  if (box && box.innerHTML.length > 50) {
78
+ // Silently refresh (don't show loading spinner)
79
  fetch(`/api/hashtag/sources?topic=${encodeURIComponent(_htTopic)}&page=0`)
80
  .then(r => r.json())
81
  .then(j => {
 
83
  if (!sources.length) return;
84
  const list = document.getElementById('ht-list');
85
  if (!list) return;
86
+ // Update content
87
  let h = '';
88
  sources.forEach((s, i) => {
89
  h += `<div class="hashtag-src-item" onclick="readArticle('${esc(s.url)}')"><div class="hashtag-src-img" id="ht-img-${i}"></div><div class="hashtag-src-text"><div class="hashtag-src-title">${esc(s.title)}</div><div class="hashtag-src-via">${esc(s.via || '')}</div></div></div>`;
90
  });
91
  list.innerHTML = h;
92
+ // Load images
93
  sources.forEach((s, i) => {
94
  if (!s.url) return;
95
  fetch('/api/article?url=' + encodeURIComponent(s.url)).then(r => r.json()).then(d => {
 
103
  }
104
  }
105
  }
106
+
107
  setTimeout(startHomepageLive, 6000);
static/match_detail_v6.js DELETED
@@ -1,212 +0,0 @@
1
- // === VNEWS — Match Detail UI (v6-final)
2
- (function(){
3
- if(window._mdLoadedV6) return;
4
- window._mdLoadedV6 = true;
5
-
6
- if(!document.getElementById('mdv6-css')){
7
- const s=document.createElement('style');
8
- s.id='mdv6-css';
9
- s.textContent=`
10
- .mdv6-sec{margin-bottom:14px}
11
- .mdv6-sec-title{font-size:12px;font-weight:800;color:#5cb87a;margin-bottom:6px;padding-bottom:4px;border-bottom:1px solid #2a2a2a}
12
- .mdv6-header{background:linear-gradient(135deg,#1a2a1f,#0d1117);border:1px solid #2d8659;border-radius:10px;padding:12px;text-align:center;margin-bottom:12px}
13
- .mdv6-teams{display:flex;align-items:center;justify-content:center;gap:12px}
14
- .mdv6-team{display:flex;flex-direction:column;align-items:center;gap:4px;flex:1}
15
- .mdv6-team img{width:44px;height:44px;object-fit:contain;background:#111;border-radius:50%;padding:4px;border:2px solid #333}
16
- .mdv6-vs .mdv6-score{font-size:26px;font-weight:900;color:#f0c040}
17
- .mdv6-vs .mdv6-status{font-size:9px;font-weight:700;padding:2px 10px;border-radius:4px}
18
- .mdv6-vs .mdv6-status.finished{background:#2d8659;color:#a8ffb8}
19
- .mdv6-vs .mdv6-status.live{background:#c0392b;color:#fff;animation:mdv6pulse 1.5s infinite}
20
- .mdv6-tabs{display:flex;gap:4px;margin-bottom:10px}
21
- .mdv6-tab-btn{flex:1;padding:9px;border:none;border-radius:6px;font-size:11px;font-weight:700;cursor:pointer}
22
- .mdv6-tab-btn.active{background:#2d8659;color:#fff}
23
- .mdv6-tab-btn:not(.active){background:#1a1a1a;color:#888}
24
- .mdv6-tab-content{display:none}
25
- .mdv6-tab-content.active{display:block}
26
- .mdv6-timeline{}
27
- .mdv6-period{font-size:11px;font-weight:800;color:#5cb87a;padding:6px 0;border-bottom:1px solid #2a2a2a;margin-bottom:2px}
28
- .mdv6-ev{display:flex;align-items:flex-start;gap:8px;padding:7px 0;border-bottom:1px solid #1a1a1a}
29
- .mdv6-ev-time{font-size:11px;font-weight:800;color:#f0c040;min-width:40px;text-align:center}
30
- .mdv6-ev-icon{width:20px;height:20px;display:flex;align-items:center;justify-content:center}
31
- .mdv6-ev-body{flex:1}
32
- .mdv6-ev-team{font-size:8px;font-weight:800;padding:1px 5px;border-radius:3px;margin-right:4px}
33
- .mdv6-ev-team.home{background:#1a3a2a;color:#5cb87a}
34
- .mdv6-ev-team.away{background:#3a2a1a;color:#e85d04}
35
- .mdv6-ev-title{font-size:12px;color:#fff;font-weight:700}
36
- .mdv6-ev-detail{font-size:11px;color:#999;margin-top:3px}
37
- .mdv6-stat-row{display:flex;align-items:center;gap:8px;padding:5px 0;font-size:11px;border-bottom:1px solid #1a1a1a}
38
- .mdv6-stat-home{width:36px;text-align:right;color:#5cb87a;font-weight:700}
39
- .mdv6-stat-label{flex:1;text-align:center;color:#999;font-size:10px}
40
- .mdv6-stat-away{width:36px;text-align:left;color:#e85d04;font-weight:700}
41
- .mdv6-pred{background:linear-gradient(135deg,#1a2a1f,#0d1117);border:1px solid #2d8659;border-radius:10px;padding:10px;margin-bottom:12px}
42
- .mdv6-pred-title{font-size:9px;color:#5cb87a;font-weight:700;text-align:center;margin-bottom:8px}
43
- .mdv6-pred-teams{display:flex;align-items:center;justify-content:center;gap:8px}
44
- .mdv6-pred-vs{font-size:14px;font-weight:800;color:#f0c040}
45
- .mdv6-recent-row{display:flex;align-items:center;gap:8px;padding:6px 8px;background:#111;border-radius:6px;margin-bottom:3px;font-size:11px}
46
- .mdv6-recent-date{font-size:9px;color:#888;min-width:55px}
47
- .mdv6-recent-score{font-size:11px;font-weight:800;color:#f0c040;min-width:36px;text-align:center}
48
- .mdv6-no-data{text-align:center;color:#666;padding:16px 8px;font-size:12px}
49
- .mdv6-warn{color:#f0c040;font-size:12px;padding:8px;background:#2a2a1a;border-radius:6px}
50
- .mdv6-source{font-size:9px;color:#5cb87a;text-align:center;padding:3px;background:#1a2a1f;border-radius:4px;margin-bottom:8px}
51
- @keyframes mdv6spin{to{transform:rotate(360deg)}}
52
- @keyframes mdv6pulse{0%,100%{opacity:1}50%{opacity:.5}}
53
- `;
54
- document.head.appendChild(s);
55
- }
56
-
57
- function esc(s){const d=document.createElement('div');d.textContent=s||'';return d.innerHTML;}
58
-
59
- // Hook: override loadMatchTab to use fast endpoint
60
- window._origLoadMatchTab = window.loadMatchTab;
61
- window.loadMatchTab = async function(tab){
62
- if(tab!=='detail'){
63
- if(window._origLoadMatchTab) return window._origLoadMatchTab.call(this, tab);
64
- return;
65
- }
66
- document.querySelectorAll('.mo-tab').forEach(t=>t.classList.remove('active'));
67
- const el=document.getElementById('mo-body');
68
- if(!el)return;
69
- el.innerHTML='<div class="mdv6-no-data"><div style="display:inline-block;width:20px;height:20px;border:2px solid #333;border-top-color:#5cb87a;border-radius:50%;animation:mdv6spin .8s linear infinite"></div><div style="margin-top:8px">Đang tải...</div></div>';
70
-
71
- const eventId=window._currentEventId;
72
- let matchUrl = window._currentMatchUrl;
73
- if(!eventId){el.innerHTML='<div class="mdv6-warn">⚠️ Không tìm thấy mã trận đấu.</div>';return;}
74
-
75
- // Auto-fetch slug if missing (for featured matches clicked without URL)
76
- if(!matchUrl && eventId){
77
- try{
78
- matchUrl = 'https://bongda.com.vn/tran-dau/' + eventId;
79
- // Store for next time
80
- window._currentMatchUrl = matchUrl;
81
- }catch(e){}
82
- }
83
-
84
- try{
85
- const url = '/api/match/' + eventId + '/detail' + (matchUrl ? '?url=' + encodeURIComponent(matchUrl) : '');
86
- const r = await fetch(url, {signal: AbortSignal.timeout(25000)});
87
- const data = await r.json();
88
- if(data.found && data.info && data.info.home_team){
89
- el.innerHTML = '<div class="mdv6-source">📡 Dữ liệu server-side nhanh</div>';
90
- renderMatchDetail(el, data);
91
- }else{
92
- el.innerHTML = '<div class="mdv6-warn">⚠️ Không thể tải dữ liệu.<br>Event ID: ' + esc(String(eventId)) + '<br><a href="https://bongda.com.vn/tran-dau/' + eventId + '" target="_blank">Xem trên bongda.com.vn</a></div>';
93
- }
94
- }catch(e){
95
- el.innerHTML = '<div class="mdv6-warn">⚠️ Lỗi: ' + esc(e.message) + '</div>';
96
- }
97
- };
98
-
99
- function renderMatchDetail(el, data){
100
- const info=data.info||{};
101
- let h='';
102
-
103
- h+='<div class="mdv6-header"><div class="mdv6-teams">';
104
- h+='<div class="mdv6-team">';
105
- if(info.home_logo)h+='<img src="' + esc(info.home_logo) + '" alt="' + esc(info.home_team) + '">';
106
- h+='<span>' + esc(info.home_team) + '</span></div>';
107
- h+='<div class="mdv6-vs">';
108
- if(info.score)h+='<span class="mdv6-score">' + esc(info.score) + '</span>';
109
- if(info.status_label){
110
- const sl=info.status_label;let cls='finished';
111
- if(sl==='LIVE'||sl==='H1'||sl==='H2')cls='live';
112
- h+='<span class="mdv6-status ' + cls + '">' + esc(sl) + '</span>';
113
- }
114
- h+='</div><div class="mdv6-team">';
115
- if(info.away_logo)h+='<img src="' + esc(info.away_logo) + '" alt="' + esc(info.away_team) + '">';
116
- h+='<span>' + esc(info.away_team) + '</span></div></div></div>';
117
-
118
- const hasEvents=data.events&&data.events.length>0;
119
- const hasStats=data.h2h_stats_parsed||data.prediction||data.recent_matches;
120
-
121
- if(hasStats||hasEvents){
122
- h+='<div class="mdv6-tabs">';
123
- if(hasStats)h+='<button class="mdv6-tab-btn active" onclick="window._mdv6ShowTab(\'stats\',this)">📊 Thống kê</button>';
124
- if(hasEvents)h+='<button class="mdv6-tab-btn" onclick="window._mdv6ShowTab(\'events\',this)">📢 Diễn biến</button>';
125
- h+='</div>';
126
- }
127
-
128
- if(hasStats){
129
- h+='<div id="mdv6-tab-stats" class="mdv6-tab-content" style="display:' + (hasEvents?'none':'block') + '">';
130
- if(data.h2h_stats_parsed){
131
- h+='<div class="mdv6-sec"><div class="mdv6-sec-title">📊 Tỷ lệ đối đầu</div>';
132
- for(const label in data.h2h_stats_parsed){
133
- const sv=data.h2h_stats_parsed[label];
134
- h+='<div class="mdv6-stat-row"><span class="mdv6-stat-home">' + esc(sv.home) + '</span><span class="mdv6-stat-label">' + esc(label) + '</span><span class="mdv6-stat-away">' + esc(sv.away) + '</span></div>';
135
- }
136
- h+='</div>';
137
- }
138
- if(data.prediction){
139
- h+='<div class="mdv6-pred"><div class="mdv6-pred-title">🎯 Dự đoán</div><div class="mdv6-pred-teams">';
140
- h+='<span>' + esc(data.prediction.home_name || '') + '</span><span class="mdv6-pred-vs">' + esc(data.prediction.result || 'VS') + '</span><span>' + esc(data.prediction.away_name || '') + '</span></div></div>';
141
- }
142
- if(data.recent_matches){
143
- h+='<div class="mdv6-sec"><div class="mdv6-sec-title">📋 Kết quả gần nhất</div>';
144
- data.recent_matches.slice(0,6).forEach(m=>{
145
- h+='<div class="mdv6-recent-row"><span class="mdv6-recent-date">' + esc(m.date) + '</span><span>' + esc(m.home) + '</span><span class="mdv6-recent-score">' + esc(m.score) + '</span><span>' + esc(m.away) + '</span></div>';
146
- });
147
- h+='</div>';
148
- }
149
- h+='</div>';
150
- }
151
-
152
- if(hasEvents){
153
- h+='<div id="mdv6-tab-events" class="mdv6-tab-content" style="display:' + (hasStats?'none':'block') + '">';
154
- h+='<div class="mdv6-sec"><div class="mdv6-sec-title">📢 Diễn biến trận đấu</div><div class="mdv6-timeline">';
155
- let lastPeriod='';
156
- data.events.forEach(ev=>{
157
- if(ev.period&&ev.period!==lastPeriod){
158
- h+='<div class="mdv6-period">' + esc(ev.period) + '</div>';
159
- lastPeriod=ev.period;
160
- }
161
- const isHome=ev.team==='home';
162
- const badge=isHome?'<span class="mdv6-ev-team home">HOME</span>':'<span class="mdv6-ev-team away">AWAY</span>';
163
- let icon='', title='', detail='';
164
-
165
- if(ev.type==='goal'){icon='⚽';title='BÀN THẮNG';detail=esc(ev.players);}
166
- else if(ev.type==='redcard'){icon='🟥';title='THỺ ĐỎ';detail=esc(ev.players);}
167
- else if(ev.type==='yellowcard'){icon='🟨';title='THỺ VÀNG';detail=esc(ev.players);}
168
- else if(ev.type==='substitution'){icon='↔️';title='THAY ĐỔI';detail=esc(ev.players);}
169
- else{icon='•';detail=esc(ev.players);}
170
-
171
- h+='<div class="mdv6-ev"><span class="mdv6-ev-time">' + esc(ev.time||'') + '</span><span class="mdv6-ev-icon">' + icon + '</span><div class="mdv6-ev-body">' + badge + ' <span class="mdv6-ev-title">' + title + '</span>';
172
- if(detail)h+='<div class="mdv6-ev-detail">' + detail + '</div>';
173
- h+='</div></div>';
174
- });
175
- h+='</div></div>';
176
- }
177
-
178
- el.innerHTML=h;
179
-
180
- window._mdv6ShowTab=(tab,btn)=>{
181
- document.querySelectorAll('.mdv6-tab-btn').forEach(b=>{b.classList.remove('active');b.style.background='#1a1a1a';b.style.color='#888';});
182
- if(btn){btn.classList.add('active');btn.style.background='#2d8659';btn.style.color='#fff';}
183
- const stats=document.getElementById('mdv6-tab-stats');
184
- const events=document.getElementById('mdv6-tab-events');
185
- if(stats)stats.style.display=tab==='stats'?'block':'none';
186
- if(events)events.style.display=tab==='events'?'block':'none';
187
- };
188
- }
189
-
190
- // Auto-extract match ID/slug on click
191
- document.addEventListener('click',e=>{
192
- const md=e.target.closest('.match-detail, .match-item');
193
- if(!md)return;
194
- const link=md.querySelector('a[href*="tran-dau"]');
195
- if(link){
196
- const href=link.getAttribute('href')||'';
197
- const m=href.match(/\/tran-dau\/(\d+)\/(?:centre|preview)\/(.+)/);
198
- if(m){
199
- window._currentEventId=m[1];
200
- window._currentSlug=m[2];
201
- window._currentMatchUrl='https://bongda.com.vn' + href;
202
- }else{
203
- const m2=href.match(/\/tran-dau\/(\d+)/);
204
- if(m2){
205
- window._currentEventId=m2[1];
206
- window._currentMatchUrl='https://bongda.com.vn' + href;
207
- }
208
- }
209
- }
210
- },true);
211
-
212
- })();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,268 +0,0 @@
1
- // ===== VNEWS VTV Player v9 — iframe sv2 proxy + EPG từ vtv.vn =====
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
-
21
- // ===== CSS =====
22
- (function injectCSS() {
23
- if (document.getElementById('vtv-pro-css')) return;
24
- var s = document.createElement('style');
25
- s.id = 'vtv-pro-css';
26
- s.textContent = [
27
- '#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}',
28
- '#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))}',
29
- '#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}',
30
- '#vtv-player-section .vtv-pro-title{font-size:13px;font-weight:700;color:#e8eaed;letter-spacing:.3px}',
31
- '#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}',
32
- '#vtv-player-section .vtv-pro-live-dot{width:5px;height:5px;border-radius:50%;background:#00cc88;animation:vtv-pro-pulse 1.2s infinite}',
33
- '@keyframes vtv-pro-pulse{0%,100%{opacity:1}50%{opacity:.3}}',
34
- '#vtv-player-section .vtv-pro-tabs{display:flex;flex-wrap:wrap;gap:2px;padding:6px 10px 8px;overflow-x:auto;scrollbar-width:none}',
35
- '#vtv-player-section .vtv-pro-tabs::-webkit-scrollbar{display:none}',
36
- '#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}',
37
- '#vtv-player-section .vtv-pro-tab:hover{background:rgba(0,102,204,.1);border-color:rgba(0,102,204,.3);color:#e8eaed}',
38
- '#vtv-player-section .vtv-pro-tab.on{background:rgba(0,102,204,.2);border-color:#0066cc;color:#fff;font-weight:600}',
39
- '#vtv-player-section .vtv-pro-tab .b{font-size:7px;opacity:.5;display:block;margin-top:1px}',
40
- '#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)}',
41
- '#vtv-player-section .vtv-pro-frame iframe{position:absolute;inset:0;width:100%!important;height:100%!important;border:none;background:#000}',
42
- '#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}',
43
- '#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}',
44
- '@keyframes vtv-pro-spin{to{transform:rotate(360deg)}}',
45
- '#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}',
46
- '#vtv-player-section .vtv-pro-err .icon{font-size:28px;opacity:.5}',
47
- '#vtv-player-section .vtv-pro-err .msg{color:#9aa0a6}',
48
- '#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}',
49
- '#vtv-player-section .vtv-pro-err button:hover{background:rgba(0,102,204,.25)}',
50
- '#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)}',
51
- '#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}',
52
- '#vtv-player-section .vtv-pro-btn:hover{background:rgba(0,102,204,.12);color:#e8eaed}',
53
- '#vtv-player-section .vtv-pro-epg{border-top:1px solid rgba(255,255,255,.04);background:rgba(0,0,0,.15)}',
54
- '#vtv-player-section .vtv-pro-epg-hdr{display:flex;align-items:center;justify-content:space-between;padding:8px 12px 4px}',
55
- '#vtv-player-section .vtv-pro-epg-title{font-size:10px;font-weight:600;color:#9aa0a6;letter-spacing:.5px;text-transform:uppercase}',
56
- '#vtv-player-section .vtv-pro-epg-time{font-size:9px;color:#5f6368;font-variant-numeric:tabular-nums}',
57
- '#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}',
58
- '#vtv-player-section .vtv-pro-epg-list::-webkit-scrollbar{width:3px}',
59
- '#vtv-player-section .vtv-pro-epg-list::-webkit-scrollbar-thumb{background:rgba(255,255,255,.08);border-radius:3px}',
60
- '#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}',
61
- '#vtv-player-section .vtv-pro-epg-row:hover{background:rgba(255,255,255,.03)}',
62
- '#vtv-player-section .vtv-pro-epg-row.now{border-left-color:#00cc88;background:rgba(0,204,136,.06)}',
63
- '#vtv-player-section .vtv-pro-epg-row.passed{opacity:.35}',
64
- '#vtv-player-section .vtv-pro-epg-row .t{font-size:10px;color:#5f6368;min-width:38px;font-variant-numeric:tabular-nums}',
65
- '#vtv-player-section .vtv-pro-epg-row.now .t{color:#00cc88;font-weight:600}',
66
- '#vtv-player-section .vtv-pro-epg-row .n{color:#e8eaed;line-height:1.3}',
67
- '#vtv-player-section .vtv-pro-epg-row.now .n{color:#fff;font-weight:500}',
68
- '#vtv-player-section .vtv-pro-epg-row .bar{flex:0 0 2px;height:12px;border-radius:1px;background:rgba(255,255,255,.08)}',
69
- '#vtv-player-section .vtv-pro-epg-row.now .bar{background:#00cc88}',
70
- '#vtv-player-section .vtv-pro-epg-row .d{font-size:8px;color:#5f6368;min-width:30px;text-align:right}',
71
- '#vtv-player-section .vtv-pro-epg-empty{color:#5f6368;font-size:11px;padding:12px 14px;text-align:center}',
72
- '#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}',
73
- ].join('');
74
- document.head.appendChild(s);
75
- })();
76
-
77
- function formatTime(d) {
78
- var h = d.getHours(), m = d.getMinutes();
79
- return (h < 10 ? '0' : '') + h + ':' + (m < 10 ? '0' : '') + m;
80
- }
81
-
82
- function getVNTime() {
83
- var now = new Date();
84
- try {
85
- 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' };
86
- var parts = new Intl.DateTimeFormat('en-CA', opts).formatToParts(now);
87
- var hh=0,mm=0,ss=0,yy=2026,mo=1,dd=1;
88
- for (var i = 0; i < parts.length; i++) {
89
- var p = parts[i];
90
- if (p.type === 'hour') hh = parseInt(p.value);
91
- else if (p.type === 'minute') mm = parseInt(p.value);
92
- else if (p.type === 'second') ss = parseInt(p.value);
93
- else if (p.type === 'year') yy = parseInt(p.value);
94
- else if (p.type === 'month') mo = parseInt(p.value);
95
- else if (p.type === 'day') dd = parseInt(p.value);
96
- }
97
- return new Date(yy, mo - 1, dd, hh, mm, ss);
98
- } catch(e) {
99
- var utc = new Date(now.getTime() + now.getTimezoneOffset() * 60000);
100
- return new Date(utc.getTime() + 7 * 3600000);
101
- }
102
- }
103
-
104
- // ===== EPG =====
105
- function loadEPG(chId) {
106
- var epgEl = document.getElementById('vtv-pro-epg-body');
107
- if (!epgEl) return;
108
- 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>';
109
- var xhr = new XMLHttpRequest();
110
- xhr.open('GET', '/api/vtv/epg/' + chId, true);
111
- xhr.timeout = 15000;
112
- xhr.onload = function() {
113
- try {
114
- var data = JSON.parse(xhr.responseText);
115
- _serverDate = data.date || '';
116
- _epgData = data.programs || [];
117
- renderEPG();
118
- } catch(e) { epgEl.innerHTML = '<div class="vtv-pro-epg-empty">Không có lịch phát sóng</div>'; }
119
- };
120
- xhr.onerror = xhr.ontimeout = function() {
121
- epgEl.innerHTML = '<div class="vtv-pro-epg-empty">Không tải được lịch phát sóng</div>';
122
- };
123
- xhr.send();
124
- }
125
-
126
- function renderEPG() {
127
- var epgEl = document.getElementById('vtv-pro-epg-body');
128
- if (!epgEl || !_epgData || !_epgData.length) {
129
- if (epgEl) epgEl.innerHTML = '<div class="vtv-pro-epg-empty">Chưa có lịch phát sóng</div>';
130
- return;
131
- }
132
- var vnNow = getVNTime();
133
- var nowStr = formatTime(vnNow);
134
- _epgData.sort(function(a, b) { return a.time.localeCompare(b.time); });
135
- for (var i = 0; i < _epgData.length; i++) {
136
- _epgData[i].now = false;
137
- var p = _epgData[i], nxt = _epgData[i + 1];
138
- if (p.time <= nowStr && (!nxt || nxt.time > nowStr)) { p.now = true; p.end_time = nxt ? nxt.time : ''; }
139
- }
140
- var html = '', prevDate = '';
141
- for (var i = 0; i < _epgData.length; i++) {
142
- var p = _epgData[i];
143
- var cls = 'vtv-pro-epg-row';
144
- if (p.now) cls += ' now';
145
- else if (p.time < nowStr) cls += ' passed';
146
- var xt = p.end_time ? ' → ' + p.end_time : (i < _epgData.length - 1 ? ' → ' + _epgData[i+1].time : '');
147
- var db = '';
148
- if (p.date && p.date !== prevDate) { db = '<span class="d">' + p.date + '</span>'; prevDate = p.date; }
149
- html += '<div class="' + cls + '"><span class="t">' + p.time + xt + '</span><span class="bar"></span><span class="n">' + p.title + '</span>' + db + '</div>';
150
- }
151
- epgEl.innerHTML = html;
152
- var nowEl = epgEl.querySelector('.now');
153
- if (nowEl) nowEl.scrollIntoView({ block: 'center', behavior: 'smooth' });
154
- }
155
-
156
- function startEpgRefresh() {
157
- if (_epgTimer) clearInterval(_epgTimer);
158
- _epgTimer = setInterval(function() { if (_currentCh) loadEPG(_currentCh); }, 60000);
159
- }
160
-
161
- function updateClock() {
162
- var timeEl = document.getElementById('vtv-pro-time');
163
- if (timeEl) {
164
- var vn = getVNTime();
165
- timeEl.textContent = formatTime(vn) + ' GMT+7' + (_serverDate ? ' - ' + _serverDate : '');
166
- }
167
- }
168
-
169
- // ===== Switch channel — iframe sv2 proxy =====
170
- window._vtvProSwitch = function(chId) {
171
- _currentCh = chId;
172
-
173
- var tabs = document.querySelectorAll('#vtv-player-section .vtv-pro-tab');
174
- for (var i = 0; i < tabs.length; i++)
175
- tabs[i].className = 'vtv-pro-tab' + (tabs[i].getAttribute('data-ch') === chId ? ' on' : '');
176
-
177
- updateClock();
178
- if (_timeTimer) clearInterval(_timeTimer);
179
- _timeTimer = setInterval(updateClock, 60000);
180
-
181
- var frame = document.getElementById('vtv-pro-frame');
182
- if (!frame) return;
183
- frame.innerHTML = '<div class="vtv-pro-load"><div class="vtv-pro-spinner"></div><span>Đang kết nối ' + chId.toUpperCase() + '...</span></div>';
184
-
185
- loadEPG(chId);
186
- startEpgRefresh();
187
-
188
- var xhr = new XMLHttpRequest();
189
- xhr.open('GET', '/api/vtv/stream/' + chId, true);
190
- xhr.timeout = 15000;
191
- xhr.onload = function() {
192
- try {
193
- var data = JSON.parse(xhr.responseText);
194
- // Chỉ dùng proxy_iframe_url (sv2) — vừa player, không mở trang mới
195
- var iframeUrl = data.proxy_iframe_url || data.iframe_url;
196
- if (!iframeUrl) {
197
- 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>';
198
- return;
199
- }
200
- // Iframe luôn vừa khung player (aspect-ratio 16/9)
201
- var iframe = document.createElement('iframe');
202
- iframe.src = iframeUrl;
203
- iframe.setAttribute('allowfullscreen', 'true');
204
- iframe.setAttribute('allow', 'autoplay; encrypted-media; fullscreen');
205
- iframe.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;border:none;background:#000';
206
- frame.innerHTML = '';
207
- frame.appendChild(iframe);
208
- } catch(e) {
209
- 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>';
210
- }
211
- };
212
- xhr.onerror = xhr.ontimeout = function() {
213
- 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>';
214
- };
215
- xhr.send();
216
- };
217
-
218
- // ===== HTML builder =====
219
- function buildHTML() {
220
- var tabs = '';
221
- for (var i = 0; i < CHANNELS.length; i++) {
222
- var ch = CHANNELS[i];
223
- tabs += '<span class="vtv-pro-tab" data-ch="'+ch.id+'" onclick="_vtvProSwitch(\''+ch.id+'\')">' +
224
- ch.name + '<span class="b">'+ch.badge+'</span></span>';
225
- }
226
- return '<div id="vtv-player-section">' +
227
- '<div class="vtv-pro-head">' +
228
- '<div class="vtv-pro-logo">V</div>' +
229
- '<span class="vtv-pro-title">VTV Player</span>' +
230
- '<span class="vtv-pro-live"><span class="vtv-pro-live-dot"></span>TRỰC TIẾP</span>' +
231
- '</div>' +
232
- '<div class="vtv-pro-tabs">'+tabs+'</div>' +
233
- '<div class="vtv-pro-frame" id="vtv-pro-frame">' +
234
- '<div class="vtv-pro-load"><div class="vtv-pro-spinner"></div><span>Chọn kênh để xem trực tiếp</span></div>' +
235
- '</div>' +
236
- '<div class="vtv-pro-controls">' +
237
- '<span id="vtv-pro-time" class="vtv-pro-btn" style="background:none;border:none;font-size:10px;color:#5f6368;margin-right:auto"></span>' +
238
- '</div>' +
239
- '<div class="vtv-pro-epg">' +
240
- '<div class="vtv-pro-epg-hdr"><span class="vtv-pro-epg-title">Lịch phát sóng — nguồn VTV.vn</span></div>' +
241
- '<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>' +
242
- '</div>' +
243
- '</div>';
244
- }
245
-
246
- function inject() {
247
- var homeEl = document.getElementById('view-home');
248
- if (!homeEl || document.getElementById('vtv-player-section')) return;
249
- var featured = document.getElementById('home-featured-area') || homeEl.querySelector('.featured-match, .fm-section, .slider-wrap');
250
- if (featured && featured.parentNode) featured.insertAdjacentHTML('afterend', buildHTML());
251
- else if (homeEl.firstChild) homeEl.insertAdjacentHTML('afterbegin', buildHTML());
252
- setTimeout(function() { if (window._vtvProSwitch) window._vtvProSwitch('vtv6'); }, 500);
253
- }
254
-
255
- var orig = window.loadHome;
256
- if (typeof orig === 'function') {
257
- window.loadHome = function() {
258
- var r = orig.apply(this, arguments);
259
- if (r && typeof r.then === 'function') return r.then(function(v) { setTimeout(inject, 2000); return v; });
260
- else { setTimeout(inject, 2000); return r; }
261
- };
262
- } else {
263
- (function waitAndInject() {
264
- if (document.getElementById('view-home') && !document.getElementById('vtv-player-section')) inject();
265
- else if (!document.getElementById('vtv-player-section')) setTimeout(waitAndInject, 1000);
266
- })();
267
- }
268
- })();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
@@ -1,48 +1,9 @@
1
- // === WORLD CUP 2026 - Highlights + Shorts ===
2
  let _wc2026Data=null;let _wcRefreshInterval=null;
3
- let _wcShortsData=[]; // YouTube shorts: VTV Nam Bộ + WC moments
4
  const WC_TEAMS={'Mexico':'🇲🇽 Mexico','Colombia':'🇨🇴 Colombia','USA':'🇺🇸 Mỹ','United States':'🇺🇸 Mỹ','Brazil':'🇧🇷 Brazil','Argentina':'🇦🇷 Argentina','Germany':'🇩🇪 Đức','France':'🇫🇷 Pháp','Spain':'🇪🇸 Tây Ban Nha','England':'🏴󠁧󠁢󠁥󠁮󠁧󠁿 Anh','Portugal':'🇵🇹 Bồ Đào Nha','Netherlands':'🇳🇱 Hà Lan','Italy':'🇮🇹 Ý','Belgium':'🇧🇪 Bỉ','Croatia':'🇭🇷 Croatia','Uruguay':'🇺🇾 Uruguay','Japan':'🇯🇵 Nhật Bản','South Korea':'🇰🇷 Hàn Quốc','Korea Republic':'🇰🇷 Hàn Quốc','Australia':'🇦🇺 Úc','Saudi Arabia':'🇸🇦 Ả Rập Xê Út','Iran':'🇮🇷 Iran','Qatar':'🇶🇦 Qatar','Canada':'🇨🇦 Canada','Morocco':'🇲🇦 Morocco','Senegal':'🇸🇳 Senegal','Ecuador':'🇪🇨 Ecuador','Serbia':'🇷🇸 Serbia','Switzerland':'🇨🇭 Thụy Sĩ','Denmark':'🇩🇰 Đan Mạch','Poland':'🇵🇱 Ba Lan','Turkey':'🇹🇷 Thổ Nhĩ Kỳ','Türkiye':'🇹🇷 Thổ Nhĩ Kỳ','Ukraine':'🇺🇦 Ukraine','Egypt':'🇪🇬 Ai Cập','Nigeria':'🇳🇬 Nigeria','Cameroon':'🇨🇲 Cameroon','Ghana':'🇬🇭 Ghana','Tunisia':'🇹🇳 Tunisia','Algeria':'🇩🇿 Algeria','South Africa':'🇿🇦 Nam Phi','Indonesia':'🇮🇩 Indonesia','Vietnam':'🇻🇳 Việt Nam','China PR':'🇨🇳 Trung Quốc','New Zealand':'🇳🇿 New Zealand','Panama':'🇵🇦 Panama','Costa Rica':'🇨🇷 Costa Rica','Jamaica':'🇯🇲 Jamaica','Honduras':'🇭🇳 Honduras','Paraguay':'🇵🇾 Paraguay','Bolivia':'🇧🇴 Bolivia','Chile':'🇨🇱 Chile','Peru':'🇵🇪 Peru','Venezuela':'🇻🇪 Venezuela','Norway':'🇳🇴 Na Uy','Sweden':'🇸🇪 Thụy Điển','Austria':'🇦🇹 Áo','Scotland':'🏴󠁧󠁢󠁳󠁣󠁴󠁿 Scotland','Wales':'🏴󠁧󠁢󠁷󠁬󠁳󠁿 Xứ Wales','Ireland':'🇮🇪 Ireland','Romania':'🇷🇴 Romania','Hungary':'🇭🇺 Hungary','Greece':'🇬🇷 Hy Lạp','Uzbekistan':'🇺🇿 Uzbekistan','Iraq':'🇮🇶 Iraq','TBD':'🏳️ TBD'};
5
  function _t(n){return WC_TEAMS[n]||WC_TEAMS[(n||'').trim()]||'🏴 '+n;}
6
 
7
- const WC_CSS=`<style>
8
- .wc-bxh{max-height:460px;overflow-y:auto;padding:2px;display:block}
9
- .wc-bxh,.wc-bxh *{box-sizing:border-box!important;float:none!important;max-width:100%!important}
10
- .wc-bxh script,.wc-bxh style,.wc-bxh ins,.wc-bxh iframe,.wc-bxh .info,.wc-bxh .btns,.wc-bxh .form{display:none!important}
11
- .wc-bxh .title-table,.wc-bxh h6{display:block!important;background:#0b1a2a!important;color:#f0c040!important;font-size:11px!important;font-weight:800!important;padding:6px 8px!important;margin:8px 0 0!important;border-radius:5px 5px 0 0!important;border-bottom:1px solid #1a3a5a!important}
12
- .wc-bxh .bxh-head{display:flex!important;align-items:center!important;padding:4px 6px!important;background:#0a1320!important;color:#6a9fca!important;font-size:9px!important;font-weight:700!important;border-bottom:1px solid #1a3a5a!important}
13
- .wc-bxh .bxh-head .h-team{flex:1!important;text-align:left!important}
14
- .wc-bxh .bxh-head .h-stat{flex:0 0 26px!important;text-align:center!important}
15
- .wc-bxh .bxh-head .h-pts{flex:0 0 30px!important;text-align:center!important;color:#f0c040!important}
16
- .wc-bxh .item{display:flex!important;align-items:center!important;padding:5px 6px!important;border-bottom:1px solid #10202f!important;background:transparent!important}
17
- .wc-bxh .item:nth-child(even){background:#0a1320!important}
18
- .wc-bxh .item .team{flex:1!important;display:flex!important;align-items:center!important;gap:5px!important;min-width:0!important;text-align:left!important}
19
- .wc-bxh .item .team>span{flex:0 0 16px!important;color:#6a9fca!important;font-size:10px!important;text-align:center!important}
20
- .wc-bxh .item .team a{flex:1!important;display:flex!important;align-items:center!important;gap:5px!important;min-width:0!important;color:#eee!important;text-decoration:none!important}
21
- .wc-bxh .item .team a p{font-size:11px!important;color:#eee!important;white-space:nowrap!important;overflow:hidden!important;text-overflow:ellipsis!important;margin:0!important}
22
- .wc-bxh .item .copy{display:flex!important;align-items:center!important;flex:0 0 auto!important}
23
- .wc-bxh .item .copy>p{flex:0 0 26px!important;text-align:center!important;font-size:10px!important;color:#ccc!important;margin:0!important}
24
- .wc-bxh .item .copy>p:last-of-type,.wc-bxh .item .copy>p:nth-last-child(2){flex:0 0 30px!important}
25
- .wc-bxh .item .copy>p strong{color:#f0c040!important;font-weight:800!important}
26
- .wc-bxh img{width:18px!important;height:18px!important;object-fit:contain!important;vertical-align:middle!important;display:inline-block!important;flex:0 0 18px!important}
27
- .wc-bxh strong{color:#f0c040!important}
28
- .wc-bxh a{color:#8ab4d8!important;text-decoration:none!important}
29
- .wc-bxh table{width:100%!important;border-collapse:collapse!important;font-size:11px!important;display:table!important}
30
- .wc-bxh th{background:#0b1a2a!important;color:#6a9fca!important;padding:5px 3px!important;font-size:9px!important;text-align:center!important}
31
- .wc-bxh td{padding:4px 3px!important;border-bottom:1px solid #0d1a2a!important;font-size:10px!important;color:#ddd!important}
32
- /* WC Shorts slider */
33
- .wc-shorts-slider{margin:8px 0}
34
- .wc-shorts-track{display:flex;overflow-x:auto;gap:8px;padding:4px 0 10px;scrollbar-width:none}
35
- .wc-shorts-track::-webkit-scrollbar{display:none}
36
- .wc-shorts-item{flex:0 0 130px;cursor:pointer;border-radius:8px;overflow:hidden;background:#0a1520;border:1px solid #1a3a5a;transition:border-color .2s}
37
- .wc-shorts-item:hover{border-color:#f0c040}
38
- .wc-shorts-thumb{position:relative;width:100%;aspect-ratio:9/16;background:#1a2a3a;overflow:hidden}
39
- .wc-shorts-thumb img{width:100%;height:100%;object-fit:cover}
40
- .wc-shorts-thumb .card-play{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:32px;height:32px;border-radius:50%;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;color:#fff;font-size:14px}
41
- .wc-shorts-info{padding:4px 6px}
42
- .wc-shorts-badge{font-size:8px;font-weight:700;padding:1px 4px;border-radius:3px;display:inline-block;margin-bottom:2px}
43
- .wc-shorts-title{font-size:10px;color:#ccc;line-height:1.2;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
44
- .wc-shorts-viewall{flex:0 0 100px;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#0b2e4a,#1a3a5a);border-radius:8px;cursor:pointer;border:1px dashed #2a5a8a;color:#8ab4d8;font-size:11px;font-weight:700;text-align:center;padding:8px}
45
- </style>`;
46
 
47
  function switchWCTab(tab){
48
  document.querySelectorAll('.wc-tab').forEach(t=>t.classList.remove('active'));
@@ -65,10 +26,10 @@ function renderWCNews(el){
65
  let h='<div class="wc-news-grid">';
66
  combined.slice(0,20).forEach((a,i)=>{
67
  const badge=a._type==='road'?'<span class="badge badge-wc">Đường tới WC</span>':'<span class="badge badge-fpt">Tin WC</span>';
68
- h+=`<div class="wc-news-item" onclick="readArticle('${esc(a.link)}')"><div class="wc-news-img" id="wcimg-${i}">${a.img?`<img src="${esc(a.img)}" loading="lazy" onerror="this.parentElement.innerHTML=''">`:''}</div><div class="wc-news-text">${badge}<div class="wc-news-title">${esc(a.title)}</div><div class="wc-news-via">${esc(a.source||'')}</div></div></div>`;
69
  });
70
  h+='</div>';el.innerHTML=h;
71
- combined.slice(0,20).forEach((a,i)=>{if(a.img)return;fetch('/api/article?url='+encodeURIComponent(a.link)).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){const x=document.getElementById('wcimg-'+i);if(x)x.innerHTML=`<img src="${esc(d.og_image||d.img)}" loading="lazy" onerror="this.parentElement.innerHTML=''">`;}}).catch(()=>{});});
72
  }
73
 
74
  // === FIXTURES ===
@@ -90,21 +51,8 @@ function renderWCFixtures(el){
90
  // === BXH ===
91
  function renderWCStandings(el){
92
  const d=_wc2026Data.standings||{};
93
- if(d.html&&d.html.length>100){
94
- el.innerHTML=WC_CSS+'<div class="wc-bxh">'+d.html+'</div>';
95
- const box=el.querySelector('.wc-bxh');
96
- if(box){
97
- box.querySelectorAll('.title-table, h6').forEach(t=>{
98
- if(t.nextElementSibling && t.nextElementSibling.classList.contains('bxh-head'))return;
99
- const head=document.createElement('div');
100
- head.className='bxh-head';
101
- head.innerHTML='<span class="h-team">Đội</span><span class="h-stat">Tr</span><span class="h-stat">T</span><span class="h-stat">H</span><span class="h-stat">B</span><span class="h-stat">BT</span><span class="h-stat">BB</span><span class="h-pts">HS</span><span class="h-pts">Đ</span>';
102
- t.insertAdjacentElement('afterend',head);
103
- });
104
- }
105
- el.querySelectorAll('a').forEach(a=>a.addEventListener('click',e=>e.preventDefault()));
106
- return;
107
- }
108
  const fx=_wc2026Data.fixtures||{};const matches=fx.matches||[];
109
  const gm=matches.filter(m=>m.group&&m.group.toLowerCase().includes('group'));
110
  if(!gm.length){el.innerHTML='<div class="loading">BXH cập nhật khi giải bắt đầu 11/06/2026<br><small style="color:#6a9fca">🔴 Livescore tự động</small></div>';return;}
@@ -121,186 +69,28 @@ function renderWCStats(el){
121
  else el.innerHTML='<div class="loading">Thống kê cập nhật khi giải bắt đầu<br><small style="color:#6a9fca">Vua phá lưới · Kiến tạo · Thẻ phạt</small></div>';
122
  }
123
 
124
- // === HIGHLIGHTS - WC match highlights from xemlaibongda.top ===
125
  async function renderWCHighlights(el){
126
- el.innerHTML='<div class="loading">Đang tải highlight World Cup...</div>';
127
  try{
128
- const wcRes = await fetch('/api/proxy/xlb?path=the-gioi/world-cup', {signal: AbortSignal.timeout(15000)}).then(r=>r.json()).catch(()=>({videos:[]}));
129
- let wcVids = (wcRes.videos||[]);
130
- // Deduplicate by link
131
- const seen = new Map();
132
- wcVids.forEach((v,i)=>{if(!seen.has(v.link)) seen.set(v.link, {...v, _li: seen.size});});
133
- wcVids = Array.from(seen.values());
134
-
135
- if(!wcVids.length){
136
- el.innerHTML='<div class="loading">Chưa có highlight World Cup<br><small style="color:#6a9fca">Highlight sẽ xuất hiện khi giải diễn ra</small></div>';
137
- return;
138
- }
139
-
140
  let h='<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:6px;max-height:450px;overflow-y:auto;padding:4px">';
141
- wcVids.forEach((v,i)=>{
142
- h+=`<div style="cursor:pointer;border-radius:6px;overflow:hidden;background:#0a1520" onclick="openHighlightFeed('world-cup',${v._li},'${esc(v.link)}')">`;
143
- h+=`<div style="position:relative;aspect-ratio:16/9;background:#1a2a3a">${v.img?`<img src="${esc(v.img)}" loading="lazy" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none'">`:''}<div class="card-play">▶</div></div>`;
144
- h+=`<div style="padding:4px 6px"><span style="font-size:8px;color:#f0c040">🌍 World Cup</span><div style="font-size:10px;color:#ccc;line-height:1.2;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden">${esc(v.title)}</div></div>`;
 
 
145
  h+='</div>';
146
  });
147
  h+='</div>';el.innerHTML=h;
148
- }catch(e){
149
- el.innerHTML='<div class="loading">Lỗi tải highlight: '+esc(e.message)+'</div>';
150
- }
151
- }
152
-
153
- // === WC SHORTS SLIDE — shown inside WC section ===
154
- function renderWCShortsSlide(container){
155
- if(!_wcShortsData || !_wcShortsData.length) return;
156
- const wrap = document.createElement('div');
157
- wrap.className = 'slider-wrap wc-shorts-slider';
158
- let h = '<div class="slider-header"><span class="slider-label">📱 Shorts World Cup</span><span style="font-size:10px;color:#8ab4d8;cursor:pointer" onclick="openWCShortsFeed(0)">Xem tất cả →</span></div>';
159
- h += '<div class="wc-shorts-track">';
160
- _wcShortsData.slice(0,10).forEach((s,i)=>{
161
- const badge = s.channel==='vtvnambo' ? 'VTV Nam Bộ' : '⚽ WC';
162
- const badgeColor = s.channel==='vtvnambo' ? '#0b6bcb' : '#f26522';
163
- h += `<div class="wc-shorts-item" onclick="openWCShortsFeed(${i})">`;
164
- h += `<div class="wc-shorts-thumb">${s.img?`<img src="${esc(s.img)}" loading="lazy" onerror="this.style.display='none'">`:''}<div class="card-play">▶</div></div>`;
165
- h += `<div class="wc-shorts-info"><span class="wc-shorts-badge" style="background:${badgeColor};color:#fff">${badge}</span><div class="wc-shorts-title">${esc(s.title)}</div></div>`;
166
- h += '</div>';
167
- });
168
- // "View all" card
169
- h += `<div class="wc-shorts-viewall" onclick="openWCShortsFeed(0)">📱 Xem tất cả<br><small style="font-size:9px;color:#6a9fca">${_wcShortsData.length} shorts</small></div>`;
170
- h += '</div>';
171
- wrap.innerHTML = h;
172
- container.appendChild(wrap);
173
- }
174
-
175
- // === WC SHORTS TIKTOK FEED — full-screen vertical scroll, continuous autoplay ===
176
- async function openWCShortsFeed(startIdx){
177
- showView('view-tiktok');
178
- const el = document.getElementById('view-tiktok');
179
- el.innerHTML = '<div class="loading">Đang tải shorts...</div>';
180
-
181
- // Fetch shorts if not loaded
182
- if(!_wcShortsData || !_wcShortsData.length){
183
- try{
184
- const r = await fetch('/api/shorts?channel=vtvnambo&count=30');
185
- _wcShortsData = await r.json() || [];
186
- }catch(e){
187
- _wcShortsData = [];
188
- }
189
- }
190
-
191
- if(!_wcShortsData.length){
192
- el.innerHTML = '<div class="loading">Chưa có shorts</div>';
193
- return;
194
- }
195
-
196
- // Rotate so startIdx is first
197
- const ordered = startIdx > 0 ? [..._wcShortsData.slice(startIdx), ..._wcShortsData.slice(0,startIdx)] : _wcShortsData;
198
-
199
- let h = `<button class="back-btn" onclick="switchCat('home')">← World Cup Shorts</button>`;
200
- h += '<div class="tiktok-container"><div class="tiktok-feed" id="wc-shorts-feed">';
201
-
202
- ordered.forEach((s,i)=>{
203
- const id = s.id || '';
204
- const src = `https://www.youtube.com/embed/${id}?autoplay=0&rel=0&playsinline=1&mute=1`;
205
- const vtag = `<iframe data-yt-src="${src}" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture"></iframe>`;
206
- const badge = s.channel==='vtvnambo' ? 'VTV Nam Bộ' : '⚽ WC';
207
- const badgeClass = s.channel==='vtvnambo' ? 'badge-wc' : 'badge-fpt';
208
- const videoId = 'wcshort-' + id;
209
- h += buildTikTokSlide({vtag,title:s.title,badge,badgeClass,videoId,idx:i,total:ordered.length,shareUrl:'https://youtube.com/shorts/'+id});
210
- });
211
-
212
- h += '</div></div>';
213
- el.innerHTML = h;
214
-
215
- // Init continuous autoplay feed
216
- initWCShortsFeed();
217
- }
218
-
219
- function initWCShortsFeed(){
220
- const feed = document.getElementById('wc-shorts-feed');
221
- if(!feed) return;
222
- const slides = feed.querySelectorAll('.tiktok-slide');
223
- let cur = -1;
224
- let isManualScroll = false;
225
- let manualTimer = null;
226
-
227
- function activate(i){
228
- if(i === cur) return;
229
- slides.forEach((sl,idx)=>{
230
- const fr = sl.querySelector('iframe');
231
- if(idx === i){
232
- // Load and play
233
- if(fr && !fr.src && fr.dataset.ytSrc) fr.src = fr.dataset.ytSrc;
234
- // For continuous feel: set autoplay=1 on activate
235
- if(fr && fr.src && !fr.src.includes('autoplay=1')){
236
- fr.src = fr.src.replace('autoplay=0','autoplay=1');
237
- }
238
- const vid = sl.dataset.vid;
239
- if(vid && !sl._viewed){ sl._viewed = true; doInteract(vid,'view'); }
240
- } else {
241
- // Pause: reload with autoplay=0 to stop
242
- if(fr && fr.src){
243
- fr.src = fr.src.replace('autoplay=1','autoplay=0');
244
- }
245
- }
246
- });
247
- cur = i;
248
- }
249
-
250
- // Scroll-based activation with snap
251
- let scrollTimer;
252
- feed.addEventListener('scroll', ()=>{
253
- isManualScroll = true;
254
- clearTimeout(manualTimer);
255
- manualTimer = setTimeout(()=>{ isManualScroll = false; }, 2000);
256
- clearTimeout(scrollTimer);
257
- scrollTimer = setTimeout(()=>{
258
- const rect = feed.getBoundingClientRect();
259
- const ctr = rect.top + rect.height / 2;
260
- let best = -1, bestD = 1e9;
261
- slides.forEach((sl,i)=>{
262
- const d = Math.abs(sl.getBoundingClientRect().top + sl.getBoundingClientRect().height/2 - ctr);
263
- if(d < bestD){ bestD = d; best = i; }
264
- });
265
- if(best >= 0) activate(best);
266
- }, 100);
267
- });
268
-
269
- // Auto-advance every 8 seconds for continuous playback
270
- let autoTimer = setInterval(()=>{
271
- if(isManualScroll) return;
272
- if(cur < slides.length - 1){
273
- const nextSl = slides[cur + 1];
274
- if(nextSl){
275
- nextSl.scrollIntoView({behavior:'smooth', block:'start'});
276
- // activate will be called by scroll event
277
- }
278
- } else {
279
- // Loop back to first
280
- slides[0].scrollIntoView({behavior:'smooth', block:'start'});
281
- }
282
- }, 8000);
283
-
284
- // Store timer for cleanup
285
- feed._autoTimer = autoTimer;
286
-
287
- // Activate first after short delay
288
- setTimeout(() => {
289
- if(slides.length > 0){
290
- slides[0].scrollIntoView({behavior:'start'});
291
- activate(0);
292
- }
293
- }, 300);
294
-
295
- // Cleanup on leave
296
- const origSwitchCat = window.switchCat;
297
- if(origSwitchCat && !origSwitchCat.__wcCleaned){
298
- window.switchCat = function(id){
299
- if(feed._autoTimer) clearInterval(feed._autoTimer);
300
- return origSwitchCat.apply(this, arguments);
301
- };
302
- window.switchCat.__wcCleaned = true;
303
- }
304
  }
305
 
306
  // === LIVE AUTO-REFRESH (90s) ===
@@ -314,4 +104,4 @@ function startWCLiveRefresh(){
314
  }catch(e){}
315
  },90000);
316
  }
317
- setTimeout(startWCLiveRefresh,5000);
 
1
+ // === WORLD CUP 2026 - COMPLETE: all tabs + highlights + live refresh ===
2
  let _wc2026Data=null;let _wcRefreshInterval=null;
 
3
  const WC_TEAMS={'Mexico':'🇲🇽 Mexico','Colombia':'🇨🇴 Colombia','USA':'🇺🇸 Mỹ','United States':'🇺🇸 Mỹ','Brazil':'🇧🇷 Brazil','Argentina':'🇦🇷 Argentina','Germany':'🇩🇪 Đức','France':'🇫🇷 Pháp','Spain':'🇪🇸 Tây Ban Nha','England':'🏴󠁧󠁢󠁥󠁮󠁧󠁿 Anh','Portugal':'🇵🇹 Bồ Đào Nha','Netherlands':'🇳🇱 Hà Lan','Italy':'🇮🇹 Ý','Belgium':'🇧🇪 Bỉ','Croatia':'🇭🇷 Croatia','Uruguay':'🇺🇾 Uruguay','Japan':'🇯🇵 Nhật Bản','South Korea':'🇰🇷 Hàn Quốc','Korea Republic':'🇰🇷 Hàn Quốc','Australia':'🇦🇺 Úc','Saudi Arabia':'🇸🇦 Ả Rập Xê Út','Iran':'🇮🇷 Iran','Qatar':'🇶🇦 Qatar','Canada':'🇨🇦 Canada','Morocco':'🇲🇦 Morocco','Senegal':'🇸🇳 Senegal','Ecuador':'🇪🇨 Ecuador','Serbia':'🇷🇸 Serbia','Switzerland':'🇨🇭 Thụy Sĩ','Denmark':'🇩🇰 Đan Mạch','Poland':'🇵🇱 Ba Lan','Turkey':'🇹🇷 Thổ Nhĩ Kỳ','Türkiye':'🇹🇷 Thổ Nhĩ Kỳ','Ukraine':'🇺🇦 Ukraine','Egypt':'🇪🇬 Ai Cập','Nigeria':'🇳🇬 Nigeria','Cameroon':'🇨🇲 Cameroon','Ghana':'🇬🇭 Ghana','Tunisia':'🇹🇳 Tunisia','Algeria':'🇩🇿 Algeria','South Africa':'🇿🇦 Nam Phi','Indonesia':'🇮🇩 Indonesia','Vietnam':'🇻🇳 Việt Nam','China PR':'🇨🇳 Trung Quốc','New Zealand':'🇳🇿 New Zealand','Panama':'🇵🇦 Panama','Costa Rica':'🇨🇷 Costa Rica','Jamaica':'🇯🇲 Jamaica','Honduras':'🇭🇳 Honduras','Paraguay':'🇵🇾 Paraguay','Bolivia':'🇧🇴 Bolivia','Chile':'🇨🇱 Chile','Peru':'🇵🇪 Peru','Venezuela':'🇻🇪 Venezuela','Norway':'🇳🇴 Na Uy','Sweden':'🇸🇪 Thụy Điển','Austria':'🇦🇹 Áo','Scotland':'🏴󠁧󠁢󠁳󠁣󠁴󠁿 Scotland','Wales':'🏴󠁧󠁢󠁷󠁬󠁳󠁿 Xứ Wales','Ireland':'🇮🇪 Ireland','Romania':'🇷🇴 Romania','Hungary':'🇭🇺 Hungary','Greece':'🇬🇷 Hy Lạp','Uzbekistan':'🇺🇿 Uzbekistan','Iraq':'🇮🇶 Iraq','TBD':'🏳️ TBD'};
4
  function _t(n){return WC_TEAMS[n]||WC_TEAMS[(n||'').trim()]||'🏴 '+n;}
5
 
6
+ const WC_CSS=`<style>.wc-bxh{max-height:450px;overflow-y:auto}.wc-bxh,.wc-bxh *{color:#ddd!important;background:transparent!important;border-color:#1a3a5a!important}.wc-bxh table{width:100%;border-collapse:collapse;font-size:11px}.wc-bxh th{background:#0b1a2a!important;color:#6a9fca!important;padding:5px 3px;font-size:9px}.wc-bxh td{padding:4px 3px;border-bottom:1px solid #0d1a2a!important}.wc-bxh img{width:16px;height:16px;vertical-align:middle;margin-right:3px}.wc-bxh strong{color:#f0c040!important}.wc-bxh .title-content{background:#0b1a2a!important;border-radius:4px;margin:6px 0 2px;padding:5px 8px}.wc-bxh .info,.wc-bxh .btns{display:none!important}.wc-bxh a{color:#8ab4d8!important;text-decoration:none!important}</style>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
  function switchWCTab(tab){
9
  document.querySelectorAll('.wc-tab').forEach(t=>t.classList.remove('active'));
 
26
  let h='<div class="wc-news-grid">';
27
  combined.slice(0,20).forEach((a,i)=>{
28
  const badge=a._type==='road'?'<span class="badge badge-wc">Đường tới WC</span>':'<span class="badge badge-fpt">Tin WC</span>';
29
+ h+=`<div class="wc-news-item" onclick="readArticle('${esc(a.link)}')"><div class="wc-news-img" id="wcimg-${i}">${a.img?`<img src="${esc(a.img)}" onerror="this.parentElement.innerHTML=''">`:''}</div><div class="wc-news-text">${badge}<div class="wc-news-title">${esc(a.title)}</div><div class="wc-news-via">${esc(a.source||'')}</div></div></div>`;
30
  });
31
  h+='</div>';el.innerHTML=h;
32
+ combined.slice(0,20).forEach((a,i)=>{if(a.img)return;fetch('/api/article?url='+encodeURIComponent(a.link)).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){const x=document.getElementById('wcimg-'+i);if(x)x.innerHTML=`<img src="${esc(d.og_image||d.img)}" onerror="this.parentElement.innerHTML=''">`;}}).catch(()=>{});});
33
  }
34
 
35
  // === FIXTURES ===
 
51
  // === BXH ===
52
  function renderWCStandings(el){
53
  const d=_wc2026Data.standings||{};
54
+ if(d.html&&d.html.length>100){el.innerHTML=WC_CSS+'<div class="wc-bxh">'+d.html+'</div>';el.querySelectorAll('a').forEach(a=>a.addEventListener('click',e=>e.preventDefault()));return;}
55
+ // Self-calculate from fixtures
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  const fx=_wc2026Data.fixtures||{};const matches=fx.matches||[];
57
  const gm=matches.filter(m=>m.group&&m.group.toLowerCase().includes('group'));
58
  if(!gm.length){el.innerHTML='<div class="loading">BXH cập nhật khi giải bắt đầu 11/06/2026<br><small style="color:#6a9fca">🔴 Livescore tự động</small></div>';return;}
 
69
  else el.innerHTML='<div class="loading">Thống kê cập nhật khi giải bắt đầu<br><small style="color:#6a9fca">Vua phá lưới · Kiến tạo · Thẻ phạt</small></div>';
70
  }
71
 
72
+ // === HIGHLIGHTS (WC + Friendly from xemlaibongda.top) ===
73
  async function renderWCHighlights(el){
74
+ el.innerHTML='<div class="loading">Đang tải highlight...</div>';
75
  try{
76
+ // Load both WC and Friendly highlights
77
+ const[wc,fr]=await Promise.all([
78
+ fetch('/api/highlights/world-cup').then(r=>r.json()).catch(()=>[]),
79
+ fetch('/api/highlights/friendly').then(r=>r.json()).catch(()=>[])
80
+ ]);
81
+ const all=[...(wc||[]).map(v=>({...v,_league:'WC'})),...(fr||[]).map(v=>({...v,_league:'Friendly'}))];
82
+ if(!all.length){el.innerHTML='<div class="loading">Chưa có highlight World Cup & Giao hữu</div>';return;}
 
 
 
 
 
83
  let h='<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:6px;max-height:450px;overflow-y:auto;padding:4px">';
84
+ all.slice(0,24).forEach((v,i)=>{
85
+ const league=v._league==='WC'?'world-cup':'friendly';
86
+ const badge=v._league==='WC'?'🌍 WC':'🤝 Friendly';
87
+ h+=`<div style="cursor:pointer;border-radius:6px;overflow:hidden;background:#0a1520" onclick="openHighlightFeed('${league}',${i})">`;
88
+ h+=`<div style="position:relative;aspect-ratio:16/9;background:#1a2a3a">${v.img?`<img src="${esc(v.img)}" style="width:100%;height:100%;object-fit:cover">`:''}<div class="card-play">▶</div></div>`;
89
+ h+=`<div style="padding:4px 6px"><span style="font-size:8px;color:#f0c040">${badge}</span><div style="font-size:10px;color:#ccc;line-height:1.2;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden">${esc(v.title)}</div></div>`;
90
  h+='</div>';
91
  });
92
  h+='</div>';el.innerHTML=h;
93
+ }catch(e){el.innerHTML='<div class="loading">Lỗi t��i highlight</div>';}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  }
95
 
96
  // === LIVE AUTO-REFRESH (90s) ===
 
104
  }catch(e){}
105
  },90000);
106
  }
107
+ setTimeout(startWCLiveRefresh,5000);
static/wc_shorts_inject.js DELETED
@@ -1,76 +0,0 @@
1
- /**
2
- * WC Shorts Inject — Chạy SAU app_v2.js
3
- * Tự tạo anchor DIV rồi render WC Shorts slide TRÊN World Cup section
4
- */
5
- (function(){
6
- 'use strict';
7
-
8
- function inject(){
9
- // Tìm WC section
10
- const wcSection = document.getElementById('wc2026-live-section');
11
- const homeEl = document.getElementById('view-home');
12
- if(!homeEl) return;
13
-
14
- // Đã inject?
15
- if(document.getElementById('wc-shorts-slide-section')) return;
16
-
17
- // Đảm bảo renderWCSSlideSection đã load
18
- if(typeof renderWCSSlideSection !== 'function'){
19
- // Chờ rồi thử lại
20
- setTimeout(inject, 500);
21
- return;
22
- }
23
-
24
- // Tạo container rồi render vào
25
- const div = document.createElement('div');
26
- div.id = 'wc-shorts-slide-container-tmp';
27
- div.style.display = 'none';
28
-
29
- // Insert TRƯỚC WC section, hoặc vào đầu home
30
- if(wcSection && wcSection.parentNode){
31
- wcSection.parentNode.insertBefore(div, wcSection);
32
- } else {
33
- homeEl.insertBefore(div, homeEl.firstChild);
34
- }
35
-
36
- // Render slide section vào div
37
- renderWCSSlideSection(div);
38
-
39
- // Bỏ wrapper thừa, di chuyển section ra đúng vị trí
40
- const section = div.querySelector('.wc-shorts-slide-section');
41
- if(section){
42
- section.id = 'wc-shorts-slide-section';
43
- if(wcSection && wcSection.parentNode){
44
- wcSection.parentNode.insertBefore(section, wcSection);
45
- }
46
- div.remove();
47
- }
48
- }
49
-
50
- // Chạy khi DOM sẵn sàng
51
- if(document.readyState === 'loading'){
52
- document.addEventListener('DOMContentLoaded', ()=> setTimeout(inject, 800));
53
- } else {
54
- setTimeout(inject, 800);
55
- }
56
-
57
- // Cũng chạy sau khi loadHome xong
58
- const origLoadHome = window.loadHome;
59
- if(origLoadHome){
60
- window.loadHome = function(){
61
- const result = origLoadHome.apply(this, arguments);
62
- setTimeout(inject, 200);
63
- setTimeout(inject, 800);
64
- setTimeout(inject, 2000);
65
- return result;
66
- };
67
- }
68
-
69
- // Fallback: retry nhiều lần
70
- let retries = 0;
71
- const retryIv = setInterval(()=>{
72
- retries++;
73
- inject();
74
- if(retries > 20) clearInterval(retryIv);
75
- }, 1000);
76
- })();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
static/yt_live.js DELETED
@@ -1,175 +0,0 @@
1
- // === VNEWS — VTV LIVE + Inline Recorder v9 ===
2
- // Features: PiP, mini-player, INLINE RECORDER, VTVGO POPUP players for each channel
3
- // FIX v10: Correct VTVGO popup URLs for all channels + World Cup 2026 TS stream links
4
- // FIX v11: Allow clicking off tabs (always clickable), better stream status handling
5
-
6
- (function(){
7
- if(window._ytLiveLoaded) return;
8
- window._ytLiveLoaded = true;
9
-
10
- const CHANNELS = [
11
- {id:'vtv1',name:'VTV1',badge:'Tin tức'},{id:'vtv2',name:'VTV2',badge:'Khoa học'},
12
- {id:'vtv3',name:'VTV3',badge:'Giải trí'},{id:'vtv4',name:'VTV4',badge:'Quốc tế'},
13
- {id:'vtv5',name:'VTV5',badge:'Miền Nam'},{id:'vtv6',name:'VTV6',badge:'Thanh niên'},
14
- {id:'vtv7',name:'VTV7',badge:'Giáo dục'},{id:'vtv8',name:'VTV8',badge:'Miền Trung'},
15
- {id:'vtv9',name:'VTV9',badge:'Miền Bắc'},{id:'vtv10',name:'VTV10',badge:'Cần Thơ'},
16
- {id:'vtvprime',name:'VTVPrime',badge:'Prime'},
17
- ];
18
-
19
- // VTVGO popup URLs for each channel (FIXED v10 - verified correct URLs)
20
- const VTV_POPUP_URLS = {
21
- vtv1: 'https://package.vtvgo.vn/channel/vtv1-1,1.html',
22
- vtv2: 'https://package.vtvgo.vn/channel/vtv2-1,2.html',
23
- vtv3: 'https://package.vtvgo.vn/channel/vtv3-1,3.html',
24
- vtv4: 'https://package.vtvgo.vn/channel/vtv4-1,4.html',
25
- vtv5: 'https://package.vtvgo.vn/channel/vtv5-1,5.html',
26
- vtv6: 'https://package.vtvgo.vn/channel/vtv6-1,13.html',
27
- vtv7: 'https://package.vtvgo.vn/channel/vtv7-1,27.html',
28
- vtv8: 'https://package.vtvgo.vn/channel/vtv8-1,36.html',
29
- vtv9: 'https://package.vtvgo.vn/channel/vtv9-1,39.html',
30
- vtv10: 'https://package.vtvgo.vn/channel/vtv10-1,6.html',
31
- };
32
-
33
- const DEFAULT_CHANNEL = 'vtv3';
34
- const NEEDS_PROXY = /fptplay\.net|vtvgo\.vn/;
35
- const STREAMS = {};
36
- let _currentCh = null, _hls = null, _loading = false, _blockInserted = false;
37
- let _streamsLoaded = false, _pipActive = false, _miniActive = false, _vtvPinned = false, _vtvPopupActive = false;
38
-
39
- // ===== RECORDER STATE =====
40
- const _rec = {
41
- active: false, startTime: null, endTime: null,
42
- isRecording: false, recorder: null, chunks: [], blob: null,
43
- ratio: 'original', _dragMarker: null, _recTimer: null,
44
- };
45
-
46
- // ===== STYLES =====
47
- const style = document.createElement('style');
48
- style.textContent = `
49
- .vtv-wrap{position:relative;margin:6px 4px;background:#111;border:1px solid #0066cc;border-radius:10px;overflow:hidden}
50
- .vtv-head{display:flex;align-items:center;gap:8px;padding:8px 10px;background:linear-gradient(90deg,#003366,#1a1a1a)}
51
- .vtv-title{font-size:13px;font-weight:800;color:#00ccff}
52
- .vtv-badge{font-size:10px;font-weight:800;color:#00ccff;animation:vtvp 1.3s infinite}
53
- @keyframes vtvp{0%,100%{opacity:1}50%{opacity:.3}}
54
- .vtv-tabs{display:flex;flex-wrap:wrap;gap:3px;padding:6px 8px;overflow-x:auto;scrollbar-width:none;background:#0d1a2a}
55
- .vtv-tabs::-webkit-scrollbar{display:none}
56
- .vtv-tab{padding:4px 8px;background:#1a2a3a;border:1px solid #2a3a4a;border-radius:10px;color:#8ab4d8;font-size:9px;cursor:pointer;white-space:nowrap;flex-shrink:0;transition:all .2s}
57
- .vtv-tab:hover{background:#0b4a7a;color:#fff}
58
- .vtv-tab.on{background:#0066cc;border-color:#00ccff;color:#fff;font-weight:700}
59
- .vtv-tab.off{opacity:.35}
60
- .vtv-frame{position:relative;width:100%;aspect-ratio:16/9;background:#000;min-height:180px}
61
- .vtv-frame video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
62
- .vtv-err{display:flex;align-items:center;justify-content:center;height:180px;color:#888;font-size:12px;text-align:center;padding:20px;flex-direction:column;gap:8px}
63
- .vtv-err button{background:#0066cc;border:none;color:#fff;padding:6px 14px;border-radius:8px;font-size:11px;cursor:pointer}
64
- .vtv-load{display:flex;align-items:center;justify-content:center;height:180px;color:#00ccff;font-size:12px;flex-direction:column;gap:8px}
65
- .vtv-spinner{width:24px;height:24px;border:2px solid #333;border-top-color:#00ccff;border-radius:50%;animation:vtvspin .8s linear infinite}
66
- @keyframes vtvspin{to{transform:rotate(360deg)}}
67
- .vtv-controls{display:flex;align-items:center;gap:4px;padding:4px 8px;background:#0d1a2a;border-top:1px solid #1a2a3a}
68
- .vtv-pip-btn{background:#1a2a3a;border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:3px 8px;border-radius:6px;cursor:pointer;transition:all .2s;display:flex;align-items:center;gap:4px}
69
- .vtv-pip-btn:hover{background:#0b4a7a;color:#fff}
70
- .vtv-pip-btn.on{background:#0066cc;border-color:#00ccff;color:#fff}
71
- .vtv-pip-btn svg{width:12px;height:12px;fill:currentColor}
72
- .vtv-epg{margin:0;padding:6px 10px;background:#0a1628;border-top:1px solid #1a2a3a}
73
- .vtv-epg-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}
74
- .vtv-epg-title{font-size:10px;font-weight:700;color:#00ccff}
75
- .vtv-epg-toggle{background:none;border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:2px 8px;border-radius:6px;cursor:pointer}
76
- .vtv-epg-list{display:flex;gap:4px;overflow-x:auto;scrollbar-width:none;padding-bottom:4px}
77
- .vtv-epg-list::-webkit-scrollbar{display:none}
78
- .vtv-epg-item{flex:0 0 auto;padding:3px 6px;background:#1a2a3a;border-radius:4px;font-size:8px;color:#8ab4d8;white-space:nowrap;cursor:pointer}
79
- .vtv-epg-item:hover{background:#2a4a6a}
80
- .vtv-epg-item.now{background:#0066cc;color:#fff;font-weight:700}
81
- .vtv-epg-item .t{font-size:7px;color:#6a8aaa}
82
- .vtv-epg-item.now .t{color:#aaccee}
83
- .vtv-epg-item .n{color:#ccc;font-size:8px}
84
- .vtv-epg-item.now .n{color:#fff}
85
- .vtv-epg-empty{color:#666;font-size:9px;padding:4px}
86
- .vtv-epg-loading{color:#00ccff;font-size:9px;padding:4px;display:flex;align-items:center;gap:6px}
87
- .vtv-epg-sp{width:10px;height:10px;border:1px solid #333;border-top-color:#00ccff;border-radius:50%;animation:vtvspin .8s linear infinite}
88
- .vtv-pin-btn{position:absolute;top:6px;right:8px;z-index:5;background:rgba(0,0,0,.5);border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:2px 6px;border-radius:4px;cursor:pointer;font-weight:700;display:flex;align-items:center;gap:3px}
89
- .vtv-pin-btn:hover{background:#0b4a7a;color:#fff}
90
- .vtv-pin-btn.pinned{background:#0066cc;border-color:#00ccff;color:#fff}
91
- .vtv-wrap.vtv-sticky{position:sticky;top:0;z-index:48;transition:all .25s ease;box-shadow:0 4px 24px rgba(0,102,204,.35)}
92
- .vtv-wrap.vtv-sticky .vtv-epg{display:none}
93
- .vtv-wrap.vtv-sticky .vtv-controls{display:none}
94
- .vtv-wrap.vtv-sticky .vtv-tabs{padding:3px 8px}
95
- .vtv-wrap.vtv-sticky .vtv-tab{padding:3px 7px;font-size:8px}
96
- .vtv-wrap.vtv-sticky .vtv-frame{max-height:140px;min-height:100px}
97
- .vtv-wrap.vtv-sticky .vtv-frame video{max-height:140px}
98
- .vtv-wrap.vtv-sticky .vtv-load{height:100px}
99
- .vtv-wrap.vtv-sticky .vtv-err{height:100px}
100
- .vtv-wrap.vtv-sticky .vtv-head{padding:5px 10px}
101
- .vtv-wrap.vtv-sticky .vtv-title{font-size:11px}
102
- .vtv-wrap.vtv-sticky .vtv-badge{font-size:8px}
103
- .vtv-wrap.vtv-sticky .vtv-pin-btn{top:4px;right:6px}
104
- .vtv-rec-btn{background:#660000;border:1px solid #990000;color:#ff6666;font-size:9px;padding:3px 8px;border-radius:6px;cursor:pointer;transition:all .2s;display:flex;align-items:center;gap:4px;font-weight:700}
105
- .vtv-rec-btn:hover{background:#990000;color:#fff}
106
- .vtv-rec-btn.recording{background:#cc0000;border-color:#ff0000;color:#fff;animation:vtv-rec-pulse 1s infinite}
107
- @keyframes vtv-rec-pulse{0%,100%{opacity:1}50%{opacity:.5}}
108
- .vtv-rec-btn svg{width:12px;height:12px;fill:currentColor}
109
- .vtv-inline-rec{display:none;padding:8px 10px;background:#0a0a1a;border-top:1px solid #1a1a3a}
110
- .vtv-inline-rec.show{display:block}
111
- .vtv-inline-rec .rec-bar{position:relative;height:32px;background:#000;border-radius:4px;overflow:hidden;cursor:pointer;border:1px solid #2a2a4a;margin-bottom:6px;user-select:none}
112
- .vtv-inline-rec .rec-bar .rec-progress{position:absolute;top:0;bottom:0;background:rgba(155,89,182,.3);left:0;width:0%;pointer-events:none}
113
- .vtv-inline-rec .rec-bar .rec-marker{position:absolute;top:0;bottom:0;width:4px;z-index:2;border-radius:2px}
114
- .vtv-inline-rec .rec-bar .rec-marker.s{background:#2ecc71}
115
- .vtv-inline-rec .rec-bar .rec-marker.e{background:#e74c3c}
116
- .vtv-inline-rec .rec-time{display:flex;justify-content:space-between;font-size:9px;color:#888;margin-bottom:6px}
117
- .vtv-inline-rec .rec-controls{display:flex;gap:4px}
118
- .vtv-inline-rec .rec-controls button{flex:1;padding:6px;border:none;border-radius:6px;font-size:10px;font-weight:700;cursor:pointer}
119
- .vtv-inline-rec .rec-controls .rec-set-start{background:#1a3a1a;border:1px solid #2d6a2d;color:#5cb87a}
120
- .vtv-inline-rec .rec-controls .rec-set-end{background:#3a1a1a;border:1px solid #6a2d2d;color:#e74c3c}
121
- .vtv-inline-rec .rec-controls .rec-go{background:#1a0a3a;border:1px solid #3a2a6a;color:#9b59b6}
122
- .vtv-inline-rec .rec-controls .rec-reset{background:#222;border:1px solid #333;color:#888}
123
- .vtv-inline-rec .rec-hint{font-size:9px;color:#666;text-align:center;margin-top:4px}
124
- .vtv-inline-rec .rec-status{font-size:10px;color:#888;text-align:center;padding:4px;margin-top:4px;background:#111;border-radius:4px}
125
- .vtv-inline-rec .rec-status.ok{color:#2ecc71}
126
- .vtv-inline-rec .rec-status.err{color:#e74c3c}
127
- .vtv-inline-rec .rec-status.recording{color:#e74c3c;animation:vtv-rec-pulse 1s infinite}
128
- .vtv-rec-panel{display:none;padding:10px;background:#0d0d20;border-top:1px solid #2a1a4a}
129
- .vtv-rec-panel.show{display:block}
130
- .vtv-rec-panel .rec-panel-title{font-size:11px;font-weight:700;color:#9b59b6;margin-bottom:8px}
131
- .vtv-rec-panel .rec-preview-wrap{margin-bottom:8px;text-align:center}
132
- .vtv-rec-panel .rec-preview-wrap video{max-width:100%;max-height:180px;border-radius:6px;background:#000}
133
- .vtv-rec-panel .rec-ratio-row{display:flex;gap:4px;margin-bottom:8px}
134
- .vtv-rec-panel .rec-ratio-row button{flex:1;padding:6px;background:#1a1a2e;border:1px solid #2a2a4a;border-radius:6px;color:#888;font-size:10px;cursor:pointer}
135
- .vtv-rec-panel .rec-ratio-row button.active{border-color:#9b59b6;color:#9b59b6;background:#2a1a4a}
136
- .vtv-rec-panel .rec-actions{display:flex;gap:4px}
137
- .vtv-rec-panel .rec-actions button{flex:1;padding:8px;border:none;border-radius:6px;font-size:11px;font-weight:700;cursor:pointer}
138
- .vtv-rec-panel .rec-actions .rec-download{background:#2d8659;color:#fff}
139
- .vtv-rec-panel .rec-actions .rec-share{background:#9b59b6;color:#fff}
140
- .vtv-rec-panel .rec-proc{text-align:center;padding:12px;color:#9b59b6;font-size:12px;display:none}
141
- .vtv-rec-panel .rec-title-input{width:100%;background:#1a1a2e;border:1px solid #2a2a4a;border-radius:6px;padding:8px;color:#ccc;font-size:11px;margin-bottom:8px;box-sizing:border-box}
142
- .vtv-rec-panel .rec-title-input:focus{border-color:#9b59b6;outline:none}
143
- .vtv-rec-panel .rec-ai-title-row{display:flex;gap:4px;margin-bottom:8px}
144
- .vtv-rec-panel .rec-ai-title-row button{flex:1;padding:6px;background:#1a1a2e;border:1px solid #2a2a4a;border-radius:6px;color:#888;font-size:10px;cursor:pointer}
145
- .vtv-rec-panel .rec-ai-title-row button:hover{border-color:#9b59b6;color:#9b59b6}
146
- .vtv-rec-panel .rec-ai-title-row button:disabled{opacity:.4;cursor:not-allowed}
147
- .vtv-mini{position:fixed;top:0;left:0;right:0;z-index:99990;background:#000;border-bottom:2px solid #0066cc;box-shadow:0 4px 20px rgba(0,102,204,.4);display:none;transition:transform .3s ease}
148
- .vtv-mini.show{display:block}
149
- .vtv-mini.hidden{transform:translateY(-88%)}
150
- .vtv-mini-frame{position:relative;width:100%;aspect-ratio:16/9;background:#000;max-height:180px}
151
- .vtv-mini-frame video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
152
- .vtv-mini-bar{display:flex;align-items:center;justify-content:space-between;padding:3px 8px;background:linear-gradient(90deg,#003366,#1a1a1a)}
153
- .vtv-mini-ch{font-size:10px;font-weight:700;color:#00ccff}
154
- .vtv-mini-epg{font-size:9px;color:#8ab4d8;max-width:180px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-left:8px}
155
- .vtv-mini-btns{display:flex;gap:3px}
156
- .vtv-mini-btn{background:#1a2a3a;border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:2px 6px;border-radius:4px;cursor:pointer}
157
- .vtv-mini-btn:hover{background:#0b4a7a;color:#fff}
158
- .vtv-mini-btn.x{background:#600;border-color:#900;color:#f66}
159
- .vtv-mini-btn.x:hover{background:#900;color:#fff}
160
- .vtv-mini-peek{position:absolute;bottom:-18px;right:10px;background:#0066cc;color:#fff;font-size:9px;padding:2px 8px;border-radius:0 0 6px 6px;cursor:pointer;display:none}
161
- .vtv-mini.hidden .vtv-mini-peek{display:block}
162
- .vtv-popup{position:fixed;top:0;left:0;right:0;margin:8px auto 0;background:rgba(0,0,0,.95);z-index:99999;display:none;flex-direction:column;max-height:calc(100vh - 16px);max-width:800px;border-radius:12px;overflow:hidden}
163
- .vtv-popup.show{display:flex}
164
- .vtv-popup:has(#vtv-popup-hdr[style*="none"]) .vtv-popup-frame{height:100%}
165
- .vtv-popup-header{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:#111;border-bottom:1px solid #333}
166
- .vtv-popup-title{font-size:14px;font-weight:700;color:#00ccff}
167
- .vtv-popup-close{background:#333;border:none;color:#fff;font-size:18px;width:32px;height:32px;border-radius:6px;cursor:pointer}
168
- .vtv-popup-frame{flex:1;position:relative;background:#000;min-height:300px}
169
- .vtv-popup-frame iframe{position:absolute;inset:0;width:100%;height:100%;border:none}
170
- .vtv-popup-frame iframe{position:absolute;inset:0;width:100%;height:100%;border:none}
171
- .vtv-popup-loader{display:flex;align-items:center;justify-content:center;height:280px;color:#00ccff;font-size:12px;flex-direction:column;gap:8px}
172
- .vtv-popup-btn{background:#1a2a3a;border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:3px 8px;border-radius:6px;cursor:pointer;margin-left:4px}
173
- .vtv-popup-btn:hover{background:#0b4a7a;color:#fff}
174
- `;
175
- document.head.appendChild(style);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vtv_api.py DELETED
@@ -1,380 +0,0 @@
1
- # VTV Stream — proxy FLV/HLS từ Love4vn M3U + EPG từ vtv.vn
2
- import re, time, threading, json, requests
3
- from fastapi import APIRouter, Query
4
- from fastapi.responses import JSONResponse, Response, StreamingResponse
5
- from bs4 import BeautifulSoup
6
- from datetime import datetime, timedelta, timezone
7
-
8
- VN_TZ = timezone(timedelta(hours=7))
9
- router = APIRouter()
10
-
11
- UA = {
12
- "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
- "Accept-Language": "vi-VN,vi;q=0.9",
14
- }
15
-
16
- # ===== NGUỒN PHÁT: HARDCODE VTV6 + M3U fallback cho các kênh khác =====
17
- HARDCODED_URLS = {
18
- "vtv6": "https://live.fptplay53.net/live/media/v6abr/live247-hls-avc/v6abr-avc1_5600000=10000-mp4a_131600=20000.m3u8",
19
- }
20
-
21
- CHANNEL_URLS = {
22
- "vtv1": None,
23
- "vtv2": None,
24
- "vtv3": None,
25
- "vtv4": None,
26
- "vtv5": None,
27
- "vtv6": HARDCODED_URLS["vtv6"], # hardcode VTV6
28
- "vtv7": None,
29
- "vtv8": None,
30
- "vtv9": None,
31
- "vtv10": None,
32
- }
33
-
34
- CHANNEL_NAMES = {
35
- "vtv1":"VTV1","vtv2":"VTV2","vtv3":"VTV3","vtv4":"VTV4","vtv5":"VTV5",
36
- "vtv6":"VTV6","vtv7":"VTV7","vtv8":"VTV8","vtv9":"VTV9","vtv10":"VTV10",
37
- "vtvprime":"VTVPrime",
38
- }
39
-
40
- M3U_URLS = [
41
- "https://raw.githubusercontent.com/Love4vn/Test/refs/heads/main/IPTV.m3u",
42
- "https://raw.githubusercontent.com/Love4vn/love4vn/main/Out_Iptv_CXT.m3u",
43
- "https://raw.githubusercontent.com/konanda-sg/Test_Love4vn/main/IPTV.m3u",
44
- "https://raw.githubusercontent.com/iptv-org/iptv/master/streams/vn.m3u",
45
- ]
46
-
47
- _channel_urls_lock = threading.Lock()
48
- _last_m3u_fetch = 0
49
- _M3U_TTL = 900
50
-
51
- def _fetch_m3u():
52
- global _last_m3u_fetch
53
- now = time.time()
54
- if now - _last_m3u_fetch < _M3U_TTL:
55
- return
56
- _last_m3u_fetch = now
57
-
58
- all_urls = {}
59
- for m3u_url in M3U_URLS:
60
- try:
61
- r = requests.get(m3u_url, headers=UA, timeout=10)
62
- if r.status_code != 200:
63
- continue
64
- text = r.text
65
- lines = text.strip().split("\n")
66
- current_channel = None
67
- for line in lines:
68
- s = line.strip()
69
- if s.startswith("#EXTINF"):
70
- m = re.search(r'tvg-id="?(\w+)"?', s)
71
- if m: current_channel = m.group(1).lower()
72
- else:
73
- m2 = re.search(r'(VTV\d+|vtv\d+)', s, re.IGNORECASE)
74
- if m2: current_channel = m2.group(1).lower()
75
- else: current_channel = None
76
- elif s.startswith("http") and current_channel:
77
- ch_id = current_channel
78
- # Map tvg-id hoặc tên kênh về id chuẩn
79
- ch_map = {
80
- "vtv1":"vtv1","vtv.vtv1":"vtv1","vtv1.vn":"vtv1",
81
- "vtv2":"vtv2","vtv.vtv2":"vtv2",
82
- "vtv3":"vtv3","vtv.vtv3":"vtv3",
83
- "vtv4":"vtv4","vtv.vtv4":"vtv4",
84
- "vtv5":"vtv5","vtv.vtv5":"vtv5",
85
- "vtv6":"vtv6","vtv.vtv6":"vtv6",
86
- "vtv7":"vtv7","vtv.vtv7":"vtv7",
87
- "vtv8":"vtv8","vtv.vtv8":"vtv8",
88
- "vtv9":"vtv9","vtv.vtv9":"vtv9",
89
- "vtv10":"vtv10","vtv.vtv10":"vtv10","vtvcầnthơ":"vtv10","vtvcantho":"vtv10",
90
- "vtvcan-tho":"vtv10",
91
- }
92
- if ch_id in ch_map:
93
- ch_id = ch_map[ch_id]
94
- # Chỉ cập nhật nếu chưa có hoặc hardcode không ghi đè
95
- if ch_id in CHANNEL_URLS and ch_id not in all_urls:
96
- # KHÔNG ghi đè hardcode (VTV6)
97
- if CHANNEL_URLS[ch_id] and CHANNEL_URLS[ch_id] == HARDCODED_URLS.get(ch_id):
98
- current_channel = None
99
- continue
100
- # Ưu tiên FLV hơn HLS/m3u8
101
- if ".flv" in s:
102
- all_urls[ch_id] = s
103
- elif ch_id not in all_urls or ".flv" not in all_urls.get(ch_id, ""):
104
- if ch_id not in all_urls:
105
- all_urls[ch_id] = s
106
- current_channel = None
107
- if len(all_urls) >= 10:
108
- break
109
- except Exception as e:
110
- print(f"[M3U] Error: {m3u_url}: {e}")
111
-
112
- with _channel_urls_lock:
113
- found = 0
114
- for ch_id in CHANNEL_URLS:
115
- # KHÔNG ghi đè hardcode
116
- if CHANNEL_URLS[ch_id] and HARDCODED_URLS.get(ch_id):
117
- continue
118
- if ch_id in all_urls:
119
- CHANNEL_URLS[ch_id] = all_urls[ch_id]
120
- found += 1
121
- print(f"[M3U] Found {found}/10 channels (hardcoded: vtv6)")
122
-
123
- def get_channel_url(channel_id):
124
- _fetch_m3u()
125
- with _channel_urls_lock:
126
- return CHANNEL_URLS.get(channel_id)
127
-
128
- def get_all_channel_urls():
129
- _fetch_m3u()
130
- with _channel_urls_lock:
131
- return dict(CHANNEL_URLS)
132
-
133
- # ===== PROXY FLV/HLS =====
134
- @router.get("/api/proxy/stream")
135
- def proxy_stream(url: str = Query(...)):
136
- """Proxy FLV/HLS stream — tránh CORS & geo-block"""
137
- try:
138
- req_headers = dict(UA)
139
- req_headers["Referer"] = "https://live.fptplay53.net/"
140
- r = requests.get(url, headers=req_headers, timeout=30, stream=True)
141
- if r.status_code != 200:
142
- return Response(content=f"stream error: {r.status_code}", status_code=502)
143
- content_type = r.headers.get("Content-Type", "video/x-flv")
144
- if "mpegurl" in content_type or "m3u8" in content_type:
145
- text = r.text
146
- lines = text.split('\n')
147
- rewritten = []
148
- for line in lines:
149
- if line.startswith('#') or not line.strip():
150
- rewritten.append(line)
151
- else:
152
- from urllib.parse import urljoin, quote
153
- seg_url = urljoin(url, line.strip())
154
- rewritten.append(f"/api/proxy/stream?url={quote(seg_url, safe='')}")
155
- return Response(content='\n'.join(rewritten).encode('utf-8'),
156
- media_type="application/vnd.apple.mpegurl",
157
- headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=60"})
158
- return StreamingResponse(
159
- r.iter_content(chunk_size=256*1024),
160
- media_type=content_type,
161
- headers={
162
- "Access-Control-Allow-Origin": "*",
163
- "Cache-Control": "public, max-age=3600",
164
- }
165
- )
166
- except Exception as e:
167
- return Response(content=f"proxy error: {str(e)[:100]}", status_code=502)
168
-
169
- @router.get("/api/proxy/flv")
170
- def proxy_flv(url: str = Query(...)):
171
- """Proxy FLV stream với Range support"""
172
- try:
173
- req_headers = dict(UA)
174
- req_headers["Referer"] = "https://live.fptplay53.net/"
175
- r = requests.get(url, headers=req_headers, timeout=30, stream=True)
176
- if r.status_code != 200:
177
- return Response(content=f"flv error: {r.status_code}", status_code=502)
178
- return StreamingResponse(
179
- r.iter_content(chunk_size=256*1024),
180
- media_type="video/x-flv",
181
- headers={
182
- "Access-Control-Allow-Origin": "*",
183
- "Cache-Control": "public, max-age=3600",
184
- }
185
- )
186
- except Exception as e:
187
- return Response(content=f"proxy error: {str(e)[:100]}", status_code=502)
188
-
189
- # ===== SV2 IFRAmE PROXY (FALLBACK) =====
190
- SV2_URLS = {
191
- "vtv1": "https://sv2.xemtivitop.com/live/hot/vtv1.php",
192
- "vtv2": "https://sv2.xemtivitop.com/live/hot/vtv2.php",
193
- "vtv3": "https://sv2.xemtivitop.com/live/hot/vtv3.php",
194
- "vtv4": "https://sv2.xemtivitop.com/live/hot/vtv4.php",
195
- "vtv5": "https://sv2.xemtivitop.com/live/hot/vtv5.php",
196
- "vtv6": "https://sv2.xemtivitop.com/live/hot/vtv6.php",
197
- "vtv7": "https://sv2.xemtivitop.com/live/hot/vtv7.php",
198
- "vtv8": "https://sv2.xemtivitop.com/live/hot/vtv8.php",
199
- "vtv9": "https://sv2.xemtivitop.com/live/hot/vtv9.php",
200
- "vtv10": "https://sv2.xemtivitop.com/live/hot/vtv10.php",
201
- }
202
-
203
- @router.get("/api/proxy/sv2/iframe")
204
- def proxy_sv2_iframe(url: str = Query(...)):
205
- try:
206
- s = requests.Session()
207
- try: s.get("https://sv2.xemtivitop.com/", headers=UA, timeout=5, verify=False)
208
- except: pass
209
- r = s.get(url, headers={**UA, "Referer": "https://sv2.xemtivitop.com/"}, timeout=15, verify=False)
210
- if r.status_code != 200:
211
- return Response(content=f"<html><body><p>sv2 error: {r.status_code}</p></body></html>", media_type="text/html",
212
- headers={"Access-Control-Allow-Origin": "*"})
213
- html = r.text
214
- html = html.replace('src="/', 'src="https://sv2.xemtivitop.com/')
215
- html = html.replace("src='/", "src='https://sv2.xemtivitop.com/")
216
- html = html.replace('href="/', 'href="https://sv2.xemtivitop.com/')
217
- html = html.replace("href='/", "href='https://sv2.xemtivitop.com/")
218
- html = html.replace('</head>', '''
219
- <style>
220
- *{margin:0;padding:0}
221
- body{background:#000;overflow:hidden}
222
- .video-js,video,iframe,#player,.player,.streaming-dimensions,#videojs{width:100%!important;height:100%!important;max-width:100%!important}
223
- </style>
224
- </head>''')
225
- return Response(content=html.encode('utf-8'), media_type="text/html",
226
- headers={
227
- "Access-Control-Allow-Origin": "*",
228
- "X-Frame-Options": "ALLOWALL",
229
- "Content-Security-Policy": "frame-ancestors *",
230
- })
231
- except Exception as e:
232
- return Response(content=f"<html><body><p>proxy error: {str(e)[:100]}</p></body></html>", media_type="text/html",
233
- headers={"Access-Control-Allow-Origin": "*"})
234
-
235
- # ===================== API ENDPOINTS =====================
236
-
237
- @router.get("/api/vtv/streams")
238
- def api_vtv_streams():
239
- result = {}
240
- urls = get_all_channel_urls()
241
- for ch_id in CHANNEL_NAMES:
242
- stream_url = urls.get(ch_id)
243
- sv2_url = SV2_URLS.get(ch_id)
244
- is_flv = stream_url and ".flv" in stream_url
245
- result[ch_id] = {
246
- "name": CHANNEL_NAMES[ch_id],
247
- "stream_url": stream_url,
248
- "proxy_url": f"/api/proxy/flv?url={requests.utils.quote(stream_url, safe='')}" if stream_url and is_flv else "",
249
- "proxy_url_hls": f"/api/proxy/stream?url={requests.utils.quote(stream_url, safe='')}" if stream_url and not is_flv else "",
250
- "is_flv": is_flv,
251
- "iframe_url": sv2_url,
252
- "proxy_iframe_url": f"/api/proxy/sv2/iframe?url={requests.utils.quote(sv2_url, safe='')}" if sv2_url else "",
253
- "status": "ok" if stream_url or sv2_url else "offline"
254
- }
255
- return JSONResponse(result)
256
-
257
- @router.get("/api/vtv/stream/{channel_id}")
258
- def api_vtv_stream(channel_id: str):
259
- channel_id = channel_id.lower().strip()
260
- if channel_id not in CHANNEL_NAMES:
261
- return JSONResponse({"error": "not found", "status": "offline"}, status_code=404)
262
- stream_url = get_channel_url(channel_id)
263
- sv2_url = SV2_URLS.get(channel_id)
264
- is_flv = stream_url and ".flv" in stream_url
265
- result = {
266
- "name": CHANNEL_NAMES[channel_id],
267
- "stream_url": stream_url,
268
- "proxy_url": f"/api/proxy/flv?url={requests.utils.quote(stream_url, safe='')}" if stream_url and is_flv else "",
269
- "proxy_url_hls": f"/api/proxy/stream?url={requests.utils.quote(stream_url, safe='')}" if stream_url and not is_flv else "",
270
- "is_flv": is_flv,
271
- "iframe_url": sv2_url,
272
- "proxy_iframe_url": f"/api/proxy/sv2/iframe?url={requests.utils.quote(sv2_url, safe='')}" if sv2_url else "",
273
- "status": "ok" if stream_url or sv2_url else "offline"
274
- }
275
- return JSONResponse(result)
276
-
277
- @router.get("/api/vtv/m3u/refresh")
278
- def api_vtv_m3u_refresh():
279
- global _last_m3u_fetch
280
- _last_m3u_fetch = 0
281
- _fetch_m3u()
282
- urls = get_all_channel_urls()
283
- found = sum(1 for v in urls.values() if v)
284
- return JSONResponse({"status": "refreshed", "channels_found": found,
285
- "channels": {k: v for k, v in urls.items() if v}})
286
-
287
- # ===================== EPG =====================
288
-
289
- _epg_cache = {}
290
- _epg_cache_time = 0
291
- _EPG_CACHE_TTL = 600
292
- VTV_CHANNEL_MAP = {
293
- "vtv1":"vtv1","vtv2":"vtv2","vtv3":"vtv3","vtv4":"vtv4","vtv5":"vtv5",
294
- "vtv5-tay-nam-bo":"vtv5","vtv5-tay-nguyen":"vtv5","vtv7":"vtv7","vtv8":"vtv8",
295
- "vtv6":"vtv6","vtv9":"vtv9","vtv-can-tho":"vtv10",
296
- }
297
-
298
- def _parse_time(time_str, reference_date=None):
299
- if not time_str: return None
300
- time_str = time_str.strip().replace("h", ":").replace("H", ":")
301
- m = re.search(r'(\d{1,2}):(\d{2})', time_str)
302
- if m:
303
- try:
304
- hour, minute = int(m.group(1)), int(m.group(2))
305
- base = reference_date or datetime.now(VN_TZ)
306
- if hour < 5: base = base - timedelta(days=1) if base.hour >= 5 else base
307
- dt = base.replace(hour=hour, minute=minute, second=0, microsecond=0)
308
- if dt.tzinfo is None: dt = dt.replace(tzinfo=VN_TZ)
309
- return dt
310
- except: pass
311
- return None
312
-
313
- def _fetch_epg_from_vtv():
314
- global _epg_cache, _epg_cache_time
315
- now_ts = time.time()
316
- if _epg_cache and now_ts - _epg_cache_time < _EPG_CACHE_TTL: return _epg_cache
317
- epg_data = {}
318
- now_vn = datetime.now(VN_TZ)
319
- try:
320
- h = {"User-Agent": UA["User-Agent"], "Accept-Language": "vi-VN,vi;q=0.9", "Referer": "https://vtv.vn/"}
321
- r = requests.get("https://vtv.vn/lich-phat-song.htm", headers=h, timeout=20)
322
- if r.status_code != 200: return epg_data
323
- r.encoding = "utf-8"
324
- soup = BeautifulSoup(r.text, "lxml")
325
- channel_order = []
326
- for link in soup.find_all('a', href=re.compile(r'truyen-hinh-truc-tuyen/([^.]+)\.htm')):
327
- ch_id = re.search(r'truyen-hinh-truc-tuyen/([^.]+)\.htm', link.get('href', ''))
328
- if ch_id and ch_id.group(1) not in channel_order: channel_order.append(ch_id.group(1))
329
- containers = soup.find_all('ul', class_=re.compile(r'\bprograms\b'))
330
- for i, container in enumerate(containers):
331
- if i >= len(channel_order): break
332
- vtv_id = channel_order[i]
333
- our_id = VTV_CHANNEL_MAP.get(vtv_id, vtv_id)
334
- if our_id not in epg_data: epg_data[our_id] = []
335
- for li in container.find_all('li', class_=re.compile(r'\bprogram\b')):
336
- t = li.find('span', class_=re.compile(r'\btime\b'))
337
- title_el = li.find('span', class_=re.compile(r'\btitle\b'))
338
- genre = li.find('a', class_=re.compile(r'\bgenre\b'))
339
- time_str = t.get_text(strip=True) if t else ""
340
- title = genre.get_text(strip=True) if genre else ""
341
- if not title and title_el: title = title_el.get_text(strip=True)
342
- if not time_str or not title: continue
343
- start_dt = _parse_time(time_str, reference_date=now_vn)
344
- if not start_dt: continue
345
- epg_data[our_id].append({"time": time_str[:5], "title": title[:80],
346
- "start_dt": start_dt, "date": start_dt.strftime("%d/%m/%Y"),})
347
- for ch_id in epg_data:
348
- epg_data[ch_id].sort(key=lambda x: x.get("start_dt") or datetime.min)
349
- except Exception as e:
350
- print(f"EPG error: {e}")
351
- _epg_cache = epg_data
352
- _epg_cache_time = now_ts
353
- return epg_data
354
-
355
- @router.get("/api/vtv/epg/{channel_id}")
356
- def api_vtv_epg(channel_id: str):
357
- channel_id = channel_id.lower().strip()
358
- if channel_id not in CHANNEL_NAMES: return JSONResponse({"error": "channel not found"}, status_code=404)
359
- epg_data = _fetch_epg_from_vtv()
360
- programmes = epg_data.get(channel_id, [])
361
- now = datetime.now(VN_TZ)
362
- today = now.date()
363
- result = []
364
- today_progs = [p for p in programmes if p.get("start_dt") and p["start_dt"].date() == today]
365
- if not today_progs: today_progs = programmes
366
- for i, p in enumerate(today_progs):
367
- start_dt = p.get("start_dt")
368
- stop_dt = today_progs[i + 1].get("start_dt") if i + 1 < len(today_progs) else None
369
- is_now = bool(start_dt and ((stop_dt and start_dt <= now < stop_dt) or start_dt <= now))
370
- end_time = stop_dt.strftime("%H:%M") if stop_dt else ""
371
- result.append({"time": p["time"], "title": p["title"], "end_time": end_time, "now": is_now, "date": p.get("date", "")})
372
- return JSONResponse({"channel": channel_id, "channel_name": CHANNEL_NAMES.get(channel_id, channel_id),
373
- "date": now.strftime("%Y-%m-%d"), "programs": result})
374
-
375
- @router.get("/api/vtv/epg")
376
- def api_vtv_epg_refresh():
377
- global _epg_cache, _epg_cache_time
378
- _epg_cache = {}; _epg_cache_time = 0
379
- epg_data = _fetch_epg_from_vtv()
380
- return JSONResponse({"status": "refreshed", "channels": len(epg_data), "total": sum(len(v) for v in epg_data.values())})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 DELETED
@@ -1,147 +0,0 @@
1
- """
2
- VTV Channels Scraper - Optimized for stable streaming
3
- Fetches stream URLs from multiple CDN sources for VTV1-VTV10 + VTV Cần Thơ
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",
12
- "Accept-Language": "vi-VN,vi;q=0.9",
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",
22
- "vtv3": "https://hd.xemtv.net/kenh/vtv3.php",
23
- "vtv4": "https://hd.xemtv.net/kenh/vtv4.php",
24
- "vtv5": "https://hd.xemtv.net/kenh/vtv5.php",
25
- "vtv6": "https://hd.xemtv.net/kenh/vtv6.php",
26
- "vtv7": "https://hd.xemtv.net/kenh/vtv7.php",
27
- "vtv8": "https://hd.xemtv.net/kenh/vtv8.php",
28
- "vtv9": "https://hd.xemtv.net/kenh/vtv9.php",
29
- "vtv10": "https://hd.xemtv.net/kenh/vtv10.php",
30
- }
31
-
32
- CHANNEL_NAMES = {
33
- "vtv1": "VTV1", "vtv2": "VTV2", "vtv3": "VTV3", "vtv4": "VTV4",
34
- "vtv5": "VTV5", "vtv6": "VTV6", "vtv7": "VTV7", "vtv8": "VTV8",
35
- "vtv9": "VTV9", "vtv10": "VTV10",
36
- }
37
-
38
- # ===== RELIABLE CDN BACKUPS (verified working URLs) =====
39
- CDN_STREAMS = {
40
- # FPTPlay - Primary (most stable)
41
- "vtv1": "https://live-a.fptplay53.net/live/media/vtv1/live247-hls-avc/index.m3u8",
42
- "vtv2": "https://live-a.fptplay53.net/live/media/vtv2/live247-hls-avc/index.m3u8",
43
- "vtv3": "https://live-a.fptplay53.net/live/media/vtv3/live247-hls-avc/index.m3u8",
44
- "vtv4": "https://live-a.fptplay53.net/live/media/vtv4/live247-hls-avc/index.m3u8",
45
- "vtv5": "https://live-a.fptplay53.net/live/media/vtv5/live247-hls-avc/index.m3u8",
46
- "vtv6": "https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/index.m3u8",
47
- "vtv7": "https://live-a.fptplay53.net/live/media/vtv7/live247-hls-avc/index.m3u8",
48
- "vtv8": "https://live-a.fptplay53.net/live/media/vtv8/live-hls-avc/index.m3u8",
49
- "vtv9": "https://live-a.fptplay53.net/live/media/vtv9/live247-hls-avc/index.m3u8",
50
- "vtv10": "https://live-a.fptplay53.net/live/media/vtv10/live247-hls-avc/index.m3u8",
51
-
52
- # VTVGo Failover
53
- "vtv1_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8",
54
- "vtv2_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv2-manifest.m3u8",
55
- "vtv3_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv3-manifest.m3u8",
56
- "vtv4_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv4-manifest.m3u8",
57
- "vtv5_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv5-manifest.m3u8",
58
- "vtv6_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv6-manifest.m3u8",
59
- "vtv7_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv7-manifest.m3u8",
60
- "vtv8_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv8-manifest.m3u8",
61
- "vtv9_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv9-manifest.m3u8",
62
- "vtv10_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv10-manifest.m3u8",
63
- }
64
-
65
- _vtv_cache = {}
66
- _vtv_lock = threading.Lock()
67
- _CACHE_TTL = 180
68
-
69
- def _cached(key):
70
- with _vtv_lock:
71
- if key in _vtv_cache and time.time() - _vtv_cache[key]['t'] < _CACHE_TTL:
72
- return _vtv_cache[key]['d']
73
- return None
74
-
75
- def _set_cache(key, data):
76
- with _vtv_lock:
77
- _vtv_cache[key] = {'t': time.time(), 'd': data}
78
-
79
- def extract_m3u8_from_html(html):
80
- if not html: return None
81
- # Look for file: "..." pattern
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: return url
86
- # Look for direct m3u8 URL
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: return url
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',
111
- 'vietnam_vtv1': 'vtv1', 'vietnam_vtv2': 'vtv2', 'vietnam_vtv3': 'vtv3',
112
- 'vietnam_vtv4': 'vtv4', 'vietnam_vtv5': 'vtv5', 'vietnam_vtv6': 'vtv6',
113
- 'vietnam_vtv7': 'vtv7', 'vietnam_vtv8': 'vtv8', 'vietnam_vtv9': 'vtv9',
114
- }
115
- channel_id = name_map.get(channel_id, channel_id)
116
-
117
- # Try direct CDN URLs first (most stable)
118
- if channel_id in CDN_STREAMS:
119
- return CDN_STREAMS[channel_id]
120
-
121
- # Try PHP endpoints as fallback
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: return m3u8
129
- except: pass
130
-
131
- # Try failover backup
132
- fb_key = f"{channel_id}_fb"
133
- if fb_key in CDN_STREAMS:
134
- return CDN_STREAMS[fb_key]
135
-
136
- return None
137
-
138
- def get_all_vtv_streams():
139
- channels = []
140
- for ch_id in CHANNEL_NAMES:
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vtv_shorts.py DELETED
@@ -1,140 +0,0 @@
1
- """
2
- VTV Nam Bộ YouTube Shorts Scraper
3
- """
4
- import requests
5
- import re
6
- import json
7
- import subprocess
8
- import html as html_lib
9
- import time
10
- import threading
11
- from xml.etree import ElementTree as ET
12
-
13
- _cache = {}
14
- _lock = threading.Lock()
15
- CACHE_TTL = 1800
16
-
17
- UA = {
18
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
19
- "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8",
20
- }
21
-
22
- def _cached(key):
23
- with _lock:
24
- if key in _cache and time.time() - _cache[key]['t'] < CACHE_TTL:
25
- return _cache[key]['d']
26
- return None
27
-
28
- def _set_cache(key, data):
29
- with _lock:
30
- _cache[key] = {'t': time.time(), 'd': data}
31
-
32
- def get_channel_id(username):
33
- cached = _cached(f'ch_id_{username}')
34
- if cached:
35
- return cached
36
- channel_id = None
37
- try:
38
- url = f"https://www.youtube.com/@{username}"
39
- r = requests.get(url, headers=UA, timeout=15)
40
- if r.status_code == 200:
41
- m = re.search(r'<meta\s+property="og:url"\s+content="https://www.youtube.com/channel/(UC[^"]+)"', r.text)
42
- if m:
43
- channel_id = m.group(1)
44
- if not channel_id:
45
- m = re.search(r'"channelId":"(UC[^"]+)"', r.text)
46
- if m:
47
- channel_id = m.group(1)
48
- except Exception as e:
49
- print(f"Error getting channel ID for @{username}: {e}")
50
- if channel_id:
51
- _set_cache(f'ch_id_{username}', channel_id)
52
- return channel_id
53
-
54
- def scrape_via_rss(channel_id, max_count=100):
55
- shorts = []
56
- try:
57
- url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
58
- r = requests.get(url, headers=UA, timeout=15)
59
- if r.status_code != 200:
60
- return shorts
61
- root = ET.fromstring(r.text)
62
- ns = {'atom': 'http://www.w3.org/2005/Atom', 'yt': 'http://www.youtube.com/xml/schemas/2015'}
63
- for entry in root.findall('atom:entry', ns)[:max_count]:
64
- title_el = entry.find('atom:title', ns)
65
- title = html_lib.unescape(title_el.text) if title_el is not None and title_el.text else ''
66
- vid_el = entry.find('yt:videoId', ns)
67
- vid = vid_el.text if vid_el is not None else ''
68
- if not vid:
69
- continue
70
- is_short = '#shorts' in title.lower() or '#short' in title.lower()
71
- link_el = entry.find('atom:link', ns)
72
- link = link_el.get('href', '') if link_el is not None else ''
73
- if '/shorts/' in link:
74
- is_short = True
75
- if is_short:
76
- shorts.append({'id': vid, 'title': title, 'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg", 'channel': 'vtvnambo'})
77
- except Exception as e:
78
- print(f"RSS scrape error: {e}")
79
- return shorts
80
-
81
- def scrape_via_ydlp(username, count=50):
82
- shorts = []
83
- try:
84
- url = f"https://www.youtube.com/@{username}/shorts"
85
- result = subprocess.run(
86
- ["yt-dlp", "--dump-json", "--flat-playlist", "--no-download", "--playlist-end", str(count), url],
87
- capture_output=True, text=True, timeout=90
88
- )
89
- if result.returncode == 0 and result.stdout.strip():
90
- seen_ids = set()
91
- for line in result.stdout.strip().split('\n'):
92
- line = line.strip()
93
- if not line:
94
- continue
95
- try:
96
- entry = json.loads(line)
97
- vid = entry.get('id', '')
98
- if not vid or vid in seen_ids:
99
- continue
100
- seen_ids.add(vid)
101
- title = entry.get('title', 'VTV Nam Bộ Short')
102
- shorts.append({'id': vid, 'title': title, 'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg", 'channel': 'vtvnambo'})
103
- except json.JSONDecodeError:
104
- continue
105
- except (subprocess.TimeoutExpired, FileNotFoundError) as e:
106
- print(f"yt-dlp error: {e}")
107
- return shorts
108
-
109
- def get_vtvnambo_shorts(max_count=50):
110
- cached = _cached('vtvnambo_shorts_v2')
111
- if cached is not None:
112
- return cached
113
- all_shorts = []
114
- seen_ids = set()
115
- channel_id = get_channel_id('vtvnambo')
116
- if channel_id:
117
- rss_shorts = scrape_via_rss(channel_id, max_count * 2)
118
- for s in rss_shorts:
119
- if s['id'] not in seen_ids:
120
- seen_ids.add(s['id'])
121
- all_shorts.append(s)
122
- if len(all_shorts) < 3:
123
- ydlp_shorts = scrape_via_ydlp('vtvnambo', max_count)
124
- for s in ydlp_shorts:
125
- if s['id'] not in seen_ids:
126
- seen_ids.add(s['id'])
127
- all_shorts.append(s)
128
- result = all_shorts[:max_count]
129
- _set_cache('vtvnambo_shorts_v2', result)
130
- return result
131
-
132
- get_vtvnamo_shorts = get_vtvnambo_shorts
133
-
134
- def get_wc_related_shorts(max_count=30):
135
- all_shorts = get_vtvnambo_shorts(max_count * 3)
136
- wc_kws = ['world cup', 'wc 2026', 'worldcup', 'fifa', 'bóng đá', 'trận đấu', 'đội tuyển']
137
- wc_shorts = [s for s in all_shorts if any(k in s.get('title', '').lower() for k in wc_kws)]
138
- if not wc_shorts:
139
- wc_shorts = all_shorts
140
- return wc_shorts[:max_count]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
wc2026_scraper.py CHANGED
@@ -114,6 +114,7 @@ def scrape_wc_news():
114
  ('https://thanhnien.vn/tim-kiem?q={q}','h3 a[href], .box-title a','https://thanhnien.vn','Thanh Niên'),
115
  ('https://tuoitre.vn/tim-kiem.htm?keywords={q}','h3 a[href], .box-title-text a','https://tuoitre.vn','Tuổi Trẻ'),
116
  ('https://thethaovanhoa.vn/tim-kiem.htm?keyword={q}','h3 a[href], .title a[href]','https://thethaovanhoa.vn','TT&VH'),
 
117
  ('https://genk.vn/tim-kiem?q={q}','a[href$=".chn"]','https://genk.vn','GenK'),
118
  ]
119
 
@@ -122,26 +123,10 @@ def scrape_wc_news():
122
  url_tpl,selector,base,source=cfg
123
  items=_search_source(url_tpl,topic,selector,base,8)
124
  return [(item,source) for item in items]
125
- # bongda.com.vn + bongdaplus.vn via Jina (JS-protected) — keyword-filter for World Cup
126
- def _fetch_bongda_jina():
127
- out=[]
128
- kws=['world cup','wc 2026','world cup 2026','tuyển','đội tuyển','vòng loại','fifa']
129
- try:
130
- from main import scrape_bongda_jina, scrape_bongdaplus_jina
131
- for a in (scrape_bongda_jina(40) or []):
132
- tl=(a.get('title','') or '').lower()
133
- if any(k in tl for k in kws):out.append(({'title':a['title'],'link':a['link']},'Bóng Đá'))
134
- for a in (scrape_bongdaplus_jina(40) or []):
135
- tl=(a.get('title','') or '').lower()
136
- if ('world cup' in tl) or ('/world-cup/' in a.get('link','')) or any(k in tl for k in kws[3:]):
137
- out.append(({'title':a['title'],'link':a['link']},'Bóng Đá+'))
138
- except:pass
139
- return out
140
 
141
  with ThreadPoolExecutor(8) as ex:
142
  futs=[ex.submit(_fetch_source,cfg) for cfg in sources_cfg]
143
- futs.append(ex.submit(_fetch_bongda_jina))
144
- for f in as_completed(futs,timeout=16):
145
  try:
146
  for item,source in f.result():
147
  all_news.append({'title':item['title'],'link':item['link'],'img':'','source':source})
 
114
  ('https://thanhnien.vn/tim-kiem?q={q}','h3 a[href], .box-title a','https://thanhnien.vn','Thanh Niên'),
115
  ('https://tuoitre.vn/tim-kiem.htm?keywords={q}','h3 a[href], .box-title-text a','https://tuoitre.vn','Tuổi Trẻ'),
116
  ('https://thethaovanhoa.vn/tim-kiem.htm?keyword={q}','h3 a[href], .title a[href]','https://thethaovanhoa.vn','TT&VH'),
117
+ ('https://bongda.com.vn/tim-kiem.html?q={q}','h3 a[href], .title a[href]','https://bongda.com.vn','Bóng Đá'),
118
  ('https://genk.vn/tim-kiem?q={q}','a[href$=".chn"]','https://genk.vn','GenK'),
119
  ]
120
 
 
123
  url_tpl,selector,base,source=cfg
124
  items=_search_source(url_tpl,topic,selector,base,8)
125
  return [(item,source) for item in items]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
 
127
  with ThreadPoolExecutor(8) as ex:
128
  futs=[ex.submit(_fetch_source,cfg) for cfg in sources_cfg]
129
+ for f in as_completed(futs,timeout=14):
 
130
  try:
131
  for item,source in f.result():
132
  all_news.append({'title':item['title'],'link':item['link'],'img':'','source':source})
yt_scraper.py DELETED
@@ -1,235 +0,0 @@
1
- """
2
- YouTube Shorts Scraper using yt-dlp (already installed on Space)
3
- Runs yt-dlp as subprocess to extract video info and direct URLs
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 = 600 # 10 min cache
15
-
16
- def _cached(key):
17
- with _lock:
18
- if key in _cache and time.time() - _cache[key]['t'] < CACHE_TTL:
19
- return _cache[key]['d']
20
- return None
21
-
22
- def _set_cache(key, data):
23
- with _lock:
24
- _cache[key] = {'t': time.time(), 'd': data}
25
-
26
- def run_yt_dlp(args, timeout=120):
27
- """Run yt-dlp and return parsed JSON lines"""
28
- try:
29
- result = subprocess.run(
30
- ["yt-dlp"] + args,
31
- capture_output=True, text=True, timeout=timeout
32
- )
33
- if result.returncode != 0 and not result.stdout.strip():
34
- print(f"yt-dlp error: {result.stderr[:200]}")
35
- return []
36
- lines = result.stdout.strip().split('\n')
37
- items = []
38
- for line in lines:
39
- line = line.strip()
40
- if not line:
41
- continue
42
- try:
43
- items.append(json.loads(line))
44
- except json.JSONDecodeError:
45
- continue
46
- return items
47
- except subprocess.TimeoutExpired:
48
- print("yt-dlp timeout")
49
- return []
50
- except FileNotFoundError:
51
- print("yt-dlp not found!")
52
- return []
53
- except Exception as e:
54
- print(f"yt-dlp exception: {e}")
55
- return []
56
-
57
- def get_channel_shorts_via_playlist(channel_username, max_count=100):
58
- """Get shorts from channel's shorts page using yt-dlp"""
59
- shorts = []
60
-
61
- # Method 1: Fetch from /shorts page
62
- url = f"https://www.youtube.com/@{channel_username}/shorts"
63
- items = run_yt_dlp([
64
- "--dump-json",
65
- "--flat-playlist",
66
- "--no-download",
67
- "--playlist-end", str(max_count),
68
- "--no-check-certificates",
69
- "--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
70
- url
71
- ], timeout=90)
72
-
73
- seen_ids = set()
74
- for item in items:
75
- vid = item.get('id', '')
76
- if not vid or vid in seen_ids:
77
- continue
78
- seen_ids.add(vid)
79
-
80
- title = item.get('title', 'VTV Nam Bộ Short')
81
- duration = item.get('duration', 0) or 0
82
-
83
- # Only include actual shorts (<= 120s to be safe)
84
- if duration <= 120 or '#shorts' in title.lower() or '#short' in title.lower():
85
- shorts.append({
86
- 'id': vid,
87
- 'title': title,
88
- 'duration': duration,
89
- 'channel': channel_username,
90
- 'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg",
91
- })
92
-
93
- return shorts
94
-
95
- def get_channel_videos_filter_shorts(channel_username, max_count=200):
96
- """Get all videos from /videos page and filter for shorts by duration"""
97
- shorts = []
98
-
99
- url = f"https://www.youtube.com/@{channel_username}/videos"
100
- items = run_yt_dlp([
101
- "--dump-json",
102
- "--flat-playlist",
103
- "--no-download",
104
- "--playlist-end", str(max_count),
105
- "--match-filter", "duration > 0 and duration <= 120",
106
- "--no-check-certificates",
107
- "--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
108
- url
109
- ], timeout=120)
110
-
111
- seen_ids = set()
112
- for item in items:
113
- vid = item.get('id', '')
114
- if not vid or vid in seen_ids:
115
- continue
116
- seen_ids.add(vid)
117
-
118
- title = item.get('title', 'VTV Nam Bộ Short')
119
- duration = item.get('duration', 0) or 0
120
-
121
- shorts.append({
122
- 'id': vid,
123
- 'title': title,
124
- 'duration': duration,
125
- 'channel': channel_username,
126
- 'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg",
127
- })
128
-
129
- return shorts
130
-
131
- def get_shorts_with_direct_url(video_ids):
132
- """Get direct video download URLs for given video IDs"""
133
- results = []
134
-
135
- for vid in video_ids[:20]: # Limit to avoid timeout
136
- try:
137
- url = f"https://www.youtube.com/shorts/{vid}"
138
- items = run_yt_dlp([
139
- "--dump-json",
140
- "--no-download",
141
- "--no-check-certificates",
142
- "--format", "best[filesize<10M]/best",
143
- url
144
- ], timeout=30)
145
-
146
- if items:
147
- info = items[0]
148
- direct_url = info.get('url', '')
149
- if not direct_url:
150
- # Try to get from formats
151
- formats = info.get('formats', [])
152
- for f in formats:
153
- if f.get('vcodec') != 'none' and f.get('acodec') != 'none':
154
- direct_url = f.get('url', '')
155
- break
156
-
157
- if direct_url:
158
- results.append({
159
- 'id': vid,
160
- 'title': info.get('title', ''),
161
- 'direct_url': direct_url,
162
- 'thumbnail': info.get('thumbnail', f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg"),
163
- 'duration': info.get('duration', 0),
164
- })
165
- except Exception as e:
166
- print(f"Error getting URL for {vid}: {e}")
167
-
168
- return results
169
-
170
- def get_vtvnambo_shorts(max_count=50):
171
- """Get all shorts from VTV Nam Bộ using yt-dlp"""
172
- cached = _cached('vtvnambo_shorts_yt')
173
- if cached is not None:
174
- return cached
175
-
176
- all_shorts = []
177
- seen_ids = set()
178
-
179
- # Method 1: /shorts page
180
- print(f"[yt-dlp] Fetching /shorts page...")
181
- shorts_page = get_channel_shorts_via_playlist('vtvnambo', max_count)
182
- for s in shorts_page:
183
- if s['id'] not in seen_ids:
184
- seen_ids.add(s['id'])
185
- all_shorts.append(s)
186
- print(f"[yt-dlp] /shorts page: {len(shorts_page)} shorts")
187
-
188
- # Method 2: /videos page with duration filter
189
- if len(all_shorts) < 5:
190
- print(f"[yt-dlp] Fetching /videos page with filter...")
191
- videos_filtered = get_channel_videos_filter_shorts('vtvnambo', max_count * 2)
192
- for s in videos_filtered:
193
- if s['id'] not in seen_ids:
194
- seen_ids.add(s['id'])
195
- all_shorts.append(s)
196
- print(f"[yt-dlp] /videos filter: {len(videos_filtered)} shorts")
197
-
198
- result = all_shorts[:max_count]
199
- _set_cache('vtvnambo_shorts_yt', result)
200
- print(f"[yt-dlp] Total: {len(result)} shorts from VTV Nam Bộ")
201
- return result
202
-
203
- def get_wc_related_shorts(max_count=30):
204
- """Get World Cup / football related shorts"""
205
- all_shorts = get_vtvnambo_shorts(max_count * 3)
206
-
207
- wc_kws = [
208
- 'world cup', 'wc 2026', 'worldcup', 'fifa', 'bóng đá',
209
- 'trận đấu', 'đội tuyển', 'tuyển', 'vòng loại',
210
- 'khoảnh khắc', 'highlights', 'bàn thắng', 'goal',
211
- 'kết quả', 'tỉ số', 'việt nam', 'vn',
212
- 'ngoại hạng', 'premier league', 'champions league',
213
- 'laliga', 'serie a', 'bundesliga', 'ligue 1',
214
- 'copa', 'europa', 'c1', 'c2',
215
- 'messi', 'ronaldo', 'neymar', 'mbappe', 'haaland',
216
- 'v-league', 'vleague', 'bóng đá việt',
217
- 'đội bóng', 'hlv', 'huấn luyện viên',
218
- 'chuyển nhượng', 'transfer',
219
- 'asian cup', 'aff cup', 'sea games',
220
- 'olympic', 'u23', 'u20', 'u17',
221
- ]
222
-
223
- wc_shorts = []
224
- for s in all_shorts:
225
- tl = s.get('title', '').lower()
226
- if any(k in tl for k in wc_kws):
227
- wc_shorts.append(s)
228
-
229
- if not wc_shorts:
230
- wc_shorts = all_shorts
231
-
232
- return wc_shorts[:max_count]
233
-
234
- # Aliases
235
- get_vtvnamo_shorts = get_vtvnambo_shorts
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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