Restore version 98c133a

#21
by bep40 - opened
.huggingface/rebuild DELETED
@@ -1 +0,0 @@
1
- rebuild
 
 
.rebuild CHANGED
@@ -1 +1 @@
1
- rebuild-20260407-vtv-fix-syntax
 
1
+ rebuilt at 2026-06-18T09:24:34.973630
.restart_trigger CHANGED
@@ -1 +1 @@
1
- restart-20260407-vtv-fix
 
1
+ # restart trigger
CHANGELOG.md CHANGED
@@ -1,71 +1,36 @@
1
- # VNEWS v6.5 - Resilient Shorts Auto-Updater
2
 
3
  ## Changes
4
 
5
- ### Critical Fix: Shorts timeout and homepage load stability
6
- **Root cause**: YouTube shorts fetching in `main.py` using `scrape_shorts()` and `_yt_channel_shorts_requests()` could hang indefinitely when YouTube blocks requests or yt-dlp times out, causing:
7
- - Homepage `/api/shorts` endpoint to time out (30s limit)
8
- - Space to appear unresponsive on first load
9
- - No fallback when sources fail
10
-
11
- **Fix applied**:
12
- 1. **shorts_updater.py** (NEW) Resilient background updater:
13
- - Hard timeout (25s) per channel using subprocess isolation
14
- - Stale-while-revalidate pattern: returns cached data immediately, updates in background
15
- - Automatic fallback to hardcoded short URLs when all sources fail
16
- - Persistent storage in `/data/shorts_cache.json` for cache across restarts
17
- - Background scheduler runs every 10 minutes automatically
18
- - No blocking on first homepage load
19
-
20
- 2. **_run.py** Integrated resilient shorts endpoint:
21
- - Overrides `/api/shorts` with non-blocking version
22
- - Returns cached/fallback data in <100ms guaranteed
23
- - Triggers background update if cache is stale or empty
24
- - Never hangs - always returns valid JSON response
25
-
26
- 3. **FALLBACK_SHORTS** — 6 hardcoded viral shorts as emergency fallback:
27
- - baodantri7941 (Dân trí) headlines
28
- - baosuckhoedoisongboyte (Sức khỏe & đời sống) stories
29
- - vtvnambo (VTV Nam Bộ) news
30
-
31
- ### Benefits
32
- - Homepage loads in <2 seconds always
33
- - Shorts data auto-updates every 10 minutes
34
- - Never times out - graceful degradation to fallback
35
- - Persistent cache survives Space restarts
36
- - Uses bucket `bep40/VNEWS-storage` for cache storage
37
-
38
- ### Channels monitored
39
- - baodantri7941 (Dân trí)
40
- - baosuckhoedoisongboyte (Sức khỏe & đời sống)
41
- - vtvnambo (VTV Nam Bộ)
42
-
43
- ---
44
-
45
- # VNEWS v5.1 - Rewrite Fix
46
-
47
- ## Changes
48
-
49
- ### Critical Fix: Rewrite button not creating posts on Tường AI
50
- **Root cause**: `_run.py` imports from `app_v2_entry.py`, but the `/api/rewrite_share` endpoint was only defined in `ai_runtime_patch_fast.py` (loaded through `app_entry.py` which is NOT used). The frontend called a non-existent endpoint → 404 → silent failure.
51
-
52
- **Fix applied**:
53
- 1. **app_v2_entry.py** — Added 3 new endpoints:
54
- - `POST /api/rewrite_slide` — Fast extractive summary (no AI needed), creates slides from article key points + images, saves to wall
55
- - `POST /api/rewrite_share` — AI-powered rewrite with extractive fallback, saves to wall
56
- - `POST /api/url_wall` — URL submission endpoint (alias for rewrite_share)
57
- - All endpoints use the same `_load_wall_posts()` / `_save_wall_posts()` and `WALL_FILE` path as the existing `/api/wall` endpoint
58
-
59
- 2. **static/index_v2.html** — Added `<script src="/static/rewrite_fix_v2.js"></script>` to load the rewrite fix
60
-
61
- 3. **static/rewrite_fix_v2.js** — New file that overrides `rewriteArticle()` to:
62
- - Call `/api/rewrite_slide` first (fast, no AI needed)
63
- - Fallback to `/api/rewrite_share` if slide fails
64
- - Show slide preview overlay after successful post
65
- - Use `prependWallPost()` to add the new post to Tường AI
66
-
67
- ### Previous changes (v5)
68
- - Rewrote match_detail_v2.py with correct event parsing
69
- - 2-tab layout for match detail (stats + timeline)
70
- - Fixed _run.py import
71
- - Dockerfile cache busting
 
1
+ # VNEWS v5 - Match Detail Fix
2
 
3
  ## Changes
4
 
5
+ ### 1. match_detail_v2.py Rewrote event parser with correct selectors
6
+ - Parse `.events > .period > .event` structure (not old `.timeline`)
7
+ - Extract event type from SVG icons in `.event-type` (goal/redcard/yellowcard/substitution)
8
+ - Parse player names from `.players > div` elements
9
+ - For goals: extract scorer + assist names
10
+ - For substitutions: extract player_out → player_in
11
+ - For cards: extract player name
12
+ - Normalize time format: `45' +2` → `45+2'`
13
+ - Fetch H2H stats from `/api/fixtures/h2h-stats` API
14
+ - Parse prediction card, recent matches, H2H standings
15
+
16
+ ### 2. static/match_detail.js Complete rewrite with 2-tab layout
17
+ - **Tab "Thống kê"**: H2H stats comparison, prediction vote, recent match results
18
+ - **Tab "Diễn biến"**: Detailed timeline with:
19
+ - ⚽ BÀN THẮNG — scorer name + assist
20
+ - 🟥 THỺ ĐỎ player name
21
+ - 🟨 THỺ VÀNG player name
22
+ - ↔️ THAY ĐỔI player_out → player_in
23
+ - Period grouping (H1, H2) with visual headers
24
+ - Team badges (HOME/AWAY) per event
25
+ - Color-coded event icons
26
+
27
+ ### 3. app_v2_entry.py Updated
28
+ - Module cache clearing for fresh match_detail_v2 import on each API call
29
+ - Single clean import for both `/detail` and `/live` endpoints
30
+ - Removed duplicate inline scraping code
31
+
32
+ ### 4. _run.py Fixed import
33
+ - Changed `import match_detail` to `import match_detail_v2`
34
+
35
+ ### 5. Dockerfile Cache busting
36
+ - Added `RUN date > /app/.build_timestamp` to force Docker rebuild
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Dockerfile CHANGED
@@ -12,5 +12,4 @@ RUN pip install --no-cache-dir -r requirements.txt || true
12
  COPY . .
13
  EXPOSE 7860
14
 
15
- # v9 - 2026-07-08 - FIX streams: removed expired ssaimh tokens. VTV6 only from sv2. Frontend uses yt_live.js CSS + EPG
16
  CMD ["uvicorn", "_run:app", "--host", "0.0.0.0", "--port", "7860", "--reload"]
 
12
  COPY . .
13
  EXPOSE 7860
14
 
 
15
  CMD ["uvicorn", "_run:app", "--host", "0.0.0.0", "--port", "7860", "--reload"]
README.md CHANGED
@@ -9,25 +9,17 @@ tags:
9
  - ml-intern
10
  ---
11
 
12
- # 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 -->
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+ <!-- rebuild: v6.5-final-1781173463 -->
 
 
 
 
 
 
 
 
_run.py CHANGED
@@ -1 +1 @@
1
- from app_v2_entry import app # v5-stable inline bongda proxy
 
1
+ from app_v2_entry import app # v5-stable inline bongda proxy
ai_ext.py CHANGED
@@ -13,16 +13,7 @@ from bs4 import BeautifulSoup
13
  from fastapi import Request, Query
14
  from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
15
 
16
- # Try to import main app, but don't fail if it doesn't exist
17
- try:
18
- from main import app
19
- except ImportError:
20
- # Create a minimal FastAPI app for standalone testing
21
- try:
22
- from fastapi import FastAPI
23
- app = FastAPI()
24
- except Exception:
25
- app = None
26
 
27
  # Import wall store from main.py so we read/write the SAME file
28
  try:
@@ -50,10 +41,6 @@ except ImportError:
50
  def _web_context(topic):
51
  return ""
52
 
53
- # ai_ext alias for backward compatibility
54
- _load_ai_wall = _load_wall
55
- _save_ai_wall = _save_wall
56
-
57
  try:
58
  from huggingface_hub import AsyncInferenceClient
59
  except Exception:
@@ -73,150 +60,21 @@ except Exception:
73
 
74
 
75
  def _hf_token():
76
- for k in ("HF_TOKEN", "HUGGINGFACE_HUB_API_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HF_API_TOKEN"):
77
  v = os.getenv(k, "").strip()
78
  if v:
79
  return v
80
  return ""
81
 
82
-
83
- def _clean_text(s: str) -> str:
84
- """Clean text for processing."""
85
- s = html_lib.unescape(s or "")
86
- s = re.sub(r"\s+", " ", s)
87
- return s.strip()
88
-
89
-
90
- def _domain(url: str) -> str:
91
- """Extract domain from URL."""
92
- try:
93
- return urlparse(url or "").netloc.replace("www.", "")
94
- except Exception:
95
- return ""
96
-
97
-
98
- async def qwen_generate(prompt: str, image_url: str = None, max_tokens: int = 1200) -> str:
99
- """Generate text using Qwen models via Hugging Face Inference API.
100
-
101
- This function provides a resilient implementation that:
102
- 1. First tries the SDK-based inference client if available
103
- 2. Falls back to REST API calls to HF router endpoint
104
- 3. Returns a fallback summary if all else fails
105
- """
106
- token = _hf_token()
107
- errors = []
108
-
109
- # Try HF router API with multiple models
110
- if token:
111
- models = [
112
- os.getenv("QWEN_VL_MODEL", ""),
113
- "Qwen/Qwen2.5-VL-7B-Instruct",
114
- "Qwen/Qwen2.5-VL-3B-Instruct",
115
- "Qwen/Qwen2.5-7B-Instruct",
116
- "Qwen/Qwen2.5-3B-Instruct",
117
- "Qwen/Qwen2.5-1.5B-Instruct",
118
- "Qwen/Qwen2.5-72B-Instruct",
119
- "meta-llama/Llama-3.3-70B-Instruct",
120
- ]
121
- # Deduplicate while preserving order
122
- seen = set()
123
- models = [m for m in models if m and m not in seen and not seen.add(m)]
124
-
125
- headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
126
-
127
- for model in models:
128
- try:
129
- is_vl = "VL" in model and image_url
130
- if is_vl:
131
- user_content = [
132
- {"type": "image_url", "image_url": {"url": image_url}},
133
- {"type": "text", "text": prompt}
134
- ]
135
- else:
136
- user_content = prompt
137
-
138
- payload = {
139
- "model": model,
140
- "messages": [
141
- {"role": "system", "content": "Bạn là trợ lý AI tiếng Việt. Trả lời tự nhiên, ngắn gọn, chính xác."},
142
- {"role": "user", "content": user_content},
143
- ],
144
- "max_tokens": min(int(max_tokens or 900), 1400),
145
- "temperature": 0.35,
146
- "top_p": 0.85,
147
- }
148
-
149
- r = requests.post(
150
- "https://router.huggingface.co/v1/chat/completions",
151
- headers=headers,
152
- json=payload,
153
- timeout=95
154
- )
155
-
156
- if r.status_code >= 300:
157
- errors.append(f"{model}: HTTP {r.status_code}")
158
- continue
159
-
160
- j = r.json()
161
- txt = (j.get("choices", [{}])[0].get("message", {}).get("content") or "").strip()
162
-
163
- if txt:
164
- return txt
165
-
166
- errors.append(f"{model}: empty response")
167
-
168
- except Exception as e:
169
- errors.append(f"{model}: {type(e).__name__}")
170
-
171
- # Fallback: extractive summary from prompt
172
- LAST_QWEN_ERROR = errors[-3:] if errors else "unknown error"
173
- return _fallback_summary_from_prompt(prompt, max_units=6)
174
-
175
-
176
- def _fallback_summary_from_prompt(prompt: str, max_units: int = 6) -> str:
177
- """Generate a simple fallback summary when AI is unavailable."""
178
- text = prompt or ""
179
- for marker in ["Nội dung nguồn:", "Nội dung bài:", "Nội dung gốc:", "Nội dung:", "Nguồn/bối cảnh internet:"]:
180
- if marker in text:
181
- text = text.split(marker, 1)[1]
182
- break
183
- text = re.sub(r"https?://\S+", "", text)
184
- text = re.sub(r"\s+", " ", text).strip()
185
-
186
- # Split into sentences - extract ALL valid sentences, not just first few
187
- sentences = re.split(r"(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])", text)
188
- units = []
189
- for s in sentences:
190
- s = _clean_text(s)
191
- if len(s) >= 30: # Lower threshold to capture more content
192
- units.append(s)
193
-
194
- if units:
195
- # Take up to max_units valid sentences
196
- result_units = units[:max_units]
197
- return "\n".join("• " + u for u in result_units)
198
- if text:
199
- # Fallback: take chunks if no sentence boundaries found
200
- chunks = []
201
- for i in range(0, min(len(text), max_units * 300), 280):
202
- chunk = _clean_text(text[i:i+300])
203
- if chunk and chunk not in chunks:
204
- chunks.append(chunk)
205
- if len(chunks) >= max_units:
206
- break
207
- if chunks:
208
- return "\n".join("• " + c for c in chunks)
209
- return "• Không có đủ nội dung để tóm tắt."
210
-
211
-
212
  HF_TOKEN = _hf_token()
213
  QWEN_VL_MODEL = os.getenv("QWEN_VL_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct")
 
214
  QWEN_TEXT_MODELS = [m.strip() for m in os.getenv(
215
  "QWEN_TEXT_MODELS",
216
  "Qwen/Qwen2.5-72B-Instruct,meta-llama/Llama-3.3-70B-Instruct,Qwen/Qwen2.5-7B-Instruct"
217
  ).split(",") if m.strip()]
218
- _WORKING_MODEL_TEXT = None
219
- _WORKING_MODEL_VL = None
220
  DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data"
221
  SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts")
222
  HEADERS = {
@@ -225,350 +83,1055 @@ HEADERS = {
225
  }
226
  LAST_QWEN_ERROR = ""
227
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
 
229
- # ===== MULTILINGUAL VOICES FOR TTS =====
230
- # Maps voice IDs to edge-tts voice names (only MultilingualNeural voices)
231
- MULTILINGUAL_VOICES = {
232
- # Vietnamese - Native voices
233
- "vi-vn-hoaimyneural": "vi-VN-HoaiMyNeural",
234
- "vi-vn-namminhneural": "vi-VN-NamMinhNeural",
235
- "hoaimy": "vi-VN-HoaiMyNeural",
236
- "namminh": "vi-VN-NamMinhNeural",
237
- "vi_female": "vi-VN-HoaiMyNeural",
238
- "vi_male": "vi-VN-NamMinhNeural",
239
- "nu": "vi-VN-HoaiMyNeural",
240
- "male": "vi-VN-NamMinhNeural",
241
- "female": "vi-VN-HoaiMyNeural",
242
- "mien-nam": "vi-VN-HoaiMyNeural",
243
- # English - Multilingual
244
- "en-us-andrewmultilingualneural": "en-US-AndrewMultilingualNeural",
245
- "en-au-williammultilingualneural": "en-AU-WilliamMultilingualNeural",
246
- "en_andrew": "en-US-AndrewMultilingualNeural",
247
- "andrew": "en-US-AndrewMultilingualNeural",
248
- "en_jenny": "en-US-AndrewMultilingualNeural",
249
- "jenny": "en-US-AndrewMultilingualNeural",
250
- # Portuguese - Thalita Multilingual ONLY
251
- "pt-br-thalitamultilingualneural": "pt-BR-ThalitaMultilingualNeural",
252
- "pt_thalita": "pt-BR-ThalitaMultilingualNeural",
253
- "thalita": "pt-BR-ThalitaMultilingualNeural",
254
- "pt_francisco": "pt-BR-ThalitaMultilingualNeural",
255
- "pt": "pt-BR-ThalitaMultilingualNeural",
256
- # French - Multilingual
257
- "fr-fr-viviennemultilingualneural": "fr-FR-VivienneMultilingualNeural",
258
- "fr-fr-remymultilingualneural": "fr-FR-RemyMultilingualNeural",
259
- "fr_denise": "fr-FR-VivienneMultilingualNeural",
260
- "denise": "fr-FR-VivienneMultilingualNeural",
261
- "fr": "fr-FR-VivienneMultilingualNeural",
262
- # German - Multilingual
263
- "de-de-seraphinamultilingualneural": "de-DE-SeraphinaMultilingualNeural",
264
- "de-de-florianmultilingualneural": "de-DE-FlorianMultilingualNeural",
265
- "de_katja": "de-DE-SeraphinaMultilingualNeural",
266
- "katja": "de-DE-SeraphinaMultilingualNeural",
267
- "de": "de-DE-SeraphinaMultilingualNeural",
268
- # Korean - Hyunsu Multilingual (NOT SunHee)
269
- "ko-kr-hyunsumultilingualneural": "ko-KR-HyunsuMultilingualNeural",
270
- "ko_sunhee": "ko-KR-HyunsuMultilingualNeural",
271
- "sunhee": "ko-KR-HyunsuMultilingualNeural",
272
- "ko": "ko-KR-HyunsuMultilingualNeural",
273
- # Italian - Multilingual
274
- "it-it-giuseppemultilingualneural": "it-IT-GiuseppeMultilingualNeural",
275
- # Spanish (fallback to English multilingual)
276
- "es_ela": "en-US-AndrewMultilingualNeural",
277
- "ela": "en-US-AndrewMultilingualNeural",
278
- "es_carlos": "en-US-AndrewMultilingualNeural",
279
- "es": "en-US-AndrewMultilingualNeural",
280
- # Japanese (fallback to English multilingual)
281
- "ja_nanami": "en-US-AndrewMultilingualNeural",
282
- "nanami": "en-US-AndrewMultilingualNeural",
283
- "ja": "en-US-AndrewMultilingualNeural",
284
- # Chinese (fallback to English multilingual)
285
- "zh_xiaochen": "en-US-AndrewMultilingualNeural",
286
- "xiaochen": "en-US-AndrewMultilingualNeural",
287
- "zh": "en-US-AndrewMultilingualNeural",
288
  }
289
 
290
 
291
- def _detect_voice_emotion(title, text):
292
- """Detect appropriate voice and emotion based on content for multilingual TTS."""
293
- content = ((title or "") + " " + (text or "")).lower()
294
-
295
- # World Cup / Football content - use Andrew multilingual
296
- if any(kw in content for kw in ["world cup", "wc 2026", "fifa", "bóng đá", "trận đấu", "bóng bóng", "đội tuyển", "cầu thủ"]):
297
- return ("andrew", "excited")
298
-
299
- # News categories - choose appropriate voice
300
- if any(kw in content for kw in ["kinh tế", "tài chính", "thị trường", "economics", "finance"]):
301
- return ("jenny", "calm")
302
- if any(kw in content for kw in ["thiên tai", "bão", "lũ lụt", "cháy nổ", "tai nạn", "disaster", "accident"]):
303
- return ("thalita", "serious")
304
- if any(kw in content for kw in ["giải trí", "showbiz", "entertainment", "hài hước"]):
305
- return ("ela", "happy")
306
- if any(kw in content for kw in ["công nghệ", "tech", "technology", "ai", "trí tuệ nhân tạo"]):
307
- return ("katja", "excited")
308
-
309
- # Default Vietnamese
310
- return ("hoaimy", "trung_tinh")
311
 
312
 
313
- def _safe_name(s: str) -> str:
314
- """Create safe filename from string."""
315
- s = re.sub(r"[^\w\-.]", "_", s)
316
- return s[:100] if len(s) > 100 else s
 
 
 
 
 
 
 
 
 
317
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
 
319
- def _download_image(url: str, fallback_title: str, out_path: str) -> bool:
320
- """Download image from URL to path."""
321
- if not url:
322
- return False
323
- try:
324
- r = requests.get(url, headers=HEADERS, timeout=15)
325
- if r.status_code == 200:
326
- os.makedirs(os.path.dirname(out_path), exist_ok=True)
327
- with open(out_path, "wb") as f:
328
- f.write(r.content)
329
- return True
330
- except Exception:
331
- pass
332
- return False
333
 
334
 
335
- def pollination_image_url(topic: str) -> str:
336
- """Generate image URL from Pollinations.ai."""
337
- return f"https://image.pollinations.ai/prompt/{quote(topic)}?width=1024&height=768&nologo=true&model=flux"
338
 
339
 
340
- # Use the same wall file as app_v2_entry.py for consistency
341
- WALL_FILE = os.path.join(DATA_DIR, "wall_posts.json")
 
 
342
 
343
- def _load_ai_wall():
344
- """Load AI wall posts from JSON file (uses wall_posts.json for consistency with app_v2_entry)."""
345
  try:
346
- if os.path.exists(WALL_FILE):
347
- with open(WALL_FILE, "r", encoding="utf-8") as f:
348
- return json.load(f)
349
  except Exception:
350
- pass
351
- return []
352
 
 
 
353
 
354
- def _save_ai_wall(posts):
355
- """Save AI wall posts to JSON file (uses wall_posts.json for consistency with app_v2_entry)."""
356
- try:
357
- os.makedirs(os.path.dirname(WALL_FILE), exist_ok=True)
358
- tmp = WALL_FILE + ".tmp"
359
- with open(tmp, "w", encoding="utf-8") as f:
360
- json.dump(posts[:100], f, ensure_ascii=False)
361
- os.replace(tmp, WALL_FILE)
362
- except Exception:
363
- pass
364
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
 
366
- # Helper functions for wall operations
367
- def _load_wall_posts():
368
- """Alias for _load_ai_wall for consistency with app_v2_entry.py."""
369
- return _load_ai_wall()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
370
 
 
 
 
 
 
 
 
 
 
 
 
 
 
371
 
372
- def _save_wall_posts(posts):
373
- """Alias for _save_ai_wall for consistency with app_v2_entry.py."""
374
- return _save_ai_wall(posts)
375
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
 
377
- def make_post(title: str, text: str, img: str, url: str, kind: str, sources=None):
378
- """Create a post dict with standard fields."""
379
  return {
380
- "id": str(int(time.time() * 1000)),
381
- "title": title,
382
- "text": text,
383
- "img": img,
384
- "url": url,
385
- "kind": kind,
386
- "sources": sources or [],
387
- "ts": int(time.time())
388
  }
389
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
 
391
- def _short_script(post) -> str:
392
- """Extract clean text for TTS from post."""
393
- text = post.get("text", "") or post.get("title", "")
394
- text = re.sub(r"^[•\-\*]\s*", "", text, flags=re.M)
395
- text = re.sub(r"\s*\n\s*", ". ", text)
396
- return _clean_text(text)[:2000] # Increased from 1000 to 2000 for full content
397
 
 
 
 
 
398
 
399
- # ===== SCRAPER FUNCTIONS (required by ai_patch.py) =====
400
- def scrape_any_url(url: str) -> dict:
401
- """Scrape any URL and extract article content.
402
-
403
- Returns dict with: title, summary, text, image, og_image, via (domain)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
404
  """
405
- try:
406
- r = requests.get(url, headers=HEADERS, timeout=15, allow_redirects=True)
407
- r.encoding = 'utf-8'
408
- soup = BeautifulSoup(r.text, 'lxml')
409
-
410
- # Remove scripts, styles, nav, footer
411
- for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
412
- tag.decompose()
413
-
414
- # Extract title
415
- h1 = soup.find('h1')
416
- ogt = soup.find('meta', property='og:title')
417
- title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else url)
418
-
419
- # Extract OG image
420
- ogi = soup.find('meta', property='og:image')
421
- og_image = ogi.get('content', '') if ogi else ''
422
-
423
- # Extract article body
424
- block = None
425
- for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
426
- el = soup.select_one(sel)
427
- if el and len(el.find_all('p')) >= 2:
428
- block = el
429
- break
430
- if not block:
431
- block = soup.body or soup
432
-
433
- # Extract text from paragraphs
434
- paragraphs = []
435
- for el in block.find_all(['p', 'h2', 'h3'], recursive=True):
436
- t = _clean_text(el.get_text(strip=True))
437
- if t and len(t) > 40:
438
- paragraphs.append(t)
439
-
440
- # Extract images
441
- images = []
442
- for el in block.find_all(['figure', 'img'], recursive=True):
443
- im = el if el.name == 'img' else el.find('img')
444
- if im:
445
- src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
446
- if src and 'base64' not in src:
447
- if src.startswith('//'):
448
- src = 'https:' + src
449
- images.append(src)
450
-
451
- # Prefer OG image as main image
452
- image = og_image or (images[0] if images else '')
453
-
454
- return {
455
- 'title': title,
456
- 'summary': paragraphs[0] if paragraphs else '',
457
- 'text': '\n'.join(paragraphs),
458
- 'image': image,
459
- 'og_image': og_image,
460
- 'via': _domain(url),
461
- 'images': images
462
- }
463
- except Exception as e:
464
- return {'title': url, 'summary': '', 'text': '', 'image': '', 'og_image': '', 'via': _domain(url), 'error': str(e)}
465
 
 
 
 
 
 
466
 
467
- def web_context(topic: str, limit: int = 5) -> tuple:
468
- """Get web context for a topic. Returns (context_text, sources_list)."""
469
- sources = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
470
  try:
471
- # Try Google News RSS
472
- rss_url = f"https://news.google.com/rss/search?q={quote_plus(topic)}&hl=vi&gl=VN&ceid=VN:vi"
473
- r = requests.get(rss_url, headers=HEADERS, timeout=15)
474
- r.encoding = 'utf-8'
475
- soup = BeautifulSoup(r.text, 'xml')
476
- for it in soup.find_all('item')[:limit]:
477
- title = it.find('title').get_text(' ', strip=True) if it.find('title') else ''
478
- link = it.find('link').get_text(strip=True) if it.find('link') else ''
479
- if title and link:
480
- sources.append({'title': title, 'url': link, 'via': _domain(link)})
481
  except Exception:
482
  pass
483
-
484
- context = f'Trên mạng có nhiều bài viết về "{topic}". Một số nguồn: ' + ', '.join([s.get('title', '') for s in sources[:3]])
485
- return context, sources
 
486
 
487
 
488
- # ===== SHORT FRAME FUNCTION (required by ai_patch.py) =====
489
- def _make_short_frame(post, img_path, out_path):
490
- """Create a short video frame from post and image.
491
-
492
- Called by ai_patch.py _make_short_frame_full when Image is available.
493
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
494
  if Image is None:
495
- # Create a minimal frame without PIL - just return success
496
- # The caller should handle this case
497
- return False
498
-
499
  W, H = 1080, 1920
500
- bg = Image.new("RGB", (W, H), (14, 14, 14))
501
-
502
  try:
503
  im = Image.open(img_path).convert("RGB")
504
- target = (1080, 760)
505
- im_ratio = im.width / max(1, im.height)
506
- target_ratio = target[0] / target[1]
507
-
508
- if im_ratio > target_ratio:
509
- new_h = target[1]
510
- new_w = int(new_h * im_ratio)
511
  else:
512
- new_w = target[0]
513
- new_h = int(new_w / im_ratio)
514
-
515
- im = im.resize((new_w, new_h))
516
- left = (new_w - target[0]) // 2
517
- top = (new_h - target[1]) // 2
518
- im = im.crop((left, top, left + target[0], top + target[1]))
519
  bg.paste(im, (0, 0))
520
  except Exception:
521
  pass
522
-
523
  draw = ImageDraw.Draw(bg)
524
-
525
  try:
526
- font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 54)
527
- font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 38)
 
528
  except Exception:
529
- font_title = font_body = None
530
-
531
- draw.rectangle((0, 720, W, H), fill=(14, 14, 14))
532
- margin = 48
533
- maxw = W - margin * 2
534
-
535
- y = 830
536
- for ln in _wrap_text(draw, post.get("title", ""), font_title, maxw, 4):
537
- draw.text((margin, y), ln, fill=(255, 255, 255), font=font_title)
538
- y += 66
539
-
540
- y += 18
541
- text = post.get("text", "")
542
- text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip()
543
- body_lines = _wrap_text(draw, text, font_body, maxw, 14)
544
- for ln in body_lines:
545
- draw.text((margin, y), ln, fill=(220, 220, 220), font=font_body)
546
- y += 50
547
- if y > 1640:
548
- break
549
-
550
  bg.save(out_path, quality=92)
551
- return True
552
 
553
 
554
- def _wrap_text(draw, text, font, max_width, max_lines):
555
- """Helper for wrapping text in frames."""
556
- words = _clean_text(text).split()
557
- lines, cur = [], ""
558
- for w in words:
559
- test = (cur + " " + w).strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
560
  try:
561
- width = draw.textbbox((0, 0), test, font=font)[2]
562
- except Exception:
563
- width = len(test) * 20
564
- if width <= max_width:
565
- cur = test
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
566
  else:
567
- if cur:
568
- lines.append(cur)
569
- cur = w
570
- if len(lines) >= max_lines:
571
- break
572
- if cur and len(lines) < max_lines:
573
- lines.append(cur)
574
- return lines
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  from fastapi import Request, Query
14
  from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
15
 
16
+ from main import app
 
 
 
 
 
 
 
 
 
17
 
18
  # Import wall store from main.py so we read/write the SAME file
19
  try:
 
41
  def _web_context(topic):
42
  return ""
43
 
 
 
 
 
44
  try:
45
  from huggingface_hub import AsyncInferenceClient
46
  except Exception:
 
60
 
61
 
62
  def _hf_token():
63
+ for k in ("HF_TOKEN", "HUGGINGFACEHUB_API_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HF_API_TOKEN"):
64
  v = os.getenv(k, "").strip()
65
  if v:
66
  return v
67
  return ""
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  HF_TOKEN = _hf_token()
70
  QWEN_VL_MODEL = os.getenv("QWEN_VL_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct")
71
+ # Fast TEXT models for summaries that don't need vision (much faster than the VL model).
72
  QWEN_TEXT_MODELS = [m.strip() for m in os.getenv(
73
  "QWEN_TEXT_MODELS",
74
  "Qwen/Qwen2.5-72B-Instruct,meta-llama/Llama-3.3-70B-Instruct,Qwen/Qwen2.5-7B-Instruct"
75
  ).split(",") if m.strip()]
76
+ _WORKING_MODEL_TEXT = None # cached last-working text model
77
+ _WORKING_MODEL_VL = None # cached last-working vision model
78
  DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data"
79
  SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts")
80
  HEADERS = {
 
83
  }
84
  LAST_QWEN_ERROR = ""
85
 
86
+ # ===== TTS VOICE CONFIG =====
87
+ # Multilingual neural voices grouped by country/language
88
+ # Format: key -> {id, gender, name, country, lang, flag}
89
+ TTS_VOICES = {
90
+ # === VIETNAM (edge-tts) ===
91
+ "hoaimy": {"id": "vi-VN-HoaiMyNeural", "gender": "female", "name": "Hoài My", "country": "Việt Nam", "lang": "vi", "flag": "🇻🇳", "engine": "edge"},
92
+ "namminh": {"id": "vi-VN-NamMinhNeural", "gender": "male", "name": "Nam Minh", "country": "Việt Nam", "lang": "vi", "flag": "🇻🇳", "engine": "edge"},
93
+ # === gTTS (Google, tiếng Việt cơ bản) ===
94
+ "gtts_vi": {"id": "gtts", "gender": "female", "name": "gTTS Google", "country": "Việt Nam", "lang": "vi", "flag": "🇻🇳", "engine": "gtts"},
95
+ # === MULTILINGUAL (đa ngôn ngữ — đọc được tiếng Việt + nhiều thứ tiếng) ===
96
+ "en_au_william": {"id": "en-AU-WilliamMultilingualNeural", "gender": "male", "name": "William (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
97
+ "en_us_andrew": {"id": "en-US-AndrewMultilingualNeural", "gender": "male", "name": "Andrew (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
98
+ "en_us_ava": {"id": "en-US-AvaMultilingualNeural", "gender": "female", "name": "Ava (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
99
+ "en_us_brian": {"id": "en-US-BrianMultilingualNeural", "gender": "male", "name": "Brian (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
100
+ "en_us_emma": {"id": "en-US-EmmaMultilingualNeural", "gender": "female", "name": "Emma (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
101
+ "fr_vivienne": {"id": "fr-FR-VivienneMultilingualNeural","gender": "female", "name": "Vivienne (Đa NN)","country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
102
+ "fr_remy": {"id": "fr-FR-RemyMultilingualNeural", "gender": "male", "name": "Rémy (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
103
+ "de_seraphina": {"id": "de-DE-SeraphinaMultilingualNeural","gender": "female","name": "Seraphina (Đa NN)","country": "Đa ngôn ngữ","lang": "multi", "flag": "🌐", "engine": "edge"},
104
+ "de_florian": {"id": "de-DE-FlorianMultilingualNeural", "gender": "male", "name": "Florian (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
105
+ "it_giuseppe": {"id": "it-IT-GiuseppeMultilingualNeural","gender": "male", "name": "Giuseppe (Đa NN)","country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
106
+ "ko_hyunsu": {"id": "ko-KR-HyunsuMultilingualNeural", "gender": "male", "name": "Hyunsu (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
107
+ "pt_thalita": {"id": "pt-BR-ThalitaMultilingualNeural", "gender": "female", "name": "Thalita (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
108
+ }
109
+ TTS_DEFAULT_VOICE = "hoaimy"
110
+ TTS_DEFAULT_SPEED = 1.2 # 1.2x speed for faster reading
111
 
112
+ # Topic -> voice mapping (auto-detect based on topic keywords)
113
+ TOPIC_VOICE_MAP = {
114
+ # Sports -> male voice
115
+ "bóng đá": "namminh", "thể thao": "namminh", "world cup": "namminh",
116
+ "premier league": "namminh", "champions league": "namminh", "la liga": "namminh",
117
+ "serie a": "namminh", "bundesliga": "namminh", "v-league": "namminh",
118
+ "tennis": "namminh", "olympic": "namminh", "f1": "namminh", "moto": "namminh",
119
+ # Lifestyle/Health/Entertainment -> female voice
120
+ "sức khỏe": "hoaimy", "làm đẹp": "hoaimy", "giải trí": "hoaimy",
121
+ "âm nhạc": "hoaimy", "phim": "hoaimy", "thời trang": "hoaimy",
122
+ "ẩm thực": "hoaimy", "du lịch": "hoaimy", "gia đình": "hoaimy",
123
+ "tình yêu": "hoaimy", "hôn nhân": "hoaimy", "mẹ và bé": "hoaimy",
124
+ # Tech/Science -> male voice
125
+ "công nghệ": "namminh", "ai": "namminh", "robot": "namminh",
126
+ "khoa học": "namminh", "vũ trụ": "namminh", "điện thoại": "namminh",
127
+ "laptop": "namminh", "game": "namminh",
128
+ # News/Politics/Economy -> male voice
129
+ "chính trị": "namminh", "kinh tế": "namminh", "tài chính": "namminh",
130
+ "chứng khoán": "namminh", "ngân hàng": "namminh", "thị trường": "namminh",
131
+ "xã hội": "namminh", "pháp luật": "namminh", "giáo dục": "namminh",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  }
133
 
134
 
135
+ def _detect_voice_for_topic(title: str, text: str) -> str:
136
+ """Auto-detect the best voice based on topic keywords."""
137
+ combined = (title + " " + text[:500]).lower()
138
+ for keyword, voice_id in TOPIC_VOICE_MAP.items():
139
+ if keyword in combined:
140
+ return voice_id
141
+ return TTS_DEFAULT_VOICE
 
 
 
 
 
 
 
 
 
 
 
 
 
142
 
143
 
144
+ # ===== EMOTION (CẢM XÚC) FOR TTS =====
145
+ # edge-tts does NOT support Azure express-as styles, so emotion is simulated
146
+ # with pitch + rate(speed multiplier) + volume. Each preset is a delta.
147
+ # rate_mul multiplies the user/auto speed; pitch is absolute Hz; volume is percent.
148
+ EMOTION_PRESETS = {
149
+ "vui": {"label": "Vui tươi", "emoji": "😊", "rate_mul": 1.06, "pitch": "+15Hz", "volume": "+6%"},
150
+ "hao_hung": {"label": "Hào hứng", "emoji": "🔥", "rate_mul": 1.12, "pitch": "+24Hz", "volume": "+12%"},
151
+ "nghiem": {"label": "Nghiêm túc", "emoji": "📰", "rate_mul": 1.00, "pitch": "-3Hz", "volume": "+0%"},
152
+ "tram": {"label": "Trầm ấm", "emoji": "🌙", "rate_mul": 0.94, "pitch": "-10Hz", "volume": "+0%"},
153
+ "buon": {"label": "Buồn/Xúc động", "emoji": "💧", "rate_mul": 0.88, "pitch": "-18Hz", "volume": "-4%"},
154
+ "trung_tinh":{"label": "Trung tính", "emoji": "🎙️", "rate_mul": 1.00, "pitch": "+0Hz", "volume": "+0%"},
155
+ }
156
+ EMOTION_DEFAULT = "trung_tinh"
157
 
158
+ # Topic keyword -> emotion. Checked in order; first match wins.
159
+ TOPIC_EMOTION_MAP = {
160
+ # Sports / wins -> excited
161
+ "chiến thắng": "hao_hung", "vô địch": "hao_hung", "world cup": "hao_hung",
162
+ "bóng đá": "hao_hung", "thể thao": "hao_hung", "ghi bàn": "hao_hung",
163
+ "champions league": "hao_hung", "premier league": "hao_hung", "chung kết": "hao_hung",
164
+ "olympic": "hao_hung", "kỷ lục": "hao_hung",
165
+ # Entertainment / lifestyle / good news -> cheerful
166
+ "giải trí": "vui", "âm nhạc": "vui", "phim": "vui", "lễ hội": "vui",
167
+ "du lịch": "vui", "ẩm thực": "vui", "thời trang": "vui", "ra mắt": "vui",
168
+ "khai trương": "vui", "tin vui": "vui", "hạnh phúc": "vui",
169
+ # Sad / accidents / loss -> sad
170
+ "tai nạn": "buon", "qua đời": "buon", "tử vong": "buon", "thiệt mạng": "buon",
171
+ "động đất": "buon", "lũ lụt": "buon", "thiên tai": "buon", "cháy": "buon",
172
+ "tang lễ": "buon", "mất tích": "buon", "thương tâm": "buon",
173
+ # Health / science / calm -> calm warm
174
+ "sức khỏe": "tram", "y tế": "tram", "bệnh": "tram", "dinh dưỡng": "tram",
175
+ "tâm lý": "tram", "thiền": "tram", "giấc ngủ": "tram",
176
+ # News / politics / economy / law -> serious
177
+ "chính trị": "nghiem", "kinh tế": "nghiem", "tài chính": "nghiem",
178
+ "chứng khoán": "nghiem", "pháp luật": "nghiem", "tòa án": "nghiem",
179
+ "ngân hàng": "nghiem", "thị trường": "nghiem", "lạm phát": "nghiem",
180
+ "công nghệ": "nghiem", "ai": "nghiem", "khoa học": "nghiem", "giáo dục": "nghiem",
181
+ }
182
 
183
+
184
+ def _detect_emotion_for_topic(title: str, text: str) -> str:
185
+ """Auto-detect emotion (cảm xúc) from topic/content keywords."""
186
+ combined = (title + " " + text[:600]).lower()
187
+ for keyword, emo in TOPIC_EMOTION_MAP.items():
188
+ if keyword in combined:
189
+ return emo
190
+ return EMOTION_DEFAULT
 
 
 
 
 
 
191
 
192
 
193
+ def _detect_voice_emotion(title: str, text: str) -> tuple:
194
+ """Return (voice_id, emotion_id) auto-chosen for the article's topic."""
195
+ return _detect_voice_for_topic(title, text), _detect_emotion_for_topic(title, text)
196
 
197
 
198
+ # ===== TEXT HELPERS =====
199
+ def _clean_text(s: str) -> str:
200
+ s = html_lib.unescape(s or "")
201
+ return re.sub(r"\s+", " ", s).strip()
202
 
203
+ def _domain(u):
 
204
  try:
205
+ return urlparse(u).netloc.replace("www.", "")
 
 
206
  except Exception:
207
+ return ""
 
208
 
209
+ def _safe_name(s):
210
+ return re.sub(r"[^a-zA-Z0-9_-]+", "_", str(s))[:80]
211
 
 
 
 
 
 
 
 
 
 
 
212
 
213
+ # ===== CLEAN AI OUTPUT =====
214
+ def _clean_ai_output(text: str) -> str:
215
+ """Remove markdown artifacts, instruction leakage, and aggressively dedup content."""
216
+ if not text:
217
+ return ""
218
+ # Remove markdown headings, bold, italic, horizontal rules
219
+ text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
220
+ text = re.sub(r'\*\*([^*]+)\*\*', r'\1', text)
221
+ text = re.sub(r'\*([^*]+)\*', r'\1', text)
222
+ text = re.sub(r'^---+\s*$', '', text, flags=re.MULTILINE)
223
+ text = re.sub(r'^[-*_]{3,}\s*$', '', text, flags=re.MULTILINE)
224
+ # Remove common AI instruction leakage phrases (entire line)
225
+ leakage = [
226
+ r'Dưới đây là', r'Theo yêu cầu', r'Tôi sẽ viết', r'Tôi sẽ tóm tắt',
227
+ r'Đây là bài', r'Đây là nội dung', r'Bài viết sau đây',
228
+ r'Nội dung (tóm tắt|chính)', r'Nhiệm vụ', r'Vai trò', r'Tôi là',
229
+ r'Dựa trên.*tôi sẽ', r'Hãy', r'Bạn cần', r'Đọc bài viết',
230
+ r'Tôi xin', r'Xin chào', r'Trân trọng', r'Kính thưa',
231
+ r'Dựa trên.*dưới đây', r'Sau đây là', r'Dưới đây là bài',
232
+ ]
233
+ for phrase in leakage:
234
+ text = re.sub(r'^' + phrase + r'[^\n]*\n?', '', text, flags=re.MULTILINE | re.IGNORECASE)
235
+ text = re.sub(r'\n{3,}', '\n\n', text)
236
+ # --- Aggressive dedup: split into sentences, remove any that repeats ---
237
+ # Normalize: collapse whitespace, strip
238
+ def _norm(s):
239
+ return re.sub(r'\s+', ' ', s.strip().lower())
240
 
241
+ # Split by sentence-ending punctuation (keep delimiters)
242
+ raw_parts = re.split(r'(?<=[.!?])\s+', text.strip())
243
+ seen_sentences = set()
244
+ unique_parts = []
245
+ for part in raw_parts:
246
+ n = _norm(part)
247
+ # Skip near-duplicate: if >70% of an existing seen sentence matches
248
+ is_dup = False
249
+ if n:
250
+ if n in seen_sentences:
251
+ is_dup = True
252
+ else:
253
+ partial = re.sub(r'\W+', '', n)
254
+ for seen in seen_sentences:
255
+ seen_clean = re.sub(r'\W+', '', seen)
256
+ # Check substring match for very similar sentences
257
+ if partial and seen_clean and (
258
+ partial in seen_clean or seen_clean in partial
259
+ ):
260
+ shorter = min(len(partial), len(seen_clean))
261
+ longer = max(len(partial), len(seen_clean))
262
+ if shorter > 20 and shorter / longer > 0.75:
263
+ is_dup = True
264
+ break
265
+ if is_dup:
266
+ continue
267
+ if n:
268
+ seen_sentences.add(n)
269
+ unique_parts.append(part)
270
 
271
+ result = ' '.join(unique_parts).strip()
272
+ # Final pass: remove any remaining consecutive duplicate lines
273
+ lines = result.split('\n')
274
+ final_lines = []
275
+ prev_line = ""
276
+ for line in lines:
277
+ stripped = line.strip()
278
+ if stripped and stripped == prev_line:
279
+ continue
280
+ final_lines.append(line)
281
+ prev_line = stripped
282
+ result = '\n'.join(final_lines).strip()
283
+ return result
284
 
 
 
 
285
 
286
+ # ===== EXTRACT ALL IMAGES FROM ARTICLE =====
287
+ def _extract_all_images(soup, base_url: str) -> List[Dict]:
288
+ """Extract ALL content images from an article page using multi-strategy approach."""
289
+ images = []
290
+ seen_urls = set()
291
+ skip_patterns = [
292
+ "avatar", "icon", "logo", "button", "banner-ad", "tracking",
293
+ "beacon", "pixel", "1x1", "spacer", "emoji", "sprite", "placeholder",
294
+ "advertisement", "ads", "widget", "sidebar", "footer-logo",
295
+ ]
296
+
297
+ def _add_image(src: str, alt: str = "", source_tag: str = "img"):
298
+ if not src or src.startswith("data:"):
299
+ return
300
+ abs_url = urljoin(base_url, src.strip())
301
+ if abs_url in seen_urls:
302
+ return
303
+ # Skip non-content images by URL pattern
304
+ if any(p in abs_url.lower() for p in skip_patterns):
305
+ return
306
+ # Skip very small images (likely icons)
307
+ try:
308
+ parsed = urlparse(abs_url)
309
+ path = parsed.path.lower()
310
+ if any(path.endswith(ext) for ext in ['.svg', '.ico', '.gif']):
311
+ return
312
+ except Exception:
313
+ pass
314
+ seen_urls.add(abs_url)
315
+ images.append({"url": abs_url, "alt": alt, "source": source_tag})
316
+
317
+ # Strategy 1: Standard <img> tags with all lazy-load attributes
318
+ for img in soup.find_all("img"):
319
+ src = (img.get("src") or img.get("data-src") or img.get("data-lazy-src") or
320
+ img.get("data-original") or img.get("data-srcset", "").split(",")[0].strip().split(" ")[0])
321
+ _add_image(src, alt=img.get("alt", ""), source_tag="img")
322
+
323
+ # Strategy 2: srcset on <img>
324
+ for img in soup.find_all("img", srcset=True):
325
+ for part in img["srcset"].split(","):
326
+ part = part.strip()
327
+ if part:
328
+ _add_image(part.split(" ")[0], alt=img.get("alt", ""), source_tag="srcset")
329
+
330
+ # Strategy 3: <picture> with <source>
331
+ for picture in soup.find_all("picture"):
332
+ for source in picture.find_all("source"):
333
+ srcset = source.get("srcset", "")
334
+ for part in srcset.split(","):
335
+ part = part.strip()
336
+ if part:
337
+ _add_image(part.split(" ")[0], source_tag="picture/srcset")
338
+ fallback_img = picture.find("img")
339
+ if fallback_img:
340
+ _add_image(
341
+ fallback_img.get("src") or fallback_img.get("data-src"),
342
+ alt=fallback_img.get("alt", ""),
343
+ source_tag="picture/img"
344
+ )
345
+
346
+ # Strategy 4: WordPress CMS patterns
347
+ for img in soup.find_all("img", class_=re.compile(r"wp-image|size-large|size-full|aligncenter")):
348
+ _add_image(img.get("data-src") or img.get("src"),
349
+ alt=img.get("alt", ""), source_tag="wp-image")
350
+
351
+ # Strategy 5: Background images in style attributes
352
+ for tag in soup.find_all(style=re.compile(r"background-image")):
353
+ for m in re.findall(r'url\(["\']?(.*?)["\']?\)', tag.get("style", "")):
354
+ _add_image(m, source_tag="background-style")
355
+
356
+ # Strategy 6: og:image (featured/hero image)
357
+ og_image = soup.find("meta", property="og:image")
358
+ if og_image and og_image.get("content"):
359
+ _add_image(og_image["content"], source_tag="og:image")
360
+
361
+ # Strategy 7: twitter:image
362
+ tw_image = soup.find("meta", attrs={"name": "twitter:image"})
363
+ if tw_image and tw_image.get("content"):
364
+ _add_image(tw_image["content"], source_tag="twitter:image")
365
+
366
+ # Strategy 8: <figure> with <figcaption>
367
+ for figure in soup.find_all("figure"):
368
+ img = figure.find("img")
369
+ if img:
370
+ src = img.get("data-src") or img.get("src")
371
+ figcaption = figure.find("figcaption")
372
+ alt = figcaption.get_text(strip=True) if figcaption else img.get("alt", "")
373
+ _add_image(src, alt=alt, source_tag="figure")
374
+
375
+ # Strategy 9: <a> tags linking to images
376
+ for a in soup.find_all("a", href=True):
377
+ href = a["href"]
378
+ if any(href.lower().endswith(ext) for ext in [".jpg", ".jpeg", ".png", ".webp", ".gif"]):
379
+ _add_image(href, alt=a.get_text(strip=True)[:80], source_tag="link")
380
+
381
+ return images
382
+
383
+
384
+ # ===== JINA READER =====
385
+ def _reader_url(target_url: str) -> str:
386
+ safe = quote(target_url, safe=":/?#[]@!$&'()*+,;=%")
387
+ return "https://r.jina.ai/http://" + safe
388
+
389
+ def jina_reader_markdown(url: str) -> str:
390
+ jr = _reader_url(url)
391
+ r = requests.get(jr, headers={"Accept": "text/markdown,text/plain,*/*", "X-Return-Format": "markdown", "User-Agent": "Mozilla/5.0"}, timeout=35)
392
+ r.raise_for_status()
393
+ return r.text or ""
394
+
395
+ def _parse_jina_markdown(md: str, url: str):
396
+ lines = [x.rstrip() for x in (md or "").splitlines()]
397
+ title = ""; first_image = ""; all_images = []; content_lines = []; in_content = False
398
+ for ln in lines:
399
+ if ln.startswith("Title:") and not title:
400
+ title = _clean_text(ln.replace("Title:", "", 1)); continue
401
+ if ln.startswith("URL Source:"):
402
+ continue
403
+ if ln.startswith("Markdown Content:"):
404
+ in_content = True; continue
405
+ # Extract ALL images from markdown ![alt](url)
406
+ for mimg in re.finditer(r'!\[[^\]]*\]\((https?://[^)]+)\)', ln):
407
+ img_url = mimg.group(1)
408
+ if img_url not in all_images:
409
+ all_images.append(img_url)
410
+ if not first_image:
411
+ first_image = img_url
412
+ if in_content or (title and not ln.startswith("Title:")):
413
+ if ln.strip():
414
+ content_lines.append(ln)
415
+ text = "\n".join(content_lines)
416
+ text = re.sub(r'!\[[^\]]*\]\([^)]+\)', '', text)
417
+ paras = []
418
+ for part in re.split(r'\n{2,}|\n(?=#{1,3}\s)', text):
419
+ t = _clean_text(re.sub(r'^#{1,6}\s*', '', part))
420
+ if len(t) >= 40:
421
+ paras.append(t)
422
+ if len(paras) >= 35:
423
+ break
424
+ if not title and paras:
425
+ title = paras[0][:90]
426
+ return {"url": url, "title": title or url, "summary": paras[0] if paras else "",
427
+ "text": "\n".join(paras), "image": first_image,
428
+ "images": all_images, "via": "jina"}
429
+
430
+
431
+ # ===== WEB SCRAPE (with full image extraction) =====
432
+ def _best_content_block(soup):
433
+ best, best_score = None, 0
434
+ for el in soup.find_all(["article", "main", "section", "div"]):
435
+ ps = el.find_all("p")
436
+ txt = " ".join(p.get_text(" ", strip=True) for p in ps)
437
+ score = len(ps) * 100 + len(txt)
438
+ cls = " ".join(el.get("class", []))
439
+ if any(k in cls.lower() for k in ["content", "article", "detail", "body", "post", "entry"]):
440
+ score += 800
441
+ if score > best_score:
442
+ best, best_score = el, score
443
+ return best
444
+
445
+ def scrape_any_url_direct(url: str):
446
+ r = requests.get(url, headers=HEADERS, timeout=18)
447
+ if r.status_code in {401, 403, 406, 409, 429, 451, 503}:
448
+ raise RuntimeError(f"blocked status {r.status_code}")
449
+ r.encoding = "utf-8"
450
+ soup = BeautifulSoup(r.text, "lxml")
451
+ for tag in soup.find_all(["script", "style", "nav", "footer", "aside", "form", "noscript"]):
452
+ tag.decompose()
453
+
454
+ # Title
455
+ title = soup.find("h1").get_text(" ", strip=True) if soup.find("h1") else ""
456
+ if not title:
457
+ ogt = soup.find("meta", property="og:title") or soup.find("meta", attrs={"name": "title"})
458
+ title = ogt.get("content", "") if ogt else (soup.title.get_text(strip=True) if soup.title else "")
459
+
460
+ # Summary
461
+ desc_tag = soup.find("meta", property="og:description") or soup.find("meta", attrs={"name": "description"})
462
+ summary = desc_tag.get("content", "") if desc_tag else ""
463
+
464
+ # Featured image (og:image)
465
+ img_tag = soup.find("meta", property="og:image") or soup.find("meta", attrs={"name": "twitter:image"})
466
+ image = img_tag.get("content", "") if img_tag else ""
467
+ if image and image.startswith("//"):
468
+ image = "https:" + image
469
+
470
+ # Extract ALL images from the article
471
+ all_images = _extract_all_images(soup, url)
472
+ image_urls = [img["url"] for img in all_images]
473
+
474
+ # Ensure featured image is first
475
+ if image and image not in image_urls:
476
+ image_urls.insert(0, image)
477
+ elif image in image_urls:
478
+ image_urls.remove(image)
479
+ image_urls.insert(0, image)
480
+
481
+ # Content paragraphs
482
+ block = _best_content_block(soup) or soup
483
+ paras, seen_p = [], set()
484
+ for p in block.find_all("p"):
485
+ t = _clean_text(p.get_text(" ", strip=True))
486
+ if len(t) >= 40 and t not in seen_p:
487
+ seen_p.add(t)
488
+ paras.append(t)
489
+ if len(paras) >= 35:
490
+ break
491
+
492
+ if not title and paras:
493
+ title = paras[0][:90]
494
 
 
 
495
  return {
496
+ "url": url, "title": title or url, "summary": paras[0] if paras else "",
497
+ "text": "\n".join(paras), "image": image_urls[0] if image_urls else "",
498
+ "images": image_urls, "via": _domain(url)
 
 
 
 
 
499
  }
500
 
501
+ def scrape_any_url(url: str):
502
+ """Try direct scrape first, fall back to Jina Reader."""
503
+ data = scrape_any_url_direct(url)
504
+ raw_text = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
505
+ if len(raw_text) >= 120:
506
+ return data
507
+ try:
508
+ md = jina_reader_markdown(url)
509
+ if md:
510
+ jr = _parse_jina_markdown(md, url)
511
+ if jr.get("text"):
512
+ if data.get("title") and data["title"] != url:
513
+ jr["title"] = data["title"]
514
+ if data.get("image"):
515
+ jr["image"] = data["image"]
516
+ if data.get("images"):
517
+ jr["images"] = data["images"]
518
+ jr["via"] = data.get("via", _domain(url)) + " + jina"
519
+ return jr
520
+ except Exception:
521
+ pass
522
+ return data
523
 
 
 
 
 
 
 
524
 
525
+ # ===== POLLINATIONS IMAGE =====
526
+ def pollinations_image_url(topic: str) -> str:
527
+ prompt = "editorial illustration, Vietnamese news, " + topic
528
+ return "https://image.pollinations.ai/prompt/" + quote(prompt, safe="") + "?width=1024&height=576&nologo=true"
529
 
530
+
531
+ @app.get("/api/ai/probe")
532
+ async def api_ai_probe():
533
+ """Diagnostic: test which chat models actually work on this token + their latency."""
534
+ import time as _t
535
+ tok = _hf_token()
536
+ out = []
537
+ extra = os.getenv("PROBE_MODELS", "").split(",")
538
+ cand = [m.strip() for m in extra if m.strip()] + [
539
+ "Qwen/Qwen2.5-VL-7B-Instruct", "Qwen/Qwen2.5-VL-3B-Instruct", "Qwen/Qwen2-VL-7B-Instruct",
540
+ "Qwen/Qwen2.5-VL-72B-Instruct", "Qwen/Qwen3-8B", "Qwen/Qwen3-4B", "Qwen/Qwen3-32B",
541
+ "Qwen/Qwen2.5-7B-Instruct-1M", "meta-llama/Llama-3.2-3B-Instruct"]
542
+ seen = set()
543
+ for m in cand:
544
+ if not m or m in seen:
545
+ continue
546
+ seen.add(m)
547
+ t0 = _t.time()
548
+ try:
549
+ c = AsyncInferenceClient(provider="auto", api_key=tok, timeout=40)
550
+ r = await c.chat_completion(model=m, messages=[{"role": "user", "content": "Trả lời đúng 1 từ: xin chào"}], max_tokens=10)
551
+ out.append({"model": m, "ok": True, "sec": round(_t.time() - t0, 1), "txt": (r.choices[0].message.content or "")[:30]})
552
+ except Exception as e:
553
+ out.append({"model": m, "ok": False, "sec": round(_t.time() - t0, 1), "err": (type(e).__name__ + ": " + str(e))[-300:]})
554
+ return JSONResponse({"results": out})
555
+
556
+
557
+ # ===== QWEN AI (strict, concise) =====
558
+ async def qwen_generate(prompt: str, image_url: Optional[str] = None, max_tokens: int = 500, image_urls: Optional[List[str]] = None):
559
+ global LAST_QWEN_ERROR, HF_TOKEN
560
+ HF_TOKEN = _hf_token()
561
+ if not HF_TOKEN:
562
+ LAST_QWEN_ERROR = "Không tìm thấy token"
563
+ return None
564
+ if not AsyncInferenceClient:
565
+ LAST_QWEN_ERROR = "Thiếu huggingface_hub"
566
+ return None
567
+ errors = []; models = []
568
+ has_images = bool(image_urls) or bool(image_url)
569
+ if has_images:
570
+ # Vision needed -> VL models
571
+ candidate = [QWEN_VL_MODEL, "Qwen/Qwen2.5-VL-7B-Instruct", "Qwen/Qwen2.5-VL-3B-Instruct"]
572
+ else:
573
+ # Text-only summary -> FAST text models first, VL only as last resort.
574
+ candidate = QWEN_TEXT_MODELS + [QWEN_VL_MODEL]
575
+ # Use the last-known-working model first to avoid wasting time on unavailable models.
576
+ global _WORKING_MODEL_TEXT, _WORKING_MODEL_VL
577
+ cached_ok = _WORKING_MODEL_VL if has_images else _WORKING_MODEL_TEXT
578
+ if cached_ok and cached_ok in candidate:
579
+ candidate = [cached_ok] + [m for m in candidate if m != cached_ok]
580
+ for m in candidate:
581
+ if m and m not in models:
582
+ models.append(m)
583
+ for model in models:
584
+ try:
585
+ client = AsyncInferenceClient(provider="auto", api_key=HF_TOKEN, timeout=60)
586
+ content = []
587
+ # Collect all images: image_urls list takes priority, fall back to single image_url
588
+ all_img_urls = []
589
+ if image_urls:
590
+ all_img_urls = image_urls[:6] # max 6 images to avoid context overflow
591
+ elif image_url:
592
+ all_img_urls = [image_url]
593
+ for img_u in all_img_urls:
594
+ if img_u and img_u.startswith("http"):
595
+ content.append({"type": "image_url", "image_url": {"url": img_u}})
596
+ content.append({"type": "text", "text": prompt})
597
+ messages = [
598
+ {"role": "system", "content": (
599
+ "Bạn là biên tập viên báo điện tử tiếng Việt. "
600
+ "NHIỆM VỤ: Chỉ TÓM TẮT nội dung, KHÔNG viết lại bài đầy đủ. "
601
+ "QUY TẮC CỨNG: "
602
+ "(1) KHÔNG lặp lại bất kỳ nội dung nào — mỗi ý chỉ xuất hiện ĐÚNG 1 LẦN. "
603
+ "(2) Nếu 2 câu diễn đạt cùng 1 ý → bỏ cây thứ 2. "
604
+ "(3) KHÔNG dùng Markdown (##, **, ---, *). "
605
+ "(4) KHÔNG viết 'Dưới đây là', 'Tôi sẽ', 'Theo yêu cầu', 'Nhiệm vụ', 'Vai trò', 'Đây là bài tóm tắt'. "
606
+ "(5) KHÔNG bịa thông tin ngoài nguồn. "
607
+ "(6) Chỉ viết ĐOẠN VĂN THUẦN, không bullet points. "
608
+ "(7) Tối đa 200 từ. Ngắn gọn, súc tích."
609
+ )},
610
+ {"role": "user", "content": content}
611
+ ]
612
+ resp = await client.chat_completion(model=model, messages=messages, max_tokens=max_tokens, temperature=0.3, top_p=0.8)
613
+ txt = (resp.choices[0].message.content or "").strip()
614
+ if txt:
615
+ LAST_QWEN_ERROR = ""
616
+ if has_images: _WORKING_MODEL_VL = model
617
+ else: _WORKING_MODEL_TEXT = model
618
+ return txt
619
+ except Exception as e:
620
+ errors.append(f"{model}: {type(e).__name__}: {str(e)[:220]}")
621
+ LAST_QWEN_ERROR = " | ".join(errors) or "Qwen không trả nội dung."
622
+ print("[qwen errors]", LAST_QWEN_ERROR)
623
+ return None
624
+
625
+
626
+ # ===== TTS GENERATION =====
627
+ async def _generate_tts_edge(text: str, voice_id: str, speed: float, out_path: str, emotion: str = None):
628
+ """Generate TTS using edge-tts (or gTTS if engine=gtts) with voice, speed & emotion control.
629
+
630
+ Emotion (cảm xúc) is simulated via pitch + rate + volume (edge-tts has no express-as).
631
  """
632
+ vcfg = TTS_VOICES.get(voice_id, TTS_VOICES[TTS_DEFAULT_VOICE])
633
+ # gTTS engine (no voice/speed/emotion control)
634
+ if vcfg.get("engine") == "gtts":
635
+ _generate_tts_gtts(text, out_path)
636
+ return
637
+ if edge_tts is None:
638
+ raise RuntimeError("edge-tts chưa cài đặt")
639
+ voice = vcfg["id"]
640
+ emo = EMOTION_PRESETS.get(emotion or EMOTION_DEFAULT, EMOTION_PRESETS[EMOTION_DEFAULT])
641
+ # Apply emotion rate multiplier on top of base speed
642
+ eff_speed = speed * emo.get("rate_mul", 1.0)
643
+ pct = int(round((eff_speed - 1.0) * 100))
644
+ rate = f"+{pct}%" if pct >= 0 else f"{pct}%"
645
+ pitch = emo.get("pitch", "+0Hz")
646
+ volume = emo.get("volume", "+0%")
647
+ communicate = edge_tts.Communicate(text, voice, rate=rate, pitch=pitch, volume=volume)
648
+ await communicate.save(out_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
649
 
650
+ def _generate_tts_gtts(text: str, out_path: str):
651
+ """Fallback TTS using gTTS (no voice/speed control)."""
652
+ if gTTS is None:
653
+ raise RuntimeError("gTTS chưa cài đặt")
654
+ gTTS(text, lang="vi").save(out_path)
655
 
656
+
657
+ # ===== SHORT VIDEO GENERATION (multi-segment: each key point with its own image) =====
658
+ def _download_image(url, fallback_topic, out_path):
659
+ """Download an image (un-proxying our own /api/proxy/img). Falls back to generated image."""
660
+ if url:
661
+ u = url
662
+ m = re.search(r'/api/proxy/img\?url=(.+)$', u)
663
+ if m:
664
+ from urllib.parse import unquote
665
+ u = unquote(m.group(1))
666
+ try:
667
+ r = requests.get(u, headers={**HEADERS, "Referer": "https://dantri.com.vn/"}, timeout=15)
668
+ if r.status_code == 200 and len(r.content) > 1000:
669
+ with open(out_path, "wb") as f:
670
+ f.write(r.content)
671
+ if Image:
672
+ Image.open(out_path).verify()
673
+ return out_path
674
+ except Exception:
675
+ pass
676
+ gen = pollinations_image_url(fallback_topic)
677
  try:
678
+ r = requests.get(gen, headers=HEADERS, timeout=25)
679
+ if r.status_code == 200 and len(r.content) > 1000:
680
+ with open(out_path, "wb") as f:
681
+ f.write(r.content)
682
+ return out_path
 
 
 
 
 
683
  except Exception:
684
  pass
685
+ if Image:
686
+ Image.new("RGB", (1080, 980), (30, 55, 42)).save(out_path)
687
+ return out_path
688
+ raise RuntimeError("Không tạo được ảnh")
689
 
690
 
691
+ def _split_keypoint_sentences(text, max_points=6):
692
+ """Split summary text into key points: prefer bullet markers, else sentences."""
693
+ text = _clean_text(text)
694
+ parts = re.split(r'\s*•\s*', text)
695
+ good = [p.strip() for p in parts if len(p.strip()) > 20]
696
+ if len(good) >= 2:
697
+ # Explicit bullet points: keep each one as-is (never merge).
698
+ return good[:max_points]
699
+ # Fallback: split into sentences and merge orphan short fragments.
700
+ pts = [p.strip() for p in re.split(r'(?<=[.!?])\s+', text) if len(p.strip()) > 20]
701
+ out = []
702
+ for p in pts:
703
+ if out and len(p) < 40:
704
+ out[-1] = (out[-1] + " " + p).strip()
705
+ else:
706
+ out.append(p)
707
+ return out[:max_points] if out else ([text] if text else [])
708
+
709
+
710
+ def _build_keypoints(post, max_points=6):
711
+ """Return [{text, image}] pairing each key point with its own image."""
712
+ slides = post.get("slides") or []
713
+ images = post.get("images") or ([post.get("img")] if post.get("img") else [])
714
+ images = [i for i in images if i]
715
+ if slides:
716
+ kps = []
717
+ for i, s in enumerate(slides[:max_points]):
718
+ t = _clean_text(s.get("text", ""))
719
+ img = s.get("image") or (images[i] if i < len(images) else (images[-1] if images else ""))
720
+ if t:
721
+ kps.append({"text": t, "image": img})
722
+ if kps:
723
+ return kps
724
+ points = _split_keypoint_sentences(post.get("text", ""), max_points)
725
+ kps = []
726
+ for i, t in enumerate(points):
727
+ img = images[i] if i < len(images) else (images[-1] if images else "")
728
+ kps.append({"text": t, "image": img})
729
+ if not kps:
730
+ kps = [{"text": _clean_text(post.get("title", "")) or "VNEWS", "image": images[0] if images else ""}]
731
+ return kps
732
+
733
+
734
+ def _wrap_text(draw, text, font, max_w):
735
+ words = text.split()
736
+ lines, cur = [], ""
737
+ for w in words:
738
+ test = (cur + " " + w).strip()
739
+ if draw.textlength(test, font=font) <= max_w:
740
+ cur = test
741
+ else:
742
+ if cur:
743
+ lines.append(cur)
744
+ cur = w
745
+ if cur:
746
+ lines.append(cur)
747
+ return lines
748
+
749
+
750
+ def _make_segment_frame(title, point_text, img_path, idx, total, out_path):
751
+ """Render a 1080x1920 vertical frame: image on top, key point text below."""
752
  if Image is None:
753
+ raise RuntimeError("Pillow chưa sẵn sàng")
 
 
 
754
  W, H = 1080, 1920
755
+ IMG_H = 980
756
+ bg = Image.new("RGB", (W, H), (12, 14, 18))
757
  try:
758
  im = Image.open(img_path).convert("RGB")
759
+ tr = W / IMG_H
760
+ ir = im.width / im.height
761
+ if ir > tr:
762
+ nh = IMG_H; nw = int(nh * ir)
 
 
 
763
  else:
764
+ nw = W; nh = int(nw / ir)
765
+ im = im.resize((nw, nh))
766
+ left = (nw - W) // 2; top = (nh - IMG_H) // 2
767
+ im = im.crop((left, top, left + W, top + IMG_H))
 
 
 
768
  bg.paste(im, (0, 0))
769
  except Exception:
770
  pass
 
771
  draw = ImageDraw.Draw(bg)
772
+ draw.rectangle((0, IMG_H, W, H), fill=(12, 14, 18))
773
  try:
774
+ f_label = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 34)
775
+ f_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 46)
776
+ f_point = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 52)
777
  except Exception:
778
+ f_label = f_title = f_point = ImageFont.load_default()
779
+ draw.text((54, IMG_H + 24), "VNEWS · Tường AI", fill=(92, 184, 122), font=f_label)
780
+ cnt = f"{idx + 1}/{total}"
781
+ draw.text((W - 54 - draw.textlength(cnt, font=f_label), IMG_H + 24), cnt, fill=(240, 192, 64), font=f_label)
782
+ y = IMG_H + 86
783
+ for ln in _wrap_text(draw, _clean_text(title), f_title, W - 108)[:2]:
784
+ draw.text((54, y), ln, fill=(255, 255, 255), font=f_title)
785
+ y += 56
786
+ y += 16
787
+ for ln in _wrap_text(draw, _clean_text(point_text), f_point, W - 108)[:11]:
788
+ draw.text((54, y), ln, fill=(225, 230, 235), font=f_point)
789
+ y += 64
 
 
 
 
 
 
 
 
 
790
  bg.save(out_path, quality=92)
791
+ return out_path
792
 
793
 
794
+ def _ffmpeg_bin():
795
+ return os.environ.get("FFMPEG_BIN", "ffmpeg")
796
+
797
+
798
+ def _audio_duration(path):
799
+ try:
800
+ out = subprocess.run([_ffmpeg_bin(), "-i", path], capture_output=True, text=True, timeout=30).stderr
801
+ m = re.search(r"Duration:\s*(\d+):(\d+):(\d+\.\d+)", out)
802
+ if m:
803
+ h, mi, s = m.groups()
804
+ return int(h) * 3600 + int(mi) * 60 + float(s)
805
+ except Exception:
806
+ pass
807
+ return 0.0
808
+
809
+
810
+ async def _generate_short_video(post, post_id: str, voice_id: str = None, speed: float = None, emotion: str = None) -> str:
811
+ """Generate a multi-segment MP4 short: each key point shown with its OWN image + narration."""
812
+ try:
813
+ os.makedirs(SHORTS_DIR, exist_ok=True)
814
+ out_mp4 = os.path.join(SHORTS_DIR, _safe_name(post_id) + ".mp4")
815
+ if os.path.exists(out_mp4) and voice_id is None and speed is None and emotion is None:
816
+ return "/api/ai/short-file/" + post_id
817
+
818
+ work = os.path.join(SHORTS_DIR, _safe_name(post_id) + "_work")
819
+ os.makedirs(work, exist_ok=True)
820
+
821
+ title = _clean_text(post.get("title", "")) or "VNEWS"
822
+ kps = _build_keypoints(post)
823
+
824
+ # Resolve voice + emotion (auto from topic, or from post, or explicit args)
825
+ auto_voice, auto_emotion = _detect_voice_emotion(post.get("title", ""), post.get("text", ""))
826
+ if voice_id is None:
827
+ voice_id = post.get("voice") or auto_voice
828
+ if emotion is None:
829
+ emotion = post.get("emotion") or auto_emotion
830
+ if speed is None:
831
+ speed = TTS_DEFAULT_SPEED
832
+ vcfg = TTS_VOICES.get(voice_id, TTS_VOICES[TTS_DEFAULT_VOICE])
833
+
834
+ seg_files = []
835
+ ff = _ffmpeg_bin()
836
+ for i, kp in enumerate(kps):
837
+ img_path = os.path.join(work, f"img{i}.jpg")
838
+ frame_path = os.path.join(work, f"frame{i}.jpg")
839
+ audio_path = os.path.join(work, f"voice{i}.mp3")
840
+ seg_mp4 = os.path.join(work, f"seg{i}.mp4")
841
+ _download_image(kp.get("image", ""), title, img_path)
842
+ _make_segment_frame(title, kp["text"], img_path, i, len(kps), frame_path)
843
+ narration = (title + ". " + kp["text"]) if i == 0 else kp["text"]
844
+ try:
845
+ await _generate_tts_edge(narration, voice_id, speed, audio_path, emotion=emotion)
846
+ except Exception as e:
847
+ print(f"[TTS edge-tts error] {e}, falling back to gTTS")
848
+ if gTTS:
849
+ _generate_tts_gtts(narration, audio_path)
850
+ else:
851
+ return ""
852
+ dur = _audio_duration(audio_path)
853
+ if dur < 1.0:
854
+ dur = 2.0
855
+ cmd = [ff, "-y", "-loop", "1", "-i", frame_path, "-i", audio_path,
856
+ "-c:v", "libx264", "-tune", "stillimage", "-pix_fmt", "yuv420p",
857
+ "-t", f"{dur + 0.4:.2f}", "-c:a", "aac", "-b:a", "128k", "-ar", "44100",
858
+ "-vf", "scale=1080:1920", "-r", "25", seg_mp4]
859
+ subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
860
+ seg_files.append(seg_mp4)
861
+
862
+ if not seg_files:
863
+ return ""
864
+ if len(seg_files) == 1:
865
+ os.replace(seg_files[0], out_mp4)
866
+ return "/api/ai/short-file/" + post_id
867
+
868
+ listfile = os.path.join(work, "concat.txt")
869
+ with open(listfile, "w", encoding="utf-8") as f:
870
+ f.write("\n".join(f"file '{s}'" for s in seg_files))
871
+ cmd = [ff, "-y", "-f", "concat", "-safe", "0", "-i", listfile,
872
+ "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k", out_mp4]
873
+ subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=300)
874
+ return "/api/ai/short-file/" + post_id
875
+ except Exception as e:
876
+ print(f"[short video error] {e}")
877
+ return ""
878
+
879
+ import threading as _threading
880
+ def _spawn_background_video(post):
881
+ """Generate the short video in a BACKGROUND thread so the rewrite/topic endpoint
882
+ can return immediately. When done, persist the video URL onto the wall post.
883
+ This is the main fix for 'rewrite tu cac nguon tin qua lau' — the user gets the
884
+ text post instantly; the video appears shortly after (or via the 'Tao Video' button)."""
885
+ pid = post.get("id")
886
+ if not pid:
887
+ return
888
+ def _run():
889
  try:
890
+ loop = asyncio.new_event_loop()
891
+ asyncio.set_event_loop(loop)
892
+ video_url = loop.run_until_complete(_generate_short_video(post, pid))
893
+ loop.close()
894
+ if video_url:
895
+ posts = _load_wall()
896
+ for i, p in enumerate(posts):
897
+ if str(p.get("id")) == str(pid):
898
+ posts[i]["video"] = video_url
899
+ break
900
+ _save_wall(posts)
901
+ print(f"[bg-video] done {pid} -> {video_url}")
902
+ except Exception as e:
903
+ print(f"[bg-video] error {pid}: {e}")
904
+ _threading.Thread(target=_run, daemon=True).start()
905
+
906
+ # ===== MAKE POST =====
907
+ def make_post(title, text, image, source_url, kind, sources=None, images=None, voice=None, emotion=None):
908
+ # Auto-pick voice + emotion from the article topic when not provided
909
+ auto_voice, auto_emotion = _detect_voice_emotion(title or "", text or "")
910
+ return {
911
+ "id": str(int(time.time() * 1000)) + str(random.randint(100, 999)),
912
+ "title": title, "text": text, "img": image, "url": source_url,
913
+ "kind": kind, "sources": sources or [], "video": "",
914
+ "images": images or [], "ts": int(time.time()),
915
+ "voice": voice or auto_voice,
916
+ "emotion": emotion or auto_emotion,
917
+ }
918
+
919
+
920
+ # ===== SHARED PROMPT BUILDER =====
921
+ def _build_rewrite_prompt(title: str, raw: str, images: List[str] = None) -> str:
922
+ image_info = ""
923
+ if images:
924
+ num = len(images)
925
+ if num == 1:
926
+ image_info = "\n\nBài viết có 1 ảnh minh họa. Hãy tham khảo ảnh để hiểu ngữ cảnh (nếu phù hợp)."
927
  else:
928
+ image_info = f"\n\nBài viết có {num} ảnh minh họa. Hãy tham khảo tất cả ảnh để hiểu ngữ cảnh và bổ sung thông tin cho bài viết (nếu phù hợp)."
929
+
930
+ return f"""Tóm tắt bài viết sau thành bài TÓM TẮT đăng Tường AI.
931
+
932
+ QUY TẮC BẮT BUỘC:
933
+ 1. Chỉ viết TÓM TẮT các ý chính. KHÔNG sao chép nguyên văn từ bài gốc.
934
+ 2. KHÔNG lặp lại bất kỳ nội dung nào. Mỗi thông tin chỉ xuất hiện ĐÚNG 1 LẦN.
935
+ 3. Nếu 2 câu nói cùng 1 ý → chỉ giữ 1 câu, bỏ cây còn lại.
936
+ 4. KHÔNG dùng Markdown (##, **, ---, *).
937
+ 5. KHÔNG viết "Dưới đây là", "Tôi sẽ", "Theo yêu cầu", "Nhiệm vụ", "Vai trò", "Đây là bài tóm tắt".
938
+ 6. Viết thành ĐOẠN VĂN THUẦN, mạch lạc, dễ đọc. Không dùng bullet points.
939
+ 7. Giữ sự thật, KHÔNG bịa thông tin.
940
+ 8. Tối đa 200 từ. Ngắn gọn, đủ ý.{image_info}
941
+
942
+ Tiêu đề gốc: {title}
943
+
944
+ Nội dung gốc:
945
+ {raw[:14000]}"""
946
+
947
+
948
+ def _build_topic_prompt(topic: str, ctx: str) -> str:
949
+ return f"""Viết bài TÓM TẮT NGẮN GỌN về chủ đề: "{topic}".
950
+
951
+ QUY TẮC BẮT BUỘC:
952
+ 1. Chỉ viết TÓM TẮT các ý chính từ nguồn. KHÔNG sao chép nguyên văn.
953
+ 2. KHÔNG lặp lại bất kỳ nội dung nào. Mỗi thông tin chỉ xuất hiện ĐÚNG 1 LẦN.
954
+ 3. Nếu 2 câu nói cùng 1 ý → chỉ giữ 1 câu.
955
+ 4. KHÔNG dùng Markdown (##, **, ---, *).
956
+ 5. KHÔNG viết "Dưới đây là", "Tôi sẽ", "Theo yêu cầu", "Nhiệm vụ", "Vai trò".
957
+ 6. Viết thành ĐOẠN VĂN THU���N, mạch lạc. Không dùng bullet points.
958
+ 7. Giữ sự thật, KHÔNG bịa.
959
+ 8. Tối đa 200 từ. Ngắn gọn, đủ ý.
960
+
961
+ Nguồn thực tế:
962
+ {ctx[:12000]}"""
963
+
964
+
965
+ # ===== WRITE ENDPOINTS =====
966
+ @app.post("/api/rewrite_share")
967
+ async def api_rewrite_share(request: Request):
968
+ body = await request.json()
969
+ url = _clean_text(body.get("url", ""))
970
+ if not url.startswith("http"):
971
+ return JSONResponse({"error": "missing url"}, status_code=400)
972
+ try:
973
+ data = scrape_any_url(url)
974
+ except Exception as e:
975
+ return JSONResponse({"error": "Không đọc được bài viết: " + str(e)[:180]}, status_code=422)
976
+ raw = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
977
+ if len(raw) < 60:
978
+ return JSONResponse({"error": "Bài viết quá ngắn để tóm tắt"}, status_code=422)
979
+
980
+ images = data.get("images", [])
981
+ prompt = _build_rewrite_prompt(data.get("title", ""), raw, images)
982
+ # Text-only summary for SPEED (images are kept on the post for display + short video).
983
+ text = await qwen_generate(prompt, max_tokens=500)
984
+ if not text:
985
+ return JSONResponse({"error": "Qwen2.5-VL chưa sẵn sàng: " + LAST_QWEN_ERROR}, status_code=503)
986
+ text = _clean_ai_output(text)
987
+ post = make_post(data.get("title") or "Bài viết", text,
988
+ images[0] if images else data.get("image", ""),
989
+ url, "rewrite", images=images)
990
+
991
+ # Save post and return IMMEDIATELY; generate the short video in the background
992
+ # (so rewrite is fast). Video appears on the wall when ready / via 'Tao Video' button.
993
+ posts = _load_wall()
994
+ posts.insert(0, post)
995
+ _save_wall(posts)
996
+ _spawn_background_video(post)
997
+ return JSONResponse({"post": post})
998
+
999
+
1000
+ @app.post("/api/url_wall")
1001
+ async def api_url_wall(request: Request):
1002
+ body = await request.json()
1003
+ url = _clean_text(body.get("url", ""))
1004
+ if not url.startswith("http"):
1005
+ return JSONResponse({"error": "missing url"}, status_code=400)
1006
+ try:
1007
+ data = scrape_any_url(url)
1008
+ except Exception as e:
1009
+ return JSONResponse({"error": "Không scrape được URL: " + str(e)[:180]}, status_code=422)
1010
+ raw = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
1011
+ if len(raw) < 60:
1012
+ return JSONResponse({"error": "URL không có đủ nội dung"}, status_code=422)
1013
+
1014
+ images = data.get("images", [])
1015
+ prompt = _build_rewrite_prompt(data.get("title", ""), raw, images)
1016
+ # Text-only summary for SPEED (images are kept on the post for display + short video).
1017
+ text = await qwen_generate(prompt, max_tokens=500)
1018
+ if not text:
1019
+ return JSONResponse({"error": "Qwen2.5-VL chưa sẵn sàng: " + LAST_QWEN_ERROR}, status_code=503)
1020
+ text = _clean_ai_output(text)
1021
+ post = make_post(data.get("title") or "Bài viết", text,
1022
+ images[0] if images else data.get("image", ""),
1023
+ url, "url", images=images)
1024
+
1025
+ posts = _load_wall()
1026
+ posts.insert(0, post)
1027
+ _save_wall(posts)
1028
+ _spawn_background_video(post)
1029
+ return JSONResponse({"post": post})
1030
+
1031
+
1032
+ @app.post("/api/topic_post")
1033
+ async def api_topic_post(request: Request):
1034
+ body = await request.json()
1035
+ topic = _clean_text(body.get("topic", ""))
1036
+ if not topic:
1037
+ return JSONResponse({"error": "missing topic"}, status_code=400)
1038
+
1039
+ ctx = _web_context(topic)
1040
+ if not ctx:
1041
+ return JSONResponse({"error": "Không lấy được dữ liệu cho chủ đề này"}, status_code=422)
1042
+
1043
+ image = pollinations_image_url(topic)
1044
+ prompt = _build_topic_prompt(topic, ctx)
1045
+ # NOTE: do NOT pass the decorative pollinations image to the VL model — feeding an
1046
+ # image makes Qwen2.5-VL much slower (it must download+process it) with no benefit for
1047
+ # a text summary. We keep the image only for display on the post. This is a major
1048
+ # speed-up for 'rewrite tong hop'. (Text-only inference is several times faster.)
1049
+ text = await qwen_generate(prompt, max_tokens=500)
1050
+ if not text:
1051
+ return JSONResponse({"error": "Qwen2.5-VL chưa sẵn sàng: " + LAST_QWEN_ERROR}, status_code=503)
1052
+ text = _clean_ai_output(text)
1053
+ post = make_post(topic, text, image, "", "topic")
1054
+
1055
+ posts = _load_wall()
1056
+ posts.insert(0, post)
1057
+ _save_wall(posts)
1058
+ _spawn_background_video(post)
1059
+ return JSONResponse({"post": post})
1060
+
1061
+
1062
+ # ===== WALL ENDPOINTS =====
1063
+ @app.get("/api/ai_wall")
1064
+ def api_ai_wall():
1065
+ return JSONResponse({"posts": _load_wall()[:80]})
1066
+
1067
+ @app.get("/api/wall")
1068
+ def api_wall():
1069
+ return JSONResponse({"posts": _load_wall()[:80]})
1070
+
1071
+
1072
+ # ===== SHORT VIDEO ENDPOINT (with voice + speed params) =====
1073
+ @app.post("/api/ai/short/{post_id}")
1074
+ async def api_ai_short(post_id: str, voice: str = Query(default=None), speed: float = Query(default=None), emotion: str = Query(default=None)):
1075
+ """Generate (or retrieve cached) short video for a wall post.
1076
+
1077
+ Query params:
1078
+ - voice: 'hoaimy' (female) | 'namminh' (male) | auto-detect if not specified
1079
+ - speed: float (default 1.2), e.g. 1.0=normal, 1.2=fast, 0.8=slow
1080
+ - emotion: 'vui'|'hao_hung'|'nghiem'|'tram'|'buon'|'trung_tinh' | auto by topic
1081
+ """
1082
+ posts = _load_wall()
1083
+ post = next((p for p in posts if str(p.get("id")) == str(post_id)), None)
1084
+ if not post:
1085
+ return JSONResponse({"error": "post not found"}, status_code=404)
1086
+
1087
+ os.makedirs(SHORTS_DIR, exist_ok=True)
1088
+ out_mp4 = os.path.join(SHORTS_DIR, _safe_name(post_id) + ".mp4")
1089
+
1090
+ # If cached and no custom voice/speed/emotion requested, return cached
1091
+ if os.path.exists(out_mp4) and voice is None and speed is None and emotion is None:
1092
+ video_url = "/api/ai/short-file/" + post_id
1093
+ for i, p in enumerate(posts):
1094
+ if str(p.get("id")) == str(post_id):
1095
+ posts[i]["video"] = video_url
1096
+ break
1097
+ _save_wall(posts)
1098
+ return JSONResponse({"video": video_url})
1099
+
1100
+ # Validate params
1101
+ if voice is not None and voice not in TTS_VOICES:
1102
+ return JSONResponse({"error": f"voice không hợp lệ. Chọn: {list(TTS_VOICES.keys())}"}, status_code=400)
1103
+ if emotion is not None and emotion not in EMOTION_PRESETS:
1104
+ return JSONResponse({"error": f"emotion không hợp lệ. Chọn: {list(EMOTION_PRESETS.keys())}"}, status_code=400)
1105
+
1106
+ video_url = await _generate_short_video(post, post_id, voice_id=voice, speed=speed, emotion=emotion)
1107
+ if video_url:
1108
+ for i, p in enumerate(posts):
1109
+ if str(p.get("id")) == str(post_id):
1110
+ posts[i]["video"] = video_url
1111
+ if voice:
1112
+ posts[i]["voice"] = voice
1113
+ if emotion:
1114
+ posts[i]["emotion"] = emotion
1115
+ break
1116
+ _save_wall(posts)
1117
+ return JSONResponse({"video": video_url})
1118
+ return JSONResponse({"error": "Không tạo được shorts"}, status_code=500)
1119
+
1120
+
1121
+ @app.get("/api/ai/short-file/{post_id}")
1122
+ def api_ai_short_file(post_id: str):
1123
+ path = os.path.join(SHORTS_DIR, _safe_name(post_id) + ".mp4")
1124
+ if not os.path.exists(path):
1125
+ return JSONResponse({"error": "not found"}, status_code=404)
1126
+ return FileResponse(path, media_type="video/mp4", filename=f"vnews-ai-{post_id}.mp4")
1127
+
1128
+
1129
+ @app.get("/api/ai/status")
1130
+ def api_ai_status():
1131
+ return JSONResponse({
1132
+ "has_token": bool(_hf_token()),
1133
+ "client_imported": AsyncInferenceClient is not None,
1134
+ "last_error": LAST_QWEN_ERROR,
1135
+ "working_text_model": _WORKING_MODEL_TEXT,
1136
+ "working_vl_model": _WORKING_MODEL_VL,
1137
+ })
ai_patch.py CHANGED
@@ -6,7 +6,6 @@ import json
6
  import html as html_lib
7
  import subprocess
8
  import requests
9
- import hashlib
10
  import ai_ext as base
11
  from ai_ext import app
12
  from fastapi import Request
@@ -42,17 +41,17 @@ def _similar(a, b):
42
  return len(ta & tb) / max(1, min(len(ta), len(tb))) >= 0.72
43
 
44
 
45
- def _dedupe_units(units, max_units=25):
46
- """Deduplicate units - only skip exact matches to ensure all bullet points are read."""
47
  out, seen = [], set()
48
  for u in units:
49
  u = _clean(re.sub(r"^[-•*\d\.\)\s]+", "", u))
50
  if len(u) < 18:
51
  continue
52
  nu = _norm(u)
53
- # Only skip exact matches, NOT similar content (to avoid skipping valid bullet points)
54
  if nu in seen:
55
  continue
 
 
56
  seen.add(nu)
57
  out.append(u)
58
  if len(out) >= max_units:
@@ -60,7 +59,7 @@ def _dedupe_units(units, max_units=25):
60
  return out
61
 
62
 
63
- def _postprocess_ai_text(text, max_units=20):
64
  text = _clean(text)
65
  if not text:
66
  return text
@@ -79,9 +78,10 @@ def _postprocess_ai_text(text, max_units=20):
79
  raw_lines.append(line)
80
  units = []
81
  for line in raw_lines:
82
- # KEEP FULL bullet point - don't truncate or split into segments
83
- if len(line) >= 18:
84
- units.append(_clean(re.sub(r"^[-•*\d\.\)\s]+", "", line)))
 
85
  units = _dedupe_units(units, max_units=max_units)
86
  if not units:
87
  return text[:900]
@@ -268,7 +268,7 @@ async def qwen_generate_resilient(prompt: str, image_url=None, max_tokens: int =
268
  errors.append("missing HF_TOKEN")
269
  base.LAST_QWEN_ERROR = " | ".join(errors[-6:]) or "Qwen unavailable; used extractive fallback"
270
  print("[qwen resilient fallback]", base.LAST_QWEN_ERROR)
271
- return _fallback_summary_from_prompt(prompt, max_units=12)
272
 
273
 
274
  if not hasattr(base, "_original_qwen_generate"):
@@ -321,8 +321,8 @@ Yêu cầu bắt buộc:
321
 
322
  Nội dung bài:
323
  {art['raw'][:14000]}"""
324
- text = await base.qwen_generate(prompt, image_url=art.get('image') or None, max_tokens=1500)
325
- text = _postprocess_ai_text(text, max_units=20)
326
  src = [art['source']]
327
  if 'Nguồn tham khảo:' not in text:
328
  text += "\n\n" + _source_line(src)
@@ -347,8 +347,8 @@ async def compat_url_wall(request: Request):
347
  if len(raw) < 120:
348
  return JSONResponse({'error': 'URL không có đủ nội dung để tóm tắt'}, status_code=422)
349
  prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
350
- text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=1500)
351
- text = _postprocess_ai_text(text, max_units=20)
352
  src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
353
  if 'Nguồn tham khảo:' not in text:
354
  text += "\n\n" + _source_line(src)
@@ -357,116 +357,6 @@ async def compat_url_wall(request: Request):
357
  return JSONResponse({'post': post})
358
 
359
 
360
- def _is_relevant_image(img_url, title, text):
361
- """Check if an image is relevant to the article content."""
362
- if not img_url:
363
- return False
364
- skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif',
365
- 'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite',
366
- 'advertisement', 'ad-banner', 'sponsored', 'banner-ads']
367
- img_lower = img_url.lower()
368
- for p in skip_patterns:
369
- if p in img_lower:
370
- return False
371
- if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']):
372
- return False
373
- return True
374
-
375
-
376
- def _filter_relevant_images(images, title, text, max_images=8):
377
- """Filter and rank images by relevance to article content."""
378
- if not images:
379
- return []
380
- seen = set()
381
- relevant = []
382
- for img in images:
383
- if img in seen:
384
- continue
385
- seen.add(img)
386
- if _is_relevant_image(img, title, text):
387
- relevant.append(img)
388
- return relevant[:max_images]
389
-
390
-
391
- def _extract_key_points_for_slides(paragraphs, max_points=12):
392
- """Extract key points from paragraphs for slides - extracts ALL sentences, not just first one."""
393
- points = []
394
- for p in paragraphs:
395
- if len(points) >= max_points:
396
- break
397
- p = _clean(p)
398
- if not p:
399
- continue
400
- # Split paragraph into sentences using Vietnamese + English punctuation - GET ALL SENTENCES
401
- sentences = re.split(r'(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])', p)
402
- sentences = [s.strip() for s in sentences if s.strip()]
403
-
404
- for sentence in sentences:
405
- if len(points) >= max_points:
406
- break
407
- sentence = _clean(sentence)
408
- if len(sentence) < 30:
409
- continue
410
- if any(sentence[:60] in existing for existing in points):
411
- continue
412
- if not sentence.endswith(('.', '!', '?')):
413
- sentence = sentence + '.'
414
- points.append(sentence)
415
- return points
416
-
417
-
418
- def _scrape_article_images(url):
419
- """Scrape article page and return only relevant images."""
420
- try:
421
- headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
422
- "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"}
423
- r = requests.get(url, headers=headers, timeout=15, allow_redirects=True)
424
- r.encoding = 'utf-8'
425
- soup = BeautifulSoup(r.text, 'lxml')
426
- for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
427
- tag.decompose()
428
- h1 = soup.find('h1')
429
- ogt = soup.find('meta', property='og:title')
430
- title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '')
431
- ogi = soup.find('meta', property='og:image')
432
- og_img = ogi.get('content', '') if ogi else ''
433
- if og_img and og_img.startswith('//'):
434
- og_img = 'https:' + og_img
435
- block = None
436
- for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
437
- el = soup.select_one(sel)
438
- if el and len(el.find_all('p')) >= 2:
439
- block = el
440
- break
441
- if not block:
442
- block = soup.body or soup
443
- paragraphs = []
444
- all_images = []
445
- seen_imgs = set()
446
- if og_img and og_img not in seen_imgs:
447
- all_images.append(og_img)
448
- seen_imgs.add(og_img)
449
- for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
450
- if el.name == 'p':
451
- t = _clean(el.get_text(strip=True))
452
- if t and len(t) > 40:
453
- paragraphs.append(t)
454
- elif el.name in ('figure', 'img'):
455
- im = el if el.name == 'img' else el.find('img')
456
- if im:
457
- src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
458
- if src and 'base64' not in src:
459
- if src.startswith('//'):
460
- src = 'https:' + src
461
- if src not in seen_imgs:
462
- all_images.append(src)
463
- seen_imgs.add(src)
464
- relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5]))
465
- return {'title': _clean(title), 'paragraphs': paragraphs, 'images': relevant_images, 'og_img': og_img}
466
- except Exception:
467
- return None
468
-
469
-
470
  @app.post('/api/rewrite_share')
471
  async def compat_rewrite_share(request: Request):
472
  body = await request.json()
@@ -481,53 +371,40 @@ async def compat_rewrite_share(request: Request):
481
  if len(raw) < 120:
482
  return JSONResponse({'error': 'Bài viết không đủ nội dung để tóm tắt'}, status_code=422)
483
  prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
484
- text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=1500)
485
- text = _postprocess_ai_text(text, max_units=20)
486
  src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
487
  if 'Nguồn tham khảo:' not in text:
488
  text += "\n\n" + _source_line(src)
489
  post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'summary', sources=src)
490
  posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
491
-
492
- # Generate slides with relevant images only
493
- slides = []
494
- page_data = _scrape_article_images(url)
495
- if page_data and page_data.get('paragraphs'):
496
- key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12)
497
- if key_points:
498
- relevant_imgs = page_data.get('images', [])
499
- if not relevant_imgs and page_data.get('og_img'):
500
- relevant_imgs = [page_data['og_img']]
501
- for i, point in enumerate(key_points):
502
- img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
503
- slides.append({'text': point, 'image': img, 'index': i + 1})
504
-
505
- return JSONResponse({'post': post, 'slides': slides})
506
 
507
 
508
  def _emotion_script(text, emotion):
509
- """Prepend emotion-appropriate prefix to text based on emotion type.
510
-
511
- NOTE: Prefix is NOT added to avoid cluttering Short AI speech.
512
- The emotion is still used for voice selection but content is read cleanly.
513
- """
514
  text = _clean(text)
515
- # REMOVED: No prefix added to keep content clean and natural
 
 
 
 
 
 
 
516
  return text
517
 
518
 
519
  def _tts_script_smart(post, emotion):
520
- raw = base._short_script(post) if hasattr(base, '_short_script') else _clean(post.get('text', '') or post.get('title', ''))
521
  raw = re.sub(r"^[•\-\*]\s*", "", raw, flags=re.M)
522
  raw = re.sub(r"\s*\n\s*", ". ", raw)
523
  raw = re.sub(r"([\.\!\?])\s*", r"\1\n", raw)
524
  raw = re.sub(r"\n{2,}", "\n", raw).strip()
525
- # REMOVED: _emotion_script call - read content cleanly without prefix
526
- # INCREASED to 3000 to read full content of all bullet points
527
- if len(raw) > 3000:
528
- raw = raw[:3000]
529
  cut = max(raw.rfind("."), raw.rfind("!"), raw.rfind("?"))
530
- if cut > 700:
531
  raw = raw[:cut + 1]
532
  return raw
533
 
@@ -642,7 +519,7 @@ def _make_short_frame_full(post, img_path, out_path):
642
 
643
 
644
 
645
- def _summary_segments_from_post(post, max_segments=25):
646
  raw = _clean(post.get('text') or post.get('title') or '')
647
  raw = re.sub(r'^Bản tin AI viết lại:\s*', '', raw, flags=re.I)
648
  raw = re.sub(r'Nguồn tham khảo:.*$', '', raw, flags=re.I|re.S).strip()
@@ -653,7 +530,7 @@ def _summary_segments_from_post(post, max_segments=25):
653
  low=ln.lower()
654
  if low.startswith(('điểm chính','tiêu đề','sapo','nguồn tham khảo')): continue
655
  if len(ln)>=18: lines.append(ln)
656
- if len(lines)<3:
657
  lines=[]
658
  for s in re.split(r'(?<=[\.\!\?])\s+', raw):
659
  s=_clean(s)
@@ -705,8 +582,7 @@ def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='ne
705
  draw.rounded_rectangle((48,834,260,880), radius=20, fill=(28,70,45))
706
  draw.text((66,842),f'Đoạn {idx+1}/{total}',fill=(235,235,235),font=font_small)
707
  y=940; maxw=W-96
708
- # INCREASED from 12 to 18 for full content display - each key point can span multiple lines
709
- for ln in _wrap_text_px(draw, segment, font_seg, maxw, 18):
710
  draw.text((48,y),ln,fill=(255,255,255),font=font_seg)
711
  y+=74
712
  if y>1500: break
@@ -718,11 +594,10 @@ def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='ne
718
  bg.save(out_path, quality=92)
719
 
720
 
721
- def _estimate_audio_duration(path, fallback=15.0):
722
- """Estimate audio duration with 15s minimum per segment for complete bullet reading."""
723
  try:
724
  pr=subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:no_key=1',path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
725
- return max(12.0, float((pr.stdout or b'').decode().strip() or fallback))
726
  except Exception:
727
  return fallback
728
 
@@ -735,7 +610,7 @@ async def patched_ai_short(post_id: str, request: Request):
735
  body = {}
736
  voice = str(body.get('voice', 'nu')).strip().lower()
737
  emotion = str(body.get('emotion', 'neutral')).strip().lower()
738
- speed = float(body.get('speed', 1.0) or 1.0)
739
  speed = max(0.85, min(1.35, speed))
740
 
741
  posts = base._load_ai_wall()
@@ -743,7 +618,7 @@ async def patched_ai_short(post_id: str, request: Request):
743
  if not post:
744
  return JSONResponse({'error': 'post not found'}, status_code=404)
745
 
746
- segments = _summary_segments_from_post(post, max_segments=25)
747
  seg_hash = hashlib.md5(('|'.join(segments)+voice+emotion+str(speed)).encode('utf-8')).hexdigest()[:8]
748
  os.makedirs(base.SHORTS_DIR, exist_ok=True)
749
  suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_{seg_hash}_scenes_nosub"
@@ -766,63 +641,11 @@ async def patched_ai_short(post_id: str, request: Request):
766
  try:
767
  base._download_image(post.get('img'), post.get('title', 'AI news'), img)
768
  edge_voice = {
769
- # Vietnamese
770
- 'vi-vn-hoaimyneural': 'vi-VN-HoaiMyNeural',
771
- 'vi-vn-namminhneural': 'vi-VN-NamMinhNeural',
772
- 'hoaimy': 'vi-VN-HoaiMyNeural',
773
- 'namminh': 'vi-VN-NamMinhNeural',
774
  'nam': 'vi-VN-NamMinhNeural',
775
  'male': 'vi-VN-NamMinhNeural',
776
  'nu': 'vi-VN-HoaiMyNeural',
777
  'female': 'vi-VN-HoaiMyNeural',
778
  'mien-nam': 'vi-VN-HoaiMyNeural',
779
- # English - Multilingual
780
- 'en-us-andrewmultilingualneural': 'en-US-AndrewMultilingualNeural',
781
- 'en-au-williammultilingualneural': 'en-AU-WilliamMultilingualNeural',
782
- 'andrew': 'en-US-AndrewMultilingualNeural',
783
- 'en_andrew': 'en-US-AndrewMultilingualNeural',
784
- 'jenny': 'en-US-AndrewMultilingualNeural',
785
- 'en_jenny': 'en-US-AndrewMultilingualNeural',
786
- # Portuguese - Multilingual (ONLY Thalita)
787
- 'pt-br-thalitamultilingualneural': 'pt-BR-ThalitaMultilingualNeural',
788
- 'thalita': 'pt-BR-ThalitaMultilingualNeural',
789
- 'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
790
- 'pt_br_thalita': 'pt-BR-ThalitaMultilingualNeural',
791
- 'pt': 'pt-BR-ThalitaMultilingualNeural',
792
- 'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
793
- # French - Multilingual
794
- 'fr-fr-viviennemultilingualneural': 'fr-FR-VivienneMultilingualNeural',
795
- 'fr-fr-remymultilingualneural': 'fr-FR-RemyMultilingualNeural',
796
- 'denise': 'fr-FR-VivienneMultilingualNeural',
797
- 'fr': 'fr-FR-VivienneMultilingualNeural',
798
- 'fr_denise': 'fr-FR-VivienneMultilingualNeural',
799
- # German - Multilingual
800
- 'de-de-seraphinamultilingualneural': 'de-DE-SeraphinaMultilingualNeural',
801
- 'de-de-florianmultilingualneural': 'de-DE-FlorianMultilingualNeural',
802
- 'katja': 'de-DE-SeraphinaMultilingualNeural',
803
- 'de': 'de-DE-SeraphinaMultilingualNeural',
804
- 'de_katja': 'de-DE-SeraphinaMultilingualNeural',
805
- # Korean - Multilingual (Hyunsu, NOT SunHee)
806
- 'ko-kr-hyusumultilingualneural': 'ko-KR-HyunsuMultilingualNeural',
807
- 'ko-kr-hyunsuneural': 'ko-KR-HyunsuMultilingualNeural',
808
- 'sunhee': 'ko-KR-HyunsuMultilingualNeural',
809
- 'ko': 'ko-KR-HyunsuMultilingualNeural',
810
- 'ko_sunhee': 'ko-KR-HyunsuMultilingualNeural',
811
- # Italian - Multilingual
812
- 'it-it-giuseppemultilingualneural': 'it-IT-GiuseppeMultilingualNeural',
813
- # Spanish (keep for backward compat)
814
- 'ela': 'en-US-AndrewMultilingualNeural',
815
- 'es_ela': 'en-US-AndrewMultilingualNeural',
816
- 'es': 'en-US-AndrewMultilingualNeural',
817
- 'es_carlos': 'en-US-AndrewMultilingualNeural',
818
- # Japanese (keep for backward compat)
819
- 'nanami': 'en-US-AndrewMultilingualNeural',
820
- 'ja': 'en-US-AndrewMultilingualNeural',
821
- 'ja_nanami': 'en-US-AndrewMultilingualNeural',
822
- # Chinese (keep for backward compat)
823
- 'xiaochen': 'en-US-AndrewMultilingualNeural',
824
- 'zh': 'en-US-AndrewMultilingualNeural',
825
- 'zh_xiaochen': 'en-US-AndrewMultilingualNeural',
826
  }.get(voice, 'vi-VN-HoaiMyNeural')
827
  part_files=[]
828
  for idx, seg in enumerate(segments):
@@ -835,13 +658,13 @@ async def patched_ai_short(post_id: str, request: Request):
835
  try:
836
  subprocess.run(['python','-m','edge_tts','--voice',edge_voice,'--text',spoken,'--write-media',aud], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
837
  except Exception:
838
- tld='com.vn' if voice in ('nu','female','mien-nam','hoaimy') else 'com'
839
  try:
840
  base.gTTS(spoken, lang='vi', tld=tld, slow=False).save(aud)
841
  except TypeError:
842
  base.gTTS(spoken, lang='vi', slow=False).save(aud)
843
  subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
844
- dur=_estimate_audio_duration(aud_fast, fallback=15.0)+0.35
845
  subprocess.run(['ffmpeg','-y','-loop','1','-t',str(dur),'-i',frame,'-i',aud_fast,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k',part], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=150)
846
  part_files.append(part)
847
  concat=os.path.join(work,'concat.txt')
@@ -876,3 +699,53 @@ def api_ai_shorts():
876
 
877
 
878
  app.router.routes = [r for r in app.router.routes if not (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set()))]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  import html as html_lib
7
  import subprocess
8
  import requests
 
9
  import ai_ext as base
10
  from ai_ext import app
11
  from fastapi import Request
 
41
  return len(ta & tb) / max(1, min(len(ta), len(tb))) >= 0.72
42
 
43
 
44
+ def _dedupe_units(units, max_units=7):
 
45
  out, seen = [], set()
46
  for u in units:
47
  u = _clean(re.sub(r"^[-•*\d\.\)\s]+", "", u))
48
  if len(u) < 18:
49
  continue
50
  nu = _norm(u)
 
51
  if nu in seen:
52
  continue
53
+ if any(_similar(u, old) for old in out):
54
+ continue
55
  seen.add(nu)
56
  out.append(u)
57
  if len(out) >= max_units:
 
59
  return out
60
 
61
 
62
+ def _postprocess_ai_text(text, max_units=7):
63
  text = _clean(text)
64
  if not text:
65
  return text
 
78
  raw_lines.append(line)
79
  units = []
80
  for line in raw_lines:
81
+ if len(line) > 260:
82
+ units.extend(re.split(r"(?<=[\.\!\?])\s+(?=[A-ZÀ-Ỹ0-9])", line))
83
+ else:
84
+ units.append(line)
85
  units = _dedupe_units(units, max_units=max_units)
86
  if not units:
87
  return text[:900]
 
268
  errors.append("missing HF_TOKEN")
269
  base.LAST_QWEN_ERROR = " | ".join(errors[-6:]) or "Qwen unavailable; used extractive fallback"
270
  print("[qwen resilient fallback]", base.LAST_QWEN_ERROR)
271
+ return _fallback_summary_from_prompt(prompt, max_units=6)
272
 
273
 
274
  if not hasattr(base, "_original_qwen_generate"):
 
321
 
322
  Nội dung bài:
323
  {art['raw'][:14000]}"""
324
+ text = await base.qwen_generate(prompt, image_url=art.get('image') or None, max_tokens=900)
325
+ text = _postprocess_ai_text(text, max_units=6)
326
  src = [art['source']]
327
  if 'Nguồn tham khảo:' not in text:
328
  text += "\n\n" + _source_line(src)
 
347
  if len(raw) < 120:
348
  return JSONResponse({'error': 'URL không có đủ nội dung để tóm tắt'}, status_code=422)
349
  prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
350
+ text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=850)
351
+ text = _postprocess_ai_text(text, max_units=6)
352
  src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
353
  if 'Nguồn tham khảo:' not in text:
354
  text += "\n\n" + _source_line(src)
 
357
  return JSONResponse({'post': post})
358
 
359
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  @app.post('/api/rewrite_share')
361
  async def compat_rewrite_share(request: Request):
362
  body = await request.json()
 
371
  if len(raw) < 120:
372
  return JSONResponse({'error': 'Bài viết không đủ nội dung để tóm tắt'}, status_code=422)
373
  prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
374
+ text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=850)
375
+ text = _postprocess_ai_text(text, max_units=6)
376
  src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
377
  if 'Nguồn tham khảo:' not in text:
378
  text += "\n\n" + _source_line(src)
379
  post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'summary', sources=src)
380
  posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
381
+ return JSONResponse({'post': post})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
382
 
383
 
384
  def _emotion_script(text, emotion):
 
 
 
 
 
385
  text = _clean(text)
386
+ if emotion == 'urgent':
387
+ return 'Tin nhanh. ' + text
388
+ if emotion == 'warm':
389
+ return 'Câu chuyện đáng chú ý. ' + text
390
+ if emotion == 'serious':
391
+ return 'Bản tin nghiêm túc. ' + text
392
+ if emotion == 'energetic':
393
+ return 'Cập nhật nổi bật. ' + text
394
  return text
395
 
396
 
397
  def _tts_script_smart(post, emotion):
398
+ raw = base._short_script(post)
399
  raw = re.sub(r"^[•\-\*]\s*", "", raw, flags=re.M)
400
  raw = re.sub(r"\s*\n\s*", ". ", raw)
401
  raw = re.sub(r"([\.\!\?])\s*", r"\1\n", raw)
402
  raw = re.sub(r"\n{2,}", "\n", raw).strip()
403
+ raw = _emotion_script(raw, emotion)
404
+ if len(raw) > 1000:
405
+ raw = raw[:1000]
 
406
  cut = max(raw.rfind("."), raw.rfind("!"), raw.rfind("?"))
407
+ if cut > 350:
408
  raw = raw[:cut + 1]
409
  return raw
410
 
 
519
 
520
 
521
 
522
+ def _summary_segments_from_post(post, max_segments=7):
523
  raw = _clean(post.get('text') or post.get('title') or '')
524
  raw = re.sub(r'^Bản tin AI viết lại:\s*', '', raw, flags=re.I)
525
  raw = re.sub(r'Nguồn tham khảo:.*$', '', raw, flags=re.I|re.S).strip()
 
530
  low=ln.lower()
531
  if low.startswith(('điểm chính','tiêu đề','sapo','nguồn tham khảo')): continue
532
  if len(ln)>=18: lines.append(ln)
533
+ if len(lines)<2:
534
  lines=[]
535
  for s in re.split(r'(?<=[\.\!\?])\s+', raw):
536
  s=_clean(s)
 
582
  draw.rounded_rectangle((48,834,260,880), radius=20, fill=(28,70,45))
583
  draw.text((66,842),f'Đoạn {idx+1}/{total}',fill=(235,235,235),font=font_small)
584
  y=940; maxw=W-96
585
+ for ln in _wrap_text_px(draw, segment, font_seg, maxw, 8):
 
586
  draw.text((48,y),ln,fill=(255,255,255),font=font_seg)
587
  y+=74
588
  if y>1500: break
 
594
  bg.save(out_path, quality=92)
595
 
596
 
597
+ def _estimate_audio_duration(path, fallback=4.0):
 
598
  try:
599
  pr=subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:no_key=1',path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
600
+ return max(1.5, float((pr.stdout or b'').decode().strip() or fallback))
601
  except Exception:
602
  return fallback
603
 
 
610
  body = {}
611
  voice = str(body.get('voice', 'nu')).strip().lower()
612
  emotion = str(body.get('emotion', 'neutral')).strip().lower()
613
+ speed = float(body.get('speed', 1.2) or 1.2)
614
  speed = max(0.85, min(1.35, speed))
615
 
616
  posts = base._load_ai_wall()
 
618
  if not post:
619
  return JSONResponse({'error': 'post not found'}, status_code=404)
620
 
621
+ segments = _summary_segments_from_post(post, max_segments=7)
622
  seg_hash = hashlib.md5(('|'.join(segments)+voice+emotion+str(speed)).encode('utf-8')).hexdigest()[:8]
623
  os.makedirs(base.SHORTS_DIR, exist_ok=True)
624
  suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_{seg_hash}_scenes_nosub"
 
641
  try:
642
  base._download_image(post.get('img'), post.get('title', 'AI news'), img)
643
  edge_voice = {
 
 
 
 
 
644
  'nam': 'vi-VN-NamMinhNeural',
645
  'male': 'vi-VN-NamMinhNeural',
646
  'nu': 'vi-VN-HoaiMyNeural',
647
  'female': 'vi-VN-HoaiMyNeural',
648
  'mien-nam': 'vi-VN-HoaiMyNeural',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
649
  }.get(voice, 'vi-VN-HoaiMyNeural')
650
  part_files=[]
651
  for idx, seg in enumerate(segments):
 
658
  try:
659
  subprocess.run(['python','-m','edge_tts','--voice',edge_voice,'--text',spoken,'--write-media',aud], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
660
  except Exception:
661
+ tld='com.vn' if voice in ('nu','female','mien-nam') else 'com'
662
  try:
663
  base.gTTS(spoken, lang='vi', tld=tld, slow=False).save(aud)
664
  except TypeError:
665
  base.gTTS(spoken, lang='vi', slow=False).save(aud)
666
  subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
667
+ dur=_estimate_audio_duration(aud_fast, fallback=4.0)+0.35
668
  subprocess.run(['ffmpeg','-y','-loop','1','-t',str(dur),'-i',frame,'-i',aud_fast,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k',part], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=150)
669
  part_files.append(part)
670
  concat=os.path.join(work,'concat.txt')
 
699
 
700
 
701
  app.router.routes = [r for r in app.router.routes if not (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set()))]
702
+
703
+ PATCH_INJECT = r'''
704
+ <style>
705
+ .ai-wall-patched{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}
706
+ .ai-wall-card{flex:0 0 250px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:8px}
707
+ .ai-wall-img{width:100%;aspect-ratio:16/9;background:#222;border-radius:8px;overflow:hidden;margin-bottom:6px}
708
+ .ai-wall-img img{width:100%;height:100%;object-fit:cover}
709
+ .ai-wall-title{font-size:12px;color:#5cb87a;font-weight:800;line-height:1.3;margin-bottom:4px}
710
+ .ai-wall-text{font-size:11px;color:#bbb;line-height:1.45;white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden}
711
+ .ai-wall-actions{display:flex;gap:6px;margin-top:8px}
712
+ .ai-wall-actions button,.ai-wall-actions select{flex:1;border:1px solid #333;background:#222;color:#ddd;border-radius:14px;padding:6px 8px;font-size:10px;min-width:0}
713
+ .ai-wall-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}
714
+ .ai-short-card{flex:0 0 145px}
715
+ .ai-short-video{width:100%;aspect-ratio:9/16;background:#000;border-radius:8px;overflow:hidden}
716
+ .ai-short-video video{width:100%;height:100%;object-fit:cover}
717
+ .ai-short-progress{position:fixed;inset:0;background:rgba(0,0,0,.78);z-index:99999;display:none;align-items:center;justify-content:center;padding:20px}
718
+ .ai-short-progress.active{display:flex}
719
+ .ai-short-box{max-width:420px;width:100%;background:#141414;border:2px solid #2d8659;border-radius:14px;padding:18px;color:#eee;box-shadow:0 0 30px rgba(45,134,89,.35)}
720
+ .ai-short-box h3{color:#5cb87a;margin-bottom:10px}
721
+ .ai-short-step{font-size:13px;line-height:1.55;color:#ccc}
722
+ .ai-short-spinner{width:34px;height:34px;border:4px solid #333;border-top-color:#5cb87a;border-radius:50%;animation:spin 1s linear infinite;margin:10px auto}
723
+ @keyframes spin{to{transform:rotate(360deg)}}
724
+ </style>
725
+ <div id="ai-short-progress" class="ai-short-progress"><div class="ai-short-box"><h3>🎬 Đang tạo Short AI</h3><div class="ai-short-spinner"></div><div class="ai-short-step" id="ai-short-step">Đang chuẩn bị...</div></div></div>
726
+ <script>
727
+ (function(){
728
+ function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
729
+ let patchedWall=[];let aiShorts=[];
730
+ function showProgress(msg){let box=document.getElementById('ai-short-progress');let st=document.getElementById('ai-short-step');if(st)st.innerHTML=msg;if(box)box.classList.add('active');}
731
+ function hideProgress(){document.getElementById('ai-short-progress')?.classList.remove('active');}
732
+ function updateAiLabels(){document.querySelectorAll('.ai-compose-title').forEach(e=>e.textContent='🤖 Tường AI: lọc từng bài theo chủ đề, tóm tắt nội dung bài');document.querySelectorAll('button').forEach(b=>{if((b.textContent||'').includes('AI viết lại'))b.textContent='🤖 Tóm tắt AI & đăng tường';});}
733
+ async function loadPatchedWall(){try{const r=await fetch('/api/ai_wall');const j=await r.json();patchedWall=j.posts||[];renderPatchedWall();updateAiLabels();}catch(e){}try{const r2=await fetch('/api/ai_shorts');const j2=await r2.json();aiShorts=j2.posts||[];renderAiShorts();}catch(e){}}
734
+ function renderAiShorts(){const home=document.getElementById('view-home');if(!home)return;document.getElementById('ai-shorts-patched')?.remove();if(!aiShorts.length)return;let wrap=document.createElement('div');wrap.id='ai-shorts-patched';wrap.className='ai-wall-patched';let h='<div class="slider-header"><span class="slider-label">🎬 Short AI</span><span class="slider-note">Video đã tạo</span></div><div class="slider-track">';aiShorts.slice(0,30).forEach((p,i)=>{h+=`<div class="ai-short-card" onclick="aiReadShortPatched(${i})"><div class="ai-short-video"><video src="${p.video}" muted playsinline preload="metadata"></video></div><div class="slider-title">${esc(p.title)}</div></div>`});h+='</div>';wrap.innerHTML=h;let wall=document.getElementById('ai-wall-patched');if(wall)wall.after(wrap);else home.prepend(wrap);}
735
+ function renderPatchedWall(){const home=document.getElementById('view-home');if(!home)return;document.getElementById('ai-wall-patched')?.remove();if(!patchedWall.length)return;let wrap=document.createElement('div');wrap.id='ai-wall-patched';wrap.className='ai-wall-patched';let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI</span><span class="slider-note">Mỗi nguồn = một bài tóm tắt</span></div><div class="slider-track">';patchedWall.slice(0,30).forEach((p,i)=>{h+=`<div class="ai-wall-card"><div class="ai-wall-img">${p.img?`<img src="${p.img}">`:''}</div><div class="ai-wall-title">${esc(p.title)}</div><div class="ai-wall-text">${esc(p.text)}</div><div class="ai-wall-actions"><button onclick="aiReadWallPatched(${i})">Xem</button><button class="primary" onclick="aiMakeShortPatched(${i})">Shorts</button></div></div>`});h+='</div>';wrap.innerHTML=h;let after=document.querySelector('.ai-compose');if(after)after.after(wrap);else home.prepend(wrap);}
736
+ window.aiReadShortPatched=function(i){const p=aiShorts[i];if(!p)return;showView('view-article');let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">Short AI</span><h1 class="article-title">${esc(p.title)}</h1><video class="article-img" src="${p.video}" controls playsinline autoplay></video><p class="article-p" style="white-space:pre-wrap">${esc(p.text||'')}</p><div class="article-actions"><button onclick="window.open('${p.video}','_blank')">⬇ Mở video</button>${p.url?`<button onclick="window.open('${p.url}','_blank')">🔗 Nguồn</button>`:''}</div></div>`;document.getElementById('view-article').innerHTML=h;window.scrollTo(0,0)};
737
+ window.aiReadWallPatched=function(i){const p=patchedWall[i];if(!p)return;showView('view-article');let sources='';if(p.sources&&p.sources.length){sources='<div class="article-summary"><b>Nguồn tham khảo:</b><br>'+p.sources.slice(0,5).map(s=>`• ${esc(s.title||s.url||'Nguồn')} ${s.url?`(${esc(new URL(s.url).hostname.replace('www.',''))})`:''}`).join('<br>')+'</div>'}let voiceBox=`<div class="article-actions"><select id="ai-short-voice"><option value="nu">Giọng nữ Việt</option><option value="nam">Giọng nam Việt</option><option value="mien-nam">Giọng miền Nam</option></select><select id="ai-short-emotion"><option value="neutral">Trung tính</option><option value="urgent">Tin nhanh</option><option value="warm">Ấm áp</option><option value="serious">Nghiêm túc</option><option value="energetic">Sôi nổi</option></select><button onclick="aiMakeShortPatched(${i})">🎬 Tạo video shorts</button></div>`;let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${esc(p.title)}</h1>${p.img?`<img class="article-img" src="${p.img}">`:''}${sources}<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>${p.video?`<video class="article-img" src="${p.video}" controls playsinline></video>`:''}<div class="article-actions">${p.url?`<button onclick="window.open('${p.url}','_blank')">🔗 Nguồn</button>`:''}</div>${voiceBox}</div>`;document.getElementById('view-article').innerHTML=h;window.scrollTo(0,0)};
738
+ window.aiMakeShortPatched=async function(i){const p=patchedWall[i];if(!p)return;let voice=document.getElementById('ai-short-voice')?.value||'nu';let emotion=document.getElementById('ai-short-emotion')?.value||'neutral';let voiceName={nu:'Giọng nữ Việt',nam:'Giọng nam Việt','mien-nam':'Giọng miền Nam'}[voice]||voice;let emotionName={neutral:'Trung tính',urgent:'Tin nhanh',warm:'Ấm áp',serious:'Nghiêm túc',energetic:'Sôi nổi'}[emotion]||emotion;let ok=confirm(`Quy trình tạo short AI:\n\n1) Dùng ảnh đại diện của bài hoặc tạo ảnh minh họa nếu thiếu.\n2) Rút gọn nội dung tóm tắt thành kịch bản đọc ngắn.\n3) Tự ngắt câu theo dấu câu và xuống dòng hợp lý.\n4) Tạo giọng đọc tiếng Việt: ${voiceName}.\n5) Áp dụng cảm xúc/kịch bản: ${emotionName}.\n6) Tăng tốc giọng đọc 1.2 lần.\n7) Mỗi đoạn tóm tắt sẽ là một cảnh riêng theo thời lượng đọc.\n8) Không thêm phụ đề; video chỉ có chữ cảnh và giọng đọc.\n9) Sau khi xong, video xuất hiện ở slide "Short AI".\n\nQuá trình có thể mất 1-3 phút. Bạn muốn bắt đầu?`);if(!ok)return;try{showProgress(`Bước 1/5: Chuẩn bị ảnh và căn chữ full width...<br>Bước 2/5: Tạo kịch bản, tự ngắt câu/xuống dòng...<br>Bước 3/5: Tạo giọng đọc ${voiceName}, cảm xúc ${emotionName}.<br>Bước 4/5: Tăng tốc 1.2x và ghép từng cảnh riêng, không phụ đề.<br>Bước 5/5: Lưu vào slide "Short AI".`);const r=await fetch('/api/ai/short/'+p.id,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice,emotion,speed:1.2})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');p.video=j.video;hideProgress();alert('Hoàn tất: video shorts đã được tạo và thêm vào slide "Short AI".');aiReadWallPatched(i);loadPatchedWall();}catch(e){hideProgress();alert('Không tạo được shorts: '+e.message)}};
739
+ window.createTopicPost=function(){let inp=document.getElementById('ai-topic-input');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&(j.posts||j.post)){let arr=j.posts||[j.post];patchedWall=arr.concat(patchedWall.filter(x=>!arr.find(y=>y.id===x.id)));renderPatchedWall();if(inp)inp.value='';alert(`Đã lọc và tóm tắt ${arr.length} bài viết theo chủ đề lên Tường AI`);}else alert(j.error||'Lỗi tạo bài')}).catch(e=>alert(e.message||'Lỗi tạo bài'));};
740
+ window.createUrlPost=function(){let inp=document.getElementById('ai-url-input');let url=(inp&&inp.value||'').trim();if(!url)return alert('Dán URL trước');if(!/^https?:\/\//i.test(url))return alert('URL cần bắt đầu bằng http:// hoặc https://');fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&j.post){patchedWall=[j.post].concat(patchedWall.filter(x=>x.id!==j.post.id));renderPatchedWall();if(inp)inp.value='';alert('Đã tóm tắt URL và đăng lên Tường AI');}else alert(j.error||'Lỗi URL')}).catch(e=>alert(e.message||'Lỗi URL'));};
741
+ window.rewriteCurrentArticle=function(){if(!window._currentArticle&&typeof _currentArticle!=='undefined')window._currentArticle=_currentArticle;let cur=window._currentArticle||_currentArticle;if(!cur)return;let btn=document.querySelector('.article-actions button.primary');if(btn){btn.textContent='Đang tóm tắt...';btn.disabled=true}fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:cur.url})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&j.post){document.getElementById('rewrite-result').innerHTML=`<div class="rewrite-box"><div class="rewrite-title">Đã tóm tắt và đăng Tường AI</div><div class="rewrite-text">${esc(j.post.text||'')}</div></div>`;patchedWall=[j.post].concat(patchedWall.filter(x=>x.id!==j.post.id));renderPatchedWall();alert('Đã tóm tắt lên Tường AI');}else alert(j.error||'Không tóm tắt được')}).catch(e=>alert(e.message||'Lỗi tóm tắt')).finally(()=>{if(btn){btn.textContent='🤖 Tóm tắt AI & đăng tường';btn.disabled=false}})};
742
+ setTimeout(loadPatchedWall,1500);setInterval(updateAiLabels,2000);
743
+ })();
744
+ </script>
745
+ '''
746
+
747
+ @app.get('/')
748
+ async def index_patched():
749
+ with open('/app/static/index.html','r',encoding='utf-8') as f:
750
+ html=f.read()
751
+ return HTMLResponse(html.replace('</body>', PATCH_INJECT+'\n</body>'))
ai_runtime_final6.py CHANGED
@@ -1,849 +1,394 @@
1
- """Final6: robust topic synthesis, stable shorts, hot topic hashtags.
2
-
3
- This runtime intentionally overrides only the topic/shorts/root endpoints from the restored app.
4
- """
5
- import re, time, json, os, threading, html as html_lib
6
- from urllib.parse import quote, urlparse, parse_qs, unquote
7
- import requests
8
- from bs4 import BeautifulSoup
9
  import ai_runtime_final5 as f5
10
- from ai_runtime_final5 import app, rt, HTMLResponse, JSONResponse, Request, Query
 
11
 
12
- _PATCH={('/api/topic_post','POST'),('/api/shorts','GET'),('/api/hot_topics','GET'),('/api/topic_sources','GET'),('/','GET')}
13
- 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)]
 
 
 
 
 
 
14
 
15
- _TOPIC_CACHE={}
16
- _HOT_CACHE={"t":0,"d":[]}
17
- _SHORTS_CACHE_FINAL6={"t":0,"d":[]}
18
- _TRANSLATE_CACHE_PATH="/data/title_vi_cache.json" if os.path.isdir('/data') else "/app/data/title_vi_cache.json"
19
- _translate_lock=threading.Lock()
20
- YOUTUBE_HANDLES=["baodantri7941","baosuckhoedoisongboyte"]
21
- UA={"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,en;q=0.8"}
22
- STOP_WORDS=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'.split())
23
- TRUSTED_SITES=['vnexpress.net','dantri.com.vn','vietnamnet.vn','tuoitre.vn','thanhnien.vn','laodong.vn','vov.vn','vtv.vn','genk.vn','cafef.vn','thethaovanhoa.vn']
24
 
25
- def clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or ""))).strip()
26
  def _domain(u):
27
- try:return urlparse(u or '').netloc.replace('www.','')
28
- except Exception:return ''
29
-
30
- def _load_title_cache():
31
- try:
32
- if os.path.exists(_TRANSLATE_CACHE_PATH):
33
- with open(_TRANSLATE_CACHE_PATH,'r',encoding='utf-8') as f:return json.load(f)
34
- except Exception:pass
35
- return {}
36
- def _save_title_cache(db):
37
- try:
38
- os.makedirs(os.path.dirname(_TRANSLATE_CACHE_PATH),exist_ok=True);tmp=_TRANSLATE_CACHE_PATH+'.tmp'
39
- with open(tmp,'w',encoding='utf-8') as f:json.dump(db,f,ensure_ascii=False)
40
- os.replace(tmp,_TRANSLATE_CACHE_PATH)
41
- except Exception:pass
42
-
43
- def _looks_vietnamese(s):
44
- s=s or ''
45
- if re.search(r'[àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ]',s,re.I):return True
46
- low=' '+s.lower()+' '
47
- return any(w in low for w in [' và ',' của ',' người ',' tại ',' trong ',' với ',' không ',' được ',' công an ',' bệnh viện ',' học sinh ',' tài xế ',' bóng đá ',' tin tức ',' sức khỏe '])
48
- def _translate_title_vi(title):
49
- title=clean(title)
50
- if not title or _looks_vietnamese(title):return title
51
- with _translate_lock:
52
- db=_load_title_cache()
53
- if title in db:return db[title]
54
- vi=title
55
- try:
56
- r=requests.get('https://translate.googleapis.com/translate_a/single',params={'client':'gtx','sl':'auto','tl':'vi','dt':'t','q':title},headers=UA,timeout=8)
57
- if r.status_code==200:
58
- data=r.json();vi=''.join(part[0] for part in data[0] if part and part[0]).strip() or title
59
- except Exception:pass
60
- vi=clean(vi)
61
- with _translate_lock:
62
- db=_load_title_cache();db[title]=vi;_save_title_cache(db)
63
- return vi
64
 
65
- # ===== Hot topics / hashtags =====
66
- def _keywords_from_title(title):
67
- title=clean(re.sub(r'\s+-\s+.*$','',title))
68
- words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',title) if len(w)>2 and w.lower() not in STOP_WORDS]
69
- phrases=[]
70
- for n in (4,3,2):
71
- for i in range(0,max(0,len(words)-n+1)):
72
- ph=' '.join(words[i:i+n]).strip()
73
- if len(ph)>=8:phrases.append(ph)
74
- if words:phrases.append(' '.join(words[:5]))
75
- return phrases[:4]
76
-
77
- def _hot_topics():
78
- now=time.time()
79
- if _HOT_CACHE['d'] and now-_HOT_CACHE['t']<900:return _HOT_CACHE['d']
80
- topics=[];seen=set()
81
- feeds=[
82
- 'https://news.google.com/rss?hl=vi&gl=VN&ceid=VN:vi',
83
- 'https://news.google.com/rss/headlines/section/topic/NATION?hl=vi&gl=VN&ceid=VN:vi',
84
- 'https://news.google.com/rss/headlines/section/topic/BUSINESS?hl=vi&gl=VN&ceid=VN:vi',
85
- 'https://news.google.com/rss/headlines/section/topic/SPORTS?hl=vi&gl=VN&ceid=VN:vi',
86
- 'https://news.google.com/rss/headlines/section/topic/TECHNOLOGY?hl=vi&gl=VN&ceid=VN:vi'
87
- ]
88
- for feed in feeds:
89
- try:
90
- r=requests.get(feed,headers=UA,timeout=10);r.encoding='utf-8'
91
- soup=BeautifulSoup(r.text,'xml')
92
- for it in soup.find_all('item')[:15]:
93
- title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
94
- for kw in _keywords_from_title(title):
95
- key=kw.lower()
96
- if key not in seen and len(kw)<=60:
97
- seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
98
- if len(topics)>=24:break
99
- if len(topics)>=24:break
100
- except Exception:pass
101
- if len(topics)>=24:break
102
- for kw in ['AI trong giáo dục','World Cup 2026','kinh tế Việt Nam','biến đổi khí hậu','giá vàng','bóng đá Việt Nam','an ninh mạng','xe điện','sức khỏe tinh thần','thị trường chứng khoán']:
103
- if kw.lower() not in seen:topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
104
- _HOT_CACHE.update({'t':now,'d':topics[:24]})
105
- return _HOT_CACHE['d']
106
- @app.get('/api/hot_topics')
107
- def api_hot_topics():return JSONResponse({'topics':_hot_topics()})
108
-
109
- # ===== Topic web research =====
110
- def _unwrap_ddg_href(href):
111
- if not href:return ''
112
- if href.startswith('//duckduckgo.com/l/?') or 'duckduckgo.com/l/?' in href:
113
- qs=parse_qs(urlparse('https:'+href if href.startswith('//') else href).query)
114
- return unquote(qs.get('uddg',[''])[0])
115
- return href
116
-
117
- def _ddg_search(query, limit=10):
118
- items=[];seen=set()
119
  try:
120
- url='https://html.duckduckgo.com/html/?q='+quote(query)
121
- r=requests.get(url,headers=UA,timeout=14);r.encoding='utf-8'
122
- soup=BeautifulSoup(r.text,'lxml')
123
- for res in soup.select('.result'):
124
- a=res.select_one('.result__title a') or res.find('a',href=True)
125
- if not a:continue
126
- link=_unwrap_ddg_href(a.get('href',''));title=clean(a.get_text(' ',strip=True));snippet=clean((res.select_one('.result__snippet') or res).get_text(' ',strip=True))
127
- if not link.startswith('http') or link in seen:continue
128
- if any(bad in link for bad in ['duckduckgo.com','youtube.com','facebook.com','tiktok.com','twitter.com','x.com']):continue
129
- seen.add(link);items.append({'title':title,'url':link,'source':_domain(link),'snippet':snippet})
130
- if len(items)>=limit:break
131
- except Exception:pass
132
- return items
133
 
134
- def _google_news_items(topic, limit=8):
135
- items=[];seen=set()
136
  try:
137
- rss='https://news.google.com/rss/search?q='+quote(topic)+'&hl=vi&gl=VN&ceid=VN:vi'
138
- r=requests.get(rss,headers=UA,timeout=12);r.encoding='utf-8'
139
- soup=BeautifulSoup(r.text,'xml')
140
- for it in soup.find_all('item')[:limit*2]:
141
- title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
142
- link=clean(it.find('link').get_text(strip=True) if it.find('link') else '')
143
- src=clean(it.find('source').get_text(' ',strip=True) if it.find('source') else _domain(link))
144
- if title and link and link not in seen:
145
- seen.add(link);items.append({'title':title,'url':link,'source':src,'snippet':''})
146
- if len(items)>=limit:break
147
- except Exception:pass
148
- return items
149
-
150
- def _candidate_urls(topic):
151
- seen=set();items=[]
152
- queries=[topic+' tin tức Việt Nam', topic+' phân tích bối cảnh', topic+' site:vnexpress.net OR site:dantri.com.vn OR site:vietnamnet.vn']
153
- for q in queries:
154
- for it in _ddg_search(q,8):
155
- if it['url'] not in seen:
156
- seen.add(it['url']);items.append(it)
157
- if len(items)>=12:break
158
- for site in TRUSTED_SITES[:8]:
159
- for it in _ddg_search(f'{topic} site:{site}',3):
160
- if it['url'] not in seen:
161
- seen.add(it['url']);items.append(it)
162
- for it in _google_news_items(topic,8):
163
- if it['url'] not in seen:
164
- seen.add(it['url']);items.append(it)
165
- return items[:24]
166
 
167
- def _extract_article_text_bs(url, max_chars=9000):
168
  try:
169
- r=requests.get(url,headers=UA,timeout=16,allow_redirects=True)
170
- if r.status_code>=400:return ''
171
- r.encoding='utf-8';soup=BeautifulSoup(r.text,'lxml')
172
- for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe','svg']):tag.decompose()
173
- candidates=[]
174
- for sel in ['article','main','.article-content','.detail-content','.singular-content','.fck_detail','.content-detail','.entry-content','.story-body','.knc-content']:
175
- el=soup.select_one(sel)
176
- if el:candidates.append(el)
177
- if not candidates:candidates=[soup.body or soup]
178
- best=max(candidates,key=lambda el:len(el.find_all('p')) if el else 0)
179
- ps=[]
180
- for el in best.find_all(['p','h2','h3'],recursive=True):
181
- t=clean(el.get_text(' ',strip=True))
182
- if len(t)>45 and not any(x in t.lower() for x in ['đăng ký nhận tin','theo dõi chúng tôi','chuyên mục','xem thêm','tin liên quan','advertisement']):ps.append(t)
183
- if sum(len(x) for x in ps)>max_chars:break
184
- return '\n'.join(ps)[:max_chars]
185
- except Exception:return ''
186
 
187
- def _jina_read_text(url, max_chars=9000):
188
  try:
189
- ju='https://r.jina.ai/http://'+url
190
- r=requests.get(ju,headers=UA,timeout=28);r.encoding='utf-8'
191
- if r.status_code!=200 or not r.text:return ''
192
- lines=[]
193
- for ln in r.text.splitlines():
194
- t=clean(ln)
195
- if not t or t.startswith(('Title:','URL Source:','Published Time:','Markdown Content:','Image:','Description:')):continue
196
- if len(t)>45:lines.append(t)
197
- if sum(len(x) for x in lines)>max_chars:break
198
- return '\n'.join(lines)[:max_chars]
199
- except Exception:return ''
200
-
201
- def _scrape_article_text(url, max_chars=9000):
202
- text=_extract_article_text_bs(url,max_chars)
203
- if len(text)<350:text=_jina_read_text(url,max_chars)
204
- return text
205
 
206
- def _score_relevance(topic, title, text, snippet=''):
207
- keys=[w.lower() for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',topic) if len(w)>2 and w.lower() not in STOP_WORDS]
208
- hay=(title+' '+snippet+' '+text[:2500]).lower()
209
- if not keys:return 1
210
- return sum(1 for k in keys if k in hay)
 
 
 
 
 
 
 
 
211
 
212
- def _web_research_context(topic):
213
- now=time.time();key=topic.lower().strip()
214
- if key in _TOPIC_CACHE and now-_TOPIC_CACHE[key]['t']<900:return _TOPIC_CACHE[key]['d']
215
- items=_candidate_urls(topic)
216
- crawled=[]
217
- for it in items:
218
- text=_scrape_article_text(it['url'],9000)
219
- rel=_score_relevance(topic,it.get('title',''),text,it.get('snippet',''))
220
- if text and len(text)>300 and rel>0:
221
- crawled.append({**it,'text':text,'rel':rel})
222
- elif it.get('snippet') and rel>0:
223
- crawled.append({**it,'text':it['snippet'],'rel':rel,'snippet_only':True})
224
- crawled=sorted(crawled,key=lambda x:(x.get('rel',0),len(x.get('text',''))),reverse=True)[:6]
225
- blocks=[];sources=[]
226
- for it in crawled:
227
- label='ĐOẠN MÔ TẢ TỪ KẾT QUẢ TÌM KIẾM' if it.get('snippet_only') else 'NỘI DUNG BÀI VIẾT ĐÃ CRAWL'
228
- blocks.append(f"NGUỒN: {it['source']}\nTIÊU ĐỀ: {it['title']}\n{label}:\n{it['text'][:8500]}")
229
- sources.append({'title':it['title'],'url':it['url'],'via':it['source']})
230
- data={'context':'\n\n---\n\n'.join(blocks),'sources':sources[:8],'count':len(blocks)}
231
- _TOPIC_CACHE[key]={'t':now,'d':data}
232
- return data
233
 
234
  def _topic_image(topic):
235
- try:return f5.base.pollinations_image_url(topic)
236
- except Exception:return 'https://image.pollinations.ai/prompt/'+quote('Vietnamese editorial illustration, '+topic)+'?width=1024&height=576&nologo=true'
237
-
238
- @app.get('/api/topic_sources')
239
- def api_topic_sources(topic:str=Query(...)):
240
- data=_web_research_context(clean(topic))
241
- return JSONResponse({'count':data.get('count',0),'sources':data.get('sources',[]),'has_context':bool(data.get('context'))})
242
 
243
- @app.post('/api/topic_post')
244
- async def topic_post_synthesis(request:Request):
245
- body=await request.json();topic=clean(body.get('topic',''))
246
- if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
247
- img=_topic_image(topic);research=_web_research_context(topic);context=research.get('context','');sources=research.get('sources',[])
248
- if not context or research.get('count',0)==0:
249
- 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)
250
- prompt=f"""Bạn biên tập viên VNEWS. Người dùng chọn chủ đề: "{topic}".
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
 
252
- Dưới đây NỘI DUNG các bài viết/đoạn mô tả đã crawl từ internet. Hãy đọc hiểu và TỔNG HỢP thành MỘT BÀI VIẾT HOÀN CHỈNH. Tuyệt đối không bê nguyên văn, không xếp danh sách tiêu đề thành bài viết, không viết kiểu trả lời chat.
 
 
253
 
254
- DỮ LIỆU CRAWL:
255
- {context[:30000]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
 
257
- Yêu cầu bắt buộc:
258
- - Viết bằng tiếng Việt, văn phong báo điện tử/tạp chí.
259
- - Tiêu đề mới, rõ, hấp dẫn.
260
- - Sapo 2-3 câu nêu vấn đề chính.
261
- - 5-8 đoạn nội dung tổng hợp: bối cảnh, diễn biến/khái niệm, phân tích, tác động, điểm cần lưu ý.
262
- - Dùng thông tin từ nội dung đã crawl để tổng hợp ý; nếu chỉ có mô tả tìm kiếm thì viết thận trọng.
263
- - KHÔNG liệt kê các tiêu đề nguồn. KHÔNG mở đầu bằng "Dưới đây là" hay "Tôi sẽ".
264
- - Cuối bài thêm mục "Nguồn tham khảo" gồm tên nguồn ngắn gọn.
265
- """
266
- text=await f5.base.qwen_generate(prompt,image_url=img,max_tokens=2800)
267
- if not text or len(text)<500:
268
- parts=[]
269
- for block in context.split('---'):
270
- body=block.split('NỘI DUNG BÀI VIẾT ĐÃ CRAWL:')[-1].split('ĐOẠN MÔ TẢ TỪ KẾT QUẢ TÌM KIẾM:')[-1].strip()
271
- if len(body)>120:parts.append(body)
272
- joined='\n\n'.join(parts)[:8500]
273
- text=(f"{topic}: những điểm chính cần biết\n\n{topic} đang thu hút sự chú ý vì liên quan đến nhiều khía cạnh thực tế. Tổng hợp từ các nội dung thu thập được, có thể nhìn vấn đề qua bối cảnh, tác động và những điểm cần theo dõi.\n\n"+joined+"\n\nNguồn tham khảo: "+', '.join(sorted({s.get('via','') for s in sources if s.get('via')})))
274
- post=f5.base.make_post(topic,text,img,'','topic_web_synthesis',sources=[s for s in sources if s.get('url')]);post['images']=[img]
275
- posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts)
276
- return JSONResponse({'post':post})
277
 
278
- # ===== Stable newest Dantri/SKDS Shorts =====
279
- def _yt_ytdlp(handle,count=30):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  try:
281
- import yt_dlp
282
- urls=[f'https://www.youtube.com/@{handle}/shorts',f'https://www.youtube.com/@{handle}/videos']
283
- out=[];seen=set();opts={'quiet':True,'extract_flat':True,'skip_download':True,'playlistend':count,'ignoreerrors':True,'no_warnings':True,'extractor_args':{'youtube':{'player_client':['web']}}}
284
- for url in urls:
285
- with yt_dlp.YoutubeDL(opts) as ydl:info=ydl.extract_info(url,download=False)
286
- for e in (info or {}).get('entries') or []:
287
- vid=e.get('id') or ''
288
- if not re.match(r'^[A-Za-z0-9_-]{11}$',vid) or vid in seen:continue
289
- title=e.get('title') or 'YouTube Short'
290
- if url.endswith('/videos') and '#short' not in title.lower() and 'shorts' not in title.lower():continue
291
- seen.add(vid);out.append({'title':title,'link':'https://www.youtube.com/watch?v='+vid,'img':'https://i.ytimg.com/vi/'+vid+'/hqdefault.jpg','source':'yt','id':vid,'channel':handle})
292
- if len(out)>=count:break
293
- if len(out)>=count:break
294
- return out
295
- except Exception:return []
296
- def _yt_html(handle,count=30):
297
- out=[];seen=set()
298
- for suffix in ['shorts','videos']:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
  try:
300
- r=requests.get(f'https://www.youtube.com/@{handle}/{suffix}',headers=UA,timeout=15);html=r.text
301
- for m in re.finditer(r'"videoId":"([A-Za-z0-9_-]{11})"',html):
302
- vid=m.group(1)
303
- if vid in seen:continue
304
- snip=html[max(0,m.start()-1200):m.start()+2200];title='YouTube Short'
305
- mt=re.search(r'"title":\{"runs":\[\{"text":"([^"]+)"',snip) or re.search(r'"accessibilityText":"([^"]+)"',snip)
306
- if mt:title=clean(mt.group(1).replace('\\n',' '))
307
- if suffix=='videos' and '#short' not in title.lower() and 'shorts' not in title.lower():continue
308
- seen.add(vid);out.append({'title':title,'link':'https://www.youtube.com/watch?v='+vid,'img':'https://i.ytimg.com/vi/'+vid+'/hqdefault.jpg','source':'yt','id':vid,'channel':handle})
309
- if len(out)>=count:break
310
- except Exception:pass
311
- if len(out)>=count:break
312
- return out[:count]
313
- def _fallback_shorts():
314
- try:return f5._fallback_shorts()
315
- except Exception:return []
316
- @app.get('/api/shorts')
317
- def api_shorts_final6(refresh:int=Query(default=0)):
318
- now=time.time()
319
- if not refresh and _SHORTS_CACHE_FINAL6['d'] and now-_SHORTS_CACHE_FINAL6['t']<600:return JSONResponse(_SHORTS_CACHE_FINAL6['d'])
320
- raw=[]
321
- for h in YOUTUBE_HANDLES:raw.extend(_yt_ytdlp(h,30) or _yt_html(h,30))
322
- raw.extend(_fallback_shorts())
323
- seen=set();out=[]
324
- for v in raw:
325
- vid=v.get('id') or ''
326
- if not vid:
327
- m=re.search(r'(?:v=|shorts/|youtu\.be/)([A-Za-z0-9_-]{11})',v.get('link',''));vid=m.group(1) if m else ''
328
- title=_translate_title_vi(v.get('title') or 'YouTube Short');key=vid or re.sub(r'\W+','',title.lower())[:80]
329
- if not key or key in seen:continue
330
- seen.add(key);item=dict(v);item['id']=vid;item['title']=title
331
- if vid:item['link']='https://www.youtube.com/watch?v='+vid;item['img']='https://i.ytimg.com/vi/'+vid+'/hqdefault.jpg'
332
- item['source']='yt';out.append(item)
333
- if len(out)>=40:break
334
- _SHORTS_CACHE_FINAL6.update({'t':now,'d':out})
335
- return JSONResponse(out)
336
 
337
- FINAL6_INJECT=r'''
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
338
  <style>
339
- #ai-topic-input-final3,.topic-final3,#ai-topic-input-final4,.topic-final4{display:none!important}.topic-final5{display:flex!important}.ai-wall-topic-live{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.hot-topic-row{display:flex;gap:6px;overflow-x:auto;padding:4px 0}.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}.hot-chip:active{transform:scale(.96)}.topic-source-note{font-size:10px;color:#777;margin-top:4px;line-height:1.3}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
  </style>
341
  <script>
342
  (function(){
343
  function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
344
- let liveTopicWall=[];
345
- async function ensureHotTopics(){let inp=document.getElementById('ai-topic-input-final5');if(!inp||document.getElementById('hot-topic-row-final6'))return;let row=document.createElement('div');row.id='hot-topic-row-final6';row.className='hot-topic-row';row.innerHTML='<span style="color:#777;font-size:11px;padding:5px 0">Đang tải từ khóa nóng...</span>';inp.insertAdjacentElement('afterend',row);let note=document.createElement('div');note.id='topic-source-note';note.className='topic-source-note';note.textContent='AI sẽ tìm nhiều nguồn, crawl nội dung bài viết rồi tổng hợp thành bài mới.';row.insertAdjacentElement('afterend',note);let j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));let topics=j.topics||[];row.innerHTML=topics.slice(0,18).map(t=>`<button class="hot-chip" onclick="document.getElementById('ai-topic-input-final5').value='${esc(t.topic).replace(/'/g,'\\\'')}';document.getElementById('ai-topic-input-final5').focus();">${esc(t.label)}</button>`).join('')||'';}
346
- async function ensureNewsShortsHome(){if(!document.getElementById('view-home')?.classList.contains('active'))return;let labels=[...document.querySelectorAll('.slider-wrap .slider-label')];let wraps=labels.filter(l=>/shorts|short /i.test(l.textContent||'')&&!/short ai/i.test(l.textContent||'')).map(l=>l.closest('.slider-wrap')).filter(Boolean);wraps.forEach((w,i)=>{if(i>0)w.remove();});let w=wraps[0];if(w){let seen=new Set();[...w.querySelectorAll('.slider-item')].forEach(it=>{let img=it.querySelector('img')?.src||'';let tt=(it.querySelector('.slider-title')?.textContent||'').trim().toLowerCase();let k=img||tt;if(k&&seen.has(k))it.remove();else if(k)seen.add(k);});if(w.querySelectorAll('.slider-item').length>=6)return;w.remove();}let sh=await fetch('/api/shorts?refresh=1').then(r=>r.json()).catch(()=>[]);if(!sh.length)return;let wrap=document.createElement('div');wrap.className='slider-wrap';wrap.id='shorts-final6-stable';let h='<div class="slider-header"><span class="slider-label">📱 Shorts Dân trí & SKĐS</span><span class="slider-note">Mới nhất</span></div><div class="slider-track">';sh.slice(0,30).forEach((a,i)=>{h+=`<div class="slider-item shorts-item" onclick="openTikTok('shorts',${i})"><div class="slider-thumb shorts-thumb">${a.img?`<img src="${esc(a.img)}">`:''}<div class="card-play">▶</div></div><div class="slider-title">${esc(a.title)}</div></div>`});h+='</div>';wrap.innerHTML=h;let comp=document.querySelector('.ai-compose')||document.getElementById('view-home').firstChild;if(comp)comp.after(wrap);else document.getElementById('view-home').prepend(wrap);}
347
- function renderLiveTopicWall(){let home=document.getElementById('view-home');if(!home||!liveTopicWall.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">';liveTopicWall.slice(0,20).forEach((p,i)=>{h+=`<div class="wall-item"><div class="wall-thumb">${p.img?`<img src="${esc(p.img)}">`:''}</div><div class="wall-title">${esc(p.title)}</div><div class="wall-text">${esc(p.text)}</div><div class="wall-actions"><button class="primary" onclick="readLiveTopicWall(${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);}
348
- window.readLiveTopicWall=function(i){let p=liveTopicWall[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="${esc(u)}" loading="lazy">`).join('')+'</div>':(p.img?`<img class="article-img" src="${esc(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">${esc(p.title)}</h1>${gal}<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</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)};
349
- 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');liveTopicWall.unshift(j.post);if(inp)inp.value='';renderLiveTopicWall();readLiveTopicWall(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'}}};
350
- 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';}ensureHotTopics();ensureNewsShortsHome();},1200);setTimeout(()=>{ensureHotTopics();ensureNewsShortsHome();},1200);
351
  })();
352
  </script>
353
  '''
354
 
355
- @app.get('/')
356
- async def index_final6():
357
- html=f5.f4.f3.f2.f1._load_index_html()
358
- body=getattr(rt.old,'PATCH_INJECT','')+f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT+FINAL6_INJECT
359
- return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
360
-
361
-
362
- # ===== FINAL6B: Vietnam hot hashtags + reliable VN RSS/source retrieval =====
363
- VN_RSS_FEEDS = [
364
- ('VnExpress Thời sự','https://vnexpress.net/rss/thoi-su.rss'),
365
- ('VnExpress Thế giới','https://vnexpress.net/rss/the-gioi.rss'),
366
- ('VnExpress Kinh doanh','https://vnexpress.net/rss/kinh-doanh.rss'),
367
- ('VnExpress Công nghệ','https://vnexpress.net/rss/so-hoa.rss'),
368
- ('VnExpress Thể thao','https://vnexpress.net/rss/the-thao.rss'),
369
- ('VnExpress Giải trí','https://vnexpress.net/rss/giai-tri.rss'),
370
- ('VnExpress Sức khỏe','https://vnexpress.net/rss/suc-khoe.rss'),
371
- ('VnExpress Giáo dục','https://vnexpress.net/rss/giao-duc.rss'),
372
- ('Dân trí Xã hội','https://dantri.com.vn/rss/xa-hoi.rss'),
373
- ('Dân trí Thế giới','https://dantri.com.vn/rss/the-gioi.rss'),
374
- ('Dân trí Kinh doanh','https://dantri.com.vn/rss/kinh-doanh.rss'),
375
- ('Dân trí Sức khỏe','https://dantri.com.vn/rss/suc-khoe.rss'),
376
- ('Dân trí Thể thao','https://dantri.com.vn/rss/the-thao.rss'),
377
- ('Dân trí Công nghệ','https://dantri.com.vn/rss/suc-manh-so.rss'),
378
- ('Vietnamnet Thời sự','https://vietnamnet.vn/thoi-su.rss'),
379
- ('Vietnamnet Kinh doanh','https://vietnamnet.vn/kinh-doanh.rss'),
380
- ('Vietnamnet Công nghệ','https://vietnamnet.vn/cong-nghe.rss'),
381
- ('Vietnamnet Thể thao','https://vietnamnet.vn/the-thao.rss'),
382
- ]
383
-
384
- def _fetch_rss_items(feed_name, feed_url, max_items=15):
385
- items=[]
386
- try:
387
- r=requests.get(feed_url,headers=UA,timeout=10);r.encoding='utf-8'
388
- soup=BeautifulSoup(r.text,'xml')
389
- for it in soup.find_all('item')[:max_items]:
390
- title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
391
- link=clean(it.find('link').get_text(strip=True) if it.find('link') else '')
392
- desc=it.find('description').get_text(' ',strip=True) if it.find('description') else ''
393
- desc_txt=clean(BeautifulSoup(desc,'lxml').get_text(' ',strip=True))
394
- if title and link:
395
- items.append({'title':title,'url':link,'source':feed_name,'snippet':desc_txt})
396
- except Exception:pass
397
- return items
398
-
399
- def _vn_rss_pool():
400
- now=time.time();key='vn_rss_pool'
401
- if key in _TOPIC_CACHE and now-_TOPIC_CACHE[key]['t']<600:return _TOPIC_CACHE[key]['d']
402
- pool=[];seen=set()
403
- for name,url in VN_RSS_FEEDS:
404
- for it in _fetch_rss_items(name,url,12):
405
- if it['url'] not in seen:
406
- seen.add(it['url']);pool.append(it)
407
- _TOPIC_CACHE[key]={'t':now,'d':pool}
408
- return pool
409
-
410
- def _topic_tokens(topic):
411
- toks=[w.lower() for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',topic or '') if len(w)>1]
412
- return [t for t in toks if t not in STOP_WORDS]
413
-
414
- def _score_topic_item(topic,item):
415
- toks=_topic_tokens(topic)
416
- hay=(item.get('title','')+' '+item.get('snippet','')+' '+item.get('source','')).lower()
417
- if not toks:return 0
418
- score=0
419
- for t in toks:
420
- if t in hay:score+=2 if len(t)>3 else 1
421
- phrase=topic.lower().strip()
422
- if phrase and phrase in hay:score+=8
423
- return score
424
-
425
- # Override: hashtags must be Việt Nam-focused, using VN news RSS directly.
426
- def _hot_topics():
427
- now=time.time()
428
- if _HOT_CACHE['d'] and now-_HOT_CACHE['t']<600:return _HOT_CACHE['d']
429
- pool=_vn_rss_pool()
430
- freq={};display={}
431
- for it in pool[:180]:
432
- title=re.sub(r'\s+-\s+.*$','',it.get('title',''))
433
- # Extract compact Vietnamese hot phrases from current VN headlines.
434
- kws=[]
435
- # quoted/name phrases first
436
- 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):
437
- if len(m)>=6:kws.append(m)
438
- kws += _keywords_from_title(title)
439
- for kw in kws[:5]:
440
- kw=clean(kw)
441
- words=[w for w in kw.split() if w.lower() not in STOP_WORDS]
442
- if len(words)<2:continue
443
- kw=' '.join(words[:5])
444
- if len(kw)<6 or len(kw)>55:continue
445
- key=kw.lower()
446
- freq[key]=freq.get(key,0)+1
447
- display[key]=kw
448
- ranked=sorted(freq.items(),key=lambda x:x[1],reverse=True)
449
- topics=[];seen=set()
450
- for key,_ in ranked:
451
- kw=display[key]
452
- if key in seen:continue
453
- seen.add(key)
454
- label='#'+re.sub(r'\s+','',kw.title())
455
- topics.append({'label':label,'topic':kw})
456
- if len(topics)>=24:break
457
- # VN fallback, not generic global.
458
- for kw in ['Giá vàng trong nước','Bão và mưa lũ','Bóng đá Việt Nam','Kinh tế Việt Nam','AI tại Việt Nam','Giá xăng dầu','Thị trường chứng khoán Việt Nam','Tuyển Việt Nam','Sức khỏe cộng đồng','An ninh mạng Việt Nam']:
459
- if kw.lower() not in seen:topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
460
- _HOT_CACHE.update({'t':now,'d':topics[:24]})
461
- return _HOT_CACHE['d']
462
-
463
- def _candidate_urls(topic):
464
- seen=set();items=[]
465
- # 1) VN RSS pool relevance is most reliable and has direct URLs.
466
- scored=[]
467
- for it in _vn_rss_pool():
468
- sc=_score_topic_item(topic,it)
469
- if sc>0:scored.append((sc,it))
470
- for sc,it in sorted(scored,key=lambda x:x[0],reverse=True)[:12]:
471
- if it['url'] not in seen:
472
- seen.add(it['url']);items.append(it)
473
- # 2) Search trusted web if RSS not enough.
474
- queries=[topic+' Việt Nam tin tức',topic+' phân tích Việt Nam',topic+' mới nhất']
475
- for q in queries:
476
- for it in _ddg_search(q,8):
477
- if it['url'] not in seen:
478
- seen.add(it['url']);items.append(it)
479
- if len(items)>=14:break
480
- # 3) Google News as supplemental titles/direct links.
481
- for it in _google_news_items(topic,10):
482
- if it['url'] not in seen:
483
- seen.add(it['url']);items.append(it)
484
- return items[:24]
485
-
486
- def _web_research_context(topic):
487
- now=time.time();key='ctx2:'+topic.lower().strip()
488
- if key in _TOPIC_CACHE and now-_TOPIC_CACHE[key]['t']<900:return _TOPIC_CACHE[key]['d']
489
- items=_candidate_urls(topic)
490
- crawled=[]
491
- for it in items:
492
- text=_scrape_article_text(it['url'],9000)
493
- rel=_score_relevance(topic,it.get('title',''),text,it.get('snippet','')) or _score_topic_item(topic,it)
494
- # If RSS item has good snippet, keep it even when full text blocks.
495
- if text and len(text)>300 and rel>0:
496
- crawled.append({**it,'text':text,'rel':rel})
497
- elif it.get('snippet') and len(it['snippet'])>120 and rel>0:
498
- crawled.append({**it,'text':it['snippet'],'rel':rel,'snippet_only':True})
499
- crawled=sorted(crawled,key=lambda x:(x.get('rel',0),len(x.get('text',''))),reverse=True)[:7]
500
- blocks=[];sources=[]
501
- for it in crawled:
502
- label='ĐOẠN MÔ TẢ TỪ RSS/TÌM KIẾM' if it.get('snippet_only') else 'NỘI DUNG BÀI VIẾT ĐÃ CRAWL'
503
- blocks.append(f"NGUỒN: {it['source']}\nTIÊU ĐỀ: {it['title']}\n{label}:\n{it['text'][:8500]}")
504
- sources.append({'title':it['title'],'url':it['url'],'via':it['source']})
505
- data={'context':'\n\n---\n\n'.join(blocks),'sources':sources[:8],'count':len(blocks)}
506
- _TOPIC_CACHE[key]={'t':now,'d':data}
507
- return data
508
-
509
-
510
- # ===== FINAL6C: FAST topic generation (RSS cache first, no slow full-page crawling) =====
511
- import asyncio
512
- _FAST_TOPIC_CACHE={}
513
- FAST_RSS_FEEDS=[
514
- ('VnExpress','https://vnexpress.net/rss/tin-moi-nhat.rss'),
515
- ('VnExpress Thời sự','https://vnexpress.net/rss/thoi-su.rss'),
516
- ('VnExpress Thế giới','https://vnexpress.net/rss/the-gioi.rss'),
517
- ('VnExpress Kinh doanh','https://vnexpress.net/rss/kinh-doanh.rss'),
518
- ('VnExpress Công nghệ','https://vnexpress.net/rss/so-hoa.rss'),
519
- ('VnExpress Thể thao','https://vnexpress.net/rss/the-thao.rss'),
520
- ('Dân trí','https://dantri.com.vn/rss/home.rss'),
521
- ('Dân trí Xã hội','https://dantri.com.vn/rss/xa-hoi.rss'),
522
- ('Dân trí Kinh doanh','https://dantri.com.vn/rss/kinh-doanh.rss'),
523
- ('Dân trí Thể thao','https://dantri.com.vn/rss/the-thao.rss'),
524
- ('Dân trí Công nghệ','https://dantri.com.vn/rss/suc-manh-so.rss'),
525
- ('Vietnamnet','https://vietnamnet.vn/rss/tin-moi-nhat.rss'),
526
- ('Vietnamnet Thời sự','https://vietnamnet.vn/thoi-su.rss'),
527
- ('Vietnamnet Kinh doanh','https://vietnamnet.vn/kinh-doanh.rss'),
528
- ('Vietnamnet Công nghệ','https://vietnamnet.vn/cong-nghe.rss'),
529
- ('Vietnamnet Thể thao','https://vietnamnet.vn/the-thao.rss'),
530
- ]
531
-
532
- def _fast_fetch_rss(feed_name, feed_url, max_items=20):
533
- items=[]
534
- try:
535
- r=requests.get(feed_url,headers=UA,timeout=6);r.encoding='utf-8'
536
- soup=BeautifulSoup(r.text,'xml')
537
- for it in soup.find_all('item')[:max_items]:
538
- title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
539
- link=clean(it.find('link').get_text(strip=True) if it.find('link') else '')
540
- desc_raw=it.find('description').get_text(' ',strip=True) if it.find('description') else ''
541
- desc=clean(BeautifulSoup(desc_raw,'lxml').get_text(' ',strip=True))
542
- if title and link:
543
- items.append({'title':title,'url':link,'source':feed_name,'snippet':desc})
544
- except Exception:pass
545
- return items
546
-
547
- def _fast_rss_pool():
548
- now=time.time();key='fast_rss_pool'
549
- if key in _FAST_TOPIC_CACHE and now-_FAST_TOPIC_CACHE[key]['t']<600:return _FAST_TOPIC_CACHE[key]['d']
550
- pool=[];seen=set()
551
- # Sequential with short timeouts is predictable; RSS is small.
552
- for name,url in FAST_RSS_FEEDS:
553
- for it in _fast_fetch_rss(name,url,16):
554
- if it['url'] not in seen:
555
- seen.add(it['url']);pool.append(it)
556
- _FAST_TOPIC_CACHE[key]={'t':now,'d':pool}
557
- return pool
558
-
559
- def _fast_topic_tokens(topic):
560
- toks=[w.lower() for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',topic or '') if len(w)>1]
561
- return [t for t in toks if t not in STOP_WORDS]
562
-
563
- def _fast_score(topic,item):
564
- toks=_fast_topic_tokens(topic)
565
- hay=(item.get('title','')+' '+item.get('snippet','')+' '+item.get('source','')).lower()
566
- if not toks:return 0
567
- score=0
568
- for t in toks:
569
- if t in hay:score+=3 if len(t)>3 else 1
570
- phrase=topic.lower().strip()
571
- if phrase and phrase in hay:score+=12
572
- return score
573
-
574
- def _fast_sources(topic, limit=8):
575
- pool=_fast_rss_pool()
576
- scored=[]
577
- for it in pool:
578
- sc=_fast_score(topic,it)
579
- if sc>0:scored.append((sc,it))
580
- scored=sorted(scored,key=lambda x:(x[0],len(x[1].get('snippet',''))),reverse=True)
581
- out=[];seen=set()
582
- for sc,it in scored:
583
- if it['url'] in seen:continue
584
- seen.add(it['url']);out.append({**it,'score':sc})
585
- if len(out)>=limit:break
586
- # If topic too narrow and no match, use top latest from VN RSS as weak context instead of slow crawling.
587
- if not out:
588
- out=pool[:min(limit,8)]
589
- return out
590
-
591
- def _fast_context(topic):
592
- now=time.time();key='fast_ctx:'+topic.lower().strip()
593
- if key in _FAST_TOPIC_CACHE and now-_FAST_TOPIC_CACHE[key]['t']<600:return _FAST_TOPIC_CACHE[key]['d']
594
- sources=_fast_sources(topic,8)
595
- blocks=[];src=[]
596
- for it in sources:
597
- text=(it.get('snippet') or '').strip()
598
- # Use title + RSS description only: fast and reliable.
599
- blocks.append(f"NGUỒN: {it.get('source','')}\nTIÊU ĐỀ: {it.get('title','')}\nTÓM TẮT RSS:\n{text}")
600
- src.append({'title':it.get('title',''),'url':it.get('url',''),'via':it.get('source','')})
601
- data={'context':'\n\n---\n\n'.join(blocks),'sources':src,'count':len(blocks)}
602
- _FAST_TOPIC_CACHE[key]={'t':now,'d':data}
603
- return data
604
-
605
- def _fallback_fast_article(topic, sources):
606
- lines=[]
607
- for s in sources[:7]:
608
- title=s.get('title','')
609
- if title:lines.append(title)
610
- body='\n'.join('• '+x for x in lines[:7])
611
- vias=', '.join(sorted({s.get('via','') for s in sources if s.get('via')}))
612
- return (f"{topic}: những điểm đáng chú ý\n\n"
613
- f"{topic} đang là chủ đề được quan tâm trong dòng tin tức hiện nay. Dựa trên các nguồn tin mới nhất, có thể tổng hợp nhanh một số điểm nổi bật để người đọc nắm bối cảnh và theo dõi tiếp diễn biến.\n\n"
614
- f"Các nguồn tin liên quan cho thấy chủ đề này gắn với những diễn biến sau:\n{body}\n\n"
615
- f"Nhìn chung, đây là vấn đề cần được theo dõi theo nhiều góc độ: bối cảnh, tác động thực tế, phản ứng của các bên liên quan và những thông tin cập nhật tiếp theo. Người đọc nên đối chiếu thêm các nguồn chính thống khi cần quyết định hoặc đánh giá chi tiết.\n\n"
616
- f"Nguồn tham khảo: {vias}")
617
-
618
- # Remove previous slow topic routes and register fast versions last.
619
- 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 {('/api/topic_post','POST'),('/api/topic_sources','GET')})]
620
-
621
- @app.get('/api/topic_sources')
622
- def api_topic_sources_fast(topic:str=Query(...)):
623
- data=_fast_context(clean(topic))
624
- return JSONResponse({'count':data.get('count',0),'sources':data.get('sources',[]),'has_context':bool(data.get('context')),'mode':'fast_rss'})
625
-
626
- @app.post('/api/topic_post')
627
- async def topic_post_fast(request:Request):
628
- body=await request.json();topic=clean(body.get('topic',''))
629
- if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
630
- img=_topic_image(topic)
631
- research=_fast_context(topic);context=research.get('context','');sources=research.get('sources',[])
632
- prompt=f"""Bạn là biên tập viên VNEWS. Hãy viết MỘT BÀI VIẾT HOÀN CHỈNH bằng tiếng Việt về chủ đề: {topic}
633
-
634
- Dữ liệu nhanh từ RSS nguồn Việt Nam:
635
- {context[:12000]}
636
-
637
- Yêu cầu:
638
- - Không liệt kê tiêu đề nguồn thành bài viết.
639
- - Tổng hợp thành bài báo/tạp chí hoàn chỉnh.
640
- - Có tiêu đề mới, sapo 2-3 câu, 4-6 đoạn phân tích/bối cảnh/tác động.
641
- - Diễn đạt lại, không sao chép nguyên văn.
642
- - Nếu dữ liệu ít, viết thận trọng và nêu các điểm cần theo dõi.
643
- - Cuối bài có mục Nguồn tham khảo.
644
- """
645
- text=None
646
- try:
647
- text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1300),timeout=28)
648
- except Exception:
649
- text=None
650
- if not text or len(text)<350:
651
- text=_fallback_fast_article(topic,sources)
652
- post=f5.base.make_post(topic,text,img,'','topic_fast_rss',sources=[s for s in sources if s.get('url')])
653
- post['images']=[img]
654
- posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts)
655
- return JSONResponse({'post':post,'mode':'fast_rss','sources_count':len(sources)})
656
-
657
-
658
- # ===== FINAL6D: FAST HOME LOAD =====
659
- _FAST_HOME_CACHE={"t":0,"d":[]}
660
- _FAST_DT_CACHE={"t":0,"d":[]}
661
- _FAST_VNEGO_CACHE={"t":0,"d":[]}
662
- _FAST_HL_CACHE={"t":0,"d":[]}
663
-
664
- def _rss_articles_fast(feed_url, group, source='vne', limit=6):
665
- out=[]
666
- try:
667
- r=requests.get(feed_url,headers=UA,timeout=4);r.encoding='utf-8'
668
- soup=BeautifulSoup(r.text,'xml')
669
- for it in soup.find_all('item')[:limit*2]:
670
- title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
671
- link=clean(it.find('link').get_text(strip=True) if it.find('link') else '')
672
- desc_raw=it.find('description').get_text(' ',strip=True) if it.find('description') else ''
673
- ds=BeautifulSoup(desc_raw,'lxml')
674
- im=ds.find('img'); img=im.get('src','') if im else ''
675
- desc=clean(ds.get_text(' ',strip=True))[:160]
676
- if title and link:
677
- out.append({'title':title,'link':link,'img':img,'summary':desc,'source':source,'group':group})
678
- if len(out)>=limit:break
679
- except Exception:pass
680
- return out
681
-
682
- def _fast_homepage():
683
- now=time.time()
684
- if _FAST_HOME_CACHE['d'] and now-_FAST_HOME_CACHE['t']<600:return _FAST_HOME_CACHE['d']
685
- feeds=[('Thời Sự','https://vnexpress.net/rss/thoi-su.rss'),('Thế Giới','https://vnexpress.net/rss/the-gioi.rss'),('Kinh Doanh','https://vnexpress.net/rss/kinh-doanh.rss'),('Công Nghệ','https://vnexpress.net/rss/so-hoa.rss'),('Thể Thao','https://vnexpress.net/rss/the-thao.rss'),('Giải Trí','https://vnexpress.net/rss/giai-tri.rss'),('Sức Khỏe','https://vnexpress.net/rss/suc-khoe.rss'),('Giáo Dục','https://vnexpress.net/rss/giao-duc.rss'),('Pháp Luật','https://vnexpress.net/rss/phap-luat.rss'),('Du Lịch','https://vnexpress.net/rss/du-lich.rss')]
686
- arts=[]
687
- try:
688
- from concurrent.futures import ThreadPoolExecutor, as_completed
689
- with ThreadPoolExecutor(max_workers=6) as ex:
690
- futs=[ex.submit(_rss_articles_fast,u,g,'vne',6) for g,u in feeds]
691
- for f in as_completed(futs,timeout=7):
692
- try:arts.extend(f.result() or [])
693
- except Exception:pass
694
- except Exception:
695
- for g,u in feeds[:5]:arts.extend(_rss_articles_fast(u,g,'vne',4))
696
- if arts:_FAST_HOME_CACHE.update({'t':now,'d':arts})
697
- return _FAST_HOME_CACHE['d'] or arts
698
-
699
- def _fast_dantri_hot():
700
- now=time.time()
701
- if _FAST_DT_CACHE['d'] and now-_FAST_DT_CACHE['t']<900:return _FAST_DT_CACHE['d']
702
- data=_rss_articles_fast('https://dantri.com.vn/rss/home.rss','Tin Nổi Bật','dantri',12)
703
- if data:_FAST_DT_CACHE.update({'t':now,'d':data})
704
- return data
705
-
706
- def _fast_vnego():
707
- now=time.time()
708
- if _FAST_VNEGO_CACHE['d'] and now-_FAST_VNEGO_CACHE['t']<900:return _FAST_VNEGO_CACHE['d']
709
- out=[]
710
- try:
711
- r=requests.get('https://vnexpress.net/vne-go',headers=UA,timeout=4);r.encoding='utf-8'
712
- soup=BeautifulSoup(r.text,'lxml');seen=set()
713
- for a in soup.find_all('a',href=True):
714
- href=a.get('href','');title=clean(a.get('title','') or a.get_text(' ',strip=True))
715
- if not title or len(title)<8 or not href.startswith('http') or href in seen:continue
716
- if '/vne-go' not in href and '/video/' not in href:continue
717
- seen.add(href);img='';im=a.find('img') or (a.parent.find('img') if a.parent else None)
718
- if im:img=im.get('data-src') or im.get('src','')
719
- out.append({'title':title,'link':href,'img':img,'source':'vne-video'})
720
- if len(out)>=10:break
721
- except Exception:pass
722
- _FAST_VNEGO_CACHE.update({'t':now,'d':out})
723
- return out
724
-
725
- def _fast_highlights():
726
- now=time.time()
727
- if _FAST_HL_CACHE['d'] and now-_FAST_HL_CACHE['t']<900:return _FAST_HL_CACHE['d']
728
- _FAST_HL_CACHE.update({'t':now,'d':[]})
729
- return []
730
-
731
- for _p in ['/api/homepage','/api/dantri_hot','/api/vne_video','/api/highlights']:
732
- app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)==_p and 'GET' in getattr(r,'methods',set()))]
733
- @app.get('/api/homepage')
734
- def api_homepage_fast():return JSONResponse(_fast_homepage())
735
- @app.get('/api/dantri_hot')
736
- def api_dantri_hot_fast():return JSONResponse(_fast_dantri_hot())
737
- @app.get('/api/vne_video')
738
- def api_vne_video_fast():return JSONResponse(_fast_vnego())
739
- @app.get('/api/highlights')
740
- def api_highlights_fast():return JSONResponse(_fast_highlights())
741
-
742
- FINAL6_FAST_HOME_INJECT = """
743
  <script>
744
  (function(){
745
- const oldFetch=window.fetch;
746
- window.__allowShortRefresh=false;
747
- 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)};
748
- setTimeout(()=>{window.__allowShortRefresh=true;},7000);
749
  })();
750
  </script>
751
- """
752
- app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
753
- @app.get('/')
754
- async def index_final6_fast_home():
755
- html=f5.f4.f3.f2.f1._load_index_html()
756
- body=getattr(rt.old,'PATCH_INJECT','')+f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT+FINAL6_INJECT+FINAL6_FAST_HOME_INJECT
757
- return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
758
-
759
-
760
- # ===== FINAL6E: SHOW SOURCE CONTENTS IN TOPIC ARTICLE =====
761
- def _extract_source_details_from_context(context, sources):
762
- details=[]
763
- # Map source urls by title for URL/via enrichment
764
- src_by_title={clean(s.get('title','')):s for s in (sources or [])}
765
- for block in (context or '').split('---'):
766
- block=block.strip()
767
- if not block:continue
768
- via='';title='';content=''
769
- m=re.search(r'NGUỒN:\s*(.*)',block)
770
- if m:via=clean(m.group(1))
771
- m=re.search(r'TIÊU ĐỀ:\s*(.*)',block)
772
- if m:title=clean(m.group(1))
773
- if 'NỘI DUNG BÀI VIẾT ĐÃ CRAWL:' in block:
774
- content=block.split('NỘI DUNG BÀI VIẾT ĐÃ CRAWL:',1)[1]
775
- elif 'TÓM TẮT RSS:' in block:
776
- content=block.split('TÓM TẮT RSS:',1)[1]
777
- elif 'ĐOẠN MÔ TẢ' in block:
778
- content=re.split(r'ĐOẠN MÔ TẢ[^:]*:',block,1)[-1]
779
- content=clean(content)
780
- if not title and not content:continue
781
- s=src_by_title.get(title,{})
782
- details.append({'title':title or s.get('title','Nguồn tham khảo'),'url':s.get('url',''),'via':via or s.get('via',''),'content':content[:1800]})
783
- if len(details)>=8:break
784
- return details
785
-
786
- # Remove prior topic endpoint and register one that stores source_details in post.
787
- 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()))]
788
-
789
- @app.post('/api/topic_post')
790
- async def topic_post_with_source_contents(request:Request):
791
- body=await request.json();topic=clean(body.get('topic',''))
792
- if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
793
- img=_topic_image(topic)
794
- research=_fast_context(topic) if '_fast_context' in globals() else _web_research_context(topic)
795
- context=research.get('context','');sources=research.get('sources',[])
796
- details=_extract_source_details_from_context(context,sources)
797
- if not context or not details:
798
- 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)
799
- 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)])
800
- prompt=f"""Bạn là biên tập viên VNEWS. Hãy viết MỘT BÀI VIẾT HOÀN CHỈNH bằng tiếng Việt về chủ đề: {topic}
801
-
802
- Dưới đây là nội dung từng nguồn đã thu thập. Hãy tổng hợp ý chính, không sao chép nguyên văn, không biến các tiêu đề thành danh sách.
803
-
804
- NỘI DUNG NGUỒN:
805
- {source_brief[:18000]}
806
-
807
- Yêu cầu:
808
- - Tiêu đề mới, rõ, hấp dẫn.
809
- - Sapo 2-3 câu.
810
- - 5-8 đoạn phân tích/bối cảnh/tác động/điểm cần lưu ý.
811
- - Không dùng câu "Dưới đây là" hoặc "Tôi sẽ".
812
- - Cuối bài có mục "Nguồn tham khảo" nêu tên nguồn.
813
- """
814
- text=None
815
- try:
816
- import asyncio
817
- text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1700),timeout=35)
818
- except Exception:
819
- text=None
820
- if not text or len(text)<350:
821
- bullets='\n'.join([f"• {d['title']}: {d.get('content','')[:320]}" for d in details[:6]])
822
- vias=', '.join(sorted({d.get('via','') for d in details if d.get('via')}))
823
- text=(f"{topic}: tổng hợp những điểm đáng chú ý\n\n"
824
- f"{topic} đang được nhiều nguồn tin đề cập với các góc nhìn khác nhau. Dưới đây là phần tổng hợp nhanh từ những nội dung đã thu thập được.\n\n"
825
- f"{bullets}\n\n"
826
- 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"
827
- f"Nguồn tham khảo: {vias}")
828
- post=f5.base.make_post(topic,text,img,'','topic_fast_rss_with_sources',sources=[s for s in sources if s.get('url')])
829
- post['images']=[img]
830
- post['source_details']=details
831
- posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts)
832
- return JSONResponse({'post':post,'mode':'fast_rss_with_source_details','sources_count':len(details)})
833
 
834
- FINAL6E_INJECT = """
835
  <style>
836
- .source-detail-box{margin-top:14px;background:#151515;border:1px solid #2b2b2b;border-radius:10px;padding:10px}.source-detail-box h3{font-size:14px;color:#5cb87a;margin-bottom:8px}.source-detail-item{background:#202020;border-radius:8px;padding:9px;margin:7px 0}.source-detail-title{font-size:12px;font-weight:700;color:#eee;line-height:1.35}.source-detail-meta{font-size:10px;color:#888;margin:3px 0}.source-detail-content{font-size:12px;color:#bbb;line-height:1.5;white-space:pre-wrap;max-height:220px;overflow:auto}.source-detail-item a{color:#5cb87a;font-size:11px;text-decoration:none}
 
837
  </style>
838
  <script>
839
  (function(){
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
  '''
 
 
 
 
 
 
 
 
 
 
1
+ """Final6 runtime: fast homepage, fast shorts, Qwen topic with source details, hashtag sources, rewrite auto-title."""
2
+ import os, re, time, json, hashlib, asyncio, threading, requests
3
+ from urllib.parse import urlparse, quote
 
 
 
 
 
4
  import ai_runtime_final5 as f5
5
+ from ai_runtime_final5 import app, base, rt, HTMLResponse, JSONResponse, Request, Query
6
+ import html as html_lib
7
 
8
+ SPACE_URL = "https://bep40-vnews.hf.space"
9
+ SHORT_CHANNELS = ["baodantri7941", "baosuckhoedoisongboyte"]
10
+ YOUTUBE_HANDLES = SHORT_CHANNELS
11
+ DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data"
12
+ os.makedirs(DATA_DIR, exist_ok=True)
13
+ SHORTS_CACHE = {"t": 0, "d": []}
14
+ AI_INTERACTIONS_FILE = os.path.join(DATA_DIR, "ai_interactions.json")
15
+ SHORT_COMMENTS_FILE = os.path.join(DATA_DIR, "short_comments.json")
16
 
17
+ def clean(s):
18
+ return re.sub(r"\s+", " ", html_lib.unescape(s or "")).strip()
 
 
 
 
 
 
 
19
 
 
20
  def _domain(u):
21
+ try: return urlparse(u or '').netloc.replace('www.', '')
22
+ except: return ''
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
+ def _lj(p, d):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  try:
26
+ if os.path.exists(p): return json.load(open(p, 'r', encoding='utf-8'))
27
+ except: pass
28
+ return d
 
 
 
 
 
 
 
 
 
 
29
 
30
+ def _sj(p, d):
 
31
  try:
32
+ os.makedirs(os.path.dirname(p), exist_ok=True)
33
+ open(p + '.tmp', 'w', encoding='utf-8').write(json.dumps(d, ensure_ascii=False))
34
+ os.replace(p + '.tmp', p)
35
+ except: pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
+ def _yt_ytdlp(handle, count=20):
38
  try:
39
+ import yt_dlp
40
+ url = f"https://www.youtube.com/@{handle}/shorts"
41
+ opts = {'quiet': True, 'extract_flat': True, 'skip_download': True, 'playlist-end': count, 'ignoreerrors': True, 'no_warnings': True}
42
+ with yt_dlp.YoutubeDL(opts) as ydl:
43
+ info = ydl.extract_info(url, download=False)
44
+ out = []
45
+ for e in (info or {}).get('entries') or []:
46
+ vid = e.get('id') or ''
47
+ if not re.match(r'^[A-Za-z0-9_-]{11}$', vid): continue
48
+ title = e.get('title') or 'YouTube Short'
49
+ out.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': handle})
50
+ return out
51
+ except: return []
 
 
 
 
52
 
53
+ def _yt_html(handle, count=20):
54
  try:
55
+ r = requests.get(f"https://www.youtube.com/@{handle}/shorts", headers=getattr(base, 'HEADERS', {}), timeout=15)
56
+ ids = []; out = []
57
+ for m in re.finditer(r'"videoId":"([A-Za-z0-9_-]{11})"', r.text):
58
+ vid = m.group(1)
59
+ if vid in ids: continue
60
+ ids.append(vid)
61
+ snip = r.text[max(0, m.start()-1000):m.start()+1800]
62
+ title = 'YouTube Short'
63
+ mt = re.search(r'"title":\{"runs":\[\{"text":"([^"]+)"', snip) or re.search(r'"accessibilityText":"([^"]+)"', snip)
64
+ if mt: title = clean(mt.group(1).replace('\\n', ' '))
65
+ out.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': handle})
66
+ if len(out) >= count: break
67
+ return out
68
+ except: return []
 
 
69
 
70
+ def _fallback_shorts():
71
+ out = []; seen = set()
72
+ hard = [
73
+ ('Lu_iCQ5YwNM', 'Công an lập hồ sơ xử lý người phụ nữ chửi bới, tát tài xế ô tô | Dân trí', 'baodantri7941'),
74
+ ('CwWvijF8BOA', 'Chú rể bật khóc nhận món quà bí mật người cha quá cố gửi 26 năm trước | Dân trí', 'baodantri7941'),
75
+ ('7Pd6vZ2Lz1M', 'Hành động ấm lòng trong tìm kiếm học sinh tử vong ở sông Lô | SKĐS', 'baosuckhoedoisongboyte'),
76
+ ('SlHLt_ZyPiE', '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', 'baosuckhoedoisongboyte'),
77
+ ]
78
+ for vid, title, ch in hard:
79
+ if vid not in seen:
80
+ seen.add(vid)
81
+ out.append({'id': vid, 'title': title, 'channel': ch, 'link': f'https://www.youtube.com/watch?v={vid}', 'img': f'https://i.ytimg.com/vi/{vid}/hqdefault.jpg', 'source': 'yt'})
82
+ return out
83
 
84
+ def _fresh_shorts():
85
+ items = []; seen = set()
86
+ for ch in YOUTUBE_HANDLES:
87
+ got = _yt_ytdlp(ch, 24) or _yt_html(ch, 24)
88
+ for v in got:
89
+ if v['id'] not in seen:
90
+ seen.add(v['id']); items.append(v)
91
+ for v in _fallback_shorts():
92
+ if v['id'] not in seen:
93
+ seen.add(v['id']); items.append(v)
94
+ return items[:50]
 
 
 
 
 
 
 
 
 
 
95
 
96
  def _topic_image(topic):
97
+ try: return base.pollinations_image_url(topic)
98
+ except: return "https://image.pollinations.ai/prompt/" + quote("Vietnamese news editorial illustration " + topic) + "?width=1024&height=576&nologo=true"
 
 
 
 
 
99
 
100
+ def _web_research_context(topic, limit=5):
101
+ try:
102
+ ctx, sources = base.web_context(topic, limit=limit)
103
+ return {"context": ctx or "", "sources": sources or []}
104
+ except:
105
+ return {"context": "", "sources": []}
106
+
107
+ def _fast_context(topic, limit=5):
108
+ return _web_research_context(topic, limit)
109
+
110
+ def _extract_source_details_from_context(ctx, sources):
111
+ """Extract detailed source info from web context for source_details field."""
112
+ details = []
113
+ if not ctx or not sources:
114
+ return details
115
+ # Parse sources from context
116
+ for s in sources[:8]:
117
+ url = s.get('url', '')
118
+ if not url: continue
119
+ details.append({
120
+ 'title': s.get('title', ''),
121
+ 'url': url,
122
+ 'via': s.get('via', _domain(url)),
123
+ 'content': s.get('excerpt', s.get('description', ''))
124
+ })
125
+ return details
126
 
127
+ _bg_home = {"t": 0, "d": []}
128
+ _bg_shorts = {"t": 0, "d": []}
129
+ _bg_lock = False
130
 
131
+ def _bg():
132
+ global _bg_lock
133
+ if _bg_lock: return
134
+ _bg_lock = True
135
+ try:
136
+ # Fast homepage: just return empty, let frontend handle it
137
+ _bg_home.update({"t": time.time(), "d": []})
138
+ # Shorts
139
+ raw = []
140
+ for h in YOUTUBE_HANDLES:
141
+ raw.extend(_yt_ytdlp(h, 20) or _yt_html(h, 20))
142
+ raw.extend(_fallback_shorts())
143
+ seen = set()
144
+ out = [v for v in raw if v.get('id') and v['id'] not in seen and not seen.add(v['id'])]
145
+ if out: _bg_shorts.update({"t": time.time(), "d": out[:40]})
146
+ except: pass
147
+ finally: _bg_lock = False
148
+
149
+ @app.on_event("startup")
150
+ async def _s():
151
+ threading.Thread(target=_bg, daemon=True).start()
152
+ threading.Thread(target=lambda: [time.sleep(600) or _bg() for _ in iter(int, 1)], daemon=True).start()
153
+
154
+ # Remove endpoints to override
155
+ app.router.routes = [r for r in app.router.routes if not (
156
+ getattr(r, 'path', None) in ('/api/homepage', '/api/shorts', '/api/ai_wall', '/api/topic_post', '/api/article/ask', '/api/topic/rewrite', '/api/rewrite_share', '/api/url_wall', '/api/short/comments', '/api/short/comment', '/api/storage_status', '/') and
157
+ any(m in getattr(r, 'methods', set()) for m in ('GET', 'POST'))
158
+ )]
159
 
160
+ @app.get('/api/homepage')
161
+ def _h():
162
+ n = time.time()
163
+ if _bg_home['d']:
164
+ if n - _bg_home['t'] > 300: threading.Thread(target=_bg, daemon=True).start()
165
+ return JSONResponse(_bg_home['d'])
166
+ threading.Thread(target=_bg, daemon=True).start()
167
+ return JSONResponse([])
 
 
 
 
 
 
 
 
 
 
 
 
168
 
169
+ @app.get('/api/shorts')
170
+ def _sh(refresh: int = Query(default=0)):
171
+ n = time.time()
172
+ if _bg_shorts['d'] and (not refresh or n - _bg_shorts['t'] < 120):
173
+ if n - _bg_shorts['t'] > 600: threading.Thread(target=_bg, daemon=True).start()
174
+ return JSONResponse(_bg_shorts['d'])
175
+ data = _fresh_shorts()
176
+ _bg_shorts.update({'t': n, 'd': data})
177
+ return JSONResponse(data)
178
+
179
+ @app.get('/api/ai_wall')
180
+ def _w():
181
+ n = int(time.time())
182
+ return JSONResponse({'posts': [p for p in f5.base._load_ai_wall() if n - int(p.get('ts') or 0) < 86400], 'persistent': os.path.isdir('/data')})
183
+
184
+ @app.get('/api/storage_status')
185
+ def _st():
186
+ return JSONResponse({'persistent': os.path.isdir('/data')})
187
+
188
+ @app.get('/api/short/comments')
189
+ def _gc(id: str = Query(...)):
190
+ return JSONResponse({'comments': _lj(SHORT_COMMENTS_FILE, {}).get(id, [])})
191
+
192
+ @app.post('/api/short/comment')
193
+ async def _pc(request: Request):
194
+ b = await request.json()
195
+ v = str(b.get('id', '')).strip()
196
+ t = clean(b.get('text', ''))
197
+ if not v or not t: return JSONResponse({'error': 'missing'}, status_code=400)
198
+ db = _lj(SHORT_COMMENTS_FILE, {})
199
+ c = db.get(v, [])
200
+ c.insert(0, {'text': t[:300], 'ts': int(time.time())})
201
+ db[v] = c[:100]
202
+ _sj(SHORT_COMMENTS_FILE, db)
203
+ return JSONResponse({'comments': db[v]})
204
+
205
+ @app.post('/api/article/ask')
206
+ async def _ask(request: Request):
207
+ b = await request.json()
208
+ q = clean(b.get('question', ''))
209
+ ctx = clean(b.get('context', ''))
210
+ url = clean(b.get('url', ''))
211
+ if not q: return JSONResponse({'error': 'missing question'}, status_code=400)
212
+ title = ''; raw = ''
213
+ if url:
214
+ try:
215
+ d = f5.base.scrape_any_url(url)
216
+ title = d.get('title', '')
217
+ raw = (d.get('summary', '') + '\n' + d.get('text', '')).strip()
218
+ except: pass
219
+ if not raw: raw = ctx[:12000]
220
+ ans = await f5.base.qwen_generate(
221
+ f'Bạn là VNEWS AI. Nội dung: "{title}"\n{raw[:9000]}\n\nHỏi: "{q}"\n\nTrả lời tự nhiên bằng tiếng Việt.',
222
+ max_tokens=1200)
223
+ return JSONResponse({'answer': ans or 'Chưa trả lời được.', 'title': title})
224
+
225
+ @app.post('/api/rewrite_share')
226
+ @app.post('/api/url_wall')
227
+ async def _rw(request: Request):
228
+ b = await request.json()
229
+ url = clean(b.get('url', ''))
230
+ ctx = clean(b.get('context', ''))
231
+ if not url.startswith('http'): return JSONResponse({'error': 'URL không hợp lệ'}, status_code=400)
232
  try:
233
+ d = f5.base.scrape_any_url(url)
234
+ title = d.get('title', '')
235
+ raw = (d.get('summary', '') + '\n' + d.get('text', '')).strip()
236
+ img = d.get('image') or ''
237
+ except:
238
+ title = ''; raw = ctx[:14000]; img = ''
239
+ if len(raw) < 50: return JSONResponse({'error': 'Không đọc được bài'}, status_code=422)
240
+ text = None
241
+ try:
242
+ text = await asyncio.wait_for(f5.base.qwen_generate(
243
+ f'Tóm tắt đăng Tường AI:\nTiêu đề: {title}\n{raw[:14000]}\n\n4-6 ý chính. Cuối ghi nguồn.',
244
+ image_url=img or None, max_tokens=1000), timeout=30)
245
+ except: pass
246
+ if not text or len(text) < 80:
247
+ text = f"Tóm tắt: {title}\n\n{raw[:1200]}\n\nNguồn: {_domain(url)}"
248
+ post = f5.base.make_post(title or 'Bài viết', text, img, url, 'rewrite',
249
+ sources=[{'title': title, 'url': url, 'via': _domain(url)}])
250
+ ps = f5.base._load_ai_wall(); ps.insert(0, post); f5.base._save_ai_wall(ps)
251
+ return JSONResponse({'post': post})
252
+
253
+ @app.post('/api/topic/rewrite')
254
+ async def _tr(request: Request):
255
+ b = await request.json()
256
+ pid = str(b.get('post_id', '')).strip()
257
+ if not pid: return JSONResponse({'error': 'missing post_id'}, status_code=400)
258
+ ps = f5.base._load_ai_wall()
259
+ p = next((x for x in ps if str(x.get('id')) == pid), None)
260
+ if not p: return JSONResponse({'error': 'Bài không tồn tại'}, status_code=404)
261
+ urls = list(dict.fromkeys(
262
+ [s['url'] for s in (p.get('source_details') or []) if s.get('url')] +
263
+ [s['url'] for s in (p.get('sources') or []) if s.get('url')]
264
+ ))[:5]
265
+ parts = []
266
+ for u in urls:
267
  try:
268
+ d = f5.base.scrape_any_url(u)
269
+ t = d.get('title', '')
270
+ r = (d.get('summary', '') + '\n' + d.get('text', '')).strip()
271
+ if r and len(r) > 150:
272
+ parts.append(f"[{_domain(u)}] {t}\n{r}")
273
+ except: pass
274
+ ac = '\n---\n'.join(parts) if parts else (p.get('text') or '')
275
+ title = p.get('title', '')
276
+ text = None
277
+ try:
278
+ text = await asyncio.wait_for(f5.base.qwen_generate(
279
+ f'Viết lại:\nChủ đề: {title}\n{ac[:16000]}\n\nTiêu đề mới + 4-6 ý + nguồn.',
280
+ image_url=p.get('img'), max_tokens=1200), timeout=35)
281
+ except: pass
282
+ if not text or len(text) < 100:
283
+ text = f"Tóm tắt: {title}\n\n{ac[:1500]}\n\nNguồn: VNEWS AI"
284
+ np = f5.base.make_post('Rewrite: ' + title, text, p.get('img', ''), '', 'rewrite_topic', sources=p.get('sources', []))
285
+ np['images'] = p.get('images', [])
286
+ all_p = f5.base._load_ai_wall(); all_p.insert(0, np); f5.base._save_ai_wall(all_p)
287
+ return JSONResponse({'post': np})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
 
289
+ @app.post('/api/topic_post')
290
+ async def _tp(request: Request):
291
+ b = await request.json()
292
+ topic = clean(b.get('topic', ''))
293
+ if not topic: return JSONResponse({'error': 'missing topic'}, status_code=400)
294
+ img = _topic_image(topic)
295
+ research = _fast_context(topic)
296
+ ctx = research.get('context', '')
297
+ src = research.get('sources', [])
298
+ det = _extract_source_details_from_context(ctx, src)
299
+ if not ctx or not src:
300
+ return JSONResponse({'error': 'Không tìm được nội dung.'}, status_code=422)
301
+ sb = '\n\n'.join([f"[{i+1}] {d.get('title', '')} ({d.get('via', '')})\n{d.get('content', '')[:1400]}" for i, d in enumerate(det)]) if det else ctx[:18000]
302
+ text = None
303
+ try:
304
+ text = await asyncio.wait_for(f5.base.qwen_generate(
305
+ f'Viết bài tiếng Việt VỀ: "{topic}"\nNGUỒN:\n{sb[:18000]}\nCHỈ viết về "{topic}". 5-8 đoạn. Cuối có nguồn.',
306
+ image_url=img, max_tokens=1700), timeout=35)
307
+ except: pass
308
+ if not text or len(text) < 300:
309
+ text = f"{topic}: tổng hợp\n\n" + '\n'.join([f"• {d['title']}: {d.get('content', '')[:300]}" for d in (det or [])[:6]]) + "\n\nNguồn: " + ', '.join(sorted({d.get('via', '') for d in (det or []) if d.get('via')}))
310
+ post = f5.base.make_post(topic, text, img, '', 'topic_focused', sources=[s for s in src if s.get('url')])
311
+ post['images'] = [img]; post['source_details'] = det
312
+ ps = f5.base._load_ai_wall(); ps.insert(0, post); f5.base._save_ai_wall(ps)
313
+ return JSONResponse({'post': post})
314
+
315
+ FINAL6_INJECT = r'''
316
  <style>
317
+ /* Livescore */
318
+ .ls-content{max-height:480px;overflow-y:auto;padding:0 6px 8px;font-size:12px;color:#ddd}
319
+ .ls-content ul{list-style:none;padding:0;margin:0}
320
+ .ls-content .title-content{display:flex;gap:6px;align-items:center;background:#222;border-radius:4px;margin:4px 0;padding:5px 8px}
321
+ .ls-content .title-content img{width:18px;height:18px}
322
+ .ls-content .title-content strong{font-size:11px;color:#ccc}
323
+ .ls-content .match-detail{padding:6px;border-bottom:1px solid #262626;cursor:pointer}
324
+ .ls-content .match-detail:hover{background:#1a2a1f}
325
+ .ls-content .match{display:flex;flex-wrap:wrap;align-items:center;gap:4px}
326
+ .ls-content .datetime{width:100%;font-size:9px;color:#888}
327
+ .ls-content .teams{display:flex;width:100%;align-items:center;gap:4px}
328
+ .ls-content .team{flex:1;display:flex;align-items:center;gap:4px;min-width:0}
329
+ .ls-content .team .name{font-size:11px;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
330
+ .ls-content .team .logo img{width:18px;height:18px}
331
+ .ls-content .home-team{justify-content:flex-end;text-align:right}
332
+ .ls-content .status{flex:0 0 54px;text-align:center}
333
+ .ls-content .status a{color:#fff;text-decoration:none;font-weight:800;font-size:12px}
334
+ .ls-content .status .label{font-size:8px;color:#888;display:block}
335
+ .ls-content .status .label.live{color:#e74c3c}
336
+ .ls-content .info,.ls-content .btns{display:none}
337
  </style>
338
  <script>
339
  (function(){
340
  function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
341
+ // Block slow YouTube refresh on first load
342
+ var _origFetch=window.fetch,_allowRefresh=false;
343
+ window.fetch=function(url,opts){try{if(String(url).indexOf('/api/shorts?refresh=1')>-1&&!_allowRefresh)url='/api/shorts';}catch(e){}return _origFetch.call(this,url,opts);};
344
+ setTimeout(function(){_allowRefresh=true;},8000);
 
 
 
345
  })();
346
  </script>
347
  '''
348
 
349
+ FINAL6_FAST_HOME_INJECT = r'''
350
+ <style>
351
+ .storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 4px}
352
+ </style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
  <script>
354
  (function(){
355
+ fetch('/api/storage_status').then(function(r){return r.json()}).then(function(j){
356
+ if(!j.persistent){var h=document.getElementById('view-home');if(h){var w=document.createElement('div');w.className='storage-warn';w.innerHTML='⚠️ <b>Persistent Storage chưa bật.</b> Bật: Space Settings → Persistent Storage → Small.';h.prepend(w);}}
357
+ });
 
358
  })();
359
  </script>
360
+ '''
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
 
362
+ FINAL6E_INJECT = r'''
363
  <style>
364
+ /* Kill ALL duplicate slides/walls from old layers */
365
+ #ai-short-home,.ai-short-home,.ai-short-card-final,[id*="ai-shorts-patched"]{display:none!important}
366
  </style>
367
  <script>
368
  (function(){
369
+ // Kill old renderers
370
+ window.renderTopicWallE=function(){};
371
+ window.renderAIShortHome=function(){};
372
+ window.renderAIShorts7=function(){};
373
+ window.renderPatchedWall=function(){};
374
+ window.renderAiShorts=function(){};
375
+ window.renderWall=function(){};
376
+ window.renderAIShorts=function(){};
377
+ window.loadPatchedWall=function(){};
378
+ window.refreshFinalWall3=function(){};
379
+ // Remove duplicate slides
380
+ setInterval(function(){
381
+ document.querySelectorAll('#ai-short-home,.ai-short-home,[id*="ai-shorts-patched"]').forEach(function(el){el.remove()});
382
+ },2000);
383
  })();
384
  </script>
385
  '''
386
+
387
+ @app.get('/')
388
+ async def _index():
389
+ html = f5.f4.f3.f2.f1._load_index_html()
390
+ body = ''
391
+ body += getattr(rt.old, 'PATCH_INJECT', '')
392
+ body += f5.f4.f3.f2.f1.FINAL_INJECT + f5.f4.f3.FINAL3_INJECT + f5.f4.FINAL4_INJECT + f5.FINAL5_INJECT
393
+ body += FINAL6_INJECT + FINAL6_FAST_HOME_INJECT + FINAL6E_INJECT
394
+ return HTMLResponse(html.replace('</body>', body + '\n</body>') if '</body>' in html else html + body)
app_v2_entry.py CHANGED
@@ -1,4 +1,4 @@
1
- """VNEWS v2 Entry Point - with fast bongda proxy + rewrite endpoints + multilingual TTS"""
2
  import sys, os
3
  from main import app, HEADERS, BONGDA_HEADERS, fetch_bongda_api, HL_LEAGUES
4
 
@@ -7,11 +7,6 @@ try:
7
  except Exception as e:
8
  print(f"[WARN] ai_ext import failed: {e}")
9
 
10
- try:
11
- import ai_patch
12
- except Exception as e:
13
- print(f"[WARN] ai_patch import failed: {e}")
14
-
15
  from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response
16
  from fastapi.staticfiles import StaticFiles
17
  from starlette.routing import Mount
@@ -327,12 +322,15 @@ def _search_all(topic,limit=36):
327
  if i<len(s) and s[i].get('url') and s[i]['url'] not in seen:seen.add(s[i]['url']);out.append(s[i])
328
  return out[:limit]
329
 
 
330
  for _path in ['/api/article', '/api/hot_topics', '/api/categories', '/api/storage_status', '/s']:
331
  app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)==_path and 'GET' in getattr(r,'methods',set()))]
332
 
 
333
  _article_cache = {}
334
  _article_cache_ttl = 1800
335
 
 
336
  _art_session = None
337
  _art_lock = threading.Lock()
338
  def _get_art_session():
@@ -349,13 +347,17 @@ def _get_art_session():
349
  return _art_session
350
 
351
  def _scrape_article_fast(url):
 
352
  from urllib.parse import urlparse
353
  domain = urlparse(url).netloc
354
  sess = _get_art_session()
 
 
355
  uas = [
356
  {"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"},
357
  {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"},
358
  ]
 
359
  for ua in uas:
360
  try:
361
  r = sess.get(url, headers=ua, timeout=6, allow_redirects=True)
@@ -363,8 +365,12 @@ def _scrape_article_fast(url):
363
  continue
364
  r.encoding = 'utf-8'
365
  soup = BeautifulSoup(r.text, 'lxml')
 
 
366
  for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe','.ads','.ad','.banner-ads','.fb-comments','.fb-root','.social-share','.related-news','.tag','.breadcrumb']):
367
  tag.decompose()
 
 
368
  title = summary = og_img = ""
369
  ogt = soup.find('meta', property='og:title')
370
  if ogt: title = ogt.get('content', '')
@@ -376,13 +382,15 @@ def _scrape_article_fast(url):
376
  if og_img.startswith('//'): og_img = 'https:' + og_img
377
  h1 = soup.find('h1')
378
  if not title and h1: title = h1.get_text(strip=True)[:200]
 
 
379
  body = []
380
  selectors = [
381
- '.fck_detail', '.sidebar-1',
382
- '.singular-content', '.dt__content', '.article-content', '.content-detail', '#divNewsContent',
383
- '.content-detail', '.main-content-detail', '.box-content',
384
- '.knc-content', '.article-body', '.detail-body',
385
- '.article-detail', '.detail-content',
386
  'article', 'main', '.cms-body', '.article__body', '.post-content',
387
  '.entry-content', '#content', '.article-text', '.story-body',
388
  ]
@@ -415,6 +423,8 @@ def _scrape_article_fast(url):
415
  if len(body) >= 2:
416
  return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img,
417
  'body': body[:50], 'source': domain, 'url': url}
 
 
418
  if title and (summary or og_img):
419
  fallback = []
420
  if og_img: fallback.append({'type': 'img', 'src': og_img})
@@ -422,38 +432,78 @@ def _scrape_article_fast(url):
422
  if fallback:
423
  return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img,
424
  'body': fallback, 'source': domain, 'url': url, 'fallback': True}
 
 
425
  if title:
426
  return {'title': _clean(title), 'summary': '', 'og_image': '',
427
  'body': [{'type': 'p', 'text': 'Nội dung đang được tải...'}],
428
  'source': domain, 'url': url, 'fallback': True}
429
- break
 
430
  except Exception:
431
  continue
 
432
  return None
433
 
434
  @app.get('/api/article')
435
  def api_article_v2(url: str = Query(...)):
 
436
  from urllib.parse import unquote
437
  safe_url = unquote(url)
 
438
  try:
 
439
  now = time.time()
440
  cached = _article_cache.get(safe_url)
441
  if cached and now - cached['t'] < _article_cache_ttl:
442
  resp = JSONResponse(cached['d'])
443
  resp.headers["Cache-Control"] = "public, max-age=1800"
444
  return resp
 
 
445
  data = _scrape_article_fast(safe_url)
 
446
  if data and data.get('body'):
447
  _article_cache[safe_url] = {'d': data, 't': now}
448
  resp = JSONResponse(data)
449
  resp.headers["Cache-Control"] = "public, max-age=1800"
450
  return resp
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451
  result = {'error': 'Không đọc được', 'url': safe_url}
452
  resp = JSONResponse(result)
453
  resp.headers["Cache-Control"] = "public, max-age=60"
454
  return resp
455
  except Exception as e:
456
- return JSONResponse({'error': f'Server error: {str(e)[:100]}', 'url': safe_url}, status_code=200)
 
 
457
 
458
  _hot_cache={'t':0,'d':[]}
459
  def _get_hot_topics():
@@ -506,8 +556,9 @@ def _st():return JSONResponse({'persistent':os.path.isdir('/data') and os.access
506
  @app.get('/s')
507
  async def _sh(url:str='',title:str='',img:str=''):return HTMLResponse(f'<!DOCTYPE html><html><head><meta property="og:title" content="{_clean(title)}"><meta property="og:image" content="{_clean(img)}"><meta http-equiv="refresh" content="0;url={_clean(url) or "/"}"></head><body></body></html>')
508
 
509
- from wc2026_scraper import scrape_summary,scrape_fixtures,scrape_standings,scrape_stats,scrape_wc_news,scrape_road_to_wc,get_wc2026_all,scrape_history,scrape_h2h,scrape_lineups,scrape_match_detail
510
 
 
511
  _xlb_cache = {}
512
  _xlb_lock = threading.Lock()
513
 
@@ -645,36 +696,54 @@ async def _pc(request:Request):
645
  with _il:idb=_lj(IF);idb.setdefault(v,{'views':0,'likes':0,'comments':0});idb[v]['comments']=len(cms);_sj(IF,idb)
646
  return JSONResponse({'comments':cms})
647
 
 
 
648
  def _load_wall_posts():
 
649
  with _wl_lock:
650
  return _lj(WALL_FILE)
651
 
652
  def _save_wall_posts(posts):
 
653
  with _wl_lock:
654
  _sj(WALL_FILE, posts)
655
 
656
  @app.get('/api/wall')
657
  def api_wall():
 
658
  posts = _load_wall_posts()
659
  if not posts:
 
660
  return JSONResponse({"posts": []})
661
  return JSONResponse({"posts": posts})
662
 
663
  @app.post('/api/wall')
664
  async def api_wall_post(request: Request):
 
 
 
 
 
665
  content_type = request.headers.get('content-type', '')
 
 
666
  if 'multipart/form-data' in content_type:
667
  try:
668
  form = await request.form()
669
  except Exception as e:
670
  return JSONResponse({"error": f"Form parse error: {str(e)}"}, status_code=400)
 
671
  title = form.get('title', 'Video mới') or 'Video mới'
672
  text = form.get('text', '') or ''
673
  source = form.get('source', 'vtv_recorder') or 'vtv_recorder'
674
  video_file = form.get('video')
 
675
  post_id = str(uuid.uuid4())[:12]
676
  video_url = None
 
 
677
  if video_file and hasattr(video_file, 'filename') and video_file.filename:
 
678
  fname = video_file.filename.lower()
679
  if fname.endswith('.mp4'):
680
  ext = '.mp4'
@@ -682,21 +751,31 @@ async def api_wall_post(request: Request):
682
  ext = '.webm'
683
  else:
684
  ext = '.webm'
 
685
  video_filename = f"wall_{post_id}{ext}"
686
  video_path = os.path.join(WALL_VIDEO_DIR, video_filename)
 
687
  try:
 
688
  content = await video_file.read()
689
  if not content:
690
  return JSONResponse({"error": "Empty video file"}, status_code=400)
 
 
691
  with open(video_path, 'wb') as f:
692
  f.write(content)
 
693
  file_size_mb = len(content) / 1024 / 1024
694
  if file_size_mb > 50:
695
  os.remove(video_path)
696
  return JSONResponse({"error": f"Video quá lớn ({file_size_mb:.1f}MB). Tối đa 50MB."}, status_code=400)
 
 
697
  video_url = f"/api/wall/video/{video_filename}"
698
  except Exception as e:
699
  return JSONResponse({"error": f"Lỗi lưu video: {str(e)}"}, status_code=500)
 
 
700
  post = {
701
  "id": post_id,
702
  "title": title[:200],
@@ -708,21 +787,29 @@ async def api_wall_post(request: Request):
708
  "created": int(time.time()),
709
  "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
710
  }
 
 
711
  posts = _load_wall_posts()
712
  if not isinstance(posts, list):
713
  posts = []
714
  posts.insert(0, post)
 
715
  posts = posts[:200]
716
  _save_wall_posts(posts)
 
717
  return JSONResponse({"post": post, "ok": True})
 
 
718
  try:
719
  body = await request.json()
720
  except:
721
  body = {}
 
722
  title = body.get('title', 'Bài mới') or 'Bài mới'
723
  text = body.get('text', '') or ''
724
  img = body.get('img', None)
725
  source = body.get('source', 'user') or 'user'
 
726
  post_id = str(uuid.uuid4())[:12]
727
  post = {
728
  "id": post_id,
@@ -735,16 +822,20 @@ async def api_wall_post(request: Request):
735
  "created": int(time.time()),
736
  "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
737
  }
 
738
  posts = _load_wall_posts()
739
  if not isinstance(posts, list):
740
  posts = []
741
  posts.insert(0, post)
742
  posts = posts[:200]
743
  _save_wall_posts(posts)
 
744
  return JSONResponse({"post": post, "ok": True})
745
 
746
  @app.get('/api/wall/video/{filename}')
747
  def api_wall_video(filename: str):
 
 
748
  if '..' in filename or '/' in filename:
749
  return Response(status_code=403)
750
  video_path = os.path.join(WALL_VIDEO_DIR, filename)
@@ -756,11 +847,14 @@ def api_wall_video(filename: str):
756
 
757
  @app.delete('/api/wall/{post_id}')
758
  def api_wall_delete(post_id: str):
 
759
  posts = _load_wall_posts()
760
  if not isinstance(posts, list):
761
  return JSONResponse({"error": "No posts"}, status_code=404)
 
762
  for i, p in enumerate(posts):
763
  if p.get('id') == post_id:
 
764
  if p.get('video'):
765
  video_name = p['video'].split('/')[-1]
766
  video_path = os.path.join(WALL_VIDEO_DIR, video_name)
@@ -769,510 +863,8 @@ def api_wall_delete(post_id: str):
769
  posts.pop(i)
770
  _save_wall_posts(posts)
771
  return JSONResponse({"ok": True})
772
- return JSONResponse({"error": "Post not found"}, status_code=404)
773
-
774
- # ===== LANGUAGE & EMOTION DETECTION =====
775
- import random as _random2
776
- from urllib.parse import quote as _quote2
777
-
778
- _UA_RW = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept-Language': 'vi-VN,vi;q=0.9'}
779
-
780
- # Unique character markers for language detection
781
- _UNIQUE_CHARS = {
782
- 'vietnamese': set('đăâêôơưàảãạáằẳẵặắầẩẫậấèẻẽẹéềễểệếìỉĩịíòỏõọóồổỗộốờởỡ���ớùủũụúừửữựứỳỷỹỵý'),
783
- 'spanish': set('ñáéíóúü¿¡'),
784
- 'portuguese': set('ãõçáéíóúâêôà'),
785
- }
786
-
787
- _STOPWORDS = {
788
- 'english': {'the', 'is', 'at', 'which', 'on', 'a', 'an', 'and', 'or', 'but', 'in', 'with', 'to', 'for', 'of', 'not', 'no', 'can', 'had', 'have', 'has', 'was', 'were', 'are', 'be', 'been', 'this', 'that', 'it', 'he', 'she', 'they', 'his', 'her', 'my', 'your', 'our', 'we', 'you', 'i'},
789
- 'vietnamese': {'là', 'của', 'và', 'có', 'được', 'cho', 'không', 'với', 'này', 'đó', 'từ', 'trong', 'đã', 'sẽ', 'một', 'các', 'những', 'về', 'tại', 'người', 'năm', 'đến', 'ra', 'lại', 'như', 'khi', 'để', 'rất', 'cũng', 'mà', 'nếu', 'sau', 'trên', 'theo', 'vì', 'do', 'nên', 'thì', 'mình', 'tôi', 'bạn', 'anh', 'chị', 'em'},
790
- 'portuguese': {'de', 'um', 'que', 'e', 'do', 'da', 'em', 'para', 'com', 'não', 'uma', 'os', 'no', 'se', 'na', 'por', 'mais', 'as', 'dos', 'como', 'mas', 'ao', 'ele', 'das', 'tem', 'seu', 'sua', 'ou', 'quando', 'muito', 'nos', 'já', 'eu', 'também', 'só', 'pelo', 'pela', 'até', 'isso', 'ela', 'entre', 'depois', 'sem', 'mesmo', 'aos', 'são', 'está', 'ter', 'ser', 'foi', 'era', 'há', 'estão', 'você', 'nós', 'eles', 'elas'},
791
- 'spanish': {'de', 'que', 'el', 'en', 'y', 'a', 'los', 'del', 'se', 'las', 'por', 'un', 'para', 'con', 'no', 'una', 'su', 'al', 'es', 'lo', 'como', 'más', 'pero', 'sus', 'le', 'ya', 'o', 'fue', 'este', 'ha', 'si', 'porque', 'esta', 'son', 'entre', 'está', 'cuando', 'muy', 'sin', 'sobre', 'ser', 'también', 'me', 'hasta', 'hay', 'donde', 'han', 'quien', 'están', 'desde', 'todo', 'nos', 'durante', 'todos', 'uno', 'les', 'ni', 'contra', 'otros', 'fueron', 'ese', 'eso', 'ante', 'ellos', 'yo', 'tú', 'él', 'ella', 'nosotros', 'usted', 'ustedes'},
792
- }
793
-
794
- def detect_language(text):
795
- """Detect language from text content using stopword + character analysis."""
796
- if not text:
797
- return 'vietnamese'
798
- text_lower = text.lower()
799
- text_chars = set(text_lower)
800
-
801
- # Strong signal: Vietnamese unique characters
802
- vn_chars = len(text_chars & _UNIQUE_CHARS['vietnamese'])
803
- if vn_chars >= 2:
804
- return 'vietnamese'
805
-
806
- # Spanish unique chars (ñ, ¿, ¡)
807
- es_chars = len(text_chars & _UNIQUE_CHARS['spanish'])
808
- pt_chars = len(text_chars & _UNIQUE_CHARS['portuguese'])
809
-
810
- # Stopword scoring
811
- words = set(re.findall(r'\b\w+\b', text_lower))
812
- scores = {}
813
- for lang, stops in _STOPWORDS.items():
814
- scores[lang] = len(words & stops) / max(len(stops), 1)
815
-
816
- # Disambiguate Portuguese vs Spanish
817
- pt_markers = {'não', 'pelo', 'pela', 'isso', 'há', 'estão', 'num', 'numa', 'tenho', 'posso', 'você', 'nós', 'eles', 'elas', 'também', 'muito', 'já', 'só', 'até', 'entre', 'depois', 'sem', 'mesmo', 'aos', 'serão'}
818
- es_markers = {'pero', 'está', 'están', 'porque', 'también', 'hasta', 'donde', 'quien', 'fue', 'son', 'fueron', 'ese', 'eso', 'ante', 'ellos', 'ella', 'nosotros', 'usted', 'ustedes', 'tú', 'él', 'desde', 'todo', 'durante', 'todos', 'uno', 'les', 'ni', 'contra', 'otros', 'fueron'}
819
-
820
- pt_overlap = len(words & pt_markers)
821
- es_overlap = len(words & es_markers)
822
-
823
- if scores.get('portuguese', 0) > 0 and pt_overlap > es_overlap:
824
- return 'portuguese'
825
- if scores.get('spanish', 0) > 0 and es_overlap > pt_overlap:
826
- return 'spanish'
827
- if scores.get('english', 0) > 0.15:
828
- return 'english'
829
-
830
- best = max(scores, key=scores.get)
831
- return best if scores[best] > 0.05 else 'vietnamese'
832
-
833
- # Emotion keyword-based detection
834
- _EMOTION_KEYWORDS = {
835
- 'happy': {
836
- 'en': ['happy', 'joy', 'wonderful', 'great', 'amazing', 'fantastic', 'love', 'excellent', 'beautiful', 'glad', 'delighted', 'pleased', 'cheerful', 'celebrate', 'victory', 'win', 'success'],
837
- 'pt': ['feliz', 'alegria', 'maravilhoso', 'ótimo', 'incrível', 'fantástico', 'amor', 'excelente', 'lindo', 'contente', 'encantado', 'vitória', 'sucesso'],
838
- 'es': ['feliz', 'alegria', 'maravilloso', 'genial', 'increíble', 'fantástico', 'amor', 'excelente', 'hermoso', 'contento', 'encantado', 'victoria', 'éxito'],
839
- 'vi': ['vui', 'hạnh phúc', 'tuyệt vời', 'tuyệt', 'ý nghĩa', 'đẹp', 'thích', 'yêu', 'vui vẻ', 'hân hoan', 'phấn khích', 'chiến thắng', 'thành công'],
840
- },
841
- 'sad': {
842
- 'en': ['sad', 'unhappy', 'terrible', 'awful', 'horrible', 'miserable', 'depressed', 'grief', 'sorrow', 'tragic', 'unfortunate', 'painful', 'death', 'die', 'kill'],
843
- 'pt': ['triste', 'infeliz', 'terrível', 'horrível', 'miserável', 'deprimido', 'dor', 'trágico', 'infelizmente', 'penoso', 'morte', 'morrer'],
844
- 'es': ['triste', 'infeliz', 'terrible', 'horrible', 'miserable', 'deprimido', 'dolor', 'trágico', 'desafortunado', 'penoso', 'muerte', 'morir'],
845
- 'vi': ['buồn', 'không vui', 'tồi tệ', 'kinh khủng', 'đau khổ', 'đau buồn', 'bi thương', 'khốn nạn', 'đau đớn', 'thảm họa', 'chết', 'mất'],
846
- },
847
- 'excited': {
848
- 'en': ['excited', 'thrilling', 'amazing', 'wow', 'incredible', 'unbelievable', 'awesome', 'exhilarating', 'electrifying', 'breathtaking', 'breakthrough', 'record'],
849
- 'pt': ['animado', 'emocionante', 'incrível', 'impressionante', 'sensacional', 'eletrizante', 'empolgante', 'recorde'],
850
- 'es': ['emocionante', 'increíble', 'impresionante', 'sensacional', 'electrizante', 'emocionado', 'entusiasmado', 'récord'],
851
- 'vi': ['hào hứng', 'phấn khích', 'thú vị', 'tuyệt cú mèo', 'đỉnh cao', 'ngoạn mục', 'sục sôi', 'kỷ lục', 'đột phá'],
852
- },
853
- 'humorous': {
854
- 'en': ['funny', 'hilarious', 'joke', 'laugh', 'comedy', 'humor', 'amusing', 'witty', 'sarcastic', 'ironic', 'ridiculous', 'absurd', 'lol', 'haha'],
855
- 'pt': ['engraçado', 'hilário', 'piada', 'rir', 'comédia', 'humor', 'divertido', 'irônico', 'ridículo', 'absurdo', 'kkk'],
856
- 'es': ['gracioso', 'hilarante', 'broma', 'risa', 'comedia', 'humor', 'divertido', 'irónico', 'ridículo', 'absurdo', 'jaja'],
857
- 'vi': ['hài hước', 'buồn cười', 'đùa', 'cười', 'hài', 'vui nhộn', 'hóm hỉnh', 'mỉa mai', 'lố bịch', 'vô lý', 'haha'],
858
- },
859
- 'serious': {
860
- 'en': ['serious', 'critical', 'important', 'urgent', 'severe', 'grave', 'significant', 'crucial', 'vital', 'essential', 'alarming', 'concerning', 'crisis', 'war', 'conflict'],
861
- 'pt': ['sério', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'essencial', 'preocupante', 'crise', 'guerra', 'conflito'],
862
- 'es': ['serio', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'esencial', 'preocupante', 'crisis', 'guerra', 'conflicto'],
863
- 'vi': ['nghiêm trọng', 'quan trọng', 'khẩn cấp', 'nghiêm túc', 'đáng kể', 'thiết yếu', 'cần thiết', 'báo động', 'lo ngại', 'khủng hoảng', 'chiến tranh', 'xung đột'],
864
- },
865
- }
866
-
867
- def detect_emotion(text, language='vietnamese'):
868
- """Detect emotion from text using keyword matching."""
869
- if not text:
870
- return 'neutral'
871
- text_lower = text.lower()
872
-
873
- scores = {}
874
- for emotion, lang_keywords in _EMOTION_KEYWORDS.items():
875
- keywords = lang_keywords.get(language, lang_keywords.get('en', []))
876
- score = sum(1 for kw in keywords if kw in text_lower)
877
- scores[emotion] = score
878
-
879
- if max(scores.values()) == 0:
880
- return 'neutral'
881
-
882
- return max(scores, key=scores.get)
883
-
884
- def detect_language_and_emotion(title, text):
885
- """Detect both language and emotion from article content."""
886
- combined = f"{title} {text}"
887
- lang = detect_language(combined)
888
- emotion = detect_emotion(combined, lang)
889
- return lang, emotion
890
-
891
- # Voice selection based on language and emotion (using MultilingualNeural voices)
892
- VOICE_BY_LANG_EMOTION = {
893
- 'vietnamese': {
894
- 'happy': ('vi-VN-HoaiMyNeural', 'vui'),
895
- 'sad': ('vi-VN-NamMinhNeural', 'buồn'),
896
- 'excited': ('vi-VN-HoaiMyNeural', 'hào hứng'),
897
- 'humorous': ('vi-VN-HoaiMyNeural', 'vui'),
898
- 'serious': ('vi-VN-NamMinhNeural', 'nghiêm túc'),
899
- 'neutral': ('vi-VN-HoaiMyNeural', 'trung_tinh'),
900
- },
901
- 'portuguese': {
902
- 'happy': ('pt-BR-ThalitaMultilingualNeural', 'feliz'),
903
- 'sad': ('pt-BR-ThalitaMultilingualNeural', 'triste'),
904
- 'excited': ('pt-BR-ThalitaMultilingualNeural', 'animado'),
905
- 'humorous': ('pt-BR-ThalitaMultilingualNeural', 'engraçado'),
906
- 'serious': ('pt-BR-ThalitaMultilingualNeural', 'sério'),
907
- 'neutral': ('pt-BR-ThalitaMultilingualNeural', 'neutro'),
908
- },
909
- 'english': {
910
- 'happy': ('en-US-AndrewMultilingualNeural', 'happy'),
911
- 'sad': ('en-AU-WilliamMultilingualNeural', 'sad'),
912
- 'excited': ('en-US-AndrewMultilingualNeural', 'excited'),
913
- 'humorous': ('en-US-AndrewMultilingualNeural', 'funny'),
914
- 'serious': ('en-AU-WilliamMultilingualNeural', 'serious'),
915
- 'neutral': ('en-US-AndrewMultilingualNeural', 'neutral'),
916
- },
917
- 'french': {
918
- 'happy': ('fr-FR-VivienneMultilingualNeural', 'heureux'),
919
- 'sad': ('fr-FR-RemyMultilingualNeural', 'triste'),
920
- 'excited': ('fr-FR-VivienneMultilingualNeural', 'excité'),
921
- 'humorous': ('fr-FR-VivienneMultilingualNeural', 'drôle'),
922
- 'serious': ('fr-FR-RemyMultilingualNeural', 'sérieux'),
923
- 'neutral': ('fr-FR-VivienneMultilingualNeural', 'neutre'),
924
- },
925
- 'german': {
926
- 'happy': ('de-DE-SeraphinaMultilingualNeural', 'glücklich'),
927
- 'sad': ('de-DE-FlorianMultilingualNeural', 'traurig'),
928
- 'excited': ('de-DE-SeraphinaMultilingualNeural', 'aufgeregt'),
929
- 'humorous': ('de-DE-SeraphinaMultilingualNeural', 'lustig'),
930
- 'serious': ('de-DE-FlorianMultilingualNeural', 'ernst'),
931
- 'neutral': ('de-DE-SeraphinaMultilingualNeural', 'neutral'),
932
- },
933
- 'korean': {
934
- 'happy': ('ko-KR-HyunsuMultilingualNeural', '행복'),
935
- 'sad': ('ko-KR-HyunsuMultilingualNeural', '슬픔'),
936
- 'excited': ('ko-KR-HyunsuMultilingualNeural', '흥분'),
937
- 'humorous': ('ko-KR-HyunsuMultilingualNeural', '유쾌'),
938
- 'serious': ('ko-KR-HyunsuMultilingualNeural', '진지'),
939
- 'neutral': ('ko-KR-HyunsuMultilingualNeural', '중립'),
940
- },
941
- 'italian': {
942
- 'happy': ('it-IT-GiuseppeMultilingualNeural', 'felice'),
943
- 'sad': ('it-IT-GiuseppeMultilingualNeural', 'triste'),
944
- 'excited': ('it-IT-GiuseppeMultilingualNeural', 'emozionato'),
945
- 'humorous': ('it-IT-GiuseppeMultilingualNeural', 'divertente'),
946
- 'serious': ('it-IT-GiuseppeMultilingualNeural', 'serio'),
947
- 'neutral': ('it-IT-GiuseppeMultilingualNeural', 'neutro'),
948
- },
949
- }
950
-
951
- # All valid voice IDs (new MultilingualNeural format)
952
- VALID_VOICES = {
953
- 'vi-VN-HoaiMyNeural', 'vi-VN-NamMinhNeural',
954
- 'en-US-AndrewMultilingualNeural', 'en-AU-WilliamMultilingualNeural',
955
- 'pt-BR-ThalitaMultilingualNeural',
956
- 'fr-FR-VivienneMultilingualNeural', 'fr-FR-RemyMultilingualNeural',
957
- 'de-DE-SeraphinaMultilingualNeural', 'de-DE-FlorianMultilingualNeural',
958
- 'ko-KR-HyunsuMultilingualNeural',
959
- 'it-IT-GiuseppeMultilingualNeural',
960
- }
961
-
962
- def get_voice_for_content(title, text, preferred_voice=None):
963
- """Get appropriate voice based on content language and emotion."""
964
- # Accept the new MultilingualNeural voices directly
965
- if preferred_voice and preferred_voice in VALID_VOICES:
966
- return preferred_voice
967
-
968
- # Also accept old shorthand voice IDs and map them to new format
969
- old_voice_map = {
970
- 'hoaimy': 'vi-VN-HoaiMyNeural',
971
- 'namminh': 'vi-VN-NamMinhNeural',
972
- 'andrew': 'en-US-AndrewMultilingualNeural',
973
- 'jenny': 'en-US-AndrewMultilingualNeural',
974
- 'thalita': 'pt-BR-ThalitaMultilingualNeural',
975
- 'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
976
- 'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
977
- 'ela': 'en-US-AndrewMultilingualNeural',
978
- 'es_carlos': 'en-US-AndrewMultilingualNeural',
979
- 'denise': 'fr-FR-VivienneMultilingualNeural',
980
- 'katja': 'de-DE-SeraphinaMultilingualNeural',
981
- 'nanami': 'en-US-AndrewMultilingualNeural',
982
- 'sunhee': 'ko-KR-HyunsuMultilingualNeural',
983
- 'xiaochen': 'en-US-AndrewMultilingualNeural',
984
- }
985
- if preferred_voice and preferred_voice in old_voice_map:
986
- return old_voice_map[preferred_voice]
987
-
988
- lang, emotion = detect_language_and_emotion(title, text)
989
- lang_map = VOICE_BY_LANG_EMOTION.get(lang, VOICE_BY_LANG_EMOTION['vietnamese'])
990
- voice, _ = lang_map.get(emotion, lang_map['neutral'])
991
- return voice
992
-
993
-
994
- def _is_relevant_image(img_url, title, text):
995
- """Check if an image is relevant to the article content."""
996
- if not img_url:
997
- return False
998
- skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif',
999
- 'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite',
1000
- 'advertisement', 'ad-banner', 'sponsored', 'banner-ads']
1001
- img_lower = img_url.lower()
1002
- for p in skip_patterns:
1003
- if p in img_lower:
1004
- return False
1005
- if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']):
1006
- return False
1007
- return True
1008
-
1009
-
1010
- def _filter_relevant_images(images, title, text, max_images=8):
1011
- """Filter and rank images by relevance to article content."""
1012
- if not images:
1013
- return []
1014
- seen = set()
1015
- relevant = []
1016
- for img in images:
1017
- if img in seen:
1018
- continue
1019
- seen.add(img)
1020
- if _is_relevant_image(img, title, text):
1021
- relevant.append(img)
1022
- return relevant[:max_images]
1023
-
1024
-
1025
- def _scrape_article_for_rewrite(url):
1026
- """Scrape article: extract title, paragraphs, RELEVANT images, OG image."""
1027
- try:
1028
- r = req.get(url, headers=_UA_RW, timeout=15, allow_redirects=True)
1029
- r.encoding = 'utf-8'
1030
- soup = BeautifulSoup(r.text, 'lxml')
1031
- for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
1032
- tag.decompose()
1033
- h1 = soup.find('h1')
1034
- ogt = soup.find('meta', property='og:title')
1035
- title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '')
1036
- ogi = soup.find('meta', property='og:image')
1037
- og_img = ogi.get('content', '') if ogi else ''
1038
- if og_img and og_img.startswith('//'):
1039
- og_img = 'https:' + og_img
1040
- block = None
1041
- for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
1042
- el = soup.select_one(sel)
1043
- if el and len(el.find_all('p')) >= 2:
1044
- block = el
1045
- break
1046
- if not block:
1047
- block = soup.body or soup
1048
- paragraphs = []
1049
- all_images = []
1050
- seen_imgs = set()
1051
- if og_img and og_img not in seen_imgs:
1052
- all_images.append(og_img)
1053
- seen_imgs.add(og_img)
1054
- for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
1055
- if el.name == 'p':
1056
- t = _clean(el.get_text(strip=True))
1057
- if t and len(t) > 40:
1058
- paragraphs.append(t)
1059
- elif el.name in ('figure', 'img'):
1060
- im = el if el.name == 'img' else el.find('img')
1061
- if im:
1062
- src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
1063
- if src and 'base64' not in src:
1064
- if src.startswith('//'):
1065
- src = 'https:' + src
1066
- if src not in seen_imgs:
1067
- all_images.append(src)
1068
- seen_imgs.add(src)
1069
- # Filter to relevant images only
1070
- relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5]))
1071
- return {'title': _clean(title), 'paragraphs': paragraphs, 'images': relevant_images, 'og_img': og_img}
1072
- except Exception:
1073
- return None
1074
-
1075
-
1076
- def _extract_key_points_rw(paragraphs, max_points=5):
1077
- """Extract key points from paragraphs - extracts ALL sentences, not just first one.
1078
-
1079
- Fixes: Original regex `^(.+?[.!?])\s` only captured first sentence per paragraph.
1080
- Now splits on all sentence boundaries and takes valid sentences until max_points.
1081
- """
1082
- points = []
1083
-
1084
- for p in paragraphs:
1085
- if len(points) >= max_points:
1086
- break
1087
-
1088
- p = _clean(p)
1089
- if not p:
1090
- continue
1091
-
1092
- # Split paragraph into sentences using Vietnamese + English punctuation
1093
- sentences = re.split(r'(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])', p)
1094
- sentences = [s.strip() for s in sentences if s.strip()]
1095
-
1096
- for sentence in sentences:
1097
- if len(points) >= max_points:
1098
- break
1099
-
1100
- # Clean sentence - remove extra whitespace
1101
- sentence = _clean(sentence)
1102
-
1103
- if len(sentence) < 30:
1104
- continue
1105
-
1106
- # Check for duplicates
1107
- if any(sentence[:60] in existing for existing in points):
1108
- continue
1109
-
1110
- # Ensure sentence ends with punctuation
1111
- if not sentence.endswith(('.', '!', '?')):
1112
- sentence = sentence + '.'
1113
-
1114
- points.append(sentence)
1115
-
1116
- # If no valid sentences found, take chunks from raw text
1117
- if not points:
1118
- raw = '\n'.join(paragraphs)
1119
- for i in range(0, min(len(raw), max_points * 300), 280):
1120
- chunk = _clean(raw[i:i+280])
1121
- if len(chunk) >= 30 and chunk not in points:
1122
- points.append(chunk + ('.' if not chunk.endswith('.') else ''))
1123
- if len(points) >= max_points:
1124
- break
1125
-
1126
- return points
1127
-
1128
-
1129
- @app.post("/api/rewrite_slide")
1130
- async def api_rewrite_slide(request: Request):
1131
- """Fast rewrite as SLIDES - no AI needed, instant response."""
1132
- body = await request.json()
1133
- url = _clean(body.get("url", ""))
1134
- context = body.get("context", "")
1135
- preferred_voice = body.get("voice", "") # Accept custom voice selection
1136
- if not url and not context:
1137
- return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
1138
- data = None
1139
- if url and url.startswith("http"):
1140
- data = _scrape_article_for_rewrite(url)
1141
- if not data and context:
1142
- paragraphs = [_clean(p) for p in context.split('\n') if len(_clean(p)) > 40]
1143
- data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
1144
- if not data or not data.get('paragraphs'):
1145
- return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
1146
- points = _extract_key_points_rw(data['paragraphs'], max_points=12)
1147
- if not points:
1148
- return JSONResponse({"error": "Không tìm được ý chính"}, status_code=422)
1149
- images = data.get('images', [])
1150
- slides = []
1151
- for i, point in enumerate(points):
1152
- img = images[i] if i < len(images) else (images[-1] if images else '')
1153
- if img and 'cdnphoto.dantri' in img:
1154
- img = '/api/proxy/img?url=' + _quote2(img, safe='')
1155
- slides.append({'text': point, 'image': img, 'index': i + 1})
1156
- summary_text = '\n\n'.join([f"• {s['text']}" for s in slides])
1157
-
1158
- # Auto-detect language and emotion
1159
- lang, emotion = detect_language_and_emotion(data['title'], summary_text)
1160
- # Use preferred voice if provided, otherwise auto-detect
1161
- voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], summary_text)
1162
-
1163
- post = {
1164
- "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
1165
- "title": data['title'],
1166
- "text": summary_text,
1167
- "img": images[0] if images else '',
1168
- "url": url,
1169
- "kind": "slide_summary",
1170
- "slides": slides,
1171
- "images": images[:10],
1172
- "video": "",
1173
- "voice": voice,
1174
- "emotion": emotion,
1175
- "language": lang,
1176
- "ts": int(time.time())
1177
- }
1178
- posts = _load_wall_posts()
1179
- posts.insert(0, post)
1180
- _save_wall_posts(posts)
1181
- return JSONResponse({"post": post, "slides": slides})
1182
-
1183
-
1184
- @app.post("/api/rewrite_share")
1185
- async def api_rewrite_share(request: Request):
1186
- """Rewrite article and post to Tường AI with SLIDES + AI text."""
1187
- body = await request.json()
1188
- url = _clean(body.get("url", ""))
1189
- ctx = _clean(body.get("context", ""))
1190
- preferred_voice = body.get("voice", "") # Accept custom voice selection
1191
- if not url and not ctx:
1192
- return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
1193
- data = None
1194
- if url and url.startswith("http"):
1195
- data = _scrape_article_for_rewrite(url)
1196
- if not data and ctx:
1197
- paragraphs = [_clean(p) for p in ctx.split('\n') if len(_clean(p)) > 40]
1198
- data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
1199
- if not data or not data.get('paragraphs'):
1200
- return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
1201
- raw_text = '\n'.join(data['paragraphs'])
1202
- if len(raw_text) < 50:
1203
- raw_text = ctx[:14000]
1204
- if len(raw_text) < 50:
1205
- return JSONResponse({"error": "Bài viết quá ngắn"}, status_code=422)
1206
- domain = ''
1207
- try:
1208
- from urllib.parse import urlparse
1209
- domain = urlparse(url).netloc.replace('www.', '')
1210
- except:
1211
- pass
1212
-
1213
- # Generate AI summary text
1214
- ai_text = None
1215
- try:
1216
- import ai_ext
1217
- if hasattr(ai_ext, 'qwen_generate'):
1218
- prompt = f'Tóm tắt đăng Tường AI:\nTiêu đề: {data["title"]}\n{raw_text[:14000]}\n\n4-6 ý chính. Cuối ghi nguồn.'
1219
- ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000)
1220
- except Exception:
1221
- pass
1222
- if not ai_text or len(ai_text) < 80:
1223
- key_pts = _extract_key_points_rw(data['paragraphs'], max_points=12)
1224
- if key_pts:
1225
- ai_text = '\n\n'.join([f"• {p}" for p in key_pts])
1226
- else:
1227
- ai_text = f"Tóm tắt: {data['title']}\n\n{raw_text[:1200]}\n\nNguồn: {domain}"
1228
-
1229
- # Build slides from key points (FIX: include slides in rewrite_share too!)
1230
- points = _extract_key_points_rw(data['paragraphs'], max_points=12)
1231
- images = data.get('images', [])
1232
- slides = []
1233
- for i, point in enumerate(points):
1234
- img = images[i] if i < len(images) else (images[-1] if images else '')
1235
- if img and 'cdnphoto.dantri' in img:
1236
- img = '/api/proxy/img?url=' + _quote2(img, safe='')
1237
- slides.append({'text': point, 'image': img, 'index': i + 1})
1238
-
1239
- # Auto-detect language and emotion
1240
- lang, emotion = detect_language_and_emotion(data['title'], ai_text)
1241
- # Use preferred voice if provided, otherwise auto-detect
1242
- voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], ai_text)
1243
-
1244
- post = {
1245
- "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
1246
- "title": data['title'],
1247
- "text": ai_text,
1248
- "img": images[0] if images else '',
1249
- "url": url,
1250
- "kind": "rewrite",
1251
- "slides": slides,
1252
- "images": images[:10],
1253
- "video": "",
1254
- "voice": voice,
1255
- "emotion": emotion,
1256
- "language": lang,
1257
- "ts": int(time.time())
1258
- }
1259
- posts = _load_wall_posts()
1260
- posts.insert(0, post)
1261
- _save_wall_posts(posts)
1262
- return JSONResponse({"post": post, "slides": slides})
1263
-
1264
-
1265
- @app.post("/api/url_wall")
1266
- async def api_url_wall(request: Request):
1267
- """Submit URL to add to Tường AI."""
1268
- body = await request.json()
1269
- url = _clean(body.get("url", ""))
1270
- if not url or not url.startswith('http'):
1271
- return JSONResponse({"error": "URL không hợp lệ"}, status_code=400)
1272
- # Reuse rewrite_share logic
1273
- req._body = json.dumps({"url": url}).encode()
1274
- return await api_rewrite_share(request)
1275
 
 
1276
 
1277
  def _bg():
1278
  time.sleep(15)
 
1
+ """VNEWS v2 Entry Point - with fast bongda proxy"""
2
  import sys, os
3
  from main import app, HEADERS, BONGDA_HEADERS, fetch_bongda_api, HL_LEAGUES
4
 
 
7
  except Exception as e:
8
  print(f"[WARN] ai_ext import failed: {e}")
9
 
 
 
 
 
 
10
  from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response
11
  from fastapi.staticfiles import StaticFiles
12
  from starlette.routing import Mount
 
322
  if i<len(s) and s[i].get('url') and s[i]['url'] not in seen:seen.add(s[i]['url']);out.append(s[i])
323
  return out[:limit]
324
 
325
+ # Remove main.py routes that app_v2_entry overrides (main.py registers first, FastAPI uses first match)
326
  for _path in ['/api/article', '/api/hot_topics', '/api/categories', '/api/storage_status', '/s']:
327
  app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)==_path and 'GET' in getattr(r,'methods',set()))]
328
 
329
+ # ===== Article cache (TTL 30 min, keyed by URL) =====
330
  _article_cache = {}
331
  _article_cache_ttl = 1800
332
 
333
+ # Dedicated session for article scraping (no rate limiter — we only scrape one article at a time per request)
334
  _art_session = None
335
  _art_lock = threading.Lock()
336
  def _get_art_session():
 
347
  return _art_session
348
 
349
  def _scrape_article_fast(url):
350
+ """Fast article scrape — single request, no rate limiter, fail fast with OG fallback."""
351
  from urllib.parse import urlparse
352
  domain = urlparse(url).netloc
353
  sess = _get_art_session()
354
+
355
+ # Try mobile UA first (lighter HTML), then desktop
356
  uas = [
357
  {"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"},
358
  {"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"},
359
  ]
360
+
361
  for ua in uas:
362
  try:
363
  r = sess.get(url, headers=ua, timeout=6, allow_redirects=True)
 
365
  continue
366
  r.encoding = 'utf-8'
367
  soup = BeautifulSoup(r.text, 'lxml')
368
+
369
+ # Remove junk
370
  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']):
371
  tag.decompose()
372
+
373
+ # Extract OG meta
374
  title = summary = og_img = ""
375
  ogt = soup.find('meta', property='og:title')
376
  if ogt: title = ogt.get('content', '')
 
382
  if og_img.startswith('//'): og_img = 'https:' + og_img
383
  h1 = soup.find('h1')
384
  if not title and h1: title = h1.get_text(strip=True)[:200]
385
+
386
+ # Try to find article body
387
  body = []
388
  selectors = [
389
+ '.fck_detail', '.sidebar-1', # VnExpress
390
+ '.singular-content', '.dt__content', '.article-content', '.content-detail', '#divNewsContent', # Dân Trí
391
+ '.content-detail', '.main-content-detail', '.box-content', # Tuổi Trẻ
392
+ '.knc-content', '.article-body', '.detail-body', # Kenh14/GenK
393
+ '.article-detail', '.detail-content', # Thanh Niên
394
  'article', 'main', '.cms-body', '.article__body', '.post-content',
395
  '.entry-content', '#content', '.article-text', '.story-body',
396
  ]
 
423
  if len(body) >= 2:
424
  return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img,
425
  'body': body[:50], 'source': domain, 'url': url}
426
+
427
+ # No body found — use OG meta as fallback
428
  if title and (summary or og_img):
429
  fallback = []
430
  if og_img: fallback.append({'type': 'img', 'src': og_img})
 
432
  if fallback:
433
  return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img,
434
  'body': fallback, 'source': domain, 'url': url, 'fallback': True}
435
+
436
+ # Got HTML but no body and no OG — return title at least
437
  if title:
438
  return {'title': _clean(title), 'summary': '', 'og_image': '',
439
  'body': [{'type': 'p', 'text': 'Nội dung đang được tải...'}],
440
  'source': domain, 'url': url, 'fallback': True}
441
+
442
+ break # Got 200 but no content at all — don't retry other UA
443
  except Exception:
444
  continue
445
+
446
  return None
447
 
448
  @app.get('/api/article')
449
  def api_article_v2(url: str = Query(...)):
450
+ """Scrape article and return JSON for VNEWS SPA. Fast, cached, with fallback."""
451
  from urllib.parse import unquote
452
  safe_url = unquote(url)
453
+
454
  try:
455
+ # Check cache first
456
  now = time.time()
457
  cached = _article_cache.get(safe_url)
458
  if cached and now - cached['t'] < _article_cache_ttl:
459
  resp = JSONResponse(cached['d'])
460
  resp.headers["Cache-Control"] = "public, max-age=1800"
461
  return resp
462
+
463
+ # Fetch fresh — use fast scraper for ALL sites (simpler, more reliable)
464
  data = _scrape_article_fast(safe_url)
465
+
466
  if data and data.get('body'):
467
  _article_cache[safe_url] = {'d': data, 't': now}
468
  resp = JSONResponse(data)
469
  resp.headers["Cache-Control"] = "public, max-age=1800"
470
  return resp
471
+
472
+ # Last resort: try RSS fallback
473
+ try:
474
+ from main import _fetch_rss_fallback
475
+ from urllib.parse import urlparse as _up
476
+ rss_data = _fetch_rss_fallback(safe_url, _up(safe_url).netloc)
477
+ if rss_data and rss_data.get('title'):
478
+ body = []
479
+ if rss_data.get('og_image'):
480
+ body.append({'type': 'img', 'src': rss_data['og_image']})
481
+ if rss_data.get('summary'):
482
+ sentences = re.split(r'(?<=[.!?])\s+', rss_data['summary'])
483
+ for s in sentences[:10]:
484
+ if len(s.strip()) > 20:
485
+ body.append({'type': 'p', 'text': s.strip()})
486
+ if body:
487
+ result = {
488
+ 'title': rss_data['title'], 'summary': rss_data['summary'][:500],
489
+ 'og_image': rss_data.get('og_image', ''), 'body': body[:50],
490
+ 'source': 'rss', 'url': safe_url, 'fallback': True, 'rss': True
491
+ }
492
+ _article_cache[safe_url] = {'d': result, 't': now}
493
+ resp = JSONResponse(result)
494
+ resp.headers["Cache-Control"] = "public, max-age=600"
495
+ return resp
496
+ except Exception:
497
+ pass
498
+
499
  result = {'error': 'Không đọc được', 'url': safe_url}
500
  resp = JSONResponse(result)
501
  resp.headers["Cache-Control"] = "public, max-age=60"
502
  return resp
503
  except Exception as e:
504
+ import traceback
505
+ tb = traceback.format_exc()
506
+ return JSONResponse({'error': f'Server error: {str(e)[:100]}', 'trace': tb[-500:], 'url': safe_url}, status_code=200)
507
 
508
  _hot_cache={'t':0,'d':[]}
509
  def _get_hot_topics():
 
556
  @app.get('/s')
557
  async def _sh(url:str='',title:str='',img:str=''):return HTMLResponse(f'<!DOCTYPE html><html><head><meta property="og:title" content="{_clean(title)}"><meta property="og:image" content="{_clean(img)}"><meta http-equiv="refresh" content="0;url={_clean(url) or "/"}"></head><body></body></html>')
558
 
559
+ 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)
560
 
561
+ # === XEMLAIBONGDA PROXY (CORS workaround for WC highlights) ===
562
  _xlb_cache = {}
563
  _xlb_lock = threading.Lock()
564
 
 
696
  with _il:idb=_lj(IF);idb.setdefault(v,{'views':0,'likes':0,'comments':0});idb[v]['comments']=len(cms);_sj(IF,idb)
697
  return JSONResponse({'comments':cms})
698
 
699
+ # ===== WALL / SHORT AI ENDPOINTS =====
700
+
701
  def _load_wall_posts():
702
+ """Load wall posts from JSON file."""
703
  with _wl_lock:
704
  return _lj(WALL_FILE)
705
 
706
  def _save_wall_posts(posts):
707
+ """Save wall posts to JSON file."""
708
  with _wl_lock:
709
  _sj(WALL_FILE, posts)
710
 
711
  @app.get('/api/wall')
712
  def api_wall():
713
+ """Get all wall posts."""
714
  posts = _load_wall_posts()
715
  if not posts:
716
+ # Return empty list, not error
717
  return JSONResponse({"posts": []})
718
  return JSONResponse({"posts": posts})
719
 
720
  @app.post('/api/wall')
721
  async def api_wall_post(request: Request):
722
+ """
723
+ Create a wall post. Supports:
724
+ - JSON body: {title, text, img, source}
725
+ - Multipart form: title, text, source + video file upload
726
+ """
727
  content_type = request.headers.get('content-type', '')
728
+
729
+ # Handle multipart upload (video file)
730
  if 'multipart/form-data' in content_type:
731
  try:
732
  form = await request.form()
733
  except Exception as e:
734
  return JSONResponse({"error": f"Form parse error: {str(e)}"}, status_code=400)
735
+
736
  title = form.get('title', 'Video mới') or 'Video mới'
737
  text = form.get('text', '') or ''
738
  source = form.get('source', 'vtv_recorder') or 'vtv_recorder'
739
  video_file = form.get('video')
740
+
741
  post_id = str(uuid.uuid4())[:12]
742
  video_url = None
743
+
744
+ # Save video file if provided
745
  if video_file and hasattr(video_file, 'filename') and video_file.filename:
746
+ # Determine extension
747
  fname = video_file.filename.lower()
748
  if fname.endswith('.mp4'):
749
  ext = '.mp4'
 
751
  ext = '.webm'
752
  else:
753
  ext = '.webm'
754
+
755
  video_filename = f"wall_{post_id}{ext}"
756
  video_path = os.path.join(WALL_VIDEO_DIR, video_filename)
757
+
758
  try:
759
+ # Read file content
760
  content = await video_file.read()
761
  if not content:
762
  return JSONResponse({"error": "Empty video file"}, status_code=400)
763
+
764
+ # Save to disk
765
  with open(video_path, 'wb') as f:
766
  f.write(content)
767
+
768
  file_size_mb = len(content) / 1024 / 1024
769
  if file_size_mb > 50:
770
  os.remove(video_path)
771
  return JSONResponse({"error": f"Video quá lớn ({file_size_mb:.1f}MB). Tối đa 50MB."}, status_code=400)
772
+
773
+ # URL to access the video
774
  video_url = f"/api/wall/video/{video_filename}"
775
  except Exception as e:
776
  return JSONResponse({"error": f"Lỗi lưu video: {str(e)}"}, status_code=500)
777
+
778
+ # Create post
779
  post = {
780
  "id": post_id,
781
  "title": title[:200],
 
787
  "created": int(time.time()),
788
  "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
789
  }
790
+
791
+ # Save to wall
792
  posts = _load_wall_posts()
793
  if not isinstance(posts, list):
794
  posts = []
795
  posts.insert(0, post)
796
+ # Keep max 200 posts
797
  posts = posts[:200]
798
  _save_wall_posts(posts)
799
+
800
  return JSONResponse({"post": post, "ok": True})
801
+
802
+ # Handle JSON body (text-only post)
803
  try:
804
  body = await request.json()
805
  except:
806
  body = {}
807
+
808
  title = body.get('title', 'Bài mới') or 'Bài mới'
809
  text = body.get('text', '') or ''
810
  img = body.get('img', None)
811
  source = body.get('source', 'user') or 'user'
812
+
813
  post_id = str(uuid.uuid4())[:12]
814
  post = {
815
  "id": post_id,
 
822
  "created": int(time.time()),
823
  "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
824
  }
825
+
826
  posts = _load_wall_posts()
827
  if not isinstance(posts, list):
828
  posts = []
829
  posts.insert(0, post)
830
  posts = posts[:200]
831
  _save_wall_posts(posts)
832
+
833
  return JSONResponse({"post": post, "ok": True})
834
 
835
  @app.get('/api/wall/video/{filename}')
836
  def api_wall_video(filename: str):
837
+ """Serve a wall video file."""
838
+ # Security: prevent path traversal
839
  if '..' in filename or '/' in filename:
840
  return Response(status_code=403)
841
  video_path = os.path.join(WALL_VIDEO_DIR, filename)
 
847
 
848
  @app.delete('/api/wall/{post_id}')
849
  def api_wall_delete(post_id: str):
850
+ """Delete a wall post and its video."""
851
  posts = _load_wall_posts()
852
  if not isinstance(posts, list):
853
  return JSONResponse({"error": "No posts"}, status_code=404)
854
+
855
  for i, p in enumerate(posts):
856
  if p.get('id') == post_id:
857
+ # Delete video file if exists
858
  if p.get('video'):
859
  video_name = p['video'].split('/')[-1]
860
  video_path = os.path.join(WALL_VIDEO_DIR, video_name)
 
863
  posts.pop(i)
864
  _save_wall_posts(posts)
865
  return JSONResponse({"ok": True})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
866
 
867
+ return JSONResponse({"error": "Post not found"}, status_code=404)
868
 
869
  def _bg():
870
  time.sleep(15)
main.py CHANGED
@@ -1,10 +1,10 @@
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=10)) # Timezone +10
8
  from concurrent.futures import ThreadPoolExecutor, as_completed
9
  from fastapi import FastAPI, Query, Request
10
  from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, Response
@@ -14,29 +14,38 @@ from bs4 import BeautifulSoup
14
 
15
  app = FastAPI()
16
 
 
 
17
  # ===== WORLD CUP 2026 SCRAPER =====
18
  from wc2026_scraper import get_wc2026_all, scrape_fixtures, scrape_standings, scrape_stats, scrape_wc_news
19
 
20
  # ===== RATE LIMITING =====
21
- _rate_limit_data = defaultdict(list)
22
  _rate_limit_lock = threading.Lock()
23
- RATE_LIMIT_MAX = 60
24
- RATE_LIMIT_WINDOW = 60
25
 
26
  def _check_rate_limit(ip: str) -> bool:
 
27
  with _rate_limit_lock:
28
  now = time.time()
 
29
  _rate_limit_data[ip] = [t for t in _rate_limit_data[ip] if now - t < RATE_LIMIT_WINDOW]
30
- if len(_rate_limit_data[ip]) >= RATE_LIMIT_MAX: 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
@@ -50,6 +59,63 @@ _cache_ttl = 300
50
  _cache_ttl_live = 60
51
  _cache_ttl_yt = 1800
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  PRIORITY_LEAGUES = ["Ngoại Hạng Anh","FA Cup","Champions League","LaLiga","Copa del Rey","Serie A","Bundesliga","Ligue 1","V-League"]
54
  LEAGUE_IDS = {"nha":27110,"laliga":27233,"seriea":27044,"bundesliga":26891,"ligue1":27212}
55
  HL_LEAGUES = {
@@ -136,84 +202,215 @@ def proxy_video(url: str = Query(...), request: Request = None):
136
  @app.get("/api/proxy/img")
137
  def proxy_img(url: str = Query(...)):
138
  try:
139
- 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):
@@ -225,34 +422,95 @@ def scrape_all_league_highlights():
225
  with ThreadPoolExecutor(8) as ex:
226
  futs = [ex.submit(_fetch, k) for k in HL_LEAGUES]
227
  for f in as_completed(futs, timeout=25):
228
- try: 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)})
@@ -305,6 +563,41 @@ def api_livescore_featured():
305
  return None
306
  return JSONResponse(_cached("ls_featured",_f,ttl=30))
307
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
  @app.get("/api/highlights")
309
  def api_highlights(): return JSONResponse(_cached("xemlaibongda_hl",scrape_xemlaibongda,ttl=_cache_ttl))
310
  @app.get("/api/highlights/leagues")
@@ -315,7 +608,7 @@ def api_highlights_league(league:str):
315
  return JSONResponse(_cached(f"hl_{league}",lambda:scrape_highlights_by_league(league),ttl=_cache_ttl))
316
 
317
  @app.get("/api/video_url")
318
- def api_video_url(url:str=Query(...), 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"})
@@ -323,39 +616,69 @@ def api_video_url(url:str=Query(...), img:str=Query(default="")):
323
  v=extract_xemlaibongda_video(url)
324
  if v:
325
  if v["type"]=="hls": v["src"]="/api/proxy/m3u8?url="+quote(v["src"],safe="")
326
- if not v.get("poster") and img: v["poster"] = img
327
  return JSONResponse(v)
328
  return JSONResponse({"error":"not found"})
329
 
330
  # ===== WORLD CUP 2026 API =====
331
- _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():
@@ -412,7 +735,8 @@ def scrape_genk_ai():
412
  for img in container.find_all("img"):
413
  s=img.get("data-src","") or img.get("src","")
414
  if s and "mediacdn" in s and "avatar" not in s and "logo" not in s: img_src=s; break
415
- if img_src: break; container=container.parent
 
416
  seen.add(href)
417
  if not img_src:
418
  try:
@@ -455,57 +779,9 @@ def api_categories():
455
  for k,(u,n) in VNE_CATS.items(): cats.append({"id":k,"name":n,"source":"vne"})
456
  return JSONResponse(cats)
457
 
458
- @app.get("/api/proxy/xlb")
459
- def api_xlb(path: str = Query(default=""), limit: int = Query(default=20)):
460
- try:
461
- url = f"https://xemlaibongda.top/{path}" if path else "https://xemlaibongda.top/"
462
- r = requests.get(url, headers=HEADERS, timeout=15)
463
- if r.status_code != 200: return JSONResponse({"videos": []})
464
- r.encoding = "utf-8"
465
- soup = BeautifulSoup(r.text, "lxml")
466
- videos, seen = [], set()
467
- for a in soup.find_all("a", href=True):
468
- href = a.get("href", "")
469
- if "/video/" not in href and "/xem-lai/" not in href: continue
470
- if not href.startswith("http"): href = "https://xemlaibongda.top" + href
471
- clean = href.split("?")[0].split("#")[0]
472
- if clean in seen: continue
473
- seen.add(clean)
474
- img_src = ""
475
- img = a.find("img") or (a.parent.find("img") if a.parent else None)
476
- if not img:
477
- p = a.parent
478
- for _ in range(5):
479
- if p and p.find("img"): img = p.find("img"); break
480
- p = p.parent if p else None
481
- if img:
482
- img_src = (img.get("data-src", "") or img.get("src", "") or img.get("data-lazy", "") or img.get("data-original", ""))
483
- if img_src.startswith("//"): img_src = "https:" + img_src
484
- elif img_src.startswith("/"): img_src = "https://xemlaibongda.top" + img_src
485
- title = a.find("h3")
486
- if not title: title = a.find("h2")
487
- if not title: title = a.find("strong")
488
- t = title.get_text(strip=True) if title else ""
489
- if not t:
490
- slug = clean.split("/video/")[-1].rstrip("/")
491
- t = slug.replace("-", " ").title()
492
- videos.append({"title": t[:100], "link": clean, "img": img_src, "source": "xemlaibongda"})
493
- if len(videos) >= limit: break
494
- return JSONResponse({"videos": videos})
495
- except Exception as e:
496
- return JSONResponse({"videos": [], "error": str(e)})
497
-
498
  @app.get("/api/article")
499
  def api_article(url:str=Query(...)):
500
- 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():
@@ -517,4 +793,5 @@ def api_hot_topics():
517
 
518
  @app.get("/", response_class=HTMLResponse)
519
  async def root():
520
- return HTMLResponse("<h1>VNEWS v17</h1><p>VTV sv2.xemtivitop.com · Timezone +10 · EPG vtv.vn</p>")
 
 
1
+ """VNEWS - FastAPI backend with livescore + xemlaibongda highlights + YouTube VTV shorts"""
2
  import re, time, subprocess, json, os, threading
3
  import html as html_lib
4
  from datetime import datetime, timezone, timedelta
5
  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
 
14
 
15
  app = FastAPI()
16
 
17
+ # ===== RATE LIMITING =====app = FastAPI()
18
+
19
  # ===== WORLD CUP 2026 SCRAPER =====
20
  from wc2026_scraper import get_wc2026_all, scrape_fixtures, scrape_standings, scrape_stats, scrape_wc_news
21
 
22
  # ===== RATE LIMITING =====
23
+ _rate_limit_data = defaultdict(list) # {ip: [timestamp1, timestamp2, ...]}
24
  _rate_limit_lock = threading.Lock()
25
+ RATE_LIMIT_MAX = 60 # Max requests per minute per IP
26
+ RATE_LIMIT_WINDOW = 60 # seconds
27
 
28
  def _check_rate_limit(ip: str) -> bool:
29
+ """Kiểm tra rate limit, return True nếu OK, False nếu bị limit"""
30
  with _rate_limit_lock:
31
  now = time.time()
32
+ # Xóa các request cũ
33
  _rate_limit_data[ip] = [t for t in _rate_limit_data[ip] if now - t < RATE_LIMIT_WINDOW]
34
+ if len(_rate_limit_data[ip]) >= RATE_LIMIT_MAX:
35
+ return False
36
  _rate_limit_data[ip].append(now)
37
  return True
38
 
39
  @app.middleware("http")
40
  async def rate_limit_middleware(request: Request, call_next):
41
+ """Middleware để kiểm tra rate limit"""
42
+ # Chỉ rate limit API endpoints
43
  if request.url.path.startswith("/api/"):
44
  ip = request.client.host
45
+ if not _check_rate_limit(ip):
46
+ return JSONResponse({"error": "rate limit exceeded"}, status_code=429)
47
+ response = await call_next(request)
48
+ return response
49
 
50
  # ===== VTV CHANNELS API =====
51
  from vtv_api import router as vtv_router
 
59
  _cache_ttl_live = 60
60
  _cache_ttl_yt = 1800
61
 
62
+ # ===== VTV NAM BO SHORTS FALLBACK =====
63
+ SHORTS_FALLBACK = [
64
+ {"id":"nqlLH6chLRo","title":"Tin nóng VTV Nam Bộ | #shorts","channel":"vtvnambo"},
65
+ {"id":"E7Kq0v3hG6w","title":"VTV Nam Bộ - Tin tức miền Nam | #shorts","channel":"vtvnambo"},
66
+ {"id":"Lu_iCQ5YwNM","title":"Công an lập hồ sơ xử lý người phụ nữ chửi bới tát nam tài xế ô tô ở Hà Nội","channel":"baodantri7941"},
67
+ {"id":"CwWvijF8BOA","title":"Chú rể Ninh Bình bật khóc nhận món quà bí mật người cha quá cố gửi 26 năm trước","channel":"baodantri7941"},
68
+ {"id":"tvPewsc2ph4","title":"Tính năng ẩn trên iPhone giúp giảm mỏi mắt","channel":"baodantri7941"},
69
+ {"id":"b1Nxzv9ixlU","title":"Y án 3 năm tù với nữ tài xế uống 8 lon bia lái xe tông chủ tịch xã tử vong","channel":"baodantri7941"},
70
+ {"id":"Xp5eTwAZAis","title":"Người đánh hàng xóm tại chung cư ở Hà Nội bị tuyên hơn 4 tháng tù","channel":"baodantri7941"},
71
+ {"id":"Htzvwg6iOBM","title":"Xe điện Audi S6 Sportback e-tron có gì đặc biệt?","channel":"baodantri7941"},
72
+ {"id":"iMdFmWvYdlo","title":"Cô gái người Nga yêu thời trang và đất nước Việt Nam","channel":"baodantri7941"},
73
+ {"id":"IVaRc6moEv8","title":"Người nông dân Trung Quốc đột quỵ bệnh viện giúp bán sạch 4 tấn táo","channel":"baodantri7941"},
74
+ {"id":"uVxqPxToItU","title":"Công an vào cuộc vụ người phụ nữ chửi bới hành hung tài xế ô tô ở Hà Nội","channel":"baodantri7941"},
75
+ {"id":"VAfgNNgZDRs","title":"Khởi tố 4 đối tượng ném bom xăng vào nhà dân ở Đồng Nai","channel":"baodantri7941"},
76
+ {"id":"sBH_-zGh0Xw","title":"Vì sao Times New Roman vẫn nổi tiếng sau hàng chục năm?","channel":"baodantri7941"},
77
+ {"id":"woKn5f2bLHM","title":"Quảng Ninh ngập sâu diện rộng sau đợt mưa lớn","channel":"baodantri7941"},
78
+ {"id":"bcpgRoxbLPw","title":"Giông lốc quật bay mái tôn ở TP.HCM","channel":"baodantri7941"},
79
+ {"id":"ZIIC5osy544","title":"Bé trai Trung Quốc rơi từ tầng 11 vẫn sống sót kỳ diệu","channel":"baodantri7941"},
80
+ {"id":"uTMJ49NQpyc","title":"Sau lớp mascot 40kg Câu chuyện mưu sinh của người trẻ ở TPHCM","channel":"baodantri7941"},
81
+ {"id":"7Pd6vZ2Lz1M","title":"Hành động ấm lòng của người đàn ông tìm kiếm 5 học sinh tử vong ở sông Lô","channel":"baosuckhoedoisongboyte"},
82
+ {"id":"SlHLt_ZyPiE","title":"Xử phạt người đàn ông xóa số điện thoại cứu hộ trên cao tốc Bắc Nam","channel":"baosuckhoedoisongboyte"},
83
+ {"id":"IUOprcJyYr4","title":"Phụ nữ táo bón có phải do lười ăn rau?","channel":"baosuckhoedoisongboyte"},
84
+ {"id":"YY8ojFNE-AU","title":"Quái xế tự quay clip nẹt pô đánh võng đăng TikTok bị xử lý","channel":"baosuckhoedoisongboyte"},
85
+ {"id":"OV7_oGdQGII","title":"Bố cô dâu khóc sụt sùi rồi quẩy cực sung gây bão mạng","channel":"baosuckhoedoisongboyte"},
86
+ {"id":"FoxhFyz2skY","title":"Người đàn ông nước ngoài đập phá ô tô bẻ cần gạt nước ở Đà Nẵng","channel":"baosuckhoedoisongboyte"},
87
+ {"id":"R1oC_I8dFPU","title":"Thanh niên buông tay lái đứng trên xe máy khi đổ đèo ở Đắk Lắk","channel":"baosuckhoedoisongboyte"},
88
+ {"id":"U0Ft6ChWAIo","title":"Cô giáo kể phút tháo chạy khỏi xe khách trước khi bị lũ vò nát ở Cao Bằng","channel":"baosuckhoedoisongboyte"},
89
+ {"id":"hH0ANeze_4E","title":"Liên tiếp hàng chục con bò bị sét đánh chết trong ngày mưa dông","channel":"baosuckhoedoisongboyte"},
90
+ {"id":"pXWt0QbAzRQ","title":"Va chạm giao thông người phụ nữ lăng mạ tài xế ô tô","channel":"baosuckhoedoisongboyte"},
91
+ {"id":"UWWLPY1OYt4","title":"CSGT chặn xe khách khống chế đối tượng cướp dây chuyền tại Gia Lai","channel":"baosuckhoedoisongboyte"},
92
+ {"id":"AxhVTQutsuo","title":"Xuất tinh sớm và những hiểu lầm thường gặp","channel":"baosuckhoedoisongboyte"},
93
+ {"id":"cNy6FgaNxYM","title":"Cô dâu khóc sưng mắt vì 6 chỉ vàng không cánh mày bay trong ngày cưới","channel":"baosuckhoedoisongboyte"},
94
+ {"id":"IDt_S6q59Ro","title":"Chở bạn gái không đội mũ bảo hiểm thanh niên đấm CSGT","channel":"baosuckhoedoisongboyte"},
95
+ {"id":"LFxJ9Ik6W0A","title":"Mệnh lệnh từ trái tim CSGT Hà Nội mở đường đưa bé 5 tháng tuổi đi cấp cứu","channel":"baosuckhoedoisongboyte"},
96
+ ]
97
+ for _v in SHORTS_FALLBACK:
98
+ _v.setdefault("link", "https://www.youtube.com/watch?v="+_v["id"])
99
+ _v.setdefault("img", "https://i.ytimg.com/vi/"+_v["id"]+"/hqdefault.jpg")
100
+ _v.setdefault("source", "yt")
101
+
102
+ SHORT_STATS_FILE = "/data/short_stats.json" if os.path.isdir("/data") else "/app/short_stats.json"
103
+ _short_lock = threading.Lock()
104
+ def _load_short_db():
105
+ try:
106
+ if os.path.exists(SHORT_STATS_FILE):
107
+ with open(SHORT_STATS_FILE,"r",encoding="utf-8") as f: return json.load(f)
108
+ except: pass
109
+ return {}
110
+ def _save_short_db(db):
111
+ try:
112
+ os.makedirs(os.path.dirname(SHORT_STATS_FILE), exist_ok=True)
113
+ tmp = SHORT_STATS_FILE + ".tmp"
114
+ with open(tmp,"w",encoding="utf-8") as f: json.dump(db, f, ensure_ascii=False)
115
+ os.replace(tmp, SHORT_STATS_FILE)
116
+ except: pass
117
+ def _short_default(): return {"views":0,"likes":0,"shares":0,"comments":[]}
118
+
119
  PRIORITY_LEAGUES = ["Ngoại Hạng Anh","FA Cup","Champions League","LaLiga","Copa del Rey","Serie A","Bundesliga","Ligue 1","V-League"]
120
  LEAGUE_IDS = {"nha":27110,"laliga":27233,"seriea":27044,"bundesliga":26891,"ligue1":27212}
121
  HL_LEAGUES = {
 
202
  @app.get("/api/proxy/img")
203
  def proxy_img(url: str = Query(...)):
204
  try:
205
+ r = requests.get(url, headers={**HEADERS, "Referer": "https://dantri.com.vn/"}, timeout=10)
 
 
 
 
 
 
206
  if r.status_code != 200: return Response(status_code=502)
207
+ ct = r.headers.get("Content-Type", "image/jpeg")
208
+ return Response(content=r.content, media_type=ct, headers={"Cache-Control": "public, max-age=86400", "Access-Control-Allow-Origin": "*"})
209
  except: return Response(status_code=502)
210
 
211
  # ===== XEMLAIBONGDA HIGHLIGHTS =====
212
  def _scrape_xemlaibongda_page(page_path, limit=20):
213
+ """
214
+ Scrape video từ xemlaibongda.top - Simple & Reliable
215
+ Dùng logic cũ đã test, không fetch từng trang (tránh timeout)
216
+ """
217
  try:
218
  url = f"https://xemlaibongda.top/{page_path}" if page_path else "https://xemlaibongda.top/"
219
  r = requests.get(url, headers=HEADERS, timeout=15)
220
+ if r.status_code != 200:
221
+ return []
222
  r.encoding = "utf-8"
223
  soup = BeautifulSoup(r.text, "lxml")
224
+ videos = []
225
+ seen = set()
226
+
227
  for a in soup.find_all("a", href=True):
228
  href = a.get("href", "")
229
+ if "/video/" not in href and "/xem-lai/" not in href:
230
+ continue
231
+
232
+ if not href.startswith("http"):
233
+ href = "https://xemlaibongda.top" + href
234
+
235
+ # Bỏ query params
236
  clean_href = href.split("?")[0].split("#")[0]
237
+ if clean_href in seen:
238
+ continue
239
  seen.add(clean_href)
240
+
241
+ # ===== Lấy THUMBNAIL =====
242
  img_src = ""
243
  img = a.find("img")
244
+ if not img and a.parent:
245
+ img = a.parent.find("img")
246
  if not img:
247
  p = a.parent
248
  for _ in range(4):
249
+ if p and p.find("img"):
250
+ img = p.find("img")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  break
252
  p = p.parent if p else None
253
+
254
+ if img:
255
+ img_src = (img.get("data-src", "") or img.get("src", "") or
256
+ img.get("data-lazy", "") or img.get("data-original", ""))
257
+ if img_src.startswith("//"):
258
+ img_src = "https:" + img_src
259
+ elif img_src.startswith("/"):
260
+ img_src = "https://xemlaibongda.top" + img_src
261
+
262
+ # ===== Lấy TITLE =====
263
  title = ""
264
+ # Thử attribute
265
  for attr in ["title", "aria-label"]:
266
  val = a.get(attr, "")
267
+ if val and len(val) >= 5:
268
+ title = val
269
+ break
270
+
271
+ # Thử các selector
272
  if not title:
273
  for selector in ["h3", "h2", "h4", ".title", ".video-title", "strong"]:
274
  try:
275
  el = a.select_one(selector)
276
+ if el:
277
+ t = el.get_text(strip=True)
278
+ if len(t) >= 5:
279
+ title = t
280
+ break
281
+ except:
282
+ pass
283
+
284
+ # Thử text content
285
  if not title:
286
  text = a.get_text(strip=True)
287
+ if text and len(text) >= 5:
288
+ title = text[:100]
289
+
290
+ # Fallback: tạo title từ slug
291
  if not title or len(title) < 3:
292
  slug = clean_href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
293
  title = slug.replace("-", " ").replace("_", " ").title()
294
  title = re.sub(r'\d{4}-\d{2}-\d{2}', '', title).strip()
295
+
296
+ if not title or len(title) < 3:
297
+ continue
298
+
299
+ # Fallback thumbnail từ slug
300
  if not img_src:
301
  slug = clean_href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
302
  img_src = f"https://xemlaibongda.top/uploads/thumb/{slug}.jpg"
303
+
304
+ videos.append({
305
+ "title": title[:100],
306
+ "link": clean_href,
307
+ "img": img_src,
308
+ "source": "xemlaibongda"
309
+ })
310
+
311
+ if len(videos) >= limit:
312
+ break
313
+
314
  return videos
315
  except Exception as e:
316
+ print(f"[xemlaibongda] Error: {e}")
317
+ return []
318
+
319
+ def _extract_img_src(a_tag):
320
+ """Extract image URL từ thẻ <a> và parent elements"""
321
+ img = a_tag.find("img")
322
+ if not img and a_tag.parent:
323
+ img = a_tag.parent.find("img")
324
+ if not img:
325
+ p = a_tag.parent
326
+ for _ in range(5): # Tìm sâu hơn
327
+ if p and p.find("img"):
328
+ img = p.find("img")
329
+ break
330
+ p = p.parent if p else None
331
+
332
+ if not img:
333
+ return ""
334
+
335
+ # Thử tất cả các attribute có thể chứa img URL
336
+ attrs = ["data-src", "src", "data-lazy", "data-original", "data-srcset", "data-thumb", "data-image"]
337
+ for attr in attrs:
338
+ val = img.get(attr, "")
339
+ if val:
340
+ if attr == "data-srcset":
341
+ val = val.split(",")[0].strip().split(" ")[0]
342
+ break
343
+ else:
344
+ val = ""
345
+
346
+ # Thử background-image từ style
347
+ if not val:
348
+ style = img.get("style", "") or img.get("data-bg", "")
349
+ bg_match = re.search(r'url\(["\']?(.*?)["\']?\)', style)
350
+ if bg_match:
351
+ val = bg_match.group(1)
352
+
353
+ # Normalize URL
354
+ if val.startswith("//"):
355
+ val = "https:" + val
356
+ elif val.startswith("/"):
357
+ val = "https://xemlaibongda.top" + val
358
+
359
+ return val
360
+
361
+ def _extract_title(a_tag, href):
362
+ """Extract title từ thẻ <a> và child/parent elements"""
363
+ title = ""
364
+
365
+ # 1. Thử các selector phổ biến cho title
366
+ title_selectors = [
367
+ "h3", "h2", "h4", "h5",
368
+ ".title", ".post-title", ".entry-title", ".video-title",
369
+ ".card-title", ".item-title", ".news-title",
370
+ "span.title", "strong", "b",
371
+ ".name", ".caption"
372
+ ]
373
+ for tag in title_selectors:
374
+ try:
375
+ t = a_tag.select_one(tag) if hasattr(a_tag, 'select_one') else None
376
+ if t:
377
+ title = t.get_text(" ", strip=True)
378
+ if len(title) >= 3:
379
+ return title
380
+ except:
381
+ pass
382
+
383
+ # 2. Thử attribute của <a>
384
+ for attr in ["title", "aria-label", "data-title"]:
385
+ val = a_tag.get(attr, "")
386
+ if val and len(val) >= 3:
387
+ return val
388
+
389
+ # 3. Thử alt text của img
390
+ img = a_tag.find("img")
391
+ if img:
392
+ alt = img.get("alt", "")
393
+ if alt and len(alt) >= 3:
394
+ return alt
395
+
396
+ # 4. Thử text content của <a> (loại bỏ quá dài)
397
+ text = a_tag.get_text(" ", strip=True)
398
+ if text and len(text) >= 3:
399
+ # Lấy dòng đầu tiên nếu có nhiều dòng
400
+ lines = [l.strip() for l in text.split("\n") if l.strip()]
401
+ if lines:
402
+ first_line = lines[0]
403
+ if len(first_line) >= 3:
404
+ return first_line[:100]
405
+
406
+ # 5. Fallback: tạo title từ slug
407
+ slug = href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
408
+ title = slug.replace("-", " ").replace("_", " ")
409
+ title = re.sub(r'\d{4}-\d{2}-\d{2}', '', title).strip()
410
+ if title:
411
+ return title.title()
412
+
413
+ return ""
414
 
415
  def scrape_xemlaibongda(): return _scrape_xemlaibongda_page("", 20)
416
  def scrape_highlights_by_league(league_key):
 
422
  with ThreadPoolExecutor(8) as ex:
423
  futs = [ex.submit(_fetch, k) for k in HL_LEAGUES]
424
  for f in as_completed(futs, timeout=25):
425
+ try:
426
+ key, vids = f.result()
427
+ if vids: results[key] = vids
428
+ except: pass
429
  return results
430
 
431
  def extract_xemlaibongda_video(url):
432
  try:
433
  r=requests.get(url, headers=HEADERS, timeout=15)
434
  if r.status_code!=200: return None
435
+ r.encoding="utf-8"; soup=BeautifulSoup(r.text,"lxml"); video=soup.find("video")
 
 
 
 
436
  if video:
437
  src=video.get("src",""); poster=video.get("poster","")
438
  if not src:
439
  source=video.find("source")
440
  if source: src=source.get("src","")
 
441
  if src: return{"src":src,"poster":poster,"type":"hls" if".m3u8" in src else"video"}
442
  m3u8s=re.findall(r'(https?://[^\s"\'<>]+\.m3u8)',r.text)
443
+ if m3u8s:
444
+ og=soup.find("meta",property="og:image"); poster=og.get("content","") if og else ""
445
+ return{"src":m3u8s[0],"poster":poster,"type":"hls"}
446
  return None
447
  except: return None
448
 
449
+ # ===== YOUTUBE SHORTS SCRAPING =====
450
+ def _yt_channel_shorts_requests(channel, count=15):
451
+ try:
452
+ url=f"https://www.youtube.com/@{channel}/shorts"
453
+ r=requests.get(url, headers={**HEADERS,"Accept-Language":"vi,en;q=0.8"}, timeout=15)
454
+ if r.status_code!=200: return []
455
+ html=r.text; ids=[]; items=[]
456
+ for m in re.finditer(r'"videoId":"([A-Za-z0-9_-]{11})"',html):
457
+ vid=m.group(1)
458
+ if vid in ids: continue
459
+ ids.append(vid)
460
+ snip=html[max(0,m.start()-900):m.start()+1600]
461
+ title=""
462
+ mt=re.search(r'"title":\{"runs":\[\{"text":"([^"]+)"',snip)
463
+ if not mt: mt=re.search(r'"accessibilityText":"([^"]+)"',snip)
464
+ if mt: title=html_lib.unescape(mt.group(1)).replace('\n',' ').strip()
465
+ if not title: title="YouTube Short"
466
+ items.append({"title":title,"link":f"https://www.youtube.com/watch?v={vid}","img":f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg","source":"yt","id":vid,"channel":channel})
467
+ if len(items)>=count: break
468
+ return items
469
+ except: return []
470
+
471
+ def scrape_shorts():
472
+ vids=[]
473
+ with ThreadPoolExecutor(3) as ex:
474
+ futs=[ex.submit(_yt_channel_shorts_requests,ch,24) for ch in ["baodantri7941","baosuckhoedoisongboyte","vtvnambo"]]
475
+ for f in as_completed(futs):
476
+ try:
477
+ r=f.result()
478
+ if r: vids.extend(r)
479
+ except: pass
480
+ merged=[]; seen=set()
481
+ for v in vids:
482
+ vid=v.get("id")
483
+ if not vid or vid in seen: continue
484
+ seen.add(vid); merged.append(v)
485
+ for v in SHORTS_FALLBACK:
486
+ vid=v.get("id")
487
+ if not vid or vid in seen: continue
488
+ seen.add(vid); merged.append(v)
489
+ return merged[:60]
490
+
491
+ # ===== VTV NAM BO & WC SHORTS - using yt-dlp =====
492
+ from yt_scraper import get_vtvnambo_shorts, get_wc_related_shorts
493
+
494
+ @app.get("/api/shorts/vtvnamo")
495
+ def api_shorts_vtvnamo(count: int = Query(default=50, le=100)):
496
+ items = get_vtvnambo_shorts(count)
497
+ if not items:
498
+ items = [v for v in SHORTS_FALLBACK if v.get("channel") == "vtvnambo"]
499
+ nql = [v for v in items if v.get("id") == "nqlLH6chLRo"]
500
+ rest = [v for v in items if v.get("id") != "nqlLH6chLRo"]
501
+ items = nql + rest
502
+ return JSONResponse(items)
503
+
504
+ @app.get("/api/shorts/wc")
505
+ def api_shorts_wc(count: int = Query(default=50, le=100)):
506
+ items = get_wc_related_shorts(count)
507
+ if not items:
508
+ items = [v for v in SHORTS_FALLBACK if v.get("channel") == "vtvnambo"]
509
+ nql = [v for v in items if v.get("id") == "nqlLH6chLRo"]
510
+ rest = [v for v in items if v.get("id") != "nqlLH6chLRo"]
511
+ items = nql + rest
512
+ return JSONResponse(items)
513
+
514
  # ===== LIVESCORE =====
515
  @app.get("/api/livescore/live")
516
  def api_livescore_live(): return JSONResponse({"html":_cached("ls_live",lambda:fetch_bongda_api("/api/fixtures/live"),ttl=_cache_ttl_live)})
 
563
  return None
564
  return JSONResponse(_cached("ls_featured",_f,ttl=30))
565
 
566
+ @app.get("/api/shorts")
567
+ def api_shorts(channel: str = Query(default="")):
568
+ if channel == "vtvnambo": return api_shorts_vtvnamo()
569
+ if channel == "wc": return api_shorts_wc()
570
+ return JSONResponse(_cached("yt_shorts_v3",scrape_shorts,ttl=_cache_ttl_yt))
571
+
572
+ @app.get("/api/short-stats")
573
+ def api_short_stats(ids:str=Query(default="")):
574
+ arr=[x for x in ids.split(",") if x]
575
+ with _short_lock:
576
+ db=_load_short_db();out={}
577
+ for vid in arr:
578
+ st=db.get(vid) or _short_default()
579
+ out[vid]={"views":int(st.get("views",0)),"likes":int(st.get("likes",0)),"shares":int(st.get("shares",0)),"comments":st.get("comments",[])[:80]}
580
+ return JSONResponse({"stats":out})
581
+
582
+ @app.post("/api/short-action")
583
+ async def api_short_action(request:Request):
584
+ try: body=await request.json()
585
+ except: body={}
586
+ vid=str(body.get("id","")).strip(); action=str(body.get("action","")).strip(); txt=str(body.get("text","")).strip()
587
+ if not vid: return JSONResponse({"error":"missing id"},status_code=400)
588
+ with _short_lock:
589
+ db=_load_short_db(); st=db.get(vid) or _short_default()
590
+ if action=="view": st["views"]=int(st.get("views",0))+1
591
+ elif action=="like": st["likes"]=int(st.get("likes",0))+1
592
+ elif action=="share": st["shares"]=int(st.get("shares",0))+1
593
+ elif action=="comment" and txt:
594
+ comments=st.get("comments",[])
595
+ comments.insert(0,{"text":txt[:180],"ts":int(time.time())})
596
+ st["comments"]=comments[:80]
597
+ st["updated"]=int(time.time()); db[vid]=st; _save_short_db(db)
598
+ out={"views":int(st.get("views",0)),"likes":int(st.get("likes",0)),"shares":int(st.get("shares",0)),"comments":st.get("comments",[])[:80]}
599
+ return JSONResponse({"stats":out})
600
+
601
  @app.get("/api/highlights")
602
  def api_highlights(): return JSONResponse(_cached("xemlaibongda_hl",scrape_xemlaibongda,ttl=_cache_ttl))
603
  @app.get("/api/highlights/leagues")
 
608
  return JSONResponse(_cached(f"hl_{league}",lambda:scrape_highlights_by_league(league),ttl=_cache_ttl))
609
 
610
  @app.get("/api/video_url")
611
+ def api_video_url(url:str=Query(...)):
612
  if "youtube.com" in url or "youtu.be" in url:
613
  m=re.search(r'(?:v=|shorts/|youtu\.be/)([a-zA-Z0-9_-]{11})',url)
614
  if m: vid=m.group(1); return JSONResponse({"src":f"https://www.youtube.com/embed/{vid}?autoplay=1&rel=0&enablejsapi=1","poster":f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg","type":"youtube"})
 
616
  v=extract_xemlaibongda_video(url)
617
  if v:
618
  if v["type"]=="hls": v["src"]="/api/proxy/m3u8?url="+quote(v["src"],safe="")
 
619
  return JSONResponse(v)
620
  return JSONResponse({"error":"not found"})
621
 
622
  # ===== WORLD CUP 2026 API =====
623
+ # Rate limiting cho WC API
624
+ _wc_request_times = []
625
+ _wc_rate_limit_lock = threading.Lock()
626
+ _WC_RATE_LIMIT = 10 # Max 10 requests per minute
627
+
628
  def _wc_rate_limit():
629
+ """Kiểm tra rate limit cho WC API"""
630
  global _wc_request_times
631
  with _wc_rate_limit_lock:
632
  now = time.time()
633
+ # Xóa các request cũ hơn 60 giây
634
  _wc_request_times = [t for t in _wc_request_times if now - t < 60]
635
+ if len(_wc_request_times) >= _WC_RATE_LIMIT:
636
+ return False
637
  _wc_request_times.append(now)
638
  return True
639
 
640
  @app.get("/api/wc2026")
641
  def api_wc2026():
642
+ """Trả về tất cả dữ liệu World Cup 2026"""
643
  return JSONResponse(_cached("wc2026", get_wc2026_all, ttl=_cache_ttl))
644
 
645
  @app.get("/api/wc2026/{tab}")
646
  def api_wc2026_tab(tab: str):
647
+ """Trả về từng tab của World Cup"""
648
  valid_tabs = ["news", "fixtures", "standings", "stats", "highlights"]
649
+ if tab not in valid_tabs:
650
+ return JSONResponse({"error": "invalid tab"}, status_code=400)
651
+
652
  def _fetch_tab():
653
+ if tab == "highlights":
654
+ return scrape_highlights_by_league("world-cup")
655
+ elif tab == "news":
656
+ return scrape_wc_news()
657
+ elif tab == "fixtures":
658
+ return scrape_fixtures()
659
+ elif tab == "standings":
660
+ return scrape_standings()
661
+ elif tab == "stats":
662
+ return scrape_stats()
663
  return []
664
+
665
  return JSONResponse(_cached(f"wc2026_{tab}", _fetch_tab, ttl=_cache_ttl))
666
 
667
+ # Note: WC functions (scrape_wc_news, scrape_fixtures, scrape_stats, scrape_standings)
668
+ # are imported from wc2026_scraper.py at the top of this file
669
+
670
+ @app.get("/api/video_url")
671
+ def api_video_url(url:str=Query(...)):
672
+ if "youtube.com" in url or "youtu.be" in url:
673
+ m=re.search(r'(?:v=|shorts/|youtu\.be/)([a-zA-Z0-9_-]{11})',url)
674
+ if m: vid=m.group(1); return JSONResponse({"src":f"https://www.youtube.com/embed/{vid}?autoplay=1&rel=0&enablejsapi=1","poster":f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg","type":"youtube"})
675
+ if "xemlaibongda.top" in url:
676
+ v=extract_xemlaibongda_video(url)
677
+ if v:
678
+ if v["type"]=="hls": v["src"]="/api/proxy/m3u8?url="+quote(v["src"],safe="")
679
+ return JSONResponse(v)
680
+ return JSONResponse({"error":"not found"})
681
+
682
  @app.get("/api/bdp_videos")
683
  def api_bdp_videos():
684
  def _f():
 
735
  for img in container.find_all("img"):
736
  s=img.get("data-src","") or img.get("src","")
737
  if s and "mediacdn" in s and "avatar" not in s and "logo" not in s: img_src=s; break
738
+ if img_src: break
739
+ container=container.parent
740
  seen.add(href)
741
  if not img_src:
742
  try:
 
779
  for k,(u,n) in VNE_CATS.items(): cats.append({"id":k,"name":n,"source":"vne"})
780
  return JSONResponse(cats)
781
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
782
  @app.get("/api/article")
783
  def api_article(url:str=Query(...)):
784
+ return JSONResponse({"error":"not supported"})
 
 
 
 
 
 
 
 
785
 
786
  @app.get("/api/storage_status")
787
  def api_storage_status():
 
793
 
794
  @app.get("/", response_class=HTMLResponse)
795
  async def root():
796
+ return HTMLResponse("<h1>VNEWS</h1><p>Running</p>")
797
+ # v14 rebuild 2026-06-18T12:21:41.503230
rewrite_fix_v2.js DELETED
@@ -1,2 +0,0 @@
1
- // No-op - all functionality built into app_v2.js
2
- (function(){})();
 
 
 
shorts_cache.py DELETED
@@ -1,86 +0,0 @@
1
- """
2
- VNEWS Shorts Runtime Cache - External Updater Module
3
- GitHub Actions fetches YouTube shorts via yt-dlp -> POST to /api/shorts/update
4
- Space saves to RAM cache + persistent file if /data available
5
- """
6
- import os
7
- import json
8
- import time
9
- import threading
10
-
11
- # Runtime cache (RAM)
12
- _shorts_runtime_cache = None
13
- _shorts_cache_ts = 0
14
- _shorts_cache_lock = threading.Lock()
15
-
16
- # Secret for authenticating update requests
17
- SHORTS_UPDATE_SECRET = os.environ.get("SHORTS_UPDATE_SECRET", "vnews-shorts-2026")
18
-
19
- # Paths
20
- SHORTS_CACHE_FILE = "/data/shorts_runtime_cache.json" if os.path.isdir("/data") else "/app/shorts_runtime_cache.json"
21
-
22
-
23
- def get_runtime_cache():
24
- """Get cached shorts (from RAM or file fallback)"""
25
- global _shorts_runtime_cache, _shorts_cache_ts
26
- with _shorts_cache_lock:
27
- if _shorts_runtime_cache is not None:
28
- age = time.time() - _shorts_cache_ts
29
- if age < 7200: # 2h fresh
30
- return _shorts_runtime_cache
31
-
32
- # Try file fallback
33
- try:
34
- if os.path.exists(SHORTS_CACHE_FILE):
35
- with open(SHORTS_CACHE_FILE, "r", encoding="utf-8") as f:
36
- data = json.load(f)
37
- age = time.time() - data.get("ts", 0)
38
- if age < 86400: # 24h stale limit
39
- items = data.get("items", [])
40
- with _shorts_cache_lock:
41
- _shorts_runtime_cache = items
42
- _shorts_cache_ts = data.get("ts", time.time())
43
- return items
44
- except Exception as e:
45
- print(f"[cache] read error: {e}")
46
-
47
- return None
48
-
49
-
50
- def set_runtime_cache(items):
51
- """Update runtime cache from external data"""
52
- global _shorts_runtime_cache, _shorts_cache_ts
53
- ts = time.time()
54
- with _shorts_cache_lock:
55
- _shorts_runtime_cache = items
56
- _shorts_cache_ts = ts
57
-
58
- # Also write to file (persistent if /data mounted)
59
- try:
60
- os.makedirs(os.path.dirname(SHORTS_CACHE_FILE), exist_ok=True)
61
- payload = {"items": items, "ts": ts, "count": len(items)}
62
- with open(SHORTS_CACHE_FILE, "w", encoding="utf-8") as f:
63
- json.dump(payload, f, ensure_ascii=False, indent=2)
64
- print(f"[cache] saved {len(items)} shorts to {SHORTS_CACHE_FILE}")
65
- except Exception as e:
66
- print(f"[cache] write skipped: {e}")
67
-
68
- return len(items)
69
-
70
-
71
- def get_cache_status():
72
- """Return status dict for the cache"""
73
- cache = None
74
- with _shorts_cache_lock:
75
- if _shorts_runtime_cache is not None:
76
- cache = _shorts_runtime_cache
77
- age = int(time.time() - _shorts_cache_ts)
78
- else:
79
- age = -1
80
- return {
81
- "cached": cache is not None,
82
- "count": len(cache) if cache else 0,
83
- "age_seconds": age,
84
- "has_persistent": os.path.isdir("/data"),
85
- "cache_file_exists": os.path.exists(SHORTS_CACHE_FILE),
86
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
shorts_rss_proxy.py DELETED
@@ -1,114 +0,0 @@
1
- """
2
- YouTube RSS Proxy - Fetches YouTube channel RSS feeds server-side
3
- Avoids CORS issues when client tries to fetch YouTube directly
4
- """
5
- import requests as req
6
- from fastapi import Query
7
- from fastapi.responses import Response
8
-
9
- HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
10
-
11
- YOUTUBE_CHANNELS = {
12
- "baodantri7941": "UC_x5TKhOgd6GhYvv5z4I3jg",
13
- "baosuckhoedoisongboyte": "UCBsY5fXTQLkF_JnH9kLkL4g",
14
- }
15
-
16
- def setup_rss_proxy(app):
17
- """Add RSS proxy endpoints to the FastAPI app"""
18
-
19
- @app.get("/api/proxy/rss")
20
- def proxy_rss(url: str = Query(...)):
21
- """Proxy YouTube RSS feed to avoid CORS"""
22
- try:
23
- r = req.get(url, headers=HEADERS, timeout=15)
24
- if r.status_code == 200:
25
- return Response(
26
- content=r.content,
27
- media_type="application/xml",
28
- headers={"Access-Control-Allow-Origin": "*"}
29
- )
30
- return Response(status_code=r.status_code)
31
- except Exception as e:
32
- return Response(status_code=502, content=str(e))
33
-
34
- @app.get("/api/shorts/rss")
35
- def shorts_via_rss():
36
- """Get shorts from YouTube RSS feeds server-side"""
37
- import xml.etree.ElementTree as ET
38
- import html as html_lib
39
- import re
40
-
41
- shorts = []
42
- seen = set()
43
-
44
- for handle, channel_id in YOUTUBE_CHANNELS.items():
45
- try:
46
- rss_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
47
- r = req.get(rss_url, headers=HEADERS, timeout=15)
48
- if r.status_code != 200:
49
- continue
50
-
51
- root = ET.fromstring(r.text)
52
- ns = {
53
- 'atom': 'http://www.w3.org/2005/Atom',
54
- 'yt': 'http://www.youtube.com/xml/schemas/2015',
55
- 'media': 'http://search.yahoo.com/mrss/'
56
- }
57
-
58
- for entry in root.findall('atom:entry', ns)[:30]:
59
- title_el = entry.find('atom:title', ns)
60
- title = html_lib.unescape(title_el.text) if title_el is not None and title_el.text else ''
61
-
62
- link_el = entry.find('atom:link', ns)
63
- link = link_el.get('href', '') if link_el is not None else ''
64
-
65
- vid_el = entry.find('yt:videoId', ns)
66
- vid = vid_el.text if vid_el is not None else ''
67
-
68
- if not vid:
69
- m = re.search(r'(?:v=|shorts/)([A-Za-z0-9_-]{11})', link)
70
- if m:
71
- vid = m.group(1)
72
-
73
- if not vid or vid in seen:
74
- continue
75
-
76
- # Check if it's a short
77
- is_short = '#shorts' in title.lower() or '#short' in title.lower() or '/shorts/' in link
78
-
79
- if not is_short:
80
- desc_el = entry.find('media:description', ns)
81
- if desc_el is not None and desc_el.text:
82
- if '#shorts' in desc_el.text.lower():
83
- is_short = True
84
-
85
- if not is_short:
86
- continue
87
-
88
- seen.add(vid)
89
-
90
- # Get thumbnail
91
- thumb = f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg"
92
- media_group = entry.find('media:group', ns)
93
- if media_group is not None:
94
- thumb_el = media_group.find('media:thumbnail', ns)
95
- if thumb_el is not None:
96
- thumb = thumb_el.get('url', thumb)
97
-
98
- shorts.append({
99
- 'id': vid,
100
- 'title': title.replace('#shorts', '').replace('#short', '').strip()[:120],
101
- 'img': thumb,
102
- 'link': f'https://www.youtube.com/shorts/{vid}',
103
- 'channel': handle,
104
- 'source': 'yt'
105
- })
106
-
107
- if len(shorts) >= 40:
108
- break
109
-
110
- except Exception as e:
111
- print(f"RSS error for {handle}: {e}")
112
- continue
113
-
114
- return {"shorts": shorts, "count": len(shorts)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
static/app_v2.js CHANGED
@@ -1,17 +1,6 @@
1
- /**
2
- * VNEWS Frontend v2 - Shorts Dantri/SKDS removed, VTV Digital CDN
3
- * v2.4 - Fixed: prependWallPost detached-element bug, makeShortVideo UI update, slide viewer for rewrite posts
4
- */
5
- function _proxyImg(url){
6
- if(!url || typeof url !== 'string') return '';
7
- if(url.startsWith('http') && !url.includes(location.host)){
8
- return '/api/proxy/img?url='+encodeURIComponent(url);
9
- }
10
- return url;
11
- }
12
-
13
- var _ttsSelections = {};
14
 
 
15
  function _fetchWithTimeout(url, ms){
16
  return new Promise((resolve,reject)=>{
17
  const ctrl=new AbortController();
@@ -28,6 +17,7 @@ async function loadHome(){
28
  const homeEl = document.getElementById('view-home');
29
  if(!homeEl) return;
30
 
 
31
  homeEl.innerHTML =
32
  '<div id="home-featured-area"></div>'
33
  +'<div class="ai-compose"><div class="ai-compose-title">🤖 AI viết bài</div><div class="ai-compose-row"><input id="topic-input" placeholder="Nhập chủ đề..."><button onclick="searchTopic()">Tìm nguồn</button></div><div class="ai-compose-row"><input id="url-input" placeholder="Dán URL bài viết..."><button class="secondary" onclick="rewriteUrl()">Rewrite</button></div><div id="hot-topics" class="hot-topic-row"></div></div>'
@@ -38,31 +28,42 @@ async function loadHome(){
38
 
39
  const afterEl = homeEl.querySelector('#home-after-wc');
40
 
 
41
  loadLivescore('today');
42
  loadHotTopics();
43
 
44
- const [featuredData, wallData, hlLeagues, wcData] = await Promise.allSettled([
 
45
  _fetchWithTimeout('/api/livescore/featured', 5000),
 
46
  _fetchWithTimeout('/api/wall', 5000),
47
  _fetchWithTimeout('/api/highlights/leagues', 10000),
 
48
  _fetchWithTimeout('/api/wc2026', 8000),
49
  ]).then(results => results.map(r => r.status === 'fulfilled' ? r.value : null));
50
 
 
51
  if(featuredData && featuredData.home){
52
  const sc=featuredData.status==='live'?'':'upcoming';
53
  const st=featuredData.status==='live'?`🔴 ${featuredData.minute||'LIVE'}`:`⏰ ${featuredData.time}`;
54
  const area=document.getElementById('home-featured-area');
55
- if(area) area.innerHTML=`<div class="featured-match" onclick="openMatch('${featuredData.event_id}')"><div class="fm-league">${featuredData.league}</div><div class="fm-teams"><div class="fm-team"><img src="${_proxyImg(featuredData.home_logo)}" onerror="this.style.display='none'"><span>${featuredData.home}</span></div><div class="fm-score">${featuredData.score||'VS'}</div><div class="fm-team"><img src="${_proxyImg(featuredData.away_logo)}" onerror="this.style.display='none'"><span>${featuredData.away}</span></div></div><div class="fm-status ${sc}">${st}</div></div>`;
56
  }
57
 
 
 
58
  _wallPosts = (wallData && wallData.posts) || [];
59
  _hlLeagueData = hlLeagues || {};
60
  _wc2026Data = wcData;
61
 
 
62
  if(wcData) switchWCTab('news');
63
 
 
 
64
  _renderWallIn(afterEl);
65
  _renderHLIn(afterEl);
 
66
  }
67
 
68
  function _renderSlidesIn(key, label, emoji, vids, afterEl){
@@ -73,9 +74,9 @@ function _renderSlidesIn(key, label, emoji, vids, afterEl){
73
  const isHL = key==='world-cup'||key==='premier-league'||key==='champions-league'||key==='la-liga'||key==='serie-a'||key==='bundesliga'||key==='friendly';
74
  vids.slice(0,isHL?8:12).forEach((a,i)=>{
75
  if(isHL){
76
- h+=`<div class="slider-item" onclick="openHighlightFeed('${key}',${i})"><div class="slider-thumb">${a.img?`<img src="${_proxyImg(a.img)}" loading="lazy">`:''}<div class="card-play">▶</div></div><div class="slider-title">${esc(a.title)}</div></div>`;
77
  } else {
78
- h+=`<div class="slider-item" onclick="readArticle('${esc(a.link)}')"><div class="slider-thumb">${a.img?`<img src="${_proxyImg(a.img)}" loading="lazy">`:''}</div><div class="slider-title">${esc(a.title)}</div></div>`;
79
  }
80
  });
81
  h+='</div>';
@@ -83,9 +84,33 @@ function _renderSlidesIn(key, label, emoji, vids, afterEl){
83
  afterEl.parentNode.insertBefore(wrap, afterEl);
84
  }
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  function _renderWallIn(afterEl){
87
  if(!_wallPosts||!_wallPosts.length||!afterEl) return;
88
  const posts=_wallPosts;
 
 
 
 
 
 
 
 
 
89
  const wrap=document.createElement('div');
90
  wrap.className='slider-wrap';wrap.id='ai-wall-wrap';
91
  let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track" id="ai-wall-track">';
@@ -106,247 +131,69 @@ function _renderHLIn(afterEl){
106
 
107
  // === WALL POST HELPERS ===
108
  function makeWallItem(p,i){
109
- var hasVideo = p.video && p.video.length > 0;
110
- var thumbContent = p.img
111
- ? '<img src="/api/proxy/img?url='+encodeURIComponent(p.img)+'" loading="lazy" onerror="this.style.display=\'none\'">'
112
- : (hasVideo ? '<video src="'+esc(p.video)+'" muted></video>' : '');
113
- var videoBadge = hasVideo ? '<div class="wall-video-badge">🎬</div>' : '';
114
- var vid = p.id||i;
115
- var lang = p.language || detectLanguage(p.title + ' ' + (p.text||''));
116
- var curVoice = p.voice || getAutoVoice(lang);
117
- var curEmotion = p.emotion || detectEmotion(p.title + ' ' + (p.text||''));
118
- var selKey = 'inline-' + vid;
119
- if(!_ttsSelections[selKey]) _ttsSelections[selKey] = {voice: curVoice, emotion: curEmotion};
120
- var voiceOpts = '';
121
- VOICE_LIST.forEach(function(v){
122
- voiceOpts += '<option value="'+v.id+'"'+(v.id===_ttsSelections[selKey].voice?' selected':'')+'>'+v.label+'</option>';
123
- });
124
- var emotOpts = '';
125
- EMOTION_LIST.forEach(function(e){
126
- emotOpts += '<option value="'+e.id+'"'+(e.id===_ttsSelections[selKey].emotion?' selected':'')+'>'+e.label+'</option>';
127
- });
128
- var spd = p.short_speed || '1.2';
129
- var voiceBar = '<div class="wall-tts-bar" style="margin:6px 0 4px;display:grid;grid-template-columns:1fr auto auto;gap:3px">'
130
- +'<select class="wvs" data-selkey="'+selKey+'" style="background:#1a1a1a;border:1px solid #333;color:#ccc;padding:3px;border-radius:6px;font-size:9px;min-width:0" onchange="_ttsSelections[this.dataset.selkey].voice=this.value">'
131
- +voiceOpts+'</select>'
132
- +'<select class="wes" data-selkey="'+selKey+'" style="background:#1a1a1a;border:1px solid #333;color:#ccc;padding:3px;border-radius:6px;font-size:9px;min-width:0" onchange="_ttsSelections[this.dataset.selkey].emotion=this.value">'
133
- +emotOpts+'</select>'
134
- +'<select class="wss" data-selkey="'+selKey+'" style="background:#1a1a1a;border:1px solid #333;color:#ccc;padding:3px;border-radius:6px;font-size:9px;min-width:0" onchange="_ttsSelections[this.dataset.selkey].speed=parseFloat(this.value)">'
135
- +'<option value="0.85"'+(spd==='0.85'?' selected':'')+'>0.85x</option>'
136
- +'<option value="1.0"'+(spd==='1.0'?' selected':'')+'>1.0x</option>'
137
- +'<option value="1.2"'+(spd==='1.2'?' selected':'')+'>1.2x</option>'
138
- +'<option value="1.35"'+(spd==='1.35'?' selected':'')+'>1.35x</option>'
139
- +'</select>'
140
- +'</div>';
141
- var makeBtn = hasVideo
142
- ? '<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed('+i+')">▶ Xem Short</button>'
143
- : '<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo(\''+esc(vid)+'\',this,_ttsSelections[\'inline-'+esc(vid)+'\'].voice,parseFloat(document.querySelector(\'.wss[data-selkey=inline-'+esc(vid)+']\').value)||1.2,_ttsSelections[\'inline-'+esc(vid)+'\'].emotion)">🎬 Tạo Short</button>';
144
- return '<div class="wall-item" id="wall-item-'+esc(vid)+'"><div class="wall-thumb">'+thumbContent+videoBadge+'</div><div class="wall-title">'+esc(p.title)+'</div><div class="wall-text">'+esc((p.text||'').slice(0,180))+'</div>'+voiceBar+'<div class="wall-actions"><button class="primary" onclick="readWallPost('+i+')">Xem</button>'+makeBtn+'</div></div>';
145
  }
146
 
147
- async function makeShortVideo(postId, btn, voice, speed, emotion){
148
  if(!postId)return;
149
  const origText = btn ? btn.textContent : '🎬 Tạo Video';
150
  if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo...';}
151
  toast('⏳ Đang tạo video shorts...');
152
- if(!voice || !emotion){
153
- const selKey = 'inline-'+postId;
154
- const container = document.querySelector('.tts-selector[data-post-id="'+postId+'"]');
155
- if(container){
156
- if(_ttsSelections[selKey]){
157
- voice = voice || _ttsSelections[selKey].voice;
158
- emotion = emotion || _ttsSelections[selKey].emotion;
159
- }
160
- if(!voice){
161
- const selectedVoiceBtn = container.querySelector('.tts-voice-btn.selected') || container.querySelector('.tts-voice-btn[style*="5cb87a"]') || container.querySelector('.tts-voice-btn');
162
- voice = selectedVoiceBtn ? selectedVoiceBtn.dataset.voice : 'vi-VN-HoaiMyNeural';
163
- }
164
- if(!emotion){
165
- const selectedEmotionBtn = container.querySelector('.tts-emotion-btn.selected') || container.querySelector('.tts-emotion-btn[style*="5cb87a"]') || container.querySelector('.tts-emotion-btn');
166
- emotion = selectedEmotionBtn ? selectedEmotionBtn.dataset.emotion : 'neutral';
167
- }
168
- const speedSelect = container.querySelector('.tts-speed');
169
- speed = speedSelect ? parseFloat(speedSelect.value) || 1.2 : (speed || 1.2);
170
- } else {
171
- voice = voice || 'vi-VN-HoaiMyNeural';
172
- emotion = emotion || 'neutral';
173
- speed = speed || 1.2;
174
- }
175
- }
176
  try{
177
- const r = await fetch('/api/ai/short/' + encodeURIComponent(postId), {method:'POST',headers:{'Content-Type':'application/json'},body: JSON.stringify({voice:voice, emotion:emotion, speed:speed})});
 
 
 
 
 
178
  const j = await r.json();
179
  if(!r.ok || j.error) throw new Error(j.error||'Lỗi tạo video');
180
  toast('✅ Đã tạo video shorts!');
181
  const p = _wallPosts.find(x => String(x.id) === String(postId));
182
  if(p){
183
  p.video = j.video;
184
- p.voice = j.voice;
185
- p.emotion = j.emotion;
186
  const itemId = 'wall-item-'+postId;
187
  const el = document.getElementById(itemId);
188
  if(el){
189
  const idx = _wallPosts.indexOf(p);
190
- el.insertAdjacentHTML('afterend', makeWallItem(p, idx));
191
- el.remove();
 
192
  }
193
  }
 
194
  }catch(e){
195
  toast('❌ '+e.message);
196
  if(btn){btn.disabled=false;btn.textContent=origText;}
197
  }
198
  }
199
 
200
- var VOICE_LIST = [
201
- {id:'vi-VN-HoaiMyNeural', label:'🎙️ Hoài My (VI)', lang:'vi'},
202
- {id:'vi-VN-NamMinhNeural', label:'🎙️ Nam Minh (VI)', lang:'vi'},
203
- {id:'en-US-AndrewMultilingualNeural', label:'🎙️ Andrew (EN)', lang:'en'},
204
- {id:'en-AU-WilliamMultilingualNeural', label:'🎙️ William (EN)', lang:'en'},
205
- {id:'pt-BR-ThalitaMultilingualNeural', label:'🎙️ Thalita (PT)', lang:'pt'},
206
- {id:'fr-FR-VivienneMultilingualNeural', label:'🎙️ Vivienne (FR)', lang:'fr'},
207
- {id:'fr-FR-RemyMultilingualNeural', label:'🎙️ Rémy (FR)', lang:'fr'},
208
- {id:'de-DE-SeraphinaMultilingualNeural', label:'🎙️ Seraphina (DE)', lang:'de'},
209
- {id:'de-DE-FlorianMultilingualNeural', label:'🎙️ Florian (DE)', lang:'de'},
210
- {id:'ko-KR-HyunsuMultilingualNeural', label:'🎙️ Hyunsu (KO)', lang:'ko'},
211
- {id:'it-IT-GiuseppeMultilingualNeural', label:'🎙️ Giuseppe (IT)', lang:'it'},
212
- ];
213
- var EMOTION_LIST = [
214
- {id:'neutral', label:'😐 Trung tính'},
215
- {id:'happy', label:'😊 Vui vẻ'},
216
- {id:'excited', label:'🔥 Hào hứng'},
217
- {id:'sad', label:'😢 Buồn'},
218
- {id:'humorous', label:'😂 Hài hước'},
219
- {id:'serious', label:'⚠️ Nghiêm túc'},
220
- {id:'urgent', label:'🚨 Khẩn cấp'},
221
- {id:'warm', label:'💖 Ấm áp'},
222
- ];
223
-
224
- document.addEventListener('click',function(e){
225
- var btn = e.target.closest('.tts-voice-btn');
226
- if(btn){
227
- var container = btn.closest('.tts-selector');
228
- if(container){
229
- var selKey = 'inline-'+container.dataset.postId;
230
- if(!_ttsSelections[selKey]) _ttsSelections[selKey]={voice:btn.dataset.voice,emotion:'neutral'};
231
- var allBtns = container.querySelectorAll('.tts-voice-btn');
232
- for(var i=0;i<allBtns.length;i++){allBtns[i].style.borderColor='#333';allBtns[i].style.background='#222';allBtns[i].classList.remove('selected');}
233
- btn.style.borderColor='#5cb87a';btn.style.background='#1a2a1f';btn.classList.add('selected');
234
- _ttsSelections[selKey].voice = btn.dataset.voice;
235
- }
236
  return;
237
  }
238
- var ebtn = e.target.closest('.tts-emotion-btn');
239
- if(ebtn){
240
- var container = ebtn.closest('.tts-selector');
241
- if(container){
242
- var selKey = 'inline-'+container.dataset.postId;
243
- if(!_ttsSelections[selKey]) _ttsSelections[selKey]={voice:'vi-VN-HoaiMyNeural',emotion:ebtn.dataset.emotion};
244
- var allBtns = container.querySelectorAll('.tts-emotion-btn');
245
- for(var i=0;i<allBtns.length;i++){allBtns[i].style.borderColor='#333';allBtns[i].style.background='#222';allBtns[i].classList.remove('selected');}
246
- ebtn.style.borderColor='#5cb87a';ebtn.style.background='#1a2a1f';ebtn.classList.add('selected');
247
- _ttsSelections[selKey].emotion = ebtn.dataset.emotion;
248
  }
249
- return;
250
  }
251
- var cbtn = e.target.closest('.tts-create-btn');
252
- if(cbtn){
253
- var container = cbtn.closest('.tts-selector');
254
- if(container){
255
- var selKey = 'inline-'+container.dataset.postId;
256
- var selVoice = _ttsSelections[selKey] ? _ttsSelections[selKey].voice : 'vi-VN-HoaiMyNeural';
257
- var selEmotion = _ttsSelections[selKey] ? _ttsSelections[selKey].emotion : 'neutral';
258
- var speedSel = container.querySelector('.tts-speed');
259
- var speed = speedSel ? parseFloat(speedSel.value)||1.2 : 1.2;
260
- window.makeShortVideo(container.dataset.postId, cbtn, selVoice, speed, selEmotion);
261
- }
262
- return;
263
- }
264
- });
265
- function detectLanguage(text){
266
- if(!text) return 'vi';
267
- var t=text.toLowerCase(), chars=new Set(t);
268
- var vnChars='đăâêôơưàảãạáằẳẵặắầẩẫậấèẻẽẹéềễểệếìỉĩịíòỏõọóồổỗộốờởỡợớùủũụúừửữựứỳỷỹỵý';
269
- var vnCount=0; for(var c of vnChars){if(chars.has(c)) vnCount++;}
270
- if(vnCount>=2) return 'vi';
271
- if(chars.has('ñ')||chars.has('¿')||chars.has('¡')) return 'es';
272
- if(chars.has('ã')||chars.has('õ')) return 'pt';
273
- var words=t.split(/\s+/);
274
- var enWords=['the','is','at','which','on','and','or','but','this','that','with','from','have','been'];
275
- var enCount=words.filter(function(w){return enWords.indexOf(w)>=0;}).length;
276
- if(enCount>=2) return 'en';
277
- return 'vi';
278
- }
279
- function detectEmotion(text){
280
- if(!text) return 'neutral';
281
- var t=text.toLowerCase();
282
- var kws={
283
- happy:['vui','hạnh phúc','tuyệt','thành công','chiến thắng','feliz','maravilloso','happy','joy','wonderful','great','amazing','love','excellent'],
284
- excited:['hào hứng','phấn khích','đột phá','kỷ lục','đỉnh cao','emocionante','increíble','excited','thrilling','unbelievable','awesome','breakthrough'],
285
- sad:['buồn','đau','mất','thảm họa','khủng hoảng','triste','terrible','sad','unhappy','tragic','painful','death'],
286
- humorous:['hài hước','buồn cười','haha','đùa','engraçado','gracioso','funny','hilarious','joke','lol'],
287
- serious:['nghiêm trọng','kh���n cấp','quan trọng','lo ngại','sério','crítico','serious','critical','urgent','severe','crisis'],
288
- urgent:['khẩn cấp','báo động','ngay lập tức','urgent','breaking','alert','emergency'],
289
- warm:['ấm áp','tình cảm','yêu thương','warm','love','heart','touching']
290
- };
291
- var bestScore=0, bestEmotion='neutral';
292
- for(var em in kws){var score=0; for(var kw of kws[em]){if(t.indexOf(kw)>=0) score++;} if(score>bestScore){bestScore=score;bestEmotion=em;}}
293
- return bestEmotion;
294
- }
295
- function getAutoVoice(lang){var map={vi:'vi-VN-HoaiMyNeural',pt:'pt-BR-ThalitaMultilingualNeural',en:'en-US-AndrewMultilingualNeural',fr:'fr-FR-VivienneMultilingualNeural',de:'de-DE-SeraphinaMultilingualNeural',ko:'ko-KR-HyunsuMultilingualNeural',it:'it-IT-GiuseppeMultilingualNeural'};return map[lang]||'vi-VN-HoaiMyNeural';}
296
- function buildVoiceEmotionSelector(post){
297
- var lang=post.language||detectLanguage(post.title+' '+(post.text||''));
298
- var _oldVoiceMap = {'hoaimy':'vi-VN-HoaiMyNeural','namminh':'vi-VN-NamMinhNeural','andrew':'en-US-AndrewMultilingualNeural','jenny':'en-US-AndrewMultilingualNeural','thalita':'pt-BR-ThalitaMultilingualNeural','pt_thalita':'pt-BR-ThalitaMultilingualNeural','vivienne':'fr-FR-VivienneMultilingualNeural','remy':'fr-FR-RemyMultilingualNeural','seraphina':'de-DE-SeraphinaMultilingualNeural','florian':'de-DE-FlorianMultilingualNeural','sunhee':'ko-KR-HyunsuMultilingualNeural','hyunsu':'ko-KR-HyunsuMultilingualNeural','giuseppe':'it-IT-GiuseppeMultilingualNeural','ela':'en-US-AndrewMultilingualNeural','denise':'fr-FR-VivienneMultilingualNeural','katja':'de-DE-SeraphinaMultilingualNeural','nanami':'en-US-AndrewMultilingualNeural','xiaochen':'en-US-AndrewMultilingualNeural','es_carlos':'en-US-AndrewMultilingualNeural','pt_francisco':'pt-BR-ThalitaMultilingualNeural'};
299
- var _postVoice = post.voice ? (_oldVoiceMap[post.voice] || post.voice) : '';
300
- var autoVoice= _postVoice || getAutoVoice(lang);
301
- var autoEmotion=post.emotion||detectEmotion(post.title+' '+(post.text||''));
302
- var selKey = 'inline-'+post.id;
303
- if(!_ttsSelections[selKey]){_ttsSelections[selKey] = {voice: autoVoice, emotion: autoEmotion};}
304
- var h='<div class="tts-selector" data-post-id="'+post.id+'" style="margin-top:10px;padding:10px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:10px">';
305
- h+='<div style="font-size:11px;color:#888;margin-bottom:6px">🎙️ Giọng đọc (ngôn ngữ: '+lang.toUpperCase()+'):</div><div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px">';
306
- VOICE_LIST.forEach(function(v){var sel=v.id===_ttsSelections[selKey].voice?'border-color:#5cb87a;background:#1a2a1f':'border-color:#333;background:#222';h+='<button class="tts-voice-btn '+(v.id===_ttsSelections[selKey].voice?'selected':'')+'" data-voice="'+v.id+'" data-post-id="'+post.id+'" style="'+sel+';border:1px solid;color:#ccc;padding:4px 8px;border-radius:10px;font-size:10px;cursor:pointer">'+v.label+'</button>';});
307
- h+='</div><div style="font-size:11px;color:#888;margin-bottom:6px">😊 Cảm xúc:</div><div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px">';
308
- EMOTION_LIST.forEach(function(e){var sel=e.id===_ttsSelections[selKey].emotion?'border-color:#5cb87a;background:#1a2a1f':'border-color:#333;background:#222';h+='<button class="tts-emotion-btn '+(e.id===_ttsSelections[selKey].emotion?'selected':'')+'" data-emotion="'+e.id+'" data-post-id="'+post.id+'" style="'+sel+';border:1px solid;color:#ccc;padding:4px 8px;border-radius:10px;font-size:10px;cursor:pointer">'+e.label+'</button>';});
309
- h+='</div><div style="display:flex;align-items:center;gap:6px;margin-bottom:8px"><span style="font-size:11px;color:#888">⚡ Tốc độ:</span>';
310
- h+='<select class="tts-speed" style="background:#222;border:1px solid #333;color:#ccc;padding:3px 8px;border-radius:8px;font-size:10px"><option value="0.85">0.85x Chậm</option><option value="1.0">1.0x Bình thường</option><option value="1.2" selected>1.2x Nhanh</option><option value="1.35">1.35x Rất nhanh</option></select></div>';
311
- h+='<button class="tts-create-btn" style="width:100%;background:#2d8659;border:0;color:#fff;padding:8px;border-radius:10px;font-size:11px;font-weight:700;cursor:pointer">🎬 Tạo Short AI</button></div>';
312
- return h;
313
  }
314
- window.showVoiceEmotionSelector=function(postId,title,text){
315
- var overlay=document.createElement('div');
316
- overlay.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:99999;display:flex;align-items:center;justify-content:center;padding:16px';
317
- var box=document.createElement('div');box.style.cssText='background:#1a1a1a;border:2px solid #2d8659;border-radius:16px;padding:20px;max-width:400px;width:100%;max-height:80vh;overflow-y:auto';
318
- var lang=detectLanguage(title+' '+text);var autoEmotion=detectEmotion(title+' '+text);
319
- var h='<h3 style="color:#5cb87a;margin-bottom:12px;font-size:16px">🎬 Tạo Short AI (ngôn ngữ: '+lang.toUpperCase()+')</h3>';
320
- h+='<div style="margin-bottom:12px"><div style="color:#aaa;font-size:11px;margin-bottom:6px">🎙️ Chọn giọng đọc:</div>';
321
- VOICE_LIST.forEach(function(v){var sel=v.id===getAutoVoice(lang)?'border-color:#5cb87a;background:#1a2a1f':'border-color:#333;background:#222';h+='<button class="ve-voice-btn" data-voice="'+v.id+'" style="display:inline-block;'+sel+';border:1px solid;color:#ccc;padding:5px 10px;border-radius:12px;font-size:10px;margin:2px;cursor:pointer">'+v.label+'</button>';});
322
- h+='</div><div style="margin-bottom:12px"><div style="color:#aaa;font-size:11px;margin-bottom:6px">😊 Chọn cảm xúc:</div>';
323
- EMOTION_LIST.forEach(function(e){var sel=e.id===autoEmotion?'border-color:#5cb87a;background:#1a2a1f':'border-color:#333;background:#222';h+='<button class="ve-emotion-btn" data-emotion="'+e.id+'" style="display:inline-block;'+sel+';border:1px solid;color:#ccc;padding:5px 10px;border-radius:12px;font-size:10px;margin:2px;cursor:pointer">'+e.label+'</button>';});
324
- h+='</div><div style="margin-bottom:12px"><div style="color:#aaa;font-size:11px;margin-bottom:6px">⚡ Tốc độ:</div>';
325
- h+='<select id="ve-speed" style="background:#222;border:1px solid #333;color:#ccc;padding:6px 12px;border-radius:10px;font-size:11px"><option value="0.85">0.85x Chậm</option><option value="1.0">1.0x Bình thường</option><option value="1.2" selected>1.2x Nhanh</option><option value="1.35">1.35x Rất nhanh</option></select></div>';
326
- h+='<div style="display:flex;gap:8px"><button id="ve-create-btn" style="flex:1;background:#2d8659;border:0;color:#fff;padding:10px;border-radius:12px;font-size:12px;font-weight:700;cursor:pointer">🎬 Tạo Short</button>';
327
- h+='<button id="ve-cancel-btn" style="background:#333;border:0;color:#ccc;padding:10px 16px;border-radius:12px;font-size:11px;cursor:pointer">✕</button></div>';
328
- h+='<div id="ve-status" style="color:#888;font-size:10px;margin-top:8px;display:none"></div>';
329
- box.innerHTML=h;overlay.appendChild(box);document.body.appendChild(overlay);
330
- var selectedVoice=getAutoVoice(lang),selectedEmotion=autoEmotion;
331
- box.querySelectorAll('.ve-voice-btn').forEach(function(btn){btn.addEventListener('click',function(){box.querySelectorAll('.ve-voice-btn').forEach(function(b){b.style.borderColor='#333';b.style.background='#222';});this.style.borderColor='#5cb87a';this.style.background='#1a2a1f';selectedVoice=this.dataset.voice;});});
332
- box.querySelectorAll('.ve-emotion-btn').forEach(function(btn){btn.addEventListener('click',function(){box.querySelectorAll('.ve-emotion-btn').forEach(function(b){b.style.borderColor='#333';b.style.background='#222';});this.style.borderColor='#5cb87a';this.style.background='#1a2a1f';selectedEmotion=this.dataset.emotion;});});
333
- box.querySelector('#ve-cancel-btn').addEventListener('click',function(){overlay.remove();});
334
- box.querySelector('#ve-create-btn').addEventListener('click',async function(){
335
- this.disabled=true;this.textContent='⏳ Đang tạo...';
336
- box.querySelector('#ve-status').style.display='block';box.querySelector('#ve-status').textContent='Đang tạo video shorts...';
337
- try{
338
- var speed=parseFloat(box.querySelector('#ve-speed').value)||1.2;
339
- if(!_ttsSelections["inline-"+postId]) _ttsSelections["inline-"+postId]={voice:"vi-VN-HoaiMyNeural",emotion:"neutral"};
340
- _ttsSelections["inline-"+postId].voice=selectedVoice;_ttsSelections["inline-"+postId].emotion=selectedEmotion;
341
- var r=await fetch('/api/ai/short/'+encodeURIComponent(postId),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:selectedVoice,emotion:selectedEmotion,speed:speed})});
342
- var j=await r.json();
343
- if(!r.ok||j.error) throw new Error(j.error||'Lỗi tạo video');
344
- toast('✅ Đã tạo Short AI!');overlay.remove();
345
- var p=_wallPosts.find(function(x){return String(x.id)===String(postId);});
346
- if(p){p.video=j.video;p.voice=j.voice;p.emotion=j.emotion;}
347
- }catch(e){this.disabled=false;this.textContent='🎬 Tạo Short';box.querySelector('#ve-status').textContent='❌ '+e.message;}
348
- });
349
- };
350
 
351
  function prependWallPost(post){
352
  _wallPosts.unshift(post);
@@ -357,7 +204,8 @@ function prependWallPost(post){
357
  if(homeEl){
358
  let insertBefore=homeEl.querySelector('.slider-wrap');
359
  const newWrap=document.createElement('div');
360
- newWrap.className='slider-wrap';newWrap.id='ai-wall-wrap';
 
361
  newWrap.innerHTML=`<div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track" id="ai-wall-track">${makeWallItem(post,0)}</div>`;
362
  if(insertBefore) homeEl.insertBefore(newWrap,insertBefore);
363
  else homeEl.appendChild(newWrap);
@@ -366,35 +214,68 @@ function prependWallPost(post){
366
  }
367
  return;
368
  }
369
- track.insertAdjacentHTML('afterbegin', makeWallItem(post, 0));
 
 
 
 
 
 
 
 
 
 
 
 
370
  track.scrollTo({left:0,behavior:'smooth'});
 
371
  }
372
 
 
 
373
  let _wallPosts=[];
374
  let _currentView='home';
375
  let _currentEventId=null;
376
  let _currentMatchUrl=null;
 
377
  let _htPage=0,_htTopic='';
378
- async function loadHotTopics(){const j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));const el=document.getElementById('hot-topics');if(!el)return;el.innerHTML=(j.topics||[]).slice(0,18).map(t=>{const topicText=t.topic||t.label.replace(/^#/,'');return`<button class="hot-chip" onclick="searchTopic('${topicText.replace(/'/g,"\\'")}')">${esc(t.label)}</button>`;}).join('');}
379
  function searchTopic(topic){if(!topic){topic=document.getElementById('topic-input')?.value.trim();if(!topic){alert('Nhập chủ đề');return;}}document.getElementById('topic-input').value='';_htTopic=topic;_htPage=0;showHashtagSources(topic,0);}
380
- async function showHashtagSources(topic,page){const box=document.getElementById('hashtag-box');if(!box)return;if(page===0)box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div class="hashtag-loading"><div class="hashtag-spinner"></div>Đang tìm...</div></div>`;try{const r=await fetch(`/api/hashtag/sources?topic=${encodeURIComponent(topic)}&page=${page}`);const j=await r.json();const sources=j.sources||[];if(!sources.length&&page===0){box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div style="color:#888;padding:8px">Không tìm được bài viết liên quan</div></div>`;return;}let h='';if(page===0)h=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)} <span style="font-size:10px;color:#888">(${j.total} bài từ 8 nguồn)</span></h3><div id="ht-list">`;sources.forEach((s,i)=>{const idx=page*8+i;h+=`<div class="hashtag-src-item" onclick="readArticle('${esc(s.url)}')"><div class="hashtag-src-img" id="ht-img-${idx}"></div><div class="hashtag-src-text"><div class="hashtag-src-title">${esc(s.title)}</div><div class="hashtag-src-via">${esc(s.via||'')}</div></div></div>`;});if(page===0){h+=`</div><button class="hashtag-rewrite-btn" onclick="rewriteHashtag('${esc(topic).replace(/'/g,"\\'")}')">🤖 Rewrite AI tổng hợp & đăng tường</button>`;if(j.has_more)h+=`<button class="hashtag-load-more" id="ht-more" onclick="loadMoreHashtag()">Tải thêm ▼</button>`;h+=`</div>`;box.innerHTML=h;}else{document.getElementById('ht-list')?.insertAdjacentHTML('beforeend',h);const btn=document.getElementById('ht-more');if(btn){if(!j.has_more)btn.remove();else{btn.disabled=false;btn.textContent='Tải thêm ▼';}}}sources.forEach((s,i)=>{const idx=page*8+i;if(!s.url)return;const ctrl=new AbortController();setTimeout(()=>ctrl.abort(),4000);fetch('/api/article?url='+encodeURIComponent(s.url),{signal:ctrl.signal}).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){const el=document.getElementById('ht-img-'+idx);if(el)el.innerHTML=`<img src="${_proxyImg(esc(d.og_image||d.img))}" onerror="this.style.display='none'">`;}}).catch(()=>{});});}catch(e){box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div style="color:#e74c3c;padding:8px">Lỗi: ${esc(e.message)}</div></div>`;}}
381
  function loadMoreHashtag(){_htPage++;const btn=document.getElementById('ht-more');if(btn){btn.disabled=true;btn.textContent='Đang tải...';}showHashtagSources(_htTopic,_htPage);}
382
  async function rewriteHashtag(topic){const btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang tổng hợp...';}try{const r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');toast('✅ Đã đăng Tường AI!');if(btn)btn.textContent='✅ Đăng thành công!';if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message);if(btn){btn.disabled=false;btn.textContent='🤖 Rewrite AI';}}}
383
  async function loadLivescore(tab){document.querySelectorAll('.ls-tab').forEach(t=>t.classList.remove('active'));document.querySelector(`.ls-tab[data-tab="${tab}"]`)?.classList.add('active');const el=document.getElementById('ls-content');if(!el)return;el.innerHTML='<div class="loading">Đang tải...</div>';let ep='/api/livescore/'+tab;if(tab.startsWith('bxh_'))ep='/api/livescore/standings/'+tab.replace('bxh_','');try{const r=await fetch(ep);const d=await r.json();el.innerHTML=d.html&&d.html.length>50?d.html:'<div class="loading">Không có dữ liệu</div>';bindMatchClicks(el);}catch(e){el.innerHTML='<div class="loading">Lỗi</div>';}}
384
- function bindMatchClicks(el){el.querySelectorAll('.match-detail').forEach(md=>{md.style.cursor='pointer';md.addEventListener('click',function(e){const statusA=this.querySelector('.status a');const teamA=this.querySelector('.teams a[href*="/tran-dau/"]');const a = statusA || teamA;if(a){e.preventDefault();e.stopPropagation();const href=a.getAttribute('href')||'';const m=href.match(/\/tran-dau\/(\d+)\//);if(m){const fullUrl=href.startsWith('http')?href:'https://bongda.com.vn'+href;openMatch(m[1],fullUrl);}}});});el.querySelectorAll('a').forEach(a=>{a.addEventListener('click',e=>{e.preventDefault();e.stopPropagation()});});}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
385
  function openMatch(id,url){if(!id)return;_currentEventId=id;if(url)_currentMatchUrl=url;document.getElementById('match-overlay').classList.add('active');document.body.style.overflow='hidden';loadMatchTab('detail')}
386
  function closeMatch(){document.getElementById('match-overlay').classList.remove('active');document.body.style.overflow=''}
387
  async function loadMatchTab(tab){document.querySelectorAll('.mo-tab').forEach(t=>t.classList.remove('active'));document.querySelectorAll('.mo-tab').forEach(t=>{if((tab==='comm'&&t.textContent==='Diễn biến')||(tab==='stats'&&t.textContent==='Thống kê')||(tab==='detail'&&t.textContent.includes('Chi tiết')))t.classList.add('active')});const el=document.getElementById('mo-body');if(!el)return;el.innerHTML='<div class="loading">Đang tải...</div>';try{let apiUrl;if(tab==='stats')apiUrl=`/api/match/${_currentEventId}/stats`;else if(tab==='comm')apiUrl=`/api/match/${_currentEventId}/commentaries`;else{apiUrl=`/api/match/${_currentEventId}/detail`;if(_currentMatchUrl)apiUrl+='?url='+encodeURIComponent(_currentMatchUrl)}const r=await fetch(apiUrl);if(!r.ok){el.innerHTML='<div class="loading">Lỗi máy chủ ('+r.status+')</div>';return}const d=await r.json();if(d.error){el.innerHTML='<div class="loading">'+esc(d.error)+'</div>';return}if(tab==='detail'&&typeof renderMatchDetail==='function'){renderMatchDetail(el,d);return}el.innerHTML=d.html||'<div class="loading">Không có dữ liệu</div>'}catch(e){el.innerHTML='<div class="loading">Lỗi</div>'}}
388
- function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]))}
389
- function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')}
390
- function switchCat(id){document.querySelectorAll('.cat').forEach(c=>c.classList.remove('active'));document.querySelector(`[data-cat="${id}"]`)?.classList.add('active');document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.querySelectorAll('video').forEach(v=>{v.pause();if(v._hls){v._hls.destroy();v._hls=null}});document.querySelectorAll('iframe[data-yt-src]').forEach(f=>{f.src=''});if(id==='home')document.getElementById('view-home').classList.add('active');else if(id==='news-all'){document.getElementById('view-cat').classList.add('active');loadNewsTab()}else{document.getElementById('view-cat').classList.add('active');loadCat(id)}}
391
- function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}}
392
- function doShare(title,url,img){const shareUrl=SPACE+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||'');if(navigator.share)navigator.share({title,url:shareUrl}).catch(()=>{});else navigator.clipboard.writeText(shareUrl).then(()=>toast('Đã sao chép!')).catch(()=>{})}
393
  async function doInteract(videoId,type){try{const r=await fetch('/api/v2/interact',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:videoId,type})});return await r.json();}catch(e){return null;}}
394
  async function getInteractions(videoId){try{return await fetch('/api/v2/interactions?id='+encodeURIComponent(videoId)).then(r=>r.json());}catch(e){return{views:0,likes:0,comments:0};}}
395
  async function getComments(videoId){try{const j=await fetch('/api/v2/comments?id='+encodeURIComponent(videoId)).then(r=>r.json());return j.comments||[];}catch(e){return[];}}
396
  async function postComment(videoId,text){try{const j=await fetch('/api/v2/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:videoId,text})}).then(r=>r.json());return j.comments||[];}catch(e){return[];}}
397
- function buildTikTokSlide(opts){return`<div class="tiktok-slide" data-vid="${esc(opts.videoId)}">${opts.vtag}<div class="tiktok-bottom"><span class="badge ${opts.badgeClass||'badge-fpt'}">${opts.badge||''}</span><p class="tiktok-title">${esc(opts.title)}</p></div><div class="tiktok-right"><button class="tiktok-right-btn" onclick="event.stopPropagation();doView('${esc(opts.videoId)}',this)"><div class="icon">👁</div><div class="count" id="vc-${opts.idx}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();doLike('${esc(opts.videoId)}',this)"><div class="icon">❤️</div><div class="count" id="lc-${opts.idx}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();toggleComments('${esc(opts.videoId)}',${opts.idx})"><div class="icon">💬</div><div class="count" id="cc-${opts.idx}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();doShare('${esc(opts.title)}','${esc(opts.shareUrl||'')}','')"><div class="icon">📤</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();toggle169View('${esc(opts.videoId)}','169-toggle-${opts.idx}')"><div class="icon" id="169-toggle-${opts.idx}">🖥️</div></button>${opts.extraBtn||''}</div><span class="tiktok-counter">${opts.idx+1}/${opts.total}</span><div class="inline-comments" id="cmt-inline-${opts.idx}" style="display:none"></div></div>`;}
398
  async function doView(videoId,btn){const j=await doInteract(videoId,'view');if(j){const c=btn.querySelector('.count');if(c)c.textContent=fmtNum(j.views);}}
399
  async function doLike(videoId,btn){const j=await doInteract(videoId,'like');if(j){const c=btn.querySelector('.count');if(c)c.textContent=fmtNum(j.likes);}}
400
  function fmtNum(n){if(!n)return'0';if(n>=1000000)return(n/1000000).toFixed(1)+'M';if(n>=1000)return(n/1000).toFixed(1)+'K';return String(n);}
@@ -402,24 +283,91 @@ async function loadCounters(videoIds){for(let i=0;i<videoIds.length;i++){const i
402
  async function toggleComments(videoId,idx){const panel=document.getElementById('cmt-inline-'+idx);if(!panel)return;if(panel.style.display!=='none'){panel.style.display='none';return;}panel.style.display='block';panel.innerHTML='<div style="padding:8px;color:#888;font-size:11px">Đang tải...</div>';const cmts=await getComments(videoId);renderInlineComments(panel,videoId,idx,cmts);}
403
  function renderInlineComments(panel,videoId,idx,cmts){let h='<div class="inline-cmt-header"><span>💬 Bình luận</span><button onclick="document.getElementById(\'cmt-inline-'+idx+'\').style.display=\'none\'">✕</button></div><div class="inline-cmt-list">';if(cmts.length){cmts.slice(-30).forEach(c=>{h+=`<div class="inline-cmt-item"><span class="inline-cmt-time">${c.time||''}</span>${esc(c.text)}</div>`;});}else{h+='<div style="color:#777;font-size:11px;padding:4px">Chưa có bình luận</div>';}h+=`</div><div class="inline-cmt-input"><input id="cmt-input-${idx}" placeholder="Viết bình luận..." onkeydown="if(event.key==='Enter')submitInlineCmt('${esc(videoId)}',${idx})"><button onclick="submitInlineCmt('${esc(videoId)}',${idx})">Gửi</button></div>`;panel.innerHTML=h;const list=panel.querySelector('.inline-cmt-list');if(list)list.scrollTop=list.scrollHeight;}
404
  async function submitInlineCmt(videoId,idx){const inp=document.getElementById('cmt-input-'+idx);if(!inp)return;const text=inp.value.trim();if(!text)return;inp.value='';inp.disabled=true;const cmts=await postComment(videoId,text);inp.disabled=false;const panel=document.getElementById('cmt-inline-'+idx);if(panel)renderInlineComments(panel,videoId,idx,cmts);const cc=document.getElementById('cc-'+idx);if(cc)cc.textContent=fmtNum(cmts.length);}
405
- function toggle169View(videoId,iconId){const slide=videoId?document.querySelector(`.tiktok-slide[data-vid="${videoId}"]`):null;if(slide){slide.classList.toggle('ratio-wide');const iconEl=iconId?document.getElementById(iconId):null;if(iconEl)iconEl.textContent=slide.classList.contains('ratio-wide')?'📺':'🖥️';else{const btn=slide.querySelector('.tiktok-right-btn .icon');if(btn)btn.textContent=slide.classList.contains('ratio-wide')?'📺':'🖥️';}return}document.querySelectorAll('.tiktok-slide.ratio-wide').forEach(s=>s.classList.remove('ratio-wide'));document.querySelectorAll('.tiktok-slide').forEach(s=>s.classList.add('ratio-wide'));document.querySelectorAll('.tiktok-right-btn .icon').forEach(b=>{if(b.textContent==='🖥️')b.textContent='📺';})}
406
  function initTikTokFeed(){const feed=document.getElementById('tiktok-feed');if(!feed)return;const slides=feed.querySelectorAll('.tiktok-slide');let cur=-1;function act(i){if(i===cur)return;slides.forEach((sl,idx)=>{const v=sl.querySelector('video');const fr=sl.querySelector('iframe');if(idx===i){if(v&&v.dataset.hls&&!v._hls&&typeof Hls!=='undefined'&&Hls.isSupported()){const hls=new Hls();hls.loadSource(v.dataset.hls);hls.attachMedia(v);hls.on(Hls.Events.MANIFEST_PARSED,()=>v.play().catch(()=>{}));v._hls=hls}else if(v)v.play().catch(()=>{});if(fr&&!fr.src&&fr.dataset.ytSrc)fr.src=fr.dataset.ytSrc;const vid=sl.dataset.vid;if(vid&&!sl._viewed){sl._viewed=true;doInteract(vid,'view').then(j=>{if(j){const vc=document.getElementById('vc-'+idx);if(vc)vc.textContent=fmtNum(j.views);}});}}else{if(v){v.pause();if(v._hls){v._hls.destroy();v._hls=null}}if(fr&&fr.src)fr.src=''}});cur=i}let sT;feed.addEventListener('scroll',()=>{clearTimeout(sT);sT=setTimeout(()=>{const rect=feed.getBoundingClientRect(),ctr=rect.top+rect.height/2;let best=-1,bestD=1e9;slides.forEach((sl,i)=>{const d=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(d<bestD){bestD=d;best=i}});if(best>=0)act(best)},150)});setTimeout(()=>act(0),400);slides.forEach(sl=>{const v=sl.querySelector('video');if(v)v.addEventListener('click',e=>{e.preventDefault();v.paused?v.play().catch(()=>{}):v.pause()})});const ids=[...slides].map(sl=>sl.dataset.vid||'');loadCounters(ids)}
407
- async function openHighlightFeed(league,idx,forceUrl){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải...</div>';let articles=(_hlLeagueData||{})[league]||[];if(!articles.length){try{articles=await fetch('/api/highlights/'+league).then(r=>r.json())}catch(e){articles=[]}}if(!articles.length){el.innerHTML='<div class="loading">Không có video</div>';return}const vids=[];const results=await Promise.all(articles.map(async(a,i)=>{try{const r=await fetch('/api/video_url?url='+encodeURIComponent(a.link)+'&img='+encodeURIComponent(a.img||''));const v=await r.json();if(v&&v.src){return{_idx:i,title:a.title||v.title||'',link:a.link||'',img:a.img||v.poster||'',src:v.src,type:v.type||'',poster:v.poster||a.img||''}}return null}catch(e){}return null}));results.forEach(r=>{if(r)vids.push(r)});vids.sort((a,b)=>a._idx-b._idx);if(!vids.length){el.innerHTML='<div class="loading">Không tìm thấy video</div>';return}let ti=vids.findIndex(v=>v._idx===idx);if(ti<0)ti=0;const ordered=ti>0?[...vids.slice(ti),...vids.slice(0,ti)]:vids;let h=`<button class="back-btn" onclick="switchCat('home')">← Highlight</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">`;ordered.forEach((v,i)=>{const isYT=v.type==='youtube',isHLS=!isYT&&v.src?.includes('.m3u8'),poster=v.poster?` poster="${esc(v.poster)}"`:'';const vtag=isYT?`<iframe data-yt-src="${v.src}" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture"></iframe>`:isHLS?`<video playsinline preload="none"${poster} data-hls="${v.src}" loop controls></video>`:`<video playsinline preload="none"${poster} loop controls><source src="${v.src}" type="video/mp4"></video>`;const videoId='hl-'+league+'-'+v._idx;h+=buildTikTokSlide({vtag,title:v.title,badge:'HL',badgeClass:'badge-fpt',videoId,idx:i,total:ordered.length,shareUrl:v.link||'',extraBtn:`<button class="tiktok-right-btn" onclick="event.stopPropagation();openHighlightFeed('${league}',${(i+1)%ordered.length})"><div class="icon">⏭️</div></button>`})});h+='</div></div>';el.innerHTML=h;setTimeout(()=>initTikTokFeed(),200);}
408
- async function openYTShortsFeed(idx){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đã xóa Shorts Dân trí/SKĐS</div>';}
409
- async function openShortAIFeed(idx){showView('view-tiktok');const el=document.getElementById('view-tiktok');if(!_wallPosts||!_wallPosts.length){el.innerHTML='<div class="loading">Không có Short AI</div>';return}const aiPosts=_wallPosts.filter(p=>p.video);if(!aiPosts.length||idx>=aiPosts.length){el.innerHTML='<div class="loading">Không có Short AI</div>';return}const ordered=aiPosts.slice(idx).concat(aiPosts.slice(0,idx));let h=`<button class="back-btn" onclick="switchCat('home')">← Tường AI</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">`;ordered.forEach((p,i)=>{const baseIdx=_wallPosts.indexOf(p);const vtag=p.video?`<video playsinline preload="none" src="${esc(p.video)}" loop controls></video>`:'';h+=buildTikTokSlide({vtag,title:p.title,badge:'Short AI',badgeClass:'badge-ai',videoId:p.id||'ai-'+i,idx:i,total:ordered.length,shareUrl:'',extraBtn:`<button class="tiktok-right-btn" onclick="event.stopPropagation();showVoiceEmotionSelector('${esc(p.id)}','${esc(p.title)}','${esc((p.text||'').slice(0,200))}')"><div class="icon">🎤</div></button>`})});h+='</div></div>';el.innerHTML=h;setTimeout(()=>initTikTokFeed(),200);}
410
- function makeShortVideo(postId, btn, voice, speed, emotion){
411
- if(!postId)return;
412
- if(!voice||!emotion){voice='vi-VN-HoaiMyNeural';emotion='neutral';}
413
- toast(' Đang tạo...');
414
- fetch('/api/ai/short/'+encodeURIComponent(postId),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice,emotion,speed:speed||1.2})}).then(r=>r.json()).then(j=>{if(j.error)throw new Error(j.error);toast('✅ OK!');}).catch(e=>toast(''+e.message));
415
- }
416
- function readWallPost(idx){const p=_wallPosts&&_wallPosts[idx];if(!p)return;if(p.slides&&p.slides.length){readSlidePost(idx);return}readArticle(p.url||'','','',p.title,p.text);}
417
- /** Show rewrite slide viewer - vertical slides with text+image */
418
- function readSlidePost(idx){const p=_wallPosts[idx];if(!p||!p.slides)return;showView('view-article');const el=document.getElementById('view-article');let h=`<button class="back-btn" onclick="switchCat('home')">← Tường AI</button><div class="slide-viewer" style="padding:12px;max-width:600px;margin:0 auto">`;p.slides.forEach((s,i)=>{h+=`<div class="slide-card" style="background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:16px;margin-bottom:12px"><div class="slide-num" style="color:#5cb87a;font-size:12px;font-weight:700;margin-bottom:6px">Slide ${s.index||i+1}/${p.slides.length}</div>${s.image?`<img src="${_proxyImg(s.image)}" style="width:100%;max-height:300px;object-fit:cover;border-radius:8px;margin-bottom:8px" loading="lazy" onerror="this.style.display=\'none\'">`:''}<p style="color:#ddd;font-size:14px;line-height:1.6;margin:0">${esc(s.text)}</p></div>`;});h+=`</div>`;el.innerHTML=h;}
419
- function readNewsTab(tab){loadNewsTab();}
420
- function loadNewsTab(){const el=document.getElementById('view-cat');if(!el)return;el.innerHTML='<div class="loading">Đang tải tin tức...</div>';fetch('/api/homepage').then(r=>r.json()).then(articles=>{if(!articles||!articles.length){el.innerHTML='<div class="loading">Không có tin</div>';return}let h='<div class="grid">';articles.forEach(a=>{const src=a.source||'vne';const badge=a.group||a.source||'';h+=`<div class="card" onclick="readArticle('${esc(a.link)}')"><div class="card-img">${a.img?`<img src="${_proxyImg(a.img)}" loading="lazy" onerror="this.style.display=\'none\'">`:''}</div><div class="card-body"><span class="badge badge-${src}">${esc(badge)}</span><div class="card-title">${esc(a.title)}</div></div></div>`;});h+='</div>';el.innerHTML=h;}).catch(()=>{el.innerHTML='<div class="loading">Lỗi tải</div>';});}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
421
 
422
- function readArticle(url,title,img,presetTitle,presetText){showView('view-article');const el=document.getElementById('view-article');el.innerHTML='<div class="loading">Đang tải...</div><button class="back-btn" onclick="switchCat(\'home\')">← Quay lại</button>';if(presetTitle){el.innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><h1 class="article-title">${esc(presetTitle)}</h1>${presetText?`<div class="article-summary">${esc(presetText)}</div>`:''}</div>`;return;}if(!url)return;fetch('/api/article?url='+encodeURIComponent(url)).then(r=>r.json()).then(d=>{let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view">`;if(d.title)h+=`<h1 class="article-title">${esc(d.title)}</h1>`;if(d.summary)h+=`<div class="article-summary">${esc(d.summary)}</div>`;if(d.body)d.body.forEach(b=>{if(b.type==='p')h+=`<p class="article-p">${esc(b.text)}</p>`;else if(b.type==='heading')h+=`<h2 class="article-h2">${esc(b.text)}</h2>`;else if(b.type==='img'&&b.src)h+=`<img class="article-img" src="${_proxyImg(b.src)}" loading="lazy" onerror="this.style.display=\'none\'">`;});h+=`</div><div class="article-actions"><button onclick="doShare('${esc(d.title||'')}','${esc(url)}','${esc(d.og_image||'')}')">📤 Chia sẻ</button><button class="primary" onclick="rewriteSlide('${esc(url)}')">🤖 Slide Rewrite AI</button></div>`;el.innerHTML=h;}).catch(()=>{el.innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><p>Không thể tải bài viết</p></div>`;});}
423
- async function rewriteSlide(url){if(!url)return;const btn=document.querySelector('.article-actions .primary')||event?.target;if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo slides...';}toast('⏳ Đang tạo slide rewrite...');try{const r=await fetch('/api/rewrite_slide',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');toast('✅ Đã tạo slide! Xem trong Tường AI.');if(btn)btn.textContent='✅ Hoàn tất';if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message);if(btn){btn.disabled=false;btn.textContent='🤖 Slide Rewrite AI';}}}
424
- function rewriteUrl(){const url=document.getElementById('url-input')?.value.trim();if(!url){alert('Nhập URL');return;}readArticle(url);}
425
- function loadCat(id){const el=document.getElementById('view-cat');el.innerHTML='<div class="loading">Đang tải...</div>';fetch('/api/category/'+id).then(r=>r.json()).then(articles=>{if(!articles||!articles.length){el.innerHTML='<div class="loading">Không có bài viết</div>';return}let h='<div class="grid">';articles.forEach(a=>{h+=`<div class="card" onclick="readArticle('${esc(a.link)}')"><div class="card-img">${a.img?`<img src="${_proxyImg(a.img)}" loading="lazy" onerror="this.style.display=\'none\'">`:''}</div><div class="card-body"><span class="badge badge-${a.source||'vne'}">${esc(a.source||'')}</span><div class="card-title">${esc(a.title)}</div></div></div>`;});h+='</div>';el.innerHTML=h;}).catch(()=>{el.innerHTML='<div class="loading">Lỗi tải</div>';});}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // === VNEWS Frontend v2 - Optimized for speed ===
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
+ // === LOAD HOME - Fast: immediate shell + parallel fetch ===
4
  function _fetchWithTimeout(url, ms){
5
  return new Promise((resolve,reject)=>{
6
  const ctrl=new AbortController();
 
17
  const homeEl = document.getElementById('view-home');
18
  if(!homeEl) return;
19
 
20
+ // Build shell IMMEDIATELY — no skeleton, no delay
21
  homeEl.innerHTML =
22
  '<div id="home-featured-area"></div>'
23
  +'<div class="ai-compose"><div class="ai-compose-title">🤖 AI viết bài</div><div class="ai-compose-row"><input id="topic-input" placeholder="Nhập chủ đề..."><button onclick="searchTopic()">Tìm nguồn</button></div><div class="ai-compose-row"><input id="url-input" placeholder="Dán URL bài viết..."><button class="secondary" onclick="rewriteUrl()">Rewrite</button></div><div id="hot-topics" class="hot-topic-row"></div></div>'
 
28
 
29
  const afterEl = homeEl.querySelector('#home-after-wc');
30
 
31
+ // Start critical loads immediately
32
  loadLivescore('today');
33
  loadHotTopics();
34
 
35
+ // Fetch all data in parallel with shorter timeouts
36
+ const [featuredData, shortsData, wallData, hlLeagues, aiData, wcData] = await Promise.allSettled([
37
  _fetchWithTimeout('/api/livescore/featured', 5000),
38
+ _fetchWithTimeout('/api/shorts', 8000),
39
  _fetchWithTimeout('/api/wall', 5000),
40
  _fetchWithTimeout('/api/highlights/leagues', 10000),
41
+ _fetchWithTimeout('/api/genk_ai', 8000),
42
  _fetchWithTimeout('/api/wc2026', 8000),
43
  ]).then(results => results.map(r => r.status === 'fulfilled' ? r.value : null));
44
 
45
+ // Render featured match
46
  if(featuredData && featuredData.home){
47
  const sc=featuredData.status==='live'?'':'upcoming';
48
  const st=featuredData.status==='live'?`🔴 ${featuredData.minute||'LIVE'}`:`⏰ ${featuredData.time}`;
49
  const area=document.getElementById('home-featured-area');
50
+ if(area) area.innerHTML=`<div class="featured-match" onclick="openMatch('${featuredData.event_id}')"><div class="fm-league">${featuredData.league}</div><div class="fm-teams"><div class="fm-team"><img src="${featuredData.home_logo}" onerror="this.style.display='none'"><span>${featuredData.home}</span></div><div class="fm-score">${featuredData.score||'VS'}</div><div class="fm-team"><img src="${featuredData.away_logo}" onerror="this.style.display='none'"><span>${featuredData.away}</span></div></div><div class="fm-status ${sc}">${st}</div></div>`;
51
  }
52
 
53
+ // Store globally
54
+ _shortsData = shortsData || [];
55
  _wallPosts = (wallData && wallData.posts) || [];
56
  _hlLeagueData = hlLeagues || {};
57
  _wc2026Data = wcData;
58
 
59
+ // Render WC if data arrived
60
  if(wcData) switchWCTab('news');
61
 
62
+ // Render sections into the after-wc area
63
+ _renderShortsIn(afterEl);
64
  _renderWallIn(afterEl);
65
  _renderHLIn(afterEl);
66
+ if(aiData && aiData.length) _renderSlidesIn('ai-articles','Ứng dụng AI','🤖',aiData,afterEl);
67
  }
68
 
69
  function _renderSlidesIn(key, label, emoji, vids, afterEl){
 
74
  const isHL = key==='world-cup'||key==='premier-league'||key==='champions-league'||key==='la-liga'||key==='serie-a'||key==='bundesliga'||key==='friendly';
75
  vids.slice(0,isHL?8:12).forEach((a,i)=>{
76
  if(isHL){
77
+ h+=`<div class="slider-item" onclick="openHighlightFeed('${key}',${i})"><div class="slider-thumb">${a.img?`<img src="${a.img}" loading="lazy">`:''}<div class="card-play">▶</div></div><div class="slider-title">${esc(a.title)}</div></div>`;
78
  } else {
79
+ h+=`<div class="slider-item" onclick="readArticle('${esc(a.link)}')"><div class="slider-thumb">${a.img?`<img src="${a.img}" loading="lazy">`:''}</div><div class="slider-title">${esc(a.title)}</div></div>`;
80
  }
81
  });
82
  h+='</div>';
 
84
  afterEl.parentNode.insertBefore(wrap, afterEl);
85
  }
86
 
87
+ function _renderShortsIn(afterEl){
88
+ if(!_shortsData||!_shortsData.length||!afterEl) return;
89
+ const mixed=interleaveShorts(_shortsData);
90
+ if(!mixed.length) return;
91
+ const wrap=document.createElement('div');
92
+ wrap.className='slider-wrap';
93
+ let h=`<div class="slider-header"><span class="slider-label">📱 Shorts Dân trí & SKĐS</span><span class="slider-note">Mới nhất · xen kẽ</span></div><div class="slider-track">`;
94
+ mixed.slice(0,30).forEach((a,i)=>{
95
+ const badge=a.channel==='baosuckhoedoisongboyte'?'SKĐS':'Dân trí';
96
+ h+=`<div class="slider-item shorts-item" onclick="openYTShortsFeed(${i})"><div class="slider-thumb shorts-thumb">${a.img?`<img src="${a.img}" loading="lazy">`:''}<div class="card-play">▶</div></div><div class="slider-title"><span style="color:#f0c040;font-size:8px">${badge}</span> ${esc(a.title)}</div></div>`;
97
+ });
98
+ h+='</div>';wrap.innerHTML=h;
99
+ afterEl.parentNode.insertBefore(wrap,afterEl);
100
+ }
101
+
102
  function _renderWallIn(afterEl){
103
  if(!_wallPosts||!_wallPosts.length||!afterEl) return;
104
  const posts=_wallPosts;
105
+ const aiShorts=posts.filter(p=>p.video);
106
+ if(aiShorts.length){
107
+ const wrap=document.createElement('div');
108
+ wrap.className='slider-wrap';
109
+ let h='<div class="slider-header"><span class="slider-label">🎬 Short AI</span></div><div class="slider-track">';
110
+ aiShorts.slice(0,20).forEach((p,i)=>{h+=`<div class="slider-item shorts-item" onclick="openShortAIFeed(${i})"><div class="slider-thumb shorts-thumb"><video src="${p.video}" muted preload="metadata"></video><div class="card-play">▶</div></div><div class="slider-title">${esc(p.title)}</div></div>`;});
111
+ h+='</div>';wrap.innerHTML=h;
112
+ afterEl.parentNode.insertBefore(wrap,afterEl);
113
+ }
114
  const wrap=document.createElement('div');
115
  wrap.className='slider-wrap';wrap.id='ai-wall-wrap';
116
  let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track" id="ai-wall-track">';
 
131
 
132
  // === WALL POST HELPERS ===
133
  function makeWallItem(p,i){
134
+ const hasVideo = p.video && p.video.length > 0;
135
+ const thumbContent = p.img
136
+ ? `<img src="${esc(p.img)}" loading="lazy" onerror="this.style.display='none'">`
137
+ : (hasVideo ? `<video src="${esc(p.video)}" muted></video>` : '');
138
+ const videoBadge = hasVideo ? `<div class="wall-video-badge">🎬</div>` : '';
139
+ const videoBtn = hasVideo
140
+ ? `<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed(${i})">▶ Xem Short</button>`
141
+ : `<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo('${esc(p.id||i)}',this)">🎬 Tạo Video</button>`;
142
+ return `<div class="wall-item" id="wall-item-${esc(p.id||i)}"><div class="wall-thumb">${thumbContent}${videoBadge}</div><div class="wall-title">${esc(p.title)}</div><div class="wall-text">${esc((p.text||'').slice(0,180))}</div><div class="wall-actions"><button class="primary" onclick="readWallPost(${i})">Xem</button>${videoBtn}</div></div>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  }
144
 
145
+ async function makeShortVideo(postId, btn, voice, speed){
146
  if(!postId)return;
147
  const origText = btn ? btn.textContent : '🎬 Tạo Video';
148
  if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo...';}
149
  toast('⏳ Đang tạo video shorts...');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  try{
151
+ let url = '/api/ai/short/'+encodeURIComponent(postId);
152
+ const params = [];
153
+ if(voice) params.push('voice='+encodeURIComponent(voice));
154
+ if(speed) params.push('speed='+encodeURIComponent(speed));
155
+ if(params.length) url += '?' + params.join('&');
156
+ const r = await fetch(url, {method:'POST'});
157
  const j = await r.json();
158
  if(!r.ok || j.error) throw new Error(j.error||'Lỗi tạo video');
159
  toast('✅ Đã tạo video shorts!');
160
  const p = _wallPosts.find(x => String(x.id) === String(postId));
161
  if(p){
162
  p.video = j.video;
 
 
163
  const itemId = 'wall-item-'+postId;
164
  const el = document.getElementById(itemId);
165
  if(el){
166
  const idx = _wallPosts.indexOf(p);
167
+ el.outerHTML = makeWallItem(p, idx);
168
+ const newEl = document.getElementById(itemId);
169
+ if(newEl) newEl.className = 'wall-item wall-item-new';
170
  }
171
  }
172
+ refreshShortAISlider();
173
  }catch(e){
174
  toast('❌ '+e.message);
175
  if(btn){btn.disabled=false;btn.textContent=origText;}
176
  }
177
  }
178
 
179
+ function refreshShortAISlider(){
180
+ const aiShorts = _wallPosts.filter(p=>p.video);
181
+ let shortAISection = document.getElementById('short-ai-section');
182
+ if(aiShorts.length === 0){
183
+ if(shortAISection) shortAISection.remove();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  return;
185
  }
186
+ if(shortAISection){
187
+ const track = shortAISection.querySelector('.slider-track');
188
+ if(track){
189
+ let h = '';
190
+ aiShorts.slice(0,20).forEach((p,i)=>{
191
+ h+=`<div class="slider-item shorts-item" onclick="openShortAIFeed(${i})"><div class="slider-thumb shorts-thumb"><video src="${esc(p.video)}" muted preload="metadata"></video><div class="card-play">▶</div></div><div class="slider-title">${esc(p.title)}</div></div>`;
192
+ });
193
+ track.innerHTML = h;
 
 
194
  }
 
195
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
 
198
  function prependWallPost(post){
199
  _wallPosts.unshift(post);
 
204
  if(homeEl){
205
  let insertBefore=homeEl.querySelector('.slider-wrap');
206
  const newWrap=document.createElement('div');
207
+ newWrap.className='slider-wrap';
208
+ newWrap.id='ai-wall-wrap';
209
  newWrap.innerHTML=`<div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track" id="ai-wall-track">${makeWallItem(post,0)}</div>`;
210
  if(insertBefore) homeEl.insertBefore(newWrap,insertBefore);
211
  else homeEl.appendChild(newWrap);
 
214
  }
215
  return;
216
  }
217
+ const div=document.createElement('div');
218
+ div.className='wall-item wall-item-new';
219
+ div.id='wall-item-'+(post.id||'new-'+Date.now());
220
+ const hasVideo = post.video && post.video.length > 0;
221
+ const thumbContent = post.img
222
+ ? `<img src="${esc(post.img)}" loading="lazy" onerror="this.style.display='none'">`
223
+ : (hasVideo ? `<video src="${esc(post.video)}" muted></video>` : '');
224
+ const videoBadge = hasVideo ? `<div class="wall-video-badge">🎬</div>` : '';
225
+ const videoBtn = hasVideo
226
+ ? `<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed(0)">▶ Xem Short</button>`
227
+ : `<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo('${esc(post.id)}',this)">🎬 Tạo Video</button>`;
228
+ div.innerHTML=`<div class="wall-thumb">${thumbContent}${videoBadge}</div><div class="wall-title">${esc(post.title)}</div><div class="wall-text">${esc((post.text||'').slice(0,180))}</div><div class="wall-actions"><button class="primary" onclick="readWallPost(0)">Xem</button>${videoBtn}</div>`;
229
+ track.prepend(div);
230
  track.scrollTo({left:0,behavior:'smooth'});
231
+ if(hasVideo) refreshShortAISlider();
232
  }
233
 
234
+ // === REST OF FUNCTIONS ===
235
+ let _shortsData=[];
236
  let _wallPosts=[];
237
  let _currentView='home';
238
  let _currentEventId=null;
239
  let _currentMatchUrl=null;
240
+ function interleaveShorts(shorts){const dt=shorts.filter(s=>s.channel==='baodantri7941');const sk=shorts.filter(s=>s.channel==='baosuckhoedoisongboyte');const result=[];let i=0,j=0;while(i<dt.length||j<sk.length){if(i<dt.length)result.push(dt[i++]);if(j<sk.length)result.push(sk[j++]);}return result;}
241
  let _htPage=0,_htTopic='';
242
+ async function loadHotTopics(){const j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));const el=document.getElementById('hot-topics');if(!el)return;el.innerHTML=(j.topics||[]).slice(0,18).map(t=>{const topicText=t.topic||t.label.replace(/^#/,'');return`<button class="hot-chip" onclick="searchTopic('${topicText.replace(/'/g,"\\'")}')">${esc(t.label)}</button>`;}).join('');if(j.topics&&j.topics[0]){const firstTopic=j.topics[0].topic||j.topics[0].label.replace(/^#/,'');setTimeout(()=>searchTopic(firstTopic),800);}}
243
  function searchTopic(topic){if(!topic){topic=document.getElementById('topic-input')?.value.trim();if(!topic){alert('Nhập chủ đề');return;}}document.getElementById('topic-input').value='';_htTopic=topic;_htPage=0;showHashtagSources(topic,0);}
244
+ async function showHashtagSources(topic,page){const box=document.getElementById('hashtag-box');if(!box)return;if(page===0)box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div class="hashtag-loading"><div class="hashtag-spinner"></div>Đang tìm...</div></div>`;try{const r=await fetch(`/api/hashtag/sources?topic=${encodeURIComponent(topic)}&page=${page}`);const j=await r.json();const sources=j.sources||[];if(!sources.length&&page===0){box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div style="color:#888;padding:8px">Không tìm được bài viết liên quan</div></div>`;return;}let h='';if(page===0)h=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)} <span style="font-size:10px;color:#888">(${j.total} bài từ 8 nguồn)</span></h3><div id="ht-list">`;sources.forEach((s,i)=>{const idx=page*8+i;h+=`<div class="hashtag-src-item" onclick="readArticle('${esc(s.url)}')"><div class="hashtag-src-img" id="ht-img-${idx}"></div><div class="hashtag-src-text"><div class="hashtag-src-title">${esc(s.title)}</div><div class="hashtag-src-via">${esc(s.via||'')}</div></div></div>`;});if(page===0){h+=`</div><button class="hashtag-rewrite-btn" onclick="rewriteHashtag('${esc(topic).replace(/'/g,"\\'")}')">🤖 Rewrite AI tổng hợp & đăng tường</button>`;if(j.has_more)h+=`<button class="hashtag-load-more" id="ht-more" onclick="loadMoreHashtag()">Tải thêm ▼</button>`;h+=`</div>`;box.innerHTML=h;}else{document.getElementById('ht-list')?.insertAdjacentHTML('beforeend',h);const btn=document.getElementById('ht-more');if(btn){if(!j.has_more)btn.remove();else{btn.disabled=false;btn.textContent='Tải thêm ▼';}}}sources.forEach((s,i)=>{const idx=page*8+i;if(!s.url)return;fetch('/api/article?url='+encodeURIComponent(s.url)).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){const el=document.getElementById('ht-img-'+idx);if(el)el.innerHTML=`<img src="${esc(d.og_image||d.img)}" onerror="this.style.display='none'">`;}}).catch(()=>{});});}catch(e){box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div style="color:#e74c3c;padding:8px">Lỗi: ${esc(e.message)}</div></div>`;}}
245
  function loadMoreHashtag(){_htPage++;const btn=document.getElementById('ht-more');if(btn){btn.disabled=true;btn.textContent='Đang tải...';}showHashtagSources(_htTopic,_htPage);}
246
  async function rewriteHashtag(topic){const btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang tổng hợp...';}try{const r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');toast('✅ Đã đăng Tường AI!');if(btn)btn.textContent='✅ Đăng thành công!';if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message);if(btn){btn.disabled=false;btn.textContent='🤖 Rewrite AI';}}}
247
  async function loadLivescore(tab){document.querySelectorAll('.ls-tab').forEach(t=>t.classList.remove('active'));document.querySelector(`.ls-tab[data-tab="${tab}"]`)?.classList.add('active');const el=document.getElementById('ls-content');if(!el)return;el.innerHTML='<div class="loading">Đang tải...</div>';let ep='/api/livescore/'+tab;if(tab.startsWith('bxh_'))ep='/api/livescore/standings/'+tab.replace('bxh_','');try{const r=await fetch(ep);const d=await r.json();el.innerHTML=d.html&&d.html.length>50?d.html:'<div class="loading">Không có dữ liệu</div>';bindMatchClicks(el);}catch(e){el.innerHTML='<div class="loading">Lỗi</div>';}}
248
+ function bindMatchClicks(el){
249
+ el.querySelectorAll('.match-detail').forEach(md=>{
250
+ md.style.cursor='pointer';
251
+ md.addEventListener('click',function(e){
252
+ const statusA=this.querySelector('.status a');
253
+ const teamA=this.querySelector('.teams a[href*="/tran-dau/"]');
254
+ const a = statusA || teamA;
255
+ if(a){
256
+ e.preventDefault();
257
+ e.stopPropagation();
258
+ const href=a.getAttribute('href')||'';
259
+ const m=href.match(/\/tran-dau\/(\d+)\//);
260
+ if(m){
261
+ const fullUrl=href.startsWith('http')?href:'https://bongda.com.vn'+href;
262
+ openMatch(m[1],fullUrl);
263
+ }
264
+ }
265
+ });
266
+ });
267
+ el.querySelectorAll('a').forEach(a=>{
268
+ a.addEventListener('click',e=>{e.preventDefault();e.stopPropagation()});
269
+ });
270
+ }
271
  function openMatch(id,url){if(!id)return;_currentEventId=id;if(url)_currentMatchUrl=url;document.getElementById('match-overlay').classList.add('active');document.body.style.overflow='hidden';loadMatchTab('detail')}
272
  function closeMatch(){document.getElementById('match-overlay').classList.remove('active');document.body.style.overflow=''}
273
  async function loadMatchTab(tab){document.querySelectorAll('.mo-tab').forEach(t=>t.classList.remove('active'));document.querySelectorAll('.mo-tab').forEach(t=>{if((tab==='comm'&&t.textContent==='Diễn biến')||(tab==='stats'&&t.textContent==='Thống kê')||(tab==='detail'&&t.textContent.includes('Chi tiết')))t.classList.add('active')});const el=document.getElementById('mo-body');if(!el)return;el.innerHTML='<div class="loading">Đang tải...</div>';try{let apiUrl;if(tab==='stats')apiUrl=`/api/match/${_currentEventId}/stats`;else if(tab==='comm')apiUrl=`/api/match/${_currentEventId}/commentaries`;else{apiUrl=`/api/match/${_currentEventId}/detail`;if(_currentMatchUrl)apiUrl+='?url='+encodeURIComponent(_currentMatchUrl)}const r=await fetch(apiUrl);if(!r.ok){el.innerHTML='<div class="loading">Lỗi máy chủ ('+r.status+')</div>';return}const d=await r.json();if(d.error){el.innerHTML='<div class="loading">'+esc(d.error)+'</div>';return}if(tab==='detail'&&typeof renderMatchDetail==='function'){renderMatchDetail(el,d);return}el.innerHTML=d.html||'<div class="loading">Không có dữ liệu</div>'}catch(e){el.innerHTML='<div class="loading">Lỗi</div>'}}
 
 
 
 
 
274
  async function doInteract(videoId,type){try{const r=await fetch('/api/v2/interact',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:videoId,type})});return await r.json();}catch(e){return null;}}
275
  async function getInteractions(videoId){try{return await fetch('/api/v2/interactions?id='+encodeURIComponent(videoId)).then(r=>r.json());}catch(e){return{views:0,likes:0,comments:0};}}
276
  async function getComments(videoId){try{const j=await fetch('/api/v2/comments?id='+encodeURIComponent(videoId)).then(r=>r.json());return j.comments||[];}catch(e){return[];}}
277
  async function postComment(videoId,text){try{const j=await fetch('/api/v2/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:videoId,text})}).then(r=>r.json());return j.comments||[];}catch(e){return[];}}
278
+ function buildTikTokSlide(opts){return`<div class="tiktok-slide" data-vid="${esc(opts.videoId)}">${opts.vtag}<div class="tiktok-bottom"><span class="badge ${opts.badgeClass||'badge-fpt'}">${opts.badge||''}</span><p class="tiktok-title">${esc(opts.title)}</p></div><div class="tiktok-right"><button class="tiktok-right-btn" onclick="event.stopPropagation();doView('${esc(opts.videoId)}',this)"><div class="icon">👁</div><div class="count" id="vc-${opts.idx}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();doLike('${esc(opts.videoId)}',this)"><div class="icon">❤️</div><div class="count" id="lc-${opts.idx}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();toggleComments('${esc(opts.videoId)}',${opts.idx})"><div class="icon">💬</div><div class="count" id="cc-${opts.idx}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();doShare('${esc(opts.title)}','${esc(opts.shareUrl||'')}','')"><div class="icon">📤</div></button>${opts.extraBtn||''}</div><span class="tiktok-counter">${opts.idx+1}/${opts.total}</span><div class="inline-comments" id="cmt-inline-${opts.idx}" style="display:none"></div></div>`;}
279
  async function doView(videoId,btn){const j=await doInteract(videoId,'view');if(j){const c=btn.querySelector('.count');if(c)c.textContent=fmtNum(j.views);}}
280
  async function doLike(videoId,btn){const j=await doInteract(videoId,'like');if(j){const c=btn.querySelector('.count');if(c)c.textContent=fmtNum(j.likes);}}
281
  function fmtNum(n){if(!n)return'0';if(n>=1000000)return(n/1000000).toFixed(1)+'M';if(n>=1000)return(n/1000).toFixed(1)+'K';return String(n);}
 
283
  async function toggleComments(videoId,idx){const panel=document.getElementById('cmt-inline-'+idx);if(!panel)return;if(panel.style.display!=='none'){panel.style.display='none';return;}panel.style.display='block';panel.innerHTML='<div style="padding:8px;color:#888;font-size:11px">Đang tải...</div>';const cmts=await getComments(videoId);renderInlineComments(panel,videoId,idx,cmts);}
284
  function renderInlineComments(panel,videoId,idx,cmts){let h='<div class="inline-cmt-header"><span>💬 Bình luận</span><button onclick="document.getElementById(\'cmt-inline-'+idx+'\').style.display=\'none\'">✕</button></div><div class="inline-cmt-list">';if(cmts.length){cmts.slice(-30).forEach(c=>{h+=`<div class="inline-cmt-item"><span class="inline-cmt-time">${c.time||''}</span>${esc(c.text)}</div>`;});}else{h+='<div style="color:#777;font-size:11px;padding:4px">Chưa có bình luận</div>';}h+=`</div><div class="inline-cmt-input"><input id="cmt-input-${idx}" placeholder="Viết bình luận..." onkeydown="if(event.key==='Enter')submitInlineCmt('${esc(videoId)}',${idx})"><button onclick="submitInlineCmt('${esc(videoId)}',${idx})">Gửi</button></div>`;panel.innerHTML=h;const list=panel.querySelector('.inline-cmt-list');if(list)list.scrollTop=list.scrollHeight;}
285
  async function submitInlineCmt(videoId,idx){const inp=document.getElementById('cmt-input-'+idx);if(!inp)return;const text=inp.value.trim();if(!text)return;inp.value='';inp.disabled=true;const cmts=await postComment(videoId,text);inp.disabled=false;const panel=document.getElementById('cmt-inline-'+idx);if(panel)renderInlineComments(panel,videoId,idx,cmts);const cc=document.getElementById('cc-'+idx);if(cc)cc.textContent=fmtNum(cmts.length);}
 
286
  function initTikTokFeed(){const feed=document.getElementById('tiktok-feed');if(!feed)return;const slides=feed.querySelectorAll('.tiktok-slide');let cur=-1;function act(i){if(i===cur)return;slides.forEach((sl,idx)=>{const v=sl.querySelector('video');const fr=sl.querySelector('iframe');if(idx===i){if(v&&v.dataset.hls&&!v._hls&&typeof Hls!=='undefined'&&Hls.isSupported()){const hls=new Hls();hls.loadSource(v.dataset.hls);hls.attachMedia(v);hls.on(Hls.Events.MANIFEST_PARSED,()=>v.play().catch(()=>{}));v._hls=hls}else if(v)v.play().catch(()=>{});if(fr&&!fr.src&&fr.dataset.ytSrc)fr.src=fr.dataset.ytSrc;const vid=sl.dataset.vid;if(vid&&!sl._viewed){sl._viewed=true;doInteract(vid,'view').then(j=>{if(j){const vc=document.getElementById('vc-'+idx);if(vc)vc.textContent=fmtNum(j.views);}});}}else{if(v){v.pause();if(v._hls){v._hls.destroy();v._hls=null}}if(fr&&fr.src)fr.src=''}});cur=i}let sT;feed.addEventListener('scroll',()=>{clearTimeout(sT);sT=setTimeout(()=>{const rect=feed.getBoundingClientRect(),ctr=rect.top+rect.height/2;let best=-1,bestD=1e9;slides.forEach((sl,i)=>{const d=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(d<bestD){bestD=d;best=i}});if(best>=0)act(best)},150)});setTimeout(()=>act(0),400);slides.forEach(sl=>{const v=sl.querySelector('video');if(v)v.addEventListener('click',e=>{e.preventDefault();v.paused?v.play().catch(()=>{}):v.pause()})});const ids=[...slides].map(sl=>sl.dataset.vid||'');loadCounters(ids)}
287
+ async function openHighlightFeed(league,idx,forceUrl){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải...</div>';let articles=(_hlLeagueData||{})[league]||[];if(!articles.length){try{articles=await fetch('/api/highlights/'+league).then(r=>r.json())}catch(e){articles=[]}}if(!articles.length){el.innerHTML='<div class="loading">Không có video</div>';return}const vids=[];const results=await Promise.all(articles.map(async(a,i)=>{try{const r=await fetch('/api/video_url?url='+encodeURIComponent(a.link));const v=await r.json();if(v&&v.src)return{...a,...v,_idx:i}}catch(e){}return null}));results.forEach(r=>{if(r)vids.push(r)});vids.sort((a,b)=>a._idx-b._idx);if(!vids.length){el.innerHTML='<div class="loading">Không tìm thấy video</div>';return}let ti=vids.findIndex(v=>v._idx===idx);if(ti<0)ti=0;const ordered=ti>0?[...vids.slice(ti),...vids.slice(0,ti)]:vids;let h=`<button class="back-btn" onclick="switchCat('home')">← Highlight</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">`;ordered.forEach((v,i)=>{const isYT=v.type==='youtube',isHLS=!isYT&&v.src?.includes('.m3u8'),poster=v.poster?` poster="${v.poster}"`:'';const vtag=isYT?`<iframe data-yt-src="${v.src}" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture"></iframe>`:isHLS?`<video playsinline preload="none"${poster} data-hls="${v.src}" loop controls></video>`:`<video playsinline preload="none"${poster} loop controls><source src="${v.src}" type="video/mp4"></video>`;const videoId='hl-'+league+'-'+(v.id||v._idx);h+=buildTikTokSlide({vtag,title:v.title,badge:'HL',badgeClass:'badge-fpt',videoId,idx:i,total:ordered.length,shareUrl:v.link||'',extraBtn:`<button class="tiktok-right-btn" onclick="event.stopPropagation();this.closest('.tiktok-slide').classList.toggle('ratio-wide')"><div class="icon"></div><div class="count">16:9</div></button>`});});h+='</div></div>';el.innerHTML=h;initTikTokFeed();}
288
+ async function openYTShortsFeed(startIdx){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải...</div>';const arts=_shortsData.length?_shortsData:await fetch('/api/shorts').then(r=>r.json()).catch(()=>[]);if(!arts.length){el.innerHTML='<div class="loading">Không có shorts</div>';return}const ordered=startIdx>0?[...arts.slice(startIdx),...arts.slice(0,startIdx)]:arts;let h=`<button class="back-btn" onclick="switchCat('home')">← Shorts</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">`;ordered.forEach((v,i)=>{const id=v.id||'';const src=`https://www.youtube.com/embed/${id}?autoplay=1&rel=0&playsinline=1`;const vtag=`<iframe data-yt-src="${src}" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture"></iframe>`;const badge=v.channel==='baosuckhoedoisongboyte'?'SKĐS':'Dân trí';const videoId='yt-'+id;h+=buildTikTokSlide({vtag,title:v.title,badge,badgeClass:'badge-fpt',videoId,idx:i,total:ordered.length,shareUrl:'https://youtube.com/watch?v='+id});});h+='</div></div>';el.innerHTML=h;initTikTokFeed();}
289
+ async function openShortAIFeed(startIdx){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải...</div>';const wall=(await fetch('/api/wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];const vids=wall.filter(p=>p.video);if(!vids.length){el.innerHTML='<div class="loading">Chưa có Short AI</div>';return}const ordered=startIdx>0?[...vids.slice(startIdx),...vids.slice(0,startIdx)]:vids;let h=`<button class="back-btn" onclick="switchCat('home')">← Short AI</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">`;ordered.forEach((p,i)=>{const vtag=`<video src="${p.video}" playsinline loop controls></video>`;const videoId='ai-'+(p.id||i);h+=buildTikTokSlide({vtag,title:p.title,badge:'AI',badgeClass:'badge-ai',videoId,idx:i,total:ordered.length,shareUrl:SPACE});});h+='</div></div>';el.innerHTML=h;initTikTokFeed();}
290
+ async function readArticle(url){showView('view-article');const el=document.getElementById('view-article');el.innerHTML='<div class="loading">Đang tải...</div>';try{const r=await fetch('/api/article?url='+encodeURIComponent(url));const data=await r.json();if(data&&!data.error&&data.body&&data.body.length){_currentArticle={url,data};let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><h1 class="article-title">${esc(data.title)}</h1>`;if(data.summary)h+=`<div class="article-summary">${esc(data.summary)}</div>`;const seen={};data.body.forEach(b=>{if(b.type==='p')h+=`<p class="article-p">${b.text}</p>`;else if(b.type==='img'&&b.src&&!seen[b.src]){seen[b.src]=1;h+=`<img class="article-img" src="${esc(b.src)}" onerror="this.style.display='none'">`}else if(b.type==='heading')h+=`<h2 class="article-h2">${esc(b.text)}</h2>`});h+=`<div class="article-actions"><button class="primary" onclick="rewriteArticle()">🤖 Rewrite AI đăng tường</button><button onclick="doShare('${esc(data.title)}','${esc(url)}','${esc(data.og_image||'')}')">📤</button><button onclick="window.open('${esc(url)}','_blank')">🔗 Gốc</button></div><div class="article-ai-ask"><h3 style="font-size:14px;color:#5cb87a">🤖 Hỏi AI</h3><textarea id="ask-q" placeholder="Hỏi về bài viết..."></textarea><button onclick="askAI()">Hỏi</button><div id="ask-a" class="article-ai-answer"></div></div></div>`;el.innerHTML=h;window.scrollTo(0,0);return;}}catch(e){}el.innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="loading"><p>Không đọc được.</p><a href="${esc(url)}" target="_blank" style="color:#5cb87a">Mở gốc →</a></div>`;}
291
+ async function rewriteArticle(){const url=_currentArticle?.url;if(!url)return;toast('⏳ Đang rewrite...');try{const r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,context:document.querySelector('.article-view')?.innerText?.slice(0,14000)||''})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error);toast('✅ Đã đăng Tường AI!');if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message)}}
292
+ async function rewriteUrl(){const url=document.getElementById('url-input')?.value.trim();if(!url)return alert('Dán URL');toast('⏳ Đang rewrite...');try{const r=await fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error);toast('✅ Đã đăng!');document.getElementById('url-input').value='';if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message)}}
293
+ async function askAI(){const q=document.getElementById('ask-q')?.value.trim();if(!q)return alert('Nhập câu hỏi');const a=document.getElementById('ask-a');a.textContent='Đang hỏi...';try{const r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:_currentArticle?.url||'',question:q,context:document.querySelector('.article-view')?.innerText?.slice(0,12000)||''})});const j=await r.json();a.textContent=j.answer||'Không trả lời được';}catch(e){a.textContent='Lỗi: '+e.message}}
294
+ async function readWallPost(i){const p=_wallPosts[i];if(!p)return;showView('view-article');
295
+ const images = p.images || [];
296
+ let imgGallery = '';
297
+ if(images.length > 0){
298
+ imgGallery = '<div class="article-image-gallery">';
299
+ images.forEach((imgUrl, imgIdx) => {
300
+ if(imgIdx === 0){
301
+ imgGallery += `<img class="article-img article-hero-img" src="${esc(imgUrl)}" onerror="this.style.display='none" loading="eager">`;
302
+ } else {
303
+ if(imgIdx === 1) imgGallery += '<div class="gallery-thumbs">';
304
+ imgGallery += `<div class="gallery-thumb"><img src="${esc(imgUrl)}" onerror="this.parentElement.style.display='none'" loading="lazy"></div>`;
305
+ }
306
+ });
307
+ if(images.length > 1) imgGallery += '</div>';
308
+ imgGallery += '</div>';
309
+ }
310
+ const hasVideo = p.video && p.video.length > 0;
311
+ const voiceOptions = [
312
+ {id:'hoaimy', label:'🎙️ Nữ — Hoài My'},
313
+ {id:'namminh', label:'🎙️ Nam — Nam Minh'},
314
+ ];
315
+ let voiceSelector = '';
316
+ if(!hasVideo){
317
+ voiceSelector = `<div class="tts-selector"><div class="tts-selector-label">🎙️ Chọn giọng đọc:</div><div class="tts-voice-btns">`;
318
+ voiceOptions.forEach(v=>{
319
+ voiceSelector += `<button class="tts-voice-btn" onclick="document.querySelectorAll('.tts-voice-btn').forEach(b=>b.classList.remove('active'));this.classList.add('active');document.getElementById('selected-voice').value='${v.id}'">${v.label}</button>`;
320
+ });
321
+ voiceSelector += `</div><div class="tts-speed-row"><span>Tốc độ:</span><select id="selected-speed"><option value="1.0">1.0x — Bình thường</option><option value="1.2" selected>1.2x — Nhanh</option><option value="1.5">1.5x — Rất nhanh</option><option value="0.8">0.8x — Chậm</option></select></div>`;
322
+ voiceSelector += `<input type="hidden" id="selected-voice" value="hoaimy"></div>`;
323
+ }
324
+ document.getElementById('view-article').innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${esc(p.title)}</h1>${imgGallery}<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>${hasVideo?`<video class="article-img" src="${esc(p.video)}" controls playsinline style="max-height:400px"></video>`:''}<div class="article-actions">${hasVideo?`<button onclick="openShortAIFeed(${i})">🎬 Xem Short</button>${voiceSelector}<button class="primary" onclick="makeShortVideo('${esc(p.id)}',this,document.getElementById('selected-voice')?.value,parseFloat(document.getElementById('selected-speed')?.value)||1.2)">🔄 Tạo lại Short</button>`:`${voiceSelector}<button class="primary" onclick="makeShortVideo('${esc(p.id)}',this,document.getElementById('selected-voice')?.value,parseFloat(document.getElementById('selected-speed')?.value)||1.2)">🎬 Tạo Video Shorts</button>`}<button onclick="doShare('${esc(p.title)}','${SPACE}','${esc(p.img||'')}')">📤</button></div></div>`;
325
+ const firstVoiceBtn = document.querySelector('.tts-voice-btn');
326
+ if(firstVoiceBtn) firstVoiceBtn.classList.add('active');
327
+ window.scrollTo(0,0)}
328
+ async function loadNewsTab(){const el=document.getElementById('view-cat');el.innerHTML='<div class="loading">Đang tải...</div>';try{const r=await fetch('/api/homepage');const news=await r.json();if(!news.length){el.innerHTML='<div class="loading">Không có tin</div>';return}const groups={};news.forEach(a=>{if(!groups[a.group])groups[a.group]=[];groups[a.group].push(a)});let h='';for(const[g,arts]of Object.entries(groups)){h+=`<div class="section-title">${g}</div><div class="grid">`;arts.slice(0,6).forEach(a=>{h+=`<div class="card" onclick="readArticle('${esc(a.link)}')"><div class="card-img">${a.img?`<img src="${a.img}" loading="lazy">`:''}</div><div class="card-body"><span class="badge badge-vne">${esc(a.source||'VnE')}</span><div class="card-title">${esc(a.title)}</div></div></div>`});h+='</div>'}el.innerHTML=h}catch(e){el.innerHTML='<div class="loading">Lỗi</div>'}}
329
+ async function loadCat(id){const el=document.getElementById('view-cat');el.innerHTML='<div class="loading">Đang tải...</div>';const arts=await fetch('/api/category/'+id).then(r=>r.json()).catch(()=>[]);if(!arts.length){el.innerHTML='<div class="loading">Không có tin</div>';return}let h='<div class="grid">';arts.forEach(a=>{h+=`<div class="card" onclick="readArticle('${esc(a.link)}')"><div class="card-img">${a.img?`<img src="${a.img}" loading="lazy">`:''}</div><div class="card-body"><span class="badge badge-vne">${esc(a.source||'')}</span><div class="card-title">${esc(a.title)}</div></div></div>`});h+='</div>';el.innerHTML=h}
330
+ fetch('/api/storage_status').then(r=>r.json()).then(j=>{if(!j.persistent){const home=document.getElementById('view-home');if(home){const w=document.createElement('div');w.className='storage-warn';w.innerHTML='⚠️ Persistent Storage chưa bật.';home.prepend(w)}}}).catch(()=>{});
331
+
332
+ (function(){
333
+ try{
334
+ var hash = window.location.hash;
335
+ if(hash && hash.length > 1){
336
+ var articleUrl = decodeURIComponent(hash.substring(1));
337
+ if(articleUrl.startsWith('http')){
338
+ history.replaceState(null, '', window.location.pathname);
339
+ setTimeout(function(){
340
+ if(typeof readArticle==='function') readArticle(articleUrl);
341
+ }, 1500);
342
+ }
343
+ }
344
+ }catch(e){}
345
+ })();
346
 
347
+ (function(){
348
+ try{
349
+ const pa=localStorage.getItem('pending_article');
350
+ const pv=localStorage.getItem('pending_video');
351
+ if(pa){
352
+ localStorage.removeItem('pending_article');
353
+ setTimeout(()=>{
354
+ if(typeof readArticle==='function') readArticle(pa);
355
+ },1500);
356
+ }
357
+ if(pv){
358
+ localStorage.removeItem('pending_video');
359
+ try{
360
+ const v=JSON.parse(pv);
361
+ if(v&&v.url) setTimeout(()=>{window.open(v.url,'_blank')},1500);
362
+ }catch(e){}
363
+ }
364
+ }catch(e){}
365
+ })();
366
+
367
+ if (document.readyState === 'loading') {
368
+ document.addEventListener('DOMContentLoaded', function() {
369
+ if (typeof loadHome === 'function') loadHome();
370
+ });
371
+ } else {
372
+ if (typeof loadHome === 'function') loadHome();
373
+ }
static/app_v2_shorts_fix.js DELETED
@@ -1,2 +0,0 @@
1
- // No-op - all functionality built into app_v2.js
2
- (function(){})();
 
 
 
static/index_v2.html CHANGED
@@ -10,12 +10,10 @@
10
  <link rel="canonical" href="https://bep40-vnews.hf.space">
11
  <link rel="stylesheet" href="/static/wc2026.css">
12
  <script src="https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js"></script>
13
- <style>
14
- *{box-sizing:border-box;margin:0;padding:0}body{background:#111;color:#eee;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;overflow-x:hidden}.header{background:linear-gradient(135deg,#0d1117,#1a3a2a 50%,#8b7500);padding:12px;text-align:center}.header h1{font-size:18px;color:#fff}.header p{font-size:10px;color:#aaa}.cats{display:flex;overflow-x:auto;background:#1a1a1a;border-bottom:1px solid #333;padding:0 4px;position:sticky;top:0;z-index:50;scrollbar-width:none}.cats::-webkit-scrollbar{display:none}.cat{padding:9px 11px;color:#888;font-size:11px;white-space:nowrap;border-bottom:2px solid transparent;cursor:pointer;flex-shrink:0}.cat.active{color:#5cb87a;border-bottom-color:#5cb87a;font-weight:700}.view{display:none}.view.active{display:block}.loading{text-align:center;padding:30px;color:#777;font-size:12px}.slider-wrap{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.slider-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.slider-label{color:#f0c040;font-size:13px;font-weight:800}.slider-note{font-size:10px;color:#777}.slider-track{display:flex;overflow-x:auto;gap:8px;padding:4px 10px 10px;scrollbar-width:none}.slider-track::-webkit-scrollbar{display:none}.slider-item{flex:0 0 160px;cursor:pointer}.slider-thumb{position:relative;width:100%;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#333}.slider-thumb img,.slider-thumb video{width:100%;height:100%;object-fit:cover}.slider-title{font-size:10px;color:#ccc;margin-top:3px;line-height:1.2;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.card-play{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:30px;height:30px;border-radius:50%;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;color:#fff;font-size:12px}.grid{display:grid;grid-template-columns:repeat(2,1fr);gap:6px;padding:6px 4px}@media(min-width:650px){.grid{grid-template-columns:repeat(3,1fr)}}.card{background:#1a1a1a;border:1px solid #222;border-radius:8px;overflow:hidden;cursor:pointer}.card-img{position:relative;aspect-ratio:16/9;background:#333}.card-img img{width:100%;height:100%;object-fit:cover}.card-body{padding:6px 8px}.card-title{font-size:11px;line-height:1.35;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.badge{font-size:8px;padding:1px 5px;border-radius:3px;font-weight:700;display:inline-block;margin-bottom:2px;color:#fff}.badge-vne{background:#c0392b}.badge-genk{background:#6a1b9a}.badge-ai{background:#2d8659}.badge-wc{background:#0b6bcb}.section-title{font-size:13px;font-weight:800;color:#5cb87a;margin:8px 0 4px;padding-left:8px;border-left:3px solid #5cb87a}.back-btn{background:#111;color:#fff;border:none;padding:10px;font-size:12px;width:100%;position:sticky;top:0;z-index:60;cursor:pointer}.article-view{padding:12px 8px 40px;max-width:760px;margin:0 auto}.article-title{font-size:18px;font-weight:800;line-height:1.3;margin-bottom:8px}.article-summary{background:#1a2a1f;border-left:3px solid #2d8659;padding:10px;margin-bottom:14px;color:#ccc;font-size:13px}.article-p{font-size:14px;line-height:1.7;color:#ccc;margin-bottom:10px}.article-img{width:100%;border-radius:6px;margin:10px 0}.article-h2{font-size:16px;margin:16px 0 8px;color:#eee}.article-actions{display:flex;gap:8px;flex-wrap:wrap;border-top:1px solid #333;margin-top:16px;padding-top:10px}.article-actions button{background:#1a1a1a;border:1px solid #333;color:#ccc;padding:7px 12px;border-radius:14px;font-size:11px;cursor:pointer}.article-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}.article-ai-ask{margin-top:12px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.article-ai-ask textarea{width:100%;min-height:60px;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px;font-size:12px}.article-ai-ask button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin-top:6px;font-size:11px;cursor:pointer}.article-ai-answer{white-space:pre-wrap;color:#ccc;font-size:13px;line-height:1.55;margin-top:8px}.tiktok-container{width:100%;height:80vh;max-height:680px;min-height:400px;background:#000}.tiktok-feed{height:100%;overflow-y:scroll;scroll-snap-type:y mandatory;scrollbar-width:none}.tiktok-feed::-webkit-scrollbar{display:none}.tiktok-slide{height:80vh;max-height:680px;min-height:400px;scroll-snap-align:start;position:relative;background:#000;display:flex;align-items:center;justify-content:center}.tiktok-slide video,.tiktok-slide iframe{width:100%;height:100%;object-fit:cover;border:none}.tiktok-slide.ratio-wide video,.tiktok-slide.ratio-wide iframe{object-fit:contain}.tiktok-bottom{position:absolute;bottom:0;left:0;right:60px;padding:12px 10px 16px;background:linear-gradient(transparent,rgba(0,0,0,.85));z-index:3}.tiktok-title{font-size:12px;color:#fff}.tiktok-counter{position:absolute;top:8px;left:8px;background:rgba(0,0,0,.5);font-size:9px;padding:2px 7px;border-radius:8px;color:#fff;z-index:4}.tiktok-right{position:absolute;right:8px;bottom:100px;display:flex;flex-direction:column;align-items:center;gap:14px;z-index:5}.tiktok-right-btn{display:flex;flex-direction:column;align-items:center;gap:2px;background:none;border:0;color:#fff;cursor:pointer;font-size:10px}.tiktok-right-btn .icon{width:42px;height:42px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:20px}.tiktok-right-btn .count{font-size:10px;color:#ddd}.inline-comments{position:absolute;bottom:0;left:0;right:0;max-height:50%;background:rgba(18,18,18,.95);border-radius:14px 14px 0 0;z-index:10;overflow:hidden;display:flex;flex-direction:column}.inline-cmt-header{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-bottom:1px solid #333;color:#5cb87a;font-size:12px;font-weight:700}.inline-cmt-header button{background:none;border:0;color:#fff;font-size:16px;cursor:pointer}.inline-cmt-list{flex:1;overflow-y:auto;padding:6px 10px;max-height:180px}.inline-cmt-item{background:#222;border-radius:8px;padding:6px 8px;margin:4px 0;color:#ccc;font-size:11px;line-height:1.3}.inline-cmt-time{font-size:9px;color:#777;margin-right:6px}.inline-cmt-input{display:flex;gap:6px;padding:8px 10px;border-top:1px solid #333}.inline-cmt-input input{flex:1;background:#222;border:1px solid #444;color:#eee;border-radius:16px;padding:7px 12px;font-size:11px}.inline-cmt-input button{background:#2d8659;border:0;color:#fff;border-radius:16px;padding:7px 12px;font-size:11px;cursor:pointer}.wc2026-section{margin:6px 4px;background:linear-gradient(135deg,#0d1117,#1a1a3a);border:1px solid #1a3a5a;border-radius:10px;overflow:hidden}.wc-header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:linear-gradient(90deg,#0b2e4a,#1a3a5a)}.wc-header h2{font-size:15px;color:#fff;margin:0}.wc-live-badge{font-size:10px;color:#e74c3c;font-weight:700;animation:wc-pulse 1.5s infinite}@keyframes wc-pulse{0%,100%{opacity:1}50%{opacity:.4}}.wc-tabs{display:flex;gap:4px;padding:8px 10px;overflow-x:auto;scrollbar-width:none}.wc-tabs::-webkit-scrollbar{display:none}.wc-tab{padding:5px 10px;background:#1a2a3a;border:1px solid #2a3a4a;border-radius:12px;color:#8ab4d8;font-size:10px;cursor:pointer;white-space:nowrap;flex-shrink:0}.wc-tab.active{background:#0b6bcb;border-color:#0b6bcb;color:#fff;font-weight:700}.wc-content{padding:8px 10px;max-height:500px;overflow-y:auto}.wc-news-grid{display:flex;flex-direction:column;gap:8px}.wc-news-item{display:flex;gap:8px;padding:8px;background:#1a2030;border-radius:8px;cursor:pointer}.wc-news-item:active{opacity:.8}.wc-news-img{flex:0 0 70px;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#222}.wc-news-img img{width:100%;height:100%;object-fit:cover}.wc-news-text{flex:1;min-width:0}.wc-news-title{font-size:11px;font-weight:700;color:#eee;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wc-news-via{font-size:9px;color:#6a9fca;margin-top:2px}.ls-section{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.ls-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.ls-header h3{color:#f0c040;font-size:13px;font-weight:800}.ls-tabs{display:flex;gap:4px;padding:0 10px 8px;overflow-x:auto;scrollbar-width:none}.ls-tabs::-webkit-scrollbar{display:none}.ls-tab{padding:4px 10px;background:#222;border:1px solid #333;border-radius:12px;color:#999;font-size:10px;white-space:nowrap;cursor:pointer;flex-shrink:0}.ls-tab.active{background:#2d8659;border-color:#2d8659;color:#fff;font-weight:700}.ls-content{max-height:420px;overflow-y:auto;padding:0 6px 8px;font-size:12px;color:#ddd}.ls-content ul{list-style:none;padding:0;margin:0}.ls-content .title-content{display:flex;gap:6px;align-items:center;background:#222;border-radius:4px;margin:4px 0;padding:5px 8px}.ls-content .title-content img{width:18px;height:18px}.ls-content .title-content strong{font-size:11px;color:#ccc}.ls-content .match-detail{padding:6px;border-bottom:1px solid #262626;cursor:pointer}.ls-content .match-detail:hover{background:#1a2a1f}.ls-content .match{display:flex;flex-wrap:wrap;align-items:center;gap:4px}.ls-content .datetime{width:100%;font-size:9px;color:#888}.ls-content .teams{display:flex;width:100%;align-items:center;gap:4px}.ls-content .team{flex:1;display:flex;align-items:center;gap:4px;min-width:0;text-decoration:none}.ls-content .team .name{font-size:11px;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ls-content .team .logo img{width:18px;height:18px}.ls-content .home-team{justify-content:flex-end;text-align:right}.ls-content .status{flex:0 0 54px;text-align:center}.ls-content .status a{color:#fff;text-decoration:none;font-weight:800;font-size:12px}.ls-content .status .label{font-size:8px;color:#888;display:block}.ls-content .status .label.live{color:#e74c3c}.ls-content .info,.ls-content .btns{display:none}.ls-content table{width:100%;border-collapse:collapse;font-size:11px;color:#ccc}.ls-content table th{background:#222;color:#999;padding:5px 4px;font-size:10px;border-bottom:1px solid #333}.ls-content table td{padding:4px 3px;border-bottom:1px solid #1a1a1a}.ls-content table .team-name{display:flex;align-items:center;gap:4px}.ls-content table .team-name img{width:16px;height:16px}.ls-content table .pts{font-weight:800;color:#f0c040}.match-overlay{position:fixed;inset:0;background:#111;z-index:9999;display:none;flex-direction:column;overflow:auto}.match-overlay.active{display:flex}.mo-header{padding:10px;background:#1a1a1a;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:1}.mo-header h3{font-size:13px;color:#eee}.mo-close{background:none;border:0;color:#fff;font-size:22px;cursor:pointer}.mo-tabs{display:flex;gap:4px;padding:8px 10px;background:#1a1a1a;overflow-x:auto}.mo-tab{padding:5px 12px;background:#222;border:1px solid #333;border-radius:10px;color:#999;font-size:10px;cursor:pointer;white-space:nowrap}.mo-tab.active{background:#2d8659;color:#fff}.mo-body{padding:8px;overflow-x:auto;font-size:12px;color:#ddd}.mo-body ul{list-style:none;padding:0;margin:0}.mo-body li{padding:5px 0;border-bottom:1px solid #222}.featured-match{margin:6px 4px;background:linear-gradient(135deg,#1a2a1f,#0d1117);border:1px solid #2d8659;border-radius:10px;padding:12px;cursor:pointer}.fm-league{text-align:center;color:#5cb87a;font-size:9px;font-weight:700;text-transform:uppercase}.fm-teams{display:flex;align-items:center;justify-content:center;gap:10px;margin-top:6px}.fm-team{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px}.fm-team img{width:32px;height:32px;object-fit:contain}.fm-team span{font-size:10px;color:#ccc;text-align:center}.fm-score{font-size:22px;font-weight:900;min-width:60px;text-align:center;color:#fff}.fm-status{text-align:center;margin-top:6px;font-size:9px;color:#e74c3c;font-weight:700}.fm-status.upcoming{color:#f0c040}.ai-compose{margin:6px 4px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.ai-compose-title{font-size:13px;font-weight:800;color:#5cb87a;margin-bottom:8px}.ai-compose-row{display:flex;gap:6px;margin-top:6px}.ai-compose input{flex:1;background:#222;border:1px solid #333;color:#eee;border-radius:18px;padding:9px 12px;font-size:12px;min-width:0}.ai-compose button{background:#2d8659;border:0;color:#fff;border-radius:18px;padding:9px 12px;font-size:11px;font-weight:700;cursor:pointer;white-space:nowrap}.ai-compose button.secondary{background:#333}.hot-topic-row{display:flex;gap:6px;overflow-x:auto;padding:4px 0;scrollbar-width:none}.hot-topic-row::-webkit-scrollbar{display:none}.hot-chip{flex:0 0 auto;background:#222;border:1px solid #333;color:#ddd;border-radius:16px;padding:5px 10px;font-size:11px;cursor:pointer;white-space:nowrap}.hot-chip:active{transform:scale(.96)}.hashtag-sources{margin:8px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.hashtag-sources h3{font-size:13px;color:#5cb87a;margin-bottom:8px}.hashtag-src-item{display:flex;gap:8px;padding:8px;background:#202020;border-radius:8px;margin:6px 0;cursor:pointer}.hashtag-src-item:active{opacity:.8}.hashtag-src-img{flex:0 0 80px;aspect-ratio:16/9;background:#333;border-radius:6px;overflow:hidden}.hashtag-src-img img{width:100%;height:100%;object-fit:cover}.hashtag-src-text{flex:1;min-width:0}.hashtag-src-title{font-size:12px;font-weight:700;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.hashtag-src-via{font-size:10px;color:#888;margin-top:2px}.hashtag-rewrite-btn{width:100%;margin-top:8px;background:#2d8659;border:0;color:#fff;padding:9px;border-radius:10px;font-size:12px;font-weight:700;cursor:pointer}.hashtag-load-more{width:100%;margin-top:8px;background:#222;border:1px solid #333;color:#ccc;padding:9px;border-radius:10px;font-size:12px;cursor:pointer}.hashtag-loading{display:flex;align-items:center;gap:8px;padding:12px;color:#888;font-size:12px}.hashtag-spinner{width:16px;height:16px;border:2px solid #333;border-top-color:#5cb87a;border-radius:50%;animation:ht-spin .8s linear infinite}@keyframes ht-spin{to{transform:rotate(360deg)}}.wall-item{flex:0 0 260px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:8px}.wall-item-new{animation:wall-flash 1.8s ease-out}@keyframes wall-flash{0%{border-color:#f0c040;box-shadow:0 0 18px rgba(240,192,64,.35)}30%{border-color:#f0c040;box-shadow:0 0 12px rgba(240,192,64,.2)}100%{border-color:#2b2b2b;box-shadow:none}}.wall-thumb{width:100%;aspect-ratio:16/9;border-radius:8px;background:#222;overflow:hidden;margin-bottom:6px;position:relative}.wall-thumb img{width:100%;height:100%;object-fit:cover}.wall-video-badge{position:absolute;top:4px;right:4px;background:rgba(45,134,89,.9);color:#fff;font-size:10px;padding:2px 6px;border-radius:6px;font-weight:700}.wall-title{font-size:12px;color:#5cb87a;font-weight:800;line-height:1.3;margin-bottom:4px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wall-text{font-size:11px;color:#bbb;line-height:1.4;white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden}.wall-actions{display:flex;gap:6px;margin-top:8px}.wall-actions button{flex:1;border:1px solid #333;background:#222;color:#ddd;border-radius:14px;padding:6px 8px;font-size:10px;cursor:pointer}.wall-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}#progress-toast{position:fixed;bottom:70px;left:50%;transform:translateX(-50%);background:#2d8659;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;z-index:99998;box-shadow:0 4px 12px rgba(0,0,0,.4);display:none;white-space:nowrap}.storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 4px}
15
- </style>
16
  </head>
17
  <body>
18
- <div class="header"><h1>📰 VNEWS</h1><p>Tin tức · Bóng đá LIVE · 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>
@@ -32,19 +30,18 @@
32
  var _cats=[],_hlLeagueData={},_currentArticle=null;window._currentEventId='';
33
  function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]))}
34
  function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')}
35
- function switchCat(id){document.querySelectorAll('.cat').forEach(c=>c.classList.remove('active'));document.querySelector('[data-cat="'+id+'"]')?.classList.add('active');document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.querySelectorAll('video').forEach(v=>{v.pause();if(v._hls){v._hls.destroy();v._hls=null}});document.querySelectorAll('iframe[data-yt-src]').forEach(f=>{f.src=''});if(id==='home')document.getElementById('view-home').classList.add('active');else if(id==='news-all'){document.getElementById('view-cat').classList.add('active');loadNewsTab()}else{document.getElementById('view-cat').classList.add('active');loadCat(id)}}
36
  function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}}
37
  function doShare(title,url,img){const shareUrl=SPACE+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||'');if(navigator.share)navigator.share({title,url:shareUrl}).catch(()=>{});else navigator.clipboard.writeText(shareUrl).then(()=>alert('Đã sao chép!')).catch(()=>{})}
38
- async function init(){_cats=await fetch('/api/categories').then(r=>r.json()).catch(()=>[]);let bar='<div class="cat active" data-cat="home">🏠</div><div class="cat" data-cat="news-all">📰 Tin tức</div>';_cats.forEach(c=>{bar+='<div class="cat" data-cat="'+c.id+'">'+c.name+'</div>'});document.getElementById('cat-bar').innerHTML=bar;document.querySelectorAll('.cat').forEach(t=>{t.onclick=()=>switchCat(t.dataset.cat)});}
39
  var SPACE=location.origin;
40
  </script>
41
- <script src="/static/app_v2.js?v=20260701"></script>
42
  <script src="/static/yt_live.js"></script>
43
- <script src="/static/vtv_init.js"></script>
44
- <script src="/static/hot_multi.js?v=1"></script>
45
  <script src="/static/wc2026_v2.js"></script>
46
  <script src="/static/live_mode.js"></script>
47
  <script src="/static/match_detail_v6.js"></script>
48
- <script>init();loadHome();</script>
49
  </body>
50
  </html>
 
10
  <link rel="canonical" href="https://bep40-vnews.hf.space">
11
  <link rel="stylesheet" href="/static/wc2026.css">
12
  <script src="https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js"></script>
13
+ <style>*{box-sizing:border-box;margin:0;padding:0}body{background:#111;color:#eee;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;overflow-x:hidden}.header{background:linear-gradient(135deg,#0d1117,#1a3a2a 50%,#8b7500);padding:12px;text-align:center}.header h1{font-size:18px;color:#fff}.header p{font-size:10px;color:#aaa}.cats{display:flex;overflow-x:auto;background:#1a1a1a;border-bottom:1px solid #333;padding:0 4px;position:sticky;top:0;z-index:50;scrollbar-width:none}.cats::-webkit-scrollbar{display:none}.cat{padding:9px 11px;color:#888;font-size:11px;white-space:nowrap;border-bottom:2px solid transparent;cursor:pointer;flex-shrink:0}.cat.active{color:#5cb87a;border-bottom-color:#5cb87a;font-weight:700}.view{display:none}.view.active{display:block}.loading{text-align:center;padding:30px;color:#777;font-size:12px}.slider-wrap{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.slider-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.slider-label{color:#f0c040;font-size:13px;font-weight:800}.slider-note{font-size:10px;color:#777}.slider-track{display:flex;overflow-x:auto;gap:8px;padding:4px 10px 10px;scrollbar-width:none}.slider-track::-webkit-scrollbar{display:none}.slider-item{flex:0 0 160px;cursor:pointer}.shorts-item{flex:0 0 110px!important}.slider-thumb{position:relative;width:100%;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#333}.shorts-thumb{aspect-ratio:3/4!important;border-radius:8px!important}.slider-thumb img,.slider-thumb video{width:100%;height:100%;object-fit:cover}.slider-title{font-size:10px;color:#ccc;margin-top:3px;line-height:1.2;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.card-play{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:30px;height:30px;border-radius:50%;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;color:#fff;font-size:12px}.grid{display:grid;grid-template-columns:repeat(2,1fr);gap:6px;padding:6px 4px}@media(min-width:650px){.grid{grid-template-columns:repeat(3,1fr)}}.card{background:#1a1a1a;border:1px solid #222;border-radius:8px;overflow:hidden;cursor:pointer}.card-img{position:relative;aspect-ratio:16/9;background:#333}.card-img img{width:100%;height:100%;object-fit:cover}.card-body{padding:6px 8px}.card-title{font-size:11px;line-height:1.35;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.badge{font-size:8px;padding:1px 5px;border-radius:3px;font-weight:700;display:inline-block;margin-bottom:2px;color:#fff}.badge-vne{background:#c0392b}.badge-bbc{background:#b80000}.badge-dt{background:#1565c0}.badge-genk{background:#6a1b9a}.badge-fpt{background:#f26522}.badge-ai{background:#2d8659}.badge-wc{background:#0b6bcb}.section-title{font-size:13px;font-weight:800;color:#5cb87a;margin:8px 0 4px;padding-left:8px;border-left:3px solid #5cb87a}.back-btn{background:#111;color:#fff;border:none;padding:10px;font-size:12px;width:100%;position:sticky;top:0;z-index:60;cursor:pointer}.article-view{padding:12px 8px 40px;max-width:760px;margin:0 auto}.article-title{font-size:18px;font-weight:800;line-height:1.3;margin-bottom:8px}.article-summary{background:#1a2a1f;border-left:3px solid #2d8659;padding:10px;margin-bottom:14px;color:#ccc;font-size:13px}.article-p{font-size:14px;line-height:1.7;color:#ccc;margin-bottom:10px}.article-img{width:100%;border-radius:6px;margin:10px 0}.article-h2{font-size:16px;margin:16px 0 8px;color:#eee}.article-actions{display:flex;gap:8px;flex-wrap:wrap;border-top:1px solid #333;margin-top:16px;padding-top:10px}.article-actions button{background:#1a1a1a;border:1px solid #333;color:#ccc;padding:7px 12px;border-radius:14px;font-size:11px;cursor:pointer}.article-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}.article-ai-ask{margin-top:12px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.article-ai-ask textarea{width:100%;min-height:60px;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px;font-size:12px}.article-ai-ask button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin-top:6px;font-size:11px;cursor:pointer}.article-ai-answer{white-space:pre-wrap;color:#ccc;font-size:13px;line-height:1.55;margin-top:8px}.tiktok-container{width:100%;height:80vh;max-height:680px;min-height:400px;background:#000}.tiktok-feed{height:100%;overflow-y:scroll;scroll-snap-type:y mandatory;scrollbar-width:none}.tiktok-feed::-webkit-scrollbar{display:none}.tiktok-slide{height:80vh;max-height:680px;min-height:400px;scroll-snap-align:start;position:relative;background:#000;display:flex;align-items:center;justify-content:center}.tiktok-slide video,.tiktok-slide iframe{width:100%;height:100%;object-fit:cover;border:none}.tiktok-slide.ratio-wide video,.tiktok-slide.ratio-wide iframe{object-fit:contain}.tiktok-bottom{position:absolute;bottom:0;left:0;right:60px;padding:12px 10px 16px;background:linear-gradient(transparent,rgba(0,0,0,.85));z-index:3}.tiktok-title{font-size:12px;color:#fff}.tiktok-counter{position:absolute;top:8px;left:8px;background:rgba(0,0,0,.5);font-size:9px;padding:2px 7px;border-radius:8px;color:#fff;z-index:4}.tiktok-right{position:absolute;right:8px;bottom:100px;display:flex;flex-direction:column;align-items:center;gap:14px;z-index:5}.tiktok-right-btn{display:flex;flex-direction:column;align-items:center;gap:2px;background:none;border:0;color:#fff;cursor:pointer;font-size:10px}.tiktok-right-btn .icon{width:42px;height:42px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:20px}.tiktok-right-btn .count{font-size:10px;color:#ddd}.inline-comments{position:absolute;bottom:0;left:0;right:0;max-height:50%;background:rgba(18,18,18,.95);border-radius:14px 14px 0 0;z-index:10;overflow:hidden;display:flex;flex-direction:column}.inline-cmt-header{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-bottom:1px solid #333;color:#5cb87a;font-size:12px;font-weight:700}.inline-cmt-header button{background:none;border:0;color:#fff;font-size:16px;cursor:pointer}.inline-cmt-list{flex:1;overflow-y:auto;padding:6px 10px;max-height:180px}.inline-cmt-item{background:#222;border-radius:8px;padding:6px 8px;margin:4px 0;color:#ccc;font-size:11px;line-height:1.3}.inline-cmt-time{font-size:9px;color:#777;margin-right:6px}.inline-cmt-input{display:flex;gap:6px;padding:8px 10px;border-top:1px solid #333}.inline-cmt-input input{flex:1;background:#222;border:1px solid #444;color:#eee;border-radius:16px;padding:7px 12px;font-size:11px}.inline-cmt-input button{background:#2d8659;border:0;color:#fff;border-radius:16px;padding:7px 12px;font-size:11px;cursor:pointer}.wc2026-section{margin:6px 4px;background:linear-gradient(135deg,#0d1117,#1a1a3a);border:1px solid #1a3a5a;border-radius:10px;overflow:hidden}.wc-header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:linear-gradient(90deg,#0b2e4a,#1a3a5a)}.wc-header h2{font-size:15px;color:#fff;margin:0}.wc-live-badge{font-size:10px;color:#e74c3c;font-weight:700;animation:wc-pulse 1.5s infinite}@keyframes wc-pulse{0%,100%{opacity:1}50%{opacity:.4}}.wc-tabs{display:flex;gap:4px;padding:8px 10px;overflow-x:auto;scrollbar-width:none}.wc-tabs::-webkit-scrollbar{display:none}.wc-tab{padding:5px 10px;background:#1a2a3a;border:1px solid #2a3a4a;border-radius:12px;color:#8ab4d8;font-size:10px;cursor:pointer;white-space:nowrap;flex-shrink:0}.wc-tab.active{background:#0b6bcb;border-color:#0b6bcb;color:#fff;font-weight:700}.wc-content{padding:8px 10px;max-height:500px;overflow-y:auto}.wc-news-grid{display:flex;flex-direction:column;gap:8px}.wc-news-item{display:flex;gap:8px;padding:8px;background:#1a2030;border-radius:8px;cursor:pointer}.wc-news-item:active{opacity:.8}.wc-news-img{flex:0 0 70px;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#222}.wc-news-img img{width:100%;height:100%;object-fit:cover}.wc-news-text{flex:1;min-width:0}.wc-news-title{font-size:11px;font-weight:700;color:#eee;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wc-news-via{font-size:9px;color:#6a9fca;margin-top:2px}.ls-section{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.ls-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.ls-header h3{color:#f0c040;font-size:13px;font-weight:800}.ls-tabs{display:flex;gap:4px;padding:0 10px 8px;overflow-x:auto;scrollbar-width:none}.ls-tabs::-webkit-scrollbar{display:none}.ls-tab{padding:4px 10px;background:#222;border:1px solid #333;border-radius:12px;color:#999;font-size:10px;white-space:nowrap;cursor:pointer;flex-shrink:0}.ls-tab.active{background:#2d8659;border-color:#2d8659;color:#fff;font-weight:700}.ls-content{max-height:420px;overflow-y:auto;padding:0 6px 8px;font-size:12px;color:#ddd}.ls-content ul{list-style:none;padding:0;margin:0}.ls-content .title-content{display:flex;gap:6px;align-items:center;background:#222;border-radius:4px;margin:4px 0;padding:5px 8px}.ls-content .title-content img{width:18px;height:18px}.ls-content .title-content strong{font-size:11px;color:#ccc}.ls-content .match-detail{padding:6px;border-bottom:1px solid #262626;cursor:pointer}.ls-content .match-detail:hover{background:#1a2a1f}.ls-content .match{display:flex;flex-wrap:wrap;align-items:center;gap:4px}.ls-content .datetime{width:100%;font-size:9px;color:#888}.ls-content .teams{display:flex;width:100%;align-items:center;gap:4px}.ls-content .team{flex:1;display:flex;align-items:center;gap:4px;min-width:0;text-decoration:none}.ls-content .team .name{font-size:11px;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ls-content .team .logo img{width:18px;height:18px}.ls-content .home-team{justify-content:flex-end;text-align:right}.ls-content .status{flex:0 0 54px;text-align:center}.ls-content .status a{color:#fff;text-decoration:none;font-weight:800;font-size:12px}.ls-content .status .label{font-size:8px;color:#888;display:block}.ls-content .status .label.live{color:#e74c3c}.ls-content .info,.ls-content .btns{display:none}.ls-content table{width:100%;border-collapse:collapse;font-size:11px;color:#ccc}.ls-content table th{background:#222;color:#999;padding:5px 4px;font-size:10px;border-bottom:1px solid #333}.ls-content table td{padding:4px 3px;border-bottom:1px solid #1a1a1a}.ls-content table .team-name{display:flex;align-items:center;gap:4px}.ls-content table .team-name img{width:16px;height:16px}.ls-content table .pts{font-weight:800;color:#f0c040}.match-overlay{position:fixed;inset:0;background:#111;z-index:9999;display:none;flex-direction:column;overflow:auto}.match-overlay.active{display:flex}.mo-header{padding:10px;background:#1a1a1a;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:1}.mo-header h3{font-size:13px;color:#eee}.mo-close{background:none;border:0;color:#fff;font-size:22px;cursor:pointer}.mo-tabs{display:flex;gap:4px;padding:8px 10px;background:#1a1a1a;overflow-x:auto}.mo-tab{padding:5px 12px;background:#222;border:1px solid #333;border-radius:10px;color:#999;font-size:10px;cursor:pointer;white-space:nowrap}.mo-tab.active{background:#2d8659;color:#fff}.mo-body{padding:8px;overflow-x:auto;font-size:12px;color:#ddd}.mo-body ul{list-style:none;padding:0;margin:0}.mo-body li{padding:5px 0;border-bottom:1px solid #222}.featured-match{margin:6px 4px;background:linear-gradient(135deg,#1a2a1f,#0d1117);border:1px solid #2d8659;border-radius:10px;padding:12px;cursor:pointer}.fm-league{text-align:center;color:#5cb87a;font-size:9px;font-weight:700;text-transform:uppercase}.fm-teams{display:flex;align-items:center;justify-content:center;gap:10px;margin-top:6px}.fm-team{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px}.fm-team img{width:32px;height:32px;object-fit:contain}.fm-team span{font-size:10px;color:#ccc;text-align:center}.fm-score{font-size:22px;font-weight:900;min-width:60px;text-align:center;color:#fff}.fm-status{text-align:center;margin-top:6px;font-size:9px;color:#e74c3c;font-weight:700}.fm-status.upcoming{color:#f0c040}.ai-compose{margin:6px 4px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.ai-compose-title{font-size:13px;font-weight:800;color:#5cb87a;margin-bottom:8px}.ai-compose-row{display:flex;gap:6px;margin-top:6px}.ai-compose input{flex:1;background:#222;border:1px solid #333;color:#eee;border-radius:18px;padding:9px 12px;font-size:12px;min-width:0}.ai-compose button{background:#2d8659;border:0;color:#fff;border-radius:18px;padding:9px 12px;font-size:11px;font-weight:700;cursor:pointer;white-space:nowrap}.ai-compose button.secondary{background:#333}.hot-topic-row{display:flex;gap:6px;overflow-x:auto;padding:4px 0;scrollbar-width:none}.hot-topic-row::-webkit-scrollbar{display:none}.hot-chip{flex:0 0 auto;background:#222;border:1px solid #333;color:#ddd;border-radius:16px;padding:5px 10px;font-size:11px;cursor:pointer;white-space:nowrap}.hot-chip:active{transform:scale(.96)}.hashtag-sources{margin:8px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.hashtag-sources h3{font-size:13px;color:#5cb87a;margin-bottom:8px}.hashtag-src-item{display:flex;gap:8px;padding:8px;background:#202020;border-radius:8px;margin:6px 0;cursor:pointer}.hashtag-src-item:active{opacity:.8}.hashtag-src-img{flex:0 0 80px;aspect-ratio:16/9;background:#333;border-radius:6px;overflow:hidden}.hashtag-src-img img{width:100%;height:100%;object-fit:cover}.hashtag-src-text{flex:1;min-width:0}.hashtag-src-title{font-size:12px;font-weight:700;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.hashtag-src-via{font-size:10px;color:#888;margin-top:2px}.hashtag-rewrite-btn{width:100%;margin-top:8px;background:#2d8659;border:0;color:#fff;padding:9px;border-radius:10px;font-size:12px;font-weight:700;cursor:pointer}.hashtag-load-more{width:100%;margin-top:8px;background:#222;border:1px solid #333;color:#ccc;padding:9px;border-radius:10px;font-size:12px;cursor:pointer}.hashtag-loading{display:flex;align-items:center;gap:8px;padding:12px;color:#888;font-size:12px}.hashtag-spinner{width:16px;height:16px;border:2px solid #333;border-top-color:#5cb87a;border-radius:50%;animation:ht-spin .8s linear infinite}@keyframes ht-spin{to{transform:rotate(360deg)}}.wall-item{flex:0 0 260px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:8px}.wall-item-new{animation:wall-flash 1.8s ease-out}@keyframes wall-flash{0%{border-color:#f0c040;box-shadow:0 0 18px rgba(240,192,64,.35)}30%{border-color:#f0c040;box-shadow:0 0 12px rgba(240,192,64,.2)}100%{border-color:#2b2b2b;box-shadow:none}}.wall-thumb{width:100%;aspect-ratio:16/9;border-radius:8px;background:#222;overflow:hidden;margin-bottom:6px;position:relative}.wall-thumb img{width:100%;height:100%;object-fit:cover}.wall-video-badge{position:absolute;top:4px;right:4px;background:rgba(45,134,89,.9);color:#fff;font-size:10px;padding:2px 6px;border-radius:6px;font-weight:700}.wall-title{font-size:12px;color:#5cb87a;font-weight:800;line-height:1.3;margin-bottom:4px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wall-text{font-size:11px;color:#bbb;line-height:1.4;white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden}.wall-actions{display:flex;gap:6px;margin-top:8px}.wall-actions button{flex:1;border:1px solid #333;background:#222;color:#ddd;border-radius:14px;padding:6px 8px;font-size:10px;cursor:pointer}.wall-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}#progress-toast{position:fixed;bottom:70px;left:50%;transform:translateX(-50%);background:#2d8659;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;z-index:99998;box-shadow:0 4px 12px rgba(0,0,0,.4);display:none;white-space:nowrap}.storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 4px}</style>
 
 
14
  </head>
15
  <body>
16
+ <div class="header"><h1>📰 VNEWS</h1><p>Tin tức · Bóng đá LIVE · Video · AI · World Cup 2026</p></div>
17
  <div class="cats" id="cat-bar"></div>
18
  <div id="view-home" class="view active"><div class="loading">Đang tải...</div></div>
19
  <div id="view-cat" class="view"></div>
 
30
  var _cats=[],_hlLeagueData={},_currentArticle=null;window._currentEventId='';
31
  function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]))}
32
  function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')}
33
+ function switchCat(id){document.querySelectorAll('.cat').forEach(c=>c.classList.remove('active'));document.querySelector(`[data-cat="${id}"]`)?.classList.add('active');document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.querySelectorAll('video').forEach(v=>{v.pause();if(v._hls){v._hls.destroy();v._hls=null}});document.querySelectorAll('iframe[data-yt-src]').forEach(f=>{f.src=''});if(id==='home')document.getElementById('view-home').classList.add('active');else if(id==='news-all'){document.getElementById('view-cat').classList.add('active');loadNewsTab()}else{document.getElementById('view-cat').classList.add('active');loadCat(id)}}
34
  function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}}
35
  function doShare(title,url,img){const shareUrl=SPACE+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||'');if(navigator.share)navigator.share({title,url:shareUrl}).catch(()=>{});else navigator.clipboard.writeText(shareUrl).then(()=>alert('Đã sao chép!')).catch(()=>{})}
36
+ async function init(){_cats=await fetch('/api/categories').then(r=>r.json()).catch(()=>[]);let bar='<div class="cat active" data-cat="home">🏠</div><div class="cat" data-cat="news-all">📰 Tin tức</div>';_cats.forEach(c=>{bar+=`<div class="cat" data-cat="${c.id}">${c.name}</div>`});document.getElementById('cat-bar').innerHTML=bar;document.querySelectorAll('.cat').forEach(t=>{t.onclick=()=>switchCat(t.dataset.cat)});}
37
  var SPACE=location.origin;
38
  </script>
39
+ <script src="/static/app_v2.js"></script>
40
  <script src="/static/yt_live.js"></script>
41
+ <script src="/static/hot_multi.js"></script>
 
42
  <script src="/static/wc2026_v2.js"></script>
43
  <script src="/static/live_mode.js"></script>
44
  <script src="/static/match_detail_v6.js"></script>
45
+ <script>init();</script>
46
  </body>
47
  </html>
static/rewrite_fix.js ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Fix rewriteArticle - call correct endpoint
2
+ // This file patches the rewriteArticle function to use /api/rewrite_slide instead of /api/rewrite_share
3
+
4
+ (function(){
5
+ // Override rewriteArticle to call /api/rewrite_slide
6
+ const origRewrite = window.rewriteArticle;
7
+ window.rewriteArticle = async function(){
8
+ const url = _currentArticle?.url;
9
+ if(!url) return;
10
+ toast('⏳ Đang tạo slide tóm tắt...');
11
+ try {
12
+ const r = await fetch('/api/rewrite_slide', {
13
+ method: 'POST',
14
+ headers: {'Content-Type': 'application/json'},
15
+ body: JSON.stringify({url, context: document.querySelector('.article-view')?.innerText?.slice(0,14000) || ''})
16
+ });
17
+ const j = await r.json();
18
+ if (!r.ok || j.error) throw new Error(j.error);
19
+ toast('✅ Đã đăng Tường AI!');
20
+ if (j.post) prependWallPost(j.post);
21
+ // Navigate to the new post on Tường AI (home). Slide overlay (if any) stays on top.
22
+ if (j.post && typeof goToWallPost === 'function') goToWallPost(j.post.id);
23
+ // Show slides preview
24
+ if (j.slides && j.slides.length) {
25
+ showSlidePreview(j.slides, j.post?.title || '');
26
+ }
27
+ } catch(e) {
28
+ // Fallback: try /api/rewrite_share (old endpoint from ai_ext)
29
+ try {
30
+ const r2 = await fetch('/api/rewrite_share', {
31
+ method: 'POST',
32
+ headers: {'Content-Type': 'application/json'},
33
+ body: JSON.stringify({url, context: document.querySelector('.article-view')?.innerText?.slice(0,14000) || ''})
34
+ });
35
+ const j2 = await r2.json();
36
+ if (r2.ok && !j2.error) {
37
+ toast('✅ Đã đăng Tường AI!');
38
+ if (j2.post) prependWallPost(j2.post);
39
+ if (j2.post && typeof goToWallPost === 'function') goToWallPost(j2.post.id);
40
+ return;
41
+ }
42
+ } catch(e2) {}
43
+ toast('❌ ' + e.message);
44
+ }
45
+ };
46
+
47
+ // Show slides as fullscreen overlay
48
+ window.showSlidePreview = function(slides, title) {
49
+ if (!slides || !slides.length) return;
50
+ const overlay = document.createElement('div');
51
+ overlay.id = 'slide-preview';
52
+ overlay.style.cssText = 'position:fixed;inset:0;background:#000;z-index:99999;display:flex;flex-direction:column;overflow:hidden';
53
+
54
+ let currentSlide = 0;
55
+ function renderSlide(idx) {
56
+ const s = slides[idx];
57
+ overlay.innerHTML = `
58
+ <div style="position:absolute;top:10px;left:10px;right:10px;display:flex;justify-content:space-between;align-items:center;z-index:2">
59
+ <button onclick="document.getElementById('slide-preview').remove()" style="background:rgba(0,0,0,.6);border:0;color:#fff;padding:8px 14px;border-radius:20px;font-size:12px;cursor:pointer">✕ Đóng</button>
60
+ <span style="color:#fff;font-size:11px;background:rgba(0,0,0,.6);padding:4px 10px;border-radius:10px">${idx+1}/${slides.length}</span>
61
+ </div>
62
+ <div style="flex:1;display:flex;align-items:center;justify-content:center;padding:20px">
63
+ ${s.image ? `<img src="${esc(s.image)}" style="max-width:100%;max-height:60vh;border-radius:10px;object-fit:contain" onerror="this.style.display='none'">` : ''}
64
+ </div>
65
+ <div style="padding:16px 20px;background:linear-gradient(transparent,rgba(0,0,0,.9));min-height:100px">
66
+ <p style="color:#fff;font-size:14px;line-height:1.6">${esc(s.text)}</p>
67
+ </div>
68
+ <div style="display:flex;gap:10px;padding:10px 20px 20px;justify-content:center">
69
+ <button onclick="prevSlide()" style="background:#333;border:0;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;cursor:pointer" ${idx===0?'disabled style="opacity:.3"':''}>← Trước</button>
70
+ <button onclick="nextSlide()" style="background:#2d8659;border:0;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;cursor:pointer" ${idx===slides.length-1?'disabled style="opacity:.3"':''}>Tiếp →</button>
71
+ </div>
72
+ `;
73
+ }
74
+
75
+ window.nextSlide = function() { if (currentSlide < slides.length - 1) { currentSlide++; renderSlide(currentSlide); } };
76
+ window.prevSlide = function() { if (currentSlide > 0) { currentSlide--; renderSlide(currentSlide); } };
77
+
78
+ renderSlide(0);
79
+ document.body.appendChild(overlay);
80
+
81
+ // Swipe support
82
+ let startX = 0;
83
+ overlay.addEventListener('touchstart', e => { startX = e.touches[0].clientX; });
84
+ overlay.addEventListener('touchend', e => {
85
+ const diff = e.changedTouches[0].clientX - startX;
86
+ if (diff < -50) nextSlide();
87
+ else if (diff > 50) prevSlide();
88
+ });
89
+ };
90
+ })();
static/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,349 +0,0 @@
1
- // ===== VNEWS VTV Player v6 — Professional UI + Real-time EPG + Auto-refresh =====
2
- // Timezone: +10 GMT (as requested)
3
- (function() {
4
- if (window._vtvInitLoaded) return;
5
- window._vtvInitLoaded = true;
6
-
7
- var CHANNELS = [
8
- {id:'vtv1',name:'VTV1',badge:'Tin tức'},{id:'vtv2',name:'VTV2',badge:'Khoa học'},
9
- {id:'vtv3',name:'VTV3',badge:'Giải trí'},{id:'vtv4',name:'VTV4',badge:'Quốc Tế'},
10
- {id:'vtv5',name:'VTV5',badge:'Miền Nam'},{id:'vtv6',name:'VTV6',badge:'Thanh Niên'},
11
- {id:'vtv7',name:'VTV7',badge:'Giáo Dục'},{id:'vtv8',name:'VTV8',badge:'Miền Trung'},
12
- {id:'vtv9',name:'VTV9',badge:'Miền Bắc'},{id:'vtv10',name:'VTV10',badge:'Cần Thơ'},
13
- {id:'vtvprime',name:'VTVPrime',badge:'Prime'},
14
- ];
15
-
16
- var _hlsInst = null;
17
- var _currentCh = null;
18
- var _epgTimer = null;
19
- var _epgData = [];
20
- var _timeTimer = null;
21
-
22
- // ===== CSS chuyên nghiệp =====
23
- (function injectCSS() {
24
- if (document.getElementById('vtv-pro-css')) return;
25
- var s = document.createElement('style');
26
- s.id = 'vtv-pro-css';
27
- s.textContent = [
28
- '#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}',
29
- '#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))}',
30
- '#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}',
31
- '#vtv-player-section .vtv-pro-title{font-size:13px;font-weight:700;color:#e8eaed;letter-spacing:.3px}',
32
- '#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}',
33
- '#vtv-player-section .vtv-pro-live-dot{width:5px;height:5px;border-radius:50%;background:#00cc88;animation:vtv-pro-pulse 1.2s infinite}',
34
- '@keyframes vtv-pro-pulse{0%,100%{opacity:1}50%{opacity:.3}}',
35
- '#vtv-player-section .vtv-pro-tabs{display:flex;flex-wrap:wrap;gap:2px;padding:6px 10px 8px;overflow-x:auto;scrollbar-width:none}',
36
- '#vtv-player-section .vtv-pro-tabs::-webkit-scrollbar{display:none}',
37
- '#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}',
38
- '#vtv-player-section .vtv-pro-tab:hover{background:rgba(0,102,204,.1);border-color:rgba(0,102,204,.3);color:#e8eaed}',
39
- '#vtv-player-section .vtv-pro-tab.on{background:rgba(0,102,204,.2);border-color:#0066cc;color:#fff;font-weight:600}',
40
- '#vtv-player-section .vtv-pro-tab .b{font-size:7px;opacity:.5;display:block;margin-top:1px}',
41
- '#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)}',
42
- '#vtv-player-section .vtv-pro-frame video{position:absolute;inset:0;width:100%!important;height:100%!important;object-fit:contain;background:#000}',
43
- '#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}',
44
- '#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}',
45
- '@keyframes vtv-pro-spin{to{transform:rotate(360deg)}}',
46
- '#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}',
47
- '#vtv-player-section .vtv-pro-err .icon{font-size:28px;opacity:.5}',
48
- '#vtv-player-section .vtv-pro-err .msg{color:#9aa0a6}',
49
- '#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}',
50
- '#vtv-player-section .vtv-pro-err button:hover{background:rgba(0,102,204,.25)}',
51
- '#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)}',
52
- '#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}',
53
- '#vtv-player-section .vtv-pro-btn:hover{background:rgba(0,102,204,.12);color:#e8eaed}',
54
- '#vtv-player-section .vtv-pro-btn.active{background:rgba(0,102,204,.2);border-color:#0066cc;color:#8ab4f8}',
55
- '#vtv-player-section .vtv-pro-epg{border-top:1px solid rgba(255,255,255,.04);background:rgba(0,0,0,.15)}',
56
- '#vtv-player-section .vtv-pro-epg-hdr{display:flex;align-items:center;justify-content:space-between;padding:8px 12px 4px}',
57
- '#vtv-player-section .vtv-pro-epg-title{font-size:10px;font-weight:600;color:#9aa0a6;letter-spacing:.5px;text-transform:uppercase}',
58
- '#vtv-player-section .vtv-pro-epg-time{font-size:9px;color:#5f6368;font-variant-numeric:tabular-nums}',
59
- '#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}',
60
- '#vtv-player-section .vtv-pro-epg-list::-webkit-scrollbar{width:3px}',
61
- '#vtv-player-section .vtv-pro-epg-list::-webkit-scrollbar-thumb{background:rgba(255,255,255,.08);border-radius:3px}',
62
- '#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}',
63
- '#vtv-player-section .vtv-pro-epg-row:hover{background:rgba(255,255,255,.03)}',
64
- '#vtv-player-section .vtv-pro-epg-row.now{border-left-color:#00cc88;background:rgba(0,204,136,.06)}',
65
- '#vtv-player-section .vtv-pro-epg-row.passed{opacity:.35}',
66
- '#vtv-player-section .vtv-pro-epg-row .t{font-size:10px;color:#5f6368;min-width:38px;font-variant-numeric:tabular-nums}',
67
- '#vtv-player-section .vtv-pro-epg-row.now .t{color:#00cc88;font-weight:600}',
68
- '#vtv-player-section .vtv-pro-epg-row .n{color:#e8eaed;line-height:1.3}',
69
- '#vtv-player-section .vtv-pro-epg-row.now .n{color:#fff;font-weight:500}',
70
- '#vtv-player-section .vtv-pro-epg-row .bar{flex:0 0 2px;height:12px;border-radius:1px;background:rgba(255,255,255,.08)}',
71
- '#vtv-player-section .vtv-pro-epg-row.now .bar{background:#00cc88}',
72
- '#vtv-player-section .vtv-pro-epg-empty{color:#5f6368;font-size:11px;padding:12px 14px;text-align:center}',
73
- '#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}',
74
- ].join('');
75
- document.head.appendChild(s);
76
- })();
77
-
78
- function proxyUrl(url) {
79
- if (!url) return url;
80
- if (url.indexOf(location.origin) === 0) return url;
81
- if (url.indexOf('/api/proxy/') === 0) return url;
82
- return '/api/proxy/m3u8/vtv?url=' + encodeURIComponent(url);
83
- }
84
-
85
- function formatTime(d) {
86
- var h = d.getHours(), m = d.getMinutes();
87
- return (h < 10 ? '0' : '') + h + ':' + (m < 10 ? '0' : '') + m;
88
- }
89
-
90
- function getVNTime() {
91
- // Timezone +10 GMT
92
- var now = new Date();
93
- return new Date(now.getTime() + 10 * 3600000);
94
- }
95
-
96
- // ===== EPG real-time =====
97
- function loadEPG(chId) {
98
- var epgEl = document.getElementById('vtv-pro-epg-body');
99
- if (!epgEl) return;
100
- 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>';
101
- var xhr = new XMLHttpRequest();
102
- xhr.open('GET', '/api/vtv/epg/' + chId, true);
103
- xhr.timeout = 15000;
104
- xhr.onload = function() {
105
- try {
106
- var data = JSON.parse(xhr.responseText);
107
- _epgData = data.programs || [];
108
- if (_epgData.length === 0) {
109
- epgEl.innerHTML = '<div class="vtv-pro-epg-empty">Không có lịch phát sóng</div>';
110
- } else {
111
- renderEPG();
112
- }
113
- } catch(e) {
114
- epgEl.innerHTML = '<div class="vtv-pro-epg-empty">Không có lịch phát sóng</div>';
115
- }
116
- };
117
- xhr.onerror = xhr.ontimeout = function() {
118
- epgEl.innerHTML = '<div class="vtv-pro-epg-empty">Không tải được lịch phát sóng</div>';
119
- };
120
- xhr.send();
121
- }
122
-
123
- function renderEPG() {
124
- var epgEl = document.getElementById('vtv-pro-epg-body');
125
- if (!epgEl) return;
126
- if (!_epgData || !_epgData.length) {
127
- epgEl.innerHTML = '<div class="vtv-pro-epg-empty">Chưa có lịch phát sóng</div>';
128
- return;
129
- }
130
- var vnNow = getVNTime();
131
- var nowStr = formatTime(vnNow);
132
-
133
- _epgData.sort(function(a, b) { return a.time.localeCompare(b.time); });
134
-
135
- // Client-side now detection using +10 timezone
136
- var foundNow = false;
137
- for (var i = 0; i < _epgData.length; i++) {
138
- _epgData[i].now = false;
139
- var p = _epgData[i];
140
- var next = _epgData[i + 1];
141
- if (p.time <= nowStr && (!next || next.time > nowStr)) {
142
- p.now = true;
143
- p.end_time = next ? next.time : '';
144
- foundNow = true;
145
- }
146
- }
147
-
148
- // If nothing is "now", highlight the upcoming one
149
- if (!foundNow) {
150
- for (var i = 0; i < _epgData.length; i++) {
151
- if (_epgData[i].time > nowStr) {
152
- _epgData[i].now = true;
153
- _epgData[i].end_time = (_epgData[i+1] || {}).time || '';
154
- break;
155
- }
156
- }
157
- }
158
-
159
- var html = '';
160
- for (var i = 0; i < _epgData.length; i++) {
161
- var p = _epgData[i];
162
- var cls = 'vtv-pro-epg-row';
163
- if (p.now) cls += ' now';
164
- else if (p.time < nowStr) cls += ' passed';
165
- var extra = '';
166
- if (p.end_time) {
167
- extra = ' → ' + p.end_time;
168
- } else if (i < _epgData.length - 1) {
169
- extra = ' → ' + _epgData[i + 1].time;
170
- }
171
- html += '<div class="' + cls + '">' +
172
- '<span class="t">' + p.time + extra + '</span>' +
173
- '<span class="bar"></span>' +
174
- '<span class="n">' + p.title + '</span>' +
175
- '</div>';
176
- }
177
- epgEl.innerHTML = html;
178
-
179
- var nowEl = epgEl.querySelector('.now');
180
- if (nowEl) {
181
- nowEl.scrollIntoView({ block: 'center', behavior: 'smooth' });
182
- }
183
- }
184
-
185
- function startEpgRefresh() {
186
- if (_epgTimer) clearInterval(_epgTimer);
187
- _epgTimer = setInterval(function() {
188
- if (_currentCh) {
189
- loadEPG(_currentCh);
190
- }
191
- }, 30000);
192
- }
193
-
194
- function updateClock() {
195
- var timeEl = document.getElementById('vtv-pro-time');
196
- if (timeEl) {
197
- timeEl.textContent = formatTime(getVNTime()) + ' GMT+10';
198
- }
199
- }
200
-
201
- // ===== Switch channel =====
202
- window._vtvProSwitch = function(chId) {
203
- if (_hlsInst) { try { _hlsInst.destroy(); } catch(e){} _hlsInst = null; }
204
- _currentCh = chId;
205
-
206
- var tabs = document.querySelectorAll('#vtv-player-section .vtv-pro-tab');
207
- for (var i = 0; i < tabs.length; i++) {
208
- tabs[i].className = 'vtv-pro-tab' + (tabs[i].getAttribute('data-ch') === chId ? ' on' : '');
209
- }
210
-
211
- updateClock();
212
- if (_timeTimer) clearInterval(_timeTimer);
213
- _timeTimer = setInterval(updateClock, 60000);
214
-
215
- var frame = document.getElementById('vtv-pro-frame');
216
- if (!frame) return;
217
- frame.innerHTML = '<div class="vtv-pro-load"><div class="vtv-pro-spinner"></div><span>Đang kết nối ' + chId.toUpperCase() + '...</span></div>';
218
-
219
- loadEPG(chId);
220
- startEpgRefresh();
221
-
222
- var xhr = new XMLHttpRequest();
223
- xhr.open('GET', '/api/vtv/stream/' + chId, true);
224
- xhr.timeout = 30000;
225
- xhr.onload = function() {
226
- try {
227
- var data = JSON.parse(xhr.responseText);
228
- var url = data.stream_url;
229
- if (!url) {
230
- frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">📡</div><div class="msg">Kênh đang tạm ngừng phát sóng</div><button onclick="_vtvProSwitch(\''+chId+'\')">Thử lại</button></div>';
231
- return;
232
- }
233
- url = proxyUrl(url);
234
- playStream(url, frame);
235
- } catch(e) {
236
- 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>';
237
- }
238
- };
239
- xhr.onerror = xhr.ontimeout = function() {
240
- 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>';
241
- };
242
- xhr.send();
243
- };
244
-
245
- function playStream(url, frame) {
246
- var video = document.createElement('video');
247
- video.id = 'vtv-pro-video';
248
- video.style.cssText = 'width:100%;height:100%;object-fit:contain';
249
- video.setAttribute('controls', '');
250
- video.setAttribute('autoplay', '');
251
- video.setAttribute('playsinline', '');
252
- frame.innerHTML = '';
253
- frame.appendChild(video);
254
-
255
- if (typeof Hls !== 'undefined' && Hls.isSupported()) {
256
- var hls = new Hls({ debug: false, enableWorker: true, maxBufferLength: 30 });
257
- hls.loadSource(url);
258
- hls.attachMedia(video);
259
- hls.on(Hls.Events.MANIFEST_PARSED, function() {
260
- video.play().catch(function(){});
261
- });
262
- hls.on(Hls.Events.ERROR, function(ev, data) {
263
- if (data.fatal) {
264
- hls.destroy();
265
- _hlsInst = null;
266
- frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">📹</div><div class="msg">Lỗi phát video</div><button onclick="_vtvProSwitch(\''+_currentCh+'\')">Thử lại</button></div>';
267
- }
268
- });
269
- _hlsInst = hls;
270
- } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
271
- video.src = url;
272
- } else {
273
- frame.innerHTML = '<div class="vtv-pro-err"><div class="msg">Trình duyệt không hỗ trợ HLS</div></div>';
274
- }
275
- }
276
-
277
- // ===== HTML builder =====
278
- function buildHTML() {
279
- var tabs = '';
280
- for (var i = 0; i < CHANNELS.length; i++) {
281
- var ch = CHANNELS[i];
282
- tabs += '<span class="vtv-pro-tab" data-ch="'+ch.id+'" onclick="_vtvProSwitch(\''+ch.id+'\')">' +
283
- ch.name + '<span class="b">'+ch.badge+'</span></span>';
284
- }
285
- return '<div id="vtv-player-section">' +
286
- '<div class="vtv-pro-head">' +
287
- '<div class="vtv-pro-logo">V</div>' +
288
- '<span class="vtv-pro-title">VTV Player</span>' +
289
- '<span class="vtv-pro-live"><span class="vtv-pro-live-dot"></span>TRỰC TIẾP</span>' +
290
- '</div>' +
291
- '<div class="vtv-pro-tabs">'+tabs+'</div>' +
292
- '<div class="vtv-pro-frame" id="vtv-pro-frame">' +
293
- '<div class="vtv-pro-load"><div class="vtv-pro-spinner"></div><span>Chọn kênh để xem trực tiếp</span></div>' +
294
- '</div>' +
295
- '<div class="vtv-pro-controls">' +
296
- '<span id="vtv-pro-time" class="vtv-pro-btn" style="background:none;border:none;font-size:10px;color:#5f6368;margin-right:auto"></span>' +
297
- '</div>' +
298
- '<div class="vtv-pro-epg">' +
299
- '<div class="vtv-pro-epg-hdr">' +
300
- '<span class="vtv-pro-epg-title">Lịch phát sóng</span>' +
301
- '</div>' +
302
- '<div class="vtv-pro-epg-list" id="vtv-pro-epg-body">' +
303
- '<div class="vtv-pro-epg-empty">Chọn kênh để xem lịch phát sóng</div>' +
304
- '</div>' +
305
- '</div>' +
306
- '</div>';
307
- }
308
-
309
- function inject() {
310
- var homeEl = document.getElementById('view-home');
311
- if (!homeEl || document.getElementById('vtv-player-section')) return;
312
-
313
- var featured = document.getElementById('home-featured-area') || homeEl.querySelector('.featured-match, .fm-section, .slider-wrap');
314
- if (featured && featured.parentNode) {
315
- featured.insertAdjacentHTML('afterend', buildHTML());
316
- } else if (homeEl.firstChild) {
317
- homeEl.insertAdjacentHTML('afterbegin', buildHTML());
318
- }
319
-
320
- // Auto-load VTV3 as default
321
- setTimeout(function() {
322
- if (window._vtvProSwitch) {
323
- window._vtvProSwitch('vtv3');
324
- }
325
- }, 500);
326
- }
327
-
328
- // Hook into loadHome
329
- var orig = window.loadHome;
330
- if (typeof orig === 'function') {
331
- window.loadHome = function() {
332
- var r = orig.apply(this, arguments);
333
- if (r && typeof r.then === 'function') {
334
- return r.then(function(v) { setTimeout(inject, 2000); return v; });
335
- } else {
336
- setTimeout(inject, 2000);
337
- return r;
338
- }
339
- };
340
- } else {
341
- (function waitAndInject() {
342
- if (document.getElementById('view-home') && !document.getElementById('vtv-player-section')) {
343
- inject();
344
- } else if (!document.getElementById('vtv-player-section')) {
345
- setTimeout(waitAndInject, 1000);
346
- }
347
- })();
348
- }
349
- })();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
static/vtv_init_loader.html DELETED
@@ -1,2 +0,0 @@
1
- <script src="/static/yt_live.js"></script>
2
- <script src="/static/vtv_init.js"></script>
 
 
 
static/wc2026_v2.js CHANGED
@@ -314,4 +314,4 @@ function startWCLiveRefresh(){
314
  }catch(e){}
315
  },90000);
316
  }
317
- setTimeout(startWCLiveRefresh,5000);
 
314
  }catch(e){}
315
  },90000);
316
  }
317
+ setTimeout(startWCLiveRefresh,5000);
static/yt_live.js CHANGED
@@ -1,7 +1,6 @@
1
- // === VNEWS — VTV LIVE + Inline Recorder v9 ===
2
- // Features: PiP, mini-player, INLINE RECORDER, VTVGO POPUP players for each channel
3
- // FIX v10: Correct VTVGO popup URLs for all channels + World Cup 2026 TS stream links
4
- // FIX v11: Allow clicking off tabs (always clickable), better stream status handling
5
 
6
  (function(){
7
  if(window._ytLiveLoaded) return;
@@ -15,161 +14,319 @@
15
  {id:'vtv9',name:'VTV9',badge:'Miền Bắc'},{id:'vtv10',name:'VTV10',badge:'Cần Thơ'},
16
  {id:'vtvprime',name:'VTVPrime',badge:'Prime'},
17
  ];
18
-
19
- // VTVGO popup URLs for each channel (FIXED v10 - verified correct URLs)
20
- const VTV_POPUP_URLS = {
21
- vtv1: 'https://package.vtvgo.vn/channel/vtv1-1,1.html',
22
- vtv2: 'https://package.vtvgo.vn/channel/vtv2-1,2.html',
23
- vtv3: 'https://package.vtvgo.vn/channel/vtv3-1,3.html',
24
- vtv4: 'https://package.vtvgo.vn/channel/vtv4-1,4.html',
25
- vtv5: 'https://package.vtvgo.vn/channel/vtv5-1,5.html',
26
- vtv6: 'https://package.vtvgo.vn/channel/vtv6-1,13.html',
27
- vtv7: 'https://package.vtvgo.vn/channel/vtv7-1,27.html',
28
- vtv8: 'https://package.vtvgo.vn/channel/vtv8-1,36.html',
29
- vtv9: 'https://package.vtvgo.vn/channel/vtv9-1,39.html',
30
- vtv10: 'https://package.vtvgo.vn/channel/vtv10-1,6.html',
31
- };
32
-
33
- const DEFAULT_CHANNEL = 'vtv3';
34
- const NEEDS_PROXY = /fptplay\.net|vtvgo\.vn/;
35
  const STREAMS = {};
36
  let _currentCh = null, _hls = null, _loading = false, _blockInserted = false;
37
- let _streamsLoaded = false, _pipActive = false, _miniActive = false, _vtvPinned = false, _vtvPopupActive = false;
 
 
 
38
 
39
- // ===== RECORDER STATE =====
40
  const _rec = {
41
  active: false, startTime: null, endTime: null,
42
  isRecording: false, recorder: null, chunks: [], blob: null,
43
- ratio: 'original', _dragMarker: null, _recTimer: null,
 
44
  };
45
 
46
- // ===== STYLES =====
47
- const style = document.createElement('style');
48
- style.textContent = `
49
- .vtv-wrap{position:relative;margin:6px 4px;background:#111;border:1px solid #0066cc;border-radius:10px;overflow:hidden}
50
- .vtv-head{display:flex;align-items:center;gap:8px;padding:8px 10px;background:linear-gradient(90deg,#003366,#1a1a1a)}
51
- .vtv-title{font-size:13px;font-weight:800;color:#00ccff}
52
- .vtv-badge{font-size:10px;font-weight:800;color:#00ccff;animation:vtvp 1.3s infinite}
53
- @keyframes vtvp{0%,100%{opacity:1}50%{opacity:.3}}
54
- .vtv-tabs{display:flex;flex-wrap:wrap;gap:3px;padding:6px 8px;overflow-x:auto;scrollbar-width:none;background:#0d1a2a}
55
- .vtv-tabs::-webkit-scrollbar{display:none}
56
- .vtv-tab{padding:4px 8px;background:#1a2a3a;border:1px solid #2a3a4a;border-radius:10px;color:#8ab4d8;font-size:9px;cursor:pointer;white-space:nowrap;flex-shrink:0;transition:all .2s}
57
- .vtv-tab:hover{background:#0b4a7a;color:#fff}
58
- .vtv-tab.on{background:#0066cc;border-color:#00ccff;color:#fff;font-weight:700}
59
- .vtv-tab.off{opacity:.35}
60
- .vtv-frame{position:relative;width:100%;aspect-ratio:16/9;background:#000;min-height:180px}
61
- .vtv-frame video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
62
- .vtv-err{display:flex;align-items:center;justify-content:center;height:180px;color:#888;font-size:12px;text-align:center;padding:20px;flex-direction:column;gap:8px}
63
- .vtv-err button{background:#0066cc;border:none;color:#fff;padding:6px 14px;border-radius:8px;font-size:11px;cursor:pointer}
64
- .vtv-load{display:flex;align-items:center;justify-content:center;height:180px;color:#00ccff;font-size:12px;flex-direction:column;gap:8px}
65
- .vtv-spinner{width:24px;height:24px;border:2px solid #333;border-top-color:#00ccff;border-radius:50%;animation:vtvspin .8s linear infinite}
66
- @keyframes vtvspin{to{transform:rotate(360deg)}}
67
- .vtv-controls{display:flex;align-items:center;gap:4px;padding:4px 8px;background:#0d1a2a;border-top:1px solid #1a2a3a}
68
- .vtv-pip-btn{background:#1a2a3a;border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:3px 8px;border-radius:6px;cursor:pointer;transition:all .2s;display:flex;align-items:center;gap:4px}
69
- .vtv-pip-btn:hover{background:#0b4a7a;color:#fff}
70
- .vtv-pip-btn.on{background:#0066cc;border-color:#00ccff;color:#fff}
71
- .vtv-pip-btn svg{width:12px;height:12px;fill:currentColor}
72
- .vtv-epg{margin:0;padding:6px 10px;background:#0a1628;border-top:1px solid #1a2a3a}
73
- .vtv-epg-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}
74
- .vtv-epg-title{font-size:10px;font-weight:700;color:#00ccff}
75
- .vtv-epg-toggle{background:none;border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:2px 8px;border-radius:6px;cursor:pointer}
76
- .vtv-epg-list{display:flex;gap:4px;overflow-x:auto;scrollbar-width:none;padding-bottom:4px}
77
- .vtv-epg-list::-webkit-scrollbar{display:none}
78
- .vtv-epg-item{flex:0 0 auto;padding:3px 6px;background:#1a2a3a;border-radius:4px;font-size:8px;color:#8ab4d8;white-space:nowrap;cursor:pointer}
79
- .vtv-epg-item:hover{background:#2a4a6a}
80
- .vtv-epg-item.now{background:#0066cc;color:#fff;font-weight:700}
81
- .vtv-epg-item .t{font-size:7px;color:#6a8aaa}
82
- .vtv-epg-item.now .t{color:#aaccee}
83
- .vtv-epg-item .n{color:#ccc;font-size:8px}
84
- .vtv-epg-item.now .n{color:#fff}
85
- .vtv-epg-empty{color:#666;font-size:9px;padding:4px}
86
- .vtv-epg-loading{color:#00ccff;font-size:9px;padding:4px;display:flex;align-items:center;gap:6px}
87
- .vtv-epg-sp{width:10px;height:10px;border:1px solid #333;border-top-color:#00ccff;border-radius:50%;animation:vtvspin .8s linear infinite}
88
- .vtv-pin-btn{position:absolute;top:6px;right:8px;z-index:5;background:rgba(0,0,0,.5);border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:2px 6px;border-radius:4px;cursor:pointer;font-weight:700;display:flex;align-items:center;gap:3px}
89
- .vtv-pin-btn:hover{background:#0b4a7a;color:#fff}
90
- .vtv-pin-btn.pinned{background:#0066cc;border-color:#00ccff;color:#fff}
91
- .vtv-wrap.vtv-sticky{position:sticky;top:0;z-index:48;transition:all .25s ease;box-shadow:0 4px 24px rgba(0,102,204,.35)}
92
- .vtv-wrap.vtv-sticky .vtv-epg{display:none}
93
- .vtv-wrap.vtv-sticky .vtv-controls{display:none}
94
- .vtv-wrap.vtv-sticky .vtv-tabs{padding:3px 8px}
95
- .vtv-wrap.vtv-sticky .vtv-tab{padding:3px 7px;font-size:8px}
96
- .vtv-wrap.vtv-sticky .vtv-frame{max-height:140px;min-height:100px}
97
- .vtv-wrap.vtv-sticky .vtv-frame video{max-height:140px}
98
- .vtv-wrap.vtv-sticky .vtv-load{height:100px}
99
- .vtv-wrap.vtv-sticky .vtv-err{height:100px}
100
- .vtv-wrap.vtv-sticky .vtv-head{padding:5px 10px}
101
- .vtv-wrap.vtv-sticky .vtv-title{font-size:11px}
102
- .vtv-wrap.vtv-sticky .vtv-badge{font-size:8px}
103
- .vtv-wrap.vtv-sticky .vtv-pin-btn{top:4px;right:6px}
104
- .vtv-rec-btn{background:#660000;border:1px solid #990000;color:#ff6666;font-size:9px;padding:3px 8px;border-radius:6px;cursor:pointer;transition:all .2s;display:flex;align-items:center;gap:4px;font-weight:700}
105
- .vtv-rec-btn:hover{background:#990000;color:#fff}
106
- .vtv-rec-btn.recording{background:#cc0000;border-color:#ff0000;color:#fff;animation:vtv-rec-pulse 1s infinite}
107
- @keyframes vtv-rec-pulse{0%,100%{opacity:1}50%{opacity:.5}}
108
- .vtv-rec-btn svg{width:12px;height:12px;fill:currentColor}
109
- .vtv-inline-rec{display:none;padding:8px 10px;background:#0a0a1a;border-top:1px solid #1a1a3a}
110
- .vtv-inline-rec.show{display:block}
111
- .vtv-inline-rec .rec-bar{position:relative;height:32px;background:#000;border-radius:4px;overflow:hidden;cursor:pointer;border:1px solid #2a2a4a;margin-bottom:6px;user-select:none}
112
- .vtv-inline-rec .rec-bar .rec-progress{position:absolute;top:0;bottom:0;background:rgba(155,89,182,.3);left:0;width:0%;pointer-events:none}
113
- .vtv-inline-rec .rec-bar .rec-marker{position:absolute;top:0;bottom:0;width:4px;z-index:2;border-radius:2px}
114
- .vtv-inline-rec .rec-bar .rec-marker.s{background:#2ecc71}
115
- .vtv-inline-rec .rec-bar .rec-marker.e{background:#e74c3c}
116
- .vtv-inline-rec .rec-time{display:flex;justify-content:space-between;font-size:9px;color:#888;margin-bottom:6px}
117
- .vtv-inline-rec .rec-controls{display:flex;gap:4px}
118
- .vtv-inline-rec .rec-controls button{flex:1;padding:6px;border:none;border-radius:6px;font-size:10px;font-weight:700;cursor:pointer}
119
- .vtv-inline-rec .rec-controls .rec-set-start{background:#1a3a1a;border:1px solid #2d6a2d;color:#5cb87a}
120
- .vtv-inline-rec .rec-controls .rec-set-end{background:#3a1a1a;border:1px solid #6a2d2d;color:#e74c3c}
121
- .vtv-inline-rec .rec-controls .rec-go{background:#1a0a3a;border:1px solid #3a2a6a;color:#9b59b6}
122
- .vtv-inline-rec .rec-controls .rec-reset{background:#222;border:1px solid #333;color:#888}
123
- .vtv-inline-rec .rec-hint{font-size:9px;color:#666;text-align:center;margin-top:4px}
124
- .vtv-inline-rec .rec-status{font-size:10px;color:#888;text-align:center;padding:4px;margin-top:4px;background:#111;border-radius:4px}
125
- .vtv-inline-rec .rec-status.ok{color:#2ecc71}
126
- .vtv-inline-rec .rec-status.err{color:#e74c3c}
127
- .vtv-inline-rec .rec-status.recording{color:#e74c3c;animation:vtv-rec-pulse 1s infinite}
128
- .vtv-rec-panel{display:none;padding:10px;background:#0d0d20;border-top:1px solid #2a1a4a}
129
- .vtv-rec-panel.show{display:block}
130
- .vtv-rec-panel .rec-panel-title{font-size:11px;font-weight:700;color:#9b59b6;margin-bottom:8px}
131
- .vtv-rec-panel .rec-preview-wrap{margin-bottom:8px;text-align:center}
132
- .vtv-rec-panel .rec-preview-wrap video{max-width:100%;max-height:180px;border-radius:6px;background:#000}
133
- .vtv-rec-panel .rec-ratio-row{display:flex;gap:4px;margin-bottom:8px}
134
- .vtv-rec-panel .rec-ratio-row button{flex:1;padding:6px;background:#1a1a2e;border:1px solid #2a2a4a;border-radius:6px;color:#888;font-size:10px;cursor:pointer}
135
- .vtv-rec-panel .rec-ratio-row button.active{border-color:#9b59b6;color:#9b59b6;background:#2a1a4a}
136
- .vtv-rec-panel .rec-actions{display:flex;gap:4px}
137
- .vtv-rec-panel .rec-actions button{flex:1;padding:8px;border:none;border-radius:6px;font-size:11px;font-weight:700;cursor:pointer}
138
- .vtv-rec-panel .rec-actions .rec-download{background:#2d8659;color:#fff}
139
- .vtv-rec-panel .rec-actions .rec-share{background:#9b59b6;color:#fff}
140
- .vtv-rec-panel .rec-proc{text-align:center;padding:12px;color:#9b59b6;font-size:12px;display:none}
141
- .vtv-rec-panel .rec-title-input{width:100%;background:#1a1a2e;border:1px solid #2a2a4a;border-radius:6px;padding:8px;color:#ccc;font-size:11px;margin-bottom:8px;box-sizing:border-box}
142
- .vtv-rec-panel .rec-title-input:focus{border-color:#9b59b6;outline:none}
143
- .vtv-rec-panel .rec-ai-title-row{display:flex;gap:4px;margin-bottom:8px}
144
- .vtv-rec-panel .rec-ai-title-row button{flex:1;padding:6px;background:#1a1a2e;border:1px solid #2a2a4a;border-radius:6px;color:#888;font-size:10px;cursor:pointer}
145
- .vtv-rec-panel .rec-ai-title-row button:hover{border-color:#9b59b6;color:#9b59b6}
146
- .vtv-rec-panel .rec-ai-title-row button:disabled{opacity:.4;cursor:not-allowed}
147
- .vtv-mini{position:fixed;top:0;left:0;right:0;z-index:99990;background:#000;border-bottom:2px solid #0066cc;box-shadow:0 4px 20px rgba(0,102,204,.4);display:none;transition:transform .3s ease}
148
- .vtv-mini.show{display:block}
149
- .vtv-mini.hidden{transform:translateY(-88%)}
150
- .vtv-mini-frame{position:relative;width:100%;aspect-ratio:16/9;background:#000;max-height:180px}
151
- .vtv-mini-frame video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
152
- .vtv-mini-bar{display:flex;align-items:center;justify-content:space-between;padding:3px 8px;background:linear-gradient(90deg,#003366,#1a1a1a)}
153
- .vtv-mini-ch{font-size:10px;font-weight:700;color:#00ccff}
154
- .vtv-mini-epg{font-size:9px;color:#8ab4d8;max-width:180px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-left:8px}
155
- .vtv-mini-btns{display:flex;gap:3px}
156
- .vtv-mini-btn{background:#1a2a3a;border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:2px 6px;border-radius:4px;cursor:pointer}
157
- .vtv-mini-btn:hover{background:#0b4a7a;color:#fff}
158
- .vtv-mini-btn.x{background:#600;border-color:#900;color:#f66}
159
- .vtv-mini-btn.x:hover{background:#900;color:#fff}
160
- .vtv-mini-peek{position:absolute;bottom:-18px;right:10px;background:#0066cc;color:#fff;font-size:9px;padding:2px 8px;border-radius:0 0 6px 6px;cursor:pointer;display:none}
161
- .vtv-mini.hidden .vtv-mini-peek{display:block}
162
- .vtv-popup{position:fixed;top:0;left:0;right:0;margin:8px auto 0;background:rgba(0,0,0,.95);z-index:99999;display:none;flex-direction:column;max-height:calc(100vh - 16px);max-width:800px;border-radius:12px;overflow:hidden}
163
- .vtv-popup.show{display:flex}
164
- .vtv-popup:has(#vtv-popup-hdr[style*="none"]) .vtv-popup-frame{height:100%}
165
- .vtv-popup-header{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:#111;border-bottom:1px solid #333}
166
- .vtv-popup-title{font-size:14px;font-weight:700;color:#00ccff}
167
- .vtv-popup-close{background:#333;border:none;color:#fff;font-size:18px;width:32px;height:32px;border-radius:6px;cursor:pointer}
168
- .vtv-popup-frame{flex:1;position:relative;background:#000;min-height:300px}
169
- .vtv-popup-frame iframe{position:absolute;inset:0;width:100%;height:100%;border:none}
170
- .vtv-popup-frame iframe{position:absolute;inset:0;width:100%;height:100%;border:none}
171
- .vtv-popup-loader{display:flex;align-items:center;justify-content:center;height:280px;color:#00ccff;font-size:12px;flex-direction:column;gap:8px}
172
- .vtv-popup-btn{background:#1a2a3a;border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:3px 8px;border-radius:6px;cursor:pointer;margin-left:4px}
173
- .vtv-popup-btn:hover{background:#0b4a7a;color:#fff}
174
- `;
175
- document.head.appendChild(style);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // === VNEWS — VTV LIVE + Inline Recorder v15 ===
2
+ // Features: DVR 5-min rewind (liveDurationInfinity), PiP, mini-player, INLINE RECORDER
3
+ // v15: Use liveDurationInfinity:true HLS.js treats live as VOD, no auto-snap. User seeks freely in buffer.
 
4
 
5
  (function(){
6
  if(window._ytLiveLoaded) return;
 
14
  {id:'vtv9',name:'VTV9',badge:'Miền Bắc'},{id:'vtv10',name:'VTV10',badge:'Cần Thơ'},
15
  {id:'vtvprime',name:'VTVPrime',badge:'Prime'},
16
  ];
17
+ const DEFAULT_CHANNEL = 'vtv6';
18
+ const DVR_BUFFER_SECONDS = 300;
19
+ const NEEDS_PROXY = /fptplay\.net/;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  const STREAMS = {};
21
  let _currentCh = null, _hls = null, _loading = false, _blockInserted = false;
22
+ let _streamsLoaded = false, _pipActive = false, _miniActive = false, _vtvPinned = false;
23
+ let _dvrSeekBarTimer = null;
24
+ let _dvrUserSeeking = false;
25
+ let _dvrAtLiveEdge = true;
26
 
 
27
  const _rec = {
28
  active: false, startTime: null, endTime: null,
29
  isRecording: false, recorder: null, chunks: [], blob: null,
30
+ stream: null, tracks: [], ratio: '16:9', aiTitle: null, aiTags: [],
31
+ _ratioCrop: null, _aiAborted: false
32
  };
33
 
34
+ function fmtSec(s){const m=Math.floor(s/60),sec=Math.floor(s%60);return m+':'+(sec<10?'0':'')+sec;}
35
+ function $(id){return document.getElementById(id);}
36
+
37
+ async function loadAllStreams(){
38
+ if(_streamsLoaded) return;
39
+ _streamsLoaded = true;
40
+ try{
41
+ const r=await fetch('/api/vtv/streams');
42
+ if(!r.ok) return;
43
+ const data=await r.json();
44
+ for(const ch of CHANNELS){
45
+ if(data[ch.id]&&data[ch.id].stream_url){
46
+ let u=data[ch.id].stream_url;
47
+ if(NEEDS_PROXY.test(u)) u='/api/proxy/m3u8/vtv?url='+encodeURIComponent(u);
48
+ STREAMS[ch.id]=[u];
49
+ } else STREAMS[ch.id]=[];
50
+ }
51
+ }catch(e){}
52
+ CHANNELS.forEach(ch=>{
53
+ const t=document.getElementById('vtvt-'+ch.id);
54
+ if(t){
55
+ if(STREAMS[ch.id]&&STREAMS[ch.id].length>0){t.classList.remove('off');t.textContent=ch.name;}
56
+ else{t.style.opacity='0.35';t.textContent=ch.name+' ✕';}
57
+ }
58
+ });
59
+ }
60
+
61
+ function createDVRBar(){
62
+ const player=$('vtv-player-wrap');
63
+ if(!player) return;
64
+ const old=$('vtv-dvr-bar');
65
+ if(old) old.remove();
66
+ const bar=document.createElement('div');
67
+ bar.id='vtv-dvr-bar';
68
+ bar.innerHTML='<div class="vtv-dvr-track"><div class="vtv-dvr-buffer" id="vtv-dvr-buffer"></div><div class="vtv-dvr-live-dot" id="vtv-dvr-live-dot"></div><div class="vtv-dvr-handle" id="vtv-dvr-handle"></div></div><div class="vtv-dvr-controls"><span class="vtv-dvr-time" id="vtv-dvr-time">LIVE</span><button class="vtv-dvr-live-btn" id="vtv-dvr-live-btn">● LIVE</button></div>';
69
+ player.appendChild(bar);
70
+ return bar;
71
+ }
72
+
73
+ function updateDVRUI(){
74
+ const video=$('vtv-player');
75
+ if(!video||!_hls) return;
76
+ const track=$('vtv-dvr-track');
77
+ const buffer=$('vtv-dvr-buffer');
78
+ const handle=$('vtv-dvr-handle');
79
+ const liveDot=$('vtv-dvr-live-dot');
80
+ const timeLabel=$('vtv-dvr-time');
81
+ if(!track||!buffer||!handle) return;
82
+ const curTime=video.currentTime;
83
+ const duration=video.duration;
84
+ if(!duration||!isFinite(duration)||duration<=0) return;
85
+ const bufferStart=Math.max(0,duration-DVR_BUFFER_SECONDS);
86
+ const bufferWidth=((duration-bufferStart)/DVR_BUFFER_SECONDS)*100;
87
+ const handlePos=((curTime-bufferStart)/(duration-bufferStart))*100;
88
+ buffer.style.width=Math.min(100,bufferWidth)+'%';
89
+ buffer.style.left=(100-Math.min(100,bufferWidth))+'%';
90
+ liveDot.style.left='100%';
91
+ handle.style.left=Math.max(0,Math.min(100,handlePos))+'%';
92
+ const behind=duration-curTime;
93
+ if(behind<3){timeLabel.textContent='LIVE';timeLabel.style.color='#e74c3c';}
94
+ else{timeLabel.textContent='-'+fmtSec(behind);timeLabel.style.color='#8ab4d8';}
95
+ }
96
+
97
+ function seekFromTrack(clientX,isRelease){
98
+ const video=$('vtv-player');
99
+ if(!video||!_hls) return;
100
+ const track=$('vtv-dvr-track');
101
+ if(!track) return;
102
+ const rect=track.getBoundingClientRect();
103
+ const pct=Math.max(0,Math.min(1,(clientX-rect.left)/rect.width));
104
+ const duration=video.duration;
105
+ if(!duration||!isFinite(duration)||duration<=0) return;
106
+ const bufferStart=Math.max(0,duration-DVR_BUFFER_SECONDS);
107
+ const seekTime=bufferStart+pct*(duration-bufferStart);
108
+ _dvrUserSeeking=!isRelease;
109
+ _dvrAtLiveEdge=(duration-seekTime<2);
110
+ video.currentTime=seekTime;
111
+ if(isRelease){video.play().catch(()=>{});}
112
+ else{video.pause();}
113
+ updateDVRUI();
114
+ }
115
+
116
+ function setupDVRBar(){
117
+ const video=$('vtv-player');
118
+ if(!video) return;
119
+ createDVRBar();
120
+ const track=$('vtv-dvr-track');
121
+ const handle=$('vtv-dvr-handle');
122
+ const liveBtn=$('vtv-dvr-live-btn');
123
+ if(!track||!handle) return;
124
+ let isDragging=false;
125
+ track.addEventListener('click',function(e){seekFromTrack(e.clientX,true);});
126
+ track.addEventListener('touchstart',function(e){e.preventDefault();seekFromTrack(e.touches[0].clientX,false);},{passive:false});
127
+ track.addEventListener('touchend',function(e){e.preventDefault();seekFromTrack(e.changedTouches[0].clientX,true);},{passive:false});
128
+ handle.addEventListener('mousedown',function(e){e.preventDefault();e.stopPropagation();isDragging=true;_dvrUserSeeking=true;});
129
+ handle.addEventListener('touchstart',function(e){e.preventDefault();e.stopPropagation();isDragging=true;_dvrUserSeeking=true;},{passive:false});
130
+ document.addEventListener('mousemove',function(e){if(isDragging){e.preventDefault();seekFromTrack(e.clientX,false);}});
131
+ document.addEventListener('touchmove',function(e){if(isDragging){e.preventDefault();seekFromTrack(e.touches[0].clientX,false);}},{passive:false});
132
+ document.addEventListener('mouseup',function(e){if(isDragging){isDragging=false;_dvrUserSeeking=false;seekFromTrack(e.clientX,true);}});
133
+ document.addEventListener('touchend',function(e){if(isDragging){isDragging=false;_dvrUserSeeking=false;if(e.changedTouches[0])seekFromTrack(e.changedTouches[0].clientX,true);}});
134
+ liveBtn.addEventListener('click',function(){
135
+ const v=$('vtv-player');
136
+ if(v&&_hls){
137
+ _dvrUserSeeking=false;_dvrAtLiveEdge=true;
138
+ const dur=v.duration;
139
+ if(dur&&isFinite(dur)&&dur>0){v.currentTime=dur;}
140
+ v.play().catch(()=>{});
141
+ updateDVRUI();
142
+ }
143
+ });
144
+ if(_dvrSeekBarTimer) clearInterval(_dvrSeekBarTimer);
145
+ _dvrSeekBarTimer=setInterval(updateDVRUI,1000);
146
+ video.addEventListener('timeupdate',function(){
147
+ if(!_hls) return;
148
+ const dur=video.duration;
149
+ if(!dur||!isFinite(dur)) return;
150
+ _dvrAtLiveEdge=((dur-video.currentTime)<2);
151
+ });
152
+ }
153
+
154
+ function showDVRBar(){const bar=$('vtv-dvr-bar');if(bar) bar.classList.add('visible');}
155
+
156
+ function setupPinButton(){
157
+ const block=document.getElementById('vtv-block');
158
+ if(!block) return;
159
+ let pinBtn=block.querySelector('.vtv-pin-btn');
160
+ if(!pinBtn){
161
+ pinBtn=document.createElement('button');
162
+ pinBtn.className='vtv-pin-btn';pinBtn.textContent='📌';pinBtn.title='Ghim khung xem';
163
+ block.style.position='relative';block.appendChild(pinBtn);
164
+ }
165
+ pinBtn.onclick=function(){
166
+ _vtvPinned=!_vtvPinned;
167
+ pinBtn.style.color=_vtvPinned?'#e74c3c':'#8ab4d8';
168
+ if(_vtvPinned){block.style.position='fixed';block.style.bottom='12px';block.style.right='12px';block.style.width='360px';block.style.zIndex='9999';block.style.borderRadius='12px';block.style.boxShadow='0 4px 24px rgba(0,0,0,0.5)';}
169
+ else{block.style.position='';block.style.bottom='';block.style.right='';block.style.width='';block.style.zIndex='';block.style.borderRadius='';block.style.boxShadow='';}
170
+ };
171
+ }
172
+
173
+ async function togglePiP(){
174
+ const video=$('vtv-player');
175
+ if(!video) return;
176
+ try{
177
+ if(document.pictureInPictureElement===video){await document.exitPictureInPicture();_pipActive=false;}
178
+ else{await video.requestPictureInPicture();_pipActive=true;}
179
+ }catch(e){}
180
+ }
181
+
182
+ function activateMiniPlayer(){
183
+ const video=$('vtv-player');
184
+ if(!video||!video.src) return;
185
+ _miniActive=true;
186
+ const mini=document.createElement('div');
187
+ mini.id='vtv-mini-player';
188
+ mini.innerHTML='<div class="vtv-mini-header"><span id="vtv-mini-ch">VTV</span><span id="vtv-mini-epg"></span><button id="vtv-mini-close" style="background:none;border:none;color:#e74c3c;font-size:18px;cursor:pointer;">✕</button></div><div class="vtv-mini-body"><div class="vtv-mini-loading">Đang tải...</div></div>';
189
+ document.body.appendChild(mini);
190
+ const mv=mini.querySelector('.vtv-mini-body');
191
+ video.style.display='none';mv.appendChild(video);
192
+ video.style.display='block';video.style.width='100%';video.style.height='100%';
193
+ mini.querySelector('.vtv-mini-loading').remove();
194
+ $('vtv-mini-close').onclick=closeMiniPlayer;
195
+ if(_currentCh){fetch('/api/vtv/epg/'+_currentCh).then(r=>r.json()).then(d=>{const p=d.programs||[];const n=p.find(x=>x.now);$('vtv-mini-epg').textContent=n?n.time+' '+n.title:(p[0]?p[0].time+' '+p[0].title:'');}).catch(()=>{});}
196
+ }
197
+
198
+ function closeMiniPlayer(){
199
+ if(!_miniActive) return;
200
+ _miniActive=false;
201
+ const mini=$('vtv-mini-player');
202
+ if(!mini) return;
203
+ const video=$('vtv-player');
204
+ if(video){mini.querySelector('.vtv-mini-body').appendChild(video);video.style.display='block';}
205
+ mini.remove();
206
+ const wrap=$('vtv-player-wrap');
207
+ if(wrap&&video){wrap.appendChild(video);video.style.display='block';}
208
+ }
209
+
210
+ function initRecorder(){
211
+ const block=document.getElementById('vtv-block');
212
+ if(!block) return;
213
+ let recBtn=block.querySelector('.vtv-rec-btn');
214
+ if(!recBtn){
215
+ recBtn=document.createElement('button');
216
+ recBtn.className='vtv-rec-btn';recBtn.textContent='⏺ REC';recBtn.title='Quay màn hình';
217
+ block.style.position='relative';block.appendChild(recBtn);
218
+ }
219
+ recBtn.onclick=function(){
220
+ if(!_rec.active){
221
+ _rec.active=true;recBtn.style.color='#e74c3c';recBtn.textContent='⏹ STOP';
222
+ _rec.chunks=[];_rec.blob=null;
223
+ const video=$('vtv-player');
224
+ if(video&&video.captureStream){
225
+ _rec.stream=video.captureStream();_rec.tracks=_rec.stream.getTracks();
226
+ try{
227
+ _rec.recorder=new MediaRecorder(_rec.stream,{mimeType:'video/webm;codecs=vp9'});
228
+ _rec.recorder.ondataavailable=function(e){if(e.data.size>0)_rec.chunks.push(e.data);};
229
+ _rec.recorder.onstop=function(){_rec.blob=new Blob(_rec.chunks,{type:'video/webm'});_rec.chunks=[];_rec.tracks.forEach(t=>t.stop());_rec.stream=null;showRecordPreview(_rec.blob);};
230
+ _rec.recorder.start(1000);_rec.isRecording=true;_rec.startTime=Date.now();
231
+ }catch(e){_rec.tracks.forEach(t=>t.stop());_rec.stream=null;_rec.active=false;recBtn.style.color='';recBtn.textContent='⏺ REC';}
232
+ }
233
+ }else{
234
+ if(_rec.isRecording&&_rec.recorder&&_rec.recorder.state==='recording'){_rec.recorder.stop();_rec.isRecording=false;_rec.endTime=Date.now();}
235
+ _rec.active=false;recBtn.style.color='';recBtn.textContent='⏺ REC';
236
+ }
237
+ };
238
+ }
239
+
240
+ function showRecordPreview(blob){
241
+ const url=URL.createObjectURL(blob);
242
+ const div=document.createElement('div');
243
+ div.style.cssText='position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:99999;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;';
244
+ div.innerHTML='<video src="'+url+'" controls style="max-width:80%;max-height:60%;border-radius:8px;"></video><div style="display:flex;gap:8px;"><button id="rec-upload" style="padding:8px 16px;background:#2ecc71;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:14px;">📤 Upload</button><button id="rec-close" style="padding:8px 16px;background:#e74c3c;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:14px;">✕ Đóng</button></div>';
245
+ document.body.appendChild(div);
246
+ $('rec-close').onclick=function(){URL.revokeObjectURL(url);div.remove();};
247
+ $('rec-upload').onclick=function(){const v=div.querySelector('video');v.pause();uploadRecording(blob,div,url);};
248
+ }
249
+
250
+ async function uploadRecording(blob,div,url){
251
+ const btn=$('rec-upload');btn.textContent='⏳ Đang upload...';btn.disabled=true;
252
+ try{const fd=new FormData();fd.append('file',blob,'recording.webm');const r=await fetch('/api/upload',{method:'POST',body:fd});const d=await r.json();if(d.ok){btn.textContent='✅ Upload thành công!';setTimeout(()=>{URL.revokeObjectURL(url);div.remove();},1500);}else{btn.textContent='❌ Lỗi: '+(d.error||'unknown');btn.disabled=false;}}catch(e){btn.textContent='❌ Lỗi kết nối';btn.disabled=false;}
253
+ }
254
+
255
+ function switchChannel(chId){
256
+ if(_loading) return;
257
+ _loading=true;
258
+ const ch=CHANNELS.find(c=>c.id===chId);
259
+ if(!ch){_loading=false;return;}
260
+ _currentCh=chId;
261
+ const video=$('vtv-player');
262
+ const loadEl=$('vtv-loading');
263
+ const errEl=$('vtv-error');
264
+ const errMsg=$('vtv-error-msg');
265
+ if(!video){_loading=false;return;}
266
+ video.style.display='none';
267
+ if(loadEl) loadEl.style.display='flex';
268
+ if(errEl) errEl.style.display='none';
269
+ if(_hls){_hls.destroy();_hls=null;}
270
+ _dvrAtLiveEdge=true;
271
+ const oldBar=$('vtv-dvr-bar');
272
+ if(oldBar) oldBar.remove();
273
+ if(_dvrSeekBarTimer){clearInterval(_dvrSeekBarTimer);_dvrSeekBarTimer=null;}
274
+ const urls=STREAMS[chId]||[];
275
+ if(!urls.length){if(loadEl)loadEl.style.display='none';if(errEl){errEl.style.display='flex';errMsg.textContent=chId==='vtvprime'?'VTVPrime: Kênh trả phí.':ch.name+': Không có luồng.';}_loading=false;return;}
276
+ _tryPlay(video,urls,0,ch.name,loadEl,errEl,errMsg);
277
+ loadEpg(chId);
278
+ if(_miniActive){$('vtv-mini-ch').textContent=ch.name;fetch('/api/vtv/epg/'+_currentCh).then(r=>r.json()).then(d=>{const p=d.programs||[];const n=p.find(x=>x.now);$('vtv-mini-epg').textContent=n?n.time+' '+n.title:(p[0]?p[0].time+' '+p[0].title:'');}).catch(()=>{});}
279
+ setTimeout(()=>{setupDVRBar();showDVRBar();},2000);
280
+ _loading=false;
281
+ }
282
+
283
+ function loadEpg(chId){
284
+ fetch('/api/vtv/epg/'+chId).then(r=>r.json()).then(d=>{const p=d.programs||[];const n=p.find(x=>x.now);const el=$('vtv-epg-now');if(el) el.textContent=n?n.time+' '+n.title:(p[0]?p[0].time+' '+p[0].title:'');}).catch(()=>{});
285
+ }
286
+
287
+ function _tryPlay(video,urls,idx,name,loadEl,errEl,errMsg){
288
+ if(idx>=urls.length){if(loadEl)loadEl.style.display='none';if(errEl){errEl.style.display='flex';errMsg.textContent=name+': Tất cả nguồn lỗi.';}return;}
289
+ const src=urls[idx];
290
+ if(loadEl) loadEl.innerHTML='<div class="vtv-spinner"></div>Kết nối '+name+' ('+(idx+1)+'/'+urls.length+')...';
291
+ if(typeof Hls!=='undefined'&&Hls.isSupported()){
292
+ const hls=new Hls({
293
+ enableWorker:true,lowLatencyMode:false,startLevel:0,capLevelToPlayerSize:true,
294
+ maxBufferLength:120,maxMaxBufferLength:180,
295
+ liveDurationInfinity:true,
296
+ liveBackBufferLength:DVR_BUFFER_SECONDS,
297
+ fragLoadingTimeOut:10000,manifestLoadingTimeOut:10000
298
+ });
299
+ _hls=hls; hls.loadSource(src); hls.attachMedia(video);
300
+ hls.on(Hls.Events.MANIFEST_PARSED,(ev,data)=>{
301
+ loadEl.style.display='none';video.style.display='block';
302
+ const dur=video.duration;
303
+ if(dur&&isFinite(dur)&&dur>0){video.currentTime=dur;}
304
+ video.play().catch(()=>{});
305
+ });
306
+ let rec=0; hls.on(Hls.Events.ERROR,(ev,data)=>{if(data.fatal){if(data.type===Hls.ErrorTypes.NETWORK_ERROR){rec++;if(rec<=3)setTimeout(()=>hls.startLoad(),2000);else{hls.destroy();_hls=null;_tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);}}else if(data.type===Hls.ErrorTypes.MEDIA_ERROR){try{hls.recoverMediaError();}catch(e){}}else{hls.destroy();_hls=null;_tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);}}});
307
+ }else if(video.canPlayType('application/vnd.apple.mpegurl')){
308
+ video.src=src;
309
+ video.addEventListener('loadedmetadata',()=>{video.play().catch(()=>{});loadEl.style.display='none';video.style.display='block';},{once:true});
310
+ video.addEventListener('error',()=>{_tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);},{once:true});
311
+ }else{if(loadEl)loadEl.style.display='none';if(errEl){errEl.style.display='flex';errMsg.textContent='Không hỗ trợ HLS';}}
312
+ }
313
+
314
+ const _origShowView=window.showView;
315
+ window.showView=function(id){if(id==='view-home'&&_miniActive)closeMiniPlayer();else if(id!=='view-home'&&_currentCh&&STREAMS[_currentCh]&&STREAMS[_currentCh].length>0&&!_miniActive)activateMiniPlayer();return _origShowView.apply(this,arguments);};
316
+ const _origReadArticle=window.readArticle; if(_origReadArticle){window.readArticle=function(url){if(_currentCh&&STREAMS[_currentCh]&&STREAMS[_currentCh].length>0&&!_miniActive)activateMiniPlayer();return _origReadArticle.apply(this,arguments);};}
317
+ const _origSwitchCat=window.switchCat; if(_origSwitchCat){window.switchCat=function(id){if(id==='home'){if(_miniActive)closeMiniPlayer();}else if(_currentCh&&STREAMS[_currentCh]&&STREAMS[_currentCh].length>0&&!_miniActive)activateMiniPlayer();return _origSwitchCat.apply(this,arguments);};}
318
+
319
+ const _origLoadHome=window.loadHome;
320
+ if(_origLoadHome&&!_origLoadHome.__vtvWrapped){
321
+ window.loadHome=async function(){const old=document.getElementById('vtv-block');if(old)old.remove();_blockInserted=false;const r=await _origLoadHome.apply(this,arguments);try{pinBlock();}catch(e){}return r;};
322
+ window.loadHome.__vtvWrapped=true;
323
+ }
324
+
325
+ window._vtvPlay=switchChannel;
326
+ window._vtvPiP=togglePiP;
327
+
328
+ loadAllStreams().then(()=>{setTimeout(()=>{window._vtvPlay(DEFAULT_CHANNEL);},300);});
329
+
330
+ setTimeout(()=>{const pipBtn=document.getElementById('vtv-pip-btn');if(pipBtn) pipBtn.onclick=togglePiP;setupPinButton();initRecorder();},1000);
331
+
332
+ })();
vtv_api.py CHANGED
@@ -1,341 +1,272 @@
1
- # VTV Stream Fix - sv2.xemtivitop.com ONLY source + EPG + proxy + timezone +10
2
- # Streams from https://sv2.xemtivitop.com/live/hot/vtv1.php ... vtv10.php
3
- import re, time, threading, json, base64, requests, urllib.parse
 
 
 
 
 
4
  from fastapi import APIRouter, Query
5
  from fastapi.responses import JSONResponse, Response
6
  from bs4 import BeautifulSoup
7
  from datetime import datetime, timedelta, timezone
8
 
9
- VN_TZ = timezone(timedelta(hours=10)) # Timezone +10 as requested
 
10
  router = APIRouter()
11
 
12
  UA = {
13
  "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
14
  "Accept-Language": "vi-VN,vi;q=0.9",
15
- "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
16
- "Sec-Fetch-Dest": "document",
17
- "Sec-Fetch-Mode": "navigate",
18
- "Sec-Fetch-Site": "none",
19
- "Upgrade-Insecure-Requests": "1",
20
  }
21
 
22
- # ===== SOLE SOURCE: sv2.xemtivitop.com PHP endpoints =====
23
- SV2_ENDPOINTS = {
24
- "vtv1": "https://sv2.xemtivitop.com/live/hot/vtv1.php",
25
- "vtv2": "https://sv2.xemtivitop.com/live/hot/vtv2.php",
26
- "vtv3": "https://sv2.xemtivitop.com/live/hot/vtv3.php",
27
- "vtv4": "https://sv2.xemtivitop.com/live/hot/vtv4.php",
28
- "vtv5": "https://sv2.xemtivitop.com/live/hot/vtv5.php",
29
- "vtv6": "https://sv2.xemtivitop.com/live/hot/vtv6.php",
30
- "vtv7": "https://sv2.xemtivitop.com/live/hot/vtv7.php",
31
- "vtv8": "https://sv2.xemtivitop.com/live/hot/vtv8.php",
32
- "vtv9": "https://sv2.xemtivitop.com/live/hot/vtv9.php",
33
- "vtv10": "https://sv2.xemtivitop.com/live/hot/vtv10.php",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  }
35
 
36
  CHANNEL_NAMES = {
37
- "vtv1":"VTV1","vtv2":"VTV2","vtv3":"VTV3","vtv4":"VTV4","vtv5":"VTV5",
38
- "vtv6":"VTV6","vtv7":"VTV7","vtv8":"VTV8","vtv9":"VTV9","vtv10":"VTV10",
39
- "vtvprime":"VTVPrime",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  }
41
 
42
- # Cache
43
- _cache = {}
44
- _lock = threading.Lock()
45
- _CACHE_TTL = 90
46
 
47
- def _cached(k):
48
- with _lock:
49
- if k in _cache and time.time() - _cache[k]['t'] < _CACHE_TTL:
50
- return _cache[k]['d']
51
  return None
52
- def _set_cache(k, d):
53
- with _lock:
54
- _cache[k] = {'t': time.time(), 'd': d}
55
 
56
- # ===== SV2 OBFUSCATION DECODING =====
 
 
57
 
58
- def _extract_from_js_obfuscation(html):
59
- """Extract m3u8 URL from sv2.xemtivitop.com PHP obfuscated JS"""
60
  if not html:
61
  return None
62
-
63
- urls = []
64
-
65
- # Method 1: Direct m3u8 URL
66
- for m in re.finditer(r'(https?://[^\s"\'<>]+\.m3u8[^\s"\'<>]*)', html):
67
- url = m.group(1).strip().rstrip('.,;)\'\"')
68
- if len(url) > 20 and url not in urls:
69
- urls.append(url)
70
-
71
- # Method 2: file: "..." pattern
72
- for m in re.finditer(r"""['"]file['"]\s*:\s*['"]([^'"]*\.m3u8[^'"]*)['"]""", html, re.IGNORECASE):
73
  url = m.group(1).strip()
74
- if url.startswith('//'):
75
- url = 'https:' + url
76
- if len(url) > 20 and url not in urls:
77
- urls.append(url)
78
-
79
- # Method 3: src: "..." or source: "..."
80
- for m in re.finditer(r"""['"]src['"]\s*:\s*['"]([^'"]*\.m3u8[^'"]*)['"]""", html, re.IGNORECASE):
81
  url = m.group(1).strip()
82
- if url.startswith('//'):
83
- url = 'https:' + url
84
- if len(url) > 20 and url not in urls:
85
- urls.append(url)
86
-
87
- # Method 4: atob decode
88
- for m in re.finditer(r'atob\s*\(\s*["\']([A-Za-z0-9+/=]+)["\']\s*\)', html):
89
- try:
90
- decoded = base64.b64decode(m.group(1)).decode('utf-8', errors='ignore')
91
- if '.m3u8' in decoded:
92
- um = re.search(r'(https?://[^\s"\'<>]+\.m3u8[^\s"\'<>]*)', decoded)
93
- if um and um.group(1) not in urls:
94
- urls.append(um.group(1))
95
- except:
96
- pass
97
-
98
- # Method 5: Script analysis + String.fromCharCode
99
  try:
100
- scripts = re.findall(r'<script[^>]*>(.*?)</script>', html, re.DOTALL | re.IGNORECASE)
101
- for script in scripts:
102
- if '.m3u8' in script:
103
- for m in re.finditer(r"""["']([^"']*\.m3u8[^"']*)["']""", script):
104
- url = m.group(1)
105
- if url.startswith('//'):
106
- url = 'https:' + url
107
- if url not in urls and len(url) > 20:
108
- urls.append(url)
109
-
110
- fcc_matches = re.findall(r'String\.fromCharCode\s*\(([^)]+)\)', script)
111
- if fcc_matches:
112
- for fcc in fcc_matches:
113
- try:
114
- codes = [int(x.strip()) for x in fcc.split(',') if x.strip().isdigit()]
115
- decoded = ''.join(chr(c) for c in codes)
116
- if '.m3u8' in decoded:
117
- um = re.search(r'(https?://[^\s"\'<>]+\.m3u8[^\s"\'<>]*)', decoded)
118
- if um and um.group(1) not in urls:
119
- urls.append(um.group(1))
120
- except:
121
- pass
122
  except:
123
  pass
124
-
125
- # Method 6: video element
 
 
 
 
126
  try:
127
- soup = BeautifulSoup(html, 'lxml')
128
- for vid in soup.find_all('video'):
129
- src = vid.get('src', '')
130
- if src and '.m3u8' in src:
131
- if src.startswith('//'):
132
- src = 'https:' + src
133
- if src not in urls:
134
- urls.append(src)
135
- for source in vid.find_all('source'):
136
- s = source.get('src', '')
137
- if s and '.m3u8' in s:
138
- if s.startswith('//'):
139
- s = 'https:' + s
140
- if s not in urls:
141
- urls.append(s)
142
-
143
- # Method 7: iframe source
144
- for iframe in soup.find_all('iframe'):
145
- src = iframe.get('src', '')
146
- if src and '.m3u8' in src:
147
- if src.startswith('//'):
148
- src = 'https:' + src
149
- if src not in urls:
150
- urls.append(src)
151
  except:
152
  pass
153
-
154
- # Method 8: "link" variable
155
- for m in re.finditer(r"""['"]?link['"]?\s*[:=]\s*['"]([^'"]*)['"]""", html, re.IGNORECASE):
156
- url = m.group(1).strip()
157
- if '.m3u8' in url:
158
- if url.startswith('//'):
159
- url = 'https:' + url
160
- if url not in urls and len(url) > 20:
161
- urls.append(url)
162
-
163
- # Deduplicate
164
- seen = set()
165
- unique_urls = []
166
- for u in urls:
167
- u_clean = u.split('?')[0].split('#')[0]
168
- if u_clean not in seen:
169
- seen.add(u_clean)
170
- unique_urls.append(u)
171
-
172
- return unique_urls[0] if unique_urls else None
173
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
 
175
- def verify_hls(url, referer="https://sv2.xemtivitop.com/", timeout=10):
 
176
  if not url:
177
  return None
178
  try:
179
- h = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
180
- if referer:
181
- h["Referer"] = referer
182
- r = requests.get(url, headers=h, timeout=timeout, allow_redirects=True, verify=False)
183
- if r.status_code == 200 and '#EXTM3U' in r.text[:500]:
184
  return url
185
  except:
186
  pass
187
  return None
188
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
 
190
  def fetch_vtv_stream(channel_id):
 
 
 
 
 
 
191
  channel_id = channel_id.lower().strip()
 
 
 
 
 
 
 
 
192
  cached = _cached(channel_id)
193
  if cached is not None:
194
  return cached
195
 
196
- result = None
197
- php_url = SV2_ENDPOINTS.get(channel_id)
198
- if php_url:
199
- try:
200
- h = dict(UA)
201
- h["Referer"] = "https://sv2.xemtivitop.com/"
202
- r = requests.get(php_url, headers=h, timeout=15, allow_redirects=True, verify=False)
203
- if r.status_code == 200:
204
- extracted = _extract_from_js_obfuscation(r.text)
205
- if extracted:
206
- verified = verify_hls(extracted, "https://sv2.xemtivitop.com/", timeout=10)
207
- if verified:
208
- result = verified
209
- else:
210
- result = extracted
211
- except Exception as e:
212
- print(f"[vtv_api] sv2 error for {channel_id}: {e}")
213
-
214
- _set_cache(channel_id, result)
215
- return result
216
 
 
 
 
 
 
 
217
 
218
- # ===================== EPG (LICH PHAT SONG) =====================
 
 
 
 
219
 
220
- def fetch_epg(channel_id):
221
- channel_id = channel_id.lower().strip()
222
- epg_map = {
223
- 'vtv1': 'vtv1', 'vtv2': 'vtv2', 'vtv3': 'vtv3', 'vtv4': 'vtv4',
224
- 'vtv5': 'vtv5', 'vtv6': 'vtv6', 'vtv7': 'vtv7', 'vtv8': 'vtv8',
225
- 'vtv9': 'vtv9', 'vtv10': 'vtv10', 'vtvprime': 'vtvprime',
226
- }
227
- epg_ch = epg_map.get(channel_id)
228
- if not epg_ch:
229
- return {"programs": [], "channel": channel_id, "date": ""}
230
-
231
- today = datetime.now(VN_TZ).strftime("%Y-%m-%d")
232
- cache_key = f"epg_{epg_ch}_{today}"
233
- cached = _cached(cache_key)
234
- if cached is not None:
235
- return cached
236
-
237
- programs = []
238
-
239
- try:
240
- h = {"User-Agent": UA["User-Agent"], "Accept-Language": "vi-VN,vi;q=0.9"}
241
-
242
- # Try channel-specific page first, then main
243
- ch_url = f"https://vtv.vn/lich-phat-song-{epg_ch}.htm"
244
- r = requests.get(ch_url, headers=h, timeout=10)
245
- r.encoding = 'utf-8'
246
-
247
- if r.status_code != 200:
248
- ch_url = "https://vtv.vn/lich-phat-song.htm"
249
- r = requests.get(ch_url, headers=h, timeout=10)
250
- r.encoding = 'utf-8'
251
-
252
- if r.status_code == 200:
253
- soup = BeautifulSoup(r.text, 'lxml')
254
-
255
- # Pattern 1: JSON data in <script>
256
- for script in soup.find_all('script'):
257
- text = script.string or ''
258
- if 'time' in text.lower() and ('title' in text.lower() or 'program' in text.lower()):
259
- for m in re.finditer(r'\[.*?\]', text, re.DOTALL):
260
- try:
261
- data = json.loads(m.group(0))
262
- if isinstance(data, list) and len(data) > 0:
263
- for item in data:
264
- if isinstance(item, dict) and 'time' in item:
265
- programs.append({
266
- 'time': item.get('time', ''),
267
- 'title': item.get('title', item.get('name', item.get('program', ''))),
268
- })
269
- except:
270
- pass
271
-
272
- # Pattern 2: Tables
273
- for table in soup.find_all('table'):
274
- for row in table.find_all('tr'):
275
- cells = row.find_all(['td', 'th'])
276
- if len(cells) >= 2:
277
- time_text = cells[0].get_text(strip=True)
278
- title_text = cells[1].get_text(strip=True)
279
- if re.match(r'^\d{1,2}:\d{2}', time_text) and len(title_text) >= 3:
280
- programs.append({'time': time_text[:5], 'title': title_text})
281
-
282
- # Pattern 3: Time-stamped text elements
283
- for el in soup.find_all(['div', 'li', 'p', 'span', 'td']):
284
- text = el.get_text(strip=True)
285
- tm = re.match(r'^(\d{1,2}:\d{2})\s*[-–—:]\s*(.+)', text)
286
- if tm and len(tm.group(2)) >= 3:
287
- programs.append({'time': tm.group(1), 'title': tm.group(2).strip()})
288
-
289
- # Pattern 4: Schedule blocks by class
290
- for cls_pattern in [r'schedule', r'program', r'lich', r'epg', r'timeline', r'table']:
291
- for el in soup.find_all(class_=re.compile(cls_pattern, re.I)):
292
- for item in el.find_all(['li', 'div', 'p']):
293
- text = item.get_text(strip=True)
294
- tm = re.match(r'^(\d{1,2}:\d{2})\s*[-–—:]\s*(.+)', text)
295
- if tm and len(tm.group(2)) >= 3:
296
- programs.append({'time': tm.group(1), 'title': tm.group(2).strip()})
297
-
298
- except Exception as e:
299
- print(f"[vtv_api] EPG error for {channel_id}: {e}")
300
-
301
- # Fallback: vtvgo.vn API
302
- if len(programs) < 3:
303
- try:
304
- api_url = f"https://vtvgo.vn/api/schedule?channel={epg_ch}&date={today}"
305
- r = requests.get(api_url, headers={"User-Agent": UA["User-Agent"]}, timeout=10)
306
- if r.status_code == 200:
307
- data = r.json()
308
- items = data if isinstance(data, list) else data.get('data', data.get('schedule', []))
309
- for item in items:
310
- if isinstance(item, dict):
311
- programs.append({
312
- 'time': item.get('time', item.get('start_time', ''))[:5],
313
- 'title': item.get('title', item.get('name', item.get('program', ''))),
314
- })
315
- except:
316
- pass
317
-
318
- # Deduplicate and sort
319
- seen = set()
320
- unique = []
321
- for p in programs:
322
- key = f"{p['time']}|{p['title']}"
323
- if key not in seen:
324
- seen.add(key)
325
- unique.append(p)
326
- unique.sort(key=lambda x: x['time'])
327
-
328
- result = {
329
- "programs": unique[:50],
330
- "channel": channel_id,
331
- "date": today,
332
- "timezone": "+10",
333
- }
334
- _set_cache(cache_key, result)
335
- return result
336
 
 
 
 
 
 
 
337
 
338
- # ===================== API ENDPOINTS =====================
 
339
 
340
  @router.get("/api/vtv/streams")
341
  def api_vtv_streams():
@@ -345,7 +276,6 @@ def api_vtv_streams():
345
  result[ch_id] = {"name": CHANNEL_NAMES[ch_id], "stream_url": stream_url, "status": "ok" if stream_url else "offline"}
346
  return JSONResponse(result)
347
 
348
-
349
  @router.get("/api/vtv/stream/{channel_id}")
350
  def api_vtv_stream(channel_id: str):
351
  stream_url = fetch_vtv_stream(channel_id)
@@ -353,64 +283,264 @@ def api_vtv_stream(channel_id: str):
353
  return JSONResponse({"stream_url": stream_url, "status": "ok"})
354
  return JSONResponse({"error": "stream not found", "status": "offline"}, status_code=404)
355
 
356
-
357
- @router.get("/api/vtv/epg/{channel_id}")
358
- def api_vtv_epg(channel_id: str):
359
- channel_id = channel_id.lower().strip()
360
- if channel_id not in CHANNEL_NAMES:
361
- channel_id = "vtv1"
362
- data = fetch_epg(channel_id)
363
- return JSONResponse(data)
364
-
365
-
366
- # ===== PROXY ENDPOINTS for VTV HLS streams =====
 
 
 
367
 
368
  @router.get("/api/proxy/m3u8/vtv")
369
- def proxy_m3u8_vtv(url: str = Query(...)):
370
  try:
371
- h = {"User-Agent": UA["User-Agent"], "Accept": "*/*", "Referer": "https://sv2.xemtivitop.com/"}
372
- r = requests.get(url, headers=h, timeout=15, verify=False)
 
 
 
 
 
 
 
373
  if r.status_code != 200:
374
  return Response(status_code=502, content="upstream error")
375
-
376
- base_url = url[:url.rfind('/')]
377
- lines = r.text.strip().split('\n')
378
  rewritten = []
 
379
  for line in lines:
380
- stripped = line.strip()
381
- if stripped.startswith('#') or not stripped:
382
  rewritten.append(line)
383
- elif stripped.startswith('http'):
384
- rewritten.append(f"/api/proxy/seg/vtv?url={urllib.parse.quote(stripped, safe='')}")
385
  else:
386
- seg_url = f"{base_url}/{stripped}"
387
- rewritten.append(f"/api/proxy/seg/vtv?url={urllib.parse.quote(seg_url, safe='')}")
388
-
389
- return Response(
390
- content='\n'.join(rewritten).encode('utf-8'),
391
- media_type="application/vnd.apple.mpegurl",
392
- headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=60"}
393
- )
394
  except Exception as e:
395
- return Response(status_code=502, content=f"proxy error: {e}")
396
-
397
 
398
  @router.get("/api/proxy/seg/vtv")
399
- def proxy_seg_vtv(url: str = Query(...)):
400
  try:
401
- h = {"User-Agent": UA["User-Agent"], "Accept": "*/*", "Referer": "https://sv2.xemtivitop.com/"}
402
- r = requests.get(url, headers=h, timeout=30, verify=False)
 
 
 
403
  if r.status_code != 200:
404
  return Response(status_code=502, content="upstream error")
405
-
406
  data = r.content
407
  if len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47:
408
  data = data[188:]
409
-
410
- return Response(
411
- content=data,
412
- media_type="video/mp2t",
413
- headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600"}
414
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
415
  except Exception as e:
416
- return Response(status_code=502, content=f"proxy error: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ VTV Channels API - Backend endpoints for VTV1-VTV10 + VTVPrime
3
+ Fetches stream URLs from xemtv.us PHP endpoints (primary)
4
+ Fallback: FPTPlay CDN → VTVGo CDN → xemtv.net (legacy)
5
+ EPG data scraped from https://vtv.vn/lich-phat-song.htm
6
+ """
7
+ import re, time, threading
8
+ import requests
9
  from fastapi import APIRouter, Query
10
  from fastapi.responses import JSONResponse, Response
11
  from bs4 import BeautifulSoup
12
  from datetime import datetime, timedelta, timezone
13
 
14
+ VN_TZ = timezone(timedelta(hours=7))
15
+
16
  router = APIRouter()
17
 
18
  UA = {
19
  "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",
20
  "Accept-Language": "vi-VN,vi;q=0.9",
 
 
 
 
 
21
  }
22
 
23
+ # ===== PRIMARY: xemtv.us (new domain, works 2025-2026) =====
24
+ XEMTV_US_ENDPOINTS = {
25
+ "vtv1": "https://xemtv.us/tv/vtv1.php",
26
+ "vtv2": "https://xemtv.us/tv/vtv2.php",
27
+ "vtv3": "https://xemtv.us/tv/vtv3.php",
28
+ "vtv4": "https://xemtv.us/tv/vtv4.php",
29
+ "vtv5": "https://xemtv.us/tv/vtv5.php",
30
+ "vtv6": "https://xemtv.us/tv/vtv6.php",
31
+ "vtv7": "https://xemtv.us/tv/vtv7.php",
32
+ "vtv8": "https://xemtv.us/tv/vtv8.php",
33
+ "vtv9": "https://xemtv.us/tv/vtv9.php",
34
+ "vtv10": "https://xemtv.us/tv/vtv10.php",
35
+ "vtvprime": "https://xemtv.us/tv/vtvprime.php",
36
+ }
37
+
38
+ # ===== LEGACY: xemtv.net (may return 403, keep as last resort) =====
39
+ XEMTV_LEGACY_ENDPOINTS = {
40
+ "vtv1": "https://hd.xemtv.net/kenh/vtv1.php",
41
+ "vtv2": "https://hd.xemtv.net/kenh/vtv2.php",
42
+ "vtv3": "https://hd.xemtv.net/kenh/vtv3.php",
43
+ "vtv4": "https://hd.xemtv.net/kenh/vtv4.php",
44
+ "vtv5": "https://hd.xemtv.net/kenh/vtv5.php",
45
+ "vtv6": "https://hd.xemtv.net/kenh/vtv6.php",
46
+ "vtv7": "https://hd.xemtv.net/kenh/vtv7.php",
47
+ "vtv8": "https://hd.xemtv.net/kenh/vtv8.php",
48
+ "vtv9": "https://hd.xemtv.net/kenh/vtv9.php",
49
+ "vtv10": "https://hd.xemtv.net/kenh/vtv10.php",
50
+ "vtvprime": "https://hd.xemtv.net/kenh/vtvprime.php",
51
  }
52
 
53
  CHANNEL_NAMES = {
54
+ "vtv1": "VTV1",
55
+ "vtv2": "VTV2",
56
+ "vtv3": "VTV3",
57
+ "vtv4": "VTV4",
58
+ "vtv5": "VTV5",
59
+ "vtv6": "VTV6",
60
+ "vtv7": "VTV7",
61
+ "vtv8": "VTV8",
62
+ "vtv9": "VTV9",
63
+ "vtv10": "VTV10",
64
+ "vtvprime": "VTVPrime",
65
+ }
66
+
67
+ # ===== FALLBACK 1: FPTPlay CDN (new URLs 2025-2026) =====
68
+ FPTPLAY_URLS = {
69
+ "vtv1": "https://live-a.fptplay53.net/live/media/vtv1/live247-hls-avc/index.m3u8",
70
+ "vtv2": "https://live-a.fptplay53.net/live/media/vtv2/live247-hls-avc/index.m3u8",
71
+ "vtv3": "https://live-a.fptplay53.net/live/media/vtv3/live247-hls-avc/index.m3u8",
72
+ "vtv4": "https://live-a.fptplay53.net/live/media/vtv4/live247-hls-avc/index.m3u8",
73
+ "vtv5": "https://live-a.fptplay53.net/live/media/vtv5/live247-hls-avc/index.m3u8",
74
+ "vtv6": "https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/index.m3u8",
75
+ "vtv7": "https://live-a.fptplay53.net/live/media/vtv7/live247-hls-avc/index.m3u8",
76
+ "vtv8": "https://live-a.fptplay53.net/live/media/vtv8/live-hls-avc/index.m3u8",
77
+ "vtv9": "https://live-a.fptplay53.net/live/media/vtv9/live247-hls-avc/index.m3u8",
78
+ "vtv10": "https://live-a.fptplay53.net/live/media/vtv10/live247-hls-avc/index.m3u8",
79
+ }
80
+
81
+ # ===== FALLBACK 2: VTVGo CDN =====
82
+ VTVGO_FAILOVER = {
83
+ "vtv1": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8",
84
+ "vtv2": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv2-manifest.m3u8",
85
+ "vtv3": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv3-manifest.m3u8",
86
+ "vtv4": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv4-manifest.m3u8",
87
+ "vtv5": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv5-manifest.m3u8",
88
+ "vtv6": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv6-manifest.m3u8",
89
+ "vtv7": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv7-manifest.m3u8",
90
+ "vtv8": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv8-manifest.m3u8",
91
+ "vtv9": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv9-manifest.m3u8",
92
  }
93
 
94
+ _vtv_cache = {}
95
+ _vtv_lock = threading.Lock()
96
+ _CACHE_TTL = 180
 
97
 
98
+ def _cached(key):
99
+ with _vtv_lock:
100
+ if key in _vtv_cache and time.time() - _vtv_cache[key]['t'] < _CACHE_TTL:
101
+ return _vtv_cache[key]['d']
102
  return None
 
 
 
103
 
104
+ def _set_cache(key, data):
105
+ with _vtv_lock:
106
+ _vtv_cache[key] = {'t': time.time(), 'd': data}
107
 
108
+ def extract_m3u8_from_html(html):
 
109
  if not html:
110
  return None
111
+ m = re.search(r"file\s*:\s*['\"]([^'\"]*\.m3u8[^'\"]*)['\"]", html, re.IGNORECASE)
112
+ if m:
 
 
 
 
 
 
 
 
 
113
  url = m.group(1).strip()
114
+ if len(url) > 20:
115
+ return url
116
+ m = re.search(r"(https?://[^\s\"'<>\\]+\.m3u8[^\s\"'<>\\]*)", html, re.IGNORECASE)
117
+ if m:
 
 
 
118
  url = m.group(1).strip()
119
+ if len(url) > 20:
120
+ return url
121
+ return None
122
+
123
+ def fetch_xemtv_us_stream(channel_id):
124
+ php_url = XEMTV_US_ENDPOINTS.get(channel_id)
125
+ if not php_url:
126
+ return None
 
 
 
 
 
 
 
 
 
127
  try:
128
+ headers = {**UA, "Referer": "https://xemtv.us/"}
129
+ r = requests.get(php_url, headers=headers, timeout=15, allow_redirects=True, verify=False)
130
+ if r.status_code == 200:
131
+ m3u8 = extract_m3u8_from_html(r.text)
132
+ if m3u8:
133
+ return m3u8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  except:
135
  pass
136
+ return None
137
+
138
+ def fetch_xemtv_legacy_stream(channel_id):
139
+ php_url = XEMTV_LEGACY_ENDPOINTS.get(channel_id)
140
+ if not php_url:
141
+ return None
142
  try:
143
+ headers = {**UA, "Referer": "https://hd.xemtv.net/"}
144
+ r = requests.get(php_url, headers=headers, timeout=15, allow_redirects=True, verify=False)
145
+ if r.status_code == 200:
146
+ m3u8 = extract_m3u8_from_html(r.text)
147
+ if m3u8:
148
+ return m3u8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  except:
150
  pass
151
+ return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
 
153
+ def fetch_fptplay_stream(channel_id):
154
+ url = FPTPLAY_URLS.get(channel_id)
155
+ if not url:
156
+ return None
157
+ try:
158
+ headers = {
159
+ "User-Agent": UA["User-Agent"],
160
+ "Referer": "https://fptplay.vn/",
161
+ "Origin": "https://fptplay.vn",
162
+ }
163
+ r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=False)
164
+ if r.status_code == 200 and '#EXTM3U' in r.text[:200]:
165
+ return url
166
+ except:
167
+ pass
168
+ return None
169
 
170
+ def fetch_vtvgo_stream(channel_id):
171
+ url = VTVGO_FAILOVER.get(channel_id)
172
  if not url:
173
  return None
174
  try:
175
+ headers = {**UA, "Referer": "https://vtvgo.vn/"}
176
+ r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=False)
177
+ if r.status_code == 200 and '#EXTM3U' in r.text[:200]:
 
 
178
  return url
179
  except:
180
  pass
181
  return None
182
 
183
+ def normalize_fptplay_url(url):
184
+ """Replace old/broken FPTPlay URLs with new working ones"""
185
+ if not url:
186
+ return url
187
+ old_to_new = {
188
+ "https://live.fptplay53.net/fnxch2/vtv1hd_abr.smil/chunklist.m3u8":
189
+ "https://live-a.fptplay53.net/live/media/vtv1/live247-hls-avc/index.m3u8",
190
+ "https://live.fptplay53.net/fnxch2/vtv2hd_abr.smil/chunklist.m3u8":
191
+ "https://live-a.fptplay53.net/live/media/vtv2/live247-hls-avc/index.m3u8",
192
+ "https://live.fptplay53.net/fnxch2/vtv3hd_abr.smil/chunklist.m3u8":
193
+ "https://live-a.fptplay53.net/live/media/vtv3/live247-hls-avc/index.m3u8",
194
+ "https://live.fptplay53.net/fnxch2/vtv4hd_abr.smil/chunklist.m3u8":
195
+ "https://live-a.fptplay53.net/live/media/vtv4/live247-hls-avc/index.m3u8",
196
+ "https://live.fptplay53.net/fnxhd1/vtv5hd_vhls.smil/chunklist.m3u8":
197
+ "https://live-a.fptplay53.net/live/media/vtv5/live247-hls-avc/index.m3u8",
198
+ "https://live.fptplay53.net/fnxhd1/vtv6hd_vhls.smil/chunklist.m3u8":
199
+ "https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/index.m3u8",
200
+ "https://live.fptplay53.net/fnxhd1/vtv7hd_vhls.smil/chunklist_b5000000.m3u8":
201
+ "https://live-a.fptplay53.net/live/media/vtv7/live247-hls-avc/index.m3u8",
202
+ "https://live.fptplay53.net/epzhd1/vtv8hd_vhls.smil/c.hunklist.m3u8":
203
+ "https://live-a.fptplay53.net/live/media/vtv8/live-hls-avc/index.m3u8",
204
+ "https://live.fptplay53.net/epzhd1/vtv8hd_vhls.smil/chunklist.m3u8":
205
+ "https://live-a.fptplay53.net/live/media/vtv8/live-hls-avc/index.m3u8",
206
+ "https://live.fptplay53.net/fnxhd1/vtv9hd_vhls.smil/chunklist.m3u8":
207
+ "https://live-a.fptplay53.net/live/media/vtv9/live247-hls-avc/index.m3u8",
208
+ "https://live.fptplay53.net/fnxhd1/vtv10hd_vhls.smil/chunklist.m3u8":
209
+ "https://live-a.fptplay53.net/live/media/vtv10/live247-hls-avc/index.m3u8",
210
+ "https://live-a.fptplay53.net/live/media/VTV5HD/live_hls_avc/index.m3u8":
211
+ "https://live-a.fptplay53.net/live/media/vtv5/live247-hls-avc/index.m3u8",
212
+ }
213
+ return old_to_new.get(url, url)
214
 
215
  def fetch_vtv_stream(channel_id):
216
+ """Fetch VTV stream with multi-source fallback chain:
217
+ 1. xemtv.us (primary - new domain, most reliable)
218
+ 2. FPTPlay CDN (fallback - new URLs)
219
+ 3. VTVGo CDN (fallback)
220
+ 4. xemtv.net legacy (last resort)
221
+ """
222
  channel_id = channel_id.lower().strip()
223
+ name_map = {
224
+ 'vtvct': 'vtv10', 'vtv-can-tho': 'vtv10', 'vtv can tho': 'vtv10',
225
+ 'vtv_can_tho': 'vtv10', 'cantho': 'vtv10',
226
+ 'vietnam_vtv1': 'vtv1', 'vietnam_vtv2': 'vtv2', 'vietnam_vtv3': 'vtv3',
227
+ 'vietnam_vtv4': 'vtv4', 'vietnam_vtv5': 'vtv5', 'vietnam_vtv6': 'vtv6',
228
+ 'vietnam_vtv7': 'vtv7', 'vietnam_vtv8': 'vtv8', 'vietnam_vtv9': 'vtv9',
229
+ }
230
+ channel_id = name_map.get(channel_id, channel_id)
231
  cached = _cached(channel_id)
232
  if cached is not None:
233
  return cached
234
 
235
+ if channel_id == 'vtvprime':
236
+ url = fetch_xemtv_us_stream('vtvprime') or fetch_xemtv_legacy_stream('vtvprime')
237
+ if url:
238
+ url = normalize_fptplay_url(url)
239
+ _set_cache(channel_id, url)
240
+ return url
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
 
242
+ # Source 1: xemtv.us (primary)
243
+ url = fetch_xemtv_us_stream(channel_id)
244
+ if url:
245
+ url = normalize_fptplay_url(url)
246
+ _set_cache(channel_id, url)
247
+ return url
248
 
249
+ # Source 2: FPTPlay CDN
250
+ url = fetch_fptplay_stream(channel_id)
251
+ if url:
252
+ _set_cache(channel_id, url)
253
+ return url
254
 
255
+ # Source 3: VTVGo CDN
256
+ url = fetch_vtvgo_stream(channel_id)
257
+ if url:
258
+ _set_cache(channel_id, url)
259
+ return url
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
 
261
+ # Source 4: xemtv.net legacy (last resort)
262
+ url = fetch_xemtv_legacy_stream(channel_id)
263
+ if url:
264
+ url = normalize_fptplay_url(url)
265
+ _set_cache(channel_id, url)
266
+ return url
267
 
268
+ _set_cache(channel_id, None)
269
+ return None
270
 
271
  @router.get("/api/vtv/streams")
272
  def api_vtv_streams():
 
276
  result[ch_id] = {"name": CHANNEL_NAMES[ch_id], "stream_url": stream_url, "status": "ok" if stream_url else "offline"}
277
  return JSONResponse(result)
278
 
 
279
  @router.get("/api/vtv/stream/{channel_id}")
280
  def api_vtv_stream(channel_id: str):
281
  stream_url = fetch_vtv_stream(channel_id)
 
283
  return JSONResponse({"stream_url": stream_url, "status": "ok"})
284
  return JSONResponse({"error": "stream not found", "status": "offline"}, status_code=404)
285
 
286
+ @router.get("/api/proxy/page")
287
+ def proxy_page(url: str = Query(...)):
288
+ try:
289
+ headers = {**UA}
290
+ if "xemtv.us" in url:
291
+ headers["Referer"] = "https://xemtv.us/"
292
+ elif "xemtv.net" in url:
293
+ headers["Referer"] = "https://hd.xemtv.net/"
294
+ r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=False)
295
+ if r.status_code != 200:
296
+ return Response(status_code=502, content="upstream error")
297
+ return Response(content=r.text.encode("utf-8"), media_type="text/html; charset=utf-8", headers={"Access-Control-Allow-Origin": "*"})
298
+ except:
299
+ return Response(status_code=502, content="proxy error")
300
 
301
  @router.get("/api/proxy/m3u8/vtv")
302
+ def proxy_vtv_m3u8(url: str = Query(...)):
303
  try:
304
+ headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
305
+ if "fptplay" in url:
306
+ headers["Referer"] = "https://fptplay.vn/"
307
+ headers["Origin"] = "https://fptplay.vn"
308
+ elif "xemtv" in url:
309
+ headers["Referer"] = "https://xemtv.us/"
310
+ elif "vtvgo" in url or "vtvdigital" in url:
311
+ headers["Referer"] = "https://vtvgo.vn/"
312
+ r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=False)
313
  if r.status_code != 200:
314
  return Response(status_code=502, content="upstream error")
315
+ content = r.text
316
+ lines = content.split('\n')
 
317
  rewritten = []
318
+ base_url = url.rsplit('/', 1)[0] + '/'
319
  for line in lines:
320
+ line = line.strip()
321
+ if not line or line.startswith('#'):
322
  rewritten.append(line)
 
 
323
  else:
324
+ seg_url = line
325
+ if not seg_url.startswith('http'):
326
+ seg_url = base_url + seg_url
327
+ if seg_url.endswith('.m3u8'):
328
+ rewritten.append("/api/proxy/m3u8/vtv?url=" + requests.utils.quote(seg_url, safe=""))
329
+ else:
330
+ rewritten.append("/api/proxy/seg/vtv?url=" + requests.utils.quote(seg_url, safe=""))
331
+ return Response(content='\n'.join(rewritten).encode("utf-8"), media_type="application/vnd.apple.mpegurl", headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "no-cache"})
332
  except Exception as e:
333
+ return Response(status_code=502, content="proxy error: " + str(e))
 
334
 
335
  @router.get("/api/proxy/seg/vtv")
336
+ def proxy_vtv_segment(url: str = Query(...)):
337
  try:
338
+ headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
339
+ if "fptplay" in url:
340
+ headers["Referer"] = "https://fptplay.vn/"
341
+ headers["Origin"] = "https://fptplay.vn"
342
+ r = requests.get(url, headers=headers, timeout=30, allow_redirects=True, verify=False)
343
  if r.status_code != 200:
344
  return Response(status_code=502, content="upstream error")
 
345
  data = r.content
346
  if len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47:
347
  data = data[188:]
348
+ return Response(content=data, media_type="video/mp2t", headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600"})
349
+ except:
350
+ return Response(status_code=502, content="proxy error")
351
+
352
+ _epg_cache = {}
353
+ _epg_cache_time = 0
354
+ _EPG_CACHE_TTL = 600 # Giảm từ 30 phút xuống 10 phút, đảm bảo dữ liệu mới
355
+
356
+ VTV_CHANNEL_MAP = {
357
+ "vtv1": "vtv1", "vtv2": "vtv2", "vtv3": "vtv3", "vtv4": "vtv4",
358
+ "vtv5": "vtv5", "vtv5-tay-nam-bo": "vtv5", "vtv5-tay-nguyen": "vtv5",
359
+ "vtv7": "vtv7", "vtv8": "vtv8", "vtv6": "vtv6", "vtv9": "vtv9",
360
+ "vtv-can-tho": "vtv10",
361
+ }
362
+
363
+ def _fetch_epg_from_vtv():
364
+ global _epg_cache, _epg_cache_time
365
+ now_ts = time.time()
366
+ if _epg_cache and now_ts - _epg_cache_time < _EPG_CACHE_TTL:
367
+ return _epg_cache
368
+ epg_data = {}
369
+ # Lấy thời gian VN hiện tại để truyền vào parse_time
370
+ now_vn = datetime.now(VN_TZ)
371
+ try:
372
+ headers = {
373
+ "User-Agent": UA["User-Agent"], "Accept-Language": "vi-VN,vi;q=0.9",
374
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
375
+ "Referer": "https://vtv.vn/",
376
+ }
377
+ r = requests.get("https://vtv.vn/lich-phat-song.htm", headers=headers, timeout=20)
378
+ if r.status_code != 200:
379
+ return epg_data
380
+ r.encoding = "utf-8"
381
+ soup = BeautifulSoup(r.text, "lxml")
382
+ channel_order = []
383
+ list_channel = soup.find(class_=re.compile(r'list-channel'))
384
+ if list_channel:
385
+ for link in list_channel.find_all('a', href=re.compile(r'truyen-hinh-truc-tuyen/([^.]+)\.htm')):
386
+ ch_id = re.search(r'truyen-hinh-truc-tuyen/([^.]+)\.htm', link.get('href', ''))
387
+ if ch_id:
388
+ channel_order.append(ch_id.group(1))
389
+ if not channel_order:
390
+ for link in soup.find_all('a', href=re.compile(r'truyen-hinh-truc-tuyen/([^.]+)\.htm')):
391
+ ch_id = re.search(r'truyen-hinh-truc-tuyen/([^.]+)\.htm', link.get('href', ''))
392
+ if ch_id and ch_id.group(1) not in channel_order:
393
+ channel_order.append(ch_id.group(1))
394
+ prog_containers = soup.find_all('ul', class_=re.compile(r'\bprograms\b'))
395
+ for i, container in enumerate(prog_containers):
396
+ if i >= len(channel_order):
397
+ break
398
+ vtv_ch_id = channel_order[i]
399
+ our_ch_id = VTV_CHANNEL_MAP.get(vtv_ch_id, vtv_ch_id)
400
+ if our_ch_id not in epg_data:
401
+ epg_data[our_ch_id] = []
402
+ for li in container.find_all('li', class_=re.compile(r'\bprogram\b')):
403
+ time_span = li.find('span', class_=re.compile(r'\btime\b'))
404
+ title_span = li.find('span', class_=re.compile(r'\btitle\b'))
405
+ genre_a = li.find('a', class_=re.compile(r'\bgenre\b'))
406
+ time_str = time_span.get_text(strip=True) if time_span else ""
407
+ title = ""
408
+ if genre_a:
409
+ title = genre_a.get_text(strip=True)
410
+ if not title and title_span:
411
+ title = title_span.get_text(strip=True)
412
+ if not time_str or not title:
413
+ continue
414
+ # Truyền reference_date để xử lý quy tắc ngày truyền hình VTV
415
+ start_dt = _parse_time(time_str, reference_date=now_vn)
416
+ if not start_dt:
417
+ continue
418
+ epg_data[our_ch_id].append({"time": time_str[:5], "title": title[:80], "start_dt": start_dt})
419
+ for ch_id in epg_data:
420
+ epg_data[ch_id].sort(key=lambda x: x.get("start_dt") or datetime.min)
421
+ seen = set()
422
+ unique = []
423
+ for p in epg_data[ch_id]:
424
+ key = (p["time"], p["title"])
425
+ if key not in seen:
426
+ seen.add(key)
427
+ unique.append(p)
428
+ epg_data[ch_id] = unique
429
  except Exception as e:
430
+ print(f"EPG vtv.vn error: {e}")
431
+ _epg_cache = epg_data
432
+ _epg_cache_time = now_ts
433
+ return epg_data
434
+
435
+ def _parse_time(time_str, reference_date=None):
436
+ """
437
+ Parse giờ từ lịch phát sóng VTV (đã là giờ VN UTC+7) sang datetime có timezone.
438
+
439
+ VTV hiển thị lịch theo ngày dương lịch (không phải ngày truyền hình).
440
+ Ví dụ: Lịch ngày 17/06 sẽ hiển thị tất cả chương trình từ 00:00 đến 23:59 ngày 17/06.
441
+
442
+ Logic:
443
+ - Giờ 00:00-04:59: Có thể là đêm khuya của ngày hôm trước HOẶC sáng sớm của ngày mới
444
+ - Giờ 05:00-23:59: Luôn thuộc ngày hiện tại
445
+ """
446
+ if not time_str:
447
+ return None
448
+ time_str = time_str.strip().replace("h", ":").replace("H", ":")
449
+ m = re.search(r'(\d{1,2}):(\d{2})', time_str)
450
+ if m:
451
+ try:
452
+ hour, minute = int(m.group(1)), int(m.group(2))
453
+
454
+ if reference_date:
455
+ base_date = reference_date
456
+ else:
457
+ # Lấy ngày hiện tại theo giờ VN (UTC+7)
458
+ now_vn = datetime.now(VN_TZ)
459
+ base_date = now_vn
460
+
461
+ from datetime import timedelta
462
+
463
+ # Xác định ngày cho giờ program
464
+ if hour < 5:
465
+ # Giờ 00:00-04:59: Cần xem giờ hiện tại để quyết định
466
+ if base_date.hour < 5:
467
+ # Nếu hiện tại cũng < 5:00 (đang trong khoảng sáng sớm)
468
+ # → Giờ program thuộc CÙNG NGÀY với giờ hiện tại
469
+ tv_date = base_date
470
+ else:
471
+ # Nếu hiện tại >= 5:00 (đã qua sáng sớm)
472
+ # → Giờ 00:00-04:59 thuộc NGÀY HÔM TRƯỚC
473
+ tv_date = base_date - timedelta(days=1)
474
+ else:
475
+ # Giờ 05:00-23:59: Luôn thuộc ngày hiện tại
476
+ tv_date = base_date
477
+
478
+ # Tạo datetime với giờ từ lịch
479
+ result = tv_date.replace(hour=hour, minute=minute, second=0, microsecond=0)
480
+
481
+ # Đảm bảo result có timezone
482
+ if result.tzinfo is None:
483
+ result = result.replace(tzinfo=VN_TZ)
484
+ return result
485
+ except:
486
+ pass
487
+ return None
488
+
489
+ def _get_epg_for_channel(channel_id):
490
+ epg_data = _fetch_epg_from_vtv()
491
+ programmes = epg_data.get(channel_id, [])
492
+ if not programmes:
493
+ return []
494
+ now = datetime.now(VN_TZ)
495
+ today = now.date()
496
+ result = []
497
+
498
+ # Lọc chỉ lấy programs của ngày hôm nay (theo lịch VTV)
499
+ today_programmes = []
500
+ for p in programmes:
501
+ start_dt = p.get("start_dt")
502
+ if start_dt and start_dt.date() == today:
503
+ today_programmes.append(p)
504
+
505
+ # Nếu không có programs cho hôm nay, dùng tất cả (fallback)
506
+ if not today_programmes:
507
+ today_programmes = programmes
508
+
509
+ for i, p in enumerate(today_programmes):
510
+ start_dt = p.get("start_dt")
511
+ stop_dt = None
512
+ if i + 1 < len(today_programmes):
513
+ stop_dt = today_programmes[i + 1].get("start_dt")
514
+ is_now = False
515
+ if start_dt:
516
+ if stop_dt:
517
+ is_now = start_dt <= now < stop_dt
518
+ else:
519
+ is_now = start_dt <= now
520
+ end_time = ""
521
+ if stop_dt:
522
+ end_time = stop_dt.strftime("%H:%M")
523
+ result.append({"time": p["time"], "title": p["title"], "end_time": end_time, "now": is_now})
524
+ return result
525
+
526
+ @router.get("/api/vtv/epg/{channel_id}")
527
+ def api_vtv_epg(channel_id: str):
528
+ channel_id = channel_id.lower().strip()
529
+ if channel_id not in CHANNEL_NAMES:
530
+ return JSONResponse({"error": "channel not found"}, status_code=404)
531
+ programs = _get_epg_for_channel(channel_id)
532
+ return JSONResponse({
533
+ "channel": channel_id, "channel_name": CHANNEL_NAMES.get(channel_id, channel_id),
534
+ "date": datetime.now(VN_TZ).strftime("%Y-%m-%d"), "programs": programs,
535
+ })
536
+
537
+ @router.get("/api/vtv/epg")
538
+ def api_vtv_epg_refresh():
539
+ global _epg_cache, _epg_cache_time
540
+ _epg_cache = {}
541
+ _epg_cache_time = 0
542
+ epg_data = _fetch_epg_from_vtv()
543
+ return JSONResponse({
544
+ "status": "refreshed", "channels": len(epg_data),
545
+ "total_programmes": sum(len(v) for v in epg_data),
546
+ })
vtv_scraper.py CHANGED
@@ -1,11 +1,8 @@
1
  """
2
- VTV Channels Scraper - Optimized for stable streaming
3
- Fetches stream URLs from multiple CDN sources for VTV1-VTV10 + VTV Cần Thơ
4
  """
5
  import requests, re, time, threading
6
- from datetime import datetime, timedelta, timezone
7
-
8
- VN_TZ = timezone(timedelta(hours=17))
9
 
10
  UA = {
11
  "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
@@ -13,9 +10,6 @@ UA = {
13
  "Referer": "https://hd.xemtv.net/",
14
  }
15
 
16
- # ===== PRIMARY CDN SOURCES (Optimized for stability) =====
17
- # Priority order: FPTPlay > VTVGo > MediaCDN
18
-
19
  XEMTV_PHP_ENDPOINTS = {
20
  "vtv1": "https://hd.xemtv.net/kenh/vtv1.php",
21
  "vtv2": "https://hd.xemtv.net/kenh/vtv2.php",
@@ -32,34 +26,30 @@ XEMTV_PHP_ENDPOINTS = {
32
  CHANNEL_NAMES = {
33
  "vtv1": "VTV1", "vtv2": "VTV2", "vtv3": "VTV3", "vtv4": "VTV4",
34
  "vtv5": "VTV5", "vtv6": "VTV6", "vtv7": "VTV7", "vtv8": "VTV8",
35
- "vtv9": "VTV9", "vtv10": "VTV10",
36
  }
37
 
38
- # ===== RELIABLE CDN BACKUPS (verified working URLs) =====
39
- CDN_STREAMS = {
40
- # FPTPlay - Primary (most stable)
41
- "vtv1": "https://live-a.fptplay53.net/live/media/vtv1/live247-hls-avc/index.m3u8",
42
- "vtv2": "https://live-a.fptplay53.net/live/media/vtv2/live247-hls-avc/index.m3u8",
43
- "vtv3": "https://live-a.fptplay53.net/live/media/vtv3/live247-hls-avc/index.m3u8",
44
- "vtv4": "https://live-a.fptplay53.net/live/media/vtv4/live247-hls-avc/index.m3u8",
45
- "vtv5": "https://live-a.fptplay53.net/live/media/vtv5/live247-hls-avc/index.m3u8",
46
- "vtv6": "https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/index.m3u8",
47
- "vtv7": "https://live-a.fptplay53.net/live/media/vtv7/live247-hls-avc/index.m3u8",
48
- "vtv8": "https://live-a.fptplay53.net/live/media/vtv8/live-hls-avc/index.m3u8",
49
- "vtv9": "https://live-a.fptplay53.net/live/media/vtv9/live247-hls-avc/index.m3u8",
50
- "vtv10": "https://live-a.fptplay53.net/live/media/vtv10/live247-hls-avc/index.m3u8",
51
-
52
- # VTVGo Failover
53
- "vtv1_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8",
54
- "vtv2_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv2-manifest.m3u8",
55
- "vtv3_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv3-manifest.m3u8",
56
- "vtv4_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv4-manifest.m3u8",
57
- "vtv5_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv5-manifest.m3u8",
58
- "vtv6_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv6-manifest.m3u8",
59
- "vtv7_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv7-manifest.m3u8",
60
- "vtv8_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv8-manifest.m3u8",
61
- "vtv9_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv9-manifest.m3u8",
62
- "vtv10_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv10-manifest.m3u8",
63
  }
64
 
65
  _vtv_cache = {}
@@ -77,34 +67,22 @@ def _set_cache(key, data):
77
  _vtv_cache[key] = {'t': time.time(), 'd': data}
78
 
79
  def extract_m3u8_from_html(html):
80
- if not html: return None
81
- # Look for file: "..." pattern
82
  m = re.search(r"file\s*:\s*['\"]([^'\"]*\.m3u8[^'\"]*)['\"]", html, re.IGNORECASE)
83
  if m:
84
  url = m.group(1).strip()
85
- if len(url) > 20: return url
86
- # Look for direct m3u8 URL
87
  m = re.search(r"(https?://[^\s\"'<>\\]+\.m3u8[^\s\"'<>\\]*)", html, re.IGNORECASE)
88
  if m:
89
  url = m.group(1).strip()
90
- if len(url) > 20: return url
91
- return None
92
-
93
- def verify_cdn(url, referer="", timeout=8):
94
- """Quick verify CDN is working"""
95
- if not url: return None
96
- try:
97
- r = requests.get(url, headers={"User-Agent": UA["User-Agent"], "Referer": referer}, timeout=timeout, allow_redirects=True, verify=False)
98
- if r.status_code == 200 and '#EXTM3U' in r.text[:500]:
99
  return url
100
- except: pass
101
  return None
102
 
103
  def fetch_vtv_stream(channel_id):
104
- """Fetch VTV stream with priority order for maximum stability"""
105
  channel_id = channel_id.lower().strip()
106
-
107
- # Normalize channel ID
108
  name_map = {
109
  'vtvct': 'vtv10', 'vtv-can-tho': 'vtv10', 'vtv can tho': 'vtv10',
110
  'vtv_can_tho': 'vtv10', 'cantho': 'vtv10', 'cần thơ': 'vtv10',
@@ -113,35 +91,41 @@ def fetch_vtv_stream(channel_id):
113
  'vietnam_vtv7': 'vtv7', 'vietnam_vtv8': 'vtv8', 'vietnam_vtv9': 'vtv9',
114
  }
115
  channel_id = name_map.get(channel_id, channel_id)
116
-
117
- # Try direct CDN URLs first (most stable)
118
- if channel_id in CDN_STREAMS:
119
- return CDN_STREAMS[channel_id]
120
-
121
- # Try PHP endpoints as fallback
 
122
  php_url = XEMTV_PHP_ENDPOINTS.get(channel_id)
123
  if php_url:
124
  try:
125
  r = requests.get(php_url, headers=UA, timeout=15, allow_redirects=True, verify=False)
126
  if r.status_code == 200:
127
  m3u8 = extract_m3u8_from_html(r.text)
128
- if m3u8: return m3u8
129
- except: pass
130
-
131
- # Try failover backup
132
- fb_key = f"{channel_id}_fb"
133
- if fb_key in CDN_STREAMS:
134
- return CDN_STREAMS[fb_key]
135
-
 
 
136
  return None
137
 
138
  def get_all_vtv_streams():
139
  channels = []
140
- for ch_id in CHANNEL_NAMES:
141
  stream_url = fetch_vtv_stream(ch_id)
142
  channels.append({
143
  'id': ch_id,
144
  'name': CHANNEL_NAMES.get(ch_id, ch_id.upper()),
145
  'stream_url': stream_url,
146
  })
147
- return channels
 
 
 
 
1
  """
2
+ VTV Channels Scraper
3
+ Fetches stream URLs from hd.xemtv.net PHP endpoints for VTV1-VTV10 + VTV Cần Thơ
4
  """
5
  import requests, re, time, threading
 
 
 
6
 
7
  UA = {
8
  "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
 
10
  "Referer": "https://hd.xemtv.net/",
11
  }
12
 
 
 
 
13
  XEMTV_PHP_ENDPOINTS = {
14
  "vtv1": "https://hd.xemtv.net/kenh/vtv1.php",
15
  "vtv2": "https://hd.xemtv.net/kenh/vtv2.php",
 
26
  CHANNEL_NAMES = {
27
  "vtv1": "VTV1", "vtv2": "VTV2", "vtv3": "VTV3", "vtv4": "VTV4",
28
  "vtv5": "VTV5", "vtv6": "VTV6", "vtv7": "VTV7", "vtv8": "VTV8",
29
+ "vtv9": "VTV9", "vtv10": "VTV Cần Thơ",
30
  }
31
 
32
+ CDN_FALLBACK = {
33
+ "vtv1": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8",
34
+ "vtv2": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv2-manifest.m3u8",
35
+ "vtv3": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv3-manifest.m3u8",
36
+ "vtv4": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv4-manifest.m3u8",
37
+ "vtv5": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv5-manifest.m3u8",
38
+ "vtv6": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv6-manifest.m3u8",
39
+ "vtv7": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv7-manifest.m3u8",
40
+ "vtv8": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv8-manifest.m3u8",
41
+ "vtv9": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv9-manifest.m3u8",
42
+ "vtv10": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv10-manifest.m3u8",
43
+ "_fpt_vtv1": "https://live.fptplay53.net/fnxch2/vtv1hd_abr.smil/chunklist.m3u8",
44
+ "_fpt_vtv2": "https://live.fptplay53.net/fnxch2/vtv2hd_abr.smil/chunklist.m3u8",
45
+ "_fpt_vtv3": "https://live.fptplay53.net/fnxch2/vtv3hd_abr.smil/chunklist.m3u8",
46
+ "_fpt_vtv4": "https://live.fptplay53.net/fnxch2/vtv4hd_abr.smil/chunklist.m3u8",
47
+ "_fpt_vtv5": "https://live-a.fptplay53.net/live/media/VTV5HD/live_hls_avc/index.m3u8",
48
+ "_fpt_vtv6": "https://live.fptplay53.net/fnxch2/vtv6hd_abr.smil/chunklist.m3u8",
49
+ "_fpt_vtv7": "https://live.fptplay53.net/fnxhd1/vtv7hd_vhls.smil/chunklist_b5000000.m3u8",
50
+ "_fpt_vtv8": "https://live.fptplay53.net/epzhd1/vtv8hd_vhls.smil/chunklist.m3u8",
51
+ "_fpt_vtv9": "https://live.fptplay53.net/fnxhd1/vtv9hd_vhls.smil/chunklist.m3u8",
52
+ "_fpt_vtv10": "https://live.fptplay53.net/fnxch2/vtvcantho_abr.smil/chunklist.m3u8",
 
 
 
 
53
  }
54
 
55
  _vtv_cache = {}
 
67
  _vtv_cache[key] = {'t': time.time(), 'd': data}
68
 
69
  def extract_m3u8_from_html(html):
70
+ if not html:
71
+ return None
72
  m = re.search(r"file\s*:\s*['\"]([^'\"]*\.m3u8[^'\"]*)['\"]", html, re.IGNORECASE)
73
  if m:
74
  url = m.group(1).strip()
75
+ if len(url) > 20:
76
+ return url
77
  m = re.search(r"(https?://[^\s\"'<>\\]+\.m3u8[^\s\"'<>\\]*)", html, re.IGNORECASE)
78
  if m:
79
  url = m.group(1).strip()
80
+ if len(url) > 20:
 
 
 
 
 
 
 
 
81
  return url
 
82
  return None
83
 
84
  def fetch_vtv_stream(channel_id):
 
85
  channel_id = channel_id.lower().strip()
 
 
86
  name_map = {
87
  'vtvct': 'vtv10', 'vtv-can-tho': 'vtv10', 'vtv can tho': 'vtv10',
88
  'vtv_can_tho': 'vtv10', 'cantho': 'vtv10', 'cần thơ': 'vtv10',
 
91
  'vietnam_vtv7': 'vtv7', 'vietnam_vtv8': 'vtv8', 'vietnam_vtv9': 'vtv9',
92
  }
93
  channel_id = name_map.get(channel_id, channel_id)
94
+ cached = _cached(channel_id)
95
+ if cached:
96
+ return cached
97
+ vtvgourl = CDN_FALLBACK.get(channel_id)
98
+ if vtvgourl:
99
+ _set_cache(channel_id, vtvgourl)
100
+ return vtvgourl
101
  php_url = XEMTV_PHP_ENDPOINTS.get(channel_id)
102
  if php_url:
103
  try:
104
  r = requests.get(php_url, headers=UA, timeout=15, allow_redirects=True, verify=False)
105
  if r.status_code == 200:
106
  m3u8 = extract_m3u8_from_html(r.text)
107
+ if m3u8:
108
+ _set_cache(channel_id, m3u8)
109
+ return m3u8
110
+ except:
111
+ pass
112
+ fpt_key = f"_fpt_{channel_id}"
113
+ fpt_url = CDN_FALLBACK.get(fpt_key)
114
+ if fpt_url:
115
+ _set_cache(channel_id, fpt_url)
116
+ return fpt_url
117
  return None
118
 
119
  def get_all_vtv_streams():
120
  channels = []
121
+ for ch_id, php_url in XEMTV_PHP_ENDPOINTS.items():
122
  stream_url = fetch_vtv_stream(ch_id)
123
  channels.append({
124
  'id': ch_id,
125
  'name': CHANNEL_NAMES.get(ch_id, ch_id.upper()),
126
  'stream_url': stream_url,
127
  })
128
+ return channels
129
+
130
+ XEMTV_CHANNELS = {v: k for k, v in CHANNEL_NAMES.items()}
131
+ CDN_STREAMS = {v: k for k, v in CDN_FALLBACK.items()}
yt_scraper_fixed.py DELETED
@@ -1,162 +0,0 @@
1
- """
2
- YouTube Shorts Scraper using yt-dlp (already installed on Space)
3
- Optimized for fast load with long cache + fallback
4
- """
5
- import subprocess
6
- import json
7
- import time
8
- import threading
9
- import os
10
- import re as re_mod
11
-
12
- _cache = {}
13
- _lock = threading.Lock()
14
- CACHE_TTL = 1800 # 30 min cache - longer to reduce timeout issues
15
-
16
- def _get_cached(key):
17
- """Get cached data if still valid"""
18
- with _lock:
19
- if key in _cache:
20
- entry = _cache[key]
21
- if time.time() - entry['t'] < CACHE_TTL:
22
- return entry['d']
23
- return None
24
-
25
- def _set_cached(key, data):
26
- """Set cache with timestamp"""
27
- with _lock:
28
- _cache[key] = {'t': time.time(), 'd': data}
29
-
30
- def run_yt_dlp(args, timeout=45):
31
- """Run yt-dlp and return parsed JSON lines - with shorter timeout"""
32
- try:
33
- result = subprocess.run(
34
- ["yt-dlp"] + args,
35
- capture_output=True, text=True, timeout=timeout
36
- )
37
- if result.returncode != 0 and not result.stdout.strip():
38
- return []
39
- lines = result.stdout.strip().split('\n')
40
- items = []
41
- for line in lines:
42
- line = line.strip()
43
- if not line:
44
- continue
45
- try:
46
- items.append(json.loads(line))
47
- except json.JSONDecodeError:
48
- continue
49
- return items
50
- except subprocess.TimeoutExpired:
51
- print("yt-dlp timeout (this is OK - using fallback)")
52
- return []
53
- except FileNotFoundError:
54
- print("yt-dlp not found - using fallback")
55
- return []
56
- except Exception as e:
57
- print(f"yt-dlp exception: {e}")
58
- return []
59
-
60
- def get_channel_shorts_fast(channel_username, max_count=25):
61
- """Get shorts fast - prioritize /shorts page only to avoid timeout"""
62
- shorts = []
63
-
64
- url = f"https://www.youtube.com/@{channel_username}/shorts"
65
- items = run_yt_dlp([
66
- "--dump-json",
67
- "--flat-playlist",
68
- "--no-download",
69
- "--playlist-end", str(max_count),
70
- "--no-check-certificates",
71
- "--quiet", # Reduce output for speed
72
- "--no-warnings",
73
- url
74
- ], timeout=35) # Increased timeout but still reasonable
75
-
76
- seen_ids = set()
77
- for item in items:
78
- vid = item.get('id', '')
79
- if not vid or vid in seen_ids:
80
- continue
81
- seen_ids.add(vid)
82
-
83
- title = item.get('title', f'{channel_username} Short')
84
-
85
- shorts.append({
86
- 'id': vid,
87
- 'title': title,
88
- 'channel': channel_username,
89
- 'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg",
90
- })
91
-
92
- if len(shorts) >= max_count:
93
- break
94
-
95
- return shorts
96
-
97
- def get_dantri_shorts(max_count=25):
98
- """Get Dantri shorts - fast, no fallback needed, cache for 30min"""
99
- cached = _get_cached('dantri_shorts_yt')
100
- if cached is not None:
101
- return cached
102
-
103
- shorts = get_channel_shorts_fast('baodantri7941', max_count)
104
-
105
- if shorts:
106
- _set_cached('dantri_shorts_yt', shorts)
107
- return shorts
108
-
109
- # Fallback to static list if scrape fails
110
- return [
111
- {"id":"Lu_iCQ5YwNM","title":"Công an lập hồ sơ xử lý người phụ nữ chửi bới tát nam tài xế ô tô ở Hà Nội","channel":"baodantri7941"},
112
- {"id":"CwWvijF8BOA","title":"Chú rể Ninh Bình bật khóc nhận món quà bí mật người cha","channel":"baodantri7941"},
113
- ]
114
-
115
- def get_skds_shorts(max_count=25):
116
- """Get SKĐS shorts - fast, no fallback needed, cache for 30min"""
117
- cached = _get_cached('skds_shorts_yt')
118
- if cached is not None:
119
- return cached
120
-
121
- shorts = get_channel_shorts_fast('baosuckhoedoisongboyte', max_count)
122
-
123
- if shorts:
124
- _set_cached('skds_shorts_yt', shorts)
125
- return shorts
126
-
127
- # Fallback to static list if scrape fails
128
- return [
129
- {"id":"7Pd6vZ2Lz1M","title":"Hành động ấm lòng của người đàn ông tìm kiếm 5 học sinh tử vong","channel":"baosuckhoedoisongboyte"},
130
- {"id":"SlHLt_ZyPiE","title":"Xử phạt người đàn ông xóa số điện thoại cứu hộ trên cao tốc Bắc Nam","channel":"baosuckhoedoisongboyte"},
131
- ]
132
-
133
- def get_dantri_skds_shorts(max_count=50):
134
- """Get interleaved Dantri + SKĐS shorts - optimized with separate caching"""
135
- # Get each channel's shorts separately (allows partial fallback)
136
- dantri = get_dantri_shorts(max_count // 2 + 10)
137
- skds = get_skds_shorts(max_count // 2 + 10)
138
-
139
- # Interleave them
140
- result = []
141
- seen = set()
142
- i, j = 0, 0
143
-
144
- while (i < len(dantri) or j < len(skds)) and len(result) < max_count:
145
- if i < len(dantri):
146
- item = dantri[i]
147
- if item.get('id') not in seen:
148
- seen.add(item.get('id'))
149
- result.append(item)
150
- i += 1
151
- if j < len(skds):
152
- item = skds[j]
153
- if item.get('id') not in seen:
154
- seen.add(item.get('id'))
155
- result.append(item)
156
- j += 1
157
-
158
- return result
159
-
160
- # For backward compatibility
161
- get_vtvnambo_shorts = get_dantri_skds_shorts
162
- get_wc_related_shorts = get_dantri_skds_shorts