This view is limited to 50 files because it contains too many changes. See the raw diff here.
.docker_rebuild DELETED
File without changes
.dockerignore DELETED
File without changes
.huggingface/main.json DELETED
File without changes
.huggingface/rebuild DELETED
@@ -1 +0,0 @@
1
- rebuild
 
 
.rebuild DELETED
@@ -1 +0,0 @@
1
- 1783786956
 
 
.rebuild_flag DELETED
File without changes
.rebuild_v2 DELETED
@@ -1 +0,0 @@
1
- 1783785895
 
 
.restart_trigger DELETED
File without changes
CHANGELOG.md DELETED
@@ -1,74 +0,0 @@
1
- # VNEWS v2.8 - Icon Change
2
- - Changed Short AI feed video share button icon from 📤 (upload/share) to 📥 (download) to distinguish from article share
3
-
4
- # VNEWS v6.5 - Resilient Shorts Auto-Updater
5
-
6
- ## Changes
7
-
8
- ### Critical Fix: Shorts timeout and homepage load stability
9
- **Root cause**: YouTube shorts fetching in `main.py` using `scrape_shorts()` and `_yt_channel_shorts_requests()` could hang indefinitely when YouTube blocks requests or yt-dlp times out, causing:
10
- - Homepage `/api/shorts` endpoint to time out (30s limit)
11
- - Space to appear unresponsive on first load
12
- - No fallback when sources fail
13
-
14
- **Fix applied**:
15
- 1. **shorts_updater.py** (NEW) — Resilient background updater:
16
- - Hard timeout (25s) per channel using subprocess isolation
17
- - Stale-while-revalidate pattern: returns cached data immediately, updates in background
18
- - Automatic fallback to hardcoded short URLs when all sources fail
19
- - Persistent storage in `/data/shorts_cache.json` for cache across restarts
20
- - Background scheduler runs every 10 minutes automatically
21
- - No blocking on first homepage load
22
-
23
- 2. **_run.py** — Integrated resilient shorts endpoint:
24
- - Overrides `/api/shorts` with non-blocking version
25
- - Returns cached/fallback data in <100ms guaranteed
26
- - Triggers background update if cache is stale or empty
27
- - Never hangs - always returns valid JSON response
28
-
29
- 3. **FALLBACK_SHORTS** — 6 hardcoded viral shorts as emergency fallback:
30
- - baodantri7941 (Dân trí) headlines
31
- - baosuckhoedoisongboyte (Sức khỏe & đời sống) stories
32
- - vtvnambo (VTV Nam Bộ) news
33
-
34
- ### Benefits
35
- - Homepage loads in <2 seconds always
36
- - Shorts data auto-updates every 10 minutes
37
- - Never times out - graceful degradation to fallback
38
- - Persistent cache survives Space restarts
39
- - Uses bucket `bep40/VNEWS-storage` for cache storage
40
-
41
- ### Channels monitored
42
- - baodantri7941 (Dân trí)
43
- - baosuckhoedoisongboyte (Sức khỏe & đời sống)
44
- - vtvnambo (VTV Nam Bộ)
45
-
46
- ---
47
-
48
- # VNEWS v5.1 - Rewrite Fix
49
-
50
- ## Changes
51
-
52
- ### Critical Fix: Rewrite button not creating posts on Tường AI
53
- **Root cause**: `_run.py` imports from `app_v2_entry.py`, but the `/api/rewrite_share` endpoint was only defined in `ai_runtime_patch_fast.py` (loaded through `app_entry.py` which is NOT used). The frontend called a non-existent endpoint → 404 → silent failure.
54
-
55
- **Fix applied**:
56
- 1. **app_v2_entry.py** — Added 3 new endpoints:
57
- - `POST /api/rewrite_slide` — Fast extractive summary (no AI needed), creates slides from article key points + images, saves to wall
58
- - `POST /api/rewrite_share` — AI-powered rewrite with extractive fallback, saves to wall
59
- - `POST /api/url_wall` — URL submission endpoint (alias for rewrite_share)
60
- - All endpoints use the same `_load_wall_posts()` / `_save_wall_posts()` and `WALL_FILE` path as the existing `/api/wall` endpoint
61
-
62
- 2. **static/index_v2.html** — Added `<script src="/static/rewrite_fix_v2.js"></script>` to load the rewrite fix
63
-
64
- 3. **static/rewrite_fix_v2.js** — New file that overrides `rewriteArticle()` to:
65
- - Call `/api/rewrite_slide` first (fast, no AI needed)
66
- - Fallback to `/api/rewrite_share` if slide fails
67
- - Show slide preview overlay after successful post
68
- - Use `prependWallPost()` to add the new post to Tường AI
69
-
70
- ### Previous changes (v5)
71
- - Rewrote match_detail_v2.py with correct event parsing
72
- - 2-tab layout for match detail (stats + timeline)
73
- - Fixed _run.py import
74
- - Dockerfile cache busting
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Dockerfile DELETED
@@ -1,16 +0,0 @@
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
- CMD ["uvicorn", "_run:app", "--host", "0.0.0.0", "--port", "7860"]
16
- # v2.7-rebuild
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 DELETED
@@ -1,574 +0,0 @@
1
- """VNEWS AI Extension - rewrite + auto short video generation.
2
- Imported by app_v2_entry.py to register /api/rewrite_share, /api/topic_post,
3
- /api/ai_wall, /api/wall, /api/ai/short endpoints on the main FastAPI app.
4
-
5
- Uses main.py's WALL_FILE (wall_posts.json) for unified data store.
6
- TTS: edge-tts (HoaiMy female, NamMinh male) with speed control + gTTS fallback.
7
- """
8
- import os, re, json, time, random, html as html_lib, subprocess, asyncio
9
- from urllib.parse import quote_plus, quote, urlparse, urljoin
10
- from typing import Optional, List, Dict
11
- import requests
12
- 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:
29
- from main import _load_wall, _save_wall, _web_context # noqa: F401
30
- except ImportError:
31
- _data_dir = "/data" if os.path.isdir("/data") else "/app/data"
32
- _wall_file = os.path.join(_data_dir, "wall_posts.json")
33
- def _load_wall():
34
- try:
35
- if os.path.exists(_wall_file):
36
- with open(_wall_file, "r", encoding="utf-8") as f:
37
- return json.load(f)
38
- except Exception:
39
- pass
40
- return []
41
- def _save_wall(posts):
42
- try:
43
- os.makedirs(os.path.dirname(_wall_file), exist_ok=True)
44
- tmp = _wall_file + ".tmp"
45
- with open(tmp, "w", encoding="utf-8") as f:
46
- json.dump(posts[:100], f, ensure_ascii=False)
47
- os.replace(tmp, _wall_file)
48
- except Exception:
49
- pass
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:
60
- AsyncInferenceClient = None
61
- try:
62
- from gtts import gTTS
63
- except Exception:
64
- gTTS = None
65
- try:
66
- from PIL import Image, ImageDraw, ImageFont
67
- except Exception:
68
- Image = ImageDraw = ImageFont = None
69
- try:
70
- import edge_tts
71
- except Exception:
72
- edge_tts = None
73
-
74
-
75
- def _hf_token():
76
- for k in ("HF_TOKEN", "HUGGINGFACE_HUB_API_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HF_API_TOKEN"):
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ai_fix2.py DELETED
@@ -1,366 +0,0 @@
1
- import os, re, subprocess, html as html_lib, json
2
- from urllib.parse import quote_plus, urlparse, parse_qs, unquote
3
- import requests
4
- import ai_patch as prev
5
- from ai_patch import app
6
- from fastapi import Request
7
- from fastapi.responses import JSONResponse, HTMLResponse, FileResponse
8
-
9
- base = prev.base
10
-
11
-
12
- def clean(s):
13
- return re.sub(r"\s+", " ", html_lib.unescape(s or "")).strip()
14
-
15
-
16
- def _is_real_article_text(raw):
17
- raw = clean(raw)
18
- if len(raw) < 500:
19
- return False
20
- # Reject search-result/title-only pages: need several real sentences.
21
- sentences = re.split(r"(?<=[\.\!\?])\s+", raw)
22
- long_sentences = [s for s in sentences if len(s) > 45]
23
- return len(long_sentences) >= 5
24
-
25
-
26
- def _extract_ddg_url(href):
27
- if not href:
28
- return ""
29
- if href.startswith("//"):
30
- href = "https:" + href
31
- if "duckduckgo.com/l/" in href:
32
- try:
33
- qs = parse_qs(urlparse(href).query)
34
- if qs.get("uddg"):
35
- return unquote(qs["uddg"][0])
36
- except Exception:
37
- pass
38
- return href
39
-
40
-
41
- def _ddg_article_urls(topic, limit=12):
42
- urls = []
43
- try:
44
- q = quote_plus(topic + " tin tức bài viết phân tích")
45
- r = requests.get("https://html.duckduckgo.com/html/?q=" + q, headers=base.HEADERS, timeout=18)
46
- r.encoding = "utf-8"
47
- from bs4 import BeautifulSoup
48
- soup = BeautifulSoup(r.text, "lxml")
49
- for a in soup.select("a.result__a"):
50
- u = _extract_ddg_url(a.get("href", ""))
51
- if not u.startswith("http"):
52
- continue
53
- if any(bad in u for bad in ["google.com", "youtube.com", "facebook.com", "x.com", "twitter.com"]):
54
- continue
55
- if u not in urls:
56
- urls.append(u)
57
- if len(urls) >= limit:
58
- break
59
- except Exception:
60
- pass
61
- return urls
62
-
63
-
64
- def _rss_article_urls(topic, limit=10):
65
- out = []
66
- try:
67
- url = "https://news.google.com/rss/search?q=" + quote_plus(topic) + "&hl=vi&gl=VN&ceid=VN:vi"
68
- r = requests.get(url, headers=base.HEADERS, timeout=15)
69
- r.encoding = "utf-8"
70
- from bs4 import BeautifulSoup
71
- soup = BeautifulSoup(r.text, "xml")
72
- for it in soup.find_all("item")[:limit]:
73
- title = it.find("title").get_text(" ", strip=True) if it.find("title") else ""
74
- link = it.find("link").get_text(strip=True) if it.find("link") else ""
75
- src = it.find("source").get_text(" ", strip=True) if it.find("source") else base._domain(link)
76
- if title and link:
77
- out.append({"title": title, "url": link, "via": src, "excerpt": title})
78
- except Exception:
79
- pass
80
- return out
81
-
82
-
83
- def _topic_source_articles(topic, limit=5):
84
- """Scrape actual article bodies. Do not accept title-only sources."""
85
- candidates = []
86
- seen = set()
87
-
88
- # 1) DuckDuckGo actual result URLs are usually more directly scrapable.
89
- for u in _ddg_article_urls(topic, limit=14):
90
- if u not in seen:
91
- seen.add(u)
92
- candidates.append({"url": u, "title": "", "via": base._domain(u)})
93
-
94
- # 2) Add base web_context sources.
95
- try:
96
- _ctx, srcs = base.web_context(topic, limit=8)
97
- for s in srcs or []:
98
- u = s.get("url") or ""
99
- if u.startswith("http") and u not in seen:
100
- seen.add(u)
101
- candidates.append(s)
102
- except Exception:
103
- pass
104
-
105
- # 3) Google News RSS fallback last.
106
- for s in _rss_article_urls(topic, limit=10):
107
- u = s.get("url") or ""
108
- if u.startswith("http") and u not in seen:
109
- seen.add(u)
110
- candidates.append(s)
111
-
112
- out = []
113
- for s in candidates[:24]:
114
- url = s.get("url") or ""
115
- try:
116
- page = base.scrape_any_url(url)
117
- raw = (page.get("summary", "") + "\n" + page.get("text", "")).strip()
118
- if not _is_real_article_text(raw):
119
- continue
120
- title = page.get("title") or s.get("title") or url
121
- via = page.get("via") or s.get("via") or base._domain(url)
122
- out.append({
123
- "title": title,
124
- "url": url,
125
- "raw": raw,
126
- "image": page.get("image") or "",
127
- "via": via,
128
- "source": {"title": title, "url": url, "excerpt": raw[:700], "via": via}
129
- })
130
- if len(out) >= limit:
131
- break
132
- except Exception:
133
- continue
134
- return out[:limit]
135
-
136
-
137
- def sentence_split(text):
138
- text = re.sub(r"^[•\-\*]\s*", "", text or "", flags=re.M)
139
- text = re.sub(r"\n+", ". ", text)
140
- parts = []
141
- for s in re.split(r"(?<=[\.\!\?])\s+", text):
142
- s = clean(s)
143
- if len(s) >= 8:
144
- parts.append(s)
145
- return parts
146
-
147
-
148
- def srt_time(sec):
149
- ms = int((sec - int(sec)) * 1000)
150
- sec = int(sec)
151
- return f"{sec//3600:02d}:{(sec%3600)//60:02d}:{sec%60:02d},{ms:03d}"
152
-
153
-
154
- def parse_timecode(t):
155
- # 00:00:01.234 or 00:00:01,234
156
- t = t.replace(',', '.')
157
- parts = t.split(':')
158
- if len(parts) == 3:
159
- return int(parts[0])*3600 + int(parts[1])*60 + float(parts[2])
160
- if len(parts) == 2:
161
- return int(parts[0])*60 + float(parts[1])
162
- return float(parts[0])
163
-
164
-
165
- def convert_vtt_to_scaled_srt(vtt_path, srt_path, speed=1.2):
166
- try:
167
- txt = open(vtt_path, 'r', encoding='utf-8').read().splitlines()
168
- cues = []
169
- i = 0
170
- while i < len(txt):
171
- line = txt[i].strip()
172
- if '-->' in line:
173
- a, b = [x.strip().split()[0] for x in line.split('-->')[:2]]
174
- start = parse_timecode(a) / speed
175
- end = parse_timecode(b) / speed
176
- i += 1
177
- texts = []
178
- while i < len(txt) and txt[i].strip():
179
- texts.append(txt[i].strip())
180
- i += 1
181
- s = clean(' '.join(texts))
182
- if s:
183
- cues.append((start, end, s))
184
- i += 1
185
- if not cues:
186
- return False
187
- with open(srt_path, 'w', encoding='utf-8') as f:
188
- for idx, (st, en, s) in enumerate(cues, 1):
189
- if en <= st:
190
- en = st + 1.2
191
- f.write(f"{idx}\n{srt_time(st)} --> {srt_time(en)}\n{s}\n\n")
192
- return True
193
- except Exception:
194
- return False
195
-
196
-
197
- def write_weighted_srt(script, path, total_duration):
198
- subs = sentence_split(script)
199
- if not subs:
200
- subs = [clean(script)[:140] or "VNEWS"]
201
- total_chars = max(1, sum(len(x) for x in subs))
202
- usable = max(2.0, float(total_duration) - 1.0)
203
- cur = 0.5
204
- with open(path, "w", encoding="utf-8") as f:
205
- for i, s in enumerate(subs, 1):
206
- dur = max(1.8, min(7.0, usable * len(s) / total_chars))
207
- start = cur
208
- end = min(total_duration - 0.15, cur + dur)
209
- cur = end + 0.18
210
- f.write(f"{i}\n{srt_time(start)} --> {srt_time(end)}\n{s}\n\n")
211
- if cur >= total_duration - 0.2:
212
- break
213
-
214
-
215
- def tts_script_full(post, emotion):
216
- title = clean(post.get("title", ""))
217
- text = clean(post.get("text", ""))
218
- text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip()
219
- prefix = {
220
- "urgent": "Tin nhanh.",
221
- "warm": "Câu chuyện đáng chú ý.",
222
- "serious": "Bản tin nghiêm túc.",
223
- "energetic": "Cập nhật nổi bật.",
224
- }.get(emotion, "")
225
- script = f"{prefix} {title}. {text}".strip()
226
- # Keep complete wall summary. Only trim pathological payloads, on sentence boundary.
227
- if len(script) > 3600:
228
- tmp = script[:3600]
229
- cut = max(tmp.rfind("."), tmp.rfind("!"), tmp.rfind("?"))
230
- script = tmp[:cut + 1] if cut > 1600 else tmp
231
- script = re.sub(r"([\.\!\?])\s*", r"\1\n", script)
232
- script = re.sub(r"\n{2,}", "\n", script).strip()
233
- return script
234
-
235
-
236
- _PATCH = {('/api/topic_post','POST'),('/api/ai/short/{post_id}','POST'),('/api/ai/short-file/{file_id}','GET'),('/','GET')}
237
- app.router.routes = [r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in _PATCH)]
238
-
239
-
240
- @app.post('/api/topic_post')
241
- async def topic_post_aggregate(request: Request):
242
- body = await request.json()
243
- topic = base._clean_text(body.get('topic',''))
244
- if not topic:
245
- return JSONResponse({'error':'missing topic'}, status_code=400)
246
- articles = _topic_source_articles(topic, limit=5)
247
- if not articles:
248
- return JSONResponse({'error':'Không scrape được nội dung bài viết thật cho chủ đề này. Hãy thử chủ đề cụ thể hơn hoặc dán URL trực tiếp.'}, status_code=422)
249
- source_blocks = []
250
- sources = []
251
- image = ""
252
- for i, art in enumerate(articles, 1):
253
- raw = art.get('raw','')
254
- source_blocks.append(f"[Nguồn {i}] {art.get('title','')} ({art.get('via','')})\n{raw[:3000]}")
255
- sources.append(art.get('source') or {'title': art.get('title'), 'url': art.get('url'), 'via': art.get('via'), 'excerpt': raw[:600]})
256
- if not image and art.get('image'):
257
- image = art.get('image')
258
- ctx = "\n\n".join(source_blocks)
259
- prompt = f"""Bạn là biên tập viên tổng hợp tin tức tiếng Việt.
260
-
261
- Chủ đề: {topic}
262
-
263
- NHIỆM VỤ:
264
- - Đọc nội dung của TẤT CẢ các bài nguồn bên dưới.
265
- - Tổng hợp thành 1 bản tóm tắt chung duy nhất, giống cách tóm tắt qua URL.
266
- - Không tạo mỗi tiêu đề thành một bài riêng.
267
- - Không chỉ liệt kê tiêu đề; phải dựa vào nội dung trong từng bài.
268
- - Không lặp ý giữa các nguồn.
269
- - Tối đa 6 gạch đầu dòng, mỗi dòng 1 câu rõ ràng.
270
- - Nếu các nguồn có góc nhìn khác nhau, gộp lại thành ý tổng hợp.
271
- - Cuối cùng thêm dòng: Nguồn tham khảo: tên website.
272
-
273
- Nội dung nguồn:
274
- {ctx[:16000]}"""
275
- text = await prev.base.qwen_generate(prompt, image_url=image or None, max_tokens=1100)
276
- text = prev._postprocess_ai_text(text, max_units=7)
277
- if 'Nguồn tham khảo:' not in text:
278
- text += '\n\n' + prev._source_line(sources)
279
- post = base.make_post('Tổng hợp: ' + topic, text, image or base.pollinations_image_url(topic), '', 'topic_aggregate', sources=sources[:5])
280
- posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
281
- return JSONResponse({'post': post, 'count_sources': len(sources)})
282
-
283
-
284
- @app.post('/api/ai/short/{post_id}')
285
- async def ai_short_full(post_id: str, request: Request):
286
- try:
287
- body = await request.json()
288
- except Exception:
289
- body = {}
290
- voice = str(body.get('voice','nu')).lower().strip()
291
- emotion = str(body.get('emotion','neutral')).lower().strip()
292
- speed = max(0.85, min(1.35, float(body.get('speed', 1.2) or 1.2)))
293
- posts = base._load_ai_wall()
294
- post = next((p for p in posts if str(p.get('id')) == str(post_id)), None)
295
- if not post:
296
- return JSONResponse({'error':'post not found'}, status_code=404)
297
- os.makedirs(base.SHORTS_DIR, exist_ok=True)
298
- suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_fullv2"
299
- out_mp4 = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix) + '.mp4')
300
- if os.path.exists(out_mp4):
301
- post['video'] = '/api/ai/short-file/' + post_id + suffix
302
- base._save_ai_wall(posts)
303
- return JSONResponse({'video': post['video'], 'speed': speed, 'subtitles': True})
304
- work = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix)); os.makedirs(work, exist_ok=True)
305
- img = os.path.join(work,'image.jpg'); frame = os.path.join(work,'frame.jpg'); audio = os.path.join(work,'voice.mp3'); audio_fast=os.path.join(work,'voice_fast.mp3'); srt=os.path.join(work,'subtitles.srt'); vtt=os.path.join(work,'subtitles.vtt')
306
- try:
307
- base._download_image(post.get('img'), post.get('title','AI news'), img)
308
- prev._make_short_frame_full(post, img, frame)
309
- script = tts_script_full(post, emotion)
310
- edge_voice = {'nam':'vi-VN-NamMinhNeural','male':'vi-VN-NamMinhNeural','nu':'vi-VN-HoaiMyNeural','female':'vi-VN-HoaiMyNeural','mien-nam':'vi-VN-HoaiMyNeural'}.get(voice,'vi-VN-HoaiMyNeural')
311
- used_edge = False
312
- try:
313
- subprocess.run(['python','-m','edge_tts','--voice',edge_voice,'--text',script,'--write-media',audio,'--write-subtitles',vtt], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=260)
314
- used_edge = True
315
- except Exception:
316
- tld = 'com.vn' if voice in ('nu','female','mien-nam') else 'com'
317
- try:
318
- base.gTTS(script, lang='vi', tld=tld, slow=False).save(audio)
319
- except TypeError:
320
- base.gTTS(script, lang='vi', slow=False).save(audio)
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
328
- if used_edge and os.path.exists(vtt):
329
- ok = convert_vtt_to_scaled_srt(vtt, srt, speed=speed)
330
- if not ok:
331
- write_weighted_srt(script, srt, duration)
332
- else:
333
- write_weighted_srt(script, srt, duration)
334
- vf = "scale=1080:1920,subtitles='{}':force_style='FontName=DejaVu Sans,FontSize=16,PrimaryColour=&H00FFFFFF,OutlineColour=&HAA000000,BorderStyle=1,Outline=1.5,Shadow=0,Alignment=2,MarginV=42'".format(srt.replace("'", "\\'"))
335
- cmd = ['ffmpeg','-y','-loop','1','-i',frame,'-i',audio_fast,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k','-vf',vf,out_mp4]
336
- subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=420)
337
- post['video'] = '/api/ai/short-file/' + post_id + suffix
338
- post['short_voice'] = voice; post['short_emotion'] = emotion; post['short_speed'] = speed; post['short_subtitles'] = True
339
- base._save_ai_wall(posts)
340
- return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': True, 'duration': duration})
341
- except Exception as e:
342
- return JSONResponse({'error':'Không tạo được shorts: '+str(e)[:180]}, status_code=500)
343
-
344
-
345
- @app.get('/api/ai/short-file/{file_id}')
346
- def ai_short_file_full(file_id: str):
347
- path = os.path.join(base.SHORTS_DIR, base._safe_name(file_id) + '.mp4')
348
- if not os.path.exists(path):
349
- return JSONResponse({'error':'not found'}, status_code=404)
350
- return FileResponse(path, media_type='video/mp4', filename=f'vnews-ai-{file_id}.mp4')
351
-
352
-
353
- app.router.routes = [r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
354
-
355
- @app.get('/')
356
- async def index_fix2():
357
- with open('/app/static/index.html','r',encoding='utf-8') as f:
358
- html = f.read()
359
- inject = prev.PATCH_INJECT + r'''
360
- <script>
361
- (function(){
362
- 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.post){window.location.reload();alert('Đã tổng hợp NỘI DUNG các bài nguồn thành 1 bản tóm tắt trê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'));};
363
- })();
364
- </script>
365
- '''
366
- return HTMLResponse(html.replace('</body>', inject+'\n</body>'))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ai_patch.py DELETED
@@ -1,917 +0,0 @@
1
- import os
2
- import re
3
- import time
4
- import random
5
- 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
13
- from fastapi.responses import JSONResponse, HTMLResponse, FileResponse
14
- from bs4 import BeautifulSoup
15
- from urllib.parse import quote_plus
16
-
17
- try:
18
- from PIL import Image, ImageDraw, ImageFont
19
- except Exception:
20
- Image = ImageDraw = ImageFont = None
21
-
22
-
23
- def _clean(s):
24
- s = html_lib.unescape(s or "")
25
- s = re.sub(r"[ \t]+", " ", s)
26
- s = re.sub(r"\n{3,}", "\n\n", s)
27
- return s.strip()
28
-
29
-
30
- def _norm(s):
31
- s = s.lower()
32
- s = re.sub(r"[^\wÀ-ỹ\s]", " ", s)
33
- s = re.sub(r"\s+", " ", s).strip()
34
- return s
35
-
36
-
37
- def _similar(a, b):
38
- ta = set(_norm(a).split())
39
- tb = set(_norm(b).split())
40
- if not ta or not tb:
41
- return False
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:
59
- break
60
- return out
61
-
62
-
63
- def _postprocess_ai_text(text, max_units=20):
64
- text = _clean(text)
65
- if not text:
66
- return text
67
- drop_prefixes = (
68
- "dưới đây", "sau đây", "bài viết", "tôi sẽ", "mình sẽ",
69
- "tóm tắt bài", "tiêu đề:", "sapo:", "nội dung:", "kết luận:"
70
- )
71
- raw_lines = []
72
- for line in re.split(r"\n+", text):
73
- line = _clean(line)
74
- if not line:
75
- continue
76
- low = line.lower().strip()
77
- if any(low.startswith(p) and len(line) < 80 for p in drop_prefixes):
78
- continue
79
- raw_lines.append(line)
80
- units = []
81
- for line in raw_lines:
82
- # KEEP FULL bullet point - don't truncate or split into segments
83
- if len(line) >= 18:
84
- units.append(_clean(re.sub(r"^[-•*\d\.\)\s]+", "", line)))
85
- units = _dedupe_units(units, max_units=max_units)
86
- if not units:
87
- return text[:900]
88
- title = ""
89
- if raw_lines and len(raw_lines[0]) <= 90 and not raw_lines[0].startswith(("-", "•", "*")):
90
- title = raw_lines[0]
91
- units = [u for u in units if not _similar(u, title)]
92
- body = "\n".join("• " + u for u in units[:max_units])
93
- return (title + "\n\n" + body).strip() if title else body
94
-
95
-
96
- def _fallback_summary_from_prompt(prompt, max_units=6):
97
- text = prompt or ""
98
- for marker in ["Nội dung nguồn:", "Nội dung bài:", "Nội dung gốc:", "Nội dung:", "Nguồn/bối cảnh internet:"]:
99
- if marker in text:
100
- text = text.split(marker, 1)[1]
101
- break
102
- text = re.sub(r"https?://\S+", "", text)
103
- text = re.sub(r"\s+", " ", text).strip()
104
- sentences = re.split(r"(?<=[\.\!\?])\s+(?=[A-ZÀ-Ỹ0-9])", text)
105
- candidates = []
106
- for s in sentences:
107
- s = _clean(s)
108
- if 45 <= len(s) <= 260:
109
- candidates.append(s)
110
- units = _dedupe_units(candidates, max_units=max_units)
111
- if units:
112
- return "\n".join("• " + u for u in units)
113
- if text:
114
- return "• " + text[:700].rsplit(" ", 1)[0]
115
- return "• Không có đủ nội dung nguồn để tóm tắt."
116
-
117
-
118
- def _source_line(sources):
119
- names = []
120
- for s in (sources or [])[:5]:
121
- via = s.get("via") or base._domain(s.get("url", "")) or s.get("title", "")
122
- if via and via not in names:
123
- names.append(via)
124
- return "Nguồn tham khảo: " + ", ".join(names[:5]) if names else "Nguồn tham khảo: tổng hợp internet"
125
-
126
-
127
- def _make_summary_prompt(title, raw, source_hint=""):
128
- return f"""Bạn là biên tập viên tóm tắt tin tức tiếng Việt.
129
-
130
- NHIỆM VỤ BẮT BUỘC:
131
- - Chỉ TÓM TẮT nội dung chính, KHÔNG viết lại toàn bộ bài.
132
- - Không lặp lại cùng một ý, cùng một câu, cùng một chi tiết.
133
- - Không thêm thông tin ngoài nguồn.
134
- - Tối đa 5 gạch đầu dòng, mỗi gạch đầu dòng 1 câu ngắn.
135
- - Nếu bài có số liệu/nhân vật/thời điểm quan trọng thì giữ lại.
136
- - Không viết phần mở bài dài, không viết văn kể lại.
137
-
138
- Tiêu đề nguồn: {title}
139
- Nguồn: {source_hint}
140
-
141
- Nội dung nguồn:
142
- {raw[:14000]}
143
- """
144
-
145
-
146
- def _direct_news_rss(topic, limit=10):
147
- out = []
148
- try:
149
- url = "https://news.google.com/rss/search?q=" + quote_plus(topic) + "&hl=vi&gl=VN&ceid=VN:vi"
150
- r = requests.get(url, headers=base.HEADERS, timeout=15)
151
- r.encoding = "utf-8"
152
- soup = BeautifulSoup(r.text, "xml")
153
- for it in soup.find_all("item")[:limit]:
154
- title = it.find("title").get_text(" ", strip=True) if it.find("title") else ""
155
- link = it.find("link").get_text(strip=True) if it.find("link") else ""
156
- src = it.find("source").get_text(" ", strip=True) if it.find("source") else base._domain(link)
157
- if title and link:
158
- out.append({"title": title, "url": link, "via": src, "excerpt": title})
159
- except Exception:
160
- pass
161
- return out
162
-
163
-
164
- def _topic_source_articles(topic, limit=5):
165
- """Return actual scraped article bodies for a topic. Each source becomes one Wall AI post."""
166
- try:
167
- _ctx, sources = base.web_context(topic, limit=limit)
168
- except Exception:
169
- sources = []
170
- if not sources:
171
- sources = _direct_news_rss(topic, limit=10)
172
- out, seen = [], set()
173
- for s in (sources or [])[:limit * 3]:
174
- url = s.get("url") or ""
175
- if not url.startswith("http") or url in seen:
176
- continue
177
- seen.add(url)
178
- try:
179
- page = base.scrape_any_url(url)
180
- raw = (page.get("summary", "") + "\n" + page.get("text", "")).strip()
181
- if len(raw) < 180:
182
- continue
183
- title = page.get("title") or s.get("title") or url
184
- via = page.get("via") or s.get("via") or base._domain(url)
185
- out.append({
186
- "title": title,
187
- "url": url,
188
- "raw": raw,
189
- "image": page.get("image") or "",
190
- "via": via,
191
- "source": {"title": title, "url": url, "excerpt": raw[:700], "via": via}
192
- })
193
- if len(out) >= limit:
194
- break
195
- except Exception:
196
- continue
197
- if not out:
198
- for s in (sources or _direct_news_rss(topic, 6))[:limit]:
199
- title = s.get("title") or topic
200
- excerpt = s.get("excerpt") or s.get("description") or s.get("content") or title
201
- url = s.get("url", "")
202
- via = s.get("via") or base._domain(url)
203
- out.append({
204
- "title": title,
205
- "url": url,
206
- "raw": excerpt,
207
- "image": base.pollinations_image_url(title),
208
- "via": via,
209
- "source": {"title": title, "url": url, "excerpt": excerpt[:700], "via": via}
210
- })
211
- return out[:limit]
212
-
213
-
214
- async def qwen_generate_resilient(prompt: str, image_url=None, max_tokens: int = 1200):
215
- errors = []
216
- token = base._hf_token()
217
- try:
218
- original = getattr(base, "_original_qwen_generate", None)
219
- if original:
220
- txt = await original(prompt, image_url=image_url, max_tokens=max_tokens)
221
- if txt:
222
- base.LAST_QWEN_ERROR = ""
223
- return txt
224
- if getattr(base, "LAST_QWEN_ERROR", ""):
225
- errors.append("sdk: " + str(base.LAST_QWEN_ERROR)[:260])
226
- except Exception as e:
227
- errors.append(f"sdk: {type(e).__name__}: {str(e)[:260]}")
228
- if token:
229
- models = []
230
- for m in [
231
- os.getenv("QWEN_VL_MODEL", ""),
232
- "Qwen/Qwen2.5-VL-7B-Instruct",
233
- "Qwen/Qwen2.5-VL-3B-Instruct",
234
- "Qwen/Qwen2.5-7B-Instruct",
235
- "Qwen/Qwen2.5-3B-Instruct",
236
- "Qwen/Qwen2.5-1.5B-Instruct",
237
- ]:
238
- if m and m not in models:
239
- models.append(m)
240
- headers = {"Authorization": "Bearer " + token, "Content-Type": "application/json"}
241
- for model in models:
242
- try:
243
- is_vl = "VL" in model and bool(image_url)
244
- user_content = ([{"type": "image_url", "image_url": {"url": image_url}}, {"type": "text", "text": prompt}] if is_vl else prompt)
245
- payload = {
246
- "model": model,
247
- "messages": [
248
- {"role": "system", "content": "Bạn là biên tập viên AI tiếng Việt. Chỉ tóm tắt súc tích nội dung nguồn, không viết lại toàn bài, không lặp ý, không bịa chi tiết."},
249
- {"role": "user", "content": user_content},
250
- ],
251
- "max_tokens": min(int(max_tokens or 900), 1400),
252
- "temperature": 0.35,
253
- "top_p": 0.85,
254
- }
255
- r = requests.post("https://router.huggingface.co/v1/chat/completions", headers=headers, json=payload, timeout=95)
256
- if r.status_code >= 300:
257
- errors.append(f"{model}: HTTP {r.status_code} {r.text[:180]}")
258
- continue
259
- j = r.json()
260
- txt = (j.get("choices", [{}])[0].get("message", {}).get("content") or "").strip()
261
- if txt:
262
- base.LAST_QWEN_ERROR = ""
263
- return txt
264
- errors.append(f"{model}: empty response")
265
- except Exception as e:
266
- errors.append(f"{model}: {type(e).__name__}: {str(e)[:220]}")
267
- else:
268
- errors.append("missing HF_TOKEN")
269
- base.LAST_QWEN_ERROR = " | ".join(errors[-6:]) or "Qwen unavailable; used extractive fallback"
270
- print("[qwen resilient fallback]", base.LAST_QWEN_ERROR)
271
- return _fallback_summary_from_prompt(prompt, max_units=12)
272
-
273
-
274
- if not hasattr(base, "_original_qwen_generate"):
275
- base._original_qwen_generate = base.qwen_generate
276
- base.qwen_generate = qwen_generate_resilient
277
-
278
-
279
- @app.get('/api/wall')
280
- def compat_wall():
281
- return JSONResponse({'posts': base._load_ai_wall()[:80]})
282
-
283
-
284
- _PATCHED_PATHS = {
285
- ('/api/topic_post', 'POST'),
286
- ('/api/url_wall', 'POST'),
287
- ('/api/rewrite_share', 'POST'),
288
- ('/api/ai/short/{post_id}', 'POST'),
289
- }
290
- app.router.routes = [
291
- r for r in app.router.routes
292
- if not any(getattr(r, 'path', None) == p and m in getattr(r, 'methods', set()) for p, m in _PATCHED_PATHS)
293
- ]
294
-
295
-
296
- @app.post('/api/topic_post')
297
- async def compat_topic_post(request: Request):
298
- body = await request.json()
299
- topic = base._clean_text(body.get('topic', ''))
300
- if not topic:
301
- return JSONResponse({'error': 'missing topic'}, status_code=400)
302
- articles = _topic_source_articles(topic, limit=4)
303
- if not articles:
304
- return JSONResponse({'error': 'Không lấy được bài viết nguồn cho chủ đề này.'}, status_code=422)
305
- new_posts = []
306
- posts = base._load_ai_wall()
307
- for art in articles:
308
- prompt = f"""Tóm tắt RIÊNG bài viết nguồn sau để đăng Tường AI.
309
-
310
- Chủ đề lọc: {topic}
311
- Tiêu đề bài nguồn: {art['title']}
312
- Nguồn: {art['via']}
313
-
314
- Yêu cầu bắt buộc:
315
- - Tóm tắt nội dung trong BÀI VIẾT này, không chỉ tiêu đề.
316
- - Không trộn với bài khác.
317
- - Không viết lại toàn bộ bài.
318
- - Không lặp ý.
319
- - 4-6 gạch đầu dòng, mỗi dòng 1 câu rõ ràng.
320
- - Giữ số liệu/nhân vật/thời điểm quan trọng nếu có.
321
-
322
- Nội dung bài:
323
- {art['raw'][:14000]}"""
324
- text = await base.qwen_generate(prompt, image_url=art.get('image') or None, max_tokens=1500)
325
- text = _postprocess_ai_text(text, max_units=20)
326
- src = [art['source']]
327
- if 'Nguồn tham khảo:' not in text:
328
- text += "\n\n" + _source_line(src)
329
- post = base.make_post(art['title'], text, art.get('image') or base.pollinations_image_url(art['title']), art.get('url') or '', 'topic_article', sources=src)
330
-
331
- # Generate slides for this post so they persist after page reload
332
- try:
333
- page_data = _scrape_article_images(art.get('url', ''))
334
- if page_data and page_data.get('paragraphs'):
335
- key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12)
336
- if key_points:
337
- relevant_imgs = page_data.get('images', [])
338
- if not relevant_imgs and page_data.get('og_img'):
339
- relevant_imgs = [page_data['og_img']]
340
- slides = []
341
- for i, point in enumerate(key_points):
342
- img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
343
- slides.append({'text': point, 'image': img, 'index': i + 1})
344
- post['slides'] = slides
345
- except Exception:
346
- pass
347
-
348
- new_posts.append(post)
349
- posts = new_posts + posts
350
- base._save_ai_wall(posts)
351
- return JSONResponse({'post': new_posts[0], 'posts': new_posts, 'count': len(new_posts)})
352
-
353
-
354
- @app.post('/api/url_wall')
355
- async def compat_url_wall(request: Request):
356
- body = await request.json()
357
- url = base._clean_text(body.get('url', ''))
358
- if not url.startswith('http'):
359
- return JSONResponse({'error': 'missing url'}, status_code=400)
360
- try:
361
- data = base.scrape_any_url(url)
362
- except Exception as e:
363
- return JSONResponse({'error': 'Không scrape được URL: ' + str(e)[:180]}, status_code=422)
364
- raw = (data.get('summary', '') + '\n' + data.get('text', '')).strip()
365
- if len(raw) < 120:
366
- return JSONResponse({'error': 'URL không có đủ nội dung để tóm tắt'}, status_code=422)
367
- prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
368
- text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=1500)
369
- text = _postprocess_ai_text(text, max_units=20)
370
- src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
371
- if 'Nguồn tham khảo:' not in text:
372
- text += "\n\n" + _source_line(src)
373
- post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'url', sources=src)
374
-
375
- # Generate slides so they persist after page reload
376
- slides = []
377
- try:
378
- page_data = _scrape_article_images(url)
379
- if page_data and page_data.get('paragraphs'):
380
- key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12)
381
- if key_points:
382
- relevant_imgs = page_data.get('images', [])
383
- if not relevant_imgs and page_data.get('og_img'):
384
- relevant_imgs = [page_data['og_img']]
385
- for i, point in enumerate(key_points):
386
- img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
387
- slides.append({'text': point, 'image': img, 'index': i + 1})
388
- except Exception:
389
- pass
390
- post['slides'] = slides
391
-
392
- posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
393
- return JSONResponse({'post': post, 'slides': slides})
394
-
395
-
396
- def _is_relevant_image(img_url, title, text):
397
- """Check if an image is relevant to the article content."""
398
- if not img_url:
399
- return False
400
- skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif',
401
- 'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite',
402
- 'advertisement', 'ad-banner', 'sponsored', 'banner-ads']
403
- img_lower = img_url.lower()
404
- for p in skip_patterns:
405
- if p in img_lower:
406
- return False
407
- if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']):
408
- return False
409
- return True
410
-
411
-
412
- def _filter_relevant_images(images, title, text, max_images=8):
413
- """Filter and rank images by relevance to article content."""
414
- if not images:
415
- return []
416
- seen = set()
417
- relevant = []
418
- for img in images:
419
- if img in seen:
420
- continue
421
- seen.add(img)
422
- if _is_relevant_image(img, title, text):
423
- relevant.append(img)
424
- return relevant[:max_images]
425
-
426
-
427
- def _extract_key_points_for_slides(paragraphs, max_points=12):
428
- """Extract key points from paragraphs for slides - extracts ALL sentences, not just first one."""
429
- points = []
430
- for p in paragraphs:
431
- if len(points) >= max_points:
432
- break
433
- p = _clean(p)
434
- if not p:
435
- continue
436
- # Split paragraph into sentences using Vietnamese + English punctuation - GET ALL SENTENCES
437
- sentences = re.split(r'(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])', p)
438
- sentences = [s.strip() for s in sentences if s.strip()]
439
-
440
- for sentence in sentences:
441
- if len(points) >= max_points:
442
- break
443
- sentence = _clean(sentence)
444
- if len(sentence) < 30:
445
- continue
446
- if any(sentence[:60] in existing for existing in points):
447
- continue
448
- if not sentence.endswith(('.', '!', '?')):
449
- sentence = sentence + '.'
450
- points.append(sentence)
451
- return points
452
-
453
-
454
- def _scrape_article_images(url):
455
- """Scrape article page and return only relevant images."""
456
- try:
457
- headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
458
- "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"}
459
- r = requests.get(url, headers=headers, timeout=15, allow_redirects=True)
460
- r.encoding = 'utf-8'
461
- soup = BeautifulSoup(r.text, 'lxml')
462
- for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
463
- tag.decompose()
464
- h1 = soup.find('h1')
465
- ogt = soup.find('meta', property='og:title')
466
- title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '')
467
- ogi = soup.find('meta', property='og:image')
468
- og_img = ogi.get('content', '') if ogi else ''
469
- if og_img and og_img.startswith('//'):
470
- og_img = 'https:' + og_img
471
- block = None
472
- for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
473
- el = soup.select_one(sel)
474
- if el and len(el.find_all('p')) >= 2:
475
- block = el
476
- break
477
- if not block:
478
- block = soup.body or soup
479
- paragraphs = []
480
- all_images = []
481
- seen_imgs = set()
482
- if og_img and og_img not in seen_imgs:
483
- all_images.append(og_img)
484
- seen_imgs.add(og_img)
485
- for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
486
- if el.name == 'p':
487
- t = _clean(el.get_text(strip=True))
488
- if t and len(t) > 40:
489
- paragraphs.append(t)
490
- elif el.name in ('figure', 'img'):
491
- im = el if el.name == 'img' else el.find('img')
492
- if im:
493
- src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
494
- if src and 'base64' not in src:
495
- if src.startswith('//'):
496
- src = 'https:' + src
497
- if src not in seen_imgs:
498
- all_images.append(src)
499
- seen_imgs.add(src)
500
- relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5]))
501
- return {'title': _clean(title), 'paragraphs': paragraphs, 'images': relevant_images, 'og_img': og_img}
502
- except Exception:
503
- return None
504
-
505
-
506
- @app.post('/api/rewrite_share')
507
- async def compat_rewrite_share(request: Request):
508
- body = await request.json()
509
- url = base._clean_text(body.get('url', ''))
510
- if not url.startswith('http'):
511
- return JSONResponse({'error': 'missing url'}, status_code=400)
512
- try:
513
- data = base.scrape_any_url(url)
514
- except Exception as e:
515
- return JSONResponse({'error': 'Không đọc được bài viết: ' + str(e)[:180]}, status_code=422)
516
- raw = (data.get('summary', '') + '\n' + data.get('text', '')).strip()
517
- if len(raw) < 120:
518
- return JSONResponse({'error': 'Bài viết không đủ nội dung để tóm tắt'}, status_code=422)
519
- prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
520
- text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=1500)
521
- text = _postprocess_ai_text(text, max_units=20)
522
- src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
523
- if 'Nguồn tham khảo:' not in text:
524
- text += "\n\n" + _source_line(src)
525
- post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'summary', sources=src)
526
-
527
- # Generate slides with relevant images only
528
- slides = []
529
- page_data = _scrape_article_images(url)
530
- if page_data and page_data.get('paragraphs'):
531
- key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12)
532
- if key_points:
533
- relevant_imgs = page_data.get('images', [])
534
- if not relevant_imgs and page_data.get('og_img'):
535
- relevant_imgs = [page_data['og_img']]
536
- for i, point in enumerate(key_points):
537
- img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
538
- slides.append({'text': point, 'image': img, 'index': i + 1})
539
-
540
- # FIX: Save slides into post so they persist after page reload
541
- post['slides'] = slides
542
- posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
543
-
544
- return JSONResponse({'post': post, 'slides': slides})
545
-
546
-
547
- def _emotion_script(text, emotion):
548
- """Prepend emotion-appropriate prefix to text based on emotion type.
549
-
550
- NOTE: Prefix is NOT added to avoid cluttering Short AI speech.
551
- The emotion is still used for voice selection but content is read cleanly.
552
- """
553
- text = _clean(text)
554
- # REMOVED: No prefix added to keep content clean and natural
555
- return text
556
-
557
-
558
- def _tts_script_smart(post, emotion):
559
- raw = base._short_script(post) if hasattr(base, '_short_script') else _clean(post.get('text', '') or post.get('title', ''))
560
- raw = re.sub(r"^[•\-\*]\s*", "", raw, flags=re.M)
561
- raw = re.sub(r"\s*\n\s*", ". ", raw)
562
- raw = re.sub(r"([\.\!\?])\s*", r"\1\n", raw)
563
- raw = re.sub(r"\n{2,}", "\n", raw).strip()
564
- # REMOVED: _emotion_script call - read content cleanly without prefix
565
- # INCREASED to 3000 to read full content of all bullet points
566
- if len(raw) > 3000:
567
- raw = raw[:3000]
568
- cut = max(raw.rfind("."), raw.rfind("!"), raw.rfind("?"))
569
- if cut > 700:
570
- raw = raw[:cut + 1]
571
- return raw
572
-
573
-
574
- def _split_subtitle_sentences(script):
575
- parts = []
576
- for line in script.splitlines():
577
- line = _clean(line)
578
- if not line:
579
- continue
580
- for s in re.split(r"(?<=[\.\!\?])\s+", line):
581
- s = _clean(s)
582
- if 8 <= len(s) <= 140:
583
- parts.append(s)
584
- return parts[:12]
585
-
586
-
587
- def _srt_time(sec):
588
- ms = int((sec - int(sec)) * 1000)
589
- sec = int(sec)
590
- h = sec // 3600
591
- m = (sec % 3600) // 60
592
- s = sec % 60
593
- return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
594
-
595
-
596
- def _write_srt(script, path, total_duration=30):
597
- subs = _split_subtitle_sentences(script)
598
- if not subs:
599
- subs = [script[:120]]
600
- dur = max(2.2, min(5.0, total_duration / max(1, len(subs))))
601
- cur = 0.3
602
- with open(path, 'w', encoding='utf-8') as f:
603
- for i, s in enumerate(subs, 1):
604
- start = cur
605
- end = cur + dur
606
- cur = end + 0.15
607
- f.write(f"{i}\n{_srt_time(start)} --> {_srt_time(end)}\n{s}\n\n")
608
-
609
-
610
- def _wrap_text_px(draw, text, font, max_width, max_lines):
611
- words = _clean(text).split()
612
- lines, cur = [], ""
613
- for w in words:
614
- test = (cur + " " + w).strip()
615
- try:
616
- width = draw.textbbox((0, 0), test, font=font)[2]
617
- except Exception:
618
- width = len(test) * 20
619
- if width <= max_width:
620
- cur = test
621
- else:
622
- if cur:
623
- lines.append(cur)
624
- cur = w
625
- if len(lines) >= max_lines:
626
- break
627
- if cur and len(lines) < max_lines:
628
- lines.append(cur)
629
- return lines
630
-
631
-
632
- def _make_short_frame_full(post, img_path, out_path):
633
- if Image is None:
634
- return base._make_short_frame(post, img_path, out_path)
635
- W, H = 1080, 1920
636
- bg = Image.new("RGB", (W, H), (14, 14, 14))
637
- try:
638
- im = Image.open(img_path).convert("RGB")
639
- target = (1080, 760)
640
- im_ratio = im.width / im.height
641
- target_ratio = target[0] / target[1]
642
- if im_ratio > target_ratio:
643
- new_h = target[1]
644
- new_w = int(new_h * im_ratio)
645
- else:
646
- new_w = target[0]
647
- new_h = int(new_w / im_ratio)
648
- im = im.resize((new_w, new_h))
649
- left = (new_w - target[0]) // 2
650
- top = (new_h - target[1]) // 2
651
- im = im.crop((left, top, left + target[0], top + target[1]))
652
- bg.paste(im, (0, 0))
653
- except Exception:
654
- pass
655
- draw = ImageDraw.Draw(bg)
656
- try:
657
- font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 54)
658
- font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 38)
659
- font_label = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 30)
660
- except Exception:
661
- font_title = font_body = font_label = None
662
- draw.rectangle((0, 720, W, H), fill=(14, 14, 14))
663
- margin = 48
664
- maxw = W - margin * 2
665
- draw.text((margin, 770), "VNEWS · Tường AI", fill=(92, 184, 122), font=font_label)
666
- y = 830
667
- for ln in _wrap_text_px(draw, post.get("title", ""), font_title, maxw, 4):
668
- draw.text((margin, y), ln, fill=(255, 255, 255), font=font_title)
669
- y += 66
670
- y += 18
671
- text = post.get("text", "")
672
- text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip()
673
- body_lines = _wrap_text_px(draw, text, font_body, maxw, 14)
674
- for ln in body_lines:
675
- draw.text((margin, y), ln, fill=(220, 220, 220), font=font_body)
676
- y += 50
677
- if y > 1640:
678
- break
679
- bg.save(out_path, quality=92)
680
-
681
-
682
-
683
-
684
- def _summary_segments_from_post(post, max_segments=25):
685
- raw = _clean(post.get('text') or post.get('title') or '')
686
- raw = re.sub(r'^Bản tin AI viết lại:\s*', '', raw, flags=re.I)
687
- raw = re.sub(r'Nguồn tham khảo:.*$', '', raw, flags=re.I|re.S).strip()
688
- lines=[]
689
- for ln in raw.splitlines():
690
- ln=_clean(re.sub(r'^[•\-\*\d\.\)\s]+','',ln))
691
- if not ln: continue
692
- low=ln.lower()
693
- if low.startswith(('điểm chính','tiêu đề','sapo','nguồn tham khảo')): continue
694
- if len(ln)>=18: lines.append(ln)
695
- if len(lines)<3:
696
- lines=[]
697
- for s in re.split(r'(?<=[\.\!\?])\s+', raw):
698
- s=_clean(s)
699
- if len(s)>=25: lines.append(s)
700
- segs=_dedupe_units(lines, max_units=max_segments)
701
- return segs[:max_segments] if segs else [post.get('title','Bản tin VNEWS')]
702
-
703
-
704
- def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='neutral'):
705
- if Image is None:
706
- return _make_short_frame_full(post, img_path, out_path)
707
- W,H=1080,1920
708
- bg=Image.new('RGB',(W,H),(10,10,10))
709
- try:
710
- im=Image.open(img_path).convert('RGB')
711
- ratio=im.width/max(1,im.height); target=W/H
712
- if ratio>target:
713
- nh=H; nw=int(nh*ratio)
714
- else:
715
- nw=W; nh=int(nw/ratio)
716
- cover=im.resize((nw,nh)); left=(nw-W)//2; top=(nh-H)//2
717
- cover=cover.crop((left,top,left+W,top+H))
718
- bg.paste(cover,(0,0))
719
- bg=Image.blend(bg, Image.new('RGB',(W,H),(0,0,0)), 0.50)
720
- hero_h=720; target=W/hero_h
721
- if ratio>target:
722
- nh=hero_h; nw=int(nh*ratio)
723
- else:
724
- nw=W; nh=int(nw/ratio)
725
- hero=im.resize((nw,nh)); left=(nw-W)//2; top=(nh-hero_h)//2
726
- hero=hero.crop((left,top,left+W,top+hero_h))
727
- bg.paste(hero,(0,0))
728
- except Exception:
729
- pass
730
- draw=ImageDraw.Draw(bg)
731
- try:
732
- font_brand=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',34)
733
- font_small=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',28)
734
- font_seg=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',58)
735
- font_title=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',34)
736
- except Exception:
737
- font_brand=font_small=font_seg=font_title=None
738
- draw.rectangle((0,680,W,H), fill=(12,12,12))
739
- dot_x=48; dot_y=742
740
- for i in range(total):
741
- fill=(92,184,122) if i==idx else (70,70,70)
742
- draw.rounded_rectangle((dot_x+i*38,dot_y,dot_x+i*38+24,dot_y+10), radius=5, fill=fill)
743
- draw.text((48,780),'VNEWS AI SHORT',fill=(110,231,143),font=font_brand)
744
- draw.rounded_rectangle((48,834,260,880), radius=20, fill=(28,70,45))
745
- draw.text((66,842),f'Đoạn {idx+1}/{total}',fill=(235,235,235),font=font_small)
746
- y=940; maxw=W-96
747
- # INCREASED from 12 to 18 for full content display - each key point can span multiple lines
748
- for ln in _wrap_text_px(draw, segment, font_seg, maxw, 18):
749
- draw.text((48,y),ln,fill=(255,255,255),font=font_seg)
750
- y+=74
751
- if y>1500: break
752
- y2=1640
753
- draw.line((48,y2-22,W-48,y2-22),fill=(70,70,70),width=2)
754
- for ln in _wrap_text_px(draw, post.get('title',''), font_title, maxw, 3):
755
- draw.text((48,y2),ln,fill=(220,220,220),font=font_title)
756
- y2+=46
757
- bg.save(out_path, quality=92)
758
-
759
-
760
- def _estimate_audio_duration(path, fallback=15.0):
761
- """Estimate audio duration with 15s minimum per segment for complete bullet reading."""
762
- try:
763
- pr=subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:no_key=1',path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
764
- return max(12.0, float((pr.stdout or b'').decode().strip() or fallback))
765
- except Exception:
766
- return fallback
767
-
768
-
769
- @app.post('/api/ai/short/{post_id}')
770
- async def patched_ai_short(post_id: str, request: Request):
771
- try:
772
- body = await request.json()
773
- except Exception:
774
- body = {}
775
- voice = str(body.get('voice', 'nu')).strip().lower()
776
- emotion = str(body.get('emotion', 'neutral')).strip().lower()
777
- speed = float(body.get('speed', 1.0) or 1.0)
778
- speed = max(0.85, min(1.35, speed))
779
-
780
- posts = base._load_ai_wall()
781
- post = next((p for p in posts if str(p.get('id')) == str(post_id)), None)
782
- if not post:
783
- return JSONResponse({'error': 'post not found'}, status_code=404)
784
-
785
- segments = _summary_segments_from_post(post, max_segments=25)
786
- seg_hash = hashlib.md5(('|'.join(segments)+voice+emotion+str(speed)).encode('utf-8')).hexdigest()[:8]
787
- os.makedirs(base.SHORTS_DIR, exist_ok=True)
788
- suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_{seg_hash}_scenes_nosub"
789
- out_mp4 = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix) + '.mp4')
790
- if os.path.exists(out_mp4):
791
- post['video'] = '/api/ai/short-file/' + post_id + suffix
792
- post['short_voice'] = voice
793
- post['short_emotion'] = emotion
794
- post['short_speed'] = speed
795
- post['short_segments'] = segments
796
- post['short_subtitles'] = False
797
- base._save_ai_wall(posts)
798
- return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': False, 'segments': segments})
799
- if base.gTTS is None:
800
- return JSONResponse({'error': 'gTTS chưa sẵn sàng'}, status_code=503)
801
-
802
- work = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix))
803
- os.makedirs(work, exist_ok=True)
804
- img = os.path.join(work, 'image.jpg')
805
- try:
806
- base._download_image(post.get('img'), post.get('title', 'AI news'), img)
807
- edge_voice = {
808
- # Vietnamese
809
- 'vi-vn-hoaimyneural': 'vi-VN-HoaiMyNeural',
810
- 'vi-vn-namminhneural': 'vi-VN-NamMinhNeural',
811
- 'hoaimy': 'vi-VN-HoaiMyNeural',
812
- 'namminh': 'vi-VN-NamMinhNeural',
813
- 'nam': 'vi-VN-NamMinhNeural',
814
- 'male': 'vi-VN-NamMinhNeural',
815
- 'nu': 'vi-VN-HoaiMyNeural',
816
- 'female': 'vi-VN-HoaiMyNeural',
817
- 'mien-nam': 'vi-VN-HoaiMyNeural',
818
- # English - Multilingual
819
- 'en-us-andrewmultilingualneural': 'en-US-AndrewMultilingualNeural',
820
- 'en-au-williammultilingualneural': 'en-AU-WilliamMultilingualNeural',
821
- 'andrew': 'en-US-AndrewMultilingualNeural',
822
- 'en_andrew': 'en-US-AndrewMultilingualNeural',
823
- 'jenny': 'en-US-AndrewMultilingualNeural',
824
- 'en_jenny': 'en-US-AndrewMultilingualNeural',
825
- # Portuguese - Multilingual (ONLY Thalita)
826
- 'pt-br-thalitamultilingualneural': 'pt-BR-ThalitaMultilingualNeural',
827
- 'thalita': 'pt-BR-ThalitaMultilingualNeural',
828
- 'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
829
- 'pt_br_thalita': 'pt-BR-ThalitaMultilingualNeural',
830
- 'pt': 'pt-BR-ThalitaMultilingualNeural',
831
- 'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
832
- # French - Multilingual
833
- 'fr-fr-viviennemultilingualneural': 'fr-FR-VivienneMultilingualNeural',
834
- 'fr-fr-remymultilingualneural': 'fr-FR-RemyMultilingualNeural',
835
- 'denise': 'fr-FR-VivienneMultilingualNeural',
836
- 'fr': 'fr-FR-VivienneMultilingualNeural',
837
- 'fr_denise': 'fr-FR-VivienneMultilingualNeural',
838
- # German - Multilingual
839
- 'de-de-seraphinamultilingualneural': 'de-DE-SeraphinaMultilingualNeural',
840
- 'de-de-florianmultilingualneural': 'de-DE-FlorianMultilingualNeural',
841
- 'katja': 'de-DE-SeraphinaMultilingualNeural',
842
- 'de': 'de-DE-SeraphinaMultilingualNeural',
843
- 'de_katja': 'de-DE-SeraphinaMultilingualNeural',
844
- # Korean - Multilingual (Hyunsu, NOT SunHee)
845
- 'ko-kr-hyusumultilingualneural': 'ko-KR-HyunsuMultilingualNeural',
846
- 'ko-kr-hyunsuneural': 'ko-KR-HyunsuMultilingualNeural',
847
- 'sunhee': 'ko-KR-HyunsuMultilingualNeural',
848
- 'ko': 'ko-KR-HyunsuMultilingualNeural',
849
- 'ko_sunhee': 'ko-KR-HyunsuMultilingualNeural',
850
- # Italian - Multilingual
851
- 'it-it-giuseppemultilingualneural': 'it-IT-GiuseppeMultilingualNeural',
852
- # Spanish (keep for backward compat)
853
- 'ela': 'en-US-AndrewMultilingualNeural',
854
- 'es_ela': 'en-US-AndrewMultilingualNeural',
855
- 'es': 'en-US-AndrewMultilingualNeural',
856
- 'es_carlos': 'en-US-AndrewMultilingualNeural',
857
- # Japanese (keep for backward compat)
858
- 'nanami': 'en-US-AndrewMultilingualNeural',
859
- 'ja': 'en-US-AndrewMultilingualNeural',
860
- 'ja_nanami': 'en-US-AndrewMultilingualNeural',
861
- # Chinese (keep for backward compat)
862
- 'xiaochen': 'en-US-AndrewMultilingualNeural',
863
- 'zh': 'en-US-AndrewMultilingualNeural',
864
- 'zh_xiaochen': 'en-US-AndrewMultilingualNeural',
865
- }.get(voice, 'vi-VN-HoaiMyNeural')
866
- part_files=[]
867
- for idx, seg in enumerate(segments):
868
- frame=os.path.join(work,f'frame_{idx:02d}.jpg')
869
- aud=os.path.join(work,f'voice_{idx:02d}.mp3')
870
- aud_fast=os.path.join(work,f'voice_{idx:02d}_fast.mp3')
871
- part=os.path.join(work,f'part_{idx:02d}.mp4')
872
- _make_scene_frame(post, seg, idx, len(segments), img, frame, emotion=emotion)
873
- spoken=_emotion_script(seg, emotion)
874
- try:
875
- subprocess.run(['python','-m','edge_tts','--voice',edge_voice,'--text',spoken,'--write-media',aud], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
876
- except Exception:
877
- tld='com.vn' if voice in ('nu','female','mien-nam','hoaimy') else 'com'
878
- try:
879
- base.gTTS(spoken, lang='vi', tld=tld, slow=False).save(aud)
880
- except TypeError:
881
- base.gTTS(spoken, lang='vi', slow=False).save(aud)
882
- subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
883
- dur=_estimate_audio_duration(aud_fast, fallback=15.0)+0.35
884
- subprocess.run(['ffmpeg','-y','-loop','1','-t',str(dur),'-i',frame,'-i',aud_fast,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k',part], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=150)
885
- part_files.append(part)
886
- concat=os.path.join(work,'concat.txt')
887
- with open(concat,'w',encoding='utf-8') as f:
888
- for p in part_files:
889
- f.write("file '" + p.replace("'", "'\\''") + "'\n")
890
- subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',concat,'-c','copy',out_mp4], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
891
- post['video'] = '/api/ai/short-file/' + post_id + suffix
892
- post['short_voice'] = voice
893
- post['short_emotion'] = emotion
894
- post['short_speed'] = speed
895
- post['short_segments'] = segments
896
- post['short_subtitles'] = False
897
- base._save_ai_wall(posts)
898
- return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': False, 'segments': segments})
899
- except Exception as e:
900
- return JSONResponse({'error': 'Không tạo được shorts: ' + str(e)[:220]}, status_code=500)
901
-
902
-
903
- @app.get('/api/ai/short-file/{file_id}')
904
- def patched_ai_short_file(file_id: str):
905
- path = os.path.join(base.SHORTS_DIR, base._safe_name(file_id) + '.mp4')
906
- if not os.path.exists(path):
907
- return JSONResponse({'error': 'not found'}, status_code=404)
908
- return FileResponse(path, media_type='video/mp4', filename=f'vnews-ai-{file_id}.mp4')
909
-
910
-
911
- @app.get('/api/ai_shorts')
912
- def api_ai_shorts():
913
- posts = [p for p in base._load_ai_wall() if p.get('video')]
914
- return JSONResponse({'posts': posts[:80]})
915
-
916
-
917
- app.router.routes = [r for r in app.router.routes if not (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set()))]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ai_runtime.py CHANGED
@@ -24,8 +24,7 @@ def _domain(url):
24
 
25
 
26
  def _strip_bullet_prefix(s):
27
- # remove bullets, numbered prefixes, leading dots commonly produced by AI summaries
28
- return clean(re.sub(r'^[\s•\-\*·▪▫●○\d\.\)\(]+', '', s or ''))
29
 
30
 
31
  def source_line(sources):
@@ -60,7 +59,6 @@ def _collect_all_images(data):
60
 
61
  def _scrape_url_with_images(url):
62
  data=base.scrape_any_url(url)
63
- # extra pass: collect every useful image from original HTML, because some readers only return one image
64
  try:
65
  import requests
66
  from bs4 import BeautifulSoup
@@ -71,7 +69,6 @@ def _scrape_url_with_images(url):
71
  src=im.get('data-src') or im.get('data-original') or im.get('data-lazy-src') or im.get('src') or ''
72
  if src.startswith('//'):src='https:'+src
73
  if src and 'base64' not in src and src not in extra:
74
- # skip tiny icons/logos as much as possible
75
  low=src.lower()
76
  if any(x in low for x in ['logo','icon','avatar','sprite']):
77
  continue
@@ -113,11 +110,9 @@ def postprocess(text):
113
  out=old._postprocess_ai_text(text, max_units=7)
114
  else:
115
  out=clean(text)
116
- # keep wall text readable, but ensure short generation later won't show bullets
117
  return out
118
 
119
 
120
- # Remove old routes we must override.
121
  _PATCH={('/api/topic_post','POST'),('/api/url_wall','POST'),('/api/rewrite_share','POST'),('/api/ai/url','POST'),('/api/ai/short/{post_id}','POST'),('/api/ai/short-file/{file_id}','GET'),('/','GET')}
122
  app.router.routes=[r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in _PATCH)]
123
 
@@ -130,19 +125,7 @@ async def url_wall_only(request:Request):
130
  except Exception as e:return JSONResponse({'error':'Không scrape được URL: '+str(e)[:180]},status_code=422)
131
  raw=(data.get('summary','')+'\n'+data.get('text','')).strip()
132
  if len(raw)<120:return JSONResponse({'error':'URL không có đủ nội dung để tóm tắt'},status_code=422)
133
- prompt=f"""Tóm tắt bài viết nguồn dưới đây để đăng lên Tường AI VNEWS.
134
-
135
- Yêu cầu bắt buộc:
136
- - Chỉ tóm tắt nội dung chính, không viết lại toàn bộ bài.
137
- - Ngắn gọn, cụ thể, dễ hiểu.
138
- - Không lặp lại ý và không thêm chi tiết ngoài nguồn.
139
- - Tối đa 5 ý chính hoặc 2 đoạn ngắn.
140
- - Tránh dùng dấu đầu dòng nếu không thật cần thiết.
141
-
142
- Tiêu đề gốc: {data.get('title','')}
143
- Nguồn: {data.get('via','') or _domain(url)}
144
- Nội dung gốc:
145
- {raw[:16000]}"""
146
  text=await base.qwen_generate(prompt,image_url=(data.get('image') or None),max_tokens=900)
147
  if not text:text=old._fallback_summary_from_prompt(prompt,max_units=5) if hasattr(old,'_fallback_summary_from_prompt') else raw[:900]
148
  text=postprocess(text)
@@ -168,190 +151,3 @@ async def ai_url_compat(request:Request):
168
  @app.post('/api/topic_post')
169
  async def topic_disabled(request:Request):
170
  return JSONResponse({'error':'Đã tắt tạo bài theo chủ đề. Vui lòng dán URL bài viết để AI tóm tắt.'},status_code=410)
171
-
172
-
173
- def split_segments(post,max_segments=8):
174
- text=clean(post.get('text') or post.get('title') or '')
175
- text=re.sub(r'Nguồn tham khảo:.*$','',text,flags=re.I|re.S).strip()
176
- lines=[]
177
- for ln in text.splitlines():
178
- ln=_strip_bullet_prefix(ln)
179
- if len(ln)>=18:lines.append(ln)
180
- if len(lines)<2:
181
- lines=[_strip_bullet_prefix(s) for s in re.split(r'(?<=[\.\!\?])\s+',text) if len(_strip_bullet_prefix(s))>=25]
182
- segs=[];cur=''
183
- for ln in lines:
184
- ln=_strip_bullet_prefix(ln)
185
- if not ln:continue
186
- if len(cur)+len(ln)<180:cur=(cur+' '+ln).strip()
187
- else:
188
- if cur:segs.append(_strip_bullet_prefix(cur))
189
- cur=ln
190
- if cur:segs.append(_strip_bullet_prefix(cur))
191
- return segs[:max_segments] or [_strip_bullet_prefix(post.get('title','VNEWS'))]
192
-
193
-
194
- def wrap_text(draw,text,font,maxw,max_lines):
195
- words=clean(text).split();lines=[];cur=''
196
- for w in words:
197
- test=(cur+' '+w).strip()
198
- try:width=draw.textbbox((0,0),test,font=font)[2]
199
- except Exception:width=len(test)*20
200
- if width<=maxw:cur=test
201
- else:
202
- if cur:lines.append(cur)
203
- cur=w
204
- if len(lines)>=max_lines:break
205
- if cur and len(lines)<max_lines:lines.append(cur)
206
- return lines
207
-
208
-
209
- def _draw_center(draw, lines, font, y, fill, W, line_h):
210
- for ln in lines:
211
- try:
212
- box=draw.textbbox((0,0),ln,font=font);tw=box[2]-box[0]
213
- except Exception:
214
- tw=len(ln)*24
215
- x=max(30,(W-tw)//2)
216
- draw.text((x,y),ln,fill=fill,font=font)
217
- y+=line_h
218
- return y
219
-
220
-
221
- def make_frame(post,seg,idx,total,img_path,out_path):
222
- if Image is None:raise RuntimeError('Pillow not ready')
223
- W,H=1080,1920;bg=Image.new('RGB',(W,H),(12,12,12))
224
- hero_h=760
225
- try:
226
- im=Image.open(img_path).convert('RGB');ratio=im.width/max(1,im.height)
227
- target=(W,hero_h);tr=target[0]/target[1]
228
- if ratio>tr:nh=target[1];nw=int(nh*ratio)
229
- else:nw=target[0];nh=int(nw/ratio)
230
- im=im.resize((nw,nh));left=(nw-target[0])//2;top=(nh-target[1])//2
231
- bg.paste(im.crop((left,top,left+target[0],top+target[1])),(0,0))
232
- except Exception:pass
233
- draw=ImageDraw.Draw(bg)
234
- try:
235
- fb=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',58)
236
- ft=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',38)
237
- fs=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',30)
238
- fsmall=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',28)
239
- except Exception:fb=ft=fs=fsmall=None
240
- # source badge on top image corner
241
- badge='Nguồn: '+_source_badge(post)
242
- try:
243
- b=draw.textbbox((0,0),badge,font=fsmall);bw=b[2]-b[0];bh=b[3]-b[1]
244
- except Exception:
245
- bw=len(badge)*16;bh=34
246
- bx=W-bw-42;by=24
247
- draw.rounded_rectangle((bx-16,by-8,W-24,by+bh+14),radius=18,fill=(0,0,0,170))
248
- draw.text((bx,by),badge,fill=(255,255,255),font=fsmall)
249
- # bottom text area
250
- draw.rectangle((0,hero_h-20,W,H),fill=(12,12,12))
251
- # progress bars centered
252
- total_w=total*38-14;start=(W-total_w)//2
253
- for i in range(total):
254
- fill=(92,184,122) if i==idx else (70,70,70)
255
- draw.rounded_rectangle((start+i*38,820,start+i*38+24,832),radius=6,fill=fill)
256
- brand='VNEWS AI SHORT'
257
- try:
258
- bb=draw.textbbox((0,0),brand,font=ft);tx=(W-(bb[2]-bb[0]))//2
259
- except Exception:tx=360
260
- draw.text((tx,870),brand,fill=(110,231,143),font=ft)
261
- clean_seg=_strip_bullet_prefix(seg)
262
- lines=wrap_text(draw,clean_seg,fb,W-120,8)
263
- block_h=len(lines)*74
264
- y=max(980, 1250-block_h//2)
265
- _draw_center(draw,lines,fb,y,(255,255,255),W,74)
266
- # small title centered near bottom
267
- title_lines=wrap_text(draw,_strip_bullet_prefix(post.get('title','')),fs,W-120,3)
268
- y2=1640
269
- draw.line((80,y2-26,W-80,y2-26),fill=(70,70,70),width=2)
270
- _draw_center(draw,title_lines,fs,y2,(220,220,220),W,42)
271
- bg.save(out_path,quality=92)
272
-
273
-
274
- def make_tts(text,voice,out_path):
275
- v={'nam':'vi-VN-NamMinhNeural','male':'vi-VN-NamMinhNeural','nu':'vi-VN-HoaiMyNeural','female':'vi-VN-HoaiMyNeural','mien-nam':'vi-VN-HoaiMyNeural'}.get(voice,'vi-VN-HoaiMyNeural')
276
- text=_strip_bullet_prefix(text)
277
- try:subprocess.run(['python','-m','edge_tts','--voice',v,'--text',text,'--write-media',out_path],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=160)
278
- except Exception:
279
- tld='com.vn' if voice in ('nu','female','mien-nam') else 'com'
280
- try:base.gTTS(text,lang='vi',tld=tld,slow=False).save(out_path)
281
- except TypeError:base.gTTS(text,lang='vi',slow=False).save(out_path)
282
-
283
-
284
- @app.post('/api/ai/short/{post_id}')
285
- async def short_segments(post_id:str,request:Request):
286
- try:body=await request.json()
287
- except Exception:body={}
288
- voice=str(body.get('voice','nu')).lower().strip();emotion=str(body.get('emotion','neutral')).lower().strip();speed=max(0.85,min(1.35,float(body.get('speed',1.2) or 1.2)))
289
- posts=base._load_ai_wall();post=next((p for p in posts if str(p.get('id'))==str(post_id)),None)
290
- if not post:return JSONResponse({'error':'post not found'},status_code=404)
291
- segs=split_segments(post,8)
292
- os.makedirs(base.SHORTS_DIR,exist_ok=True);suffix=f'_{voice}_{emotion}_{str(speed).replace(".","p")}_centered_source_nobullet'
293
- out=os.path.join(base.SHORTS_DIR,base._safe_name(post_id+suffix)+'.mp4')
294
- if os.path.exists(out):post['video']='/api/ai/short-file/'+post_id+suffix;base._save_ai_wall(posts);return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False})
295
- work=os.path.join(base.SHORTS_DIR,base._safe_name(post_id+suffix));os.makedirs(work,exist_ok=True)
296
- img=os.path.join(work,'image.jpg');base._download_image(post.get('img'),post.get('title','AI news'),img)
297
- clips=[]
298
- try:
299
- for i,seg in enumerate(segs):
300
- frame=os.path.join(work,f'f{i}.jpg');aud=os.path.join(work,f'a{i}.mp3');aud2=os.path.join(work,f'a{i}_fast.mp3');clip=os.path.join(work,f'c{i}.mp4')
301
- seg=_strip_bullet_prefix(seg)
302
- make_frame(post,seg,i,len(segs),img,frame)
303
- prefix={'urgent':'Tin nhanh.','warm':'Câu chuyện đáng chú ý.','serious':'Bản tin nghiêm túc.','energetic':'Cập nhật nổi bật.'}.get(emotion,'')
304
- spoken=(prefix+' '+seg).strip() if i==0 and prefix else seg
305
- make_tts(spoken,voice,aud)
306
- subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud2],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=120)
307
- subprocess.run(['ffmpeg','-y','-loop','1','-i',frame,'-i',aud2,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k','-vf','scale=1080:1920',clip],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=180)
308
- clips.append(clip)
309
- lf=os.path.join(work,'list.txt')
310
- with open(lf,'w',encoding='utf-8') as f:
311
- for c in clips:f.write("file '{}".format(c.replace("'","'\\''"))+"'\n")
312
- subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',lf,'-c','copy',out],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=240)
313
- 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)
314
- return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False})
315
- except Exception as e:return JSONResponse({'error':'Không tạo được shorts: '+str(e)[:200]},status_code=500)
316
-
317
-
318
- @app.get('/api/ai/short-file/{file_id}')
319
- def short_file(file_id:str):
320
- path=os.path.join(base.SHORTS_DIR,base._safe_name(file_id)+'.mp4')
321
- if not os.path.exists(path):return JSONResponse({'error':'not found'},status_code=404)
322
- return FileResponse(path,media_type='video/mp4',filename=f'vnews-ai-{file_id}.mp4')
323
-
324
-
325
- # Rebuild / with old UI injection plus final UI overrides.
326
- app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
327
- @app.get('/')
328
- async def index_runtime():
329
- with open('/app/static/index.html','r',encoding='utf-8') as f:html=f.read()
330
- inject=getattr(old,'PATCH_INJECT','')+r'''
331
- <style>
332
- /* Hide old topic UI, keep URL input only */
333
- #ai-topic-input{display:none!important}
334
- #ai-topic-input,*[onclick*="createTopicPost"]{display:none!important}
335
- .ai-topic-row,.topic-row,.ai-compose-topic{display:none!important}
336
- .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-url-only-note{font-size:11px;color:#888;margin:5px 0 8px}
337
- </style>
338
- <script>
339
- (function(){
340
- function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
341
- function hideTopicControls(){
342
- document.querySelectorAll('#ai-topic-input').forEach(e=>{let p=e.closest('.ai-compose,.ai-compose-topic,.topic-row,div'); if(p&&p.querySelector('#ai-url-input')) e.style.display='none'; else if(p) p.style.display='none';});
343
- document.querySelectorAll('button').forEach(b=>{let t=(b.textContent||'').toLowerCase();let oc=b.getAttribute('onclick')||'';if(oc.includes('createTopicPost')||t.includes('chủ đề'))b.style.display='none';});
344
- 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 để AI tóm tắt và lấy ảnh từ bài.';url.insertAdjacentElement('afterend',n);}
345
- }
346
- window.createTopicPost=function(){alert('Đã tắt ô nhập chủ đề. Vui lòng dán URL bài viết.');};
347
- 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){if(typeof prependWallPost==='function')prependWallPost(j.post);if(window.patchedWall)window.patchedWall=[j.post].concat(window.patchedWall||[]);if(inp)inp.value='';alert('Đã tóm tắt URL, lấy ảnh trong bài và đăng lên Tường AI');location.reload();}else alert(j.error||'Lỗi URL')}).catch(e=>alert(e.message||'Lỗi URL'));};
348
- 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>';}
349
- function patchReaders(){
350
- let oldRead=window.aiReadWallPatched||window.aiReadWall;
351
- window.aiReadWallPatched=window.aiReadWall=function(i){let arr=window.patchedWall||window.aiWall||[];let p=arr[i];if(!p&&oldRead)return oldRead(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 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>`:''}<button onclick="aiMakeShortPatched?aiMakeShortPatched(${i}):aiMakeShort(${i})">🎬 Tạo video shorts</button></div></div>`;document.getElementById('view-article').innerHTML=h;window.scrollTo(0,0);};
352
- }
353
- setInterval(hideTopicControls,1000);setTimeout(hideTopicControls,300);setTimeout(patchReaders,1600);
354
- })();
355
- </script>
356
- '''
357
- return HTMLResponse(html.replace('</body>',inject+'\n</body>') if '</body>' in html else html+inject)
 
24
 
25
 
26
  def _strip_bullet_prefix(s):
27
+ return clean(re.sub(r'^[\s\•\-\*·▪▫●○\d\.\)\(]+', '', s or ''))
 
28
 
29
 
30
  def source_line(sources):
 
59
 
60
  def _scrape_url_with_images(url):
61
  data=base.scrape_any_url(url)
 
62
  try:
63
  import requests
64
  from bs4 import BeautifulSoup
 
69
  src=im.get('data-src') or im.get('data-original') or im.get('data-lazy-src') or im.get('src') or ''
70
  if src.startswith('//'):src='https:'+src
71
  if src and 'base64' not in src and src not in extra:
 
72
  low=src.lower()
73
  if any(x in low for x in ['logo','icon','avatar','sprite']):
74
  continue
 
110
  out=old._postprocess_ai_text(text, max_units=7)
111
  else:
112
  out=clean(text)
 
113
  return out
114
 
115
 
 
116
  _PATCH={('/api/topic_post','POST'),('/api/url_wall','POST'),('/api/rewrite_share','POST'),('/api/ai/url','POST'),('/api/ai/short/{post_id}','POST'),('/api/ai/short-file/{file_id}','GET'),('/','GET')}
117
  app.router.routes=[r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in _PATCH)]
118
 
 
125
  except Exception as e:return JSONResponse({'error':'Không scrape được URL: '+str(e)[:180]},status_code=422)
126
  raw=(data.get('summary','')+'\n'+data.get('text','')).strip()
127
  if len(raw)<120:return JSONResponse({'error':'URL không có đủ nội dung để tóm tắt'},status_code=422)
128
+ prompt=f"Tóm tắt bài viết nguồn dưới đây để đăng lên Tường AI VNEWS.\n\nYêu cầu bắt buộc:\n- Chỉ tóm tắt nội dung chính, không viết lại toàn bộ bài.\n- Ngắn gọn, cụ thể, dễ hiểu.\n- Không lặp lại ý và không thêm chi tiết ngoài nguồn.\n- Tối đa 5 ý chính hoặc 2 đoạn ngắn.\n- Tránh dùng dấu đầu dòng nếu không thật cần thiết.\n\nTiêu đề gốc: {data.get('title','')}\nNguồn: {data.get('via','') or _domain(url)}\nNội dung gốc:\n{raw[:16000]}"
 
 
 
 
 
 
 
 
 
 
 
 
129
  text=await base.qwen_generate(prompt,image_url=(data.get('image') or None),max_tokens=900)
130
  if not text:text=old._fallback_summary_from_prompt(prompt,max_units=5) if hasattr(old,'_fallback_summary_from_prompt') else raw[:900]
131
  text=postprocess(text)
 
151
  @app.post('/api/topic_post')
152
  async def topic_disabled(request:Request):
153
  return JSONResponse({'error':'Đã tắt tạo bài theo chủ đề. Vui lòng dán URL bài viết để AI tóm tắt.'},status_code=410)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ai_runtime_final.py CHANGED
@@ -15,7 +15,6 @@ RESTORE_INDEX_URL = "https://huggingface.co/spaces/bep40/vnews/raw/restore-33c3d
15
  SPACE_URL = "https://bep40-vnews.hf.space"
16
  DEFAULT_IMG = "https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg"
17
 
18
- # Only voices that support Vietnamese reliably. Extra labels map to these Vietnamese neural voices.
19
  VN_VOICES = {
20
  "nu": "vi-VN-HoaiMyNeural", "female": "vi-VN-HoaiMyNeural", "hoaimy": "vi-VN-HoaiMyNeural",
21
  "nu-tre": "vi-VN-HoaiMyNeural", "nu-truyen-cam": "vi-VN-HoaiMyNeural", "nu-tin-nhanh": "vi-VN-HoaiMyNeural",
@@ -35,7 +34,7 @@ def _domain(url):
35
 
36
 
37
  def _strip_bullet_prefix(s):
38
- return clean(re.sub(r'^[\s•\-\*·▪▫●○\d\.\)\(]+', '', s or ''))
39
 
40
 
41
  def _source_badge_url_first(post):
@@ -60,7 +59,6 @@ def _abs_url(src, base_url):
60
 
61
  def _article_content_block(soup):
62
  for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe']):tag.decompose()
63
- # Aggressively remove related/ad/recommend containers before image collection.
64
  bad_re=re.compile(r'(related|relate|recommend|suggest|sidebar|ads|advert|popular|more|xem-them|xemthem|tin-lien-quan|tinlienquan|doc-them|docthem|other-news|news-other|article-related|box-tin|box_related|story-related|recommend-news|same-category|cate-list|news-list|most-view|banner|qc|quang-cao|sponsor)',re.I)
65
  for el in list(soup.find_all(True)):
66
  cls=' '.join(el.get('class',[])); eid=el.get('id',''); role=el.get('role','')
@@ -94,7 +92,6 @@ def _image_is_likely_article(im, src):
94
 
95
 
96
  def _article_only_images(url):
97
- """Collect images only inside main article content. If uncertain, return fewer/no images rather than related/ad images."""
98
  imgs=[]
99
  try:
100
  from bs4 import BeautifulSoup
@@ -102,7 +99,6 @@ def _article_only_images(url):
102
  soup=BeautifulSoup(r.text,'lxml')
103
  block=_article_content_block(soup)
104
  candidates=[]
105
- # Prefer figure/picture under article body; then direct img in body.
106
  for el in block.find_all(['figure','picture'],recursive=True):
107
  im=el.find('img')
108
  if im:candidates.append(im)
@@ -115,12 +111,10 @@ def _article_only_images(url):
115
  else:src=src.strip().split(' ')[0]
116
  src=_abs_url(src,url)
117
  if src in seen or not _image_is_likely_article(im,src):continue
118
- # parent text guard: skip images from any remaining related block
119
  parent_txt=' '.join((im.parent.get('class',[]) if im.parent else []))+' '+(im.parent.get('id','') if im.parent else '')
120
  if re.search(r'(related|recommend|tin-lien-quan|doc-them|xem-them|popular|ads|banner)',parent_txt,re.I):continue
121
  seen.add(src);imgs.append(src)
122
  if len(imgs)>=20:break
123
- # Use og:image ONLY as article main image fallback when no body image found.
124
  if not imgs:
125
  og=soup.find('meta',property='og:image') or soup.find('meta',attrs={'name':'twitter:image'})
126
  if og:
@@ -154,7 +148,6 @@ def _download_image_safe(url, fallback_title, out_path):
154
  r=requests.get(url,headers=getattr(base,'HEADERS',{}),timeout=18)
155
  if r.status_code==200 and len(r.content)>1200:
156
  with open(out_path,'wb') as f:f.write(r.content)
157
- # verify PIL opens it
158
  if Image:
159
  Image.open(out_path).verify()
160
  return out_path
@@ -167,7 +160,6 @@ def _download_image_safe(url, fallback_title, out_path):
167
 
168
  def final_make_tts(text,voice,out_path):
169
  text=_strip_bullet_prefix(text) or 'Bản tin VNEWS.'
170
- # Only Vietnamese voices. Unknown choices fall back to Vietnamese female.
171
  edge_voice=VN_VOICES.get(str(voice or '').lower().strip(), 'vi-VN-HoaiMyNeural')
172
  for ev in [edge_voice, 'vi-VN-HoaiMyNeural', 'vi-VN-NamMinhNeural']:
173
  try:
@@ -178,7 +170,6 @@ def final_make_tts(text,voice,out_path):
178
  base.gTTS(text,lang='vi',tld='com.vn',slow=False).save(out_path)
179
  if os.path.exists(out_path) and os.path.getsize(out_path)>1000:return out_path
180
  except Exception:pass
181
- # Last-resort silent audio guarantees short generation succeeds.
182
  subprocess.run(['ffmpeg','-y','-f','lavfi','-i','anullsrc=channel_layout=stereo:sample_rate=44100','-t','3','-q:a','9','-acodec','libmp3lame',out_path],stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=30)
183
  return out_path
184
 
@@ -219,97 +210,7 @@ def final_make_frame(post,seg,idx,total,img_path,out_path):
219
  title_lines=rt.wrap_text(draw,_strip_bullet_prefix(post.get('title','')),fs,W-120,3);y2=1640;draw.line((80,y2-26,W-80,y2-26),fill=(70,70,70),width=2);_draw_center(draw,title_lines,fs,y2,(220,220,220),W,42)
220
  bg.save(out_path,quality=92)
221
 
222
- # Monkey patches for old functions.
223
  rt.make_frame=final_make_frame;rt.make_tts=final_make_tts;rt._source_badge=_source_badge_url_first
224
 
225
- # Override endpoints.
226
  _PATCH={('/api/url_wall','POST'),('/api/rewrite_share','POST'),('/api/ai/url','POST'),('/api/ai/short/{post_id}','POST'),('/','GET'),('/aw','GET')}
227
  app.router.routes=[r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in _PATCH)]
228
-
229
- @app.post('/api/url_wall')
230
- async def final_url_wall(request:Request):
231
- body=await request.json();url=base._clean_text(body.get('url',''))
232
- if not url.startswith('http'):return JSONResponse({'error':'missing url'},status_code=400)
233
- try:data=_scrape_url_article_only(url)
234
- except Exception as e:return JSONResponse({'error':'Không scrape được URL: '+str(e)[:180]},status_code=422)
235
- raw=(data.get('summary','')+'\n'+data.get('text','')).strip()
236
- if len(raw)<120:return JSONResponse({'error':'URL không có đủ nội dung để tóm tắt'},status_code=422)
237
- prompt=f"""Tóm tắt bài viết nguồn dưới đây để đăng lên Tường AI VNEWS.
238
-
239
- Yêu cầu:
240
- - Chỉ tóm tắt nội dung chính, không viết lại toàn bộ bài.
241
- - Ngắn gọn, cụ thể, dễ hiểu.
242
- - Không lặp ý, không thêm chi tiết ngoài nguồn.
243
- - Tối đa 5 ý chính hoặc 2 đoạn ngắn.
244
- - Hạn chế dùng dấu đầu dòng.
245
-
246
- Tiêu đề gốc: {data.get('title','')}
247
- Nguồn: {_domain(url)}
248
- Nội dung gốc:
249
- {raw[:16000]}"""
250
- text=await base.qwen_generate(prompt,image_url=(data.get('image') or None),max_tokens=900)
251
- if not text:text=rt.old._fallback_summary_from_prompt(prompt,max_units=5) if hasattr(rt.old,'_fallback_summary_from_prompt') else raw[:900]
252
- text=rt.postprocess(text) if hasattr(rt,'postprocess') else text
253
- src=[{'title':data.get('title'), 'url':url, 'excerpt':raw[:500], 'via':_domain(url)}]
254
- if 'Nguồn tham khảo:' not in text:text+='\n\n'+rt.source_line(src)
255
- imgs=data.get('images') or []
256
- post=base.make_post(data.get('title') or 'Bài viết',text,imgs[0] if imgs else '',url,'url',sources=src)
257
- post['images']=imgs
258
- posts=base._load_ai_wall();posts.insert(0,post);base._save_ai_wall(posts)
259
- return JSONResponse({'post':post})
260
-
261
- @app.post('/api/rewrite_share')
262
- async def final_rewrite_share(request:Request):return await final_url_wall(request)
263
- @app.post('/api/ai/url')
264
- async def final_ai_url(request:Request):return await final_url_wall(request)
265
-
266
- @app.post('/api/ai/short/{post_id}')
267
- async def final_short(post_id:str,request:Request):
268
- try:body=await request.json()
269
- except Exception:body={}
270
- voice=str(body.get('voice','nu')).lower().strip();emotion=str(body.get('emotion','neutral')).lower().strip();speed=max(0.85,min(1.35,float(body.get('speed',1.2) or 1.2)))
271
- posts=base._load_ai_wall();post=next((p for p in posts if str(p.get('id'))==str(post_id)),None)
272
- if not post:return JSONResponse({'error':'post not found'},status_code=404)
273
- segs=rt.split_segments(post,8) if hasattr(rt,'split_segments') else [_strip_bullet_prefix(post.get('text') or post.get('title') or 'VNEWS')]
274
- imgs=[u for u in (post.get('images') or []) if u] or ([post.get('img')] if post.get('img') else [])
275
- os.makedirs(base.SHORTS_DIR,exist_ok=True);suffix=f'_{voice}_{emotion}_{str(speed).replace(".","p")}_articleimgs_vivoice'
276
- out=os.path.join(base.SHORTS_DIR,base._safe_name(post_id+suffix)+'.mp4')
277
- if os.path.exists(out):
278
- post['video']='/api/ai/short-file/'+post_id+suffix;base._save_ai_wall(posts);return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False})
279
- work=os.path.join(base.SHORTS_DIR,base._safe_name(post_id+suffix));os.makedirs(work,exist_ok=True)
280
- clips=[]
281
- try:
282
- for i,seg in enumerate(segs):
283
- img_url=imgs[i % len(imgs)] if imgs else ''
284
- img=os.path.join(work,f'image_{i}.jpg');frame=os.path.join(work,f'f{i}.jpg');aud=os.path.join(work,f'a{i}.mp3');aud2=os.path.join(work,f'a{i}_fast.mp3');clip=os.path.join(work,f'c{i}.mp4')
285
- _download_image_safe(img_url,post.get('title','AI news'),img)
286
- seg=_strip_bullet_prefix(seg);final_make_frame(post,seg,i,len(segs),img,frame)
287
- prefix={'urgent':'Tin nhanh.','warm':'Câu chuyện đáng chú ý.','serious':'Bản tin nghiêm túc.','energetic':'Cập nhật nổi bật.'}.get(emotion,'')
288
- spoken=(prefix+' '+seg).strip() if i==0 and prefix else seg
289
- final_make_tts(spoken,voice,aud)
290
- try:subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud2],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=120)
291
- except Exception:aud2=aud
292
- try:
293
- subprocess.run(['ffmpeg','-y','-loop','1','-i',frame,'-i',aud2,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k','-vf','scale=1080:1920',clip],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=180)
294
- except Exception:
295
- # last-resort visual-only 4s clip
296
- subprocess.run(['ffmpeg','-y','-loop','1','-t','4','-i',frame,'-f','lavfi','-i','anullsrc=channel_layout=stereo:sample_rate=44100','-shortest','-c:v','libx264','-pix_fmt','yuv420p','-c:a','aac','-vf','scale=1080:1920',clip],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=120)
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})
304
- except Exception as e:return JSONResponse({'error':'Không tạo được shorts: '+str(e)[:220]},status_code=500)
305
-
306
- @app.get('/aw')
307
- def ai_wall_share(post:str=Query(default=''), short:int=Query(default=0)):
308
- posts=base._load_ai_wall();p=next((x for x in posts if str(x.get('id'))==str(post)),None)
309
- if not p:return HTMLResponse(f'<script>location.href="{SPACE_URL}"</script>')
310
- title=p.get('title') or 'VNEWS AI';img=p.get('img') or DEFAULT_IMG
311
- desc=(p.get('text') or '')[:220]
312
- return HTMLResponse(f'<!doctype html><html><head><meta charset="utf-8"><title>{title}</title><meta property="og:title" content="{title}"><meta property="og:description" content="{desc}"><meta property="og:image" content="{img}"><meta property="og:type" content="article"><meta name="twitter:card" content="summary_large_image"></head><body><script>localStorage.setItem('pending_ai_post','{post}');location.href='{SPACE_URL}'</script></body></html>')
313
-
314
- FINAL_INJECT = r'''
315
- <style>
 
15
  SPACE_URL = "https://bep40-vnews.hf.space"
16
  DEFAULT_IMG = "https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg"
17
 
 
18
  VN_VOICES = {
19
  "nu": "vi-VN-HoaiMyNeural", "female": "vi-VN-HoaiMyNeural", "hoaimy": "vi-VN-HoaiMyNeural",
20
  "nu-tre": "vi-VN-HoaiMyNeural", "nu-truyen-cam": "vi-VN-HoaiMyNeural", "nu-tin-nhanh": "vi-VN-HoaiMyNeural",
 
34
 
35
 
36
  def _strip_bullet_prefix(s):
37
+ return clean(re.sub(r'^[\s\•\-\*·▪▫●○\d\.\)\(]+', '', s or ''))
38
 
39
 
40
  def _source_badge_url_first(post):
 
59
 
60
  def _article_content_block(soup):
61
  for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe']):tag.decompose()
 
62
  bad_re=re.compile(r'(related|relate|recommend|suggest|sidebar|ads|advert|popular|more|xem-them|xemthem|tin-lien-quan|tinlienquan|doc-them|docthem|other-news|news-other|article-related|box-tin|box_related|story-related|recommend-news|same-category|cate-list|news-list|most-view|banner|qc|quang-cao|sponsor)',re.I)
63
  for el in list(soup.find_all(True)):
64
  cls=' '.join(el.get('class',[])); eid=el.get('id',''); role=el.get('role','')
 
92
 
93
 
94
  def _article_only_images(url):
 
95
  imgs=[]
96
  try:
97
  from bs4 import BeautifulSoup
 
99
  soup=BeautifulSoup(r.text,'lxml')
100
  block=_article_content_block(soup)
101
  candidates=[]
 
102
  for el in block.find_all(['figure','picture'],recursive=True):
103
  im=el.find('img')
104
  if im:candidates.append(im)
 
111
  else:src=src.strip().split(' ')[0]
112
  src=_abs_url(src,url)
113
  if src in seen or not _image_is_likely_article(im,src):continue
 
114
  parent_txt=' '.join((im.parent.get('class',[]) if im.parent else []))+' '+(im.parent.get('id','') if im.parent else '')
115
  if re.search(r'(related|recommend|tin-lien-quan|doc-them|xem-them|popular|ads|banner)',parent_txt,re.I):continue
116
  seen.add(src);imgs.append(src)
117
  if len(imgs)>=20:break
 
118
  if not imgs:
119
  og=soup.find('meta',property='og:image') or soup.find('meta',attrs={'name':'twitter:image'})
120
  if og:
 
148
  r=requests.get(url,headers=getattr(base,'HEADERS',{}),timeout=18)
149
  if r.status_code==200 and len(r.content)>1200:
150
  with open(out_path,'wb') as f:f.write(r.content)
 
151
  if Image:
152
  Image.open(out_path).verify()
153
  return out_path
 
160
 
161
  def final_make_tts(text,voice,out_path):
162
  text=_strip_bullet_prefix(text) or 'Bản tin VNEWS.'
 
163
  edge_voice=VN_VOICES.get(str(voice or '').lower().strip(), 'vi-VN-HoaiMyNeural')
164
  for ev in [edge_voice, 'vi-VN-HoaiMyNeural', 'vi-VN-NamMinhNeural']:
165
  try:
 
170
  base.gTTS(text,lang='vi',tld='com.vn',slow=False).save(out_path)
171
  if os.path.exists(out_path) and os.path.getsize(out_path)>1000:return out_path
172
  except Exception:pass
 
173
  subprocess.run(['ffmpeg','-y','-f','lavfi','-i','anullsrc=channel_layout=stereo:sample_rate=44100','-t','3','-q:a','9','-acodec','libmp3lame',out_path],stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=30)
174
  return out_path
175
 
 
210
  title_lines=rt.wrap_text(draw,_strip_bullet_prefix(post.get('title','')),fs,W-120,3);y2=1640;draw.line((80,y2-26,W-80,y2-26),fill=(70,70,70),width=2);_draw_center(draw,title_lines,fs,y2,(220,220,220),W,42)
211
  bg.save(out_path,quality=92)
212
 
 
213
  rt.make_frame=final_make_frame;rt.make_tts=final_make_tts;rt._source_badge=_source_badge_url_first
214
 
 
215
  _PATCH={('/api/url_wall','POST'),('/api/rewrite_share','POST'),('/api/ai/url','POST'),('/api/ai/short/{post_id}','POST'),('/','GET'),('/aw','GET')}
216
  app.router.routes=[r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in _PATCH)]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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_clean.py DELETED
@@ -1,69 +0,0 @@
1
- """
2
- VNEWS Clean Backend - serves static/index_v2.html directly.
3
- No injection layers. All APIs from existing modules preserved.
4
- Comments feature REMOVED per user request.
5
- """
6
- import sys, os
7
-
8
- # Import the full chain which registers all API endpoints on the FastAPI app
9
- from app_main import app, _search_all, _clean
10
-
11
- # Now override the root '/' to serve our clean frontend
12
- from fastapi import Query, Request
13
- from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
14
- from fastapi.staticfiles import StaticFiles
15
- import os
16
-
17
- # Remove old '/' route
18
- app.router.routes = [r for r in app.router.routes if not (
19
- getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set())
20
- )]
21
-
22
- # Remove comment endpoints (user requested removal)
23
- app.router.routes = [r for r in app.router.routes if not (
24
- getattr(r, 'path', None) in ('/api/short/comments', '/api/short/comment')
25
- )]
26
-
27
- # Mount static files
28
- STATIC_DIR = os.path.join(os.path.dirname(__file__), 'static')
29
- app.mount('/static', StaticFiles(directory=STATIC_DIR), name='static')
30
-
31
- @app.get('/')
32
- async def serve_index():
33
- """Serve the clean v2 frontend - single HTML file, no injection."""
34
- index_path = os.path.join(STATIC_DIR, 'index_v2.html')
35
- if os.path.exists(index_path):
36
- return FileResponse(index_path, media_type='text/html')
37
- return HTMLResponse('<h1>VNEWS</h1><p>index_v2.html not found</p>', status_code=500)
38
-
39
- # Keep /api/hashtag/sources using direct search (not Google News)
40
- # This was already overridden in app_main.py with _search_all
41
- # Just make sure it's accessible
42
-
43
- # Storage status endpoint
44
- @app.get('/api/storage_status')
45
- def storage_status():
46
- """Check if persistent storage is enabled."""
47
- data_dir = '/data'
48
- persistent = os.path.isdir(data_dir) and os.access(data_dir, os.W_OK)
49
- return JSONResponse({'persistent': persistent, 'path': data_dir})
50
-
51
- # Categories for the tab bar
52
- @app.get('/api/categories')
53
- def get_categories():
54
- """Return category list for frontend tab bar."""
55
- return JSONResponse([]) # Categories moved into News tab, homepage shows media content
56
-
57
- # Share page
58
- @app.get('/s')
59
- async def share_page(url: str = '', title: str = '', img: str = ''):
60
- """OG share page for social media."""
61
- html = f'''<!DOCTYPE html><html><head>
62
- <meta property="og:title" content="{_clean(title)}">
63
- <meta property="og:url" content="{_clean(url)}">
64
- <meta property="og:image" content="{_clean(img)}">
65
- <meta property="og:type" content="article">
66
- <meta property="og:site_name" content="VNEWS">
67
- <meta http-equiv="refresh" content="0;url={_clean(url) or '/'}">
68
- </head><body>Redirecting...</body></html>'''
69
- return HTMLResponse(html)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app_entry.py DELETED
@@ -1,17 +0,0 @@
1
- """Wrapper: load main patch then inject extra fixes for tiktok-right position, kill duplicate slides, progress toast."""
2
- from ai_runtime_patch_fast import *
3
- from ai_runtime_patch_fast import app, f5, f6, rt, PATCH_INJECT
4
- from patch_extra import EXTRA_FIX
5
- from fastapi.responses import HTMLResponse
6
-
7
- # Remove old root and re-register with EXTRA_FIX appended.
8
- app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
9
-
10
- @app.get('/')
11
- async def _index_final():
12
- html=f5.f4.f3.f2.f1._load_index_html()
13
- 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
14
- body+=getattr(f6,'FINAL6_INJECT','');body+=getattr(f6,'FINAL6_FAST_HOME_INJECT','');body+=getattr(f6,'FINAL6E_INJECT','')
15
- body+=PATCH_INJECT
16
- body+=EXTRA_FIX
17
- return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 DELETED
@@ -1,1868 +0,0 @@
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
- import asyncio
25
-
26
- HL_LEAGUES['friendly'] = {"path": "giai-khac/friendly", "name": "Giao hữu", "emoji": "🤝"}
27
-
28
- STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
29
- SPACE = "https://bep40-vnews.hf.space" # SEO URL base for share links
30
- app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)=='/' and hasattr(r,'methods') and 'GET' in getattr(r,'methods',set()))]
31
- app.routes[:]=[r for r in app.routes if not isinstance(r, Mount)]
32
- app.router.routes=[r for r in app.router.routes if not isinstance(r, Mount)]
33
-
34
- def _clean(s): return re.sub(r"\s+"," ",html_lib.unescape(str(s or""))).strip()
35
-
36
- # Cache for match details (5 min TTL)
37
- _match_cache = {}
38
-
39
- # === FAST BONGDA PROXY ENDPOINT ===
40
- def _get_match_detail(event_id, slug=None):
41
- headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "text/html", "Referer": "https://bongda.com.vn/"}
42
- if slug:
43
- url = f"https://bongda.com.vn/tran-dau/{event_id}/centre/{slug}"
44
- else:
45
- url = f"https://bongda.com.vn/tran-dau/{event_id}"
46
- resp = req.get(url, headers=headers, timeout=15, allow_redirects=True)
47
- if resp.status_code != 200:
48
- return None
49
- soup = BeautifulSoup(resp.text, 'html.parser')
50
- result = {"event_id": event_id, "found": False, "sections": []}
51
- info = {}
52
- tel = soup.select_one('.teams')
53
- if tel:
54
- he = tel.select_one('.team.home')
55
- if he:
56
- p_tags = [p for p in he.select('p') if not p.get('class') or 'logo' not in p.get('class', [])]
57
- if p_tags: info['home_team'] = _clean(p_tags[0].get_text())
58
- lo = he.select_one('img')
59
- if lo: info['home_logo'] = lo.get('src', '')
60
- ae = tel.select_one('.team.away')
61
- if ae:
62
- p_tags = ae.select('p')
63
- team_ps = [p for p in p_tags if not p.get('class') or 'logo' not in p.get('class', [])]
64
- if team_ps: info['away_team'] = _clean(team_ps[-1].get_text())
65
- lo = ae.select_one('img')
66
- if lo: info['away_logo'] = lo.get('src', '')
67
- sc = tel.select_one('.score')
68
- if sc:
69
- parts = [_clean(p.get_text()) for p in sc.select('p')]
70
- if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}"
71
- lb = sc.select_one('.label')
72
- if lb: info['status_label'] = _clean(lb.get_text())
73
- if info.get('home_team') and info.get('away_team'):
74
- result['info'] = info
75
- result['found'] = True
76
- result['sections'].append('info')
77
- events = []
78
- for ev in soup.select('.events .period .event'):
79
- ev_cls = ' '.join(ev.get('class', []))
80
- ev_data = {'team': 'home' if 'home' in ev_cls else 'away', 'period': '', 'type': 'unknown', 'time': '', 'players': ''}
81
- parent = ev.parent
82
- if parent:
83
- h2 = parent.find('h2')
84
- if h2: ev_data['period'] = _clean(h2.get_text())
85
- if ev.select_one('[class*="goal"]'): ev_data['type'] = 'goal'
86
- elif ev.select_one('[class*="redcard"]'): ev_data['type'] = 'redcard'
87
- elif ev.select_one('[class*="yellowcard"]'): ev_data['type'] = 'yellowcard'
88
- elif ev.select_one('[class*="substitution"]'): ev_data['type'] = 'substitution'
89
- players_el = ev.select_one('.players')
90
- if players_el:
91
- pl_text = _clean(players_el.get_text(' ', strip=True))
92
- m = re.match(r"(\d+)'(.*)", pl_text)
93
- if m:
94
- ev_data['time'] = f"{m.group(1)}'"
95
- ev_data['players'] = m.group(2)
96
- else:
97
- ev_data['players'] = pl_text
98
- events.append(ev_data)
99
- if events:
100
- result['events'] = events
101
- result['sections'].append('events')
102
- pred = soup.select_one('.prediction-card')
103
- if pred:
104
- team_info = pred.select_one('.team-info')
105
- if team_info:
106
- teams = team_info.select('.team')
107
- pred_data = {}
108
- if len(teams) >= 2:
109
- pred_data['home_name'] = _clean(teams[0].select_one('.team-name').get_text()) if teams[0].select_one('.team-name') else ''
110
- pred_data['away_name'] = _clean(teams[1].select_one('.team-name').get_text()) if teams[1].select_one('.team-name') else ''
111
- divider = team_info.select_one('.divider')
112
- if divider: pred_data['result'] = _clean(divider.get_text())
113
- vc = pred.select_one('.vote-count')
114
- if vc: pred_data['vote_count'] = _clean(vc.get_text())
115
- result['prediction'] = pred_data
116
- recent = []
117
- ml = soup.select_one('.matches-list')
118
- if ml:
119
- for item in ml.select('.match-detail, .match-item, li'):
120
- de = item.select_one('.date, .time')
121
- le = item.select_one('.league')
122
- he_item = item.select_one('.home, .team-home')
123
- ae_item = item.select_one('.away, .team-away')
124
- se = item.select_one('.score, .result')
125
- if he_item or ae_item:
126
- 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'})
127
- if recent:
128
- result['recent_matches'] = recent
129
- result['sections'].append('recent')
130
- try:
131
- api_h = {"User-Agent": "Mozilla/5.0", "Accept": "application/json", "X-Requested-With": "XMLHttpRequest", "Referer": "https://bongda.com.vn/"}
132
- ar = req.get(f"https://bongda.com.vn/api/fixtures/h2h-stats?event_id={event_id}", headers=api_h, timeout=10)
133
- if ar.status_code == 200:
134
- ad = ar.json()
135
- if ad.get('status') == 'success' and ad.get('html'):
136
- asp = BeautifulSoup(ad['html'], 'html.parser')
137
- ast = {}
138
- for row in asp.select('li, tr'):
139
- cells = row.select('td, span, p')
140
- if len(cells) >= 3:
141
- lb = _clean(cells[0].get_text())
142
- if lb: ast[lb] = {'home': _clean(cells[1].get_text()), 'away': _clean(cells[2].get_text())}
143
- if ast:
144
- result['h2h_stats_parsed'] = ast
145
- result['sections'].append('h2h_stats')
146
- except: pass
147
- return result
148
-
149
- @app.get('/api/proxy/bongda')
150
- def proxy_bongda(event_id: int = Query(default=None), slug: str = Query(default=None)):
151
- if event_id is None:
152
- return JSONResponse({'error': 'event_id required'}, status_code=400)
153
- cache_key = f"{event_id}_{slug}"
154
- now = time.time()
155
- cached = _match_cache.get(cache_key)
156
- if cached and now - cached.get('_ts', 0) < 300:
157
- return JSONResponse(cached)
158
- try:
159
- result = _get_match_detail(event_id, slug)
160
- if result:
161
- result['_ts'] = now
162
- _match_cache[cache_key] = result
163
- return JSONResponse(result)
164
- except Exception as e:
165
- err = {"event_id": event_id, "found": False, "error": str(e), "_ts": now}
166
- _match_cache[cache_key] = err
167
- return JSONResponse(err)
168
- return JSONResponse({"event_id": event_id, "found": False})
169
-
170
- @app.get('/api/match/{event_id}/detail')
171
- def api_match_detail(event_id: int, url: str = Query(default=None)):
172
- slug = None
173
- if url:
174
- m = re.match(r'.+/tran-dau/\d+/(?:centre|preview)/(.+)', url)
175
- if m:
176
- slug = m.group(1)
177
- cache_key = f"{event_id}_{slug or ''}"
178
- now = time.time()
179
- cached = _match_cache.get(cache_key)
180
- if cached and now - cached.get('_ts', 0) < 300:
181
- return JSONResponse(cached)
182
- try:
183
- if not slug:
184
- try:
185
- home_r = req.get("https://bongda.com.vn/", headers={"User-Agent": "Mozilla/5.0"}, timeout=10)
186
- if home_r.status_code == 200:
187
- home_soup = BeautifulSoup(home_r.text, 'html.parser')
188
- for a in home_soup.select(f'a[href*="/tran-dau/{event_id}/"]'):
189
- href = a.get('href', '')
190
- m = re.match(r'/tran-dau/\d+/(?:centre|preview)/(.+)', href)
191
- if m:
192
- slug = m.group(1)
193
- cache_key = f"{event_id}_{slug}"
194
- break
195
- except: pass
196
- result = _get_match_detail(event_id, slug)
197
- if result:
198
- result['_ts'] = now
199
- _match_cache[cache_key] = result
200
- return JSONResponse(result)
201
- except Exception as e:
202
- err = {"event_id": event_id, "found": False, "error": str(e), "_ts": now}
203
- _match_cache[cache_key] = err
204
- return JSONResponse(err)
205
- return JSONResponse({"event_id": event_id, "found": False})
206
-
207
- _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())
208
-
209
- def _has_kw(topic,title):
210
- tl=topic.lower();tt=(title or'').lower()
211
- if tl in tt:return True
212
- words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',tl) if len(w)>2 and w not in _STOP]
213
- if not words:return True
214
- return any(w in tt for w in words)
215
-
216
- def _s_vnexpress(topic,limit=8):
217
- items=[]
218
- try:
219
- r=req.get(f"https://timkiem.vnexpress.net/?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
220
- for art in soup.select('article.item-news')[:limit]:
221
- a=art.select_one('h2 a, h3 a')
222
- if a and a.get('href'):
223
- t=_clean(a.get('title','') or a.get_text(strip=True))
224
- if _has_kw(topic,t):items.append({'title':t,'url':a['href'],'via':'VnExpress'})
225
- except:pass
226
- return items
227
-
228
- def _s_dantri(topic,limit=8):
229
- items=[]
230
- try:
231
- r=req.get(f"https://dantri.com.vn/tim-kiem/{quote(topic)}.htm",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
232
- for a in soup.select('h3 a[href], .article-title a[href]')[:limit*2]:
233
- t=_clean(a.get_text(strip=True));href=a.get('href','')
234
- if t and len(t)>15 and _has_kw(topic,t):
235
- if not href.startswith('http'):href='https://dantri.com.vn'+href
236
- items.append({'title':t,'url':href,'via':'Dân Trí'})
237
- if len(items)>=limit:break
238
- except:pass
239
- return items
240
-
241
- def _s_vietnamnet(topic,limit=6):
242
- items=[]
243
- try:
244
- 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')
245
- for a in soup.select('h3 a[href], .vnn-title a')[:limit*2]:
246
- t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
247
- if t and len(t)>15 and _has_kw(topic,t):
248
- if not href.startswith('http'):href='https://vietnamnet.vn'+href
249
- items.append({'title':t,'url':href,'via':'VietNamNet'})
250
- if len(items)>=limit:break
251
- except:pass
252
- return items
253
-
254
- def _s_bongda(topic,limit=5):
255
- items=[]
256
- try:
257
- r=req.get(f"https://bongda.com.vn/tim-kiem.html?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=8);soup=BeautifulSoup(r.text,'lxml')
258
- for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
259
- t=_clean(a.get_text(strip=True));href=a.get('href','')
260
- if t and len(t)>15 and _has_kw(topic,t):
261
- if not href.startswith('http'):href='https://bongda.com.vn'+href
262
- items.append({'title':t,'url':href,'via':'Bóng Đá'})
263
- if len(items)>=limit:break
264
- except:pass
265
- return items
266
-
267
- def _s_genk(topic,limit=5):
268
- items=[]
269
- try:
270
- r=req.get(f"https://genk.vn/tim-kiem?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=8);soup=BeautifulSoup(r.text,'lxml')
271
- for a in soup.select('a[href$=".chn"]')[:limit*3]:
272
- t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
273
- if t and len(t)>15 and _has_kw(topic,t):
274
- if href.startswith('/'):href='https://genk.vn'+href
275
- items.append({'title':t,'url':href,'via':'GenK'})
276
- if len(items)>=limit:break
277
- except:pass
278
- return items
279
-
280
- def _s_thanhnien(topic,limit=6):
281
- items=[]
282
- try:
283
- r=req.get(f"https://thanhnien.vn/tim-kiem?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
284
- for a in soup.select('h3 a[href], .box-title a')[:limit*2]:
285
- t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
286
- if t and len(t)>15 and _has_kw(topic,t):
287
- if not href.startswith('http'):href='https://thanhnien.vn'+href
288
- items.append({'title':t,'url':href,'via':'Thanh Niên'})
289
- if len(items)>=limit:break
290
- except:pass
291
- return items
292
-
293
- def _s_tuoitre(topic,limit=6):
294
- items=[]
295
- try:
296
- r=req.get(f"https://tuoitre.vn/tim-kiem.htm?keywords={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
297
- for a in soup.select('h3 a[href], .box-title-text a')[:limit*2]:
298
- t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
299
- if t and len(t)>15 and _has_kw(topic,t):
300
- if not href.startswith('http'):href='https://tuoitre.vn'+href
301
- items.append({'title':t,'url':href,'via':'Tuổi Trẻ'})
302
- if len(items)>=limit:break
303
- except:pass
304
- return items
305
-
306
- def _s_thethaovanhoa(topic,limit=5):
307
- items=[]
308
- try:
309
- r=req.get(f"https://thethaovanhoa.vn/tim-kiem.htm?keyword={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=8);soup=BeautifulSoup(r.text,'lxml')
310
- for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
311
- t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
312
- if t and len(t)>15 and _has_kw(topic,t):
313
- if not href.startswith('http'):href='https://thethaovanhoa.vn'+href
314
- items.append({'title':t,'url':href,'via':'TT&VH'})
315
- if len(items)>=limit:break
316
- except:pass
317
- return items
318
-
319
- def _search_all(topic,limit=36):
320
- results={}
321
- with ThreadPoolExecutor(8) as ex:
322
- futs={ex.submit(_s_vnexpress,topic,8):'vne',ex.submit(_s_dantri,topic,8):'dt',ex.submit(_s_vietnamnet,topic,6):'vnn',ex.submit(_s_bongda,topic,5):'bd',ex.submit(_s_genk,topic,5):'gk',ex.submit(_s_thanhnien,topic,6):'tn',ex.submit(_s_tuoitre,topic,6):'tt',ex.submit(_s_thethaovanhoa,topic,5):'tvh'}
323
- for f in as_completed(futs,timeout=14):
324
- try:results[futs[f]]=f.result()
325
- except:results[futs[f]]=[]
326
- srcs=list(results.values());out=[];seen=set()
327
- for i in range(max((len(s) for s in srcs),default=0)):
328
- for s in srcs:
329
- if i<len(s) and s[i].get('url') and s[i]['url'] not in seen:seen.add(s[i]['url']);out.append(s[i])
330
- return out[:limit]
331
-
332
- for _path in ['/api/article', '/api/hot_topics', '/api/categories', '/api/storage_status']:
333
- app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)==_path and 'GET' in getattr(r,'methods',set()))]
334
-
335
- _article_cache = {}
336
- _article_cache_ttl = 1800
337
-
338
- _art_session = None
339
- _art_lock = threading.Lock()
340
- def _get_art_session():
341
- global _art_session
342
- if _art_session is None:
343
- with _art_lock:
344
- if _art_session is None:
345
- _art_session = req.Session()
346
- _art_session.headers.update({
347
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
348
- "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8",
349
- "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
350
- })
351
- return _art_session
352
-
353
- def _scrape_article_fast(url):
354
- from urllib.parse import urlparse
355
- domain = urlparse(url).netloc
356
- sess = _get_art_session()
357
- uas = [
358
- {"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"},
359
- {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"},
360
- ]
361
- for ua in uas:
362
- try:
363
- r = sess.get(url, headers=ua, timeout=6, allow_redirects=True)
364
- if not r or r.status_code != 200:
365
- continue
366
- r.encoding = 'utf-8'
367
- soup = BeautifulSoup(r.text, 'lxml')
368
- for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe','.ads','.ad','.banner-ads','.fb-comments','.fb-root','.social-share','.related-news','.tag','.breadcrumb']):
369
- tag.decompose()
370
- title = summary = og_img = ""
371
- ogt = soup.find('meta', property='og:title')
372
- if ogt: title = ogt.get('content', '')
373
- ogd = soup.find('meta', property='og:description') or soup.find('meta', attrs={'name': 'description'})
374
- if ogd: summary = ogd.get('content', '')[:500]
375
- ogi = soup.find('meta', property='og:image')
376
- if ogi:
377
- og_img = ogi.get('content', '')
378
- if og_img.startswith('//'): og_img = 'https:' + og_img
379
- h1 = soup.find('h1')
380
- if not title and h1: title = h1.get_text(strip=True)[:200]
381
- body = []
382
- selectors = [
383
- '.fck_detail', '.sidebar-1',
384
- '.singular-content', '.dt__content', '.article-content', '.content-detail', '#divNewsContent',
385
- '.content-detail', '.main-content-detail', '.box-content',
386
- '.knc-content', '.article-body', '.detail-body',
387
- '.article-detail', '.detail-content',
388
- 'article', 'main', '.cms-body', '.article__body', '.post-content',
389
- '.entry-content', '#content', '.article-text', '.story-body',
390
- ]
391
- for sel in selectors:
392
- el = soup.select_one(sel)
393
- if el and len(el.find_all('p')) >= 2:
394
- seen_imgs = set()
395
- for child in el.find_all(['p','h2','h3','figure','img'], recursive=True):
396
- if child.name == 'p':
397
- t = child.get_text(strip=True)
398
- if t and len(t) > 15:
399
- body.append({'type': 'p', 'text': t})
400
- elif child.name in ('h2','h3'):
401
- t = child.get_text(strip=True)
402
- if t:
403
- body.append({'type': 'heading', 'text': t})
404
- elif child.name in ('figure','img'):
405
- im = child if child.name == 'img' else child.find('img')
406
- if im:
407
- src = im.get('data-src') or im.get('src') or im.get('data-lazy') or ''
408
- if src and 'base64' not in src and src not in seen_imgs:
409
- seen_imgs.add(src)
410
- if src.startswith('//'): src = 'https:' + src
411
- body.append({'type': 'img', 'src': src})
412
- if child.name == 'figure':
413
- cap = child.find('figcaption')
414
- if cap:
415
- ct = cap.get_text(strip=True)
416
- if ct: body.append({'type': 'p', 'text': ct})
417
- if len(body) >= 2:
418
- return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img,
419
- 'body': body[:50], 'source': domain, 'url': url}
420
- if title and (summary or og_img):
421
- fallback = []
422
- if og_img: fallback.append({'type': 'img', 'src': og_img})
423
- if summary: fallback.append({'type': 'p', 'text': summary})
424
- if fallback:
425
- return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img,
426
- 'body': fallback, 'source': domain, 'url': url, 'fallback': True}
427
- if title:
428
- return {'title': _clean(title), 'summary': '', 'og_image': '',
429
- 'body': [{'type': 'p', 'text': 'Nội dung đang được tải...'}],
430
- 'source': domain, 'url': url, 'fallback': True}
431
- break
432
- except Exception:
433
- continue
434
- return None
435
-
436
- @app.get('/api/article')
437
- def api_article_v2(url: str = Query(...)):
438
- from urllib.parse import unquote
439
- safe_url = unquote(url)
440
- try:
441
- now = time.time()
442
- cached = _article_cache.get(safe_url)
443
- if cached and now - cached['t'] < _article_cache_ttl:
444
- resp = JSONResponse(cached['d'])
445
- resp.headers["Cache-Control"] = "public, max-age=1800"
446
- return resp
447
- data = _scrape_article_fast(safe_url)
448
- if data and data.get('body'):
449
- _article_cache[safe_url] = {'d': data, 't': now}
450
- resp = JSONResponse(data)
451
- resp.headers["Cache-Control"] = "public, max-age=1800"
452
- return resp
453
- result = {'error': 'Không đọc được', 'url': safe_url}
454
- resp = JSONResponse(result)
455
- resp.headers["Cache-Control"] = "public, max-age=60"
456
- return resp
457
- except Exception as e:
458
- return JSONResponse({'error': f'Server error: {str(e)[:100]}', 'url': safe_url}, status_code=200)
459
-
460
- _hot_cache={'t':0,'d':[]}
461
- def _get_hot_topics():
462
- now=time.time()
463
- if _hot_cache['d'] and now-_hot_cache['t']<600:return _hot_cache['d']
464
- freq={};display={}
465
- feeds=['https://vnexpress.net/rss/tin-moi-nhat.rss','https://dantri.com.vn/rss/home.rss','https://vietnamnet.vn/rss/tin-moi-nhat.rss','https://thanhnien.vn/rss/home.rss','https://tuoitre.vn/rss/tin-moi-nhat.rss','https://genk.vn/rss','https://vnexpress.net/rss/the-thao.rss','https://thethaovanhoa.vn/rss/tin-nong.rss']
466
- for feed_url in feeds:
467
- try:
468
- r=req.get(feed_url,headers={'User-Agent':'Mozilla/5.0'},timeout=6);r.encoding='utf-8';soup=BeautifulSoup(r.text,'xml')
469
- for item in soup.find_all('item')[:12]:
470
- title=_clean(item.find('title').get_text() if item.find('title') else '')
471
- if not title:continue
472
- title=re.sub(r'\s*[-|].*$','',title);words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',title) if len(w)>2 and w.lower() not in _STOP]
473
- if len(words)<2:continue
474
- for n in(3,4,2):
475
- for i in range(max(0,len(words)-n+1)):
476
- phrase=' '.join(words[i:i+n])
477
- if 8<=len(phrase)<=45:key=phrase.lower();freq[key]=freq.get(key,0)+1;display[key]=phrase
478
- except:continue
479
- ranked=sorted(freq.items(),key=lambda x:x[1],reverse=True);topics=[];seen=set()
480
- for key,count in ranked:
481
- is_dup=any(len(set(e.split())&set(key.split()))/max(len(set(e.split())),len(set(key.split())),1)>0.6 for e in seen)
482
- if is_dup:continue
483
- seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',display[key].title()),'topic':display[key],'count':count})
484
- if len(topics)>=20:break
485
- for kw in['World Cup 2026','Kinh tế Việt Nam','Bóng đá châu Âu','Công nghệ AI','Giá vàng','Thời tiết']:
486
- if len(topics)>=24:break
487
- if not any(kw.lower() in s for s in seen):topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw,'count':0})
488
- _hot_cache.update({'t':now,'d':topics[:24]});return topics[:24]
489
-
490
- @app.get('/api/hot_topics')
491
- def api_hot_topics():
492
- resp = JSONResponse({'topics':_get_hot_topics()})
493
- resp.headers["Cache-Control"] = "public, max-age=120"
494
- return resp
495
- @app.get('/')
496
- async def serve_index():
497
- p=os.path.join(STATIC_DIR,'index_v2.html')
498
- if os.path.exists(p):return FileResponse(p,media_type='text/html')
499
- return HTMLResponse('<h1>VNEWS</h1>')
500
- @app.get('/api/hashtag/sources')
501
- def _ht(topic:str=Query(...),page:int=Query(default=0)):
502
- items=_search_all(topic,36);per_page=8;start=page*per_page;end=start+per_page
503
- return JSONResponse({'sources':items[start:end],'topic':topic,'page':page,'has_more':end<len(items),'total':len(items)})
504
- @app.get('/api/categories')
505
- def _cat():return JSONResponse([])
506
- @app.get('/api/storage_status')
507
- def _st():return JSONResponse({'persistent':os.path.isdir('/data') and os.access('/data',os.W_OK)})
508
- # ===== SHARE HELPERS: render content pages for shared links =====
509
- def _render_slides_page(post, safe_title, safe_img, safe_url):
510
- slides = post.get('slides', [])
511
- # Get image from post.img or first slide's image
512
- if not safe_img and slides and slides[0].get('image'):
513
- safe_img = slides[0].get('image', '')
514
- # Use text for description if available
515
- description = _clean((post.get('text') or '')[:200]) or "Tin tức tóm tắt, AI rewrite, World Cup 2026"
516
-
517
- # Build canonical URL preserving original query format if url was provided
518
- if safe_url and safe_url != '/':
519
- canonical_url = f"{SPACE}/s?url={quote(safe_url)}&title={quote(safe_title[:100])}"
520
- else:
521
- canonical_url = f"{SPACE}/s?post_id={post.get('id') or ''}"
522
-
523
- h = f'''<!DOCTYPE html>
524
- <html lang="vi">
525
- <head>
526
- <meta charset="utf-8">
527
- <meta name="viewport" content="width=device-width,initial-scale=1">
528
- <title>{_clean(safe_title)}</title>
529
- <meta property="og:title" content="{_clean(safe_title)}">
530
- <meta property="og:image" content="{_clean(safe_img)}">
531
- <meta property="og:description" content="{description}">
532
- <meta property="og:url" content="{canonical_url}">
533
- <link rel="canonical" href="{canonical_url}">
534
- <style>
535
- *{{box-sizing:border-box;margin:0;padding:0}}body{{background:#111;color:#eee;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:12px}}
536
- .slide-card{{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:16px;margin-bottom:12px;max-width:600px;margin-left:auto;margin-right:auto}}
537
- .slide-num{{color:#5cb87a;font-size:12px;font-weight:700;margin-bottom:6px}}
538
- .slide-img{{width:100%;max-height:300px;object-fit:cover;border-radius:8px;margin-bottom:8px}}
539
- .slide-text{{color:#ddd;font-size:14px;line-height:1.6;margin:0}}
540
- </style>
541
- </head>
542
- <body>'''
543
- for s in slides:
544
- img_src = s.get('image', '')
545
- if img_src and ('cdnphoto.dantri' in img_src or 'refooty' in img_src or 'vnexpress' in img_src or 'vcdn' in img_src):
546
- img_tag = f'<img src="/api/proxy/img?url={quote(img_src, safe="")}" class="slide-img" loading="lazy" onerror="this.style.display=\'none\'">'
547
- else:
548
- img_tag = f'<img src="{_clean(img_src)}" class="slide-img" loading="lazy" onerror="this.style.display=\'none\'">' if img_src else ''
549
- h += f'<div class="slide-card"><div class="slide-num">Slide {s.get("index",1)}/{len(slides)}</div>{img_tag}<p class="slide-text">{_clean(s.get("text",""))}</p></div>'
550
- h += '</body></html>'
551
- return HTMLResponse(h)
552
-
553
- def _render_video_page(post, safe_title, safe_img, safe_url):
554
- video_url = post.get('video', '')
555
- # Use text for description if available
556
- description = _clean((post.get('text') or '')[:200]) or "Tin tức tóm tắt, AI rewrite, World Cup 2026"
557
-
558
- # Build canonical URL preserving original query format if url was provided
559
- if safe_url and safe_url != '/':
560
- canonical_url = f"{SPACE}/s?url={quote(safe_url)}&title={quote(safe_title[:100])}"
561
- else:
562
- canonical_url = f"{SPACE}/s?post_id={post.get('id') or ''}"
563
-
564
- h = f'''<!DOCTYPE html>
565
- <html lang="vi">
566
- <head>
567
- <meta charset="utf-8">
568
- <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
569
- <title>{_clean(safe_title)}</title>
570
- <meta property="og:title" content="{_clean(safe_title)}">
571
- <meta property="og:image" content="{_clean(safe_img)}">
572
- <meta property="og:description" content="{description}">
573
- <meta property="og:url" content="{canonical_url}">
574
- <link rel="canonical" href="{canonical_url}">
575
- <meta name="twitter:card" content="player">
576
- <meta name="twitter:player" content="{video_url}">
577
- <style>
578
- *{{box-sizing:border-box;margin:0;padding:0}}body{{background:#111;color:#eee;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:0;overflow:hidden}}
579
- .video-container{{width:100vw;height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#000}}
580
- video{{width:100%;height:100%;max-height:100vh;object-fit:contain;background:#000}}
581
- .title-bar{{position:fixed;bottom:0;left:0;right:0;background:linear-gradient(transparent,rgba(0,0,0,.8));padding:40px 16px 16px;text-align:center}}
582
- .title-text{{color:#fff;font-size:13px;line-height:1.4;max-width:600px;margin:0 auto}}
583
- </style>
584
- </head>
585
- <body>
586
- <div class="video-container">
587
- <video src="{_clean(video_url)}" controls autoplay playsinline loop></video>
588
- <div class="title-bar"><div class="title-text">{_clean(safe_title)}</div></div>
589
- </div>
590
- </body></html>'''
591
- return HTMLResponse(h)
592
-
593
- @app.get('/s/{slug}')
594
- async def _sh_slug(slug: str, request: Request, url: str = '', title: str = '', img: str = ''):
595
- """SEO-friendly share endpoint with slug in URL path.
596
- Shows slide content when url matches a wall post, otherwise redirects.
597
- """
598
- safe_title = _clean(title) if title else 'VNEWS - Tin tức'
599
- safe_img = _clean(img) if img else ''
600
- safe_url = _clean(url) if url else '/'
601
-
602
- # Try to find post by URL first (most reliable)
603
- post = None
604
- try:
605
- if url:
606
- posts = _load_wall_posts()
607
- for p in posts:
608
- if p.get('url') == url and p.get('slides'):
609
- post = p
610
- safe_title = p.get('title', safe_title) or safe_title
611
- safe_img = p.get('img', safe_img) or safe_img
612
- safe_url = p.get('url', safe_url) or safe_url
613
- break
614
- # Fallback: any matching URL
615
- if not post and url:
616
- for p in posts:
617
- if p.get('url') == url:
618
- post = p
619
- safe_title = p.get('title', safe_title) or safe_title
620
- safe_img = p.get('img', safe_img) or safe_img
621
- safe_url = p.get('url', safe_url) or safe_url
622
- break
623
- except:
624
- pass
625
-
626
- if post and post.get('slides'):
627
- return _render_slides_page(post, safe_title, safe_img, safe_url)
628
-
629
- if post and post.get('video'):
630
- return _render_video_page(post, safe_title, safe_img, safe_url)
631
-
632
- # Otherwise redirect
633
- return HTMLResponse(f'''<!DOCTYPE html>
634
- <html lang="vi">
635
- <head>
636
- <meta charset="utf-8">
637
- <meta name="viewport" content="width=device-width,initial-scale=1">
638
- <title>{_clean(safe_title)}</title>
639
- <meta property="og:title" content="{_clean(safe_title)}">
640
- <meta property="og:image" content="{_clean(safe_img)}">
641
- <meta property="og:description" content="Tin tức tóm tắt, AI rewrite, World Cup 2026">
642
- <meta property="og:url" content="{SPACE}/s/{slug}">
643
- <link rel="canonical" href="{SPACE}/s/{slug}">
644
- <meta http-equiv="refresh" content="0;url={safe_url}">
645
- </head><body></body></html>''')
646
-
647
-
648
-
649
- @app.get('/api/proxy/img')
650
- def proxy_img(url: str = Query(default=""), max_size: int = Query(default=1200)):
651
- """Proxy image from blocked CDN to public URL."""
652
- from urllib.parse import unquote
653
- safe_url = unquote(url)
654
- import requests as _req
655
- try:
656
- r = _req.get(safe_url, timeout=10, headers={"User-Agent": "Mozilla/5.0"})
657
- if r.status_code == 200 and r.content:
658
- return Response(r.content, media_type=r.headers.get("content-type", "image/jpeg"))
659
- except Exception as e:
660
- pass
661
- return Response(status_code=502)
662
- @app.get('/s')
663
- async def _sh(url:str='',title:str='',img:str='',post_id:str=''):
664
- safe_title = _clean(title) if title else 'VNEWS - Tin tức'
665
- safe_img = _clean(img) if img else ''
666
- safe_url = _clean(url) if url else '/'
667
-
668
- # Try to find wall post by post_id or URL (prioritize posts with slides/video)
669
- post = None
670
- try:
671
- posts = _load_wall_posts()
672
- if post_id:
673
- for p in posts:
674
- if p.get('id') == post_id:
675
- post = p
676
- safe_title = p.get('title', safe_title) or safe_title
677
- safe_img = p.get('img', safe_img) or safe_img
678
- safe_url = p.get('url', safe_url) or safe_url
679
- break
680
- elif url:
681
- # Find matching URL - prioritize posts with slides or video
682
- for p in posts:
683
- if p.get('url') == url and p.get('slides'):
684
- post = p
685
- safe_title = p.get('title', safe_title) or safe_title
686
- safe_img = p.get('img', safe_img) or safe_img
687
- safe_url = p.get('url', safe_url) or safe_url
688
- break
689
- if not post:
690
- # Fallback: find any matching URL
691
- for p in posts:
692
- if p.get('url') == url:
693
- post = p
694
- safe_title = p.get('title', safe_title) or safe_title
695
- safe_img = p.get('img', safe_img) or safe_img
696
- safe_url = p.get('url', safe_url) or safe_url
697
- break
698
- except:
699
- pass
700
-
701
- if post and post.get('slides'):
702
- return _render_slides_page(post, safe_title, safe_img, safe_url)
703
-
704
- if post and post.get('video'):
705
- return _render_video_page(post, safe_title, safe_img, safe_url)
706
-
707
- # Fallback: redirect to original URL
708
- # Fetch og:image for better rich preview
709
- if url and not safe_img:
710
- try:
711
- art = _scrape_article_fast(url)
712
- if art and art.get('og_image'):
713
- safe_img = art.get('og_image', '')
714
- if art and art.get('title'):
715
- safe_title = art.get('title', safe_title)
716
- except:
717
- pass
718
-
719
- return HTMLResponse(f'''<!DOCTYPE html>
720
- <html lang="vi">
721
- <head>
722
- <meta charset="utf-8">
723
- <meta name="viewport" content="width=device-width,initial-scale=1">
724
- <title>{safe_title}</title>
725
- <meta property="og:title" content="{safe_title}">
726
- <meta property="og:image" content="{safe_img}">
727
- <meta property="og:description" content="Tin tức tóm tắt, AI rewrite, World Cup 2026">
728
- <meta property="og:url" content="{SPACE}/s?url={quote(safe_url)}">
729
- <link rel="canonical" href="{SPACE}/s?url={quote(safe_url)}">
730
- <meta http-equiv="refresh" content="0;url={safe_url}">
731
- </head><body></body></html>''')
732
-
733
- 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
734
-
735
- _xlb_cache = {}
736
- _xlb_lock = threading.Lock()
737
-
738
- def _xlb_scrape(path):
739
- url = f"https://xemlaibongda.top/{path}"
740
- r = req.get(url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}, timeout=15, allow_redirects=True)
741
- if r.status_code != 200:
742
- return []
743
- soup = BeautifulSoup(r.text, 'lxml')
744
- vids = []
745
- seen = set()
746
- for a in soup.select('a[href*="/video/"]'):
747
- href = a.get('href', '')
748
- if not href or href in seen:
749
- continue
750
- seen.add(href)
751
- if not href.startswith('http'):
752
- href = 'https://xemlaibongda.top' + href
753
- img = a.select_one('img')
754
- p = a.parent
755
- for _ in range(4):
756
- if img:
757
- break
758
- if p:
759
- img = p.select_one('img')
760
- p = p.parent
761
- img_src = ''
762
- if img:
763
- img_src = img.get('data-src','') or img.get('src','') or img.get('data-lazy','') or img.get('data-original','')
764
- if img_src.startswith('//'):
765
- img_src = 'https:' + img_src
766
- elif img_src.startswith('/'):
767
- img_src = 'https://xemlaibongda.top' + img_src
768
- title = ''
769
- for sel in ['.title', 'h3', 'h2', '.name', '.post-title', '.entry-title', '.video-title']:
770
- t = a.select_one(sel)
771
- if t:
772
- title = _clean(t.get_text())
773
- break
774
- if not title:
775
- title = _clean(a.get('title',''))
776
- if not title:
777
- img_alt = a.select_one('img')
778
- if img_alt:
779
- title = _clean(img_alt.get('alt',''))
780
- if not title:
781
- parent = a.parent
782
- if parent:
783
- pt = _clean(parent.get_text(' ',strip=True))
784
- if 5 < len(pt) < 120:
785
- title = pt
786
- if not title or len(title) < 3:
787
- continue
788
- vids.append({"link": href, "img": img_src, "title": title})
789
- if len(vids) >= 30:
790
- break
791
- return vids
792
-
793
- @app.get('/api/proxy/xlb')
794
- def proxy_xlb(path: str = Query(default="")):
795
- now = time.time()
796
- cache_key = f"xlb:{path}"
797
- with _xlb_lock:
798
- cached = _xlb_cache.get(cache_key)
799
- if cached and now - cached['t'] < 120:
800
- return JSONResponse(cached['d'])
801
- try:
802
- vids = _xlb_scrape(path)
803
- result = {"videos": vids, "count": len(vids)}
804
- with _xlb_lock:
805
- _xlb_cache[cache_key] = {'t': now, 'd': result}
806
- return JSONResponse(result)
807
- except Exception as e:
808
- return JSONResponse({"videos": [], "count": 0, "error": str(e)}, status_code=500)
809
-
810
- @app.get('/api/wc2026')
811
- def _w():return JSONResponse(get_wc2026_all())
812
- @app.get('/api/wc2026/fixtures')
813
- def _wf():return JSONResponse(scrape_fixtures())
814
- @app.get('/api/wc2026/standings')
815
- def _ws():return JSONResponse(scrape_standings())
816
- @app.get('/api/wc2026/stats')
817
- def _wst():return JSONResponse(scrape_stats())
818
- @app.get('/api/wc2026/history')
819
- def _whi():return JSONResponse(scrape_history())
820
- @app.get('/api/wc2026/news')
821
- def _wn():return JSONResponse(scrape_wc_news())
822
- @app.get('/api/wc2026/road')
823
- def _wr():return JSONResponse(scrape_road_to_wc())
824
- @app.get('/api/wc2026/h2h/{eid}')
825
- def _wh2(eid:int):return JSONResponse(scrape_h2h(eid))
826
- @app.get('/api/wc2026/lineups/{eid}')
827
- def _wl(eid:int):return JSONResponse(scrape_lineups(eid))
828
- @app.get('/api/wc2026/match/{eid}')
829
- def _wm(eid:int):return JSONResponse(scrape_match_detail(eid))
830
-
831
- DATA_DIR='/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)),'data')
832
- os.makedirs(DATA_DIR,exist_ok=True)
833
- IF=os.path.join(DATA_DIR,'interactions_v2.json')
834
- CF=os.path.join(DATA_DIR,'comments_v2.json')
835
- WALL_FILE=os.path.join(DATA_DIR,'wall_posts.json')
836
- WALL_VIDEO_DIR=os.path.join(DATA_DIR,'wall_videos')
837
- os.makedirs(WALL_VIDEO_DIR,exist_ok=True)
838
-
839
- _il=threading.Lock();_cl=threading.Lock();_wl_lock=threading.Lock()
840
- def _lj(p):
841
- try:
842
- if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8'))
843
- except:pass
844
- return [] # Return empty list instead of dict for wall posts
845
- def _sj(p,d):
846
- try:open(p+'.tmp','w',encoding='utf-8').write(json.dumps(d,ensure_ascii=False));os.replace(p+'.tmp',p)
847
- except:pass
848
-
849
- @app.post('/api/v2/interact')
850
- async def _int(request:Request):
851
- b=await request.json();v=str(b.get('id','')).strip();t=str(b.get('type','')).strip()
852
- if not v or t not in('view','like'):return JSONResponse({'error':'x'},status_code=400)
853
- 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])
854
-
855
- @app.get('/api/v2/interactions')
856
- def _gi(id:str=Query(...)):
857
- with _il:return JSONResponse(_lj(IF).get(id.strip(),{'views':0,'likes':0,'comments':0}))
858
-
859
- @app.get('/api/v2/comments')
860
- def _gc(id:str=Query(...)):
861
- with _cl:return JSONResponse({'comments':_lj(CF).get(id.strip(),[])})
862
-
863
- @app.post('/api/v2/comment')
864
- async def _pc(request:Request):
865
- b=await request.json();v=str(b.get('id','')).strip();tx=str(b.get('text','')).strip()[:500]
866
- if not v or not tx:return JSONResponse({'error':'x'},status_code=400)
867
- c={'text':tx,'time':time.strftime('%H:%M %d/%m',time.localtime()),'ts':int(time.time())}
868
- with _cl:db=_lj(CF);db.setdefault(v,[]);db[v].append(c);db[v]=db[v][-200:];_sj(CF,db);cms=db[v]
869
- with _il:idb=_lj(IF);idb.setdefault(v,{'views':0,'likes':0,'comments':0});idb[v]['comments']=len(cms);_sj(IF,idb)
870
- return JSONResponse({'comments':cms})
871
-
872
- def _load_wall_posts():
873
- with _wl_lock:
874
- return _lj(WALL_FILE)
875
-
876
- def _save_wall_posts(posts):
877
- with _wl_lock:
878
- _sj(WALL_FILE, posts)
879
-
880
- @app.get('/api/wall')
881
- def api_wall():
882
- posts = _load_wall_posts()
883
- if not posts:
884
- return JSONResponse({"posts": []})
885
- return JSONResponse({"posts": posts})
886
-
887
- @app.post('/api/wall')
888
- async def api_wall_post(request: Request):
889
- content_type = request.headers.get('content-type', '')
890
- if 'multipart/form-data' in content_type:
891
- try:
892
- form = await request.form()
893
- except Exception as e:
894
- return JSONResponse({"error": f"Form parse error: {str(e)}"}, status_code=400)
895
- title = form.get('title', 'Video mới') or 'Video mới'
896
- text = form.get('text', '') or ''
897
- source = form.get('source', 'vtv_recorder') or 'vtv_recorder'
898
- video_file = form.get('video')
899
- post_id = str(uuid.uuid4())[:12]
900
- video_url = None
901
- if video_file and hasattr(video_file, 'filename') and video_file.filename:
902
- fname = video_file.filename.lower()
903
- if fname.endswith('.mp4'):
904
- ext = '.mp4'
905
- elif fname.endswith('.webm'):
906
- ext = '.webm'
907
- else:
908
- ext = '.webm'
909
- video_filename = f"wall_{post_id}{ext}"
910
- video_path = os.path.join(WALL_VIDEO_DIR, video_filename)
911
- try:
912
- content = await video_file.read()
913
- if not content:
914
- return JSONResponse({"error": "Empty video file"}, status_code=400)
915
- with open(video_path, 'wb') as f:
916
- f.write(content)
917
- file_size_mb = len(content) / 1024 / 1024
918
- if file_size_mb > 50:
919
- os.remove(video_path)
920
- return JSONResponse({"error": f"Video quá lớn ({file_size_mb:.1f}MB). Tối đa 50MB."}, status_code=400)
921
- video_url = f"/api/wall/video/{video_filename}"
922
- except Exception as e:
923
- return JSONResponse({"error": f"Lỗi lưu video: {str(e)}"}, status_code=500)
924
- post = {
925
- "id": post_id,
926
- "title": title[:200],
927
- "text": text[:2000],
928
- "source": source,
929
- "video": video_url,
930
- "img": None,
931
- "images": [],
932
- "created": int(time.time()),
933
- "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
934
- }
935
- posts = _load_wall_posts()
936
- if not isinstance(posts, list):
937
- posts = []
938
- posts.insert(0, post)
939
- posts = posts[:200]
940
- _save_wall_posts(posts)
941
- return JSONResponse({"post": post, "ok": True})
942
- try:
943
- body = await request.json()
944
- except:
945
- body = {}
946
- title = body.get('title', 'Bài mới') or 'Bài mới'
947
- text = body.get('text', '') or ''
948
- img = body.get('img', None)
949
- source = body.get('source', 'user') or 'user'
950
- post_id = str(uuid.uuid4())[:12]
951
- post = {
952
- "id": post_id,
953
- "title": title[:200],
954
- "text": text[:2000],
955
- "source": source,
956
- "video": None,
957
- "img": img,
958
- "images": [],
959
- "created": int(time.time()),
960
- "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
961
- }
962
- posts = _load_wall_posts()
963
- if not isinstance(posts, list):
964
- posts = []
965
- posts.insert(0, post)
966
- posts = posts[:200]
967
- _save_wall_posts(posts)
968
- return JSONResponse({"post": post, "ok": True})
969
-
970
- @app.get('/api/wall/video/{filename}')
971
- def api_wall_video(filename: str):
972
- if '..' in filename or '/' in filename:
973
- return Response(status_code=403)
974
- video_path = os.path.join(WALL_VIDEO_DIR, filename)
975
- if not os.path.exists(video_path):
976
- return Response(status_code=404)
977
- ext = os.path.splitext(filename)[1].lower()
978
- media_type = 'video/mp4' if ext == '.mp4' else 'video/webm'
979
- return FileResponse(video_path, media_type=media_type)
980
-
981
- @app.delete('/api/wall/{post_id}')
982
- def api_wall_delete(post_id: str):
983
- posts = _load_wall_posts()
984
- if not isinstance(posts, list):
985
- return JSONResponse({"error": "No posts"}, status_code=404)
986
- for i, p in enumerate(posts):
987
- if p.get('id') == post_id:
988
- if p.get('video'):
989
- video_name = p['video'].split('/')[-1]
990
- video_path = os.path.join(WALL_VIDEO_DIR, video_name)
991
- if os.path.exists(video_path):
992
- os.remove(video_path)
993
- posts.pop(i)
994
- _save_wall_posts(posts)
995
- return JSONResponse({"ok": True})
996
- return JSONResponse({"error": "Post not found"}, status_code=404)
997
-
998
- # ===== LANGUAGE & EMOTION DETECTION =====
999
- import random as _random2
1000
- from urllib.parse import quote as _quote2
1001
-
1002
- _UA_RW = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept-Language': 'vi-VN,vi;q=0.9'}
1003
-
1004
- # Unique character markers for language detection
1005
- _UNIQUE_CHARS = {
1006
- 'vietnamese': set('đăâêôơưàảãạáằẳẵặắầẩẫậấèẻẽẹéềễểệếìỉĩịíòỏõọóồổỗộốờởỡợớùủũụúừửữựứỳỷỹỵý'),
1007
- 'spanish': set('ñáéíóúü¿¡'),
1008
- 'portuguese': set('ãõçáéíóúâêôà'),
1009
- }
1010
-
1011
- _STOPWORDS = {
1012
- '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'},
1013
- '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'},
1014
- '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'},
1015
- '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'},
1016
- }
1017
-
1018
- def detect_language(text):
1019
- """Detect language from text content using stopword + character analysis."""
1020
- if not text:
1021
- return 'vietnamese'
1022
- text_lower = text.lower()
1023
- text_chars = set(text_lower)
1024
-
1025
- # Strong signal: Vietnamese unique characters
1026
- vn_chars = len(text_chars & _UNIQUE_CHARS['vietnamese'])
1027
- if vn_chars >= 2:
1028
- return 'vietnamese'
1029
-
1030
- # Spanish unique chars (ñ, ¿, ¡)
1031
- es_chars = len(text_chars & _UNIQUE_CHARS['spanish'])
1032
- pt_chars = len(text_chars & _UNIQUE_CHARS['portuguese'])
1033
-
1034
- # Stopword scoring
1035
- words = set(re.findall(r'\b\w+\b', text_lower))
1036
- scores = {}
1037
- for lang, stops in _STOPWORDS.items():
1038
- scores[lang] = len(words & stops) / max(len(stops), 1)
1039
-
1040
- # Disambiguate Portuguese vs Spanish
1041
- 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'}
1042
- 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'}
1043
-
1044
- pt_overlap = len(words & pt_markers)
1045
- es_overlap = len(words & es_markers)
1046
-
1047
- if scores.get('portuguese', 0) > 0 and pt_overlap > es_overlap:
1048
- return 'portuguese'
1049
- if scores.get('spanish', 0) > 0 and es_overlap > pt_overlap:
1050
- return 'spanish'
1051
- if scores.get('english', 0) > 0.15:
1052
- return 'english'
1053
-
1054
- best = max(scores, key=scores.get)
1055
- return best if scores[best] > 0.05 else 'vietnamese'
1056
-
1057
- # Emotion keyword-based detection
1058
- _EMOTION_KEYWORDS = {
1059
- 'happy': {
1060
- 'en': ['happy', 'joy', 'wonderful', 'great', 'amazing', 'fantastic', 'love', 'excellent', 'beautiful', 'glad', 'delighted', 'pleased', 'cheerful', 'celebrate', 'victory', 'win', 'success'],
1061
- 'pt': ['feliz', 'alegria', 'maravilhoso', 'ótimo', 'incrível', 'fantástico', 'amor', 'excelente', 'lindo', 'contente', 'encantado', 'vitória', 'sucesso'],
1062
- 'es': ['feliz', 'alegria', 'maravilloso', 'genial', 'increíble', 'fantástico', 'amor', 'excelente', 'hermoso', 'contento', 'encantado', 'victoria', 'éxito'],
1063
- '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'],
1064
- },
1065
- 'sad': {
1066
- 'en': ['sad', 'unhappy', 'terrible', 'awful', 'horrible', 'miserable', 'depressed', 'grief', 'sorrow', 'tragic', 'unfortunate', 'painful', 'death', 'die', 'kill'],
1067
- 'pt': ['triste', 'infeliz', 'terrível', 'horrível', 'miserável', 'deprimido', 'dor', 'trágico', 'infelizmente', 'penoso', 'morte', 'morrer'],
1068
- 'es': ['triste', 'infeliz', 'terrible', 'horrible', 'miserable', 'deprimido', 'dolor', 'trágico', 'desafortunado', 'penoso', 'muerte', 'morir'],
1069
- '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'],
1070
- },
1071
- 'excited': {
1072
- 'en': ['excited', 'thrilling', 'amazing', 'wow', 'incredible', 'unbelievable', 'awesome', 'exhilarating', 'electrifying', 'breathtaking', 'breakthrough', 'record'],
1073
- 'pt': ['animado', 'emocionante', 'incrível', 'impressionante', 'sensacional', 'eletrizante', 'empolgante', 'recorde'],
1074
- 'es': ['emocionante', 'increíble', 'impresionante', 'sensacional', 'electrizante', 'emocionado', 'entusiasmado', 'récord'],
1075
- '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á'],
1076
- },
1077
- 'humorous': {
1078
- 'en': ['funny', 'hilarious', 'joke', 'laugh', 'comedy', 'humor', 'amusing', 'witty', 'sarcastic', 'ironic', 'ridiculous', 'absurd', 'lol', 'haha'],
1079
- 'pt': ['engraçado', 'hilário', 'piada', 'rir', 'comédia', 'humor', 'divertido', 'irônico', 'ridículo', 'absurdo', 'kkk'],
1080
- 'es': ['gracioso', 'hilarante', 'broma', 'risa', 'comedia', 'humor', 'divertido', 'irónico', 'ridículo', 'absurdo', 'jaja'],
1081
- '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'],
1082
- },
1083
- 'serious': {
1084
- 'en': ['serious', 'critical', 'important', 'urgent', 'severe', 'grave', 'significant', 'crucial', 'vital', 'essential', 'alarming', 'concerning', 'crisis', 'war', 'conflict'],
1085
- 'pt': ['sério', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'essencial', 'preocupante', 'crise', 'guerra', 'conflito'],
1086
- 'es': ['serio', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'esencial', 'preocupante', 'crisis', 'guerra', 'conflicto'],
1087
- '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'],
1088
- },
1089
- }
1090
-
1091
- def detect_emotion(text, language='vietnamese'):
1092
- """Detect emotion from text using keyword matching."""
1093
- if not text:
1094
- return 'neutral'
1095
- text_lower = text.lower()
1096
-
1097
- scores = {}
1098
- for emotion, lang_keywords in _EMOTION_KEYWORDS.items():
1099
- keywords = lang_keywords.get(language, lang_keywords.get('en', []))
1100
- score = sum(1 for kw in keywords if kw in text_lower)
1101
- scores[emotion] = score
1102
-
1103
- if max(scores.values()) == 0:
1104
- return 'neutral'
1105
-
1106
- return max(scores, key=scores.get)
1107
-
1108
- def detect_language_and_emotion(title, text):
1109
- """Detect both language and emotion from article content."""
1110
- combined = f"{title} {text}"
1111
- lang = detect_language(combined)
1112
- emotion = detect_emotion(combined, lang)
1113
- return lang, emotion
1114
-
1115
- # Voice selection based on language and emotion (using MultilingualNeural voices)
1116
- VOICE_BY_LANG_EMOTION = {
1117
- 'vietnamese': {
1118
- 'happy': ('vi-VN-HoaiMyNeural', 'vui'),
1119
- 'sad': ('vi-VN-NamMinhNeural', 'buồn'),
1120
- 'excited': ('vi-VN-HoaiMyNeural', 'hào hứng'),
1121
- 'humorous': ('vi-VN-HoaiMyNeural', 'vui'),
1122
- 'serious': ('vi-VN-NamMinhNeural', 'nghiêm túc'),
1123
- 'neutral': ('vi-VN-HoaiMyNeural', 'trung_tinh'),
1124
- },
1125
- 'portuguese': {
1126
- 'happy': ('pt-BR-ThalitaMultilingualNeural', 'feliz'),
1127
- 'sad': ('pt-BR-ThalitaMultilingualNeural', 'triste'),
1128
- 'excited': ('pt-BR-ThalitaMultilingualNeural', 'animado'),
1129
- 'humorous': ('pt-BR-ThalitaMultilingualNeural', 'engraçado'),
1130
- 'serious': ('pt-BR-ThalitaMultilingualNeural', 'sério'),
1131
- 'neutral': ('pt-BR-ThalitaMultilingualNeural', 'neutro'),
1132
- },
1133
- 'english': {
1134
- 'happy': ('en-US-AndrewMultilingualNeural', 'happy'),
1135
- 'sad': ('en-AU-WilliamMultilingualNeural', 'sad'),
1136
- 'excited': ('en-US-AndrewMultilingualNeural', 'excited'),
1137
- 'humorous': ('en-US-AndrewMultilingualNeural', 'funny'),
1138
- 'serious': ('en-AU-WilliamMultilingualNeural', 'serious'),
1139
- 'neutral': ('en-US-AndrewMultilingualNeural', 'neutral'),
1140
- },
1141
- 'french': {
1142
- 'happy': ('fr-FR-VivienneMultilingualNeural', 'heureux'),
1143
- 'sad': ('fr-FR-RemyMultilingualNeural', 'triste'),
1144
- 'excited': ('fr-FR-VivienneMultilingualNeural', 'excité'),
1145
- 'humorous': ('fr-FR-VivienneMultilingualNeural', 'drôle'),
1146
- 'serious': ('fr-FR-RemyMultilingualNeural', 'sérieux'),
1147
- 'neutral': ('fr-FR-VivienneMultilingualNeural', 'neutre'),
1148
- },
1149
- 'german': {
1150
- 'happy': ('de-DE-SeraphinaMultilingualNeural', 'glücklich'),
1151
- 'sad': ('de-DE-FlorianMultilingualNeural', 'traurig'),
1152
- 'excited': ('de-DE-SeraphinaMultilingualNeural', 'aufgeregt'),
1153
- 'humorous': ('de-DE-SeraphinaMultilingualNeural', 'lustig'),
1154
- 'serious': ('de-DE-FlorianMultilingualNeural', 'ernst'),
1155
- 'neutral': ('de-DE-SeraphinaMultilingualNeural', 'neutral'),
1156
- },
1157
- 'korean': {
1158
- 'happy': ('ko-KR-HyunsuMultilingualNeural', '행복'),
1159
- 'sad': ('ko-KR-HyunsuMultilingualNeural', '슬픔'),
1160
- 'excited': ('ko-KR-HyunsuMultilingualNeural', '흥분'),
1161
- 'humorous': ('ko-KR-HyunsuMultilingualNeural', '유쾌'),
1162
- 'serious': ('ko-KR-HyunsuMultilingualNeural', '진지'),
1163
- 'neutral': ('ko-KR-HyunsuMultilingualNeural', '중립'),
1164
- },
1165
- 'italian': {
1166
- 'happy': ('it-IT-GiuseppeMultilingualNeural', 'felice'),
1167
- 'sad': ('it-IT-GiuseppeMultilingualNeural', 'triste'),
1168
- 'excited': ('it-IT-GiuseppeMultilingualNeural', 'emozionato'),
1169
- 'humorous': ('it-IT-GiuseppeMultilingualNeural', 'divertente'),
1170
- 'serious': ('it-IT-GiuseppeMultilingualNeural', 'serio'),
1171
- 'neutral': ('it-IT-GiuseppeMultilingualNeural', 'neutro'),
1172
- },
1173
- }
1174
-
1175
- # All valid voice IDs (new MultilingualNeural format)
1176
- VALID_VOICES = {
1177
- 'vi-VN-HoaiMyNeural', 'vi-VN-NamMinhNeural',
1178
- 'en-US-AndrewMultilingualNeural', 'en-AU-WilliamMultilingualNeural',
1179
- 'pt-BR-ThalitaMultilingualNeural',
1180
- 'fr-FR-VivienneMultilingualNeural', 'fr-FR-RemyMultilingualNeural',
1181
- 'de-DE-SeraphinaMultilingualNeural', 'de-DE-FlorianMultilingualNeural',
1182
- 'ko-KR-HyunsuMultilingualNeural',
1183
- 'it-IT-GiuseppeMultilingualNeural',
1184
- }
1185
-
1186
- def get_voice_for_content(title, text, preferred_voice=None):
1187
- """Get appropriate voice based on content language and emotion."""
1188
- # Accept the new MultilingualNeural voices directly
1189
- if preferred_voice and preferred_voice in VALID_VOICES:
1190
- return preferred_voice
1191
-
1192
- # Also accept old shorthand voice IDs and map them to new format
1193
- old_voice_map = {
1194
- 'hoaimy': 'vi-VN-HoaiMyNeural',
1195
- 'namminh': 'vi-VN-NamMinhNeural',
1196
- 'andrew': 'en-US-AndrewMultilingualNeural',
1197
- 'jenny': 'en-US-AndrewMultilingualNeural',
1198
- 'thalita': 'pt-BR-ThalitaMultilingualNeural',
1199
- 'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
1200
- 'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
1201
- 'ela': 'en-US-AndrewMultilingualNeural',
1202
- 'es_carlos': 'en-US-AndrewMultilingualNeural',
1203
- 'denise': 'fr-FR-VivienneMultilingualNeural',
1204
- 'katja': 'de-DE-SeraphinaMultilingualNeural',
1205
- 'nanami': 'en-US-AndrewMultilingualNeural',
1206
- 'sunhee': 'ko-KR-HyunsuMultilingualNeural',
1207
- 'xiaochen': 'en-US-AndrewMultilingualNeural',
1208
- }
1209
- if preferred_voice and preferred_voice in old_voice_map:
1210
- return old_voice_map[preferred_voice]
1211
-
1212
- lang, emotion = detect_language_and_emotion(title, text)
1213
- lang_map = VOICE_BY_LANG_EMOTION.get(lang, VOICE_BY_LANG_EMOTION['vietnamese'])
1214
- voice, _ = lang_map.get(emotion, lang_map['neutral'])
1215
- return voice
1216
-
1217
-
1218
- def _is_relevant_image(img_url, title, text):
1219
- """Check if an image is relevant to the article content."""
1220
- if not img_url:
1221
- return False
1222
- skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif',
1223
- 'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite',
1224
- 'advertisement', 'ad-banner', 'sponsored', 'banner-ads']
1225
- img_lower = img_url.lower()
1226
- for p in skip_patterns:
1227
- if p in img_lower:
1228
- return False
1229
- if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']):
1230
- return False
1231
- return True
1232
-
1233
-
1234
- def _filter_relevant_images(images, title, text, max_images=8):
1235
- """Filter and rank images by relevance to article content."""
1236
- if not images:
1237
- return []
1238
- seen = set()
1239
- relevant = []
1240
- for img in images:
1241
- if img in seen:
1242
- continue
1243
- seen.add(img)
1244
- if _is_relevant_image(img, title, text):
1245
- relevant.append(img)
1246
- return relevant[:max_images]
1247
-
1248
-
1249
- def _scrape_article_for_rewrite(url):
1250
- """Scrape article: extract title, paragraphs, RELEVANT images, OG image."""
1251
- try:
1252
- r = req.get(url, headers=_UA_RW, timeout=15, allow_redirects=True)
1253
- r.encoding = 'utf-8'
1254
- soup = BeautifulSoup(r.text, 'lxml')
1255
- for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
1256
- tag.decompose()
1257
- h1 = soup.find('h1')
1258
- ogt = soup.find('meta', property='og:title')
1259
- title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '')
1260
- ogi = soup.find('meta', property='og:image')
1261
- og_img = ogi.get('content', '') if ogi else ''
1262
- if og_img and og_img.startswith('//'):
1263
- og_img = 'https:' + og_img
1264
- block = None
1265
- for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
1266
- el = soup.select_one(sel)
1267
- if el and len(el.find_all('p')) >= 2:
1268
- block = el
1269
- break
1270
- if not block:
1271
- block = soup.body or soup
1272
- paragraphs = []
1273
- all_images = []
1274
- seen_imgs = set()
1275
- if og_img and og_img not in seen_imgs:
1276
- all_images.append(og_img)
1277
- seen_imgs.add(og_img)
1278
- for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
1279
- if el.name == 'p':
1280
- t = _clean(el.get_text(strip=True))
1281
- if t and len(t) > 40:
1282
- paragraphs.append(t)
1283
- elif el.name in ('figure', 'img'):
1284
- im = el if el.name == 'img' else el.find('img')
1285
- if im:
1286
- src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
1287
- if src and 'base64' not in src:
1288
- if src.startswith('//'):
1289
- src = 'https:' + src
1290
- if src not in seen_imgs:
1291
- all_images.append(src)
1292
- seen_imgs.add(src)
1293
- # Filter to relevant images only
1294
- relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5]))
1295
- return {'title': _clean(title), 'paragraphs': paragraphs, 'images': relevant_images, 'og_img': og_img}
1296
- except Exception:
1297
- return None
1298
-
1299
-
1300
- def _extract_key_points_rw(paragraphs, max_points=5):
1301
- r"""Extract key points from paragraphs - extracts ALL sentences, not just first one.
1302
-
1303
- Fixes: Original regex `^(.+?[.!?])\s` only captured first sentence per paragraph.
1304
- Now splits on all sentence boundaries and takes valid sentences until max_points.
1305
- """
1306
- points = []
1307
-
1308
- for p in paragraphs:
1309
- if len(points) >= max_points:
1310
- break
1311
-
1312
- p = _clean(p)
1313
- if not p:
1314
- continue
1315
-
1316
- # Split paragraph into sentences using Vietnamese + English punctuation
1317
- sentences = re.split(r'(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])', p)
1318
- sentences = [s.strip() for s in sentences if s.strip()]
1319
-
1320
- for sentence in sentences:
1321
- if len(points) >= max_points:
1322
- break
1323
-
1324
- # Clean sentence - remove extra whitespace
1325
- sentence = _clean(sentence)
1326
-
1327
- if len(sentence) < 30:
1328
- continue
1329
-
1330
- # Check for duplicates
1331
- if any(sentence[:60] in existing for existing in points):
1332
- continue
1333
-
1334
- # Ensure sentence ends with punctuation
1335
- if not sentence.endswith(('.', '!', '?')):
1336
- sentence = sentence + '.'
1337
-
1338
- points.append(sentence)
1339
-
1340
- # If no valid sentences found, take chunks from raw text
1341
- if not points:
1342
- raw = '\n'.join(paragraphs)
1343
- for i in range(0, min(len(raw), max_points * 300), 280):
1344
- chunk = _clean(raw[i:i+280])
1345
- if len(chunk) >= 30 and chunk not in points:
1346
- points.append(chunk + ('.' if not chunk.endswith('.') else ''))
1347
- if len(points) >= max_points:
1348
- break
1349
-
1350
- return points
1351
-
1352
-
1353
- @app.post("/api/rewrite_slide")
1354
- async def api_rewrite_slide(request: Request):
1355
- """Fast rewrite as SLIDES - no AI needed, instant response."""
1356
- body = await request.json()
1357
- url = _clean(body.get("url", ""))
1358
- context = body.get("context", "")
1359
- preferred_voice = body.get("voice", "") # Accept custom voice selection
1360
- if not url and not context:
1361
- return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
1362
- data = None
1363
- if url and url.startswith("http"):
1364
- data = _scrape_article_for_rewrite(url)
1365
- if not data and context:
1366
- paragraphs = [_clean(p) for p in context.split('\n') if len(_clean(p)) > 40]
1367
- data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
1368
- if not data or not data.get('paragraphs'):
1369
- return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
1370
- points = _extract_key_points_rw(data['paragraphs'], max_points=12)
1371
- if not points:
1372
- return JSONResponse({"error": "Không tìm được ý chính"}, status_code=422)
1373
- images = data.get('images', [])
1374
- slides = []
1375
- for i, point in enumerate(points):
1376
- img = images[i] if i < len(images) else (images[-1] if images else '')
1377
- if img and 'cdnphoto.dantri' in img:
1378
- img = '/api/proxy/img?url=' + _quote2(img, safe='')
1379
- slides.append({'text': point, 'image': img, 'index': i + 1})
1380
- summary_text = '\n\n'.join([f"• {s['text']}" for s in slides])
1381
-
1382
- # Auto-detect language and emotion
1383
- lang, emotion = detect_language_and_emotion(data['title'], summary_text)
1384
- # Use preferred voice if provided, otherwise auto-detect
1385
- voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], summary_text)
1386
-
1387
- post = {
1388
- "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
1389
- "title": data['title'],
1390
- "text": summary_text,
1391
- "img": images[0] if images else '',
1392
- "url": url,
1393
- "kind": "slide_summary",
1394
- "slides": slides,
1395
- "images": images[:10],
1396
- "video": "",
1397
- "voice": voice,
1398
- "emotion": emotion,
1399
- "language": lang,
1400
- "ts": int(time.time())
1401
- }
1402
- posts = _load_wall_posts()
1403
- posts.insert(0, post)
1404
- _save_wall_posts(posts)
1405
- return JSONResponse({"post": post, "slides": slides})
1406
-
1407
-
1408
- @app.post("/api/rewrite_share")
1409
- async def api_rewrite_share(request: Request):
1410
- """Rewrite article and post to Tường AI with SLIDES + AI text."""
1411
- body = await request.json()
1412
- url = _clean(body.get("url", ""))
1413
- ctx = _clean(body.get("context", ""))
1414
- preferred_voice = body.get("voice", "") # Accept custom voice selection
1415
- if not url and not ctx:
1416
- return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
1417
- data = None
1418
- if url and url.startswith("http"):
1419
- data = _scrape_article_for_rewrite(url)
1420
- if not data and ctx:
1421
- paragraphs = [_clean(p) for p in ctx.split('\n') if len(_clean(p)) > 40]
1422
- data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
1423
- if not data or not data.get('paragraphs'):
1424
- return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
1425
- raw_text = '\n'.join(data['paragraphs'])
1426
- if len(raw_text) < 50:
1427
- raw_text = ctx[:14000]
1428
- if len(raw_text) < 50:
1429
- return JSONResponse({"error": "Bài viết quá ngắn"}, status_code=422)
1430
- domain = ''
1431
- try:
1432
- from urllib.parse import urlparse
1433
- domain = urlparse(url).netloc.replace('www.', '')
1434
- except:
1435
- pass
1436
-
1437
- # Generate AI summary text
1438
- ai_text = None
1439
- try:
1440
- import ai_ext
1441
- if hasattr(ai_ext, 'qwen_generate'):
1442
- 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.'
1443
- ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000)
1444
- except Exception:
1445
- pass
1446
- if not ai_text or len(ai_text) < 80:
1447
- key_pts = _extract_key_points_rw(data['paragraphs'], max_points=12)
1448
- if key_pts:
1449
- ai_text = '\n\n'.join([f"• {p}" for p in key_pts])
1450
- else:
1451
- ai_text = f"Tóm tắt: {data['title']}\n\n{raw_text[:1200]}\n\nNguồn: {domain}"
1452
-
1453
- # Build slides from key points (FIX: include slides in rewrite_share too!)
1454
- points = _extract_key_points_rw(data['paragraphs'], max_points=12)
1455
- images = data.get('images', [])
1456
- slides = []
1457
- for i, point in enumerate(points):
1458
- img = images[i] if i < len(images) else (images[-1] if images else '')
1459
- if img and 'cdnphoto.dantri' in img:
1460
- img = '/api/proxy/img?url=' + _quote2(img, safe='')
1461
- slides.append({'text': point, 'image': img, 'index': i + 1})
1462
-
1463
- # Auto-detect language and emotion
1464
- lang, emotion = detect_language_and_emotion(data['title'], ai_text)
1465
- # Use preferred voice if provided, otherwise auto-detect
1466
- voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], ai_text)
1467
-
1468
- post = {
1469
- "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
1470
- "title": data['title'],
1471
- "text": ai_text,
1472
- "img": images[0] if images else '',
1473
- "url": url,
1474
- "kind": "rewrite",
1475
- "slides": slides,
1476
- "images": images[:10],
1477
- "video": "",
1478
- "voice": voice,
1479
- "emotion": emotion,
1480
- "language": lang,
1481
- "ts": int(time.time())
1482
- }
1483
- posts = _load_wall_posts()
1484
- posts.insert(0, post)
1485
- _save_wall_posts(posts)
1486
- return JSONResponse({"post": post, "slides": slides})
1487
-
1488
-
1489
- @app.post("/api/url_wall")
1490
- async def api_url_wall(request: Request):
1491
- """Submit URL to add to Tường AI."""
1492
- body = await request.json()
1493
- url = _clean(body.get("url", ""))
1494
- if not url or not url.startswith('http'):
1495
- return JSONResponse({"error": "URL không hợp lệ"}, status_code=400)
1496
- # Reuse rewrite_share logic
1497
- req._body = json.dumps({"url": url}).encode()
1498
- return await api_rewrite_share(request)
1499
-
1500
-
1501
- def _bg():
1502
- time.sleep(15)
1503
- while True:
1504
- try:get_wc2026_all()
1505
- except:pass
1506
- time.sleep(90)
1507
- threading.Thread(target=_bg,daemon=True).start()
1508
-
1509
- # ===== AUTO SCHEDULER: rewrite AI + short at 7/13/19 VN time =====
1510
- _AUTO_SCHEDULE_TIMES = [(7, '07:00'), (13, '13:00'), (19, '19:00')]
1511
- _AUTO_LOG = os.path.join(DATA_DIR, 'auto_rewrite_log.json')
1512
-
1513
- def _load_auto_log():
1514
- try:
1515
- if os.path.exists(_AUTO_LOG):
1516
- with open(_AUTO_LOG, 'r') as f:
1517
- return json.load(f)
1518
- except: pass
1519
- return {}
1520
-
1521
- def _save_auto_log(log):
1522
- try:
1523
- tmp = _AUTO_LOG + '.tmp'
1524
- with open(tmp, 'w') as f:
1525
- json.dump(log, f)
1526
- os.replace(tmp, _AUTO_LOG)
1527
- except: pass
1528
-
1529
- async def _auto_fetch_short(post_id):
1530
- """Try to auto-generate a short for a post."""
1531
- try:
1532
- import httpx
1533
- async with httpx.AsyncClient(timeout=180) as cl:
1534
- r = await cl.post(
1535
- f"http://localhost:7860/api/ai/short/{post_id}",
1536
- json={"voice":"vi-VN-HoaiMyNeural","emotion":"neutral","speed":1.2},
1537
- headers={"Content-Type":"application/json"}
1538
- )
1539
- if r.status_code < 300:
1540
- sj = r.json()
1541
- if sj.get('video'):
1542
- posts = _load_wall_posts()
1543
- for p in posts:
1544
- if p.get('id') == post_id:
1545
- p['video'] = sj['video']
1546
- break
1547
- _save_wall_posts(posts)
1548
- return True
1549
- except: pass
1550
- return False
1551
-
1552
- async def _auto_rewrite_one(topic, slot_label, used_urls=None, post_index=0):
1553
- """Rewrite one topic: find articles, summarize, post to wall, trigger short.
1554
- used_urls: shared set to avoid duplicate articles across topics.
1555
- post_index: 0-based index to create multiple posts per topic (0,1,2 = up to 3 posts)."""
1556
- from urllib.parse import quote as _q
1557
- # Get MORE items to support 1-3 posts per topic
1558
- items = _search_all(topic, limit=12)
1559
- # Skip URLs already used by another topic
1560
- if used_urls is not None:
1561
- filtered = [it for it in items if it.get('url') not in used_urls]
1562
- if filtered:
1563
- items = filtered
1564
- if not items or post_index >= len(items):
1565
- return False
1566
-
1567
- # Get article at post_index (0,1,2 for multiple posts)
1568
- item = items[post_index] # post_index allows multiple articles per topic
1569
- url = item.get('url', '')
1570
- title = item.get('title', topic)
1571
- if url and used_urls is not None:
1572
- used_urls.add(url)
1573
- if not url.startswith('http'):
1574
- return False
1575
-
1576
- data = _scrape_article_for_rewrite(url)
1577
- if not data or not data.get('paragraphs'):
1578
- return False
1579
-
1580
- raw_text = '\n'.join(data['paragraphs'])
1581
- ai_text = None
1582
-
1583
- # Try AI generation
1584
- try:
1585
- import ai_ext
1586
- prompt = f"Tóm tắt tin tức (tự động {slot_label}):\nTiêu đề: {data['title']}\n{raw_text[:10000]}\n\n4-6 ý chính dạng bullet. Cuối ghi nguồn."
1587
- ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000)
1588
- except: pass
1589
-
1590
- if not ai_text or len(ai_text) < 80:
1591
- pts = data['paragraphs'][:6]
1592
- ai_text = '\n\n'.join([f"• {p[:300]}" for p in pts])
1593
- via = item.get('via', '') or urlparse(url).netloc.replace('www.', '')
1594
- ai_text += f"\n\nNguồn tham khảo: {via}"
1595
-
1596
- # Build slides
1597
- images = data.get('images', [])
1598
- pts = data['paragraphs'][:10]
1599
- slides = []
1600
- for i, p in enumerate(pts[:8]):
1601
- img = images[i] if i < len(images) else (images[-1] if images else data.get('og_img', ''))
1602
- slides.append({'text': p[:300], 'image': img, 'index': i + 1})
1603
-
1604
- post_id = str(int(time.time() * 1000)) + str(_random2.randint(100, 999))
1605
- post = {
1606
- "id": post_id, "title": data.get('title', title)[:200],
1607
- "text": ai_text, "img": images[0] if images else data.get('og_img', ''),
1608
- "url": url, "kind": "auto_rewrite", "slides": slides,
1609
- "images": images[:10], "video": "",
1610
- "voice": "vi-VN-HoaiMyNeural", "emotion": "neutral",
1611
- "language": "vietnamese", "ts": int(time.time()),
1612
- "auto_scheduled": True, "slot": slot_label,
1613
- }
1614
-
1615
- posts = _load_wall_posts()
1616
- posts.insert(0, post)
1617
- _save_wall_posts(posts)
1618
-
1619
- # Trigger short generation async
1620
- threading.Thread(target=lambda: asyncio.run(_auto_fetch_short(post_id)), daemon=True).start()
1621
- return True
1622
-
1623
- async def _do_scheduled_run(slot_label):
1624
- """Main scheduled run: 1-3 posts from 3 different HOT topics (3-9 total), no duplicates."""
1625
- print(f"[auto] Starting scheduled rewrite for {slot_label}")
1626
-
1627
- # Get top hot topics, skip duplicates
1628
- all_topics = _get_hot_topics()
1629
- seen_topics = set()
1630
- unique_topics = []
1631
- for t in all_topics:
1632
- kw = t.get('topic', '').lower().strip()
1633
- if kw and len(kw) > 5 and kw not in seen_topics:
1634
- is_dup = False
1635
- for s in seen_topics:
1636
- # Check if one topic is substring of another
1637
- if kw in s or s in kw:
1638
- is_dup = True
1639
- break
1640
- if not is_dup:
1641
- seen_topics.add(kw)
1642
- unique_topics.append(t)
1643
- if len(unique_topics) >= 3:
1644
- break
1645
-
1646
- job_topics = [t['topic'] for t in unique_topics[:3] if t.get('topic')]
1647
- if not job_topics:
1648
- print(f"[auto] No hot topics found, skipping")
1649
- return
1650
-
1651
- print(f"[auto] Running 3 topics: {job_topics}")
1652
-
1653
- # Track used URLs to avoid cross-topic duplicates
1654
- _used_urls = set()
1655
- results = []
1656
-
1657
- # Process each topic, create 1-3 posts per topic
1658
- for jt in job_topics:
1659
- for post_idx in range(3): # Try up to 3 posts per topic
1660
- try:
1661
- ok = await asyncio.wait_for(_auto_rewrite_one(jt, slot_label, _used_urls, post_idx), timeout=120)
1662
- if ok:
1663
- results.append((jt, post_idx, True))
1664
- print(f"[auto] Created post {post_idx+1} for '{jt}'")
1665
- else:
1666
- # No more articles for this topic
1667
- break
1668
- except Exception as e:
1669
- print(f"[auto] Error on '{jt}' post {post_idx}: {e}")
1670
- results.append((jt, post_idx, False))
1671
- await asyncio.sleep(1) # Small delay between posts
1672
-
1673
- # Ensure at least 3 posts total (fallback if needed)
1674
- successful_posts = sum(1 for _, _, ok in results if ok)
1675
- print(f"[auto] Done {slot_label}: {successful_posts} posts created")
1676
-
1677
- # Log
1678
- from datetime import datetime, timezone, timedelta
1679
- VN_TZ_SCHED = timezone(timedelta(hours=7))
1680
- today_str = datetime.now(VN_TZ_SCHED).strftime('%Y-%m-%d')
1681
- log = _load_auto_log()
1682
- if today_str not in log: log[today_str] = {}
1683
- log[today_str][slot_label] = {
1684
- 'time': datetime.now(VN_TZ_SCHED).strftime('%H:%M:%S'),
1685
- 'count': successful_posts,
1686
- 'total': len(job_topics),
1687
- }
1688
- _save_auto_log(log)
1689
-
1690
- def _scheduler_loop():
1691
- """Check every 60s; trigger at 7:00, 13:00, 19:00 VN time.
1692
- On startup, check for any missed slots today and run them immediately."""
1693
- time.sleep(35)
1694
- from datetime import datetime, timezone, timedelta
1695
- VN_TZ_SCHED = timezone(timedelta(hours=7))
1696
-
1697
- _last_run_date = ""
1698
- _last_run_slots = set()
1699
-
1700
- # On startup: check log for missed slots today
1701
- try:
1702
- start_now = datetime.now(VN_TZ_SCHED)
1703
- today_str = start_now.strftime('%Y-%m-%d')
1704
- current_hour = start_now.hour
1705
- current_minute = start_now.minute
1706
- log = _load_auto_log()
1707
- today_log = log.get(today_str, {})
1708
- for h, label in _AUTO_SCHEDULE_TIMES:
1709
- # Run if slot is past (either strictly earlier hour, or same hour but window has passed)
1710
- should_run = False
1711
- if h < current_hour:
1712
- should_run = True
1713
- elif h == current_hour and current_minute > 10:
1714
- should_run = True
1715
- if should_run and label not in today_log:
1716
- print(f"[auto] Detected missed slot {label} (h={h} < now={current_hour}:{current_minute}), running catch-up now")
1717
- _run_scheduled_sync(label)
1718
- _last_run_slots.add(label)
1719
- except Exception as e:
1720
- print(f"[auto] Catch-up check error: {e}")
1721
-
1722
- while True:
1723
- try:
1724
- now = datetime.now(VN_TZ_SCHED)
1725
- today = now.strftime('%Y-%m-%d')
1726
- hour = now.hour
1727
- minute = now.minute
1728
-
1729
- if today != _last_run_date:
1730
- _last_run_date = today
1731
- _last_run_slots = set()
1732
-
1733
- slot = None
1734
- for h, label in _AUTO_SCHEDULE_TIMES:
1735
- if hour == h and 0 <= minute < 5:
1736
- slot = label
1737
- break
1738
-
1739
- if slot and slot not in _last_run_slots:
1740
- _last_run_slots.add(slot)
1741
- _run_scheduled_sync(slot)
1742
- except Exception as e:
1743
- print(f"[auto] Loop error: {e}")
1744
-
1745
- time.sleep(60)
1746
-
1747
- threading.Thread(target=_scheduler_loop, daemon=True, name='auto-rewrite-scheduler').start()
1748
-
1749
- @app.get('/api/debug/auto_schedule')
1750
- async def debug_auto_schedule(slot: str = '07:00'):
1751
- """Manually trigger auto scheduler for debugging."""
1752
- try:
1753
- # Check if we can access the data directory
1754
- log = _load_auto_log()
1755
- topics = _get_hot_topics()[:3]
1756
- job_topics = [t['topic'] for t in topics if t.get('topic')]
1757
- return JSONResponse({
1758
- "slot": slot,
1759
- "log": log,
1760
- "hot_topics": job_topics,
1761
- "wall_posts_count": len(_load_wall_posts()),
1762
- "data_dir_writable": os.access(DATA_DIR, os.W_OK) if os.path.isdir(DATA_DIR) else False,
1763
- "data_dir_exists": os.path.isdir(DATA_DIR),
1764
- })
1765
- except Exception as e:
1766
- return JSONResponse({"error": str(e)}, status_code=500)
1767
-
1768
- def _run_scheduled_sync(slot):
1769
- """Run _do_scheduled_run in a separate event loop (for background thread)."""
1770
- loop = asyncio.new_event_loop()
1771
- asyncio.set_event_loop(loop)
1772
- try:
1773
- loop.run_until_complete(_do_scheduled_run(slot))
1774
- except Exception as e:
1775
- print(f"[auto] Background run error: {e}")
1776
- finally:
1777
- loop.close()
1778
-
1779
- @app.get('/api/debug/trigger_auto')
1780
- async def debug_trigger_auto(slot: str = '19:00'):
1781
- """Trigger _do_scheduled_run in background thread (non-blocking)."""
1782
- threading.Thread(target=_run_scheduled_sync, args=(slot,), daemon=True).start()
1783
- return JSONResponse({"status": "started", "slot": slot})
1784
-
1785
- # ===== SHORTS RSS PROXY ENDPOINT =====
1786
- @app.get("/api/shorts/rss")
1787
- def shorts_rss():
1788
- """Get shorts from YouTube RSS feeds server-side"""
1789
- import xml.etree.ElementTree as ET
1790
- import html as html_lib2
1791
- import re as re2
1792
-
1793
- YOUTUBE_CHANNELS = {
1794
- "baodantri7941": "UC_x5TKhOgd6GhYvv5z4I3jg",
1795
- "baosuckhoedoisongboyte": "UCBsY5fXTQLkF_JnH9kLkL4g",
1796
- }
1797
-
1798
- shorts = []
1799
- seen = set()
1800
-
1801
- for handle, channel_id in YOUTUBE_CHANNELS.items():
1802
- try:
1803
- rss_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
1804
- r = req.get(rss_url, headers=HEADERS, timeout=15)
1805
- if r.status_code != 200:
1806
- continue
1807
-
1808
- root = ET.fromstring(r.text)
1809
- ns = {
1810
- 'atom': 'http://www.w3.org/2005/Atom',
1811
- 'yt': 'http://www.youtube.com/xml/schemas/2015',
1812
- 'media': 'http://search.yahoo.com/mrss/'
1813
- }
1814
-
1815
- for entry in root.findall('atom:entry', ns)[:30]:
1816
- title_el = entry.find('atom:title', ns)
1817
- title = html_lib2.unescape(title_el.text) if title_el is not None and title_el.text else ''
1818
-
1819
- link_el = entry.find('atom:link', ns)
1820
- link = link_el.get('href', '') if link_el is not None else ''
1821
-
1822
- vid_el = entry.find('yt:videoId', ns)
1823
- vid = vid_el.text if vid_el is not None else ''
1824
-
1825
- if not vid or vid in seen:
1826
- continue
1827
-
1828
- # Check if it's a short
1829
- is_short = '#shorts' in title.lower() or '#short' in title.lower() or '/shorts/' in link
1830
-
1831
- if not is_short:
1832
- desc_el = entry.find('media:description', ns)
1833
- if desc_el is not None and desc_el.text:
1834
- if '#shorts' in desc_el.text.lower():
1835
- is_short = True
1836
-
1837
- if not is_short:
1838
- continue
1839
-
1840
- seen.add(vid)
1841
-
1842
- # Get thumbnail
1843
- thumb = f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg"
1844
- media_group = entry.find('media:group', ns)
1845
- if media_group is not None:
1846
- thumb_el = media_group.find('media:thumbnail', ns)
1847
- if thumb_el is not None:
1848
- thumb = thumb_el.get('url', thumb)
1849
-
1850
- shorts.append({
1851
- 'id': vid,
1852
- 'title': title.replace('#shorts', '').replace('#short', '').strip()[:120],
1853
- 'img': thumb,
1854
- 'link': f'https://www.youtube.com/shorts/{vid}',
1855
- 'channel': handle,
1856
- 'source': 'yt'
1857
- })
1858
-
1859
- if len(shorts) >= 40:
1860
- break
1861
-
1862
- except Exception as e:
1863
- print(f"RSS error for {handle}: {e}")
1864
- continue
1865
-
1866
- return {"shorts": shorts, "count": len(shorts)}
1867
-
1868
- 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 DELETED
@@ -1,50 +0,0 @@
1
- """Extra CSS/JS fixes injected AFTER main PATCH_INJECT."""
2
- EXTRA_FIX = r'''
3
- <style>
4
- /* Force correct position for Short AI interaction buttons */
5
- .tiktok-slide{position:relative!important}
6
- .tiktok-right{position:absolute!important;right:8px!important;bottom:100px!important;display:flex!important;flex-direction:column!important;align-items:center!important;gap:14px!important;z-index:5!important}
7
- .tiktok-right-btn{display:flex!important;flex-direction:column!important;align-items:center!important;gap:2px!important;background:none!important;border:0!important;color:#fff!important;font-size:10px!important;cursor:pointer!important}
8
- .tiktok-right-btn .icon{width:42px!important;height:42px!important;border-radius:50%!important;background:rgba(255,255,255,.12)!important;display:flex!important;align-items:center!important;justify-content:center!important;font-size:20px!important}
9
- .tiktok-right-btn .count{font-size:10px!important;color:#ddd!important}
10
- #short-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}
11
- /* Kill ALL duplicate short AI slides from old layers */
12
- #ai-short-home,.ai-short-home,.ai-short-card-final,[id*="ai-shorts-patched"]{display:none!important}
13
- </style>
14
- <div id="short-progress-toast"></div>
15
- <script>
16
- (function(){
17
- // Kill old renderers that create duplicate Short AI slides
18
- window.renderAIShortHome=function(){};
19
- window.renderAIShorts7=function(){};
20
- window.renderTopicWallE=function(){};
21
- window.renderAiShorts=function(){};
22
- // Also remove any already-rendered duplicate slides
23
- setInterval(function(){
24
- document.querySelectorAll('#ai-short-home,.ai-short-home,[id*="ai-shorts-patched"]').forEach(function(el){el.remove()});
25
- },2000);
26
- // Progress toast for short creation
27
- window.showShortProgress=function(msg){var t=document.getElementById('short-progress-toast');if(t){t.textContent=msg;t.style.display='block';}};
28
- window.hideShortProgress=function(){var t=document.getElementById('short-progress-toast');if(t)t.style.display='none';};
29
- // Override makeShortFromPost to use progress toast
30
- var _origMakeShort=window.makeShortFromPost;
31
- window.makeShortFromPost=async function(pid,btn){
32
- showShortProgress('⏳ Đang tạo Short AI...');
33
- if(btn){btn.disabled=true;btn.textContent='Đang tạo...';}
34
- try{
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){
42
- showShortProgress('❌ Lỗi: '+e.message);
43
- setTimeout(hideShortProgress,4000);
44
- }finally{
45
- if(btn){btn.disabled=false;btn.textContent='🎬 Tạo Short AI';}
46
- }
47
- };
48
- })();
49
- </script>
50
- '''
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
patch_runtime.py DELETED
@@ -1,274 +0,0 @@
1
- """Runtime patch layer for VNEWS.
2
- Keeps the current large app intact, but replaces fragile AI wall endpoints with
3
- stable JSON endpoints and injects frontend safeJson wrappers.
4
- """
5
- import hashlib
6
- import time
7
- import os
8
- from urllib.parse import quote
9
-
10
- import requests
11
- from bs4 import BeautifulSoup
12
- from fastapi import Request
13
- from fastapi.responses import JSONResponse, HTMLResponse
14
-
15
- import main as _main
16
-
17
- app = _main.app
18
- DEFAULT_IMG = "https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg"
19
-
20
-
21
- def _remove_routes(paths):
22
- app.router.routes = [r for r in app.router.routes if getattr(r, "path", None) not in set(paths)]
23
-
24
-
25
- def _safe_text(v):
26
- return (v or "").strip()
27
-
28
-
29
- def _ensure_article(url: str):
30
- data = None
31
- try:
32
- if hasattr(_main, "_article_by_url"):
33
- data = _main._article_by_url(url)
34
- except Exception:
35
- data = None
36
- if not data:
37
- try:
38
- data = _main._scrape_generic_article(url) if hasattr(_main, "_scrape_generic_article") else None
39
- except Exception:
40
- data = None
41
- if not data:
42
- data = {"title": "", "summary": "", "og_image": "", "body": [], "url": url, "source": "generic"}
43
- title = _safe_text(data.get("title"))
44
- summary = _safe_text(data.get("summary"))
45
- img = _safe_text(data.get("og_image"))
46
- body = data.get("body") or []
47
- if not title or not summary or not img or not body:
48
- try:
49
- r = requests.get(url, headers=getattr(_main, "HEADERS", {}), timeout=15)
50
- r.encoding = "utf-8"
51
- soup = BeautifulSoup(r.text, "lxml")
52
- if not title:
53
- tag = soup.find("meta", property="og:title") or soup.find("title")
54
- title = tag.get("content", "").strip() if tag and tag.name == "meta" else (tag.get_text(strip=True) if tag else "")
55
- if not summary:
56
- tag = soup.find("meta", property="og:description") or soup.find("meta", attrs={"name": "description"})
57
- summary = tag.get("content", "").strip() if tag else ""
58
- if not img:
59
- tag = soup.find("meta", property="og:image") or soup.find("meta", attrs={"name": "twitter:image"})
60
- img = tag.get("content", "").strip() if tag else ""
61
- if not body:
62
- ps = []
63
- for p in soup.find_all("p"):
64
- t = p.get_text(" ", strip=True)
65
- if len(t) > 40:
66
- ps.append({"type": "p", "text": t})
67
- if len(ps) >= 30:
68
- break
69
- body = ps
70
- except Exception:
71
- pass
72
- if not summary and body:
73
- first = next((b.get("text", "") for b in body if b.get("type") == "p" and b.get("text")), "")
74
- summary = first[:360]
75
- if not title:
76
- title = url
77
- if not img:
78
- img = DEFAULT_IMG
79
- if not body and summary:
80
- body = [{"type": "p", "text": summary}]
81
- data.update({"title": title, "summary": summary, "og_image": img, "body": body, "url": url})
82
- return data
83
-
84
-
85
- def _rewrite(data, tone="tu-nhien"):
86
- try:
87
- if hasattr(_main, "_ai_rewrite_article"):
88
- text = _main._ai_rewrite_article(data, tone=tone)
89
- if text and len(text.strip()) > 50:
90
- return text.strip()
91
- except Exception:
92
- pass
93
- title = data.get("title", "")
94
- summary = data.get("summary", "")
95
- ps = [b.get("text", "") for b in data.get("body", []) if b.get("type") == "p" and b.get("text")]
96
- lead = summary or (ps[0] if ps else "")
97
- points = "\n".join(["• " + p[:220] + ("..." if len(p) > 220 else "") for p in ps[:5]])
98
- body = "\n\n".join(ps[:10])
99
- return (f"Bản tin AI viết lại: {title}\n\n{lead}\n\n{body}\n\nĐiểm chính:\n{points}").strip()
100
-
101
-
102
- def _topic_image(topic):
103
- try:
104
- if hasattr(_main, "_image_for_topic"):
105
- return _main._image_for_topic(topic)
106
- except Exception:
107
- pass
108
- return "https://image.pollinations.ai/prompt/" + quote("editorial illustration Vietnamese news " + topic, safe="") + "?width=1024&height=576&nologo=true"
109
-
110
-
111
- def _save_post(post):
112
- try:
113
- posts = _main._load_wall() if hasattr(_main, "_load_wall") else []
114
- except Exception:
115
- posts = []
116
- posts.insert(0, post)
117
- try:
118
- if hasattr(_main, "_save_wall"):
119
- _main._save_wall(posts)
120
- except Exception:
121
- pass
122
- return post
123
-
124
-
125
- _remove_routes(["/api/url_wall", "/api/topic_post", "/api/rewrite_share", "/"])
126
-
127
-
128
- @app.post("/api/url_wall")
129
- async def patched_url_wall(request: Request):
130
- try:
131
- body = await request.json()
132
- except Exception:
133
- body = {}
134
- url = _safe_text(body.get("url"))
135
- tone = _safe_text(body.get("tone")) or "tu-nhien"
136
- if not url:
137
- return JSONResponse({"error": "missing url"}, status_code=400)
138
- try:
139
- data = _ensure_article(url)
140
- text = _rewrite(data, tone=tone)
141
- post = {
142
- "id": hashlib.md5((url + str(time.time())).encode()).hexdigest()[:12],
143
- "url": url,
144
- "title": data.get("title") or url,
145
- "summary": data.get("summary") or "",
146
- "img": data.get("og_image") or DEFAULT_IMG,
147
- "text": text or (data.get("summary") or data.get("title") or url),
148
- "source": data.get("source", "url"),
149
- "ts": int(time.time()),
150
- }
151
- _save_post(post)
152
- return JSONResponse({"post": post})
153
- except Exception as e:
154
- return JSONResponse({"error": "Không tạo được tóm tắt URL", "detail": str(e)[:300]}, status_code=500)
155
-
156
-
157
- @app.post("/api/rewrite_share")
158
- async def patched_rewrite_share(request: Request):
159
- return await patched_url_wall(request)
160
-
161
-
162
- @app.post("/api/topic_post")
163
- async def patched_topic_post(request: Request):
164
- try:
165
- body = await request.json()
166
- except Exception:
167
- body = {}
168
- topic = _safe_text(body.get("topic"))
169
- tone = _safe_text(body.get("tone")) or "tu-nhien"
170
- if not topic:
171
- return JSONResponse({"error": "missing topic"}, status_code=400)
172
- try:
173
- context = ""
174
- try:
175
- if hasattr(_main, "_topic_article_context"):
176
- context = _main._topic_article_context(topic)
177
- if not context and hasattr(_main, "_web_context"):
178
- context = _main._web_context(topic)
179
- except Exception:
180
- context = ""
181
- if not context:
182
- context = f"Chủ đề: {topic}"
183
- data = {"title": topic, "summary": context[:420], "og_image": _topic_image(topic), "body": [{"type": "p", "text": context}], "source": "topic", "url": ""}
184
- text = _rewrite(data, tone=tone)
185
- post = {
186
- "id": hashlib.md5((topic + str(time.time())).encode()).hexdigest()[:12],
187
- "url": "",
188
- "title": topic,
189
- "summary": data["summary"],
190
- "img": data["og_image"] or DEFAULT_IMG,
191
- "text": text or context,
192
- "source": "topic",
193
- "ts": int(time.time()),
194
- }
195
- _save_post(post)
196
- return JSONResponse({"post": post})
197
- except Exception as e:
198
- return JSONResponse({"error": "Không tạo được bài theo chủ đề", "detail": str(e)[:300]}, status_code=500)
199
-
200
-
201
- _FRONTEND_PATCH = r'''
202
- <script>
203
- (function(){
204
- async function safeJson(res){
205
- const text = await res.text();
206
- try { return JSON.parse(text); }
207
- catch(e){ return { error: (text || 'Server không trả JSON').slice(0,500) }; }
208
- }
209
- window.safeJson = safeJson;
210
- window.createUrlPost = function(){
211
- let inp=document.getElementById('ai-url-input');
212
- let url=(inp&&inp.value||'').trim();
213
- if(!url){ alert('Dán URL trước'); return; }
214
- fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})})
215
- .then(safeJson).then(j=>{
216
- if(j&&j.post){
217
- if(!j.post.img) j.post.img='https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg';
218
- if(!j.post.text) j.post.text=j.post.summary||j.post.title||'Không lấy được nội dung tóm tắt.';
219
- if(typeof prependWallPost==='function') prependWallPost(j.post);
220
- alert('Đã tóm tắt URL và đăng lên tường');
221
- if(inp) inp.value='';
222
- } else alert((j&&j.error)||'Lỗi URL');
223
- }).catch(e=>alert('Lỗi URL: '+e.message));
224
- };
225
- window.createTopicPost = function(){
226
- let inp=document.getElementById('ai-topic-input');
227
- let topic=(inp&&inp.value||'').trim();
228
- if(!topic){ alert('Nhập chủ đề trước'); return; }
229
- fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})})
230
- .then(safeJson).then(j=>{
231
- if(j&&j.post){
232
- if(!j.post.img) j.post.img='https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg';
233
- if(!j.post.text) j.post.text=j.post.summary||j.post.title||'Không lấy được nội dung.';
234
- if(typeof prependWallPost==='function') prependWallPost(j.post);
235
- alert('Đã tạo bài và đăng lên tường');
236
- if(inp) inp.value='';
237
- } else alert((j&&j.error)||'Lỗi tạo bài');
238
- }).catch(e=>alert('Lỗi tạo bài: '+e.message));
239
- };
240
- window.rewriteCurrentArticle = function(){
241
- if(!window._currentArticle && typeof _currentArticle!=='undefined') window._currentArticle=_currentArticle;
242
- let ca = (typeof _currentArticle!=='undefined') ? _currentArticle : window._currentArticle;
243
- if(!ca || !ca.url){ alert('Chưa có bài viết để rewrite'); return; }
244
- let tone=document.getElementById('rewrite-tone')?.value||'nghiem-tuc';
245
- let btn=document.querySelector('.article-actions button.primary');
246
- if(btn){btn.textContent='Đang rewrite...';btn.disabled=true;}
247
- fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:ca.url,tone})})
248
- .then(safeJson).then(j=>{
249
- if(j&&j.post){
250
- if(!j.post.img) j.post.img='https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg';
251
- if(!j.post.text) j.post.text=j.post.summary||j.post.title||'Không lấy được nội dung.';
252
- let box=document.getElementById('rewrite-result');
253
- if(box) box.innerHTML='<div class="rewrite-box"><div class="rewrite-title">Đã rewrite và đăng lên Tường AI</div><div class="rewrite-text">'+(j.post.text||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]))+'</div></div>';
254
- if(typeof prependWallPost==='function') prependWallPost(j.post);
255
- alert('Đã đăng lên Tường AI');
256
- } else alert((j&&j.error)||'Không tạo được bài AI');
257
- }).catch(e=>alert('Lỗi tạo bài AI: '+e.message))
258
- .finally(()=>{if(btn){btn.textContent='🤖 AI viết lại & đăng tường';btn.disabled=false;}});
259
- };
260
- })();
261
- </script>
262
- '''
263
-
264
-
265
- @app.get("/")
266
- async def patched_index():
267
- try:
268
- with open("/app/static/index.html", "r", encoding="utf-8") as f:
269
- html = f.read()
270
- if "window.safeJson" not in html:
271
- html = html.replace("</body>", _FRONTEND_PATCH + "</body>")
272
- return HTMLResponse(content=html)
273
- except Exception as e:
274
- return HTMLResponse(content=f"<pre>Index error: {str(e)}</pre>", status_code=500)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
restore_runner.py DELETED
@@ -1,31 +0,0 @@
1
- import os
2
- import sys
3
- import subprocess
4
- from huggingface_hub import snapshot_download
5
-
6
- REVISION = os.environ.get("VNEWS_RESTORE_REVISION", "bcaa2dc")
7
- REPO_ID = os.environ.get("VNEWS_REPO_ID", "bep40/vnews")
8
-
9
- # Download exact Space snapshot from Hugging Face Hub.
10
- # This avoids manually copying huge files from an old commit.
11
- snapshot_dir = snapshot_download(
12
- repo_id=REPO_ID,
13
- repo_type="space",
14
- revision=REVISION,
15
- local_dir="/tmp/vnews_restore",
16
- local_dir_use_symlinks=False,
17
- )
18
-
19
- os.chdir(snapshot_dir)
20
- sys.path.insert(0, snapshot_dir)
21
-
22
- # Commit bcaa2dc Dockerfile ran ai_patch:app.
23
- cmd = [
24
- "uvicorn",
25
- "ai_patch:app",
26
- "--host",
27
- "0.0.0.0",
28
- "--port",
29
- "7860",
30
- ]
31
- os.execvp(cmd[0], cmd)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
File without changes
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,500 +1,316 @@
1
- /**
2
- * VNEWS Frontend v2 - Shorts Dantri/SKDS removed, VTV Digital CDN
3
- * v2.8 - Changed short AI feed video share button icon (📥) to distinguish from article share (📤)
4
- * v2.6 - Fixed share links: doShare now includes post_id for wall posts,
5
- * readSlidePost has share button, /s + /s/{slug} render slides/video inline
6
- * v2.5 - Added 'Tạo lại' button on wall cards with video
7
- * v2.4 - Fixed: prependWallPost detached-element bug, makeShortVideo UI update, slide viewer for rewrite posts
8
- */
9
- function _proxyImg(url){
10
- if(!url || typeof url !== 'string') return '';
11
- if(url.startsWith('http') && !url.includes(location.host)){
12
- return '/api/proxy/img?url='+encodeURIComponent(url);
13
- }
14
- return url;
15
- }
16
-
17
- var _ttsSelections = {};
18
-
19
- function _fetchWithTimeout(url, ms){
20
- return new Promise((resolve,reject)=>{
21
- const ctrl=new AbortController();
22
- const tid=setTimeout(()=>ctrl.abort(),ms);
23
- fetch(url,{signal:ctrl.signal}).then(r=>{
24
- clearTimeout(tid);
25
- if(!r.ok) return reject(new Error('HTTP '+r.status));
26
- return r.json();
27
- }).then(d=>resolve(d)).catch(e=>{clearTimeout(tid);reject(e);});
28
- });
29
- }
30
 
 
31
  async function loadHome(){
32
- const homeEl = document.getElementById('view-home');
33
- if(!homeEl) return;
34
-
35
- homeEl.innerHTML =
36
- '<div id="home-featured-area"></div>'
37
- +'<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>'
38
- +'<div id="ai-wall-under-compose"></div>'
39
- +'<div id="hashtag-box"></div>'
40
- +'<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>'
41
- +'<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>'
42
- +'<div id="home-after-wc"></div>';
43
-
44
- const afterEl = homeEl.querySelector('#home-after-wc');
45
-
46
- loadLivescore('today');
47
- loadHotTopics();
48
-
49
- const [featuredData, wallData, hlLeagues, wcData] = await Promise.allSettled([
50
- _fetchWithTimeout('/api/livescore/featured', 5000),
51
- _fetchWithTimeout('/api/wall', 5000),
52
- _fetchWithTimeout('/api/highlights/leagues', 10000),
53
- _fetchWithTimeout('/api/wc2026', 8000),
54
- ]).then(results => results.map(r => r.status === 'fulfilled' ? r.value : null));
55
-
56
- if(featuredData && featuredData.home){
57
- const sc=featuredData.status==='live'?'':'upcoming';
58
- const st=featuredData.status==='live'?`🔴 ${featuredData.minute||'LIVE'}`:`⏰ ${featuredData.time}`;
59
- const area=document.getElementById('home-featured-area');
60
- 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>`;
61
- }
62
-
63
- _wallPosts = (wallData && wallData.posts) || [];
64
- _hlLeagueData = hlLeagues || {};
65
- _wc2026Data = wcData;
66
-
67
- if(wcData) switchWCTab('news');
68
-
69
- _renderWallIn(afterEl);
70
- _renderHLIn(afterEl);
71
- }
72
-
73
- function _renderSlidesIn(key, label, emoji, vids, afterEl){
74
- if(!vids||!vids.length||!afterEl) return;
75
- const wrap=document.createElement('div');
76
- wrap.className='slider-wrap';
77
- let h=`<div class="slider-header"><span class="slider-label">${emoji} ${label}</span></div><div class="slider-track">`;
78
- const isHL = key==='world-cup'||key==='premier-league'||key==='champions-league'||key==='la-liga'||key==='serie-a'||key==='bundesliga'||key==='friendly';
79
- vids.slice(0,isHL?8:12).forEach((a,i)=>{
80
- if(isHL){
81
- 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>`;
82
- } else {
83
- 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>`;
84
- }
85
- });
86
- h+='</div>';
87
- wrap.innerHTML=h;
88
- afterEl.parentNode.insertBefore(wrap, afterEl);
89
- }
90
-
91
- function _renderWallIn(afterEl){
92
- if(!_wallPosts||!_wallPosts.length) return;
93
- const posts=_wallPosts;
94
- const wrap=document.createElement('div');
95
- wrap.className='slider-wrap';wrap.id='ai-wall-wrap';
96
- let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track" id="ai-wall-track">';
97
- posts.slice(0,20).forEach((p,i)=>{h+=makeWallItem(p,i);});
98
- h+='</div>';wrap.innerHTML=h;
99
- const target=document.getElementById('ai-wall-under-compose');
100
- if(target) target.appendChild(wrap);
101
- else if(afterEl) afterEl.parentNode.insertBefore(wrap,afterEl);
102
- }
103
-
104
- function _renderHLIn(afterEl){
105
- if(!_hlLeagueData||Object.keys(_hlLeagueData).length===0||!afterEl) return;
106
  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:"🤝"}};
107
- for(const[key,cfg] of Object.entries(HL_CONFIG)){
108
- const vids=_hlLeagueData[key];
109
- if(!vids||!vids.length) continue;
110
- _renderSlidesIn(key,cfg.name,cfg.emoji,vids,afterEl);
111
- }
112
  }
113
 
114
  // === WALL POST HELPERS ===
115
  function makeWallItem(p,i){
116
- var hasVideo = p.video && p.video.length > 0;
117
- var thumbContent = p.img
118
- ? '<img src="/api/proxy/img?url='+encodeURIComponent(p.img)+'" loading="lazy" onerror="this.style.display=\'none\'">'
119
- : (hasVideo ? '<video src="'+esc(p.video)+'" muted></video>' : '');
120
- var videoBadge = hasVideo ? '<div class="wall-video-badge">🎬</div>' : '';
121
- var vid = p.id||i;
122
- var lang = p.language || detectLanguage(p.title + ' ' + (p.text||''));
123
- var curVoice = p.voice || getAutoVoice(lang);
124
- var curEmotion = p.emotion || detectEmotion(p.title + ' ' + (p.text||''));
125
- var selKey = 'inline-' + vid;
126
- if(!_ttsSelections[selKey]) _ttsSelections[selKey] = {voice: curVoice, emotion: curEmotion};
127
- var voiceOpts = '';
128
- VOICE_LIST.forEach(function(v){
129
- voiceOpts += '<option value="'+v.id+'"'+(v.id===_ttsSelections[selKey].voice?' selected':'')+'>'+v.label+'</option>';
130
- });
131
- var emotOpts = '';
132
- EMOTION_LIST.forEach(function(e){
133
- emotOpts += '<option value="'+e.id+'"'+(e.id===_ttsSelections[selKey].emotion?' selected':'')+'>'+e.label+'</option>';
134
- });
135
- var spd = p.short_speed || '1.2';
136
- var voiceBar = '<div class="wall-tts-bar" style="margin:6px 0 4px;display:grid;grid-template-columns:1fr auto auto;gap:3px">'
137
- +'<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">'
138
- +voiceOpts+'</select>'
139
- +'<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">'
140
- +emotOpts+'</select>'
141
- +'<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)">'
142
- +'<option value="0.85"'+(spd==='0.85'?' selected':'')+'>0.85x</option>'
143
- +'<option value="1.0"'+(spd==='1.0'?' selected':'')+'>1.0x</option>'
144
- +'<option value="1.2"'+(spd==='1.2'?' selected':'')+'>1.2x</option>'
145
- +'<option value="1.35"'+(spd==='1.35'?' selected':'')+'>1.35x</option>'
146
- +'</select>'
147
- +'</div>';
148
- var makeBtn = hasVideo
149
- ? '<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed('+i+')">▶ Xem Short</button><button class="wall-btn-make" style="margin-left:4px" onclick="event.stopPropagation();makeShortVideo(\''+esc(vid)+'\',this,_ttsSelections[\'inline-'+esc(vid)+'\'].voice,parseFloat(document.querySelector(\'.wss[data-selkey=inline-'+esc(vid)+']\').value)||1.2,_ttsSelections[\'inline-'+esc(vid)+'\'].emotion)">🎬 Tạo lại</button>'
150
- : '<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>';
151
- 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>';
152
  }
153
 
154
- async function makeShortVideo(postId, btn, voice, speed, emotion){
 
155
  if(!postId)return;
156
  const origText = btn ? btn.textContent : '🎬 Tạo Video';
157
  if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo...';}
158
  toast('⏳ Đang tạo video shorts...');
159
- if(!voice || !emotion){
160
- const selKey = 'inline-'+postId;
161
- const container = document.querySelector('.tts-selector[data-post-id="'+postId+'"]');
162
- if(container){
163
- if(_ttsSelections[selKey]){
164
- voice = voice || _ttsSelections[selKey].voice;
165
- emotion = emotion || _ttsSelections[selKey].emotion;
166
- }
167
- if(!voice){
168
- const selectedVoiceBtn = container.querySelector('.tts-voice-btn.selected') || container.querySelector('.tts-voice-btn[style*="5cb87a"]') || container.querySelector('.tts-voice-btn');
169
- voice = selectedVoiceBtn ? selectedVoiceBtn.dataset.voice : 'vi-VN-HoaiMyNeural';
170
- }
171
- if(!emotion){
172
- const selectedEmotionBtn = container.querySelector('.tts-emotion-btn.selected') || container.querySelector('.tts-emotion-btn[style*="5cb87a"]') || container.querySelector('.tts-emotion-btn');
173
- emotion = selectedEmotionBtn ? selectedEmotionBtn.dataset.emotion : 'neutral';
174
- }
175
- const speedSelect = container.querySelector('.tts-speed');
176
- speed = speedSelect ? parseFloat(speedSelect.value) || 1.2 : (speed || 1.2);
177
- } else {
178
- voice = voice || 'vi-VN-HoaiMyNeural';
179
- emotion = emotion || 'neutral';
180
- speed = speed || 1.2;
181
- }
182
- }
183
  try{
184
- 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})});
 
 
 
 
 
185
  const j = await r.json();
186
  if(!r.ok || j.error) throw new Error(j.error||'Lỗi tạo video');
187
  toast('✅ Đã tạo video shorts!');
188
  const p = _wallPosts.find(x => String(x.id) === String(postId));
189
  if(p){
190
  p.video = j.video;
191
- p.voice = j.voice;
192
- p.emotion = j.emotion;
193
  const itemId = 'wall-item-'+postId;
194
  const el = document.getElementById(itemId);
195
  if(el){
196
  const idx = _wallPosts.indexOf(p);
197
- el.insertAdjacentHTML('afterend', makeWallItem(p, idx));
198
- el.remove();
 
199
  }
200
  }
 
201
  }catch(e){
202
  toast('❌ '+e.message);
203
  if(btn){btn.disabled=false;btn.textContent=origText;}
204
  }
205
  }
206
 
207
- var VOICE_LIST = [
208
- {id:'vi-VN-HoaiMyNeural', label:'🎙️ Hoài My (VI)', lang:'vi'},
209
- {id:'vi-VN-NamMinhNeural', label:'🎙️ Nam Minh (VI)', lang:'vi'},
210
- {id:'en-US-AndrewMultilingualNeural', label:'🎙️ Andrew (EN)', lang:'en'},
211
- {id:'en-AU-WilliamMultilingualNeural', label:'🎙️ William (EN)', lang:'en'},
212
- {id:'pt-BR-ThalitaMultilingualNeural', label:'🎙️ Thalita (PT)', lang:'pt'},
213
- {id:'fr-FR-VivienneMultilingualNeural', label:'🎙️ Vivienne (FR)', lang:'fr'},
214
- {id:'fr-FR-RemyMultilingualNeural', label:'🎙️ Rémy (FR)', lang:'fr'},
215
- {id:'de-DE-SeraphinaMultilingualNeural', label:'🎙️ Seraphina (DE)', lang:'de'},
216
- {id:'de-DE-FlorianMultilingualNeural', label:'🎙️ Florian (DE)', lang:'de'},
217
- {id:'ko-KR-HyunsuMultilingualNeural', label:'🎙️ Hyunsu (KO)', lang:'ko'},
218
- {id:'it-IT-GiuseppeMultilingualNeural', label:'🎙️ Giuseppe (IT)', lang:'it'},
219
- ];
220
- var EMOTION_LIST = [
221
- {id:'neutral', label:'😐 Trung tính'},
222
- {id:'happy', label:'😊 Vui vẻ'},
223
- {id:'excited', label:'🔥 Hào hứng'},
224
- {id:'sad', label:'😢 Buồn'},
225
- {id:'humorous', label:'😂 Hài hước'},
226
- {id:'serious', label:'⚠️ Nghiêm túc'},
227
- {id:'urgent', label:'🚨 Khẩn cấp'},
228
- {id:'warm', label:'💖 Ấm áp'},
229
- ];
230
 
231
- document.addEventListener('click',function(e){
232
- var btn = e.target.closest('.tts-voice-btn');
233
- if(btn){
234
- var container = btn.closest('.tts-selector');
235
- if(container){
236
- var selKey = 'inline-'+container.dataset.postId;
237
- if(!_ttsSelections[selKey]) _ttsSelections[selKey]={voice:btn.dataset.voice,emotion:'neutral'};
238
- var allBtns = container.querySelectorAll('.tts-voice-btn');
239
- for(var i=0;i<allBtns.length;i++){allBtns[i].style.borderColor='#333';allBtns[i].style.background='#222';allBtns[i].classList.remove('selected');}
240
- btn.style.borderColor='#5cb87a';btn.style.background='#1a2a1f';btn.classList.add('selected');
241
- _ttsSelections[selKey].voice = btn.dataset.voice;
242
- }
243
- return;
244
- }
245
- var ebtn = e.target.closest('.tts-emotion-btn');
246
- if(ebtn){
247
- var container = ebtn.closest('.tts-selector');
248
- if(container){
249
- var selKey = 'inline-'+container.dataset.postId;
250
- if(!_ttsSelections[selKey]) _ttsSelections[selKey]={voice:'vi-VN-HoaiMyNeural',emotion:ebtn.dataset.emotion};
251
- var allBtns = container.querySelectorAll('.tts-emotion-btn');
252
- for(var i=0;i<allBtns.length;i++){allBtns[i].style.borderColor='#333';allBtns[i].style.background='#222';allBtns[i].classList.remove('selected');}
253
- ebtn.style.borderColor='#5cb87a';ebtn.style.background='#1a2a1f';ebtn.classList.add('selected');
254
- _ttsSelections[selKey].emotion = ebtn.dataset.emotion;
255
- }
256
  return;
257
  }
258
- var cbtn = e.target.closest('.tts-create-btn');
259
- if(cbtn){
260
- var container = cbtn.closest('.tts-selector');
261
- if(container){
262
- var selKey = 'inline-'+container.dataset.postId;
263
- var selVoice = _ttsSelections[selKey] ? _ttsSelections[selKey].voice : 'vi-VN-HoaiMyNeural';
264
- var selEmotion = _ttsSelections[selKey] ? _ttsSelections[selKey].emotion : 'neutral';
265
- var speedSel = container.querySelector('.tts-speed');
266
- var speed = speedSel ? parseFloat(speedSel.value)||1.2 : 1.2;
267
- window.makeShortVideo(container.dataset.postId, cbtn, selVoice, speed, selEmotion);
268
  }
269
- return;
270
  }
271
- });
272
- function detectLanguage(text){
273
- if(!text) return 'vi';
274
- var t=text.toLowerCase(), chars=new Set(t);
275
- var vnChars='đăâêôơưàảãạáằẳẵặắầẩẫậấèẻẽẹéềễểệếìỉĩịíòỏõọóồổỗộốờởỡợớùủũụúừửữựứỳỷỹỵý';
276
- var vnCount=0; for(var c of vnChars){if(chars.has(c)) vnCount++;}
277
- if(vnCount>=2) return 'vi';
278
- if(chars.has('ñ')||chars.has('¿')||chars.has('¡')) return 'es';
279
- if(chars.has('ã')||chars.has('õ')) return 'pt';
280
- var words=t.split(/\s+/);
281
- var enWords=['the','is','at','which','on','and','or','but','this','that','with','from','have','been'];
282
- var enCount=words.filter(function(w){return enWords.indexOf(w)>=0;}).length;
283
- if(enCount>=2) return 'en';
284
- return 'vi';
285
- }
286
- function detectEmotion(text){
287
- if(!text) return 'neutral';
288
- var t=text.toLowerCase();
289
- var kws={
290
- 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'],
291
- excited:['hào hứng','phấn khích','đột phá','kỷ lục','đỉnh cao','emocionante','increíble','excited','thrilling','unbelievable','awesome','breakthrough'],
292
- sad:['buồn','đau','mất','thảm họa','khủng hoảng','triste','terrible','sad','unhappy','tragic','painful','death'],
293
- humorous:['hài hước','buồn cười','haha','đùa','engraçado','gracioso','funny','hilarious','joke','lol'],
294
- serious:['nghiêm trọng','khẩn cấp','quan trọng','lo ngại','sério','crítico','serious','critical','urgent','severe','crisis'],
295
- urgent:['khẩn cấp','báo động','ngay lập tức','urgent','breaking','alert','emergency'],
296
- warm:['ấm áp','tình cảm','yêu thương','warm','love','heart','touching']
297
- };
298
- var bestScore=0, bestEmotion='neutral';
299
- 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;}}
300
- return bestEmotion;
301
  }
302
- 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';}
303
- function buildVoiceEmotionSelector(post){
304
- var lang=post.language||detectLanguage(post.title+' '+(post.text||''));
305
- 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'};
306
- var _postVoice = post.voice ? (_oldVoiceMap[post.voice] || post.voice) : '';
307
- var autoVoice= _postVoice || getAutoVoice(lang);
308
- var autoEmotion=post.emotion||detectEmotion(post.title+' '+(post.text||''));
309
- var selKey = 'inline-'+post.id;
310
- if(!_ttsSelections[selKey]){_ttsSelections[selKey] = {voice: autoVoice, emotion: autoEmotion};}
311
- 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">';
312
- 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">';
313
- 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>';});
314
- 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">';
315
- 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>';});
316
- h+='</div><div style="display:flex;align-items:center;gap:6px;margin-bottom:8px"><span style="font-size:11px;color:#888">⚡ Tốc độ:</span>';
317
- 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>';
318
- 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>';
319
- return h;
320
- }
321
- window.showVoiceEmotionSelector=function(postId,title,text){
322
- var overlay=document.createElement('div');
323
- 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';
324
- 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';
325
- var lang=detectLanguage(title+' '+text);var autoEmotion=detectEmotion(title+' '+text);
326
- var h='<h3 style="color:#5cb87a;margin-bottom:12px;font-size:16px">🎬 Tạo Short AI (ngôn ngữ: '+lang.toUpperCase()+')</h3>';
327
- h+='<div style="margin-bottom:12px"><div style="color:#aaa;font-size:11px;margin-bottom:6px">🎙️ Chọn giọng đọc:</div>';
328
- 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>';});
329
- h+='</div><div style="margin-bottom:12px"><div style="color:#aaa;font-size:11px;margin-bottom:6px">😊 Chọn cảm xúc:</div>';
330
- 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>';});
331
- h+='</div><div style="margin-bottom:12px"><div style="color:#aaa;font-size:11px;margin-bottom:6px">⚡ Tốc độ:</div>';
332
- 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>';
333
- 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>';
334
- 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>';
335
- h+='<div id="ve-status" style="color:#888;font-size:10px;margin-top:8px;display:none"></div>';
336
- box.innerHTML=h;overlay.appendChild(box);document.body.appendChild(overlay);
337
- var selectedVoice=getAutoVoice(lang),selectedEmotion=autoEmotion;
338
- 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;});});
339
- 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;});});
340
- box.querySelector('#ve-cancel-btn').addEventListener('click',function(){overlay.remove();});
341
- box.querySelector('#ve-create-btn').addEventListener('click',async function(){
342
- this.disabled=true;this.textContent='⏳ Đang tạo...';
343
- box.querySelector('#ve-status').style.display='block';box.querySelector('#ve-status').textContent='Đang tạo video shorts...';
344
- try{
345
- var speed=parseFloat(box.querySelector('#ve-speed').value)||1.2;
346
- if(!_ttsSelections["inline-"+postId]) _ttsSelections["inline-"+postId]={voice:"vi-VN-HoaiMyNeural",emotion:"neutral"};
347
- _ttsSelections["inline-"+postId].voice=selectedVoice;_ttsSelections["inline-"+postId].emotion=selectedEmotion;
348
- 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})});
349
- var j=await r.json();
350
- if(!r.ok||j.error) throw new Error(j.error||'Lỗi tạo video');
351
- toast('✅ Đã tạo Short AI!');overlay.remove();
352
- var p=_wallPosts.find(function(x){return String(x.id)===String(postId);});
353
- if(p){p.video=j.video;p.voice=j.voice;p.emotion=j.emotion;}
354
- }catch(e){this.disabled=false;this.textContent='🎬 Tạo Short';box.querySelector('#ve-status').textContent='❌ '+e.message;}
355
- });
356
- };
357
 
358
  function prependWallPost(post){
359
  _wallPosts.unshift(post);
360
  const track=document.getElementById('ai-wall-track');
361
  const wrap=document.getElementById('ai-wall-wrap');
362
- const target=document.getElementById('ai-wall-under-compose');
363
- if(target && (!track||!wrap)){
364
- const newWrap=document.createElement('div');
365
- newWrap.className='slider-wrap';newWrap.id='ai-wall-wrap';
366
- 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>`;
367
- target.appendChild(newWrap);
368
- const firstItem=newWrap.querySelector('.wall-item');
369
- if(firstItem)firstItem.className='wall-item wall-item-new';
 
 
 
 
 
 
 
 
370
  return;
371
  }
372
- if(track){
373
- track.insertAdjacentHTML('afterbegin', makeWallItem(post, 0));
374
- track.scrollTo({left:0,behavior:'smooth'});
375
- }
 
 
 
 
 
 
 
 
 
 
 
 
376
  }
377
 
 
 
378
  let _wallPosts=[];
379
  let _currentView='home';
380
- let _currentEventId=null;
381
- let _currentMatchUrl=null;
382
  let _htPage=0,_htTopic='';
383
- 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('');}
384
  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);}
385
- 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>`;}}
386
  function loadMoreHashtag(){_htPage++;const btn=document.getElementById('ht-more');if(btn){btn.disabled=true;btn.textContent='Đang tải...';}showHashtagSources(_htTopic,_htPage);}
387
  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';}}}
388
  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>';}}
389
- 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()});});}
390
- 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')}
391
  function closeMatch(){document.getElementById('match-overlay').classList.remove('active');document.body.style.overflow=''}
392
- 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>'}}
393
- function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]))}
394
- function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')}
395
- 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)}}
396
- function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}}
397
-
398
- // ===== doShare: COPY link to clipboard, then try native share as bonus =====
399
- function doShare(title,url,img,postId){
400
- var shareUrl;
401
- if(postId){
402
- shareUrl = SPACE+'/s?post_id='+encodeURIComponent(postId)+'&title='+encodeURIComponent(title);
403
- } else {
404
- shareUrl = SPACE+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||'');
405
- }
406
- // Try clipboard API first (modern, works on HTTPS)
407
- if(navigator.clipboard && navigator.clipboard.writeText){
408
- navigator.clipboard.writeText(shareUrl).then(function(){
409
- toast('📋 Đã sao chép link!');
410
- try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){}
411
- }).catch(function(){
412
- // Fallback: execCommand (deprecated but works on some browsers)
413
- try{
414
- var ta=document.createElement('textarea');
415
- ta.value=shareUrl;ta.style.position='fixed';ta.style.left='-9999px';ta.style.top='-9999px';ta.style.opacity='0';
416
- document.body.appendChild(ta);ta.select();ta.setSelectionRange(0,99999);
417
- if(document.execCommand('copy')){toast('📋 Đã sao chép link!');}
418
- else{prompt('📋 Sao chép link:', shareUrl);}
419
- document.body.removeChild(ta);
420
- }catch(e){prompt('📋 Sao chép link:', shareUrl);}
421
- try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){}
422
- });
423
- } else {
424
- // execCommand approach
425
- try{
426
- var ta=document.createElement('textarea');
427
- ta.value=shareUrl;ta.style.position='fixed';ta.style.left='-9999px';ta.style.top='-9999px';ta.style.opacity='0';
428
- document.body.appendChild(ta);ta.select();ta.setSelectionRange(0,99999);
429
- if(document.execCommand('copy')){toast('📋 Đã sao chép link!');}
430
- else{prompt('📋 Sao chép link:', shareUrl);}
431
- document.body.removeChild(ta);
432
- }catch(e){prompt('📋 Sao chép link:', shareUrl);}
433
- try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){}
434
- }
435
- }
436
-
437
  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;}}
438
  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};}}
439
  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[];}}
440
  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[];}}
441
- 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||'')}','','${esc(opts.postId||'')}')"><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>`;}
442
  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);}}
443
  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);}}
444
  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);}
445
  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);}}
446
- async function toggleComments(videoId,idx){const panel=document.getElementById('cmt-inline-'+idx);if(!panel)return;if(panel.style.display!=='none'){panel.style.display='none';/* Restore snap alignment */var sl=panel.closest('.tiktok-slide');if(sl)sl.style.scrollSnapAlign='start';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);/* Remove snap so feed can scroll to show comment input */var sl=panel.closest('.tiktok-slide');if(sl)sl.style.scrollSnapAlign='';setTimeout(function(){var feed=document.getElementById('tiktok-feed');if(feed){var slidePos=sl?sl.offsetTop:0;feed.scrollTop=slidePos+200;}/* Focus input */var inp=document.getElementById('cmt-input-'+idx);if(inp)inp.focus();},300);}
447
- function renderInlineComments(panel,videoId,idx,cmts){let h='<div class="inline-cmt-header"><span>💬 Bình luận</span><button onclick="toggleComments(\''+esc(videoId)+'\','+idx+')">✕</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;}
448
- 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);/* Keep focus on input after submit */var inp2=document.getElementById('cmt-input-'+idx);if(inp2)inp2.focus();}
449
- 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='📺';})}
450
- 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)}
451
- 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||'',postId:'',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);}
452
- 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>';}
453
- 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:p.video||'',postId:p.id||'',extraBtn:`<button class="tiktok-right-btn" onclick="event.stopPropagation();shareVideoToApps('${esc(p.video)}','${esc(p.title)}')"><div class="icon">📥</div></button>`})});h+='</div></div>';el.innerHTML=h;setTimeout(()=>initTikTokFeed(),200);}
454
- 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);}
455
- /** Show rewrite slide viewer - vertical slides with text+image */
456
- 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><div class="article-actions" style="padding:12px;text-align:center"><button onclick="doShare('${esc(p.title)}','${esc(p.url||'')}','${esc(p.img||'')}','${esc(p.id||'')}')" style="background:#2d8659;border:0;color:#fff;padding:8px 16px;border-radius:10px;cursor:pointer;font-size:12px">📤 Chia sẻ bài viết này</button></div>`;el.innerHTML=h;}
457
- function readNewsTab(tab){loadNewsTab();}
458
- 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>';});}
459
 
460
- 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" style="white-space:pre-wrap">${esc(presetText)}</div>`:''}</div><div class="article-actions"><button onclick="doShare('${esc(presetTitle)}','${esc(url||'')}','')">📤 Chia sẻ</button></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>`;});}
461
- 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_share',{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! Đang tạo Short AI...');if(btn)btn.textContent='✅ Đang tạo Short AI...';if(j.post){j.post.slides=j.slides||[];prependWallPost(j.post);}// Show slides immediately if available
462
- if(j.slides && j.slides.length){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">`;j.slides.forEach(s=>{h+=`<div class="slide-card" style="background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:16px;margin-bottom:12px"><div class="slide-num" style="color:#5cb87a;font-size:12px;font-weight:700;margin-bottom:6px">Slide ${s.index}/${j.slides.length}</div>${s.image?`<img src="${s.image.startsWith('/api/')?s.image:'/api/proxy/img?url='+encodeURIComponent(s.image)}" style="width:100%;max-height:300px;object-fit:cover;border-radius:8px;margin-bottom:8px" loading="lazy" onerror="this.style.display='none'">`:''}<p style="color:#ddd;font-size:14px;line-height:1.6;margin:0">${esc(s.text)}</p></div>`;});h+=`</div><div style="text-align:center;padding:12px"><div id="short-ai-status" style="color:#888;font-size:12px">⏳ Đang tạo video Short AI...</div></div>`;el.innerHTML=h;}
463
- const postId=j.post&&j.post.id;if(postId){setTimeout(async()=>{const sr=await fetch('/api/ai/short/'+encodeURIComponent(postId),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:'vi-VN-HoaiMyNeural',emotion:'neutral',speed:1.2})});const sj=await sr.json();if(sr.ok&&sj.video&&typeof _wallPosts!=='undefined'){const p=_wallPosts.find(x=>String(x.id)===String(postId));if(p){p.video=sj.video;const itemId='wall-item-'+postId;const el2=document.getElementById(itemId);if(el2){const idx=_wallPosts.indexOf(p);el2.insertAdjacentHTML('afterend',makeWallItem(p,idx));el2.remove();}}toast('✅ Short AI đã sẵn sàng!');const statusEl=document.getElementById('short-ai-status');if(statusEl)statusEl.innerHTML='✅ Short AI đẵn sàng! <button class="primary" onclick="openShortAIFeed(0)" style="background:#2d8659;border:0;color:#fff;padding:6px 12px;border-radius:10px;font-size:11px;cursor:pointer;margin-left:8px">▶ Xem ngay</button>';}},500);}if(btn)btn.textContent='✅ Hoàn tất';}catch(e){toast('❌ '+e.message);if(btn){btn.disabled=false;btn.textContent='🤖 Slide Rewrite AI';}}}
 
464
 
465
- // ===== SHARE VIDEO TO OTHER APPS =====
466
- function shareVideoToApps(videoUrl, title){
467
- if(!videoUrl){
468
- toast('Không video để chia sẻ');
469
- return;
470
- }
471
- var fullUrl = videoUrl.startsWith('http') ? videoUrl : window.location.origin + videoUrl;
472
-
473
- // Step 1: Try navigator.share with URL (async, works on mobile)
474
- try{
475
- if(navigator.share){
476
- navigator.share({
477
- title: title || 'Short AI Video',
478
- url: fullUrl
479
- }).then(function(){toast('✅ Đã chia sẻ!');return;}).catch(function(e){
480
- if(e.name==='AbortError')return;
481
- downloadVideo(fullUrl, title);
482
- });
483
- return;
484
  }
485
- }catch(e){}
486
- // Step 2: Fallback - download
487
- downloadVideo(fullUrl, title);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
488
  }
489
- function downloadVideo(url, title){
490
- var a = document.createElement('a');
491
- a.href = url;
492
- a.download = (title||'video').replace(/[^a-zA-Z0-9_\-\p{L}]/gu,'_').substring(0,60)+'.mp4';
493
- a.target = '_blank';
494
- a.rel = 'noopener';
495
- a.style.display = 'none';
496
- document.body.appendChild(a);
497
- a.click();
498
- document.body.removeChild(a);
499
- toast('📥 Đang tải video xuống...');
500
- }
 
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);
131
  const track=document.getElementById('ai-wall-track');
132
  const wrap=document.getElementById('ai-wall-wrap');
133
+ const homeEl=document.getElementById('view-home');
134
+ if(!track||!wrap){
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);}
193
  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);}}
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 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>'}}
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 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}
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,71 +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:60%;min-height:140px;background:rgba(18,18,18,.95);border-radius:14px 14px 0 0;z-index:10;overflow:clip;display:flex;flex-direction:column}.inline-cmt-header{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-bottom:1px solid #333;color:#5cb87a;font-size:12px;font-weight:700;flex-shrink:0}.inline-cmt-header button{background:none;border:0;color:#fff;font-size:16px;cursor:pointer}.inline-cmt-list{flex:1 1 auto;overflow-y:auto;padding:6px 10px;max-height:140px;min-height:40px}.inline-cmt-item{background:#222;border-radius:8px;padding:6px 8px;margin:4px 0;color:#ccc;font-size:11px;line-height:1.3}.inline-cmt-time{font-size:9px;color:#777;margin-right:6px}.inline-cmt-input{display:flex;gap:6px;padding:8px 10px;border-top:1px solid #333;flex-shrink:0;position:sticky;bottom:0;background:rgba(18,18,18,.98)}.inline-cmt-input input{flex:1;background:#222;border:1px solid #444;color:#eee;border-radius:16px;padding:7px 12px;font-size:11px;min-height:32px}.inline-cmt-input button{background:#2d8659;border:0;color:#fff;border-radius:16px;padding:7px 12px;font-size:11px;cursor:pointer;min-height:32px}.wc2026-section{margin:6px 4px;background:linear-gradient(135deg,#0d1117,#1a1a3a);border:1px solid #1a3a5a;border-radius:10px;overflow:hidden}.wc-header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:linear-gradient(90deg,#0b2e4a,#1a3a5a)}.wc-header h2{font-size:15px;color:#fff;margin:0}.wc-live-badge{font-size:10px;color:#e74c3c;font-weight:700;animation:wc-pulse 1.5s infinite}@keyframes wc-pulse{0%,100%{opacity:1}50%{opacity:.4}}.wc-tabs{display:flex;gap:4px;padding:8px 10px;overflow-x:auto;scrollbar-width:none}.wc-tabs::-webkit-scrollbar{display:none}.wc-tab{padding:5px 10px;background:#1a2a3a;border:1px solid #2a3a4a;border-radius:12px;color:#8ab4d8;font-size:10px;cursor:pointer;white-space:nowrap;flex-shrink:0}.wc-tab.active{background:#0b6bcb;border-color:#0b6bcb;color:#fff;font-weight:700}.wc-content{padding:8px 10px;max-height:500px;overflow-y:auto}.wc-news-grid{display:flex;flex-direction:column;gap:8px}.wc-news-item{display:flex;gap:8px;padding:8px;background:#1a2030;border-radius:8px;cursor:pointer}.wc-news-item:active{opacity:.8}.wc-news-img{flex:0 0 70px;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#222}.wc-news-img img{width:100%;height:100%;object-fit:cover}.wc-news-text{flex:1;min-width:0}.wc-news-title{font-size:11px;font-weight:700;color:#eee;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wc-news-via{font-size:9px;color:#6a9fca;margin-top:2px}.ls-section{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.ls-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.ls-header h3{color:#f0c040;font-size:13px;font-weight:800}.ls-tabs{display:flex;gap:4px;padding:0 10px 8px;overflow-x:auto;scrollbar-width:none}.ls-tabs::-webkit-scrollbar{display:none}.ls-tab{padding:4px 10px;background:#222;border:1px solid #333;border-radius:12px;color:#999;font-size:10px;white-space:nowrap;cursor:pointer;flex-shrink:0}.ls-tab.active{background:#2d8659;border-color:#2d8659;color:#fff;font-weight:700}.ls-content{max-height:420px;overflow-y:auto;padding:0 6px 8px;font-size:12px;color:#ddd}.ls-content ul{list-style:none;padding:0;margin:0}.ls-content .title-content{display:flex;gap:6px;align-items:center;background:#222;border-radius:4px;margin:4px 0;padding:5px 8px}.ls-content .title-content img{width:18px;height:18px}.ls-content .title-content strong{font-size:11px;color:#ccc}.ls-content .match-detail{padding:6px;border-bottom:1px solid #262626;cursor:pointer}.ls-content .match-detail:hover{background:#1a2a1f}.ls-content .match{display:flex;flex-wrap:wrap;align-items:center;gap:4px}.ls-content .datetime{width:100%;font-size:9px;color:#888}.ls-content .teams{display:flex;width:100%;align-items:center;gap:4px}.ls-content .team{flex:1;display:flex;align-items:center;gap:4px;min-width:0;text-decoration:none}.ls-content .team .name{font-size:11px;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ls-content .team .logo img{width:18px;height:18px}.ls-content .home-team{justify-content:flex-end;text-align:right}.ls-content .status{flex:0 0 54px;text-align:center}.ls-content .status a{color:#fff;text-decoration:none;font-weight:800;font-size:12px}.ls-content .status .label{font-size:8px;color:#888;display:block}.ls-content .status .label.live{color:#e74c3c}.ls-content .info,.ls-content .btns{display:none}.ls-content table{width:100%;border-collapse:collapse;font-size:11px;color:#ccc}.ls-content table th{background:#222;color:#999;padding:5px 4px;font-size:10px;border-bottom:1px solid #333}.ls-content table td{padding:4px 3px;border-bottom:1px solid #1a1a1a}.ls-content table .team-name{display:flex;align-items:center;gap:4px}.ls-content table .team-name img{width:16px;height:16px}.ls-content table .pts{font-weight:800;color:#f0c040}.match-overlay{position:fixed;inset:0;background:#111;z-index:9999;display:none;flex-direction:column;overflow:auto}.match-overlay.active{display:flex}.mo-header{padding:10px;background:#1a1a1a;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:1}.mo-header h3{font-size:13px;color:#eee}.mo-close{background:none;border:0;color:#fff;font-size:22px;cursor:pointer}.mo-tabs{display:flex;gap:4px;padding:8px 10px;background:#1a1a1a;overflow-x:auto}.mo-tab{padding:5px 12px;background:#222;border:1px solid #333;border-radius:10px;color:#999;font-size:10px;cursor:pointer;white-space:nowrap}.mo-tab.active{background:#2d8659;color:#fff}.mo-body{padding:8px;overflow-x:auto;font-size:12px;color:#ddd}.mo-body ul{list-style:none;padding:0;margin:0}.mo-body li{padding:5px 0;border-bottom:1px solid #222}.featured-match{margin:6px 4px;background:linear-gradient(135deg,#1a2a1f,#0d1117);border:1px solid #2d8659;border-radius:10px;padding:12px;cursor:pointer}.fm-league{text-align:center;color:#5cb87a;font-size:9px;font-weight:700;text-transform:uppercase}.fm-teams{display:flex;align-items:center;justify-content:center;gap:10px;margin-top:6px}.fm-team{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px}.fm-team img{width:32px;height:32px;object-fit:contain}.fm-team span{font-size:10px;color:#ccc;text-align:center}.fm-score{font-size:22px;font-weight:900;min-width:60px;text-align:center;color:#fff}.fm-status{text-align:center;margin-top:6px;font-size:9px;color:#e74c3c;font-weight:700}.fm-status.upcoming{color:#f0c040}.ai-compose{margin:6px 4px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.ai-compose-title{font-size:13px;font-weight:800;color:#5cb87a;margin-bottom:8px}.ai-compose-row{display:flex;gap:6px;margin-top:6px}.ai-compose input{flex:1;background:#222;border:1px solid #333;color:#eee;border-radius:18px;padding:9px 12px;font-size:12px;min-width:0}.ai-compose button{background:#2d8659;border:0;color:#fff;border-radius:18px;padding:9px 12px;font-size:11px;font-weight:700;cursor:pointer;white-space:nowrap}.ai-compose button.secondary{background:#333}.hot-topic-row{display:flex;gap:6px;overflow-x:auto;padding:4px 0;scrollbar-width:none}.hot-topic-row::-webkit-scrollbar{display:none}.hot-chip{flex:0 0 auto;background:#222;border:1px solid #333;color:#ddd;border-radius:16px;padding:5px 10px;font-size:11px;cursor:pointer;white-space:nowrap}.hot-chip:active{transform:scale(.96)}.hashtag-sources{margin:8px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.hashtag-sources h3{font-size:13px;color:#5cb87a;margin-bottom:8px}.hashtag-src-item{display:flex;gap:8px;padding:8px;background:#202020;border-radius:8px;margin:6px 0;cursor:pointer}.hashtag-src-item:active{opacity:.8}.hashtag-src-img{flex:0 0 80px;aspect-ratio:16/9;background:#333;border-radius:6px;overflow:hidden}.hashtag-src-img img{width:100%;height:100%;object-fit:cover}.hashtag-src-text{flex:1;min-width:0}.hashtag-src-title{font-size:12px;font-weight:700;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.hashtag-src-via{font-size:10px;color:#888;margin-top:2px}.hashtag-rewrite-btn{width:100%;margin-top:8px;background:#2d8659;border:0;color:#fff;padding:9px;border-radius:10px;font-size:12px;font-weight:700;cursor:pointer}.hashtag-load-more{width:100%;margin-top:8px;background:#222;border:1px solid #333;color:#ccc;padding:9px;border-radius:10px;font-size:12px;cursor:pointer}.hashtag-loading{display:flex;align-items:center;gap:8px;padding:12px;color:#888;font-size:12px}.hashtag-spinner{width:16px;height:16px;border:2px solid #333;border-top-color:#5cb87a;border-radius:50%;animation:ht-spin .8s linear infinite}@keyframes ht-spin{to{transform:rotate(360deg)}}.wall-item{flex:0 0 260px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:8px}.wall-item-new{animation:wall-flash 1.8s ease-out}@keyframes wall-flash{0%{border-color:#f0c040;box-shadow:0 0 18px rgba(240,192,64,.35)}30%{border-color:#f0c040;box-shadow:0 0 12px rgba(240,192,64,.2)}100%{border-color:#2b2b2b;box-shadow:none}}.wall-thumb{width:100%;aspect-ratio:16/9;border-radius:8px;background:#222;overflow:hidden;margin-bottom:6px;position:relative}.wall-thumb img{width:100%;height:100%;object-fit:cover}.wall-video-badge{position:absolute;top:4px;right:4px;background:rgba(45,134,89,.9);color:#fff;font-size:10px;padding:2px 6px;border-radius:6px;font-weight:700}.wall-title{font-size:12px;color:#5cb87a;font-weight:800;line-height:1.3;margin-bottom:4px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wall-text{font-size:11px;color:#bbb;line-height:1.4;white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden}.wall-actions{display:flex;gap:6px;margin-top:8px}.wall-actions button{flex:1;border:1px solid #333;background:#222;color:#ddd;border-radius:14px;padding:6px 8px;font-size:10px;cursor:pointer}.wall-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}#progress-toast{position:fixed;bottom:70px;left:50%;transform:translateX(-50%);background:#2d8659;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;z-index:99998;box-shadow:0 4px 12px rgba(0,0,0,.4);display:none;white-space:nowrap}.storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 4px}
15
- </style>
 
 
 
 
16
  </head>
17
  <body>
18
- <div class="header"><h1>📰 VNEWS</h1><p>Tin tức · Bóng đá LIVE · Highlight · AI · World Cup 2026</p></div>
19
  <div class="cats" id="cat-bar"></div>
20
  <div id="view-home" class="view active"><div class="loading">Đang tải...</div></div>
21
  <div id="view-cat" class="view"></div>
22
  <div id="view-video" class="view"></div>
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,postId){
38
- var shareUrl;
39
- if(postId){
40
- shareUrl = SPACE+'/s?post_id='+encodeURIComponent(postId)+'&title='+encodeURIComponent(title);
41
- } else {
42
- shareUrl = SPACE+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||'');
43
- }
44
- // Use clipboard API first (modern, works on Chrome mobile 85+)
45
- if(navigator.clipboard && navigator.clipboard.writeText){
46
- navigator.clipboard.writeText(shareUrl).then(function(){
47
- toast('📋 Đã sao chép link!');
48
- try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){}
49
- }).catch(function(){
50
- try{
51
- var ta=document.createElement('textarea');ta.value=shareUrl;ta.style.position='fixed';ta.style.left='-9999px';ta.style.top='-9999px';ta.style.opacity='0';
52
- document.body.appendChild(ta);ta.select();ta.setSelectionRange(0,99999);
53
- if(document.execCommand('copy')){toast('📋 Đã sao chép link!');}else{prompt('📋 Sao chép link:', shareUrl);}
54
- document.body.removeChild(ta);
55
- }catch(e){prompt('📋 Sao chép link:', shareUrl);}
56
- try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){}
57
- });
58
- } else {
59
- try{
60
- var ta=document.createElement('textarea');ta.value=shareUrl;ta.style.position='fixed';ta.style.left='-9999px';ta.style.top='-9999px';ta.style.opacity='0';
61
- document.body.appendChild(ta);ta.select();ta.setSelectionRange(0,99999);
62
- if(document.execCommand('copy')){toast('📋 Đã sao chép link!');}else{prompt('📋 Sao chép link:', shareUrl);}
63
- document.body.removeChild(ta);
64
- }catch(e){prompt('📋 Sao chép link:', shareUrl);}
65
- try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){}
66
- }
67
- }
68
- async function init(){_cats=await fetch('/api/categories').then(r=>r.json()).catch(()=>[]);let bar='<div class="cat active" data-cat="home">🏠</div><div class="cat" data-cat="news-all">📰 Tin tức</div>';_cats.forEach(c=>{bar+='<div class="cat" data-cat="'+c.id+'">'+c.name+'</div>'});document.getElementById('cat-bar').innerHTML=bar;document.querySelectorAll('.cat').forEach(t=>{t.onclick=()=>switchCat(t.dataset.cat)});}
69
- var SPACE=location.origin;
70
  </script>
71
- <script src="/static/app_v2.js?v=20260711"></script>
72
- <script src="/static/yt_live.js"></script>
73
- <script src="/static/vtv_init.js"></script>
74
- <script src="/static/hot_multi.js?v=1"></script>
75
  <script src="/static/wc2026_v2.js"></script>
76
  <script src="/static/live_mode.js"></script>
77
- <script src="/static/match_detail_v6.js"></script>
78
- <script>init();loadHome();</script>
79
  </body>
80
- </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,370 +0,0 @@
1
- // ===== VNEWS VTV Player v11 — SV2 iframe + HLS.js native player toggle =====
2
- (function() {
3
- if (window._vtvInitLoaded) return;
4
- window._vtvInitLoaded = true;
5
-
6
- var CHANNELS = [
7
- {id:'vtv1',name:'VTV1',badge:'Tin tức'},{id:'vtv2',name:'VTV2',badge:'Khoa học'},
8
- {id:'vtv3',name:'VTV3',badge:'Giải trí'},{id:'vtv4',name:'VTV4',badge:'Quốc Tế'},
9
- {id:'vtv5',name:'VTV5',badge:'Miền Nam'},{id:'vtv6',name:'VTV6',badge:'Thanh Niên'},
10
- {id:'vtv7',name:'VTV7',badge:'Giáo Dục'},{id:'vtv8',name:'VTV8',badge:'Miền Trung'},
11
- {id:'vtv9',name:'VTV9',badge:'Miền Bắc'},{id:'vtv10',name:'VTV10',badge:'Cần Thơ'},
12
- {id:'vtvprime',name:'VTVPrime',badge:'Prime'},
13
- ];
14
-
15
- var _currentCh = null;
16
- var _epgTimer = null;
17
- var _epgData = [];
18
- var _timeTimer = null;
19
- var _serverDate = '';
20
- var _nativeMode = false; // false=iframe, true=HLSjs
21
- var _hls = null;
22
-
23
- // ===== CSS =====
24
- (function injectCSS() {
25
- if (document.getElementById('vtv-pro-css')) return;
26
- var s = document.createElement('style');
27
- s.id = 'vtv-pro-css';
28
- s.textContent = [
29
- '#vtv-player-section{display:block!important;margin:8px 0 10px;background:linear-gradient(180deg,#0a0e17,#111622);border:1px solid rgba(0,150,255,.15);border-radius:14px;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}',
30
- '#vtv-player-section .vtv-pro-head{display:flex;align-items:center;gap:10px;padding:10px 14px;background:linear-gradient(90deg,#0a1628,rgba(0,102,204,.08))}',
31
- '#vtv-player-section .vtv-pro-logo{width:22px;height:22px;border-radius:50%;background:linear-gradient(135deg,#0066cc,#00ccff);display:flex;align-items:center;justify-content:center;font-size:11px;color:#fff;font-weight:800;flex-shrink:0}',
32
- '#vtv-player-section .vtv-pro-title{font-size:13px;font-weight:700;color:#e8eaed;letter-spacing:.3px}',
33
- '#vtv-player-section .vtv-pro-live{font-size:9px;font-weight:700;color:#00cc88;background:rgba(0,204,136,.12);padding:2px 8px;border-radius:10px;display:flex;align-items:center;gap:4px;margin-left:auto}',
34
- '#vtv-player-section .vtv-pro-live-dot{width:5px;height:5px;border-radius:50%;background:#00cc88;animation:vtv-pro-pulse 1.2s infinite}',
35
- '@keyframes vtv-pro-pulse{0%,100%{opacity:1}50%{opacity:.3}}',
36
- '#vtv-player-section .vtv-pro-tabs{display:flex;flex-wrap:wrap;gap:2px;padding:6px 10px 8px;overflow-x:auto;scrollbar-width:none}',
37
- '#vtv-player-section .vtv-pro-tabs::-webkit-scrollbar{display:none}',
38
- '#vtv-player-section .vtv-pro-tab{padding:5px 10px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.06);border-radius:8px;color:#9aa0a6;font-size:10px;font-weight:500;cursor:pointer;white-space:nowrap;flex-shrink:0;transition:all .2s ease}',
39
- '#vtv-player-section .vtv-pro-tab:hover{background:rgba(0,102,204,.1);border-color:rgba(0,102,204,.3);color:#e8eaed}',
40
- '#vtv-player-section .vtv-pro-tab.on{background:rgba(0,102,204,.2);border-color:#0066cc;color:#fff;font-weight:600}',
41
- '#vtv-player-section .vtv-pro-tab .b{font-size:7px;opacity:.5;display:block;margin-top:1px}',
42
- '#vtv-player-section .vtv-pro-frame{position:relative;width:100%;aspect-ratio:16/9;background:#000;min-height:200px;border-top:1px solid rgba(255,255,255,.04)}',
43
- '#vtv-player-section .vtv-pro-frame iframe{position:absolute;inset:0;width:100%!important;height:100%!important;border:none;background:#000}',
44
- '#vtv-player-section .vtv-pro-frame video{position:absolute;inset:0;width:100%;height:100%;background:#000}',
45
- '#vtv-player-section .vtv-pro-load{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;min-height:200px;gap:12px;color:#9aa0a6;font-size:12px}',
46
- '#vtv-player-section .vtv-pro-spinner{width:28px;height:28px;border:2px solid rgba(255,255,255,.06);border-top-color:#0066cc;border-radius:50%;animation:vtv-pro-spin .7s linear infinite}',
47
- '@keyframes vtv-pro-spin{to{transform:rotate(360deg)}}',
48
- '#vtv-player-section .vtv-pro-err{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;min-height:200px;gap:10px;color:#9aa0a6;font-size:12px;text-align:center;padding:20px}',
49
- '#vtv-player-section .vtv-pro-err .icon{font-size:28px;opacity:.5}',
50
- '#vtv-player-section .vtv-pro-err .msg{color:#9aa0a6}',
51
- '#vtv-player-section .vtv-pro-err button{background:rgba(0,102,204,.15);border:1px solid rgba(0,102,204,.3);color:#8ab4f8;padding:7px 16px;border-radius:8px;font-size:11px;cursor:pointer;transition:all .15s}',
52
- '#vtv-player-section .vtv-pro-err button:hover{background:rgba(0,102,204,.25)}',
53
- '#vtv-player-section .vtv-pro-controls{display:flex;align-items:center;gap:6px;padding:6px 12px;background:rgba(0,0,0,.3);border-top:1px solid rgba(255,255,255,.04)}',
54
- '#vtv-player-section .vtv-pro-btn{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.08);color:#9aa0a6;font-size:10px;padding:4px 10px;border-radius:6px;cursor:pointer;transition:all .15s;display:flex;align-items:center;gap:4px}',
55
- '#vtv-player-section .vtv-pro-btn:hover{background:rgba(0,102,204,.12);color:#e8eaed}',
56
- '#vtv-player-section .vtv-pro-switch{font-size:9px;padding:3px 8px;background:rgba(0,150,255,.08);border:1px solid rgba(0,150,255,.2);color:#8ab4f8;border-radius:6px;cursor:pointer;transition:all .15s;display:flex;align-items:center;gap:3px;white-space:nowrap}',
57
- '#vtv-player-section .vtv-pro-switch:hover{background:rgba(0,150,255,.2)}',
58
- '#vtv-player-section .vtv-pro-switch.active{background:rgba(0,204,136,.12);border-color:rgba(0,204,136,.3);color:#00cc88}',
59
- '#vtv-player-section .vtv-pro-epg{border-top:1px solid rgba(255,255,255,.04);background:rgba(0,0,0,.15)}',
60
- '#vtv-player-section .vtv-pro-epg-hdr{display:flex;align-items:center;justify-content:space-between;padding:8px 12px 4px}',
61
- '#vtv-player-section .vtv-pro-epg-title{font-size:10px;font-weight:600;color:#9aa0a6;letter-spacing:.5px;text-transform:uppercase}',
62
- '#vtv-player-section .vtv-pro-epg-time{font-size:9px;color:#5f6368;font-variant-numeric:tabular-nums}',
63
- '#vtv-player-section .vtv-pro-epg-list{padding:2px 8px 10px;display:flex;flex-direction:column;gap:2px;max-height:200px;overflow-y:auto;scrollbar-width:thin}',
64
- '#vtv-player-section .vtv-pro-epg-list::-webkit-scrollbar{width:3px}',
65
- '#vtv-player-section .vtv-pro-epg-list::-webkit-scrollbar-thumb{background:rgba(255,255,255,.08);border-radius:3px}',
66
- '#vtv-player-section .vtv-pro-epg-row{display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;transition:all .2s;border-left:2px solid transparent;font-size:11px}',
67
- '#vtv-player-section .vtv-pro-epg-row:hover{background:rgba(255,255,255,.03)}',
68
- '#vtv-player-section .vtv-pro-epg-row.now{border-left-color:#00cc88;background:rgba(0,204,136,.06)}',
69
- '#vtv-player-section .vtv-pro-epg-row.passed{opacity:.35}',
70
- '#vtv-player-section .vtv-pro-epg-row .t{font-size:10px;color:#5f6368;min-width:38px;font-variant-numeric:tabular-nums}',
71
- '#vtv-player-section .vtv-pro-epg-row.now .t{color:#00cc88;font-weight:600}',
72
- '#vtv-player-section .vtv-pro-epg-row .n{color:#e8eaed;line-height:1.3}',
73
- '#vtv-player-section .vtv-pro-epg-row.now .n{color:#fff;font-weight:500}',
74
- '#vtv-player-section .vtv-pro-epg-row .bar{flex:0 0 2px;height:12px;border-radius:1px;background:rgba(255,255,255,.08)}',
75
- '#vtv-player-section .vtv-pro-epg-row.now .bar{background:#00cc88}',
76
- '#vtv-player-section .vtv-pro-epg-row .d{font-size:8px;color:#5f6368;min-width:30px;text-align:right}',
77
- '#vtv-player-section .vtv-pro-epg-empty{color:#5f6368;font-size:11px;padding:12px 14px;text-align:center}',
78
- '#vtv-player-section .vtv-pro-epg-load{color:#5f6368;font-size:11px;padding:12px 14px;text-align:center;display:flex;align-items:center;justify-content:center;gap:6px}',
79
- ].join('');
80
- document.head.appendChild(s);
81
- })();
82
-
83
- function formatTime(d) {
84
- var h = d.getHours(), m = d.getMinutes();
85
- return (h < 10 ? '0' : '') + h + ':' + (m < 10 ? '0' : '') + m;
86
- }
87
-
88
- function getVNTime() {
89
- var now = new Date();
90
- try {
91
- var opts = { timeZone: 'Asia/Ho_Chi_Minh', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, year: 'numeric', month: '2-digit', day: '2-digit' };
92
- var parts = new Intl.DateTimeFormat('en-CA', opts).formatToParts(now);
93
- var hh=0,mm=0,ss=0,yy=2026,mo=1,dd=1;
94
- for (var i = 0; i < parts.length; i++) {
95
- var p = parts[i];
96
- if (p.type === 'hour') hh = parseInt(p.value);
97
- else if (p.type === 'minute') mm = parseInt(p.value);
98
- else if (p.type === 'second') ss = parseInt(p.value);
99
- else if (p.type === 'year') yy = parseInt(p.value);
100
- else if (p.type === 'month') mo = parseInt(p.value);
101
- else if (p.type === 'day') dd = parseInt(p.value);
102
- }
103
- return new Date(yy, mo - 1, dd, hh, mm, ss);
104
- } catch(e) {
105
- var utc = new Date(now.getTime() + now.getTimezoneOffset() * 60000);
106
- return new Date(utc.getTime() + 7 * 3600000);
107
- }
108
- }
109
-
110
- // ===== EPG =====
111
- function loadEPG(chId) {
112
- var epgEl = document.getElementById('vtv-pro-epg-body');
113
- if (!epgEl) return;
114
- epgEl.innerHTML = '<div class="vtv-pro-epg-load"><div class="vtv-pro-spinner" style="width:12px;height:12px;border-width:1.5px;flex-shrink:0"></div>Đang tải lịch...</div>';
115
- var xhr = new XMLHttpRequest();
116
- xhr.open('GET', '/api/vtv/epg/' + chId, true);
117
- xhr.timeout = 15000;
118
- xhr.onload = function() {
119
- try {
120
- var data = JSON.parse(xhr.responseText);
121
- _serverDate = data.date || '';
122
- _epgData = data.programs || [];
123
- renderEPG();
124
- } catch(e) { epgEl.innerHTML = '<div class="vtv-pro-epg-empty">Không có lịch phát sóng</div>'; }
125
- };
126
- xhr.onerror = xhr.ontimeout = function() {
127
- epgEl.innerHTML = '<div class="vtv-pro-epg-empty">Không tải được lịch phát sóng</div>';
128
- };
129
- xhr.send();
130
- }
131
-
132
- function renderEPG() {
133
- var epgEl = document.getElementById('vtv-pro-epg-body');
134
- if (!epgEl || !_epgData || !_epgData.length) {
135
- if (epgEl) epgEl.innerHTML = '<div class="vtv-pro-epg-empty">Chưa có lịch phát sóng</div>';
136
- return;
137
- }
138
- var vnNow = getVNTime();
139
- var nowStr = formatTime(vnNow);
140
- _epgData.sort(function(a, b) { return a.time.localeCompare(b.time); });
141
- for (var i = 0; i < _epgData.length; i++) {
142
- _epgData[i].now = false;
143
- var p = _epgData[i], nxt = _epgData[i + 1];
144
- if (p.time <= nowStr && (!nxt || nxt.time > nowStr)) { p.now = true; p.end_time = nxt ? nxt.time : ''; }
145
- }
146
- var html = '', prevDate = '';
147
- for (var i = 0; i < _epgData.length; i++) {
148
- var p = _epgData[i];
149
- var cls = 'vtv-pro-epg-row';
150
- if (p.now) cls += ' now';
151
- else if (p.time < nowStr) cls += ' passed';
152
- var xt = p.end_time ? ' → ' + p.end_time : (i < _epgData.length - 1 ? ' → ' + _epgData[i+1].time : '');
153
- var db = '';
154
- if (p.date && p.date !== prevDate) { db = '<span class="d">' + p.date + '</span>'; prevDate = p.date; }
155
- html += '<div class="' + cls + '"><span class="t">' + p.time + xt + '</span><span class="bar"></span><span class="n">' + p.title + '</span>' + db + '</div>';
156
- }
157
- epgEl.innerHTML = html;
158
- var nowEl = epgEl.querySelector('.now');
159
- if (nowEl) nowEl.scrollIntoView({ block: 'center', behavior: 'smooth' });
160
- }
161
-
162
- function startEpgRefresh() {
163
- if (_epgTimer) clearInterval(_epgTimer);
164
- _epgTimer = setInterval(function() { if (_currentCh) loadEPG(_currentCh); }, 60000);
165
- }
166
-
167
- function updateClock() {
168
- var timeEl = document.getElementById('vtv-pro-time');
169
- if (timeEl) {
170
- var vn = getVNTime();
171
- timeEl.textContent = formatTime(vn) + ' GMT+7' + (_serverDate ? ' - ' + _serverDate : '');
172
- }
173
- }
174
-
175
- // ===== Destroy HLS player =====
176
- function destroyHLS() {
177
- if (_hls) {
178
- try { _hls.destroy(); } catch(e) {}
179
- _hls = null;
180
- }
181
- }
182
-
183
- // ===== Play native HLS via proxy =====
184
- function playNativeHLS(chId, m3u8ProxyUrl) {
185
- destroyHLS();
186
- var frame = document.getElementById('vtv-pro-frame');
187
- if (!frame) return;
188
- frame.innerHTML = '<video id="vtv-native-video" controls autoplay playsinline webkit-playsinline style="position:absolute;inset:0;width:100%;height:100%;background:#000"></video>';
189
-
190
- var video = document.getElementById('vtv-native-video');
191
- if (!video) return;
192
-
193
- // Try HLS.js if available (injected via index_v2.html)
194
- if (typeof Hls !== 'undefined' && Hls.isSupported()) {
195
- _hls = new Hls({ enableWorker: false });
196
- _hls.loadSource(m3u8ProxyUrl);
197
- _hls.attachMedia(video);
198
- _hls.on(Hls.Events.MANIFEST_PARSED, function() {
199
- video.play().catch(function(){});
200
- });
201
- _hls.on(Hls.Events.ERROR, function(evt, data) {
202
- if (data.fatal) {
203
- // Fallback: direct video src
204
- destroyHLS();
205
- video.src = m3u8ProxyUrl;
206
- video.play().catch(function(){});
207
- }
208
- });
209
- } else {
210
- // Native HLS fallback (Safari/iOS)
211
- video.src = m3u8ProxyUrl;
212
- }
213
- }
214
-
215
- // ===== Toggle iframe / native HLS =====
216
- window._vtvToggleNative = function(btn) {
217
- if (!_currentCh) return;
218
- _nativeMode = !_nativeMode;
219
- btn.classList.toggle('active', _nativeMode);
220
- btn.innerHTML = _nativeMode ? '📡 HLS' : '🔌 JWPlayer';
221
-
222
- var frame = document.getElementById('vtv-pro-frame');
223
- if (!frame) return;
224
-
225
- // Re-fetch stream data then switch
226
- var xhr = new XMLHttpRequest();
227
- xhr.open('GET', '/api/vtv/stream/' + _currentCh, true);
228
- xhr.timeout = 15000;
229
- xhr.onload = function() {
230
- try {
231
- var data = JSON.parse(xhr.responseText);
232
- if (_nativeMode) {
233
- var m3u8Url = data.m3u8_proxy_url || data.proxy_url_hls || data.stream_url;
234
- if (m3u8Url) {
235
- playNativeHLS(_currentCh, m3u8Url);
236
- } else {
237
- frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">📡</div><div class="msg">Kênh không hỗ trợ HLS</div></div>';
238
- _nativeMode = false;
239
- btn.classList.remove('active');
240
- btn.innerHTML = '🔌 JWPlayer';
241
- }
242
- } else {
243
- var iframeUrl = data.iframe_url;
244
- if (iframeUrl) {
245
- destroyHLS();
246
- var iframe = document.createElement('iframe');
247
- iframe.src = iframeUrl;
248
- iframe.setAttribute('allowfullscreen', 'true');
249
- iframe.setAttribute('allow', 'autoplay; encrypted-media; fullscreen');
250
- iframe.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;border:none;background:#000';
251
- frame.innerHTML = '';
252
- frame.appendChild(iframe);
253
- }
254
- }
255
- } catch(e) {
256
- frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">⚠️</div></div>';
257
- }
258
- };
259
- xhr.onerror = function() {
260
- frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">🔌</div><div class="msg">Lỗi kết nối</div></div>';
261
- };
262
- xhr.send();
263
- };
264
-
265
- // ===== Switch channel =====
266
- window._vtvProSwitch = function(chId) {
267
- _currentCh = chId;
268
- _nativeMode = false;
269
-
270
- var tabs = document.querySelectorAll('#vtv-player-section .vtv-pro-tab');
271
- for (var i = 0; i < tabs.length; i++)
272
- tabs[i].className = 'vtv-pro-tab' + (tabs[i].getAttribute('data-ch') === chId ? ' on' : '');
273
-
274
- // Reset toggle button
275
- var toggleBtn = document.getElementById('vtv-pro-toggle-btn');
276
- if (toggleBtn) { toggleBtn.classList.remove('active'); toggleBtn.innerHTML = '🔌 JWPlayer'; }
277
-
278
- updateClock();
279
- if (_timeTimer) clearInterval(_timeTimer);
280
- _timeTimer = setInterval(updateClock, 60000);
281
-
282
- destroyHLS();
283
-
284
- var frame = document.getElementById('vtv-pro-frame');
285
- if (!frame) return;
286
- frame.innerHTML = '<div class="vtv-pro-load"><div class="vtv-pro-spinner"></div><span>Đang kết nối ' + chId.toUpperCase() + '...</span></div>';
287
-
288
- loadEPG(chId);
289
- startEpgRefresh();
290
-
291
- var xhr = new XMLHttpRequest();
292
- xhr.open('GET', '/api/vtv/stream/' + chId, true);
293
- xhr.timeout = 15000;
294
- xhr.onload = function() {
295
- try {
296
- var data = JSON.parse(xhr.responseText);
297
- var iframeUrl = data.iframe_url;
298
- if (!iframeUrl) {
299
- frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">📡</div><div class="msg">Kênh không khả dụng</div><button onclick="_vtvProSwitch(\''+chId+'\')">Thử lại</button></div>';
300
- return;
301
- }
302
- var iframe = document.createElement('iframe');
303
- iframe.src = iframeUrl;
304
- iframe.setAttribute('allowfullscreen', 'true');
305
- iframe.setAttribute('allow', 'autoplay; encrypted-media; fullscreen');
306
- iframe.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;border:none;background:#000';
307
- frame.innerHTML = '';
308
- frame.appendChild(iframe);
309
- } catch(e) {
310
- frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">⚠️</div><div class="msg">Lỗi tải dữ liệu</div><button onclick="_vtvProSwitch(\''+chId+'\')">Thử lại</button></div>';
311
- }
312
- };
313
- xhr.onerror = xhr.ontimeout = function() {
314
- frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">🔌</div><div class="msg">Mất kết nối máy chủ</div><button onclick="_vtvProSwitch(\''+chId+'\')">Thử lại</button></div>';
315
- };
316
- xhr.send();
317
- };
318
-
319
- // ===== HTML builder =====
320
- function buildHTML() {
321
- var tabs = '';
322
- for (var i = 0; i < CHANNELS.length; i++) {
323
- var ch = CHANNELS[i];
324
- tabs += '<span class="vtv-pro-tab" data-ch="'+ch.id+'" onclick="_vtvProSwitch(\''+ch.id+'\')">' +
325
- ch.name + '<span class="b">'+ch.badge+'</span></span>';
326
- }
327
- return '<div id="vtv-player-section">' +
328
- '<div class="vtv-pro-head">' +
329
- '<div class="vtv-pro-logo">V</div>' +
330
- '<span class="vtv-pro-title">VTV Player</span>' +
331
- '<span class="vtv-pro-live"><span class="vtv-pro-live-dot"></span>TRỰC TIẾP</span>' +
332
- '</div>' +
333
- '<div class="vtv-pro-tabs">'+tabs+'</div>' +
334
- '<div class="vtv-pro-frame" id="vtv-pro-frame">' +
335
- '<div class="vtv-pro-load"><div class="vtv-pro-spinner"></div><span>Chọn kênh để xem trực tiếp</span></div>' +
336
- '</div>' +
337
- '<div class="vtv-pro-controls">' +
338
- '<span id="vtv-pro-time" class="vtv-pro-btn" style="background:none;border:none;font-size:10px;color:#5f6368;margin-right:auto"></span>' +
339
- '<span class="vtv-pro-switch" id="vtv-pro-toggle-btn" onclick="_vtvToggleNative(this)">🔌 JWPlayer</span>' +
340
- '</div>' +
341
- '<div class="vtv-pro-epg">' +
342
- '<div class="vtv-pro-epg-hdr"><span class="vtv-pro-epg-title">Lịch phát sóng — nguồn VTV.vn</span></div>' +
343
- '<div class="vtv-pro-epg-list" id="vtv-pro-epg-body"><div class="vtv-pro-epg-empty">Chọn kênh để xem lịch phát sóng</div></div>' +
344
- '</div>' +
345
- '</div>';
346
- }
347
-
348
- function inject() {
349
- var homeEl = document.getElementById('view-home');
350
- if (!homeEl || document.getElementById('vtv-player-section')) return;
351
- var featured = document.getElementById('home-featured-area') || homeEl.querySelector('.featured-match, .fm-section, .slider-wrap');
352
- if (featured && featured.parentNode) featured.insertAdjacentHTML('afterend', buildHTML());
353
- else if (homeEl.firstChild) homeEl.insertAdjacentHTML('afterbegin', buildHTML());
354
- setTimeout(function() { if (window._vtvProSwitch) window._vtvProSwitch('vtv6'); }, 500);
355
- }
356
-
357
- var orig = window.loadHome;
358
- if (typeof orig === 'function') {
359
- window.loadHome = function() {
360
- var r = orig.apply(this, arguments);
361
- if (r && typeof r.then === 'function') return r.then(function(v) { setTimeout(inject, 2000); return v; });
362
- else { setTimeout(inject, 2000); return r; }
363
- };
364
- } else {
365
- (function waitAndInject() {
366
- if (document.getElementById('view-home') && !document.getElementById('vtv-player-section')) inject();
367
- else if (!document.getElementById('vtv-player-section')) setTimeout(waitAndInject, 1000);
368
- })();
369
- }
370
- })();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
static/vtv_init_loader.html DELETED
@@ -1,2 +0,0 @@
1
- <script src="/static/yt_live.js"></script>
2
- <script src="/static/vtv_init.js"></script>
 
 
 
static/wc2026_v2.js CHANGED
@@ -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);