Fix VTV6 stream + fix click tabs vtv2/vtv3/vtv9

#34
This view is limited to 50 files because it contains too many changes. See the raw diff here.
.dockerignore DELETED
File without changes
.gitignore DELETED
@@ -1,6 +0,0 @@
1
- __pycache__/
2
- *.pyc
3
- data/
4
- .data
5
- .huggingface/
6
- .restart_trigger
 
 
 
 
 
 
 
.huggingface/rebuild ADDED
@@ -0,0 +1 @@
 
 
1
+ rebuild vtv_api.py
.rebuild ADDED
@@ -0,0 +1 @@
 
 
1
+ rebuild_vtv_fix_20260706_v3
.restart_trigger ADDED
@@ -0,0 +1 @@
 
 
1
+ rebuild 1782693789.5665495 - force rebuild after voice+thumbnail fixes
CHANGELOG.md CHANGED
@@ -1,16 +1,71 @@
1
- # FPT Play Stream Selector Update
2
 
3
- Added FPT Play channel with stream selector UI similar to VTV6:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
- - New tab "FPT" (orange themed) in the channel tabs
6
- - Stream selector with 4 sources:
7
- 1. 🌐 Web FPT Play (iframe)
8
- 2. 📡 HLS Proxy (via /api/proxy/m3u8)
9
- 3. 🔗 HLS Direct
10
- 4. 📺 HD1.xemtv.net (iframe from LINK 1)
11
- - Backend vtv_api.py now returns stream_selectors for fpt-the-thao channel
12
- - Frontend handles stream switching automatically when FPT tab is active
13
 
14
  ## Changes
15
- - `static/vtv_init.js`: Added FPT tab + stream selector UI logic
16
- - `vtv_api.py`: Added FPT Play endpoint responses with stream_selectors data
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # VNEWS v6.5 - Resilient Shorts Auto-Updater
2
 
3
+ ## Changes
4
+
5
+ ### Critical Fix: Shorts timeout and homepage load stability
6
+ **Root cause**: YouTube shorts fetching in `main.py` using `scrape_shorts()` and `_yt_channel_shorts_requests()` could hang indefinitely when YouTube blocks requests or yt-dlp times out, causing:
7
+ - Homepage `/api/shorts` endpoint to time out (30s limit)
8
+ - Space to appear unresponsive on first load
9
+ - No fallback when sources fail
10
+
11
+ **Fix applied**:
12
+ 1. **shorts_updater.py** (NEW) — Resilient background updater:
13
+ - Hard timeout (25s) per channel using subprocess isolation
14
+ - Stale-while-revalidate pattern: returns cached data immediately, updates in background
15
+ - Automatic fallback to hardcoded short URLs when all sources fail
16
+ - Persistent storage in `/data/shorts_cache.json` for cache across restarts
17
+ - Background scheduler runs every 10 minutes automatically
18
+ - No blocking on first homepage load
19
+
20
+ 2. **_run.py** — Integrated resilient shorts endpoint:
21
+ - Overrides `/api/shorts` with non-blocking version
22
+ - Returns cached/fallback data in <100ms guaranteed
23
+ - Triggers background update if cache is stale or empty
24
+ - Never hangs - always returns valid JSON response
25
+
26
+ 3. **FALLBACK_SHORTS** — 6 hardcoded viral shorts as emergency fallback:
27
+ - baodantri7941 (Dân trí) headlines
28
+ - baosuckhoedoisongboyte (Sức khỏe & đời sống) stories
29
+ - vtvnambo (VTV Nam Bộ) news
30
+
31
+ ### Benefits
32
+ - Homepage loads in <2 seconds always
33
+ - Shorts data auto-updates every 10 minutes
34
+ - Never times out - graceful degradation to fallback
35
+ - Persistent cache survives Space restarts
36
+ - Uses bucket `bep40/VNEWS-storage` for cache storage
37
+
38
+ ### Channels monitored
39
+ - baodantri7941 (Dân trí)
40
+ - baosuckhoedoisongboyte (Sức khỏe & đời sống)
41
+ - vtvnambo (VTV Nam Bộ)
42
 
43
+ ---
44
+
45
+ # VNEWS v5.1 - Rewrite Fix
 
 
 
 
 
46
 
47
  ## Changes
48
+
49
+ ### Critical Fix: Rewrite button not creating posts on Tường AI
50
+ **Root cause**: `_run.py` imports from `app_v2_entry.py`, but the `/api/rewrite_share` endpoint was only defined in `ai_runtime_patch_fast.py` (loaded through `app_entry.py` which is NOT used). The frontend called a non-existent endpoint → 404 → silent failure.
51
+
52
+ **Fix applied**:
53
+ 1. **app_v2_entry.py** — Added 3 new endpoints:
54
+ - `POST /api/rewrite_slide` — Fast extractive summary (no AI needed), creates slides from article key points + images, saves to wall
55
+ - `POST /api/rewrite_share` — AI-powered rewrite with extractive fallback, saves to wall
56
+ - `POST /api/url_wall` — URL submission endpoint (alias for rewrite_share)
57
+ - All endpoints use the same `_load_wall_posts()` / `_save_wall_posts()` and `WALL_FILE` path as the existing `/api/wall` endpoint
58
+
59
+ 2. **static/index_v2.html** — Added `<script src="/static/rewrite_fix_v2.js"></script>` to load the rewrite fix
60
+
61
+ 3. **static/rewrite_fix_v2.js** — New file that overrides `rewriteArticle()` to:
62
+ - Call `/api/rewrite_slide` first (fast, no AI needed)
63
+ - Fallback to `/api/rewrite_share` if slide fails
64
+ - Show slide preview overlay after successful post
65
+ - Use `prependWallPost()` to add the new post to Tường AI
66
+
67
+ ### Previous changes (v5)
68
+ - Rewrote match_detail_v2.py with correct event parsing
69
+ - 2-tab layout for match detail (stats + timeline)
70
+ - Fixed _run.py import
71
+ - Dockerfile cache busting
Dockerfile CHANGED
@@ -2,48 +2,19 @@ FROM python:3.12-slim
2
 
3
  WORKDIR /app
4
 
5
- RUN echo "[BUILD] step1: apt-get update+install ffmpeg + Vietnamese fonts" && \
6
- apt-get update && apt-get install -y --no-install-recommends \
7
- ffmpeg \
8
- fonts-dejavu-core \
9
- fonts-noto \
10
- fonts-noto-cjk \
11
- fonts-noto-color-emoji \
12
- fonts-liberation \
13
- fonts-freefont-ttf \
14
- libfreetype6 \
15
- && rm -rf /var/lib/apt/lists/* && \
16
- echo "[BUILD] step1 done"
17
-
18
- RUN echo "[BUILD] step2: pip base pkgs (bs4/lxml)" && \
19
- pip install --no-cache-dir "beautifulsoup4>=4.12" lxml && \
20
- echo "[BUILD] step2 done"
21
-
22
- RUN echo "[BUILD] step3: pip main pkgs" && \
23
- pip install --no-cache-dir fastapi uvicorn requests beautifulsoup4 jinja2 yt-dlp huggingface_hub gTTS pillow edge-tts python-dateutil httpx pycryptodome && \
24
- echo "[BUILD] step3 done"
25
 
26
  COPY requirements.txt .
27
- RUN echo "[BUILD] step4: pip requirements.txt" && \
28
- pip install --no-cache-dir -r requirements.txt || true && \
29
- echo "[BUILD] step4 done"
30
 
31
  COPY . .
32
  EXPOSE 7860
33
 
34
- RUN echo "[BUILD] step5: setup Vietnamese font symlink" && \
35
- mkdir -p /usr/share/fonts/truetype/vn && \
36
- # Prefer Noto Sans for Vietnamese - it has full diacritic support
37
- if [ -f /usr/share/fonts/truetype/noto/NotoSans-Regular.ttf ]; then \
38
- ln -sf /usr/share/fonts/truetype/noto/NotoSans-Regular.ttf /usr/share/fonts/truetype/vn/VNFont.ttf; \
39
- elif [ -f /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf ]; then \
40
- ln -sf /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf /usr/share/fonts/truetype/vn/VNFont.ttf; \
41
- ln -sf /usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf /usr/share/fonts/truetype/vn/VNFont-Bold.ttf; \
42
- fi; \
43
- fc-cache -f -v || true; \
44
- date > /app/.build_done && \
45
- echo "[BUILD] step5 done"
46
-
47
- CMD ["uvicorn", "_run:app", "--host", "0.0.0.0", "--port", "7860"]
48
- # v3.0-vn-font-fix-short-video-2026-07-19
49
- # rebuild-trigger: m3u-perf-fix-20260829
 
2
 
3
  WORKDIR /app
4
 
5
+ RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg fonts-dejavu-core && rm -rf /var/lib/apt/lists/*
6
+ RUN pip install --no-cache-dir "beautifulsoup4>=4.12" lxml
7
+ RUN pip install --no-cache-dir fastapi uvicorn requests beautifulsoup4 jinja2 yt-dlp huggingface_hub gTTS pillow edge-tts python-dateutil httpx
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  COPY requirements.txt .
10
+ RUN pip install --no-cache-dir -r requirements.txt || true
 
 
11
 
12
  COPY . .
13
  EXPOSE 7860
14
 
15
+ # v7 - 2026-07-06 - FIX VTV2/VTV3/VTV6/VTV9: new aggressive extraction patterns + backup CDN mediacdn
16
+ CMD ["uvicorn", "_run:app", "--host", "0.0.0.0", "--port", "7860", "--reload"]
17
+ # build 1782698397
18
+ # build 1782698798
19
+ # build 1782699174
20
+ # build 1790000001
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -30,29 +30,4 @@ tags:
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
34
-
35
- ## 🎬 Short AI — Video từ link (scrap YouTube / TikTok / tin tức)
36
-
37
- Short creator có chế độ **"🔗 Video từ link"**: dán link video YouTube / TikTok /
38
- VnExpress / Dân trí / Znews / 24h... → bấm "Lấy video" để xem trước → tạo short
39
- chạy video + ảnh đã chọn bù phần còn thiếu nếu video ngắn hơn giọng đọc.
40
-
41
- ### 🔑 Cài cookies cho YouTube (bỏ chặn "Sign in to confirm you're not a bot")
42
- YouTube đôi khi chặn IP datacenter. Cách khắc phục bằng cookies:
43
-
44
- 1. Cài extension trình duyệt **"Get cookies.txt LOCALLY"** (Chrome/Edge) hoặc
45
- **"cookies.txt"** (Firefox).
46
- 2. Mở `https://www.youtube.com` (đã đăng nhập) → bấm extension → **Export** → ra file `cookies.txt` (định dạng Netscape).
47
- 3. Đưa cookies vào Space bằng **một trong hai cách**:
48
- - **Cách A (khuyến nghị):** Vào Settings của Space
49
- `huggingface.co/spaces/bep40/VNEWS/settings` → **Variables and secrets** →
50
- tạo secret tên **`YT_COOKIES`**, giá trị = toàn bộ nội dung file `cookies.txt`.
51
- - **Cách B:** đặt file `cookies.txt` vào thư mục gốc repo `VNEWS/` và commit
52
- (chú ý: cookies sẽ công khai nếu repo public — ưu tiên Cách A).
53
- 4. Rebuild Space (mỗi lần đổi secret phải **Restart** Space).
54
-
55
- Backend tự đọc `YT_COOKIES` (secret) hoặc `/app/cookies.txt`, ghi thành file tạm
56
- và truyền cho yt-dlp qua `cookiefile`. Không cần sửa code.
57
-
58
- > Lưu ý: cookies có hạn (thường vài tuần). Khi hết hạn, export lại và cập nhật secret.
 
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
RESTART_TRIGGER.md DELETED
@@ -1,7 +0,0 @@
1
- trigger rebuild v20260827fptwall
2
-
3
- - FPT wall cards now use .wall-thumb 100% width/height (16:9), identical to Short AI cards
4
- - FPT player opens a scrollable vertical feed interleaving FPT Play + Short AI videos, newest-first
5
- - 16:9 <-> 9:16 ratio toggle (🖥️/📺) works on football highlight slides and Tường AI slide player
6
- - Cache-busting: app_v2.js?v=20260827fptwall
7
- - FORCE REBUILD: this commit retriggers the Space container with the latest static/app_v2.js
 
 
 
 
 
 
 
 
ai_ext.py CHANGED
@@ -96,20 +96,27 @@ def _domain(url: str) -> str:
96
 
97
 
98
  async def qwen_generate(prompt: str, image_url: str = None, max_tokens: int = 1200) -> str:
99
- """Generate text using Llama/Qwen models via Hugging Face Inference API.
100
 
101
- Prioritizes Llama-3.3-70B for better creative/opinion writing.
 
 
 
102
  """
103
  token = _hf_token()
104
  errors = []
105
 
106
- # Try HF router API with multiple models - Llama FIRST for opinion writing
107
  if token:
108
  models = [
109
  os.getenv("QWEN_VL_MODEL", ""),
110
- "meta-llama/Llama-3.3-70B-Instruct", # FIRST - best for opinion/analysis
111
  "Qwen/Qwen2.5-VL-7B-Instruct",
 
 
 
 
112
  "Qwen/Qwen2.5-72B-Instruct",
 
113
  ]
114
  # Deduplicate while preserving order
115
  seen = set()
@@ -131,12 +138,12 @@ async def qwen_generate(prompt: str, image_url: str = None, max_tokens: int = 12
131
  payload = {
132
  "model": model,
133
  "messages": [
134
- {"role": "system", "content": "Bạn là nhà báo phản biện chuyên nghiệp. Luôn viết theo quan điểm cá nhân, phân tích sâu, không sao chép nguyên văn nguồn tin."},
135
  {"role": "user", "content": user_content},
136
  ],
137
- "max_tokens": min(int(max_tokens or 2000), 2500),
138
- "temperature": 0.75,
139
- "top_p": 0.9,
140
  }
141
 
142
  r = requests.post(
@@ -176,18 +183,20 @@ def _fallback_summary_from_prompt(prompt: str, max_units: int = 6) -> str:
176
  text = re.sub(r"https?://\S+", "", text)
177
  text = re.sub(r"\s+", " ", text).strip()
178
 
179
- # Split into sentences
180
  sentences = re.split(r"(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])", text)
181
  units = []
182
  for s in sentences:
183
  s = _clean_text(s)
184
- if len(s) >= 30:
185
  units.append(s)
186
 
187
  if units:
 
188
  result_units = units[:max_units]
189
  return "\n".join("• " + u for u in result_units)
190
  if text:
 
191
  chunks = []
192
  for i in range(0, min(len(text), max_units * 300), 280):
193
  chunk = _clean_text(text[i:i+300])
@@ -199,134 +208,367 @@ def _fallback_summary_from_prompt(prompt: str, max_units: int = 6) -> str:
199
  return "\n".join("• " + c for c in chunks)
200
  return "• Không có đủ nội dung để tóm tắt."
201
 
202
- # ===== URL scraping & article processing =====
203
- HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"}
204
 
205
- try:
206
- _shorts_base = "/data" if os.path.isdir("/data") else os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
207
- except Exception:
208
- _shorts_base = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
209
- SHORTS_DIR = os.path.join(_shorts_base, "ai_shorts")
210
- os.makedirs(SHORTS_DIR, exist_ok=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
 
212
- import random as _random2
213
- from datetime import datetime, timezone, timedelta
214
- _VN_TZ = timezone(timedelta(hours=7))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
 
216
 
217
- def _safe_name(filename: str) -> str:
218
- """Sanitize filename."""
219
- return re.sub(r"[^a-zA-Z0-9_.-]", "_", filename)[:120]
 
220
 
221
 
222
- def pollinations_image_url(topic: str) -> str:
223
- """Generate a placeholder image URL via Pollinations."""
 
 
224
  try:
225
- return "https://image.pollinations.ai/prompt/" + quote("Vietnamese editorial illustration, " + topic, safe="") + "?width=1024&height=576&nologo=true"
 
 
 
 
 
226
  except Exception:
227
- return ""
 
228
 
229
 
230
- def _download_image(url: str, fallback_title: str, out_path: str) -> str:
231
- """Download an image from URL or create a placeholder."""
232
- if url:
233
- try:
234
- r = requests.get(url, headers=HEADERS, timeout=15)
235
- if r.status_code == 200 and len(r.content) > 1200:
236
- os.makedirs(os.path.dirname(out_path), exist_ok=True)
237
- with open(out_path, "wb") as f:
238
- f.write(r.content)
239
- return out_path
240
- except Exception:
241
- pass
242
- # Fallback: create a placeholder image
243
  try:
244
- from PIL import Image, ImageDraw, ImageFont
245
- img = Image.new("RGB", (1080, 760), (24, 24, 24))
246
- draw = ImageDraw.Draw(img)
247
- try:
248
- font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 48)
249
- except Exception:
250
- font = None
251
- text = (fallback_title or "VNEWS")[:40]
252
- try:
253
- bbox = draw.textbbox((0, 0), text, font=font)
254
- tw = bbox[2] - bbox[0]
255
- except Exception:
256
- tw = len(text) * 24
257
- draw.text(((1080 - tw) // 2, 330), text, fill=(255, 255, 255), font=font)
258
- os.makedirs(os.path.dirname(out_path), exist_ok=True)
259
- img.save(out_path, quality=90)
260
- return out_path
261
  except Exception:
262
- return out_path
 
263
 
264
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  def scrape_any_url(url: str) -> dict:
266
- """Scrape article content from any URL."""
267
- if not url or not url.startswith("http"):
268
- return {"title": "", "text": "", "summary": "", "image": "", "og_image": "", "via": ""}
 
269
  try:
270
  r = requests.get(url, headers=HEADERS, timeout=15, allow_redirects=True)
271
- if r.status_code != 200 or not r.text:
272
- return {"title": "", "text": "", "summary": "", "image": "", "og_image": "", "via": _domain(url)}
273
- r.encoding = "utf-8"
274
- soup = BeautifulSoup(r.text, "lxml")
275
- 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", ".breadcrumb"]):
276
  tag.decompose()
277
- title = ""
278
- ogt = soup.find("meta", property="og:title")
279
- if ogt:
280
- title = ogt.get("content", "")
281
- h1 = soup.find("h1")
282
- if not title and h1:
283
- title = h1.get_text(strip=True)
284
- if not title:
285
- t = soup.find("title")
286
- if t:
287
- title = t.get_text(strip=True)
288
- og_image = ""
289
- ogi = soup.find("meta", property="og:image")
290
- if ogi:
291
- og_image = ogi.get("content", "")
292
- if og_image.startswith("//"):
293
- og_image = "https:" + og_image
294
- summary = ""
295
- ogd = soup.find("meta", property="og:description") or soup.find("meta", attrs={"name": "description"})
296
- if ogd:
297
- summary = ogd.get("content", "")[:500]
298
- body_text = []
299
- for sel in ["article", ".singular-content", ".detail-content", ".fck_detail", ".content-detail", ".knc-content", "main", ".cms-body", ".article__body", ".post-content", ".entry-content"]:
300
  el = soup.select_one(sel)
301
- if el and len(el.find_all("p")) >= 2:
302
- for p in el.find_all("p"):
303
- t = _clean_text(p.get_text(strip=True))
304
- if t and len(t) > 30:
305
- body_text.append(t)
306
  break
307
- if not body_text and soup.body:
308
- for p in soup.body.find_all("p"):
309
- t = _clean_text(p.get_text(strip=True))
310
- if t and len(t) > 30:
311
- body_text.append(t)
312
- text = "\n".join(body_text)
313
- return {"title": _clean_text(title), "text": text, "summary": _clean_text(summary), "image": og_image, "og_image": og_image, "via": _domain(url), "url": url}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  except Exception as e:
315
- return {"title": "", "text": "", "summary": "", "image": "", "og_image": "", "via": _domain(url)}
316
 
317
 
318
- def make_post(title: str, text: str, img: str, url: str, kind: str = "auto", sources: list = None) -> dict:
319
- """Create a wall post dict."""
320
- import random as _r2
321
- now = int(time.time() * 1000)
322
- return {
323
- "id": str(now) + str(_r2.randint(100, 999)),
324
- "title": (title or "Bài viết")[:200],
325
- "text": (text or "")[:5000],
326
- "img": img or "",
327
- "url": url or "",
328
- "kind": kind or "auto",
329
- "sources": sources or [],
330
- "created": now,
331
- "created_str": datetime.now(_VN_TZ).strftime("%H:%M %d/%m/%Y"),
332
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()
 
138
  payload = {
139
  "model": model,
140
  "messages": [
141
+ {"role": "system", "content": "Bạn là trợ 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(
 
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])
 
208
  return "\n".join("• " + c for c in chunks)
209
  return "• Không có đủ nội dung để tóm tắt."
210
 
 
 
211
 
212
+ HF_TOKEN = _hf_token()
213
+ QWEN_VL_MODEL = os.getenv("QWEN_VL_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct")
214
+ QWEN_TEXT_MODELS = [m.strip() for m in os.getenv(
215
+ "QWEN_TEXT_MODELS",
216
+ "Qwen/Qwen2.5-72B-Instruct,meta-llama/Llama-3.3-70B-Instruct,Qwen/Qwen2.5-7B-Instruct"
217
+ ).split(",") if m.strip()]
218
+ _WORKING_MODEL_TEXT = None
219
+ _WORKING_MODEL_VL = None
220
+ DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data"
221
+ SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts")
222
+ HEADERS = {
223
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
224
+ "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"
225
+ }
226
+ LAST_QWEN_ERROR = ""
227
+
228
+
229
+ # ===== MULTILINGUAL VOICES FOR TTS =====
230
+ # Maps voice IDs to edge-tts voice names (only MultilingualNeural voices)
231
+ MULTILINGUAL_VOICES = {
232
+ # Vietnamese - Native voices
233
+ "vi-vn-hoaimyneural": "vi-VN-HoaiMyNeural",
234
+ "vi-vn-namminhneural": "vi-VN-NamMinhNeural",
235
+ "hoaimy": "vi-VN-HoaiMyNeural",
236
+ "namminh": "vi-VN-NamMinhNeural",
237
+ "vi_female": "vi-VN-HoaiMyNeural",
238
+ "vi_male": "vi-VN-NamMinhNeural",
239
+ "nu": "vi-VN-HoaiMyNeural",
240
+ "male": "vi-VN-NamMinhNeural",
241
+ "female": "vi-VN-HoaiMyNeural",
242
+ "mien-nam": "vi-VN-HoaiMyNeural",
243
+ # English - Multilingual
244
+ "en-us-andrewmultilingualneural": "en-US-AndrewMultilingualNeural",
245
+ "en-au-williammultilingualneural": "en-AU-WilliamMultilingualNeural",
246
+ "en_andrew": "en-US-AndrewMultilingualNeural",
247
+ "andrew": "en-US-AndrewMultilingualNeural",
248
+ "en_jenny": "en-US-AndrewMultilingualNeural",
249
+ "jenny": "en-US-AndrewMultilingualNeural",
250
+ # Portuguese - Thalita Multilingual ONLY
251
+ "pt-br-thalitamultilingualneural": "pt-BR-ThalitaMultilingualNeural",
252
+ "pt_thalita": "pt-BR-ThalitaMultilingualNeural",
253
+ "thalita": "pt-BR-ThalitaMultilingualNeural",
254
+ "pt_francisco": "pt-BR-ThalitaMultilingualNeural",
255
+ "pt": "pt-BR-ThalitaMultilingualNeural",
256
+ # French - Multilingual
257
+ "fr-fr-viviennemultilingualneural": "fr-FR-VivienneMultilingualNeural",
258
+ "fr-fr-remymultilingualneural": "fr-FR-RemyMultilingualNeural",
259
+ "fr_denise": "fr-FR-VivienneMultilingualNeural",
260
+ "denise": "fr-FR-VivienneMultilingualNeural",
261
+ "fr": "fr-FR-VivienneMultilingualNeural",
262
+ # German - Multilingual
263
+ "de-de-seraphinamultilingualneural": "de-DE-SeraphinaMultilingualNeural",
264
+ "de-de-florianmultilingualneural": "de-DE-FlorianMultilingualNeural",
265
+ "de_katja": "de-DE-SeraphinaMultilingualNeural",
266
+ "katja": "de-DE-SeraphinaMultilingualNeural",
267
+ "de": "de-DE-SeraphinaMultilingualNeural",
268
+ # Korean - Hyunsu Multilingual (NOT SunHee)
269
+ "ko-kr-hyunsumultilingualneural": "ko-KR-HyunsuMultilingualNeural",
270
+ "ko_sunhee": "ko-KR-HyunsuMultilingualNeural",
271
+ "sunhee": "ko-KR-HyunsuMultilingualNeural",
272
+ "ko": "ko-KR-HyunsuMultilingualNeural",
273
+ # Italian - Multilingual
274
+ "it-it-giuseppemultilingualneural": "it-IT-GiuseppeMultilingualNeural",
275
+ # Spanish (fallback to English multilingual)
276
+ "es_ela": "en-US-AndrewMultilingualNeural",
277
+ "ela": "en-US-AndrewMultilingualNeural",
278
+ "es_carlos": "en-US-AndrewMultilingualNeural",
279
+ "es": "en-US-AndrewMultilingualNeural",
280
+ # Japanese (fallback to English multilingual)
281
+ "ja_nanami": "en-US-AndrewMultilingualNeural",
282
+ "nanami": "en-US-AndrewMultilingualNeural",
283
+ "ja": "en-US-AndrewMultilingualNeural",
284
+ # Chinese (fallback to English multilingual)
285
+ "zh_xiaochen": "en-US-AndrewMultilingualNeural",
286
+ "xiaochen": "en-US-AndrewMultilingualNeural",
287
+ "zh": "en-US-AndrewMultilingualNeural",
288
+ }
289
+
290
 
291
+ def _detect_voice_emotion(title, text):
292
+ """Detect appropriate voice and emotion based on content for multilingual TTS."""
293
+ content = ((title or "") + " " + (text or "")).lower()
294
+
295
+ # World Cup / Football content - use Andrew multilingual
296
+ if any(kw in content for kw in ["world cup", "wc 2026", "fifa", "bóng đá", "trận đấu", "bóng bóng", "đội tuyển", "cầu thủ"]):
297
+ return ("andrew", "excited")
298
+
299
+ # News categories - choose appropriate voice
300
+ if any(kw in content for kw in ["kinh tế", "tài chính", "thị trường", "economics", "finance"]):
301
+ return ("jenny", "calm")
302
+ if any(kw in content for kw in ["thiên tai", "bão", "lũ lụt", "cháy nổ", "tai nạn", "disaster", "accident"]):
303
+ return ("thalita", "serious")
304
+ if any(kw in content for kw in ["giải trí", "showbiz", "entertainment", "hài hước"]):
305
+ return ("ela", "happy")
306
+ if any(kw in content for kw in ["công nghệ", "tech", "technology", "ai", "trí tuệ nhân tạo"]):
307
+ return ("katja", "excited")
308
+
309
+ # Default Vietnamese
310
+ return ("hoaimy", "trung_tinh")
311
 
312
 
313
+ def _safe_name(s: str) -> str:
314
+ """Create safe filename from string."""
315
+ s = re.sub(r"[^\w\-.]", "_", s)
316
+ return s[:100] if len(s) > 100 else s
317
 
318
 
319
+ def _download_image(url: str, fallback_title: str, out_path: str) -> bool:
320
+ """Download image from URL to path."""
321
+ if not url:
322
+ return False
323
  try:
324
+ r = requests.get(url, headers=HEADERS, timeout=15)
325
+ if r.status_code == 200:
326
+ os.makedirs(os.path.dirname(out_path), exist_ok=True)
327
+ with open(out_path, "wb") as f:
328
+ f.write(r.content)
329
+ return True
330
  except Exception:
331
+ pass
332
+ return False
333
 
334
 
335
+ def pollination_image_url(topic: str) -> str:
336
+ """Generate image URL from Pollinations.ai."""
337
+ return f"https://image.pollinations.ai/prompt/{quote(topic)}?width=1024&height=768&nologo=true&model=flux"
338
+
339
+
340
+ # Use the same wall file as app_v2_entry.py for consistency
341
+ WALL_FILE = os.path.join(DATA_DIR, "wall_posts.json")
342
+
343
+ def _load_ai_wall():
344
+ """Load AI wall posts from JSON file (uses wall_posts.json for consistency with app_v2_entry)."""
 
 
 
345
  try:
346
+ if os.path.exists(WALL_FILE):
347
+ with open(WALL_FILE, "r", encoding="utf-8") as f:
348
+ return json.load(f)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
  except Exception:
350
+ pass
351
+ return []
352
 
353
 
354
+ def _save_ai_wall(posts):
355
+ """Save AI wall posts to JSON file (uses wall_posts.json for consistency with app_v2_entry)."""
356
+ try:
357
+ os.makedirs(os.path.dirname(WALL_FILE), exist_ok=True)
358
+ tmp = WALL_FILE + ".tmp"
359
+ with open(tmp, "w", encoding="utf-8") as f:
360
+ json.dump(posts[:100], f, ensure_ascii=False)
361
+ os.replace(tmp, WALL_FILE)
362
+ except Exception:
363
+ pass
364
+
365
+
366
+ # Helper functions for wall operations
367
+ def _load_wall_posts():
368
+ """Alias for _load_ai_wall for consistency with app_v2_entry.py."""
369
+ return _load_ai_wall()
370
+
371
+
372
+ def _save_wall_posts(posts):
373
+ """Alias for _save_ai_wall for consistency with app_v2_entry.py."""
374
+ return _save_ai_wall(posts)
375
+
376
+
377
+ def make_post(title: str, text: str, img: str, url: str, kind: str, sources=None):
378
+ """Create a post dict with standard fields."""
379
+ return {
380
+ "id": str(int(time.time() * 1000)),
381
+ "title": title,
382
+ "text": text,
383
+ "img": img,
384
+ "url": url,
385
+ "kind": kind,
386
+ "sources": sources or [],
387
+ "ts": int(time.time())
388
+ }
389
+
390
+
391
+ def _short_script(post) -> str:
392
+ """Extract clean text for TTS from post."""
393
+ text = post.get("text", "") or post.get("title", "")
394
+ text = re.sub(r"^[•\-\*]\s*", "", text, flags=re.M)
395
+ text = re.sub(r"\s*\n\s*", ". ", text)
396
+ return _clean_text(text)[:2000] # Increased from 1000 to 2000 for full content
397
+
398
+
399
+ # ===== SCRAPER FUNCTIONS (required by ai_patch.py) =====
400
  def scrape_any_url(url: str) -> dict:
401
+ """Scrape any URL and extract article content.
402
+
403
+ Returns dict with: title, summary, text, image, og_image, via (domain)
404
+ """
405
  try:
406
  r = requests.get(url, headers=HEADERS, timeout=15, allow_redirects=True)
407
+ r.encoding = 'utf-8'
408
+ soup = BeautifulSoup(r.text, 'lxml')
409
+
410
+ # Remove scripts, styles, nav, footer
411
+ for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
412
  tag.decompose()
413
+
414
+ # Extract title
415
+ h1 = soup.find('h1')
416
+ ogt = soup.find('meta', property='og:title')
417
+ title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else url)
418
+
419
+ # Extract OG image
420
+ ogi = soup.find('meta', property='og:image')
421
+ og_image = ogi.get('content', '') if ogi else ''
422
+
423
+ # Extract article body
424
+ block = None
425
+ for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
 
 
 
 
 
 
 
 
 
 
426
  el = soup.select_one(sel)
427
+ if el and len(el.find_all('p')) >= 2:
428
+ block = el
 
 
 
429
  break
430
+ if not block:
431
+ block = soup.body or soup
432
+
433
+ # Extract text from paragraphs
434
+ paragraphs = []
435
+ for el in block.find_all(['p', 'h2', 'h3'], recursive=True):
436
+ t = _clean_text(el.get_text(strip=True))
437
+ if t and len(t) > 40:
438
+ paragraphs.append(t)
439
+
440
+ # Extract images
441
+ images = []
442
+ for el in block.find_all(['figure', 'img'], recursive=True):
443
+ im = el if el.name == 'img' else el.find('img')
444
+ if im:
445
+ src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
446
+ if src and 'base64' not in src:
447
+ if src.startswith('//'):
448
+ src = 'https:' + src
449
+ images.append(src)
450
+
451
+ # Prefer OG image as main image
452
+ image = og_image or (images[0] if images else '')
453
+
454
+ return {
455
+ 'title': title,
456
+ 'summary': paragraphs[0] if paragraphs else '',
457
+ 'text': '\n'.join(paragraphs),
458
+ 'image': image,
459
+ 'og_image': og_image,
460
+ 'via': _domain(url),
461
+ 'images': images
462
+ }
463
  except Exception as e:
464
+ return {'title': url, 'summary': '', 'text': '', 'image': '', 'og_image': '', 'via': _domain(url), 'error': str(e)}
465
 
466
 
467
+ def web_context(topic: str, limit: int = 5) -> tuple:
468
+ """Get web context for a topic. Returns (context_text, sources_list)."""
469
+ sources = []
470
+ try:
471
+ # Try Google News RSS
472
+ rss_url = f"https://news.google.com/rss/search?q={quote_plus(topic)}&hl=vi&gl=VN&ceid=VN:vi"
473
+ r = requests.get(rss_url, headers=HEADERS, timeout=15)
474
+ r.encoding = 'utf-8'
475
+ soup = BeautifulSoup(r.text, 'xml')
476
+ for it in soup.find_all('item')[:limit]:
477
+ title = it.find('title').get_text(' ', strip=True) if it.find('title') else ''
478
+ link = it.find('link').get_text(strip=True) if it.find('link') else ''
479
+ if title and link:
480
+ sources.append({'title': title, 'url': link, 'via': _domain(link)})
481
+ except Exception:
482
+ pass
483
+
484
+ context = f'Trên mạng có nhiều bài viết về "{topic}". Một số nguồn: ' + ', '.join([s.get('title', '') for s in sources[:3]])
485
+ return context, sources
486
+
487
+
488
+ # ===== SHORT FRAME FUNCTION (required by ai_patch.py) =====
489
+ def _make_short_frame(post, img_path, out_path):
490
+ """Create a short video frame from post and image.
491
+
492
+ Called by ai_patch.py _make_short_frame_full when Image is available.
493
+ """
494
+ if Image is None:
495
+ # Create a minimal frame without PIL - just return success
496
+ # The caller should handle this case
497
+ return False
498
+
499
+ W, H = 1080, 1920
500
+ bg = Image.new("RGB", (W, H), (14, 14, 14))
501
+
502
+ try:
503
+ im = Image.open(img_path).convert("RGB")
504
+ target = (1080, 760)
505
+ im_ratio = im.width / max(1, im.height)
506
+ target_ratio = target[0] / target[1]
507
+
508
+ if im_ratio > target_ratio:
509
+ new_h = target[1]
510
+ new_w = int(new_h * im_ratio)
511
+ else:
512
+ new_w = target[0]
513
+ new_h = int(new_w / im_ratio)
514
+
515
+ im = im.resize((new_w, new_h))
516
+ left = (new_w - target[0]) // 2
517
+ top = (new_h - target[1]) // 2
518
+ im = im.crop((left, top, left + target[0], top + target[1]))
519
+ bg.paste(im, (0, 0))
520
+ except Exception:
521
+ pass
522
+
523
+ draw = ImageDraw.Draw(bg)
524
+
525
+ try:
526
+ font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 54)
527
+ font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 38)
528
+ except Exception:
529
+ font_title = font_body = None
530
+
531
+ draw.rectangle((0, 720, W, H), fill=(14, 14, 14))
532
+ margin = 48
533
+ maxw = W - margin * 2
534
+
535
+ y = 830
536
+ for ln in _wrap_text(draw, post.get("title", ""), font_title, maxw, 4):
537
+ draw.text((margin, y), ln, fill=(255, 255, 255), font=font_title)
538
+ y += 66
539
+
540
+ y += 18
541
+ text = post.get("text", "")
542
+ text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip()
543
+ body_lines = _wrap_text(draw, text, font_body, maxw, 14)
544
+ for ln in body_lines:
545
+ draw.text((margin, y), ln, fill=(220, 220, 220), font=font_body)
546
+ y += 50
547
+ if y > 1640:
548
+ break
549
+
550
+ bg.save(out_path, quality=92)
551
+ return True
552
+
553
+
554
+ def _wrap_text(draw, text, font, max_width, max_lines):
555
+ """Helper for wrapping text in frames."""
556
+ words = _clean_text(text).split()
557
+ lines, cur = [], ""
558
+ for w in words:
559
+ test = (cur + " " + w).strip()
560
+ try:
561
+ width = draw.textbbox((0, 0), test, font=font)[2]
562
+ except Exception:
563
+ width = len(test) * 20
564
+ if width <= max_width:
565
+ cur = test
566
+ else:
567
+ if cur:
568
+ lines.append(cur)
569
+ cur = w
570
+ if len(lines) >= max_lines:
571
+ break
572
+ if cur and len(lines) < max_lines:
573
+ lines.append(cur)
574
+ return lines
ai_patch.py CHANGED
@@ -327,24 +327,6 @@ Nội dung bài:
327
  if 'Nguồn tham khảo:' not in text:
328
  text += "\n\n" + _source_line(src)
329
  post = base.make_post(art['title'], text, art.get('image') or base.pollinations_image_url(art['title']), art.get('url') or '', 'topic_article', sources=src)
330
-
331
- # Generate slides for this post so they persist after page reload
332
- try:
333
- page_data = _scrape_article_images(art.get('url', ''))
334
- if page_data and page_data.get('paragraphs'):
335
- key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12)
336
- if key_points:
337
- relevant_imgs = page_data.get('images', [])
338
- if not relevant_imgs and page_data.get('og_img'):
339
- relevant_imgs = [page_data['og_img']]
340
- slides = []
341
- for i, point in enumerate(key_points):
342
- img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
343
- slides.append({'text': point, 'image': img, 'index': i + 1})
344
- post['slides'] = slides
345
- except Exception:
346
- pass
347
-
348
  new_posts.append(post)
349
  posts = new_posts + posts
350
  base._save_ai_wall(posts)
@@ -371,26 +353,8 @@ async def compat_url_wall(request: Request):
371
  if 'Nguồn tham khảo:' not in text:
372
  text += "\n\n" + _source_line(src)
373
  post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'url', sources=src)
374
-
375
- # Generate slides so they persist after page reload
376
- slides = []
377
- try:
378
- page_data = _scrape_article_images(url)
379
- if page_data and page_data.get('paragraphs'):
380
- key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12)
381
- if key_points:
382
- relevant_imgs = page_data.get('images', [])
383
- if not relevant_imgs and page_data.get('og_img'):
384
- relevant_imgs = [page_data['og_img']]
385
- for i, point in enumerate(key_points):
386
- img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
387
- slides.append({'text': point, 'image': img, 'index': i + 1})
388
- except Exception:
389
- pass
390
- post['slides'] = slides
391
-
392
  posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
393
- return JSONResponse({'post': post, 'slides': slides})
394
 
395
 
396
  def _is_relevant_image(img_url, title, text):
@@ -523,6 +487,7 @@ async def compat_rewrite_share(request: Request):
523
  if 'Nguồn tham khảo:' not in text:
524
  text += "\n\n" + _source_line(src)
525
  post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'summary', sources=src)
 
526
 
527
  # Generate slides with relevant images only
528
  slides = []
@@ -537,10 +502,6 @@ async def compat_rewrite_share(request: Request):
537
  img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
538
  slides.append({'text': point, 'image': img, 'index': i + 1})
539
 
540
- # FIX: Save slides into post so they persist after page reload
541
- post['slides'] = slides
542
- posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
543
-
544
  return JSONResponse({'post': post, 'slides': slides})
545
 
546
 
 
327
  if 'Nguồn tham khảo:' not in text:
328
  text += "\n\n" + _source_line(src)
329
  post = base.make_post(art['title'], text, art.get('image') or base.pollinations_image_url(art['title']), art.get('url') or '', 'topic_article', sources=src)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  new_posts.append(post)
331
  posts = new_posts + posts
332
  base._save_ai_wall(posts)
 
353
  if 'Nguồn tham khảo:' not in text:
354
  text += "\n\n" + _source_line(src)
355
  post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'url', sources=src)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
  posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
357
+ return JSONResponse({'post': post})
358
 
359
 
360
  def _is_relevant_image(img_url, title, text):
 
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 = []
 
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
 
ai_runtime_fix.py DELETED
@@ -1,394 +0,0 @@
1
- """VNEWS Short Video Fix - standalone module with clean registration.
2
- This module MUST be imported LAST to register /api/ai/short endpoints.
3
- FIX v1: No route filtering issues - registers endpoints unconditionally.
4
- FIX v2: SSE inline endpoint for auto homepage updates
5
- """
6
- import os
7
- import re
8
- import time
9
- import json
10
- import sys
11
- import logging
12
- import asyncio
13
- import hashlib
14
- import subprocess
15
- import requests
16
- from datetime import datetime, timezone, timedelta
17
- from urllib.parse import urlparse
18
- from fastapi import Request, Query
19
- from fastapi.responses import JSONResponse, FileResponse
20
-
21
- # Import dependencies
22
- try:
23
- import ai_ext as base
24
- except ImportError:
25
- import ai_runtime_final6 as base
26
-
27
- # Try to import app from various sources
28
- try:
29
- from app_v2_entry import app
30
- except ImportError:
31
- try:
32
- from main import app
33
- except ImportError:
34
- from ai_runtime_final6 import app
35
-
36
- _log = logging.getLogger("short_fix")
37
- _log.setLevel(logging.INFO)
38
- if not _log.handlers:
39
- _log.addHandler(logging.StreamHandler(sys.stderr))
40
-
41
- DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data"
42
- os.makedirs(DATA_DIR, exist_ok=True)
43
- SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts")
44
- os.makedirs(SHORTS_DIR, exist_ok=True)
45
-
46
- # ===== VIETNAMESE FONT DETECTION =====
47
- _VN_FONT_REG = None
48
- _VN_FONT_BOLD = None
49
-
50
- def _get_vn_fonts():
51
- """Find Vietnamese-supporting fonts."""
52
- global _VN_FONT_REG, _VN_FONT_BOLD
53
- if _VN_FONT_REG is not None:
54
- return _VN_FONT_REG, _VN_FONT_BOLD
55
-
56
- try:
57
- from PIL import ImageFont
58
- except Exception:
59
- _log.error("PIL not available!")
60
- return None, None
61
-
62
- # Priority: Noto > DejaVu > Liberation
63
- reg_paths = [
64
- "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
65
- "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
66
- "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
67
- "/usr/share/fonts/truetype/freefont/FreeSans.ttf",
68
- ]
69
- bold_paths = [
70
- "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf",
71
- "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
72
- "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
73
- "/usr/share/fonts/truetype/freefont/FreeSans.ttf",
74
- ]
75
-
76
- for path in reg_paths:
77
- if os.path.exists(path):
78
- try:
79
- _VN_FONT_REG = ImageFont.truetype(path, 40)
80
- _log.info(f"Found regular font: {path}")
81
- break
82
- except:
83
- continue
84
-
85
- for path in bold_paths:
86
- if os.path.exists(path):
87
- try:
88
- _VN_FONT_BOLD = ImageFont.truetype(path, 52)
89
- _log.info(f"Found bold font: {path}")
90
- break
91
- except:
92
- continue
93
-
94
- if _VN_FONT_REG is None:
95
- _VN_FONT_REG = ImageFont.load_default()
96
- if _VN_FONT_BOLD is None:
97
- _VN_FONT_BOLD = _VN_FONT_REG
98
-
99
- return _VN_FONT_REG, _VN_FONT_BOLD
100
-
101
-
102
- def _clean(s):
103
- import html as html_lib
104
- return re.sub(r"\s+", " ", html_lib.unescape(str(s or ""))).strip()
105
-
106
-
107
- # ===== ROBUST TEXT SEGMENTATION =====
108
- def _split_into_segments(text, max_segments=10, min_len=30):
109
- """Split text into segments - multi strategy."""
110
- text = _clean(text)
111
- if not text:
112
- return []
113
-
114
- # Strategy 1: bullet points
115
- lines = text.split('\n')
116
- segmented = []
117
- for line in lines:
118
- line = _clean(line)
119
- line_bare = re.sub(r'^[•\-\*\d\.\)\s]+', '', line).strip()
120
- if len(line_bare) > min_len:
121
- segmented.append(line_bare)
122
- elif len(line) > min_len:
123
- segmented.append(line)
124
-
125
- # Strategy 2: sentences (Vietnamese)
126
- if len(segmented) < 2:
127
- sents = re.split(r'(?<=[.!?])\s+(?=[A-Z0-9À-ỸĐ])', text)
128
- segmented = [s for s in sents if len(_clean(s)) > min_len]
129
-
130
- # Strategy 3: character chunks
131
- if not segmented:
132
- words = text.split()
133
- for i in range(0, min(len(words), max_segments * 20), 20):
134
- chunk = ' '.join(words[i:i+20])
135
- if len(chunk) > min_len:
136
- segmented.append(chunk)
137
-
138
- # Strategy 4: fallback
139
- if not segmented:
140
- segmented = [text[:300]]
141
-
142
- return segmented[:max_segments]
143
-
144
-
145
- # ===== SHORT VIDEO GENERATOR =====
146
- def _gen_short_core(post, work_dir):
147
- """Core short generation - returns video path or None."""
148
- post_id = post.get('id', '')
149
- text = post.get('text', '') or post.get('title', '')
150
-
151
- if not post_id or len(text) < 100:
152
- _log.error(f"Invalid post: id={post_id}, text_len={len(text)}")
153
- return None
154
-
155
- segments = _split_into_segments(text, max_segments=10, min_len=30)
156
- if not segments:
157
- _log.error("No segments generated")
158
- return None
159
-
160
- _log.info(f"Generating short: {len(segments)} segments")
161
-
162
- seg_hash = hashlib.md5(('|'.join(segments) + 'nu').encode()).hexdigest()[:8]
163
- suffix = f"_nu_{seg_hash}"
164
- out_mp4 = os.path.join(work_dir, f"{post_id}{suffix}.mp4")
165
-
166
- if os.path.exists(out_mp4):
167
- _log.info(f"Already exists: {out_mp4}")
168
- return out_mp4
169
-
170
- # Check dependencies
171
- try:
172
- subprocess.run(['ffmpeg', '-version'], capture_output=True, timeout=5)
173
- except Exception as e:
174
- _log.error(f"ffmpeg missing: {e}")
175
- return None
176
-
177
- # Download image
178
- img_path = os.path.join(work_dir, 'bg.jpg')
179
- downloaded = False
180
- try:
181
- img_url = post.get('img', '')
182
- if img_url and img_url.startswith('http'):
183
- r = requests.get(img_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=12)
184
- if r.status_code == 200:
185
- with open(img_path, 'wb') as f:
186
- f.write(r.content)
187
- downloaded = True
188
- except Exception as e:
189
- _log.warning(f"Image download: {e}")
190
-
191
- try:
192
- from PIL import Image, ImageDraw
193
- has_pil = True
194
- except:
195
- has_pil = False
196
- _log.warning("PIL not available")
197
-
198
- try:
199
- from gtts import gTTS
200
- has_tts = True
201
- except:
202
- has_tts = False
203
- _log.warning("gTTS not available")
204
-
205
- parts = []
206
-
207
- for i, seg in enumerate(segments[:10]):
208
- frame = os.path.join(work_dir, f'frame_{i}.jpg')
209
- audio = os.path.join(work_dir, f'audio_{i}.mp3')
210
- part = os.path.join(work_dir, f'part_{i}.mp4')
211
-
212
- # Create frame
213
- try:
214
- if has_pil:
215
- _make_frame(post, seg, img_path, downloaded, frame)
216
- else:
217
- subprocess.run(['ffmpeg', '-y', '-f', 'lavfi', '-i',
218
- 'color=c=black:s=1080x1920:d=1', '-frames:v', '1', frame],
219
- capture_output=True, timeout=20)
220
- except Exception as e:
221
- _log.error(f"Frame error: {e}")
222
- continue
223
-
224
- # Create audio
225
- if has_tts:
226
- try:
227
- tts = _clean(seg)[:300]
228
- gTTS(tts, lang='vi', slow=False).save(audio)
229
- except Exception as e:
230
- _log.warning(f"TTS error: {e}")
231
- audio = None
232
-
233
- # Combine
234
- dur = 10
235
- try:
236
- cmd = ['ffmpeg', '-y', '-loop', '1', '-t', str(dur), '-i', frame]
237
- if has_tts and os.path.exists(audio):
238
- cmd += ['-i', audio, '-shortest']
239
- else:
240
- cmd += ['-f', 'lavfi', '-i', 'anullsrc', '-shortest']
241
- cmd += ['-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p',
242
- '-c:a', 'aac', '-b:a', '128k', part]
243
- subprocess.run(cmd, capture_output=True, timeout=120)
244
- if os.path.exists(part) and os.path.getsize(part) > 5000:
245
- parts.append(part)
246
- except Exception as e:
247
- _log.error(f"Part combine error: {e}")
248
-
249
- if not parts:
250
- _log.error("No video parts created!")
251
- return None
252
-
253
- # Concatenate
254
- try:
255
- concat = os.path.join(work_dir, 'list.txt')
256
- with open(concat, 'w') as f:
257
- for p in parts:
258
- f.write(f"file '{p}'\n")
259
- subprocess.run(['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', concat, '-c', 'copy', out_mp4],
260
- capture_output=True, timeout=180)
261
- _log.info(f"Short created: {out_mp4}")
262
- return out_mp4
263
- except Exception as e:
264
- _log.error(f"Concat error: {e}")
265
- return None
266
-
267
-
268
- def _make_frame(post, text, img_path, downloaded, out_path):
269
- """Create video frame with Vietnamese font."""
270
- from PIL import Image, ImageDraw
271
- _get_vn_fonts()
272
-
273
- W, H = 1080, 1920
274
- bg = Image.new('RGB', (W, H), (15, 23, 38))
275
- d = ImageDraw.Draw(bg)
276
-
277
- # Background image
278
- if downloaded and os.path.exists(img_path):
279
- try:
280
- im = Image.open(img_path).convert('RGB')
281
- im = im.resize((W, 760))
282
- bg.paste(im, (0, 0))
283
- except:
284
- pass
285
-
286
- # Title
287
- d.rectangle([0, 0, W, 100], fill=(25, 118, 210))
288
- ttl = post.get('title', '')[:50]
289
- if _VN_FONT_BOLD:
290
- d.text((W//2, 50), ttl, fill='white', font=_VN_FONT_BOLD, anchor='mm')
291
-
292
- # Content
293
- y = 150
294
- for ln in _wrap_text(d, text[:200], _VN_FONT_REG, 80, 920, 10):
295
- d.text((80, y), ln, fill='white', font=_VN_FONT_REG)
296
- y += 55
297
-
298
- bg.save(out_path, quality=85)
299
-
300
-
301
- def _wrap_text(draw, text, font, x, max_w, max_lines):
302
- """Word wrap text."""
303
- words = text.split()
304
- lines = []
305
- cur = []
306
- for w in words:
307
- test = ' '.join(cur + [w])
308
- try:
309
- w_px = draw.textbbox((0, 0), test, font=font)[2]
310
- except:
311
- w_px = len(test) * 22
312
- if w_px <= max_w:
313
- cur.append(w)
314
- else:
315
- if cur:
316
- lines.append(' '.join(cur))
317
- cur = [w]
318
- if len(lines) >= max_lines:
319
- break
320
- if cur and len(lines) < max_lines:
321
- lines.append(' '.join(cur))
322
- return lines
323
-
324
-
325
- def _gen_short_sync(post) -> str:
326
- """Sync wrapper - returns video URL."""
327
- work = os.path.join(SHORTS_DIR, f"work_{post.get('id', int(time.time()))}")
328
- os.makedirs(work, exist_ok=True)
329
- result = _gen_short_core(post, work)
330
- if result:
331
- # Update wall
332
- try:
333
- wall = base._load_ai_wall()
334
- for i, p in enumerate(wall):
335
- if str(p.get('id')) == str(post.get('id')):
336
- p['video'] = f'/api/ai/short-file/{post.get("id")}_nu_{hashlib.md5(str(post).encode()).hexdigest()[:8]}'
337
- wall[i] = p
338
- break
339
- base._save_ai_wall(wall)
340
- # Notify SSE for auto-update
341
- try:
342
- from auto_update_sse import notify_new_short
343
- notify_new_short(post)
344
- except:
345
- pass
346
- except Exception as e:
347
- _log.warning(f"Wall update: {e}")
348
- return result
349
- return ''
350
-
351
-
352
- # ===== REGISTER ENDPOINTS - MUST BE AT MODULE LEVEL =====
353
- @app.post('/api/ai/short/{post_id}')
354
- async def api_short_generate(post_id: str, request: Request):
355
- _log.info(f"POST /api/ai/short/{post_id}")
356
- wall = base._load_ai_wall()
357
- post = next((p for p in wall if str(p.get('id')) == str(post_id)), None)
358
- if not post:
359
- return JSONResponse({'error': 'Post not found in wall'}, status_code=404)
360
-
361
- if post.get('video'):
362
- return JSONResponse({'post': post, 'video': post['video'], 'status': 'done'})
363
-
364
- loop = asyncio.get_event_loop()
365
- result = await loop.run_in_executor(None, _gen_short_sync, post)
366
-
367
- if result:
368
- # Get the video URL from wall (updated in _gen_short_sync)
369
- wall = base._load_ai_wall()
370
- post = next((p for p in wall if str(p.get('id')) == str(post_id)), post)
371
- return JSONResponse({'post': post, 'video': post.get('video'), 'status': 'done'})
372
- return JSONResponse({'error': 'Video generation failed'}, status_code=500)
373
-
374
-
375
- @app.get('/api/ai/short-file/{file_id:path}')
376
- async def api_short_file(file_id: str):
377
- safe = re.sub(r'[^\w\-.]', '_', file_id)[:100]
378
- for fname in os.listdir(SHORTS_DIR) if os.path.isdir(SHORTS_DIR) else []:
379
- if fname.endswith('.mp4') and safe in fname:
380
- return FileResponse(os.path.join(SHORTS_DIR, fname), media_type='video/mp4')
381
- return JSONResponse({'error': 'Not found'}, status_code=404)
382
-
383
-
384
- # ===== SSE ENDPOINT FOR AUTO-UPDATE =====
385
- try:
386
- from auto_update_sse import sse_events as _sse_handler
387
- app.add_api_route('/api/events', _sse_handler, methods=['GET'])
388
- _log.info("SSE endpoint registered at /api/events")
389
- except Exception as e:
390
- _log.warning(f"SSE route not loaded: {e}")
391
-
392
-
393
- # Log startup
394
- _log.info("Short video endpoints registered")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ai_runtime_patch_fast.py CHANGED
@@ -1,5 +1,5 @@
1
  """Final patch v2: fix topic rewrite, remove duplicate short slide, full short interaction buttons."""
2
- import re, threading, time, json, os, asyncio, requests
3
  import ai_runtime_final6 as f6
4
  from ai_runtime_final6 import app, rt, f5, HTMLResponse, JSONResponse, Request, Query
5
  import html as html_lib
@@ -121,69 +121,6 @@ async def _tp(request:Request):
121
  post=f5.base.make_post(topic,text,img,'','topic_focused',sources=[s for s in src if s.get('url')]);post['images']=[img];post['source_details']=det
122
  ps=f5.base._load_ai_wall();ps.insert(0,post);f5.base._save_ai_wall(ps);return JSONResponse({'post':post})
123
 
124
- # ===== M3U IPTV Channels (FPT Play Bóng Đá live streams) =====
125
- _M3U_URL = "https://raw.githubusercontent.com/Love4vn/Stalker2M3U/refs/heads/1/live_schedule_Optimize.m3u"
126
- _m3u_cache = {"t": 0, "d": []}
127
-
128
- def _parse_m3u():
129
- """Fetch & parse the M3U playlist. Returns list of channel dicts."""
130
- r = requests.get(_M3U_URL, headers={"User-Agent": "Mozilla/5.0"}, timeout=20)
131
- if r.status_code != 200:
132
- return []
133
- lines = r.text.splitlines()
134
- out = []
135
- cur = {}
136
- for line in lines:
137
- line = line.strip()
138
- if line.startswith("#EXTINF"):
139
- t_match = re.search(r',(.+)$', line)
140
- cur = {
141
- "title": t_match.group(1).strip() if t_match else "Channel",
142
- "id": "",
143
- "logo": "",
144
- "group": "",
145
- "url": "",
146
- "vlcopts": {},
147
- }
148
- for attr_match in re.finditer(r'(\w+)="([^"]*)"', line):
149
- k, v = attr_match.group(1), attr_match.group(2)
150
- if k == "tvg-id":
151
- cur["id"] = v
152
- elif k == "tvg-logo":
153
- cur["logo"] = v
154
- elif k == "group-title":
155
- cur["group"] = v
156
- elif line.startswith("#EXTVLCOPT:"):
157
- key_part = line[len("#EXTVLCOPT:"):]
158
- if "=" in key_part:
159
- k, v = key_part.split("=", 1)
160
- cur.setdefault("vlcopts", {})[k.strip()] = v.strip()
161
- elif line and not line.startswith("#") and cur:
162
- cur["url"] = line
163
- out.append(cur)
164
- cur = {}
165
- # Deduplicate by URL (same channel may have multiple stalker entries)
166
- seen = set()
167
- deduped = []
168
- for c in out:
169
- if c["url"] not in seen:
170
- seen.add(c["url"])
171
- deduped.append(c)
172
- return deduped
173
-
174
- @app.get('/api/m3u/channels')
175
- def api_m3u_channels(refresh: int = Query(default=0)):
176
- n = int(time.time())
177
- if not refresh and _m3u_cache["d"] and n - _m3u_cache["t"] < 300:
178
- return JSONResponse({"channels": _m3u_cache["d"], "updated": _m3u_cache["t"]})
179
- try:
180
- channels = _parse_m3u()
181
- _m3u_cache["t"] = n
182
- _m3u_cache["d"] = channels
183
- return JSONResponse({"channels": channels, "updated": n})
184
- except Exception as e:
185
- return JSONResponse({"channels": _m3u_cache["d"], "updated": _m3u_cache["t"], "error": str(e)})
186
-
187
  PATCH_INJECT=r'''
188
  <style>
189
  .short-cmt-panel{position:fixed;bottom:0;left:0;right:0;max-height:55vh;background:#181818;border-radius:16px 16px 0 0;z-index:99999;padding:14px;display:none;overflow-y:auto}.short-cmt-panel.active{display:block}.short-cmt-panel textarea{width:100%;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px;margin:6px 0;min-height:60px}.short-cmt-panel button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin:4px}.cmt-item{background:#222;border-radius:8px;padding:7px;margin:5px 0;color:#ccc;font-size:12px}
@@ -194,18 +131,6 @@ button[onclick*="rewriteCurrentArticle"]{display:none!important}
194
  /* Hide ALL old Short AI slides from previous layers */
195
  #ai-short-home,.ai-short-home,.ai-short-card-final{display:none!important}
196
  .source-detail-box a[target="_blank"]{display:none!important}
197
- /* M3U IPTV grid */
198
- .m3u-grid{padding:12px}
199
- .m3u-group-label{color:#5cb87a;font-size:14px;font-weight:700;margin:10px 0 6px 4px}
200
- .m3u-group-track{display:grid;grid-template-columns:repeat(2,1fr);gap:8px}
201
- .m3u-channel-card{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:10px;padding:8px;cursor:pointer;transition:0.2s;position:relative}
202
- .m3u-channel-card:hover{border-color:#5cb87a;transform:translateY(-1px)}
203
- .m3u-channel-thumb{width:100%;height:60px;background:#222;border-radius:6px;overflow:hidden;display:flex;align-items:center;justify-content:center}
204
- .m3u-channel-thumb img{width:100%;height:100%;object-fit-cover}
205
- .m3u-no-logo{font-size:24px;color:#5cb87a}
206
- .m3u-channel-title{font-size:11px;color:#ccc;margin-top:4px;line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
207
- .badge-live{background:#ff4444 !important;color:#fff !important;animation:pulse 1.5s infinite}
208
- @keyframes pulse{0%,100%{opacity:1}50%{opacity:0.6}}
209
  </style>
210
  <div id="short-cmt-panel" class="short-cmt-panel"></div>
211
  <script>
@@ -250,163 +175,6 @@ let oldRA=window.readArticle;if(oldRA){window.readArticle=async function(){let r
250
  let _hl=false;function dH(){if(_hl)return;_hl=true;setTimeout(()=>{if(typeof ensureHotTopics==='function')ensureHotTopics();if(typeof ensureNewsShortsHome==='function')ensureNewsShortsHome();},4000);}
251
  if(document.readyState==='complete')dH();else window.addEventListener('load',dH);
252
  setInterval(()=>{patchArticle();patchShortBtns();patchOldSourceLinks();},1500);
253
-
254
- // === M3U IPTV Player — FPT Play Bóng Đá live streams ===
255
- var _m3uChannels = [];
256
- async function loadM3UChannels(){
257
- var el = document.getElementById('view-m3u');
258
- if(!el) return;
259
- el.innerHTML = '<div class="loading">Đang tải danh sách kênh trực tiếp...</div><button class="back-btn" onclick="switchCat(\'home\')">← Trang chủ</button>';
260
- try{
261
- var r = await fetch('/api/m3u/channels');
262
- var j = await r.json();
263
- _m3uChannels = j.channels || [];
264
- }catch(e){
265
- el.innerHTML = '<button class="back-btn" onclick="switchCat(\'home\')">← Trang chủ</button><div class="loading">Lỗi tải kênh: '+esc(e.message||e)+'</div>';
266
- return;
267
- }
268
- if(!_m3uChannels.length){
269
- el.innerHTML = '<button class="back-btn" onclick="switchCat(\'home\')">← Trang chủ</button><div class="loading">Không có kênh nào</div>';
270
- return;
271
- }
272
- // Group channels by group-title
273
- var groups = {};
274
- _m3uChannels.forEach(function(c){
275
- var g = c.group || 'Khác';
276
- if(!groups[g]) groups[g] = [];
277
- groups[g].push(c);
278
- });
279
- var html = '<button class="back-btn" onclick="switchCat(\'home\')">← Trang chủ</button>';
280
- html += '<div class="m3u-grid">';
281
- Object.keys(groups).forEach(function(gname){
282
- html += '<div class="m3u-group-label">'+esc(gname)+'</div>';
283
- html += '<div class="m3u-group-track">';
284
- groups[gname].forEach(function(ch, i){
285
- var logo = ch.logo || '';
286
- var imgTag = logo ? '<img src="'+esc(logo)+'" loading="lazy" onerror="this.style.display=\'none\'">' : '<div class="m3u-no-logo">📺</div>';
287
- var title = (ch.title || 'Kênh').replace(/\s{2,}/g, ' ').trim();
288
- html += '<div class="m3u-channel-card" onclick="openM3UPlayer('+_m3uChannels.indexOf(ch)+')" title="'+esc(title)+'">'
289
- + '<div class="m3u-channel-thumb">'+imgTag+'</div>'
290
- + '<div class="m3u-channel-title">'+esc(title)+'</div>'
291
- + '</div>';
292
- });
293
- html += '</div>';
294
- });
295
- html += '</div>';
296
- el.innerHTML = html;
297
- }
298
-
299
- function openM3UPlayer(chIdx){
300
- var ch = _m3uChannels[chIdx];
301
- if(!ch) return;
302
- var streamUrl = ch.url || '';
303
- if(!streamUrl) return toast('Không có link stream');
304
- // Build the full ordered list: clicked channel first
305
- var ordered = [];
306
- for(var i = chIdx; i < _m3uChannels.length; i++) ordered.push(_m3uChannels[i]);
307
- for(var i = 0; i < chIdx; i++) ordered.push(_m3uChannels[i]);
308
- // Build TikTok-style viewer with MPEG-TS streams proxied through /api/proxy/seg
309
- // MPEG-TS over HTTP is progressive — we use <video> with the proxied URL
310
- // We need to proxy the stream URL through our server to avoid CORS
311
- var h = '<button class="back-btn" onclick="closeM3UViewer()">← Kênh khác</button>';
312
- h += '<div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">';
313
- ordered.forEach(function(ch, i){
314
- var streamUrl = ch.url || '';
315
- var proxyUrl = '';
316
- if(streamUrl){
317
- proxyUrl = '/api/proxy/seg?url=' + encodeURIComponent(streamUrl);
318
- }
319
- var logo = ch.logo || '';
320
- var imgTag = logo ? '<img src="'+esc(logo)+'" onerror="this.style.display=\'none\'">' : '';
321
- var vtag = '<div class="yt-thumb-wrap" style="'+(logo?('background-image:url(\''+esc(logo)+'\')'):'')+'">'
322
- + '<video playsinline preload="none" data-m3u="' + esc(proxyUrl) + '" loop'
323
- + (imgTag ? ' poster="' + esc(logo) + '"' : '')
324
- + ' style="width:100%;height:100%;object-fit:contain;background:#000">'
325
- + '</video></div>';
326
- var title = (ch.title || 'Kênh').replace(/\s{2,}/g, ' ').trim();
327
- h += buildTikTokSlide({
328
- vtag: vtag,
329
- title: title,
330
- badge: 'LIVE',
331
- badgeClass: 'badge-live',
332
- videoId: 'm3u-' + i,
333
- idx: i,
334
- total: ordered.length,
335
- shareUrl: streamUrl || location.href,
336
- postId: '',
337
- extraBtn: '<button class="tiktok-right-btn" onclick="event.stopPropagation();copyStreamLink(' + chIdx + ')" title="Sao chép link stream"><div class="icon">🔗</div></button>'
338
- });
339
- });
340
- h += '</div></div>';
341
- showView('view-tiktok');
342
- var el = document.getElementById('view-tiktok');
343
- if(el) el.innerHTML = h;
344
- setTimeout(function(){ if(typeof initTikTokFeed === 'function') initTikTokFeed(); }, 200);
345
- }
346
-
347
- function closeM3UViewer(){
348
- showView('view-m3u');
349
- var feed = document.getElementById('tiktok-feed');
350
- if(feed){ feed.innerHTML = ''; }
351
- document.querySelectorAll('video[data-m3u]').forEach(function(v){
352
- if(v._m3uRetry) clearTimeout(v._m3uRetry);
353
- if(v._hls){ v._hls.destroy(); v._hls = null; }
354
- v.pause();
355
- v.src = '';
356
- });
357
- document.querySelectorAll('iframe[data-yt-src]').forEach(function(f){ f.src = ''; });
358
- }
359
-
360
- function copyStreamLink(chIdx){
361
- var ch = _m3uChannels[chIdx];
362
- if(!ch || !ch.url) return toast('Không có link stream');
363
- var shareUrl = ch.url + (ch.vlcopts && ch.vlcopts['http-header'] ? ' (có Authorization header)' : '');
364
- if(navigator.clipboard && navigator.clipboard.writeText){
365
- navigator.clipboard.writeText(ch.url).then(function(){ toast('📋 Đã sao chép link stream!'); }).catch(function(){
366
- prompt('📋 Link stream:', ch.url);
367
- });
368
- }else{
369
- prompt('📋 Link stream:', ch.url);
370
- }
371
- }
372
-
373
- // Patch initTikTokFeed to handle MPEG-TS streams (video[data-m3u]) — same as Short AI <video>
374
- let _origInitTikTok = null;
375
- (function(){
376
- // Override: after initTikTokFeed builds the feed, also handle data-m3u videos
377
- var orig = window.initTikTokFeed;
378
- if(orig && !orig._patched){
379
- window.initTikTokFeed = function(){
380
- // Store reference to original initTikTokFeed's interval
381
- orig.call(this);
382
- // Patch the video play/pause logic to handle MPEG-TS streams
383
- // The existing initTikTokFeed already handles <video> elements via play/pause
384
- // so data-m3u videos will work — we just need to set src when active
385
- var feed = document.getElementById('tiktok-feed');
386
- if(!feed) return;
387
- var slides = feed.querySelectorAll('.tiktok-slide');
388
- var checkM3U = function(){
389
- slides.forEach(function(sl){
390
- var v = sl.querySelector('video[data-m3u]');
391
- if(!v) return;
392
- if(sl._active && !v.src && v.dataset.m3u){
393
- v.src = v.dataset.m3u;
394
- // For MPEG-TS, we rely on browser native support (Safari/Edge) or
395
- // the stream being playable via the proxy
396
- v.play().catch(function(){});
397
- } else if(!sl._active && v.src){
398
- v.pause();
399
- }
400
- });
401
- };
402
- checkM3U();
403
- var int = setInterval(checkM3U, 1000);
404
- // Clear after 60s
405
- setTimeout(function(){ clearInterval(int); }, 60000);
406
- };
407
- window.initTikTokFeed._patched = true;
408
- }
409
- })();
410
  })();
411
  </script>
412
  '''
 
1
  """Final patch v2: fix topic rewrite, remove duplicate short slide, full short interaction buttons."""
2
+ import re, threading, time, json, os, asyncio
3
  import ai_runtime_final6 as f6
4
  from ai_runtime_final6 import app, rt, f5, HTMLResponse, JSONResponse, Request, Query
5
  import html as html_lib
 
121
  post=f5.base.make_post(topic,text,img,'','topic_focused',sources=[s for s in src if s.get('url')]);post['images']=[img];post['source_details']=det
122
  ps=f5.base._load_ai_wall();ps.insert(0,post);f5.base._save_ai_wall(ps);return JSONResponse({'post':post})
123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  PATCH_INJECT=r'''
125
  <style>
126
  .short-cmt-panel{position:fixed;bottom:0;left:0;right:0;max-height:55vh;background:#181818;border-radius:16px 16px 0 0;z-index:99999;padding:14px;display:none;overflow-y:auto}.short-cmt-panel.active{display:block}.short-cmt-panel textarea{width:100%;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px;margin:6px 0;min-height:60px}.short-cmt-panel button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin:4px}.cmt-item{background:#222;border-radius:8px;padding:7px;margin:5px 0;color:#ccc;font-size:12px}
 
131
  /* Hide ALL old Short AI slides from previous layers */
132
  #ai-short-home,.ai-short-home,.ai-short-card-final{display:none!important}
133
  .source-detail-box a[target="_blank"]{display:none!important}
 
 
 
 
 
 
 
 
 
 
 
 
134
  </style>
135
  <div id="short-cmt-panel" class="short-cmt-panel"></div>
136
  <script>
 
175
  let _hl=false;function dH(){if(_hl)return;_hl=true;setTimeout(()=>{if(typeof ensureHotTopics==='function')ensureHotTopics();if(typeof ensureNewsShortsHome==='function')ensureNewsShortsHome();},4000);}
176
  if(document.readyState==='complete')dH();else window.addEventListener('load',dH);
177
  setInterval(()=>{patchArticle();patchShortBtns();patchOldSourceLinks();},1500);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  })();
179
  </script>
180
  '''
ai_runtime_patch_final.py DELETED
@@ -1,78 +0,0 @@
1
- """Final patch: homepage fix + AI topics at top + SSE auto-update"""
2
- import re, json, time
3
- from fastapi.responses import HTMLResponse, JSONResponse
4
- from fastapi import Query
5
-
6
- # Import chain - must be after ai_runtime_final6
7
- try:
8
- import ai_runtime_final6 as f6
9
- from ai_runtime_final6 import app, f5
10
- from main import rt
11
- except Exception as e:
12
- print(f"[ERROR] f6 import: {e}")
13
- f6 = None
14
- f5 = None
15
- rt = None
16
-
17
- PATCH_CSS_JS = r'''
18
- <style>
19
- .short-cmt-panel{position:fixed;bottom:0;left:0;right:0;max-height:55vh;background:#181818;border-radius:16px 16px 0 0;z-index:99999;padding:14px;display:none;overflow-y:auto}.short-cmt-panel.active{display:block}.short-cmt-panel textarea{width:100%;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px;margin:6px 0;min-height:60px}.short-cmt-panel button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin:4px}.cmt-item{background:#222;border-radius:8px;padding:7px;margin:5px 0;color:#ccc;font-size:12px}
20
- .storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 0}
21
- button[onclick*="rewriteCurrentArticle"]{display:none!important}
22
- #ai-short-home,.ai-short-home,.ai-short-card-final{display:none!important}
23
- </style>
24
- <div id="short-cmt-panel" class="short-cmt-panel"></div>
25
- <script>
26
- (function(){
27
- function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
28
- fetch('/api/storage_status').then(r=>r.json()).then(j=>{if(!j.persistent){let h=document.getElementById('view-home');if(h){let w=document.createElement('div');w.className='storage-warn';w.innerHTML='⚠️ Persistent Storage chưa bật. Bật: Space Settings → Persistent Storage → Small.';h.prepend(w);}}});
29
-
30
- // ===== AI HOT TOPICS PREPEND =====
31
- const AI_HOT_TOPICS = ['Công nghệ AI', 'World Cup 2026', 'Kinh tế Việt Nam', 'Bóng đá châu Âu'];
32
- async function ensureHotTopics(){let inp=document.getElementById('ai-topic-input-final5');if(!inp||document.getElementById('hot-topic-row-ai'))return;let row=document.createElement('div');row.id='hot-topic-row-ai';row.style.cssText='display:flex;gap:6px;overflow-x:auto;padding:4px 0;margin:6px 0';let topics=[];try{let j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));topics=j.topics||[];}catch(e){topics=[];}AI_HOT_TOPICS.forEach(ai=>{if(!topics.find(t=>(t.topic||'').toLowerCase()===ai.toLowerCase())){topics.unshift({label:'#'+ai.replace(/\s+/g,''),topic:ai,count:0});}});row.innerHTML=topics.slice(0,14).map(t=>`<button class="hot-chip" style="flex:0 0 auto;background:#222;border:1px solid #333;color:#ddd;border-radius:16px;padding:5px 10px;font-size:11px;cursor:pointer" onclick="document.getElementById('ai-topic-input-final5').value='${esc(t.topic).replace(/'/g,'\\''}';document.getElementById('ai-topic-input-final5').focus();searchTopic('${esc(t.topic).replace(/'/g,'\\''}')">${esc(t.label)}</button>`).join('');inp.insertAdjacentElement('afterend',row);}
33
-
34
- // ===== SSE AUTO UPDATE =====
35
- let _sseSource=null;
36
- function connectSSE(){try{_sseSource=new EventSource('/api/events');_sseSource.onmessage=e=>{try{const d=JSON.parse(e.data);if(d.type==='new_post'||d.type==='new_short'){fetch('/api/ai_wall').then(r=>r.json()).then(j=>{_wallPosts=j.posts||[];if(typeof renderShortAISlide==='function')renderShortAISlide();const track=document.getElementById('ai-wall-track');if(track)_wallPosts.length?track.innerHTML=_wallPosts.slice(0,20).map((p,i)=>makeWallItem(p,i)).join(''):null;});}}catch{}};_sseSource.onerror=()=>setTimeout(connectSSE,5000);}catch(e){}}
37
- let _lastWallLen=0;
38
- setInterval(()=>{fetch('/api/ai_wall').then(r=>r.json()).then(j=>{const w=j.posts||[];if(w.length!==_lastWallLen){_lastWallLen=w.length;_wallPosts=w;const track=document.getElementById('ai-wall-track');if(track)w.length?track.innerHTML=w.slice(0,20).map((p,i)=>makeWallItem(p,i)).join(''):null;if(typeof renderShortAISlide==='function')renderShortAISlide();}}).catch(()=>{});},30000);
39
- connectSSE();
40
-
41
- // ===== Short AI slide =====
42
- async function renderShortAISlide(){let home=document.getElementById('view-home');if(!home)return;document.getElementById('short-ai-final-slide')?.remove();let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];let vids=wall.filter(p=>p.video);if(!vids.length)return;let wrap=document.createElement('div');wrap.id='short-ai-final-slide';wrap.className='slider-wrap';wrap.innerHTML='<div class="slider-header"><span class="slider-label">🎬 Short AI</span></div><div class="slider-track">'+vids.slice(0,30).map((p,i)=>`<div class="slider-item shorts-item" onclick="openAIShortFeed(${i})"><div class="slider-thumb shorts-thumb"><video src="${p.video}" muted preload="metadata" style="width:100%;height:100%;object-fit:cover"></video><div class="card-play">▶</div></div><div class="slider-title">${esc(p.title)}</div></div>`).join('')+'</div>';let comp=home.querySelector('.ai-compose');(comp&&comp.nextSibling?comp.parentNode.insertBefore(wrap,comp.nextSibling):home.prepend(wrap));}
43
- setTimeout(renderShortAISlide,2500);
44
-
45
- setInterval(ensureHotTopics,2000);
46
- })();
47
- </script>
48
- '''
49
-
50
- # Register route if possible
51
- if f6 and app:
52
- try:
53
- # Remove duplicate / route to avoid conflict
54
- original_routes = [r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
55
- app.router.routes = original_routes
56
-
57
- @app.get('/')
58
- async def patch_homepage():
59
- html = f5.f4.f3.f2.f1._load_index_html() if f5 else "<html><body></body></html>"
60
- body = ""
61
- if hasattr(rt,'old') and hasattr(rt.old,'PATCH_INJECT'):
62
- body += getattr(rt.old,'PATCH_INJECT','')
63
- if f5:
64
- body += getattr(f5.f4.f3.f2.f1,'FINAL_INJECT','') if hasattr(f5,'f4') else ''
65
- body += getattr(f5.f4.f3,'FINAL3_INJECT','') if hasattr(f5,'f4') else ''
66
- body += getattr(f5.f4,'FINAL4_INJECT','') if hasattr(f5,'f4') else ''
67
- body += getattr(f5,'FINAL5_INJECT','') if hasattr(f5,'f4') else ''
68
- body += getattr(f6,'FINAL6_INJECT','') if f6 else ''
69
- body += getattr(f6,'FINAL6_FAST_HOME_INJECT','') if f6 else ''
70
- body += getattr(f6,'FINAL6E_INJECT','') if f6 else ''
71
- body += PATCH_CSS_JS
72
- if '</body>' in html:
73
- html = html.replace('</body>', body + '\n</body>')
74
- else:
75
- html = html + body
76
- return HTMLResponse(html)
77
- except Exception as e:
78
- print(f"[ERROR] register route: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ai_short_v2.py DELETED
@@ -1,2083 +0,0 @@
1
- """VNEWS Short AI v2 — Short creator with TikTok background music, uploaded audio,
2
- uploaded video/image background, and 'recreate from designed slides' (image-only,
3
- reusing previous short audio, NO text overlays) mode.
4
-
5
- Registered by app_v2_entry.py (import ai_short_v2). Uses the SAME wall store
6
- (WALL_FILE) as the wall endpoints so posts created by the designer survive and
7
- can be re-shorted purely from designed images.
8
- """
9
- import os
10
- import re
11
- import json
12
- import time
13
- import uuid
14
- import hashlib
15
- import subprocess
16
- import threading
17
-
18
- import requests
19
- from urllib.parse import quote as urllib_quote
20
- from fastapi import Request, UploadFile, File, Query
21
- from fastapi.responses import JSONResponse, FileResponse, Response, StreamingResponse
22
-
23
- try:
24
- import yt_dlp
25
- except Exception: # pragma: no cover
26
- yt_dlp = None
27
-
28
- try:
29
- from PIL import Image, ImageDraw, ImageFont
30
- except Exception: # pragma: no cover
31
- Image = ImageDraw = ImageFont = None
32
-
33
- try:
34
- from main import app
35
- except Exception: # pragma: no cover
36
- from fastapi import FastAPI
37
- app = FastAPI()
38
-
39
- DATA_DIR = "/data" if os.path.isdir("/data") else os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
40
- WALL_FILE = os.path.join(DATA_DIR, "wall_posts.json")
41
- SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts")
42
- UPLOAD_DIR = os.path.join(DATA_DIR, "short_uploads")
43
- WALL_IMG_DIR = os.path.join(DATA_DIR, "wall_imgs")
44
- os.makedirs(SHORTS_DIR, exist_ok=True)
45
- os.makedirs(UPLOAD_DIR, exist_ok=True)
46
-
47
- _wl_lock = threading.Lock()
48
-
49
- UA_HEADERS = {
50
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
51
- "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
52
- "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8",
53
- }
54
-
55
- # ---------------------------------------------------------------------------
56
- # Video scraping (YouTube / TikTok / news sites) via yt-dlp + oEmbed fallback
57
- # ---------------------------------------------------------------------------
58
- _SCRAPE_CACHE = {} # url -> {ok, ...} (in-memory, TTL 30 min)
59
- _SCRAPE_TTL = 30 * 60
60
-
61
- YTDLP_OPTS = {
62
- "format": "best[height<=720][ext=mp4]/best[height<=720]/best",
63
- "quiet": True,
64
- "no_warnings": True,
65
- "noplaylist": True,
66
- "socket_timeout": 30,
67
- "retries": 5,
68
- "fragment_retries": 5,
69
- "ignoreerrors": False,
70
- "extractor_args": {"youtube": {"player_client": ["tv", "ios", "mweb"]}},
71
- "http_headers": UA_HEADERS,
72
- }
73
-
74
-
75
- # ---------------------------------------------------------------------------
76
- # YouTube cookies (bypasses the "Sign in to confirm you're not a bot" block).
77
- # Sources, in priority order:
78
- # 1. YT_COOKIES env var (HF Space secret — raw Netscape-format cookies.txt)
79
- # 2. /app/cookies.txt (a cookies.txt file baked into the repo/runtime)
80
- # yt-dlp reads a Netscape cookies.txt via the 'cookiefile' option.
81
- # ---------------------------------------------------------------------------
82
- _cookie_file = None
83
-
84
-
85
- def _ensure_cookies():
86
- """Materialise a cookies.txt (Netscape format) for yt-dlp if any cookie
87
- source is available. Returns the cookiefile path or None."""
88
- global _cookie_file
89
- if _cookie_file and os.path.exists(_cookie_file):
90
- return _cookie_file
91
- content = None
92
- # 1. HF Space secret YT_COOKIES (raw cookies.txt content)
93
- secret = os.environ.get("YT_COOKIES", "").strip()
94
- if secret and ("# Netscape" in secret or "#HTTP" in secret or "youtube.com" in secret or "\tTRUE" in secret):
95
- content = secret
96
- # 2. baked-in file
97
- if not content:
98
- for p in ("/app/cookies.txt", "cookies.txt"):
99
- if os.path.exists(p):
100
- content = open(p, encoding="utf-8", errors="ignore").read()
101
- break
102
- if not content:
103
- _cookie_file = None
104
- return None
105
- try:
106
- path = os.path.join(DATA_DIR, "yt_cookies.txt")
107
- os.makedirs(os.path.dirname(path), exist_ok=True)
108
- with open(path, "w", encoding="utf-8") as f:
109
- f.write(content)
110
- _cookie_file = path
111
- return path
112
- except Exception:
113
- return None
114
-
115
-
116
- def _ytdlp_opts():
117
- """YTDLP opts + cookiefile (only when cookies are available)."""
118
- opts = dict(YTDLP_OPTS)
119
- cf = _ensure_cookies()
120
- if cf:
121
- opts["cookiefile"] = cf
122
- # also add "cookiesfrombrowser" fallback? No — datacenter IPs can't read
123
- # a local browser. cookiefile is the reliable path.
124
- return opts
125
-
126
-
127
- def _oembed_probe(url):
128
- """Cheap metadata fallback for TikTok/Facebook/Instagram/YouTube (no direct URL)."""
129
- try:
130
- host = (re.sub(r"^https?://", "", url).split("/")[0] or "").lower()
131
- api = None
132
- if "tiktok" in host:
133
- api = "https://www.tiktok.com/oembed?url=" + urllib_quote(url, safe="")
134
- elif "facebook" in host or "fb.watch" in host:
135
- api = "https://www.facebook.com/plugins/video/oembed.json?url=" + urllib_quote(url, safe="")
136
- elif "youtube.com" in host or "youtu.be" in host:
137
- api = "https://www.youtube.com/oembed?url=" + urllib_quote(url, safe="") + "&format=json"
138
- if not api:
139
- return None
140
- # Facebook oEmbed often times out from datacenter IPs; use a shorter timeout
141
- _timeout = 8 if ("facebook" in host or "fb.watch" in host) else 12
142
- j = {}
143
- try:
144
- r = requests.get(api, headers=UA_HEADERS, timeout=_timeout)
145
- if r.status_code == 200:
146
- j = r.json() or {}
147
- except Exception:
148
- j = {}
149
- # YouTube: youtube.com/oembed is often SNI-blocked from datacenter IPs.
150
- # Fall back to _yt_meta (noembed.com cascade) which reliably returns
151
- # title+thumbnail+description from restricted networks.
152
- yt_meta = None
153
- if "youtube.com" in host or "youtu.be" in host:
154
- yt_meta = _yt_meta(url)
155
- title = ((j.get("title") or "") or (yt_meta or {}).get("title") or "").strip()[:200]
156
- thumb = ((j.get("thumbnail_url") or "") or (yt_meta or {}).get("thumbnail") or "").strip()
157
- desc = (yt_meta or {}).get("description") or ""
158
- if "youtube.com" in host or "youtu.be" in host:
159
- if not desc:
160
- desc = (yt_meta or {}).get("description") or ""
161
- embed_url = _oembed_embed_url(url)
162
- # oEmbed providers (notably TikTok short links like vm.tiktok.com) don't
163
- # expose the video ID in the URL, but the `html` field contains the
164
- # canonical <iframe src> — parse it so short links still embed.
165
- if not embed_url:
166
- try:
167
- m_html = re.search(r'<iframe[^>]+src="([^"]+)"', ((j or {}).get("html") or ""))
168
- if m_html:
169
- embed_url = m_html.group(1).replace("&amp;", "&")
170
- if "?" in embed_url and "autoplay" not in embed_url:
171
- embed_url += "&autoplay=1&rel=0"
172
- except Exception:
173
- pass
174
- return {
175
- "ok": True,
176
- "title": title[:200],
177
- "thumbnail": thumb,
178
- "duration": None,
179
- "extractor": "oembed",
180
- "direct_url": None,
181
- "previewable": False, # no direct streamable URL from oEmbed alone
182
- "embed_url": embed_url,
183
- "description": desc[:2000],
184
- "note": "Có thể lấy được thông tin, nhưng không tải được video trực tiếp từ link này.",
185
- }
186
- except Exception:
187
- return None
188
-
189
-
190
- def _yt_meta(url):
191
- """Fetch YouTube video metadata (title/description/thumbnail).
192
-
193
- Cascades over several providers so at least title+thumbnail always come
194
- back even from datacenter IPs where www.youtube.com is SNI-blocked:
195
- 1. noembed.com — reliable title+thumb (works from restricted IPs)
196
- 2. youtube oEmbed — title + thumbnail_url
197
- 3. watch page og:tags — title + description + thumbnail (best-effort)
198
- Description is best-effort (YT oEmbed has no description field); when
199
- unavailable the caller falls back to the title. Returns dict or None."""
200
- try:
201
- vid = _yt_id_from_url(url)
202
- if not vid:
203
- return None
204
- out = {"video_id": vid, "title": "", "description": "", "thumbnail": ""}
205
- watch = f"https://www.youtube.com/watch?v={vid}"
206
-
207
- # 1) noembed (title+thumb, no desc)
208
- try:
209
- r = requests.get(f"https://noembed.com/embed?url={urllib_quote(watch, safe='')}",
210
- headers=UA_HEADERS, timeout=15)
211
- if r.status_code == 200:
212
- j = r.json() or {}
213
- out["title"] = (j.get("title") or "").strip()[:250]
214
- out["thumbnail"] = (j.get("thumbnail_url") or "").strip()
215
- except Exception:
216
- pass
217
-
218
- # 2) youtube oEmbed (title+thumb)
219
- if not out["title"]:
220
- try:
221
- r2 = requests.get(f"https://www.youtube.com/oembed?url={urllib_quote(watch, safe='')}&format=json",
222
- headers=UA_HEADERS, timeout=15)
223
- if r2.status_code == 200:
224
- j2 = r2.json() or {}
225
- out["title"] = (j2.get("title") or "").strip()[:250]
226
- out["thumbnail"] = (j2.get("thumbnail_url") or "").strip()
227
- except Exception:
228
- pass
229
-
230
- # 3) watch page og:tags (title + description + thumb, best-effort)
231
- try:
232
- r3 = requests.get(watch, headers=UA_HEADERS, timeout=20)
233
- if r3.status_code == 200:
234
- h3 = r3.text
235
- def _og(prop):
236
- mm = re.search(r'<meta[^>]+property="og:' + prop + r'"[^>]+content="([^"]*)"', h3) or \
237
- re.search(r'<meta[^>]+content="([^"]*)"[^>]+property="og:' + prop + r'"', h3)
238
- return mm.group(1).strip() if mm else ""
239
- def _nm(name):
240
- mm = re.search(r'<meta[^>]+name="' + name + r'"[^>]+content="([^"]*)"', h3)
241
- return mm.group(1).strip() if mm else ""
242
- if not out["title"]:
243
- out["title"] = _og("title") or _nm("title")
244
- out["description"] = _og("description") or _nm("description")
245
- if not out["thumbnail"]:
246
- out["thumbnail"] = _og("image") or ""
247
- if out["thumbnail"].startswith("//"):
248
- out["thumbnail"] = "https:" + out["thumbnail"]
249
- except Exception:
250
- pass
251
-
252
- # 4) r.jina.ai reader proxy — server-side fetch that renders the watch
253
- # page and returns Title/Description/Thumbnail lines as markdown.
254
- # Requires the Space origin in the Origin header (CORS check). Works
255
- # from datacenter IPs where youtube.com is SNI-blocked.
256
- if not out["description"] or not out["title"]:
257
- try:
258
- _hdrs = dict(UA_HEADERS)
259
- _hdrs["Origin"] = "https://bep40-vnews.hf.space"
260
- r4 = requests.get(f"https://r.jina.ai/{watch}", headers=_hdrs, timeout=30)
261
- if r4.status_code == 200:
262
- h4 = r4.text
263
- _tm = re.search(r'(?:^|\n)Title:\s*(.+)', h4)
264
- _dm = re.search(r'(?:^|\n)Description:\s*(.+)', h4, re.S)
265
- _im = re.search(r'(?:^|\n)Thumbnail:\s*(\S+)', h4)
266
- if _tm:
267
- out["title"] = _tm.group(1).strip()[:250]
268
- if _dm:
269
- out["description"] = _dm.group(1).strip()[:2000]
270
- if _im:
271
- th = _im.group(1).strip()
272
- if th.startswith("//"):
273
- th = "https:" + th
274
- out["thumbnail"] = th
275
- except Exception:
276
- pass
277
-
278
- if not out["title"] and not out["description"]:
279
- return None
280
- return {
281
- "video_id": vid,
282
- "title": (out["title"] or "").strip()[:250],
283
- "description": (out["description"] or "").strip()[:2000],
284
- "thumbnail": out["thumbnail"],
285
- }
286
- except Exception:
287
- return None
288
-
289
-
290
- def _yt_id_from_url(url):
291
- m = re.search(r'(?:youtube\.com/(?:embed|watch)\?v=|youtu\.be/|youtube-nocookie\.com/embed/)([a-zA-Z0-9_-]{11})', (url or ""))
292
- return m.group(1) if m else ""
293
-
294
-
295
- def _is_known_social(url):
296
- """True for platforms whose content is embeddable via oEmbed iframes."""
297
- try:
298
- host = (re.sub(r"^https?://", "", url or "").split("/")[0] or "").lower()
299
- return any(k in host for k in ("tiktok", "facebook", "fb.watch", "instagram", "youtube", "youtu.be", "vimeo", "twitter", "x.com"))
300
- except Exception:
301
- return False
302
-
303
-
304
- def _oembed_embed_url(url):
305
- """Derive a browser-embeddable iframe URL for social video links.
306
-
307
- Used as a Short HOT fallback when the direct media cannot be downloaded:
308
- the post is still created, and the TikTok-style feed renders it as an
309
- oEmbed iframe slide (exactly like the football video slides on YouTube)
310
- while the title/description/thumb come from the oEmbed metadata.
311
- """
312
- try:
313
- host = (re.sub(r"^https?://", "", url).split("/")[0] or "").lower()
314
- u = (url or "").strip()
315
- if "tiktok" in host:
316
- m = re.search(r"/(?:video|@[\w.\-]+/video|embed/v2)/(\d+)", u)
317
- if m:
318
- return f"https://www.tiktok.com/embed/v2/{m.group(1)}"
319
- return ""
320
- if "facebook" in host or "fb.watch" in host or "fb.gg" in host:
321
- return "https://www.facebook.com/plugins/video.php?href=" + urllib_quote(u, safe="") + "&show_text=0"
322
- if "instagram" in host:
323
- m = re.search(r"/p/([\w\-]+)/?", u)
324
- if m:
325
- return f"https://www.instagram.com/p/{m.group(1)}/embed/"
326
- if "youtube.com" in host or "youtu.be" in host:
327
- m = re.search(r"(?:v=|shorts/|embed/|youtu\.be/)([a-zA-Z0-9_-]{11})", u)
328
- if m:
329
- return f"https://www.youtube.com/embed/{m.group(1)}?autoplay=1&rel=0"
330
- if "x.com" in host or "twitter" in host:
331
- m = re.search(r"/status/(\d+)", u)
332
- if m:
333
- return f"https://platform.twitter.com/embed/Tweet.html?id={m.group(1)}&dnt=true"
334
- if "vimeo" in host:
335
- m = re.search(r"/(\d+)$", u)
336
- if m:
337
- return f"https://player.vimeo.com/video/{m.group(1)}"
338
- except Exception:
339
- pass
340
- return ""
341
-
342
-
343
- def _is_real_http(u):
344
- """True if a candidate URL is a real http(s) media URL (not a JS template)."""
345
- u = (u or "").strip()
346
- if not re.match(r"^https?://", u):
347
- return False
348
- if "'" in u or '"' in u or "+" in u or u.count("(") > 0:
349
- return False
350
- return True
351
-
352
-
353
- def _scrape_page_html(url):
354
- """Fallback scraper for news sites (24h, dantri, znews...): parse the article
355
- HTML for og:video / <video>/<source> / m3u8|mp4 regex inside JS configs
356
- (e.g. 24h 'playlistConf' blob). Returns info dict or None."""
357
- try:
358
- r = requests.get(url, headers=UA_HEADERS, timeout=25)
359
- if r.status_code != 200:
360
- return None
361
- r.encoding = "utf-8"
362
- html = r.text
363
- except Exception:
364
- return None
365
- title = ""
366
- mt = re.search(r'<meta[^>]+property="og:title"[^>]+content="([^"]+)"', html) or \
367
- re.search(r'<meta[^>]+content="([^"]+)"[^>]+property="og:title"', html) or \
368
- re.search(r"<title[^>]*>([^<]+)</title>", html)
369
- if mt:
370
- title = re.sub(r"\s+", " ", mt.group(1)).strip()[:250]
371
-
372
- # 1) og:video
373
- cands = []
374
- for pat in (r'property="og:video[^"]*"\s+content="([^"]+)"',
375
- r'content="([^"]+)"\s+property="og:video[^"]*"'):
376
- cands += re.findall(pat, html)
377
- # 2) <video>/<source> tags
378
- cands += re.findall(r'<source[^>]+src="([^"]+)"', html)
379
- cands += re.findall(r"<source[^>]+src='([^']+)'", html)
380
- cands += re.findall(r'<video[^>]+src="([^"]+)"', html)
381
- # 3) raw .mp4/.m3u8 URLs in the page (incl. JS playlistConf src:"...")
382
- cands += re.findall(r'https?://[^"\'\s<>\\]+?\.(?:m3u8|mp4)[^"\'\s<>\\]*', html)
383
- # 4) src: "..." inside JS (m3u8/mp4)
384
- cands += re.findall(r'src\s*:\s*["\']([^"\']+?\.(?:m3u8|mp4)[^"\']*)["\']', html)
385
-
386
- # 5) YouTube iframe embeds (e.g. bongdaplus.vn video pages embed highlights)
387
- for yt_iframe_src in re.findall(r'<iframe[^>]+src="([^"]*(?:youtube\.com/embed|youtu\.be/|youtube-nocookie\.com/embed)[^"]*)"[^>]*>', html, re.I):
388
- yt_iframe_src = yt_iframe_src.strip()
389
- if yt_iframe_src.startswith("//"): yt_iframe_src = "https:" + yt_iframe_src
390
- yt_id = re.search(r'(?:embed/|youtu\.be/|v=)([a-zA-Z0-9_-]{11})', yt_iframe_src)
391
- if yt_id:
392
- cands.append(f"https://www.youtube.com/embed/{yt_id.group(1)}?autoplay=1&rel=0&enablejsapi=1")
393
- else:
394
- cands.append(yt_iframe_src)
395
-
396
- # 6) YouTube embed URLs in JS strings (src: "https://youtube.com/embed/...")
397
- for yt_js in re.findall(r'src\s*:\s*["\']([^"\']*(?:youtube\.com/embed|youtu\.be/)[^"\']*)["\']', html, re.I):
398
- yt_js = yt_js.strip()
399
- if yt_js.startswith("//"): yt_js = "https:" + yt_js
400
- cands.append(yt_js)
401
-
402
- # normalize relative URLs + dedupe + prefer 720p m3u8 then mp4
403
- from urllib.parse import urljoin
404
- seen = set()
405
- good = []
406
- for c in cands:
407
- c = c.strip().strip('"').strip("'")
408
- if not c or not _is_real_http(c) and not c.startswith("/"):
409
- continue
410
- if c.startswith("/"):
411
- c = urljoin(url, c)
412
- if not c.startswith("http"):
413
- continue
414
- if c in seen:
415
- continue
416
- seen.add(c)
417
- good.append(c)
418
- if not good:
419
- return None
420
-
421
- # score: prefer m3u8 720p > m3u8 > mp4 720p > mp4
422
- def score(c):
423
- s = 0
424
- if "720p" in c or "720" in c:
425
- s += 20
426
- if ".m3u8" in c:
427
- s += 10
428
- if "1080" in c:
429
- s += 18
430
- if "480" in c:
431
- s += 8
432
- return s
433
- good.sort(key=score, reverse=True)
434
- direct = good[0]
435
-
436
- thumb = ""
437
- mt2 = re.search(r'<meta[^>]+property="og:image[^"]*"\s+content="([^"]+)"', html) or \
438
- re.search(r'<meta[^>]+content="([^"]+)"[^>]+property="og:image[^"]*"', html)
439
- if mt2:
440
- thumb = mt2.group(1).strip()
441
-
442
- is_hls = ".m3u8" in direct.lower()
443
- is_youtube = "youtube.com/embed" in direct.lower() or "youtu.be" in direct.lower() or "youtube-nocookie" in direct.lower()
444
- return {
445
- "ok": True,
446
- "url": url,
447
- "title": title,
448
- "duration": None,
449
- "thumbnail": thumb,
450
- "direct_url": direct,
451
- "extractor": "youtube" if is_youtube else "html-page",
452
- "previewable": True,
453
- "is_hls": is_hls,
454
- "note": "",
455
- }
456
-
457
-
458
- def _resolve_media_url(url):
459
- """Fetch the body of a media URL using the right headers (referer for news CDNs)."""
460
- HDRS = dict(UA_HEADERS)
461
- host = (re.sub(r"^https?://", "", url).split("/")[0] or "").lower()
462
- if "vnecdn" in host or "vnexpress" in host:
463
- HDRS["Referer"] = "https://vnexpress.net/"
464
- elif "cdn.24h" in host or "24h.com.vn" in host:
465
- HDRS["Referer"] = "https://www.24h.com.vn/"
466
- return HDRS
467
-
468
-
469
- def _scrape_video_info(url):
470
- """Extract direct video URL + metadata for a user-pasted link.
471
-
472
- Returns dict with keys: ok, url (original), title, duration, thumbnail,
473
- direct_url (proxy-able), extractor, previewable, note.
474
- """
475
- try:
476
- now = time.time()
477
- cached = _SCRAPE_CACHE.get(url)
478
- if cached and now - cached.get("_t", 0) < _SCRAPE_TTL:
479
- return cached
480
- except Exception:
481
- pass
482
-
483
- # Early-detect social/video platforms that yt-dlp handles slowly or not at all.
484
- # Facebook URLs block datacenter IPs → yt-dlp hangs for 15s+; skip it and
485
- # go straight to oEmbed metadata-only fallback.
486
- _host_l = (re.sub(r"^https?://", "", url).split("/")[0] or "").lower()
487
- _is_facebook = "facebook" in _host_l or "fb.watch" in _host_l
488
-
489
- out = None
490
- if yt_dlp is not None and not _is_facebook:
491
- try:
492
- with yt_dlp.YoutubeDL(_ytdlp_opts()) as ydl:
493
- info = ydl.extract_info(url, download=False)
494
- # pick best direct (non-m3u8) progressive URL
495
- direct = None
496
- if info:
497
- direct = info.get("url") or ""
498
- for f in info.get("formats") or []:
499
- proto = (f.get("protocol") or "")
500
- if f.get("url") and proto not in ("m3u8_native", "m3u8"):
501
- direct = f.get("url")
502
- break
503
- dur = info.get("duration") if info else None
504
- try:
505
- dur = float(dur) if dur is not None else None
506
- except Exception:
507
- dur = None
508
- extractor = (info.get("extractor_key") or info.get("extractor") or "generic") if info else "generic"
509
- is_hls = bool(direct) and ".m3u8" in direct.lower()
510
- # If yt-dlp returned a bogus JS template URL ('+d+' etc.), treat as
511
- # a scrape failure -> fall through to the HTML page parser.
512
- yt_bad = not _is_real_http(direct)
513
- out = {
514
- "ok": not yt_bad,
515
- "url": url,
516
- "title": (info.get("title") or "").strip()[:250] if info else "",
517
- "duration": dur,
518
- "thumbnail": (info.get("thumbnail") or "").strip() or "" if not yt_bad else "",
519
- "direct_url": direct or "",
520
- "extractor": extractor,
521
- "previewable": bool(direct) and not yt_bad,
522
- "is_hls": is_hls,
523
- "note": "",
524
- }
525
- except Exception as e:
526
- if getattr(e, "args", None):
527
- msg = str(e.args[0]) if isinstance(e.args[0], str) else str(e)
528
- else:
529
- msg = str(e)
530
- # split real error after '[xxx] '
531
- m = re.search(r"\]\s*(.+)$", msg)
532
- err = m.group(1) if m else msg[:200]
533
- out = {"ok": False, "url": url, "error": err[:200]}
534
-
535
- # YouTube-specific fallback: if yt-dlp fails (SSL/EOF, rate-limit),
536
- # extract video ID and return the embed iframe URL + thumbnail.
537
- # The frontend renders YouTube embed URLs as iframes.
538
- yt_id_match = re.search(r'(?:youtube\.com/(?:embed|watch)\?v=|youtu\.be/|youtube-nocookie\.com/embed/)([a-zA-Z0-9_-]{11})', url)
539
- if yt_id_match:
540
- yt_id = yt_id_match.group(1)
541
- out = {
542
- "ok": True,
543
- "url": url,
544
- "title": f"YouTube video {yt_id}",
545
- "duration": None,
546
- "thumbnail": f"https://i.ytimg.com/vi/{yt_id}/hqdefault.jpg",
547
- "direct_url": f"https://www.youtube.com/embed/{yt_id}?autoplay=1&rel=0&enablejsapi=1",
548
- "extractor": "youtube-fallback",
549
- "previewable": True,
550
- "is_hls": False,
551
- "note": "yt-dlp failed, using YouTube embed directly",
552
- }
553
-
554
- # YouTube enrichment: grab the watch-page meta tags (title/description/
555
- # thumbnail). yt-dlp often gives a title + mp4 URL but NO description, and
556
- # the fallback path gives a bare 'YouTube video {id}' — the og: tags from
557
- # the watch page fix both. Only fills gaps; keeps the existing direct_url.
558
- if "youtube.com" in _host_l or "youtu.be" in _host_l:
559
- _meta = _yt_meta(url)
560
- if _meta:
561
- if not out or not out.get("ok") or not (out.get("title") or "").strip() or "YouTube video" in (out.get("title") or ""):
562
- out_title = _meta.get("title") or ""
563
- else:
564
- out_title = (out.get("title") or "")
565
- desc = _meta.get("description") or ""
566
- thumb = _meta.get("thumbnail") or (out or {}).get("thumbnail", "")
567
- if not out:
568
- out = {"ok": True, "url": url}
569
- out["title"] = out_title or _meta.get("title") or (out.get("title") or "")
570
- out["description"] = desc
571
- if thumb:
572
- out["thumbnail"] = thumb
573
-
574
- # If yt-dlp failed / gave a bad URL, try parsing the article HTML directly
575
- # (handles 24h.com.vn JS configs, dantri, znews, some vnexpress pages).
576
- if not out or not out.get("ok") or not out.get("direct_url"):
577
- page = _scrape_page_html(url)
578
- if page:
579
- # keep yt-dlp title/duration for social sites, take media from page
580
- base = {"ok": True, "url": url,
581
- "title": page.get("title") or (out or {}).get("title", ""),
582
- "duration": (out or {}).get("duration") if (out or {}).get("direct_url") else page.get("duration"),
583
- "thumbnail": page.get("thumbnail") or (out or {}).get("thumbnail", ""),
584
- "direct_url": page.get("direct_url"),
585
- "extractor": page.get("extractor") or "html-page",
586
- "previewable": True,
587
- "is_hls": page.get("is_hls", False),
588
- "note": page.get("note", ""),
589
- }
590
- out = base
591
-
592
- # metadata-only fallback (oEmbed) — lets the user at least see title/thumb
593
- if not out or not out.get("ok"):
594
- probe = _oembed_probe(url)
595
- if probe:
596
- probe["url"] = url
597
- out = probe
598
-
599
- if not out:
600
- out = {"ok": False, "url": url, "error": "Không lấy được thông tin video từ link này."}
601
- if "is_hls" not in out:
602
- out["is_hls"] = bool(out.get("direct_url", "")) and ".m3u8" in (out.get("direct_url", "") or "").lower()
603
- try:
604
- out["_t"] = time.time()
605
- _SCRAPE_CACHE[url] = out
606
- if len(_SCRAPE_CACHE) > 200:
607
- for k in list(_SCRAPE_CACHE)[:50]:
608
- _SCRAPE_CACHE.pop(k, None)
609
- except Exception:
610
- pass
611
- return out
612
-
613
-
614
- def _load_wall_posts():
615
- try:
616
- with _wl_lock:
617
- if os.path.exists(WALL_FILE):
618
- with open(WALL_FILE, "r", encoding="utf-8") as f:
619
- return json.load(f) or []
620
- except Exception:
621
- pass
622
- return []
623
-
624
-
625
- def _save_wall_posts(posts):
626
- try:
627
- with _wl_lock:
628
- tmp = WALL_FILE + ".tmp"
629
- with open(tmp, "w", encoding="utf-8") as f:
630
- json.dump(posts, f, ensure_ascii=False)
631
- os.replace(tmp, WALL_FILE)
632
- except Exception:
633
- pass
634
-
635
-
636
- def _clean(s):
637
- s = re.sub(r"[ \t]+", " ", s or "")
638
- s = re.sub(r"\n{3,}", "\n\n", s)
639
- return s.strip()
640
-
641
-
642
- def _safe_name(name):
643
- return re.sub(r"[^a-zA-Z0-9_.-]", "_", name)[:120]
644
-
645
-
646
- # =========================================================================
647
- # TikTok / background MUSIC list — royalty-free tracks with verified direct
648
- # mp3 URLs (incompetech CC-BY 4.0, Pixabay, Archive.org).
649
- # =========================================================================
650
- MUSIC_LIST = [
651
- {"id": "none", "name": "🚫 Không có nhạc nền", "url": ""},
652
- {"id": "carefree", "name": "🎵 Carefree (Joyful)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Carefree.mp3"},
653
- {"id": "wholesome", "name": "🎵 Wholesome (Warm)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Wholesome.mp3"},
654
- {"id": "fluffing", "name": "🎵 Fluffing a Duck (Quirky)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Fluffing%20a%20Duck.mp3"},
655
- {"id": "loping", "name": "🎵 Loping Sting (Short)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Loping%20Sting.mp3"},
656
- {"id": "modern", "name": "🎵 Modern Vibes (Electronic)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Modern%20Vibes.mp3"},
657
- {"id": "cheezee", "name": "🎵 Chee Zee Jungle (Upbeat)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Chee%20Zee%20Jungle.mp3"},
658
- {"id": "constance", "name": "🎵 Constance (Cinematic)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Constance.mp3"},
659
- {"id": "edm", "name": "🎵 EDM Detection Mode (Energetic)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/EDM%20Detection%20Mode.mp3"},
660
- {"id": "spyglass", "name": "🎵 Spy Glass (Mystery)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Spy%20Glass.mp3"},
661
- {"id": "raving", "name": "🎵 Raving Energy (Dance)", "url": "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Raving%20Energy.mp3"},
662
- {"id": "pixabay", "name": "🎵 Pixabay Sunset Vibes", "url": "https://cdn.pixabay.com/download/audio/2022/05/27/audio_1808fbf07a.mp3"},
663
- ]
664
-
665
- MUSIC_BY_ID = {m["id"]: m for m in MUSIC_LIST}
666
-
667
-
668
- # =========================================================================
669
- # local URL resolution (uploaded files / wall images) + remote fetch
670
- # =========================================================================
671
- def _fetch_bytes(url):
672
- """Fetch image/audio/video bytes from a URL. Handles local /api/ URLs."""
673
- if not url:
674
- return None
675
- if url.startswith("/api/ai-short/upload/"):
676
- p = os.path.join(UPLOAD_DIR, os.path.basename(url.rstrip("/")))
677
- if os.path.exists(p):
678
- with open(p, "rb") as f:
679
- return f.read()
680
- return None
681
- if url.startswith("/api/wall/img/"):
682
- p = os.path.join(WALL_IMG_DIR, os.path.basename(url.rstrip("/")))
683
- if os.path.exists(p):
684
- with open(p, "rb") as f:
685
- return f.read()
686
- return None
687
- try:
688
- r = requests.get(url, headers=UA_HEADERS, timeout=30)
689
- if r.status_code == 200 and len(r.content) > 900:
690
- return r.content
691
- except Exception:
692
- pass
693
- return None
694
-
695
-
696
- def _download_to_file(url, dst, max_mb=400):
697
- """Stream a remote file (video) to disk without loading it into RAM.
698
- Returns True on success. Handles same-origin /api/ URLs by copy.
699
- If the URL is an HLS playlist (.m3u8), downloads+decrypts all segments
700
- via _download_hls_to_ts (handles AES-128 encryption from news CDNs).
701
- YouTube URLs are downloaded via yt-dlp."""
702
- try:
703
- # YouTube embed / watch URLs → use yt-dlp to fetch best MP4
704
- yt_match = re.search(r'youtube\.com/(?:embed|watch)\?v=([a-zA-Z0-9_-]{11})|youtu\.be/([a-zA-Z0-9_-]{11})', (url or ""))
705
- if yt_dlp is not None and yt_match:
706
- yt_id = yt_match.group(1) or yt_match.group(2)
707
- tmp_out = dst + ".yt.mp4"
708
- cf = _ensure_cookies()
709
- ytdlp_args = ["yt-dlp", "-f", "best[ext=mp4][height<=720]/best[ext=mp4]/best",
710
- "--no-playlist", "--remux-video", "mp4", "-o", tmp_out,
711
- "--timeout", "30", "--no-warnings"]
712
- if cf:
713
- ytdlp_args += ["--cookiefile", cf]
714
- # use the video ID to construct the canonical watch URL
715
- ytdlp_args.append(f"https://www.youtube.com/watch?v={yt_id}")
716
- try:
717
- subprocess.run(ytdlp_args, check=True, stdout=subprocess.PIPE,
718
- stderr=subprocess.PIPE, timeout=180)
719
- if os.path.exists(tmp_out) and os.path.getsize(tmp_out) > 0:
720
- os.rename(tmp_out, dst)
721
- return True
722
- except Exception:
723
- pass
724
- return False
725
-
726
- # HLS playlists need segment-wise download + AES decryption
727
- if ".m3u8" in (url or "").lower():
728
- return _download_hls_to_ts(url, dst, max_mb=max_mb)
729
- if url.startswith("/api/ai-short/upload/"):
730
- p = os.path.join(UPLOAD_DIR, os.path.basename(url.rstrip("/")))
731
- if os.path.exists(p):
732
- with open(p, "rb") as f, open(dst, "wb") as g:
733
- g.write(f.read())
734
- return True
735
- return False
736
- if url.startswith("/api/wall/img/"):
737
- p = os.path.join(WALL_IMG_DIR, os.path.basename(url.rstrip("/")))
738
- if os.path.exists(p):
739
- with open(p, "rb") as f, open(dst, "wb") as g:
740
- g.write(f.read())
741
- return True
742
- return False
743
- with requests.get(url, headers=UA_HEADERS, timeout=60, stream=True,
744
- allow_redirects=True) as r:
745
- if r.status_code != 200:
746
- return False
747
- total = 0
748
- with open(dst, "wb") as f:
749
- for chunk in r.iter_content(chunk_size=256 * 1024):
750
- if not chunk:
751
- continue
752
- f.write(chunk)
753
- total += len(chunk)
754
- if total > max_mb * 1024 * 1024:
755
- return False
756
- return os.path.exists(dst) and os.path.getsize(dst) > 900
757
- except Exception:
758
- return False
759
-
760
-
761
- def _download_hls_to_ts(m3u8_url, dst, max_mb=400):
762
- """Download an HLS stream (incl. AES-128 encrypted) to a single .ts file,
763
- decrypting segments in Python. Returns True on success.
764
-
765
- Avoids relying on ffmpeg's network HLS demuxer (which can be fragile on
766
- news CDNs like vnecdn/24h that require referer + AES-128 keys)."""
767
- try:
768
- from urllib.parse import urljoin, urlparse
769
- try:
770
- from Crypto.Cipher import AES
771
- except Exception:
772
- AES = None
773
- HDRS = _resolve_media_url(m3u8_url)
774
- r = requests.get(m3u8_url, headers=HDRS, timeout=30)
775
- if r.status_code != 200:
776
- return False
777
- text = r.text
778
- if "#EXTM3U" not in text:
779
- # Not actually HLS — write raw bytes
780
- with open(dst, "wb") as f:
781
- f.write(r.content)
782
- return os.path.exists(dst) and os.path.getsize(dst) > 900
783
- # master playlist? pick a variant (prefer 720)
784
- if "#EXT-X-STREAM-INF" in text:
785
- ch = re.findall(r'#EXT-X-STREAM-INF[^\n]*\n([^\n]+)', text)
786
- variants = [v.strip() for v in ch if v.strip()]
787
- # pick one containing 720, else first
788
- chosen = next((v for v in variants if "720" in v), variants[0] if variants else None)
789
- if chosen:
790
- if not chosen.startswith("http"):
791
- chosen = urljoin(m3u8_url, chosen)
792
- return _download_hls_to_ts(chosen, dst, max_mb=max_mb)
793
-
794
- key = None
795
- key_uri = None
796
- km = re.search(r'#EXT-X-KEY:METHOD=AES-128,URI="([^"]+)"', text)
797
- if km:
798
- key_uri = km.group(1)
799
- if not key_uri.startswith("http"):
800
- key_uri = urljoin(m3u8_url, key_uri)
801
- kr = requests.get(key_uri, headers=HDRS, timeout=20)
802
- if kr.status_code == 200:
803
- key = kr.content
804
- use_aes = key is not None and AES is not None
805
-
806
- # media sequence drives the AES-CBC IV
807
- seq = 0
808
- mm = re.search(r'#EXT-X-MEDIA-SEQUENCE:\s*(\d+)', text)
809
- if mm:
810
- seq = int(mm.group(1))
811
- keyfm = re.search(r'#EXT-X-KEY:METHOD=AES-128,.*?IV=0x([0-9A-Fa-f]+)', text)
812
- explicit_iv = keyfm.group(1) if keyfm else None
813
-
814
- segs = [l.strip() for l in text.splitlines()
815
- if l.strip() and not l.startswith("#")]
816
- total = 0
817
- with open(dst, "wb") as out:
818
- for i, s in enumerate(segs):
819
- if not s.startswith("http"):
820
- s = urljoin(m3u8_url, s)
821
- sr = requests.get(s, headers=HDRS, timeout=60)
822
- if sr.status_code != 200:
823
- return False
824
- data = sr.content
825
- sr.close()
826
- if use_aes:
827
- # AES-128-CBC decrypt the ENTIRE segment with one call so
828
- # PKCS7 + CBC chaining stay correct (segment is self-contained).
829
- if explicit_iv:
830
- iv = bytes.fromhex(explicit_iv)
831
- else:
832
- iv = (seq + i).to_bytes(16, "big")
833
- data = AES.new(key, AES.MODE_CBC, iv).decrypt(data)
834
- out.write(data)
835
- total += len(data)
836
- if total > max_mb * 1024 * 1024:
837
- return False
838
- return os.path.exists(dst) and os.path.getsize(dst) > 900
839
- except Exception:
840
- return False
841
-
842
-
843
- def _probe_duration(path, fallback=15.0):
844
- try:
845
- pr = subprocess.run(
846
- ["ffprobe", "-v", "error", "-show_entries", "format=duration",
847
- "-of", "default=noprint_wrappers=1:no_key=1", path],
848
- stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30)
849
- return max(1.0, float((pr.stdout or b"").decode().strip() or fallback))
850
- except Exception:
851
- return fallback
852
-
853
-
854
- def _probe_has_audio(path):
855
- try:
856
- pr = subprocess.run(
857
- ["ffprobe", "-v", "error", "-select_streams", "a", "-show_entries",
858
- "stream=index", "-of", "csv=p=0", path],
859
- stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30)
860
- return bool((pr.stdout or b"").decode().strip())
861
- except Exception:
862
- return False
863
-
864
-
865
- def _first_frame(video_path, thumb_path, t=0.5):
866
- """Extract a thumbnail frame from a video at time t seconds.
867
- Returns the served URL path or empty string."""
868
- try:
869
- os.makedirs(os.path.dirname(thumb_path), exist_ok=True)
870
- subprocess.run(
871
- ["ffmpeg", "-y", "-ss", str(t), "-i", video_path,
872
- "-frames:v", "1", "-q:v", "3", thumb_path],
873
- stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=60)
874
- if os.path.exists(thumb_path) and os.path.getsize(thumb_path) > 500:
875
- return f"/api/wall/img/{os.path.basename(thumb_path)}"
876
- except Exception:
877
- pass
878
- return ""
879
-
880
-
881
- # =========================================================================
882
- # Image helpers
883
- # =========================================================================
884
- def _wrap_text_px(draw, text, font, max_width, max_lines):
885
- words = _clean(text).split()
886
- lines, cur = [], ""
887
- for w in words:
888
- test = (cur + " " + w).strip()
889
- try:
890
- width = draw.textbbox((0, 0), test, font=font)[2]
891
- except Exception:
892
- width = len(test) * 20
893
- if width <= max_width:
894
- cur = test
895
- else:
896
- if cur:
897
- lines.append(cur)
898
- cur = w
899
- if len(lines) >= max_lines:
900
- break
901
- if cur and len(lines) < max_lines:
902
- lines.append(cur)
903
- return lines
904
-
905
-
906
- def _cover_resize(img, w, h):
907
- """Center-crop + resize to exactly (w, h)."""
908
- rw, rh = w / h, img.width / img.height
909
- if rw > rh: # image too tall -> crop bottom/top
910
- nh = int(img.width / rw)
911
- top = max(0, (img.height - nh) // 2)
912
- img = img.crop((0, top, img.width, top + nh))
913
- else: # image too wide -> crop left/right
914
- nw = int(img.height * rw)
915
- left = max(0, (img.width - nw) // 2)
916
- img = img.crop((left, 0, left + nw, img.height))
917
- return img.resize((w, h))
918
-
919
-
920
- def _frame_from_image(image_bytes, out_path, seg_idx=0, seg_total=1):
921
- """Full-bleed designed image background + tiny progress chip. NO text."""
922
- W, H = 1080, 1920
923
- bg = Image.new("RGB", (W, H), (10, 10, 10))
924
- try:
925
- im = Image.open(io_bytes(image_bytes)).convert("RGB")
926
- bg = _cover_resize(im, W, H)
927
- except Exception:
928
- pass
929
- draw = ImageDraw.Draw(bg)
930
- try:
931
- font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 26)
932
- except Exception:
933
- font_small = None
934
- if seg_total > 1:
935
- chip = f"{seg_idx + 1}/{seg_total}"
936
- try:
937
- tw = draw.textbbox((0, 0), chip, font=font_small)[2]
938
- except Exception:
939
- tw = len(chip) * 14
940
- draw.rounded_rectangle((W - 40 - tw - 24, 34, W - 22, 74), radius=18, fill=(0, 0, 0))
941
- draw.text((W - 40 - tw - 10, 40), chip, fill=(255, 255, 255), font=font_small)
942
- bg.save(out_path, quality=92)
943
-
944
-
945
- def _frame_with_text(image_bytes, out_path, segment, title, seg_idx=0, seg_total=1):
946
- """Designed image bg + centered key point + footer title (default design)."""
947
- W, H = 1080, 1920
948
- bg = Image.new("RGB", (W, H), (10, 10, 10))
949
- try:
950
- im = Image.open(io_bytes(image_bytes)).convert("RGB")
951
- cover = _cover_resize(im, W, H)
952
- bg = Image.blend(cover, Image.new("RGB", (W, H), (0, 0, 0)), 0.45)
953
- except Exception:
954
- pass
955
- draw = ImageDraw.Draw(bg)
956
- try:
957
- font_brand = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 34)
958
- font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 28)
959
- font_seg = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 56)
960
- font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 32)
961
- except Exception:
962
- font_brand = font_small = font_seg = font_title = None
963
- draw.rectangle((0, 620, W, H), fill=(12, 12, 12))
964
- y = 700
965
- dot_x, dot_y = 48, 762
966
- for i in range(max(1, seg_total)):
967
- fill = (92, 184, 122) if i == seg_idx else (70, 70, 70)
968
- draw.rounded_rectangle((dot_x + i * 38, dot_y, dot_x + i * 38 + 24, dot_y + 10), radius=5, fill=fill)
969
- draw.text((48, 800), "VNEWS AI SHORT", fill=(110, 231, 143), font=font_brand)
970
- draw.rounded_rectangle((48, 854, 260, 900), radius=20, fill=(28, 70, 45))
971
- draw.text((66, 862), f"Đoạn {seg_idx + 1}/{max(1, seg_total)}", fill=(235, 235, 235), font=font_small)
972
- y = 960
973
- maxw = W - 96
974
- for ln in _wrap_text_px(draw, segment, font_seg, maxw, 16):
975
- draw.text((48, y), ln, fill=(255, 255, 255), font=font_seg)
976
- y += 72
977
- if y > 1560:
978
- break
979
- y2 = 1700
980
- draw.line((48, y2 - 24, W - 48, y2 - 24), fill=(70, 70, 70), width=2)
981
- for ln in _wrap_text_px(draw, title, font_title, maxw, 3):
982
- draw.text((48, y2), ln, fill=(220, 220, 220), font=font_title)
983
- y2 += 44
984
- bg.save(out_path, quality=92)
985
-
986
-
987
- def io_bytes(b):
988
- import io
989
- return io.BytesIO(b)
990
-
991
-
992
- def _tts_one(text, voice, speed, out_path):
993
- """Generate one TTS segment (edge-tts with gTTS fallback), speed-adjusted.
994
- Returns the final file path or None on failure."""
995
- edge_voice = {
996
- "vi-vn-hoaimyneural": "vi-VN-HoaiMyNeural", "vi-vn-namminhneural": "vi-VN-NamMinhNeural",
997
- "hoaimy": "vi-VN-HoaiMyNeural", "namminh": "vi-VN-NamMinhNeural", "nam": "vi-VN-NamMinhNeural",
998
- "male": "vi-VN-NamMinhNeural", "nu": "vi-VN-HoaiMyNeural", "female": "vi-VN-HoaiMyNeural",
999
- "mien-nam": "vi-VN-HoaiMyNeural",
1000
- "en-us-andrewmultilingualneural": "en-US-AndrewMultilingualNeural",
1001
- "en-au-williammultilingualneural": "en-AU-WilliamMultilingualNeural",
1002
- "andrew": "en-US-AndrewMultilingualNeural", "en_andrew": "en-US-AndrewMultilingualNeural",
1003
- "jenny": "en-US-AndrewMultilingualNeural", "en_jenny": "en-US-AndrewMultilingualNeural",
1004
- "pt-br-thalitamultilingualneural": "pt-BR-ThalitaMultilingualNeural",
1005
- "thalita": "pt-BR-ThalitaMultilingualNeural", "pt": "pt-BR-ThalitaMultilingualNeural",
1006
- "fr-fr-viviennemultilingualneural": "fr-FR-VivienneMultilingualNeural",
1007
- "fr-fr-remymultilingualneural": "fr-FR-RemyMultilingualNeural",
1008
- "denise": "fr-FR-VivienneMultilingualNeural", "fr": "fr-FR-VivienneMultilingualNeural",
1009
- "de-de-seraphinamultilingualneural": "de-DE-SeraphinaMultilingualNeural",
1010
- "de-de-florianmultilingualneural": "de-DE-FlorianMultilingualNeural",
1011
- "katja": "de-DE-SeraphinaMultilingualNeural", "de": "de-DE-SeraphinaMultilingualNeural",
1012
- "ko-kr-hyusumultilingualneural": "ko-KR-HyunsuMultilingualNeural",
1013
- "ko-kr-hyunsuneural": "ko-KR-HyunsuMultilingualNeural", "sunhee": "ko-KR-HyunsuMultilingualNeural",
1014
- "ko": "ko-KR-HyunsuMultilingualNeural",
1015
- "it-it-giuseppemultilingualneural": "it-IT-GiuseppeMultilingualNeural",
1016
- "ela": "en-US-AndrewMultilingualNeural", "es": "en-US-AndrewMultilingualNeural",
1017
- }.get(voice.lower(), voice)
1018
- aud = out_path
1019
- aud_fast = re.sub(r"\.mp3$", "_fast.mp3", out_path)
1020
- try:
1021
- subprocess.run(
1022
- ["python", "-m", "edge_tts", "--voice", edge_voice, "--text", text,
1023
- "--write-media", aud],
1024
- check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
1025
- except Exception:
1026
- aud = None
1027
- try:
1028
- from gtts import gTTS
1029
- tld = "com.vn" if voice.lower() in ("nu", "female", "mien-nam", "hoaimy") else "com"
1030
- try:
1031
- gTTS(text, lang="vi", tld=tld, slow=False).save(aud)
1032
- except TypeError:
1033
- gTTS(text, lang="vi", slow=False).save(aud)
1034
- except Exception:
1035
- pass
1036
- if aud and os.path.exists(aud) and os.path.getsize(aud) > 900:
1037
- try:
1038
- subprocess.run(["ffmpeg", "-y", "-i", aud, "-filter:a", f"atempo={speed}", "-vn", aud_fast],
1039
- check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
1040
- if os.path.exists(aud_fast) and os.path.getsize(aud_fast) > 900:
1041
- return aud_fast
1042
- except Exception:
1043
- pass
1044
- return aud
1045
- return None
1046
-
1047
-
1048
- def _concat_audio(paths, out_path, pad=0.3):
1049
- """Concatenate audio files into one track (m4a/aac)."""
1050
- if not paths:
1051
- return None
1052
- lst = os.path.join(os.path.dirname(out_path), "_alist.txt")
1053
- with open(lst, "w", encoding="utf-8") as f:
1054
- for p in paths:
1055
- f.write("file '" + p.replace("'", "'\\''") + "'\n")
1056
- subprocess.run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", lst,
1057
- "-c:a", "aac", "-b:a", "128k", out_path],
1058
- stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=240)
1059
- return out_path if os.path.exists(out_path) and os.path.getsize(out_path) > 900 else None
1060
-
1061
-
1062
- def _assemble_video_short(work, video_path, video_dur, img_paths, segs,
1063
- reuse_audio_path, video_audio_path, voice, speed,
1064
- music_path, out_mp4, fallback_title):
1065
- """New video-first short assembly.
1066
-
1067
- Timeline: scraped/uploaded video plays first (up to min(video_dur, voice)),
1068
- then the designed/selected images fill the remainder of the voice duration
1069
- (or until the video covers the whole short). The voice track is
1070
- continuous TTS narration of the segments (or reused previous short audio).
1071
-
1072
- Returns (out_mp4, total_dur) or raises on failure.
1073
- """
1074
- # ---------- 1. narration track (continuous) ----------
1075
- narr = None
1076
- if reuse_audio_path and os.path.exists(reuse_audio_path):
1077
- narr = os.path.join(work, "narr.m4a")
1078
- subprocess.run(["ffmpeg", "-y", "-i", reuse_audio_path,
1079
- "-c:a", "aac", "-b:a", "128k", narr],
1080
- stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
1081
- narr = narr if os.path.exists(narr) and os.path.getsize(narr) > 900 else None
1082
- else:
1083
- voice_parts = []
1084
- for i, seg in enumerate(segs):
1085
- vf = _tts_one(seg, voice, speed, os.path.join(work, f"vf_{i:02d}.mp3"))
1086
- if vf:
1087
- voice_parts.append(vf)
1088
- if voice_parts:
1089
- narr = _concat_audio(voice_parts, os.path.join(work, "narr.m4a"))
1090
- if narr and os.path.exists(narr):
1091
- # small tail silence so the last word isn't cut
1092
- tail = os.path.join(work, "narr_tail.m4a")
1093
- subprocess.run(["ffmpeg", "-y", "-i", narr, "-af", "apad=pad_dur=0.6",
1094
- "-c:a", "aac", "-b:a", "128k", tail],
1095
- stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
1096
- if os.path.exists(tail) and os.path.getsize(tail) > 900:
1097
- narr = tail
1098
-
1099
- narr_dur = _probe_duration(narr, fallback=0.0) if narr else 0.0
1100
- total_dur = narr_dur
1101
- if total_dur < 1.0:
1102
- # no voice -> short is driven by video length (or music overlays later)
1103
- total_dur = max(1.0, video_dur)
1104
-
1105
- # ---------- 2. split timeline ----------
1106
- # When video is longer than narration (e.g. scraped YouTube video in HOT mode),
1107
- # let the video play at its full length and fill remaining time with images.
1108
- # When narration is longer, video plays first then images fill the remainder.
1109
- if video_dur > 0 and narr_dur < video_dur:
1110
- # Video is longer than narration → video plays first at full length,
1111
- # then images fill any remaining narration time (usually minimal).
1112
- use_video = video_dur
1113
- total_dur = max(use_video, narr_dur)
1114
- img_dur = total_dur - use_video
1115
- else:
1116
- use_video = max(0.0, min(video_dur, total_dur))
1117
- img_dur = total_dur - use_video
1118
- imgs = img_paths or []
1119
- # image durations: split remaining time across available images (min 1.2s,
1120
- # repeat cyclically if the voice is longer than all images)
1121
- img_plan = []
1122
- if img_dur > 0.5 and imgs:
1123
- per = max(1.2, min(8.0, img_dur / len(imgs)))
1124
- remaining = img_dur
1125
- i = 0
1126
- while remaining > 0.3:
1127
- img_plan.append((imgs[i % len(imgs)], min(per, remaining)))
1128
- remaining -= per
1129
- i += 1
1130
-
1131
- # ---------- 3. build ffmpeg command ----------
1132
- cmd = ["ffmpeg", "-y"]
1133
- # video input first (index 0)
1134
- cmd += ["-i", video_path]
1135
- # image loop inputs
1136
- for idx, (_, dur) in enumerate(img_plan):
1137
- cmd += ["-framerate", "25", "-loop", "1", "-t", str(max(0.4, dur)), "-i", img_plan[idx][0]]
1138
- # narration + optional video audio inputs
1139
- audio_inputs = []
1140
- narr_idx = None
1141
- va_idx = None
1142
- if narr:
1143
- narr_idx = 1 + len(img_plan)
1144
- cmd += ["-i", narr]
1145
- audio_inputs.append(narr_idx)
1146
- if video_audio_path and os.path.exists(video_audio_path):
1147
- va_idx = 1 + len(img_plan) + (1 if narr_idx is not None else 0)
1148
- cmd += ["-i", video_audio_path]
1149
- audio_inputs.append(va_idx)
1150
-
1151
- # filter graph
1152
- fc = []
1153
- # video inputs: [0:v] = scraped video, [k:v] = images
1154
- vlabels = []
1155
- # video: trim to use_video, scale/crop to 1080x1920 portrait, normalize fps
1156
- fc.append(
1157
- f"[0:v]trim=duration={use_video:.3f},setpts=PTS-STARTPTS,"
1158
- f"scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,"
1159
- f"fps=25,format=yuv420p[v0]"
1160
- )
1161
- vlabels.append("[v0]")
1162
- for idx, (_, dur) in enumerate(img_plan):
1163
- lab = f"v{idx + 1}"
1164
- fc.append(
1165
- f"[{1 + idx}:v]trim=duration={dur:.3f},setpts=PTS-STARTPTS,"
1166
- f"scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,"
1167
- f"fps=25,format=yuv420p[{lab}]"
1168
- )
1169
- vlabels.append(f"[{lab}]")
1170
- if len(vlabels) > 1:
1171
- concat_in = "".join(vlabels)
1172
- fc.append(f"{concat_in}concat=n={len(vlabels)}:v=1:a=0[vout]")
1173
- vmap = "[vout]"
1174
- else:
1175
- vmap = "[v0]"
1176
-
1177
- # audio graph
1178
- if audio_inputs:
1179
- amix_sources = []
1180
- if narr_idx is not None:
1181
- fc.append(f"[{narr_idx}:a]aresample=44100,atrim=duration={total_dur:.3f},asetpts=PTS-STARTPTS[n]")
1182
- amix_sources.append("[n]")
1183
- if va_idx is not None:
1184
- fc.append(f"[{va_idx}:a]aresample=44100,volume=0.85,atrim=duration={use_video:.3f},asetpts=PTS-STARTPTS[xva]")
1185
- amix_sources.append("[xva]")
1186
- fc.append(f"{''.join(amix_sources)}amix=inputs={len(amix_sources)}:duration=first:dropout_transition=0:normalize=0[aout]")
1187
- amap = "[aout]"
1188
- else:
1189
- amap = None
1190
-
1191
- fc_str = ";".join(fc)
1192
- cmd += ["-filter_complex", fc_str]
1193
- cmd += ["-map", vmap]
1194
- if amap:
1195
- cmd += ["-map", amap, "-c:a", "aac", "-b:a", "160k"]
1196
- else:
1197
- cmd += ["-an"]
1198
- cmd += ["-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
1199
- "-pix_fmt", "yuv420p", "-movflags", "+faststart",
1200
- "-t", f"{total_dur:.3f}", "-shortest", out_mp4]
1201
- subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=600)
1202
- return out_mp4, total_dur
1203
-
1204
-
1205
- def _segments_from_post(post, max_segments=25):
1206
- raw = _clean(post.get("text") or post.get("title") or "")
1207
- raw = re.sub(r"^Bản tin AI viết lại:\s*", "", raw, flags=re.I)
1208
- raw = re.sub(r"Nguồn tham khảo:.*$", "", raw, flags=re.I | re.S).strip()
1209
- lines = []
1210
- for ln in raw.splitlines():
1211
- ln = _clean(re.sub(r"^[•\-\*\d\.\)\s]+", "", ln))
1212
- if not ln:
1213
- continue
1214
- low = ln.lower()
1215
- if low.startswith(("điểm chính", "tiêu đề", "sapo", "nguồn tham khảo")):
1216
- continue
1217
- if len(ln) >= 18:
1218
- lines.append(ln)
1219
- if len(lines) < 3:
1220
- lines = []
1221
- for s in re.split(r"(?<=[\.\!\?])\s+", raw):
1222
- s = _clean(s)
1223
- if len(s) >= 25:
1224
- lines.append(s)
1225
- seen = set()
1226
- out = []
1227
- for u in lines[: max_segments * 3]:
1228
- nu = re.sub(r"[^\wÀ-ỹ\s]", "", u.lower())
1229
- if nu in seen:
1230
- continue
1231
- seen.add(nu)
1232
- out.append(u)
1233
- if len(out) >= max_segments:
1234
- break
1235
- return out[:max_segments] if out else [post.get("title", "Bản tin VNEWS")]
1236
-
1237
-
1238
- def _find_previous_short(post_id):
1239
- """Find the most recent short mp4 for this post (prior generation)."""
1240
- prefix = _safe_name(post_id)
1241
- best, best_mtime = None, 0
1242
- try:
1243
- for f in os.listdir(SHORTS_DIR):
1244
- # current naming: <post_id>v2_<...>.mp4
1245
- if f.startswith(prefix + "v2_") and f.endswith(".mp4"):
1246
- p = os.path.join(SHORTS_DIR, f)
1247
- mt = os.path.getmtime(p)
1248
- if mt > best_mtime:
1249
- best, best_mtime = p, mt
1250
- except Exception:
1251
- pass
1252
- # also legacy patched files: <post_id>_<voice>_..._scenes_nosub.mp4
1253
- if not best:
1254
- try:
1255
- for f in os.listdir(SHORTS_DIR):
1256
- if f.startswith(prefix + "_") and f.endswith("_scenes_nosub.mp4"):
1257
- p = os.path.join(SHORTS_DIR, f)
1258
- mt = os.path.getmtime(p)
1259
- if mt > best_mtime:
1260
- best, best_mtime = p, mt
1261
- except Exception:
1262
- pass
1263
- return best
1264
-
1265
-
1266
- # =========================================================================
1267
- # Endpoints
1268
- # =========================================================================
1269
- @app.get("/api/ai-short/music")
1270
- def api_short_music_list():
1271
- return JSONResponse({"music": MUSIC_LIST})
1272
-
1273
-
1274
- @app.get("/api/ai-short/music/stream")
1275
- def api_short_music_stream(url: str = Query(...)):
1276
- """Proxy một track nhạc nền với CORS headers để trình duyệt phát trước khi chọn."""
1277
- try:
1278
- r = requests.get(url, headers=UA_HEADERS, timeout=30,
1279
- stream=True, allow_redirects=True)
1280
- if r.status_code != 200:
1281
- return Response(status_code=502)
1282
- return Response(
1283
- content=r.content,
1284
- media_type="audio/mpeg",
1285
- headers={
1286
- "Cache-Control": "public, max-age=86400",
1287
- "Access-Control-Allow-Origin": "*",
1288
- "Accept-Ranges": "bytes",
1289
- "Content-Disposition": "inline",
1290
- },
1291
- )
1292
- except Exception:
1293
- return Response(status_code=502)
1294
-
1295
-
1296
- @app.post("/api/ai-short/upload")
1297
- async def api_short_upload(file: UploadFile = File(...)):
1298
- """Upload audio/video/image used as short background."""
1299
- try:
1300
- if not file or not hasattr(file, "filename"):
1301
- return JSONResponse({"error": "Thiếu file"}, status_code=400)
1302
- fname = file.filename or ""
1303
- ext = os.path.splitext(fname)[1].lower()
1304
- if ext not in (".mp3", ".wav", ".m4a", ".aac", ".ogg",
1305
- ".mp4", ".webm", ".mov",
1306
- ".jpg", ".jpeg", ".png", ".webp"):
1307
- return JSONResponse({"error": "Định dạng file không hỗ trợ"}, status_code=400)
1308
- content = await file.read()
1309
- if not content:
1310
- return JSONResponse({"error": "File rỗng"}, status_code=400)
1311
- if len(content) > 60 * 1024 * 1024:
1312
- return JSONResponse({"error": "File quá lớn (>60MB)"}, status_code=400)
1313
- fuid = str(uuid.uuid4())[:12]
1314
- out_name = f"su_{fuid}{ext}"
1315
- with open(os.path.join(UPLOAD_DIR, out_name), "wb") as f:
1316
- f.write(content)
1317
- kind = ("audio" if ext in (".mp3", ".wav", ".m4a", ".aac", ".ogg")
1318
- else ("video" if ext in (".mp4", ".webm", ".mov") else "image"))
1319
- return JSONResponse({"ok": True, "url": f"/api/ai-short/upload/{out_name}", "kind": kind})
1320
- except Exception as e:
1321
- return JSONResponse({"error": f"Lỗi upload: {str(e)[:150]}"}, status_code=500)
1322
-
1323
-
1324
- @app.get("/api/ai-short/upload/{fname}")
1325
- def api_short_upload_file(fname: str):
1326
- if ".." in fname or "/" in fname:
1327
- return JSONResponse({"error": "bad name"}, status_code=400)
1328
- p = os.path.join(UPLOAD_DIR, fname)
1329
- if not os.path.exists(p):
1330
- return JSONResponse({"error": "not found"}, status_code=404)
1331
- ext = os.path.splitext(fname)[1].lower()
1332
- mt = {
1333
- ".mp3": "audio/mpeg", ".wav": "audio/wav", ".m4a": "audio/mp4",
1334
- ".aac": "audio/aac", ".ogg": "audio/ogg",
1335
- ".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime",
1336
- ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
1337
- ".webp": "image/webp",
1338
- }.get(ext, "application/octet-stream")
1339
- return FileResponse(p, media_type=mt)
1340
-
1341
-
1342
- @app.get("/api/ai-short/scrape")
1343
- def api_scrape_video(url: str = Query(...)):
1344
- """Scrap video info from any link (YouTube/TikTok/news...).
1345
-
1346
- Returns direct media URL + duration + thumbnail so the frontend can
1347
- show a preview before generating the short.
1348
- """
1349
- if not url or not re.match(r"^https?://", url):
1350
- return JSONResponse({"error": "Link không hợp lệ"}, status_code=400)
1351
- info = _scrape_video_info(url.strip())
1352
- if not info.get("ok"):
1353
- return JSONResponse({"error": info.get("error", "Không lấy được video")}, status_code=422)
1354
- # strip cache-internal key before returning to the client
1355
- info = {k: v for k, v in info.items() if k != "_t"}
1356
- return JSONResponse(info)
1357
-
1358
-
1359
- @app.get("/api/ai-short/scrape/preview")
1360
- def api_scrape_video_preview(url: str = Query(...), request: Request = None):
1361
- """Stream the scraped video (or its direct mp4) so the browser can play
1362
- it inside the short creator modal. Range-capable proxy.
1363
- """
1364
- if not url or not re.match(r"^https?://", url):
1365
- return JSONResponse({"error": "Link không hợp lệ"}, status_code=400)
1366
- req_headers = dict(UA_HEADERS)
1367
- if request and request.headers.get("range"):
1368
- req_headers["Range"] = request.headers["range"]
1369
- try:
1370
- r = requests.get(url, headers=req_headers, timeout=30, stream=True,
1371
- allow_redirects=True)
1372
- except Exception:
1373
- return JSONResponse({"error": "Không tải được video" }, status_code=502)
1374
- if r.status_code >= 400:
1375
- return JSONResponse({"error": "Lỗi nguồn video"}, status_code=502)
1376
- resp_headers = {
1377
- "Access-Control-Allow-Origin": "*",
1378
- "Accept-Ranges": "bytes",
1379
- "Content-Type": r.headers.get("Content-Type", "video/mp4"),
1380
- }
1381
- if "Content-Range" in r.headers:
1382
- resp_headers["Content-Range"] = r.headers["Content-Range"]
1383
- if "Content-Length" in r.headers:
1384
- resp_headers["Content-Length"] = r.headers["Content-Length"]
1385
- if "Cache-Control" in r.headers:
1386
- resp_headers["Cache-Control"] = r.headers["Cache-Control"]
1387
- else:
1388
- resp_headers["Cache-Control"] = "public, max-age=3600"
1389
-
1390
- def gen():
1391
- try:
1392
- yield from r.iter_content(chunk_size=256 * 1024)
1393
- finally:
1394
- r.close()
1395
-
1396
- return StreamingResponse(gen(), status_code=r.status_code, headers=resp_headers)
1397
-
1398
-
1399
- @app.get("/api/ai-short/scrape/hls")
1400
- def api_scrape_video_hls(url: str = Query(...)):
1401
- """Proxy an HLS master/media playlist and rewrite segment + key URIs to
1402
- same-origin proxy routes so the browser (hls.js) can play AES-128 HLS
1403
- from news CDNs (vnexpress/24h) without CORS errors."""
1404
- if not url or not re.match(r"^https?://", url):
1405
- return JSONResponse({"error": "Link không hợp lệ"}, status_code=400)
1406
- try:
1407
- HDRS = _resolve_media_url(url)
1408
- r = requests.get(url, headers=HDRS, timeout=25)
1409
- if r.status_code != 200:
1410
- return JSONResponse({"error": "Lỗi nguồn HLS"}, status_code=502)
1411
- text = r.text
1412
- if "#EXTM3U" not in text:
1413
- # not a playlist; pass through as binary
1414
- return Response(content=r.content,
1415
- media_type=r.headers.get("Content-Type", "video/mp4"),
1416
- headers={"Access-Control-Allow-Origin": "*"})
1417
- from urllib.parse import urljoin, quote
1418
- out_lines = []
1419
- for line in text.splitlines():
1420
- s = line.strip()
1421
- if s.startswith("#"):
1422
- # rewrite #EXT-X-KEY URI to same-origin key proxy
1423
- if "#EXT-X-KEY" in s and "URI=" in s:
1424
- m = re.search(r'URI="([^"]+)"', s)
1425
- if m:
1426
- ku = m.group(1)
1427
- if not ku.startswith("http"):
1428
- ku = urljoin(url, ku)
1429
- s2 = s.replace(m.group(0), 'URI="/api/ai-short/scrape/key?url=' + urllib_quote(ku, safe="") + '"')
1430
- out_lines.append(s2)
1431
- continue
1432
- out_lines.append(s)
1433
- elif s.startswith("http"): # absolute segment URI
1434
- out_lines.append("/api/ai-short/scrape/seg?url=" + urllib_quote(s, safe=""))
1435
- elif s: # relative segment URI
1436
- out_lines.append("/api/ai-short/scrape/seg?url=" + urllib_quote(urljoin(url, s), safe=""))
1437
- return Response(content="\n".join(out_lines).encode("utf-8"),
1438
- media_type="application/vnd.apple.mpegurl",
1439
- headers={"Access-Control-Allow-Origin": "*",
1440
- "Cache-Control": "public, max-age=300"})
1441
- except Exception:
1442
- return JSONResponse({"error": "Lỗi proxy HLS"}, status_code=502)
1443
-
1444
-
1445
- @app.get("/api/ai-short/scrape/seg")
1446
- def api_scrape_video_seg(url: str = Query(...)):
1447
- """Stream a single HLS segment (.ts) with proper referer headers."""
1448
- if not url or not re.match(r"^https?://", url):
1449
- return JSONResponse({"error": "Link không hợp lệ"}, status_code=400)
1450
- try:
1451
- HDRS = _resolve_media_url(url)
1452
- r = requests.get(url, headers=HDRS, timeout=60, stream=True)
1453
- if r.status_code != 200:
1454
- return JSONResponse({"error": "Lỗi segment"}, status_code=502)
1455
- return StreamingResponse(r.iter_content(chunk_size=256 * 1024),
1456
- media_type="video/mp2t",
1457
- headers={"Access-Control-Allow-Origin": "*",
1458
- "Cache-Control": "public, max-age=3600"})
1459
- except Exception:
1460
- return JSONResponse({"error": "Lỗi segment"}, status_code=502)
1461
-
1462
-
1463
- @app.get("/api/ai-short/scrape/key")
1464
- def api_scrape_video_key(url: str = Query(...)):
1465
- """Proxy the AES-128 key URI for HLS playlists."""
1466
- if not url or not re.match(r"^https?://", url):
1467
- return JSONResponse({"error": "Link không hợp lệ"}, status_code=400)
1468
- try:
1469
- HDRS = _resolve_media_url(url)
1470
- r = requests.get(url, headers=HDRS, timeout=20)
1471
- if r.status_code != 200:
1472
- return JSONResponse({"error": "Lỗi key"}, status_code=502)
1473
- return Response(content=r.content, media_type="application/octet-stream",
1474
- headers={"Access-Control-Allow-Origin": "*",
1475
- "Cache-Control": "public, max-age=3600"})
1476
- except Exception:
1477
- return JSONResponse({"error": "Lỗi key"}, status_code=502)
1478
-
1479
-
1480
- @app.post("/api/ai-short")
1481
- async def api_short_generate(request: Request):
1482
- """Generate a Short AI video.
1483
-
1484
- JSON body:
1485
- post_id (str) wall post id (required)
1486
- use_slides (bool) one scene per designed slide; image = designed slide
1487
- image; NO text overlays; narration = audio of previous
1488
- short if available, else TTS of slide text (audio only).
1489
- images (list[str]) custom background image URLs (replaces default)
1490
- audio_music (str) music id from MUSIC_LIST ('' -> none)
1491
- audio_url (str) custom audio URL (uploaded /api/ai-short/upload/...)
1492
- voice/emotion/speed — TTS controls
1493
- """
1494
- try:
1495
- body = await request.json()
1496
- except Exception:
1497
- body = {}
1498
- post_id = _clean(str(body.get("post_id", "")))
1499
- create_new = bool(body.get("create_new"))
1500
- posts = _load_wall_posts()
1501
- if not isinstance(posts, list):
1502
- posts = []
1503
- post = None
1504
- if create_new:
1505
- # "Thêm Short HOT" from the homepage: no source wall post. Create a
1506
- # brand-new post now; the generated video is attached to it and it is
1507
- # posted to the AI wall. Retention: if generation fails, the draft is
1508
- # removed again so no empty post stays on the wall.
1509
- import uuid as _uuid
1510
- new_id = str(_uuid.uuid4())[:12]
1511
- hot_title = _clean(str(body.get("title", "")))[:200]
1512
- hot_text = _clean(str(body.get("text", "")))[:2000]
1513
- hot_imgs = [str(x) for x in (body.get("images") or []) if str(x).strip()][:10]
1514
- post = {
1515
- "id": new_id,
1516
- "title": hot_title or "Short HOT",
1517
- "text": hot_text,
1518
- "source": "hot_short",
1519
- "kind": "hot_short",
1520
- "video": None,
1521
- "img": hot_imgs[0] if hot_imgs else None,
1522
- "images": hot_imgs,
1523
- "url": _clean(str(body.get("url", ""))),
1524
- "voice": _clean(str(body.get("voice", ""))),
1525
- "emotion": _clean(str(body.get("emotion", ""))),
1526
- "language": _clean(str(body.get("language", "vi"))),
1527
- "created": int(time.time()),
1528
- "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
1529
- }
1530
- posts.insert(0, post)
1531
- post_id = new_id
1532
- else:
1533
- if not post_id:
1534
- return JSONResponse({"error": "Thiếu post_id"}, status_code=400)
1535
- post = next((p for p in posts if str(p.get("id")) == str(post_id)), None)
1536
- if not post:
1537
- return JSONResponse({"error": "Không tìm thấy bài viết"}, status_code=404)
1538
-
1539
- use_slides = bool(body.get("use_slides"))
1540
- reuse_audio = body.get("reuse_audio")
1541
- if reuse_audio is None:
1542
- # default: when recreating from slides, reuse the previous short's audio
1543
- reuse_audio = use_slides
1544
- custom_images = [str(x) for x in (body.get("images") or []) if str(x).strip()]
1545
- fixed_image = _clean(str(body.get("fixed_image", ""))) # 1 ảnh cố định cho nhiều slide
1546
- raw_indices = body.get("slide_indices") or []
1547
- slide_indices = []
1548
- for v in raw_indices:
1549
- try:
1550
- slide_indices.append(int(v))
1551
- except Exception:
1552
- pass
1553
- video_url = _clean(str(body.get("video_url", "")))
1554
- video_muted = bool(body.get("video_muted"))
1555
- # oEmbed fallback for Short HOT: passed by the frontend when the scraped
1556
- # link has metadata but the direct media download failed (e.g. TikTok/
1557
- # Facebook/Instagram which block datacenter IPs). The short is then saved
1558
- # as an embeddable slide (like the football video slides) with the
1559
- # metadata auto-filled from oEmbed.
1560
- embed_url = _clean(str(body.get("embed_url", "")))
1561
- embed_title = _clean(str(body.get("embed_title", "")))[:250]
1562
- embed_thumb = _clean(str(body.get("embed_thumb", "")))
1563
- embed_desc = _clean(str(body.get("embed_desc", "")))[:5000]
1564
- try:
1565
- video_dur = float(body.get("video_dur")) if body.get("video_dur") is not None else None
1566
- except Exception:
1567
- video_dur = None
1568
- scrape_mode = bool(body.get("scrape_mode"))
1569
- audio_music = _clean(str(body.get("audio_music", "")))
1570
- audio_url = _clean(str(body.get("audio_url", "")))
1571
- voice = _clean(str(body.get("voice", "vi-VN-HoaiMyNeural")))
1572
- emotion = _clean(str(body.get("emotion", "neutral")))
1573
- try:
1574
- speed = max(0.85, min(1.35, float(body.get("speed", 1.0) or 1.0)))
1575
- except Exception:
1576
- speed = 1.0
1577
-
1578
- os.makedirs(SHORTS_DIR, exist_ok=True)
1579
-
1580
- # ---------- scene images ----------
1581
- scene_images = []
1582
- slide_texts = []
1583
- if use_slides:
1584
- slides = post.get("slides") or []
1585
- if not slides:
1586
- return JSONResponse({"error": "Bài chưa có ảnh slide thiết kế. Hãy chọn '🎨 Thiết kế ảnh' trước."}, status_code=400)
1587
- # optional per-slide selection from the recreate modal
1588
- if slide_indices:
1589
- slides = [s for i, s in enumerate(slides) if i in slide_indices]
1590
- if not slides:
1591
- return JSONResponse({"error": "Slide đã chọn không hợp lệ."}, status_code=400)
1592
- for s in slides:
1593
- im = (s.get("image") or "").strip()
1594
- if im:
1595
- scene_images.append(im)
1596
- slide_texts.append(_clean(s.get("text", "")))
1597
- # fixed_image: 1 ảnh do người dùng chọn (trong danh sách rewrite hoặc upload)
1598
- # thay thế toàn bộ ảnh slide -> dùng ảnh này cho N slide (sau dedupe = 1 scene)
1599
- if fixed_image:
1600
- scene_images = [fixed_image]
1601
- slide_texts = [slide_texts[0] if slide_texts else ""]
1602
- # dedupe identical designed images globally: if the same image appears
1603
- # in multiple slides (e.g. only 1 slide was designed, others share the
1604
- # fallback cover), collapse to one scene so the slide isn't repeated
1605
- deduped = []
1606
- seen_imgs = set()
1607
- for im in scene_images:
1608
- if im not in seen_imgs:
1609
- seen_imgs.add(im)
1610
- deduped.append(im)
1611
- scene_images = deduped
1612
- if not scene_images:
1613
- return JSONResponse({"error": "Các slide chưa có ảnh đã thiết kế."}, status_code=400)
1614
- # align slide_texts to the deduped scenes (keep first text per image group)
1615
- if slide_texts and len(slide_texts) > len(scene_images):
1616
- grouped = []
1617
- seen = set()
1618
- for idx, s in enumerate(slides):
1619
- im = (s.get("image") or "").strip()
1620
- if not im or im in seen:
1621
- continue
1622
- seen.add(im)
1623
- grouped.append(slide_texts[idx] if idx < len(slide_texts) else "")
1624
- if grouped:
1625
- slide_texts = grouped
1626
- else:
1627
- slide_texts = slide_texts[:len(scene_images)]
1628
- else:
1629
- # fixed_image (chosen from rewrite list) replaces the bg for the short
1630
- if fixed_image:
1631
- scene_images = [fixed_image]
1632
- else:
1633
- for im in custom_images:
1634
- scene_images.append(im)
1635
- if not scene_images:
1636
- scene_images = [(x or "").strip() for x in (post.get("images") or []) if (x or "").strip()]
1637
- if not scene_images:
1638
- slides = post.get("slides") or []
1639
- for s in slides:
1640
- im = (s.get("image") or "").strip()
1641
- if im:
1642
- scene_images.append(im)
1643
- if not scene_images and post.get("img"):
1644
- scene_images = [post.get("img")]
1645
- # video-only (scraped/uploaded) short: video is the background, so
1646
- # images are OPTIONAL. Let the video-first assembler handle it.
1647
- if not scene_images and not video_url:
1648
- # Short HOT oEmbed fallback: no direct media AND no background
1649
- # image -> still create the embeddable slide with the oEmbed
1650
- # thumbnail as the visual (if any), so the wall/feed can show it.
1651
- if embed_url:
1652
- pass
1653
- else:
1654
- # AUTO oEmbed FALLBACK: the frontend couldn't get a video
1655
- # (or even embed metadata) from the link — derive the
1656
- # embed iframe URL from the source link itself (cheap, no
1657
- # network) and enrich with oEmbed metadata if available.
1658
- src_url = _clean(str(body.get("url", "")) or post.get("url") or "")
1659
- if src_url and (_is_known_social(src_url) or body.get("allow_oembed_fallback")):
1660
- embed_url = _oembed_embed_url(src_url)
1661
- _probe3 = _oembed_probe(src_url)
1662
- if _probe3:
1663
- embed_url = _probe3.get("embed_url") or embed_url
1664
- embed_title = _probe3.get("title") or embed_title
1665
- embed_thumb = _probe3.get("thumbnail") or embed_thumb
1666
- embed_desc = _probe3.get("description") or embed_desc
1667
- if not embed_url:
1668
- return JSONResponse({"error": "Bài viết không có ảnh nền"}, status_code=400)
1669
- segs0 = _segments_from_post(post)
1670
- if len(scene_images) > len(segs0):
1671
- scene_images = scene_images[:max(1, len(segs0))]
1672
-
1673
- # ---------- Short HOT oEmbed shortcut ----------
1674
- # When the source link is oEmbed-only (TikTok/FB/IG block datacenter IPs,
1675
- # or the scrape returned no direct media URL), save the post as an
1676
- # embeddable slide right away with the auto-fetched title/desc/thumb.
1677
- if not video_url and embed_url:
1678
- # Don't trust the client for metadata: when the post has a source URL
1679
- # and we still lack a thumbnail/title at save time (e.g. an older page
1680
- # cached in the browser didn't send embed_thumb), probe oEmbed NOW so
1681
- # the wall post always gets its thumbnail + title.
1682
- _src_url = _clean(str(body.get("url", "")) or post.get("url") or "")
1683
- if (not embed_thumb or not embed_title) and _src_url:
1684
- _probe_save = _oembed_probe(_src_url)
1685
- if _probe_save:
1686
- embed_title = embed_title or _probe_save.get("title") or ""
1687
- embed_thumb = embed_thumb or _probe_save.get("thumbnail") or ""
1688
- embed_desc = embed_desc or _probe_save.get("description") or ""
1689
- post["video"] = embed_url
1690
- post["embed_oembed"] = True
1691
- post["title"] = embed_title or post.get("title") or "Short HOT"
1692
- post["text"] = embed_desc or post.get("text") or embed_title or ""
1693
- post["description"] = embed_desc or ""
1694
- if embed_thumb:
1695
- post["img"] = embed_thumb
1696
- post["images"] = [embed_thumb] + [x for x in (post.get("images") or []) if x != embed_thumb]
1697
- post["short_thumb"] = embed_thumb
1698
- else:
1699
- post["short_thumb"] = post.get("img")
1700
- post["extractor"] = "oembed"
1701
- _save_wall_posts(posts)
1702
- return JSONResponse({"video": embed_url, "oembed": True, "post": post})
1703
-
1704
- # ---------- naming / cache ----------
1705
- scene_key = "slides" if use_slides else "norm"
1706
- sel_key = ("s" + "".join(str(i) for i in slide_indices)) if slide_indices else ""
1707
- vid_key = hashlib.md5(video_url.encode("utf-8")).hexdigest()[:8] if video_url else ""
1708
- img_key = hashlib.md5("|".join(scene_images).encode("utf-8")).hexdigest()[:8]
1709
- mus_key = audio_music or "nomus"
1710
- aud_key = hashlib.md5(audio_url.encode("utf-8")).hexdigest()[:8] if audio_url else "none"
1711
- suffix = f"v2_{scene_key}_{'v'+vid_key if vid_key else ''}{sel_key}{img_key}_{mus_key}_{aud_key}_{voice}_{emotion}_{str(speed).replace('.', 'p')}"
1712
- out_mp4 = os.path.join(SHORTS_DIR, _safe_name(post_id + suffix) + ".mp4")
1713
- out_url = "/api/ai/short-file/" + post_id + suffix
1714
-
1715
- if os.path.exists(out_mp4):
1716
- post["video"] = out_url
1717
- post["short_voice"] = voice
1718
- post["short_emotion"] = emotion
1719
- post["short_speed"] = speed
1720
- post["short_music"] = audio_music
1721
- post["short_audio_url"] = audio_url
1722
- post["short_use_slides"] = use_slides
1723
- post["short_images"] = scene_images
1724
- _save_wall_posts(posts)
1725
- return JSONResponse({"video": out_url, "voice": voice, "emotion": emotion,
1726
- "speed": speed, "music": audio_music, "use_slides": use_slides,
1727
- "post": post})
1728
-
1729
- work = os.path.join(SHORTS_DIR, _safe_name(post_id + suffix))
1730
- os.makedirs(work, exist_ok=True)
1731
-
1732
- # ---------- download scene images ----------
1733
- local_imgs = []
1734
- for i, im_url in enumerate(scene_images):
1735
- dst = os.path.join(work, f"scene_{i:02d}.jpg")
1736
- data = _fetch_bytes(im_url)
1737
- if data:
1738
- with open(dst, "wb") as f:
1739
- f.write(data)
1740
- local_imgs.append(dst)
1741
- if not local_imgs:
1742
- img = Image.new("RGB", (1080, 1920), (24, 24, 24))
1743
- img.save(os.path.join(work, "scene_00.jpg"), quality=90)
1744
- local_imgs = [os.path.join(work, "scene_00.jpg")]
1745
-
1746
- # ---------- uploaded/scraped video background ----------
1747
- video_audio_path = None
1748
- using_video_first = False # video plays as-is (video-first assembly), images fill tail
1749
- if video_url:
1750
- vid_local = os.path.join(work, "bg_video.mp4")
1751
- dl_ok = _download_to_file(video_url, vid_local)
1752
- if not dl_ok:
1753
- # ---- Short HOT oEmbed fallback: can't download the media -> save
1754
- # the post as an embeddable slide with auto title/desc/thumb from
1755
- # the scrape metadata. The feed renders it as an iframe slide,
1756
- # exactly like the football video slides on YouTube.
1757
- fb_embed = embed_url or _oembed_embed_url(_clean(str(body.get("url", "")) or post.get("url") or ""))
1758
- fb_title = embed_title or post.get("title") or ""
1759
- fb_thumb = embed_thumb or post.get("img") or ""
1760
- fb_desc = embed_desc or ""
1761
- if not fb_title or not fb_thumb or not fb_desc:
1762
- _probe2 = _oembed_probe(_clean(str(body.get("url", "")) or post.get("url") or ""))
1763
- if _probe2:
1764
- fb_embed = fb_embed or _probe2.get("embed_url") or ""
1765
- fb_title = fb_title or _probe2.get("title") or ""
1766
- fb_thumb = fb_thumb or _probe2.get("thumbnail") or ""
1767
- fb_desc = fb_desc or (_probe2.get("description") or "")
1768
- if fb_embed:
1769
- post["video"] = fb_embed
1770
- post["embed_oembed"] = True
1771
- post["title"] = fb_title or post.get("title") or "Short HOT"
1772
- post["text"] = fb_desc or post.get("text") or fb_title or ""
1773
- post["description"] = fb_desc or ""
1774
- if fb_thumb:
1775
- post["img"] = fb_thumb
1776
- post["images"] = [fb_thumb] + [x for x in (post.get("images") or []) if x != fb_thumb]
1777
- post["short_thumb"] = fb_thumb
1778
- else:
1779
- post["short_thumb"] = post.get("img")
1780
- post["short_video_url"] = video_url
1781
- post["extractor"] = "oembed"
1782
- _save_wall_posts(posts)
1783
- return JSONResponse({"video": fb_embed, "oembed": True, "post": post})
1784
- return JSONResponse({"error": "Không tải được video từ link. Link đã hết hạn hoặc nguồn chặn."}, status_code=422)
1785
- try:
1786
- vdur = _probe_duration(vid_local, fallback=6.0)
1787
- except Exception:
1788
- vdur = 6.0
1789
- if video_dur and video_dur > 0:
1790
- vdur = float(video_dur)
1791
- # extract video's own audio (used as narration track)
1792
- vaudio = os.path.join(work, "vaudio.m4a")
1793
- subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", vid_local, "-vn",
1794
- "-c:a", "aac", "-b:a", "128k", vaudio],
1795
- stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
1796
- if os.path.exists(vaudio) and os.path.getsize(vaudio) > 900:
1797
- video_audio_path = vaudio
1798
-
1799
- # --- NEW: video-first mode (scraped video or muted uploaded video)
1800
- # Video plays first; designed/selected images fill the rest if the
1801
- # video is shorter than the voice. No per-frame mosaic.
1802
- using_video_first = scrape_mode or video_muted
1803
- if not using_video_first:
1804
- # legacy uploaded-video mode: extract up to N frames as the
1805
- # moving-background scenes
1806
- n_frames = max(1, min(5, len(local_imgs) if len(local_imgs) >= 1 else 5))
1807
- frame_imgs = []
1808
- for i in range(n_frames):
1809
- t = vdur * (i + 0.5) / n_frames
1810
- fimg = os.path.join(work, f"vc_{i:02d}.jpg")
1811
- subprocess.run(["ffmpeg", "-y", "-v", "error", "-ss", str(t), "-i", vid_local,
1812
- "-frames:v", "1", "-q:v", "3", fimg],
1813
- stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=60)
1814
- if os.path.exists(fimg) and os.path.getsize(fimg) > 1200:
1815
- frame_imgs.append(fimg)
1816
- if frame_imgs:
1817
- local_imgs = frame_imgs
1818
-
1819
- # ---------- narration plan ----------
1820
- reuse_audio_path = None
1821
- if reuse_audio:
1822
- prev = _find_previous_short(post_id)
1823
- if prev:
1824
- try:
1825
- prev_audio = os.path.join(work, "prev_audio.m4a")
1826
- subprocess.run(["ffmpeg", "-y", "-i", prev, "-vn", "-acodec", "aac", "-b:a", "128k", prev_audio],
1827
- check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
1828
- if os.path.exists(prev_audio) and os.path.getsize(prev_audio) > 900:
1829
- reuse_audio_path = prev_audio
1830
- except Exception:
1831
- reuse_audio_path = None
1832
- if video_audio_path and not reuse_audio_path and not video_muted and not using_video_first:
1833
- # uploaded/scraped video has its own audio -> use it instead of TTS
1834
- # (only in legacy mosaic mode; video-first mode keeps video audio as a
1835
- # background layer mixed with the TTS narration)
1836
- reuse_audio_path = video_audio_path
1837
- segs = slide_texts if (use_slides and slide_texts) else _segments_from_post(post)
1838
- while len(segs) < len(local_imgs):
1839
- segs.append(segs[-1] if segs else post.get("title", "Bản tin VNEWS"))
1840
-
1841
- # ---------- NEW: video-first short assembly ----------
1842
- if using_video_first:
1843
- # images for the tail: designed slides first, then post images
1844
- tail_imgs = list(local_imgs) if (local_imgs and not (use_slides and fixed_image)) else local_imgs
1845
- # if user chose a fixed image -> only that image for the tail
1846
- if fixed_image:
1847
- fimg = os.path.join(work, "fixed_tail.jpg")
1848
- data = _fetch_bytes(fixed_image)
1849
- if data:
1850
- with open(fimg, "wb") as f:
1851
- f.write(data)
1852
- tail_imgs = [fimg] if os.path.getsize(fimg) > 1200 else (tail_imgs or [])
1853
- # ensure at least the post image exists as tail
1854
- if not tail_imgs:
1855
- fallback_img = os.path.join(work, "scene_00.jpg")
1856
- if os.path.exists(fallback_img):
1857
- tail_imgs = [fallback_img]
1858
- # TTS segments: for video-first, use per-slide texts if slides,
1859
- # else the whole article as segments (each read out)
1860
- tts_segs = slide_texts if (use_slides and slide_texts) else _segments_from_post(post)
1861
- if not tts_segs:
1862
- tts_segs = [post.get("title", "Bản tin VNEWS")]
1863
- try:
1864
- out_mp4, _total = _assemble_video_short(
1865
- work, vid_local, vdur,
1866
- tail_imgs, tts_segs,
1867
- reuse_audio_path,
1868
- (video_audio_path if not video_muted else None),
1869
- voice, speed,
1870
- None, out_mp4, post.get("title", ""),
1871
- )
1872
- # music overlay handled by the shared block below
1873
- music_url = ""
1874
- if audio_url:
1875
- music_url = audio_url
1876
- elif audio_music and audio_music in MUSIC_BY_ID:
1877
- music_url = MUSIC_BY_ID[audio_music].get("url", "")
1878
- if music_url:
1879
- music_path = os.path.join(work, "music.mp3")
1880
- data = _fetch_bytes(music_url)
1881
- if data:
1882
- with open(music_path, "wb") as f:
1883
- f.write(data)
1884
- if os.path.exists(music_path) and os.path.getsize(music_path) > 900:
1885
- muxed = os.path.join(work, "muxed.mp4")
1886
- vid_dur2 = _probe_duration(out_mp4, fallback=30.0)
1887
- if _probe_has_audio(out_mp4):
1888
- fc = (
1889
- f"[1:a]volume=0.5,aloop=loop=-1:size=2e9,atrim=duration={vid_dur2}[m];"
1890
- f"[0:a][m]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[aout]"
1891
- )
1892
- cmd = ["ffmpeg", "-y", "-i", out_mp4, "-i", music_path,
1893
- "-filter_complex", fc, "-map", "0:v", "-map", "[aout]",
1894
- "-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-shortest", muxed]
1895
- else:
1896
- fc = f"[1:a]volume=0.55,aloop=loop=-1:size=2e9,atrim=duration={vid_dur2}[a]"
1897
- cmd = ["ffmpeg", "-y", "-i", out_mp4, "-i", music_path,
1898
- "-filter_complex", fc, "-map", "0:v", "-map", "[a]",
1899
- "-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-shortest", muxed]
1900
- subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=240)
1901
- os.replace(muxed, out_mp4)
1902
- post["video"] = out_url
1903
- post["short_thumb"] = _first_frame(out_mp4, os.path.join(WALL_IMG_DIR, _safe_name(post_id + suffix + "_thumb") + ".jpg"))
1904
- post["short_voice"] = voice
1905
- post["short_emotion"] = emotion
1906
- post["short_speed"] = speed
1907
- post["short_music"] = audio_music
1908
- post["short_audio_url"] = audio_url
1909
- post["short_use_slides"] = use_slides
1910
- post["short_images"] = scene_images
1911
- post["short_video_url"] = video_url
1912
- post["short_video_muted"] = video_muted
1913
- _save_wall_posts(posts)
1914
- return JSONResponse({"video": out_url, "voice": voice, "emotion": emotion,
1915
- "speed": speed, "music": audio_music, "use_slides": use_slides,
1916
- "post": post})
1917
- except Exception as e:
1918
- if create_new:
1919
- _load_wall_posts() # ensure fresh list
1920
- posts2 = [p for p in _load_wall_posts() if str(p.get("id")) != str(post_id)]
1921
- _save_wall_posts(posts2)
1922
- return JSONResponse({"error": "Không tạo được shorts: " + str(e)[:220]}, status_code=500)
1923
-
1924
- # ---------- frame rendering ----------
1925
- frames = []
1926
- for idx in range(len(local_imgs)):
1927
- frame = os.path.join(work, f"frame_{idx:02d}.jpg")
1928
- with open(local_imgs[idx], "rb") as f:
1929
- data = f.read()
1930
- if use_slides:
1931
- _frame_from_image(data, frame, seg_idx=idx, seg_total=len(local_imgs))
1932
- else:
1933
- _frame_with_text(data, frame, segs[idx], post.get("title", ""), seg_idx=idx, seg_total=len(local_imgs))
1934
- frames.append(frame)
1935
-
1936
- part_files = []
1937
- try:
1938
- if reuse_audio_path:
1939
- # ---------- reuse previous short audio ----------
1940
- total_dur = _probe_duration(reuse_audio_path, fallback=30.0)
1941
- per = max(2.2, min(8.0, total_dur / max(1, len(frames))))
1942
- for idx, frame in enumerate(frames):
1943
- part = os.path.join(work, f"part_{idx:02d}.mp4")
1944
- subprocess.run(
1945
- ["ffmpeg", "-y", "-loop", "1", "-t", str(per), "-i", frame,
1946
- "-c:v", "libx264", "-tune", "stillimage", "-pix_fmt", "yuv420p", "-an", part],
1947
- check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=150)
1948
- part_files.append(part)
1949
- concat = os.path.join(work, "concat.txt")
1950
- with open(concat, "w", encoding="utf-8") as f:
1951
- for p in part_files:
1952
- f.write("file '" + p.replace("'", "'\\''") + "'\n")
1953
- subprocess.run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat, "-c", "copy", out_mp4],
1954
- check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=240)
1955
- # mux previous audio (trim to video length)
1956
- vid_dur = _probe_duration(out_mp4, fallback=total_dur)
1957
- with_audio = os.path.join(work, "with_audio.mp4")
1958
- subprocess.run(
1959
- ["ffmpeg", "-y", "-i", out_mp4, "-i", reuse_audio_path,
1960
- "-filter_complex", f"[1:a]atrim=duration={vid_dur}[a]",
1961
- "-map", "0:v", "-map", "[a]", "-c:v", "copy", "-c:a", "aac",
1962
- "-b:a", "128k", "-shortest", with_audio],
1963
- check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=240)
1964
- os.replace(with_audio, out_mp4)
1965
- else:
1966
- # ---------- fresh TTS narration ----------
1967
- edge_voice = {
1968
- "vi-vn-hoaimyneural": "vi-VN-HoaiMyNeural", "vi-vn-namminhneural": "vi-VN-NamMinhNeural",
1969
- "hoaimy": "vi-VN-HoaiMyNeural", "namminh": "vi-VN-NamMinhNeural", "nam": "vi-VN-NamMinhNeural",
1970
- "male": "vi-VN-NamMinhNeural", "nu": "vi-VN-HoaiMyNeural", "female": "vi-VN-HoaiMyNeural",
1971
- "mien-nam": "vi-VN-HoaiMyNeural",
1972
- "en-us-andrewmultilingualneural": "en-US-AndrewMultilingualNeural",
1973
- "en-au-williammultilingualneural": "en-AU-WilliamMultilingualNeural",
1974
- "andrew": "en-US-AndrewMultilingualNeural", "en_andrew": "en-US-AndrewMultilingualNeural",
1975
- "jenny": "en-US-AndrewMultilingualNeural", "en_jenny": "en-US-AndrewMultilingualNeural",
1976
- "pt-br-thalitamultilingualneural": "pt-BR-ThalitaMultilingualNeural",
1977
- "thalita": "pt-BR-ThalitaMultilingualNeural", "pt": "pt-BR-ThalitaMultilingualNeural",
1978
- "fr-fr-viviennemultilingualneural": "fr-FR-VivienneMultilingualNeural",
1979
- "fr-fr-remymultilingualneural": "fr-FR-RemyMultilingualNeural",
1980
- "denise": "fr-FR-VivienneMultilingualNeural", "fr": "fr-FR-VivienneMultilingualNeural",
1981
- "de-de-seraphinamultilingualneural": "de-DE-SeraphinaMultilingualNeural",
1982
- "de-de-florianmultilingualneural": "de-DE-FlorianMultilingualNeural",
1983
- "katja": "de-DE-SeraphinaMultilingualNeural", "de": "de-DE-SeraphinaMultilingualNeural",
1984
- "ko-kr-hyusumultilingualneural": "ko-KR-HyunsuMultilingualNeural",
1985
- "ko-kr-hyunsuneural": "ko-KR-HyunsuMultilingualNeural", "sunhee": "ko-KR-HyunsuMultilingualNeural",
1986
- "ko": "ko-KR-HyunsuMultilingualNeural",
1987
- "it-it-giuseppemultilingualneural": "it-IT-GiuseppeMultilingualNeural",
1988
- "ela": "en-US-AndrewMultilingualNeural", "es": "en-US-AndrewMultilingualNeural",
1989
- }.get(voice.lower(), voice)
1990
- for idx, frame in enumerate(frames):
1991
- aud = os.path.join(work, f"voice_{idx:02d}.mp3")
1992
- aud_fast = os.path.join(work, f"voice_{idx:02d}_fast.mp3")
1993
- part = os.path.join(work, f"part_{idx:02d}.mp4")
1994
- spoken = segs[idx] if idx < len(segs) else post.get("title", "Bản tin VNEWS")
1995
- try:
1996
- subprocess.run(
1997
- ["python", "-m", "edge_tts", "--voice", edge_voice, "--text", spoken,
1998
- "--write-media", aud],
1999
- check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
2000
- except Exception:
2001
- aud = None
2002
- try:
2003
- from gtts import gTTS
2004
- tld = "com.vn" if voice.lower() in ("nu", "female", "mien-nam", "hoaimy") else "com"
2005
- try:
2006
- gTTS(spoken, lang="vi", tld=tld, slow=False).save(aud)
2007
- except TypeError:
2008
- gTTS(spoken, lang="vi", slow=False).save(aud)
2009
- except Exception:
2010
- pass
2011
- if aud and os.path.exists(aud) and os.path.getsize(aud) > 900:
2012
- subprocess.run(["ffmpeg", "-y", "-i", aud, "-filter:a", f"atempo={speed}", "-vn", aud_fast],
2013
- check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
2014
- dur = _probe_duration(aud_fast, fallback=15.0) + 0.35
2015
- subprocess.run(
2016
- ["ffmpeg", "-y", "-loop", "1", "-t", str(dur), "-i", frame, "-i", aud_fast,
2017
- "-shortest", "-c:v", "libx264", "-tune", "stillimage", "-pix_fmt", "yuv420p",
2018
- "-c:a", "aac", "-b:a", "128k", part],
2019
- check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=150)
2020
- else:
2021
- subprocess.run(
2022
- ["ffmpeg", "-y", "-loop", "1", "-t", "4", "-i", frame,
2023
- "-c:v", "libx264", "-tune", "stillimage", "-pix_fmt", "yuv420p", "-an", part],
2024
- check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
2025
- part_files.append(part)
2026
- concat = os.path.join(work, "concat.txt")
2027
- with open(concat, "w", encoding="utf-8") as f:
2028
- for p in part_files:
2029
- f.write("file '" + p.replace("'", "'\\''") + "'\n")
2030
- subprocess.run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat, "-c", "copy", out_mp4],
2031
- check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=240)
2032
-
2033
- # ---------- background music overlay ----------
2034
- music_url = ""
2035
- if audio_url:
2036
- music_url = audio_url
2037
- elif audio_music and audio_music in MUSIC_BY_ID:
2038
- music_url = MUSIC_BY_ID[audio_music].get("url", "")
2039
- if music_url:
2040
- music_path = os.path.join(work, "music.mp3")
2041
- data = _fetch_bytes(music_url)
2042
- if data:
2043
- with open(music_path, "wb") as f:
2044
- f.write(data)
2045
- if os.path.exists(music_path) and os.path.getsize(music_path) > 900:
2046
- muxed = os.path.join(work, "muxed.mp4")
2047
- vid_dur = _probe_duration(out_mp4, fallback=30.0)
2048
- if _probe_has_audio(out_mp4):
2049
- # narration at full volume + music at 0.5, no amix downscale (normalize=0)
2050
- fc = (
2051
- f"[1:a]volume=0.5,aloop=loop=-1:size=2e9,atrim=duration={vid_dur}[m];"
2052
- f"[0:a][m]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[aout]"
2053
- )
2054
- cmd = ["ffmpeg", "-y", "-i", out_mp4, "-i", music_path,
2055
- "-filter_complex", fc, "-map", "0:v", "-map", "[aout]",
2056
- "-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-shortest", muxed]
2057
- else:
2058
- fc = f"[1:a]volume=0.55,aloop=loop=-1:size=2e9,atrim=duration={vid_dur}[a]"
2059
- cmd = ["ffmpeg", "-y", "-i", out_mp4, "-i", music_path,
2060
- "-filter_complex", fc, "-map", "0:v", "-map", "[a]",
2061
- "-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-shortest", muxed]
2062
- subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=240)
2063
- os.replace(muxed, out_mp4)
2064
-
2065
- except Exception as e:
2066
- if create_new:
2067
- posts2 = [p for p in _load_wall_posts() if str(p.get("id")) != str(post_id)]
2068
- _save_wall_posts(posts2)
2069
- return JSONResponse({"error": "Không tạo được shorts: " + str(e)[:220]}, status_code=500)
2070
-
2071
- post["video"] = out_url
2072
- post["short_thumb"] = _first_frame(out_mp4, os.path.join(WALL_IMG_DIR, _safe_name(post_id + suffix + "_thumb") + ".jpg"))
2073
- post["short_voice"] = voice
2074
- post["short_emotion"] = emotion
2075
- post["short_speed"] = speed
2076
- post["short_music"] = audio_music
2077
- post["short_audio_url"] = audio_url
2078
- post["short_use_slides"] = use_slides
2079
- post["short_images"] = scene_images
2080
-
2081
- return JSONResponse({"video": out_url, "voice": voice, "emotion": emotion,
2082
- "speed": speed, "music": audio_music, "use_slides": use_slides,
2083
- "post": post})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app_v2.js DELETED
The diff for this file is too large to render. See raw diff
 
app_v2_entry.py CHANGED
@@ -12,12 +12,7 @@ try:
12
  except Exception as e:
13
  print(f"[WARN] ai_patch import failed: {e}")
14
 
15
- try:
16
- import ai_short_v2 # Short AI v2: TikTok music, uploaded audio/video/img, recreate from slides
17
- except Exception as e:
18
- print(f"[WARN] ai_short_v2 import failed: {e}")
19
-
20
- from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response, StreamingResponse
21
  from fastapi.staticfiles import StaticFiles
22
  from starlette.routing import Mount
23
  from fastapi import Query, Request, UploadFile, File, Form
@@ -26,35 +21,16 @@ from bs4 import BeautifulSoup
26
  import re, html as html_lib, json, threading, time, uuid
27
  from concurrent.futures import ThreadPoolExecutor, as_completed
28
  from urllib.parse import quote
29
- import asyncio
30
 
31
  HL_LEAGUES['friendly'] = {"path": "giai-khac/friendly", "name": "Giao hữu", "emoji": "🤝"}
32
 
33
  STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
34
- SPACE = "https://bep40-vnews.hf.space" # SEO URL base for share links
35
  app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)=='/' and hasattr(r,'methods') and 'GET' in getattr(r,'methods',set()))]
36
  app.routes[:]=[r for r in app.routes if not isinstance(r, Mount)]
37
  app.router.routes=[r for r in app.router.routes if not isinstance(r, Mount)]
38
 
39
  def _clean(s): return re.sub(r"\s+"," ",html_lib.unescape(str(s or""))).strip()
40
 
41
- def _ensure_sentence_complete(text):
42
- """Ensure text ends with complete sentence (ends with . ! or ?). Trim if cut mid-sentence."""
43
- text = _clean(text)
44
- if not text:
45
- return text
46
- # Find last sentence ending
47
- for end_char in ['.', '!', '?']:
48
- last_pos = text.rfind(end_char)
49
- if last_pos > len(text) * 0.5: # Keep if ending is in latter half
50
- return text[:last_pos + 1].strip()
51
- # If no ending found, try to find last complete sentence
52
- sentences = re.split(r'(?<=[.!?])\s+', text)
53
- complete = [s.strip() for s in sentences if s.strip() and len(s.strip()) > 20]
54
- if complete[:-1]: # Return all but last incomplete
55
- return ' '.join(complete[:-1])
56
- return text[:150] + '.' if len(text) > 150 else text
57
-
58
  # Cache for match details (5 min TTL)
59
  _match_cache = {}
60
 
@@ -231,7 +207,7 @@ _STOP=set('và của các những một được trong với cho tại sau trư
231
  def _has_kw(topic,title):
232
  tl=topic.lower();tt=(title or'').lower()
233
  if tl in tt:return True
234
- words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',tl) if len(w)>2 and w.lower() not in _STOP]
235
  if not words:return True
236
  return any(w in tt for w in words)
237
 
@@ -351,7 +327,7 @@ def _search_all(topic,limit=36):
351
  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])
352
  return out[:limit]
353
 
354
- for _path in ['/api/article', '/api/hot_topics', '/api/categories', '/api/storage_status']:
355
  app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)==_path and 'GET' in getattr(r,'methods',set()))]
356
 
357
  _article_cache = {}
@@ -480,39 +456,24 @@ def api_article_v2(url: str = Query(...)):
480
  return JSONResponse({'error': f'Server error: {str(e)[:100]}', 'url': safe_url}, status_code=200)
481
 
482
  _hot_cache={'t':0,'d':[]}
483
- def _parse_feed(feed_url):
484
- try:
485
- r=req.get(feed_url,headers={'User-Agent':'Mozilla/5.0'},timeout=4)
486
- r.encoding='utf-8';soup=BeautifulSoup(r.text,'xml')
487
- out=[]
488
- for item in soup.find_all('item')[:12]:
489
- t=item.find('title')
490
- title=_clean(t.get_text() if t else '')
491
- if title: out.append(title)
492
- return out
493
- except Exception:
494
- return []
495
  def _get_hot_topics():
496
  now=time.time()
497
  if _hot_cache['d'] and now-_hot_cache['t']<600:return _hot_cache['d']
498
- feeds=['https://vnexpress.net/rss/tin-moi-nhat.rss','https://dantri.com.vn/rss/home.rss','https://vietnamnet.vn/rss/tin-moi-nhat.rss','https://thanhnien.vn/rss/home.rss','https://tuoitre.vn/rss/tin-moi-nhat.rss','https://genk.vn/rss','https://vnexpress.net/rss/the-thao.rss','https://thethaovanhoa.vn/rss/tin-nong.rss']
499
- titles=[]
500
- with ThreadPoolExecutor(8) as ex:
501
- futs=[ex.submit(_parse_feed,f) for f in feeds]
502
- for f in as_completed(futs,timeout=5):
503
- try:
504
- for t in f.result(): titles.append(t)
505
- except Exception: pass
506
  freq={};display={}
507
- for title in titles:
508
- if not title: continue
509
- title=re.sub(r'\s*[-|].*$','',title)
510
- words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',title) if len(w)>2 and w.lower() not in _STOP]
511
- if len(words)<2:continue
512
- for n in(3,4,2):
513
- for i in range(max(0,len(words)-n+1)):
514
- phrase=' '.join(words[i:i+n])
515
- if 8<=len(phrase)<=45:key=phrase.lower();freq[key]=freq.get(key,0)+1;display[key]=phrase
 
 
 
 
 
516
  ranked=sorted(freq.items(),key=lambda x:x[1],reverse=True);topics=[];seen=set()
517
  for key,count in ranked:
518
  is_dup=any(len(set(e.split())&set(key.split()))/max(len(set(e.split())),len(set(key.split())),1)>0.6 for e in seen)
@@ -539,206 +500,11 @@ def _ht(topic:str=Query(...),page:int=Query(default=0)):
539
  items=_search_all(topic,36);per_page=8;start=page*per_page;end=start+per_page
540
  return JSONResponse({'sources':items[start:end],'topic':topic,'page':page,'has_more':end<len(items),'total':len(items)})
541
  @app.get('/api/categories')
542
- def _cat():
543
- try:
544
- from main import VNE_CATS
545
- cats=[{"id":k,"name":n,"source":"vne"} for k,(u,n) in VNE_CATS.items()]
546
- resp=JSONResponse(cats)
547
- resp.headers["Cache-Control"]="public, max-age=600"
548
- return resp
549
- except Exception:
550
- return JSONResponse([])
551
  @app.get('/api/storage_status')
552
  def _st():return JSONResponse({'persistent':os.path.isdir('/data') and os.access('/data',os.W_OK)})
553
- # ===== SHARE HELPERS: render content pages for shared links =====
554
- def _render_slides_page(post, safe_title, safe_img, safe_url):
555
- slides = post.get('slides', [])
556
- # Get image from post.img or first slide's image
557
- if not safe_img and slides and slides[0].get('image'):
558
- safe_img = slides[0].get('image', '')
559
- # Use text for description if available
560
- description = _clean((post.get('text') or '')[:200]) or "Tin tức tóm tắt, AI rewrite, World Cup 2026"
561
-
562
- # Build canonical URL preserving original query format if url was provided
563
- if safe_url and safe_url != '/':
564
- canonical_url = f"{SPACE}/s?url={quote(safe_url)}&title={quote(safe_title[:100])}"
565
- else:
566
- canonical_url = f"{SPACE}/s?post_id={post.get('id') or ''}"
567
-
568
- h = f'''<!DOCTYPE html>
569
- <html lang="vi">
570
- <head>
571
- <meta charset="utf-8">
572
- <meta name="viewport" content="width=device-width,initial-scale=1">
573
- <title>{_clean(safe_title)}</title>
574
- <meta property="og:title" content="{_clean(safe_title)}">
575
- <meta property="og:image" content="{_clean(safe_img)}">
576
- <meta property="og:description" content="{description}">
577
- <meta property="og:url" content="{canonical_url}">
578
- <link rel="canonical" href="{canonical_url}">
579
- <style>
580
- *{{box-sizing:border-box;margin:0;padding:0}}body{{background:#111;color:#eee;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:12px}}
581
- .slide-card{{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:16px;margin-bottom:12px;max-width:600px;margin-left:auto;margin-right:auto}}
582
- .slide-num{{color:#5cb87a;font-size:12px;font-weight:700;margin-bottom:6px}}
583
- .slide-img{{width:100%;max-height:300px;object-fit:cover;border-radius:8px;margin-bottom:8px}}
584
- .slide-text{{color:#ddd;font-size:14px;line-height:1.6;margin:0}}
585
- </style>
586
- </head>
587
- <body>'''
588
- for s in slides:
589
- img_src = s.get('image', '')
590
- if img_src and ('cdnphoto.dantri' in img_src or 'refooty' in img_src or 'vnexpress' in img_src or 'vcdn' in img_src):
591
- img_tag = f'<img src="/api/proxy/img?url={quote(img_src, safe="")}" class="slide-img" loading="lazy" onerror="this.style.display=\'none\'">'
592
- else:
593
- img_tag = f'<img src="{_clean(img_src)}" class="slide-img" loading="lazy" onerror="this.style.display=\'none\'">' if img_src else ''
594
- h += f'<div class="slide-card"><div class="slide-num">Slide {s.get("index",1)}/{len(slides)}</div>{img_tag}<p class="slide-text">{_clean(s.get("text",""))}</p></div>'
595
- h += '</body></html>'
596
- return HTMLResponse(h)
597
-
598
- def _render_video_page(post, safe_title, safe_img, safe_url):
599
- video_url = post.get('video', '')
600
- # Use text for description if available
601
- description = _clean((post.get('text') or '')[:200]) or "Tin tức tóm tắt, AI rewrite, World Cup 2026"
602
-
603
- # Build canonical URL preserving original query format if url was provided
604
- if safe_url and safe_url != '/':
605
- canonical_url = f"{SPACE}/s?url={quote(safe_url)}&title={quote(safe_title[:100])}"
606
- else:
607
- canonical_url = f"{SPACE}/s?post_id={post.get('id') or ''}"
608
-
609
- h = f'''<!DOCTYPE html>
610
- <html lang="vi">
611
- <head>
612
- <meta charset="utf-8">
613
- <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
614
- <title>{_clean(safe_title)}</title>
615
- <meta property="og:title" content="{_clean(safe_title)}">
616
- <meta property="og:image" content="{_clean(safe_img)}">
617
- <meta property="og:description" content="{description}">
618
- <meta property="og:url" content="{canonical_url}">
619
- <link rel="canonical" href="{canonical_url}">
620
- <meta name="twitter:card" content="player">
621
- <meta name="twitter:player" content="{video_url}">
622
- <style>
623
- *{{box-sizing:border-box;margin:0;padding:0}}body{{background:#111;color:#eee;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:0;overflow:hidden}}
624
- .video-container{{width:100vw;height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#000}}
625
- video{{width:100%;height:100%;max-height:100vh;object-fit:contain;background:#000}}
626
- .title-bar{{position:fixed;bottom:0;left:0;right:0;background:linear-gradient(transparent,rgba(0,0,0,.8));padding:40px 16px 16px;text-align:center}}
627
- .title-text{{color:#fff;font-size:13px;line-height:1.4;max-width:600px;margin:0 auto}}
628
- </style>
629
- </head>
630
- <body>
631
- <div class="video-container">
632
- <video src="{_clean(video_url)}" controls autoplay playsinline loop></video>
633
- <div class="title-bar"><div class="title-text">{_clean(safe_title)}</div></div>
634
- </div>
635
- </body></html>'''
636
- return HTMLResponse(h)
637
-
638
- @app.get('/s/{slug}')
639
- async def _sh_slug(slug: str, request: Request, url: str = '', title: str = '', img: str = ''):
640
- """SEO-friendly share endpoint with slug in URL path.
641
- Shows slide content when slug matches a wall post ID, otherwise redirects.
642
- """
643
- safe_title = _clean(title) if title else 'VNEWS - Tin tức'
644
- safe_img = _clean(img) if img else ''
645
- safe_url = _clean(url) if url else '/'
646
-
647
- # Try to find post by slug (post ID)
648
- post = None
649
- try:
650
- if slug and len(slug) > 5: # Likely a post ID
651
- posts = _load_wall_posts()
652
- for p in posts:
653
- if p.get('id') == slug:
654
- post = p
655
- safe_title = p.get('title', safe_title) or safe_title
656
- safe_img = p.get('img', safe_img) or safe_img
657
- safe_url = p.get('url', safe_url) or safe_url
658
- break
659
- except:
660
- pass
661
-
662
- if post and post.get('slides'):
663
- return _render_slides_page(post, safe_title, safe_img, safe_url)
664
-
665
- if post and post.get('video'):
666
- return _render_video_page(post, safe_title, safe_img, safe_url)
667
-
668
- # Otherwise redirect
669
- return HTMLResponse(f'''<!DOCTYPE html>
670
- <html lang="vi">
671
- <head>
672
- <meta charset="utf-8">
673
- <meta name="viewport" content="width=device-width,initial-scale=1">
674
- <title>{_clean(safe_title)}</title>
675
- <meta property="og:title" content="{_clean(safe_title)}">
676
- <meta property="og:image" content="{_clean(safe_img)}">
677
- <meta property="og:description" content="Tin tức tóm tắt, AI rewrite, World Cup 2026">
678
- <meta property="og:url" content="{SPACE}/s/{slug}">
679
- <link rel="canonical" href="{SPACE}/s/{slug}">
680
- <meta http-equiv="refresh" content="0;url={safe_url}">
681
- </head><body></body></html>''')
682
-
683
  @app.get('/s')
684
- async def _sh(url:str='',title:str='',img:str='',post_id:str=''):
685
- safe_title = _clean(title) if title else 'VNEWS - Tin tức'
686
- safe_img = _clean(img) if img else ''
687
- safe_url = _clean(url) if url else '/'
688
-
689
- # Try to find wall post by post_id or URL (prioritize posts with slides/video)
690
- post = None
691
- try:
692
- posts = _load_wall_posts()
693
- if post_id:
694
- for p in posts:
695
- if p.get('id') == post_id:
696
- post = p
697
- safe_title = p.get('title', safe_title) or safe_title
698
- safe_img = p.get('img', safe_img) or safe_img
699
- safe_url = p.get('url', safe_url) or safe_url
700
- break
701
- elif url:
702
- # Find matching URL - prioritize posts with slides or video
703
- for p in posts:
704
- if p.get('url') == url and p.get('slides'):
705
- post = p
706
- safe_title = p.get('title', safe_title) or safe_title
707
- safe_img = p.get('img', safe_img) or safe_img
708
- safe_url = p.get('url', safe_url) or safe_url
709
- break
710
- if not post:
711
- # Fallback: find any matching URL
712
- for p in posts:
713
- if p.get('url') == url:
714
- post = p
715
- safe_title = p.get('title', safe_title) or safe_title
716
- safe_img = p.get('img', safe_img) or safe_img
717
- safe_url = p.get('url', safe_url) or safe_url
718
- break
719
- except:
720
- pass
721
-
722
- if post and post.get('slides'):
723
- return _render_slides_page(post, safe_title, safe_img, safe_url)
724
-
725
- if post and post.get('video'):
726
- return _render_video_page(post, safe_title, safe_img, safe_url)
727
-
728
- # Fallback: redirect to original URL
729
- return HTMLResponse(f'''<!DOCTYPE html>
730
- <html lang="vi">
731
- <head>
732
- <meta charset="utf-8">
733
- <meta name="viewport" content="width=device-width,initial-scale=1">
734
- <title>{safe_title}</title>
735
- <meta property="og:title" content="{safe_title}">
736
- <meta property="og:image" content="{safe_img}">
737
- <meta property="og:description" content="Tin tức tóm tắt, AI rewrite, World Cup 2026">
738
- <meta property="og:url" content="{SPACE}/s?url={quote(safe_url)}">
739
- <link rel="canonical" href="{SPACE}/s?url={quote(safe_url)}">
740
- <meta http-equiv="refresh" content="0;url={safe_url}">
741
- </head><body></body></html>''')
742
 
743
  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
744
 
@@ -881,273 +647,15 @@ async def _pc(request:Request):
881
 
882
  def _load_wall_posts():
883
  with _wl_lock:
884
- posts = _lj(WALL_FILE)
885
- if not isinstance(posts, list):
886
- posts = []
887
- return posts
888
 
889
  def _save_wall_posts(posts):
890
  with _wl_lock:
891
  _sj(WALL_FILE, posts)
892
 
893
- def _post_has_valid_image(p):
894
- """A wall post is considered to have a 'valid' (non-placeholder) image if
895
- at least one of the following is a real, non-placeholder URL:
896
- - p.img (main thumbnail)
897
- - p.short_thumb (generated by Short AI / oEmbed fallback)
898
- - p.images[] (fallback image list)
899
- - p.slides[].image (designed slide images)
900
-
901
- Auto posts that failed metadata enrichment — e.g. pollinations.ai
902
- AI-generated placeholder images, vnexpress logo placeholders, or empty/None
903
- fields — are flagged as having NO valid image so they can be filtered out
904
- of the wall (they show the wrong picture vs the original article)."""
905
- PLACEHOLDER_MARKERS = (
906
- "pollinations.ai", # AI-generated editorial illustration placeholder
907
- "logo_default.jpg", # vnexpress placeholder logo
908
- "logo.png",
909
- "/logo",
910
- "placeholder",
911
- "no-image",
912
- "blank",
913
- )
914
-
915
- def _is_placeholder(url):
916
- if not url or not isinstance(url, str):
917
- return True
918
- u = url.strip().lower()
919
- if not u or u.startswith(("about:blank", "data:")):
920
- return True
921
- return any(m in u for m in PLACEHOLDER_MARKERS)
922
-
923
- def _is_real(url):
924
- return bool(url) and not _is_placeholder(url)
925
-
926
- img = p.get("img")
927
- if _is_real(img):
928
- return True
929
- thumb = p.get("short_thumb")
930
- if _is_real(thumb):
931
- return True
932
- images = p.get("images") or []
933
- if any(_is_real(x) for x in images):
934
- return True
935
- slides = p.get("slides") or []
936
- if any(_is_real(s.get("image")) for s in slides if isinstance(s, dict)):
937
- return True
938
- return False
939
-
940
- def _post_has_slide_info(p):
941
- """True if the post has usable slide/rewrite AI content:
942
- - designed slides with text+image, OR
943
- - a generated short video (/api/ai/short-file/) with a real thumbnail, OR
944
- - an oEmbed iframe (YouTube/TikTok/Facebook embed) with a real thumbnail
945
- (Short HOT fallback case — embeddable, viewable as a slide)."""
946
- slides = p.get("slides") or []
947
- if any((s.get("image") or "").strip() and (s.get("text") or "").strip() for s in slides if isinstance(s, dict)):
948
- return True
949
- # Generated short video with a real thumbnail
950
- vid = (p.get("video") or "").strip()
951
- if vid and "api/ai/short-file/" in vid and (p.get("short_thumb") or "").strip():
952
- return True
953
- # oEmbed iframe (YouTube/TikTok/FB) with a valid image -> viewable embed slide
954
- if vid and ("youtube.com/embed" in vid or "tiktok.com/embed" in vid or
955
- "facebook.com/plugins" in vid or "instagram.com" in vid or
956
- "twitter.com" in vid or "x.com" in vid or "player.vimeo" in vid):
957
- if _post_has_valid_image(p):
958
- return True
959
- return False
960
-
961
- def _is_auto_post(p):
962
- """Auto-generated wall posts: Short HOT, oEmbed fallback, FPT auto,
963
- slide-rewrite AI (auto_rewrite), topic auto-summary (topic_article), or
964
- posts with no meaningful source / created by the AI pipeline."""
965
- kind = (p.get("kind") or "").strip().lower()
966
- source = (p.get("source") or "").strip().lower()
967
- return kind in ("hot_short", "slide_summary", "auto_rewrite", "topic_article", "summary") or \
968
- source in ("hot_short", "fptplay", "ai", "auto_rewrite", "topic_article")
969
-
970
- def _created_ts(p):
971
- """Best-effort creation timestamp. Posts created by the AI pipeline often
972
- store `created` as a millisecond epoch; some older posts (auto_rewrite)
973
- omit it and instead embed the timestamp in the `id` (Unix ms). Fall back
974
- to 0 so sorting never crashes."""
975
- for field in ("created", "ts"):
976
- v = p.get(field)
977
- if v is not None:
978
- try:
979
- return int(v)
980
- except (ValueError, TypeError):
981
- pass
982
- # try to derive from id (numeric prefix = Unix ms timestamp)
983
- pid = str(p.get("id", "")).strip()
984
- if pid.isdigit() and len(pid) >= 12:
985
- try:
986
- return int(pid)
987
- except (ValueError, TypeError):
988
- pass
989
- return 0
990
-
991
- def _wall_posts_for_view():
992
- """Apply the user-facing wall filtering + sorting:
993
- 1) Hide auto-generated posts that have a wrong/placeholder image (no
994
- valid image at all — pollinations.ai, vnexpress logo, empty) or have
995
- no slide-AI content (no slides / no short-video / no embed). These
996
- are the 'bài đăng tự động bị sai ảnh / ko có thông tin slide AI' that
997
- don't match the original article. This filter is VIEW-ONLY — it does
998
- NOT delete from the persistent store.
999
- 2) Sort newest-first by creation timestamp, mixing all sources (FPT,
1000
- slide rewrite AI, etc.) without source separation."""
1001
- posts = _load_wall_posts()
1002
- if not posts:
1003
- return []
1004
- filtered = []
1005
- for p in posts:
1006
- if not isinstance(p, dict):
1007
- continue
1008
- if _is_auto_post(p) and not (_post_has_valid_image(p) and _post_has_slide_info(p)):
1009
- # auto post with a wrong/placeholder/missing image or no slide AI
1010
- # content -> hide from the wall (non-destructive)
1011
- continue
1012
- filtered.append(p)
1013
- filtered.sort(key=_created_ts, reverse=True)
1014
- return filtered
1015
-
1016
- @app.get("/api/ai/wall")
1017
- def api_ai_wall():
1018
- return JSONResponse({"posts": _wall_posts_for_view()})
1019
-
1020
-
1021
- # ===== YOUTUBE RSS FEED PROXY (avoids browser CORS) =====
1022
- _YT_FEED_CACHE = {}
1023
- _YT_FEED_TTL = 600 # 10 minutes
1024
-
1025
-
1026
- def _fetch_youtube_rss_feed(channel_id: str = "UC4LvrpNXujjbGOS4RDvr41g"):
1027
- """Fetch & parse the YouTube RSS feed server-side.
1028
-
1029
- The browser cannot reach youtube.com directly (CORS / network block).
1030
- We try two sources in order:
1031
- 1) Direct feed XML (works if the server has outbound YouTube access)
1032
- 2) rss2json.com proxy (always works — it fetches the feed server-side)
1033
- Cached for _YT_FEED_TTL seconds."""
1034
- now = time.time()
1035
- cached = _YT_FEED_CACHE.get(channel_id)
1036
- if cached and now - cached.get('_ts', 0) < _YT_FEED_TTL:
1037
- return cached.get('videos', [])
1038
- out = []
1039
- try:
1040
- # Attempt 1: direct YouTube RSS
1041
- feed_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
1042
- try:
1043
- r = req.get(feed_url, headers=HEADERS, timeout=10)
1044
- if r.status_code == 200 and r.text.strip():
1045
- soup = BeautifulSoup(r.text, 'xml')
1046
- for e in soup.find_all('entry'):
1047
- out.extend(_parse_yt_entry(e))
1048
- except Exception as ex2:
1049
- print(f"[yt-feed] direct fetch failed, trying rss2json: {ex2}")
1050
- # Attempt 2: rss2json proxy (fallback / always)
1051
- if not out:
1052
- rss2json_url = f"https://api.rss2json.com/v1/api.json?rss_url={feed_url}"
1053
- r2 = req.get(rss2json_url, headers=HEADERS, timeout=15)
1054
- if r2.status_code == 200:
1055
- data = r2.json()
1056
- if data.get('status') == 'ok':
1057
- for item in data.get('items', []):
1058
- vid_m = re.search(r'([a-zA-Z0-9_-]{11})', item.get('id', ''))
1059
- if not vid_m:
1060
- continue
1061
- vid = vid_m.group(1)
1062
- title = item.get('title', 'Video') or 'Video'
1063
- # prefer the youtube watch URL, fallback to item link
1064
- link = item.get('link', '')
1065
- if '/watch' not in link:
1066
- link = f'https://www.youtube.com/watch?v={vid}'
1067
- # thumbnail: prefer media:thumbnail, fallback to youtube hqdefault
1068
- thumb = item.get('thumbnail', f'https://i.ytimg.com/vi/{vid}/hqdefault.jpg')
1069
- published = item.get('pubDate', '')
1070
- out.append({
1071
- 'id': 'yt-' + vid,
1072
- 'videoId': vid,
1073
- 'title': title.strip()[:200],
1074
- 'link': link,
1075
- 'img': thumb,
1076
- 'published': published,
1077
- 'source': 'yt-feed'
1078
- })
1079
- if out:
1080
- _YT_FEED_CACHE[channel_id] = {'_ts': now, 'videos': out}
1081
- return out
1082
- except Exception as ex:
1083
- print(f"[yt-feed] server error: {ex}")
1084
- return []
1085
-
1086
-
1087
- def _parse_yt_entry(e):
1088
- """Parse a single YouTube RSS <entry> element into a video dict."""
1089
- out = []
1090
- try:
1091
- id_el = e.find('id')
1092
- if not id_el:
1093
- return out
1094
- vid_m = re.search(r'([a-zA-Z0-9_-]{11})', id_el.get_text(''))
1095
- if not vid_m:
1096
- return out
1097
- vid = vid_m.group(1)
1098
- title_el = e.find('title')
1099
- title = title_el.get_text('') if title_el else 'Video'
1100
- link_el = e.find('link', {'rel': 'alternate'})
1101
- link = link_el.get('href') if link_el else f'https://www.youtube.com/watch?v={vid}'
1102
- pub_el = e.find('published')
1103
- published = pub_el.get_text('') if pub_el else ''
1104
- ns = {'media': 'http://search.yahoo.com/mrss/'}
1105
- thumb_el = e.find('media:thumbnail', ns)
1106
- thumb_url = thumb_el.get('url') if thumb_el else f'https://i.ytimg.com/vi/{vid}/hqdefault.jpg'
1107
- out.append({
1108
- 'id': 'yt-' + vid,
1109
- 'videoId': vid,
1110
- 'title': title.strip()[:200],
1111
- 'link': link,
1112
- 'img': thumb_url,
1113
- 'published': published,
1114
- 'source': 'yt-feed'
1115
- })
1116
- except Exception as ex:
1117
- print(f"[yt-feed] parse entry error: {ex}")
1118
- return out
1119
-
1120
-
1121
- @app.get("/api/yt/feed")
1122
- def api_yt_feed(channel_id: str = Query(default="UC4LvrpNXujjbGOS4RDvr41g")):
1123
- """Server-side YouTube RSS feed fetcher — avoids browser CORS.
1124
- Returns parsed feed videos as JSON for the Tường AI wall."""
1125
- videos = _fetch_youtube_rss_feed(channel_id)
1126
- return JSONResponse({"videos": videos, "count": len(videos)})
1127
- @app.post("/api/ai/short/{post_id}")
1128
- def api_ai_short(post_id: str):
1129
- """Generate a short video for a wall post.
1130
- Returns clear JSON error when video not available yet.
1131
- """
1132
- post_id_s = str(post_id)
1133
- posts = _load_wall_posts()
1134
- if not isinstance(posts, list):
1135
- posts = []
1136
- post = None
1137
- for p in posts:
1138
- pid = str(p.get("id", ""))
1139
- if pid == post_id_s or pid.startswith(post_id_s):
1140
- post = p
1141
- break
1142
- if not post:
1143
- return JSONResponse({"error": "Không tìm thấy bài viết", "post_id": post_id}, status_code=404)
1144
- if post.get("video"):
1145
- return JSONResponse({"video": post["video"], "post": post})
1146
- return JSONResponse({"error": "Chưa có video cho bài này. Vui lòng upload video trước."}, status_code=409)
1147
-
1148
  @app.get('/api/wall')
1149
  def api_wall():
1150
- posts = _wall_posts_for_view()
1151
  if not posts:
1152
  return JSONResponse({"posts": []})
1153
  return JSONResponse({"posts": posts})
@@ -1215,66 +723,22 @@ async def api_wall_post(request: Request):
1215
  text = body.get('text', '') or ''
1216
  img = body.get('img', None)
1217
  source = body.get('source', 'user') or 'user'
1218
- incoming_id = body.get('id') or '' # allow client to (re)publish an existing post by id
1219
  post_id = str(uuid.uuid4())[:12]
1220
- # Preserve slide-design post fields (slides, kind, url, images, video, voice, etc.)
1221
- # so that "Thiết kế ảnh" -> "Đăng lên Tường AI" keeps the selected slides.
1222
- slides = body.get('slides')
1223
- kind = body.get('kind') or 'user'
1224
- post_url = body.get('url') or ''
1225
- images = body.get('images') or []
1226
- video = body.get('video')
1227
- voice = body.get('voice') or ''
1228
- emotion = body.get('emotion') or ''
1229
- language = body.get('language') or ''
1230
  post = {
1231
  "id": post_id,
1232
  "title": title[:200],
1233
  "text": text[:2000],
1234
  "source": source,
1235
- "video": video,
1236
  "img": img,
1237
- "images": images[:10],
1238
- "url": post_url,
1239
- "kind": kind,
1240
- "slides": slides if slides is not None else None,
1241
- "voice": voice,
1242
- "emotion": emotion,
1243
- "language": language,
1244
  "created": int(time.time()),
1245
  "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
1246
  }
1247
- # Remove keys with None so the JSON stays lean (but keep structure)
1248
- post = {k: v for k, v in post.items() if v is not None}
1249
  posts = _load_wall_posts()
1250
  if not isinstance(posts, list):
1251
  posts = []
1252
- # If the client re-publishes (e.g. designer "Lưu & Đăng lên Tường AI" sends
1253
- # the original post id), update that post in place instead of creating a
1254
- # duplicate. This keeps the homepage wall clean (no duplicate slide posts).
1255
- updated = False
1256
- if incoming_id:
1257
- for _p in posts:
1258
- if str(_p.get('id')) == str(incoming_id):
1259
- # Merge new fields into the existing post, but PRESERVE the
1260
- # original id (and created timestamp) so the post identity is
1261
- # stable and no duplicate is created.
1262
- preserve_id = _p.get('id')
1263
- preserve_created = _p.get('created')
1264
- preserve_created_str = _p.get('created_str')
1265
- _p.update(post)
1266
- _p['id'] = preserve_id
1267
- if preserve_created is not None: _p['created'] = preserve_created
1268
- if preserve_created_str is not None: _p['created_str'] = preserve_created_str
1269
- updated = True
1270
- post = _p # return the merged post (with stable id) to the client
1271
- break
1272
- if not updated:
1273
- posts.insert(0, post)
1274
- else:
1275
- # Move the updated post to the top so it re-appears at the top of the wall.
1276
- posts = [p for p in posts if str(p.get('id')) != str(incoming_id)]
1277
- posts.insert(0, post)
1278
  posts = posts[:200]
1279
  _save_wall_posts(posts)
1280
  return JSONResponse({"post": post, "ok": True})
@@ -1290,57 +754,6 @@ def api_wall_video(filename: str):
1290
  media_type = 'video/mp4' if ext == '.mp4' else 'video/webm'
1291
  return FileResponse(video_path, media_type=media_type)
1292
 
1293
- WALL_IMG_DIR = os.path.join(DATA_DIR, 'wall_imgs')
1294
- os.makedirs(WALL_IMG_DIR, exist_ok=True)
1295
-
1296
- @app.post('/api/wall/img')
1297
- async def api_wall_img(request: Request):
1298
- """Upload a designed slide image (PNG) and return a served URL."""
1299
- global WALL_IMG_DIR
1300
- try:
1301
- form = await request.form()
1302
- f = form.get('file')
1303
- if not f or not hasattr(f, 'filename') or not f.filename:
1304
- return JSONResponse({"error": "Thiếu file ảnh"}, status_code=400)
1305
- ext = os.path.splitext(f.filename)[1].lower()
1306
- if ext not in ('.png', '.jpg', '.jpeg', '.webp'):
1307
- ext = '.png'
1308
- img_id = str(uuid.uuid4())[:12]
1309
- fname = f"wallimg_{img_id}{ext}"
1310
- fpath = os.path.join(WALL_IMG_DIR, fname)
1311
- content = await f.read()
1312
- if not content:
1313
- return JSONResponse({"error": "File rỗng"}, status_code=400)
1314
- if len(content) > 15 * 1024 * 1024:
1315
- return JSONResponse({"error": "Ảnh quá lớn (>15MB)"}, status_code=400)
1316
- with open(fpath, 'wb') as fh:
1317
- fh.write(content)
1318
- post_id = (form.get('post_id') or '').strip()
1319
- if post_id:
1320
- posts = _load_wall_posts()
1321
- if isinstance(posts, list):
1322
- for p in posts:
1323
- if str(p.get('id')) == str(post_id):
1324
- p['img'] = f"/api/wall/img/{fname}"
1325
- break
1326
- _save_wall_posts(posts)
1327
- url = f"/api/wall/img/{fname}"
1328
- return JSONResponse({"ok": True, "url": url, "img_id": img_id})
1329
- except Exception as e:
1330
- return JSONResponse({"error": f"Lỗi upload ảnh: {str(e)[:150]}"}, status_code=500)
1331
-
1332
-
1333
- @app.get('/api/wall/img/{fname}')
1334
- def api_wall_img_file(fname: str):
1335
- if '..' in fname or '/' in fname:
1336
- return Response(status_code=403)
1337
- img_path = os.path.join(WALL_IMG_DIR, fname)
1338
- if not os.path.exists(img_path):
1339
- return Response(status_code=404)
1340
- ext = os.path.splitext(fname)[1].lower()
1341
- media_type = 'image/png' if ext == '.png' else ('image/jpeg' if ext in ('.jpg', '.jpeg') else 'image/webp')
1342
- return FileResponse(img_path, media_type=media_type)
1343
-
1344
  @app.delete('/api/wall/{post_id}')
1345
  def api_wall_delete(post_id: str):
1346
  posts = _load_wall_posts()
@@ -1358,41 +771,6 @@ def api_wall_delete(post_id: str):
1358
  return JSONResponse({"ok": True})
1359
  return JSONResponse({"error": "Post not found"}, status_code=404)
1360
 
1361
- @app.post('/api/wall/cleanup')
1362
- def api_wall_cleanup():
1363
- """Permanently remove auto-generated wall posts that are completely
1364
- unrecoverable (no valid image, no video, no slides, no embed — i.e. a
1365
- post that can never be displayed), and sort the remaining posts
1366
- newest-first. Returns the number of removed posts and the new count.
1367
-
1368
- NOTE: Conservative — auto posts that HAVE a valid image or a video/embed
1369
- are preserved (they remain visible at view time via
1370
- _wall_posts_for_view). Only use this to purge genuine garbage."""
1371
- posts = _load_wall_posts()
1372
- if not isinstance(posts, list):
1373
- return JSONResponse({"error": "No posts"}, status_code=404)
1374
- before = len(posts)
1375
- kept = []
1376
- removed = []
1377
- for p in posts:
1378
- if not isinstance(p, dict):
1379
- removed.append(str(p))
1380
- continue
1381
- if _is_auto_post(p) and not _post_has_valid_image(p) and not _post_has_slide_info(p) and not (p.get("video") or "").strip():
1382
- removed.append(p.get('id', '?'))
1383
- else:
1384
- kept.append(p)
1385
- # sort newest-first
1386
- kept.sort(key=_created_ts, reverse=True)
1387
- _save_wall_posts(kept)
1388
- return JSONResponse({
1389
- "ok": True,
1390
- "before": before,
1391
- "removed_count": before - len(kept),
1392
- "removed_ids": removed,
1393
- "after": len(kept),
1394
- })
1395
-
1396
  # ===== LANGUAGE & EMOTION DETECTION =====
1397
  import random as _random2
1398
  from urllib.parse import quote as _quote2
@@ -1696,7 +1074,7 @@ def _scrape_article_for_rewrite(url):
1696
 
1697
 
1698
  def _extract_key_points_rw(paragraphs, max_points=5):
1699
- r"""Extract key points from paragraphs - extracts ALL sentences, not just first one.
1700
 
1701
  Fixes: Original regex `^(.+?[.!?])\s` only captured first sentence per paragraph.
1702
  Now splits on all sentence boundaries and takes valid sentences until max_points.
@@ -1896,513 +1274,6 @@ async def api_url_wall(request: Request):
1896
  return await api_rewrite_share(request)
1897
 
1898
 
1899
- # ===== PERSONAL OPINION POST v2: AI tổng hợp bài viết từ quan điểm + nguồn tin HOT =====
1900
-
1901
- # ===== KEYWORD EXTRACTION FROM OPINION =====
1902
- _STOP_WORDS_EX = set("""
1903
- và của các những một được trong với cho tại sau trước khi không người
1904
- việt nam hôm nay mới nhất nóng tin tức cập nhật theo từ đến là có thì
1905
- này đã để về lại nên cũng rất như vì do nếu sẽ nếu thế nhưng mà vẫn
1906
- đang vào ra hơn đây đó nào cả cùng đã từng hãy còn chỉ cũng đều khiến
1907
- được đã bị bởi qua những lúc cái gì cô chú bác anh chị em bạn tôi mình
1908
- ông bà thầy vì vậy chính phải ấy đấy đâu đó thôi nhé đấy ạ nhỉ
1909
- ngày tháng năm giờ phút giây tuần tháng quý
1910
- """.strip().split())
1911
-
1912
- def _extract_keywords_from_opinion(text, max_keywords=5):
1913
- """Extract meaningful keywords from user's opinion for news search."""
1914
- if not text:
1915
- return []
1916
- text = text.lower()
1917
- text = re.sub(r'https?://\S+', '', text)
1918
- text = re.sub(r'[^\w\sÀ-ỹ]', ' ', text)
1919
- text = re.sub(r'\s+', ' ', text).strip()
1920
- words = [w for w in text.split() if len(w) > 2 and w not in _STOP_WORDS_EX]
1921
- word_scores = {}
1922
- for w in words:
1923
- word_scores[w] = word_scores.get(w, 0) + 1
1924
- sorted_words = sorted(word_scores.items(), key=lambda x: -x[1])
1925
- top_words = [w for w, s in sorted_words[:max_keywords]]
1926
- phrases = []
1927
- for i in range(len(words) - 1):
1928
- phrase = words[i] + ' ' + words[i + 1]
1929
- if len(phrase) > 5:
1930
- phrases.append(phrase)
1931
- phrase_scores = {}
1932
- for p in phrases:
1933
- phrase_scores[p] = phrase_scores.get(p, 0) + 1
1934
- sorted_phrases = sorted(phrase_scores.items(), key=lambda x: -x[1])
1935
- top_phrases = [p for p, s in sorted_phrases[:3]]
1936
- result = []
1937
- for p in top_phrases:
1938
- if p not in result:
1939
- result.append(p)
1940
- for w in top_words:
1941
- if w not in result:
1942
- result.append(w)
1943
- return result[:max_keywords]
1944
-
1945
-
1946
- @app.post("/api/personal_post/preview")
1947
- async def api_personal_post_preview(request: Request):
1948
- """Preview personal post: fetch full articles, let AI compose logical article with images."""
1949
- body = await request.json()
1950
- opinion = _clean(body.get("opinion", ""))
1951
- selected_topics = body.get("selected_topics", []) or []
1952
- selected_sources = body.get("selected_sources", []) or []
1953
-
1954
- if not opinion or len(opinion) < 10:
1955
- return JSONResponse({"error": "Quan điểm cá nhân quá ngắn (cần ít nhất 10 ký tự)"}, status_code=400)
1956
-
1957
- # Lấy keywords từ QUAN ĐIỂM CÁ NHÂN để tìm nguồn tin chính xác
1958
- keywords = _extract_keywords_from_opinion(opinion, max_keywords=5)
1959
- if keywords:
1960
- selected_topics = keywords[:3]
1961
- else:
1962
- # Fallback: hot topics
1963
- hot = _get_hot_topics()
1964
- selected_topics = [t.get("topic", "") for t in hot[:3] if t.get("topic")]
1965
-
1966
- # Tìm nguồn tin
1967
- all_sources = []
1968
- seen_urls = set()
1969
- for topic in selected_topics[:3]:
1970
- sources = _search_all(topic, limit=5)
1971
- for s in sources:
1972
- if s.get("url") and s["url"] not in seen_urls:
1973
- seen_urls.add(s["url"])
1974
- all_sources.append(s)
1975
- if len(all_sources) >= 6:
1976
- break
1977
- if len(all_sources) >= 6:
1978
- break
1979
-
1980
- for src in selected_sources:
1981
- if src.get("url") and src["url"] not in seen_urls:
1982
- all_sources.insert(0, src)
1983
-
1984
- # Scrape nội dung đầy đủ từng nguồn (paragraphs + images)
1985
- source_details = []
1986
- source_images = []
1987
- for src in all_sources[:5]:
1988
- url = src.get("url", "")
1989
- if not url:
1990
- continue
1991
- try:
1992
- art = _scrape_article_for_rewrite(url)
1993
- if art:
1994
- src_detail = {
1995
- "title": art.get("title", src.get("title", "")),
1996
- "url": url,
1997
- "via": src.get("via", ""),
1998
- "paragraphs": art.get("paragraphs", [])[:8],
1999
- "images": art.get("images", [])[:3],
2000
- "og_image": art.get("og_img", "")
2001
- }
2002
- source_details.append(src_detail)
2003
- # Collect images for proxy
2004
- for img in art.get("images", [])[:2]:
2005
- if any(x in img for x in ["cdnphoto.dantri", "vnexpress", "vcdn", "refooty"]):
2006
- img = "/api/proxy/img?url=" + _quote2(img, safe="")
2007
- source_images.append(img)
2008
- except:
2009
- pass
2010
- if len(source_details) >= 5:
2011
- break
2012
-
2013
- # Tạo title từ opinion
2014
- opinion_words = re.findall(r"[A-Za-zÀ-ỹ0-9]+", opinion)
2015
- title_words = opinion_words[:8] if len(opinion_words) >= 8 else opinion_words[:4]
2016
- title = " ".join([w[0].upper() + w[1:] for w in title_words]) if title_words else "Quan điểm cá nhân"
2017
- title = title[:80]
2018
-
2019
- # AI sinh bài viết hoàn chỉnh
2020
- ai_text = None
2021
- try:
2022
- import ai_ext
2023
- if hasattr(ai_ext, 'qwen_generate'):
2024
- # Build detailed context from source articles
2025
- source_context = ""
2026
- for i, sd in enumerate(source_details[:5]):
2027
- src_title = sd.get("title", "")
2028
- src_via = sd.get("via", "")
2029
- src_paras = sd.get("paragraphs", [])
2030
- source_context += f"\n=== Nguồn {i+1}: {src_title} ({src_via}) ===\n"
2031
- for j, p in enumerate(src_paras[:4]):
2032
- source_context += f" - {p[:300]}\n"
2033
-
2034
- prompt = (
2035
- "QUAN ĐIỂM: " + opinion[:500] + "\nNGUỒN: " + source_context[:1000] + "\n\n"
2036
- "=== NGUỒN TIN THAM KHẢO ===\n" + source_context + "\n\n"
2037
- "=== YÊU CẦU VIẾT BÀI THEO SLIDE ===\n"
2038
- "Viết bài thành 5-6 ĐOẠN VĂN NGẮN, mỗi đoạn là 1 SLIDE.\n"
2039
- "\n"
2040
- "QUAN TRỌNG NHẤT: MỗI SLIDE PHẢI KẾT HỢP QUAN ĐIỂM CÁ NHÂN + NỘI DUNG NGUỒN TIN, KHÔNG PHẢI CHỈ NÓI VỀ NGUỒN TIN.\n"
2041
- "\n"
2042
- "SLIDE 1 - MỞ ĐẦU:\n"
2043
- "- NHIỆN HỮU QUAN ĐIỂM CÁ NHÂN LÊN ĐẦU\n"
2044
- "- Giới thiệu chủ đề, nêu rõ quan điểm của bạn (dựa vào QUAN ĐIỂM CÁ NHÂN ở trên)\n"
2045
- "- 2-4 câu hoàn chỉnh\n"
2046
- "\n"
2047
- "SLIDE 2-3-4-5 - PHÂN TÍCH:\n"
2048
- "- Mỗi slide: B�Commencer bằng QUAN ĐIỂM CÁ NHÂN, sau đó dẫn chứng từ 1 nguồn tin\n"
2049
- "- Ví dụ: \"Theo quan điểm của tôi, đây là vấn đề cần lưu ý. Theo VnExpress...\"\n"
2050
- "- Dẫn chứng từ nguồn (ghi rõ tên báo: Theo VnExpress, Theo Thanh Niên...)\n"
2051
- "- 2-4 câu hoàn chỉnh mỗi slide\n"
2052
- "\n"
2053
- "SLIDE 6 - KẾT LUẬN:\n"
2054
- "- Tổng kết quan điểm cá nhân, đưa ra nhận định cuối cùng\n"
2055
- "- 2-3 câu hoàn chỉnh\n"
2056
- "\n"
2057
- "Định dạng đầu ra:\n"
2058
- "---SLIDE 1---\n"
2059
- "[nội dung đoạn văn slide 1]\n"
2060
- "---SLIDE 2---\n"
2061
- "[nội dung đoạn văn slide 2]\n"
2062
- "...v.v...\n"
2063
- "\n"
2064
- "QUAN TRỌNG:\n"
2065
- "- Mỗi slide là 1 đoạn văn HOÀN CHỈNH, 2-4 câu\n"
2066
- "- PHẢI KẾT THÚC BẰNG DẤU CHẤM (.) HOẢN TOÀN\n"
2067
- "- Kết hợp QUAN ĐIỂM CÁ NHÂN với NỘI DUNG NGUỒN TIN\n"
2068
- "- Không gạch đầu dòng, không bullet points\n"
2069
- "- Viết liền mạch tự nhiên, giọng văn báo chí\n"
2070
- "- Mỗi slide phải khác nhau, không lặp ý\n"
2071
- "- Độ dài: 300-600 từ"
2072
- )
2073
- ai_text = None # Không dùng AI, để code tự kết hợp opinion + source
2074
- except:
2075
- pass
2076
-
2077
- if not ai_text or len(ai_text) < 100:
2078
- # Fallback: build article manually
2079
- ai_text = "## " + title + "\n\n"
2080
- ai_text += opinion + "\n\n"
2081
- for i, sd in enumerate(source_details[:5]):
2082
- ai_text += "### " + sd.get("title", f"Nguồn {i+1}") + "\n"
2083
- for p in sd.get("paragraphs", [])[:3]:
2084
- ai_text += p[:250] + "\n"
2085
- ai_text += "*Nguồn: " + sd.get("via", "") + "*\n\n"
2086
- ai_text += "\n---\n*Bài viết tổng hợp từ quan điểm cá nhân và các nguồn tin liên quan*"
2087
-
2088
- # Parse slides từ AI output (format: ---SLIDE N--- content)
2089
- slides = []
2090
- if ai_text:
2091
- # Try to parse the ---SLIDE--- format
2092
- pattern = r'---SLIDE\s*(\d+)---\s*\n(.*?)(?=---SLIDE|\Z)'
2093
- matches = re.findall(pattern, ai_text, re.DOTALL)
2094
-
2095
- if matches:
2096
- for idx, (num, content) in enumerate(matches):
2097
- # Normalize: ensure complete sentences
2098
- text = _ensure_sentence_complete(content)
2099
- if len(text) > 40:
2100
- img = source_images[idx] if idx < len(source_images) else ""
2101
- slides.append({"text": text, "image": img, "index": idx + 1})
2102
-
2103
- # If we have parsed slides, ensure minimum 3
2104
- if len(slides) < 3:
2105
- # Use parsed slides as base, fill remaining from AI text
2106
- used_indices = set()
2107
- for s in slides:
2108
- used_indices.add(s['index'] - 1)
2109
-
2110
- # Split remaining AI text into more slides
2111
- sentences = re.split(r'(?<=[.!?])\s+', ai_text)
2112
- current_chunk = ""
2113
- next_idx = len(slides)
2114
-
2115
- for sent in sentences:
2116
- sent = _ensure_sentence_complete(sent)
2117
- if len(sent) < 20:
2118
- continue
2119
-
2120
- # Skip if this sentence is already in parsed slides
2121
- found = False
2122
- for slide in slides:
2123
- if sent[:50] in slide['text']:
2124
- found = True
2125
- break
2126
-
2127
- if found:
2128
- continue
2129
-
2130
- if current_chunk and len(current_chunk + " " + sent) <= 380:
2131
- current_chunk += " " + sent
2132
- else:
2133
- if len(current_chunk) > 50:
2134
- img = source_images[next_idx] if next_idx < len(source_images) else ""
2135
- slides.append({"text": current_chunk, "image": img, "index": next_idx + 1})
2136
- current_chunk = sent
2137
- next_idx += 1
2138
-
2139
- # Add final chunk
2140
- if len(current_chunk) > 50 and next_idx < 6:
2141
- img = source_images[next_idx] if next_idx < len(source_images) else ""
2142
- slides.append({"text": current_chunk, "image": img, "index": next_idx + 1})
2143
-
2144
- # Ultimate fallback: create slides from opinion + source
2145
- if len(slides) < 2:
2146
- slides = []
2147
- # Slide 1: opinion
2148
- if opinion and len(opinion) > 20:
2149
- slides.append({"text": opinion[:450], "image": source_images[0] if source_images else "", "index": 1})
2150
-
2151
- # Slide 2-6: from AI text or sources
2152
- if ai_text:
2153
- sentences = re.split(r'(?<=[.!?])\s+', ai_text)
2154
- for i, sent in enumerate(sentences[:5]):
2155
- text = _ensure_sentence_complete(_clean(sent))
2156
- if len(text) > 60:
2157
- if len(slides) < 6:
2158
- img = source_images[len(slides)] if len(slides) < len(source_images) else ""
2159
- slides.append({"text": text, "image": img, "index": len(slides) + 1})
2160
-
2161
- # Fill remaining with key points from sources - KẾT HỢP VỚI QUAN ĐIỂM CÁ NHÂN
2162
- src_idx = len(slides)
2163
- while len(slides) < 4 and src_idx < len(source_details):
2164
- paragraphs = source_details[src_idx].get("paragraphs", [])
2165
- src_title = source_details[src_idx].get("title", "")
2166
- src_via = source_details[src_idx].get("via", "")
2167
- for p in paragraphs[:2]:
2168
- if len(p) > 60 and len(slides) < 6:
2169
- # Kết hợp opinion với nội dung source
2170
- combined = f"Theo góc nhìn của tôi, {opinion[:100]}... Theo {src_via}: {p[:250]}"
2171
- img = source_images[len(slides)] if len(slides) < len(source_images) else ""
2172
- slides.append({"text": _ensure_sentence_complete(combined), "image": img, "index": len(slides) + 1})
2173
- break # Mỗi nguồn 1 slide
2174
- src_idx += 1
2175
-
2176
- # Final fallback: ensure at least 2-3 slides
2177
- while len(slides) < 3:
2178
- idx = len(slides)
2179
- if idx == 0 and opinion:
2180
- slides.append({"text": opinion[:400], "image": "", "index": 1})
2181
- elif ai_text:
2182
- slides.append({"text": ai_text[idx*300:(idx+1)*300], "image": "", "index": idx + 1})
2183
- else:
2184
- slides.append({"text": f"Nguồn tham khảo {idx + 1}", "image": "", "index": idx + 1})
2185
-
2186
- preview = {
2187
- "title": title,
2188
- "text": ai_text,
2189
- "opinion": opinion,
2190
- "images": source_images[:10],
2191
- "sources": source_details[:5],
2192
- "slides": slides[:6] # Max 6 slides
2193
- }
2194
-
2195
- return JSONResponse({"preview": preview})
2196
-
2197
-
2198
- @app.post("/api/personal_post")
2199
- async def api_personal_post(request: Request):
2200
- """Create and save personal opinion post."""
2201
- body = await request.json()
2202
- opinion = _clean(body.get("opinion", ""))
2203
- selected_topics = body.get("selected_topics", []) or []
2204
- selected_sources = body.get("selected_sources", []) or []
2205
- custom_title = body.get("custom_title", "")
2206
- custom_slides = body.get("custom_slides", [])
2207
-
2208
- if not opinion or len(opinion) < 10:
2209
- return JSONResponse({"error": "Quan điểm cá nhân quá ngắn (cần ít nhất 10 ký tự)"}, status_code=400)
2210
-
2211
- if not selected_topics:
2212
- # Lấy keywords từ QUAN ĐIỂM CÁ NHÂN để tìm nguồn tin chính xác
2213
- keywords = _extract_keywords_from_opinion(opinion, max_keywords=5)
2214
- if keywords:
2215
- selected_topics = keywords[:3]
2216
- else:
2217
- hot = _get_hot_topics()
2218
- selected_topics = [t.get("topic", "") for t in hot[:3] if t.get("topic")]
2219
-
2220
- all_sources = []
2221
- seen_urls = set()
2222
- for topic in selected_topics[:3]:
2223
- sources = _search_all(topic, limit=5)
2224
- for s in sources:
2225
- if s.get("url") and s["url"] not in seen_urls:
2226
- seen_urls.add(s["url"])
2227
- all_sources.append(s)
2228
- if len(all_sources) >= 6:
2229
- break
2230
- if len(all_sources) >= 6:
2231
- break
2232
-
2233
- for src in selected_sources:
2234
- if src.get("url") and src["url"] not in seen_urls:
2235
- all_sources.insert(0, src)
2236
-
2237
- source_details = []
2238
- source_images = []
2239
- for src in all_sources[:5]:
2240
- url = src.get("url", "")
2241
- if not url:
2242
- continue
2243
- try:
2244
- art = _scrape_article_for_rewrite(url)
2245
- if art:
2246
- src_detail = {
2247
- "title": art.get("title", src.get("title", "")),
2248
- "url": url,
2249
- "via": src.get("via", ""),
2250
- "paragraphs": art.get("paragraphs", [])[:6],
2251
- "images": art.get("images", [])[:2],
2252
- "og_image": art.get("og_img", "")
2253
- }
2254
- source_details.append(src_detail)
2255
- for img in art.get("images", [])[:2]:
2256
- if any(x in img for x in ["cdnphoto.dantri", "vnexpress", "vcdn", "refooty"]):
2257
- img = "/api/proxy/img?url=" + _quote2(img, safe="")
2258
- source_images.append(img)
2259
- except:
2260
- pass
2261
-
2262
- # Title
2263
- if custom_title:
2264
- title = custom_title[:80]
2265
- else:
2266
- opinion_words = re.findall(r"[A-Za-zÀ-ỹ0-9]+", opinion)
2267
- title_words = opinion_words[:8] if len(opinion_words) >= 8 else opinion_words[:4]
2268
- title = " ".join([w[0].upper() + w[1:] for w in title_words]) if title_words else "Quan điểm cá nhân"
2269
- title = title[:80]
2270
-
2271
- # AI sinh bài
2272
- ai_text = None
2273
- try:
2274
- import ai_ext
2275
- if hasattr(ai_ext, 'qwen_generate'):
2276
- source_context = ""
2277
- for i, sd in enumerate(source_details[:5]):
2278
- src_title = sd.get("title", "")
2279
- src_via = sd.get("via", "")
2280
- src_paras = sd.get("paragraphs", [])
2281
- source_context += f"\nNguồn {i+1}: {src_title} ({src_via})\n"
2282
- for j, p in enumerate(src_paras[:3]):
2283
- source_context += f" - {p[:300]}\n"
2284
- prompt = (
2285
- "QUAN ĐIỂM: " + opinion[:500] + "\nNGUỒN: " + source_context[:1000] + "\n\n"
2286
- "=== NGUỒN TIN ===\n" + source_context + "\n\n"
2287
- "=== YÊU CẦU VIẾT BÀI THEO SLIDE ===\n"
2288
- "Viết bài thành 5-6 ĐOẠN VĂN NGẮN, mỗi đoạn là 1 SLIDE.\n"
2289
- "\n"
2290
- "SLIDE 1 - MỞ ĐẦU: Giới thiệu chủ đề, nêu quan điểm cá nhân (2-4 câu hoàn chỉnh)\n"
2291
- "SLIDE 2-3-4-5 - PHÂN TÍCH: Mỗi slide dùng 1 nguồn tin cụ thể, kết hợp quan điểm cá nhân, ghi rõ nguồn (Theo VnExpress...), 2-4 câu hoàn chỉnh, thành 1 đoạn văn hoàn chỉnh\n"
2292
- "SLIDE 6 - KẾT LUẬN: Tổng kết quan điểm, nhận định cuối cùng (2-3 câu hoàn chỉnh)\n"
2293
- "\n"
2294
- "Định dạng:\n"
2295
- "---SLIDE 1---\n[đoạn văn hoàn chỉnh kết thúc bằng dấu chấm]\n---SLIDE 2---\n[đoạn văn hoàn chỉnh kết thúc bằng dấu chấm]\n...\n"
2296
- "\n"
2297
- "QUAN TRỌNG: Mỗi slide là 1 đoạn văn HOÀN CHỈNH, 2-4 câu, PHẢI KẾT THÚC BẰNG DẤU CHẤM (.). Kết hợp QUAN ĐIỂM + NGUỒN TIN. Không gạch đầu dòng. Viết liền mạch. 300-600 từ."
2298
- )
2299
- ai_text = None # Không dùng AI, để code tự kết hợp opinion + source
2300
- except:
2301
- pass
2302
-
2303
- if not ai_text or len(ai_text) < 100:
2304
- ai_text = "## " + title + "\n\n" + opinion + "\n\n"
2305
- for i, sd in enumerate(source_details[:5]):
2306
- ai_text += "### " + sd.get("title", "") + "\n"
2307
- for p in sd.get("paragraphs", [])[:2]:
2308
- ai_text += p[:250] + "\n"
2309
- ai_text += "\n---\n*Nguồn: " + sd.get("via", "") + "*\n\n"
2310
-
2311
- # Tạo slides
2312
- if custom_slides and len(custom_slides) > 0:
2313
- slides = []
2314
- for i, slide in enumerate(custom_slides):
2315
- slides.append({
2316
- "text": slide.get("text", ""),
2317
- "image": slide.get("image", ""),
2318
- "index": i + 1
2319
- })
2320
- else:
2321
- slides = []
2322
- # Parse từ AI output (format: ---SLIDE N---)
2323
- if ai_text:
2324
- pattern = r'---SLIDE\s*(\d+)---\s*\n(.*?)(?=---SLIDE|\Z)'
2325
- matches = re.findall(pattern, ai_text, re.DOTALL)
2326
- if matches:
2327
- for idx, (num, content) in enumerate(matches):
2328
- # Normalize: ensure complete sentences
2329
- text = _ensure_sentence_complete(content)
2330
- if len(text) > 30:
2331
- img = source_images[idx] if idx < len(source_images) else ""
2332
- slides.append({"text": text, "image": img, "index": idx + 1})
2333
-
2334
- # Fallback: split by paragraphs
2335
- if len(slides) < 3:
2336
- paragraphs = [p.strip() for p in re.split(r'\n\n+', ai_text) if p.strip()]
2337
- slides = []
2338
- para_count = 0
2339
- for p in paragraphs:
2340
- p = p.strip()
2341
- if p.startswith('#') or p.startswith('---') or p.startswith('*Nguồn'):
2342
- continue
2343
- # Normalize: ensure complete sentences
2344
- p_normalized = _ensure_sentence_complete(p)
2345
- if len(p_normalized) > 50:
2346
- img = source_images[para_count] if para_count < len(source_images) else ""
2347
- slides.append({"text": p_normalized, "image": img, "index": para_count + 1})
2348
- para_count += 1
2349
- if para_count >= 6:
2350
- break
2351
-
2352
- if len(slides) < 2:
2353
- slides = []
2354
- # Slide 1: QUAN ĐIỂM CÁ NHÂN (BẮT BUỘC)
2355
- slides.append({"text": f"Theo quan điểm cá nhân: {opinion[:400]}", "image": source_images[0] if source_images else "", "index": 1})
2356
-
2357
- # Slide 2-6: KẾT HỢP QUAN ĐIỂM + SOURCE
2358
- for i in range(min(5, len(source_details))):
2359
- if len(slides) >= 6:
2360
- break
2361
- src = source_details[i]
2362
- src_via = src.get("via", "")
2363
- src_paras = src.get("paragraphs", [])
2364
-
2365
- src_text = ""
2366
- for p in src_paras[:2]:
2367
- p = p.strip()[:280]
2368
- if len(p) > 50:
2369
- src_text = p
2370
- break
2371
-
2372
- if src_text:
2373
- combined = f"Theo góc nhìn cá nhân, {opinion[:60]}. Theo {src_via}: {src_text}"
2374
- img = source_images[len(slides)] if len(slides) < len(source_images) else (source_images[-1] if source_images else "")
2375
- slides.append({"text": _ensure_sentence_complete(combined), "image": img, "index": len(slides) + 1})
2376
-
2377
- lang, emotion = detect_language_and_emotion(title, ai_text)
2378
- voice = get_voice_for_content(title, ai_text)
2379
-
2380
- post = {
2381
- "id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
2382
- "title": title,
2383
- "text": ai_text,
2384
- "img": source_images[0] if source_images else "",
2385
- "url": "",
2386
- "kind": "personal_opinion",
2387
- "slides": slides,
2388
- "images": source_images[:10],
2389
- "video": "",
2390
- "voice": voice,
2391
- "emotion": emotion,
2392
- "language": lang,
2393
- "ts": int(time.time()),
2394
- "sources": source_details[:5]
2395
- }
2396
-
2397
- posts = _load_wall_posts()
2398
- posts.insert(0, post)
2399
- _save_wall_posts(posts)
2400
-
2401
- return JSONResponse({"post": post, "slides": slides})
2402
-
2403
-
2404
- # ===== END PERSONAL OPINION POST v2 =====
2405
-
2406
  def _bg():
2407
  time.sleep(15)
2408
  while True:
@@ -2411,555 +1282,4 @@ def _bg():
2411
  time.sleep(90)
2412
  threading.Thread(target=_bg,daemon=True).start()
2413
 
2414
- # ===== AUTO SCHEDULER: rewrite AI + short at 7/13/19 VN time =====
2415
- _AUTO_SCHEDULE_TIMES = [(7, '07:00'), (13, '13:00'), (19, '19:00')]
2416
- _AUTO_LOG = os.path.join(DATA_DIR, 'auto_rewrite_log.json')
2417
-
2418
- def _load_auto_log():
2419
- try:
2420
- if os.path.exists(_AUTO_LOG):
2421
- with open(_AUTO_LOG, 'r') as f:
2422
- return json.load(f)
2423
- except: pass
2424
- return {}
2425
-
2426
- def _save_auto_log(log):
2427
- try:
2428
- tmp = _AUTO_LOG + '.tmp'
2429
- with open(tmp, 'w') as f:
2430
- json.dump(log, f)
2431
- os.replace(tmp, _AUTO_LOG)
2432
- except: pass
2433
-
2434
- async def _auto_fetch_short(post_id):
2435
- """Try to auto-generate a short for a post."""
2436
- try:
2437
- import httpx
2438
- async with httpx.AsyncClient(timeout=180) as cl:
2439
- r = await cl.post(
2440
- f"http://localhost:7860/api/ai/short/{post_id}",
2441
- json={"voice":"vi-VN-HoaiMyNeural","emotion":"neutral","speed":1.2},
2442
- headers={"Content-Type":"application/json"}
2443
- )
2444
- if r.status_code < 300:
2445
- sj = r.json()
2446
- if sj.get('video'):
2447
- posts = _load_wall_posts()
2448
- for p in posts:
2449
- if p.get('id') == post_id:
2450
- p['video'] = sj['video']
2451
- break
2452
- _save_wall_posts(posts)
2453
- return True
2454
- except: pass
2455
- return False
2456
-
2457
- async def _auto_rewrite_one(topic, slot_label, used_urls=None, post_index=0):
2458
- """Rewrite one topic: find articles, summarize, post to wall, trigger short.
2459
- used_urls: shared set to avoid duplicate articles across topics.
2460
- post_index: 0-based index to create multiple posts per topic (0,1,2 = up to 3 posts)."""
2461
- from urllib.parse import quote as _q
2462
- # Get MORE items to support 1-3 posts per topic
2463
- items = _search_all(topic, limit=12)
2464
- # Skip URLs already used by another topic
2465
- if used_urls is not None:
2466
- filtered = [it for it in items if it.get('url') not in used_urls]
2467
- if filtered:
2468
- items = filtered
2469
- if not items or post_index >= len(items):
2470
- return False
2471
-
2472
- # Get article at post_index (0,1,2 for multiple posts)
2473
- item = items[post_index] # post_index allows multiple articles per topic
2474
- url = item.get('url', '')
2475
- title = item.get('title', topic)
2476
- if url and used_urls is not None:
2477
- used_urls.add(url)
2478
- if not url.startswith('http'):
2479
- return False
2480
-
2481
- data = _scrape_article_for_rewrite(url)
2482
- if not data or not data.get('paragraphs'):
2483
- return False
2484
-
2485
- raw_text = '\n'.join(data['paragraphs'])
2486
- ai_text = None
2487
-
2488
- # Try AI generation
2489
- try:
2490
- import ai_ext
2491
- prompt = f"Tóm tắt tin tức (tự động {slot_label}):\nTiêu đề: {data['title']}\n{raw_text[:10000]}\n\n4-6 ý chính dạng bullet. Cuối ghi nguồn."
2492
- ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000)
2493
- except: pass
2494
-
2495
- if not ai_text or len(ai_text) < 80:
2496
- pts = data['paragraphs'][:6]
2497
- ai_text = '\n\n'.join([f"• {p[:300]}" for p in pts])
2498
- via = item.get('via', '') or urlparse(url).netloc.replace('www.', '')
2499
- ai_text += f"\n\nNguồn tham khảo: {via}"
2500
-
2501
- # Build slides
2502
- images = data.get('images', [])
2503
- pts = data['paragraphs'][:10]
2504
- slides = []
2505
- for i, p in enumerate(pts[:8]):
2506
- img = images[i] if i < len(images) else (images[-1] if images else data.get('og_img', ''))
2507
- slides.append({'text': p[:300], 'image': img, 'index': i + 1})
2508
-
2509
- post_id = str(int(time.time() * 1000)) + str(_random2.randint(100, 999))
2510
- post = {
2511
- "id": post_id, "title": data.get('title', title)[:200],
2512
- "text": ai_text, "img": images[0] if images else data.get('og_img', ''),
2513
- "url": url, "kind": "auto_rewrite", "slides": slides,
2514
- "images": images[:10], "video": "",
2515
- "voice": "vi-VN-HoaiMyNeural", "emotion": "neutral",
2516
- "language": "vietnamese", "ts": int(time.time()),
2517
- "auto_scheduled": True, "slot": slot_label,
2518
- }
2519
-
2520
- posts = _load_wall_posts()
2521
- posts.insert(0, post)
2522
- _save_wall_posts(posts)
2523
-
2524
- # Trigger short generation async
2525
- threading.Thread(target=lambda: asyncio.run(_auto_fetch_short(post_id)), daemon=True).start()
2526
- return True
2527
-
2528
- async def _do_scheduled_run(slot_label):
2529
- """Main scheduled run: 1-3 posts from 3 different HOT topics (3-9 total), no duplicates."""
2530
- print(f"[auto] Starting scheduled rewrite for {slot_label}")
2531
-
2532
- # Get top hot topics, skip duplicates
2533
- all_topics = _get_hot_topics()
2534
- seen_topics = set()
2535
- unique_topics = []
2536
- for t in all_topics:
2537
- kw = t.get('topic', '').lower().strip()
2538
- if kw and len(kw) > 5 and kw not in seen_topics:
2539
- is_dup = False
2540
- for s in seen_topics:
2541
- # Check if one topic is substring of another
2542
- if kw in s or s in kw:
2543
- is_dup = True
2544
- break
2545
- if not is_dup:
2546
- seen_topics.add(kw)
2547
- unique_topics.append(t)
2548
- if len(unique_topics) >= 3:
2549
- break
2550
-
2551
- job_topics = [t['topic'] for t in unique_topics[:3] if t.get('topic')]
2552
- if not job_topics:
2553
- print(f"[auto] No hot topics found, skipping")
2554
- return
2555
-
2556
- print(f"[auto] Running 3 topics: {job_topics}")
2557
-
2558
- # Track used URLs to avoid cross-topic duplicates
2559
- _used_urls = set()
2560
- results = []
2561
-
2562
- # Process each topic, create 1-3 posts per topic
2563
- for jt in job_topics:
2564
- for post_idx in range(3): # Try up to 3 posts per topic
2565
- try:
2566
- ok = await asyncio.wait_for(_auto_rewrite_one(jt, slot_label, _used_urls, post_idx), timeout=120)
2567
- if ok:
2568
- results.append((jt, post_idx, True))
2569
- print(f"[auto] Created post {post_idx+1} for '{jt}'")
2570
- else:
2571
- # No more articles for this topic
2572
- break
2573
- except Exception as e:
2574
- print(f"[auto] Error on '{jt}' post {post_idx}: {e}")
2575
- results.append((jt, post_idx, False))
2576
- await asyncio.sleep(1) # Small delay between posts
2577
-
2578
- # Ensure at least 3 posts total (fallback if needed)
2579
- successful_posts = sum(1 for _, _, ok in results if ok)
2580
- print(f"[auto] Done {slot_label}: {successful_posts} posts created")
2581
-
2582
- # Log
2583
- from datetime import datetime, timezone, timedelta
2584
- VN_TZ_SCHED = timezone(timedelta(hours=7))
2585
- today_str = datetime.now(VN_TZ_SCHED).strftime('%Y-%m-%d')
2586
- log = _load_auto_log()
2587
- if today_str not in log: log[today_str] = {}
2588
- log[today_str][slot_label] = {
2589
- 'time': datetime.now(VN_TZ_SCHED).strftime('%H:%M:%S'),
2590
- 'count': successful_posts,
2591
- 'total': len(job_topics),
2592
- }
2593
- _save_auto_log(log)
2594
-
2595
- def _scheduler_loop():
2596
- """Check every 60s; trigger at 7:00, 13:00, 19:00 VN time.
2597
- On startup, check for any missed slots today and run them immediately."""
2598
- time.sleep(35)
2599
- from datetime import datetime, timezone, timedelta
2600
- VN_TZ_SCHED = timezone(timedelta(hours=7))
2601
-
2602
- _last_run_date = ""
2603
- _last_run_slots = set()
2604
-
2605
- # On startup: check log for missed slots today
2606
- try:
2607
- start_now = datetime.now(VN_TZ_SCHED)
2608
- today_str = start_now.strftime('%Y-%m-%d')
2609
- current_hour = start_now.hour
2610
- current_minute = start_now.minute
2611
- log = _load_auto_log()
2612
- today_log = log.get(today_str, {})
2613
- for h, label in _AUTO_SCHEDULE_TIMES:
2614
- # Run if slot is past (either strictly earlier hour, or same hour but window has passed)
2615
- should_run = False
2616
- if h < current_hour:
2617
- should_run = True
2618
- elif h == current_hour and current_minute > 10:
2619
- should_run = True
2620
- if should_run and label not in today_log:
2621
- print(f"[auto] Detected missed slot {label} (h={h} < now={current_hour}:{current_minute}), running catch-up now")
2622
- _run_scheduled_sync(label)
2623
- _last_run_slots.add(label)
2624
- except Exception as e:
2625
- print(f"[auto] Catch-up check error: {e}")
2626
-
2627
- while True:
2628
- try:
2629
- now = datetime.now(VN_TZ_SCHED)
2630
- today = now.strftime('%Y-%m-%d')
2631
- hour = now.hour
2632
- minute = now.minute
2633
-
2634
- if today != _last_run_date:
2635
- _last_run_date = today
2636
- _last_run_slots = set()
2637
-
2638
- slot = None
2639
- for h, label in _AUTO_SCHEDULE_TIMES:
2640
- if hour == h and 0 <= minute < 5:
2641
- slot = label
2642
- break
2643
-
2644
- if slot and slot not in _last_run_slots:
2645
- _last_run_slots.add(slot)
2646
- _run_scheduled_sync(slot)
2647
- except Exception as e:
2648
- print(f"[auto] Loop error: {e}")
2649
-
2650
- time.sleep(60)
2651
-
2652
- threading.Thread(target=_scheduler_loop, daemon=True, name='auto-rewrite-scheduler').start()
2653
-
2654
- def _wall_cleanup_once():
2655
- """One-time startup cleanup: sort the persistent wall store newest-first
2656
- and remove truly-garbage auto posts (no valid image at ALL, no video, no
2657
- slides, no embed URL — i.e. a post that can never be displayed).
2658
-
2659
- NOTE: This is intentionally conservative. Posts with a valid image or a
2660
- valid embed/video are NEVER deleted here — they are only hidden at view
2661
- time by _wall_posts_for_view() when the image is a placeholder. This
2662
- prevents accidental data loss of FPT Short AI posts whose image is valid
2663
- but may temporarily lack a short_thumb."""
2664
- try:
2665
- time.sleep(8)
2666
- posts = _load_wall_posts()
2667
- if not isinstance(posts, list):
2668
- return
2669
- kept = []
2670
- removed = 0
2671
- removed_ids = []
2672
- needs_sort = False
2673
- for p in posts:
2674
- if not isinstance(p, dict):
2675
- removed += 1
2676
- continue
2677
- if _is_auto_post(p):
2678
- has_img = _post_has_valid_image(p)
2679
- has_content = _post_has_slide_info(p)
2680
- vid = (p.get("video") or "").strip()
2681
- # Only delete auto posts that are completely unrecoverable:
2682
- # no valid image AND no video/slides/embed at all.
2683
- if not has_img and not has_content and not vid:
2684
- removed += 1
2685
- removed_ids.append(p.get('id', '?'))
2686
- continue
2687
- kept.append(p)
2688
- # Sort if any post lacks proper ordering
2689
- prev_ts = None
2690
- is_sorted = True
2691
- for p in kept:
2692
- ts = _created_ts(p)
2693
- if prev_ts is not None and ts > prev_ts:
2694
- is_sorted = False
2695
- break
2696
- prev_ts = ts
2697
- if removed > 0 or not is_sorted:
2698
- kept.sort(key=_created_ts, reverse=True)
2699
- _save_wall_posts(kept)
2700
- print(f"[wall] startup cleanup: removed {removed} garbage posts ({removed_ids[:10]}), kept {len(kept)}, sorted newest-first")
2701
- except Exception as e:
2702
- print(f"[wall] startup cleanup error: {e}")
2703
-
2704
- threading.Thread(target=_wall_cleanup_once, daemon=True, name='wall-cleanup').start()
2705
-
2706
- @app.get('/api/debug/auto_schedule')
2707
- async def debug_auto_schedule(slot: str = '07:00'):
2708
- """Manually trigger auto scheduler for debugging."""
2709
- try:
2710
- # Check if we can access the data directory
2711
- log = _load_auto_log()
2712
- topics = _get_hot_topics()[:3]
2713
- job_topics = [t['topic'] for t in topics if t.get('topic')]
2714
- return JSONResponse({
2715
- "slot": slot,
2716
- "log": log,
2717
- "hot_topics": job_topics,
2718
- "wall_posts_count": len(_load_wall_posts()),
2719
- "data_dir_writable": os.access(DATA_DIR, os.W_OK) if os.path.isdir(DATA_DIR) else False,
2720
- "data_dir_exists": os.path.isdir(DATA_DIR),
2721
- })
2722
- except Exception as e:
2723
- return JSONResponse({"error": str(e)}, status_code=500)
2724
-
2725
- def _run_scheduled_sync(slot):
2726
- """Run _do_scheduled_run in a separate event loop (for background thread)."""
2727
- loop = asyncio.new_event_loop()
2728
- asyncio.set_event_loop(loop)
2729
- try:
2730
- loop.run_until_complete(_do_scheduled_run(slot))
2731
- except Exception as e:
2732
- print(f"[auto] Background run error: {e}")
2733
- finally:
2734
- loop.close()
2735
-
2736
- @app.get('/api/debug/trigger_auto')
2737
- async def debug_trigger_auto(slot: str = '19:00'):
2738
- """Trigger _do_scheduled_run in background thread (non-blocking)."""
2739
- threading.Thread(target=_run_scheduled_sync, args=(slot,), daemon=True).start()
2740
- return JSONResponse({"status": "started", "slot": slot})
2741
-
2742
- # ===== SHORTS RSS PROXY ENDPOINT =====
2743
- @app.get("/api/shorts/rss")
2744
- def shorts_rss():
2745
- """Get shorts from YouTube RSS feeds server-side"""
2746
- import xml.etree.ElementTree as ET
2747
- import html as html_lib2
2748
- import re as re2
2749
-
2750
- YOUTUBE_CHANNELS = {
2751
- "baodantri7941": "UC_x5TKhOgd6GhYvv5z4I3jg",
2752
- "baosuckhoedoisongboyte": "UCBsY5fXTQLkF_JnH9kLkL4g",
2753
- }
2754
-
2755
- shorts = []
2756
- seen = set()
2757
-
2758
- for handle, channel_id in YOUTUBE_CHANNELS.items():
2759
- try:
2760
- rss_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
2761
- r = req.get(rss_url, headers=HEADERS, timeout=15)
2762
- if r.status_code != 200:
2763
- continue
2764
-
2765
- root = ET.fromstring(r.text)
2766
- ns = {
2767
- 'atom': 'http://www.w3.org/2005/Atom',
2768
- 'yt': 'http://www.youtube.com/xml/schemas/2015',
2769
- 'media': 'http://search.yahoo.com/mrss/'
2770
- }
2771
-
2772
- for entry in root.findall('atom:entry', ns)[:30]:
2773
- title_el = entry.find('atom:title', ns)
2774
- title = html_lib2.unescape(title_el.text) if title_el is not None and title_el.text else ''
2775
-
2776
- link_el = entry.find('atom:link', ns)
2777
- link = link_el.get('href', '') if link_el is not None else ''
2778
-
2779
- vid_el = entry.find('yt:videoId', ns)
2780
- vid = vid_el.text if vid_el is not None else ''
2781
-
2782
- if not vid or vid in seen:
2783
- continue
2784
-
2785
- # Check if it's a short
2786
- is_short = '#shorts' in title.lower() or '#short' in title.lower() or '/shorts/' in link
2787
-
2788
- if not is_short:
2789
- desc_el = entry.find('media:description', ns)
2790
- if desc_el is not None and desc_el.text:
2791
- if '#shorts' in desc_el.text.lower():
2792
- is_short = True
2793
-
2794
- if not is_short:
2795
- continue
2796
-
2797
- seen.add(vid)
2798
-
2799
- # Get thumbnail
2800
- thumb = f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg"
2801
- media_group = entry.find('media:group', ns)
2802
- if media_group is not None:
2803
- thumb_el = media_group.find('media:thumbnail', ns)
2804
- if thumb_el is not None:
2805
- thumb = thumb_el.get('url', thumb)
2806
-
2807
- shorts.append({
2808
- 'id': vid,
2809
- 'title': title.replace('#shorts', '').replace('#short', '').strip()[:120],
2810
- 'img': thumb,
2811
- 'link': f'https://www.youtube.com/shorts/{vid}',
2812
- 'channel': handle,
2813
- 'source': 'yt'
2814
- })
2815
-
2816
- if len(shorts) >= 40:
2817
- break
2818
-
2819
- except Exception as e:
2820
- print(f"RSS error for {handle}: {e}")
2821
- continue
2822
-
2823
- return {"shorts": shorts, "count": len(shorts)}
2824
-
2825
- # ===== M3U IPTV Channels (FPT Play Bóng Đá live streams) =====
2826
- _M3U_URL = "https://raw.githubusercontent.com/Love4vn/Stalker2M3U/refs/heads/1/live_schedule_Optimize.m3u"
2827
- _m3u_cache = {"t": 0, "d": []}
2828
- _M3U_CACHE_FILE = os.path.join("/data" if os.path.isdir('/data') else "/app/data", 'm3u_cache.json')
2829
-
2830
- def _load_m3u_disk():
2831
- """Load M3U cache from disk (if available)."""
2832
- try:
2833
- if os.path.exists(_M3U_CACHE_FILE):
2834
- with open(_M3U_CACHE_FILE, 'r', encoding='utf-8') as f:
2835
- d = json.load(f)
2836
- if d and isinstance(d.get('channels'), list):
2837
- return d
2838
- except Exception:
2839
- pass
2840
- return None
2841
-
2842
- def _save_m3u_disk(channels, ts):
2843
- """Save M3U cache to disk."""
2844
- try:
2845
- os.makedirs(os.path.dirname(_M3U_CACHE_FILE), exist_ok=True)
2846
- with open(_M3U_CACHE_FILE, 'w', encoding='utf-8') as f:
2847
- json.dump({"channels": channels, "t": ts}, f, ensure_ascii=False)
2848
- except Exception:
2849
- pass
2850
-
2851
- def _parse_m3u():
2852
- """Fetch & parse the M3U playlist from GitHub."""
2853
- r = req.get(_M3U_URL, headers={"User-Agent": "Mozilla/5.0"}, timeout=20)
2854
- if r.status_code != 200:
2855
- return []
2856
- lines = r.text.splitlines()
2857
- out = []
2858
- cur = {}
2859
- for line in lines:
2860
- line = line.strip()
2861
- if line.startswith("#EXTINF"):
2862
- t_match = re.search(r',(.+)$', line)
2863
- cur = {
2864
- "title": t_match.group(1).strip() if t_match else "Channel",
2865
- "id": "",
2866
- "logo": "",
2867
- "group": "",
2868
- "url": "",
2869
- "vlcopts": {},
2870
- }
2871
- for attr_match in re.finditer(r'(\w+)="([^"]*)"', line):
2872
- k, v = attr_match.group(1), attr_match.group(2)
2873
- if k == "tvg-id":
2874
- cur["id"] = v
2875
- elif k == "tvg-logo":
2876
- cur["logo"] = v
2877
- elif k == "group-title":
2878
- cur["group"] = v
2879
- elif line.startswith("#EXTVLCOPT:"):
2880
- key_part = line[len("#EXTVLCOPT:"):]
2881
- if "=" in key_part:
2882
- k, v = key_part.split("=", 1)
2883
- cur.setdefault("vlcopts", {})[k.strip()] = v.strip()
2884
- elif line and not line.startswith("#") and cur:
2885
- cur["url"] = line
2886
- out.append(cur)
2887
- cur = {}
2888
- # Deduplicate by URL
2889
- seen = set()
2890
- deduped = []
2891
- for c in out:
2892
- if c["url"] not in seen:
2893
- seen.add(c["url"])
2894
- deduped.append(c)
2895
- return deduped
2896
-
2897
- def _m3u_refresh():
2898
- """Refresh M3U cache in background."""
2899
- n = int(time.time())
2900
- try:
2901
- channels = _parse_m3u()
2902
- _m3u_cache["t"] = n
2903
- _m3u_cache["d"] = channels
2904
- _save_m3u_disk(channels, n)
2905
- except Exception:
2906
- pass
2907
-
2908
- def _m3u_load():
2909
- """Load M3U: return disk/in-memory cache immediately, refresh in background if stale."""
2910
- global _m3u_cache
2911
- n = int(time.time())
2912
- # Try in-memory cache first
2913
- if _m3u_cache["d"] and n - _m3u_cache["t"] < 600:
2914
- return _m3u_cache["d"], _m3u_cache["t"]
2915
- # Try disk cache
2916
- disk = _load_m3u_disk()
2917
- if disk and disk.get("channels"):
2918
- _m3u_cache = {"t": disk.get("t", 0), "d": disk["channels"]}
2919
- # Refresh in background if older than 5 min
2920
- if n - _m3u_cache["t"] >= 300:
2921
- threading.Thread(target=_m3u_refresh, daemon=True).start()
2922
- return _m3u_cache["d"], _m3u_cache["t"]
2923
- # No cache at all — fetch synchronously (first run)
2924
- _m3u_refresh()
2925
- return _m3u_cache["d"], _m3u_cache["t"]
2926
-
2927
- @app.get('/api/m3u/channels')
2928
- def api_m3u_channels(refresh: int = Query(default=0)):
2929
- if refresh:
2930
- threading.Thread(target=_m3u_refresh, daemon=True).start()
2931
- return JSONResponse({"channels": _m3u_cache["d"], "updated": _m3u_cache["t"], "refreshing": True})
2932
- channels, ts = _m3u_load()
2933
- return JSONResponse({"channels": channels, "updated": ts})
2934
-
2935
- # Pre-warm M3U cache on startup
2936
- threading.Thread(target=lambda: _m3u_load(), daemon=True).start()
2937
-
2938
- @app.get('/api/proxy/m3u')
2939
- def proxy_m3u_stream(url: str = Query(...), ua: str = Query(default=None), cookie: str = Query(default=None), auth: str = Query(default=None)):
2940
- """Proxy MPEG-TS / HLS streams from stalker portals with custom headers."""
2941
- if not url or not url.startswith('http'):
2942
- return Response(status_code=400, content="Invalid URL")
2943
- headers = dict(HEADERS)
2944
- if ua:
2945
- headers["User-Agent"] = ua
2946
- if cookie:
2947
- headers["Cookie"] = cookie
2948
- if auth:
2949
- headers["Authorization"] = auth
2950
- try:
2951
- r = req.get(url, headers=headers, timeout=15, stream=True)
2952
- if r.status_code != 200:
2953
- return Response(status_code=502, content="upstream error")
2954
- # Determine media type
2955
- ct = r.headers.get("Content-Type", "")
2956
- media_type = "video/mp2t" if "mp2t" in ct or "mpeg" in ct.lower() or ".ts" in url else (ct or "video/mp2t")
2957
- return StreamingResponse(
2958
- (chunk for chunk in r.iter_content(chunk_size=65536)),
2959
- media_type=media_type,
2960
- headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "no-cache", "Accept-Ranges": "bytes"}
2961
- )
2962
- except Exception as e:
2963
- return Response(status_code=502, content=str(e)[:200])
2964
-
2965
- app.mount('/static',StaticFiles(directory=STATIC_DIR),name='vnews_static')
 
12
  except Exception as e:
13
  print(f"[WARN] ai_patch import failed: {e}")
14
 
15
+ from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response
 
 
 
 
 
16
  from fastapi.staticfiles import StaticFiles
17
  from starlette.routing import Mount
18
  from fastapi import Query, Request, UploadFile, File, Form
 
21
  import re, html as html_lib, json, threading, time, uuid
22
  from concurrent.futures import ThreadPoolExecutor, as_completed
23
  from urllib.parse import quote
 
24
 
25
  HL_LEAGUES['friendly'] = {"path": "giai-khac/friendly", "name": "Giao hữu", "emoji": "🤝"}
26
 
27
  STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
 
28
  app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)=='/' and hasattr(r,'methods') and 'GET' in getattr(r,'methods',set()))]
29
  app.routes[:]=[r for r in app.routes if not isinstance(r, Mount)]
30
  app.router.routes=[r for r in app.router.routes if not isinstance(r, Mount)]
31
 
32
  def _clean(s): return re.sub(r"\s+"," ",html_lib.unescape(str(s or""))).strip()
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  # Cache for match details (5 min TTL)
35
  _match_cache = {}
36
 
 
207
  def _has_kw(topic,title):
208
  tl=topic.lower();tt=(title or'').lower()
209
  if tl in tt:return True
210
+ words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',tl) if len(w)>2 and w not in _STOP]
211
  if not words:return True
212
  return any(w in tt for w in words)
213
 
 
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 = {}
 
456
  return JSONResponse({'error': f'Server error: {str(e)[:100]}', 'url': safe_url}, status_code=200)
457
 
458
  _hot_cache={'t':0,'d':[]}
 
 
 
 
 
 
 
 
 
 
 
 
459
  def _get_hot_topics():
460
  now=time.time()
461
  if _hot_cache['d'] and now-_hot_cache['t']<600:return _hot_cache['d']
 
 
 
 
 
 
 
 
462
  freq={};display={}
463
+ feeds=['https://vnexpress.net/rss/tin-moi-nhat.rss','https://dantri.com.vn/rss/home.rss','https://vietnamnet.vn/rss/tin-moi-nhat.rss','https://thanhnien.vn/rss/home.rss','https://tuoitre.vn/rss/tin-moi-nhat.rss','https://genk.vn/rss','https://vnexpress.net/rss/the-thao.rss','https://thethaovanhoa.vn/rss/tin-nong.rss']
464
+ for feed_url in feeds:
465
+ try:
466
+ r=req.get(feed_url,headers={'User-Agent':'Mozilla/5.0'},timeout=6);r.encoding='utf-8';soup=BeautifulSoup(r.text,'xml')
467
+ for item in soup.find_all('item')[:12]:
468
+ title=_clean(item.find('title').get_text() if item.find('title') else '')
469
+ if not title:continue
470
+ title=re.sub(r'\s*[-|].*$','',title);words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',title) if len(w)>2 and w.lower() not in _STOP]
471
+ if len(words)<2:continue
472
+ for n in(3,4,2):
473
+ for i in range(max(0,len(words)-n+1)):
474
+ phrase=' '.join(words[i:i+n])
475
+ if 8<=len(phrase)<=45:key=phrase.lower();freq[key]=freq.get(key,0)+1;display[key]=phrase
476
+ except:continue
477
  ranked=sorted(freq.items(),key=lambda x:x[1],reverse=True);topics=[];seen=set()
478
  for key,count in ranked:
479
  is_dup=any(len(set(e.split())&set(key.split()))/max(len(set(e.split())),len(set(key.split())),1)>0.6 for e in seen)
 
500
  items=_search_all(topic,36);per_page=8;start=page*per_page;end=start+per_page
501
  return JSONResponse({'sources':items[start:end],'topic':topic,'page':page,'has_more':end<len(items),'total':len(items)})
502
  @app.get('/api/categories')
503
+ def _cat():return JSONResponse([])
 
 
 
 
 
 
 
 
504
  @app.get('/api/storage_status')
505
  def _st():return JSONResponse({'persistent':os.path.isdir('/data') and os.access('/data',os.W_OK)})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
506
  @app.get('/s')
507
+ async def _sh(url:str='',title:str='',img:str=''):return HTMLResponse(f'<!DOCTYPE html><html><head><meta property="og:title" content="{_clean(title)}"><meta property="og:image" content="{_clean(img)}"><meta http-equiv="refresh" content="0;url={_clean(url) or "/"}"></head><body></body></html>')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
508
 
509
  from wc2026_scraper import scrape_summary,scrape_fixtures,scrape_standings,scrape_stats,scrape_wc_news,scrape_road_to_wc,get_wc2026_all,scrape_history,scrape_h2h,scrape_lineups,scrape_match_detail
510
 
 
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})
 
723
  text = body.get('text', '') or ''
724
  img = body.get('img', None)
725
  source = body.get('source', 'user') or 'user'
 
726
  post_id = str(uuid.uuid4())[:12]
 
 
 
 
 
 
 
 
 
 
727
  post = {
728
  "id": post_id,
729
  "title": title[:200],
730
  "text": text[:2000],
731
  "source": source,
732
+ "video": None,
733
  "img": img,
734
+ "images": [],
 
 
 
 
 
 
735
  "created": int(time.time()),
736
  "created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
737
  }
 
 
738
  posts = _load_wall_posts()
739
  if not isinstance(posts, list):
740
  posts = []
741
+ posts.insert(0, post)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
742
  posts = posts[:200]
743
  _save_wall_posts(posts)
744
  return JSONResponse({"post": post, "ok": True})
 
754
  media_type = 'video/mp4' if ext == '.mp4' else 'video/webm'
755
  return FileResponse(video_path, media_type=media_type)
756
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
757
  @app.delete('/api/wall/{post_id}')
758
  def api_wall_delete(post_id: str):
759
  posts = _load_wall_posts()
 
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
 
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.
 
1274
  return await api_rewrite_share(request)
1275
 
1276
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1277
  def _bg():
1278
  time.sleep(15)
1279
  while True:
 
1282
  time.sleep(90)
1283
  threading.Thread(target=_bg,daemon=True).start()
1284
 
1285
+ app.mount('/static',StaticFiles(directory=STATIC_DIR),name='vnews_static')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app_v2_entry.py.gitigignore DELETED
@@ -1,3 +0,0 @@
1
- .pyc
2
- __pycache__/
3
- *.pyc
 
 
 
 
app_v2_entry_hot.py DELETED
@@ -1,24 +0,0 @@
1
- """Hot topics patch - makes AI topics always visible at top of HOT list."""
2
- # This file is imported by app_v2_entry.py
3
-
4
- # AI topics to prepend to hot topics
5
- AI_HOT_TOPICS = [
6
- {'label': '#Công nghệ AI', 'topic': 'Công nghệ AI', 'count': 0},
7
- {'label': '#World Cup 2026', 'topic': 'World Cup 2026', 'count': 0},
8
- {'label': '#Kinh tế Việt Nam', 'topic': 'Kinh tế Việt Nam', 'count': 0},
9
- {'label': '#Bóng đá châu Âu', 'topic': 'Bóng đá châu Âu', 'count': 0},
10
- {'label': '#Giá vàng', 'topic': 'Giá vàng', 'count': 0},
11
- {'label': '#Thời tiết', 'topic': 'Thời tiết', 'count': 0},
12
- ]
13
-
14
- def prepend_ai_hot_topics(topics):
15
- """Prepend AI topics to hot topics list, ensuring they're always visible."""
16
- if not topics:
17
- return AI_HOT_TOPICS[:]
18
- # Remove duplicates that already exist
19
- existing_topics = [t.get('topic', '').lower() for t in topics]
20
- result = []
21
- for ai_topic in AI_HOT_TOPICS:
22
- if ai_topic.get('topic', '').lower() not in existing_topics:
23
- result.append(ai_topic)
24
- return result + topics
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app_v2_patch.py DELETED
@@ -1,111 +0,0 @@
1
- """VNEWS v2 Patch - auto scheduler + status endpoints + keep-alive.
2
- This is imported by app_v2_entry.py to add auto posting functionality.
3
- FIX v2: Catch-up scheduler + keep-alive to prevent Space sleep
4
- """
5
- import sys, os, threading, json, time, logging
6
- from datetime import datetime, timezone, timedelta
7
- from fastapi import Request
8
- from fastapi.responses import JSONResponse
9
- import requests as _req
10
-
11
- VN_TZ = timezone(timedelta(hours=7))
12
- LOG = logging.getLogger("app_v2_patch")
13
- LOG.setLevel(logging.INFO)
14
- if not LOG.handlers:
15
- ch = logging.StreamHandler()
16
- ch.setFormatter(logging.Formatter('%(asctime)s [app_v2_patch] %(levelname)s: %(message)s'))
17
- LOG.addHandler(ch)
18
-
19
- # ===== Keep-alive: prevent Space from sleeping =====
20
- # HF Spaces sleep after ~30 min of inactivity on free tier
21
- # This thread pings the Space every 10 minutes to keep it alive
22
- SPACE_URL = "https://bep40-vnews.hf.space"
23
-
24
- def _keep_alive_loop():
25
- """Ping the Space every 10 minutes to prevent sleep."""
26
- LOG.info(f"🔄 Keep-alive thread started - ping {SPACE_URL} every 10 min")
27
- while True:
28
- try:
29
- time.sleep(600) # 10 minutes
30
- _req.get(f"{SPACE_URL}/api/scheduler/status",
31
- headers={"User-Agent": "VNEWS-KeepAlive/1.0"},
32
- timeout=15)
33
- LOG.debug("Keep-alive ping OK")
34
- except Exception as e:
35
- LOG.warning(f"Keep-alive ping failed (Space may be sleeping): {e}")
36
-
37
- # Start keep-alive in background
38
- try:
39
- _ka_thread = threading.Thread(target=_keep_alive_loop, daemon=True, name="keep-alive")
40
- _ka_thread.start()
41
- LOG.info("🔄 Keep-alive started - Space will stay awake")
42
- except Exception as e:
43
- LOG.warning(f"Keep-alive start failed: {e}")
44
-
45
- # ===== Start auto scheduler =====
46
- try:
47
- import auto_scheduler as _as
48
- _as.start_auto_scheduler()
49
- LOG.info("[auto_scheduler] Started successfully - will post at 7:00, 13:00, 19:00 VN time (with catch-up)")
50
- except Exception as e:
51
- LOG.error(f"[auto_scheduler] Start failed: {e}")
52
-
53
- def register_scheduler_endpoints(app):
54
- """Register scheduler status/trigger endpoints on the FastAPI app."""
55
-
56
- @app.get('/api/scheduler/status')
57
- def scheduler_status():
58
- running = any(t.name == 'auto-scheduler' and t.is_alive() for t in threading.enumerate())
59
- keep_alive = any(t.name == 'keep-alive' and t.is_alive() for t in threading.enumerate())
60
-
61
- # Load state to show which slots ran today
62
- today_str = datetime.now(VN_TZ).strftime('%Y-%m-%d')
63
- state = {}
64
- try:
65
- state_file = '/data/scheduler_state.json' if os.path.isdir('/data') else None
66
- if state_file and os.path.exists(state_file):
67
- state = json.load(open(state_file, 'r'))
68
- except:
69
- pass
70
-
71
- ran_today = state.get(today_str, {}) if state else {}
72
-
73
- return JSONResponse({
74
- "running": running,
75
- "keep_alive": keep_alive,
76
- "schedule": "7:00, 13:00, 19:00 VN time",
77
- "today": today_str,
78
- "slots_ran_today": ran_today,
79
- "catch_up_enabled": True,
80
- "next_run": "7:00, 13:00, or 19:00 VN time (whichever is next)"
81
- })
82
-
83
- @app.post('/api/scheduler/trigger')
84
- async def scheduler_trigger():
85
- try:
86
- import auto_scheduler as _as2
87
- _as2._run_scheduled_posting()
88
- return JSONResponse({"ok": True, "message": "Scheduled posting triggered manually"})
89
- except Exception as e:
90
- return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
91
-
92
- @app.get('/api/scheduler/force')
93
- def scheduler_force():
94
- """Force-run all missed slots immediately. Useful after deploy."""
95
- try:
96
- import auto_scheduler as _as2
97
- _as2._check_missed_slots()
98
- return JSONResponse({"ok": True, "message": "Missed slots check triggered"})
99
- except Exception as e:
100
- return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
101
-
102
- return app
103
-
104
-
105
- # Auto-register on the main app from app_v2_entry
106
- try:
107
- from main import app
108
- register_scheduler_endpoints(app)
109
- LOG.info("[app_v2_patch] Scheduler endpoints registered: /api/scheduler/status, /api/scheduler/trigger, /api/scheduler/force")
110
- except Exception as e:
111
- LOG.error(f"[app_v2_patch] Could not register endpoints: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
auto_scheduler.py DELETED
@@ -1,396 +0,0 @@
1
- """VNEWS Auto Scheduler - tự động đăng 3 bài rewrite AI + shorts từ 3 chủ đề HOT
2
- Vào các khung giờ: 7:00, 13:00, 19:00 (giờ Việt Nam)
3
- Mỗi bài: Rewrite AI từ nguồn báo + short video tự động
4
- FIX v7: Giữ nguyên tiêu đề gốc từng bài viết + thêm "Tin tóm tắt VNEWS 7h sáng/13h trưa/19h tối" ở đầu text
5
- """
6
- import os, re, json, time, threading, asyncio, logging, random, hashlib, html as html_lib
7
- from datetime import datetime, timezone, timedelta, date
8
- from urllib.parse import quote
9
- import requests
10
- from bs4 import BeautifulSoup
11
-
12
- # Import storage for persistent data
13
- from storage import load_wall_posts, save_wall_posts, DATA_DIR
14
-
15
- VN_TZ = timezone(timedelta(hours=7))
16
- LOG = logging.getLogger("auto_scheduler")
17
- LOG.setLevel(logging.INFO)
18
- if not LOG.handlers:
19
- ch = logging.StreamHandler()
20
- ch.setFormatter(logging.Formatter('%(asctime)s [%(name)s] %(levelname)s: %(message)s'))
21
- LOG.addHandler(ch)
22
-
23
- SCHEDULE_TIMES = [(7, 0), (13, 0), (19, 0)]
24
- SCHEDULE_LABELS = {t: f"{t[0]:02d}:{t[1]:02d}" for t in SCHEDULE_TIMES}
25
-
26
- os.makedirs(DATA_DIR, exist_ok=True)
27
- SCHEDULE_STATE_FILE = os.path.join(DATA_DIR, 'scheduler_state.json')
28
-
29
- def _load_state():
30
- try:
31
- if os.path.exists(SCHEDULE_STATE_FILE):
32
- with open(SCHEDULE_STATE_FILE, 'r') as f: return json.load(f)
33
- except: pass
34
- return {}
35
-
36
- def _save_state(state):
37
- try:
38
- tmp = SCHEDULE_STATE_FILE + '.tmp'
39
- with open(tmp, 'w') as f: json.dump(state, f, ensure_ascii=False)
40
- os.replace(tmp, SCHEDULE_STATE_FILE)
41
- except Exception as e: LOG.warning(f"Cannot save state: {e}")
42
-
43
- _STOP = set('và của các những một được trong với cho tại sau trước khi không người vietnam hôm nay mới nhất nóng tin tức cập nhật theo từ đến là có thì này đã để'.split())
44
-
45
- def _clean(s):
46
- s = html_lib.unescape(s or "")
47
- # FIX: Remove malformed HTML artifacts (truncated tags without closing >)
48
- s = s.replace('<a href=" src="', '').replace("<a href=' src='", '')
49
- s = s.replace('<a href=" src=', '').replace("<a href=' src=", '')
50
- s = re.sub(r'<[^>]+>', '', s) # Remove all HTML tags
51
- return re.sub(r"\s+", " ", s).strip()
52
-
53
- def _get_hot_topics():
54
- freq = {}; display = {}
55
- feeds = [
56
- 'https://vnexpress.net/rss/tin-moi-nhat.rss',
57
- 'https://dantri.com.vn/rss/home.rss',
58
- 'https://vietnamnet.vn/rss/tin-moi-nhat.rss',
59
- 'https://thanhnien.vn/rss/home.rss',
60
- 'https://tuoitre.vn/rss/tin-moi-nhat.rss',
61
- 'https://genk.vn/rss',
62
- 'https://vnexpress.net/rss/the-thao.rss',
63
- 'https://thethaovanhoa.vn/rss/tin-nong.rss',
64
- 'https://vnexpress.net/rss/kinh-doanh.rss',
65
- 'https://dantri.com.vn/rss/the-gioi.rss',
66
- ]
67
- for feed_url in feeds:
68
- try:
69
- r = requests.get(feed_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=6)
70
- r.encoding = 'utf-8'
71
- soup = BeautifulSoup(r.text, 'xml')
72
- for item in soup.find_all('item')[:12]:
73
- title = _clean(item.find('title').get_text() if item.find('title') else '')
74
- if not title: continue
75
- title = re.sub(r'\s*[-|].*$', '', title)
76
- words = [w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+', title) if len(w) > 2 and w.lower() not in _STOP]
77
- if len(words) < 2: continue
78
- for n in (3, 4, 2):
79
- for i in range(max(0, len(words) - n + 1)):
80
- phrase = ' '.join(words[i:i + n])
81
- if 8 <= len(phrase) <= 45:
82
- key = phrase.lower()
83
- freq[key] = freq.get(key, 0) + 1
84
- display[key] = phrase
85
- except: continue
86
- ranked = sorted(freq.items(), key=lambda x: x[1], reverse=True)
87
- topics = []; seen = set()
88
- for key, count in ranked:
89
- kw = display[key]
90
- is_dup = any(len(set(e.split()) & set(key.split())) / max(len(set(e.split())), len(set(key.split())), 1) > 0.6 for e in seen)
91
- if is_dup: continue
92
- seen.add(key)
93
- topics.append({'label': '#' + re.sub(r'\s+', '', kw.title()), 'topic': kw, 'count': count})
94
- if len(topics) >= 20: break
95
- for kw in ['World Cup 2026', 'Kinh tế Việt Nam', 'Bóng đá châu Âu', 'Công nghệ AI', 'Giá vàng', 'Thời tiết']:
96
- if len(topics) >= 24: break
97
- if not any(kw.lower() in s for s in seen):
98
- topics.append({'label': '#' + re.sub(r'\s+', '', kw.title()), 'topic': kw, 'count': 0})
99
- return topics[:24]
100
-
101
- _ai_ext = None; _ai_patch = None
102
- def _get_ai_ext():
103
- global _ai_ext
104
- if _ai_ext is None: import ai_ext as m; _ai_ext = m
105
- return _ai_ext
106
- def _get_ai_patch():
107
- global _ai_patch
108
- if _ai_patch is None: import ai_patch as m; _ai_patch = m
109
- return _ai_patch
110
-
111
- _RSS_FEEDS = [
112
- ('https://vnexpress.net/rss/tin-moi-nhat.rss', 'VnExpress'),
113
- ('https://dantri.com.vn/rss/home.rss', 'Dân Trí'),
114
- ('https://vietnamnet.vn/rss/tin-moi-nhat.rss', 'VietNamNet'),
115
- ('https://thanhnien.vn/rss/home.rss', 'Thanh Niên'),
116
- ('https://tuoitre.vn/rss/tin-moi-nhat.rss', 'Tuổi Trẻ'),
117
- ('https://genk.vn/rss', 'GenK'),
118
- ('https://vnexpress.net/rss/the-thao.rss', 'VnExpress'),
119
- ('https://thethaovanhoa.vn/rss/tin-nong.rss', 'TT&VH'),
120
- ('https://vnexpress.net/rss/kinh-doanh.rss', 'VnExpress'),
121
- ('https://dantri.com.vn/rss/the-gioi.rss', 'Dân Trí'),
122
- ]
123
-
124
- def _search_articles_by_topic(topic, limit=4):
125
- all_articles = []; seen_urls = set()
126
- topic_lower = topic.lower()
127
- topic_words = set(re.findall(r'[A-Za-zÀ-ỹ0-9]+', topic_lower))
128
- for feed_url, source in _RSS_FEEDS:
129
- try:
130
- r = requests.get(feed_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=6)
131
- r.encoding = 'utf-8'
132
- soup = BeautifulSoup(r.text, 'xml')
133
- for item in soup.find_all('item')[:8]:
134
- title = _clean(item.find('title').get_text() if item.find('title') else '')
135
- link = _clean(item.find('link').get_text() if item.find('link') else '')
136
- desc = _clean(item.find('description').get_text() if item.find('description') else '')
137
- if not title or not link or link in seen_urls: continue
138
- seen_urls.add(link)
139
- title_words = set(re.findall(r'[A-Za-zÀ-ỹ0-9]+', title.lower()))
140
- overlap = len(topic_words & title_words) if topic_words else 0
141
- exact_match = topic_lower in title.lower() or topic_lower in desc.lower()
142
- if exact_match or overlap >= 2:
143
- img = ''
144
- encl = item.find('enclosure')
145
- if encl: img = encl.get('url', '')
146
- if not img:
147
- try:
148
- art_r = requests.get(link, headers={'User-Agent': 'Mozilla/5.0'}, timeout=4)
149
- art_r.encoding = 'utf-8'
150
- art_soup = BeautifulSoup(art_r.text, 'lxml')
151
- ogi = art_soup.find('meta', property='og:image')
152
- if ogi: img = ogi.get('content', '')
153
- except: pass
154
- all_articles.append({'title': title, 'url': link, 'raw': desc or title, 'image': img, 'via': source, 'source': {'title': title, 'url': link, 'excerpt': (desc or title)[:700], 'via': source}})
155
- if len(all_articles) >= limit: break
156
- except: continue
157
- return all_articles[:limit]
158
-
159
- async def _create_ai_post(topic):
160
- ai_ext = _get_ai_ext(); ai_patch = _get_ai_patch()
161
- articles = _search_articles_by_topic(topic, limit=4)
162
- if not articles:
163
- LOG.warning(f"No articles for topic: {topic}. Fallback.")
164
- return await _create_fallback_post(topic, ai_ext, ai_patch)
165
- posts = []
166
- # Get schedule time label for text intro (7h sáng, 13h trưa, 19h tối)
167
- now = datetime.now(VN_TZ)
168
- hour = now.hour
169
- time_label = "7h sáng" if hour == 7 else ("13h trưa" if hour == 13 else "19h tối")
170
- text_intro = f"Tin tóm tắt VNEWS {time_label}"
171
- wall = ai_ext._load_ai_wall()
172
- if not isinstance(wall, list): wall = []
173
- for art in articles:
174
- try:
175
- prompt = ai_patch._make_summary_prompt(art.get('title', topic), art.get('raw', ''), art.get('via', ''))
176
- text = await ai_ext.qwen_generate(prompt, image_url=art.get('image'), max_tokens=1500)
177
- text = ai_patch._postprocess_ai_text(text, max_units=20)
178
- src = [art.get('source', {'title': art.get('title', topic), 'url': art.get('url', ''), 'via': art.get('via', '')})]
179
- # Prepend time label intro to text (giữ nguyên title là tiêu đề gốc của bài báo)
180
- if text and not text.startswith(text_intro):
181
- text = f"{text_intro}\n\n{text}"
182
- if 'Nguồn tham khảo:' not in (text or ''):
183
- text = (text or '') + "\n\n" + ai_patch._source_line(src)
184
- img = art.get('image') or ai_ext.pollination_image_url(art.get('title', topic))
185
- # Dùng art.get('title') GIỮ NGUYÊN tiêu đề gốc từ bài báo
186
- post = ai_ext.make_post(art.get('title', topic), text, img, art.get('url', ''), 'auto_scheduled', sources=src)
187
- try:
188
- page_data = ai_patch._scrape_article_images(art.get('url', ''))
189
- if page_data and page_data.get('paragraphs'):
190
- kp = ai_patch._extract_key_points_for_slides(page_data['paragraphs'], max_points=8)
191
- if kp:
192
- imgs = page_data.get('images', [])
193
- if not imgs and page_data.get('og_img'): imgs = [page_data['og_img']]
194
- slides = []
195
- for i, pt in enumerate(kp):
196
- slides.append({'text': pt, 'image': imgs[i] if i < len(imgs) else (imgs[-1] if imgs else ''), 'index': i + 1})
197
- post['slides'] = slides
198
- except: pass
199
- posts.append(post)
200
- except Exception as e:
201
- LOG.error(f"Error post: {e}")
202
- if not posts: return await _create_fallback_post(topic, ai_ext, ai_patch)
203
- wall = posts + wall
204
- ai_ext._save_ai_wall(wall)
205
- for post in posts:
206
- try: _try_generate_short(post)
207
- except: pass
208
- return posts
209
-
210
- async def _create_fallback_post(topic, ai_ext, ai_patch):
211
- LOG.info(f"Fallback: {topic}")
212
- try:
213
- # Still add time label to fallback posts
214
- now = datetime.now(VN_TZ)
215
- hour = now.hour
216
- time_label = "7h sáng" if hour == 7 else ("13h trưa" if hour == 13 else "19h tối")
217
- text_intro = f"Tin tóm tắt VNEWS {time_label}"
218
- text = f"{text_intro}\n\n• {topic} đang là chủ đề nóng hôm nay.\n• Theo dõi VNEWS để cập nhật tin tức mới nhất."
219
- img = ai_ext.pollination_image_url(topic)
220
- post = ai_ext.make_post(topic, text, img, '', 'auto_scheduled', sources=[])
221
- wall = ai_ext._load_ai_wall()
222
- if not isinstance(wall, list): wall = []
223
- wall = [post] + wall
224
- ai_ext._save_ai_wall(wall)
225
- LOG.info(f"Fallback saved: {topic}")
226
- return [post]
227
- except Exception as e:
228
- LOG.error(f"Fallback failed: {e}")
229
- return []
230
-
231
- def _try_generate_short(post):
232
- post_id = post.get('id', '')
233
- if not post_id: return
234
- try:
235
- ai_ext = _get_ai_ext(); ai_patch = _get_ai_patch()
236
- if ai_ext.gTTS is None: return
237
- segments = ai_patch._summary_segments_from_post(post, max_segments=15)
238
- if not segments: return
239
- seg_hash = hashlib.md5(('|'.join(segments) + 'nu' + 'neutral' + '1.0').encode('utf-8')).hexdigest()[:8]
240
- suffix = f"_nu_neutral_1p0_{seg_hash}_scenes_nosub"
241
- out_mp4 = os.path.join(ai_ext.SHORTS_DIR, ai_ext._safe_name(post_id + suffix) + '.mp4')
242
- if os.path.exists(out_mp4):
243
- post['video'] = '/api/ai/short-file/' + post_id + suffix
244
- wall = ai_ext._load_ai_wall()
245
- for i, p in enumerate(wall):
246
- if p.get('id') == post_id: wall[i] = post; break
247
- ai_ext._save_ai_wall(wall); return
248
- threading.Thread(target=lambda: _generate_short_worker(post, segments, post_id, suffix, out_mp4), daemon=True).start()
249
- except Exception as e: LOG.warning(f"Short init: {e}")
250
-
251
- def _generate_short_worker(post, segments, post_id, suffix, out_mp4):
252
- import subprocess
253
- try:
254
- ai_ext = _get_ai_ext(); ai_patch = _get_ai_patch()
255
- work = os.path.join(ai_ext.SHORTS_DIR, ai_ext._safe_name(post_id + suffix))
256
- os.makedirs(work, exist_ok=True)
257
- img = os.path.join(work, 'image.jpg')
258
- ai_ext._download_image(post.get('img'), post.get('title', 'AI news'), img)
259
- part_files = []
260
- for idx, seg in enumerate(segments[:10]):
261
- frame = os.path.join(work, f'frame_{idx:02d}.jpg')
262
- aud = os.path.join(work, f'voice_{idx:02d}.mp3')
263
- aud_fast = os.path.join(work, f'voice_{idx:02d}_fast.mp3')
264
- part = os.path.join(work, f'part_{idx:02d}.mp4')
265
- try: ai_patch._make_scene_frame(post, seg, idx, min(len(segments), 10), img, frame, emotion='neutral')
266
- except:
267
- if not os.path.exists(img): continue
268
- from PIL import Image
269
- Image.new('RGB', (1080, 1920), (14, 14, 14)).save(frame, quality=85)
270
- tts_text = re.sub(r'^[•\-\*\d\.\)\s]+', '', seg).strip()
271
- try: ai_ext.gTTS(tts_text, lang='vi', slow=False).save(aud)
272
- except:
273
- try: ai_ext.gTTS(tts_text, lang='vi', tld='com.vn', slow=False).save(aud)
274
- except: continue
275
- subprocess.run(['ffmpeg', '-y', '-i', aud, '-filter:a', 'atempo=1.0', '-vn', aud_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
276
- dur = 12.0
277
- try:
278
- pr = subprocess.run(['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:no_key=1', aud_fast], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
279
- dur = max(8.0, float((pr.stdout or b'').decode().strip() or 12.0)) + 0.5
280
- except: pass
281
- 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)
282
- part_files.append(part)
283
- if part_files:
284
- concat = os.path.join(work, 'concat.txt')
285
- with open(concat, 'w', encoding='utf-8') as f:
286
- for p in part_files: f.write("file '" + p.replace("'", "'\\''") + "'\n")
287
- subprocess.run(['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', concat, '-c', 'copy', out_mp4], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
288
- post['video'] = '/api/ai/short-file/' + post_id + suffix
289
- post['short_voice'] = 'nu'; post['short_emotion'] = 'neutral'; post['short_speed'] = 1.0
290
- post['short_segments'] = segments; post['short_subtitles'] = False
291
- wall = ai_ext._load_ai_wall()
292
- for i, p in enumerate(wall):
293
- if p.get('id') == post_id: wall[i] = post; break
294
- ai_ext._save_ai_wall(wall)
295
- LOG.info(f"Short: {post_id}")
296
- except Exception as e: LOG.warning(f"Short fail: {e}")
297
-
298
- def _run_async(coro):
299
- """Run async coroutine safely regardless of current event loop state."""
300
- try:
301
- loop = asyncio.get_running_loop()
302
- except RuntimeError:
303
- return asyncio.run(coro)
304
- import concurrent.futures
305
- with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
306
- return pool.submit(asyncio.run, coro).result(timeout=300)
307
-
308
- def _run_scheduled_posting():
309
- LOG.info("=" * 50)
310
- LOG.info("Scheduler triggered at %s", datetime.now(VN_TZ).strftime('%H:%M %d/%m/%Y'))
311
- LOG.info("=" * 50)
312
- try:
313
- hot_topics = _get_hot_topics()
314
- if not hot_topics:
315
- LOG.warning("No hot topics"); return
316
- selected = []; seen_labels = set()
317
- for t in hot_topics:
318
- label = t.get('label', '')
319
- if label and label not in seen_labels:
320
- seen_labels.add(label); selected.append(t['topic'])
321
- if len(selected) >= 3: break
322
- if len(selected) < 3:
323
- selected = ['Thời sự Việt Nam', 'Kinh tế Việt Nam', 'Thể thao']
324
- LOG.info(f"Topics: {selected}")
325
- async def _do_all():
326
- results = []
327
- for topic in selected:
328
- try:
329
- posts = await _create_ai_post(topic)
330
- results.append({'topic': topic, 'posts': len(posts) if posts else 0})
331
- LOG.info(f"{'✓' if posts else '✗'} {topic}: {len(posts) if posts else 0} posts")
332
- except Exception as e:
333
- LOG.error(f"Error {topic}: {e}")
334
- results.append({'topic': topic, 'posts': 0})
335
- return results
336
- results = _run_async(_do_all())
337
- LOG.info(f"Done: {len(results)} topics")
338
- for r in results: LOG.info(f" • {r['topic']}: {r['posts']} bài")
339
- except Exception as e:
340
- LOG.error(f"Scheduler error: {e}", exc_info=True)
341
-
342
- def _check_missed_slots():
343
- try:
344
- state = _load_state()
345
- today_str = datetime.now(VN_TZ).strftime('%Y-%m-%d')
346
- now = datetime.now(VN_TZ); cur_mins = now.hour * 60 + now.minute
347
- ran = state.get(today_str, {})
348
- for s in SCHEDULE_TIMES:
349
- lbl = SCHEDULE_LABELS[s]; sm = s[0] * 60 + s[1]
350
- if ran.get(lbl): continue
351
- if cur_mins >= sm:
352
- LOG.info(f"Catch-up: {lbl}")
353
- _run_scheduled_posting()
354
- if today_str not in state: state[today_str] = {}
355
- state[today_str][lbl] = True; _save_state(state)
356
- except Exception as e: LOG.error(f"Catch-up: {e}")
357
-
358
- def _scheduler_loop():
359
- LOG.info("Scheduler started")
360
- LOG.info(f"Schedule: {', '.join(f'{h:02d}:{m:02d}' for h,m in SCHEDULE_TIMES)} VN")
361
- state = _load_state(); today_str = datetime.now(VN_TZ).strftime('%Y-%m-%d')
362
- ran = state.get(today_str, {})
363
- now = datetime.now(VN_TZ); cur_mins = now.hour * 60 + now.minute
364
- for s in SCHEDULE_TIMES:
365
- lbl = SCHEDULE_LABELS[s]; sm = s[0] * 60 + s[1]
366
- if ran.get(lbl): LOG.info(f" ✓ {lbl} done"); continue
367
- if cur_mins >= sm:
368
- LOG.info(f" → {lbl} missed! Catch-up")
369
- _run_scheduled_posting()
370
- if today_str not in state: state[today_str] = {}
371
- state[today_str][lbl] = True; _save_state(state)
372
- else: LOG.info(f" ⏩ {lbl} upcoming")
373
- while True:
374
- try:
375
- now = datetime.now(VN_TZ)
376
- ck = (now.hour, now.minute)
377
- state = _load_state(); today_str = now.strftime('%Y-%m-%d')
378
- ran = state.get(today_str, {})
379
- for s in SCHEDULE_TIMES:
380
- lbl = SCHEDULE_LABELS[s]
381
- if ck == s and not ran.get(lbl):
382
- LOG.info(f"On-time: {lbl}")
383
- _run_scheduled_posting()
384
- if today_str not in state: state[today_str] = {}
385
- state[today_str][lbl] = True; _save_state(state)
386
- break
387
- time.sleep(60)
388
- except Exception as e:
389
- LOG.error(f"Loop: {e}")
390
- time.sleep(60)
391
-
392
- def start_auto_scheduler():
393
- t = threading.Thread(target=_scheduler_loop, daemon=True, name="auto-scheduler")
394
- t.start()
395
- LOG.info("Auto scheduler started")
396
- return t
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
auto_update_sse.py DELETED
@@ -1,55 +0,0 @@
1
- """Auto-update SSE endpoint for VNEWS - pushes updates when new posts/shorts published."""
2
- import asyncio
3
- import json
4
- import time
5
- from fastapi import Request
6
- from fastapi.responses import StreamingResponse
7
-
8
- # Connected clients queue
9
- _clients = []
10
- _lock = asyncio.Lock()
11
-
12
- async def _notify_clients(event_type: str, data: dict):
13
- """Send notification to all SSE clients."""
14
- if not _clients:
15
- return
16
- msg = f"data: {json.dumps({'type': event_type, 'data': data, 'ts': int(time.time())})}\n\n"
17
- async with _lock:
18
- dead = []
19
- for q in _clients:
20
- try:
21
- await q.put_nowait(msg)
22
- except asyncio.QueueFull:
23
- pass
24
- except:
25
- dead.append(q)
26
- for q in dead:
27
- if q in _clients:
28
- _clients.remove(q)
29
-
30
- # Public functions to call from other modules
31
- notify_new_post = lambda post: asyncio.create_task(_notify_clients("new_post", post)) if post else None
32
- notify_new_short = lambda post: asyncio.create_task(_notify_clients("new_short", post)) if post else None
33
-
34
- async def sse_events(request: Request):
35
- """SSE endpoint for real-time updates on homepage."""
36
- q = asyncio.Queue(maxsize=10)
37
- _clients.append(q)
38
-
39
- async def event_generator():
40
- try:
41
- # Send initial connection message
42
- yield "data: {\"type\":\"connected\",\"ts\":null}\n\n"
43
- while not await request.is_disconnected():
44
- try:
45
- msg = await asyncio.wait_for(q.get(), timeout=25.0)
46
- yield msg
47
- except asyncio.TimeoutError:
48
- yield ":keepalive\n\n"
49
- except:
50
- pass
51
- finally:
52
- if q in _clients:
53
- _clients.remove(q)
54
-
55
- return StreamingResponse(event_generator(), media_type="text/event-stream")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bdp_full.html DELETED
@@ -1,1250 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <title>Hull City vs MU: Quỷ Đỏ vào hang bắt hổ - Bongdaplus.vn</title>
5
-
6
-
7
- <meta name="MobileOptimized" content="100" />
8
- <meta name="viewport" content="width=device-width, initial-scale=1" />
9
- <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
10
- <meta http-equiv="content-language" content="vi" />
11
- <meta http-equiv="refresh" content="300" />
12
- <meta property="fb:pages" content="1738672379699789" />
13
- <meta property="dable:item_id" content="2015111202028" />
14
- <meta property="og:image" itemprop="thumbnailUrl" content="https://bongdaplus.vn/img/logo-daily.png" />
15
- <link href="/favicon.ico" rel="shortcut icon" type="image/x-icon" />
16
- <link href="/favicon.svg" rel="icon" sizes="any" type="image/svg+xml" />
17
- <link href="/apple-touch-icon.png" rel="apple-touch-icon" />
18
- <!--iPhone icons-->
19
- <link href="/logo-57.png" rel="apple-touch-icon-precomposed" sizes="57x57" />
20
- <link href="/logo-114.png" rel="apple-touch-icon-precomposed" sizes="114x114" />
21
- <!--iPad icons-->
22
- <link href="/logo-72.png" rel="apple-touch-icon-precomposed" sizes="72x72" />
23
- <link href="/logo-114.png" rel="apple-touch-icon-precomposed" sizes="114x114" />
24
- <!--Android icon-->
25
- <link href="/logo-114.png" rel="apple-touch-icon-precomposed" sizes="1x1" />
26
- <!--Symbian icon-->
27
- <link href="/logo-57.png" rel="nokia-touch-icon" sizes="57x57" />
28
- <link rel="preconnect" href="https://fonts.googleapis.com">
29
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
30
- <link href="https://fonts.googleapis.com/css2?family=Mulish:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,200;1,300;1,400;1,500;1,600;1,700;1,800&display=swap" rel="stylesheet">
31
- <link href="https://fonts.googleapis.com/css2?family=Noto+Serif:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap" rel="stylesheet">
32
- <link href="/css/premium.css?id=0000&amp;v=ivL0rSTnUIBx45p3hw7UIAzbXjyQ9rgPVSyjqPglN_o" rel="stylesheet">
33
-
34
- <script async src="https://www.googletagmanager.com/gtag/js?id=UA-20374657-1"></script>
35
- <script>
36
- window.dataLayer = window.dataLayer || [];
37
- function gtag() { dataLayer.push(arguments); }
38
- gtag('js', new Date());
39
-
40
- gtag('config', 'UA-20374657-1');
41
- </script>
42
- <!-- Google tag (gtag.js) -->
43
- <script async src="https://www.googletagmanager.com/gtag/js?id=G-Y1DBNQWMVW"></script>
44
- <script>
45
- window.dataLayer = window.dataLayer || [];
46
- function gtag() { dataLayer.push(arguments); }
47
- gtag('js', new Date());
48
-
49
- gtag('config', 'G-Y1DBNQWMVW');
50
- </script>
51
-
52
- <script async src="https://static.amcdn.vn/tka/cdn.js" type="text/javascript"></script>
53
- <script>var arfAsync = arfAsync || [];</script>
54
- <script id="arf-core-js" onerror="window.arferrorload=true;" src="//media1.admicro.vn/cms/Arf.min.js" async></script>
55
- <script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-3619133031508264" crossorigin="anonymous"></script>
56
- </head>
57
- <body>
58
- <script type="application/ld+json">
59
- {
60
- "@context": "https://schema.org",
61
- "@type": "Organization",
62
- "name": "TẠP CHÍ ĐIỆN TỬ BÓNG ĐÁ - BONGDAPLUS",
63
- "url": "https://bongdaplus.vn",
64
- "logo": "https://bongdaplus.vn/img/logo.png",
65
- "contactPoint": {
66
- "@type": "ContactPoint",
67
- "telephone": "(84.24)35541188",
68
- "contactType": "customer service",
69
- "areaServed": "VN",
70
- "availableLanguage": "Vietnamese"
71
- },
72
- "sameAs": [
73
- "https://www.facebook.com/baobongda",
74
- "https://www.youtube.com/channel/UCvDYMBE6GipX2VGacqOU2_Q",
75
- "https://bongdaplus.vn",
76
- "https://www.pinterest.com/bongdaplus",
77
- "https://soundcloud.com/baobongda"
78
- ]
79
- }
80
- </script>
81
- <header>
82
- <center><div id="ADMTOP" class="banner admob" banner-adpos="top"></div></center>
83
-
84
- <div class="cont-wrap">
85
- <div class="mmnu-nav">
86
- <button class="ico mnu-sch mmnu-trigger hide-w"></button>
87
- <button class="ico menubar mmnu-trigger hide-m"></button>
88
- <button class="ico close mmnu-closer hide"></button>
89
- </div>
90
- <a class="logo" href="/"></a>
91
- <button class="ico bell hide-w hide"></button>
92
-
93
- <div class="authen-nav dropper">
94
- <a class="member">
95
- <span class="avatar">
96
- </span>
97
- </a>
98
- <div class="drop-down rgt">
99
- <ul class="lst">
100
-
101
- <li><a href="#" data-showpaner="#loginview">Đăng nhập</a></li>
102
- <li><a href="https://member.bongdaplus.vn/Identity/Account/Register?returnUrl=%2F" target="_blank">Tạo tài khoản</a></li>
103
-
104
-
105
- </ul>
106
- </div>
107
- </div>
108
- </div>
109
- </header>
110
- <input type="hidden" id="catrefid" value="video" />
111
- <input type="hidden" id="objectid" value="98267" />
112
- <input type="hidden" id="objecttype" value="1" />
113
- <input type="hidden" id="parentid" value="0" />
114
- <input type="hidden" id="commentnum" value="0" />
115
- <img src="/imgthumbnail/98267/1/1" alt="" height="0" width="0" />
116
-
117
- <main>
118
- <div class="nav-menu">
119
- <div class="menu-bar">
120
- <div class="cont-wrap">
121
- <div class="menu-side">
122
- <ul class="bar-w">
123
- <li><a class="ico home" href="/"></a></li>
124
- <li><a title="AFF Cup" href="/aff-cup">AFF Cup</a></li>
125
- <li class="popover">
126
- <a title="Việt Nam" href="/bong-da-viet-nam">Việt Nam</a>
127
- <div class="drop-down">
128
- <ul class="lst">
129
- <li><a title="V-League" href="/v-league">V-League</a></li>
130
- <li><a title="Hạng Nhất" href="/hang-nhat-quoc-gia">Hạng Nhất</a></li>
131
- <li><a title="Cúp Quốc gia" href="/cup-quoc-gia">Cúp Quốc gia</a></li>
132
- <li><a title="ĐTQG" href="/doi-tuyen-quoc-gia-viet-nam">ĐTQG</a></li>
133
- <li><a title="Bóng đá Nữ" href="/bong-da-nu-viet-nam">Bóng đá Nữ</a></li>
134
- <li><a title="U17 Quốc gia" href="/u17-viet-nam">U17 Quốc gia</a></li>
135
- <li><a title="U19 Quốc Gia" href="/u19-viet-nam">U19 Quốc Gia</a></li>
136
- <li><a title="U21 Quốc Gia" href="/u21-viet-nam">U21 Quốc Gia</a></li>
137
- <li><a title="Các đội tuyển trẻ" href="/cac-doi-tuyen-tre-viet-nam">Các đội tuyển trẻ</a></li>
138
- <li><a title="Futsal" href="/futsal">Futsal</a></li>
139
- <li><a title="Phong trào" href="/bong-da-phong-trao">Phong trào</a></li>
140
- <li><a title="Đơn vị Kết nghĩa" href="/ket-nghia-anh-em">Đơn vị Kết nghĩa</a></li>
141
- </ul>
142
- </div>
143
- </li>
144
- <li class="popover">
145
- <a title="Chuyển nhượng" href="/tin-chuyen-nhuong">Chuyển nhượng</a>
146
- <div class="drop-down">
147
- <ul class="lst">
148
- <li><a title="Tin chuyển nhượng Ngoại Hạng Anh" href="/tin-chuyen-nhuong-ngoai-hang-anh">Tin chuyển nhượng Ngoại Hạng Anh</a></li>
149
- <li><a title="Tin chuyển nhượng V-league" href="/tin-chuyen-nhuong-v-league">Tin chuyển nhượng V-league</a></li>
150
- </ul>
151
- </div>
152
- </li>
153
- <li><a title="Champions League" href="/champions-league-cup-c1">Champions League</a></li>
154
- <li class="popover">
155
- <a title="Tây Ban Nha" href="/bong-da-tay-ban-nha">Tây Ban Nha</a>
156
- <div class="drop-down">
157
- <ul class="lst">
158
- <li><a title="VĐQG Tây Ban Nha" href="/la-liga">VĐQG Tây Ban Nha</a></li>
159
- <li><a title="Cúp Nhà Vua" href="/cup-nha-vua-tay-ban-nha">Cúp Nhà Vua</a></li>
160
- <li><a title="Đội tuyển TBN" href="/doi-tuyen-tay-ban-nha">Đội tuyển TBN</a></li>
161
- </ul>
162
- </div>
163
- </li>
164
- <li class="popover">
165
- <a title="Pháp" href="/bong-da-phap">Pháp</a>
166
- <div class="drop-down">
167
- <ul class="lst">
168
- <li><a title="VĐQG Pháp" href="/ligue-1">VĐQG Pháp</a></li>
169
- <li><a title="Cúp Quốc gia Pháp" href="/cup-quoc-gia-phap">Cúp Quốc gia Pháp</a></li>
170
- <li><a title="Đội tuyển Pháp" href="/doi-tuyen-phap">Đội tuyển Pháp</a></li>
171
- </ul>
172
- </div>
173
- </li>
174
- <li class="popover">
175
- <a title="Italia" href="/bong-da-y">Italia</a>
176
- <div class="drop-down">
177
- <ul class="lst">
178
- <li><a title="VĐQG Italia" href="/serie-a">VĐQG Italia</a></li>
179
- <li><a title="Coppa Italia" href="/coppa-italia">Coppa Italia</a></li>
180
- <li><a title="Đội tuyển Italia" href="/doi-tuyen-y">Đội tuyển Italia</a></li>
181
- </ul>
182
- </div>
183
- </li>
184
- <li class="popover">
185
- <a title="Thể thao" href="/the-thao">Thể thao</a>
186
- <div class="drop-down">
187
- <ul class="lst">
188
- <li><a title="eSports" href="/esports">eSports</a></li>
189
- <li><a title="Tennis" href="/tennis">Tennis</a></li>
190
- <li><a title="Pickleball" href="/pickleball">Pickleball</a></li>
191
- <li><a title="MMA" href="/mma">MMA</a></li>
192
- <li><a title="Đua xe F1" href="/dua-xe-f1">Đua xe F1</a></li>
193
- <li><a title="Bóng rổ" href="/bong-ro">Bóng rổ</a></li>
194
- <li><a title="Olympic 2028" href="/olympic">Olympic 2028</a></li>
195
- </ul>
196
- </div>
197
- </li>
198
- <li><a title="Nhận định bóng đá" href="/nhan-dinh-bong-da-tags">Nhận định</a></li>
199
- <li><a title="Multimedia" href="/video">Multimedia</a></li>
200
- <li class="hide"><a title="Bongdaplus Premium" href="/premium">Premium <i class="ico crown"></i></a></li>
201
- </ul>
202
- </div>
203
- <div class="menu-nav">
204
- <a class="ico home hide-w" href="/"></a>
205
- <ul class="nav">
206
- <li class="hide-m"><i class="sepr"></i></li>
207
- <li><a href="/tin-moi">Mới nhất</a></li>
208
- <li class="hide-w"><a title="Bongdaplus Premium" href="/premium">Premium <i class="ico crown"></i></a></li>
209
- <li><a title="Anh" href="/bong-da-anh">Bóng đá Anh</a></li>
210
- <li class="hide-w"><a title="AFF Cup" href="/aff-cup">AFF Cup</a></li>
211
- <li class="hide-w"><a title="Việt Nam" href="/bong-da-viet-nam">Việt Nam</a></li>
212
- <li class="hide-w"><a title="Chuyển nhượng" href="/tin-chuyen-nhuong">Chuyển nhượng</a></li>
213
- <li class="hide-w"><a title="Champions League" href="/champions-league-cup-c1">Champions League</a></li>
214
- <li class="hide-w"><a title="Tây Ban Nha" href="/bong-da-tay-ban-nha">Tây Ban Nha</a></li>
215
- <li class="hide-w"><a title="Pháp" href="/bong-da-phap">Pháp</a></li>
216
- <li class="hide-w"><a title="Italia" href="/bong-da-y">Italia</a></li>
217
- <li class="hide-w"><a title="Thể thao" href="/the-thao">Thể thao</a></li>
218
- <li class="hide-w"><a title="Nhận định" href="/nhan-dinh-bong-da-tags">Nhận định</a></li>
219
- <li class="hide-w"><a title="Thế giới" href="/the-gioi">Thế giới</a></li>
220
- <li class="hide-w"><a title="Multimedia" href="/video">Multimedia</a></li>
221
- <li class="hide-w"><a title="Bóng đá Cuộc sống" href="/bong-da-cuoc-song">Bóng đá Cuộc sống</a></li>
222
- </ul>
223
- <div class="srch-bar hide-m">
224
- <button class="ico close"></button>
225
- <form class="srch-form" action="/tim-kiem">
226
- <input class="pattern toggler" name="q" type="text" placeholder="Nhập nội dung cần tìm" />
227
- </form>
228
- </div>
229
- </div>
230
- </div>
231
- </div>
232
- <div class="clr"></div>
233
- </div>
234
- <div class="mob-menu">
235
- <div class="mmnu-cap">
236
- <button class="ico close" data-action="hide"></button>
237
- <a class="logo" href="/"></a>
238
- </div>
239
- <form class="mmnu-sch " action="/tim-kiem">
240
- <input type="text" name="q">
241
- <button class="ico search" type="submit"></button>
242
- </form>
243
- <div class="mmnu-lst">
244
- <div class="menu-row">
245
- <ul class="bar">
246
- <li class="exp">
247
- <a class="major" title="Tin mới nhất" href="/tin-moi"><span class="ico blinkr"></span> Mới nhất</a>
248
- </li>
249
- <li>
250
- <ul class="lst">
251
- <li><a class="fst" title="Nh���n định Bóng đá" href="/nhan-dinh-bong-da-tags">Nhận định</a></li>
252
- </ul>
253
- </li>
254
- <li>
255
- <ul class="lst">
256
- <li><a class="fst" title="Lịch thi đấu bóng đá" href="/lich-thi-dau-bong-da">Lịch thi đấu</a></li>
257
- </ul>
258
- </li>
259
- <li class="exp"></li>
260
- <li class="exp"><a class="major" title="Bóng đá Plus Premium" href="/premium">Bóng đá Premium <i class="ico crown"></i></a></li>
261
- <li>
262
- <ul class="lst">
263
- <li><a class="fst" title="Bóng đá Việt Nam" href="/bong-da-viet-nam">Bóng đá Việt Nam</a></li>
264
- <li><a title="Đội tuyển Việt Nam" href="/doi-tuyen-quoc-gia-viet-nam">Đội tuyển Việt Nam</a></li>
265
- <li><a title="V.League" href="/v-league">V.League</a></li>
266
- <li><a title="Hạng Nhất" href="/hang-nhat-viet-nam">Hạng Nhất</a></li>
267
- <li><a title="Cúp Quốc gia" href="/cup-quoc-gia-viet-nam">Cúp Quốc gia</a></li>
268
- <li><a title="Bóng đá nữ" href="/bong-da-nu-viet-nam">Bóng đá nữ</a></li>
269
- <li><a title="Futsal" href="/futsal">Futsal</a></li>
270
- <li><a title="Phong trào" href="/bong-da-phong-trao">Bóng đá Phong trào</a></li>
271
- <li><a title="Chuyển nhượng V.League" href="/tin-chuyen-nhuong-v-league">Chuyển nhượng</a></li>
272
- </ul>
273
- </li>
274
- <li>
275
- <ul class="lst">
276
- <li><a class="fst" title="Bóng đá Anh" href="/bong-da-anh">Bóng đá Anh</a></li>
277
- <li><a title="Đội tuyển Anh" href="/doi-tuyen-anh">Đội tuyển Anh</a></li>
278
- <li><a title="Ngoại hạng Anh" href="/ngoai-hang-anh">Ngoại hạng Anh</a></li>
279
- <li><a title="FA Cup" href="/fa-cup">FA Cup</a></li>
280
- <li><a title="League Cup" href="/cup-lien-doan-anh">League Cup</a></li>
281
- </ul>
282
- <div class="clx"></div>
283
- <ul class="lst">
284
- <li><a class="fst" title="Tây Ban nha" href="/bong-da-tay-ban-nha">Tây Ban nha</a></li>
285
- <li><a title="Đội tuyển TBN" href="/doi-tuyen-tay-ban-nha">Đội tuyển TBN</a></li>
286
- <li><a title="La Liga" href="/la-liga">La Liga</a></li>
287
- <li><a title="Cúp Nhà Vua" href="/cup-nha-vua-tay-ban-nha">Cúp Nhà Vua</a></li>
288
- </ul>
289
- </li>
290
- <li class="exp"></li>
291
- <li>
292
- <ul class="lst">
293
- <li><a class="fst" title="Bóng đá Pháp" href="/bong-da-phap">Bóng đá Pháp</a></li>
294
- <li><a title="Đội tuyển Pháp" href="/doi-tuyen-phap">Đội tuyển Pháp</a></li>
295
- <li><a title="Ligue 1" href="/ligue-1">Ligue 1</a></li>
296
- <li><a title="Cúp QG Pháp" href="/cup-quoc-gia-phap">Cúp QG Pháp</a></li>
297
- <li><a title="Cúp Liên đoàn Pháp" href="/cup-lien-doan-phap-tags">Cúp Liên đoàn Pháp</a></li>
298
- </ul>
299
- </li>
300
- <li>
301
- <ul class="lst">
302
- <li><a class="fst" href="#">Bóng đá Khu vực</a></li>
303
- <li><a title="Asian Cup" href="/asian-cup">Asian Cup</a></li>
304
- <li><a title="Asian Games" href="/asian-games">Asian Games</a></li>
305
- <li><a title="AFF Cup" href="/aff-cup">AFF Cup</a></li>
306
- <li><a title="SEA Games" href="/sea-games">SEA Games</a></li>
307
- </ul>
308
- </li>
309
- <li class="exp"></li>
310
- <li>
311
- <ul class="lst">
312
- <li><a class="fst" title="Bóng đá Italia" href="/bong-da-y">Bóng đá Italia</a></li>
313
- <li><a title="Đội tuyển Italia" href="/doi-tuyen-y">Đội tuyển Italia</a></li>
314
- <li><a title="Serie A" href="/serie-a">Serie A</a></li>
315
- <li><a title="Coppa Italia" href="/coppa-italia">Coppa Italia</a></li>
316
- </ul>
317
- </li>
318
- <li>
319
- <ul class="lst">
320
- <li><a class="fst" title="Bóng đá Đức" href="/bong-da-duc">Bóng đá Đức</a></li>
321
- <li><a title="Đội tuyển Đức" href="/doi-tuyen-duc">Đội tuyển Đức</a></li>
322
- <li><a title="Bundesliga" href="/bundesliga">Bundesliga</a></li>
323
- <li><a title="Cúp QG Đức" href="/cup-quoc-gia-duc">Cúp QG Đức</a></li>
324
- </ul>
325
- </li>
326
- <li class="exp"></li>
327
- <li>
328
- <ul class="lst">
329
- <li><a class="fst" href="#">Nội dung Đặc biệt</a></li>
330
- <li><a title="Big Story" href="/emagazine">Big Story</a></li>
331
- <li><a title="X-File" href="/x-file">X-File</a></li>
332
- <li><a title="Infographic" href="/infographic">Infographic</a></li>
333
- <li><a title="Interactive" href="/interactive">Interactive</a></li>
334
- </ul>
335
- </li>
336
- <li>
337
- <ul class="lst">
338
- <li><a class="fst" href="#">Giải đấu CLB</a></li>
339
- <li><a title="Champions Leage" href="/champions-league-cup-c1">Champions League</a></li>
340
- <li><a title="Europa League" href="/europa-league">Europa League</a></li>
341
- <li><a title="Club World Cup" href="/fifa-club-world-cup-tags">Club World Cup</a></li>
342
- <li><a title="Europa Conference League" href="/europa-conference-league">Europa Conf League</a></li>
343
- </ul>
344
- </li>
345
- <li class="exp"></li>
346
- <li>
347
- <ul class="lst">
348
- <li><a class="fst" href="#">Chuyên đề</a></li>
349
- <li><a title="Tin chuyển nhượng" href="/tin-chuyen-nhuong">Chuyển nhượng</a></li>
350
- <li><a title="Hậu trường bóng đá" href="/hau-truong-bong-da">Hậu trường</a></li>
351
- <li><a title="Điểm tin" href="/diem-tin">Điểm tin</a></li>
352
- <li><a title="Showbiz Ngôi sao" href="/ngoi-sao-showbiz">Showbiz Ngôi sao</a></li>
353
- <li><a title="Góc check var" href="/goc-check-var">Góc check var</a></li>
354
- <li><a title="Công nghệ" href="/cong-nghe">Công nghệ</a></li>
355
- <li><a title="Thông tin Thị trường" href="/thi-truong">Thị trường</a></li>
356
- <li><a title="Bóng đá & Cuộc sống" href="/bong-da-cuoc-song">Bóng đá & Cuộc sống</a></li>
357
- </ul>
358
- </li>
359
- <li>
360
- <ul class="lst">
361
- <li><a class="fst" href="#">Đội tuyển</a></li>
362
- <li><a title="World Cup nữ 2028" href="/world-cup-nu-2023-tags">World Cup nữ 2028</a></li>
363
- <li><a title="World Cup" href="/world-cup">World Cup 2026</a></li>
364
- <li><a title="European Championship 2028" href="/euro-cup-chau-au">EURO 2028</a></li>
365
- <li><a title="Nations League" href="/nations-league">Nations League</a></li>
366
- <li><a title="Copa America" href="/copa-america">Copa America</a></li>
367
- <li><a title="CAN" href="/can">CAN</a></li>
368
- <li><a title="Olympic 2028" href="/olympic">Olympic 2028</a></li>
369
- </ul>
370
- </li>
371
- <li class="exp"></li>
372
- <li>
373
- <ul class="lst">
374
- <li><a class="fst" title="Multimedia" href="/video">Multimedia</a></li>
375
- </ul>
376
- </li>
377
- <li>
378
- <ul class="lst">
379
- <li>&nbsp;</li>
380
- </ul>
381
- </li>
382
- <li class="exp"></li>
383
- <li>
384
- <ul class="lst">
385
- <li><a class="fst" href="/lich-thi-dau-bong-da">Lịch thi đấu</a></li>
386
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-viet-nam" title="Lịch thi đấu V.League">V.League</a></li>
387
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-anh" title="Lịch thi đấu Ngoại hạng Anh">Ngoại hạng Anh</a></li>
388
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-phap" title="Lịch thi đấu Ligue 1 Pháp">Ligue 1</a></li>
389
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-tay-ban-nha" title="Lịch thi đấu La Liga Tây Ban Nha">La Liga</a></li>
390
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-duc" title="Lịch thi đấu Bundesliga Đức">Bundesliga</a></li>
391
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-y" title="Lịch thi đấu Serie A Italia">Serie A</a></li>
392
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?champions-league-cup-c1" title="Lịch thi đấu Champions League">Champions League</a></li>
393
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?europa-league" title="Lịch thi đấu Europa League">Europa League</a></li>
394
- </ul>
395
- </li>
396
- <li>
397
- <ul class="lst">
398
- <li><a class="fst" href="/bang-xep-hang-bong-da">Bảng xếp hạng</a></li>
399
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-viet-nam" title="Bảng xếp hạng V.League">V.League</a></li>
400
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-anh" title="Bảng xếp hạng Ngoại hạng Anh">Ngoại hạng Anh</a></li>
401
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-phap" title="Bảng xếp hạng Ligue 1 Pháp">Ligue 1</a></li>
402
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-tay-ban-nha" title="Bảng xếp hạng La Liga Tây Ban Nha">La Liga</a></li>
403
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-duc" title="Bảng xếp hạng Bundesliga Đức">Bundesliga</a></li>
404
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-y" title="Bảng xếp hạng Serie A Italia">Serie A</a></li>
405
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?champions-league-cup-c1" title="Bảng xếp hạng Champions League">Champions League</a></li>
406
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?europa-league" title="Bảng xếp hạng Europa League">Europa League</a></li>
407
- </ul>
408
- </li>
409
- <li class="exp"></li>
410
- <li><a class="caption" href="https://phui.bongdaplus.vn" target="_blank">Bóng đá phủi</a></li>
411
- <li><a class="caption" href="/tennis">Tennis</a></li>
412
- <li><a class="caption" href="/esports">Esports</a></li>
413
- <li><a class="caption" href="/the-thao">Thể thao +</a></li>
414
- </ul>
415
- </div>
416
- </div>
417
- <div class="mmnu-end tx-cen">
418
- <p>Theo dõi thông tin</p>
419
- <ul class="bar social-lnk">
420
- <li class="hide-m">Theo dõi thông tin</li>
421
- <li><a class="ico vff-lnk" target="_blank" href="https://vff.org.vn/" style="background-size:28px;opacity:1;" rel="nofollow"></a></li>
422
- <li><a class="ico fb-lnk" target="_blank" href="https://www.facebook.com/BaoBongDa/" style="background-size:28px;opacity:1;"></a></li>
423
- <li><a class="ico yt-lnk" target="_blank" href="https://www.youtube.com/channel/UCF0VFBZu-5SRJXMV_FMysEA" style="background-size:28px;opacity:1;"></a></li>
424
- </ul>
425
- </div>
426
- </div>
427
- <div class="mega-menu">
428
- <div class="cont-wrap">
429
- <div class="menu-row">
430
- <ul class="bar">
431
- <li><a class="caption" title="Tin mới nhất" href="/tin-moi">Mới nhất</a></li>
432
- <li>
433
- <ul class="lst">
434
- <li><a class="fst" title="Bóng đá Việt Nam" href="/bong-da-viet-nam">Bóng đá Việt Nam</a></li>
435
- <li><a title="Đội tuyển Việt Nam" href="/doi-tuyen-quoc-gia-viet-nam">Đội tuyển Việt Nam</a></li>
436
- <li><a title="V.League" href="/v-league">V.League</a></li>
437
- <li><a title="Hạng Nhất" href="/hang-nhat-viet-nam">Hạng Nhất</a></li>
438
- <li><a title="Cúp Quốc gia" href="/cup-quoc-gia-viet-nam">Cúp Quốc gia</a></li>
439
- <li><a title="Cúp châu Á" href="/asian-cup">Cúp châu Á</a></li>
440
- <li><a title="Bóng đá nữ" href="/bong-da-nu-viet-nam">Bóng đá nữ</a></li>
441
- <li><a title="Futsal" href="/futsal">Futsal</a></li>
442
- <li><a title="Phong trào" href="/bong-da-phong-trao">Phong trào</a></li>
443
- <li><a title="Chuyển nhượng V.League" href="/tin-chuyen-nhuong-v-league">Chuyển nhượng V.League</a></li>
444
- <li><a title="Tin khác" href="/tin-khac">Tin khác</a></li>
445
- </ul>
446
- </li>
447
-
448
- <li>
449
- <ul class="lst">
450
- <li><a class="fst" title="Bóng đá Anh" href="/bong-da-anh">Bóng đá Anh</a></li>
451
- <li><a title="Đội tuyển Anh" href="/doi-tuyen-anh">Đội tuyển Anh</a></li>
452
- <li><a title="Ngoại hạng Anh" href="/ngoai-hang-anh">Ngoại hạng Anh</a></li>
453
- <li><a title="FA Cup" href="/fa-cup">FA Cup</a></li>
454
- <li><a title="League Cup" href="/cup-lien-doan-anh">League Cup</a></li>
455
- </ul>
456
- <div class="clx"></div>
457
- <ul class="lst">
458
- <li><a class="fst" href="#">Giải đấu CLB</a></li>
459
- <li><a title="Champions Leage" href="/champions-league-cup-c1">Champions League</a></li>
460
- <li><a title="Europa League" href="/europa-league">Europa League</a></li>
461
- <li><a title="Club World Cup" href="/fifa-club-world-cup-tags">Club World Cup</a></li>
462
- <li><a title="Europa Conference League" href="/europa-conference-league">Europa Conference League</a></li>
463
- </ul>
464
- </li>
465
-
466
- <li>
467
- <ul class="lst">
468
- <li><a class="fst" title="Bóng đá Italia" href="/bong-da-y">Bóng đá Italia</a></li>
469
- <li><a title="Đội tuyển Italia" href="/doi-tuyen-y">Đội tuyển Italia</a></li>
470
- <li><a title="Serie A" href="/serie-a">Serie A</a></li>
471
- <li><a title="Coppa Italia" href="/coppa-italia">Coppa Italia</a></li>
472
- </ul>
473
- <div class="clx"></div>
474
- <ul class="lst">
475
- <li><a class="fst" href="#">Đội tuyển</a></li>
476
- <li><a title="World Cup nữ 2028" href="/world-cup-nu-2023-tags">World Cup nữ 2028</a></li>
477
- <li><a title="World Cup" href="/world-cup">World Cup 2026</a></li>
478
- <li><a title="European Championship 2028" href="/euro-cup-chau-au">EURO 2028</a></li>
479
- <li><a title="Nations League" href="/nations-league">Nations League</a></li>
480
- <li><a title="Copa America" href="/copa-america">Copa America</a></li>
481
- <li><a title="CAN" href="/can">CAN</a></li>
482
- <li><a title="Olympic" href="/olympic">Olympic 2028</a></li>
483
- </ul>
484
- </li>
485
-
486
- <li>
487
- <ul class="lst">
488
- <li><a class="fst" title="Bóng đá Pháp" href="/bong-da-phap">Bóng đá Pháp</a></li>
489
- <li><a title="Đội tuyển Pháp" href="/doi-tuyen-phap">Đội tuyển Pháp</a></li>
490
- <li><a title="Ligue 1" href="/ligue-1">Ligue 1</a></li>
491
- <li><a title="Cúp QG Pháp" href="/cup-quoc-gia-phap">Cúp QG Pháp</a></li>
492
- <li><a title="Cúp Liên đoàn Pháp" href="/cup-lien-doan-phap-tags">Cúp Liên đoàn Pháp</a></li>
493
- </ul>
494
- <div class="clx"></div>
495
- <ul class="lst">
496
- <li><a class="fst" href="#">Khu vực</a></li>
497
- <li><a title="Asian Cup" href="/asian-cup">Asian Cup</a></li>
498
- <li><a title="Asian Games" href="/asian-games">Asian Games</a></li>
499
- <li><a title="AFF Cup" href="/aff-cup">AFF Cup</a></li>
500
- <li><a title="SEA Games" href="/sea-games">SEA Games</a></li>
501
- </ul>
502
- </li>
503
-
504
- <li>
505
- <ul class="lst">
506
- <li><a class="fst" title="Tây Ban nha" href="/bong-da-tay-ban-nha">Tây Ban nha</a></li>
507
- <li><a title="Đội tuyển TBN" href="/doi-tuyen-tay-ban-nha">Đội tuyển TBN</a></li>
508
- <li><a title="La Liga" href="/la-liga">La Liga</a></li>
509
- <li><a title="Cúp Nhà Vua" href="/cup-nha-vua-tay-ban-nha">Cúp Nhà Vua</a></li>
510
- </ul>
511
- <div class="clx"></div>
512
- <ul class="lst">
513
- <li><a class="fst" href="#">Chuyên đề</a></li>
514
- <li><a title="Tin chuyển nhượng" href="/tin-chuyen-nhuong">Chuyển nhượng</a></li>
515
- <li><a title="Hậu trường bóng đá" href="/hau-truong-bong-da">Hậu trường</a></li>
516
- <li><a title="Điểm tin" href="/diem-tin">Điểm tin</a></li>
517
- <li><a title="Showbiz Ngôi sao" href="/ngoi-sao-showbiz">Showbiz Ngôi sao</a></li>
518
- <li><a title="Góc check var" href="/goc-check-var">Góc check var</a></li>
519
- <li><a title="Công nghệ" href="/cong-nghe">Công nghệ</a></li>
520
- <li><a title="Thông tin Thị trường" href="/thi-truong">Thị trường</a></li>
521
- </ul>
522
- </li>
523
-
524
- <li>
525
- <ul class="lst">
526
- <li><a class="fst" title="Bóng đá Đức" href="/bong-da-duc">Bóng đá Đức</a></li>
527
- <li><a title="Đội tuyển Đức" href="/doi-tuyen-duc">Đội tuyển Đức</a></li>
528
- <li><a title="Bundesliga" href="/bundesliga">Bundesliga</a></li>
529
- <li><a title="Cúp QG Đức" href="/cup-quoc-gia-duc">Cúp QG Đức</a></li>
530
- </ul>
531
- <div class="clx"></div>
532
- <ul class="lst">
533
- <li><a class="fst" href="#">Đặc biệt</a></li>
534
- <li><a title="Big Story" href="/emagazine">Big Story</a></li>
535
- <li><a title="X-File" href="/x-file">X-File</a></li>
536
- <li><a title="Infographic" href="/infographic">Infographic</a></li>
537
- <li><a title="Interactive" href="/interactive">Interactive</a></li>
538
- </ul>
539
- </li>
540
- </ul>
541
- </div>
542
-
543
- <div class="menu-row">
544
- <ul class="bar">
545
- <li>&nbsp;</li>
546
- <li><a title="Tin chuyển nhượng" href="/tin-chuyen-nhuong">Tin chuyển nhượng</a></li>
547
- <li><a title="Bóng đá & Cuộc sống" href="/bong-da-cuoc-song">Bóng đá & Cuộc sống</a></li>
548
- <li><a title="Thông tin Thị trường" href="/thi-truong">Thông tin Thị trường</a></li>
549
- <li><a title="Tin tức Công nghệ" href="/cong-nghe">Công nghệ</a></li>
550
- <li><a title="Tin tức Xe auto bike" href="/xe-co">Xe auto</a></li>
551
- </ul>
552
- </div>
553
-
554
- <div class="menu-row">
555
- <ul class="bar">
556
- <li>&nbsp;</li>
557
- <li><a class="caption" title="Nhận định Bóng đá" href="/nhan-dinh-bong-da-tags">Nhận định</a></li>
558
- <li><a class="caption" title="Tư vấn Bóng đá" href="/soi-keo-bong-da">Tư vấn</a></li>
559
- <li><a class="caption" title="Multimedia" href="/video">Multimedia</a></li>
560
- <li><a class="caption" title="Youtube" target="_blank" href="https://www.youtube.com/channel/UCF0VFBZu-5SRJXMV_FMysEA">Youtube</a></li>
561
- <li><a class="caption" title="Facebook fanpage" target="_blank" href="https://www.facebook.com/BaoBongDa">FB Fanpage</a></li>
562
- <li>
563
- <a class="caption" title="Premium" href="/premium">Premium <i class="ico crown"></i></a>
564
- </li>
565
- </ul>
566
- </div>
567
-
568
- <div class="menu-row">
569
- <ul class="bar">
570
- <li><a class="caption" href="/lich-thi-dau-bong-da">Lịch thi đấu</a></li>
571
- <li>
572
- <ul class="lst">
573
- <li><a class="fst" href="/lich-thi-dau-bong-da">Lịch thi đấu hôm nay</a></li>
574
- <li><a href="/lich-thi-dau-bong-da?bong-da-viet-nam" title="Lịch thi đấu V.League">V.League</a></li>
575
- <li><a href="/lich-thi-dau-bong-da?bong-da-anh" title="Lịch thi đấu Ngoại hạng Anh">Ngoại hạng Anh</a></li>
576
- <li><a href="/lich-thi-dau-bong-da?bong-da-phap" title="Lịch thi đấu Ligue 1 Pháp">Ligue 1</a></li>
577
- <li><a href="/lich-thi-dau-bong-da?bong-da-tay-ban-nha" title="Lịch thi đấu La Liga Tây Ban Nha">La Liga</a></li>
578
- <li><a href="/lich-thi-dau-bong-da?bong-da-duc" title="Lịch thi đấu Bundesliga Đức">Bundesliga</a></li>
579
- <li><a href="/lich-thi-dau-bong-da?bong-da-y" title="Lịch thi đấu Serie A Italia">Serie A</a></li>
580
- <li><a href="/lich-thi-dau-bong-da?champions-league-cup-c1" title="Lịch thi đấu Champions League">Champions League</a></li>
581
- <li><a href="/lich-thi-dau-bong-da?europa-league" title="Lịch thi đấu Europa League">Europa League</a></li>
582
- </ul>
583
- </li>
584
-
585
- <li>
586
- <ul class="lst">
587
- <li><a class="fst" href="/ket-qua-bong-da">Kết quả mới nhất</a></li>
588
- <li><a href="/lich-thi-dau-bong-da?bong-da-viet-nam" title="Kết quả V.League">V.League</a></li>
589
- <li><a href="/lich-thi-dau-bong-da?bong-da-anh" title="Kết quả Ngoại hạng Anh">Ngoại hạng Anh</a></li>
590
- <li><a href="/lich-thi-dau-bong-da?bong-da-phap" title="Kết quả Ligue 1 Pháp">Ligue 1</a></li>
591
- <li><a href="/lich-thi-dau-bong-da?bong-da-tay-ban-nha" title="Kết quả La Liga Tây Ban Nha">La Liga</a></li>
592
- <li><a href="/lich-thi-dau-bong-da?bong-da-duc" title="Kết quả Bundesliga Đức">Bundesliga</a></li>
593
- <li><a href="/lich-thi-dau-bong-da?bong-da-y" title="Kết quả Serie A Italia">Serie A</a></li>
594
- <li><a href="/lich-thi-dau-bong-da?champions-league-cup-c1" title="Kết quả Champions League">Champions League</a></li>
595
- <li><a href="/lich-thi-dau-bong-da?europa-league" title="Kết quả Europa League">Europa League</a></li>
596
- </ul>
597
- </li>
598
-
599
- <li>
600
- <ul class="lst">
601
- <li><a class="fst" href="#">Bảng xếp hạng</a></li>
602
- <li><a href="/bang-xep-hang-bong-da?bong-da-viet-nam" title="Bảng xếp hạng V.League">V.League</a></li>
603
- <li><a href="/bang-xep-hang-bong-da?bong-da-anh" title="Bảng xếp hạng Ngoại hạng Anh">Ngoại hạng Anh</a></li>
604
- <li><a href="/bang-xep-hang-bong-da?bong-da-phap" title="Bảng xếp hạng Ligue 1 Pháp">Ligue 1</a></li>
605
- <li><a href="/bang-xep-hang-bong-da?bong-da-tay-ban-nha" title="Bảng xếp hạng La Liga Tây Ban Nha">La Liga</a></li>
606
- <li><a href="/bang-xep-hang-bong-da?bong-da-duc" title="Bảng xếp hạng Bundesliga Đức">Bundesliga</a></li>
607
- <li><a href="/bang-xep-hang-bong-da?bong-da-y" title="Bảng xếp hạng Serie A Italia">Serie A</a></li>
608
- <li><a href="/bang-xep-hang-bong-da?champions-league-cup-c1" title="Bảng xếp hạng Champions League">Champions League</a></li>
609
- <li><a href="/bang-xep-hang-bong-da?europa-league" title="Bảng xếp hạng Europa League">Europa League</a></li>
610
- </ul>
611
- </li>
612
-
613
- <li>
614
- <ul class="lst">
615
- <li><a class="caption" href="https://phui.bongdaplus.vn" target="_blank">Bóng đá phủi</a></li>
616
- <li><a class="caption" href="/tennis">Tennis</a></li>
617
- </ul>
618
- </li>
619
- <li>
620
- <ul class="lst">
621
- <li><a class="caption" href="/esports">Esports</a></li>
622
- <li><a class="caption" href="/the-thao">Thể thao +</a></li>
623
- </ul>
624
- </li>
625
-
626
- <li>
627
- <ul class="lst">
628
- <li>
629
- <div class="copyr">
630
- <div class="splt hide-w"></div>
631
- <p><b class="allcap">Tạp chí Bóng Đá</b></p>
632
- <p>Giấy phép số 48/GP-BTTTT cấp ngày 05/02/2020</p>
633
- <p>&nbsp;</p>
634
- <p>
635
- Tổng biên tập:<br /> <b>Vũ Khắc Sơn</b><br />
636
- Phó tổng biên tập:<br /> <b>Nguyễn Hà Thanh</b>
637
- </p>
638
- <p>&nbsp;</p>
639
- <p>
640
- <a class="contact-showr" href="#">Thông tin tòa soạn</a><br />
641
- <a class="contact-showr" href="#">Liên hệ quảng cáo</a>
642
- </p>
643
- <p>Liên đoàn BĐVN <a href="https://vff.org.vn/" target="_blank" rel="nofollow"><img alt="VFF" src="/img/logo_Vff.png" style="width:28px;" /></a></p>
644
- </div>
645
- </li>
646
- </ul>
647
- </li>
648
- </ul>
649
- </div>
650
- </div>
651
- </div>
652
- <div class="menu-tags hide-w">
653
- <div class="today"></div>
654
- <div class="mtags">
655
- <a title="#HighlightBóngđá" href="/video/highlight">#HighlightBóngđá</a>
656
- <a title="#PremierLeague" href="/ngoai-hang-anh">#PremierLeague</a>
657
- <a title="#V.League" href="/v-league">#V.League</a>
658
- </div>
659
- </div>
660
-
661
- <div id="fxtSlideCover" class="fixt-strm" style="margin-bottom:5px;">
662
- <div class="cont-wrap">
663
- <a class="brand" href="https://admin.giaminhmedia.vn/www/delivery/cl.php?bannerid=1411&zoneid=328&sig=8300d1283663cec85d90499507ffd94240954967132167c0f302d7388b8f9bb5&dest=http%3A%2F%2Fadmin.giaminhmedia.vn%2Fwww%2Funiscore.php" target="_blank" rel="nofollow" style="margin-left:5px;">
664
- <img alt="" src="https://bongdaplus.vn/img/logouniscore-tiny.png">
665
- </a>
666
- <ul id="fxtSlide" class="nav"></ul>
667
- </div>
668
- </div>
669
-
670
- <div class="clz"></div>
671
- <div id="ADTOP" class="banner adweb admob" banner-adpos="top"></div>
672
-
673
- <section class="bread-crums">
674
- <div class="clz"></div>
675
- <div class="cont-wrap">
676
- <div class="chain-navs">
677
- <a href="/video">Multimedia</a>&raquo;
678
- </div>
679
- </div>
680
- </section>
681
-
682
- <section class="media-box">
683
- <div class="clz"></div>
684
- <div class="cont-wrap video-box">
685
- <div class="row">
686
- <div class="col m12 w8 clip-box">
687
- <div class="play-box rbox">
688
- <iframe id="ytbFrBpl" class="play-frame" src="https://www.youtube.com/embed/Qokb_8w6p0s?si=QNKAZIYxNrtn2hSN" cid="UCmn1v6lrIDZghUfbxXaorUw" vnum="0" class="latestVideoEmbed" title="Bongdaplus trên Youtube" frameborder="0" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen=""></iframe>
689
- </div>
690
- </div>
691
-
692
- <div class="col m12 w4 clip-cont">
693
- <div class="ovrscrl">
694
- <div class="clip-info">
695
- <h1 class="title">Hull City vs MU: Quỷ Đỏ vào hang bắt hổ</h1>
696
- <p class="desc">
697
- Mùa giải Premier League 2026/27 của MU sẽ mở màn bằng chuyến làm khách đầy thử thách trước tân binh Hull City tại sân MKM. Cuộc đối đầu Hull City vs MU là màn chạm trán đáng chú ý khi “Đàn Hổ” đang tràn đầy khí thế, trong khi Quỷ đỏ không được phép sảy chân nếu muốn sớm tạo đà cho cuộc đua vô địch.
698
- </p>
699
-
700
- <div>08:07 - 22/08/2026</div>
701
- <div class="editor"></div>
702
- <div class="hash-tags">
703
- <b>Tags:</b>
704
- <a href="/video/hull-city-vs-mu-tags">Hull City vs MU</a>
705
- <a href="/video/nhan-dinh-hull-city-vs-mu-tags">nh&#x1EAD;n &#x111;&#x1ECB;nh Hull City vs MU</a>
706
- <a href="/video/premier-league-tags">Premier League</a>
707
- <a href="/video/mu-tags">MU</a>
708
- </div>
709
- <style>
710
- .emobar .emo:not(:empty) {
711
- background-position: left 10px top 50%;
712
- padding: 0 13px 0 36px;
713
- }
714
- .emobar .emo {
715
- border-radius: 16px;
716
- background-color: #d7d3d3;
717
- background-size: auto 18px;
718
- cursor: pointer;
719
- margin: 0 3px;
720
- padding: 0 16px;
721
- line-height: 32px;
722
- height: 32px;
723
- }
724
- .emopicked {
725
- border: solid 1px #767676;
726
- }
727
- </style>
728
- <div class="emobar tx-cen">
729
- <div class="clx"></div>
730
- <a class="emo like" onclick="btnNewsEmotion_click(1)" id="btnEmo1"> <span id="numemo1"></span></a>
731
- <a class="emo heart" onclick="btnNewsEmotion_click(2)" id="btnEmo2"> <span id="numemo2"></span></a>
732
- <a class="emo star" onclick="btnNewsEmotion_click(4)" id="btnEmo4"> <span id="numemo4"></span></a>
733
- <a class="emo comment" id="ncmt" href="#commenting" data-action="comment" data-act-only="true"></a>
734
- </div>
735
- </div>
736
- </div>
737
- </div>
738
- </div>
739
- </div>
740
-
741
- <div class="clx"></div>
742
- <div class="cont-view">
743
-
744
-
745
-
746
-
747
- <style>
748
- .btnlikedislikecmt {
749
- border-radius: 9px;
750
- border: solid 1px #767676;
751
- background-color: #cecece;
752
- }
753
-
754
- sup {
755
- padding: 3px;
756
- position: initial;
757
- vertical-align: initial;
758
- }
759
-
760
- .TopComment {
761
- border: solid 1px #fdd835;
762
- }
763
-
764
- .TopComment .info .member {
765
- color: red;
766
- }
767
-
768
- .TopComment .info .avatar {
769
- border: solid 1px red;
770
- }
771
-
772
- .comment .summ .replyat {
773
- /*color: #1b5e20;*/
774
- display: inline-block;
775
- margin: 0 8px 0 0;
776
- font-weight: bold;
777
- /* text-decoration: underline; */
778
- }
779
- </style>
780
- <div id="postComment" class="comm-zone">
781
- <a class="anchor" name="#comments"></a>
782
- <div class="clz"></div>
783
- <div class="xcap">Bình luận</div>
784
- <div class="clz"></div>
785
- <div class="comm-post ">
786
- <div id="formcomment" class="comm-form">
787
- <label class="txt-fld">
788
- <textarea id="txtcomment_0" name="txtcomment_0" maxlength="1000" placeholder="Hãy chia sẻ suy nghĩ của bạn..." onclick="showcommentloginform();"></textarea>
789
- </label>
790
- <div class="actions">
791
- <button id="btnComment" class="btn btn-send base" onclick="btnComment_click(0,0)">Gửi</button>
792
- </div>
793
- <center><div class="cmterr"></div></center>
794
- </div>
795
- </div>
796
- <div class="clz"></div>
797
- <div class="comm-frame">
798
- <div id="NewsComments"></div>
799
- <div class="clz"></div>
800
- <div class="cat-news">
801
- <div id="btnloadviewmore" class="view-more hide"><a onclick="loadviewmorecomment();">Xem thêm</a></div>
802
- </div>
803
- <div class="clz"></div>
804
- </div>
805
- </div>
806
- </div>
807
- <div class="clz"></div>
808
- </section>
809
-
810
-
811
-
812
-
813
-
814
- <div class="clx"></div>
815
- <center><div id="ADHOT" class="banner adweb admob" banner-adpos="hot"></div></center>
816
- <div class="clx"></div>
817
-
818
-
819
- <section class="cat-news">
820
- <div class="cont-wrap">
821
- <div class="capt-cover">
822
- <div class="caption">Video mới nhất</div>
823
- <button class="more-lnk"></button>
824
- </div>
825
- <div class="row flex">
826
- <div class="col m12 w3">
827
- <div class="news">
828
- <a class="thumb" href="/video/du-doan-ty-so-hull-mu-than-rua-ali-33-chon-doi-nao-982702608.html"><img alt="Dự đoán tỷ số Hull vs MU: Thần Rùa Ali 33 chọn đội nào?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/18/80/Anh-chu-480-x-270.jpg" /></a>
829
- <a class="title" href="/video/du-doan-ty-so-hull-mu-than-rua-ali-33-chon-doi-nao-982702608.html">Dự đoán tỷ số Hull vs MU: Thần Rùa Ali 33 chọn đội nào?</a>
830
- <span class="info">
831
- 43 ph&#xFA;t tr&#x1B0;&#x1EDB;c
832
- </span>
833
- <span class="info">
834
- </span>
835
- </div>
836
- </div>
837
- <div class="col m12 w3">
838
- <div class="news">
839
- <a class="thumb" href="/video/hull-city-vs-mu-quy-do-vao-hang-bat-ho-982672608.html"><img alt="Hull City vs MU: Quỷ Đỏ vào hang bắt hổ" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/18/13/ThumbHullvsMU-up.jpg" /></a>
840
- <a class="title" href="/video/hull-city-vs-mu-quy-do-vao-hang-bat-ho-982672608.html">Hull City vs MU: Quỷ Đỏ vào hang bắt hổ</a>
841
- <span class="info">
842
- 44 ph&#xFA;t tr&#x1B0;&#x1EDB;c
843
- </span>
844
- <span class="info">
845
- </span>
846
- </div>
847
- </div>
848
- <div class="col m12 w3">
849
- <div class="news">
850
- <a class="thumb" href="/video/du-doan-ty-so-brentford-vs-tottenham-premier-league-2026-27-than-rua-ali-33-chon-doi-nao-982862608.html"><img alt="Dự đoán tỷ số Brentford vs Tottenham: Thần Rùa Ali 33 chọn đội nào?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/19/80/Brentford-vs-Tottenham-480-x-270-moi.jpg" /></a>
851
- <a class="title" href="/video/du-doan-ty-so-brentford-vs-tottenham-premier-league-2026-27-than-rua-ali-33-chon-doi-nao-982862608.html">Dự đoán tỷ số Brentford vs Tottenham: Thần Rùa Ali 33 chọn đội nào?</a>
852
- <span class="info">
853
- 44 ph&#xFA;t tr&#x1B0;&#x1EDB;c
854
- </span>
855
- <span class="info">
856
- </span>
857
- </div>
858
- </div>
859
- <div class="col m12 w3">
860
- <div class="news">
861
- <a class="thumb" href="/video/brentford-vs-tottenham-de-zerbi-vuot-ai-gtech-982822608.html"><img alt="Brentford vs Tottenham: De Zerbi vượt ải Gtech" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/19/13/Brenfort-vs-Tottenham.jpg" /></a>
862
- <a class="title" href="/video/brentford-vs-tottenham-de-zerbi-vuot-ai-gtech-982822608.html">Brentford vs Tottenham: De Zerbi vượt ải Gtech</a>
863
- <span class="info">
864
- 45 ph&#xFA;t tr&#x1B0;&#x1EDB;c
865
- </span>
866
- <span class="info">
867
- </span>
868
- </div>
869
- </div>
870
- <div class="col m12 w3">
871
- <div class="news">
872
- <a class="thumb" href="/video/du-doan-ty-so-arsenal-vs-coventry-than-rua-ali-33-chon-doi-nao-982612608.html"><img alt="Dự đoán tỷ số Arsenal vs Coventry: Thần Rùa Ali 33 chọn đội nào?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/17/0/thumblai.jpg" /></a>
873
- <a class="title" href="/video/du-doan-ty-so-arsenal-vs-coventry-than-rua-ali-33-chon-doi-nao-982612608.html">Dự đoán tỷ số Arsenal vs Coventry: Thần Rùa Ali 33 chọn đội nào?</a>
874
- <span class="info">
875
- 09 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
876
- </span>
877
- <span class="info">
878
- </span>
879
- </div>
880
- </div>
881
- <div class="col m12 w3">
882
- <div class="news">
883
- <a class="thumb" href="/video/arsenal-vs-coventry-nha-vua-mo-man-bang-mot-chien-thang-982662608.html"><img alt="Arsenal vs Coventry: Nhà vua mở màn bằng một chiến thắng?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/18/13/ThumbArsenalvsCoventry-up.jpg" /></a>
884
- <a class="title" href="/video/arsenal-vs-coventry-nha-vua-mo-man-bang-mot-chien-thang-982662608.html">Arsenal vs Coventry: Nhà vua mở màn bằng một chiến thắng?</a>
885
- <span class="info">
886
- 09 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
887
- </span>
888
- <span class="info">
889
- </span>
890
- </div>
891
- </div>
892
- <div class="col m12 w3">
893
- <div class="news">
894
- <a class="thumb" href="/video/truc-tuyen-truoc-tran-arsenal-vs-coventry-hull-vs-mu-983222608.html"><img alt="Trực tuyến trước trận Arsenal vs Coventry, Hull vs MU" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/truc-tuyen-truoc-tran-Arsenal-vs-Coventry.jpg" /></a>
895
- <a class="title" href="/video/truc-tuyen-truoc-tran-arsenal-vs-coventry-hull-vs-mu-983222608.html">Trực tuyến trước trận Arsenal vs Coventry, Hull vs MU</a>
896
- <span class="info">
897
- 11 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
898
- </span>
899
- <span class="info">
900
- </span>
901
- </div>
902
- </div>
903
- <div class="col m12 w3">
904
- <div class="news">
905
- <a class="thumb" href="/video/dt-thai-lan-tap-cach-san-rajamangala-40km-hlv-anthony-hudson-chot-luon-doi-hinh-dau-viet-nam-983202608.html"><img alt=" ĐT Thái Lan tập cách sân Rajamangala 40km, HLV Anthony Hudson chốt luôn đội hình đấu Việt Nam" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/80/HLV-Thai-Lan-chot-doi-hinh.jpg" /></a>
906
- <a class="title" href="/video/dt-thai-lan-tap-cach-san-rajamangala-40km-hlv-anthony-hudson-chot-luon-doi-hinh-dau-viet-nam-983202608.html"> ĐT Thái Lan tập cách sân Rajamangala 40km, HLV Anthony Hudson chốt luôn đội hình đấu Việt Nam</a>
907
- <span class="info">
908
- 13 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
909
- </span>
910
- <span class="info">
911
- </span>
912
- </div>
913
- </div>
914
- <div class="col m12 w3">
915
- <div class="news">
916
- <a class="thumb" href="/video/tro-lai-san-tung-vo-dich-aff-cup-2024-dt-viet-nam-quyet-lam-nen-lich-su-983212608.html"><img alt=" Trở lại sân từng vô địch AFF Cup 2024, ĐT Việt Nam quyết làm nên lịch sử" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/80/Viet-Nam-tap-2-1-8.jpg" /></a>
917
- <a class="title" href="/video/tro-lai-san-tung-vo-dich-aff-cup-2024-dt-viet-nam-quyet-lam-nen-lich-su-983212608.html"> Trở lại sân từng vô địch AFF Cup 2024, ĐT Việt Nam quyết làm nên lịch sử</a>
918
- <span class="info">
919
- 14 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
920
- </span>
921
- <span class="info">
922
- </span>
923
- </div>
924
- </div>
925
- <div class="col m12 w3">
926
- <div class="news">
927
- <a class="thumb" href="/video/vong-1-premier-league-2026-27-lieu-co-cu-soc-983112608.html"><img alt="Nhận định Premier League vòng 1: Liệu có cú sốc?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/thumbvong1-480.jpg" /></a>
928
- <a class="title" href="/video/vong-1-premier-league-2026-27-lieu-co-cu-soc-983112608.html">Nhận định Premier League vòng 1: Liệu có cú sốc?</a>
929
- <span class="info">
930
- 14 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
931
- </span>
932
- <span class="info">
933
- </span>
934
- </div>
935
- </div>
936
- <div class="col m12 w3">
937
- <div class="news">
938
- <a class="thumb" href="/video/ronaldinho-gay-soc-46-tuoi-van-tro-lai-san-co-khoac-ao-clb-serie-c-983192608.html"><img alt="Ronaldinho gây sốc: 46 tuổi vẫn trở lại sân cỏ, khoác áo CLB Serie C" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/26/Ronaldinho480.jpg" /></a>
939
- <a class="title" href="/video/ronaldinho-gay-soc-46-tuoi-van-tro-lai-san-co-khoac-ao-clb-serie-c-983192608.html">Ronaldinho gây sốc: 46 tuổi vẫn trở lại sân cỏ, khoác áo CLB Serie C</a>
940
- <span class="info">
941
- 14 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
942
- </span>
943
- <span class="info">
944
- </span>
945
- </div>
946
- </div>
947
- <div class="col m12 w3">
948
- <div class="news">
949
- <a class="thumb" href="/video/arsenal-no-bom-tan-51-trieu-bang-de-va-hang-thu-983122608.html"><img alt="Arsenal nổ bom tấn 51 triệu bảng để vá hàng thủ" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/26/Konsa480.jpg" /></a>
950
- <a class="title" href="/video/arsenal-no-bom-tan-51-trieu-bang-de-va-hang-thu-983122608.html">Arsenal nổ bom tấn 51 triệu bảng để vá hàng thủ</a>
951
- <span class="info">
952
- 14 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
953
- </span>
954
- <span class="info">
955
- </span>
956
- </div>
957
- </div>
958
- <div class="col m12 w3">
959
- <div class="news">
960
- <a class="thumb" href="/video/truc-tuyen-hop-bao-viet-nam-hlv-kim-sang-sik-noi-gi-khi-pho-chu-tich-thai-lan-mia-mai-dan-nhap-tich-983182608.html"><img alt="Trực tuyến họp báo Việt Nam: HLV Kim Sang Sik nói gì khi Phó Chủ tịch Thái Lan mỉa mai dàn nhập tịch" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/anhliveVN.jpg" /></a>
961
- <a class="title" href="/video/truc-tuyen-hop-bao-viet-nam-hlv-kim-sang-sik-noi-gi-khi-pho-chu-tich-thai-lan-mia-mai-dan-nhap-tich-983182608.html">Trực tuyến họp báo Việt Nam: HLV Kim Sang Sik nói gì khi Phó Chủ tịch Thái Lan mỉa mai dàn nhập tịch</a>
962
- <span class="info">
963
- 16 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
964
- </span>
965
- <span class="info">
966
- </span>
967
- </div>
968
- </div>
969
- <div class="col m12 w3">
970
- <div class="news">
971
- <a class="thumb" href="/video/truc-tuyen-thai-lan-hop-bao-hlv-thach-dau-dt-viet-nam-983162608.html"><img alt=" Trực tuyến Thái Lan họp báo: HLV Anthony Hudson thách đấu ĐT Việt Nam" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/anhlivesua-480.jpg" /></a>
972
- <a class="title" href="/video/truc-tuyen-thai-lan-hop-bao-hlv-thach-dau-dt-viet-nam-983162608.html"> Trực tuyến Thái Lan họp báo: HLV Anthony Hudson thách đấu ĐT Việt Nam</a>
973
- <span class="info">
974
- 18 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
975
- </span>
976
- <span class="info">
977
- </span>
978
- </div>
979
- </div>
980
- <div class="col m12 w3">
981
- <div class="news">
982
- <a class="thumb" href="/video/ban-tin-21-8-dt-viet-nam-chiu-thong-ke-cuc-xau-truoc-chung-ket-thai-lan-choi-chieu-nhu-indo-983152608.html"><img alt="Bản tin 21/8: ĐT Việt Nam chịu thống kê cực xấu trước chung kết, Thái Lan chơi chiêu như Indo" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/Thumb-tin-nong-21-8-480-up.jpg" /></a>
983
- <a class="title" href="/video/ban-tin-21-8-dt-viet-nam-chiu-thong-ke-cuc-xau-truoc-chung-ket-thai-lan-choi-chieu-nhu-indo-983152608.html">Bản tin 21/8: ĐT Việt Nam chịu thống kê cực xấu trước chung kết, Thái Lan chơi chiêu như Indo</a>
984
- <span class="info">
985
- 18 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
986
- </span>
987
- <span class="info">
988
- </span>
989
- </div>
990
- </div>
991
- <div class="col m12 w3">
992
- <div class="news">
993
- <a class="thumb" href="/video/thai-lan-khoa-cdv-viet-nam-chi-3-000-ve-cho-tran-chung-ket-tai-rajamangala-983142608.html"><img alt="Thái Lan 'khóa' CĐV Việt Nam: Chỉ 3.000 vé cho trận chung kết tại Rajamangala" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/26/cdv.jpg" /></a>
994
- <a class="title" href="/video/thai-lan-khoa-cdv-viet-nam-chi-3-000-ve-cho-tran-chung-ket-tai-rajamangala-983142608.html">Thái Lan 'khóa' CĐV Việt Nam: Chỉ 3.000 vé cho trận chung kết tại Rajamangala</a>
995
- <span class="info">
996
- 20 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
997
- </span>
998
- <span class="info">
999
- </span>
1000
- </div>
1001
- </div>
1002
- <div class="col m12 w3">
1003
- <div class="news">
1004
- <a class="thumb" href="/video/truoc-tran-thai-lan-vs-viet-nam-svd-rajamagala-vang-tanh-hon-chua-ba-danh-983132608.html"><img alt="Trước trận Thái Lan vs Việt Nam: SVĐ Rajamagala vắng tanh hơn chùa Bà Đanh" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/livethumbYT-480.jpg" /></a>
1005
- <a class="title" href="/video/truoc-tran-thai-lan-vs-viet-nam-svd-rajamagala-vang-tanh-hon-chua-ba-danh-983132608.html">Trước trận Thái Lan vs Việt Nam: SVĐ Rajamagala vắng tanh hơn chùa Bà Đanh</a>
1006
- <span class="info">
1007
- 22 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
1008
- </span>
1009
- <span class="info">
1010
- </span>
1011
- </div>
1012
- </div>
1013
- <div class="col m12 w3">
1014
- <div class="news">
1015
- <a class="thumb" href="/video/du-doan-ty-so-thai-lan-vs-viet-nam-than-rua-ali-33-chon-doi-nao-983092608.html"><img alt="Dự đoán tỷ số Thái Lan vs Việt Nam: Thần Rùa Ali 33 chọn đội nào?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/20/80/ThumbRuaThaivsVietnam-480.jpg" /></a>
1016
- <a class="title" href="/video/du-doan-ty-so-thai-lan-vs-viet-nam-than-rua-ali-33-chon-doi-nao-983092608.html">Dự đoán tỷ số Thái Lan vs Việt Nam: Thần Rùa Ali 33 chọn đội nào?</a>
1017
- <span class="info">
1018
- 19:18 ng&#xE0;y 20/08/2026
1019
- </span>
1020
- <span class="info">
1021
- </span>
1022
- </div>
1023
- </div>
1024
- <div class="col m12 w3">
1025
- <div class="news">
1026
- <a class="thumb" href="/video/truyen-thong-anh-dac-biet-chu-y-den-ngoi-no-cua-dt-viet-nam-983072608.html"><img alt="Truyền thông Anh đặc biệt chú ý đến 'ngòi nổ' của ĐT Việt Nam" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/20/26/xuan-son.jpg" /></a>
1027
- <a class="title" href="/video/truyen-thong-anh-dac-biet-chu-y-den-ngoi-no-cua-dt-viet-nam-983072608.html">Truyền thông Anh đặc biệt chú ý đến 'ngòi nổ' của ĐT Việt Nam</a>
1028
- <span class="info">
1029
- 19:18 ng&#xE0;y 20/08/2026
1030
- </span>
1031
- <span class="info">
1032
- </span>
1033
- </div>
1034
- </div>
1035
- <div class="col m12 w3">
1036
- <div class="news">
1037
- <a class="thumb" href="/video/dt-viet-nam-co-mat-tai-thai-lan-ao-trang-van-chua-bi-thay-kim-vut-het-983082608.html"><img alt="ĐT Việt Nam có mặt tại Thái Lan, áo trắng vẫn chưa bị thầy Kim bỏ hết" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/20/26/Kim-Sang-Sik.jpg" /></a>
1038
- <a class="title" href="/video/dt-viet-nam-co-mat-tai-thai-lan-ao-trang-van-chua-bi-thay-kim-vut-het-983082608.html">ĐT Việt Nam có mặt tại Thái Lan, áo trắng vẫn chưa bị thầy Kim bỏ hết</a>
1039
- <span class="info">
1040
- 17:32 ng&#xE0;y 20/08/2026
1041
- </span>
1042
- <span class="info">
1043
- </span>
1044
- </div>
1045
- </div>
1046
- </div>
1047
- </div>
1048
- <div class="clz"></div>
1049
- </section>
1050
-
1051
-
1052
-
1053
- <div class="clx"></div>
1054
- <center><div id="ADBOT" class="banner adweb admob" banner-adpos="footer"></div></center>
1055
- </main>
1056
-
1057
- <style>
1058
- .iframpanel {
1059
- width: 100%;
1060
- max-width: 500px;
1061
- height: 560px;
1062
- overflow: hidden;
1063
- }
1064
-
1065
- </style>
1066
- <div class="paner-wrap">
1067
- <div class="over-layer hide"></div>
1068
- <div id="loginview" class="login-box paner hide iframpanel">
1069
- <div class="pan-capt">
1070
- <i class="pan-close" data-action="hide"></i>
1071
- </div>
1072
-
1073
-
1074
- <iframe src="https://member.bongdaplus.vn/Identity/Account/Login?ReturnUrl=%2FHome%2FLoginFromBongdaplus" frameborder="0" style="overflow:hidden;height:100%;width:100%"></iframe>
1075
-
1076
- </div>
1077
- <div id="commentloginview" class="login-box paner hide iframpanel">
1078
-
1079
-
1080
- <iframe src="https://member.bongdaplus.vn/Identity/Account/Login?ReturnUrl=%2FHome%2FCommentLoginFromBongdaplus" frameborder="0" style="overflow:hidden;height:100%;width:100%"></iframe>
1081
-
1082
- </div>
1083
- </div>
1084
- <input type="hidden" id="txtFullname" value="" />
1085
- <input type="hidden" id="txtUserid" value="" />
1086
-
1087
-
1088
-
1089
-
1090
-
1091
-
1092
- <center><div id="ADFT" class="banner adweb admob" banner-adpos="footer"></div></center>
1093
- <footer>
1094
- <div class="cont-wrap">
1095
- <div class="row">
1096
- <div class="col m12 w3">
1097
- <div class="caption">Tạp chí điện tử Bóng đá</div>
1098
- </div>
1099
- <div class="col m12 w6">
1100
- <p>Giấy phép số 48/GP-BTTTT cấp ngày 05/02/2020</p>
1101
- <p>Tổng biên tập: &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <b>Vũ Khắc Sơn</b></p>
1102
- <p>Phó tổng biên tập: &nbsp; &nbsp;<b>Nguyễn Hà Thanh</b></p>
1103
- <div class="hide-w">
1104
- <p>Địa chỉ: &nbsp;<b>Tầng 6, Tòa nhà Licogi 13 Tower, 164 đường Khuất Duy Tiến, phường Thanh Xuân, Thành phố Hà Nội</b></p>
1105
- <p>Văn phòng giao dịch: <b>Tầng 5-6-7, toà nhà LĐBĐVN, số 18 Lý Văn Phức, phường Ô Chợ Dừa, Hà Nội</b></p>
1106
- <p>Điện thoại: <b>(84.24) 3554 1188 - (84.24) 3554 1199</b></p>
1107
- <p>
1108
- Email: &nbsp; &nbsp; <a href="mailto:toasoan@bongdaplus.vn"><b>toasoan@bongdaplus.vn</b></a> |
1109
- <a href="mailto:vanphong@bongdaplus.vn"><b>vanphong@bongdaplus.vn</b></a>
1110
- </p>
1111
- <div class="spl"></div>
1112
- <p class="caption">Liên hệ quảng cáo</p>
1113
- <p>Hotline: &nbsp;<b>0903 203 412</b></p>
1114
- <p>Email: &nbsp; &nbsp;<a href="mailto:tien.nguyen@giaminhmedia.vn"><b>tien.nguyen@giaminhmedia.vn</b></a></p>
1115
- </div>
1116
- </div>
1117
- <div class="col m12 w3">
1118
- <ul class="bar social-lnk rgt-w">
1119
- <li class="hide-m">Theo dõi thông tin</li>
1120
- <li><a class="ico vff-lnk" href="https://vff.org.vn/" target="_blank" rel="nofollow"></a></li>
1121
- <li><a class="ico fb-lnk" href="https://www.facebook.com/BaoBongDa/"></a></li>
1122
- <li><a class="ico yt-lnk" href="https://www.youtube.com/channel/UCvDYMBE6GipX2VGacqOU2_Q"></a></li>
1123
- </ul>
1124
- </div>
1125
- </div>
1126
- <div class="foot-menu">
1127
- <ul class="bar hide-m">
1128
- <li><a href="#" data-showpaner="#contactview">Thông tin tòa soạn</a></li>
1129
- <li><a href="#" data-showpaner="#advertview">Liên hệ quảng cáo</a></li>
1130
- </ul>
1131
- <div class="copy-rgt">
1132
- Bản quyền &copy;2011 Bongdaplus.vn. Chỉ được phát hành lại thông tin khi
1133
- có sự đồng ý bằng văn bản của Tạp chí điện tử Bóng đá
1134
- </div>
1135
- <div class="clx hide-m"></div>
1136
- </div>
1137
- </div>
1138
- </footer>
1139
-
1140
- <div class="paner-wrap">
1141
- <div class="over-layer hide"></div>
1142
- <div id="contactview" class="contact-box paner hide">
1143
- <div class="pan-capt">
1144
- <div class="pan-title">Thông tin Toà soạn</div>
1145
- <i class="pan-close" data-action="hide"></i>
1146
- </div>
1147
- <div class="pan-cont">
1148
- <div class="xcap lead tx-prime tx-cen">Tạp chí Điện tử Bóng Đá</div>
1149
- <div class="row">
1150
- <div class="col w3 tx-base tx-rgt">Tổng biên tập:</div>
1151
- <div class="col w9"><b>Vũ Khắc Sơn</b></div>
1152
- <div class="clx"></div>
1153
- <div class="col w3 tx-base tx-rgt">Phó Tổng biên tập:</div>
1154
- <div class="col w9">
1155
- <b>Nguyễn Hà Thanh</b>
1156
- </div>
1157
- </div>
1158
- <div class="splt"></div>
1159
- <div class="row">
1160
- <div class="col w3 tx-base tx-rgt">Địa chỉ:</div>
1161
- <div class="col w9"><b>Tầng 6, Tòa nhà Licogi 13 Tower, 164 đường Khuất Duy Tiến, phường Thanh Xuân, Thành phố Hà Nội</b></div>
1162
- <div class="col w3 tx-base tx-rgt">Văn phòng giao dịch:</div>
1163
- <div class="col w9"><b>Tầng 5-6-7, toà nhà LĐBĐVN, số 18 Lý Văn Phức, phường Ô Chợ Dừa, Hà Nội</b></div>
1164
- <div class="col w3 tx-base tx-rgt">Tel:</div>
1165
- <div class="col w9"><b>(84.24) 3554 1188 - (84.24) 3554 1199</b></div>
1166
- <div class="col w3 tx-base tx-rgt">Fax:</div>
1167
- <div class="col w9"><b>(84.24) 3553 9898</b></div>
1168
- <div class="col w3 tx-base tx-rgt">Email:</div>
1169
- <div class="col w9">
1170
- <a href="mailto:toasoan@bongdaplus.vn"><b>toasoan@bongdaplus.vn</b></a>
1171
- <a href="mailto:vanphong@bongdaplus.vn"><b>vanphong@bongdaplus.vn</b></a>
1172
- </div>
1173
- </div>
1174
- </div>
1175
- </div>
1176
-
1177
- <div id="advertview" class="contact-box paner hide">
1178
- <div class="pan-capt">
1179
- <div class="pan-title">Thông tin Liên hệ</div>
1180
- <i class="pan-close" data-action="hide"></i>
1181
- </div>
1182
- <div class="pan-cont">
1183
- <div class="xcap lead tx-prime tx-cen">Tạp chí Điện tử Bóng Đá</div>
1184
- <div class="row">
1185
- <div class="col w4 tx-base tx-rgt">Hotline:</div>
1186
- <div class="col w8"><b>0903 203 412</b></div>
1187
- <div class="col w4 tx-base tx-rgt">Email:</div>
1188
- <div class="col w8"><a href="mailto:tien.nguyen@giaminhmedia.vn"><b>tien.nguyen@giaminhmedia.vn</b></a></div>
1189
- </div>
1190
- <p class="tx-base">Địa chỉ liên hệ:</p>
1191
- <b>Tầng 6, Tòa nhà Licogi 13 Tower, 164 đường Khuất Duy Tiến, phường Thanh Xuân, Thành phố Hà Nội</b>
1192
- <p class="tx-base">Văn phòng giao dịch:</p>
1193
- <b>Tầng 5-6-7, toà nhà LĐBĐVN, số 18 Lý Văn Phức, phường Ô Chợ Dừa, Hà Nội</b>
1194
- </div>
1195
- </div>
1196
-
1197
-
1198
- <div id="loginview" class="login-box paner hide">
1199
- <div class="pan-capt">
1200
- <div class="pan-title">Đăng nhập</div>
1201
- <i class="pan-close" data-action="hide"></i>
1202
- </div>
1203
- <div class="pan-cont">
1204
- <form class="soc-auth" action="#">
1205
- <button><i class="ico gg-color"></i>Google</button>
1206
- <button><i class="ico fb-color"></i>Facebook</button>
1207
- </form>
1208
- <div class="clx"></div>
1209
- <div class="splt"><span>hoặc</span></div>
1210
- <form class="loginer" action="//member.bongdaplus.vn/Identity/Account/Login?ReturnUrl=/" method="post">
1211
- <p>Email:</p>
1212
- <label class="txt-fld" for="email">
1213
- <input id="email" name="Input.Email" type="email" />
1214
- <span>Nhập địa chỉ email của bạn</span>
1215
- </label>
1216
- <label class="err-fld hide" for="email">Địa chỉ email không được để trống</label>
1217
- <p>Mật khẩu:</p>
1218
- <label class="txt-fld" for="passw">
1219
- <input id="passw" name="Input.Password" type="password" data-required="Mật khẩu không được để trống" />
1220
- <span>Nhập mật khẩu của bạn</span>
1221
- </label>
1222
- <label class="err-fld hide" for="passw">Mật khẩu không được để trống</label>
1223
- <p class="ft-sm tx-rgt"><a class="fg-psw" href="#" data-action="hide" data-showpaner="#forgetview">Quên mật khẩu?</a></p>
1224
- <label class="rdo-fld" for="persist">
1225
- <input id="persist" name="Input.RememberMe" type="checkbox" />
1226
- Ghi nhớ thông tin đăng nhập
1227
- </label>
1228
- <br>
1229
- <div class="fld-box">
1230
- <button class="btn major" type="submit">Đăng nhập</button>
1231
- </div>
1232
- <p class="ft-sm tx-cen">
1233
- Bạn chưa có tài khoản?
1234
- <a class="act-now" href="#" data-action="hide" data-showpaner="#registview">Đăng ký ngay</a>
1235
- </p>
1236
- </form>
1237
- </div>
1238
- </div>
1239
- </div>
1240
-
1241
- <script src="/lib/jquery/dist/jquery.min.js"></script>
1242
- <script src="/js/js_cookie.min.js?v=0H3Nuz3aug3afVbUlsu12Puxva3CP4EhJtPExqs54Vg"></script>
1243
- <script src="/js/bongdaplus.js?v=gIIX1pYQ8DB66N40YPyoHGaBgBFoHkPMMCARX4L6NdA"></script>
1244
- <script src="/js/premium.js?v=mbXB8QoXMl1zoEFSeE6z-mwQTb0ot7C8zj_F8JqsvNc"></script>
1245
-
1246
- <script>var _ad_info_code = 'mda/video';</script>
1247
- <script src="/js/advloader.js?v=dvvR0ygTjyYzNcQmQ_dI5bwYeX2Ww0nBjBBmCt9DWjQ"></script>
1248
-
1249
- </body>
1250
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bdp_page.html DELETED
@@ -1,1250 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <title>Hull City vs MU: Quỷ Đỏ vào hang bắt hổ - Bongdaplus.vn</title>
5
-
6
-
7
- <meta name="MobileOptimized" content="100" />
8
- <meta name="viewport" content="width=device-width, initial-scale=1" />
9
- <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
10
- <meta http-equiv="content-language" content="vi" />
11
- <meta http-equiv="refresh" content="300" />
12
- <meta property="fb:pages" content="1738672379699789" />
13
- <meta property="dable:item_id" content="2015111202028" />
14
- <meta property="og:image" itemprop="thumbnailUrl" content="https://bongdaplus.vn/img/logo-daily.png" />
15
- <link href="/favicon.ico" rel="shortcut icon" type="image/x-icon" />
16
- <link href="/favicon.svg" rel="icon" sizes="any" type="image/svg+xml" />
17
- <link href="/apple-touch-icon.png" rel="apple-touch-icon" />
18
- <!--iPhone icons-->
19
- <link href="/logo-57.png" rel="apple-touch-icon-precomposed" sizes="57x57" />
20
- <link href="/logo-114.png" rel="apple-touch-icon-precomposed" sizes="114x114" />
21
- <!--iPad icons-->
22
- <link href="/logo-72.png" rel="apple-touch-icon-precomposed" sizes="72x72" />
23
- <link href="/logo-114.png" rel="apple-touch-icon-precomposed" sizes="114x114" />
24
- <!--Android icon-->
25
- <link href="/logo-114.png" rel="apple-touch-icon-precomposed" sizes="1x1" />
26
- <!--Symbian icon-->
27
- <link href="/logo-57.png" rel="nokia-touch-icon" sizes="57x57" />
28
- <link rel="preconnect" href="https://fonts.googleapis.com">
29
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
30
- <link href="https://fonts.googleapis.com/css2?family=Mulish:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,200;1,300;1,400;1,500;1,600;1,700;1,800&display=swap" rel="stylesheet">
31
- <link href="https://fonts.googleapis.com/css2?family=Noto+Serif:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap" rel="stylesheet">
32
- <link href="/css/premium.css?id=0000&amp;v=ivL0rSTnUIBx45p3hw7UIAzbXjyQ9rgPVSyjqPglN_o" rel="stylesheet">
33
-
34
- <script async src="https://www.googletagmanager.com/gtag/js?id=UA-20374657-1"></script>
35
- <script>
36
- window.dataLayer = window.dataLayer || [];
37
- function gtag() { dataLayer.push(arguments); }
38
- gtag('js', new Date());
39
-
40
- gtag('config', 'UA-20374657-1');
41
- </script>
42
- <!-- Google tag (gtag.js) -->
43
- <script async src="https://www.googletagmanager.com/gtag/js?id=G-Y1DBNQWMVW"></script>
44
- <script>
45
- window.dataLayer = window.dataLayer || [];
46
- function gtag() { dataLayer.push(arguments); }
47
- gtag('js', new Date());
48
-
49
- gtag('config', 'G-Y1DBNQWMVW');
50
- </script>
51
-
52
- <script async src="https://static.amcdn.vn/tka/cdn.js" type="text/javascript"></script>
53
- <script>var arfAsync = arfAsync || [];</script>
54
- <script id="arf-core-js" onerror="window.arferrorload=true;" src="//media1.admicro.vn/cms/Arf.min.js" async></script>
55
- <script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-3619133031508264" crossorigin="anonymous"></script>
56
- </head>
57
- <body>
58
- <script type="application/ld+json">
59
- {
60
- "@context": "https://schema.org",
61
- "@type": "Organization",
62
- "name": "TẠP CHÍ ĐIỆN TỬ BÓNG ĐÁ - BONGDAPLUS",
63
- "url": "https://bongdaplus.vn",
64
- "logo": "https://bongdaplus.vn/img/logo.png",
65
- "contactPoint": {
66
- "@type": "ContactPoint",
67
- "telephone": "(84.24)35541188",
68
- "contactType": "customer service",
69
- "areaServed": "VN",
70
- "availableLanguage": "Vietnamese"
71
- },
72
- "sameAs": [
73
- "https://www.facebook.com/baobongda",
74
- "https://www.youtube.com/channel/UCvDYMBE6GipX2VGacqOU2_Q",
75
- "https://bongdaplus.vn",
76
- "https://www.pinterest.com/bongdaplus",
77
- "https://soundcloud.com/baobongda"
78
- ]
79
- }
80
- </script>
81
- <header>
82
- <center><div id="ADMTOP" class="banner admob" banner-adpos="top"></div></center>
83
-
84
- <div class="cont-wrap">
85
- <div class="mmnu-nav">
86
- <button class="ico mnu-sch mmnu-trigger hide-w"></button>
87
- <button class="ico menubar mmnu-trigger hide-m"></button>
88
- <button class="ico close mmnu-closer hide"></button>
89
- </div>
90
- <a class="logo" href="/"></a>
91
- <button class="ico bell hide-w hide"></button>
92
-
93
- <div class="authen-nav dropper">
94
- <a class="member">
95
- <span class="avatar">
96
- </span>
97
- </a>
98
- <div class="drop-down rgt">
99
- <ul class="lst">
100
-
101
- <li><a href="#" data-showpaner="#loginview">Đăng nhập</a></li>
102
- <li><a href="https://member.bongdaplus.vn/Identity/Account/Register?returnUrl=%2F" target="_blank">Tạo tài khoản</a></li>
103
-
104
-
105
- </ul>
106
- </div>
107
- </div>
108
- </div>
109
- </header>
110
- <input type="hidden" id="catrefid" value="video" />
111
- <input type="hidden" id="objectid" value="98267" />
112
- <input type="hidden" id="objecttype" value="1" />
113
- <input type="hidden" id="parentid" value="0" />
114
- <input type="hidden" id="commentnum" value="0" />
115
- <img src="/imgthumbnail/98267/1/1" alt="" height="0" width="0" />
116
-
117
- <main>
118
- <div class="nav-menu">
119
- <div class="menu-bar">
120
- <div class="cont-wrap">
121
- <div class="menu-side">
122
- <ul class="bar-w">
123
- <li><a class="ico home" href="/"></a></li>
124
- <li><a title="AFF Cup" href="/aff-cup">AFF Cup</a></li>
125
- <li class="popover">
126
- <a title="Việt Nam" href="/bong-da-viet-nam">Việt Nam</a>
127
- <div class="drop-down">
128
- <ul class="lst">
129
- <li><a title="V-League" href="/v-league">V-League</a></li>
130
- <li><a title="Hạng Nhất" href="/hang-nhat-quoc-gia">Hạng Nhất</a></li>
131
- <li><a title="Cúp Quốc gia" href="/cup-quoc-gia">Cúp Quốc gia</a></li>
132
- <li><a title="ĐTQG" href="/doi-tuyen-quoc-gia-viet-nam">ĐTQG</a></li>
133
- <li><a title="Bóng đá Nữ" href="/bong-da-nu-viet-nam">Bóng đá Nữ</a></li>
134
- <li><a title="U17 Quốc gia" href="/u17-viet-nam">U17 Quốc gia</a></li>
135
- <li><a title="U19 Quốc Gia" href="/u19-viet-nam">U19 Quốc Gia</a></li>
136
- <li><a title="U21 Quốc Gia" href="/u21-viet-nam">U21 Quốc Gia</a></li>
137
- <li><a title="Các đội tuyển trẻ" href="/cac-doi-tuyen-tre-viet-nam">Các đội tuyển trẻ</a></li>
138
- <li><a title="Futsal" href="/futsal">Futsal</a></li>
139
- <li><a title="Phong trào" href="/bong-da-phong-trao">Phong trào</a></li>
140
- <li><a title="Đơn vị Kết nghĩa" href="/ket-nghia-anh-em">Đơn vị Kết nghĩa</a></li>
141
- </ul>
142
- </div>
143
- </li>
144
- <li class="popover">
145
- <a title="Chuyển nhượng" href="/tin-chuyen-nhuong">Chuyển nhượng</a>
146
- <div class="drop-down">
147
- <ul class="lst">
148
- <li><a title="Tin chuyển nhượng Ngoại Hạng Anh" href="/tin-chuyen-nhuong-ngoai-hang-anh">Tin chuyển nhượng Ngoại Hạng Anh</a></li>
149
- <li><a title="Tin chuyển nhượng V-league" href="/tin-chuyen-nhuong-v-league">Tin chuyển nhượng V-league</a></li>
150
- </ul>
151
- </div>
152
- </li>
153
- <li><a title="Champions League" href="/champions-league-cup-c1">Champions League</a></li>
154
- <li class="popover">
155
- <a title="Tây Ban Nha" href="/bong-da-tay-ban-nha">Tây Ban Nha</a>
156
- <div class="drop-down">
157
- <ul class="lst">
158
- <li><a title="VĐQG Tây Ban Nha" href="/la-liga">VĐQG Tây Ban Nha</a></li>
159
- <li><a title="Cúp Nhà Vua" href="/cup-nha-vua-tay-ban-nha">Cúp Nhà Vua</a></li>
160
- <li><a title="Đội tuyển TBN" href="/doi-tuyen-tay-ban-nha">Đội tuyển TBN</a></li>
161
- </ul>
162
- </div>
163
- </li>
164
- <li class="popover">
165
- <a title="Pháp" href="/bong-da-phap">Pháp</a>
166
- <div class="drop-down">
167
- <ul class="lst">
168
- <li><a title="VĐQG Pháp" href="/ligue-1">VĐQG Pháp</a></li>
169
- <li><a title="Cúp Quốc gia Pháp" href="/cup-quoc-gia-phap">Cúp Quốc gia Pháp</a></li>
170
- <li><a title="Đội tuyển Pháp" href="/doi-tuyen-phap">Đội tuyển Pháp</a></li>
171
- </ul>
172
- </div>
173
- </li>
174
- <li class="popover">
175
- <a title="Italia" href="/bong-da-y">Italia</a>
176
- <div class="drop-down">
177
- <ul class="lst">
178
- <li><a title="VĐQG Italia" href="/serie-a">VĐQG Italia</a></li>
179
- <li><a title="Coppa Italia" href="/coppa-italia">Coppa Italia</a></li>
180
- <li><a title="Đội tuyển Italia" href="/doi-tuyen-y">Đội tuyển Italia</a></li>
181
- </ul>
182
- </div>
183
- </li>
184
- <li class="popover">
185
- <a title="Thể thao" href="/the-thao">Thể thao</a>
186
- <div class="drop-down">
187
- <ul class="lst">
188
- <li><a title="eSports" href="/esports">eSports</a></li>
189
- <li><a title="Tennis" href="/tennis">Tennis</a></li>
190
- <li><a title="Pickleball" href="/pickleball">Pickleball</a></li>
191
- <li><a title="MMA" href="/mma">MMA</a></li>
192
- <li><a title="Đua xe F1" href="/dua-xe-f1">Đua xe F1</a></li>
193
- <li><a title="Bóng rổ" href="/bong-ro">Bóng rổ</a></li>
194
- <li><a title="Olympic 2028" href="/olympic">Olympic 2028</a></li>
195
- </ul>
196
- </div>
197
- </li>
198
- <li><a title="Nhận định bóng đá" href="/nhan-dinh-bong-da-tags">Nhận định</a></li>
199
- <li><a title="Multimedia" href="/video">Multimedia</a></li>
200
- <li class="hide"><a title="Bongdaplus Premium" href="/premium">Premium <i class="ico crown"></i></a></li>
201
- </ul>
202
- </div>
203
- <div class="menu-nav">
204
- <a class="ico home hide-w" href="/"></a>
205
- <ul class="nav">
206
- <li class="hide-m"><i class="sepr"></i></li>
207
- <li><a href="/tin-moi">Mới nhất</a></li>
208
- <li class="hide-w"><a title="Bongdaplus Premium" href="/premium">Premium <i class="ico crown"></i></a></li>
209
- <li><a title="Anh" href="/bong-da-anh">Bóng đá Anh</a></li>
210
- <li class="hide-w"><a title="AFF Cup" href="/aff-cup">AFF Cup</a></li>
211
- <li class="hide-w"><a title="Việt Nam" href="/bong-da-viet-nam">Việt Nam</a></li>
212
- <li class="hide-w"><a title="Chuyển nhượng" href="/tin-chuyen-nhuong">Chuyển nhượng</a></li>
213
- <li class="hide-w"><a title="Champions League" href="/champions-league-cup-c1">Champions League</a></li>
214
- <li class="hide-w"><a title="Tây Ban Nha" href="/bong-da-tay-ban-nha">Tây Ban Nha</a></li>
215
- <li class="hide-w"><a title="Pháp" href="/bong-da-phap">Pháp</a></li>
216
- <li class="hide-w"><a title="Italia" href="/bong-da-y">Italia</a></li>
217
- <li class="hide-w"><a title="Thể thao" href="/the-thao">Thể thao</a></li>
218
- <li class="hide-w"><a title="Nhận định" href="/nhan-dinh-bong-da-tags">Nhận định</a></li>
219
- <li class="hide-w"><a title="Thế giới" href="/the-gioi">Thế giới</a></li>
220
- <li class="hide-w"><a title="Multimedia" href="/video">Multimedia</a></li>
221
- <li class="hide-w"><a title="Bóng đá Cuộc sống" href="/bong-da-cuoc-song">Bóng đá Cuộc sống</a></li>
222
- </ul>
223
- <div class="srch-bar hide-m">
224
- <button class="ico close"></button>
225
- <form class="srch-form" action="/tim-kiem">
226
- <input class="pattern toggler" name="q" type="text" placeholder="Nhập nội dung cần tìm" />
227
- </form>
228
- </div>
229
- </div>
230
- </div>
231
- </div>
232
- <div class="clr"></div>
233
- </div>
234
- <div class="mob-menu">
235
- <div class="mmnu-cap">
236
- <button class="ico close" data-action="hide"></button>
237
- <a class="logo" href="/"></a>
238
- </div>
239
- <form class="mmnu-sch " action="/tim-kiem">
240
- <input type="text" name="q">
241
- <button class="ico search" type="submit"></button>
242
- </form>
243
- <div class="mmnu-lst">
244
- <div class="menu-row">
245
- <ul class="bar">
246
- <li class="exp">
247
- <a class="major" title="Tin mới nhất" href="/tin-moi"><span class="ico blinkr"></span> Mới nhất</a>
248
- </li>
249
- <li>
250
- <ul class="lst">
251
- <li><a class="fst" title="Nh���n định Bóng đá" href="/nhan-dinh-bong-da-tags">Nhận định</a></li>
252
- </ul>
253
- </li>
254
- <li>
255
- <ul class="lst">
256
- <li><a class="fst" title="Lịch thi đấu bóng đá" href="/lich-thi-dau-bong-da">Lịch thi đấu</a></li>
257
- </ul>
258
- </li>
259
- <li class="exp"></li>
260
- <li class="exp"><a class="major" title="Bóng đá Plus Premium" href="/premium">Bóng đá Premium <i class="ico crown"></i></a></li>
261
- <li>
262
- <ul class="lst">
263
- <li><a class="fst" title="Bóng đá Việt Nam" href="/bong-da-viet-nam">Bóng đá Việt Nam</a></li>
264
- <li><a title="Đội tuyển Việt Nam" href="/doi-tuyen-quoc-gia-viet-nam">Đội tuyển Việt Nam</a></li>
265
- <li><a title="V.League" href="/v-league">V.League</a></li>
266
- <li><a title="Hạng Nhất" href="/hang-nhat-viet-nam">Hạng Nhất</a></li>
267
- <li><a title="Cúp Quốc gia" href="/cup-quoc-gia-viet-nam">Cúp Quốc gia</a></li>
268
- <li><a title="Bóng đá nữ" href="/bong-da-nu-viet-nam">Bóng đá nữ</a></li>
269
- <li><a title="Futsal" href="/futsal">Futsal</a></li>
270
- <li><a title="Phong trào" href="/bong-da-phong-trao">Bóng đá Phong trào</a></li>
271
- <li><a title="Chuyển nhượng V.League" href="/tin-chuyen-nhuong-v-league">Chuyển nhượng</a></li>
272
- </ul>
273
- </li>
274
- <li>
275
- <ul class="lst">
276
- <li><a class="fst" title="Bóng đá Anh" href="/bong-da-anh">Bóng đá Anh</a></li>
277
- <li><a title="Đội tuyển Anh" href="/doi-tuyen-anh">Đội tuyển Anh</a></li>
278
- <li><a title="Ngoại hạng Anh" href="/ngoai-hang-anh">Ngoại hạng Anh</a></li>
279
- <li><a title="FA Cup" href="/fa-cup">FA Cup</a></li>
280
- <li><a title="League Cup" href="/cup-lien-doan-anh">League Cup</a></li>
281
- </ul>
282
- <div class="clx"></div>
283
- <ul class="lst">
284
- <li><a class="fst" title="Tây Ban nha" href="/bong-da-tay-ban-nha">Tây Ban nha</a></li>
285
- <li><a title="Đội tuyển TBN" href="/doi-tuyen-tay-ban-nha">Đội tuyển TBN</a></li>
286
- <li><a title="La Liga" href="/la-liga">La Liga</a></li>
287
- <li><a title="Cúp Nhà Vua" href="/cup-nha-vua-tay-ban-nha">Cúp Nhà Vua</a></li>
288
- </ul>
289
- </li>
290
- <li class="exp"></li>
291
- <li>
292
- <ul class="lst">
293
- <li><a class="fst" title="Bóng đá Pháp" href="/bong-da-phap">Bóng đá Pháp</a></li>
294
- <li><a title="Đội tuyển Pháp" href="/doi-tuyen-phap">Đội tuyển Pháp</a></li>
295
- <li><a title="Ligue 1" href="/ligue-1">Ligue 1</a></li>
296
- <li><a title="Cúp QG Pháp" href="/cup-quoc-gia-phap">Cúp QG Pháp</a></li>
297
- <li><a title="Cúp Liên đoàn Pháp" href="/cup-lien-doan-phap-tags">Cúp Liên đoàn Pháp</a></li>
298
- </ul>
299
- </li>
300
- <li>
301
- <ul class="lst">
302
- <li><a class="fst" href="#">Bóng đá Khu vực</a></li>
303
- <li><a title="Asian Cup" href="/asian-cup">Asian Cup</a></li>
304
- <li><a title="Asian Games" href="/asian-games">Asian Games</a></li>
305
- <li><a title="AFF Cup" href="/aff-cup">AFF Cup</a></li>
306
- <li><a title="SEA Games" href="/sea-games">SEA Games</a></li>
307
- </ul>
308
- </li>
309
- <li class="exp"></li>
310
- <li>
311
- <ul class="lst">
312
- <li><a class="fst" title="Bóng đá Italia" href="/bong-da-y">Bóng đá Italia</a></li>
313
- <li><a title="Đội tuyển Italia" href="/doi-tuyen-y">Đội tuyển Italia</a></li>
314
- <li><a title="Serie A" href="/serie-a">Serie A</a></li>
315
- <li><a title="Coppa Italia" href="/coppa-italia">Coppa Italia</a></li>
316
- </ul>
317
- </li>
318
- <li>
319
- <ul class="lst">
320
- <li><a class="fst" title="Bóng đá Đức" href="/bong-da-duc">Bóng đá Đức</a></li>
321
- <li><a title="Đội tuyển Đức" href="/doi-tuyen-duc">Đội tuyển Đức</a></li>
322
- <li><a title="Bundesliga" href="/bundesliga">Bundesliga</a></li>
323
- <li><a title="Cúp QG Đức" href="/cup-quoc-gia-duc">Cúp QG Đức</a></li>
324
- </ul>
325
- </li>
326
- <li class="exp"></li>
327
- <li>
328
- <ul class="lst">
329
- <li><a class="fst" href="#">Nội dung Đặc biệt</a></li>
330
- <li><a title="Big Story" href="/emagazine">Big Story</a></li>
331
- <li><a title="X-File" href="/x-file">X-File</a></li>
332
- <li><a title="Infographic" href="/infographic">Infographic</a></li>
333
- <li><a title="Interactive" href="/interactive">Interactive</a></li>
334
- </ul>
335
- </li>
336
- <li>
337
- <ul class="lst">
338
- <li><a class="fst" href="#">Giải đấu CLB</a></li>
339
- <li><a title="Champions Leage" href="/champions-league-cup-c1">Champions League</a></li>
340
- <li><a title="Europa League" href="/europa-league">Europa League</a></li>
341
- <li><a title="Club World Cup" href="/fifa-club-world-cup-tags">Club World Cup</a></li>
342
- <li><a title="Europa Conference League" href="/europa-conference-league">Europa Conf League</a></li>
343
- </ul>
344
- </li>
345
- <li class="exp"></li>
346
- <li>
347
- <ul class="lst">
348
- <li><a class="fst" href="#">Chuyên đề</a></li>
349
- <li><a title="Tin chuyển nhượng" href="/tin-chuyen-nhuong">Chuyển nhượng</a></li>
350
- <li><a title="Hậu trường bóng đá" href="/hau-truong-bong-da">Hậu trường</a></li>
351
- <li><a title="Điểm tin" href="/diem-tin">Điểm tin</a></li>
352
- <li><a title="Showbiz Ngôi sao" href="/ngoi-sao-showbiz">Showbiz Ngôi sao</a></li>
353
- <li><a title="Góc check var" href="/goc-check-var">Góc check var</a></li>
354
- <li><a title="Công nghệ" href="/cong-nghe">Công nghệ</a></li>
355
- <li><a title="Thông tin Thị trường" href="/thi-truong">Thị trường</a></li>
356
- <li><a title="Bóng đá & Cuộc sống" href="/bong-da-cuoc-song">Bóng đá & Cuộc sống</a></li>
357
- </ul>
358
- </li>
359
- <li>
360
- <ul class="lst">
361
- <li><a class="fst" href="#">Đội tuyển</a></li>
362
- <li><a title="World Cup nữ 2028" href="/world-cup-nu-2023-tags">World Cup nữ 2028</a></li>
363
- <li><a title="World Cup" href="/world-cup">World Cup 2026</a></li>
364
- <li><a title="European Championship 2028" href="/euro-cup-chau-au">EURO 2028</a></li>
365
- <li><a title="Nations League" href="/nations-league">Nations League</a></li>
366
- <li><a title="Copa America" href="/copa-america">Copa America</a></li>
367
- <li><a title="CAN" href="/can">CAN</a></li>
368
- <li><a title="Olympic 2028" href="/olympic">Olympic 2028</a></li>
369
- </ul>
370
- </li>
371
- <li class="exp"></li>
372
- <li>
373
- <ul class="lst">
374
- <li><a class="fst" title="Multimedia" href="/video">Multimedia</a></li>
375
- </ul>
376
- </li>
377
- <li>
378
- <ul class="lst">
379
- <li>&nbsp;</li>
380
- </ul>
381
- </li>
382
- <li class="exp"></li>
383
- <li>
384
- <ul class="lst">
385
- <li><a class="fst" href="/lich-thi-dau-bong-da">Lịch thi đấu</a></li>
386
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-viet-nam" title="Lịch thi đấu V.League">V.League</a></li>
387
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-anh" title="Lịch thi đấu Ngoại hạng Anh">Ngoại hạng Anh</a></li>
388
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-phap" title="Lịch thi đấu Ligue 1 Pháp">Ligue 1</a></li>
389
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-tay-ban-nha" title="Lịch thi đấu La Liga Tây Ban Nha">La Liga</a></li>
390
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-duc" title="Lịch thi đấu Bundesliga Đức">Bundesliga</a></li>
391
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?bong-da-y" title="Lịch thi đấu Serie A Italia">Serie A</a></li>
392
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?champions-league-cup-c1" title="Lịch thi đấu Champions League">Champions League</a></li>
393
- <li class="hide-m"><a href="/lich-thi-dau-bong-da?europa-league" title="Lịch thi đấu Europa League">Europa League</a></li>
394
- </ul>
395
- </li>
396
- <li>
397
- <ul class="lst">
398
- <li><a class="fst" href="/bang-xep-hang-bong-da">Bảng xếp hạng</a></li>
399
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-viet-nam" title="Bảng xếp hạng V.League">V.League</a></li>
400
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-anh" title="Bảng xếp hạng Ngoại hạng Anh">Ngoại hạng Anh</a></li>
401
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-phap" title="Bảng xếp hạng Ligue 1 Pháp">Ligue 1</a></li>
402
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-tay-ban-nha" title="Bảng xếp hạng La Liga Tây Ban Nha">La Liga</a></li>
403
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-duc" title="Bảng xếp hạng Bundesliga Đức">Bundesliga</a></li>
404
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?bong-da-y" title="Bảng xếp hạng Serie A Italia">Serie A</a></li>
405
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?champions-league-cup-c1" title="Bảng xếp hạng Champions League">Champions League</a></li>
406
- <li class="hide-m"><a href="/bang-xep-hang-bong-da?europa-league" title="Bảng xếp hạng Europa League">Europa League</a></li>
407
- </ul>
408
- </li>
409
- <li class="exp"></li>
410
- <li><a class="caption" href="https://phui.bongdaplus.vn" target="_blank">Bóng đá phủi</a></li>
411
- <li><a class="caption" href="/tennis">Tennis</a></li>
412
- <li><a class="caption" href="/esports">Esports</a></li>
413
- <li><a class="caption" href="/the-thao">Thể thao +</a></li>
414
- </ul>
415
- </div>
416
- </div>
417
- <div class="mmnu-end tx-cen">
418
- <p>Theo dõi thông tin</p>
419
- <ul class="bar social-lnk">
420
- <li class="hide-m">Theo dõi thông tin</li>
421
- <li><a class="ico vff-lnk" target="_blank" href="https://vff.org.vn/" style="background-size:28px;opacity:1;" rel="nofollow"></a></li>
422
- <li><a class="ico fb-lnk" target="_blank" href="https://www.facebook.com/BaoBongDa/" style="background-size:28px;opacity:1;"></a></li>
423
- <li><a class="ico yt-lnk" target="_blank" href="https://www.youtube.com/channel/UCF0VFBZu-5SRJXMV_FMysEA" style="background-size:28px;opacity:1;"></a></li>
424
- </ul>
425
- </div>
426
- </div>
427
- <div class="mega-menu">
428
- <div class="cont-wrap">
429
- <div class="menu-row">
430
- <ul class="bar">
431
- <li><a class="caption" title="Tin mới nhất" href="/tin-moi">Mới nhất</a></li>
432
- <li>
433
- <ul class="lst">
434
- <li><a class="fst" title="Bóng đá Việt Nam" href="/bong-da-viet-nam">Bóng đá Việt Nam</a></li>
435
- <li><a title="Đội tuyển Việt Nam" href="/doi-tuyen-quoc-gia-viet-nam">Đội tuyển Việt Nam</a></li>
436
- <li><a title="V.League" href="/v-league">V.League</a></li>
437
- <li><a title="Hạng Nhất" href="/hang-nhat-viet-nam">Hạng Nhất</a></li>
438
- <li><a title="Cúp Quốc gia" href="/cup-quoc-gia-viet-nam">Cúp Quốc gia</a></li>
439
- <li><a title="Cúp châu Á" href="/asian-cup">Cúp châu Á</a></li>
440
- <li><a title="Bóng đá nữ" href="/bong-da-nu-viet-nam">Bóng đá nữ</a></li>
441
- <li><a title="Futsal" href="/futsal">Futsal</a></li>
442
- <li><a title="Phong trào" href="/bong-da-phong-trao">Phong trào</a></li>
443
- <li><a title="Chuyển nhượng V.League" href="/tin-chuyen-nhuong-v-league">Chuyển nhượng V.League</a></li>
444
- <li><a title="Tin khác" href="/tin-khac">Tin khác</a></li>
445
- </ul>
446
- </li>
447
-
448
- <li>
449
- <ul class="lst">
450
- <li><a class="fst" title="Bóng đá Anh" href="/bong-da-anh">Bóng đá Anh</a></li>
451
- <li><a title="Đội tuyển Anh" href="/doi-tuyen-anh">Đội tuyển Anh</a></li>
452
- <li><a title="Ngoại hạng Anh" href="/ngoai-hang-anh">Ngoại hạng Anh</a></li>
453
- <li><a title="FA Cup" href="/fa-cup">FA Cup</a></li>
454
- <li><a title="League Cup" href="/cup-lien-doan-anh">League Cup</a></li>
455
- </ul>
456
- <div class="clx"></div>
457
- <ul class="lst">
458
- <li><a class="fst" href="#">Giải đấu CLB</a></li>
459
- <li><a title="Champions Leage" href="/champions-league-cup-c1">Champions League</a></li>
460
- <li><a title="Europa League" href="/europa-league">Europa League</a></li>
461
- <li><a title="Club World Cup" href="/fifa-club-world-cup-tags">Club World Cup</a></li>
462
- <li><a title="Europa Conference League" href="/europa-conference-league">Europa Conference League</a></li>
463
- </ul>
464
- </li>
465
-
466
- <li>
467
- <ul class="lst">
468
- <li><a class="fst" title="Bóng đá Italia" href="/bong-da-y">Bóng đá Italia</a></li>
469
- <li><a title="Đội tuyển Italia" href="/doi-tuyen-y">Đội tuyển Italia</a></li>
470
- <li><a title="Serie A" href="/serie-a">Serie A</a></li>
471
- <li><a title="Coppa Italia" href="/coppa-italia">Coppa Italia</a></li>
472
- </ul>
473
- <div class="clx"></div>
474
- <ul class="lst">
475
- <li><a class="fst" href="#">Đội tuyển</a></li>
476
- <li><a title="World Cup nữ 2028" href="/world-cup-nu-2023-tags">World Cup nữ 2028</a></li>
477
- <li><a title="World Cup" href="/world-cup">World Cup 2026</a></li>
478
- <li><a title="European Championship 2028" href="/euro-cup-chau-au">EURO 2028</a></li>
479
- <li><a title="Nations League" href="/nations-league">Nations League</a></li>
480
- <li><a title="Copa America" href="/copa-america">Copa America</a></li>
481
- <li><a title="CAN" href="/can">CAN</a></li>
482
- <li><a title="Olympic" href="/olympic">Olympic 2028</a></li>
483
- </ul>
484
- </li>
485
-
486
- <li>
487
- <ul class="lst">
488
- <li><a class="fst" title="Bóng đá Pháp" href="/bong-da-phap">Bóng đá Pháp</a></li>
489
- <li><a title="Đội tuyển Pháp" href="/doi-tuyen-phap">Đội tuyển Pháp</a></li>
490
- <li><a title="Ligue 1" href="/ligue-1">Ligue 1</a></li>
491
- <li><a title="Cúp QG Pháp" href="/cup-quoc-gia-phap">Cúp QG Pháp</a></li>
492
- <li><a title="Cúp Liên đoàn Pháp" href="/cup-lien-doan-phap-tags">Cúp Liên đoàn Pháp</a></li>
493
- </ul>
494
- <div class="clx"></div>
495
- <ul class="lst">
496
- <li><a class="fst" href="#">Khu vực</a></li>
497
- <li><a title="Asian Cup" href="/asian-cup">Asian Cup</a></li>
498
- <li><a title="Asian Games" href="/asian-games">Asian Games</a></li>
499
- <li><a title="AFF Cup" href="/aff-cup">AFF Cup</a></li>
500
- <li><a title="SEA Games" href="/sea-games">SEA Games</a></li>
501
- </ul>
502
- </li>
503
-
504
- <li>
505
- <ul class="lst">
506
- <li><a class="fst" title="Tây Ban nha" href="/bong-da-tay-ban-nha">Tây Ban nha</a></li>
507
- <li><a title="Đội tuyển TBN" href="/doi-tuyen-tay-ban-nha">Đội tuyển TBN</a></li>
508
- <li><a title="La Liga" href="/la-liga">La Liga</a></li>
509
- <li><a title="Cúp Nhà Vua" href="/cup-nha-vua-tay-ban-nha">Cúp Nhà Vua</a></li>
510
- </ul>
511
- <div class="clx"></div>
512
- <ul class="lst">
513
- <li><a class="fst" href="#">Chuyên đề</a></li>
514
- <li><a title="Tin chuyển nhượng" href="/tin-chuyen-nhuong">Chuyển nhượng</a></li>
515
- <li><a title="Hậu trường bóng đá" href="/hau-truong-bong-da">Hậu trường</a></li>
516
- <li><a title="Điểm tin" href="/diem-tin">Điểm tin</a></li>
517
- <li><a title="Showbiz Ngôi sao" href="/ngoi-sao-showbiz">Showbiz Ngôi sao</a></li>
518
- <li><a title="Góc check var" href="/goc-check-var">Góc check var</a></li>
519
- <li><a title="Công nghệ" href="/cong-nghe">Công nghệ</a></li>
520
- <li><a title="Thông tin Thị trường" href="/thi-truong">Thị trường</a></li>
521
- </ul>
522
- </li>
523
-
524
- <li>
525
- <ul class="lst">
526
- <li><a class="fst" title="Bóng đá Đức" href="/bong-da-duc">Bóng đá Đức</a></li>
527
- <li><a title="Đội tuyển Đức" href="/doi-tuyen-duc">Đội tuyển Đức</a></li>
528
- <li><a title="Bundesliga" href="/bundesliga">Bundesliga</a></li>
529
- <li><a title="Cúp QG Đức" href="/cup-quoc-gia-duc">Cúp QG Đức</a></li>
530
- </ul>
531
- <div class="clx"></div>
532
- <ul class="lst">
533
- <li><a class="fst" href="#">Đặc biệt</a></li>
534
- <li><a title="Big Story" href="/emagazine">Big Story</a></li>
535
- <li><a title="X-File" href="/x-file">X-File</a></li>
536
- <li><a title="Infographic" href="/infographic">Infographic</a></li>
537
- <li><a title="Interactive" href="/interactive">Interactive</a></li>
538
- </ul>
539
- </li>
540
- </ul>
541
- </div>
542
-
543
- <div class="menu-row">
544
- <ul class="bar">
545
- <li>&nbsp;</li>
546
- <li><a title="Tin chuyển nhượng" href="/tin-chuyen-nhuong">Tin chuyển nhượng</a></li>
547
- <li><a title="Bóng đá & Cuộc sống" href="/bong-da-cuoc-song">Bóng đá & Cuộc sống</a></li>
548
- <li><a title="Thông tin Thị trường" href="/thi-truong">Thông tin Thị trường</a></li>
549
- <li><a title="Tin tức Công nghệ" href="/cong-nghe">Công nghệ</a></li>
550
- <li><a title="Tin tức Xe auto bike" href="/xe-co">Xe auto</a></li>
551
- </ul>
552
- </div>
553
-
554
- <div class="menu-row">
555
- <ul class="bar">
556
- <li>&nbsp;</li>
557
- <li><a class="caption" title="Nhận định Bóng đá" href="/nhan-dinh-bong-da-tags">Nhận định</a></li>
558
- <li><a class="caption" title="Tư vấn Bóng đá" href="/soi-keo-bong-da">Tư vấn</a></li>
559
- <li><a class="caption" title="Multimedia" href="/video">Multimedia</a></li>
560
- <li><a class="caption" title="Youtube" target="_blank" href="https://www.youtube.com/channel/UCF0VFBZu-5SRJXMV_FMysEA">Youtube</a></li>
561
- <li><a class="caption" title="Facebook fanpage" target="_blank" href="https://www.facebook.com/BaoBongDa">FB Fanpage</a></li>
562
- <li>
563
- <a class="caption" title="Premium" href="/premium">Premium <i class="ico crown"></i></a>
564
- </li>
565
- </ul>
566
- </div>
567
-
568
- <div class="menu-row">
569
- <ul class="bar">
570
- <li><a class="caption" href="/lich-thi-dau-bong-da">Lịch thi đấu</a></li>
571
- <li>
572
- <ul class="lst">
573
- <li><a class="fst" href="/lich-thi-dau-bong-da">Lịch thi đấu hôm nay</a></li>
574
- <li><a href="/lich-thi-dau-bong-da?bong-da-viet-nam" title="Lịch thi đấu V.League">V.League</a></li>
575
- <li><a href="/lich-thi-dau-bong-da?bong-da-anh" title="Lịch thi đấu Ngoại hạng Anh">Ngoại hạng Anh</a></li>
576
- <li><a href="/lich-thi-dau-bong-da?bong-da-phap" title="Lịch thi đấu Ligue 1 Pháp">Ligue 1</a></li>
577
- <li><a href="/lich-thi-dau-bong-da?bong-da-tay-ban-nha" title="Lịch thi đấu La Liga Tây Ban Nha">La Liga</a></li>
578
- <li><a href="/lich-thi-dau-bong-da?bong-da-duc" title="Lịch thi đấu Bundesliga Đức">Bundesliga</a></li>
579
- <li><a href="/lich-thi-dau-bong-da?bong-da-y" title="Lịch thi đấu Serie A Italia">Serie A</a></li>
580
- <li><a href="/lich-thi-dau-bong-da?champions-league-cup-c1" title="Lịch thi đấu Champions League">Champions League</a></li>
581
- <li><a href="/lich-thi-dau-bong-da?europa-league" title="Lịch thi đấu Europa League">Europa League</a></li>
582
- </ul>
583
- </li>
584
-
585
- <li>
586
- <ul class="lst">
587
- <li><a class="fst" href="/ket-qua-bong-da">Kết quả mới nhất</a></li>
588
- <li><a href="/lich-thi-dau-bong-da?bong-da-viet-nam" title="Kết quả V.League">V.League</a></li>
589
- <li><a href="/lich-thi-dau-bong-da?bong-da-anh" title="Kết quả Ngoại hạng Anh">Ngoại hạng Anh</a></li>
590
- <li><a href="/lich-thi-dau-bong-da?bong-da-phap" title="Kết quả Ligue 1 Pháp">Ligue 1</a></li>
591
- <li><a href="/lich-thi-dau-bong-da?bong-da-tay-ban-nha" title="Kết quả La Liga Tây Ban Nha">La Liga</a></li>
592
- <li><a href="/lich-thi-dau-bong-da?bong-da-duc" title="Kết quả Bundesliga Đức">Bundesliga</a></li>
593
- <li><a href="/lich-thi-dau-bong-da?bong-da-y" title="Kết quả Serie A Italia">Serie A</a></li>
594
- <li><a href="/lich-thi-dau-bong-da?champions-league-cup-c1" title="Kết quả Champions League">Champions League</a></li>
595
- <li><a href="/lich-thi-dau-bong-da?europa-league" title="Kết quả Europa League">Europa League</a></li>
596
- </ul>
597
- </li>
598
-
599
- <li>
600
- <ul class="lst">
601
- <li><a class="fst" href="#">Bảng xếp hạng</a></li>
602
- <li><a href="/bang-xep-hang-bong-da?bong-da-viet-nam" title="Bảng xếp hạng V.League">V.League</a></li>
603
- <li><a href="/bang-xep-hang-bong-da?bong-da-anh" title="Bảng xếp hạng Ngoại hạng Anh">Ngoại hạng Anh</a></li>
604
- <li><a href="/bang-xep-hang-bong-da?bong-da-phap" title="Bảng xếp hạng Ligue 1 Pháp">Ligue 1</a></li>
605
- <li><a href="/bang-xep-hang-bong-da?bong-da-tay-ban-nha" title="Bảng xếp hạng La Liga Tây Ban Nha">La Liga</a></li>
606
- <li><a href="/bang-xep-hang-bong-da?bong-da-duc" title="Bảng xếp hạng Bundesliga Đức">Bundesliga</a></li>
607
- <li><a href="/bang-xep-hang-bong-da?bong-da-y" title="Bảng xếp hạng Serie A Italia">Serie A</a></li>
608
- <li><a href="/bang-xep-hang-bong-da?champions-league-cup-c1" title="Bảng xếp hạng Champions League">Champions League</a></li>
609
- <li><a href="/bang-xep-hang-bong-da?europa-league" title="Bảng xếp hạng Europa League">Europa League</a></li>
610
- </ul>
611
- </li>
612
-
613
- <li>
614
- <ul class="lst">
615
- <li><a class="caption" href="https://phui.bongdaplus.vn" target="_blank">Bóng đá phủi</a></li>
616
- <li><a class="caption" href="/tennis">Tennis</a></li>
617
- </ul>
618
- </li>
619
- <li>
620
- <ul class="lst">
621
- <li><a class="caption" href="/esports">Esports</a></li>
622
- <li><a class="caption" href="/the-thao">Thể thao +</a></li>
623
- </ul>
624
- </li>
625
-
626
- <li>
627
- <ul class="lst">
628
- <li>
629
- <div class="copyr">
630
- <div class="splt hide-w"></div>
631
- <p><b class="allcap">Tạp chí Bóng Đá</b></p>
632
- <p>Giấy phép số 48/GP-BTTTT cấp ngày 05/02/2020</p>
633
- <p>&nbsp;</p>
634
- <p>
635
- Tổng biên tập:<br /> <b>Vũ Khắc Sơn</b><br />
636
- Phó tổng biên tập:<br /> <b>Nguyễn Hà Thanh</b>
637
- </p>
638
- <p>&nbsp;</p>
639
- <p>
640
- <a class="contact-showr" href="#">Thông tin tòa soạn</a><br />
641
- <a class="contact-showr" href="#">Liên hệ quảng cáo</a>
642
- </p>
643
- <p>Liên đoàn BĐVN <a href="https://vff.org.vn/" target="_blank" rel="nofollow"><img alt="VFF" src="/img/logo_Vff.png" style="width:28px;" /></a></p>
644
- </div>
645
- </li>
646
- </ul>
647
- </li>
648
- </ul>
649
- </div>
650
- </div>
651
- </div>
652
- <div class="menu-tags hide-w">
653
- <div class="today"></div>
654
- <div class="mtags">
655
- <a title="#HighlightBóngđá" href="/video/highlight">#HighlightBóngđá</a>
656
- <a title="#PremierLeague" href="/ngoai-hang-anh">#PremierLeague</a>
657
- <a title="#V.League" href="/v-league">#V.League</a>
658
- </div>
659
- </div>
660
-
661
- <div id="fxtSlideCover" class="fixt-strm" style="margin-bottom:5px;">
662
- <div class="cont-wrap">
663
- <a class="brand" href="https://admin.giaminhmedia.vn/www/delivery/cl.php?bannerid=1411&zoneid=328&sig=8300d1283663cec85d90499507ffd94240954967132167c0f302d7388b8f9bb5&dest=http%3A%2F%2Fadmin.giaminhmedia.vn%2Fwww%2Funiscore.php" target="_blank" rel="nofollow" style="margin-left:5px;">
664
- <img alt="" src="https://bongdaplus.vn/img/logouniscore-tiny.png">
665
- </a>
666
- <ul id="fxtSlide" class="nav"></ul>
667
- </div>
668
- </div>
669
-
670
- <div class="clz"></div>
671
- <div id="ADTOP" class="banner adweb admob" banner-adpos="top"></div>
672
-
673
- <section class="bread-crums">
674
- <div class="clz"></div>
675
- <div class="cont-wrap">
676
- <div class="chain-navs">
677
- <a href="/video">Multimedia</a>&raquo;
678
- </div>
679
- </div>
680
- </section>
681
-
682
- <section class="media-box">
683
- <div class="clz"></div>
684
- <div class="cont-wrap video-box">
685
- <div class="row">
686
- <div class="col m12 w8 clip-box">
687
- <div class="play-box rbox">
688
- <iframe id="ytbFrBpl" class="play-frame" src="https://www.youtube.com/embed/Qokb_8w6p0s?si=QNKAZIYxNrtn2hSN" cid="UCmn1v6lrIDZghUfbxXaorUw" vnum="0" class="latestVideoEmbed" title="Bongdaplus trên Youtube" frameborder="0" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen=""></iframe>
689
- </div>
690
- </div>
691
-
692
- <div class="col m12 w4 clip-cont">
693
- <div class="ovrscrl">
694
- <div class="clip-info">
695
- <h1 class="title">Hull City vs MU: Quỷ Đỏ vào hang bắt hổ</h1>
696
- <p class="desc">
697
- Mùa giải Premier League 2026/27 của MU sẽ mở màn bằng chuyến làm khách đầy thử thách trước tân binh Hull City tại sân MKM. Cuộc đối đầu Hull City vs MU là màn chạm trán đáng chú ý khi “Đàn Hổ” đang tràn đầy khí thế, trong khi Quỷ đỏ không được phép sảy chân nếu muốn sớm tạo đà cho cuộc đua vô địch.
698
- </p>
699
-
700
- <div>08:07 - 22/08/2026</div>
701
- <div class="editor"></div>
702
- <div class="hash-tags">
703
- <b>Tags:</b>
704
- <a href="/video/hull-city-vs-mu-tags">Hull City vs MU</a>
705
- <a href="/video/nhan-dinh-hull-city-vs-mu-tags">nh&#x1EAD;n &#x111;&#x1ECB;nh Hull City vs MU</a>
706
- <a href="/video/premier-league-tags">Premier League</a>
707
- <a href="/video/mu-tags">MU</a>
708
- </div>
709
- <style>
710
- .emobar .emo:not(:empty) {
711
- background-position: left 10px top 50%;
712
- padding: 0 13px 0 36px;
713
- }
714
- .emobar .emo {
715
- border-radius: 16px;
716
- background-color: #d7d3d3;
717
- background-size: auto 18px;
718
- cursor: pointer;
719
- margin: 0 3px;
720
- padding: 0 16px;
721
- line-height: 32px;
722
- height: 32px;
723
- }
724
- .emopicked {
725
- border: solid 1px #767676;
726
- }
727
- </style>
728
- <div class="emobar tx-cen">
729
- <div class="clx"></div>
730
- <a class="emo like" onclick="btnNewsEmotion_click(1)" id="btnEmo1"> <span id="numemo1"></span></a>
731
- <a class="emo heart" onclick="btnNewsEmotion_click(2)" id="btnEmo2"> <span id="numemo2"></span></a>
732
- <a class="emo star" onclick="btnNewsEmotion_click(4)" id="btnEmo4"> <span id="numemo4"></span></a>
733
- <a class="emo comment" id="ncmt" href="#commenting" data-action="comment" data-act-only="true"></a>
734
- </div>
735
- </div>
736
- </div>
737
- </div>
738
- </div>
739
- </div>
740
-
741
- <div class="clx"></div>
742
- <div class="cont-view">
743
-
744
-
745
-
746
-
747
- <style>
748
- .btnlikedislikecmt {
749
- border-radius: 9px;
750
- border: solid 1px #767676;
751
- background-color: #cecece;
752
- }
753
-
754
- sup {
755
- padding: 3px;
756
- position: initial;
757
- vertical-align: initial;
758
- }
759
-
760
- .TopComment {
761
- border: solid 1px #fdd835;
762
- }
763
-
764
- .TopComment .info .member {
765
- color: red;
766
- }
767
-
768
- .TopComment .info .avatar {
769
- border: solid 1px red;
770
- }
771
-
772
- .comment .summ .replyat {
773
- /*color: #1b5e20;*/
774
- display: inline-block;
775
- margin: 0 8px 0 0;
776
- font-weight: bold;
777
- /* text-decoration: underline; */
778
- }
779
- </style>
780
- <div id="postComment" class="comm-zone">
781
- <a class="anchor" name="#comments"></a>
782
- <div class="clz"></div>
783
- <div class="xcap">Bình luận</div>
784
- <div class="clz"></div>
785
- <div class="comm-post ">
786
- <div id="formcomment" class="comm-form">
787
- <label class="txt-fld">
788
- <textarea id="txtcomment_0" name="txtcomment_0" maxlength="1000" placeholder="Hãy chia sẻ suy nghĩ của bạn..." onclick="showcommentloginform();"></textarea>
789
- </label>
790
- <div class="actions">
791
- <button id="btnComment" class="btn btn-send base" onclick="btnComment_click(0,0)">Gửi</button>
792
- </div>
793
- <center><div class="cmterr"></div></center>
794
- </div>
795
- </div>
796
- <div class="clz"></div>
797
- <div class="comm-frame">
798
- <div id="NewsComments"></div>
799
- <div class="clz"></div>
800
- <div class="cat-news">
801
- <div id="btnloadviewmore" class="view-more hide"><a onclick="loadviewmorecomment();">Xem thêm</a></div>
802
- </div>
803
- <div class="clz"></div>
804
- </div>
805
- </div>
806
- </div>
807
- <div class="clz"></div>
808
- </section>
809
-
810
-
811
-
812
-
813
-
814
- <div class="clx"></div>
815
- <center><div id="ADHOT" class="banner adweb admob" banner-adpos="hot"></div></center>
816
- <div class="clx"></div>
817
-
818
-
819
- <section class="cat-news">
820
- <div class="cont-wrap">
821
- <div class="capt-cover">
822
- <div class="caption">Video mới nhất</div>
823
- <button class="more-lnk"></button>
824
- </div>
825
- <div class="row flex">
826
- <div class="col m12 w3">
827
- <div class="news">
828
- <a class="thumb" href="/video/du-doan-ty-so-hull-mu-than-rua-ali-33-chon-doi-nao-982702608.html"><img alt="Dự đoán tỷ số Hull vs MU: Thần Rùa Ali 33 chọn đội nào?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/18/80/Anh-chu-480-x-270.jpg" /></a>
829
- <a class="title" href="/video/du-doan-ty-so-hull-mu-than-rua-ali-33-chon-doi-nao-982702608.html">Dự đoán tỷ số Hull vs MU: Thần Rùa Ali 33 chọn đội nào?</a>
830
- <span class="info">
831
- 42 ph&#xFA;t tr&#x1B0;&#x1EDB;c
832
- </span>
833
- <span class="info">
834
- </span>
835
- </div>
836
- </div>
837
- <div class="col m12 w3">
838
- <div class="news">
839
- <a class="thumb" href="/video/hull-city-vs-mu-quy-do-vao-hang-bat-ho-982672608.html"><img alt="Hull City vs MU: Quỷ Đỏ vào hang bắt hổ" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/18/13/ThumbHullvsMU-up.jpg" /></a>
840
- <a class="title" href="/video/hull-city-vs-mu-quy-do-vao-hang-bat-ho-982672608.html">Hull City vs MU: Quỷ Đỏ vào hang bắt hổ</a>
841
- <span class="info">
842
- 43 ph&#xFA;t tr&#x1B0;&#x1EDB;c
843
- </span>
844
- <span class="info">
845
- </span>
846
- </div>
847
- </div>
848
- <div class="col m12 w3">
849
- <div class="news">
850
- <a class="thumb" href="/video/du-doan-ty-so-brentford-vs-tottenham-premier-league-2026-27-than-rua-ali-33-chon-doi-nao-982862608.html"><img alt="Dự đoán tỷ số Brentford vs Tottenham: Thần Rùa Ali 33 chọn đội nào?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/19/80/Brentford-vs-Tottenham-480-x-270-moi.jpg" /></a>
851
- <a class="title" href="/video/du-doan-ty-so-brentford-vs-tottenham-premier-league-2026-27-than-rua-ali-33-chon-doi-nao-982862608.html">Dự đoán tỷ số Brentford vs Tottenham: Thần Rùa Ali 33 chọn đội nào?</a>
852
- <span class="info">
853
- 43 ph&#xFA;t tr&#x1B0;&#x1EDB;c
854
- </span>
855
- <span class="info">
856
- </span>
857
- </div>
858
- </div>
859
- <div class="col m12 w3">
860
- <div class="news">
861
- <a class="thumb" href="/video/brentford-vs-tottenham-de-zerbi-vuot-ai-gtech-982822608.html"><img alt="Brentford vs Tottenham: De Zerbi vượt ải Gtech" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/19/13/Brenfort-vs-Tottenham.jpg" /></a>
862
- <a class="title" href="/video/brentford-vs-tottenham-de-zerbi-vuot-ai-gtech-982822608.html">Brentford vs Tottenham: De Zerbi vượt ải Gtech</a>
863
- <span class="info">
864
- 43 ph&#xFA;t tr&#x1B0;&#x1EDB;c
865
- </span>
866
- <span class="info">
867
- </span>
868
- </div>
869
- </div>
870
- <div class="col m12 w3">
871
- <div class="news">
872
- <a class="thumb" href="/video/du-doan-ty-so-arsenal-vs-coventry-than-rua-ali-33-chon-doi-nao-982612608.html"><img alt="Dự đoán tỷ số Arsenal vs Coventry: Thần Rùa Ali 33 chọn đội nào?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/17/0/thumblai.jpg" /></a>
873
- <a class="title" href="/video/du-doan-ty-so-arsenal-vs-coventry-than-rua-ali-33-chon-doi-nao-982612608.html">Dự đoán tỷ số Arsenal vs Coventry: Thần Rùa Ali 33 chọn đội nào?</a>
874
- <span class="info">
875
- 09 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
876
- </span>
877
- <span class="info">
878
- </span>
879
- </div>
880
- </div>
881
- <div class="col m12 w3">
882
- <div class="news">
883
- <a class="thumb" href="/video/arsenal-vs-coventry-nha-vua-mo-man-bang-mot-chien-thang-982662608.html"><img alt="Arsenal vs Coventry: Nhà vua mở màn bằng một chiến thắng?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/18/13/ThumbArsenalvsCoventry-up.jpg" /></a>
884
- <a class="title" href="/video/arsenal-vs-coventry-nha-vua-mo-man-bang-mot-chien-thang-982662608.html">Arsenal vs Coventry: Nhà vua mở màn bằng một chiến thắng?</a>
885
- <span class="info">
886
- 09 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
887
- </span>
888
- <span class="info">
889
- </span>
890
- </div>
891
- </div>
892
- <div class="col m12 w3">
893
- <div class="news">
894
- <a class="thumb" href="/video/truc-tuyen-truoc-tran-arsenal-vs-coventry-hull-vs-mu-983222608.html"><img alt="Trực tuyến trước trận Arsenal vs Coventry, Hull vs MU" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/truc-tuyen-truoc-tran-Arsenal-vs-Coventry.jpg" /></a>
895
- <a class="title" href="/video/truc-tuyen-truoc-tran-arsenal-vs-coventry-hull-vs-mu-983222608.html">Trực tuyến trước trận Arsenal vs Coventry, Hull vs MU</a>
896
- <span class="info">
897
- 11 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
898
- </span>
899
- <span class="info">
900
- </span>
901
- </div>
902
- </div>
903
- <div class="col m12 w3">
904
- <div class="news">
905
- <a class="thumb" href="/video/dt-thai-lan-tap-cach-san-rajamangala-40km-hlv-anthony-hudson-chot-luon-doi-hinh-dau-viet-nam-983202608.html"><img alt=" ĐT Thái Lan tập cách sân Rajamangala 40km, HLV Anthony Hudson chốt luôn đội hình đấu Việt Nam" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/80/HLV-Thai-Lan-chot-doi-hinh.jpg" /></a>
906
- <a class="title" href="/video/dt-thai-lan-tap-cach-san-rajamangala-40km-hlv-anthony-hudson-chot-luon-doi-hinh-dau-viet-nam-983202608.html"> ĐT Thái Lan tập cách sân Rajamangala 40km, HLV Anthony Hudson chốt luôn đội hình đấu Việt Nam</a>
907
- <span class="info">
908
- 13 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
909
- </span>
910
- <span class="info">
911
- </span>
912
- </div>
913
- </div>
914
- <div class="col m12 w3">
915
- <div class="news">
916
- <a class="thumb" href="/video/tro-lai-san-tung-vo-dich-aff-cup-2024-dt-viet-nam-quyet-lam-nen-lich-su-983212608.html"><img alt=" Trở lại sân từng vô địch AFF Cup 2024, ĐT Việt Nam quyết làm nên lịch sử" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/80/Viet-Nam-tap-2-1-8.jpg" /></a>
917
- <a class="title" href="/video/tro-lai-san-tung-vo-dich-aff-cup-2024-dt-viet-nam-quyet-lam-nen-lich-su-983212608.html"> Trở lại sân từng vô địch AFF Cup 2024, ĐT Việt Nam quyết làm nên lịch sử</a>
918
- <span class="info">
919
- 14 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
920
- </span>
921
- <span class="info">
922
- </span>
923
- </div>
924
- </div>
925
- <div class="col m12 w3">
926
- <div class="news">
927
- <a class="thumb" href="/video/vong-1-premier-league-2026-27-lieu-co-cu-soc-983112608.html"><img alt="Nhận định Premier League vòng 1: Liệu có cú sốc?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/thumbvong1-480.jpg" /></a>
928
- <a class="title" href="/video/vong-1-premier-league-2026-27-lieu-co-cu-soc-983112608.html">Nhận định Premier League vòng 1: Liệu có cú sốc?</a>
929
- <span class="info">
930
- 14 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
931
- </span>
932
- <span class="info">
933
- </span>
934
- </div>
935
- </div>
936
- <div class="col m12 w3">
937
- <div class="news">
938
- <a class="thumb" href="/video/ronaldinho-gay-soc-46-tuoi-van-tro-lai-san-co-khoac-ao-clb-serie-c-983192608.html"><img alt="Ronaldinho gây sốc: 46 tuổi vẫn trở lại sân cỏ, khoác áo CLB Serie C" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/26/Ronaldinho480.jpg" /></a>
939
- <a class="title" href="/video/ronaldinho-gay-soc-46-tuoi-van-tro-lai-san-co-khoac-ao-clb-serie-c-983192608.html">Ronaldinho gây sốc: 46 tuổi vẫn trở lại sân cỏ, khoác áo CLB Serie C</a>
940
- <span class="info">
941
- 14 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
942
- </span>
943
- <span class="info">
944
- </span>
945
- </div>
946
- </div>
947
- <div class="col m12 w3">
948
- <div class="news">
949
- <a class="thumb" href="/video/arsenal-no-bom-tan-51-trieu-bang-de-va-hang-thu-983122608.html"><img alt="Arsenal nổ bom tấn 51 triệu bảng để vá hàng thủ" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/26/Konsa480.jpg" /></a>
950
- <a class="title" href="/video/arsenal-no-bom-tan-51-trieu-bang-de-va-hang-thu-983122608.html">Arsenal nổ bom tấn 51 triệu bảng để vá hàng thủ</a>
951
- <span class="info">
952
- 14 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
953
- </span>
954
- <span class="info">
955
- </span>
956
- </div>
957
- </div>
958
- <div class="col m12 w3">
959
- <div class="news">
960
- <a class="thumb" href="/video/truc-tuyen-hop-bao-viet-nam-hlv-kim-sang-sik-noi-gi-khi-pho-chu-tich-thai-lan-mia-mai-dan-nhap-tich-983182608.html"><img alt="Trực tuyến họp báo Việt Nam: HLV Kim Sang Sik nói gì khi Phó Chủ tịch Thái Lan mỉa mai dàn nhập tịch" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/anhliveVN.jpg" /></a>
961
- <a class="title" href="/video/truc-tuyen-hop-bao-viet-nam-hlv-kim-sang-sik-noi-gi-khi-pho-chu-tich-thai-lan-mia-mai-dan-nhap-tich-983182608.html">Trực tuyến họp báo Việt Nam: HLV Kim Sang Sik nói gì khi Phó Chủ tịch Thái Lan mỉa mai dàn nhập tịch</a>
962
- <span class="info">
963
- 16 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
964
- </span>
965
- <span class="info">
966
- </span>
967
- </div>
968
- </div>
969
- <div class="col m12 w3">
970
- <div class="news">
971
- <a class="thumb" href="/video/truc-tuyen-thai-lan-hop-bao-hlv-thach-dau-dt-viet-nam-983162608.html"><img alt=" Trực tuyến Thái Lan họp báo: HLV Anthony Hudson thách đấu ĐT Việt Nam" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/anhlivesua-480.jpg" /></a>
972
- <a class="title" href="/video/truc-tuyen-thai-lan-hop-bao-hlv-thach-dau-dt-viet-nam-983162608.html"> Trực tuyến Thái Lan họp báo: HLV Anthony Hudson thách đấu ĐT Việt Nam</a>
973
- <span class="info">
974
- 18 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
975
- </span>
976
- <span class="info">
977
- </span>
978
- </div>
979
- </div>
980
- <div class="col m12 w3">
981
- <div class="news">
982
- <a class="thumb" href="/video/ban-tin-21-8-dt-viet-nam-chiu-thong-ke-cuc-xau-truoc-chung-ket-thai-lan-choi-chieu-nhu-indo-983152608.html"><img alt="Bản tin 21/8: ĐT Việt Nam chịu thống kê cực xấu trước chung kết, Thái Lan chơi chiêu như Indo" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/Thumb-tin-nong-21-8-480-up.jpg" /></a>
983
- <a class="title" href="/video/ban-tin-21-8-dt-viet-nam-chiu-thong-ke-cuc-xau-truoc-chung-ket-thai-lan-choi-chieu-nhu-indo-983152608.html">Bản tin 21/8: ĐT Việt Nam chịu thống kê cực xấu trước chung kết, Thái Lan chơi chiêu như Indo</a>
984
- <span class="info">
985
- 18 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
986
- </span>
987
- <span class="info">
988
- </span>
989
- </div>
990
- </div>
991
- <div class="col m12 w3">
992
- <div class="news">
993
- <a class="thumb" href="/video/thai-lan-khoa-cdv-viet-nam-chi-3-000-ve-cho-tran-chung-ket-tai-rajamangala-983142608.html"><img alt="Thái Lan 'khóa' CĐV Việt Nam: Chỉ 3.000 vé cho trận chung kết tại Rajamangala" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/26/cdv.jpg" /></a>
994
- <a class="title" href="/video/thai-lan-khoa-cdv-viet-nam-chi-3-000-ve-cho-tran-chung-ket-tai-rajamangala-983142608.html">Thái Lan 'khóa' CĐV Việt Nam: Chỉ 3.000 vé cho trận chung kết tại Rajamangala</a>
995
- <span class="info">
996
- 20 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
997
- </span>
998
- <span class="info">
999
- </span>
1000
- </div>
1001
- </div>
1002
- <div class="col m12 w3">
1003
- <div class="news">
1004
- <a class="thumb" href="/video/truoc-tran-thai-lan-vs-viet-nam-svd-rajamagala-vang-tanh-hon-chua-ba-danh-983132608.html"><img alt="Trước trận Thái Lan vs Việt Nam: SVĐ Rajamagala vắng tanh hơn chùa Bà Đanh" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/livethumbYT-480.jpg" /></a>
1005
- <a class="title" href="/video/truoc-tran-thai-lan-vs-viet-nam-svd-rajamagala-vang-tanh-hon-chua-ba-danh-983132608.html">Trước trận Thái Lan vs Việt Nam: SVĐ Rajamagala vắng tanh hơn chùa Bà Đanh</a>
1006
- <span class="info">
1007
- 21 gi&#x1EDD; tr&#x1B0;&#x1EDB;c
1008
- </span>
1009
- <span class="info">
1010
- </span>
1011
- </div>
1012
- </div>
1013
- <div class="col m12 w3">
1014
- <div class="news">
1015
- <a class="thumb" href="/video/du-doan-ty-so-thai-lan-vs-viet-nam-than-rua-ali-33-chon-doi-nao-983092608.html"><img alt="Dự đoán tỷ số Thái Lan vs Việt Nam: Thần Rùa Ali 33 chọn đội nào?" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/20/80/ThumbRuaThaivsVietnam-480.jpg" /></a>
1016
- <a class="title" href="/video/du-doan-ty-so-thai-lan-vs-viet-nam-than-rua-ali-33-chon-doi-nao-983092608.html">Dự đoán tỷ số Thái Lan vs Việt Nam: Thần Rùa Ali 33 chọn đội nào?</a>
1017
- <span class="info">
1018
- 19:18 ng&#xE0;y 20/08/2026
1019
- </span>
1020
- <span class="info">
1021
- </span>
1022
- </div>
1023
- </div>
1024
- <div class="col m12 w3">
1025
- <div class="news">
1026
- <a class="thumb" href="/video/truyen-thong-anh-dac-biet-chu-y-den-ngoi-no-cua-dt-viet-nam-983072608.html"><img alt="Truyền thông Anh đặc biệt chú ý đến 'ngòi nổ' của ĐT Việt Nam" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/20/26/xuan-son.jpg" /></a>
1027
- <a class="title" href="/video/truyen-thong-anh-dac-biet-chu-y-den-ngoi-no-cua-dt-viet-nam-983072608.html">Truyền thông Anh đặc biệt chú ý đến 'ngòi nổ' của ĐT Việt Nam</a>
1028
- <span class="info">
1029
- 19:18 ng&#xE0;y 20/08/2026
1030
- </span>
1031
- <span class="info">
1032
- </span>
1033
- </div>
1034
- </div>
1035
- <div class="col m12 w3">
1036
- <div class="news">
1037
- <a class="thumb" href="/video/dt-viet-nam-co-mat-tai-thai-lan-ao-trang-van-chua-bi-thay-kim-vut-het-983082608.html"><img alt="ĐT Việt Nam có mặt tại Thái Lan, áo trắng vẫn chưa bị thầy Kim bỏ hết" src="https://cdn.bongdaplus.vn/assets/Media/2026/08/20/26/Kim-Sang-Sik.jpg" /></a>
1038
- <a class="title" href="/video/dt-viet-nam-co-mat-tai-thai-lan-ao-trang-van-chua-bi-thay-kim-vut-het-983082608.html">ĐT Việt Nam có mặt tại Thái Lan, áo trắng vẫn chưa bị thầy Kim bỏ hết</a>
1039
- <span class="info">
1040
- 17:32 ng&#xE0;y 20/08/2026
1041
- </span>
1042
- <span class="info">
1043
- </span>
1044
- </div>
1045
- </div>
1046
- </div>
1047
- </div>
1048
- <div class="clz"></div>
1049
- </section>
1050
-
1051
-
1052
-
1053
- <div class="clx"></div>
1054
- <center><div id="ADBOT" class="banner adweb admob" banner-adpos="footer"></div></center>
1055
- </main>
1056
-
1057
- <style>
1058
- .iframpanel {
1059
- width: 100%;
1060
- max-width: 500px;
1061
- height: 560px;
1062
- overflow: hidden;
1063
- }
1064
-
1065
- </style>
1066
- <div class="paner-wrap">
1067
- <div class="over-layer hide"></div>
1068
- <div id="loginview" class="login-box paner hide iframpanel">
1069
- <div class="pan-capt">
1070
- <i class="pan-close" data-action="hide"></i>
1071
- </div>
1072
-
1073
-
1074
- <iframe src="https://member.bongdaplus.vn/Identity/Account/Login?ReturnUrl=%2FHome%2FLoginFromBongdaplus" frameborder="0" style="overflow:hidden;height:100%;width:100%"></iframe>
1075
-
1076
- </div>
1077
- <div id="commentloginview" class="login-box paner hide iframpanel">
1078
-
1079
-
1080
- <iframe src="https://member.bongdaplus.vn/Identity/Account/Login?ReturnUrl=%2FHome%2FCommentLoginFromBongdaplus" frameborder="0" style="overflow:hidden;height:100%;width:100%"></iframe>
1081
-
1082
- </div>
1083
- </div>
1084
- <input type="hidden" id="txtFullname" value="" />
1085
- <input type="hidden" id="txtUserid" value="" />
1086
-
1087
-
1088
-
1089
-
1090
-
1091
-
1092
- <center><div id="ADFT" class="banner adweb admob" banner-adpos="footer"></div></center>
1093
- <footer>
1094
- <div class="cont-wrap">
1095
- <div class="row">
1096
- <div class="col m12 w3">
1097
- <div class="caption">Tạp chí điện tử Bóng đá</div>
1098
- </div>
1099
- <div class="col m12 w6">
1100
- <p>Giấy phép số 48/GP-BTTTT cấp ngày 05/02/2020</p>
1101
- <p>Tổng biên tập: &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <b>Vũ Khắc Sơn</b></p>
1102
- <p>Phó tổng biên tập: &nbsp; &nbsp;<b>Nguyễn Hà Thanh</b></p>
1103
- <div class="hide-w">
1104
- <p>Địa chỉ: &nbsp;<b>Tầng 6, Tòa nhà Licogi 13 Tower, 164 đường Khuất Duy Tiến, phường Thanh Xuân, Thành phố Hà Nội</b></p>
1105
- <p>Văn phòng giao dịch: <b>Tầng 5-6-7, toà nhà LĐBĐVN, số 18 Lý Văn Phức, phường Ô Chợ Dừa, Hà Nội</b></p>
1106
- <p>Điện thoại: <b>(84.24) 3554 1188 - (84.24) 3554 1199</b></p>
1107
- <p>
1108
- Email: &nbsp; &nbsp; <a href="mailto:toasoan@bongdaplus.vn"><b>toasoan@bongdaplus.vn</b></a> |
1109
- <a href="mailto:vanphong@bongdaplus.vn"><b>vanphong@bongdaplus.vn</b></a>
1110
- </p>
1111
- <div class="spl"></div>
1112
- <p class="caption">Liên hệ quảng cáo</p>
1113
- <p>Hotline: &nbsp;<b>0903 203 412</b></p>
1114
- <p>Email: &nbsp; &nbsp;<a href="mailto:tien.nguyen@giaminhmedia.vn"><b>tien.nguyen@giaminhmedia.vn</b></a></p>
1115
- </div>
1116
- </div>
1117
- <div class="col m12 w3">
1118
- <ul class="bar social-lnk rgt-w">
1119
- <li class="hide-m">Theo dõi thông tin</li>
1120
- <li><a class="ico vff-lnk" href="https://vff.org.vn/" target="_blank" rel="nofollow"></a></li>
1121
- <li><a class="ico fb-lnk" href="https://www.facebook.com/BaoBongDa/"></a></li>
1122
- <li><a class="ico yt-lnk" href="https://www.youtube.com/channel/UCvDYMBE6GipX2VGacqOU2_Q"></a></li>
1123
- </ul>
1124
- </div>
1125
- </div>
1126
- <div class="foot-menu">
1127
- <ul class="bar hide-m">
1128
- <li><a href="#" data-showpaner="#contactview">Thông tin tòa soạn</a></li>
1129
- <li><a href="#" data-showpaner="#advertview">Liên hệ quảng cáo</a></li>
1130
- </ul>
1131
- <div class="copy-rgt">
1132
- Bản quyền &copy;2011 Bongdaplus.vn. Chỉ được phát hành lại thông tin khi
1133
- có sự đồng ý bằng văn bản của Tạp chí điện tử Bóng đá
1134
- </div>
1135
- <div class="clx hide-m"></div>
1136
- </div>
1137
- </div>
1138
- </footer>
1139
-
1140
- <div class="paner-wrap">
1141
- <div class="over-layer hide"></div>
1142
- <div id="contactview" class="contact-box paner hide">
1143
- <div class="pan-capt">
1144
- <div class="pan-title">Thông tin Toà soạn</div>
1145
- <i class="pan-close" data-action="hide"></i>
1146
- </div>
1147
- <div class="pan-cont">
1148
- <div class="xcap lead tx-prime tx-cen">Tạp chí Điện tử Bóng Đá</div>
1149
- <div class="row">
1150
- <div class="col w3 tx-base tx-rgt">Tổng biên tập:</div>
1151
- <div class="col w9"><b>Vũ Khắc Sơn</b></div>
1152
- <div class="clx"></div>
1153
- <div class="col w3 tx-base tx-rgt">Phó Tổng biên tập:</div>
1154
- <div class="col w9">
1155
- <b>Nguyễn Hà Thanh</b>
1156
- </div>
1157
- </div>
1158
- <div class="splt"></div>
1159
- <div class="row">
1160
- <div class="col w3 tx-base tx-rgt">Địa chỉ:</div>
1161
- <div class="col w9"><b>Tầng 6, Tòa nhà Licogi 13 Tower, 164 đường Khuất Duy Tiến, phường Thanh Xuân, Thành phố Hà Nội</b></div>
1162
- <div class="col w3 tx-base tx-rgt">Văn phòng giao dịch:</div>
1163
- <div class="col w9"><b>Tầng 5-6-7, toà nhà LĐBĐVN, số 18 Lý Văn Phức, phường Ô Chợ Dừa, Hà Nội</b></div>
1164
- <div class="col w3 tx-base tx-rgt">Tel:</div>
1165
- <div class="col w9"><b>(84.24) 3554 1188 - (84.24) 3554 1199</b></div>
1166
- <div class="col w3 tx-base tx-rgt">Fax:</div>
1167
- <div class="col w9"><b>(84.24) 3553 9898</b></div>
1168
- <div class="col w3 tx-base tx-rgt">Email:</div>
1169
- <div class="col w9">
1170
- <a href="mailto:toasoan@bongdaplus.vn"><b>toasoan@bongdaplus.vn</b></a>
1171
- <a href="mailto:vanphong@bongdaplus.vn"><b>vanphong@bongdaplus.vn</b></a>
1172
- </div>
1173
- </div>
1174
- </div>
1175
- </div>
1176
-
1177
- <div id="advertview" class="contact-box paner hide">
1178
- <div class="pan-capt">
1179
- <div class="pan-title">Thông tin Liên hệ</div>
1180
- <i class="pan-close" data-action="hide"></i>
1181
- </div>
1182
- <div class="pan-cont">
1183
- <div class="xcap lead tx-prime tx-cen">Tạp chí Điện tử Bóng Đá</div>
1184
- <div class="row">
1185
- <div class="col w4 tx-base tx-rgt">Hotline:</div>
1186
- <div class="col w8"><b>0903 203 412</b></div>
1187
- <div class="col w4 tx-base tx-rgt">Email:</div>
1188
- <div class="col w8"><a href="mailto:tien.nguyen@giaminhmedia.vn"><b>tien.nguyen@giaminhmedia.vn</b></a></div>
1189
- </div>
1190
- <p class="tx-base">Địa chỉ liên hệ:</p>
1191
- <b>Tầng 6, Tòa nhà Licogi 13 Tower, 164 đường Khuất Duy Tiến, phường Thanh Xuân, Thành phố Hà Nội</b>
1192
- <p class="tx-base">Văn phòng giao dịch:</p>
1193
- <b>Tầng 5-6-7, toà nhà LĐBĐVN, số 18 Lý Văn Phức, phường Ô Chợ Dừa, Hà Nội</b>
1194
- </div>
1195
- </div>
1196
-
1197
-
1198
- <div id="loginview" class="login-box paner hide">
1199
- <div class="pan-capt">
1200
- <div class="pan-title">Đăng nhập</div>
1201
- <i class="pan-close" data-action="hide"></i>
1202
- </div>
1203
- <div class="pan-cont">
1204
- <form class="soc-auth" action="#">
1205
- <button><i class="ico gg-color"></i>Google</button>
1206
- <button><i class="ico fb-color"></i>Facebook</button>
1207
- </form>
1208
- <div class="clx"></div>
1209
- <div class="splt"><span>hoặc</span></div>
1210
- <form class="loginer" action="//member.bongdaplus.vn/Identity/Account/Login?ReturnUrl=/" method="post">
1211
- <p>Email:</p>
1212
- <label class="txt-fld" for="email">
1213
- <input id="email" name="Input.Email" type="email" />
1214
- <span>Nhập địa chỉ email của bạn</span>
1215
- </label>
1216
- <label class="err-fld hide" for="email">Địa chỉ email không được để trống</label>
1217
- <p>Mật khẩu:</p>
1218
- <label class="txt-fld" for="passw">
1219
- <input id="passw" name="Input.Password" type="password" data-required="Mật khẩu không được để trống" />
1220
- <span>Nhập mật khẩu của bạn</span>
1221
- </label>
1222
- <label class="err-fld hide" for="passw">Mật khẩu không được để trống</label>
1223
- <p class="ft-sm tx-rgt"><a class="fg-psw" href="#" data-action="hide" data-showpaner="#forgetview">Quên mật khẩu?</a></p>
1224
- <label class="rdo-fld" for="persist">
1225
- <input id="persist" name="Input.RememberMe" type="checkbox" />
1226
- Ghi nhớ thông tin đăng nhập
1227
- </label>
1228
- <br>
1229
- <div class="fld-box">
1230
- <button class="btn major" type="submit">Đăng nhập</button>
1231
- </div>
1232
- <p class="ft-sm tx-cen">
1233
- Bạn chưa có tài khoản?
1234
- <a class="act-now" href="#" data-action="hide" data-showpaner="#registview">Đăng ký ngay</a>
1235
- </p>
1236
- </form>
1237
- </div>
1238
- </div>
1239
- </div>
1240
-
1241
- <script src="/lib/jquery/dist/jquery.min.js"></script>
1242
- <script src="/js/js_cookie.min.js?v=0H3Nuz3aug3afVbUlsu12Puxva3CP4EhJtPExqs54Vg"></script>
1243
- <script src="/js/bongdaplus.js?v=gIIX1pYQ8DB66N40YPyoHGaBgBFoHkPMMCARX4L6NdA"></script>
1244
- <script src="/js/premium.js?v=mbXB8QoXMl1zoEFSeE6z-mwQTb0ot7C8zj_F8JqsvNc"></script>
1245
-
1246
- <script>var _ad_info_code = 'mda/video';</script>
1247
- <script src="/js/advloader.js?v=dvvR0ygTjyYzNcQmQ_dI5bwYeX2Ww0nBjBBmCt9DWjQ"></script>
1248
-
1249
- </body>
1250
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
embed.html DELETED
@@ -1,56 +0,0 @@
1
- <!DOCTYPE html>
2
- <html>
3
- <head>
4
- <meta name="viewport" content="width=device-width" />
5
- <title>EmbedVideo - Bongdaplus.vn </title>
6
-
7
- <link href="/css/video-js.css" rel="stylesheet">
8
- <link href="/css/style.css" rel="stylesheet">
9
- <link href="/css/player.css?new=43666246" rel="stylesheet">
10
- <script src="https://imasdk.googleapis.com/js/sdkloader/ima3.js"></script>
11
- <script src="/js/video.min.js"></script>
12
- <!-- Google tag (gtag.js) -->
13
- <script async src="https://www.googletagmanager.com/gtag/js?id=G-EDHD2H8E9T"></script>
14
- <script>window.dataLayer = window.dataLayer || []; function gtag() { dataLayer.push(arguments); } gtag('js', new Date()); gtag('config', 'G-EDHD2H8E9T');</script>
15
- </head>
16
- <body class="vjs-fullscreen">
17
-
18
- <div class="">
19
- <video id="videoPlayer" class="video-js" preload="auto" width="100%" height="100%" poster="https://cdn.bongdaplus.vn/assets/Media/2026/08/18/13/ThumbHullvsMU-up.jpg" controls disablePictureInPicture>
20
- <source src="https://cdn.bongdaplus.vn/assets//Media/2026/08/18/13/thumbhullvsmu-up.jpg" type="video/mp4">
21
- </video>
22
- <script>
23
- var adpath = 'mob/'; var cat = 127, evt = 0;
24
- if (window.matchMedia('(min-width: 1023px)').matches || (window.parent.Detect && !window.parent.Detect.isMob())) {
25
- adpath = 'web/';
26
- }
27
- adpath += 'mda';
28
- document.write('<script src="/adscripts/' + adpath + '/vast.js?new=' + (new Date()).getTime() + '"></' + 'script>');
29
- </script>
30
-
31
- <script>
32
- var vdo = videojs('videoPlayer', { autoplay: true });
33
- vdo.relatedCarousel([{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/18/80/Anh-chu-480-x-270.jpg', vdoref: '/video/du-doan-ty-so-hull-mu-than-rua-ali-33-chon-doi-nao-982702608.html', title: 'Dự đoán tỷ số Hull vs MU: Thần Rùa Ali 33 chọn đội nào?', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/18/13/ThumbHullvsMU-up.jpg', vdoref: '/video/hull-city-vs-mu-quy-do-vao-hang-bat-ho-982672608.html', title: 'Hull City vs MU: Quỷ Đỏ vào hang bắt hổ', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/19/80/Brentford-vs-Tottenham-480-x-270-moi.jpg', vdoref: '/video/du-doan-ty-so-brentford-vs-tottenham-premier-league-2026-27-than-rua-ali-33-chon-doi-nao-982862608.html', title: 'Dự đoán tỷ số Brentford vs Tottenham: Thần Rùa Ali 33 chọn đội nào?', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/19/13/Brenfort-vs-Tottenham.jpg', vdoref: '/video/brentford-vs-tottenham-de-zerbi-vuot-ai-gtech-982822608.html', title: 'Brentford vs Tottenham: De Zerbi vượt ải Gtech', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/17/0/thumblai.jpg', vdoref: '/video/du-doan-ty-so-arsenal-vs-coventry-than-rua-ali-33-chon-doi-nao-982612608.html', title: 'Dự đoán tỷ số Arsenal vs Coventry: Thần Rùa Ali 33 chọn đội nào?', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/18/13/ThumbArsenalvsCoventry-up.jpg', vdoref: '/video/arsenal-vs-coventry-nha-vua-mo-man-bang-mot-chien-thang-982662608.html', title: 'Arsenal vs Coventry: Nhà vua mở màn bằng một chiến thắng?', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/truc-tuyen-truoc-tran-Arsenal-vs-Coventry.jpg', vdoref: '/video/truc-tuyen-truoc-tran-arsenal-vs-coventry-hull-vs-mu-983222608.html', title: 'Trực tuyến trước trận Arsenal vs Coventry, Hull vs MU', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/80/HLV-Thai-Lan-chot-doi-hinh.jpg', vdoref: '/video/dt-thai-lan-tap-cach-san-rajamangala-40km-hlv-anthony-hudson-chot-luon-doi-hinh-dau-viet-nam-983202608.html', title: ' ĐT Thái Lan tập cách sân Rajamangala 40km, HLV Anthony Hudson chốt luôn đội hình đấu Việt Nam', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/80/Viet-Nam-tap-2-1-8.jpg', vdoref: '/video/tro-lai-san-tung-vo-dich-aff-cup-2024-dt-viet-nam-quyet-lam-nen-lich-su-983212608.html', title: ' Trở lại sân từng vô địch AFF Cup 2024, ĐT Việt Nam quyết làm nên lịch sử', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/thumbvong1-480.jpg', vdoref: '/video/vong-1-premier-league-2026-27-lieu-co-cu-soc-983112608.html', title: 'Nhận định Premier League vòng 1: Liệu có cú sốc?', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/26/Ronaldinho480.jpg', vdoref: '/video/ronaldinho-gay-soc-46-tuoi-van-tro-lai-san-co-khoac-ao-clb-serie-c-983192608.html', title: 'Ronaldinho gây sốc: 46 tuổi vẫn trở lại sân cỏ, khoác áo CLB Serie C', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/26/Konsa480.jpg', vdoref: '/video/arsenal-no-bom-tan-51-trieu-bang-de-va-hang-thu-983122608.html', title: 'Arsenal nổ bom tấn 51 triệu bảng để vá hàng thủ', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/anhliveVN.jpg', vdoref: '/video/truc-tuyen-hop-bao-viet-nam-hlv-kim-sang-sik-noi-gi-khi-pho-chu-tich-thai-lan-mia-mai-dan-nhap-tich-983182608.html', title: 'Trực tuyến họp báo Việt Nam: HLV Kim Sang Sik nói gì khi Phó Chủ tịch Thái Lan mỉa mai dàn nhập tịch', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/anhlivesua-480.jpg', vdoref: '/video/truc-tuyen-thai-lan-hop-bao-hlv-thach-dau-dt-viet-nam-983162608.html', title: ' Trực tuyến Thái Lan họp báo: HLV Anthony Hudson thách đấu ĐT Việt Nam', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/Thumb-tin-nong-21-8-480-up.jpg', vdoref: '/video/ban-tin-21-8-dt-viet-nam-chiu-thong-ke-cuc-xau-truoc-chung-ket-thai-lan-choi-chieu-nhu-indo-983152608.html', title: 'Bản tin 218: ĐT Việt Nam chịu thống kê cực xấu trước chung kết, Thái Lan chơi chiêu như Indo', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/26/cdv.jpg', vdoref: '/video/thai-lan-khoa-cdv-viet-nam-chi-3-000-ve-cho-tran-chung-ket-tai-rajamangala-983142608.html', title: 'Thái Lan khóa CĐV Việt Nam: Chỉ 3.000 vé cho trận chung kết tại Rajamangala', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/21/13/livethumbYT-480.jpg', vdoref: '/video/truoc-tran-thai-lan-vs-viet-nam-svd-rajamagala-vang-tanh-hon-chua-ba-danh-983132608.html', title: 'Trước trận Thái Lan vs Việt Nam: SVĐ Rajamagala vắng tanh hơn chùa Bà Đanh', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/20/80/ThumbRuaThaivsVietnam-480.jpg', vdoref: '/video/du-doan-ty-so-thai-lan-vs-viet-nam-than-rua-ali-33-chon-doi-nao-983092608.html', title: 'Dự đoán tỷ số Thái Lan vs Việt Nam: Thần Rùa Ali 33 chọn đội nào?', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/20/26/xuan-son.jpg', vdoref: '/video/truyen-thong-anh-dac-biet-chu-y-den-ngoi-no-cua-dt-viet-nam-983072608.html', title: 'Truyền thông Anh đặc biệt chú ý đến ngòi nổ của ĐT Việt Nam', target: '_parent' },{ poster: 'https://cdn.bongdaplus.vn/assets/Media/2026/08/20/26/Kim-Sang-Sik.jpg', vdoref: '/video/dt-viet-nam-co-mat-tai-thai-lan-ao-trang-van-chua-bi-thay-kim-vut-het-983082608.html', title: 'ĐT Việt Nam có mặt tại Thái Lan, áo trắng vẫn chưa bị thầy Kim bỏ hết', target: '_parent' }]);
34
-
35
- var vst = window['vast'] || '';
36
- if (vst) {
37
- vst = vst.replace('[yo_page_url]', window.parent.location.href);
38
- vdo.adsVastClient(vst);
39
- }
40
- vdo.ready(function () { vdo.play(); }); var counter = 0;
41
- if (window.parent.location.href.indexOf('.vn/video/') < 0) {
42
- window.addEventListener("click", function () {
43
- if (!counter) {
44
- var img = document.createElement("img");
45
- img.src = "/imgthumbnail/98267/1/1";
46
- img.width = 0;
47
- img.height = 0;
48
- document.body.appendChild(img);
49
- counter++;
50
- }
51
- });
52
- }
53
- </script>
54
- </div>
55
- </body>
56
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
headers.txt DELETED
@@ -1,9 +0,0 @@
1
- HTTP/2 200
2
- strict-transport-security: max-age=31536000; includeSubDomains; preload
3
- set-cookie: BongdaplusView_98267_1=1; expires=Sat, 22 Aug 2026 02:21:27 GMT; path=/
4
- x-frame-options: SAMEORIGIN
5
- x-xss-protection: 1; mode=block
6
- x-content-type-options: nosniff
7
- x-powered-by: ASP.NET
8
- date: Sat, 22 Aug 2026 01:51:27 GMT
9
-
 
 
 
 
 
 
 
 
 
 
index_v2.html DELETED
@@ -1,98 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="vi">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
6
- <title>VNEWS - Tin Tức Việt Nam</title>
7
- <meta name="description" content="Tin tức tổng hợp, bóng đá trực tiếp, video highlight, AI tóm tắt.">
8
- <meta property="og:title" content="VNEWS - Tin Tức Việt Nam">
9
- <meta property="og:image" content="https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg">
10
- <link rel="canonical" href="https://bep40-vnews.hf.space">
11
- <link rel="stylesheet" href="/static/wc2026.css"><link rel="stylesheet" href="/static/fm_fix.css">
12
- <script src="https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js"></script>
13
- <style>
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{width:100%;height:100%;object-fit:cover;border:none}.tiktok-slide iframe{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:177.78vh;min-width:100%;height:100%;border:none;pointer-events:none;max-width:none}.tiktok-slide.ratio-wide video{width:100%;height:100%;object-fit:contain}.tiktok-slide.ratio-wide iframe{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:calc(100vw - 1px);height:56.25vw;max-width:100vw;max-height:100%;border:none;pointer-events:none}.tiktok-bottom{position:absolute;bottom:0;left:0;right:60px;padding:12px 10px 16px;background:linear-gradient(transparent,rgba(0,0,0,.85));z-index:3}.tiktok-title{font-size:12px;color:#fff}.tiktok-counter{position:absolute;top:8px;left:8px;background:rgba(0,0,0,.5);font-size:9px;padding:2px 7px;border-radius:8px;color:#fff;z-index:4}.tiktok-right{position:absolute;right:8px;bottom:100px;display:flex;flex-direction:column;align-items:center;gap:14px;z-index:5}.tiktok-right-btn{display:flex;flex-direction:column;align-items:center;gap:2px;background:none;border:0;color:#fff;cursor:pointer;font-size:10px}.tiktok-right-btn .icon{width:42px;height:42px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:20px}.tiktok-right-btn .count{font-size:10px;color:#ddd}.inline-comments{position:absolute;bottom:0;left:0;right:0;max-height:60%;min-height:140px;background:rgba(18,18,18,.95);border-radius:14px 14px 0 0;z-index:10;overflow:clip;display:flex;flex-direction:column}.inline-cmt-header{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-bottom:1px solid #333;color:#5cb87a;font-size:12px;font-weight:700;flex-shrink:0}.inline-cmt-header button{background:none;border:0;color:#fff;font-size:16px;cursor:pointer}.inline-cmt-list{flex:1 1 auto;overflow-y:auto;padding:6px 10px;max-height:140px;min-height:40px}.inline-cmt-item{background:#222;border-radius:8px;padding:6px 8px;margin:4px 0;color:#ccc;font-size:11px;line-height:1.3}.inline-cmt-time{font-size:9px;color:#777;margin-right:6px}.inline-cmt-input{display:flex;gap:6px;padding:8px 10px;border-top:1px solid #333;flex-shrink:0;position:sticky;bottom:0;background:rgba(18,18,18,.98)}.inline-cmt-input input{flex:1;background:#222;border:1px solid #444;color:#eee;border-radius:16px;padding:7px 12px;font-size:11px;min-height:32px}.inline-cmt-input button{background:#2d8659;border:0;color:#fff;border-radius:16px;padding:7px 12px;font-size:11px;cursor:pointer;min-height:32px}.wc2026-section{margin:6px 4px;background:linear-gradient(135deg,#0d1117,#1a1a3a);border:1px solid #1a3a5a;border-radius:10px;overflow:hidden}.wc-header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:linear-gradient(90deg,#0b2e4a,#1a3a5a)}.wc-header h2{font-size:15px;color:#fff;margin:0}.wc-live-badge{font-size:10px;color:#e74c3c;font-weight:700;animation:wc-pulse 1.5s infinite}@keyframes wc-pulse{0%,100%{opacity:1}50%{opacity:.4}}.wc-tabs{display:flex;gap:4px;padding:8px 10px;overflow-x:auto;scrollbar-width:none}.wc-tabs::-webkit-scrollbar{display:none}.wc-tab{padding:5px 10px;background:#1a2a3a;border:1px solid #2a3a4a;border-radius:12px;color:#8ab4d8;font-size:10px;cursor:pointer;white-space:nowrap;flex-shrink:0}.wc-tab.active{background:#0b6bcb;border-color:#0b6bcb;color:#fff;font-weight:700}.wc-content{padding:8px 10px;max-height:500px;overflow-y:auto}.wc-news-grid{display:flex;flex-direction:column;gap:8px}.wc-news-item{display:flex;gap:8px;padding:8px;background:#1a2030;border-radius:8px;cursor:pointer}.wc-news-item:active{opacity:.8}.wc-news-img{flex:0 0 70px;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#222}.wc-news-img img{width:100%;height:100%;object-fit:cover}.wc-news-text{flex:1;min-width:0}.wc-news-title{font-size:11px;font-weight:700;color:#eee;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wc-news-via{font-size:9px;color:#6a9fca;margin-top:2px}.ls-section{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.ls-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.ls-header h3{color:#f0c040;font-size:13px;font-weight:800}.ls-tabs{display:flex;gap:4px;padding:0 10px 8px;overflow-x:auto;scrollbar-width:none}.ls-tabs::-webkit-scrollbar{display:none}.ls-tab{padding:4px 10px;background:#222;border:1px solid #333;border-radius:12px;color:#999;font-size:10px;white-space:nowrap;cursor:pointer;flex-shrink:0}.ls-tab.active{background:#2d8659;border-color:#2d8659;color:#fff;font-weight:700}.ls-content{max-height:420px;overflow-y:auto;padding:0 6px 8px;font-size:12px;color:#ddd}.ls-content ul{list-style:none;padding:0;margin:0}.ls-content .title-content{display:flex;gap:6px;align-items:center;background:#222;border-radius:4px;margin:4px 0;padding:5px 8px}.ls-content .title-content img{width:18px;height:18px}.ls-content .title-content strong{font-size:11px;color:#ccc}.ls-content .match-detail{padding:6px;border-bottom:1px solid #262626;cursor:pointer}.ls-content .match-detail:hover{background:#1a2a1f}.ls-content .match{display:flex;flex-wrap:wrap;align-items:center;gap:4px}.ls-content .datetime{width:100%;font-size:9px;color:#888}.ls-content .teams{display:flex;width:100%;align-items:center;gap:4px}.ls-content .team{flex:1;display:flex;align-items:center;gap:4px;min-width:0;text-decoration:none}.ls-content .team .name{font-size:11px;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ls-content .team .logo img{width:18px;height:18px}.ls-content .home-team{justify-content:flex-end;text-align:right}.ls-content .status{flex:0 0 54px;text-align:center}.ls-content .status a{color:#fff;text-decoration:none;font-weight:800;font-size:12px}.ls-content .status .label{font-size:8px;color:#888;display:block}.ls-content .status .label.live{color:#e74c3c}.ls-content .info,.ls-content .btns{display:none}.ls-content table{width:100%;border-collapse:collapse;font-size:11px;color:#ccc}.ls-content table th{background:#222;color:#999;padding:5px 4px;font-size:10px;border-bottom:1px solid #333}.ls-content table td{padding:4px 3px;border-bottom:1px solid #1a1a1a}.ls-content table .team-name{display:flex;align-items:center;gap:4px}.ls-content table .team-name img{width:16px;height:16px}.ls-content table .pts{font-weight:800;color:#f0c040}.match-overlay{position:fixed;inset:0;background:#111;z-index:9999;display:none;flex-direction:column;overflow:auto}.match-overlay.active{display:flex}.mo-header{padding:10px;background:#1a1a1a;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:1}.mo-header h3{font-size:13px;color:#eee}.mo-close{background:none;border:0;color:#fff;font-size:22px;cursor:pointer}.mo-tabs{display:flex;gap:4px;padding:8px 10px;background:#1a1a1a;overflow-x:auto}.mo-tab{padding:5px 12px;background:#222;border:1px solid #333;border-radius:10px;color:#999;font-size:10px;cursor:pointer;white-space:nowrap}.mo-tab.active{background:#2d8659;color:#fff}.mo-body{padding:8px;overflow-x:auto;font-size:12px;color:#ddd}.mo-body ul{list-style:none;padding:0;margin:0}.mo-body li{padding:5px 0;border-bottom:1px solid #222}.featured-match{margin:6px 4px;background:linear-gradient(135deg,#1a2a1f,#0d1117);border:1px solid #2d8659;border-radius:10px;padding:12px;cursor:pointer}.fm-league{text-align:center;color:#5cb87a;font-size:9px;font-weight:700;text-transform:uppercase}.fm-teams{display:flex;align-items:center;justify-content:center;gap:10px;margin-top:6px}.fm-team{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px}.fm-team img{width:32px;height:32px;object-fit:contain}.fm-team span{font-size:10px;color:#ccc;text-align:center}.fm-score{font-size:22px;font-weight:900;min-width:60px;text-align:center;color:#fff}.fm-status{text-align:center;margin-top:6px;font-size:9px;color:#e74c3c;font-weight:700}.fm-status.upcoming{color:#f0c040}.ai-compose{margin:6px 4px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.ai-compose-title{font-size:13px;font-weight:800;color:#5cb87a;margin-bottom:8px}.ai-compose-row{display:flex;gap:6px;margin-top:6px}.ai-compose input{flex:1;background:#222;border:1px solid #333;color:#eee;border-radius:18px;padding:9px 12px;font-size:12px;min-width:0}.ai-compose button{background:#2d8659;border:0;color:#fff;border-radius:18px;padding:9px 12px;font-size:11px;font-weight:700;cursor:pointer;white-space:nowrap}.ai-compose button.secondary{background:#333}.ai-compose button.hot-short-btn{background:linear-gradient(135deg,#8b1a5a,#c0392b);border:1px solid #e07050;color:#fff;box-shadow:0 0 10px rgba(192,57,43,.4);animation:hot-pulse 2s infinite}@keyframes hot-pulse{0%,100%{box-shadow:0 0 6px rgba(192,57,43,.35)}50%{box-shadow:0 0 16px rgba(192,57,43,.75)}}.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}.wall-actions button.wall-btn-design{background:#1a3a5a;border-color:#3a5a7a;color:#fff;font-weight:700}#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
- .hl-load-more:hover{background:#2a2a2a !important}
16
- .slider-wrap{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}
17
- .slider-track{display:flex;overflow-x:auto;gap:8px;padding:4px 10px 10px;scrollbar-width:none}
18
- .slider-track::-webkit-scrollbar{display:none}
19
- .slider-item{flex:0 0 160px;cursor:pointer}
20
- .slider-thumb{position:relative;width:100%;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#333}
21
- .slider-thumb img,.slider-thumb video{width:100%;height:100%;object-fit:cover}
22
- .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}
23
- .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}
24
- .ls-section-header{padding:8px 10px;font-size:11px;font-weight:700;color:#5cb87a;border-bottom:1px solid #2a2a2a}
25
- .ls-section-header.upcoming{color:#f0c040}
26
- .ls-section-header.finished{color:#e74c3c}
27
- .ls-section-header.yesterday{color:#8ab4f0}
28
- .ls-section-header.older{color:#888}
29
- .hl-load-more{margin:4px 8px 8px;background:#222;border:1px solid #333;color:#ccc;padding:6px 12px;border-radius:8px;font-size:11px;cursor:pointer;width:calc(100% - 16px);display:block}
30
- .hl-load-more:hover{background:#2a2a2a}
31
- </style>
32
- </head>
33
- <body>
34
- <div class="header"><h1>📰 VNEWS</h1><p>Tin tức · Bóng đá LIVE · Highlight · AI · World Cup 2026</p></div>
35
- <div class="cats" id="cat-bar"></div>
36
- <div id="home-hl-section"></div>
37
- <div id="view-home" class="view active"><div class="loading">Đang tải...</div></div>
38
- <div id="view-cat" class="view"></div>
39
- <div id="view-video" class="view"></div>
40
- <div id="view-tiktok" class="view"></div>
41
- <div id="view-article" class="view"></div>
42
- <div class="match-overlay" id="match-overlay">
43
- <div class="mo-header"><h3 id="mo-title">Chi tiết trận đấu</h3><button class="mo-close" onclick="closeMatch()">✕</button></div>
44
- <div class="mo-tabs"><span class="mo-tab active" onclick="loadMatchTab('detail')">📋 Chi tiết</span><span class="mo-tab" onclick="loadMatchTab('comm')">Diễn biến</span><span class="mo-tab" onclick="loadMatchTab('stats')">Thống kê</span></div>
45
- <div class="mo-body" id="mo-body"><div class="loading">Đang tải...</div></div>
46
- </div>
47
- <div id="progress-toast"></div>
48
- <script>
49
- var _cats=[],_hlLeagueData={},_currentArticle=null;window._currentEventId='';
50
- function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]))}
51
- function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')}
52
- 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)}}
53
- function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}}
54
- function doShare(title,url,img,postId){
55
- var shareUrl;
56
- if(postId){
57
- shareUrl = SPACE+'/s?post_id='+encodeURIComponent(postId)+'&title='+encodeURIComponent(title);
58
- } else {
59
- shareUrl = SPACE+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||'');
60
- }
61
- // Use clipboard API first (modern, works on Chrome mobile 85+)
62
- if(navigator.clipboard && navigator.clipboard.writeText){
63
- navigator.clipboard.writeText(shareUrl).then(function(){
64
- toast('📋 Đã sao chép link!');
65
- try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){}
66
- }).catch(function(){
67
- try{
68
- var ta=document.createElement('textarea');ta.value=shareUrl;ta.style.position='fixed';ta.style.left='-9999px';ta.style.top='-9999px';ta.style.opacity='0';
69
- document.body.appendChild(ta);ta.select();ta.setSelectionRange(0,99999);
70
- if(document.execCommand('copy')){toast('📋 Đã sao chép link!');}else{prompt('📋 Sao chép link:', shareUrl);}
71
- document.body.removeChild(ta);
72
- }catch(e){prompt('📋 Sao chép link:', shareUrl);}
73
- try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){}
74
- });
75
- } else {
76
- try{
77
- var ta=document.createElement('textarea');ta.value=shareUrl;ta.style.position='fixed';ta.style.left='-9999px';ta.style.top='-9999px';ta.style.opacity='0';
78
- document.body.appendChild(ta);ta.select();ta.setSelectionRange(0,99999);
79
- if(document.execCommand('copy')){toast('📋 Đã sao chép link!');}else{prompt('📋 Sao chép link:', shareUrl);}
80
- document.body.removeChild(ta);
81
- }catch(e){prompt('📋 Sao chép link:', shareUrl);}
82
- try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){}
83
- }
84
- }
85
- 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)});}
86
- var SPACE=location.origin;
87
- </script>
88
- <script src="/static/app_v2.js?v=20260827fptwall"></script>
89
- <script src="/static/designer_v2.js?v=20260817fix1"></script>
90
- <script src="/static/yt_live.js"></script>
91
- <script src="/static/vtv_init.js"></script>
92
- <script src="/static/hot_multi.js?v=1"></script>
93
- <script src="/static/wc2026_v2.js"></script>
94
- <script src="/static/live_mode.js"></script>
95
- <script src="/static/match_detail_v6.js"></script>
96
- <script>init();loadHome();</script>
97
- </body>
98
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
logs_route.py DELETED
@@ -1,106 +0,0 @@
1
- """Independent logs page for VNEWS Space.
2
- Serves /logs (HTML) and /logs.txt (raw) so build/runtime errors are visible
3
- even when the Hugging Face build-logs tab is stuck/unavailable.
4
- Mounted from _run.py.
5
- """
6
- import os
7
- import time
8
- import json
9
- import subprocess
10
- from fastapi import Request
11
- from fastapi.responses import HTMLResponse, PlainTextResponse
12
-
13
- try:
14
- from app_v2_entry import app
15
- except Exception:
16
- from main import app
17
-
18
- BUILD_DONE = "/app/.build_done"
19
- DATA_DIR = '/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data')
20
-
21
-
22
- def _collect():
23
- lines = []
24
- lines.append("=== VNEWS LOGS ===")
25
- lines.append("generated: " + time.strftime('%Y-%m-%d %H:%M:%S', time.localtime()))
26
- lines.append("")
27
- # Build marker
28
- if os.path.exists(BUILD_DONE):
29
- lines.append("[BUILD] .build_done exists -> container started OK")
30
- try:
31
- lines.append("[BUILD] built at: " + open(BUILD_DONE).read().strip())
32
- except Exception:
33
- pass
34
- else:
35
- lines.append("[BUILD] WARNING: .build_done MISSING -> uvicorn started before build finished?")
36
- lines.append("")
37
-
38
- # Space status from HF runtime file
39
- try:
40
- import json as _j
41
- mj = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.huggingface', 'main.json')
42
- if os.path.exists(mj):
43
- lines.append("[RUNTIME] .huggingface/main.json present")
44
- else:
45
- lines.append("[RUNTIME] .huggingface/main.json NOT found")
46
- except Exception as e:
47
- lines.append("[RUNTIME] error: " + str(e))
48
- lines.append("")
49
-
50
- # Data dir contents
51
- lines.append("[DATA] dir=" + DATA_DIR)
52
- try:
53
- if os.path.isdir(DATA_DIR):
54
- for f in sorted(os.listdir(DATA_DIR)):
55
- p = os.path.join(DATA_DIR, f)
56
- lines.append(" - %s (%d bytes)" % (f, os.path.getsize(p)))
57
- else:
58
- lines.append(" (data dir missing)")
59
- except Exception as e:
60
- lines.append(" error: " + str(e))
61
- lines.append("")
62
-
63
- # Recent container logs (stdout) if captured
64
- log_paths = ["/tmp/vnews_stdout.log", os.path.join(DATA_DIR, "app.log")]
65
- for lp in log_paths:
66
- if os.path.exists(lp):
67
- lines.append("[STDOUT] tail of " + lp + ":")
68
- try:
69
- with open(lp, "r", errors="replace") as fh:
70
- tail = fh.read().splitlines()[-50:]
71
- for l in tail:
72
- lines.append(" " + l)
73
- except Exception as e:
74
- lines.append(" read error: " + str(e))
75
- lines.append("")
76
-
77
- # Environment hints
78
- lines.append("[ENV] HF_SPACE: " + os.environ.get("HF_SPACE", "?"))
79
- lines.append("[ENV] SPACE_ID: " + os.environ.get("SPACE_ID", "?"))
80
- lines.append("[ENV] CUDA/CPU: " + ("gpu" if os.environ.get("CUDA_VISIBLE_DEVICES") else "cpu"))
81
- lines.append("")
82
- lines.append("=== END ===")
83
- return "\n".join(lines)
84
-
85
-
86
- @app.get("/logs")
87
- def logs_page(request: Request):
88
- txt = _collect()
89
- html = (
90
- "<!DOCTYPE html><html lang='vi'><head><meta charset='utf-8'>"
91
- "<meta name='viewport' content='width=device-width,initial-scale=1'>"
92
- "<title>VNEWS Logs</title>"
93
- "<style>body{background:#0d1117;color:#c9d1d9;font-family:monospace;padding:16px}"
94
- "pre{white-space:pre-wrap;word-break:break-word;font-size:13px;line-height:1.5}"
95
- "a{color:#58a6ff}</style></head><body>"
96
- "<h2>VNEWS — Build & Runtime Logs</h2>"
97
- "<p><a href='/logs.txt'>📄 raw text</a> · refresh để cập nhật</p>"
98
- "<pre>" + txt.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;") + "</pre>"
99
- "</body></html>"
100
- )
101
- return HTMLResponse(html)
102
-
103
-
104
- @app.get("/logs.txt")
105
- def logs_raw(request: Request):
106
- return PlainTextResponse(_collect())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
main.py CHANGED
@@ -1,7 +1,7 @@
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, date
5
  from collections import defaultdict
6
 
7
  VN_TZ = timezone(timedelta(hours=7))
@@ -51,7 +51,7 @@ _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":36781,"laliga":38843,"seriea":36072,"bundesliga":40040,"ligue1":37298}
55
  HL_LEAGUES = {
56
  "premier-league":{"path":"anh/premier-league","name":"Premier League","emoji":"🏴󠁧󠁢󠁥󠁮󠁧󠁿"},
57
  "fa-cup":{"path":"anh/fa-cup","name":"FA Cup","emoji":"🏆"},
@@ -69,17 +69,8 @@ def _cached(key, fn, ttl=None):
69
  except: data=_cache.get(key,{}).get("d",[])
70
  _cache[key]={"d":data,"t":now}; return data
71
  def _get(url, headers=None):
72
- h=headers or HEADERS
73
- for attempt in range(3):
74
- try:
75
- r=requests.get(url, headers=h, timeout=15)
76
- r.encoding="utf-8"
77
- return BeautifulSoup(r.text,"lxml")
78
- except Exception:
79
- if attempt < 2:
80
- time.sleep(0.5 * (attempt + 1))
81
- else:
82
- raise
83
  def fetch_bongda_api(endpoint):
84
  try:
85
  r=requests.get(f"https://bongda.com.vn{endpoint}", headers=BONGDA_HEADERS, timeout=10)
@@ -160,7 +151,7 @@ def proxy_img(url: str = Query(...)):
160
  def _scrape_xemlaibongda_page(page_path, limit=20):
161
  try:
162
  url = f"https://xemlaibongda.top/{page_path}" if page_path else "https://xemlaibongda.top/"
163
- r = requests.get(url, headers=HEADERS, timeout=8)
164
  if r.status_code != 200: return []
165
  r.encoding = "utf-8"
166
  soup = BeautifulSoup(r.text, "lxml")
@@ -218,7 +209,7 @@ def _scrape_xemlaibongda_page(page_path, limit=20):
218
  if not img_src:
219
  slug = clean_href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
220
  img_src = f"https://xemlaibongda.top/uploads/thumb/{slug}.jpg"
221
- videos.append({"title": title[:100], "link": clean_href, "img": img_src, "source": "xemlaibongda", "published": _video_published(img_src, clean_href)})
222
  if len(videos) >= limit: break
223
  return videos
224
  except Exception as e:
@@ -227,240 +218,18 @@ def _scrape_xemlaibongda_page(page_path, limit=20):
227
  def scrape_xemlaibongda(): return _scrape_xemlaibongda_page("", 20)
228
  def scrape_highlights_by_league(league_key):
229
  if league_key not in HL_LEAGUES: return []
230
- # Try bongdaplus.vn first (reliable from HF Spaces), fallback from there
231
- bp_vids = scrape_bongdaplus_by_league(league_key, 20)
232
- if bp_vids: return bp_vids
233
- # Fallback: xemlaibongda.top (may be slow/unreachable)
234
- vids = _scrape_xemlaibongda_page(HL_LEAGUES[league_key]["path"], 20)
235
- if vids: return vids
236
- # Ultimate fallback: bongdaplus.vn general video feed, only for primary home league
237
- if league_key == "premier-league":
238
- return scrape_bongdaplus_videos(20)
239
- return []
240
  def scrape_all_league_highlights():
241
  results = {}
242
  def _fetch(key): return key, scrape_highlights_by_league(key)
243
  with ThreadPoolExecutor(8) as ex:
244
  futs = [ex.submit(_fetch, k) for k in HL_LEAGUES]
245
- for f in as_completed(futs, timeout=20):
246
  try: key, vids = f.result()
247
  except: continue
248
  if vids: results[key] = vids
249
- if not results:
250
- fb = scrape_bongdaplus_videos(20)
251
- if fb:
252
- results["premier-league"] = fb
253
- # The league sub-pages resolve to the SAME content from this server (the
254
- # football sites serve one generic list regardless of league path). Return
255
- # the unique videos once per league (deduped against the global set) and
256
- # enrich with the general bongdaplus feed so the homepage slider gets a
257
- # genuinely bigger, duplicate-free list instead of 8x the same 10 clips.
258
- global_seen = set()
259
- seen_per_league = {}
260
- enriched = {}
261
- try:
262
- extra = scrape_bongdaplus_videos(40)
263
- except Exception:
264
- extra = []
265
- for key, vids in list(results.items()):
266
- uniq = []
267
- fresh = []
268
- for v in vids:
269
- link = (v or {}).get("link", "")
270
- if not link or link in seen_per_league.get(key, set()): continue
271
- seen_per_league.setdefault(key, set()).add(link)
272
- if link in global_seen:
273
- fresh.append(v) # duplicate across leagues — will dedupe
274
- continue
275
- global_seen.add(link)
276
- uniq.append(v)
277
- # league-local unique list
278
- if uniq:
279
- results[key] = uniq
280
- # still add across-league dupes to each league so per-league feeds
281
- # are non-empty, but ONLY if the league has < 10 unique videos
282
- if len(uniq) < 10:
283
- for v in fresh:
284
- link = (v or {}).get("link", "")
285
- if link in seen_per_league.get(key, set()): continue
286
- seen_per_league[key].add(link)
287
- uniq.append(v)
288
- results[key] = uniq
289
- # enrich league with global feed leftovers when it's still thin
290
- if len(uniq) < 10:
291
- for v in extra:
292
- link = (v or {}).get("link", "")
293
- if not link or link in seen_per_league.get(key, set()): continue
294
- seen_per_league[key].add(link)
295
- uniq.append(v)
296
- results[key] = uniq
297
  return results
298
 
299
- BDP_HL = "https://bongdaplus.vn/video"
300
- def _video_published(img_src, link=""):
301
- """Best-effort publish date from image URL (bongdaplus stores dates as
302
- /Media/YYYY/MM/DD/) or from a dated slug. Returns 'YYYY-MM-DD' or ''."""
303
- try:
304
- if img_src:
305
- m = re.search(r"/Media/(\d{4})/(\d{2})/(\d{2})/", img_src)
306
- if m:
307
- return "%s-%s-%s" % (m.group(1), m.group(2), m.group(3))
308
- if link:
309
- m2 = re.search(r"/(\d{4})/(\d{2})/(\d{2})/", link)
310
- if m2:
311
- return "%s-%s-%s" % (m2.group(1), m2.group(2), m2.group(3))
312
- except Exception:
313
- pass
314
- return ""
315
-
316
- def scrape_bongdaplus_videos(limit=20):
317
- """Scrape football highlight/clip videos from bongdaplus.vn/video (reachable from HF Spaces)."""
318
- try:
319
- r = requests.get(BDP_HL, headers=HEADERS, timeout=15)
320
- if r.status_code != 200: return []
321
- r.encoding = "utf-8"
322
- soup = BeautifulSoup(r.text, "lxml")
323
- videos = []; seen = set()
324
- for a in soup.find_all("a", href=True):
325
- href = a.get("href","")
326
- if "/video/" not in href or not href.endswith(".html"): continue
327
- if not href.startswith("http"): href = "https://bongdaplus.vn" + href
328
- link = href.split("?")[0].split("#")[0]
329
- if link in seen: continue
330
- seen.add(link)
331
- img_src = ""
332
- img = a.find("img")
333
- if img:
334
- img_src = img.get("data-src","") or img.get("src","") or ""
335
- title = ""
336
- t = a.get("title","") or ""
337
- if t and len(t)>=5: title = t
338
- if not title:
339
- img_alt = img.get("alt","") if img else ""
340
- if img_alt and len(img_alt)>=5: title = img_alt
341
- if not title:
342
- txt = a.get_text(strip=True)
343
- if txt and len(txt)>=5: title = txt
344
- title = re.sub(r'^(VIDEO\s*:\s*|VIDEO\s+)', '', title, flags=re.I).strip()
345
- if not title or len(title)<5: continue
346
- if img_src and img_src.startswith("//"): img_src = "https:" + img_src
347
- videos.append({"title": title[:100], "link": link, "img": img_src, "source": "bongdaplus", "published": _video_published(img_src, link)})
348
- if len(videos) >= limit: break
349
- return videos
350
- except Exception as e:
351
- print(f"[bongdaplus] Error: {e}"); return []
352
-
353
- # Bongdaplus.vn league-specific video URLs (used as fallback when xemlaibongda.top fails)
354
- BDP_LEAGUE_URLS = {
355
- "premier-league": "https://bongdaplus.vn/video/premier-league",
356
- "la-liga": "https://bongdaplus.vn/video/la-liga",
357
- "serie-a": "https://bongdaplus.vn/video/serie-a",
358
- "bundesliga": "https://bongdaplus.vn/video/bundesliga",
359
- "champions-league": "https://bongdaplus.vn/video/champions-league",
360
- "fa-cup": "https://bongdaplus.vn/video/fa-cup",
361
- "europa-league": "https://bongdaplus.vn/video/europa-league",
362
- "world-cup": "https://bongdaplus.vn/video/world-cup",
363
- }
364
- def scrape_bongdaplus_by_league(league_key, limit=20):
365
- """Scrape bongdaplus.vn video page for a specific league."""
366
- url = BDP_LEAGUE_URLS.get(league_key)
367
- if not url: return []
368
- try:
369
- r = requests.get(url, headers=HEADERS, timeout=15)
370
- if r.status_code != 200: return []
371
- r.encoding = "utf-8"
372
- soup = BeautifulSoup(r.text, "lxml")
373
- videos = []; seen = set()
374
- for a in soup.find_all("a", href=True):
375
- href = a.get("href","")
376
- if "/video/" not in href or not href.endswith(".html"): continue
377
- if not href.startswith("http"): href = "https://bongdaplus.vn" + href
378
- link = href.split("?")[0].split("#")[0]
379
- if link in seen: continue
380
- seen.add(link)
381
- img_src = ""
382
- img = a.find("img")
383
- if img:
384
- img_src = img.get("data-src","") or img.get("src","") or ""
385
- title = ""
386
- t = a.get("title","") or ""
387
- if t and len(t)>=5: title = t
388
- if not title:
389
- img_alt = img.get("alt","") if img else ""
390
- if img_alt and len(img_alt)>=5: title = img_alt
391
- if not title:
392
- txt = a.get_text(strip=True)
393
- if txt and len(txt)>=5: title = txt
394
- title = re.sub(r'^(VIDEO\s*:\s*|VIDEO\s+)', '', title, flags=re.I).strip()
395
- if not title or len(title)<5: continue
396
- if img_src and img_src.startswith("//"): img_src = "https:" + img_src
397
- videos.append({"title": title[:100], "link": link, "img": img_src, "source": "bongdaplus", "published": _video_published(img_src, link)})
398
- if len(videos) >= limit: break
399
- return videos
400
- except Exception as e:
401
- print(f"[bongdaplus:{league_key}] Error: {e}"); return []
402
-
403
- def extract_bongdaplus_video(url):
404
- """Extract direct MP4 from bongdaplus.vn video detail via its embed page.
405
-
406
- The embed page (/video-embed/{id}.html) sometimes only has a JPG placeholder
407
- as <source>. In that case, fall back to the main video page
408
- (/video/{slug}.html) which contains a YouTube iframe with the actual
409
- highlight.
410
- """
411
- try:
412
- m = re.search(r'/video/(?:[^/]+-)?(\d+)\.html', url)
413
- if not m:
414
- return None
415
- vid = m.group(1)
416
- embed = f"https://bongdaplus.vn/video-embed/{vid}.html"
417
- r = requests.get(embed, headers=HEADERS, timeout=15)
418
- if r.status_code != 200: return None
419
- r.encoding = "utf-8"
420
- soup = BeautifulSoup(r.text, "lxml")
421
- video = soup.find("video")
422
- src = ""
423
- poster = ""
424
- if video:
425
- src = video.get("src","")
426
- poster = video.get("poster","")
427
- if not src:
428
- src_el = soup.find("source")
429
- if src_el: src = src_el.get("src","")
430
- if not src: return None
431
- src = src.split("?")[0].split("#")[0]
432
- # Only accept real media; placeholder .jpg/jpeg/png means video not available
433
- if not re.search(r'\.(mp4|m3u8|webm)$', src, re.I):
434
- # The embed source is a JPG placeholder — fall back to the main video
435
- # page and look for a YouTube iframe (actual highlight video).
436
- try:
437
- r2 = requests.get(url, headers=HEADERS, timeout=15)
438
- if r2.status_code == 200:
439
- soup2 = BeautifulSoup(r2.text, "lxml")
440
- yt_iframe = soup2.find("iframe", src=re.compile(r"youtube\.com/embed|youtube-nocookie\.com/embed"))
441
- if yt_iframe:
442
- yt_src = yt_iframe.get("src","").strip()
443
- if yt_src.startswith("//"): yt_src = "https:" + yt_src
444
- yt_id = re.search(r'/(?:embed|v)/([a-zA-Z0-9_-]{11})', yt_src)
445
- if yt_id:
446
- poster = poster or ""
447
- if not poster:
448
- og = soup2.find("meta", property="og:image")
449
- if og: poster = og.get("content","")
450
- if not poster:
451
- og = soup.find("meta", property="og:image")
452
- if og: poster = og.get("content","")
453
- return {"src": f"https://www.youtube.com/embed/{yt_id.group(1)}?autoplay=1&rel=0&enablejsapi=1", "poster": poster or yt_src, "type": "youtube"}
454
- except Exception:
455
- pass
456
- return None
457
- if not poster:
458
- og = soup.find("meta",property="og:image")
459
- if og: poster = og.get("content","")
460
- return {"src": src, "poster": poster, "type": "video"}
461
- except Exception:
462
- return None
463
-
464
  def extract_xemlaibongda_video(url):
465
  try:
466
  r=requests.get(url, headers=HEADERS, timeout=15)
@@ -497,207 +266,9 @@ def api_livescore_results():
497
  today=datetime.now(VN_TZ).strftime("%Y-%m-%d");return JSONResponse({"html":_cached("ls_results",lambda:fetch_bongda_api(f"/api/fixtures/get-by-date?date={today}&status=finished"),ttl=_cache_ttl)})
498
  @app.get("/api/livescore/standings/{league}")
499
  def api_livescore_standings(league:str):
500
- tid=LEAGUE_IDS.get(league,36781);return JSONResponse({"html":_cached(f"ls_bxh_{league}",lambda:fetch_bongda_api(f"/api/league-table/home?tournament_id={tid}&is_detail=True"),ttl=_cache_ttl)})
501
  @app.get("/api/livescore/date/{date}")
502
  def api_livescore_date(date:str):return JSONResponse({"html":fetch_bongda_api(f"/api/fixtures/get-by-date?date={date}")})
503
-
504
- def _compute_updates7d():
505
- from datetime import date as _date
506
- today = _date.today()
507
- all_html = []
508
- # Past 7 days (results)
509
- for i in range(7, 0, -1):
510
- d = (today - timedelta(days=i)).strftime("%Y-%m-%d")
511
- html = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}&status=finished")
512
- if html and len(html) > 50:
513
- soup = BeautifulSoup(html, "lxml")
514
- day_label = (today - timedelta(days=i)).strftime("%d/%m")
515
- for match in soup.select(".match-detail"):
516
- dt = soup.new_tag("div", **{"class": "datetime"})
517
- dt.string = f"📅 {day_label}"
518
- match.insert(0, dt)
519
- all_html.append(str(soup))
520
- # Next 7 days (upcoming)
521
- for i in range(7):
522
- d = (today + timedelta(days=i)).strftime("%Y-%m-%d")
523
- html = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}")
524
- if html and len(html) > 50:
525
- soup = BeautifulSoup(html, "lxml")
526
- day_label = (today + timedelta(days=i)).strftime("%d/%m")
527
- for match in soup.select(".match-detail"):
528
- dt = soup.new_tag("div", **{"class": "datetime"})
529
- dt.string = f"📅 {day_label}"
530
- match.insert(0, dt)
531
- all_html.append(str(soup))
532
- combined = "<div class='updates7d'>" + "".join(all_html) + "</div>"
533
- return combined if all_html else ""
534
-
535
- _updates7d_cache = {"t": 0, "d": "", "busy": False}
536
- _updates7d_lock = threading.Lock()
537
- def _start_updates7d_refresh():
538
- def _run():
539
- try:
540
- with _updates7d_lock:
541
- if _updates7d_cache["busy"]: return
542
- _updates7d_cache["busy"] = True
543
- data = _compute_updates7d()
544
- with _updates7d_lock:
545
- if data:
546
- _updates7d_cache["t"] = time.time()
547
- _updates7d_cache["d"] = data
548
- _updates7d_cache["busy"] = False
549
- except Exception:
550
- with _updates7d_lock:
551
- _updates7d_cache["busy"] = False
552
- th = threading.Thread(target=_run, daemon=True)
553
- th.start()
554
-
555
- @app.get("/api/livescore/updates7d")
556
- def api_livescore_updates7d():
557
- """Aggregate matches. Never blocks synchronously — returns cached value instantly
558
- and refreshes in a background thread. Pre-warmed at startup."""
559
- now = time.time()
560
- with _updates7d_lock:
561
- fresh = _updates7d_cache["d"] and (now - _updates7d_cache["t"] < _cache_ttl)
562
- stale = _updates7d_cache["d"]
563
- if not fresh:
564
- _start_updates7d_refresh()
565
- return JSONResponse({"html": stale or "", "cached": bool(stale)})
566
-
567
- # ===== LIVESCORE RECENT (prioritized: upcoming → just-finished today → yesterday → older) =====
568
- def _compute_recent():
569
- """Compute recent livescore with priority: upcoming → just-finished today → yesterday → older."""
570
- from datetime import date as _date
571
- today = _date.today()
572
- all_html = []
573
-
574
- # Helper to extract match details with date label
575
- def _extract_matches(date_str, day_label, status_param=""):
576
- url_path = f"/api/fixtures/get-by-date?date={date_str}"
577
- if status_param:
578
- url_path += f"&status={status_param}"
579
- html = fetch_bongda_api(url_path)
580
- if not html or len(html) <= 50:
581
- return
582
- soup = BeautifulSoup(html, "lxml")
583
- for match in soup.select(".match-detail"):
584
- dt = soup.new_tag("div", **{"class": "datetime section-date"})
585
- dt.string = f"📅 {day_label}"
586
- match.insert(0, dt)
587
- all_html.append(str(soup))
588
-
589
- # 1) Trận sắp tới (hôm nay + ngày mai)
590
- upcoming_html = []
591
- for offset in [0, 1]:
592
- d = (today + timedelta(days=offset)).strftime("%Y-%m-%d")
593
- day_label = (today + timedelta(days=offset)).strftime("%d/%m")
594
- html = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}")
595
- if html and len(html) > 50:
596
- soup = BeautifulSoup(html, "lxml")
597
- for match in soup.select(".match-detail"):
598
- dt = soup.new_tag("div", **{"class": "datetime section-date"})
599
- dt.string = f"📅 {day_label}"
600
- match.insert(0, dt)
601
- upcoming_html.append(str(soup))
602
- if upcoming_html:
603
- all_html.append("<div class='ls-section-header upcoming'><span>⏰ Trận sắp tới</span></div><div class='ls-section-body upcoming'>" + "".join(upcoming_html) + "</div>")
604
-
605
- # 2) Vừa kết thúc hôm nay
606
- today_str = today.strftime("%Y-%m-%d")
607
- finished_today = fetch_bongda_api(f"/api/fixtures/get-by-date?date={today_str}&status=finished")
608
- if finished_today and len(finished_today) > 50:
609
- soup = BeautifulSoup(finished_today, "lxml")
610
- for match in soup.select(".match-detail"):
611
- dt = soup.new_tag("div", **{"class": "datetime section-date"})
612
- dt.string = f"📅 {today.strftime('%d/%m')}"
613
- match.insert(0, dt)
614
- all_html.append("<div class='ls-section-header finished'><span>✅ Vừa kết thúc</span></div><div class='ls-section-body finished'>" + str(soup) + "</div>")
615
-
616
- # 3) Hôm qua
617
- yesterday = today - timedelta(days=1)
618
- d = yesterday.strftime("%Y-%m-%d")
619
- html_yesterday = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}&status=finished")
620
- if html_yesterday and len(html_yesterday) > 50:
621
- soup = BeautifulSoup(html_yesterday, "lxml")
622
- for match in soup.select(".match-detail"):
623
- dt = soup.new_tag("div", **{"class": "datetime section-date"})
624
- dt.string = f"📅 {yesterday.strftime('%d/%m')}"
625
- match.insert(0, dt)
626
- all_html.append("<div class='ls-section-header yesterday'><span>📅 Hôm qua</span></div><div class='ls-section-body yesterday'>" + str(soup) + "</div>")
627
-
628
- # 4) Các ngày cũ hơn (2 ngày trước → 6 ngày trước)
629
- older_html = []
630
- for i in range(2, 7):
631
- d = (today - timedelta(days=i)).strftime("%Y-%m-%d")
632
- day_label = (today - timedelta(days=i)).strftime("%d/%m")
633
- html = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}&status=finished")
634
- if html and len(html) > 50:
635
- soup = BeautifulSoup(html, "lxml")
636
- for match in soup.select(".match-detail"):
637
- dt = soup.new_tag("div", **{"class": "datetime section-date"})
638
- dt.string = f"📅 {day_label}"
639
- match.insert(0, dt)
640
- older_html.append(str(soup))
641
- if older_html:
642
- all_html.append("<div class='ls-section-header older'><span>🗓️ Kết quả trước</span></div><div class='ls-section-body older'>" + "".join(older_html) + "</div>")
643
-
644
- combined = "<div class='updates7d'>" + "".join(all_html) + "</div>"
645
- return combined if all_html else ""
646
-
647
- _recent_cache = {"t": 0, "d": "", "busy": False}
648
- _recent_lock = threading.Lock()
649
- def _start_recent_refresh():
650
- def _run():
651
- try:
652
- with _recent_lock:
653
- if _recent_cache["busy"]: return
654
- _recent_cache["busy"] = True
655
- data = _compute_recent()
656
- with _recent_lock:
657
- if data:
658
- _recent_cache["t"] = time.time()
659
- _recent_cache["d"] = data
660
- _recent_cache["busy"] = False
661
- except Exception:
662
- with _recent_lock:
663
- _recent_cache["busy"] = False
664
- th = threading.Thread(target=_run, daemon=True)
665
- th.start()
666
-
667
- @app.get("/api/livescore/recent")
668
- def api_livescore_recent():
669
- """Recent matches with new priority: upcoming today/tomorrow → finished today → yesterday → older."""
670
- now = time.time()
671
- with _recent_lock:
672
- fresh = _recent_cache["d"] and (now - _recent_cache["t"] < _cache_ttl)
673
- stale = _recent_cache["d"]
674
- if not fresh:
675
- _start_recent_refresh()
676
- return JSONResponse({"html": stale or "", "cached": bool(stale)})
677
-
678
- def _warm_recent():
679
- def _warm():
680
- try:
681
- time.sleep(3)
682
- _start_recent_refresh()
683
- except Exception:
684
- pass
685
- th = threading.Thread(target=_warm, daemon=True)
686
- th.start()
687
- _warm_recent()
688
-
689
- def _warm_updates7d():
690
- # Lightweight startup pre-warm so the first user request is instant.
691
- def _warm():
692
- try:
693
- time.sleep(3)
694
- _start_updates7d_refresh()
695
- except Exception:
696
- pass
697
- th = threading.Thread(target=_warm, daemon=True)
698
- th.start()
699
- _warm_updates7d()
700
-
701
  @app.get("/api/match/{event_id}/commentaries")
702
  def api_match_commentaries(event_id:int):return JSONResponse({"html":fetch_bongda_api(f"/api/fixtures/commentaries?event_id={event_id}")})
703
  @app.get("/api/match/{event_id}/stats")
@@ -735,7 +306,7 @@ def api_livescore_featured():
735
  return JSONResponse(_cached("ls_featured",_f,ttl=30))
736
 
737
  @app.get("/api/highlights")
738
- def api_highlights(): return JSONResponse(_cached("xemlaibongda_hl",lambda: scrape_bongdaplus_videos(20) or scrape_xemlaibongda(),ttl=_cache_ttl))
739
  @app.get("/api/highlights/leagues")
740
  def api_highlights_leagues(): return JSONResponse(_cached("hl_leagues",scrape_all_league_highlights,ttl=_cache_ttl))
741
  @app.get("/api/highlights/{league}")
@@ -743,47 +314,6 @@ def api_highlights_league(league:str):
743
  if league not in HL_LEAGUES: return JSONResponse({"error":"league not found"})
744
  return JSONResponse(_cached(f"hl_{league}",lambda:scrape_highlights_by_league(league),ttl=_cache_ttl))
745
 
746
- @app.get("/api/highlights/{league}/page")
747
- def api_highlights_league_page(league:str, page:int=Query(default=0, ge=0), limit:int=Query(default=15, ge=5, le=40)):
748
- """Paginated league highlights for 'Xem thêm' load-more. Supports 'all' to fetch every league aggregated."""
749
- if league == "all":
750
- all_vids = []
751
- with ThreadPoolExecutor(8) as ex:
752
- futs = {ex.submit(scrape_highlights_by_league, k): k for k in HL_LEAGUES}
753
- for f in as_completed(futs, timeout=20):
754
- try:
755
- all_vids.extend(f.result())
756
- except: pass
757
- # The league sub-pages resolve to the same content from this server,
758
- # so dedupe by link — the slider/feed must not show 8x the same 10
759
- # videos. If dedup leaves few unique items, enrich from the general
760
- # bongdaplus video feed to add genuinely different videos.
761
- seen = set(); uniq = []
762
- for v in all_vids:
763
- link = (v or {}).get("link", "")
764
- if not link or link in seen: continue
765
- seen.add(link); uniq.append(v)
766
- if len(uniq) < 20:
767
- try:
768
- extra = scrape_bongdaplus_videos(40)
769
- for v in extra:
770
- link = (v or {}).get("link", "")
771
- if not link or link in seen: continue
772
- seen.add(link); uniq.append(v)
773
- except Exception:
774
- pass
775
- all_vids = uniq
776
- start = page * limit
777
- end = start + limit
778
- paged = all_vids[start:end]
779
- return JSONResponse({"videos": paged, "league": "all", "page": page, "has_more": end < len(all_vids), "total": len(all_vids)})
780
- if league not in HL_LEAGUES: return JSONResponse({"error":"league not found"})
781
- all_vids = scrape_highlights_by_league(league)
782
- start = page * limit
783
- end = start + limit
784
- paged = all_vids[start:end]
785
- return JSONResponse({"videos": paged, "league": league, "page": page, "has_more": end < len(all_vids), "total": len(all_vids)})
786
-
787
  @app.get("/api/video_url")
788
  def api_video_url(url:str=Query(...), img:str=Query(default="")):
789
  if "youtube.com" in url or "youtu.be" in url:
@@ -795,116 +325,8 @@ def api_video_url(url:str=Query(...), img:str=Query(default="")):
795
  if v["type"]=="hls": v["src"]="/api/proxy/m3u8?url="+quote(v["src"],safe="")
796
  if not v.get("poster") and img: v["poster"] = img
797
  return JSONResponse(v)
798
- if "bongdaplus.vn" in url and "/video/" in url:
799
- v=extract_bongdaplus_video(url)
800
- if v:
801
- if not v.get("poster") and img: v["poster"] = img
802
- return JSONResponse(v)
803
  return JSONResponse({"error":"not found"})
804
 
805
- # ===== FPT PLAY (YouTube channel) API =====
806
- # The datacenter cannot reach youtube.com directly, so this endpoint keeps a
807
- # persisted list of the channel's latest videos. The frontend (running in the
808
- # user's browser, which CAN reach YouTube) periodically fetches the channel via
809
- # r.jina.ai, then POSTs the result here so the slide auto-updates when the
810
- # channel uploads — exactly like "Thêm Short HOT" but fully automatic.
811
- FPT_FILE = "/data/fpt_play_videos.json"
812
- FPT_CHANNEL_URL = "https://www.youtube.com/@fptbongdaofficial"
813
- FPT_CACHE_TTL = 600 # 10 min
814
-
815
- def _fpt_load():
816
- try:
817
- if os.path.exists(FPT_FILE):
818
- with open(FPT_FILE, encoding="utf-8") as f:
819
- j = json.load(f)
820
- if isinstance(j, list):
821
- return j
822
- except Exception:
823
- pass
824
- return []
825
-
826
- def _fpt_save(videos):
827
- try:
828
- with open(FPT_FILE, "w", encoding="utf-8") as f:
829
- json.dump(videos, f, ensure_ascii=False)
830
- except Exception:
831
- pass
832
-
833
- def _fpt_try_server_fetch():
834
- """Server-side attempt (jina proxy with our Origin). Usually blocked from
835
- the datacenter, but harmless to try once so the slide works even without
836
- a client push."""
837
- try:
838
- _hdrs = dict(HEADERS)
839
- _hdrs["Origin"] = "https://bep40-vnews.hf.space"
840
- r = requests.get("https://r.jina.ai/" + FPT_CHANNEL_URL + "/videos",
841
- headers=_hdrs, timeout=40)
842
- if r.status_code != 200:
843
- return []
844
- txt = r.text
845
- out = []
846
- seen = set()
847
- # jina markdown lists entries as "1. Title - url" or link lines with yt ids
848
- for m in re.finditer(r"https?://(?:www\.)?youtube\.com/watch\?v=([a-zA-Z0-9_-]{11})", txt):
849
- vid = m.group(1)
850
- if vid in seen: continue
851
- seen.add(vid)
852
- out.append({
853
- "id": vid,
854
- "title": "",
855
- "link": "https://youtu.be/" + vid,
856
- "img": "https://i.ytimg.com/vi/%s/hqdefault.jpg" % vid,
857
- "source": "fptplay",
858
- "published": "",
859
- })
860
- return out
861
- except Exception:
862
- return []
863
-
864
- @app.get("/api/fptplay/videos")
865
- def api_fptplay_videos():
866
- vids = _fpt_load()
867
- if not vids:
868
- vids = _fpt_try_server_fetch()
869
- if vids: _fpt_save(vids)
870
- return JSONResponse({"channel": FPT_CHANNEL_URL, "videos": vids, "updated": int(time.time())})
871
-
872
- @app.post("/api/fptplay/update")
873
- async def api_fptplay_update(request: Request):
874
- try:
875
- body = await request.json()
876
- except Exception:
877
- body = {}
878
- incoming = body.get("videos") or []
879
- if not isinstance(incoming, list) or not incoming:
880
- return JSONResponse({"ok": True, "videos": _fpt_load()})
881
- seen = set()
882
- merged = []
883
- for v in _fpt_load():
884
- vid = (v or {}).get("id", "")
885
- if vid and vid in seen: continue
886
- if vid: seen.add(vid)
887
- merged.append(v)
888
- added = 0
889
- for v in incoming:
890
- vid = (v or {}).get("id", "") or ""
891
- if not vid or vid in seen: continue
892
- seen.add(vid)
893
- # keep user-friendly fields, always normalize the watch link
894
- merged.append({
895
- "id": vid,
896
- "title": (v.get("title") or "").strip()[:200],
897
- "link": (v.get("link") or "https://youtu.be/" + vid),
898
- "img": (v.get("img") or "https://i.ytimg.com/vi/%s/hqdefault.jpg" % vid),
899
- "source": "fptplay",
900
- "published": (v.get("published") or "")[:10],
901
- })
902
- added += 1
903
- # newest first
904
- merged.sort(key=lambda x: (x.get("published") or ""), reverse=True)
905
- _fpt_save(merged[:50])
906
- return JSONResponse({"ok": True, "added": added, "videos": merged[:50]})
907
-
908
  # ===== WORLD CUP 2026 API =====
909
  _wc_request_times = []; _wc_rate_limit_lock = threading.Lock()
910
  _WC_RATE_LIMIT = 10
@@ -970,8 +392,7 @@ def scrape_vne(cat_url):
970
  if src: img=src.get("srcset","").split(",")[0].strip().split(" ")[0]
971
  arts.append({"title":t,"link":lk,"img":img,"source":"vne"})
972
  return arts
973
- except Exception:
974
- return []
975
 
976
  def scrape_genk_ai():
977
  try:
@@ -1036,19 +457,10 @@ def api_categories():
1036
 
1037
  @app.get("/api/proxy/xlb")
1038
  def api_xlb(path: str = Query(default=""), limit: int = Query(default=20)):
1039
- # Try xemlaibongda.top first (fast timeout), fallback to bongdaplus.vn
1040
- videos = _do_xlb_scrape_xlb(path, limit)
1041
- if not videos:
1042
- videos = _do_xlb_scrape_bdp(limit)
1043
- if not videos:
1044
- return JSONResponse({"videos": [], "error": "No videos available"})
1045
- return JSONResponse({"videos": videos})
1046
-
1047
- def _do_xlb_scrape_xlb(path, limit):
1048
  try:
1049
  url = f"https://xemlaibongda.top/{path}" if path else "https://xemlaibongda.top/"
1050
- r = requests.get(url, headers=HEADERS, timeout=8)
1051
- if r.status_code != 200: return []
1052
  r.encoding = "utf-8"
1053
  soup = BeautifulSoup(r.text, "lxml")
1054
  videos, seen = [], set()
@@ -1077,18 +489,11 @@ def _do_xlb_scrape_xlb(path, limit):
1077
  if not t:
1078
  slug = clean.split("/video/")[-1].rstrip("/")
1079
  t = slug.replace("-", " ").title()
1080
- videos.append({"title": t[:100], "link": clean, "img": img_src, "source": "xemlaibongda", "published": _video_published(img_src, clean)})
1081
  if len(videos) >= limit: break
1082
- return videos
1083
- except Exception:
1084
- return []
1085
-
1086
- def _do_xlb_scrape_bdp(limit):
1087
- """Fallback: scrape bongdaplus.vn videos for the xlb endpoint."""
1088
- try:
1089
- return scrape_bongdaplus_videos(limit)
1090
- except Exception:
1091
- return []
1092
 
1093
  @app.get("/api/article")
1094
  def api_article(url:str=Query(...)):
@@ -1112,4 +517,4 @@ def api_hot_topics():
1112
 
1113
  @app.get("/", response_class=HTMLResponse)
1114
  async def root():
1115
- return HTMLResponse("<h1>VNEWS v17</h1><p>VTV Digital CDN ssaimh · No shorts Dantri/SKDS · Homepage full content</p>")
 
1
  """VNEWS - FastAPI backend with livescore + xemlaibongda highlights + VTV channels"""
2
  import re, time, subprocess, json, os, threading
3
  import html as html_lib
4
+ from datetime import datetime, timezone, timedelta
5
  from collections import defaultdict
6
 
7
  VN_TZ = timezone(timedelta(hours=7))
 
51
  _cache_ttl_yt = 1800
52
 
53
  PRIORITY_LEAGUES = ["Ngoại Hạng Anh","FA Cup","Champions League","LaLiga","Copa del Rey","Serie A","Bundesliga","Ligue 1","V-League"]
54
+ LEAGUE_IDS = {"nha":27110,"laliga":27233,"seriea":27044,"bundesliga":26891,"ligue1":27212}
55
  HL_LEAGUES = {
56
  "premier-league":{"path":"anh/premier-league","name":"Premier League","emoji":"🏴󠁧󠁢󠁥󠁮󠁧󠁿"},
57
  "fa-cup":{"path":"anh/fa-cup","name":"FA Cup","emoji":"🏆"},
 
69
  except: data=_cache.get(key,{}).get("d",[])
70
  _cache[key]={"d":data,"t":now}; return data
71
  def _get(url, headers=None):
72
+ h=headers or HEADERS; r=requests.get(url, headers=h, timeout=15); r.encoding="utf-8"
73
+ return BeautifulSoup(r.text,"lxml")
 
 
 
 
 
 
 
 
 
74
  def fetch_bongda_api(endpoint):
75
  try:
76
  r=requests.get(f"https://bongda.com.vn{endpoint}", headers=BONGDA_HEADERS, timeout=10)
 
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")
 
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:
 
218
  def scrape_xemlaibongda(): return _scrape_xemlaibongda_page("", 20)
219
  def scrape_highlights_by_league(league_key):
220
  if league_key not in HL_LEAGUES: return []
221
+ return _scrape_xemlaibongda_page(HL_LEAGUES[league_key]["path"], 20)
 
 
 
 
 
 
 
 
 
222
  def scrape_all_league_highlights():
223
  results = {}
224
  def _fetch(key): return key, scrape_highlights_by_league(key)
225
  with ThreadPoolExecutor(8) as ex:
226
  futs = [ex.submit(_fetch, k) for k in HL_LEAGUES]
227
+ for f in as_completed(futs, timeout=25):
228
  try: key, vids = f.result()
229
  except: continue
230
  if vids: results[key] = vids
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  return results
232
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  def extract_xemlaibongda_video(url):
234
  try:
235
  r=requests.get(url, headers=HEADERS, timeout=15)
 
266
  today=datetime.now(VN_TZ).strftime("%Y-%m-%d");return JSONResponse({"html":_cached("ls_results",lambda:fetch_bongda_api(f"/api/fixtures/get-by-date?date={today}&status=finished"),ttl=_cache_ttl)})
267
  @app.get("/api/livescore/standings/{league}")
268
  def api_livescore_standings(league:str):
269
+ tid=LEAGUE_IDS.get(league,27110);return JSONResponse({"html":_cached(f"ls_bxh_{league}",lambda:fetch_bongda_api(f"/api/league-table/home?tournament_id={tid}&is_detail=True"),ttl=_cache_ttl)})
270
  @app.get("/api/livescore/date/{date}")
271
  def api_livescore_date(date:str):return JSONResponse({"html":fetch_bongda_api(f"/api/fixtures/get-by-date?date={date}")})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  @app.get("/api/match/{event_id}/commentaries")
273
  def api_match_commentaries(event_id:int):return JSONResponse({"html":fetch_bongda_api(f"/api/fixtures/commentaries?event_id={event_id}")})
274
  @app.get("/api/match/{event_id}/stats")
 
306
  return JSONResponse(_cached("ls_featured",_f,ttl=30))
307
 
308
  @app.get("/api/highlights")
309
+ def api_highlights(): return JSONResponse(_cached("xemlaibongda_hl",scrape_xemlaibongda,ttl=_cache_ttl))
310
  @app.get("/api/highlights/leagues")
311
  def api_highlights_leagues(): return JSONResponse(_cached("hl_leagues",scrape_all_league_highlights,ttl=_cache_ttl))
312
  @app.get("/api/highlights/{league}")
 
314
  if league not in HL_LEAGUES: return JSONResponse({"error":"league not found"})
315
  return JSONResponse(_cached(f"hl_{league}",lambda:scrape_highlights_by_league(league),ttl=_cache_ttl))
316
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  @app.get("/api/video_url")
318
  def api_video_url(url:str=Query(...), img:str=Query(default="")):
319
  if "youtube.com" in url or "youtu.be" in url:
 
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
 
392
  if src: img=src.get("srcset","").split(",")[0].strip().split(" ")[0]
393
  arts.append({"title":t,"link":lk,"img":img,"source":"vne"})
394
  return arts
395
+ except: return []
 
396
 
397
  def scrape_genk_ai():
398
  try:
 
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()
 
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(...)):
 
517
 
518
  @app.get("/", response_class=HTMLResponse)
519
  async def root():
520
+ return HTMLResponse("<h1>VNEWS v17</h1><p>VTV Digital CDN ssaimh · No shorts Dantri/SKDS · Homepage full content</p>")
patch_ai_hot.py DELETED
@@ -1,55 +0,0 @@
1
- """PATCH AI: prepend AI topics to hot list + homepage route fix"""
2
- import re, json, time
3
- from fastapi.responses import HTMLResponse
4
-
5
- # Import at runtime to avoid circular
6
- try:
7
- from main import app, rt
8
- import ai_runtime_final6 as f6
9
- from ai_runtime_final6 import f5
10
- except:
11
- f6, f5, rt = None, None, None
12
-
13
- # Patch hot_topics to prepend AI topics
14
- if f6 and hasattr(f6, '_HOT_CACHE') and hasattr(f6, '_hot_topics'):
15
- _orig_hot = f6._hot_topics
16
- def _hot_topics_patched():
17
- topics = _orig_hot()
18
- # Prepend AI topics to front
19
- for ai in ['Công nghệ AI', 'World Cup 2026', 'Kinh tế Việt Nam']:
20
- if not any(ai.lower() == t.get('topic','').lower() for t in topics):
21
- topics.insert(0, {'label': f'#{ai.replace(" ", "")}', 'topic': ai, 'count': 0})
22
- return topics[:24]
23
- f6._hot_topics = f6._HOT_CACHE['d'] = _hot_topics_patched()
24
- f6._HOT_CACHE['t'] = time.time()
25
-
26
- PATCH_INJECT = r'''
27
- <script>
28
- const AI_HOT_TOPICS = ['Công nghệ AI', 'World Cup 2026', 'Kinh tế Việt Nam', 'Bóng đá châu Âu'];
29
- async function ensureHotTopics(){let i=document.getElementById('ai-topic-input-final5');if(!i||document.getElementById('ai-hot-row'))return;let r=document.createElement('div');r.id='ai-hot-row';r.style.cssText='display:flex;gap:6px;overflow-x:auto;padding:4px 0;margin:6px 0';let t=[];try{let j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));t=j.topics||[];}catch(e){}AI_HOT_TOPICS.forEach(ai=>{if(!t.find(x=>(x.topic||'').toLowerCase()===ai.toLowerCase())){t.unshift({label:'#'+ai.replace(/\s+/g,''),topic:ai});}});r.innerHTML=t.slice(0,14).map(x=>`<button class="hot-chip" style="flex:0 0 auto;background:#222;border:1px solid #333;color:#ddd;border-radius:16px;padding:5px 10px;font-size:11px;cursor:pointer" onclick="document.getElementById('ai-topic-input-final5').value='${x.topic.replace(/'/g,'\\''}';document.getElementById('ai-topic-input-final5').focus();searchTopic('${x.topic.replace(/'/g,'\\''}')">${x.label}</button>`).join('');i.insertAdjacentElement('afterend',r);}
30
- setInterval(ensureHotTopics,1500);
31
- </script>
32
- '''
33
-
34
- # Register homepage route
35
- if app and f5 and f6:
36
- # Remove old / route
37
- app.router.routes = [r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
38
-
39
- @app.get('/')
40
- async def patch_homepage():
41
- html = f5.f4.f3.f2.f1._load_index_html() if f5 else "<html><body></body></html>"
42
- body = (getattr(rt.old,'PATCH_INJECT','') if hasattr(rt,'old') else '') + \
43
- (getattr(f5.f4.f3.f2.f1,'FINAL_INJECT','') if f5 else '') + \
44
- (getattr(f5.f4.f3,'FINAL3_INJECT','') if f5 else '') + \
45
- (getattr(f5.f4,'FINAL4_INJECT','') if f5 else '') + \
46
- (getattr(f5,'FINAL5_INJECT','') if f5 else '') + \
47
- (getattr(f6,'FINAL6_INJECT','') or '') + \
48
- (getattr(f6,'FINAL6_FAST_HOME_INJECT','') or '') + \
49
- (getattr(f6,'FINAL6E_INJECT','') or '') + \
50
- PATCH_INJECT
51
- if '</body>' in html:
52
- html = html.replace('</body>', body + '\n</body>')
53
- else:
54
- html += body
55
- return HTMLResponse(html)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
rebuild3.md DELETED
@@ -1 +0,0 @@
1
- rebuild
 
 
rebuild_trigger.txt DELETED
@@ -1 +0,0 @@
1
- M3U perf fix: disk cache + background pre-warm Sat Aug 29 00:43:20 UTC 2026
 
 
requirements.txt CHANGED
@@ -11,6 +11,3 @@ pillow
11
  edge-tts
12
  python-dateutil
13
  httpx
14
- python-multipart
15
- pycryptodome
16
- # trigger rebuild 1784359130
 
11
  edge-tts
12
  python-dateutil
13
  httpx
 
 
 
restart.txt DELETED
@@ -1 +0,0 @@
1
- rebuild 1787553359
 
 
restart2.md DELETED
@@ -1 +0,0 @@
1
- restart with _run.py fix
 
 
restart_trigger.md DELETED
@@ -1,14 +0,0 @@
1
- # VNEWS Space Restart Trigger
2
-
3
- ## Version: v20260822d
4
-
5
- ### All fixes applied:
6
- 1. **Scrolling fix**: `-webkit-overflow-scrolling: touch` + `scroll-behavior: smooth` on `.tiktok-feed`
7
- 2. **Thumbnail fix**: `.yt-thumb-wrap` with background image for YouTube iframes, YouTube hqdefault fallback
8
- 3. **Lazy loading**: IntersectionObserver to unload slides far from viewport
9
- 4. **SHORT HOT video looping**: Video plays at full duration instead of being trimmed to narration length
10
- 5. **CSS consistency**: Same poster/thumbnail styling for all TikTok slides
11
- 6. **Homepage loading**: Retry logic for `scrape_vne`, bongdaplus.vn as primary source, faster xemlaibongda timeout
12
- 7. **YouTube SSL error**: Increased yt-dlp retries (5), socket_timeout 30s, YouTube embed fallback
13
-
14
- Last restart: 20260822d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
rewrite_slide.py CHANGED
@@ -84,36 +84,17 @@ def _scrape_article_full(url):
84
  return None
85
 
86
 
87
- def _ensure_complete_sentence(text):
88
- """Ensure text ends with complete sentence ending."""
89
- text = _clean(text)
90
- if not text:
91
- return text
92
- # Find last complete sentence ending
93
- for end_char in ['.', '!', '?']:
94
- last_pos = text.rfind(end_char)
95
- if last_pos > len(text) * 0.5: # Keep if ending is in latter half
96
- return text[:last_pos + 1].strip()
97
- return text
98
-
99
  def _extract_key_points(paragraphs, max_points=5):
100
- """Extract key points: take first COMPLETE sentence of each significant paragraph."""
101
  points = []
102
  for p in paragraphs:
103
  if len(points) >= max_points: break
104
- # Find ALL sentence endings and take the first complete sentence
105
- # that ends with . ! or ?
106
- sentences = re.split(r'(?<=[.!?])\s+', p)
107
- sentence = ""
108
- for s in sentences:
109
- s = _clean(s)
110
- if len(s) >= 30 and re.search(r'[.!?]$', s):
111
- sentence = s
112
- break
113
-
114
- # If no complete sentence found, take first sentence and ensure it ends
115
- if not sentence and sentences:
116
- sentence = _ensure_complete_sentence(sentences[0])
117
 
118
  # Skip if too short or duplicate
119
  if len(sentence) < 30: continue
 
84
  return None
85
 
86
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  def _extract_key_points(paragraphs, max_points=5):
88
+ """Extract key points: take first sentence of each significant paragraph."""
89
  points = []
90
  for p in paragraphs:
91
  if len(points) >= max_points: break
92
+ # Take first complete sentence (ends with . ! ?)
93
+ m = re.match(r'^(.+?[.!?])\s', p)
94
+ if m:
95
+ sentence = m.group(1)
96
+ else:
97
+ sentence = p[:150] + ('.' if not p.endswith('.') else '')
 
 
 
 
 
 
 
98
 
99
  # Skip if too short or duplicate
100
  if len(sentence) < 30: continue
runtime.txt CHANGED
@@ -0,0 +1 @@
 
 
1
+ python-3.12.x
static/app_v2.js CHANGED
The diff for this file is too large to render. See raw diff
 
static/designer_v2.js DELETED
@@ -1,903 +0,0 @@
1
- /* =========================================================================
2
- * designer_v2.js — Slide Designer nâng cấp cho VNEWS
3
- * - Nhiều hiệu ứng độc đáo (gradient, blur, neon, top-banner, lower-third,
4
- * card, corner-frame, stars, vignette, split, spotlight, diagonal, solid)
5
- * - Bộ lọc màu (grayscale, sepia, saturate, warm, cool, invert)
6
- * - Chữ phát sáng (neon/glow)
7
- * - Vị trí chữ (trên/giữa/dưới)
8
- * - Chọn 1 / nhiều / TẤT CẢ slide trong modal sửa ảnh
9
- * - Đăng lên Tường AI là bài dạng slide gồm CÁC SLIDE ĐÃ CHỌN
10
- *
11
- * 2026-08-12 FIX (2 lỗi báo cáo):
12
- * - Bug 1: upload ảnh preview + nút "Áp dụng ảnh". _dsHandleBgFile được gắn
13
- * trực tiếp làm listener 'change' nên nhận Event object, `input.files`
14
- * undefined -> upload im lặng không làm gì. Giờ đọc evt.target.files.
15
- * Nút "Áp dụng ảnh này cho TẤT CẢ slide đã chọn" luôn hiển thị.
16
- * - Bug 2: chọn 1 ảnh rewrite áp dụng cho nhiều slide. Danh sách ảnh dùng
17
- * data-index + delegated click (hết fragile inline onclick với URL thô).
18
- *
19
- * 2026-08-13 FIX (lỗi báo cáo tiếp):
20
- * - Bug 3: "dùng 1 ảnh cho nhiều slide" vẫn chỉ áp dụng cho slide đầu.
21
- * Giờ có _dsAppliedImg (ảnh đã áp dụng ở cấp thiết kế): khi chọn ảnh bất kỳ
22
- * (rewrite / upload / URL) sẽ áp dụng cho TẤT CẢ slide đã chọn NGAY LẬP TỨC;
23
- * khi đổi lựa chọn slide (chọn thêm / bỏ chọn / chọn tất cả) ảnh đó vẫn được
24
- * áp dụng tự động cho các slide mới được chọn; khi lưu, mọi slide được chọn
25
- * đều dùng ảnh đó.
26
- * - Bug 4: ảnh preview hiển thị đúng tỉ lệ nhưng sau khi tạo ảnh xong hiển thị
27
- * sai tỉ lệ + ảnh nền không định vị chính xác như preview. Preview và khi
28
- * lưu giờ render Ở CÙNG một hàm `_dsRenderSlide(canvas, opts, cb)` (cùng
29
- * resolution, cùng cover-fit, cùng filter, cùng text) nên file tạo ra khớp
30
- * TUYỆT ĐỐI với preview. Ảnh trên Tường/slide viewer giữ đúng tỉ lệ khung
31
- * (ko cắt xén bằng object-fit:cover nữa).
32
- * - Thêm MÀU NỀN + nhiều ẢNH NỀN MẪU khi chọn "Nền đều (phủ màu tối)" —
33
- * dải màu nền (solid color swatches) và bộ ảnh nền mẫu (gradient mờ) để
34
- * người dùng tạo ảnh nền trơn thay vì chỉ phủ màu tối.
35
- * ========================================================================= */
36
-
37
- /* ---------------- state ---------------- */
38
- let _dsState = {
39
- postIdx: -1, // index into _wallPosts
40
- selected: [], // array of selected slide indices (into p.slides)
41
- };
42
-
43
- /* per-slide image override map: {si: imageUrl} set by the rewrite image list */
44
- let _dsImgMap = {};
45
-
46
- /* srcs backing the rewrite image list (Bug 2 fix: index-based, no inline onclick) */
47
- let _dsRewriteImgSrcs = [];
48
-
49
- /* designer-level "applied image": the image the user picked (rewrite / upload /
50
- URL). Applied to ALL selected slides, including slides selected LATER.
51
- Bug 3 fix: keeps "1 ảnh cho nhiều slide" working when selection changes. */
52
- let _dsAppliedImg = '';
53
-
54
- /* solid-color background for the 'solid' layout (map because _dsOptions reads it) */
55
- let _dsBgColor = '';
56
- /* sample background image (proxied URL) for the 'solid' layout */
57
- let _dsBgSample = '';
58
-
59
- /* ---------------- ratio / layout helpers ---------------- */
60
- function designerGetRatioV2(){
61
- const v = document.getElementById('ds-ratio')?.value || '3:4';
62
- const m = v.split(':');
63
- return m.length === 2 ? {w: parseInt(m[0]), h: parseInt(m[1])} : {w:3,h:4};
64
- }
65
- function designerGetLayoutV2(){ return document.getElementById('ds-layout')?.value || 'solid'; }
66
- function designerGetFilterV2(){ return document.getElementById('ds-filter')?.value || 'none'; }
67
- function designerGetGlowV2(){ return document.getElementById('ds-glow')?.value || 'none'; }
68
- function designerGetPosV2(){ return document.getElementById('ds-pos')?.value || 'middle'; }
69
-
70
- /* ---------------- open modal (override) ---------------- */
71
- function openSlideDesigner(idx){
72
- const p = _wallPosts[idx]; if(!p || !p.slides || !p.slides.length) return;
73
- _dsState.postIdx = idx;
74
- // default: chỉ slide đầu tiên (slide 1 = ảnh đại diện bài đăng tường).
75
- // Người dùng có thể chọn thêm bằng checkbox hoặc "Chọn tất cả".
76
- _dsState.selected = p.slides.length ? [0] : [];
77
- _dsBgDataUrl = null; // reset uploaded bg between opens
78
- _dsTextColor = '#ffffff';
79
- _dsImgMode = 'rewrite'; // default: use rewrite image
80
- _dsImgMap = {}; // reset per-slide image overrides
81
- _dsRewriteImgSrcs = [];
82
- _dsAppliedImg = ''; // reset designer-level applied image
83
- _buildDesignerModal(p);
84
- }
85
-
86
- function _buildDesignerModal(p){
87
- const overlay = document.createElement('div');
88
- overlay.id = 'slide-designer-overlay';
89
- overlay.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.94);z-index:99999;display:flex;align-items:center;justify-content:center;padding:12px;overflow-y:auto';
90
- let h='<div style="background:#16161a;border:2px solid #5cb87a;border-radius:16px;padding:18px;max-width:780px;width:100%;max-height:94vh;overflow-y:auto">';
91
- h+='<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px"><h3 style="color:#5cb87a;margin:0;font-size:16px">🎨 Thiết kế ảnh slide</h3><button onclick="document.getElementById(\'slide-designer-overlay\').remove()" style="background:none;border:0;color:#fff;font-size:24px;cursor:pointer">✕</button></div>';
92
-
93
- /* ---- 1. Chọn slide (1 / nhiều / tất cả) ---- */
94
- h+='<div style="margin-bottom:10px"><label style="color:#888;font-size:11px;display:block;margin-bottom:6px">📌 Chọn slide để chỉnh sửa & đăng lên Tường AI:</label>';
95
- h+='<div style="margin-bottom:8px;display:flex;gap:8px;flex-wrap:wrap">';
96
- h+='<button id="ds-sel-all" style="background:#2d8659;border:0;color:#fff;padding:6px 12px;border-radius:8px;font-size:11px;cursor:pointer">✅ Chọn tất cả</button>';
97
- h+='<button id="ds-sel-none" style="background:#333;border:0;color:#ddd;padding:6px 12px;border-radius:8px;font-size:11px;cursor:pointer">🚫 Bỏ chọn</button>';
98
- h+='</div><div id="ds-slide-list" style="display:flex;flex-wrap:wrap;gap:8px">';
99
- p.slides.forEach((s,i)=>{
100
- const preview = s.image ? `<img src="${_proxyImg(s.image)}" style="width:100%;height:54px;object-fit:cover;border-radius:6px;margin-bottom:4px" onerror="this.style.display='none'">` : `<div style="width:100%;height:54px;background:#222;border-radius:6px;margin-bottom:4px;display:flex;align-items:center;justify-content:center;color:#555;font-size:20px">🖼️</div>`;
101
- const isSel = _dsState.selected.includes(i);
102
- h+=`<label data-idx="${i}" class="ds-slide-chip" style="width:110px;border:2px solid #2d8659;background:#1e2621;border-radius:10px;padding:6px;cursor:pointer;text-align:center">
103
- <input type="checkbox" class="ds-slide-chk" data-idx="${i}"${isSel?' checked':''} style="accent-color:#5cb87a;width:14px;height:14px">
104
- ${preview}
105
- <div style="color:#eee;font-size:10px;line-height:1.3;height:32px;overflow:hidden">Slide ${s.index||i+1}: ${(s.text||'').slice(0,34)}</div>
106
- </label>`;
107
- });
108
- h+='</div></div>';
109
-
110
- /* ---- 2. Nội dung từng slide đã chọn ---- */
111
- h+='<div style="margin-bottom:10px"><label style="color:#888;font-size:11px;display:block;margin-bottom:4px">✏️ Nội dung từng slide:</label><div id="ds-texts"></div></div>';
112
-
113
- /* ---- 3. Cấu hình thiết kế ---- */
114
- h+='<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px">';
115
- h+='<div><label style="color:#888;font-size:11px;display:block;margin-bottom:4px">Tỉ lệ cắt ảnh:</label><select id="ds-ratio" style="width:100%;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:12px"><option value="3:4">📱 3:4 (dọc)</option><option value="1:1">📐 1:1 (vuông)</option><option value="4:3">🖥️ 4:3 (ngang)</option><option value="16:9">🎞️ 16:9 (rộng)</option></select></div>';
116
- h+='<div><label style="color:#888;font-size:11px;display:block;margin-bottom:4px">Vị trí chữ:</label><select id="ds-pos" style="width:100%;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:12px"><option value="middle">⬅️ Giữa</option><option value="top">⬆️ Trên</option><option value="bottom">⬇️ Dưới</option></select></div>';
117
- h+='<div style="grid-column:1/3"><label style="color:#888;font-size:11px;display:block;margin-bottom:4px">✨ Hiệu ứng nền:</label><select id="ds-layout" style="width:100%;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:12px">';
118
- h+='<option value="solid">🎨 Nền đều (phủ màu tối)</option>';
119
- h+='<option value="gradient">🌈 Gradient (đổ màu)</option>';
120
- h+='<option value="vignette">🌙 Vignette (viền tối dần)</option>';
121
- h+='<option value="blur">🌀 Làm mờ hậu cảnh</option>';
122
- h+='<option value="split">✂️ Chia đôi (trên mờ, dưới rõ)</option>';
123
- h+='<option value="spotlight">🎯 Spotlight (ánh sáng giữa)</option>';
124
- h+='<option value="diagonal">↘️ Đường chéo</option>';
125
- h+='<option value="neon">💡 Neon (nền tối + chữ phát sáng)</option>';
126
- h+='<option value="top-banner">🏷️ Banner trên cùng</option>';
127
- h+='<option value="lower-third">📺 Lower-third (chữ dưới)</option>';
128
- h+='<option value="card">🃏 Card bo góc</option>';
129
- h+='<option value="corner">🖼️ Khung viền góc</option>';
130
- h+='<option value="stars">🌠 Nền sao lấp lánh</option>';
131
- h+='</select></div>';
132
- h+='<div><label style="color:#888;font-size:11px;display:block;margin-bottom:4px">🎚️ Bộ lọc màu:</label><select id="ds-filter" style="width:100%;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:12px"><option value="none">Nguyên bản</option><option value="grayscale">⚪ Đen trắng</option><option value="sepia">🟤 Sepia</option><option value="saturate">🎨 Bão hòa mạnh</option><option value="warm">🔥 Ấm áp</option><option value="cool">❄️ Mát lạnh</option><option value="invert">🔁 Đảo màu</option><option value="noir">🖤 Noir tương phản</option></select></div>';
133
- h+='<div><label style="color:#888;font-size:11px;display:block;margin-bottom:4px">💡 Phát sáng chữ:</label><select id="ds-glow" style="width:100%;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:12px"><option value="none">Tắt</option><option value="white">✨ Tỏa trắng</option><option value="color">🌈 Tỏa màu chữ</option><option value="neon">💡 Neon mạnh</option></select></div>';
134
- h+='</div>';
135
-
136
- /* ---- 3b. Màu nền + ảnh nền mẫu (chỉ hiển thị khi chọn "Nền đều/solid") ---- */
137
- h+='<div id="ds-bg-solid-wrap" style="display:none;margin-bottom:10px;padding:10px;background:#1e2621;border:1px solid #2d8659;border-radius:10px">';
138
- h+='<div style="color:#888;font-size:11px;display:block;margin-bottom:6px">🎨 Màu nền (nền trơn):</div><div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px" id="ds-bg-colors">';
139
- ['#0f172a','#1e293b','#334155','#1a1a2e','#16213e','#0f3460','#202040','#2d3436','#2c2c54','#3d1e1e','#4a1e3d','#1e3d2f','#3a2e1e','#111827','#0b0b1a','#233329'].forEach(c=>{h+=`<button class="ds-bgcolor-btn" data-color="${c}" style="width:30px;height:30px;border-radius:8px;border:2px solid #333;background:${c};cursor:pointer;padding:0" title="${c}"></button>`;});
140
- h+='</div>';
141
- h+='<div style="color:#888;font-size:11px;display:block;margin-bottom:6px">🖼️ Hoặc chọn ẢNH NỀN MẪU:</div><div style="display:flex;flex-wrap:wrap;gap:6px" id="ds-bg-samples">';
142
- _DS_BG_SAMPLES.forEach((b,bi)=>{h+=`<button type="button" class="ds-bgsample-btn" data-bi="${bi}" style="width:52px;height:68px;border-radius:8px;border:2px solid #333;background:#222;background-image:url('${_dsImageSrc(b)}');background-size:cover;background-position:center;cursor:pointer;padding:0;position:relative"><span style="position:absolute;right:3px;top:3px;font-size:9px;line-height:1;color:#fff;text-shadow:0 0 3px #000">🖼️</span></button>`;});
143
- h+='</div>';
144
- h+='<div style="color:#666;font-size:9px;margin-top:6px">Chọn màu nền hoặc ảnh nền mẫu — ảnh nền mẫu sẽ được phủ mờ để chữ dễ đọc. Nếu không chọn, dùng màu tối mặc định.</div>';
145
- h+='</div>';
146
-
147
- /* ---- 4. Màu chữ + nền ---- */
148
- h+='<div style="margin-bottom:10px"><label style="color:#888;font-size:11px;display:block;margin-bottom:4px">🎨 Màu chữ:</label><div style="display:flex;gap:6px;flex-wrap:wrap" id="ds-colors">';
149
- ['#ffffff','#000000','#ff4444','#44ff44','#4444ff','#ffff00','#ff00ff','#00ffff','#ff8800','#88ff00','#ffd700','#ff69b4'].forEach(c=>{h+=`<button class="ds-color-btn" data-color="${c}" style="width:26px;height:26px;border-radius:50%;border:2px solid ${c==='#ffffff'?'#888':'#333'};background:${c};cursor:pointer;padding:0"></button>`;});
150
- h+='</div></div>';
151
-
152
- /* ---- 5. Ảnh nền (source buttons + URL/upload) ---- */
153
- h+='<div style="margin-bottom:6px"><label style="color:#888;font-size:11px;display:block;margin-bottom:4px">🖼️ Ảnh nền:</label>';
154
- h+='<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:6px">';
155
- h+='<button id="ds-img-src-rewrite" class="ds-img-src-btn" style="background:#2d8659;border:0;color:#fff;padding:6px 10px;border-radius:8px;font-size:10px;cursor:pointer">📰 Ảnh từ bài rewrite</button>';
156
- h+='<button id="ds-img-src-custom" class="ds-img-src-btn" style="background:#333;border:0;color:#ccc;padding:6px 10px;border-radius:8px;font-size:10px;cursor:pointer">🖼️ Ảnh riêng (URL/upload)</button>';
157
- h+='<button id="ds-img-src-none" class="ds-img-src-btn" style="background:#333;border:0;color:#ccc;padding:6px 10px;border-radius:8px;font-size:10px;cursor:pointer">🚫 Không ảnh (nền trơn)</button>';
158
- h+='</div>';
159
- /* URL + upload panel (only the URL/upload inputs collapse; apply button stays visible) */
160
- h+='<div id="ds-custom-img-url-wrap" style="display:none">';
161
- h+='<input id="ds-bg-url" type="text" placeholder="https://... (URL ảnh)" style="width:100%;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:12px;margin-bottom:6px">';
162
- h+='<input type="file" id="ds-bg-file" accept="image/*" style="font-size:11px;color:#888">';
163
- h+='</div>';
164
- /* "Áp dụng ảnh này cho TẤT CẢ slide đã chọn" — ALWAYS visible (Bug 1 fix) */
165
- h+='<div style="display:flex;gap:6px;margin-top:6px;flex-wrap:wrap;align-items:center"><button id="ds-apply-img-btn" style="background:#2d8659;border:0;color:#fff;padding:6px 12px;border-radius:8px;font-size:10px;cursor:pointer">📌 Áp dụng ảnh này cho TẤT CẢ slide đã chọn</button><span id="ds-apply-img-hint" style="color:#5cb87a;font-size:9px;line-height:1.6">✓ Áp dụng để dùng 1 ảnh cho nhiều slide</span></div>';
166
- h+='<div style="color:#888;font-size:10px">Ảnh tải lên/URL nhập từ ngoài (hoặc chọn từ danh sách dưới) sẽ thay thế ảnh bài rewrite và áp dụng cho TẤT CẢ slide đã chọn.</div>';
167
- h+='<div id="ds-rewrite-imgs" style="margin-top:8px;display:none"><label style="color:#888;font-size:10px;display:block;margin-bottom:4px">📋 Danh sách ảnh từ bài rewrite (bấm để chọn ảnh):</label><div id="ds-rewrite-list" style="display:flex;flex-wrap:wrap;gap:6px"></div></div>';
168
- h+='</div>';
169
-
170
- h+='<div style="margin-top:14px;display:flex;gap:8px;justify-content:center;flex-wrap:wrap"><button id="ds-preview-btn" style="background:#2d8659;border:0;color:#fff;padding:10px 18px;border-radius:10px;font-size:12px;font-weight:700;cursor:pointer">👁 Xem trước</button><button id="ds-save-btn" style="background:#1a3a5a;border:1px solid #3a5a7b;color:#fff;padding:10px 18px;border-radius:10px;font-size:12px;font-weight:700;cursor:pointer">📤 Lưu & Đăng lên Tường AI ('+p.slides.length+' slide)</button></div>';
171
- h+='<div id="ds-preview-area" style="margin-top:12px;text-align:center;display:none"></div>';
172
- h+='<div id="ds-status" style="color:#888;font-size:11px;margin-top:8px;text-align:center"></div>';
173
- overlay.innerHTML=h; document.body.appendChild(overlay);
174
-
175
- /* button label update fn */
176
- window._dsUpdateSaveLabel = function(){
177
- const n = _dsState.selected.length;
178
- const b = document.getElementById('ds-save-btn');
179
- if(b) b.textContent = '📤 Lưu & Đăng lên Tường AI ('+n+' slide)';
180
- };
181
-
182
- /* ---- slide selection wiring ---- */
183
- overlay.querySelectorAll('.ds-slide-chk').forEach(chk=>{
184
- chk.addEventListener('change', _dsSyncSelection);
185
- });
186
- document.getElementById('ds-sel-all').addEventListener('click', ()=>{ _setAllSelected(true); });
187
- document.getElementById('ds-sel-none').addEventListener('click', ()=>{ _setAllSelected(false); });
188
- document.getElementById('ds-slide-list').addEventListener('click', e=>{
189
- const lbl = e.target.closest('.ds-slide-chip');
190
- if(!lbl) return;
191
- const chk = lbl.querySelector('.ds-slide-chk');
192
- if(e.target === chk) return;
193
- chk.checked = !chk.checked; _dsSyncSelection();
194
- });
195
-
196
- /* ---- inputs wiring ---- */
197
- document.getElementById('ds-preview-btn').addEventListener('click', _dsPreview);
198
- document.getElementById('ds-save-btn').addEventListener('click', ()=>_dsSave(p));
199
- ['ds-texts','ds-ratio','ds-layout','ds-filter','ds-glow','ds-pos'].forEach(id=>{
200
- ['change','input'].forEach(evt=>{
201
- const el=document.getElementById(id); if(el) el.addEventListener(evt, ()=>_dsDebounce(_dsPreview, 150));
202
- });
203
- });
204
- // URL input: as user types a custom image URL, live-preview + apply to selected
205
- const bgUrlInput=document.getElementById('ds-bg-url');
206
- if(bgUrlInput){
207
- bgUrlInput.addEventListener('input', ()=>{
208
- const v=bgUrlInput.value.trim();
209
- if(v && v.length>8){
210
- _dsSetImgMode('custom');
211
- const proxied=_dsImageSrc(v);
212
- _dsAppliedImg = proxied; // Bug 3: applies to current + future selections
213
- const sel=_dsState.selected;
214
- if(sel.length){ sel.forEach(si=>{ _dsImgMap[si]=proxied; }); }
215
- else { _dsImgMap[0]=proxied; }
216
- }
217
- _dsDebounce(_dsPreview,150);
218
- });
219
- }
220
- // Bug 1 fix: pass through the event; _dsHandleBgFile reads evt.target.files
221
- document.getElementById('ds-bg-file').addEventListener('change', _dsHandleBgFile);
222
- ['ds-img-src-rewrite','ds-img-src-custom','ds-img-src-none'].forEach(id=>{
223
- const b=document.getElementById(id);
224
- if(b) b.addEventListener('click', ()=>{
225
- _dsSetImgMode(id==='ds-img-src-rewrite'?'rewrite':id==='ds-img-src-custom'?'custom':'none');
226
- });
227
- });
228
- /* "Áp dụng ảnh này cho TẤT CẢ slide đã chọn" — always-visible apply button */
229
- const applyBtn=document.getElementById('ds-apply-img-btn');
230
- if(applyBtn){
231
- applyBtn.addEventListener('click', function(){
232
- let chosen = _dsBgDataUrl || (document.getElementById('ds-bg-url')?.value||'');
233
- if(!chosen){ toast('⚠️ Chọn ảnh (tải lên hoặc nhập URL) trước khi áp dụng'); return; }
234
- _dsSetImgMode('custom');
235
- let proxied = _dsImageSrc(chosen);
236
- _dsAppliedImg = proxied; // Bug 3 fix
237
- dsApplyImgToSelected(proxied);
238
- });
239
- }
240
- // Toggle the "nền trơn" (solid) color + sample-image panel when layout changes
241
- const layoutSel=document.getElementById('ds-layout');
242
- if(layoutSel){
243
- const toggleSolidPanel=()=>{
244
- const wrap=document.getElementById('ds-bg-solid-wrap');
245
- if(wrap) wrap.style.display = layoutSel.value==='solid' ? 'block' : 'none';
246
- };
247
- layoutSel.addEventListener('change', toggleSolidPanel);
248
- layoutSel.addEventListener('input', toggleSolidPanel);
249
- toggleSolidPanel();
250
- }
251
- // Solid background color swatches
252
- const bgColorBox=document.getElementById('ds-bg-colors');
253
- if(bgColorBox){
254
- bgColorBox.addEventListener('click', e=>{
255
- const b=e.target.closest('.ds-bgcolor-btn'); if(!b) return;
256
- document.querySelectorAll('#ds-bg-colors .ds-bgcolor-btn').forEach(x=>x.style.borderColor='#333');
257
- b.style.borderColor='#fff';
258
- _dsBgColor = b.dataset.color;
259
- _dsBgSample = ''; // color takes precedence over sample image
260
- document.querySelectorAll('#ds-bg-samples .ds-bgsample-btn').forEach(x=>x.style.borderColor='#333');
261
- _dsStatus('🎨 Đã chọn màu nền: '+_dsBgColor);
262
- _dsDebounce(_dsPreview, 150);
263
- });
264
- }
265
- // Sample background images (only for solid layout)
266
- const bgSampleBox=document.getElementById('ds-bg-samples');
267
- if(bgSampleBox){
268
- bgSampleBox.addEventListener('click', e=>{
269
- const b=e.target.closest('.ds-bgsample-btn'); if(!b) return;
270
- const bi=parseInt(b.dataset.bi,10);
271
- const src=_DS_BG_SAMPLES[bi];
272
- if(!src) return;
273
- document.querySelectorAll('#ds-bg-samples .ds-bgsample-btn').forEach(x=>x.style.borderColor='#333');
274
- b.style.borderColor='#ffd700';
275
- _dsBgSample = _dsImageSrc(src);
276
- _dsBgColor = ''; // sample image takes precedence over color
277
- document.querySelectorAll('#ds-bg-colors .ds-bgcolor-btn').forEach(x=>x.style.borderColor='#333');
278
- _dsStatus('🖼️ Đã chọn ảnh nền mẫu');
279
- _dsDebounce(_dsPreview, 150);
280
- });
281
- }
282
- _syncImgModeBtns();
283
- _dsRefreshRewriteImgs(p);
284
- document.getElementById('ds-colors').addEventListener('click', e=>{
285
- const b = e.target.closest('.ds-color-btn'); if(!b) return;
286
- overlay.querySelectorAll('.ds-color-btn').forEach(x=>x.style.borderColor='#333');
287
- b.style.borderColor='#fff';
288
- _dsTextColor = b.dataset.color;
289
- _dsDebounce(_dsPreview, 150);
290
- });
291
-
292
- _dsRefreshTexts(p);
293
- _dsUpdateSaveLabel();
294
- _dsPreview();
295
- }
296
-
297
- /* ---------------- selection sync ---------------- */
298
- function _setAllSelected(on){
299
- _dsState.selected = [];
300
- document.querySelectorAll('#ds-slide-list .ds-slide-chk').forEach(chk=>{ chk.checked = on; if(on) _dsState.selected.push(parseInt(chk.dataset.idx)); });
301
- // Bug 3 fix: if an image was already applied at designer level, push it to
302
- // every slide that is (now) selected so "1 ảnh cho nhiều slide" also covers
303
- // slides selected AFTER the image was picked.
304
- if(on && _dsAppliedImg){
305
- _dsState.selected.forEach(si=>{ _dsImgMap[si] = _dsAppliedImg; });
306
- }
307
- const p = _wallPosts[_dsState.postIdx];
308
- if(p) _dsRefreshTexts(p);
309
- _dsUpdateSaveLabel();
310
- _dsDebounce(_dsPreview, 150);
311
- }
312
- function _dsSyncSelection(){
313
- _dsState.selected = [];
314
- document.querySelectorAll('#ds-slide-list .ds-slide-chk:checked').forEach(chk=>{ _dsState.selected.push(parseInt(chk.dataset.idx)); });
315
- // Bug 3 fix: same propagation for checkbox changes.
316
- if(_dsAppliedImg){
317
- _dsState.selected.forEach(si=>{ if(!_dsImgMap[si]) _dsImgMap[si] = _dsAppliedImg; });
318
- }
319
- const p = _wallPosts[_dsState.postIdx];
320
- if(p) _dsRefreshTexts(p);
321
- _dsUpdateSaveLabel();
322
- _dsDebounce(_dsPreview, 150);
323
- }
324
-
325
- /* ---------------- per-slide text areas ---------------- */
326
- function _dsRefreshTexts(p){
327
- const box = document.getElementById('ds-texts'); if(!box) return;
328
- if(!_dsState.selected.length){ box.innerHTML='<div style="color:#888;font-size:11px;padding:6px">Chưa chọn slide nào.</div>'; return; }
329
- let h='<div style="display:flex;flex-direction:column;gap:6px">';
330
- _dsState.selected.forEach((si, k)=>{
331
- const s = p.slides[si];
332
- h+=`<textarea data-sivec="${k}" data-sidx="${si}" style="width:100%;min-height:44px;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:13px;resize:vertical">${esc(s.text||'')}</textarea>`;
333
- });
334
- h+='</div>';
335
- box.innerHTML=h;
336
- box.querySelectorAll('textarea').forEach(ta=>{
337
- ta.addEventListener('input', ()=>{ _dsDebounce(_dsPreview, 200); });
338
- });
339
- }
340
-
341
- /* ---------------- bg file ---------------- */
342
- let _dsBgDataUrl = null;
343
- let _dsTextColor = '#ffffff';
344
- let _dsImgMode = 'rewrite'; // 'rewrite' | 'custom' | 'none'
345
- /* Bug 1 fix: this is bound as a 'change' listener, so the first arg is the
346
- Event. Read the <input> from evt.target.files (previously input.files was
347
- undefined, so the upload silently did nothing and the preview/apply button
348
- never saw the image). */
349
- function _dsHandleBgFile(evt){
350
- const input = evt && evt.target ? evt.target : evt;
351
- const file = (input && input.files && input.files[0]) ? input.files[0] : null;
352
- if(!file) return;
353
- _dsSetImgMode('custom'); // show the custom-img URL/upload panel
354
- const reader=new FileReader();
355
- reader.onload=function(e){
356
- _dsBgDataUrl=e.target.result;
357
- _dsImgMode='custom';
358
- _syncImgModeBtns();
359
- _dsStatus('✅ Đã chọn ảnh: '+(file.name||'').slice(0,30) + (file.size? ' ('+Math.round(file.size/1024)+'KB)':''));
360
- // Auto-apply uploaded image to ALL currently selected slides so the upload
361
- // has an immediate, visible effect (preview + save will use it for every slide).
362
- _dsAppliedImg = _dsBgDataUrl; // Bug 3 fix: applies to future selections too
363
- const sel=_dsState.selected;
364
- if(sel.length){ sel.forEach(si=>{ _dsImgMap[si]=_dsBgDataUrl; }); }
365
- else { _dsImgMap[0]=_dsBgDataUrl; }
366
- _dsDebounce(_dsPreview,150);
367
- };
368
- reader.readAsDataURL(file);
369
- }
370
- /* Show a transient status line in the designer footer. */
371
- function _dsStatus(msg){
372
- const s=document.getElementById('ds-status'); if(s) s.textContent=msg;
373
- }
374
- function _syncImgModeBtns(){
375
- ['ds-img-src-rewrite','ds-img-src-custom','ds-img-src-none'].forEach(id=>{
376
- const b=document.getElementById(id);
377
- if(!b) return;
378
- const on = (id==='ds-img-src-rewrite' && _dsImgMode==='rewrite') ||
379
- (id==='ds-img-src-custom' && _dsImgMode==='custom') ||
380
- (id==='ds-img-src-none' && _dsImgMode==='none');
381
- b.style.background = on ? '#2d8659' : '#333';
382
- b.style.color = on ? '#fff' : '#ccc';
383
- });
384
- const wrap=document.getElementById('ds-custom-img-url-wrap');
385
- if(wrap) wrap.style.display = _dsImgMode==='custom' ? 'block' : 'none';
386
- }
387
- function _dsSetImgMode(m){
388
- _dsImgMode = m;
389
- _syncImgModeBtns();
390
- _dsDebounce(_dsPreview, 150);
391
- }
392
-
393
- /* Build a list of available rewrite images (post.img + each slide.image)
394
- and render them so the user can pick 1 image to apply to the selected slides.
395
- Bug 2 fix: uses data-index + a delegated click handler (no fragile inline
396
- onclick embedding the raw URL, which broke on URLs containing quotes). */
397
- function _dsRefreshRewriteImgs(p){
398
- const box = document.getElementById('ds-rewrite-list');
399
- if(!box) return;
400
- const imgs = [];
401
- const seen = new Set();
402
- // post-level image first
403
- if(p.img && typeof p.img === 'string' && p.img.length){
404
- const key = p.img.startsWith('/api/') ? p.img : _dsImageSrc(p.img);
405
- if(!seen.has(key)){ seen.add(key); imgs.push({src: key, label: 'Ảnh bài (đại diện)'}); }
406
- }
407
- // then per-slide images
408
- if(Array.isArray(p.slides)){
409
- p.slides.forEach((s, i)=>{
410
- const raw = s.image || s.img || '';
411
- if(raw && typeof raw === 'string' && raw.length){
412
- const key = raw.startsWith('/api/') ? raw : _dsImageSrc(raw);
413
- if(!seen.has(key)){ seen.add(key); imgs.push({src: key, label: `Slide ${s.index||i+1}`}); }
414
- }
415
- });
416
- }
417
- if(!imgs.length){
418
- box.innerHTML = '<div style="color:#666;font-size:10px">Không có ảnh rewrite nào.</div>';
419
- const wrap = document.getElementById('ds-rewrite-imgs');
420
- if(wrap) wrap.style.display = 'none';
421
- return;
422
- }
423
- const wrap = document.getElementById('ds-rewrite-imgs');
424
- if(wrap) wrap.style.display = 'block';
425
- _dsRewriteImgSrcs = imgs.map(x=>x.src); // index -> src
426
- let h='';
427
- imgs.forEach((img, idx)=>{
428
- h += '<div style="display:flex;flex-direction:column;align-items:center;gap:2px">'
429
- + '<img src="'+esc(img.src)+'" data-ds-rw="'+idx+'" style="width:48px;height:48px;object-fit:cover;border-radius:6px;border:2px solid #2d8659;cursor:pointer" '
430
- + 'onerror="this.style.display=\'none\'">'
431
- + '<div style="color:#aaa;font-size:8px;text-align:center">'+img.label+'</div>'
432
- + '</div>';
433
- });
434
- box.innerHTML = h;
435
- // Bug 2 fix: delegated click handler (event delegation, URL-safe)
436
- box.onclick = function(e){
437
- const im = e.target.closest('img[data-ds-rw]');
438
- if(!im) return;
439
- const idx = parseInt(im.getAttribute('data-ds-rw'), 10);
440
- const src = _dsRewriteImgSrcs[idx];
441
- if(src) dsApplyImgToSelected(src);
442
- };
443
- }
444
-
445
- /* Apply a chosen image src to ALL selected slides (or the current preview slide). */
446
- function dsApplyImgToSelected(imgSrc){
447
- _dsAppliedImg = imgSrc; // Bug 3 fix: remember at designer level (future selections too)
448
- const sel = _dsState.selected;
449
- if(sel.length){
450
- sel.forEach(si=>{ _dsImgMap[si] = imgSrc; });
451
- }else{
452
- _dsImgMap[0] = imgSrc;
453
- }
454
- _dsImgMode = 'custom'; // visually marks we are using a chosen image
455
- _syncImgModeBtns();
456
- _dsStatus('✅ Đã áp dụng ảnh cho '+(sel.length? sel.length+' slide đã chọn':'slide hiện tại'));
457
- // refresh preview immediately (selected slide 0 is what we preview)
458
- const p=_wallPosts[_dsState.postIdx];
459
- if(p && sel.length){ _dsOptions(p, sel[0]); }
460
- _dsPreview();
461
- }
462
-
463
- function _dsDebounce(fn,d){ clearTimeout(fn._t); fn._t=setTimeout(fn,d); }
464
-
465
- /* ---------------- get design options ---------------- */
466
- function _dsOptions(p, si){
467
- const s = p.slides[si];
468
- const ta = document.querySelector(`#ds-texts textarea[data-sidx="${si}"]`);
469
- const text = ta ? ta.value : (s.text||'');
470
- const bgInput = document.getElementById('ds-bg-url')?.value||'';
471
- // Per-slide image override: when user picks an image from the rewrite list,
472
- // it is stored in _dsImgMap[si] and applied to that specific slide.
473
- let bgUrl = '';
474
- // Solid layout + user picked a SAMPLE background image -> use it as the
475
- // background photo (dark overlay for readability) instead of a flat color.
476
- const isSolid = designerGetLayoutV2()==='solid';
477
- const useSample = isSolid && _dsBgSample && !_dsAppliedImg;
478
- if(useSample) bgUrl = _dsBgSample;
479
- // Bug 3 fix: designer-level applied image wins for every selected slide.
480
- // Applied when the user picked an image (rewrite picker / upload / URL /
481
- // "Áp dụng" button) — even for slides selected AFTER the image was picked.
482
- if(!bgUrl && _dsAppliedImg) bgUrl = _dsAppliedImg;
483
- if(!bgUrl && _dsImgMap[si]) bgUrl = _dsImgMap[si];
484
- if(!bgUrl){
485
- if(_dsImgMode === 'custom'){
486
- bgUrl = _dsBgDataUrl || bgInput || '';
487
- } else if(_dsImgMode === 'rewrite'){
488
- bgUrl = s.image || p.img || '';
489
- } // none -> ''
490
- }
491
- return {
492
- si, s, text, bgUrl,
493
- ratio: designerGetRatioV2(),
494
- layout: isSolid ? 'solid' : designerGetLayoutV2(),
495
- filter: designerGetFilterV2(),
496
- glow: designerGetGlowV2(),
497
- pos: designerGetPosV2(),
498
- color: _dsTextColor,
499
- bgColor: _dsBgColor, // solid-layout background color ('' = default dark)
500
- bgSample: useSample ? _dsBgSample : '', // 'sample' mode: img passed to draw
501
- };
502
- }
503
-
504
- /* ---------------- drawing core ---------------- */
505
- function _dsImageSrc(url){
506
- if(!url) return '';
507
- if(url.startsWith('/api/') || url.startsWith('data:')) return url;
508
- return '/api/proxy/img?url='+encodeURIComponent(url);
509
- }
510
- /* Only tag crossOrigin=anonymous for proxied EXTERNAL images.
511
- Setting it on data: URLs or local /api/ paths can cause silent onload
512
- failures (black preview) in some browser/CORS configurations. */
513
- function _dsMakeImg(url){
514
- const img=new Image();
515
- if(url && !url.startsWith('data:') && !url.startsWith('/api/')) img.crossOrigin='anonymous';
516
- return img;
517
- }
518
-
519
- /* Sample background images for the "Nền đều (solid)" layout — abstract
520
- gradients that look good with white text. Served through the /api/proxy/img
521
- endpoint (same-origin, no crossOrigin needed — _dsMakeImg handles that).
522
- Keyed for the modal background swatches and for _dsDrawLayout. */
523
- const _DS_BG_SAMPLES = [
524
- 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=900&q=60', // sơn hùng vĩ (núi)
525
- 'https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=900&q=60', // sương mù rừng
526
- 'https://images.unsplash.com/photo-1462331940025-496dfbfc7564?w=900&q=60', // vũ trụ
527
- 'https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=900&q=60', // núi rừng
528
- 'https://images.unsplash.com/photo-1499002238440-d264edd596ec?w=900&q=60', // hoàng hôn
529
- 'https://images.unsplash.com/photo-1448375240586-882707db888b?w=900&q=60', // rừng tối
530
- 'https://images.unsplash.com/photo-1519681393784-d120267933ba?w=900&q=60', // trời sao
531
- 'https://images.unsplash.com/photo-1518173946687-a4c8892bbd9f?w=900&q=60', // biển đêm
532
- 'https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?w=900&q=60', // đồng cỏ
533
- ];
534
-
535
- /* draw bg image with cover-fit + color filter */
536
- function _dsDrawBg(ctx, W, H, bgUrl, filter, blur){
537
- ctx.save();
538
- ctx.fillStyle='#111'; ctx.fillRect(0,0,W,H);
539
- // filter string
540
- let f='';
541
- if(filter==='grayscale') f='grayscale(1)';
542
- else if(filter==='sepia') f='sepia(0.85)';
543
- else if(filter==='saturate') f='saturate(2.4)';
544
- else if(filter==='warm') f='sepia(0.45) saturate(1.5) hue-rotate(-15deg)';
545
- else if(filter==='cool') f='saturate(1.2) hue-rotate(15deg) brightness(1.05)';
546
- else if(filter==='invert') f='invert(1)';
547
- else if(filter==='noir') f='grayscale(1) contrast(1.6) brightness(0.9)';
548
- if(blur) f=(f?f+' ':'')+'blur('+blur+'px)';
549
- ctx.filter = f;
550
- if(bgUrl){
551
- const img = new Image(); img.crossOrigin='anonymous';
552
- // Note: async path handled by caller via onload; this fn is synchronous draw of already-loaded img
553
- }
554
- ctx.restore();
555
- }
556
-
557
- /* ---------------- layout effects (with loaded bg image) ---------------- */
558
- function _dsDrawLayout(ctx, W, H, opts, img){
559
- const { layout, filter, bgUrl, bgColor } = opts;
560
- // base: solid layout can use the chosen background color (no image needed)
561
- let baseColor = '#141414';
562
- if(layout==='solid' && !img && bgColor) baseColor = bgColor;
563
- ctx.fillStyle = baseColor; ctx.fillRect(0,0,W,H);
564
- // draw bg image cover-fit if present & loaded
565
- if(img){
566
- const blur = layout==='blur' ? Math.max(6, Math.round(W/90)) : 0;
567
- ctx.save();
568
- let f='';
569
- if(filter==='grayscale') f='grayscale(1)';
570
- else if(filter==='sepia') f='sepia(0.85)';
571
- else if(filter==='saturate') f='saturate(2.4)';
572
- else if(filter==='warm') f='sepia(0.45) saturate(1.5) hue-rotate(-15deg)';
573
- else if(filter==='cool') f='saturate(1.2) hue-rotate(15deg) brightness(1.05)';
574
- else if(filter==='invert') f='invert(1)';
575
- else if(filter==='noir') f='grayscale(1) contrast(1.6) brightness(0.9)';
576
- if(blur) f=(f?f+' ':'')+'blur('+blur+'px)';
577
- ctx.filter = f;
578
- const iw=img.naturalWidth||1, ih=img.naturalHeight||1;
579
- const scale=Math.max(W/iw, H/ih);
580
- const dw=iw*scale, dh=ih*scale;
581
- const dx=(W-dw)/2, dy=(H-dh)/2;
582
- ctx.drawImage(img,0,0,iw,ih,dx,dy,dw,dh);
583
- ctx.filter='none';
584
- ctx.restore();
585
- }
586
- // overlay per layout
587
- if(layout==='gradient'){
588
- const g=ctx.createLinearGradient(0,0,W,H);
589
- g.addColorStop(0,'rgba(50,20,90,0.75)'); g.addColorStop(0.5,'rgba(20,60,120,0.6)'); g.addColorStop(1,'rgba(120,30,60,0.75)');
590
- ctx.fillStyle=g; ctx.fillRect(0,0,W,H);
591
- }
592
- else if(layout==='vignette'){
593
- const g=ctx.createRadialGradient(W/2,H/2,0,W/2,H/2,Math.max(W,H)*0.62);
594
- g.addColorStop(0,'rgba(0,0,0,0)'); g.addColorStop(1,'rgba(0,0,0,0.78)');
595
- ctx.fillStyle=g; ctx.fillRect(0,0,W,H);
596
- }
597
- else if(layout==='split'){
598
- const g=ctx.createLinearGradient(0,0,0,H);
599
- g.addColorStop(0,'rgba(0,0,0,0.85)'); g.addColorStop(0.5,'rgba(0,0,0,0.15)'); g.addColorStop(1,'rgba(0,0,0,0.85)');
600
- ctx.fillStyle=g; ctx.fillRect(0,0,W,H);
601
- }
602
- else if(layout==='spotlight'){
603
- ctx.fillStyle='rgba(0,0,0,0.72)'; ctx.fillRect(0,0,W,H);
604
- const cx=W/2, cy=H*0.45, r=Math.max(W,H)*0.4;
605
- const g=ctx.createRadialGradient(cx,cy,0,cx,cy,r);
606
- g.addColorStop(0,'rgba(255,255,255,0.45)'); g.addColorStop(0.6,'rgba(255,255,255,0.12)'); g.addColorStop(1,'rgba(0,0,0,0.9)');
607
- ctx.fillStyle=g; ctx.fillRect(0,0,W,H);
608
- }
609
- else if(layout==='diagonal'){
610
- ctx.fillStyle='rgba(0,0,0,0.55)'; ctx.fillRect(0,0,W,H);
611
- ctx.fillStyle='rgba(0,0,0,0.8)';
612
- ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(W*0.9,0); ctx.lineTo(0,H*0.9); ctx.closePath(); ctx.fill();
613
- }
614
- else if(layout==='neon'){
615
- ctx.fillStyle='rgba(10,10,30,0.85)'; ctx.fillRect(0,0,W,H);
616
- ctx.strokeStyle='rgba(80,220,255,0.5)'; ctx.lineWidth=Math.max(2,W/300);
617
- ctx.strokeRect(W*0.03,H*0.03,W*0.94,H*0.94);
618
- }
619
- else if(layout==='top-banner'){
620
- ctx.fillStyle='rgba(0,0,0,0)';
621
- const g=ctx.createLinearGradient(0,0,0,H*0.5);
622
- g.addColorStop(0,'rgba(0,0,0,0.8)'); g.addColorStop(1,'rgba(0,0,0,0)');
623
- ctx.fillStyle=g; ctx.fillRect(0,0,W,H*0.5);
624
- }
625
- else if(layout==='lower-third'){
626
- const g=ctx.createLinearGradient(0,H*0.55,0,H);
627
- g.addColorStop(0,'rgba(0,0,0,0)'); g.addColorStop(1,'rgba(0,0,0,0.82)');
628
- ctx.fillStyle=g; ctx.fillRect(0,H*0.55,W,H*0.45);
629
- }
630
- else if(layout==='card'){
631
- ctx.fillStyle='rgba(0,0,0,0.35)'; ctx.fillRect(0,0,W,H);
632
- // rounded card
633
- const pad=Math.round(W*0.04);
634
- ctx.fillStyle='rgba(0,0,0,0.65)';
635
- _dsRoundRect(ctx, pad, H*0.16, W-2*pad, H*0.68, Math.round(W*0.05));
636
- ctx.fill();
637
- ctx.strokeStyle='rgba(255,255,255,0.25)'; ctx.lineWidth=Math.max(2,W/400); _dsRoundRect(ctx,pad,H*0.16,W-2*pad,H*0.68,Math.round(W*0.05)); ctx.stroke();
638
- }
639
- else if(layout==='corner'){
640
- ctx.fillStyle='rgba(0,0,0,0.3)'; ctx.fillRect(0,0,W,H);
641
- ctx.strokeStyle='rgba(255,215,0,0.9)'; ctx.lineWidth=Math.max(4,W/160);
642
- const L=Math.round(W*0.18), c=Math.round(W*0.04);
643
- [[0,c],[c,0],[W,c],[W-c,0],[0,H-c],[c,H],[W,H-c],[W-c,H]].forEach(([x,y],i)=>{
644
- const top = y===0 || y===c; const left = x===0 || x===c;
645
- ctx.beginPath();
646
- if(i<4){ // top corners
647
- if(top && left){ ctx.moveTo(x,y+L); ctx.lineTo(x,y); ctx.lineTo(x+L,y); }
648
- else if(top){ ctx.moveTo(x,y+L); ctx.lineTo(x,y); ctx.lineTo(x-L,y); }
649
- else { ctx.moveTo(x-L,y); ctx.lineTo(x,y); ctx.lineTo(x,y+L); }
650
- } else { // bottom corners
651
- if(left){ ctx.moveTo(x,y-L); ctx.lineTo(x,y); ctx.lineTo(x+L,y); }
652
- else { ctx.moveTo(x-L,y); ctx.lineTo(x,y); ctx.lineTo(x,y-L); }
653
- }
654
- ctx.stroke();
655
- });
656
- }
657
- else if(layout==='stars'){
658
- ctx.fillStyle='rgba(8,8,30,0.82)'; ctx.fillRect(0,0,W,H);
659
- const n=Math.round(W*H/18000);
660
- for(let i=0;i<n;i++){
661
- const x=Math.random()*W, y=Math.random()*H, r=Math.random()*Math.max(1,W/300)+0.5;
662
- ctx.fillStyle=`rgba(255,255,255,${0.4+Math.random()*0.6})`;
663
- ctx.beginPath(); ctx.arc(x,y,r,0,Math.PI*2); ctx.fill();
664
- }
665
- }
666
- else { // solid
667
- if(img && bgUrl){
668
- // sample background photo (or user image) -> dark overlay so text is readable
669
- ctx.fillStyle='rgba(0,0,0,0.45)'; ctx.fillRect(0,0,W,H);
670
- } else if(bgColor){
671
- // chosen solid color with a subtle gradient + soft vignette for depth
672
- const g=ctx.createLinearGradient(0,0,W,H);
673
- g.addColorStop(0, bgColor);
674
- g.addColorStop(1, _dsShadeColor(bgColor, -0.25));
675
- ctx.fillStyle=g; ctx.fillRect(0,0,W,H);
676
- const vg=ctx.createRadialGradient(W/2,H/2,0,W/2,H/2,Math.max(W,H)*0.66);
677
- vg.addColorStop(0,'rgba(0,0,0,0)'); vg.addColorStop(1,'rgba(0,0,0,0.42)');
678
- ctx.fillStyle=vg; ctx.fillRect(0,0,W,H);
679
- } else {
680
- ctx.fillStyle='rgba(0,0,0,0.6)'; ctx.fillRect(0,0,W,H);
681
- }
682
- }
683
- }
684
-
685
- /* Darken (n<0) or lighten (n>0) a hex color by n*100% (for solid bg gradient). */
686
- function _dsShadeColor(hex, n){
687
- try{
688
- hex=String(hex||'').replace('#','');
689
- if(hex.length===3) hex=hex.split('').map(c=>c+c).join('');
690
- if(hex.length!==6) return hex;
691
- const num=parseInt(hex,16);
692
- let r=(num>>16)&255, g=(num>>8)&255, b=num&255;
693
- const t = n<0 ? 0 : 255;
694
- const p = Math.abs(n);
695
- r=Math.round((t-r)*p)+r; g=Math.round((t-g)*p)+g; b=Math.round((t-b)*p)+b;
696
- return '#'+((1<<24)+(r<<16)+(g<<8)+b).toString(16).slice(1);
697
- }catch(e){ return hex; }
698
- }
699
-
700
- function _dsRoundRect(ctx,x,y,w,h,r){
701
- ctx.beginPath();
702
- ctx.moveTo(x+r,y);
703
- ctx.arcTo(x+w,y,x+w,y+h,r);
704
- ctx.arcTo(x+w,y+h,x,y+h,r);
705
- ctx.arcTo(x,y+h,x,y,r);
706
- ctx.arcTo(x,y,x+w,y,r);
707
- ctx.closePath();
708
- }
709
-
710
- /* ---------------- text drawing ---------------- */
711
- function _dsDrawText(ctx, W, H, opts, baseFontScale){
712
- const { text, pos, glow, color } = opts;
713
- if(!text || !text.trim()) return;
714
- const base = Math.max(28, Math.round(W*baseFontScale));
715
- const fs = Math.min(base, Math.round(W*0.09));
716
- ctx.font = 'bold '+fs+'px "Segoe UI","Arial","Noto Sans",sans-serif';
717
- ctx.textAlign='center';
718
- // split lines
719
- const lines=[]; let line='';
720
- const maxW=Math.round(W*0.82);
721
- text.split(' ').forEach(w=>{
722
- const test = line? line+' '+w : w;
723
- if(ctx.measureText(test).width>maxW && line){ lines.push(line.trim()); line=w; } else { line=test; }
724
- });
725
- if(line) lines.push(line.trim());
726
- const lh=Math.round(fs*1.25);
727
- const blockH=lines.length*lh;
728
- let y;
729
- if(pos==='top') y=Math.round(H*0.14)+lh/2;
730
- else if(pos==='bottom') y=Math.round(H*0.88)-blockH+lh/2;
731
- else y=Math.round(H/2)-blockH/2+lh/2;
732
- // glow
733
- if(glow==='white'){ ctx.shadowColor='rgba(255,255,255,0.95)'; ctx.shadowBlur=Math.max(8,fs*0.5); }
734
- else if(glow==='color'){ ctx.shadowColor=color; ctx.shadowBlur=Math.max(10,fs*0.6); }
735
- else if(glow==='neon'){ ctx.shadowColor='#00ffff'; ctx.shadowBlur=Math.max(16,fs*0.9); }
736
- ctx.fillStyle=color;
737
- // subtle stroke for legibility at top/bottom
738
- if(pos==='top'||pos==='bottom'){ ctx.strokeStyle='rgba(0,0,0,0.6)'; ctx.lineWidth=Math.max(2,fs*0.12); ctx.strokeText(textLine(text),W/2,y); }
739
- lines.forEach((l,li)=>{ ctx.fillText(l, W/2, y+li*lh); });
740
- ctx.shadowBlur=0;
741
- }
742
- function textLine(t){ return t; }
743
-
744
- /* ---------------- compute final canvas size for a design ---------------- */
745
- function _dsFinalSize(opts){
746
- const r=opts.ratio;
747
- const w = (r.w>=r.h) ? 1800 : 1350;
748
- const h = Math.round(w*r.h/r.w);
749
- return { w, h };
750
- }
751
-
752
- /* Shared renderer (Bug 4 fix): renders a slide onto `canvas` using the SAME
753
- code path and SAME resolution for BOTH preview and final save, so the
754
- published image is pixel-identical to what the user saw in the preview —
755
- same cover-fit, same filter, same text placement, same background. */
756
- function _dsRenderSlide(canvas, opts, done){
757
- const ctx = canvas.getContext('2d');
758
- const bgUrl = _dsImageSrc(opts.bgUrl);
759
- function draw(img){
760
- _dsDrawLayout(ctx, canvas.width, canvas.height, opts, img||null);
761
- _dsDrawText(ctx, canvas.width, canvas.height, opts, 0.052);
762
- if(done) done(img||null);
763
- }
764
- if(bgUrl){
765
- const img=_dsMakeImg(bgUrl);
766
- img.onload=function(){ draw(img); };
767
- img.onerror=function(){ console.warn('[designer] bg image failed to load:', bgUrl); draw(null); };
768
- img.src=bgUrl;
769
- } else {
770
- draw(null);
771
- }
772
- }
773
-
774
- /* ---------------- preview ---------------- */
775
- function _dsPreview(){
776
- const p=_wallPosts[_dsState.postIdx]; if(!p) return;
777
- const area=document.getElementById('ds-preview-area'); if(!area) return;
778
- const sel=_dsState.selected;
779
- if(!sel.length){ area.style.display='block'; area.innerHTML='<div style="color:#888;font-size:12px">Chưa chọn slide nào để xem trước.</div>'; return; }
780
- area.style.display='block';
781
- area.innerHTML='<div style="color:#888;font-size:12px">⏳ Đang tạo xem trước...</div>';
782
- // display-scale: cap visible width at 300px, keep aspect ratio per slide
783
- const dispW=Math.min(300, Math.floor((Math.max(document.documentElement.clientWidth||540, document.body.clientWidth||540))*0.5));
784
- const cans=[];
785
- sel.forEach((si, k)=>{
786
- const opts=_dsOptions(p, si);
787
- const { w, h }=_dsFinalSize(opts);
788
- const canvas=document.createElement('canvas');
789
- canvas.width=w; canvas.height=h;
790
- canvas.style.width=dispW+'px'; canvas.style.height='auto';
791
- canvas.style.borderRadius='12px'; canvas.style.boxShadow='0 8px 24px rgba(0,0,0,.5)'; canvas.style.display='block'; canvas.style.margin='0 auto 14px auto';
792
- cans.push({opts, canvas});
793
- });
794
- let remaining=cans.length;
795
- function allDone(){
796
- if(--remaining>0) return;
797
- let out='';
798
- if(cans.length>1) out+='<div style="color:#888;font-size:10px;margin-bottom:8px">Xem trước '+(cans.length)+' slide đã chọn (cuộn xuống để xem hết):</div>';
799
- area.innerHTML=out;
800
- cans.forEach((c,k)=>{
801
- if(k>0) area.appendChild(document.createElement('br'));
802
- area.appendChild(c.canvas);
803
- });
804
- }
805
- cans.forEach(c=>{ _dsRenderSlide(c.canvas, c.opts, allDone); });
806
- }
807
-
808
- /* ---------------- save: render + upload each selected slide, then post ---------------- */
809
- async function _dsSave(p){
810
- const statusEl=document.getElementById('ds-status'); if(statusEl) statusEl.textContent='⏳ Đang tạo ảnh các slide...';
811
- const sel=_dsState.selected;
812
- if(!sel.length){ if(statusEl) statusEl.textContent='⚠️ Chưa chọn slide nào để đăng.'; return; }
813
- const saveBtn=document.getElementById('ds-save-btn'); if(saveBtn){ saveBtn.disabled=true; saveBtn.textContent='⏳ Đang xử lý...'; }
814
-
815
- const uploaded = []; // {si, url}
816
- try{
817
- // render each selected slide at final resolution
818
- for(let k=0;k<sel.length;k++){
819
- const si=sel[k];
820
- if(statusEl) statusEl.textContent=`⏳ Đang tạo ảnh slide ${k+1}/${sel.length}... (${p.slides.length} slide trong bài)`;
821
- const opts=_dsOptions(p, si);
822
- const { w: FINAL_W, h: FINAL_H }=_dsFinalSize(opts);
823
- const canvas=document.createElement('canvas'); canvas.width=FINAL_W; canvas.height=FINAL_H;
824
- // Shared render path (Bug 4 fix) — identical to preview.
825
- const url = await new Promise((resolve)=>{
826
- _dsRenderSlide(canvas, opts, function(){
827
- // Nén JPEG thay PNG: dung lượng rất nhỏ (thường 5-10x nhỏ hơn),
828
- // ảnh tải nhanh hơn nhiều trên Tường AI. Chất lượng 0.82 giữ chữ sắc nét.
829
- canvas.toBlob(async function(blob){
830
- const fd=new FormData();
831
- fd.append('file', blob, 'slide_'+(si+1)+'.jpg');
832
- fd.append('post_id', p.id||'');
833
- try{
834
- const r=await fetch('/api/wall/img',{method:'POST',body:fd});
835
- const j=await r.json();
836
- if(!r.ok || j.error) throw new Error(j.error||'Lỗi upload');
837
- resolve(j.url);
838
- }catch(e){ resolve({error:e.message}); }
839
- },'image/jpeg', 0.82);
840
- });
841
- });
842
- if(url && url.error) throw new Error(url.error);
843
- uploaded.push({si, url});
844
- // update slide image live
845
- if(p.slides[si]) p.slides[si].image = url;
846
- }
847
-
848
- if(statusEl) statusEl.textContent='⏳ Đang đăng lên Tường AI...';
849
-
850
- // build updated slides array (keep unselected unchanged)
851
- const newSlides = p.slides.map((s,i)=>{
852
- const up = uploaded.find(u=>u.si===i);
853
- // ensure text edits are applied too
854
- const ta = document.querySelector(`#ds-texts textarea[data-sidx="${i}"]`);
855
- return {
856
- text: ta ? ta.value : (s.text||''),
857
- image: up ? up.url : (s.image||''),
858
- index: s.index || (i+1),
859
- };
860
- });
861
-
862
- // pick selected slides only -> the wall post is a SLIDE-IMAGE post of selected slides
863
- const selSlides = newSlides.filter((_,i)=> sel.includes(i));
864
-
865
- const post = {
866
- id: p.id,
867
- title: p.title||'',
868
- text: selSlides.map(s=>'• '+s.text).join('\n\n'),
869
- img: selSlides[0] ? selSlides[0].image : p.img||'',
870
- url: p.url||'',
871
- slides: selSlides, // <-- bài đăng bao gồm chỉ các slide đã chọn
872
- images: selSlides.map(s=>s.image),
873
- kind: p.kind||'slide_summary',
874
- video: p.video||'',
875
- voice: p.voice||'',
876
- emotion: p.emotion||'',
877
- language: p.language||'',
878
- ts: p.ts||Math.floor(Date.now()/1000),
879
- };
880
-
881
- const wr=await fetch('/api/wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(post)});
882
- const wj=await wr.json();
883
- if(wr.ok && wj.post){
884
- prependWallPost(wj.post);
885
- toast('✅ Đã đăng bài slide lên Tường AI ('+selSlides.length+' slide)!');
886
- } else {
887
- toast('✅ Đã lưu ảnh slide!');
888
- }
889
- document.getElementById('slide-designer-overlay').remove();
890
- }catch(e){
891
- if(statusEl) statusEl.textContent='❌ '+e.message;
892
- toast('❌ '+e.message);
893
- if(saveBtn){ saveBtn.disabled=false; saveBtn.textContent='📤 Lưu & Đăng lên Tường AI ('+sel.length+' slide)'; }
894
- }
895
- }
896
-
897
- /* keep old helper names harmless for any other caller */
898
- function updateDesignerText(){}
899
- function designerGetRatio(){ return designerGetRatioV2(); }
900
- function designerGetLayout(){ return designerGetLayoutV2(); }
901
- function designerDebounce(fn,d){ _dsDebounce(fn,d); }
902
- function handleDesignerBgFile(){}
903
- function previewDesignerSlide(){ _dsPreview(); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
static/fm_fix.css CHANGED
@@ -168,262 +168,3 @@
168
  min-width:100%!important;max-width:none!important;max-height:none!important;
169
  object-fit:cover!important;
170
  }
171
-
172
- /* ============================================================
173
- BXH / BÅNG XẼP HẠNG (standing league table) FIX
174
- Bongda returns div-based Tailwind rows (.ranking-table .leaderboard
175
- .leaderboard-item / .head / .team / .copy). The old CSS only styled
176
- <table> so the standings rendered broken. Style the divs directly.
177
- ============================================================ */
178
- #ls-content .ranking-table,
179
- #ls-content .leaderboard{display:block!important;width:100%!important}
180
- #ls-content .ranking-table .head,
181
- #ls-content .leaderboard .head{display:flex!important;width:100%!important;background:#222!important;border-bottom:1px solid #333!important;padding:5px 0!important;font-size:10px!important;color:#999!important}
182
- #ls-content .ranking-table .head .team,
183
- #ls-content .leaderboard .head .team{flex:1 1 auto!important;min-width:0!important}
184
- #ls-content .ranking-table .head .team span,
185
- #ls-content .leaderboard .head .team span{display:inline-block!important;width:18px!important;text-align:center!important;color:#888!important}
186
- #ls-content .ranking-table .head .team .link,
187
- #ls-content .leaderboard .head .team .link{font-size:10px!important;color:#999!important;font-weight:700!important}
188
- #ls-content .ranking-table .head .copy,
189
- #ls-content .leaderboard .head .copy{display:flex!important;gap:2px!important;align-items:center!important;justify-content:flex-end!important;min-width:0!important}
190
- #ls-content .ranking-table .head .copy p,
191
- #ls-content .leaderboard .head .copy p{font-size:9px!important;color:#888!important;min-width:16px!important;text-align:center!important;margin:0!important}
192
- #ls-content .ranking-table .head .copy p.form,
193
- #ls-content .leaderboard .head .copy p.form{display:none!important}
194
- #ls-content .ranking-table .leaderboard-item,
195
- #ls-content .ranking-table #team_,
196
- #ls-content .leaderboard .leaderboard-item{
197
- display:table!important;width:100%!important;table-layout:fixed!important;
198
- border-collapse:collapse!important;border-bottom:1px solid #1a1a1a!important;
199
- font-size:11px!important;color:#ccc!important;background:transparent!important
200
- }
201
- #ls-content .ranking-table .leaderboard-item>div.team,
202
- #ls-content .ranking-table .leaderboard-item>.team,
203
- #ls-content .leaderboard .leaderboard-item .team{
204
- display:table-cell!important;vertical-align:middle!important;
205
- padding:5px 4px!important;text-align:left!important;min-width:0!important
206
- }
207
- #ls-content .ranking-table .leaderboard-item>div.team span,
208
- #ls-content .ranking-table .leaderboard-item>.team span{
209
- display:inline-block!important;width:18px!important;min-width:18px!important;
210
- text-align:center!important;font-size:10px!important;color:#888!important;font-weight:600!important
211
- }
212
- #ls-content .ranking-table .leaderboard-item>div.team a.link,
213
- #ls-content .ranking-table .leaderboard-item>.team a.link{
214
- display:inline-flex!important;align-items:center!important;gap:4px!important;
215
- min-width:0!important;font-size:11px!important;color:#ccc!important;text-decoration:none!important
216
- }
217
- #ls-content .ranking-table .leaderboard-item>div.team a.link img,
218
- #ls-content .ranking-table .leaderboard-item>.team a.link img{width:16px!important;height:16px!important;object-fit:contain!important;flex:0 0 auto!important}
219
- #ls-content .ranking-table .leaderboard-item>div.team a.link p,
220
- #ls-content .ranking-table .leaderboard-item>.team a.link p{
221
- font-size:11px!important;color:#ccc!important;margin:0!important;
222
- white-space:nowrap!important;overflow:hidden!important;text-overflow:ellipsis!important
223
- }
224
- #ls-content .ranking-table .leaderboard-item>div.copy,
225
- #ls-content .ranking-table .leaderboard-item>.copy,
226
- #ls-content .leaderboard .leaderboard-item .copy{
227
- display:table-cell!important;vertical-align:middle!important;
228
- padding:5px 2px!important;text-align:center!important;white-space:nowrap!important
229
- }
230
- #ls-content .ranking-table .leaderboard-item>div.copy p,
231
- #ls-content .ranking-table .leaderboard-item>.copy p{
232
- display:inline-block!important;min-width:22px!important;text-align:center!important;
233
- font-size:11px!important;color:#ccc!important;margin:0 1px!important
234
- }
235
- #ls-content .ranking-table .leaderboard-item>div.copy p strong,
236
- #ls-content .ranking-table .leaderboard-item>.copy p strong{
237
- display:inline-block!important;min-width:22px!important;text-align:center!important;
238
- font-weight:800!important;color:#f0c040!important;font-size:12px!important
239
- }
240
- #ls-content .ranking-table .leaderboard-item>div.copy p.form,
241
- #ls-content .ranking-table .leaderboard-item>.copy p.form{display:inline-flex!important;flex-wrap:nowrap!important}
242
- #ls-content .ranking-table .leaderboard-item>div.copy p.form span,
243
- #ls-content .ranking-table .leaderboard-item>.copy p.form span{
244
- display:inline-block!important;min-width:14px!important;height:14px!important;
245
- line-height:14px!important;text-align:center!important;font-size:9px!important;
246
- border-radius:2px!important;margin:0 1px!important;color:#fff!important
247
- }
248
- #ls-content .ranking-table .leaderboard-item>div.copy p.form span.bg-green,
249
- #ls-content .ranking-table .leaderboard-item>.copy p.form span.bg-green{background:#2d8659!important}
250
- #ls-content .ranking-table .leaderboard-item>div.copy p.form span.bg-gray-5,
251
- #ls-content .ranking-table .leaderboard-item>.copy p.form span.bg-gray-5,
252
- #ls-content .leaderboard .leaderboard-item .copy p.form span.bg-gray-5,
253
- #ls-content .ranking-table .leaderboard-item .copy p.form span.bg-gray-5{background:#555!important;color:#ccc!important}
254
- #ls-content .ranking-table .leaderboard-item>div.copy p.form span.bg-red,
255
- #ls-content .ranking-table .leaderboard-item>.copy p.form span.bg-red{background:#c0392b!important}
256
- /* hide the bongda left accent bar that floats absolutely */
257
- #ls-content .ranking-table .leaderboard-item>div[class*="w-["],
258
- #ls-content .ranking-table .leaderboard-item div.relative{display:none!important}
259
- /* top row = champion accent can stay, but clean layout */
260
- #ls-content .leaderboard-item{margin:0!important}
261
- #ls-content .rank-table{width:100%!important;overflow-x:auto!important}
262
- /* fallback generic so ANY div-based standings table gets readable */
263
- #ls-content .team .name,#ls-content .team .logo{font-size:11px!important;color:#ccc!important}
264
- #ls-content .copy p{font-size:11px!important;color:#ccc!important}
265
- /* injected BXH header + row layout (see _decorateStandings in app_v2.js) */
266
- #ls-content .ls-bxh-head{
267
- display:flex!important;width:100%!important;align-items:center!important;gap:4px!important;
268
- background:#222!important;border-bottom:1px solid #333!important;
269
- padding:5px 2px!important;position:sticky!important;top:0!important;z-index:2!important
270
- }
271
- #ls-content .ls-bxh-head .team{flex:1!important;min-width:0!important;display:flex!important;align-items:center!important;gap:4px!important}
272
- #ls-content .ls-bxh-head .team span{display:inline-block!important;width:18px!important;min-width:18px!important;text-align:center!important;color:#888!important;font-size:10px!important;font-weight:700!important}
273
- #ls-content .ls-bxh-head .team p{font-size:10px!important;color:#999!important;font-weight:700!important;margin:0!important;text-transform:uppercase!important}
274
- #ls-content .ls-bxh-head .copy{display:flex!important;align-items:center!important;gap:2px!important;white-space:nowrap!important}
275
- #ls-content .ls-bxh-head .copy p{display:inline-block!important;min-width:20px!important;text-align:center!important;font-size:9px!important;color:#888!important;font-weight:700!important;margin:0!important}
276
- #ls-content .leaderboard-item{display:flex!important;width:100%!important;align-items:center!important;gap:4px!important;border-bottom:1px solid #1a1a1a!important;padding:4px 2px!important;background:transparent!important}
277
- #ls-content .leaderboard-item .team{flex:1!important;min-width:0!important;display:flex!important;align-items:center!important;gap:4px!important}
278
- #ls-content .leaderboard-item .team span{display:inline-block!important;width:18px!important;min-width:18px!important;text-align:center!important;color:#888!important;font-size:10px!important;font-weight:600!important}
279
- #ls-content .leaderboard-item .team a{display:inline-flex!important;align-items:center!important;gap:4px!important;font-size:11px!important;color:#ccc!important;text-decoration:none!important;overflow:hidden!important;min-width:0!important}
280
- #ls-content .leaderboard-item .team a img{width:16px!important;height:16px!important;object-fit:contain!important;flex:0 0 auto!important}
281
- #ls-content .leaderboard-item .team a p{font-size:11px!important;color:#ccc!important;margin:0!important;white-space:nowrap!important;overflow:hidden!important;text-overflow:ellipsis!important}
282
- #ls-content .leaderboard-item .copy{display:flex!important;align-items:center!important;gap:2px!important;white-space:nowrap!important}
283
- #ls-content .leaderboard-item .copy p{display:inline-block!important;min-width:20px!important;text-align:center!important;font-size:11px!important;color:#ccc!important;margin:0!important}
284
- #ls-content .leaderboard-item .copy p strong{font-weight:800!important;color:#f0c040!important;min-width:20px!important;text-align:center!important}
285
- #ls-content .leaderboard-item .copy p.form{display:inline-flex!important;flex-wrap:nowrap!important}
286
- #ls-content .leaderboard-item .copy p.form span{width:14px!important;height:14px!important;line-height:14px!important;font-size:9px!important;border-radius:2px!important;color:#fff!important;margin:0 1px!important;text-align:center!important}
287
- #ls-content .leaderboard-item .copy p.form span.bg-green{background:#2d8659!important;display:inline-block!important}
288
- #ls-content .leaderboard-item .copy p.form span.bg-gray-5{background:#555!important;color:#ccc!important;display:inline-block!important}
289
- #ls-content .leaderboard-item .copy p.form span.bg-red{background:#c0392b!important;display:inline-block!important}
290
- #ls-content .leaderboard-item>div[class*="w-["]{display:none!important}#ls-content .leaderboard-item>div[class*="w-["]{display:none!important}
291
-
292
- /* ============================================================
293
- FPT PLAY / HIGHLIGHT VIDEO OVERFLOW FIX v2
294
- fptplay videos open in the TikTok-style feed inside .yt-thumb-wrap >
295
- iframe (YouTube) or <video>. <iframe> ignores object-fit, so a 9:16
296
- Shorts (or any non-16:9) source stretched the slide and overflowed
297
- width/height — the iframe's intrinsic aspect ratio pushed it beyond the
298
- slide bounds.
299
-
300
- BUG FIX v2 (two issues):
301
- 1) "FPT Play videos displayed differently from Short AI videos — too tall
302
- and wide": The old rule sized iframes to 177.78vh (16:9 of viewport
303
- height, ~1.78x screen wide). Short AI <video> tags used width:100%;
304
- height:100%; object-fit:contain. The iframe was much wider/taller than
305
- the video. Fix: make iframe use width:100%; height:100% — identical to
306
- <video> — centered and clipped by the slide's overflow:hidden.
307
-
308
- 2) "9:16 ↔ 16:9 ratio toggle button (🖥️/📺) doesn't work on football
309
- player slides and wall videos": The inline CSS .ratio-wide rule
310
- (specificity 0,3,1) was overridden by fm_fix.css selectors using the
311
- #tiktok-feed ID (specificity 1,3,1). The .ratio-wide rules never applied
312
- to iframes inside #tiktok-feed. Fix: add .ratio-wide override rules
313
- with #tiktok-feed ID prefix (matching or higher specificity) AFTER the
314
- default iframe rules, so the !important cascade honors the toggle.
315
-
316
- Sizing model: iframes don't support object-fit, so we use
317
- position:absolute + transform centering inside .yt-thumb-wrap (which is
318
- position:relative, width:100%, height:100%). The slide's
319
- overflow:hidden clips any letterbox/pillarbox bars — same visual result
320
- as object-fit:contain on <video>.
321
- ============================================================ */
322
- .tiktok-slide{overflow:hidden!important;position:relative!important}
323
- .tiktok-slide video{max-width:100%!important;max-height:100%!important;object-fit:contain!important;width:100%!important;height:100%!important;border:none!important}
324
- /* FPT play / YouTube iframe in the TikTok viewer: SAME sizing as <video>.
325
- Width and height 100% of .yt-thumb-wrap (which fills .tiktok-slide),
326
- centered via transform, clipped by overflow:hidden on the slide.
327
- This makes iframes appear IDENTICAL to Short AI <video> tags. */
328
- #tiktok-feed .tiktok-slide .yt-thumb-wrap iframe,
329
- #wc-shorts-feed .tiktok-slide .yt-thumb-wrap iframe,
330
- #tt-feed .tiktok-slide .yt-thumb-wrap iframe,
331
- .tiktok-slide .yt-thumb-wrap iframe{
332
- position:absolute!important;
333
- top:50%!important;
334
- left:50%!important;
335
- transform:translate(-50%,-50%)!important;
336
- width:100%!important; /* match <video> — 100% of slide */
337
- height:100%!important; /* match <video> — 100% of slide */
338
- min-width:100%!important;
339
- min-height:100%!important;
340
- max-height:100%!important;
341
- max-width:100vw!important; /* never overflow screen width */
342
- border:none!important;
343
- }
344
- /* WC Shorts path: iframe is a DIRECT child of .tiktok-slide (no .yt-thumb-wrap) */
345
- #tiktok-feed .tiktok-slide > iframe,
346
- #wc-shorts-feed .tiktok-slide > iframe,
347
- .tiktok-slide > iframe{
348
- position:absolute!important;
349
- top:50%!important;
350
- left:50%!important;
351
- transform:translate(-50%,-50%)!important;
352
- width:100%!important; /* match <video> — 100% of slide */
353
- height:100%!important; /* match <video> — 100% of slide */
354
- min-width:100%!important;
355
- min-height:100%!important;
356
- max-height:100%!important;
357
- max-width:100vw!important; /* never overflow screen width */
358
- border:none!important;
359
- }
360
- /* RATIO TOGGLE FIX: when .ratio-wide is on a .tiktok-slide inside
361
- #tiktok-feed, switch from 9:16 portrait to 16:9 landscape.
362
- These rules use #tiktok-feed ID prefix (same specificity as the default
363
- iframe rules above) AND .ratio-wide class, so they win the CSS cascade
364
- only when the toggle button is active. The default 9:16 sizing (width:100%,
365
- height:100%) is overridden to 16:9 landscape (width:100vw, height:56.25vw). */
366
- #tiktok-feed .tiktok-slide.ratio-wide .yt-thumb-wrap iframe,
367
- #wc-shorts-feed .tiktok-slide.ratio-wide .yt-thumb-wrap iframe,
368
- #tt-feed .tiktok-slide.ratio-wide .yt-thumb-wrap iframe,
369
- .tiktok-slide.ratio-wide .yt-thumb-wrap iframe{
370
- position:absolute!important;
371
- top:50%!important;
372
- left:50%!important;
373
- transform:translate(-50%,-50%)!important;
374
- width:calc(100vw - 1px)!important;
375
- height:56.25vw!important;
376
- max-width:100vw!important;
377
- max-height:100dvh!important;
378
- min-width:100%!important;
379
- min-height:100%!important;
380
- border:none!important;
381
- pointer-events:auto!important;
382
- }
383
- #tiktok-feed .tiktok-slide.ratio-wide > iframe,
384
- #wc-shorts-feed .tiktok-slide.ratio-wide > iframe,
385
- .tiktok-slide.ratio-wide > iframe{
386
- position:absolute!important;
387
- top:50%!important;
388
- left:50%!important;
389
- transform:translate(-50%,-50%)!important;
390
- width:calc(100vw - 1px)!important;
391
- height:56.25vw!important;
392
- max-width:100vw!important;
393
- max-height:100dvh!important;
394
- min-width:100%!important;
395
- min-height:100%!important;
396
- border:none!important;
397
- pointer-events:auto!important;
398
- }
399
- /* Toggle back to 9:16 (default) — reset to 100% sizing */
400
- #tt-feed .tiktok-slide:not(.ratio-wide) .yt-thumb-wrap iframe,
401
- #tt-feed .tiktok-slide:not(.ratio-wide) > iframe{
402
- width:100%!important;
403
- height:100%!important;
404
- max-width:100vw!important;
405
- min-width:100%!important;
406
- }
407
- /* .yt-thumb-wrap is the positioning context for the centered iframe */
408
- .yt-thumb-wrap{position:relative!important;overflow:hidden!important;width:100%!important;height:100%!important;background:#000!important}
409
- .yt-thumb-wrap img,.yt-thumb-wrap video{width:100%!important;height:100%!important;object-fit:cover!important}
410
- .wall-item{overflow:hidden!important}
411
- .wall-thumb img,.wall-thumb video{max-width:100%!important;max-height:100%!important;object-fit:cover!important}
412
- .slider-thumb{overflow:hidden!important}
413
- .slider-thumb img,.slider-thumb video{max-width:100%!important;max-height:100%!important;object-fit:cover!important}
414
- .fpt-slider-thumb img,.fpt-slider-thumb video{max-width:100%!important;max-height:100%!important;object-fit:cover!important}
415
-
416
- /* FPT Play slider moved into Livescore section - ensure it fits within the ls-section */
417
- .ls-section #home-fpt-section{margin-top:4px;padding:0 4px}
418
- /* M3U IPTV grid */
419
- .m3u-grid{padding:12px}
420
- .m3u-group-label{color:#5cb87a;font-size:14px;font-weight:700;margin:10px 0 6px 4px}
421
- .m3u-group-track{display:grid;grid-template-columns:repeat(2,1fr);gap:8px}
422
- .m3u-channel-card{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:10px;padding:8px;cursor:pointer;transition:0.2s;position:relative}
423
- .m3u-channel-card:hover{border-color:#5cb87a;transform:translateY(-1px)}
424
- .m3u-channel-thumb{width:100%;height:60px;background:#222;border-radius:6px;overflow:hidden;display:flex;align-items:center;justify-content:center}
425
- .m3u-channel-thumb img{width:100%;height:100%;object-fit-cover}
426
- .m3u-no-logo{font-size:24px;color:#5cb87a}
427
- .m3u-channel-title{font-size:11px;color:#ccc;margin-top:4px;line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
428
- .badge-live{background:#ff4444 !important;color:#fff !important;animation:pulse 1.5s infinite}
429
- @keyframes pulse{0%,100%{opacity:1}50%{opacity:0.6}}
 
168
  min-width:100%!important;max-width:none!important;max-height:none!important;
169
  object-fit:cover!important;
170
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
static/hot_multi.js CHANGED
@@ -1,35 +1,17 @@
1
  // === OVERRIDE: loadHotTopics loads from MULTIPLE hashtags ===
2
- // v3: 5 topics (AI Thế Giới + AI Việt Nam + top 3 hot) + lazy load more
3
  // This file loaded AFTER app_v2.js, overrides the function
4
 
5
- const HT_AI_TOPICS = ['AI Thế Giới', 'AI Việt Nam'];
6
- let _htAllSources = [];
7
- let _htTopics = [];
8
- let _htPages = 0;
9
- let _htDates = [];
10
-
11
  async function loadHotTopics(){
12
  const j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));
13
  const el=document.getElementById('hot-topics');if(!el)return;
14
  const topics=j.topics||[];
15
-
16
- // Prepend AI topics that aren't already in the list
17
- const prepend = HT_AI_TOPICS.filter(ai =>
18
- !topics.some(t => (t.topic || '').toLowerCase().includes(ai.toLowerCase()))
19
- ).map(ai => ({label: '#' + ai.replace(/\s+/g, ''), topic: ai, count: 0}));
20
-
21
- const finalTopics = [...prepend, ...topics].slice(0, 18);
22
-
23
- el.innerHTML=finalTopics.map(t=>{
24
  const topicText=t.topic||t.label.replace(/^#/,'');
25
  return`<button class="hot-chip" onclick="searchTopic('${topicText.replace(/'/g,"\\'")}')">${esc(t.label)}</button>`;
26
  }).join('');
27
-
28
- // Load tin HOT = tổng hợp từ TOP 5 hashtag (AI Thế Giới + AI Việt Nam + top 3 hot)
29
- const top5 = [...prepend, ...topics].slice(0, 5);
30
- if(top5.length>=2){
31
- _htTopics = top5.map(t=>t.topic||t.label.replace(/^#/,''));
32
- loadMultiHashtag(_htTopics);
33
  }else if(topics.length){
34
  searchTopic(topics[0].topic||topics[0].label.replace(/^#/,''));
35
  }
@@ -37,9 +19,6 @@ async function loadHotTopics(){
37
 
38
  async function loadMultiHashtag(topicList){
39
  const box=document.getElementById('hashtag-box');if(!box)return;
40
- _htAllSources = [];
41
- _htPages = 0;
42
-
43
  box.innerHTML=`<div class="hashtag-sources"><h3>🔥 Tin HOT tổng hợp</h3><div class="hashtag-loading"><div class="hashtag-spinner"></div>Tổng hợp từ ${topicList.length} chủ đề nóng nhất...</div></div>`;
44
  try{
45
  const results=await Promise.all(topicList.map(topic=>
@@ -54,70 +33,17 @@ async function loadMultiHashtag(topicList){
54
  if(src&&src.url&&!seen.has(src.url)){seen.add(src.url);src._topic=topicList[j];all.push(src);}
55
  }
56
  }
57
- _htAllSources = all;
58
- _renderHT();
 
 
 
 
 
 
 
 
 
 
59
  }catch(e){box.innerHTML=`<div class="hashtag-sources"><h3>🔥 Tin HOT</h3><div style="color:#e74c3c;padding:8px">Lỗi tải tin</div></div>`;}
60
  }
61
-
62
- function _renderHT(){
63
- const box=document.getElementById('hashtag-box');if(!box)return;
64
- const all = _htAllSources;
65
- if(!all.length){box.innerHTML=`<div class="hashtag-sources"><h3>🔥 Tin HOT</h3><div style="color:#888;padding:8px">Đang cập nhật...</div></div>`;return;}
66
-
67
- const perPage = 12;
68
- const total = all.length;
69
- const shown = Math.min((_htPages + 1) * perPage, total);
70
- const visible = all.slice(0, shown);
71
-
72
- let h=`<div class="hashtag-sources"><h3>🔥 Tin HOT tổng hợp <span style="font-size:10px;color:#888">(${total} bài · ${_htTopics.length} chủ đề)</span></h3><div id="ht-list">`;
73
- visible.forEach((s,i)=>{
74
- const dateStr = _htDates[i] ? `<span style="font-size:9px;color:#666;margin-left:4px">${esc(_htDates[i])}</span>` : '';
75
- h+=`<div class="hashtag-src-item" onclick="readArticle('${esc(s.url)}')"><div class="hashtag-src-img" id="ht-img-${i}"></div><div class="hashtag-src-text"><div class="hashtag-src-title">${esc(s.title)}</div><div class="hashtag-src-via">${esc(s.via||'')}${dateStr} · <span style="color:#f0c040;font-size:9px">#${esc(s._topic||'')}</span></div></div></div>`;
76
- });
77
- h+=`</div><div style="display:flex;gap:4px;flex-wrap:wrap;margin-top:8px">`;
78
- _htTopics.forEach(t=>{h+=`<button class="hot-chip" onclick="searchTopic('${t.replace(/'/g,"\\'")}')" style="font-size:10px">🔍 #${esc(t)}</button>`;});
79
- h+=`</div>`;
80
- if(shown < total){
81
- h+=`<button class="hashtag-load-more" id="ht-load-more" onclick="htLoadMore()">Tải thêm (${total - shown} tin) ▼</button>`;
82
- }
83
- h+=`</div>`;
84
- box.innerHTML=h;
85
-
86
- // Lazy load images + dates
87
- visible.forEach((s,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-'+i);if(el)el.innerHTML=`<img src="${esc(d.og_image||d.img)}" onerror="this.style.display='none'">`;}if(d&&d.date_vn){_htDates[i]=d.date_vn;const viaEl=document.querySelector('#ht-list .hashtag-src-item:nth-child('+(i+1)+') .hashtag-src-via');if(viaEl&&!viaEl.querySelector('.ht-date')){const sp=document.createElement('span');sp.className='ht-date';sp.style.cssText='font-size:9px;color:#666;margin-left:4px';sp.textContent=d.date_vn;viaEl.insertBefore(sp,viaEl.querySelector('span')||null);}}}).catch(()=>{});});
88
- _htTopic = _htTopics[0];
89
- }
90
-
91
- async function htLoadMore(){
92
- const btn=document.getElementById('ht-load-more');if(!btn)return;
93
- btn.disabled=true;btn.textContent='Đang tải thêm...';
94
- const nextPage = _htPages + 1;
95
-
96
- try{
97
- // Fetch next page from each topic
98
- const results=await Promise.all(_htTopics.map(topic=>
99
- fetch(`/api/hashtag/sources?topic=${encodeURIComponent(topic)}&page=${nextPage}`).then(r=>r.json()).catch(()=>({sources:[]}))
100
- ));
101
- _htPages = nextPage;
102
-
103
- // Interleave new sources
104
- const seen=new Set(_htAllSources.map(s=>s.url));
105
- const mx=Math.max(...results.map(r=>(r.sources||[]).length));
106
- const newItems=[];
107
- for(let i=0;i<mx;i++){
108
- for(let j=0;j<results.length;j++){
109
- const src=(results[j].sources||[])[i];
110
- if(src&&src.url&&!seen.has(src.url)){seen.add(src.url);src._topic=_htTopics[j];newItems.push(src);}
111
- }
112
- }
113
-
114
- if(newItems.length){
115
- _htAllSources = [..._htAllSources, ...newItems];
116
- }
117
-
118
- _renderHT();
119
- }catch(e){
120
- btn.disabled=false;btn.textContent='Tải thêm ▼';
121
- toast('❌ Lỗi tải thêm');
122
- }
123
- }
 
1
  // === OVERRIDE: loadHotTopics loads from MULTIPLE hashtags ===
 
2
  // This file loaded AFTER app_v2.js, overrides the function
3
 
 
 
 
 
 
 
4
  async function loadHotTopics(){
5
  const j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));
6
  const el=document.getElementById('hot-topics');if(!el)return;
7
  const topics=j.topics||[];
8
+ el.innerHTML=topics.slice(0,18).map(t=>{
 
 
 
 
 
 
 
 
9
  const topicText=t.topic||t.label.replace(/^#/,'');
10
  return`<button class="hot-chip" onclick="searchTopic('${topicText.replace(/'/g,"\\'")}')">${esc(t.label)}</button>`;
11
  }).join('');
12
+ // Load tin HOT = tổng hợp từ TOP 3 hashtag nóng nhất
13
+ if(topics.length>=2){
14
+ loadMultiHashtag(topics.slice(0,3).map(t=>t.topic||t.label.replace(/^#/,'')));
 
 
 
15
  }else if(topics.length){
16
  searchTopic(topics[0].topic||topics[0].label.replace(/^#/,''));
17
  }
 
19
 
20
  async function loadMultiHashtag(topicList){
21
  const box=document.getElementById('hashtag-box');if(!box)return;
 
 
 
22
  box.innerHTML=`<div class="hashtag-sources"><h3>🔥 Tin HOT tổng hợp</h3><div class="hashtag-loading"><div class="hashtag-spinner"></div>Tổng hợp từ ${topicList.length} chủ đề nóng nhất...</div></div>`;
23
  try{
24
  const results=await Promise.all(topicList.map(topic=>
 
33
  if(src&&src.url&&!seen.has(src.url)){seen.add(src.url);src._topic=topicList[j];all.push(src);}
34
  }
35
  }
36
+ if(!all.length){box.innerHTML=`<div class="hashtag-sources"><h3>🔥 Tin HOT</h3><div style="color:#888;padding:8px">Đang cập nhật...</div></div>`;return;}
37
+ let h=`<div class="hashtag-sources"><h3>🔥 Tin HOT tổng hợp <span style="font-size:10px;color:#888">(${all.length} bài · ${topicList.length} chủ đề)</span></h3><div id="ht-list">`;
38
+ all.slice(0,12).forEach((s,i)=>{
39
+ h+=`<div class="hashtag-src-item" onclick="readArticle('${esc(s.url)}')"><div class="hashtag-src-img" id="ht-img-${i}"></div><div class="hashtag-src-text"><div class="hashtag-src-title">${esc(s.title)}</div><div class="hashtag-src-via">${esc(s.via||'')} · <span style="color:#f0c040;font-size:9px">#${esc(s._topic||'')}</span></div></div></div>`;
40
+ });
41
+ h+=`</div><div style="display:flex;gap:4px;flex-wrap:wrap;margin-top:8px">`;
42
+ topicList.forEach(t=>{h+=`<button class="hot-chip" onclick="searchTopic('${t.replace(/'/g,"\\'")}')" style="font-size:10px">🔍 #${esc(t)}</button>`;});
43
+ h+=`</div></div>`;
44
+ box.innerHTML=h;
45
+ // Lazy load images
46
+ all.slice(0,12).forEach((s,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-'+i);if(el)el.innerHTML=`<img src="${esc(d.og_image||d.img)}" onerror="this.style.display='none'">`;}}).catch(()=>{});});
47
+ _htTopic=topicList[0];
48
  }catch(e){box.innerHTML=`<div class="hashtag-sources"><h3>🔥 Tin HOT</h3><div style="color:#e74c3c;padding:8px">Lỗi tải tin</div></div>`;}
49
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
static/index_v2.html CHANGED
@@ -8,40 +8,18 @@
8
  <meta property="og:title" content="VNEWS - Tin Tức Việt Nam">
9
  <meta property="og:image" content="https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg">
10
  <link rel="canonical" href="https://bep40-vnews.hf.space">
11
- <link rel="stylesheet" href="/static/wc2026.css"><link rel="stylesheet" href="/static/fm_fix.css">
12
  <script src="https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js"></script>
13
- <style>
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;overflow-y:hidden;gap:8px;padding:4px 10px 10px;scrollbar-width:none;-webkit-overflow-scrolling:touch;touch-action:pan-x pan-y;overscroll-behavior-x:contain;scroll-snap-type:x proximity}.slider-track>*{flex:0 0 auto;scroll-snap-align:start;-webkit-tap-highlight-color:transparent}.slider-track::-webkit-scrollbar{display:none}.slider-item{flex:0 0 160px;cursor:pointer;box-sizing:border-box}.hl-league-block{display:inline-flex;align-items:flex-start;flex:0 0 auto;min-width:0}.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:8px;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-container.feed-fullscreen{position:fixed;inset:0;width:100vw;height:100dvh;max-height:none;min-height:0;z-index:99990;background:#000}.tiktok-container.feed-fullscreen .tiktok-feed,.tiktok-container.feed-fullscreen .tiktok-slide{height:100dvh;max-height:100dvh;min-height:0}.tiktok-feed{height:100%;overflow-y:scroll;-webkit-overflow-scrolling:touch;scroll-behavior:smooth;scroll-snap-type:y mandatory;scrollbar-width:none;touch-action:pan-y;-webkit-user-select:none;user-select:none;overscroll-behavior:contain}.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;touch-action:pan-y;cursor:pointer}.tiktok-slide video{width:100%;height:100%;object-fit:cover;border:none;touch-action:manipulation}.tiktok-slide iframe{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:100%;height:100%;border:none;pointer-events:none}.tiktok-slide .slide-gesture{position:absolute;inset:0;z-index:2;touch-action:pan-y;pointer-events:auto;cursor:pointer;background:transparent}.tiktok-slide .tiktok-bottom,.tiktok-slide .tiktok-right,.tiktok-slide .tiktok-counter{pointer-events:auto}.tiktok-slide .inline-comments{pointer-events:auto}.yt-thumb-wrap{position:relative;width:100%;height:100%;background-size:cover;background-position:center;background-image:url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="1" height="1"%3E%3C/svg%3E')}.yt-thumb-wrap iframe{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:100%;height:100%;border:none;pointer-events:auto;cursor:pointer}.tiktok-slide.ratio-wide video{width:100%!important;height:100%!important;object-fit:contain!important}.tiktok-slide.ratio-wide iframe{position:absolute!important;top:50%!important;left:50%!important;transform:translate(-50%,-50%)!important;width:calc(100vw - 1px)!important;height:56.25vw!important;max-width:100vw!important;max-height:100%!important;border:none!important;pointer-events:none!important}.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;max-height:34px;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.tiktok-desc{font-size:10px;color:#bbb;margin-top:3px;max-height:40px;overflow:hidden;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical}.tiktok-counter{position:absolute;top:8px;left:8px;background:rgba(0,0,0,.5);font-size:9px;padding:2px 7px;border-radius:8px;color:#fff;z-index:4}.tiktok-right{position:absolute;right:8px;bottom:100px;display:flex;flex-direction:column;align-items:center;gap:14px;z-index:5}.tiktok-right-btn{display:flex;flex-direction:column;align-items:center;gap:2px;background:none;border:0;color:#fff;cursor:pointer;font-size:10px}.tiktok-right-btn .icon{width:42px;height:42px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:20px}.tiktok-right-btn .count{font-size:10px;color:#ddd}.inline-comments{position:absolute;bottom:0;left:0;right:0;max-height:60%;min-height:140px;background:rgba(18,18,18,.95);border-radius:14px 14px 0 0;z-index:10;overflow:clip;display:flex;flex-direction:column}.inline-cmt-header{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-bottom:1px solid #333;color:#5cb87a;font-size:12px;font-weight:700;flex-shrink:0}.inline-cmt-header button{background:none;border:0;color:#fff;font-size:16px;cursor:pointer}.inline-cmt-list{flex:1 1 auto;overflow-y:auto;padding:6px 10px;max-height:140px;min-height:40px}.inline-cmt-item{background:#222;border-radius:8px;padding:6px 8px;margin:4px 0;color:#ccc;font-size:11px;line-height:1.3}.inline-cmt-time{font-size:9px;color:#777;margin-right:6px}.inline-cmt-input{display:flex;gap:6px;padding:8px 10px;border-top:1px solid #333;flex-shrink:0;position:sticky;bottom:0;background:rgba(18,18,18,.98)}.inline-cmt-input input{flex:1;background:#222;border:1px solid #444;color:#eee;border-radius:16px;padding:7px 12px;font-size:11px;min-height:32px}.inline-cmt-input button{background:#2d8659;border:0;color:#fff;border-radius:16px;padding:7px 12px;font-size:11px;cursor:pointer;min-height:32px}.wc2026-section{margin:6px 4px;background:linear-gradient(135deg,#0d1117,#1a1a3a);border:1px solid #1a3a5a;border-radius:10px;overflow:hidden}.wc-header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:linear-gradient(90deg,#0b2e4a,#1a3a5a)}.wc-header h2{font-size:15px;color:#fff;margin:0}.wc-live-badge{font-size:10px;color:#e74c3c;font-weight:700;animation:wc-pulse 1.5s infinite}@keyframes wc-pulse{0%,100%{opacity:1}50%{opacity:.4}}.wc-tabs{display:flex;gap:4px;padding:8px 10px;overflow-x:auto;scrollbar-width:none}.wc-tabs::-webkit-scrollbar{display:none}.wc-tab{padding:5px 10px;background:#1a2a3a;border:1px solid #2a3a4a;border-radius:12px;color:#8ab4f0;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}.ai-compose button.hot-short-btn{background:linear-gradient(135deg,#8b1a5a,#c0392b);border:1px solid #e07050;color:#fff;box-shadow:0 0 10px rgba(192,57,43,.4);animation:hot-pulse 2s infinite}@keyframes hot-pulse{0%,100%{box-shadow:0 0 6px rgba(192,57,43,.35)}50%{box-shadow:0 0 16px rgba(192,57,43,.75)}}.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,.wall-thumb video{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}.wall-actions button.wall-btn-design{background:#1a3a5a;border-color:#3a5a7a;color:#fff;font-weight:700}#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
- .hl-load-more{margin:4px 8px 8px;background:#222;border:1px solid #333;color:#ccc;padding:6px 12px;border-radius:8px;font-size:11px;cursor:pointer;width:calc(100% - 16px);display:block}
16
- .hl-load-more:hover{background:#2a2a2a}
17
- .ls-section-header{padding:8px 10px;font-size:11px;font-weight:700;color:#5cb87a;border-bottom:1px solid #2a2a2a}
18
- .ls-section-header.upcoming{color:#f0c040}
19
- .ls-section-header.finished{color:#e74c3c}
20
- .ls-section-header.yesterday{color:#8ab4f0}
21
- .ls-section-header.older{color:#888}.ls-section-header.older{color:#888}
22
- /* FPT PLAY / HIGHLIGHT video overflow fix v2 — same containment as short-AI wall
23
- FIX: iframes now use width:100%/height:100% (matching <video>) instead of
24
- width:177.78vh. FPT/YouTube iframes now appear IDENTICAL to Short AI <video> tags.
25
- The .ratio-wide toggle (16:9) is handled by fm_fix.css with matching ID specificity. */
26
- .tiktok-slide{overflow:hidden!important}
27
- .tiktok-slide video{position:absolute!important;top:50%!important;left:50%!important;transform:translate(-50%,-50%)!important;width:100%!important;height:100%!important;object-fit:contain!important;border:none!important;pointer-events:auto!important}
28
- .tiktok-slide iframe{position:absolute!important;top:50%!important;left:50%!important;transform:translate(-50%,-50%)!important;width:100%!important;height:100%!important;border:none!important;pointer-events:none!important}
29
- .tiktok-slide.ratio-wide iframe{position:absolute!important;top:50%!important;left:50%!important;transform:translate(-50%,-50%)!important;width:calc(100vw - 1px)!important;height:56.25vw!important;max-width:100vw!important;max-height:100dvh!important;border:none!important;pointer-events:auto!important}
30
- .yt-thumb-wrap{overflow:hidden!important}
31
- .yt-thumb-wrap iframe{position:absolute!important;top:50%!important;left:50%!important;transform:translate(-50%,-50%)!important;width:100%!important;height:100%!important;border:none!important;pointer-events:auto!important;cursor:pointer!important}
32
- .wall-thumb img,.wall-thumb video{max-width:100%!important;max-height:100%!important;object-fit:cover!important}
33
- </style>
34
  </head>
35
  <body>
36
  <div class="header"><h1>📰 VNEWS</h1><p>Tin tức · Bóng đá LIVE · Highlight · AI · World Cup 2026</p></div>
37
  <div class="cats" id="cat-bar"></div>
38
- <div id="home-hl-section"></div>
39
  <div id="view-home" class="view active"><div class="loading">Đang tải...</div></div>
40
  <div id="view-cat" class="view"></div>
41
  <div id="view-video" class="view"></div>
42
  <div id="view-tiktok" class="view"></div>
43
  <div id="view-article" class="view"></div>
44
- <div id="view-m3u" class="view"></div>
45
  <div class="match-overlay" id="match-overlay">
46
  <div class="mo-header"><h3 id="mo-title">Chi tiết trận đấu</h3><button class="mo-close" onclick="closeMatch()">✕</button></div>
47
  <div class="mo-tabs"><span class="mo-tab active" onclick="loadMatchTab('detail')">📋 Chi tiết</span><span class="mo-tab" onclick="loadMatchTab('comm')">Diễn biến</span><span class="mo-tab" onclick="loadMatchTab('stats')">Thống kê</span></div>
@@ -52,45 +30,14 @@
52
  var _cats=[],_hlLeagueData={},_currentArticle=null;window._currentEventId='';
53
  function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]))}
54
  function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')}
55
- 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 if(id==='m3u'){document.getElementById('view-m3u').classList.add('active');if(typeof loadM3UChannels==='function')loadM3UChannels()}else{document.getElementById('view-cat').classList.add('active');loadCat(id)}}
56
  function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}}
57
- function doShare(title,url,img,postId){
58
- var shareUrl;
59
- if(postId){
60
- shareUrl = SPACE+'/s?post_id='+encodeURIComponent(postId)+'&title='+encodeURIComponent(title);
61
- } else {
62
- shareUrl = SPACE+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||'');
63
- }
64
- if(navigator.clipboard && navigator.clipboard.writeText){
65
- navigator.clipboard.writeText(shareUrl).then(function(){
66
- toast('📋 Đã sao chép link!');
67
- try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){}
68
- }).catch(function(){
69
- try{
70
- var ta=document.createElement('textarea');ta.value=shareUrl;ta.style.position='fixed';ta.style.left='-9999px';ta.style.top='-9999px';ta.style.opacity='0';
71
- document.body.appendChild(ta);ta.select();ta.setSelectionRange(0,99999);
72
- if(document.execCommand('copy')){toast('📋 Đã sao chép link!');}else{prompt('📋 Sao chép link:', shareUrl);}
73
- document.body.removeChild(ta);
74
- }catch(e){prompt('📋 Sao chép link:', shareUrl);}
75
- try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){}
76
- });
77
- } else {
78
- try{
79
- var ta=document.createElement('textarea');ta.value=shareUrl;ta.style.position='fixed';ta.style.left='-9999px';ta.style.top='-9999px';ta.style.opacity='0';
80
- document.body.appendChild(ta);ta.select();ta.setSelectionRange(0,99999);
81
- if(document.execCommand('copy')){toast('📋 Đã sao chép link!');}else{prompt('📋 Sao chép link:', shareUrl);}
82
- document.body.removeChild(ta);
83
- }catch(e){prompt('📋 Sao chép link:', shareUrl);}
84
- try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){}
85
- }
86
- }
87
- 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><div class="cat" data-cat="m3u">📺 Trực tiếp</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)});}
88
  var SPACE=location.origin;
89
  </script>
90
- <script src="/static/app_v2.js?v=20260828m3uplayer"></script>
91
- <script src="/static/designer_v2.js?v=20260817fix1"></script>
92
  <script src="/static/yt_live.js"></script>
93
- <script src="/static/vtv_init.js"></script>
94
  <script src="/static/hot_multi.js?v=1"></script>
95
  <script src="/static/wc2026_v2.js"></script>
96
  <script src="/static/live_mode.js"></script>
 
8
  <meta property="og:title" content="VNEWS - Tin Tức Việt Nam">
9
  <meta property="og:image" content="https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg">
10
  <link rel="canonical" href="https://bep40-vnews.hf.space">
11
+ <link rel="stylesheet" href="/static/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}.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}</style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  </head>
15
  <body>
16
  <div class="header"><h1>📰 VNEWS</h1><p>Tin tức · Bóng đá LIVE · Highlight · 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>
20
  <div id="view-video" class="view"></div>
21
  <div id="view-tiktok" class="view"></div>
22
  <div id="view-article" class="view"></div>
 
23
  <div class="match-overlay" id="match-overlay">
24
  <div class="mo-header"><h3 id="mo-title">Chi tiết trận đấu</h3><button class="mo-close" onclick="closeMatch()">✕</button></div>
25
  <div class="mo-tabs"><span class="mo-tab active" onclick="loadMatchTab('detail')">📋 Chi tiết</span><span class="mo-tab" onclick="loadMatchTab('comm')">Diễn biến</span><span class="mo-tab" onclick="loadMatchTab('stats')">Thống kê</span></div>
 
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?v=20260701"></script>
 
40
  <script src="/static/yt_live.js"></script>
 
41
  <script src="/static/hot_multi.js?v=1"></script>
42
  <script src="/static/wc2026_v2.js"></script>
43
  <script src="/static/live_mode.js"></script>
static/live_mode.js CHANGED
@@ -23,29 +23,7 @@
23
  .hashtag-src-img img{width:100%!important;height:100%!important;object-fit:cover!important}
24
  .hashtag-src-text{flex:1!important;min-width:0!important}
25
  .hashtag-src-title{font-size:12px!important;font-weight:700!important;color:#eee!important;display:-webkit-box!important;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden!important}
26
- .hashtag-src-via{font-size:10px!important;color:#888!important;margin-top:2px!important}
27
- #ls-content .ranking-table .head,#ls-content .leaderboard .head{width:100%!important}
28
- #ls-content .ranking-table .leaderboard-item,#ls-content .leaderboard .leaderboard-item{display:flex!important;width:100%!important;align-items:center!important;gap:4px!important;border-bottom:1px solid #1a1a1a!important;padding:4px 2px!important;background:transparent!important}
29
- #ls-content .ranking-table .leaderboard-item>div.team,#ls-content .ranking-table .leaderboard-item>.team,#ls-content .leaderboard .leaderboard-item .team{flex:1!important;min-width:0!important;display:flex!important;align-items:center!important;gap:4px!important}
30
- #ls-content .leaderboard-item .team a{display:inline-flex!important;align-items:center!important;gap:4px!important;font-size:11px!important;color:#ccc!important;text-decoration:none!important;overflow:hidden!important}
31
- #ls-content .leaderboard-item .team a img{width:16px!important;height:16px!important;object-fit:contain!important}
32
- #ls-content .leaderboard-item .team a p{font-size:11px!important;color:#ccc!important;margin:0!important;white-space:nowrap!important;overflow:hidden!important;text-overflow:ellipsis!important}
33
- #ls-content .leaderboard-item .copy{display:flex!important;align-items:center!important;gap:2px!important;white-space:nowrap!important}
34
- #ls-content .leaderboard-item .copy p{display:inline-block!important;min-width:20px!important;text-align:center!important;font-size:11px!important;color:#ccc!important;margin:0!important}
35
- #ls-content .leaderboard-item .copy p strong{font-weight:800!important;color:#f0c040!important;min-width:20px!important;text-align:center!important}
36
- #ls-content .leaderboard-item .copy p.form span{width:14px!important;height:14px!important;line-height:14px!important;font-size:9px!important;border-radius:2px!important;color:#fff!important;margin:0 1px!important}
37
- #ls-content .leaderboard-item .copy p.form span.bg-green{background:#2d8659!important}
38
- #ls-content .leaderboard-item .copy p.form span.bg-gray-5{background:#555!important;color:#ccc!important}
39
- #ls-content .leaderboard-item .copy p.form span.bg-red{background:#c0392b!important}
40
- #ls-content .leaderboard-item>div[class*="w-["]{display:none!important}
41
- #ls-content .leaderboard-item .w-\[2px\]{display:none!important}
42
- /* FPT PLAY / HIGHLIGHT video overflow fix - same containment as short-AI wall */
43
- .tiktok-slide{overflow:hidden!important}
44
- .tiktok-slide video,.tiktok-slide iframe{max-width:100%!important;max-height:100%!important;object-fit:contain!important}
45
- .yt-thumb-wrap{overflow:hidden!important}
46
- .yt-thumb-wrap iframe{max-width:100%!important;max-height:100%!important}
47
- .wall-item{overflow:hidden!important}
48
- .wall-thumb img,.wall-thumb video{max-width:100%!important;max-height:100%!important;object-fit:cover!important}`;
49
  document.head.appendChild(style);
50
 
51
  // Load rewrite fix
 
23
  .hashtag-src-img img{width:100%!important;height:100%!important;object-fit:cover!important}
24
  .hashtag-src-text{flex:1!important;min-width:0!important}
25
  .hashtag-src-title{font-size:12px!important;font-weight:700!important;color:#eee!important;display:-webkit-box!important;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden!important}
26
+ .hashtag-src-via{font-size:10px!important;color:#888!important;margin-top:2px!important}`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  document.head.appendChild(style);
28
 
29
  // Load rewrite fix
static/rewrite_fix_v2.js DELETED
@@ -1,2 +0,0 @@
1
- // No-op - all functionality built into app_v2.js
2
- (function(){})();
 
 
 
static/vtv_init.js DELETED
@@ -1,392 +0,0 @@
1
- // ===== VNEWS VTV Player v11 + FPT Play — Simple toggle like VTV6 =====
2
- (function() {
3
- if (window._vtvInitLoaded) return;
4
- window._vtvInitLoaded = true;
5
-
6
- var CHANNELS = [
7
- {id:'vtv1',name:'VTV1',badge:'Tin tức'},{id:'vtv2',name:'VTV2',badge:'Khoa học'},
8
- {id:'vtv3',name:'VTV3',badge:'Giải trí'},{id:'vtv4',name:'VTV4',badge:'Quốc Tế'},
9
- {id:'vtv5',name:'VTV5',badge:'Miền Nam'},{id:'vtv6',name:'VTV6',badge:'Thanh Niên'},
10
- {id:'vtv7',name:'VTV7',badge:'Giáo Dục'},{id:'vtv8',name:'VTV8',badge:'Miền Trung'},
11
- {id:'vtv9',name:'VTV9',badge:'Miền Bắc'},{id:'vtv10',name:'VTV10',badge:'Cần Thơ'},
12
- {id:'vtvprime',name:'VTVPrime',badge:'Prime'},
13
- {id:'fpt-the-thao',name:'FPT',badge:'Thể Thao'},
14
- ];
15
-
16
- // FPT Play iframe source — lấy từ iframe #1 của https://hd1.xemtv.net/kenh-fptplay-truc-tuyen-the-thao.html
17
- // Trang đó có 3 iframe: ads/below1.php (quảng cáo), ads/below.php (quảng cáo), kenh/fptplay.php (stream chính)
18
- var _fptIfUrl = 'https://hd1.xemtv.net/kenh/fptplay.php';
19
-
20
- var _currentCh = null;
21
- var _epgTimer = null;
22
- var _epgData = [];
23
- var _timeTimer = null;
24
- var _serverDate = '';
25
- var _nativeMode = false;
26
- var _hls = null;
27
-
28
- // ===== CSS =====
29
- (function injectCSS() {
30
- if (document.getElementById('vtv-pro-css')) return;
31
- var s = document.createElement('style');
32
- s.id = 'vtv-pro-css';
33
- s.textContent = [
34
- '#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}',
35
- '#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))}',
36
- '#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}',
37
- '#vtv-player-section .vtv-pro-title{font-size:13px;font-weight:700;color:#e8eaed;letter-spacing:.3px}',
38
- '#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}',
39
- '#vtv-player-section .vtv-pro-live-dot{width:5px;height:5px;border-radius:50%;background:#00cc88;animation:vtv-pro-pulse 1.2s infinite}',
40
- '@keyframes vtv-pro-pulse{0%,100%{opacity:1}50%{opacity:.3}}',
41
- '#vtv-player-section .vtv-pro-tabs{display:flex;flex-wrap:wrap;gap:2px;padding:6px 10px 8px;overflow-x:auto;scrollbar-width:none}',
42
- '#vtv-player-section .vtv-pro-tabs::-webkit-scrollbar{display:none}',
43
- '#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}',
44
- '#vtv-player-section .vtv-pro-tab:hover{background:rgba(0,102,204,.1);border-color:rgba(0,102,204,.3);color:#e8eaed}',
45
- '#vtv-player-section .vtv-pro-tab.on{background:rgba(0,102,204,.2);border-color:#0066cc;color:#fff;font-weight:600}',
46
- '#vtv-player-section .vtv-pro-tab.fpt{background:rgba(255,100,0,.04);border-color:rgba(255,120,0,.08);color:#ffa866}',
47
- '#vtv-player-section .vtv-pro-tab.fpt.on{background:rgba(255,100,0,.2);border-color:#ff6400;color:#fff}',
48
- '#vtv-player-section .vtv-pro-tab .b{font-size:7px;opacity:.5;display:block;margin-top:1px}',
49
- '#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)}',
50
- '#vtv-player-section .vtv-pro-frame iframe{position:absolute;inset:0;width:100%!important;height:100%!important;border:none;background:#000}',
51
- '#vtv-player-section .vtv-pro-frame video{position:absolute;inset:0;width:100%;height:100%;background:#000}',
52
- '#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}',
53
- '#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}',
54
- '@keyframes vtv-pro-spin{to{transform:rotate(360deg)}}',
55
- '#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}',
56
- '#vtv-player-section .vtv-pro-err .icon{font-size:28px;opacity:.5}',
57
- '#vtv-player-section .vtv-pro-err .msg{color:#9aa0a6}',
58
- '#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}',
59
- '#vtv-player-section .vtv-pro-err button:hover{background:rgba(0,102,204,.25)}',
60
- '#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)}',
61
- '#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}',
62
- '#vtv-player-section .vtv-pro-btn:hover{background:rgba(0,102,204,.12);color:#e8eaed}',
63
- '#vtv-player-section .vtv-pro-switch{font-size:9px;padding:3px 8px;background:rgba(0,150,255,.08);border:1px solid rgba(0,150,255,.2);color:#8ab4f8;border-radius:6px;cursor:pointer;transition:all .15s;display:flex;align-items:center;gap:3px;white-space:nowrap}',
64
- '#vtv-player-section .vtv-pro-switch:hover{background:rgba(0,150,255,.2)}',
65
- '#vtv-player-section .vtv-pro-switch.active{background:rgba(0,204,136,.12);border-color:rgba(0,204,136,.3);color:#00cc88}',
66
- '#vtv-player-section .vtv-pro-epg{border-top:1px solid rgba(255,255,255,.04);background:rgba(0,0,0,.15)}',
67
- '#vtv-player-section .vtv-pro-epg-hdr{display:flex;align-items:center;justify-content:space-between;padding:8px 12px 4px}',
68
- '#vtv-player-section .vtv-pro-epg-title{font-size:10px;font-weight:600;color:#9aa0a6;letter-spacing:.5px;text-transform:uppercase}',
69
- '#vtv-player-section .vtv-pro-epg-time{font-size:9px;color:#5f6368;font-variant-numeric:tabular-nums}',
70
- '#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}',
71
- '#vtv-player-section .vtv-pro-epg-list::-webkit-scrollbar{width:3px}',
72
- '#vtv-player-section .vtv-pro-epg-list::-webkit-scrollbar-thumb{background:rgba(255,255,255,.08);border-radius:3px}',
73
- '#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}',
74
- '#vtv-player-section .vtv-pro-epg-row:hover{background:rgba(255,255,255,.03)}',
75
- '#vtv-player-section .vtv-pro-epg-row.now{border-left-color:#00cc88;background:rgba(0,204,136,.06)}',
76
- '#vtv-player-section .vtv-pro-epg-row.passed{opacity:.35}',
77
- '#vtv-player-section .vtv-pro-epg-row .t{font-size:10px;color:#5f6368;min-width:38px;font-variant-numeric:tabular-nums}',
78
- '#vtv-player-section .vtv-pro-epg-row.now .t{color:#00cc88;font-weight:600}',
79
- '#vtv-player-section .vtv-pro-epg-row .n{color:#e8eaed;line-height:1.3}',
80
- '#vtv-player-section .vtv-pro-epg-row.now .n{color:#fff;font-weight:500}',
81
- '#vtv-player-section .vtv-pro-epg-row .bar{flex:0 0 2px;height:12px;border-radius:1px;background:rgba(255,255,255,.08)}',
82
- '#vtv-player-section .vtv-pro-epg-row.now .bar{background:#00cc88}',
83
- '#vtv-player-section .vtv-pro-epg-row .d{font-size:8px;color:#5f6368;min-width:30px;text-align:right}',
84
- '#vtv-player-section .vtv-pro-epg-empty{color:#5f6368;font-size:11px;padding:12px 14px;text-align:center}',
85
- '#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}',
86
- ].join('');
87
- document.head.appendChild(s);
88
- })();
89
-
90
- function formatTime(d) {
91
- var h = d.getHours(), m = d.getMinutes();
92
- return (h < 10 ? '0' : '') + h + ':' + (m < 10 ? '0' : '') + m;
93
- }
94
-
95
- function getVNTime() {
96
- var now = new Date();
97
- try {
98
- var opts = { timeZone: 'Asia/Ho_Chi_Minh', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, year: 'numeric', month: '2-digit', day: '2-digit' };
99
- var parts = new Intl.DateTimeFormat('en-CA', opts).formatToParts(now);
100
- var hh=0,mm=0,ss=0,yy=2026,mo=1,dd=1;
101
- for (var i = 0; i < parts.length; i++) {
102
- var p = parts[i];
103
- if (p.type === 'hour') hh = parseInt(p.value);
104
- else if (p.type === 'minute') mm = parseInt(p.value);
105
- else if (p.type === 'second') ss = parseInt(p.value);
106
- else if (p.type === 'year') yy = parseInt(p.value);
107
- else if (p.type === 'month') mo = parseInt(p.value);
108
- else if (p.type === 'day') dd = parseInt(p.value);
109
- }
110
- return new Date(yy, mo - 1, dd, hh, mm, ss);
111
- } catch(e) {
112
- var utc = new Date(now.getTime() + now.getTimezoneOffset() * 60000);
113
- return new Date(utc.getTime() + 7 * 3600000);
114
- }
115
- }
116
-
117
- // ===== EPG =====
118
- function loadEPG(chId) {
119
- if (chId.indexOf('fpt') >= 0) return; // FPT ko có EPG
120
- var epgEl = document.getElementById('vtv-pro-epg-body');
121
- if (!epgEl) return;
122
- 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>';
123
- var xhr = new XMLHttpRequest();
124
- xhr.open('GET', '/api/vtv/epg/' + chId, true);
125
- xhr.timeout = 15000;
126
- xhr.onload = function() {
127
- try {
128
- var data = JSON.parse(xhr.responseText);
129
- _serverDate = data.date || '';
130
- _epgData = data.programs || [];
131
- renderEPG();
132
- } catch(e) { epgEl.innerHTML = '<div class="vtv-pro-epg-empty">Không có lịch phát sóng</div>'; }
133
- };
134
- xhr.onerror = xhr.ontimeout = function() {
135
- epgEl.innerHTML = '<div class="vtv-pro-epg-empty">Không tải được lịch phát sóng</div>';
136
- };
137
- xhr.send();
138
- }
139
-
140
- function renderEPG() {
141
- var epgEl = document.getElementById('vtv-pro-epg-body');
142
- if (!epgEl || !_epgData || !_epgData.length) {
143
- if (epgEl) epgEl.innerHTML = '<div class="vtv-pro-epg-empty">Chưa có lịch phát sóng</div>';
144
- return;
145
- }
146
- var vnNow = getVNTime();
147
- var nowStr = formatTime(vnNow);
148
- _epgData.sort(function(a, b) { return a.time.localeCompare(b.time); });
149
- for (var i = 0; i < _epgData.length; i++) {
150
- _epgData[i].now = false;
151
- var p = _epgData[i], nxt = _epgData[i + 1];
152
- if (p.time <= nowStr && (!nxt || nxt.time > nowStr)) { p.now = true; p.end_time = nxt ? nxt.time : ''; }
153
- }
154
- var html = '', prevDate = '';
155
- for (var i = 0; i < _epgData.length; i++) {
156
- var p = _epgData[i];
157
- var cls = 'vtv-pro-epg-row';
158
- if (p.now) cls += ' now';
159
- else if (p.time < nowStr) cls += ' passed';
160
- var xt = p.end_time ? ' -> ' + p.end_time : (i < _epgData.length - 1 ? ' -> ' + _epgData[i+1].time : '');
161
- var db = '';
162
- if (p.date && p.date !== prevDate) { db = '<span class="d">' + p.date + '</span>'; prevDate = p.date; }
163
- html += '<div class="' + cls + '"><span class="t">' + p.time + xt + '</span><span class="bar"></span><span class="n">' + p.title + '</span>' + db + '</div>';
164
- }
165
- epgEl.innerHTML = html;
166
- var nowEl = epgEl.querySelector('.now');
167
- if (nowEl) nowEl.scrollIntoView({ block: 'center', behavior: 'smooth' });
168
- }
169
-
170
- function startEpgRefresh() {
171
- if (_epgTimer) clearInterval(_epgTimer);
172
- _epgTimer = setInterval(function() { if (_currentCh && _currentCh.indexOf('fpt') < 0) loadEPG(_currentCh); }, 60000);
173
- }
174
-
175
- function updateClock() {
176
- var timeEl = document.getElementById('vtv-pro-time');
177
- if (timeEl) {
178
- var vn = getVNTime();
179
- timeEl.textContent = formatTime(vn) + ' GMT+7' + (_serverDate ? ' - ' + _serverDate : '');
180
- }
181
- }
182
-
183
- // ===== Destroy HLS player =====
184
- function destroyHLS() {
185
- if (_hls) {
186
- try { _hls.destroy(); } catch(e) {}
187
- _hls = null;
188
- }
189
- }
190
-
191
- // ===== Play native HLS via proxy =====
192
- function playNativeHLS(m3u8Url) {
193
- destroyHLS();
194
- var frame = document.getElementById('vtv-pro-frame');
195
- if (!frame) return;
196
- frame.innerHTML = '<video id="vtv-native-video" controls autoplay playsinline webkit-playsinline style="position:absolute;inset:0;width:100%;height:100%;background:#000"></video>';
197
- var video = document.getElementById('vtv-native-video');
198
- if (!video) return;
199
- if (typeof Hls !== 'undefined' && Hls.isSupported()) {
200
- _hls = new Hls({ enableWorker: false });
201
- _hls.loadSource(m3u8Url);
202
- _hls.attachMedia(video);
203
- _hls.on(Hls.Events.MANIFEST_PARSED, function() {
204
- video.play().catch(function(){});
205
- });
206
- _hls.on(Hls.Events.ERROR, function(evt, data) {
207
- if (data.fatal) {
208
- destroyHLS();
209
- video.src = m3u8Url;
210
- video.play().catch(function(){});
211
- }
212
- });
213
- } else {
214
- video.src = m3u8Url;
215
- }
216
- }
217
-
218
- // ===== Load iframe into frame =====
219
- function loadIframe(iframeUrl) {
220
- destroyHLS();
221
- var frame = document.getElementById('vtv-pro-frame');
222
- if (!frame) return;
223
- var iframe = document.createElement('iframe');
224
- iframe.src = iframeUrl;
225
- iframe.setAttribute('allowfullscreen', 'true');
226
- iframe.setAttribute('allow', 'autoplay; encrypted-media; fullscreen');
227
- iframe.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;border:none;background:#000';
228
- frame.innerHTML = '';
229
- frame.appendChild(iframe);
230
- }
231
-
232
- // ===== Fetch stream data for any channel including FPT =====
233
- function fetchStreamData(chId, callback) {
234
- if (chId.indexOf('fpt') >= 0) {
235
- // FPT Play: use iframe #1 from hd1.xemtv.net page (kenh/fptplay.php)
236
- callback({ iframe_url: _fptIfUrl });
237
- return;
238
- }
239
- // All other channels: use server API
240
- var xhr = new XMLHttpRequest();
241
- xhr.open('GET', '/api/vtv/stream/' + chId, true);
242
- xhr.timeout = 15000;
243
- xhr.onload = function() {
244
- try {
245
- callback(JSON.parse(xhr.responseText));
246
- } catch(e) { callback(null); }
247
- };
248
- xhr.onerror = xhr.ontimeout = function() { callback(null); };
249
- xhr.send();
250
- }
251
-
252
- // ===== Switch channel =====
253
- window._vtvProSwitch = function(chId) {
254
- _currentCh = chId;
255
- _nativeMode = false;
256
-
257
- // Update tabs
258
- var tabs = document.querySelectorAll('#vtv-player-section .vtv-pro-tab');
259
- for (var i = 0; i < tabs.length; i++) {
260
- var t = tabs[i];
261
- var isActive = (t.getAttribute('data-ch') === chId);
262
- t.className = 'vtv-pro-tab' + (isActive ? ' on' : '') + (isActive && chId.indexOf('fpt') >= 0 ? ' fpt on' : '');
263
- }
264
-
265
- var toggleBtn = document.getElementById('vtv-pro-toggle-btn');
266
-
267
- // Show/hide toggle button (same for all channels including FPT)
268
- if (toggleBtn) {
269
- toggleBtn.style.display = '';
270
- toggleBtn.classList.remove('active');
271
- toggleBtn.innerHTML = '\ud83d\udd0c JWPlayer';
272
- }
273
-
274
- // Loading state
275
- var frame = document.getElementById('vtv-pro-frame');
276
- if (frame) frame.innerHTML = '<div class="vtv-pro-load"><div class="vtv-pro-spinner"></div><span>Đang kết nối ' + chId.toUpperCase() + '...</span></div>';
277
- destroyHLS();
278
- updateClock();
279
- loadEPG(chId);
280
- startEpgRefresh();
281
-
282
- // Fetch stream and load
283
- fetchStreamData(chId, function(data) {
284
- if (!data || !data.iframe_url) {
285
- frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">\ud83d\udce1</div><div class="msg">Kênh không khả dụng</div><button onclick="_vtvProSwitch(\''+chId+'\')">Thử lại</button></div>';
286
- return;
287
- }
288
- loadIframe(data.iframe_url);
289
- });
290
- };
291
-
292
- // ===== Toggle iframe / native HLS (for all channels including FPT) =====
293
- window._vtvToggleNative = function(btn) {
294
- if (!_currentCh) return;
295
- _nativeMode = !_nativeMode;
296
- btn.classList.toggle('active', _nativeMode);
297
- btn.innerHTML = _nativeMode ? '\ud83d\udce1 HLS' : '\ud83d\udd0c JWPlayer';
298
-
299
- var frame = document.getElementById('vtv-pro-frame');
300
- if (!frame) return;
301
-
302
- if (_currentCh.indexOf('fpt') >= 0) {
303
- // FPT mode: toggle between iframe and HLS
304
- if (_nativeMode) {
305
- // Switch to HLS
306
- destroyHLS();
307
- playNativeHLS('/api/proxy/m3u8?url=https://live.fptplay53.net/epzhd1/htvcthethao_vhls.smil/chunklist.m3u8');
308
- } else {
309
- // Switch back to iframe
310
- loadIframe(_fptIfUrl);
311
- }
312
- } else {
313
- // VTV mode: toggle via server
314
- var xhr = new XMLHttpRequest();
315
- xhr.open('GET', '/api/vtv/stream/' + _currentCh, true);
316
- xhr.timeout = 15000;
317
- xhr.onload = function() {
318
- try {
319
- var data = JSON.parse(xhr.responseText);
320
- if (_nativeMode) {
321
- var m3u8Url = data.m3u8_proxy_url || data.proxy_url_hls || data.stream_url;
322
- if (m3u8Url) {
323
- playNativeHLS(m3u8Url);
324
- } else {
325
- frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">\ud83d\udce1</div><div class="msg">Kênh không hỗ trợ HLS</div></div>';
326
- _nativeMode = false;
327
- btn.classList.remove('active');
328
- btn.innerHTML = '\ud83d\udd0c JWPlayer';
329
- }
330
- } else {
331
- if (data.iframe_url) loadIframe(data.iframe_url);
332
- }
333
- } catch(e) { frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">\u26a0\ufe0f</div></div>'; }
334
- };
335
- xhr.onerror = function() { frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">\ud83d\udd0c</div><div class="msg">Lỗi kết nối</div></div>'; };
336
- xhr.send();
337
- }
338
- };
339
-
340
- // ===== HTML builder =====
341
- function buildHTML() {
342
- var tabs = '';
343
- for (var i = 0; i < CHANNELS.length; i++) {
344
- var ch = CHANNELS[i];
345
- var isFpt = ch.id.indexOf('fpt') >= 0;
346
- tabs += '<span class="vtv-pro-tab' + (isFpt ? ' fpt' : '') + '" data-ch="'+ch.id+'" onclick="_vtvProSwitch(\''+ch.id+'\')" ' + (isFpt ? 'data-fpt="true"' : '') + '>' +
347
- ch.name + '<span class="b">'+ch.badge+'</span></span>';
348
- }
349
- return '<div id="vtv-player-section">' +
350
- '<div class="vtv-pro-head">' +
351
- '<div class="vtv-pro-logo">V</div>' +
352
- '<span class="vtv-pro-title">VTV Player</span>' +
353
- '<span class="vtv-pro-live"><span class="vtv-pro-live-dot"></span>TRỰC TIẾP</span>' +
354
- '</div>' +
355
- '<div class="vtv-pro-tabs">'+tabs+'</div>' +
356
- '<div class="vtv-pro-frame" id="vtv-pro-frame">' +
357
- '<div class="vtv-pro-load"><div class="vtv-pro-spinner"></div><span>Chọn kênh để xem trực tiếp</span></div>' +
358
- '</div>' +
359
- '<div class="vtv-pro-controls">' +
360
- '<span id="vtv-pro-time" class="vtv-pro-btn" style="background:none;border:none;font-size:10px;color:#5f6368;margin-right:auto"></span>' +
361
- '<span class="vtv-pro-switch" id="vtv-pro-toggle-btn" onclick="_vtvToggleNative(this)">\ud83d\udd0c JWPlayer</span>' +
362
- '</div>' +
363
- '<div class="vtv-pro-epg">' +
364
- '<div class="vtv-pro-epg-hdr"><span class="vtv-pro-epg-title">Lịch phát sóng \u2014 nguồn VTV.vn</span></div>' +
365
- '<div class="vtv-pro-epg-list" id="vtv-pro-epg-body"><div class="vtv-pro-epg-empty">Chọn kênh để xem lịch phát sóng</div></div>' +
366
- '</div>' +
367
- '</div>';
368
- }
369
-
370
- function inject() {
371
- var homeEl = document.getElementById('view-home');
372
- if (!homeEl || document.getElementById('vtv-player-section')) return;
373
- var featured = document.getElementById('home-featured-area') || homeEl.querySelector('.featured-match, .fm-section, .slider-wrap');
374
- if (featured && featured.parentNode) featured.insertAdjacentHTML('afterend', buildHTML());
375
- else if (homeEl.firstChild) homeEl.insertAdjacentHTML('afterbegin', buildHTML());
376
- setTimeout(function() { if (window._vtvProSwitch) window._vtvProSwitch('vtv6'); }, 500);
377
- }
378
-
379
- var orig = window.loadHome;
380
- if (typeof orig === 'function') {
381
- window.loadHome = function() {
382
- var r = orig.apply(this, arguments);
383
- if (r && typeof r.then === 'function') return r.then(function(v) { setTimeout(inject, 2000); return v; });
384
- else { setTimeout(inject, 2000); return r; }
385
- };
386
- } else {
387
- (function waitAndInject() {
388
- if (document.getElementById('view-home') && !document.getElementById('vtv-player-section')) inject();
389
- else if (!document.getElementById('vtv-player-section')) setTimeout(waitAndInject, 1000);
390
- })();
391
- }
392
- })();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/yt_live.js CHANGED
@@ -1,13 +1,175 @@
1
- // ===== VNEWS — VTV LIVE + Inline Recorder (v9) =====
2
- // NOTE: This file was truncated during a previous edit and broke page load.
3
- // All VTV player functionality now lives in /static/vtv_init.js (v11).
4
- // This stub keeps any legacy references working without errors.
5
- (function() {
6
- 'use strict';
7
- if (window._vtvLiveStubLoaded) return;
8
- window._vtvLiveStubLoaded = true;
9
- // Legacy function names that other modules may check via typeof guards.
10
- window.initYTLive = window.initYTLive || function() {};
11
- window.openYTLive = window.openYTLive || function() {};
12
- window.toggleYTLive = window.toggleYTLive || function() {};
13
- })();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // === VNEWS — VTV LIVE + Inline Recorder v9 ===
2
+ // Features: PiP, mini-player, INLINE RECORDER, VTVGO POPUP players for each channel
3
+ // FIX v10: Correct VTVGO popup URLs for all channels + World Cup 2026 TS stream links
4
+ // FIX v11: Allow clicking off tabs (always clickable), better stream status handling
5
+
6
+ (function(){
7
+ if(window._ytLiveLoaded) return;
8
+ window._ytLiveLoaded = true;
9
+
10
+ const CHANNELS = [
11
+ {id:'vtv1',name:'VTV1',badge:'Tin tức'},{id:'vtv2',name:'VTV2',badge:'Khoa học'},
12
+ {id:'vtv3',name:'VTV3',badge:'Giải trí'},{id:'vtv4',name:'VTV4',badge:'Quốc tế'},
13
+ {id:'vtv5',name:'VTV5',badge:'Miền Nam'},{id:'vtv6',name:'VTV6',badge:'Thanh niên'},
14
+ {id:'vtv7',name:'VTV7',badge:'Giáo dục'},{id:'vtv8',name:'VTV8',badge:'Miền Trung'},
15
+ {id:'vtv9',name:'VTV9',badge:'Miền Bắc'},{id:'vtv10',name:'VTV10',badge:'Cần Thơ'},
16
+ {id:'vtvprime',name:'VTVPrime',badge:'Prime'},
17
+ ];
18
+
19
+ // VTVGO popup URLs for each channel (FIXED v10 - verified correct URLs)
20
+ const VTV_POPUP_URLS = {
21
+ vtv1: 'https://package.vtvgo.vn/channel/vtv1-1,1.html',
22
+ vtv2: 'https://package.vtvgo.vn/channel/vtv2-1,2.html',
23
+ vtv3: 'https://package.vtvgo.vn/channel/vtv3-1,3.html',
24
+ vtv4: 'https://package.vtvgo.vn/channel/vtv4-1,4.html',
25
+ vtv5: 'https://package.vtvgo.vn/channel/vtv5-1,5.html',
26
+ vtv6: 'https://package.vtvgo.vn/channel/vtv6-1,13.html',
27
+ vtv7: 'https://package.vtvgo.vn/channel/vtv7-1,27.html',
28
+ vtv8: 'https://package.vtvgo.vn/channel/vtv8-1,36.html',
29
+ vtv9: 'https://package.vtvgo.vn/channel/vtv9-1,39.html',
30
+ vtv10: 'https://package.vtvgo.vn/channel/vtv10-1,6.html',
31
+ };
32
+
33
+ const DEFAULT_CHANNEL = 'vtv3';
34
+ const NEEDS_PROXY = /fptplay\.net|vtvgo\.vn/;
35
+ const STREAMS = {};
36
+ let _currentCh = null, _hls = null, _loading = false, _blockInserted = false;
37
+ let _streamsLoaded = false, _pipActive = false, _miniActive = false, _vtvPinned = false, _vtvPopupActive = false;
38
+
39
+ // ===== RECORDER STATE =====
40
+ const _rec = {
41
+ active: false, startTime: null, endTime: null,
42
+ isRecording: false, recorder: null, chunks: [], blob: null,
43
+ ratio: 'original', _dragMarker: null, _recTimer: null,
44
+ };
45
+
46
+ // ===== STYLES =====
47
+ const style = document.createElement('style');
48
+ style.textContent = `
49
+ .vtv-wrap{position:relative;margin:6px 4px;background:#111;border:1px solid #0066cc;border-radius:10px;overflow:hidden}
50
+ .vtv-head{display:flex;align-items:center;gap:8px;padding:8px 10px;background:linear-gradient(90deg,#003366,#1a1a1a)}
51
+ .vtv-title{font-size:13px;font-weight:800;color:#00ccff}
52
+ .vtv-badge{font-size:10px;font-weight:800;color:#00ccff;animation:vtvp 1.3s infinite}
53
+ @keyframes vtvp{0%,100%{opacity:1}50%{opacity:.3}}
54
+ .vtv-tabs{display:flex;flex-wrap:wrap;gap:3px;padding:6px 8px;overflow-x:auto;scrollbar-width:none;background:#0d1a2a}
55
+ .vtv-tabs::-webkit-scrollbar{display:none}
56
+ .vtv-tab{padding:4px 8px;background:#1a2a3a;border:1px solid #2a3a4a;border-radius:10px;color:#8ab4d8;font-size:9px;cursor:pointer;white-space:nowrap;flex-shrink:0;transition:all .2s}
57
+ .vtv-tab:hover{background:#0b4a7a;color:#fff}
58
+ .vtv-tab.on{background:#0066cc;border-color:#00ccff;color:#fff;font-weight:700}
59
+ .vtv-tab.off{opacity:.35}
60
+ .vtv-frame{position:relative;width:100%;aspect-ratio:16/9;background:#000;min-height:180px}
61
+ .vtv-frame video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
62
+ .vtv-err{display:flex;align-items:center;justify-content:center;height:180px;color:#888;font-size:12px;text-align:center;padding:20px;flex-direction:column;gap:8px}
63
+ .vtv-err button{background:#0066cc;border:none;color:#fff;padding:6px 14px;border-radius:8px;font-size:11px;cursor:pointer}
64
+ .vtv-load{display:flex;align-items:center;justify-content:center;height:180px;color:#00ccff;font-size:12px;flex-direction:column;gap:8px}
65
+ .vtv-spinner{width:24px;height:24px;border:2px solid #333;border-top-color:#00ccff;border-radius:50%;animation:vtvspin .8s linear infinite}
66
+ @keyframes vtvspin{to{transform:rotate(360deg)}}
67
+ .vtv-controls{display:flex;align-items:center;gap:4px;padding:4px 8px;background:#0d1a2a;border-top:1px solid #1a2a3a}
68
+ .vtv-pip-btn{background:#1a2a3a;border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:3px 8px;border-radius:6px;cursor:pointer;transition:all .2s;display:flex;align-items:center;gap:4px}
69
+ .vtv-pip-btn:hover{background:#0b4a7a;color:#fff}
70
+ .vtv-pip-btn.on{background:#0066cc;border-color:#00ccff;color:#fff}
71
+ .vtv-pip-btn svg{width:12px;height:12px;fill:currentColor}
72
+ .vtv-epg{margin:0;padding:6px 10px;background:#0a1628;border-top:1px solid #1a2a3a}
73
+ .vtv-epg-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}
74
+ .vtv-epg-title{font-size:10px;font-weight:700;color:#00ccff}
75
+ .vtv-epg-toggle{background:none;border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:2px 8px;border-radius:6px;cursor:pointer}
76
+ .vtv-epg-list{display:flex;gap:4px;overflow-x:auto;scrollbar-width:none;padding-bottom:4px}
77
+ .vtv-epg-list::-webkit-scrollbar{display:none}
78
+ .vtv-epg-item{flex:0 0 auto;padding:3px 6px;background:#1a2a3a;border-radius:4px;font-size:8px;color:#8ab4d8;white-space:nowrap;cursor:pointer}
79
+ .vtv-epg-item:hover{background:#2a4a6a}
80
+ .vtv-epg-item.now{background:#0066cc;color:#fff;font-weight:700}
81
+ .vtv-epg-item .t{font-size:7px;color:#6a8aaa}
82
+ .vtv-epg-item.now .t{color:#aaccee}
83
+ .vtv-epg-item .n{color:#ccc;font-size:8px}
84
+ .vtv-epg-item.now .n{color:#fff}
85
+ .vtv-epg-empty{color:#666;font-size:9px;padding:4px}
86
+ .vtv-epg-loading{color:#00ccff;font-size:9px;padding:4px;display:flex;align-items:center;gap:6px}
87
+ .vtv-epg-sp{width:10px;height:10px;border:1px solid #333;border-top-color:#00ccff;border-radius:50%;animation:vtvspin .8s linear infinite}
88
+ .vtv-pin-btn{position:absolute;top:6px;right:8px;z-index:5;background:rgba(0,0,0,.5);border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:2px 6px;border-radius:4px;cursor:pointer;font-weight:700;display:flex;align-items:center;gap:3px}
89
+ .vtv-pin-btn:hover{background:#0b4a7a;color:#fff}
90
+ .vtv-pin-btn.pinned{background:#0066cc;border-color:#00ccff;color:#fff}
91
+ .vtv-wrap.vtv-sticky{position:sticky;top:0;z-index:48;transition:all .25s ease;box-shadow:0 4px 24px rgba(0,102,204,.35)}
92
+ .vtv-wrap.vtv-sticky .vtv-epg{display:none}
93
+ .vtv-wrap.vtv-sticky .vtv-controls{display:none}
94
+ .vtv-wrap.vtv-sticky .vtv-tabs{padding:3px 8px}
95
+ .vtv-wrap.vtv-sticky .vtv-tab{padding:3px 7px;font-size:8px}
96
+ .vtv-wrap.vtv-sticky .vtv-frame{max-height:140px;min-height:100px}
97
+ .vtv-wrap.vtv-sticky .vtv-frame video{max-height:140px}
98
+ .vtv-wrap.vtv-sticky .vtv-load{height:100px}
99
+ .vtv-wrap.vtv-sticky .vtv-err{height:100px}
100
+ .vtv-wrap.vtv-sticky .vtv-head{padding:5px 10px}
101
+ .vtv-wrap.vtv-sticky .vtv-title{font-size:11px}
102
+ .vtv-wrap.vtv-sticky .vtv-badge{font-size:8px}
103
+ .vtv-wrap.vtv-sticky .vtv-pin-btn{top:4px;right:6px}
104
+ .vtv-rec-btn{background:#660000;border:1px solid #990000;color:#ff6666;font-size:9px;padding:3px 8px;border-radius:6px;cursor:pointer;transition:all .2s;display:flex;align-items:center;gap:4px;font-weight:700}
105
+ .vtv-rec-btn:hover{background:#990000;color:#fff}
106
+ .vtv-rec-btn.recording{background:#cc0000;border-color:#ff0000;color:#fff;animation:vtv-rec-pulse 1s infinite}
107
+ @keyframes vtv-rec-pulse{0%,100%{opacity:1}50%{opacity:.5}}
108
+ .vtv-rec-btn svg{width:12px;height:12px;fill:currentColor}
109
+ .vtv-inline-rec{display:none;padding:8px 10px;background:#0a0a1a;border-top:1px solid #1a1a3a}
110
+ .vtv-inline-rec.show{display:block}
111
+ .vtv-inline-rec .rec-bar{position:relative;height:32px;background:#000;border-radius:4px;overflow:hidden;cursor:pointer;border:1px solid #2a2a4a;margin-bottom:6px;user-select:none}
112
+ .vtv-inline-rec .rec-bar .rec-progress{position:absolute;top:0;bottom:0;background:rgba(155,89,182,.3);left:0;width:0%;pointer-events:none}
113
+ .vtv-inline-rec .rec-bar .rec-marker{position:absolute;top:0;bottom:0;width:4px;z-index:2;border-radius:2px}
114
+ .vtv-inline-rec .rec-bar .rec-marker.s{background:#2ecc71}
115
+ .vtv-inline-rec .rec-bar .rec-marker.e{background:#e74c3c}
116
+ .vtv-inline-rec .rec-time{display:flex;justify-content:space-between;font-size:9px;color:#888;margin-bottom:6px}
117
+ .vtv-inline-rec .rec-controls{display:flex;gap:4px}
118
+ .vtv-inline-rec .rec-controls button{flex:1;padding:6px;border:none;border-radius:6px;font-size:10px;font-weight:700;cursor:pointer}
119
+ .vtv-inline-rec .rec-controls .rec-set-start{background:#1a3a1a;border:1px solid #2d6a2d;color:#5cb87a}
120
+ .vtv-inline-rec .rec-controls .rec-set-end{background:#3a1a1a;border:1px solid #6a2d2d;color:#e74c3c}
121
+ .vtv-inline-rec .rec-controls .rec-go{background:#1a0a3a;border:1px solid #3a2a6a;color:#9b59b6}
122
+ .vtv-inline-rec .rec-controls .rec-reset{background:#222;border:1px solid #333;color:#888}
123
+ .vtv-inline-rec .rec-hint{font-size:9px;color:#666;text-align:center;margin-top:4px}
124
+ .vtv-inline-rec .rec-status{font-size:10px;color:#888;text-align:center;padding:4px;margin-top:4px;background:#111;border-radius:4px}
125
+ .vtv-inline-rec .rec-status.ok{color:#2ecc71}
126
+ .vtv-inline-rec .rec-status.err{color:#e74c3c}
127
+ .vtv-inline-rec .rec-status.recording{color:#e74c3c;animation:vtv-rec-pulse 1s infinite}
128
+ .vtv-rec-panel{display:none;padding:10px;background:#0d0d20;border-top:1px solid #2a1a4a}
129
+ .vtv-rec-panel.show{display:block}
130
+ .vtv-rec-panel .rec-panel-title{font-size:11px;font-weight:700;color:#9b59b6;margin-bottom:8px}
131
+ .vtv-rec-panel .rec-preview-wrap{margin-bottom:8px;text-align:center}
132
+ .vtv-rec-panel .rec-preview-wrap video{max-width:100%;max-height:180px;border-radius:6px;background:#000}
133
+ .vtv-rec-panel .rec-ratio-row{display:flex;gap:4px;margin-bottom:8px}
134
+ .vtv-rec-panel .rec-ratio-row button{flex:1;padding:6px;background:#1a1a2e;border:1px solid #2a2a4a;border-radius:6px;color:#888;font-size:10px;cursor:pointer}
135
+ .vtv-rec-panel .rec-ratio-row button.active{border-color:#9b59b6;color:#9b59b6;background:#2a1a4a}
136
+ .vtv-rec-panel .rec-actions{display:flex;gap:4px}
137
+ .vtv-rec-panel .rec-actions button{flex:1;padding:8px;border:none;border-radius:6px;font-size:11px;font-weight:700;cursor:pointer}
138
+ .vtv-rec-panel .rec-actions .rec-download{background:#2d8659;color:#fff}
139
+ .vtv-rec-panel .rec-actions .rec-share{background:#9b59b6;color:#fff}
140
+ .vtv-rec-panel .rec-proc{text-align:center;padding:12px;color:#9b59b6;font-size:12px;display:none}
141
+ .vtv-rec-panel .rec-title-input{width:100%;background:#1a1a2e;border:1px solid #2a2a4a;border-radius:6px;padding:8px;color:#ccc;font-size:11px;margin-bottom:8px;box-sizing:border-box}
142
+ .vtv-rec-panel .rec-title-input:focus{border-color:#9b59b6;outline:none}
143
+ .vtv-rec-panel .rec-ai-title-row{display:flex;gap:4px;margin-bottom:8px}
144
+ .vtv-rec-panel .rec-ai-title-row button{flex:1;padding:6px;background:#1a1a2e;border:1px solid #2a2a4a;border-radius:6px;color:#888;font-size:10px;cursor:pointer}
145
+ .vtv-rec-panel .rec-ai-title-row button:hover{border-color:#9b59b6;color:#9b59b6}
146
+ .vtv-rec-panel .rec-ai-title-row button:disabled{opacity:.4;cursor:not-allowed}
147
+ .vtv-mini{position:fixed;top:0;left:0;right:0;z-index:99990;background:#000;border-bottom:2px solid #0066cc;box-shadow:0 4px 20px rgba(0,102,204,.4);display:none;transition:transform .3s ease}
148
+ .vtv-mini.show{display:block}
149
+ .vtv-mini.hidden{transform:translateY(-88%)}
150
+ .vtv-mini-frame{position:relative;width:100%;aspect-ratio:16/9;background:#000;max-height:180px}
151
+ .vtv-mini-frame video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
152
+ .vtv-mini-bar{display:flex;align-items:center;justify-content:space-between;padding:3px 8px;background:linear-gradient(90deg,#003366,#1a1a1a)}
153
+ .vtv-mini-ch{font-size:10px;font-weight:700;color:#00ccff}
154
+ .vtv-mini-epg{font-size:9px;color:#8ab4d8;max-width:180px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-left:8px}
155
+ .vtv-mini-btns{display:flex;gap:3px}
156
+ .vtv-mini-btn{background:#1a2a3a;border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:2px 6px;border-radius:4px;cursor:pointer}
157
+ .vtv-mini-btn:hover{background:#0b4a7a;color:#fff}
158
+ .vtv-mini-btn.x{background:#600;border-color:#900;color:#f66}
159
+ .vtv-mini-btn.x:hover{background:#900;color:#fff}
160
+ .vtv-mini-peek{position:absolute;bottom:-18px;right:10px;background:#0066cc;color:#fff;font-size:9px;padding:2px 8px;border-radius:0 0 6px 6px;cursor:pointer;display:none}
161
+ .vtv-mini.hidden .vtv-mini-peek{display:block}
162
+ .vtv-popup{position:fixed;top:0;left:0;right:0;margin:8px auto 0;background:rgba(0,0,0,.95);z-index:99999;display:none;flex-direction:column;max-height:calc(100vh - 16px);max-width:800px;border-radius:12px;overflow:hidden}
163
+ .vtv-popup.show{display:flex}
164
+ .vtv-popup:has(#vtv-popup-hdr[style*="none"]) .vtv-popup-frame{height:100%}
165
+ .vtv-popup-header{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:#111;border-bottom:1px solid #333}
166
+ .vtv-popup-title{font-size:14px;font-weight:700;color:#00ccff}
167
+ .vtv-popup-close{background:#333;border:none;color:#fff;font-size:18px;width:32px;height:32px;border-radius:6px;cursor:pointer}
168
+ .vtv-popup-frame{flex:1;position:relative;background:#000;min-height:300px}
169
+ .vtv-popup-frame iframe{position:absolute;inset:0;width:100%;height:100%;border:none}
170
+ .vtv-popup-frame iframe{position:absolute;inset:0;width:100%;height:100%;border:none}
171
+ .vtv-popup-loader{display:flex;align-items:center;justify-content:center;height:280px;color:#00ccff;font-size:12px;flex-direction:column;gap:8px}
172
+ .vtv-popup-btn{background:#1a2a3a;border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:3px 8px;border-radius:6px;cursor:pointer;margin-left:4px}
173
+ .vtv-popup-btn:hover{background:#0b4a7a;color:#fff}
174
+ `;
175
+ document.head.appendChild(style);
storage.py DELETED
@@ -1,150 +0,0 @@
1
- """Persistent storage for VNEWS using Hugging Face Dataset as backend."""
2
- import os, json, time, threading
3
- from pathlib import Path
4
- from huggingface_hub import HfApi, hf_hub_download, upload_file
5
- from huggingface_hub.utils import RepositoryNotFoundError
6
-
7
- DATASET_REPO = "bep40/VNEWS-data"
8
- DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data"
9
- os.makedirs(DATA_DIR, exist_ok=True)
10
-
11
- LOCAL_WALL_FILE = os.path.join(DATA_DIR, "wall_posts.json")
12
- LOCAL_INTERACTIONS_FILE = os.path.join(DATA_DIR, "interactions_v2.json")
13
- LOCAL_COMMENTS_FILE = os.path.join(DATA_DIR, "comments_v2.json")
14
- LOCAL_SHORTS_CACHE = os.path.join(DATA_DIR, "shorts_cache.json")
15
-
16
- _lock = threading.Lock()
17
- _api = HfApi()
18
-
19
- def _download_file(repo_path: str, local_path: str) -> bool:
20
- """Download a file from HF dataset to local path."""
21
- try:
22
- downloaded = hf_hub_download(
23
- repo_id=DATASET_REPO,
24
- filename=repo_path,
25
- repo_type="dataset",
26
- local_dir=DATA_DIR,
27
- local_dir_use_symlinks=False,
28
- )
29
- if downloaded and downloaded != local_path:
30
- import shutil
31
- shutil.copy2(downloaded, local_path)
32
- return True
33
- except Exception as e:
34
- print(f"[storage] Download {repo_path} failed: {e}")
35
- return False
36
-
37
- def _upload_file(local_path: str, repo_path: str) -> bool:
38
- """Upload a local file to HF dataset."""
39
- try:
40
- upload_file(
41
- path_or_fileobj=local_path,
42
- path_in_repo=repo_path,
43
- repo_id=DATASET_REPO,
44
- repo_type="dataset",
45
- )
46
- return True
47
- except Exception as e:
48
- print(f"[storage] Upload {repo_path} failed: {e}")
49
- return False
50
-
51
- def init_storage():
52
- """Initialize storage by downloading existing data from HF dataset."""
53
- print("[storage] Initializing from HF dataset...")
54
- for local, remote in [
55
- (LOCAL_WALL_FILE, "wall_posts.json"),
56
- (LOCAL_INTERACTIONS_FILE, "interactions_v2.json"),
57
- (LOCAL_COMMENTS_FILE, "comments_v2.json"),
58
- (LOCAL_SHORTS_CACHE, "shorts_cache.json"),
59
- ]:
60
- if not os.path.exists(local):
61
- _download_file(remote, local)
62
- print("[storage] Storage initialized")
63
-
64
- def _load_json(local_path: str, default):
65
- """Load JSON from local file with default fallback."""
66
- try:
67
- if os.path.exists(local_path):
68
- with open(local_path, 'r', encoding='utf-8') as f:
69
- return json.load(f)
70
- except Exception:
71
- pass
72
- return default
73
-
74
- def _save_json(local_path: str, data):
75
- """Save JSON to local file atomically."""
76
- try:
77
- tmp = local_path + ".tmp"
78
- with open(tmp, 'w', encoding='utf-8') as f:
79
- json.dump(data, f, ensure_ascii=False)
80
- os.replace(tmp, local_path)
81
- return True
82
- except Exception as e:
83
- print(f"[storage] Save {local_path} failed: {e}")
84
- return False
85
-
86
- # ===== Wall Posts =====
87
- def load_wall_posts() -> list:
88
- """Load wall posts from local file (synced from HF dataset)."""
89
- with _lock:
90
- return _load_json(LOCAL_WALL_FILE, [])
91
-
92
- def save_wall_posts(posts: list) -> bool:
93
- """Save wall posts to local file and async sync to HF dataset."""
94
- with _lock:
95
- ok = _save_json(LOCAL_WALL_FILE, posts[:200]) # Keep max 200
96
- if ok:
97
- # Async upload to HF dataset
98
- threading.Thread(
99
- target=lambda: _upload_file(LOCAL_WALL_FILE, "wall_posts.json"),
100
- daemon=True
101
- ).start()
102
- return ok
103
-
104
- # ===== Interactions =====
105
- def load_interactions() -> dict:
106
- with _lock:
107
- return _load_json(LOCAL_INTERACTIONS_FILE, {})
108
-
109
- def save_interactions(data: dict) -> bool:
110
- with _lock:
111
- ok = _save_json(LOCAL_INTERACTIONS_FILE, data)
112
- if ok:
113
- threading.Thread(
114
- target=lambda: _upload_file(LOCAL_INTERACTIONS_FILE, "interactions_v2.json"),
115
- daemon=True
116
- ).start()
117
- return ok
118
-
119
- # ===== Comments =====
120
- def load_comments() -> dict:
121
- with _lock:
122
- return _load_json(LOCAL_COMMENTS_FILE, {})
123
-
124
- def save_comments(data: dict) -> bool:
125
- with _lock:
126
- ok = _save_json(LOCAL_COMMENTS_FILE, data)
127
- if ok:
128
- threading.Thread(
129
- target=lambda: _upload_file(LOCAL_COMMENTS_FILE, "comments_v2.json"),
130
- daemon=True
131
- ).start()
132
- return ok
133
-
134
- # ===== Shorts Cache =====
135
- def load_shorts_cache() -> dict:
136
- with _lock:
137
- return _load_json(LOCAL_SHORTS_CACHE, {})
138
-
139
- def save_shorts_cache(data: dict) -> bool:
140
- with _lock:
141
- ok = _save_json(LOCAL_SHORTS_CACHE, data)
142
- if ok:
143
- threading.Thread(
144
- target=lambda: _upload_file(LOCAL_SHORTS_CACHE, "shorts_cache.json"),
145
- daemon=True
146
- ).start()
147
- return ok
148
-
149
- # Auto-sync on startup
150
- init_storage()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vtv_api.py CHANGED
@@ -1,223 +1,898 @@
1
- # VTV Stream — SV2/sv.xemtivitop iframe URLs + M3U fallback + EPG + FPT Play
2
- import re, time, threading, json, requests
 
 
 
 
 
 
 
 
 
3
  from fastapi import APIRouter, Query
4
- from fastapi.responses import JSONResponse, Response, StreamingResponse
5
  from bs4 import BeautifulSoup
6
  from datetime import datetime, timedelta, timezone
7
- from urllib.parse import quote
8
 
9
  VN_TZ = timezone(timedelta(hours=7))
 
10
  router = APIRouter()
11
 
12
  UA = {
13
  "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
14
  "Accept-Language": "vi-VN,vi;q=0.9",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  }
16
 
17
- HARDCODED_URLS = {
18
- "vtv6": "https://freem3u.xyz/api/live/play.m3u8?vid=10043",
19
- "vtv10": "https://live.fptplay53.net/live/media/vtv10/live247-hls-avc/index.m3u8",
 
 
 
 
 
 
 
 
 
 
20
  }
21
- CHANNEL_URLS = {k: (HARDCODED_URLS[k] if k in HARDCODED_URLS else None) for k in ["vtv1","vtv2","vtv3","vtv4","vtv5","vtv6","vtv7","vtv8","vtv9","vtv10"]}
22
- CHANNEL_NAMES = {"vtv1":"VTV1","vtv2":"VTV2","vtv3":"VTV3","vtv4":"VTV4","vtv5":"VTV5","vtv6":"VTV6","vtv7":"VTV7","vtv8":"VTV8","vtv9":"VTV9","vtv10":"VTV10","vtvprime":"VTVPrime"}
23
 
24
- IFRAME_URLS = {
 
25
  "vtv1": "https://sv2.xemtivitop.com/live/hot/vtv1.php",
26
  "vtv2": "https://sv2.xemtivitop.com/live/hot/vtv2.php",
27
  "vtv3": "https://sv2.xemtivitop.com/live/hot/vtv3.php",
28
  "vtv4": "https://sv2.xemtivitop.com/live/hot/vtv4.php",
29
  "vtv5": "https://sv2.xemtivitop.com/live/hot/vtv5.php",
30
  "vtv6": "https://sv2.xemtivitop.com/live/hot/vtv6.php",
31
- "vtv7": "https://sv.xemtivitop.com/live/vtv/vtv7.php",
32
  "vtv8": "https://sv2.xemtivitop.com/live/hot/vtv8.php",
33
  "vtv9": "https://sv2.xemtivitop.com/live/hot/vtv9.php",
34
  "vtv10": "https://sv2.xemtivitop.com/live/hot/vtv10.php",
35
  }
36
 
37
- # ===== FPT PLAY =====
38
- FPT_PLAY_IFRAMES = {
39
- "fpt-the-thao": "https://hd1.xemtv.net/kenh-fptplay-truc-tuyen-the-thao.html",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  }
41
 
42
- FPT_PLAY_STREAM_SELECTORS = {
43
- "fpt-the-thao": {
44
- "label": "FPT Play Thể Thao",
45
- "streams": [
46
- {"label":"📡 IPTV Primary","url":"https://live.fptplay53.net/epzhd1/htvcthethao_mhls.smil/master.m3u8","type":"m3u8"},
47
- {"label":"📡 IPTV Fallback","url":"https://live.fptplay53.net/epzhd1/htvcthethao_vhls.smil/chunklist.m3u8","type":"m3u8"},
48
- {"label":"🔗 hd1.xemtv.net","url":"https://hd1.xemtv.net/kenh-fptplay-truc-tuyen-the-thao.html","type":"iframe","allow":"autoplay; encrypted-media; fullscreen"},
49
- ],
50
- },
51
  }
52
 
53
- M3U_URLS = ["https://raw.githubusercontent.com/Love4vn/Test/refs/heads/main/IPTV.m3u", "https://raw.githubusercontent.com/Love4vn/love4vn/main/Out_Iptv_CXT.m3u", "https://raw.githubusercontent.com/konanda-sg/Test_Love4vn/main/IPTV.m3u", "https://raw.githubusercontent.com/iptv-org/iptv/master/streams/vn.m3u"]
54
- _channel_urls_lock = threading.Lock()
55
- _last_m3u_fetch = 0
56
- _M3U_TTL = 900
57
-
58
- def _fetch_m3u():
59
- global _last_m3u_fetch
60
- now = time.time()
61
- if now - _last_m3u_fetch < _M3U_TTL: return
62
- _last_m3u_fetch = now
63
- all_urls = {}
64
- for m3u_url in M3U_URLS:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  try:
66
- r = requests.get(m3u_url, headers=UA, timeout=10)
67
- if r.status_code != 200: continue
68
- text = r.text
69
- lines = text.strip().split("\n")
70
- current_channel = None
71
- for line in lines:
72
- s = line.strip()
73
- if s.startswith("#EXTINF"):
74
- m = re.search(r'tvg-id="?(\w+)"?', s)
75
- if m: current_channel = m.group(1).lower()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  else:
77
- m2 = re.search(r'(VTV\d+|vtv\d+)', s, re.IGNORECASE)
78
- if m2: current_channel = m2.group(1).lower()
79
- else: current_channel = None
80
- elif s.startswith("http") and current_channel:
81
- ch_id = current_channel
82
- ch_map = {"vtv1":"vtv1","vtv2":"vtv2","vtv3":"vtv3","vtv4":"vtv4","vtv5":"vtv5","vtv6":"vtv6","vtv7":"vtv7","vtv8":"vtv8","vtv9":"vtv9","vtv10":"vtv10","vtvcầnthơ":"vtv10","vtvcantho":"vtv10","vtvcan-tho":"vtv10"}
83
- if ch_id in ch_map: ch_id = ch_map[ch_id]
84
- if ch_id in CHANNEL_URLS and ch_id not in all_urls:
85
- if CHANNEL_URLS[ch_id] and HARDCODED_URLS.get(ch_id): current_channel = None; continue
86
- if ".flv" in s: all_urls[ch_id] = s
87
- elif ch_id not in all_urls or ".flv" not in all_urls.get(ch_id,""):
88
- if ch_id not in all_urls: all_urls[ch_id] = s
89
- current_channel = None
90
- if len(all_urls) >= 10: break
91
- except: pass
92
- with _channel_urls_lock:
93
- found = 0
94
- for ch_id in CHANNEL_URLS:
95
- if CHANNEL_URLS[ch_id] and HARDCODED_URLS.get(ch_id): continue
96
- if ch_id in all_urls: CHANNEL_URLS[ch_id] = all_urls[ch_id]; found += 1
97
- print(f"[M3U] Found {found}/10 channels (hardcoded: vtv6, vtv10)")
98
-
99
- def get_channel_url(channel_id):
100
- _fetch_m3u()
101
- with _channel_urls_lock: return CHANNEL_URLS.get(channel_id)
 
 
 
 
 
 
 
102
 
103
  @router.get("/api/vtv/streams")
104
  def api_vtv_streams():
105
- urls = dict(CHANNEL_URLS)
106
  result = {}
107
  for ch_id in CHANNEL_NAMES:
108
- stream_url = urls.get(ch_id)
109
- iframe_url = IFRAME_URLS.get(ch_id)
110
- is_flv = stream_url and ".flv" in stream_url
111
- result[ch_id] = {"name": CHANNEL_NAMES[ch_id], "stream_url": stream_url, "proxy_url": f"/api/proxy/flv?url={quote(stream_url, safe='')}" if stream_url and is_flv else "", "proxy_url_hls": f"/api/proxy/stream?url={quote(stream_url, safe='')}" if stream_url and not is_flv else "", "is_flv": is_flv, "iframe_url": iframe_url, "status": "ok"}
112
- # Add FPT Play channels
113
- for ch_id in FPT_PLAY_STREAM_SELECTORS:
114
- streams = FPT_PLAY_STREAM_SELECTORS[ch_id]["streams"]
115
- primary = streams[0]["url"] if streams else ""
116
- result[ch_id] = {"name": FPT_PLAY_STREAM_SELECTORS[ch_id]["label"], "stream_url": primary, "m3u8_proxy_url": f"/api/proxy/m3u8?url={quote(primary, safe='')}" if primary else "", "iframe_url": FPT_PLAY_IFRAMES.get(ch_id), "stream_selectors": FPT_PLAY_STREAM_SELECTORS[ch_id], "is_fpt_play": True, "status": "ok"}
117
  return JSONResponse(result)
118
 
119
  @router.get("/api/vtv/stream/{channel_id}")
120
  def api_vtv_stream(channel_id: str):
121
- channel_id = channel_id.lower().strip()
122
- # Check FPT Play FIRST
123
- if channel_id in FPT_PLAY_STREAM_SELECTORS:
124
- streams = FPT_PLAY_STREAM_SELECTORS[channel_id]["streams"]
125
- primary = streams[0]["url"] if streams else ""
126
- return JSONResponse({"name": FPT_PLAY_STREAM_SELECTORS[channel_id]["label"], "stream_url": primary, "m3u8_proxy_url": f"/api/proxy/m3u8?url={quote(primary, safe='')}" if primary else "", "iframe_url": FPT_PLAY_IFRAMES.get(channel_id), "stream_selectors": FPT_PLAY_STREAM_SELECTORS[channel_id], "is_fpt_play": True, "status": "ok"})
127
- if channel_id not in CHANNEL_NAMES: return JSONResponse({"error":"not found"}, status_code=404)
128
- stream_url = get_channel_url(channel_id)
129
- iframe_url = IFRAME_URLS.get(channel_id)
130
- is_flv = stream_url and ".flv" in stream_url
131
- m3u8_proxy = ""
132
- if stream_url and not is_flv and stream_url.startswith("http"): m3u8_proxy = f"/api/proxy/m3u8?url={quote(stream_url, safe='')}"
133
- return JSONResponse({"name": CHANNEL_NAMES[channel_id], "stream_url": stream_url, "m3u8_proxy_url": m3u8_proxy, "proxy_url": f"/api/proxy/flv?url={quote(stream_url, safe='')}" if stream_url and is_flv else "", "proxy_url_hls": f"/api/proxy/stream?url={quote(stream_url, safe='')}" if stream_url and not is_flv else "", "is_flv": is_flv, "iframe_url": iframe_url, "status": "ok"})
134
-
135
- @router.get("/api/vtv/m3u/refresh")
136
- def api_vtv_m3u_refresh():
137
- global _last_m3u_fetch
138
- _last_m3u_fetch = 0
139
- _fetch_m3u()
140
- urls = dict(CHANNEL_URLS)
141
- found = sum(1 for v in urls.values() if v)
142
- return JSONResponse({"status":"refreshed","channels_found":found,"channels":{k:v for k,v in urls.items() if v}})
143
-
144
- _epg_cache = {}; _epg_cache_time = 0; _EPG_CACHE_TTL = 600
145
- VTV_CHANNEL_MAP = {"vtv1":"vtv1","vtv2":"vtv2","vtv3":"vtv3","vtv4":"vtv4","vtv5":"vtv5","vtv5-tay-nam-bo":"vtv5","vtv5-tay-nguyen":"vtv5","vtv6":"vtv6","vtv7":"vtv7","vtv8":"vtv8","vtv9":"vtv9","vtv-can-tho":"vtv10"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
 
147
  def _parse_time(time_str, reference_date=None):
148
- if not time_str: return None
149
- time_str = time_str.strip().replace("h",":").replace("H",":")
 
150
  m = re.search(r'(\d{1,2}):(\d{2})', time_str)
151
  if m:
152
  try:
153
  hour, minute = int(m.group(1)), int(m.group(2))
154
- base = reference_date or datetime.now(VN_TZ)
155
- if hour < 5: base = base - timedelta(days=1) if base.hour >= 5 else base
156
- dt = base.replace(hour=hour, minute=minute, second=0, microsecond=0)
157
- if dt.tzinfo is None: dt = dt.replace(tzinfo=VN_TZ)
158
- return dt
159
- except: pass
 
 
 
 
 
160
  return None
161
 
162
  def _fetch_epg_from_vtv():
163
  global _epg_cache, _epg_cache_time
164
  now_ts = time.time()
165
- if _epg_cache and now_ts - _epg_cache_time < _EPG_CACHE_TTL: return _epg_cache
 
 
166
  epg_data = {}
167
  now_vn = datetime.now(VN_TZ)
 
168
  try:
169
- h = {"User-Agent": UA["User-Agent"], "Accept-Language": "vi-VN,vi;q=0.9", "Referer": "https://vtv.vn/"}
170
- r = requests.get("https://vtv.vn/lich-phat-song.htm", headers=h, timeout=20)
171
- if r.status_code != 200: return epg_data
 
 
 
 
 
 
172
  r.encoding = "utf-8"
173
  soup = BeautifulSoup(r.text, "lxml")
 
174
  channel_order = []
175
- for link in soup.find_all('a', href=re.compile(r'truyen-hinh-truc-tuyen/([^.]+)\.htm')):
176
- ch_id = re.search(r'truyen-hinh-truc-tuyen/([^.]+)\.htm', link.get('href',''))
177
- if ch_id and ch_id.group(1) not in channel_order: channel_order.append(ch_id.group(1))
178
- containers = soup.find_all('ul', class_=re.compile(r'\bprograms\b'))
179
- for i, container in enumerate(containers):
180
- if i >= len(channel_order): break
181
- vtv_id = channel_order[i]
182
- our_id = VTV_CHANNEL_MAP.get(vtv_id, vtv_id)
183
- if our_id not in epg_data: epg_data[our_id] = []
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  for li in container.find_all('li', class_=re.compile(r'\bprogram\b')):
185
- t = li.find('span', class_=re.compile(r'\btime\b'))
186
- title_el = li.find('span', class_=re.compile(r'\btitle\b'))
187
- genre = li.find('a', class_=re.compile(r'\bgenre\b'))
188
- time_str = t.get_text(strip=True) if t else ""
189
- title = genre.get_text(strip=True) if genre else ""
190
- if not title and title_el: title = title_el.get_text(strip=True)
191
- if not time_str or not title: continue
 
 
 
 
192
  start_dt = _parse_time(time_str, reference_date=now_vn)
193
- if not start_dt: continue
194
- epg_data[our_id].append({"time":time_str[:5],"title":title[:80],"start_dt":start_dt,"date":start_dt.strftime("%d/%m/%Y")})
195
- for ch_id in epg_data: epg_data[ch_id].sort(key=lambda x: x.get("start_dt") or datetime.min)
196
- except Exception as e: print(f"EPG error: {e}")
197
- _epg_cache = epg_data; _epg_cache_time = now_ts
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  return epg_data
199
 
200
- @router.get("/api/vtv/epg/{channel_id}")
201
- def api_vtv_epg(channel_id: str):
202
- channel_id = channel_id.lower().strip()
203
- if channel_id not in CHANNEL_NAMES: return JSONResponse({"error":"channel not found"}, status_code=404)
204
  epg_data = _fetch_epg_from_vtv()
205
  programmes = epg_data.get(channel_id, [])
 
 
206
  now = datetime.now(VN_TZ)
207
  today = now.date()
208
  result = []
209
- today_progs = [p for p in programmes if p.get("start_dt") and p["start_dt"].date() == today] or programmes
210
- for i, p in enumerate(today_progs):
 
 
211
  start_dt = p.get("start_dt")
212
- stop_dt = today_progs[i+1].get("start_dt") if i+1 < len(today_progs) else None
213
  is_now = bool(start_dt and ((stop_dt and start_dt <= now < stop_dt) or start_dt <= now))
214
  end_time = stop_dt.strftime("%H:%M") if stop_dt else ""
215
- result.append({"time":p["time"],"title":p["title"],"end_time":end_time,"now":is_now,"date":p.get("date","")})
216
- return JSONResponse({"channel":channel_id,"channel_name":CHANNEL_NAMES.get(channel_id,channel_id),"date":now.strftime("%Y-%m-%d"),"programs":result})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
 
218
  @router.get("/api/vtv/epg")
219
  def api_vtv_epg_refresh():
220
  global _epg_cache, _epg_cache_time
221
- _epg_cache = {}; _epg_cache_time = 0
 
222
  epg_data = _fetch_epg_from_vtv()
223
- return JSONResponse({"status":"refreshed","channels":len(epg_data),"total":sum(len(v) for v in epg_data.values())})
 
 
 
 
 
1
+ """
2
+ VTV Channels API - Backend endpoints for VTV1-VTV10 + VTVPrime
3
+ Fetches stream URLs from multiple CDN sources with aggressive extraction and fast failover.
4
+
5
+ FIX 2026-07-06: Force sv2.xemtivitop.com as PRIMARY source for VTV2, VTV3, VTV6, VTV9.
6
+ VTV6 MUST use ONLY sv2.xemtivitop.com/live/hot/vtv6.php - no other sources.
7
+ Added dedicated extraction for sv2 PHP endpoints with JS parse, redirect chain capture.
8
+ FIX v12: Added extract_base64_m3u8(), POST fallback, JS unescape patterns, query param probing, LONG_TIMEOUT=25
9
+ """
10
+ import re, time, threading, json, base64
11
+ import requests
12
  from fastapi import APIRouter, Query
13
+ from fastapi.responses import JSONResponse, Response
14
  from bs4 import BeautifulSoup
15
  from datetime import datetime, timedelta, timezone
 
16
 
17
  VN_TZ = timezone(timedelta(hours=7))
18
+
19
  router = APIRouter()
20
 
21
  UA = {
22
  "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",
23
  "Accept-Language": "vi-VN,vi;q=0.9",
24
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
25
+ "Sec-Fetch-Dest": "document",
26
+ "Sec-Fetch-Mode": "navigate",
27
+ "Sec-Fetch-Site": "none",
28
+ "Upgrade-Insecure-Requests": "1",
29
+ }
30
+
31
+ # ===== VTV Digital CDN (ssaimh) =====
32
+ VTV_DIGITAL_SSAIMH_URLS = {
33
+ "vtv1": "https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv1/master.m3u8",
34
+ "vtv2": "https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv2/master.m3u8",
35
+ "vtv3": "https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv3/master.m3u8",
36
+ "vtv4": "https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv4/master.m3u8",
37
+ "vtv5": "https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv5/master.m3u8",
38
+ "vtv7": "https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv7/master.m3u8",
39
+ "vtv8": "https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv8/master.m3u8",
40
+ "vtv9": "https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv9/master.m3u8",
41
+ "vtv10": "https://vtvgolive-ssaimh.vtvdigital.vn/o-QNezId5ssiM7KaV4MfXQ/1781372128/manifest/vtv6/master.m3u8",
42
  }
43
 
44
+ # ===== xemtv.us PHP endpoints =====
45
+ XEMTV_US_ENDPOINTS = {
46
+ "vtv1": "https://xemtv.us/tv/vtv1.php",
47
+ "vtv2": "https://xemtv.us/tv/vtv2.php",
48
+ "vtv3": "https://xemtv.us/tv/vtv3.php",
49
+ "vtv4": "https://xemtv.us/tv/vtv4.php",
50
+ "vtv5": "https://xemtv.us/tv/vtv5.php",
51
+ "vtv6": "https://xemtv.us/tv/vtv6.php",
52
+ "vtv7": "https://xemtv.us/tv/vtv7.php",
53
+ "vtv8": "https://xemtv.us/tv/vtv8.php",
54
+ "vtv9": "https://xemtv.us/tv/vtv9.php",
55
+ "vtv10": "https://xemtv.us/tv/vtv10.php",
56
+ "vtvprime": "https://xemtv.us/tv/vtvprime.php",
57
  }
 
 
58
 
59
+ # ===== sv2.xemtivitop.com PHP endpoints =====
60
+ SV2_XEMTIVITOP_ENDPOINTS = {
61
  "vtv1": "https://sv2.xemtivitop.com/live/hot/vtv1.php",
62
  "vtv2": "https://sv2.xemtivitop.com/live/hot/vtv2.php",
63
  "vtv3": "https://sv2.xemtivitop.com/live/hot/vtv3.php",
64
  "vtv4": "https://sv2.xemtivitop.com/live/hot/vtv4.php",
65
  "vtv5": "https://sv2.xemtivitop.com/live/hot/vtv5.php",
66
  "vtv6": "https://sv2.xemtivitop.com/live/hot/vtv6.php",
67
+ "vtv7": "https://sv2.xemtivitop.com/live/hot/vtv7.php",
68
  "vtv8": "https://sv2.xemtivitop.com/live/hot/vtv8.php",
69
  "vtv9": "https://sv2.xemtivitop.com/live/hot/vtv9.php",
70
  "vtv10": "https://sv2.xemtivitop.com/live/hot/vtv10.php",
71
  }
72
 
73
+ # ===== xemtivitop.com blogspot pages =====
74
+ XEMTIVITOP_ENDPOINTS = {
75
+ "vtv1": "https://www.xemtivitop.com/2018/10/vtv1-online.html?m=1",
76
+ "vtv2": "https://www.xemtivitop.com/2018/10/vtv2-online.html?m=1",
77
+ "vtv3": "https://www.xemtivitop.com/2018/10/vtv3-online.html?m=1",
78
+ "vtv4": "https://www.xemtivitop.com/2018/10/vtv4-online.html?m=1",
79
+ "vtv5": "https://www.xemtivitop.com/2018/10/vtv5-online.html?m=1",
80
+ "vtv6": "https://www.xemtivitop.com/2018/10/vtv6-online.html?m=1",
81
+ "vtv7": "https://www.xemtivitop.com/2018/10/vtv7-online.html?m=1",
82
+ "vtv8": "https://www.xemtivitop.com/2018/10/vtv8-online.html?m=1",
83
+ "vtv9": "https://www.xemtivitop.com/2018/10/vtv9-online.html?m=1",
84
+ "vtv10": "https://www.xemtivitop.com/2020/05/vtv10-can-tho-online.html?m=1",
85
+ }
86
+
87
+ # ===== xemtv.net legacy =====
88
+ XEMTV_LEGACY_ENDPOINTS = {
89
+ "vtv1": "https://hd.xemtv.net/kenh/vtv1.php",
90
+ "vtv2": "https://hd.xemtv.net/kenh/vtv2.php",
91
+ "vtv3": "https://hd.xemtv.net/kenh/vtv3.php",
92
+ "vtv4": "https://hd.xemtv.net/kenh/vtv4.php",
93
+ "vtv5": "https://hd.xemtv.net/kenh/vtv5.php",
94
+ "vtv6": "https://hd.xemtv.net/kenh/vtv6.php",
95
+ "vtv7": "https://hd.xemtv.net/kenh/vtv7.php",
96
+ "vtv8": "https://hd.xemtv.net/kenh/vtv8.php",
97
+ "vtv9": "https://hd.xemtv.net/kenh/vtv9.php",
98
+ "vtv10": "https://hd.xemtv.net/kenh/vtv10.php",
99
+ "vtvprime": "https://hd.xemtv.net/kenh/vtvprime.php",
100
+ }
101
+
102
+ CHANNEL_NAMES = {
103
+ "vtv1": "VTV1", "vtv2": "VTV2", "vtv3": "VTV3", "vtv4": "VTV4",
104
+ "vtv5": "VTV5", "vtv6": "VTV6", "vtv7": "VTV7", "vtv8": "VTV8",
105
+ "vtv9": "VTV9", "vtv10": "VTV10", "vtvprime": "VTVPrime",
106
+ }
107
+
108
+ # ===== FPTPlay CDN =====
109
+ FPTPLAY_URLS = {
110
+ "vtv1": "https://live-a.fptplay53.net/live/media/vtv1/live247-hls-avc/index.m3u8",
111
+ "vtv2": "https://live-a.fptplay53.net/live/media/vtv2/live247-hls-avc/index.m3u8",
112
+ "vtv3": "https://live-a.fptplay53.net/live/media/vtv3/live247-hls-avc/index.m3u8",
113
+ "vtv4": "https://live-a.fptplay53.net/live/media/vtv4/live247-hls-avc/index.m3u8",
114
+ "vtv5": "https://live-a.fptplay53.net/live/media/vtv5/live247-hls-avc/index.m3u8",
115
+ "vtv6": "https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/index.m3u8",
116
+ "vtv7": "https://live-a.fptplay53.net/live/media/vtv7/live247-hls-avc/index.m3u8",
117
+ "vtv8": "https://live-a.fptplay53.net/live/media/vtv8/live-hls-avc/index.m3u8",
118
+ "vtv9": "https://live-a.fptplay53.net/live/media/vtv9/live247-hls-avc/index.m3u8",
119
+ "vtv10": "https://live-a.fptplay53.net/live/media/vtv10/live247-hls-avc/index.m3u8",
120
+ }
121
+
122
+ # ===== VTVGo CDN =====
123
+ VTVGO_FAILOVER = {
124
+ "vtv1": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8",
125
+ "vtv2": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv2-manifest.m3u8",
126
+ "vtv3": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv3-manifest.m3u8",
127
+ "vtv4": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv4-manifest.m3u8",
128
+ "vtv5": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv5-manifest.m3u8",
129
+ "vtv6": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv6-manifest.m3u8",
130
+ "vtv7": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv7-manifest.m3u8",
131
+ "vtv8": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv8-manifest.m3u8",
132
+ "vtv9": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv9-manifest.m3u8",
133
  }
134
 
135
+ # ===== Backup CDN sources =====
136
+ BACKUP_CDN_URLS = {
137
+ "vtv2": "http://tv.mediacdn.vn/live/hls/vtv2.m3u8",
138
+ "vtv3": "http://tv.mediacdn.vn/live/hls/vtv3.m3u8",
139
+ "vtv6": "http://tv.mediacdn.vn/live/hls/vtv6.m3u8",
140
+ "vtv9": "http://tv.mediacdn.vn/live/hls/vtv9.m3u8",
 
 
 
141
  }
142
 
143
+ # Channels that have KNOWN working ssaimh CDN
144
+ SSAIMH_WORKING_CHANNELS = {"vtv1", "vtv4", "vtv5", "vtv7", "vtv8", "vtv10"}
145
+
146
+ # Channels that MUST use sv2.xemtivitop.com as ONLY source (forced)
147
+ SV2_ONLY_CHANNELS = {"vtv6"} # VTV6 exclusively from sv2
148
+
149
+ # Channels that prioritize sv2.xemtivitop.com first
150
+ SV2_PRIORITY_CHANNELS = {"vtv2", "vtv3", "vtv9"}
151
+
152
+ _vtv_cache = {}
153
+ _vtv_lock = threading.Lock()
154
+ _CACHE_TTL = 180 # 3 minutes
155
+
156
+ FAST_TIMEOUT = 5
157
+ NORMAL_TIMEOUT = 12
158
+ LONG_TIMEOUT = 25
159
+
160
+ def _cached(key):
161
+ with _vtv_lock:
162
+ if key in _vtv_cache and time.time() - _vtv_cache[key]['t'] < _CACHE_TTL:
163
+ return _vtv_cache[key]['d']
164
+ return None
165
+
166
+ def _set_cache(key, data):
167
+ with _vtv_lock:
168
+ _vtv_cache[key] = {'t': time.time(), 'd': data}
169
+
170
+ def extract_base64_m3u8(html):
171
+ """Extract m3u8 URL from base64-encoded strings in JS like atob('...') or btoa('...')"""
172
+ if not html:
173
+ return None
174
+ # Pattern: atob("base64string") - decode it
175
+ for m in re.finditer(r'(?:atob|btoa)\s*\(\s*["\']([A-Za-z0-9+/=]+)["\']\s*\)', html):
176
+ try:
177
+ decoded = base64.b64decode(m.group(1)).decode('utf-8', errors='ignore')
178
+ if '.m3u8' in decoded and len(decoded) > 20:
179
+ # Extract URL from decoded
180
+ url_m = re.search(r'(https?://[^\s"\'<>]+\.m3u8[^\s"\'<>]*)', decoded)
181
+ if url_m:
182
+ return url_m.group(1)
183
+ except:
184
+ pass
185
+ # Pattern: decodeURIComponent / unescape
186
+ for m in re.finditer(r'(?:decodeURIComponent|unescape)\s*\(\s*["\']([^"\']+)["\']\s*\)', html):
187
  try:
188
+ from urllib.parse import unquote
189
+ decoded = unquote(m.group(1))
190
+ if '.m3u8' in decoded:
191
+ url_m = re.search(r'(https?://[^\s"\'<>]+\.m3u8[^\s"\'<>]*)', decoded)
192
+ if url_m:
193
+ return url_m.group(1)
194
+ except:
195
+ pass
196
+ return None
197
+
198
+ def extract_m3u8_aggressive(html):
199
+ """Aggressively extract m3u8 URL from HTML using ALL possible patterns."""
200
+ if not html:
201
+ return None
202
+
203
+ # Strip HTML comments
204
+ html = re.sub(r'<!--.*?-->', '', html, flags=re.DOTALL)
205
+
206
+ patterns = [
207
+ # Pattern 1: file: '...m3u8'
208
+ r"file\s*:\s*['\"]([^'\"]*\.m3u8[^'\"]*)['\"]",
209
+ # Pattern 2: file = "..."
210
+ r"file\s*=\s*['\"]([^'\"]*\.m3u8[^'\"]*)['\"]",
211
+ # Pattern 3: src="...m3u8"
212
+ r'src\s*=\s*["\']([^"\']*\.m3u8[^"\']*)["\']',
213
+ # Pattern 4: data-src="...m3u8"
214
+ r'data-src\s*=\s*["\']([^"\']*\.m3u8[^"\']*)["\']',
215
+ # Pattern 5: player.src
216
+ r'player\.(?:src|source)\s*\(\s*["\']([^"\']*\.m3u8[^"\']*)["\']',
217
+ # Pattern 6: hls.loadSource
218
+ r'(?:hls|video)\.(?:loadSource|src)\s*\(\s*["\']([^"\']*\.m3u8[^"\']*)["\']',
219
+ # Pattern 7: source/sourceConfig
220
+ r"""['"]source['"]\s*:\s*['"]([^'"]*\.m3u8[^'"]*)['"]""",
221
+ # Pattern 8: url:
222
+ r"""['"]url['"]\s*:\s*['"]([^'"]*\.m3u8[^'"]*)['"]""",
223
+ # Pattern 9: bare m3u8
224
+ r"(https?://[^\s\"\"'<>\\]+\.m3u8[^\s\"\"'<>\\]*)",
225
+ # Pattern 10: <source src
226
+ r'<source\s+[^>]*src\s*=\s*["\']([^"\']*\.m3u8[^"\']*)["\']',
227
+ # Pattern 11: href
228
+ r'href\s*=\s*["\']([^"\']*\.m3u8[^"\']*)["\']',
229
+ # Pattern 12: window.location
230
+ r'(?:window\.)?location\s*[:=]\s*["\']([^"\']*\.m3u8[^"\']*)["\']',
231
+ # Pattern 13: var source = / streamUrl = / stream_url =
232
+ r'(?:source|streamUrl|stream_url|videoUrl|video_url|hls_url|hlsUrl)\s*[:=]\s*["\']([^"\']*\.m3u8[^"\']*)["\']',
233
+ # Pattern 14: m3u8 URL without http prefix but with leading /
234
+ r"['\"](/[^'\"]+\.m3u8[^'\"]*)['\"]",
235
+ # Pattern 15: PHP redirect URL containing m3u8 parameter
236
+ r"(?:redirect|go|link|url)\s*[:=]\s*['\"]([^'\"]*m3u8[^'\"]*)['\"]",
237
+ # Pattern 16: decoded m3u8 in URL parameters
238
+ r"['\"]([^'\"]*%2F[^'\"]*\.m3u8[^'\"]*)['\"]",
239
+ # Pattern 17: base64 encoded m3u8 in data attribute
240
+ r"data-[a-z]+\s*=\s*['\"](https?://[^'\"]+)['\"]",
241
+ # Pattern 18: direct m3u8 URL with CDN domain
242
+ r"(https?://[a-zA-Z0-9.-]+\.(?:cdn|cloudfront|akamaized|vtvgo|fptplay|mediacdn|ssaimh)[^\"'<>\\s]*(?:\.m3u8)[^\"'<>\\s]*)",
243
+ # Pattern 19: JS obfuscated var (_0x...) concatenation - try catching hex vars
244
+ r"""['"]source['"]\s*[+:=]+\s*['"]([^'"]*)['"]""",
245
+ # Pattern 20: document.write with m3u8
246
+ r"""document\.write\s*\(\s*['"]([^'"]*\.m3u8[^'"]*)['"]""",
247
+ # Pattern 21: JSON.parse with m3u8
248
+ r"""JSON\.parse\s*\(\s*['"]([^'"]+)['"]\s*\)""",
249
+ # Pattern 22: eval with m3u8
250
+ r"""eval\s*\(\s*['"]([^'"]*\.m3u8[^'"]*)['"]""",
251
+ # Pattern 23: PHP echo/print with m3u8
252
+ r"""(?:echo|print|printf)\s*['"]([^'"]*\.m3u8[^'"]*)['"]""",
253
+ # Pattern 24: $_GET / $_POST / $_REQUEST with stream param
254
+ r"""['"]([^'"]*stream[^'"]*m3u8[^'"]*)['"]""",
255
+ # Pattern 25: location.href / self.location with m3u8
256
+ r"""(?:self\.)?location(?:\.href)?\s*=\s*['"]([^'"]*\.m3u8[^'"]*)['"]""",
257
+ # Pattern 26: embed src / object data with m3u8
258
+ r"""<(?:embed|object)\s+[^>]*?(?:src|data)\s*=\s*['"]([^'"]*\.m3u8[^'"]*)['"]""",
259
+ ]
260
+
261
+ for pat in patterns:
262
+ m = re.search(pat, html, re.IGNORECASE)
263
+ if m:
264
+ url = m.group(1).strip()
265
+ # Clean up trailing junk
266
+ url = re.sub(r'[,\s\"\'<>]+$', '', url)
267
+ if len(url) > 20 and (url.startswith('http') or url.startswith('/')):
268
+ return url
269
+
270
+ return None
271
+
272
+
273
+ def fetch_sv2_stream_direct(sv2_php_url, referer="https://sv2.xemtivitop.com/"):
274
+ """
275
+ Dedicated fetcher for sv2.xemtivitop.com PHP endpoints.
276
+ Uses browser-like session with cookies, follows all redirects aggressively.
277
+ Tries POST fallback, base64 decode, query param probing.
278
+ """
279
+ session = requests.Session()
280
+ session.headers.update({
281
+ "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",
282
+ "Accept-Language": "vi-VN,vi;q=0.9",
283
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
284
+ "Sec-Fetch-Dest": "document",
285
+ "Sec-Fetch-Mode": "navigate",
286
+ "Sec-Fetch-Site": "none",
287
+ "Upgrade-Insecure-Requests": "1",
288
+ "Referer": referer,
289
+ "Origin": referer.rstrip("/"),
290
+ })
291
+
292
+ try:
293
+ # Step 1: fetch the PHP page with full redirect following
294
+ r = session.get(sv2_php_url, timeout=LONG_TIMEOUT, allow_redirects=True, verify=False)
295
+
296
+ # Check redirect chain for m3u8 URL
297
+ if r.history:
298
+ for resp in r.history:
299
+ loc = resp.headers.get('Location', '')
300
+ if '.m3u8' in loc and len(loc) > 20:
301
+ if not loc.startswith('http'):
302
+ from urllib.parse import urljoin
303
+ loc = urljoin(sv2_php_url, loc)
304
+ return loc, "redirect_chain"
305
+ # Also check final URL
306
+ if '.m3u8' in r.url and len(r.url) > 20:
307
+ return r.url, "final_redirect_m3u8"
308
+
309
+ if r.status_code != 200:
310
+ return None, f"http_{r.status_code}"
311
+
312
+ html = r.text
313
+ if not html or len(html) < 50:
314
+ return None, "empty_response"
315
+
316
+ # Try base64 extraction FIRST (sv2 PHP often uses base64 obfuscation)
317
+ b64_url = extract_base64_m3u8(html)
318
+ if b64_url:
319
+ return b64_url, "base64_decoded"
320
+
321
+ # Try aggressive extraction on PHP response
322
+ m3u8 = extract_m3u8_aggressive(html)
323
+ if m3u8:
324
+ return m3u8, "extracted_from_php"
325
+
326
+ # Step 2: Parse for iframe and follow
327
+ soup = BeautifulSoup(html, 'html.parser')
328
+
329
+ # Find ALL iframes (not just first)
330
+ for iframe in soup.find_all('iframe', src=True):
331
+ src = iframe.get('src', '').strip()
332
+ if not src:
333
+ continue
334
+ if not src.startswith('http'):
335
+ from urllib.parse import urljoin
336
+ src = urljoin(sv2_php_url, src)
337
+
338
+ try:
339
+ r2 = session.get(src, timeout=NORMAL_TIMEOUT, allow_redirects=True, verify=False)
340
+ if r2.status_code == 200:
341
+ # Try base64 on iframe content too
342
+ b64_url2 = extract_base64_m3u8(r2.text)
343
+ if b64_url2:
344
+ return b64_url2, "iframe_base64"
345
+ m3u8 = extract_m3u8_aggressive(r2.text)
346
+ if m3u8:
347
+ return m3u8, "iframe_extracted"
348
+ except:
349
+ continue
350
+
351
+ # Step 3: Try POST fallback with various payloads
352
+ post_payloads = [
353
+ {"play": "1", "stream": "1"},
354
+ {"play": "1"},
355
+ {"stream": "1"},
356
+ {"link": "1"},
357
+ {"go": "1"},
358
+ {"token": "1"},
359
+ {"live": "1"},
360
+ {"auth": "1"},
361
+ ]
362
+ for payload in post_payloads:
363
+ try:
364
+ r_post = session.post(sv2_php_url, data=payload, timeout=NORMAL_TIMEOUT, allow_redirects=True, verify=False)
365
+ if r_post.status_code == 200 and len(r_post.text) > 50:
366
+ html_post = r_post.text
367
+ b64_url2 = extract_base64_m3u8(html_post)
368
+ if b64_url2:
369
+ return b64_url2, f"post_{list(payload.keys())[0]}_base64"
370
+ m3u8 = extract_m3u8_aggressive(html_post)
371
+ if m3u8:
372
+ return m3u8, f"post_{list(payload.keys())[0]}"
373
+ except:
374
+ continue
375
+
376
+ # Step 4: Try query param probing
377
+ query_params = ["?stream=1", "?play=1", "?link=1", "?id=1", "?channel=vtv6", "?go=1", "?live=1"]
378
+ for qp in query_params:
379
+ try:
380
+ r_q = session.get(sv2_php_url + qp, timeout=NORMAL_TIMEOUT, allow_redirects=True, verify=False)
381
+ if r_q.status_code == 200 and len(r_q.text) > 50:
382
+ b64_url2 = extract_base64_m3u8(r_q.text)
383
+ if b64_url2:
384
+ return b64_url2, f"query_{qp[1:]}_base64"
385
+ m3u8 = extract_m3u8_aggressive(r_q.text)
386
+ if m3u8:
387
+ return m3u8, f"query_{qp[1:]}"
388
+ except:
389
+ continue
390
+
391
+ # Step 5: Try fetch with different referer (some PHP pages check for specific referer)
392
+ alt_referers = [
393
+ "https://sv2.xemtivitop.com/",
394
+ "https://www.xemtivitop.com/",
395
+ "https://xemtivitop.com/",
396
+ "https://xemtv.us/",
397
+ ]
398
+ for alt_ref in alt_referers:
399
+ try:
400
+ alt_headers = dict(session.headers)
401
+ alt_headers["Referer"] = alt_ref
402
+ r3 = requests.get(sv2_php_url, headers=alt_headers, timeout=NORMAL_TIMEOUT, allow_redirects=True, verify=False)
403
+ if r3.status_code == 200:
404
+ b64_url2 = extract_base64_m3u8(r3.text)
405
+ if b64_url2:
406
+ return b64_url2, f"alt_ref_{alt_ref.split('/')[2]}_base64"
407
+ m3u8 = extract_m3u8_aggressive(r3.text)
408
+ if m3u8:
409
+ return m3u8, f"alt_referer_{alt_ref}"
410
+ except:
411
+ continue
412
+
413
+ return None, "no_m3u8_found"
414
+
415
+ except Exception as e:
416
+ return None, f"error:{str(e)[:80]}"
417
+ finally:
418
+ session.close()
419
+
420
+
421
+ def verify_hls_url(url, referer="https://vtvgo.vn/", timeout=FAST_TIMEOUT):
422
+ """Quickly verify a URL returns valid HLS content."""
423
+ if not url:
424
+ return None
425
+ try:
426
+ headers = {
427
+ "User-Agent": UA["User-Agent"],
428
+ "Referer": referer,
429
+ "Origin": referer.rstrip("/"),
430
+ "Accept": "*/*",
431
+ }
432
+ r = requests.get(url, headers=headers, timeout=timeout, allow_redirects=True, verify=False)
433
+ if r.status_code == 200 and '#EXTM3U' in r.text[:500]:
434
+ return url
435
+ except:
436
+ pass
437
+ return None
438
+
439
+
440
+ def fetch_url_with_follow(php_url, referer, timeout=NORMAL_TIMEOUT):
441
+ """Fetch a PHP page, follow redirects, parse html deeply."""
442
+ try:
443
+ headers = {**UA, "Referer": referer}
444
+ r = requests.get(php_url, headers=headers, timeout=timeout, allow_redirects=True, verify=False)
445
+ if r.status_code == 200:
446
+ if r.history:
447
+ for resp in reversed(r.history):
448
+ loc = resp.headers.get('Location', '')
449
+ if '.m3u8' in loc and len(loc) > 20:
450
+ if not loc.startswith('http'):
451
+ from urllib.parse import urljoin
452
+ loc = urljoin(php_url, loc)
453
+ return loc, "redirect"
454
+ # Try base64 extraction too
455
+ b64_url = extract_base64_m3u8(r.text)
456
+ if b64_url:
457
+ return b64_url, "base64_extracted"
458
+ m3u8 = extract_m3u8_aggressive(r.text)
459
+ if m3u8:
460
+ return m3u8, "extracted"
461
+ soup = BeautifulSoup(r.text, 'html.parser')
462
+ seen_iframes = set()
463
+ for _ in range(3):
464
+ iframe = soup.find('iframe', src=True)
465
+ if not iframe:
466
+ break
467
+ src = iframe.get('src', '').strip()
468
+ if not src or src in seen_iframes:
469
+ break
470
+ seen_iframes.add(src)
471
+ if not src.startswith('http'):
472
+ from urllib.parse import urljoin
473
+ src = urljoin(php_url, src)
474
+ try:
475
+ headers2 = {**UA, "Referer": referer}
476
+ r2 = requests.get(src, headers=headers2, timeout=NORMAL_TIMEOUT, allow_redirects=True, verify=False)
477
+ if r2.status_code == 200:
478
+ b64_url2 = extract_base64_m3u8(r2.text)
479
+ if b64_url2:
480
+ return b64_url2, "iframe_base64"
481
+ m3u8 = extract_m3u8_aggressive(r2.text)
482
+ if m3u8:
483
+ return m3u8, "iframe_follow"
484
+ soup = BeautifulSoup(r2.text, 'html.parser')
485
+ except:
486
+ break
487
+ return None, "not_found"
488
+ except Exception as e:
489
+ return None, f"error:{str(e)[:50]}"
490
+
491
+
492
+ def fetch_vtv_stream(channel_id):
493
+ channel_id = channel_id.lower().strip()
494
+ name_map = {
495
+ 'vtvct': 'vtv10', 'vtv-can-tho': 'vtv10', 'vtv can tho': 'vtv10',
496
+ 'vtv_can_tho': 'vtv10', 'cantho': 'vtv10',
497
+ 'vietnam_vtv1': 'vtv1', 'vietnam_vtv2': 'vtv2', 'vietnam_vtv3': 'vtv3',
498
+ 'vietnam_vtv4': 'vtv4', 'vietnam_vtv5': 'vtv5', 'vietnam_vtv6': 'vtv6',
499
+ 'vietnam_vtv7': 'vtv7', 'vietnam_vtv8': 'vtv8', 'vietnam_vtv9': 'vtv9',
500
+ }
501
+ channel_id = name_map.get(channel_id, channel_id)
502
+
503
+ # Check cache
504
+ cached = _cached(channel_id)
505
+ if cached is not None:
506
+ return cached
507
+
508
+ result = None
509
+
510
+ # ===== SV2-ONLY CHANNELS (VTV6) - ONLY from sv2.xemtivitop.com =====
511
+ if channel_id in SV2_ONLY_CHANNELS:
512
+ sv2_url = SV2_XEMTIVITOP_ENDPOINTS.get(channel_id)
513
+ if sv2_url:
514
+ url, method = fetch_sv2_stream_direct(sv2_url, "https://sv2.xemtivitop.com/")
515
+ if url:
516
+ # Try verify - if fails, still return the URL (might be non-standard HLS)
517
+ verified = verify_hls_url(url, "https://sv2.xemtivitop.com/", timeout=NORMAL_TIMEOUT) if '.m3u8' in url else None
518
+ if verified:
519
+ result = verified
520
+ elif '.m3u8' in url and len(url) > 20:
521
+ result = url # use unverified if it looks like an m3u8
522
+ elif url and method == "redirect_chain":
523
+ result = url # redirect chain results are usually correct
524
+ _set_cache(channel_id, result)
525
+ return result
526
+
527
+ # ===== SV2-PRIORITY CHANNELS (VTV2, VTV3, VTV9) =====
528
+ if channel_id in SV2_PRIORITY_CHANNELS:
529
+ sv2_url = SV2_XEMTIVITOP_ENDPOINTS.get(channel_id)
530
+ if sv2_url:
531
+ url, method = fetch_sv2_stream_direct(sv2_url, "https://sv2.xemtivitop.com/")
532
+ if url:
533
+ verified = verify_hls_url(url, "https://sv2.xemtivitop.com/", timeout=NORMAL_TIMEOUT) if '.m3u8' in url else None
534
+ if verified:
535
+ result = verified
536
+ elif '.m3u8' in url and len(url) > 20:
537
+ result = url
538
+ elif url and method == "redirect_chain":
539
+ result = url
540
+
541
+ # ===== ALL OTHER CHANNELS: standard priority chain =====
542
+ if not result:
543
+ # Priority 1: VTV Digital CDN (ssaimh)
544
+ if channel_id in VTV_DIGITAL_SSAIMH_URLS and channel_id in SSAIMH_WORKING_CHANNELS:
545
+ result = verify_hls_url(VTV_DIGITAL_SSAIMH_URLS[channel_id], "https://vtvgo.vn/", timeout=FAST_TIMEOUT)
546
+
547
+ # Priority 2: vtvprime
548
+ if not result and channel_id == 'vtvprime':
549
+ for src_name, src_dict, src_ref in [
550
+ ("xemtv.us", XEMTV_US_ENDPOINTS, "https://xemtv.us/"),
551
+ ("sv2.xemtivitop.com", SV2_XEMTIVITOP_ENDPOINTS, "https://sv2.xemtivitop.com/"),
552
+ ("xemtv.net", XEMTV_LEGACY_ENDPOINTS, "https://hd.xemtv.net/"),
553
+ ]:
554
+ if channel_id in src_dict:
555
+ url, _ = fetch_url_with_follow(src_dict[channel_id], src_ref)
556
+ if url:
557
+ result = verify_hls_url(url, timeout=NORMAL_TIMEOUT) if '.m3u8' in url else url
558
+ if not result and url:
559
+ result = url
560
+ if result:
561
+ break
562
+
563
+ # Priority 3: sv2.xemtivitop.com (for non-SV2-priority channels)
564
+ if not result and channel_id in SV2_XEMTIVITOP_ENDPOINTS:
565
+ php_url = SV2_XEMTIVITOP_ENDPOINTS[channel_id]
566
+ url, method = fetch_url_with_follow(php_url, "https://sv2.xemtivitop.com/")
567
+ if url:
568
+ result = verify_hls_url(url, "https://sv2.xemtivitop.com/", timeout=NORMAL_TIMEOUT) if '.m3u8' in url else url
569
+ if not result and url:
570
+ result = url
571
+
572
+ # Priority 4: xemtv.us (for non-vtvprime)
573
+ if not result and channel_id in XEMTV_US_ENDPOINTS and channel_id != 'vtvprime':
574
+ php_url = XEMTV_US_ENDPOINTS[channel_id]
575
+ url, method = fetch_url_with_follow(php_url, "https://xemtv.us/")
576
+ if url:
577
+ result = verify_hls_url(url, "https://xemtv.us/", timeout=NORMAL_TIMEOUT) if '.m3u8' in url else url
578
+ if not result and url:
579
+ result = url
580
+
581
+ # Priority 5: xemtivitop.com blogspot
582
+ if not result and channel_id in XEMTIVITOP_ENDPOINTS:
583
+ try:
584
+ page_url = XEMTIVITOP_ENDPOINTS[channel_id]
585
+ headers = {**UA, "Referer": "https://www.xemtivitop.com/"}
586
+ r = requests.get(page_url, headers=headers, timeout=NORMAL_TIMEOUT, allow_redirects=True, verify=False)
587
+ if r.status_code == 200:
588
+ b64_url = extract_base64_m3u8(r.text)
589
+ if b64_url:
590
+ result = b64_url
591
  else:
592
+ url = extract_m3u8_aggressive(r.text)
593
+ if url:
594
+ result = verify_hls_url(url, "https://www.xemtivitop.com/", timeout=NORMAL_TIMEOUT) if '.m3u8' in url else url
595
+ if not result and url:
596
+ result = url
597
+ except:
598
+ pass
599
+
600
+ # Fallback 1: FPTPlay CDN
601
+ if not result and channel_id in FPTPLAY_URLS:
602
+ result = verify_hls_url(FPTPLAY_URLS[channel_id], "https://fptplay.vn/", timeout=NORMAL_TIMEOUT)
603
+
604
+ # Fallback 2: VTVGo CDN
605
+ if not result and channel_id in VTVGO_FAILOVER:
606
+ result = verify_hls_url(VTVGO_FAILOVER[channel_id], "https://vtvgo.vn/", timeout=NORMAL_TIMEOUT)
607
+
608
+ # Fallback 3: mediacdn backup
609
+ if not result and channel_id in BACKUP_CDN_URLS:
610
+ result = verify_hls_url(BACKUP_CDN_URLS[channel_id], "http://tv.mediacdn.vn/", timeout=NORMAL_TIMEOUT)
611
+
612
+ # Last resort: xemtv.net legacy
613
+ if not result and channel_id in XEMTV_LEGACY_ENDPOINTS and channel_id != 'vtvprime':
614
+ php_url = XEMTV_LEGACY_ENDPOINTS[channel_id]
615
+ url, method = fetch_url_with_follow(php_url, "https://hd.xemtv.net/")
616
+ if url and '.m3u8' in url:
617
+ result = url
618
+
619
+ _set_cache(channel_id, result)
620
+ return result
621
+
622
+
623
+ # ===================== API ENDPOINTS =====================
624
 
625
  @router.get("/api/vtv/streams")
626
  def api_vtv_streams():
 
627
  result = {}
628
  for ch_id in CHANNEL_NAMES:
629
+ stream_url = fetch_vtv_stream(ch_id)
630
+ result[ch_id] = {
631
+ "name": CHANNEL_NAMES[ch_id],
632
+ "stream_url": stream_url,
633
+ "status": "ok" if stream_url else "offline"
634
+ }
 
 
 
635
  return JSONResponse(result)
636
 
637
  @router.get("/api/vtv/stream/{channel_id}")
638
  def api_vtv_stream(channel_id: str):
639
+ stream_url = fetch_vtv_stream(channel_id)
640
+ if stream_url:
641
+ return JSONResponse({"stream_url": stream_url, "status": "ok"})
642
+ return JSONResponse({"error": "stream not found", "status": "offline"}, status_code=404)
643
+
644
+ @router.get("/api/proxy/page")
645
+ def proxy_page(url: str = Query(...)):
646
+ try:
647
+ headers = {**UA}
648
+ if "xemtv.us" in url: headers["Referer"] = "https://xemtv.us/"
649
+ elif "xemtv.net" in url: headers["Referer"] = "https://hd.xemtv.net/"
650
+ elif "sv2.xemtivitop.com" in url: headers["Referer"] = "https://sv2.xemtivitop.com/"
651
+ elif "xemtivitop.com" in url: headers["Referer"] = "https://www.xemtivitop.com/"
652
+ r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=False)
653
+ if r.status_code != 200:
654
+ return Response(status_code=502, content="upstream error")
655
+ return Response(
656
+ content=r.text.encode("utf-8"),
657
+ media_type="text/html; charset=utf-8",
658
+ headers={"Access-Control-Allow-Origin": "*"}
659
+ )
660
+ except Exception as e:
661
+ return Response(status_code=502, content=f"proxy error: {str(e)[:100]}")
662
+
663
+ @router.get("/api/proxy/m3u8/vtv")
664
+ def proxy_vtv_m3u8(url: str = Query(...)):
665
+ try:
666
+ headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
667
+ if "fptplay" in url:
668
+ headers["Referer"] = "https://fptplay.vn/"
669
+ headers["Origin"] = "https://fptplay.vn"
670
+ elif "xemtv" in url:
671
+ headers["Referer"] = "https://xemtv.us/"
672
+ elif "vtvdigital" in url or "vtvgo" in url:
673
+ headers["Referer"] = "https://vtvgo.vn/"
674
+ elif "sv2.xemtivitop" in url:
675
+ headers["Referer"] = "https://sv2.xemtivitop.com/"
676
+ elif "mediacdn" in url:
677
+ headers["Referer"] = "http://tv.mediacdn.vn/"
678
+ r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=False)
679
+ if r.status_code != 200:
680
+ return Response(status_code=502, content="upstream error")
681
+ content = r.text
682
+ lines = content.split('\n')
683
+ base_url = url.rsplit('/', 1)[0] + '/' if '/' in url else url
684
+ rewritten = []
685
+ for line in lines:
686
+ line = line.strip()
687
+ if not line or line.startswith('#'):
688
+ rewritten.append(line)
689
+ else:
690
+ seg_url = line if line.startswith('http') else base_url + line
691
+ if seg_url.endswith('.m3u8'):
692
+ rewritten.append("/api/proxy/m3u8/vtv?url=" + requests.utils.quote(seg_url, safe=""))
693
+ else:
694
+ rewritten.append("/api/proxy/seg/vtv?url=" + requests.utils.quote(seg_url, safe=""))
695
+ return Response(
696
+ content='\n'.join(rewritten).encode("utf-8"),
697
+ media_type="application/vnd.apple.mpegurl",
698
+ headers={
699
+ "Access-Control-Allow-Origin": "*",
700
+ "Cache-Control": "no-cache"
701
+ }
702
+ )
703
+ except Exception as e:
704
+ return Response(status_code=502, content=f"proxy error: {str(e)[:100]}")
705
+
706
+ @router.get("/api/proxy/seg/vtv")
707
+ def proxy_vtv_segment(url: str = Query(...)):
708
+ try:
709
+ headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
710
+ if "fptplay" in url:
711
+ headers["Referer"] = "https://fptplay.vn/"
712
+ headers["Origin"] = "https://fptplay.vn"
713
+ elif "mediacdn" in url:
714
+ headers["Referer"] = "http://tv.mediacdn.vn/"
715
+ r = requests.get(url, headers=headers, timeout=30, allow_redirects=True, verify=False)
716
+ if r.status_code != 200:
717
+ return Response(status_code=502, content="upstream error")
718
+ data = r.content
719
+ if len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47:
720
+ data = data[188:]
721
+ return Response(
722
+ content=data,
723
+ media_type="video/mp2t",
724
+ headers={
725
+ "Access-Control-Allow-Origin": "*",
726
+ "Cache-Control": "public, max-age=3600"
727
+ }
728
+ )
729
+ except:
730
+ return Response(status_code=502, content="proxy error")
731
+
732
+
733
+ # ===================== EPG =====================
734
+
735
+ _epg_cache = {}
736
+ _epg_cache_time = 0
737
+ _EPG_CACHE_TTL = 600
738
+
739
+ VTV_CHANNEL_MAP = {
740
+ "vtv1": "vtv1", "vtv2": "vtv2", "vtv3": "vtv3", "vtv4": "vtv4",
741
+ "vtv5": "vtv5", "vtv5-tay-nam-bo": "vtv5", "vtv5-tay-nguyen": "vtv5",
742
+ "vtv7": "vtv7", "vtv8": "vtv8", "vtv6": "vtv6", "vtv9": "vtv9",
743
+ "vtv-can-tho": "vtv10",
744
+ }
745
 
746
  def _parse_time(time_str, reference_date=None):
747
+ if not time_str:
748
+ return None
749
+ time_str = time_str.strip().replace("h", ":").replace("H", ":")
750
  m = re.search(r'(\d{1,2}):(\d{2})', time_str)
751
  if m:
752
  try:
753
  hour, minute = int(m.group(1)), int(m.group(2))
754
+ base_date = reference_date or datetime.now(VN_TZ)
755
+ if hour < 5:
756
+ tv_date = base_date - timedelta(days=1) if base_date.hour >= 5 else base_date
757
+ else:
758
+ tv_date = base_date
759
+ result = tv_date.replace(hour=hour, minute=minute, second=0, microsecond=0)
760
+ if result.tzinfo is None:
761
+ result = result.replace(tzinfo=VN_TZ)
762
+ return result
763
+ except:
764
+ pass
765
  return None
766
 
767
  def _fetch_epg_from_vtv():
768
  global _epg_cache, _epg_cache_time
769
  now_ts = time.time()
770
+ if _epg_cache and now_ts - _epg_cache_time < _EPG_CACHE_TTL:
771
+ return _epg_cache
772
+
773
  epg_data = {}
774
  now_vn = datetime.now(VN_TZ)
775
+
776
  try:
777
+ headers = {
778
+ "User-Agent": UA["User-Agent"],
779
+ "Accept-Language": "vi-VN,vi;q=0.9",
780
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
781
+ "Referer": "https://vtv.vn/",
782
+ }
783
+ r = requests.get("https://vtv.vn/lich-phat-song.htm", headers=headers, timeout=20)
784
+ if r.status_code != 200:
785
+ return epg_data
786
  r.encoding = "utf-8"
787
  soup = BeautifulSoup(r.text, "lxml")
788
+
789
  channel_order = []
790
+ list_channel = soup.find(class_=re.compile(r'list-channel'))
791
+ if list_channel:
792
+ for link in list_channel.find_all('a', href=re.compile(r'truyen-hinh-truc-tuyen/([^.]+)\.htm')):
793
+ ch_id = re.search(r'truyen-hinh-truc-tuyen/([^.]+)\.htm', link.get('href', ''))
794
+ if ch_id:
795
+ channel_order.append(ch_id.group(1))
796
+
797
+ if not channel_order:
798
+ for link in soup.find_all('a', href=re.compile(r'truyen-hinh-truc-tuyen/([^.]+)\.htm')):
799
+ ch_id = re.search(r'truyen-hinh-truc-tuyen/([^.]+)\.htm', link.get('href', ''))
800
+ if ch_id and ch_id.group(1) not in channel_order:
801
+ channel_order.append(ch_id.group(1))
802
+
803
+ prog_containers = soup.find_all('ul', class_=re.compile(r'\bprograms\b'))
804
+ for i, container in enumerate(prog_containers):
805
+ if i >= len(channel_order):
806
+ break
807
+ vtv_ch_id = channel_order[i]
808
+ our_ch_id = VTV_CHANNEL_MAP.get(vtv_ch_id, vtv_ch_id)
809
+ if our_ch_id not in epg_data:
810
+ epg_data[our_ch_id] = []
811
+
812
  for li in container.find_all('li', class_=re.compile(r'\bprogram\b')):
813
+ time_span = li.find('span', class_=re.compile(r'\btime\b'))
814
+ title_span = li.find('span', class_=re.compile(r'\btitle\b'))
815
+ genre_a = li.find('a', class_=re.compile(r'\bgenre\b'))
816
+ time_str = time_span.get_text(strip=True) if time_span else ""
817
+ title = ""
818
+ if genre_a:
819
+ title = genre_a.get_text(strip=True)
820
+ if not title and title_span:
821
+ title = title_span.get_text(strip=True)
822
+ if not time_str or not title:
823
+ continue
824
  start_dt = _parse_time(time_str, reference_date=now_vn)
825
+ if not start_dt:
826
+ continue
827
+ epg_data[our_ch_id].append({
828
+ "time": time_str[:5],
829
+ "title": title[:80],
830
+ "start_dt": start_dt
831
+ })
832
+
833
+ for ch_id in epg_data:
834
+ epg_data[ch_id].sort(key=lambda x: x.get("start_dt") or datetime.min)
835
+ seen = set()
836
+ unique = []
837
+ for p in epg_data[ch_id]:
838
+ key = (p["time"], p["title"])
839
+ if key not in seen:
840
+ seen.add(key)
841
+ unique.append(p)
842
+ epg_data[ch_id] = unique
843
+
844
+ except Exception as e:
845
+ print(f"EPG vtv.vn error: {e}")
846
+
847
+ _epg_cache = epg_data
848
+ _epg_cache_time = now_ts
849
  return epg_data
850
 
851
+ def _get_epg_for_channel(channel_id):
 
 
 
852
  epg_data = _fetch_epg_from_vtv()
853
  programmes = epg_data.get(channel_id, [])
854
+ if not programmes:
855
+ return []
856
  now = datetime.now(VN_TZ)
857
  today = now.date()
858
  result = []
859
+ today_programmes = [p for p in programmes if p.get("start_dt") and p["start_dt"].date() == today]
860
+ if not today_programmes:
861
+ today_programmes = programmes
862
+ for i, p in enumerate(today_programmes):
863
  start_dt = p.get("start_dt")
864
+ stop_dt = today_programmes[i + 1].get("start_dt") if i + 1 < len(today_programmes) else None
865
  is_now = bool(start_dt and ((stop_dt and start_dt <= now < stop_dt) or start_dt <= now))
866
  end_time = stop_dt.strftime("%H:%M") if stop_dt else ""
867
+ result.append({
868
+ "time": p["time"],
869
+ "title": p["title"],
870
+ "end_time": end_time,
871
+ "now": is_now
872
+ })
873
+ return result
874
+
875
+ @router.get("/api/vtv/epg/{channel_id}")
876
+ def api_vtv_epg(channel_id: str):
877
+ channel_id = channel_id.lower().strip()
878
+ if channel_id not in CHANNEL_NAMES:
879
+ return JSONResponse({"error": "channel not found"}, status_code=404)
880
+ programs = _get_epg_for_channel(channel_id)
881
+ return JSONResponse({
882
+ "channel": channel_id,
883
+ "channel_name": CHANNEL_NAMES.get(channel_id, channel_id),
884
+ "date": datetime.now(VN_TZ).strftime("%Y-%m-%d"),
885
+ "programs": programs,
886
+ })
887
 
888
  @router.get("/api/vtv/epg")
889
  def api_vtv_epg_refresh():
890
  global _epg_cache, _epg_cache_time
891
+ _epg_cache = {}
892
+ _epg_cache_time = 0
893
  epg_data = _fetch_epg_from_vtv()
894
+ return JSONResponse({
895
+ "status": "refreshed",
896
+ "channels": len(epg_data),
897
+ "total_programmes": sum(len(v) for v in epg_data)
898
+ })