Spaces:
Running
Running
Restore to c93b544
#4
by bep40 - opened
This view is limited to 50 files because it contains too many changes. See the raw diff here.
- .dockerignore +0 -0
- .gitignore +0 -6
- CHANGELOG.md +0 -16
- Dockerfile +0 -49
- README.md +2 -47
- RESTART_TRIGGER.md +0 -10
- _run.py +0 -1
- ai_ext.py +0 -332
- ai_fix2.py +0 -366
- ai_patch.py +0 -917
- ai_runtime.py +2 -206
- ai_runtime_final.py +1 -100
- ai_runtime_final6.py +481 -5
- ai_runtime_fix.py +0 -394
- ai_runtime_patch_fast.py +1 -233
- ai_runtime_patch_final.py +0 -78
- ai_short_v2.py +0 -2083
- app/static/app_v2.js +231 -0
- app_clean.py +0 -69
- app_entry.py +0 -17
- app_main.py +24 -90
- app_v2.js +0 -0
- app_v2_entry.py +0 -0
- app_v2_entry.py.gitigignore +0 -3
- app_v2_entry_hot.py +0 -24
- app_v2_entry_test.py +0 -16
- app_v2_entry_v2.py +0 -16
- app_v2_patch.py +0 -111
- auto_scheduler.py +0 -396
- auto_update_sse.py +0 -55
- bdp_full.html +0 -1250
- bdp_page.html +0 -1250
- bongda_proxy.py +0 -113
- embed.html +0 -56
- headers.txt +0 -9
- index_v2.html +0 -98
- logs_route.py +0 -106
- main.py +0 -0
- main_patch.py +0 -8
- match_detail.py +0 -309
- match_detail_v2.py +0 -418
- patch_ai_hot.py +0 -55
- patch_extra.py +0 -50
- patch_runtime.py +0 -274
- piped_client.py +0 -258
- rebuild3.md +0 -1
- rebuild_trigger.txt +0 -1
- requirements.txt +0 -16
- restart.txt +0 -1
- restart2.md +0 -1
.dockerignore
DELETED
|
File without changes
|
.gitignore
DELETED
|
@@ -1,6 +0,0 @@
|
|
| 1 |
-
__pycache__/
|
| 2 |
-
*.pyc
|
| 3 |
-
data/
|
| 4 |
-
.data
|
| 5 |
-
.huggingface/
|
| 6 |
-
.restart_trigger
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
CHANGELOG.md
DELETED
|
@@ -1,16 +0,0 @@
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Dockerfile
DELETED
|
@@ -1,49 +0,0 @@
|
|
| 1 |
-
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
README.md
CHANGED
|
@@ -9,50 +9,5 @@ tags:
|
|
| 9 |
- ml-intern
|
| 10 |
---
|
| 11 |
|
| 12 |
-
#
|
| 13 |
-
|
| 14 |
-
**v18 - FIXED VTV2/VTV3/VTV6/VTV9 stream hanging**
|
| 15 |
-
|
| 16 |
-
## 🔧 Changes in v18 (2026-07-06)
|
| 17 |
-
- **VTV2, VTV3, VTV6, VTV9**: Skip expired ssaimh CDN token → immediately fall through to sv2.xemtivitop.com
|
| 18 |
-
- **15+ extraction patterns** for m3u8 URL (up from 5), including: file:, src=, source:, player.src(), hls.loadSource(), href=, `<source src>`, url:, window.location, iframe follow (3 levels deep), base64 decode
|
| 19 |
-
- **Backup CDN** `tv.mediacdn.vn` for VTV2/VTV3/VTV6/VTV9
|
| 20 |
-
- **Fast timeout** 5s for CDN, 12s for PHP endpoints (was 15s each = 60s+ total)
|
| 21 |
-
- **sv2.xemtivitop.com** re-prioritized to check BEFORE xemtv.us
|
| 22 |
-
- **Iframe chain following**: if a PHP page returns an iframe → follow it up to 3 levels to find the m3u8
|
| 23 |
-
|
| 24 |
-
## Features:
|
| 25 |
-
- 📰 News from VnExpress (10 categories) + GenK AI
|
| 26 |
-
- ⚽ Livescore from bongda.com.vn (live, today, upcoming, results, standings)
|
| 27 |
-
- 🎬 Football highlights from xemlaibongda.top (8 leagues)
|
| 28 |
-
- 📺 VTV live channels (VTV1→VTV10, VTV Prime)
|
| 29 |
-
- Priority: ssaimh CDN → sv2.xemtivitop.com → xemtv.us → xemtivitop blogspot → FPTPlay → VTVGo → mediacdn → xemtv.net
|
| 30 |
-
- 🏆 World Cup 2026 (news, fixtures, standings, stats, highlights)
|
| 31 |
-
- 🤖 AI article writing + TTS (multilingual, emotion-aware)
|
| 32 |
-
- 🔍 Topic search (8 news sources)
|
| 33 |
-
- 🎤 TTS: voice selector + emotion selector + speed control
|
| 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.
|
|
|
|
| 9 |
- ml-intern
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# bep40/vnews
|
| 13 |
+
<!-- build: 2026-06-12T06:45:00 -->
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
RESTART_TRIGGER.md
DELETED
|
@@ -1,10 +0,0 @@
|
|
| 1 |
-
# VNEWS Space Restart Trigger
|
| 2 |
-
|
| 3 |
-
trigger rebuild v20260829m3ufix
|
| 4 |
-
|
| 5 |
-
## Fixes applied this build:
|
| 6 |
-
1. **M3U channel logo fix**: `.m3u-channel-thumb` changed from `height:60px; object-fit-cover` (rectangular crop with syntax error — missing colon) to `aspect-ratio:1/1; max-width:80px; height:auto; object-fit:contain` (square thumbnails showing full logos without cropping)
|
| 7 |
-
2. **Black screen on live channels fix**: HLS manifest in `proxy_m3u_hls` changed from `#EXTINF:6.0` with `MEDIA-SEQUENCE:0` and no `ENDLIST` (causing Hls.js to not re-request segments after 6s) to `#EXTINF:3600` with `#EXT-X-ENDLIST` (single continuous segment that Hls.js plays indefinitely)
|
| 8 |
-
3. **proxy_segment timeout fix**: Changed `timeout=15` to `timeout=(10, 60)` for live TS streams that may not send data for several seconds, and `max_sec` default from 60 to 0 (open-ended streaming)
|
| 9 |
-
|
| 10 |
-
FORCE REBUILD: this commit retriggers the Space container with the latest fixes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_run.py
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
from app_v2_entry import app # v5-stable inline bongda proxy
|
|
|
|
|
|
ai_ext.py
DELETED
|
@@ -1,332 +0,0 @@
|
|
| 1 |
-
"""VNEWS AI Extension - rewrite + auto short video generation.
|
| 2 |
-
Imported by app_v2_entry.py to register /api/rewrite_share, /api/topic_post,
|
| 3 |
-
/api/ai_wall, /api/wall, /api/ai/short endpoints on the main FastAPI app.
|
| 4 |
-
|
| 5 |
-
Uses main.py's WALL_FILE (wall_posts.json) for unified data store.
|
| 6 |
-
TTS: edge-tts (HoaiMy female, NamMinh male) with speed control + gTTS fallback.
|
| 7 |
-
"""
|
| 8 |
-
import os, re, json, time, random, html as html_lib, subprocess, asyncio
|
| 9 |
-
from urllib.parse import quote_plus, quote, urlparse, urljoin
|
| 10 |
-
from typing import Optional, List, Dict
|
| 11 |
-
import requests
|
| 12 |
-
from bs4 import BeautifulSoup
|
| 13 |
-
from fastapi import Request, Query
|
| 14 |
-
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
|
| 15 |
-
|
| 16 |
-
# Try to import main app, but don't fail if it doesn't exist
|
| 17 |
-
try:
|
| 18 |
-
from main import app
|
| 19 |
-
except ImportError:
|
| 20 |
-
# Create a minimal FastAPI app for standalone testing
|
| 21 |
-
try:
|
| 22 |
-
from fastapi import FastAPI
|
| 23 |
-
app = FastAPI()
|
| 24 |
-
except Exception:
|
| 25 |
-
app = None
|
| 26 |
-
|
| 27 |
-
# Import wall store from main.py so we read/write the SAME file
|
| 28 |
-
try:
|
| 29 |
-
from main import _load_wall, _save_wall, _web_context # noqa: F401
|
| 30 |
-
except ImportError:
|
| 31 |
-
_data_dir = "/data" if os.path.isdir("/data") else "/app/data"
|
| 32 |
-
_wall_file = os.path.join(_data_dir, "wall_posts.json")
|
| 33 |
-
def _load_wall():
|
| 34 |
-
try:
|
| 35 |
-
if os.path.exists(_wall_file):
|
| 36 |
-
with open(_wall_file, "r", encoding="utf-8") as f:
|
| 37 |
-
return json.load(f)
|
| 38 |
-
except Exception:
|
| 39 |
-
pass
|
| 40 |
-
return []
|
| 41 |
-
def _save_wall(posts):
|
| 42 |
-
try:
|
| 43 |
-
os.makedirs(os.path.dirname(_wall_file), exist_ok=True)
|
| 44 |
-
tmp = _wall_file + ".tmp"
|
| 45 |
-
with open(tmp, "w", encoding="utf-8") as f:
|
| 46 |
-
json.dump(posts[:100], f, ensure_ascii=False)
|
| 47 |
-
os.replace(tmp, _wall_file)
|
| 48 |
-
except Exception:
|
| 49 |
-
pass
|
| 50 |
-
def _web_context(topic):
|
| 51 |
-
return ""
|
| 52 |
-
|
| 53 |
-
# ai_ext alias for backward compatibility
|
| 54 |
-
_load_ai_wall = _load_wall
|
| 55 |
-
_save_ai_wall = _save_wall
|
| 56 |
-
|
| 57 |
-
try:
|
| 58 |
-
from huggingface_hub import AsyncInferenceClient
|
| 59 |
-
except Exception:
|
| 60 |
-
AsyncInferenceClient = None
|
| 61 |
-
try:
|
| 62 |
-
from gtts import gTTS
|
| 63 |
-
except Exception:
|
| 64 |
-
gTTS = None
|
| 65 |
-
try:
|
| 66 |
-
from PIL import Image, ImageDraw, ImageFont
|
| 67 |
-
except Exception:
|
| 68 |
-
Image = ImageDraw = ImageFont = None
|
| 69 |
-
try:
|
| 70 |
-
import edge_tts
|
| 71 |
-
except Exception:
|
| 72 |
-
edge_tts = None
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
def _hf_token():
|
| 76 |
-
for k in ("HF_TOKEN", "HUGGINGFACE_HUB_API_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HF_API_TOKEN"):
|
| 77 |
-
v = os.getenv(k, "").strip()
|
| 78 |
-
if v:
|
| 79 |
-
return v
|
| 80 |
-
return ""
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
def _clean_text(s: str) -> str:
|
| 84 |
-
"""Clean text for processing."""
|
| 85 |
-
s = html_lib.unescape(s or "")
|
| 86 |
-
s = re.sub(r"\s+", " ", s)
|
| 87 |
-
return s.strip()
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
def _domain(url: str) -> str:
|
| 91 |
-
"""Extract domain from URL."""
|
| 92 |
-
try:
|
| 93 |
-
return urlparse(url or "").netloc.replace("www.", "")
|
| 94 |
-
except Exception:
|
| 95 |
-
return ""
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
async def qwen_generate(prompt: str, image_url: str = None, max_tokens: int = 1200) -> str:
|
| 99 |
-
"""Generate text using 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()
|
| 116 |
-
models = [m for m in models if m and m not in seen and not seen.add(m)]
|
| 117 |
-
|
| 118 |
-
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
| 119 |
-
|
| 120 |
-
for model in models:
|
| 121 |
-
try:
|
| 122 |
-
is_vl = "VL" in model and image_url
|
| 123 |
-
if is_vl:
|
| 124 |
-
user_content = [
|
| 125 |
-
{"type": "image_url", "image_url": {"url": image_url}},
|
| 126 |
-
{"type": "text", "text": prompt}
|
| 127 |
-
]
|
| 128 |
-
else:
|
| 129 |
-
user_content = prompt
|
| 130 |
-
|
| 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(
|
| 143 |
-
"https://router.huggingface.co/v1/chat/completions",
|
| 144 |
-
headers=headers,
|
| 145 |
-
json=payload,
|
| 146 |
-
timeout=95
|
| 147 |
-
)
|
| 148 |
-
|
| 149 |
-
if r.status_code >= 300:
|
| 150 |
-
errors.append(f"{model}: HTTP {r.status_code}")
|
| 151 |
-
continue
|
| 152 |
-
|
| 153 |
-
j = r.json()
|
| 154 |
-
txt = (j.get("choices", [{}])[0].get("message", {}).get("content") or "").strip()
|
| 155 |
-
|
| 156 |
-
if txt:
|
| 157 |
-
return txt
|
| 158 |
-
|
| 159 |
-
errors.append(f"{model}: empty response")
|
| 160 |
-
|
| 161 |
-
except Exception as e:
|
| 162 |
-
errors.append(f"{model}: {type(e).__name__}")
|
| 163 |
-
|
| 164 |
-
# Fallback: extractive summary from prompt
|
| 165 |
-
LAST_QWEN_ERROR = errors[-3:] if errors else "unknown error"
|
| 166 |
-
return _fallback_summary_from_prompt(prompt, max_units=6)
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
def _fallback_summary_from_prompt(prompt: str, max_units: int = 6) -> str:
|
| 170 |
-
"""Generate a simple fallback summary when AI is unavailable."""
|
| 171 |
-
text = prompt or ""
|
| 172 |
-
for marker in ["Nội dung nguồn:", "Nội dung bài:", "Nội dung gốc:", "Nội dung:", "Nguồn/bối cảnh internet:"]:
|
| 173 |
-
if marker in text:
|
| 174 |
-
text = text.split(marker, 1)[1]
|
| 175 |
-
break
|
| 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])
|
| 194 |
-
if chunk and chunk not in chunks:
|
| 195 |
-
chunks.append(chunk)
|
| 196 |
-
if len(chunks) >= max_units:
|
| 197 |
-
break
|
| 198 |
-
if chunks:
|
| 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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ai_fix2.py
DELETED
|
@@ -1,366 +0,0 @@
|
|
| 1 |
-
import os, re, subprocess, html as html_lib, json
|
| 2 |
-
from urllib.parse import quote_plus, urlparse, parse_qs, unquote
|
| 3 |
-
import requests
|
| 4 |
-
import ai_patch as prev
|
| 5 |
-
from ai_patch import app
|
| 6 |
-
from fastapi import Request
|
| 7 |
-
from fastapi.responses import JSONResponse, HTMLResponse, FileResponse
|
| 8 |
-
|
| 9 |
-
base = prev.base
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
def clean(s):
|
| 13 |
-
return re.sub(r"\s+", " ", html_lib.unescape(s or "")).strip()
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
def _is_real_article_text(raw):
|
| 17 |
-
raw = clean(raw)
|
| 18 |
-
if len(raw) < 500:
|
| 19 |
-
return False
|
| 20 |
-
# Reject search-result/title-only pages: need several real sentences.
|
| 21 |
-
sentences = re.split(r"(?<=[\.\!\?])\s+", raw)
|
| 22 |
-
long_sentences = [s for s in sentences if len(s) > 45]
|
| 23 |
-
return len(long_sentences) >= 5
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
def _extract_ddg_url(href):
|
| 27 |
-
if not href:
|
| 28 |
-
return ""
|
| 29 |
-
if href.startswith("//"):
|
| 30 |
-
href = "https:" + href
|
| 31 |
-
if "duckduckgo.com/l/" in href:
|
| 32 |
-
try:
|
| 33 |
-
qs = parse_qs(urlparse(href).query)
|
| 34 |
-
if qs.get("uddg"):
|
| 35 |
-
return unquote(qs["uddg"][0])
|
| 36 |
-
except Exception:
|
| 37 |
-
pass
|
| 38 |
-
return href
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
def _ddg_article_urls(topic, limit=12):
|
| 42 |
-
urls = []
|
| 43 |
-
try:
|
| 44 |
-
q = quote_plus(topic + " tin tức bài viết phân tích")
|
| 45 |
-
r = requests.get("https://html.duckduckgo.com/html/?q=" + q, headers=base.HEADERS, timeout=18)
|
| 46 |
-
r.encoding = "utf-8"
|
| 47 |
-
from bs4 import BeautifulSoup
|
| 48 |
-
soup = BeautifulSoup(r.text, "lxml")
|
| 49 |
-
for a in soup.select("a.result__a"):
|
| 50 |
-
u = _extract_ddg_url(a.get("href", ""))
|
| 51 |
-
if not u.startswith("http"):
|
| 52 |
-
continue
|
| 53 |
-
if any(bad in u for bad in ["google.com", "youtube.com", "facebook.com", "x.com", "twitter.com"]):
|
| 54 |
-
continue
|
| 55 |
-
if u not in urls:
|
| 56 |
-
urls.append(u)
|
| 57 |
-
if len(urls) >= limit:
|
| 58 |
-
break
|
| 59 |
-
except Exception:
|
| 60 |
-
pass
|
| 61 |
-
return urls
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
def _rss_article_urls(topic, limit=10):
|
| 65 |
-
out = []
|
| 66 |
-
try:
|
| 67 |
-
url = "https://news.google.com/rss/search?q=" + quote_plus(topic) + "&hl=vi&gl=VN&ceid=VN:vi"
|
| 68 |
-
r = requests.get(url, headers=base.HEADERS, timeout=15)
|
| 69 |
-
r.encoding = "utf-8"
|
| 70 |
-
from bs4 import BeautifulSoup
|
| 71 |
-
soup = BeautifulSoup(r.text, "xml")
|
| 72 |
-
for it in soup.find_all("item")[:limit]:
|
| 73 |
-
title = it.find("title").get_text(" ", strip=True) if it.find("title") else ""
|
| 74 |
-
link = it.find("link").get_text(strip=True) if it.find("link") else ""
|
| 75 |
-
src = it.find("source").get_text(" ", strip=True) if it.find("source") else base._domain(link)
|
| 76 |
-
if title and link:
|
| 77 |
-
out.append({"title": title, "url": link, "via": src, "excerpt": title})
|
| 78 |
-
except Exception:
|
| 79 |
-
pass
|
| 80 |
-
return out
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
def _topic_source_articles(topic, limit=5):
|
| 84 |
-
"""Scrape actual article bodies. Do not accept title-only sources."""
|
| 85 |
-
candidates = []
|
| 86 |
-
seen = set()
|
| 87 |
-
|
| 88 |
-
# 1) DuckDuckGo actual result URLs are usually more directly scrapable.
|
| 89 |
-
for u in _ddg_article_urls(topic, limit=14):
|
| 90 |
-
if u not in seen:
|
| 91 |
-
seen.add(u)
|
| 92 |
-
candidates.append({"url": u, "title": "", "via": base._domain(u)})
|
| 93 |
-
|
| 94 |
-
# 2) Add base web_context sources.
|
| 95 |
-
try:
|
| 96 |
-
_ctx, srcs = base.web_context(topic, limit=8)
|
| 97 |
-
for s in srcs or []:
|
| 98 |
-
u = s.get("url") or ""
|
| 99 |
-
if u.startswith("http") and u not in seen:
|
| 100 |
-
seen.add(u)
|
| 101 |
-
candidates.append(s)
|
| 102 |
-
except Exception:
|
| 103 |
-
pass
|
| 104 |
-
|
| 105 |
-
# 3) Google News RSS fallback last.
|
| 106 |
-
for s in _rss_article_urls(topic, limit=10):
|
| 107 |
-
u = s.get("url") or ""
|
| 108 |
-
if u.startswith("http") and u not in seen:
|
| 109 |
-
seen.add(u)
|
| 110 |
-
candidates.append(s)
|
| 111 |
-
|
| 112 |
-
out = []
|
| 113 |
-
for s in candidates[:24]:
|
| 114 |
-
url = s.get("url") or ""
|
| 115 |
-
try:
|
| 116 |
-
page = base.scrape_any_url(url)
|
| 117 |
-
raw = (page.get("summary", "") + "\n" + page.get("text", "")).strip()
|
| 118 |
-
if not _is_real_article_text(raw):
|
| 119 |
-
continue
|
| 120 |
-
title = page.get("title") or s.get("title") or url
|
| 121 |
-
via = page.get("via") or s.get("via") or base._domain(url)
|
| 122 |
-
out.append({
|
| 123 |
-
"title": title,
|
| 124 |
-
"url": url,
|
| 125 |
-
"raw": raw,
|
| 126 |
-
"image": page.get("image") or "",
|
| 127 |
-
"via": via,
|
| 128 |
-
"source": {"title": title, "url": url, "excerpt": raw[:700], "via": via}
|
| 129 |
-
})
|
| 130 |
-
if len(out) >= limit:
|
| 131 |
-
break
|
| 132 |
-
except Exception:
|
| 133 |
-
continue
|
| 134 |
-
return out[:limit]
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
def sentence_split(text):
|
| 138 |
-
text = re.sub(r"^[•\-\*]\s*", "", text or "", flags=re.M)
|
| 139 |
-
text = re.sub(r"\n+", ". ", text)
|
| 140 |
-
parts = []
|
| 141 |
-
for s in re.split(r"(?<=[\.\!\?])\s+", text):
|
| 142 |
-
s = clean(s)
|
| 143 |
-
if len(s) >= 8:
|
| 144 |
-
parts.append(s)
|
| 145 |
-
return parts
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
def srt_time(sec):
|
| 149 |
-
ms = int((sec - int(sec)) * 1000)
|
| 150 |
-
sec = int(sec)
|
| 151 |
-
return f"{sec//3600:02d}:{(sec%3600)//60:02d}:{sec%60:02d},{ms:03d}"
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
def parse_timecode(t):
|
| 155 |
-
# 00:00:01.234 or 00:00:01,234
|
| 156 |
-
t = t.replace(',', '.')
|
| 157 |
-
parts = t.split(':')
|
| 158 |
-
if len(parts) == 3:
|
| 159 |
-
return int(parts[0])*3600 + int(parts[1])*60 + float(parts[2])
|
| 160 |
-
if len(parts) == 2:
|
| 161 |
-
return int(parts[0])*60 + float(parts[1])
|
| 162 |
-
return float(parts[0])
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
def convert_vtt_to_scaled_srt(vtt_path, srt_path, speed=1.2):
|
| 166 |
-
try:
|
| 167 |
-
txt = open(vtt_path, 'r', encoding='utf-8').read().splitlines()
|
| 168 |
-
cues = []
|
| 169 |
-
i = 0
|
| 170 |
-
while i < len(txt):
|
| 171 |
-
line = txt[i].strip()
|
| 172 |
-
if '-->' in line:
|
| 173 |
-
a, b = [x.strip().split()[0] for x in line.split('-->')[:2]]
|
| 174 |
-
start = parse_timecode(a) / speed
|
| 175 |
-
end = parse_timecode(b) / speed
|
| 176 |
-
i += 1
|
| 177 |
-
texts = []
|
| 178 |
-
while i < len(txt) and txt[i].strip():
|
| 179 |
-
texts.append(txt[i].strip())
|
| 180 |
-
i += 1
|
| 181 |
-
s = clean(' '.join(texts))
|
| 182 |
-
if s:
|
| 183 |
-
cues.append((start, end, s))
|
| 184 |
-
i += 1
|
| 185 |
-
if not cues:
|
| 186 |
-
return False
|
| 187 |
-
with open(srt_path, 'w', encoding='utf-8') as f:
|
| 188 |
-
for idx, (st, en, s) in enumerate(cues, 1):
|
| 189 |
-
if en <= st:
|
| 190 |
-
en = st + 1.2
|
| 191 |
-
f.write(f"{idx}\n{srt_time(st)} --> {srt_time(en)}\n{s}\n\n")
|
| 192 |
-
return True
|
| 193 |
-
except Exception:
|
| 194 |
-
return False
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
def write_weighted_srt(script, path, total_duration):
|
| 198 |
-
subs = sentence_split(script)
|
| 199 |
-
if not subs:
|
| 200 |
-
subs = [clean(script)[:140] or "VNEWS"]
|
| 201 |
-
total_chars = max(1, sum(len(x) for x in subs))
|
| 202 |
-
usable = max(2.0, float(total_duration) - 1.0)
|
| 203 |
-
cur = 0.5
|
| 204 |
-
with open(path, "w", encoding="utf-8") as f:
|
| 205 |
-
for i, s in enumerate(subs, 1):
|
| 206 |
-
dur = max(1.8, min(7.0, usable * len(s) / total_chars))
|
| 207 |
-
start = cur
|
| 208 |
-
end = min(total_duration - 0.15, cur + dur)
|
| 209 |
-
cur = end + 0.18
|
| 210 |
-
f.write(f"{i}\n{srt_time(start)} --> {srt_time(end)}\n{s}\n\n")
|
| 211 |
-
if cur >= total_duration - 0.2:
|
| 212 |
-
break
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
def tts_script_full(post, emotion):
|
| 216 |
-
title = clean(post.get("title", ""))
|
| 217 |
-
text = clean(post.get("text", ""))
|
| 218 |
-
text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip()
|
| 219 |
-
prefix = {
|
| 220 |
-
"urgent": "Tin nhanh.",
|
| 221 |
-
"warm": "Câu chuyện đáng chú ý.",
|
| 222 |
-
"serious": "Bản tin nghiêm túc.",
|
| 223 |
-
"energetic": "Cập nhật nổi bật.",
|
| 224 |
-
}.get(emotion, "")
|
| 225 |
-
script = f"{prefix} {title}. {text}".strip()
|
| 226 |
-
# Keep complete wall summary. Only trim pathological payloads, on sentence boundary.
|
| 227 |
-
if len(script) > 3600:
|
| 228 |
-
tmp = script[:3600]
|
| 229 |
-
cut = max(tmp.rfind("."), tmp.rfind("!"), tmp.rfind("?"))
|
| 230 |
-
script = tmp[:cut + 1] if cut > 1600 else tmp
|
| 231 |
-
script = re.sub(r"([\.\!\?])\s*", r"\1\n", script)
|
| 232 |
-
script = re.sub(r"\n{2,}", "\n", script).strip()
|
| 233 |
-
return script
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
_PATCH = {('/api/topic_post','POST'),('/api/ai/short/{post_id}','POST'),('/api/ai/short-file/{file_id}','GET'),('/','GET')}
|
| 237 |
-
app.router.routes = [r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in _PATCH)]
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
@app.post('/api/topic_post')
|
| 241 |
-
async def topic_post_aggregate(request: Request):
|
| 242 |
-
body = await request.json()
|
| 243 |
-
topic = base._clean_text(body.get('topic',''))
|
| 244 |
-
if not topic:
|
| 245 |
-
return JSONResponse({'error':'missing topic'}, status_code=400)
|
| 246 |
-
articles = _topic_source_articles(topic, limit=5)
|
| 247 |
-
if not articles:
|
| 248 |
-
return JSONResponse({'error':'Không scrape được nội dung bài viết thật cho chủ đề này. Hãy thử chủ đề cụ thể hơn hoặc dán URL trực tiếp.'}, status_code=422)
|
| 249 |
-
source_blocks = []
|
| 250 |
-
sources = []
|
| 251 |
-
image = ""
|
| 252 |
-
for i, art in enumerate(articles, 1):
|
| 253 |
-
raw = art.get('raw','')
|
| 254 |
-
source_blocks.append(f"[Nguồn {i}] {art.get('title','')} ({art.get('via','')})\n{raw[:3000]}")
|
| 255 |
-
sources.append(art.get('source') or {'title': art.get('title'), 'url': art.get('url'), 'via': art.get('via'), 'excerpt': raw[:600]})
|
| 256 |
-
if not image and art.get('image'):
|
| 257 |
-
image = art.get('image')
|
| 258 |
-
ctx = "\n\n".join(source_blocks)
|
| 259 |
-
prompt = f"""Bạn là biên tập viên tổng hợp tin tức tiếng Việt.
|
| 260 |
-
|
| 261 |
-
Chủ đề: {topic}
|
| 262 |
-
|
| 263 |
-
NHIỆM VỤ:
|
| 264 |
-
- Đọc nội dung của TẤT CẢ các bài nguồn bên dưới.
|
| 265 |
-
- Tổng hợp thành 1 bản tóm tắt chung duy nhất, giống cách tóm tắt qua URL.
|
| 266 |
-
- Không tạo mỗi tiêu đề thành một bài riêng.
|
| 267 |
-
- Không chỉ liệt kê tiêu đề; phải dựa vào nội dung trong từng bài.
|
| 268 |
-
- Không lặp ý giữa các nguồn.
|
| 269 |
-
- Tối đa 6 gạch đầu dòng, mỗi dòng 1 câu rõ ràng.
|
| 270 |
-
- Nếu các nguồn có góc nhìn khác nhau, gộp lại thành ý tổng hợp.
|
| 271 |
-
- Cuối cùng thêm dòng: Nguồn tham khảo: tên website.
|
| 272 |
-
|
| 273 |
-
Nội dung nguồn:
|
| 274 |
-
{ctx[:16000]}"""
|
| 275 |
-
text = await prev.base.qwen_generate(prompt, image_url=image or None, max_tokens=1100)
|
| 276 |
-
text = prev._postprocess_ai_text(text, max_units=7)
|
| 277 |
-
if 'Nguồn tham khảo:' not in text:
|
| 278 |
-
text += '\n\n' + prev._source_line(sources)
|
| 279 |
-
post = base.make_post('Tổng hợp: ' + topic, text, image or base.pollinations_image_url(topic), '', 'topic_aggregate', sources=sources[:5])
|
| 280 |
-
posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
|
| 281 |
-
return JSONResponse({'post': post, 'count_sources': len(sources)})
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
@app.post('/api/ai/short/{post_id}')
|
| 285 |
-
async def ai_short_full(post_id: str, request: Request):
|
| 286 |
-
try:
|
| 287 |
-
body = await request.json()
|
| 288 |
-
except Exception:
|
| 289 |
-
body = {}
|
| 290 |
-
voice = str(body.get('voice','nu')).lower().strip()
|
| 291 |
-
emotion = str(body.get('emotion','neutral')).lower().strip()
|
| 292 |
-
speed = max(0.85, min(1.35, float(body.get('speed', 1.2) or 1.2)))
|
| 293 |
-
posts = base._load_ai_wall()
|
| 294 |
-
post = next((p for p in posts if str(p.get('id')) == str(post_id)), None)
|
| 295 |
-
if not post:
|
| 296 |
-
return JSONResponse({'error':'post not found'}, status_code=404)
|
| 297 |
-
os.makedirs(base.SHORTS_DIR, exist_ok=True)
|
| 298 |
-
suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_fullv2"
|
| 299 |
-
out_mp4 = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix) + '.mp4')
|
| 300 |
-
if os.path.exists(out_mp4):
|
| 301 |
-
post['video'] = '/api/ai/short-file/' + post_id + suffix
|
| 302 |
-
base._save_ai_wall(posts)
|
| 303 |
-
return JSONResponse({'video': post['video'], 'speed': speed, 'subtitles': True})
|
| 304 |
-
work = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix)); os.makedirs(work, exist_ok=True)
|
| 305 |
-
img = os.path.join(work,'image.jpg'); frame = os.path.join(work,'frame.jpg'); audio = os.path.join(work,'voice.mp3'); audio_fast=os.path.join(work,'voice_fast.mp3'); srt=os.path.join(work,'subtitles.srt'); vtt=os.path.join(work,'subtitles.vtt')
|
| 306 |
-
try:
|
| 307 |
-
base._download_image(post.get('img'), post.get('title','AI news'), img)
|
| 308 |
-
prev._make_short_frame_full(post, img, frame)
|
| 309 |
-
script = tts_script_full(post, emotion)
|
| 310 |
-
edge_voice = {'nam':'vi-VN-NamMinhNeural','male':'vi-VN-NamMinhNeural','nu':'vi-VN-HoaiMyNeural','female':'vi-VN-HoaiMyNeural','mien-nam':'vi-VN-HoaiMyNeural'}.get(voice,'vi-VN-HoaiMyNeural')
|
| 311 |
-
used_edge = False
|
| 312 |
-
try:
|
| 313 |
-
subprocess.run(['python','-m','edge_tts','--voice',edge_voice,'--text',script,'--write-media',audio,'--write-subtitles',vtt], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=260)
|
| 314 |
-
used_edge = True
|
| 315 |
-
except Exception:
|
| 316 |
-
tld = 'com.vn' if voice in ('nu','female','mien-nam') else 'com'
|
| 317 |
-
try:
|
| 318 |
-
base.gTTS(script, lang='vi', tld=tld, slow=False).save(audio)
|
| 319 |
-
except TypeError:
|
| 320 |
-
base.gTTS(script, lang='vi', slow=False).save(audio)
|
| 321 |
-
subprocess.run(['ffmpeg','-y','-i',audio,'-filter:a',f'atempo={speed}','-vn',audio_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=220)
|
| 322 |
-
duration = 45.0
|
| 323 |
-
try:
|
| 324 |
-
pr = subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:no_key=1',audio_fast], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
|
| 325 |
-
duration = float((pr.stdout or b'45').decode().strip() or 45)
|
| 326 |
-
except Exception:
|
| 327 |
-
pass
|
| 328 |
-
if used_edge and os.path.exists(vtt):
|
| 329 |
-
ok = convert_vtt_to_scaled_srt(vtt, srt, speed=speed)
|
| 330 |
-
if not ok:
|
| 331 |
-
write_weighted_srt(script, srt, duration)
|
| 332 |
-
else:
|
| 333 |
-
write_weighted_srt(script, srt, duration)
|
| 334 |
-
vf = "scale=1080:1920,subtitles='{}':force_style='FontName=DejaVu Sans,FontSize=16,PrimaryColour=&H00FFFFFF,OutlineColour=&HAA000000,BorderStyle=1,Outline=1.5,Shadow=0,Alignment=2,MarginV=42'".format(srt.replace("'", "\\'"))
|
| 335 |
-
cmd = ['ffmpeg','-y','-loop','1','-i',frame,'-i',audio_fast,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k','-vf',vf,out_mp4]
|
| 336 |
-
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=420)
|
| 337 |
-
post['video'] = '/api/ai/short-file/' + post_id + suffix
|
| 338 |
-
post['short_voice'] = voice; post['short_emotion'] = emotion; post['short_speed'] = speed; post['short_subtitles'] = True
|
| 339 |
-
base._save_ai_wall(posts)
|
| 340 |
-
return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': True, 'duration': duration})
|
| 341 |
-
except Exception as e:
|
| 342 |
-
return JSONResponse({'error':'Không tạo được shorts: '+str(e)[:180]}, status_code=500)
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
@app.get('/api/ai/short-file/{file_id}')
|
| 346 |
-
def ai_short_file_full(file_id: str):
|
| 347 |
-
path = os.path.join(base.SHORTS_DIR, base._safe_name(file_id) + '.mp4')
|
| 348 |
-
if not os.path.exists(path):
|
| 349 |
-
return JSONResponse({'error':'not found'}, status_code=404)
|
| 350 |
-
return FileResponse(path, media_type='video/mp4', filename=f'vnews-ai-{file_id}.mp4')
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
app.router.routes = [r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
|
| 354 |
-
|
| 355 |
-
@app.get('/')
|
| 356 |
-
async def index_fix2():
|
| 357 |
-
with open('/app/static/index.html','r',encoding='utf-8') as f:
|
| 358 |
-
html = f.read()
|
| 359 |
-
inject = prev.PATCH_INJECT + r'''
|
| 360 |
-
<script>
|
| 361 |
-
(function(){
|
| 362 |
-
window.createTopicPost=function(){let inp=document.getElementById('ai-topic-input');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&j.post){window.location.reload();alert('Đã tổng hợp NỘI DUNG các bài nguồn thành 1 bản tóm tắt trên Tường AI');}else alert(j.error||'Lỗi tạo bài')}).catch(e=>alert(e.message||'Lỗi tạo bài'));};
|
| 363 |
-
})();
|
| 364 |
-
</script>
|
| 365 |
-
'''
|
| 366 |
-
return HTMLResponse(html.replace('</body>', inject+'\n</body>'))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ai_patch.py
DELETED
|
@@ -1,917 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
import re
|
| 3 |
-
import time
|
| 4 |
-
import random
|
| 5 |
-
import json
|
| 6 |
-
import html as html_lib
|
| 7 |
-
import subprocess
|
| 8 |
-
import requests
|
| 9 |
-
import hashlib
|
| 10 |
-
import ai_ext as base
|
| 11 |
-
from ai_ext import app
|
| 12 |
-
from fastapi import Request
|
| 13 |
-
from fastapi.responses import JSONResponse, HTMLResponse, FileResponse
|
| 14 |
-
from bs4 import BeautifulSoup
|
| 15 |
-
from urllib.parse import quote_plus
|
| 16 |
-
|
| 17 |
-
try:
|
| 18 |
-
from PIL import Image, ImageDraw, ImageFont
|
| 19 |
-
except Exception:
|
| 20 |
-
Image = ImageDraw = ImageFont = None
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
def _clean(s):
|
| 24 |
-
s = html_lib.unescape(s or "")
|
| 25 |
-
s = re.sub(r"[ \t]+", " ", s)
|
| 26 |
-
s = re.sub(r"\n{3,}", "\n\n", s)
|
| 27 |
-
return s.strip()
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
def _norm(s):
|
| 31 |
-
s = s.lower()
|
| 32 |
-
s = re.sub(r"[^\wÀ-ỹ\s]", " ", s)
|
| 33 |
-
s = re.sub(r"\s+", " ", s).strip()
|
| 34 |
-
return s
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
def _similar(a, b):
|
| 38 |
-
ta = set(_norm(a).split())
|
| 39 |
-
tb = set(_norm(b).split())
|
| 40 |
-
if not ta or not tb:
|
| 41 |
-
return False
|
| 42 |
-
return len(ta & tb) / max(1, min(len(ta), len(tb))) >= 0.72
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
def _dedupe_units(units, max_units=25):
|
| 46 |
-
"""Deduplicate units - only skip exact matches to ensure all bullet points are read."""
|
| 47 |
-
out, seen = [], set()
|
| 48 |
-
for u in units:
|
| 49 |
-
u = _clean(re.sub(r"^[-•*\d\.\)\s]+", "", u))
|
| 50 |
-
if len(u) < 18:
|
| 51 |
-
continue
|
| 52 |
-
nu = _norm(u)
|
| 53 |
-
# Only skip exact matches, NOT similar content (to avoid skipping valid bullet points)
|
| 54 |
-
if nu in seen:
|
| 55 |
-
continue
|
| 56 |
-
seen.add(nu)
|
| 57 |
-
out.append(u)
|
| 58 |
-
if len(out) >= max_units:
|
| 59 |
-
break
|
| 60 |
-
return out
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
def _postprocess_ai_text(text, max_units=20):
|
| 64 |
-
text = _clean(text)
|
| 65 |
-
if not text:
|
| 66 |
-
return text
|
| 67 |
-
drop_prefixes = (
|
| 68 |
-
"dưới đây", "sau đây", "bài viết", "tôi sẽ", "mình sẽ",
|
| 69 |
-
"tóm tắt bài", "tiêu đề:", "sapo:", "nội dung:", "kết luận:"
|
| 70 |
-
)
|
| 71 |
-
raw_lines = []
|
| 72 |
-
for line in re.split(r"\n+", text):
|
| 73 |
-
line = _clean(line)
|
| 74 |
-
if not line:
|
| 75 |
-
continue
|
| 76 |
-
low = line.lower().strip()
|
| 77 |
-
if any(low.startswith(p) and len(line) < 80 for p in drop_prefixes):
|
| 78 |
-
continue
|
| 79 |
-
raw_lines.append(line)
|
| 80 |
-
units = []
|
| 81 |
-
for line in raw_lines:
|
| 82 |
-
# KEEP FULL bullet point - don't truncate or split into segments
|
| 83 |
-
if len(line) >= 18:
|
| 84 |
-
units.append(_clean(re.sub(r"^[-•*\d\.\)\s]+", "", line)))
|
| 85 |
-
units = _dedupe_units(units, max_units=max_units)
|
| 86 |
-
if not units:
|
| 87 |
-
return text[:900]
|
| 88 |
-
title = ""
|
| 89 |
-
if raw_lines and len(raw_lines[0]) <= 90 and not raw_lines[0].startswith(("-", "•", "*")):
|
| 90 |
-
title = raw_lines[0]
|
| 91 |
-
units = [u for u in units if not _similar(u, title)]
|
| 92 |
-
body = "\n".join("• " + u for u in units[:max_units])
|
| 93 |
-
return (title + "\n\n" + body).strip() if title else body
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
def _fallback_summary_from_prompt(prompt, max_units=6):
|
| 97 |
-
text = prompt or ""
|
| 98 |
-
for marker in ["Nội dung nguồn:", "Nội dung bài:", "Nội dung gốc:", "Nội dung:", "Nguồn/bối cảnh internet:"]:
|
| 99 |
-
if marker in text:
|
| 100 |
-
text = text.split(marker, 1)[1]
|
| 101 |
-
break
|
| 102 |
-
text = re.sub(r"https?://\S+", "", text)
|
| 103 |
-
text = re.sub(r"\s+", " ", text).strip()
|
| 104 |
-
sentences = re.split(r"(?<=[\.\!\?])\s+(?=[A-ZÀ-Ỹ0-9])", text)
|
| 105 |
-
candidates = []
|
| 106 |
-
for s in sentences:
|
| 107 |
-
s = _clean(s)
|
| 108 |
-
if 45 <= len(s) <= 260:
|
| 109 |
-
candidates.append(s)
|
| 110 |
-
units = _dedupe_units(candidates, max_units=max_units)
|
| 111 |
-
if units:
|
| 112 |
-
return "\n".join("• " + u for u in units)
|
| 113 |
-
if text:
|
| 114 |
-
return "• " + text[:700].rsplit(" ", 1)[0]
|
| 115 |
-
return "• Không có đủ nội dung nguồn để tóm tắt."
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
def _source_line(sources):
|
| 119 |
-
names = []
|
| 120 |
-
for s in (sources or [])[:5]:
|
| 121 |
-
via = s.get("via") or base._domain(s.get("url", "")) or s.get("title", "")
|
| 122 |
-
if via and via not in names:
|
| 123 |
-
names.append(via)
|
| 124 |
-
return "Nguồn tham khảo: " + ", ".join(names[:5]) if names else "Nguồn tham khảo: tổng hợp internet"
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
def _make_summary_prompt(title, raw, source_hint=""):
|
| 128 |
-
return f"""Bạn là biên tập viên tóm tắt tin tức tiếng Việt.
|
| 129 |
-
|
| 130 |
-
NHIỆM VỤ BẮT BUỘC:
|
| 131 |
-
- Chỉ TÓM TẮT nội dung chính, KHÔNG viết lại toàn bộ bài.
|
| 132 |
-
- Không lặp lại cùng một ý, cùng một câu, cùng một chi tiết.
|
| 133 |
-
- Không thêm thông tin ngoài nguồn.
|
| 134 |
-
- Tối đa 5 gạch đầu dòng, mỗi gạch đầu dòng 1 câu ngắn.
|
| 135 |
-
- Nếu bài có số liệu/nhân vật/thời điểm quan trọng thì giữ lại.
|
| 136 |
-
- Không viết phần mở bài dài, không viết văn kể lại.
|
| 137 |
-
|
| 138 |
-
Tiêu đề nguồn: {title}
|
| 139 |
-
Nguồn: {source_hint}
|
| 140 |
-
|
| 141 |
-
Nội dung nguồn:
|
| 142 |
-
{raw[:14000]}
|
| 143 |
-
"""
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
def _direct_news_rss(topic, limit=10):
|
| 147 |
-
out = []
|
| 148 |
-
try:
|
| 149 |
-
url = "https://news.google.com/rss/search?q=" + quote_plus(topic) + "&hl=vi&gl=VN&ceid=VN:vi"
|
| 150 |
-
r = requests.get(url, headers=base.HEADERS, timeout=15)
|
| 151 |
-
r.encoding = "utf-8"
|
| 152 |
-
soup = BeautifulSoup(r.text, "xml")
|
| 153 |
-
for it in soup.find_all("item")[:limit]:
|
| 154 |
-
title = it.find("title").get_text(" ", strip=True) if it.find("title") else ""
|
| 155 |
-
link = it.find("link").get_text(strip=True) if it.find("link") else ""
|
| 156 |
-
src = it.find("source").get_text(" ", strip=True) if it.find("source") else base._domain(link)
|
| 157 |
-
if title and link:
|
| 158 |
-
out.append({"title": title, "url": link, "via": src, "excerpt": title})
|
| 159 |
-
except Exception:
|
| 160 |
-
pass
|
| 161 |
-
return out
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
def _topic_source_articles(topic, limit=5):
|
| 165 |
-
"""Return actual scraped article bodies for a topic. Each source becomes one Wall AI post."""
|
| 166 |
-
try:
|
| 167 |
-
_ctx, sources = base.web_context(topic, limit=limit)
|
| 168 |
-
except Exception:
|
| 169 |
-
sources = []
|
| 170 |
-
if not sources:
|
| 171 |
-
sources = _direct_news_rss(topic, limit=10)
|
| 172 |
-
out, seen = [], set()
|
| 173 |
-
for s in (sources or [])[:limit * 3]:
|
| 174 |
-
url = s.get("url") or ""
|
| 175 |
-
if not url.startswith("http") or url in seen:
|
| 176 |
-
continue
|
| 177 |
-
seen.add(url)
|
| 178 |
-
try:
|
| 179 |
-
page = base.scrape_any_url(url)
|
| 180 |
-
raw = (page.get("summary", "") + "\n" + page.get("text", "")).strip()
|
| 181 |
-
if len(raw) < 180:
|
| 182 |
-
continue
|
| 183 |
-
title = page.get("title") or s.get("title") or url
|
| 184 |
-
via = page.get("via") or s.get("via") or base._domain(url)
|
| 185 |
-
out.append({
|
| 186 |
-
"title": title,
|
| 187 |
-
"url": url,
|
| 188 |
-
"raw": raw,
|
| 189 |
-
"image": page.get("image") or "",
|
| 190 |
-
"via": via,
|
| 191 |
-
"source": {"title": title, "url": url, "excerpt": raw[:700], "via": via}
|
| 192 |
-
})
|
| 193 |
-
if len(out) >= limit:
|
| 194 |
-
break
|
| 195 |
-
except Exception:
|
| 196 |
-
continue
|
| 197 |
-
if not out:
|
| 198 |
-
for s in (sources or _direct_news_rss(topic, 6))[:limit]:
|
| 199 |
-
title = s.get("title") or topic
|
| 200 |
-
excerpt = s.get("excerpt") or s.get("description") or s.get("content") or title
|
| 201 |
-
url = s.get("url", "")
|
| 202 |
-
via = s.get("via") or base._domain(url)
|
| 203 |
-
out.append({
|
| 204 |
-
"title": title,
|
| 205 |
-
"url": url,
|
| 206 |
-
"raw": excerpt,
|
| 207 |
-
"image": base.pollinations_image_url(title),
|
| 208 |
-
"via": via,
|
| 209 |
-
"source": {"title": title, "url": url, "excerpt": excerpt[:700], "via": via}
|
| 210 |
-
})
|
| 211 |
-
return out[:limit]
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
async def qwen_generate_resilient(prompt: str, image_url=None, max_tokens: int = 1200):
|
| 215 |
-
errors = []
|
| 216 |
-
token = base._hf_token()
|
| 217 |
-
try:
|
| 218 |
-
original = getattr(base, "_original_qwen_generate", None)
|
| 219 |
-
if original:
|
| 220 |
-
txt = await original(prompt, image_url=image_url, max_tokens=max_tokens)
|
| 221 |
-
if txt:
|
| 222 |
-
base.LAST_QWEN_ERROR = ""
|
| 223 |
-
return txt
|
| 224 |
-
if getattr(base, "LAST_QWEN_ERROR", ""):
|
| 225 |
-
errors.append("sdk: " + str(base.LAST_QWEN_ERROR)[:260])
|
| 226 |
-
except Exception as e:
|
| 227 |
-
errors.append(f"sdk: {type(e).__name__}: {str(e)[:260]}")
|
| 228 |
-
if token:
|
| 229 |
-
models = []
|
| 230 |
-
for m in [
|
| 231 |
-
os.getenv("QWEN_VL_MODEL", ""),
|
| 232 |
-
"Qwen/Qwen2.5-VL-7B-Instruct",
|
| 233 |
-
"Qwen/Qwen2.5-VL-3B-Instruct",
|
| 234 |
-
"Qwen/Qwen2.5-7B-Instruct",
|
| 235 |
-
"Qwen/Qwen2.5-3B-Instruct",
|
| 236 |
-
"Qwen/Qwen2.5-1.5B-Instruct",
|
| 237 |
-
]:
|
| 238 |
-
if m and m not in models:
|
| 239 |
-
models.append(m)
|
| 240 |
-
headers = {"Authorization": "Bearer " + token, "Content-Type": "application/json"}
|
| 241 |
-
for model in models:
|
| 242 |
-
try:
|
| 243 |
-
is_vl = "VL" in model and bool(image_url)
|
| 244 |
-
user_content = ([{"type": "image_url", "image_url": {"url": image_url}}, {"type": "text", "text": prompt}] if is_vl else prompt)
|
| 245 |
-
payload = {
|
| 246 |
-
"model": model,
|
| 247 |
-
"messages": [
|
| 248 |
-
{"role": "system", "content": "Bạn là biên tập viên AI tiếng Việt. Chỉ tóm tắt súc tích nội dung nguồn, không viết lại toàn bài, không lặp ý, không bịa chi tiết."},
|
| 249 |
-
{"role": "user", "content": user_content},
|
| 250 |
-
],
|
| 251 |
-
"max_tokens": min(int(max_tokens or 900), 1400),
|
| 252 |
-
"temperature": 0.35,
|
| 253 |
-
"top_p": 0.85,
|
| 254 |
-
}
|
| 255 |
-
r = requests.post("https://router.huggingface.co/v1/chat/completions", headers=headers, json=payload, timeout=95)
|
| 256 |
-
if r.status_code >= 300:
|
| 257 |
-
errors.append(f"{model}: HTTP {r.status_code} {r.text[:180]}")
|
| 258 |
-
continue
|
| 259 |
-
j = r.json()
|
| 260 |
-
txt = (j.get("choices", [{}])[0].get("message", {}).get("content") or "").strip()
|
| 261 |
-
if txt:
|
| 262 |
-
base.LAST_QWEN_ERROR = ""
|
| 263 |
-
return txt
|
| 264 |
-
errors.append(f"{model}: empty response")
|
| 265 |
-
except Exception as e:
|
| 266 |
-
errors.append(f"{model}: {type(e).__name__}: {str(e)[:220]}")
|
| 267 |
-
else:
|
| 268 |
-
errors.append("missing HF_TOKEN")
|
| 269 |
-
base.LAST_QWEN_ERROR = " | ".join(errors[-6:]) or "Qwen unavailable; used extractive fallback"
|
| 270 |
-
print("[qwen resilient fallback]", base.LAST_QWEN_ERROR)
|
| 271 |
-
return _fallback_summary_from_prompt(prompt, max_units=12)
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
if not hasattr(base, "_original_qwen_generate"):
|
| 275 |
-
base._original_qwen_generate = base.qwen_generate
|
| 276 |
-
base.qwen_generate = qwen_generate_resilient
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
@app.get('/api/wall')
|
| 280 |
-
def compat_wall():
|
| 281 |
-
return JSONResponse({'posts': base._load_ai_wall()[:80]})
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
_PATCHED_PATHS = {
|
| 285 |
-
('/api/topic_post', 'POST'),
|
| 286 |
-
('/api/url_wall', 'POST'),
|
| 287 |
-
('/api/rewrite_share', 'POST'),
|
| 288 |
-
('/api/ai/short/{post_id}', 'POST'),
|
| 289 |
-
}
|
| 290 |
-
app.router.routes = [
|
| 291 |
-
r for r in app.router.routes
|
| 292 |
-
if not any(getattr(r, 'path', None) == p and m in getattr(r, 'methods', set()) for p, m in _PATCHED_PATHS)
|
| 293 |
-
]
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
@app.post('/api/topic_post')
|
| 297 |
-
async def compat_topic_post(request: Request):
|
| 298 |
-
body = await request.json()
|
| 299 |
-
topic = base._clean_text(body.get('topic', ''))
|
| 300 |
-
if not topic:
|
| 301 |
-
return JSONResponse({'error': 'missing topic'}, status_code=400)
|
| 302 |
-
articles = _topic_source_articles(topic, limit=4)
|
| 303 |
-
if not articles:
|
| 304 |
-
return JSONResponse({'error': 'Không lấy được bài viết nguồn cho chủ đề này.'}, status_code=422)
|
| 305 |
-
new_posts = []
|
| 306 |
-
posts = base._load_ai_wall()
|
| 307 |
-
for art in articles:
|
| 308 |
-
prompt = f"""Tóm tắt RIÊNG bài viết nguồn sau để đăng Tường AI.
|
| 309 |
-
|
| 310 |
-
Chủ đề lọc: {topic}
|
| 311 |
-
Tiêu đề bài nguồn: {art['title']}
|
| 312 |
-
Nguồn: {art['via']}
|
| 313 |
-
|
| 314 |
-
Yêu cầu bắt buộc:
|
| 315 |
-
- Tóm tắt nội dung trong BÀI VIẾT này, không chỉ tiêu đề.
|
| 316 |
-
- Không trộn với bài khác.
|
| 317 |
-
- Không viết lại toàn bộ bài.
|
| 318 |
-
- Không lặp ý.
|
| 319 |
-
- 4-6 gạch đầu dòng, mỗi dòng 1 câu rõ ràng.
|
| 320 |
-
- Giữ số liệu/nhân vật/thời điểm quan trọng nếu có.
|
| 321 |
-
|
| 322 |
-
Nội dung bài:
|
| 323 |
-
{art['raw'][:14000]}"""
|
| 324 |
-
text = await base.qwen_generate(prompt, image_url=art.get('image') or None, max_tokens=1500)
|
| 325 |
-
text = _postprocess_ai_text(text, max_units=20)
|
| 326 |
-
src = [art['source']]
|
| 327 |
-
if 'Nguồn tham khảo:' not in text:
|
| 328 |
-
text += "\n\n" + _source_line(src)
|
| 329 |
-
post = base.make_post(art['title'], text, art.get('image') or base.pollinations_image_url(art['title']), art.get('url') or '', 'topic_article', sources=src)
|
| 330 |
-
|
| 331 |
-
# Generate slides for this post so they persist after page reload
|
| 332 |
-
try:
|
| 333 |
-
page_data = _scrape_article_images(art.get('url', ''))
|
| 334 |
-
if page_data and page_data.get('paragraphs'):
|
| 335 |
-
key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12)
|
| 336 |
-
if key_points:
|
| 337 |
-
relevant_imgs = page_data.get('images', [])
|
| 338 |
-
if not relevant_imgs and page_data.get('og_img'):
|
| 339 |
-
relevant_imgs = [page_data['og_img']]
|
| 340 |
-
slides = []
|
| 341 |
-
for i, point in enumerate(key_points):
|
| 342 |
-
img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
|
| 343 |
-
slides.append({'text': point, 'image': img, 'index': i + 1})
|
| 344 |
-
post['slides'] = slides
|
| 345 |
-
except Exception:
|
| 346 |
-
pass
|
| 347 |
-
|
| 348 |
-
new_posts.append(post)
|
| 349 |
-
posts = new_posts + posts
|
| 350 |
-
base._save_ai_wall(posts)
|
| 351 |
-
return JSONResponse({'post': new_posts[0], 'posts': new_posts, 'count': len(new_posts)})
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
@app.post('/api/url_wall')
|
| 355 |
-
async def compat_url_wall(request: Request):
|
| 356 |
-
body = await request.json()
|
| 357 |
-
url = base._clean_text(body.get('url', ''))
|
| 358 |
-
if not url.startswith('http'):
|
| 359 |
-
return JSONResponse({'error': 'missing url'}, status_code=400)
|
| 360 |
-
try:
|
| 361 |
-
data = base.scrape_any_url(url)
|
| 362 |
-
except Exception as e:
|
| 363 |
-
return JSONResponse({'error': 'Không scrape được URL: ' + str(e)[:180]}, status_code=422)
|
| 364 |
-
raw = (data.get('summary', '') + '\n' + data.get('text', '')).strip()
|
| 365 |
-
if len(raw) < 120:
|
| 366 |
-
return JSONResponse({'error': 'URL không có đủ nội dung để tóm tắt'}, status_code=422)
|
| 367 |
-
prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
|
| 368 |
-
text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=1500)
|
| 369 |
-
text = _postprocess_ai_text(text, max_units=20)
|
| 370 |
-
src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
|
| 371 |
-
if 'Nguồn tham khảo:' not in text:
|
| 372 |
-
text += "\n\n" + _source_line(src)
|
| 373 |
-
post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'url', sources=src)
|
| 374 |
-
|
| 375 |
-
# Generate slides so they persist after page reload
|
| 376 |
-
slides = []
|
| 377 |
-
try:
|
| 378 |
-
page_data = _scrape_article_images(url)
|
| 379 |
-
if page_data and page_data.get('paragraphs'):
|
| 380 |
-
key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12)
|
| 381 |
-
if key_points:
|
| 382 |
-
relevant_imgs = page_data.get('images', [])
|
| 383 |
-
if not relevant_imgs and page_data.get('og_img'):
|
| 384 |
-
relevant_imgs = [page_data['og_img']]
|
| 385 |
-
for i, point in enumerate(key_points):
|
| 386 |
-
img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
|
| 387 |
-
slides.append({'text': point, 'image': img, 'index': i + 1})
|
| 388 |
-
except Exception:
|
| 389 |
-
pass
|
| 390 |
-
post['slides'] = slides
|
| 391 |
-
|
| 392 |
-
posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
|
| 393 |
-
return JSONResponse({'post': post, 'slides': slides})
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
def _is_relevant_image(img_url, title, text):
|
| 397 |
-
"""Check if an image is relevant to the article content."""
|
| 398 |
-
if not img_url:
|
| 399 |
-
return False
|
| 400 |
-
skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif',
|
| 401 |
-
'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite',
|
| 402 |
-
'advertisement', 'ad-banner', 'sponsored', 'banner-ads']
|
| 403 |
-
img_lower = img_url.lower()
|
| 404 |
-
for p in skip_patterns:
|
| 405 |
-
if p in img_lower:
|
| 406 |
-
return False
|
| 407 |
-
if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']):
|
| 408 |
-
return False
|
| 409 |
-
return True
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
def _filter_relevant_images(images, title, text, max_images=8):
|
| 413 |
-
"""Filter and rank images by relevance to article content."""
|
| 414 |
-
if not images:
|
| 415 |
-
return []
|
| 416 |
-
seen = set()
|
| 417 |
-
relevant = []
|
| 418 |
-
for img in images:
|
| 419 |
-
if img in seen:
|
| 420 |
-
continue
|
| 421 |
-
seen.add(img)
|
| 422 |
-
if _is_relevant_image(img, title, text):
|
| 423 |
-
relevant.append(img)
|
| 424 |
-
return relevant[:max_images]
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
def _extract_key_points_for_slides(paragraphs, max_points=12):
|
| 428 |
-
"""Extract key points from paragraphs for slides - extracts ALL sentences, not just first one."""
|
| 429 |
-
points = []
|
| 430 |
-
for p in paragraphs:
|
| 431 |
-
if len(points) >= max_points:
|
| 432 |
-
break
|
| 433 |
-
p = _clean(p)
|
| 434 |
-
if not p:
|
| 435 |
-
continue
|
| 436 |
-
# Split paragraph into sentences using Vietnamese + English punctuation - GET ALL SENTENCES
|
| 437 |
-
sentences = re.split(r'(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])', p)
|
| 438 |
-
sentences = [s.strip() for s in sentences if s.strip()]
|
| 439 |
-
|
| 440 |
-
for sentence in sentences:
|
| 441 |
-
if len(points) >= max_points:
|
| 442 |
-
break
|
| 443 |
-
sentence = _clean(sentence)
|
| 444 |
-
if len(sentence) < 30:
|
| 445 |
-
continue
|
| 446 |
-
if any(sentence[:60] in existing for existing in points):
|
| 447 |
-
continue
|
| 448 |
-
if not sentence.endswith(('.', '!', '?')):
|
| 449 |
-
sentence = sentence + '.'
|
| 450 |
-
points.append(sentence)
|
| 451 |
-
return points
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
def _scrape_article_images(url):
|
| 455 |
-
"""Scrape article page and return only relevant images."""
|
| 456 |
-
try:
|
| 457 |
-
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
| 458 |
-
"Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"}
|
| 459 |
-
r = requests.get(url, headers=headers, timeout=15, allow_redirects=True)
|
| 460 |
-
r.encoding = 'utf-8'
|
| 461 |
-
soup = BeautifulSoup(r.text, 'lxml')
|
| 462 |
-
for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
|
| 463 |
-
tag.decompose()
|
| 464 |
-
h1 = soup.find('h1')
|
| 465 |
-
ogt = soup.find('meta', property='og:title')
|
| 466 |
-
title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '')
|
| 467 |
-
ogi = soup.find('meta', property='og:image')
|
| 468 |
-
og_img = ogi.get('content', '') if ogi else ''
|
| 469 |
-
if og_img and og_img.startswith('//'):
|
| 470 |
-
og_img = 'https:' + og_img
|
| 471 |
-
block = None
|
| 472 |
-
for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
|
| 473 |
-
el = soup.select_one(sel)
|
| 474 |
-
if el and len(el.find_all('p')) >= 2:
|
| 475 |
-
block = el
|
| 476 |
-
break
|
| 477 |
-
if not block:
|
| 478 |
-
block = soup.body or soup
|
| 479 |
-
paragraphs = []
|
| 480 |
-
all_images = []
|
| 481 |
-
seen_imgs = set()
|
| 482 |
-
if og_img and og_img not in seen_imgs:
|
| 483 |
-
all_images.append(og_img)
|
| 484 |
-
seen_imgs.add(og_img)
|
| 485 |
-
for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
|
| 486 |
-
if el.name == 'p':
|
| 487 |
-
t = _clean(el.get_text(strip=True))
|
| 488 |
-
if t and len(t) > 40:
|
| 489 |
-
paragraphs.append(t)
|
| 490 |
-
elif el.name in ('figure', 'img'):
|
| 491 |
-
im = el if el.name == 'img' else el.find('img')
|
| 492 |
-
if im:
|
| 493 |
-
src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
|
| 494 |
-
if src and 'base64' not in src:
|
| 495 |
-
if src.startswith('//'):
|
| 496 |
-
src = 'https:' + src
|
| 497 |
-
if src not in seen_imgs:
|
| 498 |
-
all_images.append(src)
|
| 499 |
-
seen_imgs.add(src)
|
| 500 |
-
relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5]))
|
| 501 |
-
return {'title': _clean(title), 'paragraphs': paragraphs, 'images': relevant_images, 'og_img': og_img}
|
| 502 |
-
except Exception:
|
| 503 |
-
return None
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
@app.post('/api/rewrite_share')
|
| 507 |
-
async def compat_rewrite_share(request: Request):
|
| 508 |
-
body = await request.json()
|
| 509 |
-
url = base._clean_text(body.get('url', ''))
|
| 510 |
-
if not url.startswith('http'):
|
| 511 |
-
return JSONResponse({'error': 'missing url'}, status_code=400)
|
| 512 |
-
try:
|
| 513 |
-
data = base.scrape_any_url(url)
|
| 514 |
-
except Exception as e:
|
| 515 |
-
return JSONResponse({'error': 'Không đọc được bài viết: ' + str(e)[:180]}, status_code=422)
|
| 516 |
-
raw = (data.get('summary', '') + '\n' + data.get('text', '')).strip()
|
| 517 |
-
if len(raw) < 120:
|
| 518 |
-
return JSONResponse({'error': 'Bài viết không đủ nội dung để tóm tắt'}, status_code=422)
|
| 519 |
-
prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
|
| 520 |
-
text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=1500)
|
| 521 |
-
text = _postprocess_ai_text(text, max_units=20)
|
| 522 |
-
src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
|
| 523 |
-
if 'Nguồn tham khảo:' not in text:
|
| 524 |
-
text += "\n\n" + _source_line(src)
|
| 525 |
-
post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'summary', sources=src)
|
| 526 |
-
|
| 527 |
-
# Generate slides with relevant images only
|
| 528 |
-
slides = []
|
| 529 |
-
page_data = _scrape_article_images(url)
|
| 530 |
-
if page_data and page_data.get('paragraphs'):
|
| 531 |
-
key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12)
|
| 532 |
-
if key_points:
|
| 533 |
-
relevant_imgs = page_data.get('images', [])
|
| 534 |
-
if not relevant_imgs and page_data.get('og_img'):
|
| 535 |
-
relevant_imgs = [page_data['og_img']]
|
| 536 |
-
for i, point in enumerate(key_points):
|
| 537 |
-
img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
|
| 538 |
-
slides.append({'text': point, 'image': img, 'index': i + 1})
|
| 539 |
-
|
| 540 |
-
# FIX: Save slides into post so they persist after page reload
|
| 541 |
-
post['slides'] = slides
|
| 542 |
-
posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
|
| 543 |
-
|
| 544 |
-
return JSONResponse({'post': post, 'slides': slides})
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
def _emotion_script(text, emotion):
|
| 548 |
-
"""Prepend emotion-appropriate prefix to text based on emotion type.
|
| 549 |
-
|
| 550 |
-
NOTE: Prefix is NOT added to avoid cluttering Short AI speech.
|
| 551 |
-
The emotion is still used for voice selection but content is read cleanly.
|
| 552 |
-
"""
|
| 553 |
-
text = _clean(text)
|
| 554 |
-
# REMOVED: No prefix added to keep content clean and natural
|
| 555 |
-
return text
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
def _tts_script_smart(post, emotion):
|
| 559 |
-
raw = base._short_script(post) if hasattr(base, '_short_script') else _clean(post.get('text', '') or post.get('title', ''))
|
| 560 |
-
raw = re.sub(r"^[•\-\*]\s*", "", raw, flags=re.M)
|
| 561 |
-
raw = re.sub(r"\s*\n\s*", ". ", raw)
|
| 562 |
-
raw = re.sub(r"([\.\!\?])\s*", r"\1\n", raw)
|
| 563 |
-
raw = re.sub(r"\n{2,}", "\n", raw).strip()
|
| 564 |
-
# REMOVED: _emotion_script call - read content cleanly without prefix
|
| 565 |
-
# INCREASED to 3000 to read full content of all bullet points
|
| 566 |
-
if len(raw) > 3000:
|
| 567 |
-
raw = raw[:3000]
|
| 568 |
-
cut = max(raw.rfind("."), raw.rfind("!"), raw.rfind("?"))
|
| 569 |
-
if cut > 700:
|
| 570 |
-
raw = raw[:cut + 1]
|
| 571 |
-
return raw
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
def _split_subtitle_sentences(script):
|
| 575 |
-
parts = []
|
| 576 |
-
for line in script.splitlines():
|
| 577 |
-
line = _clean(line)
|
| 578 |
-
if not line:
|
| 579 |
-
continue
|
| 580 |
-
for s in re.split(r"(?<=[\.\!\?])\s+", line):
|
| 581 |
-
s = _clean(s)
|
| 582 |
-
if 8 <= len(s) <= 140:
|
| 583 |
-
parts.append(s)
|
| 584 |
-
return parts[:12]
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
def _srt_time(sec):
|
| 588 |
-
ms = int((sec - int(sec)) * 1000)
|
| 589 |
-
sec = int(sec)
|
| 590 |
-
h = sec // 3600
|
| 591 |
-
m = (sec % 3600) // 60
|
| 592 |
-
s = sec % 60
|
| 593 |
-
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
def _write_srt(script, path, total_duration=30):
|
| 597 |
-
subs = _split_subtitle_sentences(script)
|
| 598 |
-
if not subs:
|
| 599 |
-
subs = [script[:120]]
|
| 600 |
-
dur = max(2.2, min(5.0, total_duration / max(1, len(subs))))
|
| 601 |
-
cur = 0.3
|
| 602 |
-
with open(path, 'w', encoding='utf-8') as f:
|
| 603 |
-
for i, s in enumerate(subs, 1):
|
| 604 |
-
start = cur
|
| 605 |
-
end = cur + dur
|
| 606 |
-
cur = end + 0.15
|
| 607 |
-
f.write(f"{i}\n{_srt_time(start)} --> {_srt_time(end)}\n{s}\n\n")
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
def _wrap_text_px(draw, text, font, max_width, max_lines):
|
| 611 |
-
words = _clean(text).split()
|
| 612 |
-
lines, cur = [], ""
|
| 613 |
-
for w in words:
|
| 614 |
-
test = (cur + " " + w).strip()
|
| 615 |
-
try:
|
| 616 |
-
width = draw.textbbox((0, 0), test, font=font)[2]
|
| 617 |
-
except Exception:
|
| 618 |
-
width = len(test) * 20
|
| 619 |
-
if width <= max_width:
|
| 620 |
-
cur = test
|
| 621 |
-
else:
|
| 622 |
-
if cur:
|
| 623 |
-
lines.append(cur)
|
| 624 |
-
cur = w
|
| 625 |
-
if len(lines) >= max_lines:
|
| 626 |
-
break
|
| 627 |
-
if cur and len(lines) < max_lines:
|
| 628 |
-
lines.append(cur)
|
| 629 |
-
return lines
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
def _make_short_frame_full(post, img_path, out_path):
|
| 633 |
-
if Image is None:
|
| 634 |
-
return base._make_short_frame(post, img_path, out_path)
|
| 635 |
-
W, H = 1080, 1920
|
| 636 |
-
bg = Image.new("RGB", (W, H), (14, 14, 14))
|
| 637 |
-
try:
|
| 638 |
-
im = Image.open(img_path).convert("RGB")
|
| 639 |
-
target = (1080, 760)
|
| 640 |
-
im_ratio = im.width / im.height
|
| 641 |
-
target_ratio = target[0] / target[1]
|
| 642 |
-
if im_ratio > target_ratio:
|
| 643 |
-
new_h = target[1]
|
| 644 |
-
new_w = int(new_h * im_ratio)
|
| 645 |
-
else:
|
| 646 |
-
new_w = target[0]
|
| 647 |
-
new_h = int(new_w / im_ratio)
|
| 648 |
-
im = im.resize((new_w, new_h))
|
| 649 |
-
left = (new_w - target[0]) // 2
|
| 650 |
-
top = (new_h - target[1]) // 2
|
| 651 |
-
im = im.crop((left, top, left + target[0], top + target[1]))
|
| 652 |
-
bg.paste(im, (0, 0))
|
| 653 |
-
except Exception:
|
| 654 |
-
pass
|
| 655 |
-
draw = ImageDraw.Draw(bg)
|
| 656 |
-
try:
|
| 657 |
-
font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 54)
|
| 658 |
-
font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 38)
|
| 659 |
-
font_label = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 30)
|
| 660 |
-
except Exception:
|
| 661 |
-
font_title = font_body = font_label = None
|
| 662 |
-
draw.rectangle((0, 720, W, H), fill=(14, 14, 14))
|
| 663 |
-
margin = 48
|
| 664 |
-
maxw = W - margin * 2
|
| 665 |
-
draw.text((margin, 770), "VNEWS · Tường AI", fill=(92, 184, 122), font=font_label)
|
| 666 |
-
y = 830
|
| 667 |
-
for ln in _wrap_text_px(draw, post.get("title", ""), font_title, maxw, 4):
|
| 668 |
-
draw.text((margin, y), ln, fill=(255, 255, 255), font=font_title)
|
| 669 |
-
y += 66
|
| 670 |
-
y += 18
|
| 671 |
-
text = post.get("text", "")
|
| 672 |
-
text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip()
|
| 673 |
-
body_lines = _wrap_text_px(draw, text, font_body, maxw, 14)
|
| 674 |
-
for ln in body_lines:
|
| 675 |
-
draw.text((margin, y), ln, fill=(220, 220, 220), font=font_body)
|
| 676 |
-
y += 50
|
| 677 |
-
if y > 1640:
|
| 678 |
-
break
|
| 679 |
-
bg.save(out_path, quality=92)
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
def _summary_segments_from_post(post, max_segments=25):
|
| 685 |
-
raw = _clean(post.get('text') or post.get('title') or '')
|
| 686 |
-
raw = re.sub(r'^Bản tin AI viết lại:\s*', '', raw, flags=re.I)
|
| 687 |
-
raw = re.sub(r'Nguồn tham khảo:.*$', '', raw, flags=re.I|re.S).strip()
|
| 688 |
-
lines=[]
|
| 689 |
-
for ln in raw.splitlines():
|
| 690 |
-
ln=_clean(re.sub(r'^[•\-\*\d\.\)\s]+','',ln))
|
| 691 |
-
if not ln: continue
|
| 692 |
-
low=ln.lower()
|
| 693 |
-
if low.startswith(('điểm chính','tiêu đề','sapo','nguồn tham khảo')): continue
|
| 694 |
-
if len(ln)>=18: lines.append(ln)
|
| 695 |
-
if len(lines)<3:
|
| 696 |
-
lines=[]
|
| 697 |
-
for s in re.split(r'(?<=[\.\!\?])\s+', raw):
|
| 698 |
-
s=_clean(s)
|
| 699 |
-
if len(s)>=25: lines.append(s)
|
| 700 |
-
segs=_dedupe_units(lines, max_units=max_segments)
|
| 701 |
-
return segs[:max_segments] if segs else [post.get('title','Bản tin VNEWS')]
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='neutral'):
|
| 705 |
-
if Image is None:
|
| 706 |
-
return _make_short_frame_full(post, img_path, out_path)
|
| 707 |
-
W,H=1080,1920
|
| 708 |
-
bg=Image.new('RGB',(W,H),(10,10,10))
|
| 709 |
-
try:
|
| 710 |
-
im=Image.open(img_path).convert('RGB')
|
| 711 |
-
ratio=im.width/max(1,im.height); target=W/H
|
| 712 |
-
if ratio>target:
|
| 713 |
-
nh=H; nw=int(nh*ratio)
|
| 714 |
-
else:
|
| 715 |
-
nw=W; nh=int(nw/ratio)
|
| 716 |
-
cover=im.resize((nw,nh)); left=(nw-W)//2; top=(nh-H)//2
|
| 717 |
-
cover=cover.crop((left,top,left+W,top+H))
|
| 718 |
-
bg.paste(cover,(0,0))
|
| 719 |
-
bg=Image.blend(bg, Image.new('RGB',(W,H),(0,0,0)), 0.50)
|
| 720 |
-
hero_h=720; target=W/hero_h
|
| 721 |
-
if ratio>target:
|
| 722 |
-
nh=hero_h; nw=int(nh*ratio)
|
| 723 |
-
else:
|
| 724 |
-
nw=W; nh=int(nw/ratio)
|
| 725 |
-
hero=im.resize((nw,nh)); left=(nw-W)//2; top=(nh-hero_h)//2
|
| 726 |
-
hero=hero.crop((left,top,left+W,top+hero_h))
|
| 727 |
-
bg.paste(hero,(0,0))
|
| 728 |
-
except Exception:
|
| 729 |
-
pass
|
| 730 |
-
draw=ImageDraw.Draw(bg)
|
| 731 |
-
try:
|
| 732 |
-
font_brand=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',34)
|
| 733 |
-
font_small=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',28)
|
| 734 |
-
font_seg=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',58)
|
| 735 |
-
font_title=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',34)
|
| 736 |
-
except Exception:
|
| 737 |
-
font_brand=font_small=font_seg=font_title=None
|
| 738 |
-
draw.rectangle((0,680,W,H), fill=(12,12,12))
|
| 739 |
-
dot_x=48; dot_y=742
|
| 740 |
-
for i in range(total):
|
| 741 |
-
fill=(92,184,122) if i==idx else (70,70,70)
|
| 742 |
-
draw.rounded_rectangle((dot_x+i*38,dot_y,dot_x+i*38+24,dot_y+10), radius=5, fill=fill)
|
| 743 |
-
draw.text((48,780),'VNEWS AI SHORT',fill=(110,231,143),font=font_brand)
|
| 744 |
-
draw.rounded_rectangle((48,834,260,880), radius=20, fill=(28,70,45))
|
| 745 |
-
draw.text((66,842),f'Đoạn {idx+1}/{total}',fill=(235,235,235),font=font_small)
|
| 746 |
-
y=940; maxw=W-96
|
| 747 |
-
# INCREASED from 12 to 18 for full content display - each key point can span multiple lines
|
| 748 |
-
for ln in _wrap_text_px(draw, segment, font_seg, maxw, 18):
|
| 749 |
-
draw.text((48,y),ln,fill=(255,255,255),font=font_seg)
|
| 750 |
-
y+=74
|
| 751 |
-
if y>1500: break
|
| 752 |
-
y2=1640
|
| 753 |
-
draw.line((48,y2-22,W-48,y2-22),fill=(70,70,70),width=2)
|
| 754 |
-
for ln in _wrap_text_px(draw, post.get('title',''), font_title, maxw, 3):
|
| 755 |
-
draw.text((48,y2),ln,fill=(220,220,220),font=font_title)
|
| 756 |
-
y2+=46
|
| 757 |
-
bg.save(out_path, quality=92)
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
def _estimate_audio_duration(path, fallback=15.0):
|
| 761 |
-
"""Estimate audio duration with 15s minimum per segment for complete bullet reading."""
|
| 762 |
-
try:
|
| 763 |
-
pr=subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:no_key=1',path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
|
| 764 |
-
return max(12.0, float((pr.stdout or b'').decode().strip() or fallback))
|
| 765 |
-
except Exception:
|
| 766 |
-
return fallback
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
@app.post('/api/ai/short/{post_id}')
|
| 770 |
-
async def patched_ai_short(post_id: str, request: Request):
|
| 771 |
-
try:
|
| 772 |
-
body = await request.json()
|
| 773 |
-
except Exception:
|
| 774 |
-
body = {}
|
| 775 |
-
voice = str(body.get('voice', 'nu')).strip().lower()
|
| 776 |
-
emotion = str(body.get('emotion', 'neutral')).strip().lower()
|
| 777 |
-
speed = float(body.get('speed', 1.0) or 1.0)
|
| 778 |
-
speed = max(0.85, min(1.35, speed))
|
| 779 |
-
|
| 780 |
-
posts = base._load_ai_wall()
|
| 781 |
-
post = next((p for p in posts if str(p.get('id')) == str(post_id)), None)
|
| 782 |
-
if not post:
|
| 783 |
-
return JSONResponse({'error': 'post not found'}, status_code=404)
|
| 784 |
-
|
| 785 |
-
segments = _summary_segments_from_post(post, max_segments=25)
|
| 786 |
-
seg_hash = hashlib.md5(('|'.join(segments)+voice+emotion+str(speed)).encode('utf-8')).hexdigest()[:8]
|
| 787 |
-
os.makedirs(base.SHORTS_DIR, exist_ok=True)
|
| 788 |
-
suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_{seg_hash}_scenes_nosub"
|
| 789 |
-
out_mp4 = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix) + '.mp4')
|
| 790 |
-
if os.path.exists(out_mp4):
|
| 791 |
-
post['video'] = '/api/ai/short-file/' + post_id + suffix
|
| 792 |
-
post['short_voice'] = voice
|
| 793 |
-
post['short_emotion'] = emotion
|
| 794 |
-
post['short_speed'] = speed
|
| 795 |
-
post['short_segments'] = segments
|
| 796 |
-
post['short_subtitles'] = False
|
| 797 |
-
base._save_ai_wall(posts)
|
| 798 |
-
return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': False, 'segments': segments})
|
| 799 |
-
if base.gTTS is None:
|
| 800 |
-
return JSONResponse({'error': 'gTTS chưa sẵn sàng'}, status_code=503)
|
| 801 |
-
|
| 802 |
-
work = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix))
|
| 803 |
-
os.makedirs(work, exist_ok=True)
|
| 804 |
-
img = os.path.join(work, 'image.jpg')
|
| 805 |
-
try:
|
| 806 |
-
base._download_image(post.get('img'), post.get('title', 'AI news'), img)
|
| 807 |
-
edge_voice = {
|
| 808 |
-
# Vietnamese
|
| 809 |
-
'vi-vn-hoaimyneural': 'vi-VN-HoaiMyNeural',
|
| 810 |
-
'vi-vn-namminhneural': 'vi-VN-NamMinhNeural',
|
| 811 |
-
'hoaimy': 'vi-VN-HoaiMyNeural',
|
| 812 |
-
'namminh': 'vi-VN-NamMinhNeural',
|
| 813 |
-
'nam': 'vi-VN-NamMinhNeural',
|
| 814 |
-
'male': 'vi-VN-NamMinhNeural',
|
| 815 |
-
'nu': 'vi-VN-HoaiMyNeural',
|
| 816 |
-
'female': 'vi-VN-HoaiMyNeural',
|
| 817 |
-
'mien-nam': 'vi-VN-HoaiMyNeural',
|
| 818 |
-
# English - Multilingual
|
| 819 |
-
'en-us-andrewmultilingualneural': 'en-US-AndrewMultilingualNeural',
|
| 820 |
-
'en-au-williammultilingualneural': 'en-AU-WilliamMultilingualNeural',
|
| 821 |
-
'andrew': 'en-US-AndrewMultilingualNeural',
|
| 822 |
-
'en_andrew': 'en-US-AndrewMultilingualNeural',
|
| 823 |
-
'jenny': 'en-US-AndrewMultilingualNeural',
|
| 824 |
-
'en_jenny': 'en-US-AndrewMultilingualNeural',
|
| 825 |
-
# Portuguese - Multilingual (ONLY Thalita)
|
| 826 |
-
'pt-br-thalitamultilingualneural': 'pt-BR-ThalitaMultilingualNeural',
|
| 827 |
-
'thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 828 |
-
'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 829 |
-
'pt_br_thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 830 |
-
'pt': 'pt-BR-ThalitaMultilingualNeural',
|
| 831 |
-
'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
|
| 832 |
-
# French - Multilingual
|
| 833 |
-
'fr-fr-viviennemultilingualneural': 'fr-FR-VivienneMultilingualNeural',
|
| 834 |
-
'fr-fr-remymultilingualneural': 'fr-FR-RemyMultilingualNeural',
|
| 835 |
-
'denise': 'fr-FR-VivienneMultilingualNeural',
|
| 836 |
-
'fr': 'fr-FR-VivienneMultilingualNeural',
|
| 837 |
-
'fr_denise': 'fr-FR-VivienneMultilingualNeural',
|
| 838 |
-
# German - Multilingual
|
| 839 |
-
'de-de-seraphinamultilingualneural': 'de-DE-SeraphinaMultilingualNeural',
|
| 840 |
-
'de-de-florianmultilingualneural': 'de-DE-FlorianMultilingualNeural',
|
| 841 |
-
'katja': 'de-DE-SeraphinaMultilingualNeural',
|
| 842 |
-
'de': 'de-DE-SeraphinaMultilingualNeural',
|
| 843 |
-
'de_katja': 'de-DE-SeraphinaMultilingualNeural',
|
| 844 |
-
# Korean - Multilingual (Hyunsu, NOT SunHee)
|
| 845 |
-
'ko-kr-hyusumultilingualneural': 'ko-KR-HyunsuMultilingualNeural',
|
| 846 |
-
'ko-kr-hyunsuneural': 'ko-KR-HyunsuMultilingualNeural',
|
| 847 |
-
'sunhee': 'ko-KR-HyunsuMultilingualNeural',
|
| 848 |
-
'ko': 'ko-KR-HyunsuMultilingualNeural',
|
| 849 |
-
'ko_sunhee': 'ko-KR-HyunsuMultilingualNeural',
|
| 850 |
-
# Italian - Multilingual
|
| 851 |
-
'it-it-giuseppemultilingualneural': 'it-IT-GiuseppeMultilingualNeural',
|
| 852 |
-
# Spanish (keep for backward compat)
|
| 853 |
-
'ela': 'en-US-AndrewMultilingualNeural',
|
| 854 |
-
'es_ela': 'en-US-AndrewMultilingualNeural',
|
| 855 |
-
'es': 'en-US-AndrewMultilingualNeural',
|
| 856 |
-
'es_carlos': 'en-US-AndrewMultilingualNeural',
|
| 857 |
-
# Japanese (keep for backward compat)
|
| 858 |
-
'nanami': 'en-US-AndrewMultilingualNeural',
|
| 859 |
-
'ja': 'en-US-AndrewMultilingualNeural',
|
| 860 |
-
'ja_nanami': 'en-US-AndrewMultilingualNeural',
|
| 861 |
-
# Chinese (keep for backward compat)
|
| 862 |
-
'xiaochen': 'en-US-AndrewMultilingualNeural',
|
| 863 |
-
'zh': 'en-US-AndrewMultilingualNeural',
|
| 864 |
-
'zh_xiaochen': 'en-US-AndrewMultilingualNeural',
|
| 865 |
-
}.get(voice, 'vi-VN-HoaiMyNeural')
|
| 866 |
-
part_files=[]
|
| 867 |
-
for idx, seg in enumerate(segments):
|
| 868 |
-
frame=os.path.join(work,f'frame_{idx:02d}.jpg')
|
| 869 |
-
aud=os.path.join(work,f'voice_{idx:02d}.mp3')
|
| 870 |
-
aud_fast=os.path.join(work,f'voice_{idx:02d}_fast.mp3')
|
| 871 |
-
part=os.path.join(work,f'part_{idx:02d}.mp4')
|
| 872 |
-
_make_scene_frame(post, seg, idx, len(segments), img, frame, emotion=emotion)
|
| 873 |
-
spoken=_emotion_script(seg, emotion)
|
| 874 |
-
try:
|
| 875 |
-
subprocess.run(['python','-m','edge_tts','--voice',edge_voice,'--text',spoken,'--write-media',aud], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
| 876 |
-
except Exception:
|
| 877 |
-
tld='com.vn' if voice in ('nu','female','mien-nam','hoaimy') else 'com'
|
| 878 |
-
try:
|
| 879 |
-
base.gTTS(spoken, lang='vi', tld=tld, slow=False).save(aud)
|
| 880 |
-
except TypeError:
|
| 881 |
-
base.gTTS(spoken, lang='vi', slow=False).save(aud)
|
| 882 |
-
subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
|
| 883 |
-
dur=_estimate_audio_duration(aud_fast, fallback=15.0)+0.35
|
| 884 |
-
subprocess.run(['ffmpeg','-y','-loop','1','-t',str(dur),'-i',frame,'-i',aud_fast,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k',part], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=150)
|
| 885 |
-
part_files.append(part)
|
| 886 |
-
concat=os.path.join(work,'concat.txt')
|
| 887 |
-
with open(concat,'w',encoding='utf-8') as f:
|
| 888 |
-
for p in part_files:
|
| 889 |
-
f.write("file '" + p.replace("'", "'\\''") + "'\n")
|
| 890 |
-
subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',concat,'-c','copy',out_mp4], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
|
| 891 |
-
post['video'] = '/api/ai/short-file/' + post_id + suffix
|
| 892 |
-
post['short_voice'] = voice
|
| 893 |
-
post['short_emotion'] = emotion
|
| 894 |
-
post['short_speed'] = speed
|
| 895 |
-
post['short_segments'] = segments
|
| 896 |
-
post['short_subtitles'] = False
|
| 897 |
-
base._save_ai_wall(posts)
|
| 898 |
-
return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': False, 'segments': segments})
|
| 899 |
-
except Exception as e:
|
| 900 |
-
return JSONResponse({'error': 'Không tạo được shorts: ' + str(e)[:220]}, status_code=500)
|
| 901 |
-
|
| 902 |
-
|
| 903 |
-
@app.get('/api/ai/short-file/{file_id}')
|
| 904 |
-
def patched_ai_short_file(file_id: str):
|
| 905 |
-
path = os.path.join(base.SHORTS_DIR, base._safe_name(file_id) + '.mp4')
|
| 906 |
-
if not os.path.exists(path):
|
| 907 |
-
return JSONResponse({'error': 'not found'}, status_code=404)
|
| 908 |
-
return FileResponse(path, media_type='video/mp4', filename=f'vnews-ai-{file_id}.mp4')
|
| 909 |
-
|
| 910 |
-
|
| 911 |
-
@app.get('/api/ai_shorts')
|
| 912 |
-
def api_ai_shorts():
|
| 913 |
-
posts = [p for p in base._load_ai_wall() if p.get('video')]
|
| 914 |
-
return JSONResponse({'posts': posts[:80]})
|
| 915 |
-
|
| 916 |
-
|
| 917 |
-
app.router.routes = [r for r in app.router.routes if not (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set()))]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ai_runtime.py
CHANGED
|
@@ -24,8 +24,7 @@ def _domain(url):
|
|
| 24 |
|
| 25 |
|
| 26 |
def _strip_bullet_prefix(s):
|
| 27 |
-
|
| 28 |
-
return clean(re.sub(r'^[\s•\-\*·▪▫●○\d\.\)\(]+', '', s or ''))
|
| 29 |
|
| 30 |
|
| 31 |
def source_line(sources):
|
|
@@ -60,7 +59,6 @@ def _collect_all_images(data):
|
|
| 60 |
|
| 61 |
def _scrape_url_with_images(url):
|
| 62 |
data=base.scrape_any_url(url)
|
| 63 |
-
# extra pass: collect every useful image from original HTML, because some readers only return one image
|
| 64 |
try:
|
| 65 |
import requests
|
| 66 |
from bs4 import BeautifulSoup
|
|
@@ -71,7 +69,6 @@ def _scrape_url_with_images(url):
|
|
| 71 |
src=im.get('data-src') or im.get('data-original') or im.get('data-lazy-src') or im.get('src') or ''
|
| 72 |
if src.startswith('//'):src='https:'+src
|
| 73 |
if src and 'base64' not in src and src not in extra:
|
| 74 |
-
# skip tiny icons/logos as much as possible
|
| 75 |
low=src.lower()
|
| 76 |
if any(x in low for x in ['logo','icon','avatar','sprite']):
|
| 77 |
continue
|
|
@@ -113,11 +110,9 @@ def postprocess(text):
|
|
| 113 |
out=old._postprocess_ai_text(text, max_units=7)
|
| 114 |
else:
|
| 115 |
out=clean(text)
|
| 116 |
-
# keep wall text readable, but ensure short generation later won't show bullets
|
| 117 |
return out
|
| 118 |
|
| 119 |
|
| 120 |
-
# Remove old routes we must override.
|
| 121 |
_PATCH={('/api/topic_post','POST'),('/api/url_wall','POST'),('/api/rewrite_share','POST'),('/api/ai/url','POST'),('/api/ai/short/{post_id}','POST'),('/api/ai/short-file/{file_id}','GET'),('/','GET')}
|
| 122 |
app.router.routes=[r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in _PATCH)]
|
| 123 |
|
|
@@ -130,19 +125,7 @@ async def url_wall_only(request:Request):
|
|
| 130 |
except Exception as e:return JSONResponse({'error':'Không scrape được URL: '+str(e)[:180]},status_code=422)
|
| 131 |
raw=(data.get('summary','')+'\n'+data.get('text','')).strip()
|
| 132 |
if len(raw)<120:return JSONResponse({'error':'URL không có đủ nội dung để tóm tắt'},status_code=422)
|
| 133 |
-
prompt=f"
|
| 134 |
-
|
| 135 |
-
Yêu cầu bắt buộc:
|
| 136 |
-
- Chỉ tóm tắt nội dung chính, không viết lại toàn bộ bài.
|
| 137 |
-
- Ngắn gọn, cụ thể, dễ hiểu.
|
| 138 |
-
- Không lặp lại ý và không thêm chi tiết ngoài nguồn.
|
| 139 |
-
- Tối đa 5 ý chính hoặc 2 đoạn ngắn.
|
| 140 |
-
- Tránh dùng dấu đầu dòng nếu không thật cần thiết.
|
| 141 |
-
|
| 142 |
-
Tiêu đề gốc: {data.get('title','')}
|
| 143 |
-
Nguồn: {data.get('via','') or _domain(url)}
|
| 144 |
-
Nội dung gốc:
|
| 145 |
-
{raw[:16000]}"""
|
| 146 |
text=await base.qwen_generate(prompt,image_url=(data.get('image') or None),max_tokens=900)
|
| 147 |
if not text:text=old._fallback_summary_from_prompt(prompt,max_units=5) if hasattr(old,'_fallback_summary_from_prompt') else raw[:900]
|
| 148 |
text=postprocess(text)
|
|
@@ -168,190 +151,3 @@ async def ai_url_compat(request:Request):
|
|
| 168 |
@app.post('/api/topic_post')
|
| 169 |
async def topic_disabled(request:Request):
|
| 170 |
return JSONResponse({'error':'Đã tắt tạo bài theo chủ đề. Vui lòng dán URL bài viết để AI tóm tắt.'},status_code=410)
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
def split_segments(post,max_segments=8):
|
| 174 |
-
text=clean(post.get('text') or post.get('title') or '')
|
| 175 |
-
text=re.sub(r'Nguồn tham khảo:.*$','',text,flags=re.I|re.S).strip()
|
| 176 |
-
lines=[]
|
| 177 |
-
for ln in text.splitlines():
|
| 178 |
-
ln=_strip_bullet_prefix(ln)
|
| 179 |
-
if len(ln)>=18:lines.append(ln)
|
| 180 |
-
if len(lines)<2:
|
| 181 |
-
lines=[_strip_bullet_prefix(s) for s in re.split(r'(?<=[\.\!\?])\s+',text) if len(_strip_bullet_prefix(s))>=25]
|
| 182 |
-
segs=[];cur=''
|
| 183 |
-
for ln in lines:
|
| 184 |
-
ln=_strip_bullet_prefix(ln)
|
| 185 |
-
if not ln:continue
|
| 186 |
-
if len(cur)+len(ln)<180:cur=(cur+' '+ln).strip()
|
| 187 |
-
else:
|
| 188 |
-
if cur:segs.append(_strip_bullet_prefix(cur))
|
| 189 |
-
cur=ln
|
| 190 |
-
if cur:segs.append(_strip_bullet_prefix(cur))
|
| 191 |
-
return segs[:max_segments] or [_strip_bullet_prefix(post.get('title','VNEWS'))]
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
def wrap_text(draw,text,font,maxw,max_lines):
|
| 195 |
-
words=clean(text).split();lines=[];cur=''
|
| 196 |
-
for w in words:
|
| 197 |
-
test=(cur+' '+w).strip()
|
| 198 |
-
try:width=draw.textbbox((0,0),test,font=font)[2]
|
| 199 |
-
except Exception:width=len(test)*20
|
| 200 |
-
if width<=maxw:cur=test
|
| 201 |
-
else:
|
| 202 |
-
if cur:lines.append(cur)
|
| 203 |
-
cur=w
|
| 204 |
-
if len(lines)>=max_lines:break
|
| 205 |
-
if cur and len(lines)<max_lines:lines.append(cur)
|
| 206 |
-
return lines
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
def _draw_center(draw, lines, font, y, fill, W, line_h):
|
| 210 |
-
for ln in lines:
|
| 211 |
-
try:
|
| 212 |
-
box=draw.textbbox((0,0),ln,font=font);tw=box[2]-box[0]
|
| 213 |
-
except Exception:
|
| 214 |
-
tw=len(ln)*24
|
| 215 |
-
x=max(30,(W-tw)//2)
|
| 216 |
-
draw.text((x,y),ln,fill=fill,font=font)
|
| 217 |
-
y+=line_h
|
| 218 |
-
return y
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
def make_frame(post,seg,idx,total,img_path,out_path):
|
| 222 |
-
if Image is None:raise RuntimeError('Pillow not ready')
|
| 223 |
-
W,H=1080,1920;bg=Image.new('RGB',(W,H),(12,12,12))
|
| 224 |
-
hero_h=760
|
| 225 |
-
try:
|
| 226 |
-
im=Image.open(img_path).convert('RGB');ratio=im.width/max(1,im.height)
|
| 227 |
-
target=(W,hero_h);tr=target[0]/target[1]
|
| 228 |
-
if ratio>tr:nh=target[1];nw=int(nh*ratio)
|
| 229 |
-
else:nw=target[0];nh=int(nw/ratio)
|
| 230 |
-
im=im.resize((nw,nh));left=(nw-target[0])//2;top=(nh-target[1])//2
|
| 231 |
-
bg.paste(im.crop((left,top,left+target[0],top+target[1])),(0,0))
|
| 232 |
-
except Exception:pass
|
| 233 |
-
draw=ImageDraw.Draw(bg)
|
| 234 |
-
try:
|
| 235 |
-
fb=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',58)
|
| 236 |
-
ft=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',38)
|
| 237 |
-
fs=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',30)
|
| 238 |
-
fsmall=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',28)
|
| 239 |
-
except Exception:fb=ft=fs=fsmall=None
|
| 240 |
-
# source badge on top image corner
|
| 241 |
-
badge='Nguồn: '+_source_badge(post)
|
| 242 |
-
try:
|
| 243 |
-
b=draw.textbbox((0,0),badge,font=fsmall);bw=b[2]-b[0];bh=b[3]-b[1]
|
| 244 |
-
except Exception:
|
| 245 |
-
bw=len(badge)*16;bh=34
|
| 246 |
-
bx=W-bw-42;by=24
|
| 247 |
-
draw.rounded_rectangle((bx-16,by-8,W-24,by+bh+14),radius=18,fill=(0,0,0,170))
|
| 248 |
-
draw.text((bx,by),badge,fill=(255,255,255),font=fsmall)
|
| 249 |
-
# bottom text area
|
| 250 |
-
draw.rectangle((0,hero_h-20,W,H),fill=(12,12,12))
|
| 251 |
-
# progress bars centered
|
| 252 |
-
total_w=total*38-14;start=(W-total_w)//2
|
| 253 |
-
for i in range(total):
|
| 254 |
-
fill=(92,184,122) if i==idx else (70,70,70)
|
| 255 |
-
draw.rounded_rectangle((start+i*38,820,start+i*38+24,832),radius=6,fill=fill)
|
| 256 |
-
brand='VNEWS AI SHORT'
|
| 257 |
-
try:
|
| 258 |
-
bb=draw.textbbox((0,0),brand,font=ft);tx=(W-(bb[2]-bb[0]))//2
|
| 259 |
-
except Exception:tx=360
|
| 260 |
-
draw.text((tx,870),brand,fill=(110,231,143),font=ft)
|
| 261 |
-
clean_seg=_strip_bullet_prefix(seg)
|
| 262 |
-
lines=wrap_text(draw,clean_seg,fb,W-120,8)
|
| 263 |
-
block_h=len(lines)*74
|
| 264 |
-
y=max(980, 1250-block_h//2)
|
| 265 |
-
_draw_center(draw,lines,fb,y,(255,255,255),W,74)
|
| 266 |
-
# small title centered near bottom
|
| 267 |
-
title_lines=wrap_text(draw,_strip_bullet_prefix(post.get('title','')),fs,W-120,3)
|
| 268 |
-
y2=1640
|
| 269 |
-
draw.line((80,y2-26,W-80,y2-26),fill=(70,70,70),width=2)
|
| 270 |
-
_draw_center(draw,title_lines,fs,y2,(220,220,220),W,42)
|
| 271 |
-
bg.save(out_path,quality=92)
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
def make_tts(text,voice,out_path):
|
| 275 |
-
v={'nam':'vi-VN-NamMinhNeural','male':'vi-VN-NamMinhNeural','nu':'vi-VN-HoaiMyNeural','female':'vi-VN-HoaiMyNeural','mien-nam':'vi-VN-HoaiMyNeural'}.get(voice,'vi-VN-HoaiMyNeural')
|
| 276 |
-
text=_strip_bullet_prefix(text)
|
| 277 |
-
try:subprocess.run(['python','-m','edge_tts','--voice',v,'--text',text,'--write-media',out_path],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=160)
|
| 278 |
-
except Exception:
|
| 279 |
-
tld='com.vn' if voice in ('nu','female','mien-nam') else 'com'
|
| 280 |
-
try:base.gTTS(text,lang='vi',tld=tld,slow=False).save(out_path)
|
| 281 |
-
except TypeError:base.gTTS(text,lang='vi',slow=False).save(out_path)
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
@app.post('/api/ai/short/{post_id}')
|
| 285 |
-
async def short_segments(post_id:str,request:Request):
|
| 286 |
-
try:body=await request.json()
|
| 287 |
-
except Exception:body={}
|
| 288 |
-
voice=str(body.get('voice','nu')).lower().strip();emotion=str(body.get('emotion','neutral')).lower().strip();speed=max(0.85,min(1.35,float(body.get('speed',1.2) or 1.2)))
|
| 289 |
-
posts=base._load_ai_wall();post=next((p for p in posts if str(p.get('id'))==str(post_id)),None)
|
| 290 |
-
if not post:return JSONResponse({'error':'post not found'},status_code=404)
|
| 291 |
-
segs=split_segments(post,8)
|
| 292 |
-
os.makedirs(base.SHORTS_DIR,exist_ok=True);suffix=f'_{voice}_{emotion}_{str(speed).replace(".","p")}_centered_source_nobullet'
|
| 293 |
-
out=os.path.join(base.SHORTS_DIR,base._safe_name(post_id+suffix)+'.mp4')
|
| 294 |
-
if os.path.exists(out):post['video']='/api/ai/short-file/'+post_id+suffix;base._save_ai_wall(posts);return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False})
|
| 295 |
-
work=os.path.join(base.SHORTS_DIR,base._safe_name(post_id+suffix));os.makedirs(work,exist_ok=True)
|
| 296 |
-
img=os.path.join(work,'image.jpg');base._download_image(post.get('img'),post.get('title','AI news'),img)
|
| 297 |
-
clips=[]
|
| 298 |
-
try:
|
| 299 |
-
for i,seg in enumerate(segs):
|
| 300 |
-
frame=os.path.join(work,f'f{i}.jpg');aud=os.path.join(work,f'a{i}.mp3');aud2=os.path.join(work,f'a{i}_fast.mp3');clip=os.path.join(work,f'c{i}.mp4')
|
| 301 |
-
seg=_strip_bullet_prefix(seg)
|
| 302 |
-
make_frame(post,seg,i,len(segs),img,frame)
|
| 303 |
-
prefix={'urgent':'Tin nhanh.','warm':'Câu chuyện đáng chú ý.','serious':'Bản tin nghiêm túc.','energetic':'Cập nhật nổi bật.'}.get(emotion,'')
|
| 304 |
-
spoken=(prefix+' '+seg).strip() if i==0 and prefix else seg
|
| 305 |
-
make_tts(spoken,voice,aud)
|
| 306 |
-
subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud2],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=120)
|
| 307 |
-
subprocess.run(['ffmpeg','-y','-loop','1','-i',frame,'-i',aud2,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k','-vf','scale=1080:1920',clip],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=180)
|
| 308 |
-
clips.append(clip)
|
| 309 |
-
lf=os.path.join(work,'list.txt')
|
| 310 |
-
with open(lf,'w',encoding='utf-8') as f:
|
| 311 |
-
for c in clips:f.write("file '{}".format(c.replace("'","'\\''"))+"'\n")
|
| 312 |
-
subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',lf,'-c','copy',out],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=240)
|
| 313 |
-
post['video']='/api/ai/short-file/'+post_id+suffix;post['short_subtitles']=False;post['short_segments']=segs;post['short_speed']=speed;base._save_ai_wall(posts)
|
| 314 |
-
return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False})
|
| 315 |
-
except Exception as e:return JSONResponse({'error':'Không tạo được shorts: '+str(e)[:200]},status_code=500)
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
@app.get('/api/ai/short-file/{file_id}')
|
| 319 |
-
def short_file(file_id:str):
|
| 320 |
-
path=os.path.join(base.SHORTS_DIR,base._safe_name(file_id)+'.mp4')
|
| 321 |
-
if not os.path.exists(path):return JSONResponse({'error':'not found'},status_code=404)
|
| 322 |
-
return FileResponse(path,media_type='video/mp4',filename=f'vnews-ai-{file_id}.mp4')
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
# Rebuild / with old UI injection plus final UI overrides.
|
| 326 |
-
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
|
| 327 |
-
@app.get('/')
|
| 328 |
-
async def index_runtime():
|
| 329 |
-
with open('/app/static/index.html','r',encoding='utf-8') as f:html=f.read()
|
| 330 |
-
inject=getattr(old,'PATCH_INJECT','')+r'''
|
| 331 |
-
<style>
|
| 332 |
-
/* Hide old topic UI, keep URL input only */
|
| 333 |
-
#ai-topic-input{display:none!important}
|
| 334 |
-
#ai-topic-input,*[onclick*="createTopicPost"]{display:none!important}
|
| 335 |
-
.ai-topic-row,.topic-row,.ai-compose-topic{display:none!important}
|
| 336 |
-
.ai-wall-gallery{display:grid;grid-template-columns:repeat(2,1fr);gap:6px;margin:10px 0}.ai-wall-gallery img{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:8px;background:#222}.ai-wall-gallery img:first-child{grid-column:1/-1}.ai-url-only-note{font-size:11px;color:#888;margin:5px 0 8px}
|
| 337 |
-
</style>
|
| 338 |
-
<script>
|
| 339 |
-
(function(){
|
| 340 |
-
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}
|
| 341 |
-
function hideTopicControls(){
|
| 342 |
-
document.querySelectorAll('#ai-topic-input').forEach(e=>{let p=e.closest('.ai-compose,.ai-compose-topic,.topic-row,div'); if(p&&p.querySelector('#ai-url-input')) e.style.display='none'; else if(p) p.style.display='none';});
|
| 343 |
-
document.querySelectorAll('button').forEach(b=>{let t=(b.textContent||'').toLowerCase();let oc=b.getAttribute('onclick')||'';if(oc.includes('createTopicPost')||t.includes('chủ đề'))b.style.display='none';});
|
| 344 |
-
let url=document.getElementById('ai-url-input'); if(url&&!document.getElementById('ai-url-only-note')){let n=document.createElement('div');n.id='ai-url-only-note';n.className='ai-url-only-note';n.textContent='Dán URL bài viết để AI tóm tắt và lấy ảnh từ bài.';url.insertAdjacentElement('afterend',n);}
|
| 345 |
-
}
|
| 346 |
-
window.createTopicPost=function(){alert('Đã tắt ô nhập chủ đề. Vui lòng dán URL bài viết.');};
|
| 347 |
-
window.createUrlPost=function(){let inp=document.getElementById('ai-url-input');let url=(inp&&inp.value||'').trim();if(!url)return alert('Dán URL trước');if(!/^https?:\/\//i.test(url))return alert('URL cần bắt đầu bằng http:// hoặc https://');fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&j.post){if(typeof prependWallPost==='function')prependWallPost(j.post);if(window.patchedWall)window.patchedWall=[j.post].concat(window.patchedWall||[]);if(inp)inp.value='';alert('Đã tóm tắt URL, lấy ảnh trong bài và đăng lên Tường AI');location.reload();}else alert(j.error||'Lỗi URL')}).catch(e=>alert(e.message||'Lỗi URL'));};
|
| 348 |
-
function galleryHtml(p){let imgs=(p.images||[]).filter(Boolean);if(!imgs.length&&p.img)imgs=[p.img];if(!imgs.length)return '';return '<div class="ai-wall-gallery">'+imgs.slice(0,12).map(u=>`<img src="${esc(u)}" loading="lazy">`).join('')+'</div>';}
|
| 349 |
-
function patchReaders(){
|
| 350 |
-
let oldRead=window.aiReadWallPatched||window.aiReadWall;
|
| 351 |
-
window.aiReadWallPatched=window.aiReadWall=function(i){let arr=window.patchedWall||window.aiWall||[];let p=arr[i];if(!p&&oldRead)return oldRead(i);if(!p)return;showView('view-article');let sources='';if(p.sources&&p.sources.length){sources='<div class="article-summary"><b>Nguồn tham khảo:</b><br>'+p.sources.slice(0,5).map(s=>`• ${esc(s.title||s.url||'Nguồn')} ${s.url?`(${esc(new URL(s.url).hostname.replace('www.',''))})`:''}`).join('<br>')+'</div>'}let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${esc(p.title)}</h1>${galleryHtml(p)}${sources}<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>${p.video?`<video class="article-img" src="${p.video}" controls playsinline></video>`:''}<div class="article-actions">${p.url?`<button onclick="window.open('${p.url}','_blank')">🔗 Nguồn</button>`:''}<button onclick="aiMakeShortPatched?aiMakeShortPatched(${i}):aiMakeShort(${i})">🎬 Tạo video shorts</button></div></div>`;document.getElementById('view-article').innerHTML=h;window.scrollTo(0,0);};
|
| 352 |
-
}
|
| 353 |
-
setInterval(hideTopicControls,1000);setTimeout(hideTopicControls,300);setTimeout(patchReaders,1600);
|
| 354 |
-
})();
|
| 355 |
-
</script>
|
| 356 |
-
'''
|
| 357 |
-
return HTMLResponse(html.replace('</body>',inject+'\n</body>') if '</body>' in html else html+inject)
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
def _strip_bullet_prefix(s):
|
| 27 |
+
return clean(re.sub(r'^[\s\•\-\*·▪▫●○\d\.\)\(]+', '', s or ''))
|
|
|
|
| 28 |
|
| 29 |
|
| 30 |
def source_line(sources):
|
|
|
|
| 59 |
|
| 60 |
def _scrape_url_with_images(url):
|
| 61 |
data=base.scrape_any_url(url)
|
|
|
|
| 62 |
try:
|
| 63 |
import requests
|
| 64 |
from bs4 import BeautifulSoup
|
|
|
|
| 69 |
src=im.get('data-src') or im.get('data-original') or im.get('data-lazy-src') or im.get('src') or ''
|
| 70 |
if src.startswith('//'):src='https:'+src
|
| 71 |
if src and 'base64' not in src and src not in extra:
|
|
|
|
| 72 |
low=src.lower()
|
| 73 |
if any(x in low for x in ['logo','icon','avatar','sprite']):
|
| 74 |
continue
|
|
|
|
| 110 |
out=old._postprocess_ai_text(text, max_units=7)
|
| 111 |
else:
|
| 112 |
out=clean(text)
|
|
|
|
| 113 |
return out
|
| 114 |
|
| 115 |
|
|
|
|
| 116 |
_PATCH={('/api/topic_post','POST'),('/api/url_wall','POST'),('/api/rewrite_share','POST'),('/api/ai/url','POST'),('/api/ai/short/{post_id}','POST'),('/api/ai/short-file/{file_id}','GET'),('/','GET')}
|
| 117 |
app.router.routes=[r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in _PATCH)]
|
| 118 |
|
|
|
|
| 125 |
except Exception as e:return JSONResponse({'error':'Không scrape được URL: '+str(e)[:180]},status_code=422)
|
| 126 |
raw=(data.get('summary','')+'\n'+data.get('text','')).strip()
|
| 127 |
if len(raw)<120:return JSONResponse({'error':'URL không có đủ nội dung để tóm tắt'},status_code=422)
|
| 128 |
+
prompt=f"Tóm tắt bài viết nguồn dưới đây để đăng lên Tường AI VNEWS.\n\nYêu cầu bắt buộc:\n- Chỉ tóm tắt nội dung chính, không viết lại toàn bộ bài.\n- Ngắn gọn, cụ thể, dễ hiểu.\n- Không lặp lại ý và không thêm chi tiết ngoài nguồn.\n- Tối đa 5 ý chính hoặc 2 đoạn ngắn.\n- Tránh dùng dấu đầu dòng nếu không thật cần thiết.\n\nTiêu đề gốc: {data.get('title','')}\nNguồn: {data.get('via','') or _domain(url)}\nNội dung gốc:\n{raw[:16000]}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
text=await base.qwen_generate(prompt,image_url=(data.get('image') or None),max_tokens=900)
|
| 130 |
if not text:text=old._fallback_summary_from_prompt(prompt,max_units=5) if hasattr(old,'_fallback_summary_from_prompt') else raw[:900]
|
| 131 |
text=postprocess(text)
|
|
|
|
| 151 |
@app.post('/api/topic_post')
|
| 152 |
async def topic_disabled(request:Request):
|
| 153 |
return JSONResponse({'error':'Đã tắt tạo bài theo chủ đề. Vui lòng dán URL bài viết để AI tóm tắt.'},status_code=410)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ai_runtime_final.py
CHANGED
|
@@ -15,7 +15,6 @@ RESTORE_INDEX_URL = "https://huggingface.co/spaces/bep40/vnews/raw/restore-33c3d
|
|
| 15 |
SPACE_URL = "https://bep40-vnews.hf.space"
|
| 16 |
DEFAULT_IMG = "https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg"
|
| 17 |
|
| 18 |
-
# Only voices that support Vietnamese reliably. Extra labels map to these Vietnamese neural voices.
|
| 19 |
VN_VOICES = {
|
| 20 |
"nu": "vi-VN-HoaiMyNeural", "female": "vi-VN-HoaiMyNeural", "hoaimy": "vi-VN-HoaiMyNeural",
|
| 21 |
"nu-tre": "vi-VN-HoaiMyNeural", "nu-truyen-cam": "vi-VN-HoaiMyNeural", "nu-tin-nhanh": "vi-VN-HoaiMyNeural",
|
|
@@ -35,7 +34,7 @@ def _domain(url):
|
|
| 35 |
|
| 36 |
|
| 37 |
def _strip_bullet_prefix(s):
|
| 38 |
-
return clean(re.sub(r'^[\s•\-\*·▪▫●○\d\.\)\(]+', '', s or ''))
|
| 39 |
|
| 40 |
|
| 41 |
def _source_badge_url_first(post):
|
|
@@ -60,7 +59,6 @@ def _abs_url(src, base_url):
|
|
| 60 |
|
| 61 |
def _article_content_block(soup):
|
| 62 |
for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe']):tag.decompose()
|
| 63 |
-
# Aggressively remove related/ad/recommend containers before image collection.
|
| 64 |
bad_re=re.compile(r'(related|relate|recommend|suggest|sidebar|ads|advert|popular|more|xem-them|xemthem|tin-lien-quan|tinlienquan|doc-them|docthem|other-news|news-other|article-related|box-tin|box_related|story-related|recommend-news|same-category|cate-list|news-list|most-view|banner|qc|quang-cao|sponsor)',re.I)
|
| 65 |
for el in list(soup.find_all(True)):
|
| 66 |
cls=' '.join(el.get('class',[])); eid=el.get('id',''); role=el.get('role','')
|
|
@@ -94,7 +92,6 @@ def _image_is_likely_article(im, src):
|
|
| 94 |
|
| 95 |
|
| 96 |
def _article_only_images(url):
|
| 97 |
-
"""Collect images only inside main article content. If uncertain, return fewer/no images rather than related/ad images."""
|
| 98 |
imgs=[]
|
| 99 |
try:
|
| 100 |
from bs4 import BeautifulSoup
|
|
@@ -102,7 +99,6 @@ def _article_only_images(url):
|
|
| 102 |
soup=BeautifulSoup(r.text,'lxml')
|
| 103 |
block=_article_content_block(soup)
|
| 104 |
candidates=[]
|
| 105 |
-
# Prefer figure/picture under article body; then direct img in body.
|
| 106 |
for el in block.find_all(['figure','picture'],recursive=True):
|
| 107 |
im=el.find('img')
|
| 108 |
if im:candidates.append(im)
|
|
@@ -115,12 +111,10 @@ def _article_only_images(url):
|
|
| 115 |
else:src=src.strip().split(' ')[0]
|
| 116 |
src=_abs_url(src,url)
|
| 117 |
if src in seen or not _image_is_likely_article(im,src):continue
|
| 118 |
-
# parent text guard: skip images from any remaining related block
|
| 119 |
parent_txt=' '.join((im.parent.get('class',[]) if im.parent else []))+' '+(im.parent.get('id','') if im.parent else '')
|
| 120 |
if re.search(r'(related|recommend|tin-lien-quan|doc-them|xem-them|popular|ads|banner)',parent_txt,re.I):continue
|
| 121 |
seen.add(src);imgs.append(src)
|
| 122 |
if len(imgs)>=20:break
|
| 123 |
-
# Use og:image ONLY as article main image fallback when no body image found.
|
| 124 |
if not imgs:
|
| 125 |
og=soup.find('meta',property='og:image') or soup.find('meta',attrs={'name':'twitter:image'})
|
| 126 |
if og:
|
|
@@ -154,7 +148,6 @@ def _download_image_safe(url, fallback_title, out_path):
|
|
| 154 |
r=requests.get(url,headers=getattr(base,'HEADERS',{}),timeout=18)
|
| 155 |
if r.status_code==200 and len(r.content)>1200:
|
| 156 |
with open(out_path,'wb') as f:f.write(r.content)
|
| 157 |
-
# verify PIL opens it
|
| 158 |
if Image:
|
| 159 |
Image.open(out_path).verify()
|
| 160 |
return out_path
|
|
@@ -167,7 +160,6 @@ def _download_image_safe(url, fallback_title, out_path):
|
|
| 167 |
|
| 168 |
def final_make_tts(text,voice,out_path):
|
| 169 |
text=_strip_bullet_prefix(text) or 'Bản tin VNEWS.'
|
| 170 |
-
# Only Vietnamese voices. Unknown choices fall back to Vietnamese female.
|
| 171 |
edge_voice=VN_VOICES.get(str(voice or '').lower().strip(), 'vi-VN-HoaiMyNeural')
|
| 172 |
for ev in [edge_voice, 'vi-VN-HoaiMyNeural', 'vi-VN-NamMinhNeural']:
|
| 173 |
try:
|
|
@@ -178,7 +170,6 @@ def final_make_tts(text,voice,out_path):
|
|
| 178 |
base.gTTS(text,lang='vi',tld='com.vn',slow=False).save(out_path)
|
| 179 |
if os.path.exists(out_path) and os.path.getsize(out_path)>1000:return out_path
|
| 180 |
except Exception:pass
|
| 181 |
-
# Last-resort silent audio guarantees short generation succeeds.
|
| 182 |
subprocess.run(['ffmpeg','-y','-f','lavfi','-i','anullsrc=channel_layout=stereo:sample_rate=44100','-t','3','-q:a','9','-acodec','libmp3lame',out_path],stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=30)
|
| 183 |
return out_path
|
| 184 |
|
|
@@ -219,97 +210,7 @@ def final_make_frame(post,seg,idx,total,img_path,out_path):
|
|
| 219 |
title_lines=rt.wrap_text(draw,_strip_bullet_prefix(post.get('title','')),fs,W-120,3);y2=1640;draw.line((80,y2-26,W-80,y2-26),fill=(70,70,70),width=2);_draw_center(draw,title_lines,fs,y2,(220,220,220),W,42)
|
| 220 |
bg.save(out_path,quality=92)
|
| 221 |
|
| 222 |
-
# Monkey patches for old functions.
|
| 223 |
rt.make_frame=final_make_frame;rt.make_tts=final_make_tts;rt._source_badge=_source_badge_url_first
|
| 224 |
|
| 225 |
-
# Override endpoints.
|
| 226 |
_PATCH={('/api/url_wall','POST'),('/api/rewrite_share','POST'),('/api/ai/url','POST'),('/api/ai/short/{post_id}','POST'),('/','GET'),('/aw','GET')}
|
| 227 |
app.router.routes=[r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in _PATCH)]
|
| 228 |
-
|
| 229 |
-
@app.post('/api/url_wall')
|
| 230 |
-
async def final_url_wall(request:Request):
|
| 231 |
-
body=await request.json();url=base._clean_text(body.get('url',''))
|
| 232 |
-
if not url.startswith('http'):return JSONResponse({'error':'missing url'},status_code=400)
|
| 233 |
-
try:data=_scrape_url_article_only(url)
|
| 234 |
-
except Exception as e:return JSONResponse({'error':'Không scrape được URL: '+str(e)[:180]},status_code=422)
|
| 235 |
-
raw=(data.get('summary','')+'\n'+data.get('text','')).strip()
|
| 236 |
-
if len(raw)<120:return JSONResponse({'error':'URL không có đủ nội dung để tóm tắt'},status_code=422)
|
| 237 |
-
prompt=f"""Tóm tắt bài viết nguồn dưới đây để đăng lên Tường AI VNEWS.
|
| 238 |
-
|
| 239 |
-
Yêu cầu:
|
| 240 |
-
- Chỉ tóm tắt nội dung chính, không viết lại toàn bộ bài.
|
| 241 |
-
- Ngắn gọn, cụ thể, dễ hiểu.
|
| 242 |
-
- Không lặp ý, không thêm chi tiết ngoài nguồn.
|
| 243 |
-
- Tối đa 5 ý chính hoặc 2 đoạn ngắn.
|
| 244 |
-
- Hạn chế dùng dấu đầu dòng.
|
| 245 |
-
|
| 246 |
-
Tiêu đề gốc: {data.get('title','')}
|
| 247 |
-
Nguồn: {_domain(url)}
|
| 248 |
-
Nội dung gốc:
|
| 249 |
-
{raw[:16000]}"""
|
| 250 |
-
text=await base.qwen_generate(prompt,image_url=(data.get('image') or None),max_tokens=900)
|
| 251 |
-
if not text:text=rt.old._fallback_summary_from_prompt(prompt,max_units=5) if hasattr(rt.old,'_fallback_summary_from_prompt') else raw[:900]
|
| 252 |
-
text=rt.postprocess(text) if hasattr(rt,'postprocess') else text
|
| 253 |
-
src=[{'title':data.get('title'), 'url':url, 'excerpt':raw[:500], 'via':_domain(url)}]
|
| 254 |
-
if 'Nguồn tham khảo:' not in text:text+='\n\n'+rt.source_line(src)
|
| 255 |
-
imgs=data.get('images') or []
|
| 256 |
-
post=base.make_post(data.get('title') or 'Bài viết',text,imgs[0] if imgs else '',url,'url',sources=src)
|
| 257 |
-
post['images']=imgs
|
| 258 |
-
posts=base._load_ai_wall();posts.insert(0,post);base._save_ai_wall(posts)
|
| 259 |
-
return JSONResponse({'post':post})
|
| 260 |
-
|
| 261 |
-
@app.post('/api/rewrite_share')
|
| 262 |
-
async def final_rewrite_share(request:Request):return await final_url_wall(request)
|
| 263 |
-
@app.post('/api/ai/url')
|
| 264 |
-
async def final_ai_url(request:Request):return await final_url_wall(request)
|
| 265 |
-
|
| 266 |
-
@app.post('/api/ai/short/{post_id}')
|
| 267 |
-
async def final_short(post_id:str,request:Request):
|
| 268 |
-
try:body=await request.json()
|
| 269 |
-
except Exception:body={}
|
| 270 |
-
voice=str(body.get('voice','nu')).lower().strip();emotion=str(body.get('emotion','neutral')).lower().strip();speed=max(0.85,min(1.35,float(body.get('speed',1.2) or 1.2)))
|
| 271 |
-
posts=base._load_ai_wall();post=next((p for p in posts if str(p.get('id'))==str(post_id)),None)
|
| 272 |
-
if not post:return JSONResponse({'error':'post not found'},status_code=404)
|
| 273 |
-
segs=rt.split_segments(post,8) if hasattr(rt,'split_segments') else [_strip_bullet_prefix(post.get('text') or post.get('title') or 'VNEWS')]
|
| 274 |
-
imgs=[u for u in (post.get('images') or []) if u] or ([post.get('img')] if post.get('img') else [])
|
| 275 |
-
os.makedirs(base.SHORTS_DIR,exist_ok=True);suffix=f'_{voice}_{emotion}_{str(speed).replace(".","p")}_articleimgs_vivoice'
|
| 276 |
-
out=os.path.join(base.SHORTS_DIR,base._safe_name(post_id+suffix)+'.mp4')
|
| 277 |
-
if os.path.exists(out):
|
| 278 |
-
post['video']='/api/ai/short-file/'+post_id+suffix;base._save_ai_wall(posts);return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False})
|
| 279 |
-
work=os.path.join(base.SHORTS_DIR,base._safe_name(post_id+suffix));os.makedirs(work,exist_ok=True)
|
| 280 |
-
clips=[]
|
| 281 |
-
try:
|
| 282 |
-
for i,seg in enumerate(segs):
|
| 283 |
-
img_url=imgs[i % len(imgs)] if imgs else ''
|
| 284 |
-
img=os.path.join(work,f'image_{i}.jpg');frame=os.path.join(work,f'f{i}.jpg');aud=os.path.join(work,f'a{i}.mp3');aud2=os.path.join(work,f'a{i}_fast.mp3');clip=os.path.join(work,f'c{i}.mp4')
|
| 285 |
-
_download_image_safe(img_url,post.get('title','AI news'),img)
|
| 286 |
-
seg=_strip_bullet_prefix(seg);final_make_frame(post,seg,i,len(segs),img,frame)
|
| 287 |
-
prefix={'urgent':'Tin nhanh.','warm':'Câu chuyện đáng chú ý.','serious':'Bản tin nghiêm túc.','energetic':'Cập nhật nổi bật.'}.get(emotion,'')
|
| 288 |
-
spoken=(prefix+' '+seg).strip() if i==0 and prefix else seg
|
| 289 |
-
final_make_tts(spoken,voice,aud)
|
| 290 |
-
try:subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud2],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=120)
|
| 291 |
-
except Exception:aud2=aud
|
| 292 |
-
try:
|
| 293 |
-
subprocess.run(['ffmpeg','-y','-loop','1','-i',frame,'-i',aud2,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k','-vf','scale=1080:1920',clip],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=180)
|
| 294 |
-
except Exception:
|
| 295 |
-
# last-resort visual-only 4s clip
|
| 296 |
-
subprocess.run(['ffmpeg','-y','-loop','1','-t','4','-i',frame,'-f','lavfi','-i','anullsrc=channel_layout=stereo:sample_rate=44100','-shortest','-c:v','libx264','-pix_fmt','yuv420p','-c:a','aac','-vf','scale=1080:1920',clip],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=120)
|
| 297 |
-
clips.append(clip)
|
| 298 |
-
lf=os.path.join(work,'list.txt')
|
| 299 |
-
with open(lf,'w',encoding='utf-8') as f:
|
| 300 |
-
for c in clips:f.write("file '"+c.replace("","'\\''"))+"'\n")
|
| 301 |
-
subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',lf,'-c','copy',out],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=240)
|
| 302 |
-
post['video']='/api/ai/short-file/'+post_id+suffix;post['short_subtitles']=False;post['short_segments']=segs;post['short_speed']=speed;base._save_ai_wall(posts)
|
| 303 |
-
return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False})
|
| 304 |
-
except Exception as e:return JSONResponse({'error':'Không tạo được shorts: '+str(e)[:220]},status_code=500)
|
| 305 |
-
|
| 306 |
-
@app.get('/aw')
|
| 307 |
-
def ai_wall_share(post:str=Query(default=''), short:int=Query(default=0)):
|
| 308 |
-
posts=base._load_ai_wall();p=next((x for x in posts if str(x.get('id'))==str(post)),None)
|
| 309 |
-
if not p:return HTMLResponse(f'<script>location.href="{SPACE_URL}"</script>')
|
| 310 |
-
title=p.get('title') or 'VNEWS AI';img=p.get('img') or DEFAULT_IMG
|
| 311 |
-
desc=(p.get('text') or '')[:220]
|
| 312 |
-
return HTMLResponse(f'<!doctype html><html><head><meta charset="utf-8"><title>{title}</title><meta property="og:title" content="{title}"><meta property="og:description" content="{desc}"><meta property="og:image" content="{img}"><meta property="og:type" content="article"><meta name="twitter:card" content="summary_large_image"></head><body><script>localStorage.setItem('pending_ai_post','{post}');location.href='{SPACE_URL}'</script></body></html>')
|
| 313 |
-
|
| 314 |
-
FINAL_INJECT = r'''
|
| 315 |
-
<style>
|
|
|
|
| 15 |
SPACE_URL = "https://bep40-vnews.hf.space"
|
| 16 |
DEFAULT_IMG = "https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg"
|
| 17 |
|
|
|
|
| 18 |
VN_VOICES = {
|
| 19 |
"nu": "vi-VN-HoaiMyNeural", "female": "vi-VN-HoaiMyNeural", "hoaimy": "vi-VN-HoaiMyNeural",
|
| 20 |
"nu-tre": "vi-VN-HoaiMyNeural", "nu-truyen-cam": "vi-VN-HoaiMyNeural", "nu-tin-nhanh": "vi-VN-HoaiMyNeural",
|
|
|
|
| 34 |
|
| 35 |
|
| 36 |
def _strip_bullet_prefix(s):
|
| 37 |
+
return clean(re.sub(r'^[\s\•\-\*·▪▫●○\d\.\)\(]+', '', s or ''))
|
| 38 |
|
| 39 |
|
| 40 |
def _source_badge_url_first(post):
|
|
|
|
| 59 |
|
| 60 |
def _article_content_block(soup):
|
| 61 |
for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe']):tag.decompose()
|
|
|
|
| 62 |
bad_re=re.compile(r'(related|relate|recommend|suggest|sidebar|ads|advert|popular|more|xem-them|xemthem|tin-lien-quan|tinlienquan|doc-them|docthem|other-news|news-other|article-related|box-tin|box_related|story-related|recommend-news|same-category|cate-list|news-list|most-view|banner|qc|quang-cao|sponsor)',re.I)
|
| 63 |
for el in list(soup.find_all(True)):
|
| 64 |
cls=' '.join(el.get('class',[])); eid=el.get('id',''); role=el.get('role','')
|
|
|
|
| 92 |
|
| 93 |
|
| 94 |
def _article_only_images(url):
|
|
|
|
| 95 |
imgs=[]
|
| 96 |
try:
|
| 97 |
from bs4 import BeautifulSoup
|
|
|
|
| 99 |
soup=BeautifulSoup(r.text,'lxml')
|
| 100 |
block=_article_content_block(soup)
|
| 101 |
candidates=[]
|
|
|
|
| 102 |
for el in block.find_all(['figure','picture'],recursive=True):
|
| 103 |
im=el.find('img')
|
| 104 |
if im:candidates.append(im)
|
|
|
|
| 111 |
else:src=src.strip().split(' ')[0]
|
| 112 |
src=_abs_url(src,url)
|
| 113 |
if src in seen or not _image_is_likely_article(im,src):continue
|
|
|
|
| 114 |
parent_txt=' '.join((im.parent.get('class',[]) if im.parent else []))+' '+(im.parent.get('id','') if im.parent else '')
|
| 115 |
if re.search(r'(related|recommend|tin-lien-quan|doc-them|xem-them|popular|ads|banner)',parent_txt,re.I):continue
|
| 116 |
seen.add(src);imgs.append(src)
|
| 117 |
if len(imgs)>=20:break
|
|
|
|
| 118 |
if not imgs:
|
| 119 |
og=soup.find('meta',property='og:image') or soup.find('meta',attrs={'name':'twitter:image'})
|
| 120 |
if og:
|
|
|
|
| 148 |
r=requests.get(url,headers=getattr(base,'HEADERS',{}),timeout=18)
|
| 149 |
if r.status_code==200 and len(r.content)>1200:
|
| 150 |
with open(out_path,'wb') as f:f.write(r.content)
|
|
|
|
| 151 |
if Image:
|
| 152 |
Image.open(out_path).verify()
|
| 153 |
return out_path
|
|
|
|
| 160 |
|
| 161 |
def final_make_tts(text,voice,out_path):
|
| 162 |
text=_strip_bullet_prefix(text) or 'Bản tin VNEWS.'
|
|
|
|
| 163 |
edge_voice=VN_VOICES.get(str(voice or '').lower().strip(), 'vi-VN-HoaiMyNeural')
|
| 164 |
for ev in [edge_voice, 'vi-VN-HoaiMyNeural', 'vi-VN-NamMinhNeural']:
|
| 165 |
try:
|
|
|
|
| 170 |
base.gTTS(text,lang='vi',tld='com.vn',slow=False).save(out_path)
|
| 171 |
if os.path.exists(out_path) and os.path.getsize(out_path)>1000:return out_path
|
| 172 |
except Exception:pass
|
|
|
|
| 173 |
subprocess.run(['ffmpeg','-y','-f','lavfi','-i','anullsrc=channel_layout=stereo:sample_rate=44100','-t','3','-q:a','9','-acodec','libmp3lame',out_path],stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=30)
|
| 174 |
return out_path
|
| 175 |
|
|
|
|
| 210 |
title_lines=rt.wrap_text(draw,_strip_bullet_prefix(post.get('title','')),fs,W-120,3);y2=1640;draw.line((80,y2-26,W-80,y2-26),fill=(70,70,70),width=2);_draw_center(draw,title_lines,fs,y2,(220,220,220),W,42)
|
| 211 |
bg.save(out_path,quality=92)
|
| 212 |
|
|
|
|
| 213 |
rt.make_frame=final_make_frame;rt.make_tts=final_make_tts;rt._source_badge=_source_badge_url_first
|
| 214 |
|
|
|
|
| 215 |
_PATCH={('/api/url_wall','POST'),('/api/rewrite_share','POST'),('/api/ai/url','POST'),('/api/ai/short/{post_id}','POST'),('/','GET'),('/aw','GET')}
|
| 216 |
app.router.routes=[r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in _PATCH)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ai_runtime_final6.py
CHANGED
|
@@ -840,10 +840,486 @@ FINAL6E_INJECT = """
|
|
| 840 |
function escE(s){return String(s||'').replace(/[&<>\"']/g,m=>({'&':'&','<':'<','>':'>','\"':'"',"'":'''}[m]));}
|
| 841 |
window.__topicWallE=[];
|
| 842 |
function sourceDetailsHtml(p){let arr=p.source_details||[];if(!arr.length)return '';let h='<div class="source-detail-box"><h3>📚 Nội dung từng nguồn đã dùng</h3>';arr.forEach((s,i)=>{h+=`<div class="source-detail-item"><div class="source-detail-title">${i+1}. ${escE(s.title)}</div><div class="source-detail-meta">${escE(s.via||'Nguồn')}</div><div class="source-detail-content">${escE(s.content||'')}</div>${s.url?`<a href="${escE(s.url)}" target="_blank">Mở nguồn gốc</a>`:''}</div>`});h+='</div>';return h;}
|
| 843 |
-
function renderTopicWallE(){let home=document.getElementById('view-home');if(!home||!window.__topicWallE.length)return;document.getElementById('ai-wall-topic-live')?.remove();let wrap=document.createElement('div');wrap.id='ai-wall-topic-live';wrap.className='ai-wall-topic-live';let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI mới</span><span class="slider-note">
|
| 844 |
-
window.readTopicWallE=function(i){let p=window.__topicWallE[i];if(!p)return;showView('view-article');let imgs=(p.images||[]).filter(Boolean);let gal=imgs.length?'<div class="ai-wall-gallery">'+imgs.slice(0,12).map(u=>`<img src="${escE(u)}" loading="lazy">`).join('')+'</div>':(p.img?`<img class="article-img" src="${escE(p.img)}">`:'');
|
| 845 |
-
window.createTopicPostFinal5=async function(){let inp=document.getElementById('ai-topic-input-final5');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');let btn=document.getElementById('ai-topic-btn-final5');if(btn){btn.disabled=true;btn.textContent='Đang
|
| 846 |
-
setInterval(()=>{document.querySelectorAll('#ai-topic-input-final3,.topic-final3,#ai-topic-input-final4,.topic-final4').forEach(e=>(e.closest('.topic-final3,.topic-final4,.ai-compose-row')||e).remove());let b=document.getElementById('ai-topic-btn-final5');if(b){b.style.display='block';b.textContent='✨ Tạo bài tổng hợp từ web bằng Qwen';}},1200);
|
| 847 |
})();
|
| 848 |
</script>
|
| 849 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 840 |
function escE(s){return String(s||'').replace(/[&<>\"']/g,m=>({'&':'&','<':'<','>':'>','\"':'"',"'":'''}[m]));}
|
| 841 |
window.__topicWallE=[];
|
| 842 |
function sourceDetailsHtml(p){let arr=p.source_details||[];if(!arr.length)return '';let h='<div class="source-detail-box"><h3>📚 Nội dung từng nguồn đã dùng</h3>';arr.forEach((s,i)=>{h+=`<div class="source-detail-item"><div class="source-detail-title">${i+1}. ${escE(s.title)}</div><div class="source-detail-meta">${escE(s.via||'Nguồn')}</div><div class="source-detail-content">${escE(s.content||'')}</div>${s.url?`<a href="${escE(s.url)}" target="_blank">Mở nguồn gốc</a>`:''}</div>`});h+='</div>';return h;}
|
| 843 |
+
function renderTopicWallE(){let home=document.getElementById('view-home');if(!home||!window.__topicWallE.length)return;document.getElementById('ai-wall-topic-live')?.remove();let wrap=document.createElement('div');wrap.id='ai-wall-topic-live';wrap.className='ai-wall-topic-live';let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI mới</span><span class="slider-note">Có nội dung nguồn</span></div><div class="slider-track">';window.__topicWallE.slice(0,20).forEach((p,i)=>{h+=`<div class="wall-item"><div class="wall-thumb">${p.img?`<img src="${escE(p.img)}">`:''}</div><div class="wall-title">${escE(p.title)}</div><div class="wall-text">${escE(p.text)}</div><div class="wall-actions"><button class="primary" onclick="readTopicWallE(${i})">Xem</button></div></div>`});h+='</div>';wrap.innerHTML=h;let comp=document.querySelector('.ai-compose');if(comp)comp.after(wrap);else home.prepend(wrap);}
|
| 844 |
+
window.readTopicWallE=function(i){let p=window.__topicWallE[i];if(!p)return;showView('view-article');let imgs=(p.images||[]).filter(Boolean);let gal=imgs.length?'<div class="ai-wall-gallery">'+imgs.slice(0,12).map(u=>`<img src="${escE(u)}" loading="lazy">`).join('')+'</div>':(p.img?`<img class="article-img" src="${escE(p.img)}">`:'');document.getElementById('view-article').innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${escE(p.title)}</h1>${gal}<p class="article-p" style="white-space:pre-wrap">${escE(p.text)}</p>${sourceDetailsHtml(p)}<div class="article-actions"><button onclick="shareAI?shareAI(${JSON.stringify(p).replace(/\"/g,'"')},false):navigator.clipboard.writeText(location.href)">📤 Chia sẻ</button></div></div>`;window.scrollTo(0,0)};
|
| 845 |
+
window.createTopicPostFinal5=async function(){let inp=document.getElementById('ai-topic-input-final5');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');let btn=document.getElementById('ai-topic-btn-final5');if(btn){btn.disabled=true;btn.textContent='Đang lấy nguồn...'}try{let src=await fetch('/api/topic_sources?topic='+encodeURIComponent(topic)).then(r=>r.json()).catch(()=>null);if(btn&&src)btn.textContent='Đã tìm '+(src.count||0)+' nguồn, đang viết...';let r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');window.__topicWallE.unshift(j.post);if(inp)inp.value='';renderTopicWallE();readTopicWallE(0);}catch(e){alert(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='✨ Tạo bài tổng hợp từ web bằng Qwen'}}};
|
|
|
|
| 846 |
})();
|
| 847 |
</script>
|
| 848 |
+
"""
|
| 849 |
+
|
| 850 |
+
# Override root one last time to append source-details UI.
|
| 851 |
+
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
|
| 852 |
+
@app.get('/')
|
| 853 |
+
async def index_final6_source_details():
|
| 854 |
+
html=f5.f4.f3.f2.f1._load_index_html()
|
| 855 |
+
body=getattr(rt.old,'PATCH_INJECT','')+f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT+FINAL6_INJECT+globals().get('FINAL6_FAST_HOME_INJECT','')+FINAL6E_INJECT
|
| 856 |
+
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|
| 857 |
+
|
| 858 |
+
|
| 859 |
+
# ===== FINAL6F: CLEAN TOPIC OUTPUT + IN-APP SOURCE READER =====
|
| 860 |
+
def _clean_generated_article(text, topic=''):
|
| 861 |
+
"""Remove prompt/instruction leakage from generated topic articles."""
|
| 862 |
+
text=str(text or '').strip()
|
| 863 |
+
bad_patterns=[
|
| 864 |
+
r'^\s*[•\-]*\s*Hãy viết .*',
|
| 865 |
+
r'^\s*[•\-]*\s*Dưới đây là .*',
|
| 866 |
+
r'^\s*[•\-]*\s*Dữ liệu .*',
|
| 867 |
+
r'^\s*[•\-]*\s*NỘI DUNG NGUỒN.*',
|
| 868 |
+
r'^\s*[•\-]*\s*Yêu cầu\s*:.*',
|
| 869 |
+
r'^\s*[•\-]*\s*Tiêu đề mới.*',
|
| 870 |
+
r'^\s*[•\-]*\s*Sapo\s*2.*',
|
| 871 |
+
r'^\s*[•\-]*\s*5\s*[-–]\s*8\s*đoạn.*',
|
| 872 |
+
r'^\s*[•\-]*\s*Không dùng câu.*',
|
| 873 |
+
r'^\s*[•\-]*\s*Cuối bài.*',
|
| 874 |
+
r'^\s*[•\-]*\s*Không liệt kê.*',
|
| 875 |
+
r'^\s*[•\-]*\s*Tổng hợp thành.*',
|
| 876 |
+
r'^\s*[•\-]*\s*Diễn đạt lại.*',
|
| 877 |
+
r'^\s*[•\-]*\s*Tuyệt đối.*',
|
| 878 |
+
]
|
| 879 |
+
out=[]
|
| 880 |
+
for ln in text.splitlines():
|
| 881 |
+
s=ln.strip()
|
| 882 |
+
if not s:
|
| 883 |
+
out.append(ln);continue
|
| 884 |
+
if any(re.search(p,s,re.I) for p in bad_patterns):
|
| 885 |
+
continue
|
| 886 |
+
out.append(ln)
|
| 887 |
+
cleaned='\n'.join(out).strip()
|
| 888 |
+
# If model returned a markdown code/prompt-like block, keep content after first plausible title line.
|
| 889 |
+
cleaned=re.sub(r'^(?:Bài viết|Nội dung bài viết)\s*[::]\s*','',cleaned,flags=re.I).strip()
|
| 890 |
+
# Remove duplicated leading topic instruction if it appears inline.
|
| 891 |
+
cleaned=re.sub(r'Hãy viết MỘT BÀI VIẾT HOÀN CHỈNH[^\n\.]*[\.\n]*','',cleaned,flags=re.I).strip()
|
| 892 |
+
return cleaned or text
|
| 893 |
+
|
| 894 |
+
def _source_article_data(url):
|
| 895 |
+
try:
|
| 896 |
+
r=requests.get(url,headers=UA,timeout=14);r.encoding='utf-8'
|
| 897 |
+
soup=BeautifulSoup(r.text,'lxml')
|
| 898 |
+
h1=soup.find('h1')
|
| 899 |
+
ogt=soup.find('meta',property='og:title')
|
| 900 |
+
ogd=soup.find('meta',property='og:description')
|
| 901 |
+
ogi=soup.find('meta',property='og:image')
|
| 902 |
+
title=clean(h1.get_text(' ',strip=True) if h1 else (ogt.get('content','') if ogt else ''))
|
| 903 |
+
summary=clean(ogd.get('content','') if ogd else '')
|
| 904 |
+
img=ogi.get('content','') if ogi else ''
|
| 905 |
+
except Exception:
|
| 906 |
+
title='';summary='';img=''
|
| 907 |
+
text=_scrape_article_text(url,12000)
|
| 908 |
+
body=[]
|
| 909 |
+
for para in re.split(r'\n+',text or ''):
|
| 910 |
+
para=clean(para)
|
| 911 |
+
if len(para)>35:
|
| 912 |
+
body.append({'type':'p','text':para})
|
| 913 |
+
if len(body)>=80:break
|
| 914 |
+
if not title:title=url
|
| 915 |
+
if not body and summary:body=[{'type':'p','text':summary}]
|
| 916 |
+
return {'title':title,'summary':summary,'og_image':img,'body':body,'source':'topic-source','url':url}
|
| 917 |
+
|
| 918 |
+
@app.get('/api/topic_source_article')
|
| 919 |
+
def api_topic_source_article(url:str=Query(...)):
|
| 920 |
+
if not url.startswith('http'):
|
| 921 |
+
return JSONResponse({'error':'bad url'},status_code=400)
|
| 922 |
+
return JSONResponse(_source_article_data(url))
|
| 923 |
+
|
| 924 |
+
# Override topic generation one last time with output cleaning and source details.
|
| 925 |
+
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/api/topic_post' and 'POST' in getattr(r,'methods',set()))]
|
| 926 |
+
|
| 927 |
+
@app.post('/api/topic_post')
|
| 928 |
+
async def topic_post_clean_final(request:Request):
|
| 929 |
+
body=await request.json();topic=clean(body.get('topic',''))
|
| 930 |
+
if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
|
| 931 |
+
img=_topic_image(topic)
|
| 932 |
+
research=_fast_context(topic) if '_fast_context' in globals() else _web_research_context(topic)
|
| 933 |
+
context=research.get('context','');sources=research.get('sources',[])
|
| 934 |
+
details=_extract_source_details_from_context(context,sources) if '_extract_source_details_from_context' in globals() else []
|
| 935 |
+
if not details:
|
| 936 |
+
# Build details directly from sources/snippets if helper unavailable or empty.
|
| 937 |
+
for s in sources[:8]:
|
| 938 |
+
details.append({'title':s.get('title',''),'url':s.get('url',''),'via':s.get('via',''),'content':s.get('excerpt','') or s.get('snippet','') or ''})
|
| 939 |
+
if not context and not details:
|
| 940 |
+
return JSONResponse({'error':'Không tìm/crawl được đủ nội dung về chủ đề này. Hãy thử chủ đề cụ thể hơn hoặc dùng hashtag gợi ý.'},status_code=422)
|
| 941 |
+
source_brief='\n\n'.join([f"[{i+1}] {d.get('title','')} ({d.get('via','')})\n{d.get('content','')[:1400]}" for i,d in enumerate(details[:8])])
|
| 942 |
+
prompt=f"""Vai trò: biên tập viên VNEWS.
|
| 943 |
+
Nhiệm vụ: viết một bài báo tiếng Việt hoàn chỉnh về chủ đề "{topic}" dựa trên các nguồn bên dưới.
|
| 944 |
+
|
| 945 |
+
Nguồn thu thập:
|
| 946 |
+
{source_brief[:18000]}
|
| 947 |
+
|
| 948 |
+
Quy tắc biên tập:
|
| 949 |
+
1. Chỉ xuất bản bài viết cuối cùng, không nhắc lại yêu cầu, không liệt kê chỉ dẫn.
|
| 950 |
+
2. Không sao chép nguyên văn; hãy tổng hợp và diễn đạt lại.
|
| 951 |
+
3. Bài có tiêu đề, sapo, các đoạn phân tích/bối cảnh/tác động, và mục Nguồn tham khảo ngắn.
|
| 952 |
+
4. Không dùng các câu như "Dưới đây là", "Tôi sẽ", "Yêu cầu".
|
| 953 |
+
"""
|
| 954 |
+
text=None
|
| 955 |
+
try:
|
| 956 |
+
import asyncio
|
| 957 |
+
text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1700),timeout=35)
|
| 958 |
+
except Exception:
|
| 959 |
+
text=None
|
| 960 |
+
if not text or len(text)<350:
|
| 961 |
+
bullets='\n'.join([f"• {d.get('title','')}: {d.get('content','')[:320]}" for d in details[:6]])
|
| 962 |
+
vias=', '.join(sorted({d.get('via','') for d in details if d.get('via')}))
|
| 963 |
+
text=(f"{topic}: tổng hợp những điểm đáng chú ý\n\n"
|
| 964 |
+
f"{topic} đang được nhiều nguồn tin đề cập với các góc nhìn khác nhau. Dựa trên nội dung đã thu thập, có thể rút ra một số điểm chính để người đọc nắm nhanh bối cảnh.\n\n"
|
| 965 |
+
f"{bullets}\n\n"
|
| 966 |
+
f"Nhìn chung, chủ đề này cần được theo dõi thêm ở các khía cạnh: bối cảnh, tác động thực tế, phản ứng của các bên liên quan và các diễn biến mới trong thời gian tới.\n\n"
|
| 967 |
+
f"Nguồn tham khảo: {vias}")
|
| 968 |
+
text=_clean_generated_article(text,topic)
|
| 969 |
+
post=f5.base.make_post(topic,text,img,'','topic_clean_with_sources',sources=[s for s in sources if s.get('url')])
|
| 970 |
+
post['images']=[img]
|
| 971 |
+
post['source_details']=details[:8]
|
| 972 |
+
posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts)
|
| 973 |
+
return JSONResponse({'post':post,'mode':'clean_with_source_details','sources_count':len(details)})
|
| 974 |
+
|
| 975 |
+
FINAL6F_INJECT = """
|
| 976 |
+
<script>
|
| 977 |
+
(function(){
|
| 978 |
+
function escF(s){return String(s||'').replace(/[&<>\\"']/g,m=>({'&':'&','<':'<','>':'>','\\"':'"',"'":'''}[m]));}
|
| 979 |
+
window.readTopicSourceE=async function(url){
|
| 980 |
+
showView('view-article');
|
| 981 |
+
const el=document.getElementById('view-article');
|
| 982 |
+
el.innerHTML='<div class="loading">Đang tải nguồn...</div>';
|
| 983 |
+
try{
|
| 984 |
+
let data=await fetch('/api/topic_source_article?url='+encodeURIComponent(url)).then(r=>r.json());
|
| 985 |
+
if(!data||data.error||!data.body||!data.body.length){throw new Error('Không đọc được nguồn')}
|
| 986 |
+
let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><h1 class="article-title">${escF(data.title)}</h1>`;
|
| 987 |
+
if(data.summary)h+=`<div class="article-summary">${escF(data.summary)}</div>`;
|
| 988 |
+
if(data.og_image)h+=`<img class="article-img" src="${escF(data.og_image)}">`;
|
| 989 |
+
data.body.forEach(b=>{if(b.type==='p')h+=`<p class="article-p">${escF(b.text)}</p>`;else if(b.type==='heading')h+=`<h2 class="article-h2">${escF(b.text)}</h2>`;});
|
| 990 |
+
h+=`<div class="article-actions"><button onclick="window.open('${escF(url)}','_blank')">🔗 Mở gốc</button></div></div>`;
|
| 991 |
+
el.innerHTML=h;window.scrollTo(0,0);
|
| 992 |
+
}catch(e){el.innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="loading">Không đọc được nguồn.<br><a href="${escF(url)}" target="_blank" style="color:#5cb87a">Mở link gốc</a></div>`}
|
| 993 |
+
};
|
| 994 |
+
// Upgrade existing source detail boxes: replace external open behavior by in-app button.
|
| 995 |
+
function enhanceSourceButtons(){document.querySelectorAll('.source-detail-item a[href]').forEach(a=>{let u=a.getAttribute('href');if(!u||a.dataset.vnews)return;a.dataset.vnews='1';a.textContent='Xem trực tiếp trên VNEWS';a.setAttribute('href','javascript:void(0)');a.onclick=function(){readTopicSourceE(u);return false;};});}
|
| 996 |
+
setInterval(enhanceSourceButtons,1000);setTimeout(enhanceSourceButtons,500);
|
| 997 |
+
})();
|
| 998 |
+
</script>
|
| 999 |
+
"""
|
| 1000 |
+
|
| 1001 |
+
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
|
| 1002 |
+
@app.get('/')
|
| 1003 |
+
async def index_final6_clean_links():
|
| 1004 |
+
html=f5.f4.f3.f2.f1._load_index_html()
|
| 1005 |
+
body=getattr(rt.old,'PATCH_INJECT','')+f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT+FINAL6_INJECT+globals().get('FINAL6_FAST_HOME_INJECT','')+globals().get('FINAL6E_INJECT','')+FINAL6F_INJECT
|
| 1006 |
+
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|
| 1007 |
+
|
| 1008 |
+
|
| 1009 |
+
# ===== FINAL6G: SELECTED FAST SOURCES =====
|
| 1010 |
+
# Restrict hot hashtags + topic articles to the requested sources only:
|
| 1011 |
+
# Thethaovanhoa, Dantri, VTV, VnExpress, Vatvostudio, GenK, VNReview.
|
| 1012 |
+
SELECTED_SOURCE_FEEDS=[
|
| 1013 |
+
('VnExpress','https://vnexpress.net/rss/tin-moi-nhat.rss'),
|
| 1014 |
+
('VnExpress Thời sự','https://vnexpress.net/rss/thoi-su.rss'),
|
| 1015 |
+
('VnExpress Thế giới','https://vnexpress.net/rss/the-gioi.rss'),
|
| 1016 |
+
('VnExpress Kinh doanh','https://vnexpress.net/rss/kinh-doanh.rss'),
|
| 1017 |
+
('VnExpress Công nghệ','https://vnexpress.net/rss/so-hoa.rss'),
|
| 1018 |
+
('VnExpress Thể thao','https://vnexpress.net/rss/the-thao.rss'),
|
| 1019 |
+
('Dân trí','https://dantri.com.vn/rss/home.rss'),
|
| 1020 |
+
('Dân trí Xã hội','https://dantri.com.vn/rss/xa-hoi.rss'),
|
| 1021 |
+
('Dân trí Kinh doanh','https://dantri.com.vn/rss/kinh-doanh.rss'),
|
| 1022 |
+
('Dân trí Thể thao','https://dantri.com.vn/rss/the-thao.rss'),
|
| 1023 |
+
('Dân trí Công nghệ','https://dantri.com.vn/rss/suc-manh-so.rss'),
|
| 1024 |
+
('VTV','https://vtv.vn/rss/trang-chu.rss'),
|
| 1025 |
+
('VTV Thời sự','https://vtv.vn/rss/thoi-su.rss'),
|
| 1026 |
+
('VTV Công nghệ','https://vtv.vn/rss/cong-nghe.rss'),
|
| 1027 |
+
('Thể thao văn hóa','https://thethaovanhoa.vn/rss/home.rss'),
|
| 1028 |
+
('Thể thao văn hóa Bóng đá','https://thethaovanhoa.vn/rss/bong-da.rss'),
|
| 1029 |
+
('GenK','https://genk.vn/home.rss'),
|
| 1030 |
+
('GenK AI','https://genk.vn/ai.rss'),
|
| 1031 |
+
('VNReview','https://vnreview.vn/rss/home.rss'),
|
| 1032 |
+
('VNReview Công nghệ','https://vnreview.vn/rss/cong-nghe.rss'),
|
| 1033 |
+
]
|
| 1034 |
+
SELECTED_HOMEPAGES=[
|
| 1035 |
+
('VTV','https://vtv.vn/'),
|
| 1036 |
+
('Thể thao văn hóa','https://thethaovanhoa.vn/'),
|
| 1037 |
+
('GenK','https://genk.vn/'),
|
| 1038 |
+
('VNReview','https://vnreview.vn/'),
|
| 1039 |
+
('Vatvostudio','https://vatvostudio.vn/'),
|
| 1040 |
+
]
|
| 1041 |
+
SELECTED_DOMAINS=['vnexpress.net','dantri.com.vn','vtv.vn','thethaovanhoa.vn','genk.vn','vnreview.vn','vatvostudio.vn']
|
| 1042 |
+
|
| 1043 |
+
def _selected_fetch_rss(feed_name, feed_url, max_items=10):
|
| 1044 |
+
items=[]
|
| 1045 |
+
try:
|
| 1046 |
+
r=requests.get(feed_url,headers=UA,timeout=4);r.encoding='utf-8'
|
| 1047 |
+
if r.status_code>=400:return []
|
| 1048 |
+
soup=BeautifulSoup(r.text,'xml')
|
| 1049 |
+
for it in soup.find_all('item')[:max_items]:
|
| 1050 |
+
title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
|
| 1051 |
+
link=clean(it.find('link').get_text(strip=True) if it.find('link') else '')
|
| 1052 |
+
desc_raw=it.find('description').get_text(' ',strip=True) if it.find('description') else ''
|
| 1053 |
+
ds=BeautifulSoup(desc_raw,'lxml')
|
| 1054 |
+
desc=clean(ds.get_text(' ',strip=True))
|
| 1055 |
+
if title and link:
|
| 1056 |
+
items.append({'title':title,'url':link,'source':feed_name,'snippet':desc})
|
| 1057 |
+
except Exception:pass
|
| 1058 |
+
return items
|
| 1059 |
+
|
| 1060 |
+
def _selected_scrape_homepage(name, url, max_items=10):
|
| 1061 |
+
items=[];seen=set()
|
| 1062 |
+
try:
|
| 1063 |
+
r=requests.get(url,headers=UA,timeout=4);r.encoding='utf-8'
|
| 1064 |
+
if r.status_code>=400:return []
|
| 1065 |
+
soup=BeautifulSoup(r.text,'lxml')
|
| 1066 |
+
base=url.rstrip('/')
|
| 1067 |
+
for a in soup.find_all('a',href=True):
|
| 1068 |
+
href=a.get('href','').strip();title=clean(a.get('title','') or a.get_text(' ',strip=True))
|
| 1069 |
+
if not href or not title or len(title)<18:continue
|
| 1070 |
+
if href.startswith('/'):
|
| 1071 |
+
p=urlparse(url); href=f'{p.scheme}://{p.netloc}{href}'
|
| 1072 |
+
if not href.startswith('http') or href in seen:continue
|
| 1073 |
+
dom=_domain(href)
|
| 1074 |
+
if not any(d in dom for d in SELECTED_DOMAINS):continue
|
| 1075 |
+
if any(x in href.lower() for x in ['#','javascript:','facebook','youtube','tiktok']):continue
|
| 1076 |
+
seen.add(href)
|
| 1077 |
+
items.append({'title':title,'url':href,'source':name,'snippet':''})
|
| 1078 |
+
if len(items)>=max_items:break
|
| 1079 |
+
except Exception:pass
|
| 1080 |
+
return items
|
| 1081 |
+
|
| 1082 |
+
def _fast_rss_pool():
|
| 1083 |
+
now=time.time();key='selected_fast_pool'
|
| 1084 |
+
if key in _FAST_TOPIC_CACHE and now-_FAST_TOPIC_CACHE[key]['t']<600:return _FAST_TOPIC_CACHE[key]['d']
|
| 1085 |
+
pool=[];seen=set()
|
| 1086 |
+
# RSS first: fast and reliable.
|
| 1087 |
+
for name,url in SELECTED_SOURCE_FEEDS:
|
| 1088 |
+
for it in _selected_fetch_rss(name,url,10):
|
| 1089 |
+
if it['url'] not in seen:
|
| 1090 |
+
seen.add(it['url']);pool.append(it)
|
| 1091 |
+
# Homepage fallback for sources with weak/no RSS, especially Vatvostudio.
|
| 1092 |
+
for name,url in SELECTED_HOMEPAGES:
|
| 1093 |
+
for it in _selected_scrape_homepage(name,url,10):
|
| 1094 |
+
if it['url'] not in seen:
|
| 1095 |
+
seen.add(it['url']);pool.append(it)
|
| 1096 |
+
_FAST_TOPIC_CACHE[key]={'t':now,'d':pool}
|
| 1097 |
+
return pool
|
| 1098 |
+
|
| 1099 |
+
def _hot_topics():
|
| 1100 |
+
now=time.time()
|
| 1101 |
+
if _HOT_CACHE['d'] and now-_HOT_CACHE['t']<600:return _HOT_CACHE['d']
|
| 1102 |
+
pool=_fast_rss_pool()
|
| 1103 |
+
freq={};display={}
|
| 1104 |
+
for it in pool[:220]:
|
| 1105 |
+
title=re.sub(r'\s+-\s+.*$','',it.get('title',''))
|
| 1106 |
+
kws=[]
|
| 1107 |
+
for m in re.findall(r'([A-ZĐÀ-Ỹ][A-Za-zÀ-ỹ0-9]+(?:\s+[A-ZĐÀ-ỸA-Za-zÀ-ỹ0-9][A-Za-zÀ-ỹ0-9]+){1,4})',title):
|
| 1108 |
+
if len(m)>=6:kws.append(m)
|
| 1109 |
+
kws+=_keywords_from_title(title)
|
| 1110 |
+
for kw in kws[:5]:
|
| 1111 |
+
words=[w for w in clean(kw).split() if w.lower() not in STOP_WORDS]
|
| 1112 |
+
if len(words)<2:continue
|
| 1113 |
+
kw=' '.join(words[:5])
|
| 1114 |
+
if len(kw)<6 or len(kw)>55:continue
|
| 1115 |
+
key=kw.lower();freq[key]=freq.get(key,0)+1;display[key]=kw
|
| 1116 |
+
topics=[];seen=set()
|
| 1117 |
+
for key,_ in sorted(freq.items(),key=lambda x:x[1],reverse=True):
|
| 1118 |
+
kw=display[key]
|
| 1119 |
+
if key in seen:continue
|
| 1120 |
+
seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
|
| 1121 |
+
if len(topics)>=24:break
|
| 1122 |
+
for kw in ['AI tại Việt Nam','Công nghệ Việt Nam','VTV thời sự','VnExpress kinh doanh','Dân trí xã hội','GenK AI','VNReview công nghệ','Vatvostudio smartphone','Thể thao văn hóa World Cup']:
|
| 1123 |
+
if kw.lower() not in seen:topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
|
| 1124 |
+
_HOT_CACHE.update({'t':now,'d':topics[:24]})
|
| 1125 |
+
return _HOT_CACHE['d']
|
| 1126 |
+
|
| 1127 |
+
def _candidate_urls(topic):
|
| 1128 |
+
seen=set();items=[]
|
| 1129 |
+
scored=[]
|
| 1130 |
+
for it in _fast_rss_pool():
|
| 1131 |
+
sc=_fast_score(topic,it)
|
| 1132 |
+
if sc>0:scored.append((sc,it))
|
| 1133 |
+
for sc,it in sorted(scored,key=lambda x:(x[0],len(x[1].get('snippet',''))),reverse=True)[:14]:
|
| 1134 |
+
if it['url'] not in seen:
|
| 1135 |
+
seen.add(it['url']);items.append(it)
|
| 1136 |
+
# Search only selected sources when RSS lacks matches.
|
| 1137 |
+
if len(items)<6:
|
| 1138 |
+
for dom in SELECTED_DOMAINS:
|
| 1139 |
+
for it in _ddg_search(f'{topic} site:{dom}',4):
|
| 1140 |
+
if it['url'] not in seen:
|
| 1141 |
+
seen.add(it['url']);items.append(it)
|
| 1142 |
+
if len(items)>=12:break
|
| 1143 |
+
return items[:20]
|
| 1144 |
+
|
| 1145 |
+
|
| 1146 |
+
# ===== FINAL6G: SOURCE-LIMITED FAST TOPICS AND FAST HOME =====
|
| 1147 |
+
# Limit hot hashtags/topic context to requested sources and make homepage APIs return quickly.
|
| 1148 |
+
_SOURCE_FEEDS = [
|
| 1149 |
+
('VnExpress','https://vnexpress.net/rss/tin-moi-nhat.rss','vnexpress.net'),
|
| 1150 |
+
('VnExpress Thời sự','https://vnexpress.net/rss/thoi-su.rss','vnexpress.net'),
|
| 1151 |
+
('VnExpress Thế giới','https://vnexpress.net/rss/the-gioi.rss','vnexpress.net'),
|
| 1152 |
+
('VnExpress Kinh doanh','https://vnexpress.net/rss/kinh-doanh.rss','vnexpress.net'),
|
| 1153 |
+
('VnExpress Công nghệ','https://vnexpress.net/rss/so-hoa.rss','vnexpress.net'),
|
| 1154 |
+
('VnExpress Thể thao','https://vnexpress.net/rss/the-thao.rss','vnexpress.net'),
|
| 1155 |
+
('Dân trí','https://dantri.com.vn/rss/home.rss','dantri.com.vn'),
|
| 1156 |
+
('Dân trí Xã hội','https://dantri.com.vn/rss/xa-hoi.rss','dantri.com.vn'),
|
| 1157 |
+
('Dân trí Kinh doanh','https://dantri.com.vn/rss/kinh-doanh.rss','dantri.com.vn'),
|
| 1158 |
+
('Dân trí Thể thao','https://dantri.com.vn/rss/the-thao.rss','dantri.com.vn'),
|
| 1159 |
+
('Dân trí Công nghệ','https://dantri.com.vn/rss/suc-manh-so.rss','dantri.com.vn'),
|
| 1160 |
+
('VTV','https://vtv.vn/rss/trang-chu.rss','vtv.vn'),
|
| 1161 |
+
('VTV Thời sự','https://vtv.vn/rss/thoi-su.rss','vtv.vn'),
|
| 1162 |
+
('GenK','https://genk.vn/rss/home.rss','genk.vn'),
|
| 1163 |
+
('GenK AI','https://genk.vn/ai.rss','genk.vn'),
|
| 1164 |
+
('VnReview','https://vnreview.vn/rss/tin-moi-nhat.rss','vnreview.vn'),
|
| 1165 |
+
('VnReview Công nghệ','https://vnreview.vn/rss/cong-nghe.rss','vnreview.vn'),
|
| 1166 |
+
('Vật Vờ Studio','https://vatvostudio.vn/feed/','vatvostudio.vn'),
|
| 1167 |
+
('Thể thao văn hóa','https://thethaovanhoa.vn/rss/home.rss','thethaovanhoa.vn'),
|
| 1168 |
+
('Thể thao văn hóa World Cup','https://thethaovanhoa.vn/rss/world-cup-2026.rss','thethaovanhoa.vn'),
|
| 1169 |
+
]
|
| 1170 |
+
_SOURCE_CACHE={'t':0,'items':[]}
|
| 1171 |
+
_FAST_ROUTE_CACHE={}
|
| 1172 |
+
|
| 1173 |
+
def _feed_items_source_limited(max_per_feed=10):
|
| 1174 |
+
now=time.time()
|
| 1175 |
+
if _SOURCE_CACHE['items'] and now-_SOURCE_CACHE['t']<600:return _SOURCE_CACHE['items']
|
| 1176 |
+
items=[];seen=set()
|
| 1177 |
+
def one(feed):
|
| 1178 |
+
name,url,dom=feed;out=[]
|
| 1179 |
+
try:
|
| 1180 |
+
r=requests.get(url,headers=UA,timeout=3.5);r.encoding='utf-8'
|
| 1181 |
+
soup=BeautifulSoup(r.text,'xml')
|
| 1182 |
+
for it in soup.find_all('item')[:max_per_feed*2]:
|
| 1183 |
+
title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
|
| 1184 |
+
link=clean(it.find('link').get_text(strip=True) if it.find('link') else '')
|
| 1185 |
+
desc_raw=it.find('description').get_text(' ',strip=True) if it.find('description') else ''
|
| 1186 |
+
ds=BeautifulSoup(desc_raw,'lxml')
|
| 1187 |
+
img='';im=ds.find('img')
|
| 1188 |
+
if im:img=im.get('src','') or im.get('data-src','')
|
| 1189 |
+
desc=clean(ds.get_text(' ',strip=True))[:700]
|
| 1190 |
+
if title and link:
|
| 1191 |
+
out.append({'title':title,'url':link,'link':link,'source':name,'via':name,'domain':dom,'snippet':desc,'img':img})
|
| 1192 |
+
if len(out)>=max_per_feed:break
|
| 1193 |
+
except Exception:pass
|
| 1194 |
+
return out
|
| 1195 |
+
try:
|
| 1196 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 1197 |
+
with ThreadPoolExecutor(max_workers=8) as ex:
|
| 1198 |
+
futs=[ex.submit(one,f) for f in _SOURCE_FEEDS]
|
| 1199 |
+
for f in as_completed(futs,timeout=5.5):
|
| 1200 |
+
try:
|
| 1201 |
+
for it in f.result() or []:
|
| 1202 |
+
if it['url'] not in seen:
|
| 1203 |
+
seen.add(it['url']);items.append(it)
|
| 1204 |
+
except Exception:pass
|
| 1205 |
+
except Exception:
|
| 1206 |
+
for f in _SOURCE_FEEDS[:8]:
|
| 1207 |
+
for it in one(f):
|
| 1208 |
+
if it['url'] not in seen:
|
| 1209 |
+
seen.add(it['url']);items.append(it)
|
| 1210 |
+
_SOURCE_CACHE.update({'t':now,'items':items})
|
| 1211 |
+
return items
|
| 1212 |
+
|
| 1213 |
+
def _score_topic_source(topic,it):
|
| 1214 |
+
toks=[w.lower() for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',topic or '') if len(w)>1 and w.lower() not in STOP_WORDS]
|
| 1215 |
+
hay=(it.get('title','')+' '+it.get('snippet','')+' '+it.get('source','')).lower()
|
| 1216 |
+
if not toks:return 0
|
| 1217 |
+
score=sum((3 if len(t)>3 else 1) for t in toks if t in hay)
|
| 1218 |
+
if topic.lower().strip() and topic.lower().strip() in hay:score+=12
|
| 1219 |
+
return score
|
| 1220 |
+
|
| 1221 |
+
def _hot_topics():
|
| 1222 |
+
now=time.time()
|
| 1223 |
+
if _HOT_CACHE['d'] and now-_HOT_CACHE['t']<600:return _HOT_CACHE['d']
|
| 1224 |
+
freq={};display={}
|
| 1225 |
+
for it in _feed_items_source_limited(8)[:180]:
|
| 1226 |
+
title=re.sub(r'\s+-\s+.*$','',it.get('title',''))
|
| 1227 |
+
kws=[]
|
| 1228 |
+
for m in re.findall(r'([A-ZĐÀ-Ỹ][A-Za-zÀ-ỹ0-9]+(?:\s+[A-ZĐÀ-ỸA-Za-zÀ-ỹ0-9][A-Za-zÀ-ỹ0-9]+){1,4})',title):
|
| 1229 |
+
if len(m)>=6:kws.append(m)
|
| 1230 |
+
kws += _keywords_from_title(title)
|
| 1231 |
+
for kw in kws[:4]:
|
| 1232 |
+
words=[w for w in clean(kw).split() if w.lower() not in STOP_WORDS]
|
| 1233 |
+
if len(words)<2:continue
|
| 1234 |
+
kw=' '.join(words[:5])
|
| 1235 |
+
if 6<=len(kw)<=55:
|
| 1236 |
+
key=kw.lower();freq[key]=freq.get(key,0)+1;display[key]=kw
|
| 1237 |
+
topics=[]
|
| 1238 |
+
for key,_ in sorted(freq.items(),key=lambda x:x[1],reverse=True):
|
| 1239 |
+
kw=display[key];topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
|
| 1240 |
+
if len(topics)>=24:break
|
| 1241 |
+
for kw in ['Giá vàng trong nước','AI tại Việt Nam','Bóng đá Việt Nam','Kinh tế Việt Nam','Công nghệ AI','Vật Vờ Studio','World Cup 2026','Sức khỏe cộng đồng']:
|
| 1242 |
+
if not any(t['topic'].lower()==kw.lower() for t in topics):topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
|
| 1243 |
+
_HOT_CACHE.update({'t':now,'d':topics[:24]})
|
| 1244 |
+
return _HOT_CACHE['d']
|
| 1245 |
+
|
| 1246 |
+
def _fast_context(topic):
|
| 1247 |
+
now=time.time();key='source_limited_ctx:'+topic.lower().strip()
|
| 1248 |
+
if key in _FAST_TOPIC_CACHE and now-_FAST_TOPIC_CACHE[key]['t']<600:return _FAST_TOPIC_CACHE[key]['d']
|
| 1249 |
+
pool=_feed_items_source_limited(12)
|
| 1250 |
+
scored=[]
|
| 1251 |
+
for it in pool:
|
| 1252 |
+
sc=_score_topic_source(topic,it)
|
| 1253 |
+
if sc>0:scored.append((sc,it))
|
| 1254 |
+
if not scored:
|
| 1255 |
+
# Try broader matching by first token only before giving up.
|
| 1256 |
+
first=(_fast_topic_tokens(topic) or [''])[0]
|
| 1257 |
+
if first:
|
| 1258 |
+
for it in pool:
|
| 1259 |
+
if first in (it.get('title','')+' '+it.get('snippet','')).lower():scored.append((1,it))
|
| 1260 |
+
picked=[it for sc,it in sorted(scored,key=lambda x:(x[0],len(x[1].get('snippet',''))),reverse=True)[:8]]
|
| 1261 |
+
if not picked:picked=pool[:6]
|
| 1262 |
+
blocks=[];src=[]
|
| 1263 |
+
for it in picked:
|
| 1264 |
+
content=it.get('snippet','') or it.get('title','')
|
| 1265 |
+
blocks.append(f"NGUỒN: {it.get('source','')}\nTIÊU ĐỀ: {it.get('title','')}\nTÓM TẮT RSS:\n{content}")
|
| 1266 |
+
src.append({'title':it.get('title',''),'url':it.get('url',''),'via':it.get('source',''),'snippet':content})
|
| 1267 |
+
data={'context':'\n\n---\n\n'.join(blocks),'sources':src,'count':len(blocks)}
|
| 1268 |
+
_FAST_TOPIC_CACHE[key]={'t':now,'d':data}
|
| 1269 |
+
return data
|
| 1270 |
+
|
| 1271 |
+
# Override slow search functions to never crawl open web during topic generation.
|
| 1272 |
+
def _web_research_context(topic):
|
| 1273 |
+
return _fast_context(topic)
|
| 1274 |
+
|
| 1275 |
+
def _candidate_urls(topic):
|
| 1276 |
+
return _fast_context(topic).get('sources',[])
|
| 1277 |
+
|
| 1278 |
+
# Fast homepage endpoints from requested source RSS; no slow HTML scrapers.
|
| 1279 |
+
def _fast_homepage_sources():
|
| 1280 |
+
now=time.time();key='home_sources'
|
| 1281 |
+
if key in _FAST_ROUTE_CACHE and now-_FAST_ROUTE_CACHE[key]['t']<600:return _FAST_ROUTE_CACHE[key]['d']
|
| 1282 |
+
groups=[];seen=set()
|
| 1283 |
+
group_map=[('Tin mới','https://vnexpress.net/rss/tin-moi-nhat.rss','vne'),('Thời Sự','https://vnexpress.net/rss/thoi-su.rss','vne'),('Kinh Doanh','https://vnexpress.net/rss/kinh-doanh.rss','vne'),('Công Nghệ','https://vnexpress.net/rss/so-hoa.rss','vne'),('Dân Trí','https://dantri.com.vn/rss/home.rss','dantri'),('GenK','https://genk.vn/rss/home.rss','genk'),('VnReview','https://vnreview.vn/rss/tin-moi-nhat.rss','vnreview')]
|
| 1284 |
+
for g,u,s in group_map:
|
| 1285 |
+
for it in _rss_articles_fast(u,g,s,6) if '_rss_articles_fast' in globals() else []:
|
| 1286 |
+
if it['link'] not in seen:
|
| 1287 |
+
seen.add(it['link']);groups.append(it)
|
| 1288 |
+
_FAST_ROUTE_CACHE[key]={'t':now,'d':groups}
|
| 1289 |
+
return groups
|
| 1290 |
+
|
| 1291 |
+
for _p in ['/api/homepage','/api/dantri_hot','/api/vne_video','/api/highlights','/api/hot_topics','/api/topic_sources']:
|
| 1292 |
+
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)==_p and 'GET' in getattr(r,'methods',set()))]
|
| 1293 |
+
@app.get('/api/homepage')
|
| 1294 |
+
def api_homepage_source_fast():return JSONResponse(_fast_homepage_sources())
|
| 1295 |
+
@app.get('/api/dantri_hot')
|
| 1296 |
+
def api_dantri_hot_source_fast():
|
| 1297 |
+
data=[{**it,'source':'dantri','link':it.get('url') or it.get('link')} for it in _feed_items_source_limited(8) if it.get('domain')=='dantri.com.vn'][:12]
|
| 1298 |
+
return JSONResponse(data)
|
| 1299 |
+
@app.get('/api/vne_video')
|
| 1300 |
+
def api_vne_video_source_fast():
|
| 1301 |
+
return JSONResponse([]) # do not block homepage if VnEgo is slow
|
| 1302 |
+
@app.get('/api/highlights')
|
| 1303 |
+
def api_highlights_source_fast():return JSONResponse([])
|
| 1304 |
+
@app.get('/api/hot_topics')
|
| 1305 |
+
def api_hot_topics_source_fast():return JSONResponse({'topics':_hot_topics(),'sources':'vn_only'})
|
| 1306 |
+
@app.get('/api/topic_sources')
|
| 1307 |
+
def api_topic_sources_source_fast(topic:str=Query(...)):
|
| 1308 |
+
data=_fast_context(clean(topic));return JSONResponse({'count':data.get('count',0),'sources':data.get('sources',[]),'has_context':bool(data.get('context')),'mode':'source_limited_rss'})
|
| 1309 |
+
|
| 1310 |
+
# Override root: include all existing UI but add a script that prevents forced shorts refresh on initial load.
|
| 1311 |
+
ROOT_FAST_INJECT="""
|
| 1312 |
+
<script>
|
| 1313 |
+
(function(){
|
| 1314 |
+
const oldFetch=window.fetch;window.__allowShortRefresh=false;
|
| 1315 |
+
window.fetch=function(url,opts){try{let u=String(url||'');if(u.includes('/api/shorts?refresh=1')&&!window.__allowShortRefresh)url='/api/shorts';}catch(e){}return oldFetch.call(this,url,opts)};
|
| 1316 |
+
setTimeout(()=>{window.__allowShortRefresh=true;},8000);
|
| 1317 |
+
})();
|
| 1318 |
+
</script>
|
| 1319 |
+
"""
|
| 1320 |
+
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
|
| 1321 |
+
@app.get('/')
|
| 1322 |
+
async def index_final_fast_sources():
|
| 1323 |
+
html=f5.f4.f3.f2.f1._load_index_html()
|
| 1324 |
+
body=getattr(rt.old,'PATCH_INJECT','')+f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT+FINAL6_INJECT+globals().get('FINAL6_FAST_HOME_INJECT','')+globals().get('FINAL6E_INJECT','')+globals().get('FINAL6F_INJECT','')+ROOT_FAST_INJECT
|
| 1325 |
+
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|
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
|
| 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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("&", "&")
|
| 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/static/app_v2.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// === VNEWS Frontend v2 - Full Functions ===
|
| 2 |
+
// Updated: Voice selector + speed control + image gallery + auto voice detect
|
| 3 |
+
|
| 4 |
+
// === LOAD HOME ===
|
| 5 |
+
async function loadHome(){
|
| 6 |
+
const[featured,sh,wall,hlLeagues,ai,wcData]=await Promise.all([
|
| 7 |
+
fetch('/api/livescore/featured').then(r=>r.json()).catch(()=>null),
|
| 8 |
+
fetch('/api/shorts').then(r=>r.json()).catch(()=>[]),
|
| 9 |
+
fetch('/api/wall').then(r=>r.json()).catch(()=>({posts:[]})),
|
| 10 |
+
fetch('/api/highlights/leagues').then(r=>r.json()).catch(()=>({})),
|
| 11 |
+
fetch('/api/genk_ai').then(r=>r.json()).catch(()=>[]),
|
| 12 |
+
fetch('/api/wc2026').then(r=>r.json()).catch(()=>null)
|
| 13 |
+
]);
|
| 14 |
+
_hlLeagueData=hlLeagues;
|
| 15 |
+
_wc2026Data=wcData;
|
| 16 |
+
_shortsData=interleaveShorts(sh||[]);
|
| 17 |
+
_wallPosts=(wall&&wall.posts)||[];
|
| 18 |
+
let h='';
|
| 19 |
+
if(featured&&featured.home){const sc=featured.status==='live'?'':'upcoming';const st=featured.status==='live'?`🔴 ${featured.minute||'LIVE'}`:`⏰ ${featured.time}`;h+=`<div class="featured-match" onclick="openMatch('${featured.event_id}')"><div class="fm-league">${featured.league}</div><div class="fm-teams"><div class="fm-team"><img src="${featured.home_logo}" onerror="this.style.display='none'"><span>${featured.home}</span></div><div class="fm-score">${featured.score||'VS'}</div><div class="fm-team"><img src="${featured.away_logo}" onerror="this.style.display='none'"><span>${featured.away}</span></div></div><div class="fm-status ${sc}">${st}</div></div>`;}
|
| 20 |
+
h+=`<div class="ai-compose"><div class="ai-compose-title">🤖 AI viết bài</div><div class="ai-compose-row"><input id="topic-input" placeholder="Nhập chủ đề..."><button onclick="searchTopic()">Tìm nguồn</button></div><div class="ai-compose-row"><input id="url-input" placeholder="Dán URL bài viết..."><button class="secondary" onclick="rewriteUrl()">Rewrite</button></div><div id="hot-topics" class="hot-topic-row"></div></div>`;
|
| 21 |
+
h+='<div id="hashtag-box"></div>';
|
| 22 |
+
h+=`<div class="ls-section"><div class="ls-header"><h3>⚽ Livescore</h3></div><div class="ls-tabs"><span class="ls-tab active" data-tab="today" onclick="loadLivescore('today')">📅 Hôm nay</span><span class="ls-tab" data-tab="live" onclick="loadLivescore('live')">🔴 Live</span><span class="ls-tab" data-tab="incoming" onclick="loadLivescore('incoming')">⏰ Sắp tới</span><span class="ls-tab" data-tab="results" onclick="loadLivescore('results')">✅ Kết quả</span><span class="ls-tab" data-tab="bxh_nha" onclick="loadLivescore('bxh_nha')">🏆 NHA</span><span class="ls-tab" data-tab="bxh_laliga" onclick="loadLivescore('bxh_laliga')">🏆 La Liga</span></div><div class="ls-content" id="ls-content"><div class="loading">Đang tải...</div></div></div>`;
|
| 23 |
+
h+=`<div id="wc2026-live-section" class="wc2026-section"><div class="wc-header"><h2>🏆 World Cup 2026</h2><span class="wc-live-badge">● LIVE</span></div><div class="wc-tabs"><span class="wc-tab active" onclick="switchWCTab('news')">📰 Tin tức</span><span class="wc-tab" onclick="switchWCTab('fixtures')">📅 Lịch thi đấu</span><span class="wc-tab" onclick="switchWCTab('standings')">🏆 BXH</span><span class="wc-tab" onclick="switchWCTab('highlights')">🎬 Highlight</span><span class="wc-tab" onclick="switchWCTab('stats')">📊 Thống kê</span></div><div class="wc-content" id="wc-content"><div class="loading">Đang tải World Cup 2026...</div></div></div>`;
|
| 24 |
+
const wallPosts=_wallPosts;
|
| 25 |
+
const aiShorts=wallPosts.filter(p=>p.video);
|
| 26 |
+
if(aiShorts.length){h+='<div class="slider-wrap"><div class="slider-header"><span class="slider-label">🎬 Short AI</span></div><div class="slider-track">';aiShorts.slice(0,20).forEach((p,i)=>{h+=`<div class="slider-item shorts-item" onclick="openShortAIFeed(${i})"><div class="slider-thumb shorts-thumb"><video src="${p.video}" muted preload="metadata"></video><div class="card-play">▶</div></div><div class="slider-title">${esc(p.title)}</div></div>`});h+='</div></div>';}
|
| 27 |
+
if(_shortsData.length){h+='<div class="slider-wrap"><div class="slider-header"><span class="slider-label">📱 Shorts Dân trí & SKĐS</span><span class="slider-note">Mới nhất · xen kẽ</span></div><div class="slider-track">';_shortsData.slice(0,30).forEach((a,i)=>{const badge=a.channel==='baosuckhoedoisongboyte'?'SKĐS':'Dân trí';h+=`<div class="slider-item shorts-item" onclick="openYTShortsFeed(${i})"><div class="slider-thumb shorts-thumb">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="slider-title"><span style="color:#f0c040;font-size:8px">${badge}</span> ${esc(a.title)}</div></div>`;});h+='</div></div>';}
|
| 28 |
+
if(wallPosts.length){h+=`<div class="slider-wrap" id="ai-wall-wrap"><div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track" id="ai-wall-track">`;wallPosts.slice(0,20).forEach((p,i)=>{h+=makeWallItem(p,i)});h+='</div></div>';}
|
| 29 |
+
const HL_CONFIG={"world-cup":{name:"World Cup 2026",emoji:"🌍"},"premier-league":{name:"Premier League",emoji:"🏴"},"champions-league":{name:"Champions League",emoji:"⭐"},"la-liga":{name:"La Liga",emoji:"🇪🇸"},"serie-a":{name:"Serie A",emoji:"🇮🇹"},"bundesliga":{name:"Bundesliga",emoji:"🇩🇪"},"friendly":{name:"Giao hữu",emoji:"🤝"}};
|
| 30 |
+
for(const[key,cfg] of Object.entries(HL_CONFIG)){const vids=hlLeagues[key];if(!vids||!vids.length)continue;h+=`<div class="slider-wrap"><div class="slider-header"><span class="slider-label">${cfg.emoji} ${cfg.name}</span></div><div class="slider-track">`;vids.slice(0,8).forEach((a,i)=>{h+=`<div class="slider-item" onclick="openHighlightFeed('${key}',${i})"><div class="slider-thumb">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="slider-title">${esc(a.title)}</div></div>`});h+='</div></div>';}
|
| 31 |
+
if(ai&&ai.length){h+='<div class="slider-wrap"><div class="slider-header"><span class="slider-label">🤖 Ứng dụng AI</span></div><div class="slider-track">';ai.slice(0,12).forEach(a=>{h+=`<div class="slider-item" onclick="readArticle('${esc(a.link)}')"><div class="slider-thumb">${a.img?`<img src="${a.img}">`:''}</div><div class="slider-title">${esc(a.title)}</div></div>`});h+='</div></div>';}
|
| 32 |
+
document.getElementById('view-home').innerHTML=h;
|
| 33 |
+
loadLivescore('today');loadHotTopics();
|
| 34 |
+
if(_wc2026Data)switchWCTab('news');
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// === WALL POST HELPERS ===
|
| 38 |
+
function makeWallItem(p,i){
|
| 39 |
+
const hasVideo = p.video && p.video.length > 0;
|
| 40 |
+
const thumbContent = p.img
|
| 41 |
+
? `<img src="${esc(p.img)}" onerror="this.style.display='none'">`
|
| 42 |
+
: (hasVideo ? `<video src="${esc(p.video)}" muted></video>` : '');
|
| 43 |
+
const videoBadge = hasVideo
|
| 44 |
+
? `<div class="wall-video-badge">🎬</div>`
|
| 45 |
+
: '';
|
| 46 |
+
const videoBtn = hasVideo
|
| 47 |
+
? `<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed(${i})">▶ Xem Short</button>`
|
| 48 |
+
: `<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo('${esc(p.id||i)}',this)">🎬 Tạo Video</button>`;
|
| 49 |
+
|
| 50 |
+
return `<div class="wall-item" id="wall-item-${esc(p.id||i)}">
|
| 51 |
+
<div class="wall-thumb">
|
| 52 |
+
${thumbContent}
|
| 53 |
+
${videoBadge}
|
| 54 |
+
</div>
|
| 55 |
+
<div class="wall-title">${esc(p.title)}</div>
|
| 56 |
+
<div class="wall-text">${esc((p.text||'').slice(0,180))}</div>
|
| 57 |
+
<div class="wall-actions">
|
| 58 |
+
<button class="primary" onclick="readWallPost(${i})">Xem</button>
|
| 59 |
+
${videoBtn}
|
| 60 |
+
</div>
|
| 61 |
+
</div>`;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
// === GENERATE SHORT VIDEO FOR A WALL POST ===
|
| 65 |
+
async function makeShortVideo(postId, btn, voice, speed){
|
| 66 |
+
if(!postId)return;
|
| 67 |
+
const origText = btn ? btn.textContent : '🎬 Tạo Video';
|
| 68 |
+
if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo...';}
|
| 69 |
+
toast('⏳ Đang tạo video shorts...');
|
| 70 |
+
try{
|
| 71 |
+
let url = '/api/ai/short/'+encodeURIComponent(postId);
|
| 72 |
+
const params = [];
|
| 73 |
+
if(voice) params.push('voice='+encodeURIComponent(voice));
|
| 74 |
+
if(speed) params.push('speed='+encodeURIComponent(speed));
|
| 75 |
+
if(params.length) url += '?' + params.join('&');
|
| 76 |
+
const r = await fetch(url, {method:'POST'});
|
| 77 |
+
const j = await r.json();
|
| 78 |
+
if(!r.ok || j.error) throw new Error(j.error||'Lỗi tạo video');
|
| 79 |
+
toast('✅ Đã tạo video shorts!');
|
| 80 |
+
const p = _wallPosts.find(x => String(x.id) === String(postId));
|
| 81 |
+
if(p){
|
| 82 |
+
p.video = j.video;
|
| 83 |
+
const itemId = 'wall-item-'+postId;
|
| 84 |
+
const el = document.getElementById(itemId);
|
| 85 |
+
if(el){
|
| 86 |
+
const idx = _wallPosts.indexOf(p);
|
| 87 |
+
el.outerHTML = makeWallItem(p, idx);
|
| 88 |
+
const newEl = document.getElementById(itemId);
|
| 89 |
+
if(newEl) newEl.className = 'wall-item wall-item-new';
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
refreshShortAISlider();
|
| 93 |
+
}catch(e){
|
| 94 |
+
toast('❌ '+e.message);
|
| 95 |
+
if(btn){btn.disabled=false;btn.textContent=origText;}
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
// Refresh Short AI slider after video generation
|
| 100 |
+
function refreshShortAISlider(){
|
| 101 |
+
const aiShorts = _wallPosts.filter(p=>p.video);
|
| 102 |
+
let shortAISection = document.getElementById('short-ai-section');
|
| 103 |
+
if(aiShorts.length === 0){
|
| 104 |
+
if(shortAISection) shortAISection.remove();
|
| 105 |
+
return;
|
| 106 |
+
}
|
| 107 |
+
if(shortAISection){
|
| 108 |
+
const track = shortAISection.querySelector('.slider-track');
|
| 109 |
+
if(track){
|
| 110 |
+
let h = '';
|
| 111 |
+
aiShorts.slice(0,20).forEach((p,i)=>{
|
| 112 |
+
h+=`<div class="slider-item shorts-item" onclick="openShortAIFeed(${i})"><div class="slider-thumb shorts-thumb"><video src="${esc(p.video)}" muted preload="metadata"></video><div class="card-play">▶</div></div><div class="slider-title">${esc(p.title)}</div></div>`;
|
| 113 |
+
});
|
| 114 |
+
track.innerHTML = h;
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
function prependWallPost(post){
|
| 120 |
+
_wallPosts.unshift(post);
|
| 121 |
+
const track=document.getElementById('ai-wall-track');
|
| 122 |
+
const wrap=document.getElementById('ai-wall-wrap');
|
| 123 |
+
const homeEl=document.getElementById('view-home');
|
| 124 |
+
if(!track||!wrap){
|
| 125 |
+
if(homeEl){
|
| 126 |
+
let insertBefore=homeEl.querySelector('.slider-wrap');
|
| 127 |
+
const newWrap=document.createElement('div');
|
| 128 |
+
newWrap.className='slider-wrap';
|
| 129 |
+
newWrap.id='ai-wall-wrap';
|
| 130 |
+
newWrap.innerHTML=`<div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track" id="ai-wall-track">${makeWallItem(post,0)}</div>`;
|
| 131 |
+
if(insertBefore){
|
| 132 |
+
homeEl.insertBefore(newWrap,insertBefore);
|
| 133 |
+
}else{
|
| 134 |
+
homeEl.appendChild(newWrap);
|
| 135 |
+
}
|
| 136 |
+
const firstItem=newWrap.querySelector('.wall-item');
|
| 137 |
+
if(firstItem)firstItem.className='wall-item wall-item-new';
|
| 138 |
+
}
|
| 139 |
+
return;
|
| 140 |
+
}
|
| 141 |
+
const div=document.createElement('div');
|
| 142 |
+
div.className='wall-item wall-item-new';
|
| 143 |
+
div.id='wall-item-'+(post.id||'new-'+Date.now());
|
| 144 |
+
const hasVideo = post.video && post.video.length > 0;
|
| 145 |
+
const thumbContent = post.img
|
| 146 |
+
? `<img src="${esc(post.img)}" onerror="this.style.display='none'">`
|
| 147 |
+
: (hasVideo ? `<video src="${esc(post.video)}" muted></video>` : '');
|
| 148 |
+
const videoBadge = hasVideo ? `<div class="wall-video-badge">🎬</div>` : '';
|
| 149 |
+
const videoBtn = hasVideo
|
| 150 |
+
? `<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed(0)">▶ Xem Short</button>`
|
| 151 |
+
: `<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo('${esc(post.id)}',this)">🎬 Tạo Video</button>`;
|
| 152 |
+
div.innerHTML=`<div class="wall-thumb">${thumbContent}${videoBadge}</div><div class="wall-title">${esc(post.title)}</div><div class="wall-text">${esc((post.text||'').slice(0,180))}</div><div class="wall-actions"><button class="primary" onclick="readWallPost(0)">Xem</button>${videoBtn}</div>`;
|
| 153 |
+
track.prepend(div);
|
| 154 |
+
track.scrollTo({left:0,behavior:'smooth'});
|
| 155 |
+
if(hasVideo) refreshShortAISlider();
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
// === REST OF FUNCTIONS ===
|
| 159 |
+
let _shortsData=[];
|
| 160 |
+
let _wallPosts=[];
|
| 161 |
+
let _currentView='home';
|
| 162 |
+
function interleaveShorts(shorts){const dt=shorts.filter(s=>s.channel==='baodantri7941');const sk=shorts.filter(s=>s.channel==='baosuckhoedoisongboyte');const result=[];let i=0,j=0;while(i<dt.length||j<sk.length){if(i<dt.length)result.push(dt[i++]);if(j<sk.length)result.push(sk[j++]);}return result;}
|
| 163 |
+
let _htPage=0,_htTopic='';
|
| 164 |
+
async function loadHotTopics(){const j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));const el=document.getElementById('hot-topics');if(!el)return;el.innerHTML=(j.topics||[]).slice(0,18).map(t=>{const topicText=t.topic||t.label.replace(/^#/,'');return`<button class="hot-chip" onclick="searchTopic('${topicText.replace(/'/g,"\\'")}')">${esc(t.label)}</button>`;}).join('');if(j.topics&&j.topics[0]){const firstTopic=j.topics[0].topic||j.topics[0].label.replace(/^#/,'');setTimeout(()=>searchTopic(firstTopic),800);}}
|
| 165 |
+
function searchTopic(topic){if(!topic){topic=document.getElementById('topic-input')?.value.trim();if(!topic){alert('Nhập chủ đề');return;}}document.getElementById('topic-input').value='';_htTopic=topic;_htPage=0;showHashtagSources(topic,0);}
|
| 166 |
+
async function showHashtagSources(topic,page){const box=document.getElementById('hashtag-box');if(!box)return;if(page===0)box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div class="hashtag-loading"><div class="hashtag-spinner"></div>Đang tìm...</div></div>`;try{const r=await fetch(`/api/hashtag/sources?topic=${encodeURIComponent(topic)}&page=${page}`);const j=await r.json();const sources=j.sources||[];if(!sources.length&&page===0){box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div style="color:#888;padding:8px">Không tìm được bài viết liên quan</div></div>`;return;}let h='';if(page===0)h=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)} <span style="font-size:10px;color:#888">(${j.total} bài từ 8 nguồn)</span></h3><div id="ht-list">`;sources.forEach((s,i)=>{const idx=page*8+i;h+=`<div class="hashtag-src-item" onclick="readArticle('${esc(s.url)}')"><div class="hashtag-src-img" id="ht-img-${idx}"></div><div class="hashtag-src-text"><div class="hashtag-src-title">${esc(s.title)}</div><div class="hashtag-src-via">${esc(s.via||'')}</div></div></div>`;});if(page===0){h+=`</div><button class="hashtag-rewrite-btn" onclick="rewriteHashtag('${esc(topic).replace(/'/g,"\\'")}')">🤖 Rewrite AI tổng hợp & đăng tường</button>`;if(j.has_more)h+=`<button class="hashtag-load-more" id="ht-more" onclick="loadMoreHashtag()">Tải thêm ▼</button>`;h+=`</div>`;box.innerHTML=h;}else{document.getElementById('ht-list')?.insertAdjacentHTML('beforeend',h);const btn=document.getElementById('ht-more');if(btn){if(!j.has_more)btn.remove();else{btn.disabled=false;btn.textContent='Tải thêm ▼';}}}sources.forEach((s,i)=>{const idx=page*8+i;if(!s.url)return;fetch('/api/article?url='+encodeURIComponent(s.url)).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){const el=document.getElementById('ht-img-'+idx);if(el)el.innerHTML=`<img src="${esc(d.og_image||d.img)}" onerror="this.style.display='none'">`;}}).catch(()=>{});});}catch(e){box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div style="color:#e74c3c;padding:8px">Lỗi: ${esc(e.message)}</div></div>`;}}
|
| 167 |
+
function loadMoreHashtag(){_htPage++;const btn=document.getElementById('ht-more');if(btn){btn.disabled=true;btn.textContent='Đang tải...';}showHashtagSources(_htTopic,_htPage);}
|
| 168 |
+
async function rewriteHashtag(topic){const btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang tổng hợp...';}try{const r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');toast('✅ Đã đăng Tường AI!');if(btn)btn.textContent='✅ Đăng thành công!';if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message);if(btn){btn.disabled=false;btn.textContent='🤖 Rewrite AI';}}}
|
| 169 |
+
async function loadLivescore(tab){document.querySelectorAll('.ls-tab').forEach(t=>t.classList.remove('active'));document.querySelector(`.ls-tab[data-tab="${tab}"]`)?.classList.add('active');const el=document.getElementById('ls-content');if(!el)return;el.innerHTML='<div class="loading">Đang tải...</div>';let ep='/api/livescore/'+tab;if(tab.startsWith('bxh_'))ep='/api/livescore/standings/'+tab.replace('bxh_','');try{const r=await fetch(ep);const d=await r.json();el.innerHTML=d.html&&d.html.length>50?d.html:'<div class="loading">Không có dữ liệu</div>';bindMatchClicks(el);}catch(e){el.innerHTML='<div class="loading">Lỗi</div>';}}
|
| 170 |
+
function bindMatchClicks(el){el.querySelectorAll('.match-detail').forEach(md=>{md.addEventListener('click',function(e){e.preventDefault();const a=this.querySelector('.status a');if(a){const m=(a.getAttribute('href')||'').match(/\/tran-dau\/(\d+)\//);if(m)openMatch(m[1])}})});el.querySelectorAll('a').forEach(a=>a.addEventListener('click',e=>e.preventDefault()))}
|
| 171 |
+
function openMatch(id){if(!id)return;_currentEventId=id;document.getElementById('match-overlay').classList.add('active');document.body.style.overflow='hidden';loadMatchTab('comm')}
|
| 172 |
+
function closeMatch(){document.getElementById('match-overlay').classList.remove('active');document.body.style.overflow=''}
|
| 173 |
+
async function loadMatchTab(tab){document.querySelectorAll('.mo-tab').forEach(t=>t.classList.remove('active'));document.querySelectorAll('.mo-tab').forEach(t=>{if((tab==='comm'&&t.textContent==='Diễn biến')||(tab==='stats'&&t.textContent==='Thống kê'))t.classList.add('active')});const el=document.getElementById('mo-body');el.innerHTML='<div class="loading">Đang tải...</div>';try{const r=await fetch(tab==='stats'?`/api/match/${_currentEventId}/stats`:`/api/match/${_currentEventId}/commentaries`);const d=await r.json();el.innerHTML=d.html||'<div class="loading">Không có dữ liệu</div>'}catch(e){el.innerHTML='<div class="loading">Lỗi</div>'}}
|
| 174 |
+
async function doInteract(videoId,type){try{const r=await fetch('/api/v2/interact',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:videoId,type})});return await r.json();}catch(e){return null;}}
|
| 175 |
+
async function getInteractions(videoId){try{return await fetch('/api/v2/interactions?id='+encodeURIComponent(videoId)).then(r=>r.json());}catch(e){return{views:0,likes:0,comments:0};}}
|
| 176 |
+
async function getComments(videoId){try{const j=await fetch('/api/v2/comments?id='+encodeURIComponent(videoId)).then(r=>r.json());return j.comments||[];}catch(e){return[];}}
|
| 177 |
+
async function postComment(videoId,text){try{const j=await fetch('/api/v2/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:videoId,text})}).then(r=>r.json());return j.comments||[];}catch(e){return[];}}
|
| 178 |
+
function buildTikTokSlide(opts){return`<div class="tiktok-slide" data-vid="${esc(opts.videoId)}">${opts.vtag}<div class="tiktok-bottom"><span class="badge ${opts.badgeClass||'badge-fpt'}">${opts.badge||''}</span><p class="tiktok-title">${esc(opts.title)}</p></div><div class="tiktok-right"><button class="tiktok-right-btn" onclick="event.stopPropagation();doView('${esc(opts.videoId)}',this)"><div class="icon">👁</div><div class="count" id="vc-${opts.idx}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();doLike('${esc(opts.videoId)}',this)"><div class="icon">❤️</div><div class="count" id="lc-${opts.idx}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();toggleComments('${esc(opts.videoId)}',${opts.idx})"><div class="icon">💬</div><div class="count" id="cc-${opts.idx}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();doShare('${esc(opts.title)}','${esc(opts.shareUrl||'')}','')"><div class="icon">📤</div></button>${opts.extraBtn||''}</div><span class="tiktok-counter">${opts.idx+1}/${opts.total}</span><div class="inline-comments" id="cmt-inline-${opts.idx}" style="display:none"></div></div>`;}
|
| 179 |
+
async function doView(videoId,btn){const j=await doInteract(videoId,'view');if(j){const c=btn.querySelector('.count');if(c)c.textContent=fmtNum(j.views);}}
|
| 180 |
+
async function doLike(videoId,btn){const j=await doInteract(videoId,'like');if(j){const c=btn.querySelector('.count');if(c)c.textContent=fmtNum(j.likes);}}
|
| 181 |
+
function fmtNum(n){if(!n)return'0';if(n>=1000000)return(n/1000000).toFixed(1)+'M';if(n>=1000)return(n/1000).toFixed(1)+'K';return String(n);}
|
| 182 |
+
async function loadCounters(videoIds){for(let i=0;i<videoIds.length;i++){const id=videoIds[i];if(!id)continue;const j=await getInteractions(id);const vc=document.getElementById('vc-'+i);if(vc)vc.textContent=fmtNum(j.views);const lc=document.getElementById('lc-'+i);if(lc)lc.textContent=fmtNum(j.likes);const cc=document.getElementById('cc-'+i);if(cc)cc.textContent=fmtNum(j.comments);}}
|
| 183 |
+
async function toggleComments(videoId,idx){const panel=document.getElementById('cmt-inline-'+idx);if(!panel)return;if(panel.style.display!=='none'){panel.style.display='none';return;}panel.style.display='block';panel.innerHTML='<div style="padding:8px;color:#888;font-size:11px">Đang tải...</div>';const cmts=await getComments(videoId);renderInlineComments(panel,videoId,idx,cmts);}
|
| 184 |
+
function renderInlineComments(panel,videoId,idx,cmts){let h='<div class="inline-cmt-header"><span>💬 Bình luận</span><button onclick="document.getElementById(\'cmt-inline-'+idx+'\').style.display=\'none\'">✕</button></div><div class="inline-cmt-list">';if(cmts.length){cmts.slice(-30).forEach(c=>{h+=`<div class="inline-cmt-item"><span class="inline-cmt-time">${c.time||''}</span>${esc(c.text)}</div>`;});}else{h+='<div style="color:#777;font-size:11px;padding:4px">Chưa có bình luận</div>';}h+=`</div><div class="inline-cmt-input"><input id="cmt-input-${idx}" placeholder="Viết bình luận..." onkeydown="if(event.key==='Enter')submitInlineCmt('${esc(videoId)}',${idx})"><button onclick="submitInlineCmt('${esc(videoId)}',${idx})">Gửi</button></div>`;panel.innerHTML=h;const list=panel.querySelector('.inline-cmt-list');if(list)list.scrollTop=list.scrollHeight;}
|
| 185 |
+
async function submitInlineCmt(videoId,idx){const inp=document.getElementById('cmt-input-'+idx);if(!inp)return;const text=inp.value.trim();if(!text)return;inp.value='';inp.disabled=true;const cmts=await postComment(videoId,text);inp.disabled=false;const panel=document.getElementById('cmt-inline-'+idx);if(panel)renderInlineComments(panel,videoId,idx,cmts);const cc=document.getElementById('cc-'+idx);if(cc)cc.textContent=fmtNum(cmts.length);}
|
| 186 |
+
function initTikTokFeed(){const feed=document.getElementById('tiktok-feed');if(!feed)return;const slides=feed.querySelectorAll('.tiktok-slide');let cur=-1;function act(i){if(i===cur)return;slides.forEach((sl,idx)=>{const v=sl.querySelector('video');const fr=sl.querySelector('iframe');if(idx===i){if(v&&v.dataset.hls&&!v._hls&&typeof Hls!=='undefined'&&Hls.isSupported()){const hls=new Hls();hls.loadSource(v.dataset.hls);hls.attachMedia(v);hls.on(Hls.Events.MANIFEST_PARSED,()=>v.play().catch(()=>{}));v._hls=hls}else if(v)v.play().catch(()=>{});if(fr&&!fr.src&&fr.dataset.ytSrc)fr.src=fr.dataset.ytSrc;const vid=sl.dataset.vid;if(vid&&!sl._viewed){sl._viewed=true;doInteract(vid,'view').then(j=>{if(j){const vc=document.getElementById('vc-'+idx);if(vc)vc.textContent=fmtNum(j.views);}});}}else{if(v){v.pause();if(v._hls){v._hls.destroy();v._hls=null}}if(fr&&fr.src)fr.src=''}});cur=i}let sT;feed.addEventListener('scroll',()=>{clearTimeout(sT);sT=setTimeout(()=>{const rect=feed.getBoundingClientRect(),ctr=rect.top+rect.height/2;let best=-1,bestD=1e9;slides.forEach((sl,i)=>{const d=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(d<bestD){bestD=d;best=i}});if(best>=0)act(best)},150)});setTimeout(()=>act(0),400);slides.forEach(sl=>{const v=sl.querySelector('video');if(v)v.addEventListener('click',e=>{e.preventDefault();v.paused?v.play().catch(()=>{}):v.pause()})});const ids=[...slides].map(sl=>sl.dataset.vid||'');loadCounters(ids);}
|
| 187 |
+
async function openHighlightFeed(league,idx){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải...</div>';let articles=(_hlLeagueData||{})[league]||[];if(!articles.length){try{articles=await fetch('/api/highlights/'+league).then(r=>r.json())}catch(e){articles=[]}}if(!articles.length){el.innerHTML='<div class="loading">Không có video</div>';return}const vids=[];const results=await Promise.all(articles.map(async(a,i)=>{try{const r=await fetch('/api/video_url?url='+encodeURIComponent(a.link));const v=await r.json();if(v&&v.src)return{...a,...v,_idx:i}}catch(e){}return null}));results.forEach(r=>{if(r)vids.push(r)});vids.sort((a,b)=>a._idx-b._idx);if(!vids.length){el.innerHTML='<div class="loading">Không tìm thấy video</div>';return}let ti=vids.findIndex(v=>v._idx===idx);if(ti<0)ti=0;const ordered=ti>0?[...vids.slice(ti),...vids.slice(0,ti)]:vids;let h=`<button class="back-btn" onclick="switchCat('home')">← Highlight</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">`;ordered.forEach((v,i)=>{const isYT=v.type==='youtube',isHLS=!isYT&&v.src?.includes('.m3u8'),poster=v.poster?` poster="${v.poster}"`:'';const vtag=isYT?`<iframe data-yt-src="${v.src}" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture"></iframe>`:isHLS?`<video playsinline preload="none"${poster} data-hls="${v.src}" loop controls></video>`:`<video playsinline preload="none"${poster} loop controls><source src="${v.src}" type="video/mp4"></video>`;const videoId='hl-'+league+'-'+(v.id||v._idx);h+=buildTikTokSlide({vtag,title:v.title,badge:'HL',badgeClass:'badge-fpt',videoId,idx:i,total:ordered.length,shareUrl:v.link||'',extraBtn:`<button class="tiktok-right-btn" onclick="event.stopPropagation();this.closest('.tiktok-slide').classList.toggle('ratio-wide')"><div class="icon">⬜</div><div class="count">16:9</div></button>`});});h+='</div></div>';el.innerHTML=h;initTikTokFeed();}
|
| 188 |
+
async function openYTShortsFeed(startIdx){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải...</div>';const arts=_shortsData.length?_shortsData:await fetch('/api/shorts').then(r=>r.json()).catch(()=>[]);if(!arts.length){el.innerHTML='<div class="loading">Không có shorts</div>';return}const ordered=startIdx>0?[...arts.slice(startIdx),...arts.slice(0,startIdx)]:arts;let h=`<button class="back-btn" onclick="switchCat('home')">← Shorts</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">`;ordered.forEach((v,i)=>{const id=v.id||'';const src=`https://www.youtube.com/embed/${id}?autoplay=1&rel=0&playsinline=1`;const vtag=`<iframe data-yt-src="${src}" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture"></iframe>`;const badge=v.channel==='baosuckhoedoisongboyte'?'SKĐS':'Dân trí';const videoId='yt-'+id;h+=buildTikTokSlide({vtag,title:v.title,badge,badgeClass:'badge-fpt',videoId,idx:i,total:ordered.length,shareUrl:'https://youtube.com/watch?v='+id});});h+='</div></div>';el.innerHTML=h;initTikTokFeed();}
|
| 189 |
+
async function openShortAIFeed(startIdx){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải...</div>';const wall=(await fetch('/api/wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];const vids=wall.filter(p=>p.video);if(!vids.length){el.innerHTML='<div class="loading">Chưa có Short AI</div>';return}const ordered=startIdx>0?[...vids.slice(startIdx),...vids.slice(0,startIdx)]:vids;let h=`<button class="back-btn" onclick="switchCat('home')">← Short AI</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">`;ordered.forEach((p,i)=>{const vtag=`<video src="${p.video}" playsinline loop controls></video>`;const videoId='ai-'+(p.id||i);h+=buildTikTokSlide({vtag,title:p.title,badge:'AI',badgeClass:'badge-ai',videoId,idx:i,total:ordered.length,shareUrl:SPACE});});h+='</div></div>';el.innerHTML=h;initTikTokFeed();}
|
| 190 |
+
async function readArticle(url){showView('view-article');const el=document.getElementById('view-article');el.innerHTML='<div class="loading">Đang tải...</div>';try{const r=await fetch('/api/article?url='+encodeURIComponent(url));const data=await r.json();if(data&&!data.error&&data.body&&data.body.length){_currentArticle={url,data};let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><h1 class="article-title">${esc(data.title)}</h1>`;if(data.summary)h+=`<div class="article-summary">${esc(data.summary)}</div>`;const seen={};data.body.forEach(b=>{if(b.type==='p')h+=`<p class="article-p">${b.text}</p>`;else if(b.type==='img'&&b.src&&!seen[b.src]){seen[b.src]=1;h+=`<img class="article-img" src="${esc(b.src)}" onerror="this.style.display='none'">`}else if(b.type==='heading')h+=`<h2 class="article-h2">${esc(b.text)}</h2>`});h+=`<div class="article-actions"><button class="primary" onclick="rewriteArticle()">🤖 Rewrite AI đăng tường</button><button onclick="doShare('${esc(data.title)}','${esc(url)}','${esc(data.og_image||'')}')">📤</button><button onclick="window.open('${esc(url)}','_blank')">🔗 Gốc</button></div><div class="article-ai-ask"><h3 style="font-size:14px;color:#5cb87a">🤖 Hỏi AI</h3><textarea id="ask-q" placeholder="Hỏi về bài viết..."></textarea><button onclick="askAI()">Hỏi</button><div id="ask-a" class="article-ai-answer"></div></div></div>`;el.innerHTML=h;window.scrollTo(0,0);return;}}catch(e){}el.innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="loading"><p>Không đọc được.</p><a href="${esc(url)}" target="_blank" style="color:#5cb87a">Mở gốc →</a></div>`;}
|
| 191 |
+
async function rewriteArticle(){const url=_currentArticle?.url;if(!url)return;toast('⏳ Đang rewrite...');try{const r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,context:document.querySelector('.article-view')?.innerText?.slice(0,14000)||''})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error);toast('✅ Đã đăng Tường AI!');if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message)}}
|
| 192 |
+
async function rewriteUrl(){const url=document.getElementById('url-input')?.value.trim();if(!url)return alert('Dán URL');toast('⏳ Đang rewrite...');try{const r=await fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error);toast('✅ Đã đăng!');document.getElementById('url-input').value='';if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message)}}
|
| 193 |
+
async function askAI(){const q=document.getElementById('ask-q')?.value.trim();if(!q)return alert('Nhập câu hỏi');const a=document.getElementById('ask-a');a.textContent='Đang hỏi...';try{const r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:_currentArticle?.url||'',question:q,context:document.querySelector('.article-view')?.innerText?.slice(0,12000)||''})});const j=await r.json();a.textContent=j.answer||'Không trả lời được';}catch(e){a.textContent='Lỗi: '+e.message}}
|
| 194 |
+
async function readWallPost(i){const p=_wallPosts[i];if(!p)return;showView('view-article');
|
| 195 |
+
// Build image gallery HTML
|
| 196 |
+
const images = p.images || [];
|
| 197 |
+
let imgGallery = '';
|
| 198 |
+
if(images.length > 0){
|
| 199 |
+
imgGallery = '<div class="article-image-gallery">';
|
| 200 |
+
images.forEach((imgUrl, idx) => {
|
| 201 |
+
if(idx === 0){
|
| 202 |
+
imgGallery += `<img class="article-img article-hero-img" src="${esc(imgUrl)}" onerror="this.style.display='none" loading="eager">`;
|
| 203 |
+
} else {
|
| 204 |
+
if(idx === 1) imgGallery += '<div class="gallery-thumbs">';
|
| 205 |
+
imgGallery += `<div class="gallery-thumb"><img src="${esc(imgUrl)}" onerror="this.parentElement.style.display='none'" loading="lazy"></div>`;
|
| 206 |
+
}
|
| 207 |
+
});
|
| 208 |
+
if(images.length > 1) imgGallery += '</div>';
|
| 209 |
+
imgGallery += '</div>';
|
| 210 |
+
}
|
| 211 |
+
const hasVideo = p.video && p.video.length > 0;
|
| 212 |
+
const voiceOptions = [
|
| 213 |
+
{id:'hoaimy', label:'🎙️ Nữ — Hoài My'},
|
| 214 |
+
{id:'namminh', label:'🎙️ Nam — Nam Minh'},
|
| 215 |
+
];
|
| 216 |
+
let voiceSelector = '';
|
| 217 |
+
if(!hasVideo){
|
| 218 |
+
voiceSelector = `<div class="tts-selector"><div class="tts-selector-label">🎙️ Chọn giọng đọc:</div><div class="tts-voice-btns">`;
|
| 219 |
+
voiceOptions.forEach(v=>{
|
| 220 |
+
voiceSelector += `<button class="tts-voice-btn" onclick="document.querySelectorAll('.tts-voice-btn').forEach(b=>b.classList.remove('active'));this.classList.add('active');document.getElementById('selected-voice').value='${v.id}'">${v.label}</button>`;
|
| 221 |
+
});
|
| 222 |
+
voiceSelector += `</div><div class="tts-speed-row"><span>Tốc độ:</span><select id="selected-speed"><option value="1.0">1.0x — Bình thường</option><option value="1.2" selected>1.2x — Nhanh</option><option value="1.5">1.5x — Rất nhanh</option><option value="0.8">0.8x — Chậm</option></select></div>`;
|
| 223 |
+
voiceSelector += `<input type="hidden" id="selected-voice" value="hoaimy"></div>`;
|
| 224 |
+
}
|
| 225 |
+
document.getElementById('view-article').innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${esc(p.title)}</h1>${imgGallery}<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>${hasVideo?`<video class="article-img" src="${esc(p.video)}" controls playsinline style="max-height:400px"></video>`:''}<div class="article-actions">${hasVideo?`<button onclick="openShortAIFeed(${i})">🎬 Xem Short</button>${voiceSelector}<button class="primary" onclick="makeShortVideo('${esc(p.id)}',this,document.getElementById('selected-voice')?.value,parseFloat(document.getElementById('selected-speed')?.value)||1.2)">🔄 Tạo lại Short</button>`:`${voiceSelector}<button class="primary" onclick="makeShortVideo('${esc(p.id)}',this,document.getElementById('selected-voice')?.value,parseFloat(document.getElementById('selected-speed')?.value)||1.2)">🎬 Tạo Video Shorts</button>`}<button onclick="doShare('${esc(p.title)}','${SPACE}','${esc(p.img||'')}')">📤</button></div></div>`;
|
| 226 |
+
const firstVoiceBtn = document.querySelector('.tts-voice-btn');
|
| 227 |
+
if(firstVoiceBtn) firstVoiceBtn.classList.add('active');
|
| 228 |
+
window.scrollTo(0,0)}
|
| 229 |
+
async function loadNewsTab(){const el=document.getElementById('view-cat');el.innerHTML='<div class="loading">Đang tải...</div>';try{const r=await fetch('/api/homepage');const news=await r.json();if(!news.length){el.innerHTML='<div class="loading">Không có tin</div>';return}const groups={};news.forEach(a=>{if(!groups[a.group])groups[a.group]=[];groups[a.group].push(a)});let h='';for(const[g,arts] of Object.entries(groups)){h+=`<div class="section-title">${g}</div><div class="grid">`;arts.slice(0,6).forEach(a=>{h+=`<div class="card" onclick="readArticle('${esc(a.link)}')"><div class="card-img">${a.img?`<img src="${a.img}">`:''}</div><div class="card-body"><span class="badge badge-vne">${esc(a.source||'VnE')}</span><div class="card-title">${esc(a.title)}</div></div></div>`});h+='</div>'}el.innerHTML=h}catch(e){el.innerHTML='<div class="loading">Lỗi</div>'}}
|
| 230 |
+
async function loadCat(id){const el=document.getElementById('view-cat');el.innerHTML='<div class="loading">Đang tải...</div>';const arts=await fetch('/api/category/'+id).then(r=>r.json()).catch(()=>[]);if(!arts.length){el.innerHTML='<div class="loading">Không có tin</div>';return}let h='<div class="grid">';arts.forEach(a=>{h+=`<div class="card" onclick="readArticle('${esc(a.link)}')"><div class="card-img">${a.img?`<img src="${a.img}">`:''}</div><div class="card-body"><span class="badge badge-vne">${esc(a.source||'')}</span><div class="card-title">${esc(a.title)}</div></div></div>`});h+='</div>';el.innerHTML=h}
|
| 231 |
+
fetch('/api/storage_status').then(r=>r.json()).then(j=>{if(!j.persistent){const home=document.getElementById('view-home');if(home){const w=document.createElement('div');w.className='storage-warn';w.innerHTML='⚠️ Persistent Storage chưa bật.';home.prepend(w)}}}).catch(()=>{});
|
app_clean.py
DELETED
|
@@ -1,69 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
VNEWS Clean Backend - serves static/index_v2.html directly.
|
| 3 |
-
No injection layers. All APIs from existing modules preserved.
|
| 4 |
-
Comments feature REMOVED per user request.
|
| 5 |
-
"""
|
| 6 |
-
import sys, os
|
| 7 |
-
|
| 8 |
-
# Import the full chain which registers all API endpoints on the FastAPI app
|
| 9 |
-
from app_main import app, _search_all, _clean
|
| 10 |
-
|
| 11 |
-
# Now override the root '/' to serve our clean frontend
|
| 12 |
-
from fastapi import Query, Request
|
| 13 |
-
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
| 14 |
-
from fastapi.staticfiles import StaticFiles
|
| 15 |
-
import os
|
| 16 |
-
|
| 17 |
-
# Remove old '/' route
|
| 18 |
-
app.router.routes = [r for r in app.router.routes if not (
|
| 19 |
-
getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set())
|
| 20 |
-
)]
|
| 21 |
-
|
| 22 |
-
# Remove comment endpoints (user requested removal)
|
| 23 |
-
app.router.routes = [r for r in app.router.routes if not (
|
| 24 |
-
getattr(r, 'path', None) in ('/api/short/comments', '/api/short/comment')
|
| 25 |
-
)]
|
| 26 |
-
|
| 27 |
-
# Mount static files
|
| 28 |
-
STATIC_DIR = os.path.join(os.path.dirname(__file__), 'static')
|
| 29 |
-
app.mount('/static', StaticFiles(directory=STATIC_DIR), name='static')
|
| 30 |
-
|
| 31 |
-
@app.get('/')
|
| 32 |
-
async def serve_index():
|
| 33 |
-
"""Serve the clean v2 frontend - single HTML file, no injection."""
|
| 34 |
-
index_path = os.path.join(STATIC_DIR, 'index_v2.html')
|
| 35 |
-
if os.path.exists(index_path):
|
| 36 |
-
return FileResponse(index_path, media_type='text/html')
|
| 37 |
-
return HTMLResponse('<h1>VNEWS</h1><p>index_v2.html not found</p>', status_code=500)
|
| 38 |
-
|
| 39 |
-
# Keep /api/hashtag/sources using direct search (not Google News)
|
| 40 |
-
# This was already overridden in app_main.py with _search_all
|
| 41 |
-
# Just make sure it's accessible
|
| 42 |
-
|
| 43 |
-
# Storage status endpoint
|
| 44 |
-
@app.get('/api/storage_status')
|
| 45 |
-
def storage_status():
|
| 46 |
-
"""Check if persistent storage is enabled."""
|
| 47 |
-
data_dir = '/data'
|
| 48 |
-
persistent = os.path.isdir(data_dir) and os.access(data_dir, os.W_OK)
|
| 49 |
-
return JSONResponse({'persistent': persistent, 'path': data_dir})
|
| 50 |
-
|
| 51 |
-
# Categories for the tab bar
|
| 52 |
-
@app.get('/api/categories')
|
| 53 |
-
def get_categories():
|
| 54 |
-
"""Return category list for frontend tab bar."""
|
| 55 |
-
return JSONResponse([]) # Categories moved into News tab, homepage shows media content
|
| 56 |
-
|
| 57 |
-
# Share page
|
| 58 |
-
@app.get('/s')
|
| 59 |
-
async def share_page(url: str = '', title: str = '', img: str = ''):
|
| 60 |
-
"""OG share page for social media."""
|
| 61 |
-
html = f'''<!DOCTYPE html><html><head>
|
| 62 |
-
<meta property="og:title" content="{_clean(title)}">
|
| 63 |
-
<meta property="og:url" content="{_clean(url)}">
|
| 64 |
-
<meta property="og:image" content="{_clean(img)}">
|
| 65 |
-
<meta property="og:type" content="article">
|
| 66 |
-
<meta property="og:site_name" content="VNEWS">
|
| 67 |
-
<meta http-equiv="refresh" content="0;url={_clean(url) or '/'}">
|
| 68 |
-
</head><body>Redirecting...</body></html>'''
|
| 69 |
-
return HTMLResponse(html)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app_entry.py
DELETED
|
@@ -1,17 +0,0 @@
|
|
| 1 |
-
"""Wrapper: load main patch then inject extra fixes for tiktok-right position, kill duplicate slides, progress toast."""
|
| 2 |
-
from ai_runtime_patch_fast import *
|
| 3 |
-
from ai_runtime_patch_fast import app, f5, f6, rt, PATCH_INJECT
|
| 4 |
-
from patch_extra import EXTRA_FIX
|
| 5 |
-
from fastapi.responses import HTMLResponse
|
| 6 |
-
|
| 7 |
-
# Remove old root and re-register with EXTRA_FIX appended.
|
| 8 |
-
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
|
| 9 |
-
|
| 10 |
-
@app.get('/')
|
| 11 |
-
async def _index_final():
|
| 12 |
-
html=f5.f4.f3.f2.f1._load_index_html()
|
| 13 |
-
body=getattr(rt.old,'PATCH_INJECT','')+f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT
|
| 14 |
-
body+=getattr(f6,'FINAL6_INJECT','');body+=getattr(f6,'FINAL6_FAST_HOME_INJECT','');body+=getattr(f6,'FINAL6E_INJECT','')
|
| 15 |
-
body+=PATCH_INJECT
|
| 16 |
-
body+=EXTRA_FIX
|
| 17 |
-
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app_main.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""VNEWS v2 - Clean frontend. CRITICAL: removes ALL old routes before registering new ones."""
|
| 2 |
from app_run import *
|
| 3 |
from app_run import app, f5, f6, rt, PATCH_INJECT, UNIFIED_INJECT_FIXED, HIGHLIGHT_FULL_OVERRIDE, EXTRA_WALL_FIX, FAST_HASHTAG_JS
|
| 4 |
-
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
|
| 5 |
from fastapi.staticfiles import StaticFiles
|
| 6 |
from fastapi import Query, Request
|
| 7 |
import requests as req
|
|
@@ -67,17 +67,25 @@ def _search_all(topic, limit=40):
|
|
| 67 |
if i.get('url') and i['url'] not in seen:seen.add(i['url']);unique.append(i)
|
| 68 |
return unique[:limit]
|
| 69 |
|
| 70 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
app.router.routes = [r for r in app.router.routes if not (
|
| 72 |
(getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set())) or
|
| 73 |
(getattr(r, 'path', None) == '/api/hashtag/sources' and 'GET' in getattr(r, 'methods', set())) or
|
| 74 |
(getattr(r, 'path', None) in ('/api/short/comments', '/api/short/comment'))
|
| 75 |
)]
|
|
|
|
| 76 |
app.routes[:] = [r for r in app.routes if not (
|
| 77 |
-
hasattr(r, 'path') and getattr(r, 'path', None) == '/' and
|
| 78 |
hasattr(r, 'methods') and 'GET' in getattr(r, 'methods', set())
|
| 79 |
)]
|
| 80 |
|
|
|
|
|
|
|
|
|
|
| 81 |
STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
|
| 82 |
|
| 83 |
@app.get('/api/hashtag/sources')
|
|
@@ -98,56 +106,9 @@ def _storage():return JSONResponse({'persistent':os.path.isdir('/data') and os.a
|
|
| 98 |
async def _share(url:str='',title:str='',img:str=''):
|
| 99 |
return HTMLResponse(f'<!DOCTYPE html><html><head><meta property="og:title" content="{_clean(title)}"><meta property="og:image" content="{_clean(img)}"><meta http-equiv="refresh" content="0;url={_clean(url) or "/"}"></head><body>Redirecting...</body></html>')
|
| 100 |
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
r = req.get(url, headers={'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36','Accept-Language':'vi-VN,vi;q=0.9','Referer':'https://hd.xemtv.net/'}, timeout=15)
|
| 105 |
-
return HTMLResponse(content=r.text)
|
| 106 |
-
except:
|
| 107 |
-
return HTMLResponse(content='', status_code=502)
|
| 108 |
-
|
| 109 |
-
@app.get('/api/proxy/hls')
|
| 110 |
-
def proxy_hls(url: str = Query(...)):
|
| 111 |
-
try:
|
| 112 |
-
headers = {
|
| 113 |
-
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
| 114 |
-
'Accept': '*/*',
|
| 115 |
-
'Accept-Language': 'vi-VN,vi;q=0.9',
|
| 116 |
-
'Referer': 'https://fptplay.vn/',
|
| 117 |
-
'Origin': 'https://fptplay.vn',
|
| 118 |
-
}
|
| 119 |
-
r = req.get(url, headers=headers, timeout=15)
|
| 120 |
-
content_type = r.headers.get('Content-Type', 'application/vnd.apple.mpegurl')
|
| 121 |
-
text = r.text
|
| 122 |
-
base_url = url.rsplit('/', 1)[0] + '/'
|
| 123 |
-
def _rewrite_url(m):
|
| 124 |
-
seg_url = m.group(0)
|
| 125 |
-
if seg_url.startswith('http'):
|
| 126 |
-
return '/api/proxy/seg?url=' + quote(seg_url, safe='')
|
| 127 |
-
elif seg_url.startswith('/'):
|
| 128 |
-
return '/api/proxy/seg?url=' + quote(base_url.rsplit('/', 2)[0] + seg_url, safe='')
|
| 129 |
-
else:
|
| 130 |
-
return '/api/proxy/seg?url=' + quote(base_url + seg_url, safe='')
|
| 131 |
-
text = re.sub(r'https?://[^\s"\'<>]+\.(ts|m3u8)[^\s"\'<>]*', _rewrite_url, text)
|
| 132 |
-
return HTMLResponse(content=text, media_type=content_type)
|
| 133 |
-
except:
|
| 134 |
-
return HTMLResponse(content='', status_code=502)
|
| 135 |
-
|
| 136 |
-
@app.get('/api/proxy/seg')
|
| 137 |
-
def proxy_seg(url: str = Query(...)):
|
| 138 |
-
try:
|
| 139 |
-
headers = {
|
| 140 |
-
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
| 141 |
-
'Referer': 'https://fptplay.vn/',
|
| 142 |
-
'Origin': 'https://fptplay.vn',
|
| 143 |
-
}
|
| 144 |
-
r = req.get(url, headers=headers, timeout=15)
|
| 145 |
-
content_type = r.headers.get('Content-Type', 'video/MP2T')
|
| 146 |
-
return Response(content=r.content, media_type=content_type)
|
| 147 |
-
except:
|
| 148 |
-
return Response(content=b'', status_code=502)
|
| 149 |
-
|
| 150 |
-
# Interactions
|
| 151 |
DATA_DIR = '/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data')
|
| 152 |
os.makedirs(DATA_DIR, exist_ok=True)
|
| 153 |
INTERACTIONS_FILE = os.path.join(DATA_DIR, 'interactions_v2.json')
|
|
@@ -199,7 +160,9 @@ async def api_post_comment(request:Request):
|
|
| 199 |
idb[vid]['comments']=len(comments);_save_json(INTERACTIONS_FILE,idb)
|
| 200 |
return JSONResponse({'comments':comments})
|
| 201 |
|
| 202 |
-
#
|
|
|
|
|
|
|
| 203 |
from wc2026_scraper import (
|
| 204 |
scrape_summary, scrape_fixtures, scrape_standings, scrape_stats,
|
| 205 |
scrape_wc_news, scrape_road_to_wc, get_wc2026_all,
|
|
@@ -229,41 +192,6 @@ def api_wc2026_lineups(event_id:int):return JSONResponse(scrape_lineups(event_id
|
|
| 229 |
@app.get('/api/wc2026/match/{event_id}')
|
| 230 |
def api_wc2026_match(event_id:int):return JSONResponse(scrape_match_detail(event_id))
|
| 231 |
|
| 232 |
-
# Match Detail API (for any match from bongda.com.vn)
|
| 233 |
-
from match_detail import fetch_match_detail, fetch_match_detail_by_url, _bongda_api
|
| 234 |
-
|
| 235 |
-
@app.get('/api/match/{event_id}/detail')
|
| 236 |
-
def api_match_detail(event_id: int, url: str = Query(default=None)):
|
| 237 |
-
"""Get complete match detail. Optional 'url' param with full bongda URL (with slug) for HTML scraping."""
|
| 238 |
-
if url:
|
| 239 |
-
return JSONResponse(fetch_match_detail_by_url(url))
|
| 240 |
-
return JSONResponse(fetch_match_detail(event_id))
|
| 241 |
-
|
| 242 |
-
@app.get('/api/match/{event_id}/commentaries')
|
| 243 |
-
def api_match_commentaries(event_id: int):
|
| 244 |
-
"""Get match commentaries from bongda API."""
|
| 245 |
-
comm = _bongda_api("/api/fixtures/commentaries", {"event_id": event_id})
|
| 246 |
-
if comm and comm.get("status") == "success":
|
| 247 |
-
html = comm.get("html", "")
|
| 248 |
-
if html and len(html.strip()) > 10:
|
| 249 |
-
return JSONResponse({"html": html})
|
| 250 |
-
return JSONResponse({"html": ""})
|
| 251 |
-
|
| 252 |
-
@app.get('/api/match/{event_id}/stats')
|
| 253 |
-
def api_match_stats(event_id: int):
|
| 254 |
-
"""Get match player performance stats from bongda API."""
|
| 255 |
-
perf = _bongda_api("/api/event-standing/player-performance", {"event_id": event_id})
|
| 256 |
-
if perf and perf.get("status") == "success":
|
| 257 |
-
html = perf.get("html", "")
|
| 258 |
-
if html and len(html.strip()) > 10:
|
| 259 |
-
return JSONResponse({"html": html})
|
| 260 |
-
return JSONResponse({"html": ""})
|
| 261 |
-
|
| 262 |
-
@app.get('/api/match/detail')
|
| 263 |
-
def api_match_detail_by_url(url: str = Query(...)):
|
| 264 |
-
"""Get match detail by full bongda.com.vn URL."""
|
| 265 |
-
return JSONResponse(fetch_match_detail_by_url(url))
|
| 266 |
-
|
| 267 |
def _wc2026_bg_refresh():
|
| 268 |
time.sleep(10)
|
| 269 |
while True:
|
|
@@ -272,12 +200,18 @@ def _wc2026_bg_refresh():
|
|
| 272 |
time.sleep(90)
|
| 273 |
threading.Thread(target=_wc2026_bg_refresh,daemon=True).start()
|
| 274 |
|
| 275 |
-
#
|
|
|
|
|
|
|
| 276 |
@app.get('/')
|
| 277 |
async def _index_v2():
|
|
|
|
| 278 |
index_path = os.path.join(STATIC_DIR, 'index_v2.html')
|
| 279 |
if os.path.exists(index_path):
|
| 280 |
return FileResponse(index_path, media_type='text/html')
|
| 281 |
return HTMLResponse('<html><body><h1>VNEWS v2</h1><p>index_v2.html not found</p></body></html>')
|
| 282 |
|
|
|
|
|
|
|
|
|
|
| 283 |
app.mount('/static', StaticFiles(directory=STATIC_DIR), name='vnews_static')
|
|
|
|
| 1 |
"""VNEWS v2 - Clean frontend. CRITICAL: removes ALL old routes before registering new ones."""
|
| 2 |
from app_run import *
|
| 3 |
from app_run import app, f5, f6, rt, PATCH_INJECT, UNIFIED_INJECT_FIXED, HIGHLIGHT_FULL_OVERRIDE, EXTRA_WALL_FIX, FAST_HASHTAG_JS
|
| 4 |
+
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
|
| 5 |
from fastapi.staticfiles import StaticFiles
|
| 6 |
from fastapi import Query, Request
|
| 7 |
import requests as req
|
|
|
|
| 67 |
if i.get('url') and i['url'] not in seen:seen.add(i['url']);unique.append(i)
|
| 68 |
return unique[:limit]
|
| 69 |
|
| 70 |
+
# ============================================================
|
| 71 |
+
# AGGRESSIVELY remove ALL old '/' routes from entire chain
|
| 72 |
+
# This ensures our new '/' route is the ONLY one serving
|
| 73 |
+
# ============================================================
|
| 74 |
+
# Remove from app.router.routes (APIRoute objects)
|
| 75 |
app.router.routes = [r for r in app.router.routes if not (
|
| 76 |
(getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set())) or
|
| 77 |
(getattr(r, 'path', None) == '/api/hashtag/sources' and 'GET' in getattr(r, 'methods', set())) or
|
| 78 |
(getattr(r, 'path', None) in ('/api/short/comments', '/api/short/comment'))
|
| 79 |
)]
|
| 80 |
+
# Also remove from app.routes (includes mounts)
|
| 81 |
app.routes[:] = [r for r in app.routes if not (
|
| 82 |
+
hasattr(r, 'path') and getattr(r, 'path', None) == '/' and
|
| 83 |
hasattr(r, 'methods') and 'GET' in getattr(r, 'methods', set())
|
| 84 |
)]
|
| 85 |
|
| 86 |
+
# ============================================================
|
| 87 |
+
# DEFINE ALL NEW ROUTES
|
| 88 |
+
# ============================================================
|
| 89 |
STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
|
| 90 |
|
| 91 |
@app.get('/api/hashtag/sources')
|
|
|
|
| 106 |
async def _share(url:str='',title:str='',img:str=''):
|
| 107 |
return HTMLResponse(f'<!DOCTYPE html><html><head><meta property="og:title" content="{_clean(title)}"><meta property="og:image" content="{_clean(img)}"><meta http-equiv="refresh" content="0;url={_clean(url) or "/"}"></head><body>Redirecting...</body></html>')
|
| 108 |
|
| 109 |
+
# ============================================================
|
| 110 |
+
# INTERACTIONS + COMMENTS
|
| 111 |
+
# ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
DATA_DIR = '/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data')
|
| 113 |
os.makedirs(DATA_DIR, exist_ok=True)
|
| 114 |
INTERACTIONS_FILE = os.path.join(DATA_DIR, 'interactions_v2.json')
|
|
|
|
| 160 |
idb[vid]['comments']=len(comments);_save_json(INTERACTIONS_FILE,idb)
|
| 161 |
return JSONResponse({'comments':comments})
|
| 162 |
|
| 163 |
+
# ============================================================
|
| 164 |
+
# WORLD CUP 2026 API
|
| 165 |
+
# ============================================================
|
| 166 |
from wc2026_scraper import (
|
| 167 |
scrape_summary, scrape_fixtures, scrape_standings, scrape_stats,
|
| 168 |
scrape_wc_news, scrape_road_to_wc, get_wc2026_all,
|
|
|
|
| 192 |
@app.get('/api/wc2026/match/{event_id}')
|
| 193 |
def api_wc2026_match(event_id:int):return JSONResponse(scrape_match_detail(event_id))
|
| 194 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
def _wc2026_bg_refresh():
|
| 196 |
time.sleep(10)
|
| 197 |
while True:
|
|
|
|
| 200 |
time.sleep(90)
|
| 201 |
threading.Thread(target=_wc2026_bg_refresh,daemon=True).start()
|
| 202 |
|
| 203 |
+
# ============================================================
|
| 204 |
+
# SERVE V2 FRONTEND - THIS MUST BE THE ONLY '/' ROUTE
|
| 205 |
+
# ============================================================
|
| 206 |
@app.get('/')
|
| 207 |
async def _index_v2():
|
| 208 |
+
"""Serve clean v2 frontend from static/index_v2.html"""
|
| 209 |
index_path = os.path.join(STATIC_DIR, 'index_v2.html')
|
| 210 |
if os.path.exists(index_path):
|
| 211 |
return FileResponse(index_path, media_type='text/html')
|
| 212 |
return HTMLResponse('<html><body><h1>VNEWS v2</h1><p>index_v2.html not found</p></body></html>')
|
| 213 |
|
| 214 |
+
# ============================================================
|
| 215 |
+
# MOUNT STATIC FILES LAST (must be after all route definitions)
|
| 216 |
+
# ============================================================
|
| 217 |
app.mount('/static', StaticFiles(directory=STATIC_DIR), name='vnews_static')
|
app_v2.js
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
app_v2_entry.py
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
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_entry_test.py
DELETED
|
@@ -1,16 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
VNEWS App v2 - Main application with match detail API
|
| 3 |
-
"""
|
| 4 |
-
import os, json, re, time, asyncio, hashlib, logging, threading, importlib, sys
|
| 5 |
-
from datetime import datetime, timezone, timedelta
|
| 6 |
-
from pathlib import Path
|
| 7 |
-
from typing import Optional
|
| 8 |
-
|
| 9 |
-
import httpx
|
| 10 |
-
import requests
|
| 11 |
-
from fastapi import FastAPI, HTTPException, Query
|
| 12 |
-
from fastapi.responses import JSONResponse, FileResponse, HTMLResponse
|
| 13 |
-
from fastapi.staticfiles import StaticFiles
|
| 14 |
-
from fastapi.templating import Jinja2Templates
|
| 15 |
-
|
| 16 |
-
# ... (rest of app_v2_entry.py content)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app_v2_entry_v2.py
DELETED
|
@@ -1,16 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
VNEWS App v2 - Main application with match detail API
|
| 3 |
-
"""
|
| 4 |
-
import os, json, re, time, asyncio, hashlib, logging, threading, importlib
|
| 5 |
-
from datetime import datetime, timezone, timedelta
|
| 6 |
-
from pathlib import Path
|
| 7 |
-
from typing import Optional
|
| 8 |
-
|
| 9 |
-
import httpx
|
| 10 |
-
import requests
|
| 11 |
-
from fastapi import FastAPI, HTTPException, Query
|
| 12 |
-
from fastapi.responses import JSONResponse, FileResponse, HTMLResponse
|
| 13 |
-
from fastapi.staticfiles import StaticFiles
|
| 14 |
-
from fastapi.templating import Jinja2Templates
|
| 15 |
-
|
| 16 |
-
# ... (rest of app_v2_entry.py content)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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&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> </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> </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> </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> </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> </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>»
|
| 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ận đị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út trướ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út trướ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út trướ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út trướ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ờ trướ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ờ trướ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ờ trướ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ờ trướ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ờ trướ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ờ trướ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ờ trướ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ờ trướ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ờ trướ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ờ trướ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ờ trướ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ờ trướ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ờ trướ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à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à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à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: <b>Vũ Khắc Sơn</b></p>
|
| 1102 |
-
<p>Phó tổng biên tập: <b>Nguyễn Hà Thanh</b></p>
|
| 1103 |
-
<div class="hide-w">
|
| 1104 |
-
<p>Địa chỉ: <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: <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: <b>0903 203 412</b></p>
|
| 1114 |
-
<p>Email: <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 ©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&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> </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> </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> </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> </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> </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>»
|
| 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ận đị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út trướ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út trướ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út trướ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út trướ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ờ trướ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ờ trướ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ờ trướ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ờ trướ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ờ trướ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ờ trướ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ờ trướ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ờ trướ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ờ trướ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ờ trướ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ờ trướ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ờ trướ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ờ trướ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à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à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à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: <b>Vũ Khắc Sơn</b></p>
|
| 1102 |
-
<p>Phó tổng biên tập: <b>Nguyễn Hà Thanh</b></p>
|
| 1103 |
-
<div class="hide-w">
|
| 1104 |
-
<p>Địa chỉ: <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: <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: <b>0903 203 412</b></p>
|
| 1114 |
-
<p>Email: <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 ©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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
bongda_proxy.py
DELETED
|
@@ -1,113 +0,0 @@
|
|
| 1 |
-
"""VNEWS — Bongda Proxy Endpoint (for fast match detail loading)"""
|
| 2 |
-
import requests
|
| 3 |
-
from bs4 import BeautifulSoup
|
| 4 |
-
import re
|
| 5 |
-
import json
|
| 6 |
-
|
| 7 |
-
def _cl(s):
|
| 8 |
-
return re.sub(r'\s+', ' ', str(s or '')).strip()
|
| 9 |
-
|
| 10 |
-
def _normalize_time(raw):
|
| 11 |
-
t = _cl(raw)
|
| 12 |
-
t = re.sub(r"(\d+)'\s*\+(\d+)", r"\1+\2'", t)
|
| 13 |
-
t = t.replace("''", "'")
|
| 14 |
-
return t
|
| 15 |
-
|
| 16 |
-
def scrape_match_html(event_id, url=None):
|
| 17 |
-
result = {"event_id": event_id, "found": False, "sections": []}
|
| 18 |
-
headers = {
|
| 19 |
-
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
| 20 |
-
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
| 21 |
-
"Referer": "https://bongda.com.vn/",
|
| 22 |
-
}
|
| 23 |
-
html = None
|
| 24 |
-
urls_to_try = [url] if url else []
|
| 25 |
-
urls_to_try += [
|
| 26 |
-
f"https://bongda.com.vn/tran-dau/{event_id}/centre/",
|
| 27 |
-
f"https://bongda.com.vn/tran-dau/{event_id}/preview/",
|
| 28 |
-
]
|
| 29 |
-
for u in urls_to_try:
|
| 30 |
-
if not u:
|
| 31 |
-
continue
|
| 32 |
-
try:
|
| 33 |
-
resp = requests.get(u, headers=headers, timeout=15, allow_redirects=True)
|
| 34 |
-
if resp.status_code == 200 and len(resp.text) > 1000:
|
| 35 |
-
html = resp.text
|
| 36 |
-
break
|
| 37 |
-
except Exception:
|
| 38 |
-
continue
|
| 39 |
-
if not html:
|
| 40 |
-
return result
|
| 41 |
-
try:
|
| 42 |
-
soup = BeautifulSoup(html, 'html.parser')
|
| 43 |
-
info = {}
|
| 44 |
-
tel = soup.select_one('.teams')
|
| 45 |
-
if tel:
|
| 46 |
-
he = tel.select_one('.team.home')
|
| 47 |
-
if he:
|
| 48 |
-
ne = he.select_one('p:not(.logo)') or he.find('p')
|
| 49 |
-
if ne: info['home_team'] = _cl(ne.get_text())
|
| 50 |
-
lo = he.select_one('img')
|
| 51 |
-
if lo: info['home_logo'] = lo.get('src', '')
|
| 52 |
-
ae = tel.select_one('.team.away')
|
| 53 |
-
if ae:
|
| 54 |
-
ne = ae.select_one('p:not(.logo)') or ae.find('p')
|
| 55 |
-
if ne: info['away_team'] = _cl(ne.get_text())
|
| 56 |
-
lo = ae.select_one('img')
|
| 57 |
-
if lo: info['away_logo'] = lo.get('src', '')
|
| 58 |
-
sc = tel.select_one('.score')
|
| 59 |
-
if sc:
|
| 60 |
-
parts = [_cl(p.get_text()) for p in sc.select('p')]
|
| 61 |
-
if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}"
|
| 62 |
-
lb = sc.select_one('.label')
|
| 63 |
-
if lb: info['status_label'] = _cl(lb.get_text())
|
| 64 |
-
if info.get('home_team') and info.get('away_team'):
|
| 65 |
-
result['info'] = info
|
| 66 |
-
result['found'] = True
|
| 67 |
-
result['sections'].append('info')
|
| 68 |
-
else:
|
| 69 |
-
return result
|
| 70 |
-
events = []
|
| 71 |
-
events_div = soup.select_one('.events')
|
| 72 |
-
if events_div:
|
| 73 |
-
period = ''
|
| 74 |
-
for child in events_div.children:
|
| 75 |
-
if not hasattr(child, 'name') or not child.name: continue
|
| 76 |
-
cls = ' '.join(child.get('class', []))
|
| 77 |
-
if 'period' in cls:
|
| 78 |
-
h2 = child.find('h2')
|
| 79 |
-
if h2: period = _cl(h2.get_text())
|
| 80 |
-
for ev in child.children:
|
| 81 |
-
if not hasattr(ev, 'name') or not ev.name: continue
|
| 82 |
-
ev_cls = ' '.join(ev.get('class', []))
|
| 83 |
-
if 'event' not in ev_cls: continue
|
| 84 |
-
ev_data = {'team': 'home' if 'home' in ev_cls else 'away', 'period': period, 'type': 'unknown', 'time': ''}
|
| 85 |
-
type_el = ev.select_one('.event-type')
|
| 86 |
-
if type_el:
|
| 87 |
-
if type_el.select_one('[class*="redcard"]'): ev_data['type'] = 'redcard'
|
| 88 |
-
elif type_el.select_one('[class*="yellowcard"]'): ev_data['type'] = 'yellowcard'
|
| 89 |
-
elif type_el.select_one('[class*="goal"]'): ev_data['type'] = 'goal'
|
| 90 |
-
elif type_el.select_one('[class*="substitution"]'): ev_data['type'] = 'substitution'
|
| 91 |
-
players_el = ev.select_one('.players')
|
| 92 |
-
if players_el:
|
| 93 |
-
time_el = players_el.select_one('.event-time')
|
| 94 |
-
if time_el: ev_data['time'] = _normalize_time(time_el.get_text())
|
| 95 |
-
text = _cl(players_el.get_text(' ', strip=True).replace(ev_data['time'], '').strip())
|
| 96 |
-
ev_data['players'] = text
|
| 97 |
-
events.append(ev_data)
|
| 98 |
-
if events:
|
| 99 |
-
result['events'] = events
|
| 100 |
-
result['sections'].append('events')
|
| 101 |
-
except Exception as e:
|
| 102 |
-
result['error'] = str(e)
|
| 103 |
-
return result
|
| 104 |
-
|
| 105 |
-
from fastapi import Query
|
| 106 |
-
from fastapi.responses import JSONResponse
|
| 107 |
-
|
| 108 |
-
def add_bongda_proxy_endpoint(app):
|
| 109 |
-
@app.get('/api/proxy/bongda')
|
| 110 |
-
def proxy_bongda(event_id: int = Query(default=None), url: str = Query(default=None)):
|
| 111 |
-
if event_id is None:
|
| 112 |
-
return JSONResponse({'error': 'event_id required'}, status_code=400)
|
| 113 |
-
return JSONResponse(scrape_match_html(event_id, url))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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("&", "&").replace("<", "<").replace(">", ">") + "</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
|
The diff for this file is too large to render.
See raw diff
|
|
|
main_patch.py
DELETED
|
@@ -1,8 +0,0 @@
|
|
| 1 |
-
# PATCH: Add these 2 lines to main.py right after "app = FastAPI()"
|
| 2 |
-
# Line 1: from vtv_api import router as vtv_router
|
| 3 |
-
# Line 2: app.include_router(vtv_router)
|
| 4 |
-
#
|
| 5 |
-
# This enables the VTV1-VTV10 + VTVPrime stream endpoints:
|
| 6 |
-
# GET /api/vtv/streams - Get all channel streams
|
| 7 |
-
# GET /api/vtv/stream/{id} - Get specific channel stream
|
| 8 |
-
# GET /api/proxy/page?url=... - Proxy web pages (for xemtv PHP scraping)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
match_detail.py
DELETED
|
@@ -1,309 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Match Detail Scraper for bongda.com.vn
|
| 3 |
-
"""
|
| 4 |
-
import requests, re, json, time, threading
|
| 5 |
-
from bs4 import BeautifulSoup
|
| 6 |
-
|
| 7 |
-
def _sp(html):
|
| 8 |
-
try:
|
| 9 |
-
return BeautifulSoup(html, 'lxml')
|
| 10 |
-
except:
|
| 11 |
-
return BeautifulSoup(html, 'html.parser')
|
| 12 |
-
|
| 13 |
-
BH = {
|
| 14 |
-
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
| 15 |
-
"Accept": "application/json, text/javascript, */*; q=0.01",
|
| 16 |
-
"Referer": "https://bongda.com.vn/",
|
| 17 |
-
"X-Requested-With": "XMLHttpRequest",
|
| 18 |
-
}
|
| 19 |
-
HH = {
|
| 20 |
-
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
| 21 |
-
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
| 22 |
-
"Referer": "https://bongda.com.vn/",
|
| 23 |
-
}
|
| 24 |
-
|
| 25 |
-
def _cl(s):
|
| 26 |
-
return re.sub(r'\s+', ' ', str(s or '')).strip()
|
| 27 |
-
|
| 28 |
-
def _api(ep, params=None):
|
| 29 |
-
try:
|
| 30 |
-
url = f"https://bongda.com.vn{ep}"
|
| 31 |
-
if params:
|
| 32 |
-
url += "?" + "&".join(f"{k}={v}" for k, v in params.items())
|
| 33 |
-
r = requests.get(url, headers=BH, timeout=15)
|
| 34 |
-
if r.status_code == 200:
|
| 35 |
-
try: return r.json()
|
| 36 |
-
except: pass
|
| 37 |
-
except: pass
|
| 38 |
-
return None
|
| 39 |
-
|
| 40 |
-
def _get_teams(soup):
|
| 41 |
-
info = {}
|
| 42 |
-
tel = soup.select_one('.teams')
|
| 43 |
-
if not tel:
|
| 44 |
-
return info
|
| 45 |
-
he = tel.select_one('.team.home, .home-team')
|
| 46 |
-
if he:
|
| 47 |
-
ne = he.select_one('p:not(.logo)') or he.find('p')
|
| 48 |
-
if ne: info['home_team'] = _cl(ne.get_text())
|
| 49 |
-
lo = he.select_one('img')
|
| 50 |
-
if lo: info['home_logo'] = lo.get('src', '')
|
| 51 |
-
le = he if he.name == 'a' else he.find('a')
|
| 52 |
-
if le and le.get('href'):
|
| 53 |
-
m = re.search(r'/doi-bong/(\d+)/', le['href'])
|
| 54 |
-
if m: info['home_team_id'] = m.group(1)
|
| 55 |
-
ae = tel.select_one('.team.away, .away-team')
|
| 56 |
-
if ae:
|
| 57 |
-
ne = ae.select_one('p:not(.logo)') or ae.find('p')
|
| 58 |
-
if ne: info['away_team'] = _cl(ne.get_text())
|
| 59 |
-
lo = ae.select_one('img')
|
| 60 |
-
if lo: info['away_logo'] = lo.get('src', '')
|
| 61 |
-
le = ae if ae.name == 'a' else ae.find('a')
|
| 62 |
-
if le and le.get('href'):
|
| 63 |
-
m = re.search(r'/doi-bong/(\d+)/', le['href'])
|
| 64 |
-
if m: info['away_team_id'] = m.group(1)
|
| 65 |
-
sc = tel.select_one('.score')
|
| 66 |
-
if sc:
|
| 67 |
-
parts = [_cl(p.get_text()) for p in sc.select('p')]
|
| 68 |
-
if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}"
|
| 69 |
-
lb = sc.select_one('.label')
|
| 70 |
-
if lb: info['status_label'] = _cl(lb.get_text())
|
| 71 |
-
return info
|
| 72 |
-
|
| 73 |
-
def _get_timeline(soup):
|
| 74 |
-
tl = []
|
| 75 |
-
el = soup.select_one('.timeline')
|
| 76 |
-
if not el: return tl
|
| 77 |
-
half = ''
|
| 78 |
-
for c in el.children:
|
| 79 |
-
if not hasattr(c, 'name') or not c.name: continue
|
| 80 |
-
t = _cl(c.get_text())
|
| 81 |
-
if not t: continue
|
| 82 |
-
if t in ['H1','H2','Hiệp 1','Hiệp 2']:
|
| 83 |
-
half = t; continue
|
| 84 |
-
m = re.match(r"(\d+'\+?\d*)", t)
|
| 85 |
-
if m:
|
| 86 |
-
tl.append({'time': m.group(1), 'text': t[m.end():].strip(), 'half': half})
|
| 87 |
-
elif len(t) > 5:
|
| 88 |
-
tl.append({'time': '', 'text': t, 'half': half})
|
| 89 |
-
return tl
|
| 90 |
-
|
| 91 |
-
def _get_events(soup):
|
| 92 |
-
evts = []
|
| 93 |
-
for el in soup.select('.event'):
|
| 94 |
-
e = {}
|
| 95 |
-
cl = ' '.join(el.get('class', []))
|
| 96 |
-
e['team'] = 'home' if 'home' in cl else ('away' if 'away' in cl else '')
|
| 97 |
-
ps = [_cl(p.get_text()) for p in el.select('p')]
|
| 98 |
-
ps = [p for p in ps if p]
|
| 99 |
-
if ps: e['players'] = ps
|
| 100 |
-
tl = el.select_one('.time, .minute, span')
|
| 101 |
-
if tl: e['time'] = _cl(tl.get_text())
|
| 102 |
-
evts.append(e)
|
| 103 |
-
return evts
|
| 104 |
-
|
| 105 |
-
def _get_stats(soup):
|
| 106 |
-
st = {}
|
| 107 |
-
for sel in ['.match-stats','[class*="stats"]']:
|
| 108 |
-
el = soup.select_one(sel)
|
| 109 |
-
if el and len(str(el)) > 50:
|
| 110 |
-
for row in el.select('li,tr,.stat-row'):
|
| 111 |
-
cells = row.select('td,span,p')
|
| 112 |
-
if len(cells) >= 3:
|
| 113 |
-
lb = _cl(cells[0].get_text())
|
| 114 |
-
if lb: st[lb] = {'home': _cl(cells[1].get_text()), 'away': _cl(cells[2].get_text())}
|
| 115 |
-
if st: break
|
| 116 |
-
return st
|
| 117 |
-
|
| 118 |
-
def _get_h2h(soup):
|
| 119 |
-
h2h = {'matches': [], 'stats': {}}
|
| 120 |
-
for sel in ['.head-to-head','[class*="h2h"]']:
|
| 121 |
-
el = soup.select_one(sel)
|
| 122 |
-
if el and len(str(el)) > 50:
|
| 123 |
-
for it in el.select('li,tr,.match-item'):
|
| 124 |
-
m = {}
|
| 125 |
-
cells = it.select('td,span,p')
|
| 126 |
-
if len(cells) >= 3:
|
| 127 |
-
m['date'] = _cl(cells[0].get_text())
|
| 128 |
-
m['home'] = _cl(cells[1].get_text())
|
| 129 |
-
m['score'] = _cl(cells[2].get_text())
|
| 130 |
-
if m.get('home'):
|
| 131 |
-
if len(cells) > 3: m['away'] = _cl(cells[3].get_text())
|
| 132 |
-
h2h['matches'].append(m)
|
| 133 |
-
if h2h['matches']: break
|
| 134 |
-
return h2h
|
| 135 |
-
|
| 136 |
-
def _get_form(soup):
|
| 137 |
-
f = {'home': [], 'away': []}
|
| 138 |
-
for sel in ['.form-guide','[class*="form"]']:
|
| 139 |
-
el = soup.select_one(sel)
|
| 140 |
-
if el and len(str(el)) > 50:
|
| 141 |
-
items = el.select('li,.form-item,tr')
|
| 142 |
-
for it in items[:10]:
|
| 143 |
-
t = _cl(it.get_text())
|
| 144 |
-
if t: f['home'].append({'text': t})
|
| 145 |
-
for it in items[10:20]:
|
| 146 |
-
t = _cl(it.get_text())
|
| 147 |
-
if t: f['away'].append({'text': t})
|
| 148 |
-
break
|
| 149 |
-
return f
|
| 150 |
-
|
| 151 |
-
def _get_info(soup):
|
| 152 |
-
info = {}
|
| 153 |
-
mi = soup.select_one('.match-info')
|
| 154 |
-
if mi:
|
| 155 |
-
te = mi.select_one('.times,li')
|
| 156 |
-
if te: info['datetime'] = _cl(te.get_text())
|
| 157 |
-
le = soup.select_one('.league,.tournament,[class*="league"]')
|
| 158 |
-
if le: info['league'] = _cl(le.get_text())
|
| 159 |
-
return info
|
| 160 |
-
|
| 161 |
-
def _scrape(url):
|
| 162 |
-
print(f"[DEBUG] _scrape: {url[:80]}", flush=True)
|
| 163 |
-
try:
|
| 164 |
-
r = requests.get(url, headers=HH, timeout=15, allow_redirects=True)
|
| 165 |
-
print(f"[DEBUG] HTTP={r.status_code}", flush=True)
|
| 166 |
-
if r.status_code != 200:
|
| 167 |
-
return False, {}
|
| 168 |
-
sp = _sp(r.text)
|
| 169 |
-
d = {}
|
| 170 |
-
|
| 171 |
-
teams = _get_teams(sp)
|
| 172 |
-
print(f"[DEBUG] teams={teams}", flush=True)
|
| 173 |
-
if teams: d['info'] = teams
|
| 174 |
-
|
| 175 |
-
mi = _get_info(sp)
|
| 176 |
-
if mi:
|
| 177 |
-
d.setdefault('info', {}).update(mi)
|
| 178 |
-
|
| 179 |
-
tl = _get_timeline(sp)
|
| 180 |
-
if tl:
|
| 181 |
-
d['timeline'] = tl
|
| 182 |
-
d['commentaries_html'] = '\n'.join([f"{t.get('time','')} {t.get('text','')}" for t in tl])
|
| 183 |
-
|
| 184 |
-
ev = _get_events(sp)
|
| 185 |
-
if ev: d['events'] = ev
|
| 186 |
-
|
| 187 |
-
st = _get_stats(sp)
|
| 188 |
-
if st:
|
| 189 |
-
d['stats_parsed'] = st
|
| 190 |
-
d['stats_html'] = str(st)
|
| 191 |
-
|
| 192 |
-
h2h = _get_h2h(sp)
|
| 193 |
-
if h2h.get('matches'): d['h2h_matches'] = h2h['matches']
|
| 194 |
-
if h2h.get('stats'): d['h2h_stats'] = h2h['stats']
|
| 195 |
-
|
| 196 |
-
if '/preview/' in url:
|
| 197 |
-
fm = _get_form(sp)
|
| 198 |
-
if fm.get('home'): d['home_form'] = fm['home']
|
| 199 |
-
if fm.get('away'): d['away_form'] = fm['away']
|
| 200 |
-
|
| 201 |
-
print(f"[DEBUG] success keys={list(d.keys())}", flush=True)
|
| 202 |
-
return True, d
|
| 203 |
-
except Exception as e:
|
| 204 |
-
import traceback
|
| 205 |
-
print(f"[DEBUG] error: {e}", flush=True)
|
| 206 |
-
traceback.print_exc()
|
| 207 |
-
return False, {}
|
| 208 |
-
|
| 209 |
-
def fetch_match_detail_by_url(url):
|
| 210 |
-
m = re.search(r'/tran-dau/(\d+)/', url)
|
| 211 |
-
if not m: return {"error": "Could not extract event_id", "found": False}
|
| 212 |
-
event_id = int(m.group(1))
|
| 213 |
-
res = {"event_id": event_id, "found": False, "sections": []}
|
| 214 |
-
_fetch_api(event_id, res)
|
| 215 |
-
ok, d = _scrape(url)
|
| 216 |
-
print(f"[DEBUG] by_url: ok={ok} d_keys={list(d.keys())}", flush=True)
|
| 217 |
-
if ok: _merge(res, d)
|
| 218 |
-
return res
|
| 219 |
-
|
| 220 |
-
def fetch_match_detail(event_id):
|
| 221 |
-
print(f"[DEBUG] fetch_match_detail({event_id})", flush=True)
|
| 222 |
-
res = {"event_id": event_id, "found": False, "sections": []}
|
| 223 |
-
_fetch_api(event_id, res)
|
| 224 |
-
|
| 225 |
-
for pt in ["centre", "preview"]:
|
| 226 |
-
url = f"https://bongda.com.vn/tran-dau/{event_id}/{pt}/"
|
| 227 |
-
ok, d = _scrape(url)
|
| 228 |
-
print(f"[DEBUG] {pt}: ok={ok}", flush=True)
|
| 229 |
-
if ok:
|
| 230 |
-
_merge(res, d)
|
| 231 |
-
if res.get("found"): break
|
| 232 |
-
|
| 233 |
-
print(f"[DEBUG] final: found={res['found']} sections={res['sections']}", flush=True)
|
| 234 |
-
return res
|
| 235 |
-
|
| 236 |
-
def _fetch_api(eid, res):
|
| 237 |
-
pm = _api("/api/event-standing/pre-match", {"event_id": eid})
|
| 238 |
-
res["pre_match"] = pm
|
| 239 |
-
res["pre_match_html"] = pm.get("html","") if pm and pm.get("status")=="success" and len(pm.get("html","").strip())>10 else ""
|
| 240 |
-
|
| 241 |
-
hm = _api("/api/fixtures/h2h-match", {"event_id": eid})
|
| 242 |
-
res["h2h_match"] = hm
|
| 243 |
-
if hm and hm.get("status")=="success":
|
| 244 |
-
h = hm.get("html","")
|
| 245 |
-
if len(h.strip())>10:
|
| 246 |
-
res["h2h_html"] = h
|
| 247 |
-
res["sections"].append("h2h")
|
| 248 |
-
else: res["h2h_html"] = ""
|
| 249 |
-
|
| 250 |
-
hs = _api("/api/fixtures/h2h-stats", {"event_id": eid})
|
| 251 |
-
res["h2h_stats"] = hs
|
| 252 |
-
if hs and hs.get("status")=="success":
|
| 253 |
-
h = hs.get("html","")
|
| 254 |
-
if len(h.strip())>10:
|
| 255 |
-
res["h2h_stats_html"] = h
|
| 256 |
-
res["sections"].append("h2h_stats")
|
| 257 |
-
try:
|
| 258 |
-
sp = _sp(h)
|
| 259 |
-
stats = {}
|
| 260 |
-
for row in sp.select('li,tr,.stat-row'):
|
| 261 |
-
cells = row.select('td,span,p')
|
| 262 |
-
if len(cells)>=3:
|
| 263 |
-
lb = _cl(cells[0].get_text())
|
| 264 |
-
if lb: stats[lb] = {'home': _cl(cells[1].get_text()), 'away': _cl(cells[2].get_text())}
|
| 265 |
-
if stats: res["h2h_stats_parsed"] = stats
|
| 266 |
-
except: pass
|
| 267 |
-
else: res["h2h_stats_html"] = ""
|
| 268 |
-
|
| 269 |
-
pf = _api("/api/event-standing/player-performance", {"event_id": eid})
|
| 270 |
-
res["performance"] = pf
|
| 271 |
-
if pf and pf.get("status")=="success" and len(pf.get("html","").strip())>10:
|
| 272 |
-
res["stats_html"] = pf["html"]
|
| 273 |
-
res["sections"].append("stats")
|
| 274 |
-
else: res["stats_html"] = ""
|
| 275 |
-
|
| 276 |
-
cm = _api("/api/fixtures/commentaries", {"event_id": eid})
|
| 277 |
-
if cm and cm.get("status")=="success" and len(cm.get("html","").strip())>10:
|
| 278 |
-
res["commentaries_html"] = cm["html"]
|
| 279 |
-
res["sections"].append("commentaries")
|
| 280 |
-
elif not res.get("commentaries_html"): res["commentaries_html"] = ""
|
| 281 |
-
|
| 282 |
-
def _merge(res, d):
|
| 283 |
-
if d.get("info"):
|
| 284 |
-
res.setdefault("info", {}).update(d["info"])
|
| 285 |
-
res["found"] = True
|
| 286 |
-
if "info" not in res["sections"]: res["sections"].append("info")
|
| 287 |
-
if d.get("timeline"):
|
| 288 |
-
res["timeline"] = d["timeline"]
|
| 289 |
-
if not res.get("commentaries_html"): res["commentaries_html"] = d.get("commentaries_html","")
|
| 290 |
-
res["sections"].append("commentaries")
|
| 291 |
-
if d.get("events"):
|
| 292 |
-
res["events"] = d["events"]
|
| 293 |
-
res["sections"].append("events")
|
| 294 |
-
if d.get("stats_parsed"):
|
| 295 |
-
res["stats_parsed"] = d["stats_parsed"]
|
| 296 |
-
if not res.get("stats_html"): res["stats_html"] = d.get("stats_html","")
|
| 297 |
-
res["sections"].append("stats")
|
| 298 |
-
if d.get("h2h_matches"):
|
| 299 |
-
res["h2h"] = d["h2h_matches"]
|
| 300 |
-
res["sections"].append("h2h")
|
| 301 |
-
if d.get("h2h_stats"):
|
| 302 |
-
res["h2h_stats_parsed"] = d["h2h_stats"]
|
| 303 |
-
res["sections"].append("h2h_stats")
|
| 304 |
-
if d.get("home_form"):
|
| 305 |
-
res["home_form"] = d["home_form"]
|
| 306 |
-
res["sections"].append("home_form")
|
| 307 |
-
if d.get("away_form"):
|
| 308 |
-
res["away_form"] = d["away_form"]
|
| 309 |
-
res["sections"].append("away_form")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
match_detail_v2.py
DELETED
|
@@ -1,418 +0,0 @@
|
|
| 1 |
-
"""VNEWS — Match Detail Parser v2 (html.parser only, no lxml dependency)"""
|
| 2 |
-
import re
|
| 3 |
-
import requests
|
| 4 |
-
from bs4 import BeautifulSoup
|
| 5 |
-
|
| 6 |
-
HEADERS = {
|
| 7 |
-
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
| 8 |
-
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
| 9 |
-
"Accept-Language": "vi-VN,vi;q=0.9",
|
| 10 |
-
"Referer": "https://bongda.com.vn/",
|
| 11 |
-
}
|
| 12 |
-
|
| 13 |
-
API_HEADERS = {
|
| 14 |
-
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
| 15 |
-
"Accept": "application/json, text/javascript, */*; q=0.01",
|
| 16 |
-
"X-Requested-With": "XMLHttpRequest",
|
| 17 |
-
"Referer": "https://bongda.com.vn/",
|
| 18 |
-
}
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
def _cl(s):
|
| 22 |
-
return re.sub(r'\s+', ' ', str(s or '')).strip()
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
def _normalize_time(raw):
|
| 26 |
-
t = _cl(raw)
|
| 27 |
-
if not t:
|
| 28 |
-
return t
|
| 29 |
-
t = re.sub(r"(\d+)'\s*\+(\d+)", r"\1+\2'", t)
|
| 30 |
-
t = t.replace("''", "'")
|
| 31 |
-
return t
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
def _mk(html):
|
| 35 |
-
"""Parse HTML using html.parser (lxml may not be available)."""
|
| 36 |
-
return BeautifulSoup(html, 'html.parser')
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
def fetch_html(url, timeout=8):
|
| 40 |
-
resp = requests.get(url, headers=HEADERS, timeout=timeout, allow_redirects=True)
|
| 41 |
-
resp.raise_for_status()
|
| 42 |
-
return resp.text
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
def parse_events(sp):
|
| 46 |
-
"""Parse .events > .period > .event structure."""
|
| 47 |
-
events = []
|
| 48 |
-
events_div = sp.select_one('.events')
|
| 49 |
-
if not events_div:
|
| 50 |
-
return events
|
| 51 |
-
|
| 52 |
-
current_period = ''
|
| 53 |
-
for child in events_div.children:
|
| 54 |
-
if not hasattr(child, 'name') or not child.name:
|
| 55 |
-
continue
|
| 56 |
-
cls_str = ' '.join(child.get('class', []) if child.get('class') else [])
|
| 57 |
-
|
| 58 |
-
if 'period' in cls_str:
|
| 59 |
-
h2 = child.find('h2')
|
| 60 |
-
if h2:
|
| 61 |
-
current_period = _cl(h2.get_text())
|
| 62 |
-
|
| 63 |
-
for ev in child.children:
|
| 64 |
-
if not hasattr(ev, 'name') or not ev.name:
|
| 65 |
-
continue
|
| 66 |
-
ev_cls_str = ' '.join(ev.get('class', []) if ev.get('class') else [])
|
| 67 |
-
if 'event' not in ev_cls_str:
|
| 68 |
-
continue
|
| 69 |
-
|
| 70 |
-
team = 'home' if 'home' in ev_cls_str else 'away'
|
| 71 |
-
ev_data = {
|
| 72 |
-
'team': team, 'period': current_period, 'type': 'unknown',
|
| 73 |
-
'time': '', 'players': '', 'player_in': '', 'player_out': '',
|
| 74 |
-
'scorer': '', 'assist': '', 'card_type': '', 'player': '',
|
| 75 |
-
}
|
| 76 |
-
|
| 77 |
-
type_el = ev.select_one('.event-type')
|
| 78 |
-
if type_el:
|
| 79 |
-
if type_el.select_one('[class*="redcard"]'):
|
| 80 |
-
ev_data['type'] = 'redcard'; ev_data['card_type'] = 'red'
|
| 81 |
-
elif type_el.select_one('[class*="yellowcard"]'):
|
| 82 |
-
ev_data['type'] = 'yellowcard'; ev_data['card_type'] = 'yellow'
|
| 83 |
-
elif type_el.select_one('[class*="goal"]'):
|
| 84 |
-
ev_data['type'] = 'goal'
|
| 85 |
-
elif type_el.select_one('[class*="substitution"]'):
|
| 86 |
-
ev_data['type'] = 'substitution'
|
| 87 |
-
else:
|
| 88 |
-
for rect in type_el.select('svg rect'):
|
| 89 |
-
if rect.get('fill') == '#E20007':
|
| 90 |
-
ev_data['type'] = 'redcard'; ev_data['card_type'] = 'red'; break
|
| 91 |
-
if ev_data['type'] == 'unknown':
|
| 92 |
-
for circle in type_el.select('svg circle'):
|
| 93 |
-
if circle.get('fill') == 'white' and circle.get('r') == '8':
|
| 94 |
-
ev_data['type'] = 'goal'; break
|
| 95 |
-
if ev_data['type'] == 'unknown' and ev.select_one('.players.subst'):
|
| 96 |
-
ev_data['type'] = 'substitution'
|
| 97 |
-
|
| 98 |
-
players_el = ev.select_one('.players')
|
| 99 |
-
if players_el and ev_data['type'] == 'unknown':
|
| 100 |
-
pcls = ' '.join(players_el.get('class', []) if players_el.get('class') else [])
|
| 101 |
-
if 'goal' in pcls: ev_data['type'] = 'goal'
|
| 102 |
-
elif 'card' in pcls: ev_data['type'] = 'redcard'; ev_data['card_type'] = 'red'
|
| 103 |
-
elif 'subst' in pcls: ev_data['type'] = 'substitution'
|
| 104 |
-
|
| 105 |
-
if players_el:
|
| 106 |
-
time_el = players_el.select_one('.event-time')
|
| 107 |
-
if time_el:
|
| 108 |
-
ev_data['time'] = _normalize_time(time_el.get_text())
|
| 109 |
-
ev_data['players'] = _cl(players_el.get_text(' ', strip=True))
|
| 110 |
-
|
| 111 |
-
texts = []
|
| 112 |
-
for d in players_el.find_all('div', recursive=False):
|
| 113 |
-
t = _cl(d.get_text())
|
| 114 |
-
if t and t != ev_data['time']:
|
| 115 |
-
texts.append(t)
|
| 116 |
-
for p in players_el.find_all('p', recursive=False):
|
| 117 |
-
t = _cl(p.get_text())
|
| 118 |
-
if t and t not in texts:
|
| 119 |
-
texts.append(t)
|
| 120 |
-
|
| 121 |
-
if ev_data['type'] == 'substitution':
|
| 122 |
-
if len(texts) >= 2:
|
| 123 |
-
ev_data['player_out'] = texts[0]; ev_data['player_in'] = texts[1]
|
| 124 |
-
elif len(texts) == 1:
|
| 125 |
-
ev_data['player_in'] = texts[0]
|
| 126 |
-
elif ev_data['type'] == 'goal':
|
| 127 |
-
if len(texts) >= 1: ev_data['scorer'] = texts[0]
|
| 128 |
-
if len(texts) >= 2: ev_data['assist'] = texts[1]
|
| 129 |
-
elif ev_data['type'] in ('redcard', 'yellowcard'):
|
| 130 |
-
if texts: ev_data['player'] = ' '.join(texts)
|
| 131 |
-
|
| 132 |
-
events.append(ev_data)
|
| 133 |
-
return events
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
def fetch_match_detail(event_id: int) -> dict:
|
| 137 |
-
import concurrent.futures
|
| 138 |
-
result = {"event_id": event_id, "found": False, "sections": []}
|
| 139 |
-
|
| 140 |
-
html = None
|
| 141 |
-
base = f"https://bongda.com.vn/tran-dau/{event_id}"
|
| 142 |
-
urls = [base + suffix for suffix in ['/centre/', '/preview/', '/bao-cao-nhanh/']]
|
| 143 |
-
|
| 144 |
-
# Try all URLs in parallel, take first success
|
| 145 |
-
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as ex:
|
| 146 |
-
futures = {ex.submit(requests.get, url, headers=HEADERS, timeout=8, allow_redirects=True): url for url in urls}
|
| 147 |
-
for future in concurrent.futures.as_completed(futures, timeout=12):
|
| 148 |
-
try:
|
| 149 |
-
resp = future.result()
|
| 150 |
-
if resp.status_code == 200 and len(resp.text) > 1000:
|
| 151 |
-
html = resp.text
|
| 152 |
-
for f in futures:
|
| 153 |
-
f.cancel()
|
| 154 |
-
break
|
| 155 |
-
except Exception:
|
| 156 |
-
continue
|
| 157 |
-
|
| 158 |
-
if not html:
|
| 159 |
-
return result
|
| 160 |
-
|
| 161 |
-
sp = _mk(html)
|
| 162 |
-
info = {}
|
| 163 |
-
|
| 164 |
-
tel = sp.select_one('.teams')
|
| 165 |
-
if tel:
|
| 166 |
-
he = tel.select_one('.team.home') or tel.select_one('[class*="home"]')
|
| 167 |
-
if he:
|
| 168 |
-
ne = he.select_one('p:not(.logo)') or he.find('p')
|
| 169 |
-
if ne: info['home_team'] = _cl(ne.get_text())
|
| 170 |
-
lo = he.select_one('img')
|
| 171 |
-
if lo: info['home_logo'] = lo.get('src', '')
|
| 172 |
-
|
| 173 |
-
ae = tel.select_one('.team.away') or tel.select_one('[class*="away"]')
|
| 174 |
-
if ae:
|
| 175 |
-
ne = ae.select_one('p:not(.logo)') or ae.find('p')
|
| 176 |
-
if ne: info['away_team'] = _cl(ne.get_text())
|
| 177 |
-
lo = ae.select_one('img')
|
| 178 |
-
if lo: info['away_logo'] = lo.get('src', '')
|
| 179 |
-
|
| 180 |
-
sc = tel.select_one('.score')
|
| 181 |
-
if sc:
|
| 182 |
-
parts = [_cl(p.get_text()) for p in sc.select('p')]
|
| 183 |
-
if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}"
|
| 184 |
-
lb = sc.select_one('.label')
|
| 185 |
-
if lb: info['status_label'] = _cl(lb.get_text())
|
| 186 |
-
|
| 187 |
-
if info.get('home_team') and info.get('away_team'):
|
| 188 |
-
result['info'] = info
|
| 189 |
-
result['found'] = True
|
| 190 |
-
result['sections'].append('info')
|
| 191 |
-
else:
|
| 192 |
-
return result
|
| 193 |
-
|
| 194 |
-
mi = sp.select_one('.match-info')
|
| 195 |
-
if mi:
|
| 196 |
-
for sel in ['.times', 'li']:
|
| 197 |
-
el = mi.select_one(sel)
|
| 198 |
-
if el:
|
| 199 |
-
t = _cl(el.get_text())
|
| 200 |
-
if t: info.setdefault('datetime', t); break
|
| 201 |
-
|
| 202 |
-
events = parse_events(sp)
|
| 203 |
-
if events:
|
| 204 |
-
result['events'] = events
|
| 205 |
-
result['sections'].append('events')
|
| 206 |
-
|
| 207 |
-
pred = sp.select_one('.prediction-card')
|
| 208 |
-
if pred:
|
| 209 |
-
pred_data = {}
|
| 210 |
-
team_info = pred.select_one('.team-info')
|
| 211 |
-
if team_info:
|
| 212 |
-
teams = team_info.select('.team')
|
| 213 |
-
if len(teams) >= 2:
|
| 214 |
-
pred_data['home_name'] = _cl(teams[0].select_one('.team-name').get_text()) if teams[0].select_one('.team-name') else ''
|
| 215 |
-
pred_data['away_name'] = _cl(teams[1].select_one('.team-name').get_text()) if teams[1].select_one('.team-name') else ''
|
| 216 |
-
divider = team_info.select_one('.divider')
|
| 217 |
-
if divider: pred_data['result'] = _cl(divider.get_text())
|
| 218 |
-
vote_count = pred.select_one('.vote-count')
|
| 219 |
-
if vote_count: pred_data['vote_count'] = _cl(vote_count.get_text())
|
| 220 |
-
result['prediction'] = pred_data
|
| 221 |
-
|
| 222 |
-
try:
|
| 223 |
-
ar = requests.get(
|
| 224 |
-
f"https://bongda.com.vn/api/fixtures/h2h-stats?event_id={event_id}",
|
| 225 |
-
headers=API_HEADERS, timeout=6
|
| 226 |
-
)
|
| 227 |
-
if ar.status_code == 200:
|
| 228 |
-
ad = ar.json()
|
| 229 |
-
if ad.get('status') == 'success' and ad.get('html'):
|
| 230 |
-
asp = _mk(ad['html'])
|
| 231 |
-
ast = {}
|
| 232 |
-
for row in asp.select('li, tr, .stat-row'):
|
| 233 |
-
cells = row.select('td, span, p')
|
| 234 |
-
if len(cells) >= 3:
|
| 235 |
-
lb = _cl(cells[0].get_text())
|
| 236 |
-
if lb: ast[lb] = {'home': _cl(cells[1].get_text()), 'away': _cl(cells[2].get_text())}
|
| 237 |
-
if ast: result['h2h_stats_parsed'] = ast; result['sections'].append('h2h_stats')
|
| 238 |
-
except Exception:
|
| 239 |
-
pass
|
| 240 |
-
|
| 241 |
-
h2h_data = []
|
| 242 |
-
h2h_el = sp.select_one('.h2h-standings')
|
| 243 |
-
if h2h_el:
|
| 244 |
-
rows = h2h_el.select('.ranking-table tbody tr, .leaderboard tr')
|
| 245 |
-
for row in rows:
|
| 246 |
-
cells = row.select('td')
|
| 247 |
-
if len(cells) >= 4:
|
| 248 |
-
logo = row.select_one('img')
|
| 249 |
-
name_el = row.select_one('.team-name, p.link, .name')
|
| 250 |
-
h2h_data.append({
|
| 251 |
-
'pos': _cl(cells[0].get_text()),
|
| 252 |
-
'logo': logo.get('src', '') if logo else '',
|
| 253 |
-
'name': _cl(name_el.get_text()) if name_el else '',
|
| 254 |
-
'played': _cl(cells[1].get_text()) if len(cells) > 1 else '',
|
| 255 |
-
'wins': _cl(cells[2].get_text()) if len(cells) > 2 else '',
|
| 256 |
-
'draws': _cl(cells[3].get_text()) if len(cells) > 3 else '',
|
| 257 |
-
'losses': _cl(cells[4].get_text()) if len(cells) > 4 else '',
|
| 258 |
-
'gf': _cl(cells[5].get_text()) if len(cells) > 5 else '',
|
| 259 |
-
'ga': _cl(cells[6].get_text()) if len(cells) > 6 else '',
|
| 260 |
-
'points': _cl(cells[8].get_text()) if len(cells) > 8 else '',
|
| 261 |
-
})
|
| 262 |
-
if h2h_data: result['h2h_standings'] = h2h_data; result['sections'].append('h2h_standings')
|
| 263 |
-
|
| 264 |
-
recent_matches = []
|
| 265 |
-
matches_list = sp.select_one('.matches-list')
|
| 266 |
-
if matches_list:
|
| 267 |
-
for item in matches_list.select('.match-detail, .match-item, li'):
|
| 268 |
-
date_el = item.select_one('.date, .time, .match-time')
|
| 269 |
-
league_el = item.select_one('.league')
|
| 270 |
-
home_el = item.select_one('.home, .team-home')
|
| 271 |
-
away_el = item.select_one('.away, .team-away')
|
| 272 |
-
score_el = item.select_one('.score, .result')
|
| 273 |
-
if home_el or away_el:
|
| 274 |
-
recent_matches.append({
|
| 275 |
-
'date': _cl(date_el.get_text()) if date_el else '',
|
| 276 |
-
'league': _cl(league_el.get_text()) if league_el else '',
|
| 277 |
-
'home': _cl(home_el.get_text()) if home_el else '',
|
| 278 |
-
'away': _cl(away_el.get_text()) if away_el else '',
|
| 279 |
-
'score': _cl(score_el.get_text()) if score_el else 'vs',
|
| 280 |
-
})
|
| 281 |
-
if recent_matches: result['recent_matches'] = recent_matches; result['sections'].append('recent')
|
| 282 |
-
|
| 283 |
-
return result
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
def fetch_match_detail_by_url(url: str) -> dict:
|
| 287 |
-
import concurrent.futures
|
| 288 |
-
eid_match = re.search(r'/tran-dau/(\d+)/', url)
|
| 289 |
-
if not eid_match:
|
| 290 |
-
return {"event_id": 0, "found": False, "error": "Cannot extract event_id from URL"}
|
| 291 |
-
event_id = int(eid_match.group(1))
|
| 292 |
-
result = {"event_id": event_id, "found": False, "sections": []}
|
| 293 |
-
|
| 294 |
-
html = None
|
| 295 |
-
try:
|
| 296 |
-
resp = requests.get(url, headers=HEADERS, timeout=8, allow_redirects=True)
|
| 297 |
-
if resp.status_code == 200 and len(resp.text) > 1000:
|
| 298 |
-
html = resp.text
|
| 299 |
-
except Exception:
|
| 300 |
-
pass
|
| 301 |
-
|
| 302 |
-
if not html:
|
| 303 |
-
return fetch_match_detail(event_id)
|
| 304 |
-
|
| 305 |
-
sp = _mk(html)
|
| 306 |
-
info = {}
|
| 307 |
-
|
| 308 |
-
tel = sp.select_one('.teams')
|
| 309 |
-
if tel:
|
| 310 |
-
he = tel.select_one('.team.home') or tel.select_one('[class*="home"]')
|
| 311 |
-
if he:
|
| 312 |
-
ne = he.select_one('p:not(.logo)') or he.find('p')
|
| 313 |
-
if ne: info['home_team'] = _cl(ne.get_text())
|
| 314 |
-
lo = he.select_one('img')
|
| 315 |
-
if lo: info['home_logo'] = lo.get('src', '')
|
| 316 |
-
ae = tel.select_one('.team.away') or tel.select_one('[class*="away"]')
|
| 317 |
-
if ae:
|
| 318 |
-
ne = ae.select_one('p:not(.logo)') or ae.find('p')
|
| 319 |
-
if ne: info['away_team'] = _cl(ne.get_text())
|
| 320 |
-
lo = ae.select_one('img')
|
| 321 |
-
if lo: info['away_logo'] = lo.get('src', '')
|
| 322 |
-
sc = tel.select_one('.score')
|
| 323 |
-
if sc:
|
| 324 |
-
parts = [_cl(p.get_text()) for p in sc.select('p')]
|
| 325 |
-
if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}"
|
| 326 |
-
lb = sc.select_one('.label')
|
| 327 |
-
if lb: info['status_label'] = _cl(lb.get_text())
|
| 328 |
-
|
| 329 |
-
if info.get('home_team') and info.get('away_team'):
|
| 330 |
-
result['info'] = info; result['found'] = True; result['sections'].append('info')
|
| 331 |
-
else:
|
| 332 |
-
return fetch_match_detail(event_id)
|
| 333 |
-
|
| 334 |
-
mi = sp.select_one('.match-info')
|
| 335 |
-
if mi:
|
| 336 |
-
te = mi.select_one('.times, li')
|
| 337 |
-
if te: info.setdefault('datetime', _cl(te.get_text()))
|
| 338 |
-
|
| 339 |
-
events = parse_events(sp)
|
| 340 |
-
if events: result['events'] = events; result['sections'].append('events')
|
| 341 |
-
|
| 342 |
-
pred = sp.select_one('.prediction-card')
|
| 343 |
-
if pred:
|
| 344 |
-
pred_data = {}
|
| 345 |
-
team_info = pred.select_one('.team-info')
|
| 346 |
-
if team_info:
|
| 347 |
-
teams = team_info.select('.team')
|
| 348 |
-
if len(teams) >= 2:
|
| 349 |
-
pred_data['home_name'] = _cl(teams[0].select_one('.team-name').get_text()) if teams[0].select_one('.team-name') else ''
|
| 350 |
-
pred_data['away_name'] = _cl(teams[1].select_one('.team-name').get_text()) if teams[1].select_one('.team-name') else ''
|
| 351 |
-
divider = team_info.select_one('.divider')
|
| 352 |
-
if divider: pred_data['result'] = _cl(divider.get_text())
|
| 353 |
-
vote_count = pred.select_one('.vote-count')
|
| 354 |
-
if vote_count: pred_data['vote_count'] = _cl(vote_count.get_text())
|
| 355 |
-
result['prediction'] = pred_data
|
| 356 |
-
|
| 357 |
-
try:
|
| 358 |
-
ar = requests.get(
|
| 359 |
-
f"https://bongda.com.vn/api/fixtures/h2h-stats?event_id={event_id}",
|
| 360 |
-
headers=API_HEADERS, timeout=6
|
| 361 |
-
)
|
| 362 |
-
if ar.status_code == 200:
|
| 363 |
-
ad = ar.json()
|
| 364 |
-
if ad.get('status') == 'success' and ad.get('html'):
|
| 365 |
-
asp = _mk(ad['html'])
|
| 366 |
-
ast = {}
|
| 367 |
-
for row in asp.select('li, tr, .stat-row'):
|
| 368 |
-
cells = row.select('td, span, p')
|
| 369 |
-
if len(cells) >= 3:
|
| 370 |
-
lb = _cl(cells[0].get_text())
|
| 371 |
-
if lb: ast[lb] = {'home': _cl(cells[1].get_text()), 'away': _cl(cells[2].get_text())}
|
| 372 |
-
if ast: result['h2h_stats_parsed'] = ast; result['sections'].append('h2h_stats')
|
| 373 |
-
except Exception:
|
| 374 |
-
pass
|
| 375 |
-
|
| 376 |
-
h2h_data = []
|
| 377 |
-
h2h_el = sp.select_one('.h2h-standings')
|
| 378 |
-
if h2h_el:
|
| 379 |
-
rows = h2h_el.select('.ranking-table tbody tr, .leaderboard tr')
|
| 380 |
-
for row in rows:
|
| 381 |
-
cells = row.select('td')
|
| 382 |
-
if len(cells) >= 4:
|
| 383 |
-
logo = row.select_one('img')
|
| 384 |
-
name_el = row.select_one('.team-name, p.link, .name')
|
| 385 |
-
h2h_data.append({
|
| 386 |
-
'pos': _cl(cells[0].get_text()),
|
| 387 |
-
'logo': logo.get('src', '') if logo else '',
|
| 388 |
-
'name': _cl(name_el.get_text()) if name_el else '',
|
| 389 |
-
'played': _cl(cells[1].get_text()) if len(cells) > 1 else '',
|
| 390 |
-
'wins': _cl(cells[2].get_text()) if len(cells) > 2 else '',
|
| 391 |
-
'draws': _cl(cells[3].get_text()) if len(cells) > 3 else '',
|
| 392 |
-
'losses': _cl(cells[4].get_text()) if len(cells) > 4 else '',
|
| 393 |
-
'gf': _cl(cells[5].get_text()) if len(cells) > 5 else '',
|
| 394 |
-
'ga': _cl(cells[6].get_text()) if len(cells) > 6 else '',
|
| 395 |
-
'points': _cl(cells[8].get_text()) if len(cells) > 8 else '',
|
| 396 |
-
})
|
| 397 |
-
if h2h_data: result['h2h_standings'] = h2h_data; result['sections'].append('h2h_standings')
|
| 398 |
-
|
| 399 |
-
recent_matches = []
|
| 400 |
-
matches_list = sp.select_one('.matches-list')
|
| 401 |
-
if matches_list:
|
| 402 |
-
for item in matches_list.select('.match-detail, .match-item, li'):
|
| 403 |
-
date_el = item.select_one('.date, .time, .match-time')
|
| 404 |
-
league_el = item.select_one('.league')
|
| 405 |
-
home_el = item.select_one('.home, .team-home')
|
| 406 |
-
away_el = item.select_one('.away, .team-away')
|
| 407 |
-
score_el = item.select_one('.score, .result')
|
| 408 |
-
if home_el or away_el:
|
| 409 |
-
recent_matches.append({
|
| 410 |
-
'date': _cl(date_el.get_text()) if date_el else '',
|
| 411 |
-
'league': _cl(league_el.get_text()) if league_el else '',
|
| 412 |
-
'home': _cl(home_el.get_text()) if home_el else '',
|
| 413 |
-
'away': _cl(away_el.get_text()) if away_el else '',
|
| 414 |
-
'score': _cl(score_el.get_text()) if score_el else 'vs',
|
| 415 |
-
})
|
| 416 |
-
if recent_matches: result['recent_matches'] = recent_matches; result['sections'].append('recent')
|
| 417 |
-
|
| 418 |
-
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
patch_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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
patch_extra.py
DELETED
|
@@ -1,50 +0,0 @@
|
|
| 1 |
-
"""Extra CSS/JS fixes injected AFTER main PATCH_INJECT."""
|
| 2 |
-
EXTRA_FIX = r'''
|
| 3 |
-
<style>
|
| 4 |
-
/* Force correct position for Short AI interaction buttons */
|
| 5 |
-
.tiktok-slide{position:relative!important}
|
| 6 |
-
.tiktok-right{position:absolute!important;right:8px!important;bottom:100px!important;display:flex!important;flex-direction:column!important;align-items:center!important;gap:14px!important;z-index:5!important}
|
| 7 |
-
.tiktok-right-btn{display:flex!important;flex-direction:column!important;align-items:center!important;gap:2px!important;background:none!important;border:0!important;color:#fff!important;font-size:10px!important;cursor:pointer!important}
|
| 8 |
-
.tiktok-right-btn .icon{width:42px!important;height:42px!important;border-radius:50%!important;background:rgba(255,255,255,.12)!important;display:flex!important;align-items:center!important;justify-content:center!important;font-size:20px!important}
|
| 9 |
-
.tiktok-right-btn .count{font-size:10px!important;color:#ddd!important}
|
| 10 |
-
#short-progress-toast{position:fixed;bottom:70px;left:50%;transform:translateX(-50%);background:#2d8659;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;z-index:99998;box-shadow:0 4px 12px rgba(0,0,0,.4);display:none;white-space:nowrap}
|
| 11 |
-
/* Kill ALL duplicate short AI slides from old layers */
|
| 12 |
-
#ai-short-home,.ai-short-home,.ai-short-card-final,[id*="ai-shorts-patched"]{display:none!important}
|
| 13 |
-
</style>
|
| 14 |
-
<div id="short-progress-toast"></div>
|
| 15 |
-
<script>
|
| 16 |
-
(function(){
|
| 17 |
-
// Kill old renderers that create duplicate Short AI slides
|
| 18 |
-
window.renderAIShortHome=function(){};
|
| 19 |
-
window.renderAIShorts7=function(){};
|
| 20 |
-
window.renderTopicWallE=function(){};
|
| 21 |
-
window.renderAiShorts=function(){};
|
| 22 |
-
// Also remove any already-rendered duplicate slides
|
| 23 |
-
setInterval(function(){
|
| 24 |
-
document.querySelectorAll('#ai-short-home,.ai-short-home,[id*="ai-shorts-patched"]').forEach(function(el){el.remove()});
|
| 25 |
-
},2000);
|
| 26 |
-
// Progress toast for short creation
|
| 27 |
-
window.showShortProgress=function(msg){var t=document.getElementById('short-progress-toast');if(t){t.textContent=msg;t.style.display='block';}};
|
| 28 |
-
window.hideShortProgress=function(){var t=document.getElementById('short-progress-toast');if(t)t.style.display='none';};
|
| 29 |
-
// Override makeShortFromPost to use progress toast
|
| 30 |
-
var _origMakeShort=window.makeShortFromPost;
|
| 31 |
-
window.makeShortFromPost=async function(pid,btn){
|
| 32 |
-
showShortProgress('⏳ Đang tạo Short AI...');
|
| 33 |
-
if(btn){btn.disabled=true;btn.textContent='Đang tạo...';}
|
| 34 |
-
try{
|
| 35 |
-
var r=await fetch('/api/ai/short/'+pid,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:'nu',emotion:'neutral',speed:1.2})});
|
| 36 |
-
var j=await r.json();
|
| 37 |
-
if(!r.ok||j.error)throw new Error(j.error||'Lỗi');
|
| 38 |
-
showShortProgress('✅ Đ�ã tạo Short AI!');
|
| 39 |
-
setTimeout(hideShortProgress,3000);
|
| 40 |
-
if(typeof renderShortAISlide==='function')renderShortAISlide();
|
| 41 |
-
}catch(e){
|
| 42 |
-
showShortProgress('❌ Lỗi: '+e.message);
|
| 43 |
-
setTimeout(hideShortProgress,4000);
|
| 44 |
-
}finally{
|
| 45 |
-
if(btn){btn.disabled=false;btn.textContent='🎬 Tạo Short AI';}
|
| 46 |
-
}
|
| 47 |
-
};
|
| 48 |
-
})();
|
| 49 |
-
</script>
|
| 50 |
-
'''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
patch_runtime.py
DELETED
|
@@ -1,274 +0,0 @@
|
|
| 1 |
-
"""Runtime patch layer for VNEWS.
|
| 2 |
-
Keeps the current large app intact, but replaces fragile AI wall endpoints with
|
| 3 |
-
stable JSON endpoints and injects frontend safeJson wrappers.
|
| 4 |
-
"""
|
| 5 |
-
import hashlib
|
| 6 |
-
import time
|
| 7 |
-
import os
|
| 8 |
-
from urllib.parse import quote
|
| 9 |
-
|
| 10 |
-
import requests
|
| 11 |
-
from bs4 import BeautifulSoup
|
| 12 |
-
from fastapi import Request
|
| 13 |
-
from fastapi.responses import JSONResponse, HTMLResponse
|
| 14 |
-
|
| 15 |
-
import main as _main
|
| 16 |
-
|
| 17 |
-
app = _main.app
|
| 18 |
-
DEFAULT_IMG = "https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg"
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
def _remove_routes(paths):
|
| 22 |
-
app.router.routes = [r for r in app.router.routes if getattr(r, "path", None) not in set(paths)]
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
def _safe_text(v):
|
| 26 |
-
return (v or "").strip()
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
def _ensure_article(url: str):
|
| 30 |
-
data = None
|
| 31 |
-
try:
|
| 32 |
-
if hasattr(_main, "_article_by_url"):
|
| 33 |
-
data = _main._article_by_url(url)
|
| 34 |
-
except Exception:
|
| 35 |
-
data = None
|
| 36 |
-
if not data:
|
| 37 |
-
try:
|
| 38 |
-
data = _main._scrape_generic_article(url) if hasattr(_main, "_scrape_generic_article") else None
|
| 39 |
-
except Exception:
|
| 40 |
-
data = None
|
| 41 |
-
if not data:
|
| 42 |
-
data = {"title": "", "summary": "", "og_image": "", "body": [], "url": url, "source": "generic"}
|
| 43 |
-
title = _safe_text(data.get("title"))
|
| 44 |
-
summary = _safe_text(data.get("summary"))
|
| 45 |
-
img = _safe_text(data.get("og_image"))
|
| 46 |
-
body = data.get("body") or []
|
| 47 |
-
if not title or not summary or not img or not body:
|
| 48 |
-
try:
|
| 49 |
-
r = requests.get(url, headers=getattr(_main, "HEADERS", {}), timeout=15)
|
| 50 |
-
r.encoding = "utf-8"
|
| 51 |
-
soup = BeautifulSoup(r.text, "lxml")
|
| 52 |
-
if not title:
|
| 53 |
-
tag = soup.find("meta", property="og:title") or soup.find("title")
|
| 54 |
-
title = tag.get("content", "").strip() if tag and tag.name == "meta" else (tag.get_text(strip=True) if tag else "")
|
| 55 |
-
if not summary:
|
| 56 |
-
tag = soup.find("meta", property="og:description") or soup.find("meta", attrs={"name": "description"})
|
| 57 |
-
summary = tag.get("content", "").strip() if tag else ""
|
| 58 |
-
if not img:
|
| 59 |
-
tag = soup.find("meta", property="og:image") or soup.find("meta", attrs={"name": "twitter:image"})
|
| 60 |
-
img = tag.get("content", "").strip() if tag else ""
|
| 61 |
-
if not body:
|
| 62 |
-
ps = []
|
| 63 |
-
for p in soup.find_all("p"):
|
| 64 |
-
t = p.get_text(" ", strip=True)
|
| 65 |
-
if len(t) > 40:
|
| 66 |
-
ps.append({"type": "p", "text": t})
|
| 67 |
-
if len(ps) >= 30:
|
| 68 |
-
break
|
| 69 |
-
body = ps
|
| 70 |
-
except Exception:
|
| 71 |
-
pass
|
| 72 |
-
if not summary and body:
|
| 73 |
-
first = next((b.get("text", "") for b in body if b.get("type") == "p" and b.get("text")), "")
|
| 74 |
-
summary = first[:360]
|
| 75 |
-
if not title:
|
| 76 |
-
title = url
|
| 77 |
-
if not img:
|
| 78 |
-
img = DEFAULT_IMG
|
| 79 |
-
if not body and summary:
|
| 80 |
-
body = [{"type": "p", "text": summary}]
|
| 81 |
-
data.update({"title": title, "summary": summary, "og_image": img, "body": body, "url": url})
|
| 82 |
-
return data
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
def _rewrite(data, tone="tu-nhien"):
|
| 86 |
-
try:
|
| 87 |
-
if hasattr(_main, "_ai_rewrite_article"):
|
| 88 |
-
text = _main._ai_rewrite_article(data, tone=tone)
|
| 89 |
-
if text and len(text.strip()) > 50:
|
| 90 |
-
return text.strip()
|
| 91 |
-
except Exception:
|
| 92 |
-
pass
|
| 93 |
-
title = data.get("title", "")
|
| 94 |
-
summary = data.get("summary", "")
|
| 95 |
-
ps = [b.get("text", "") for b in data.get("body", []) if b.get("type") == "p" and b.get("text")]
|
| 96 |
-
lead = summary or (ps[0] if ps else "")
|
| 97 |
-
points = "\n".join(["• " + p[:220] + ("..." if len(p) > 220 else "") for p in ps[:5]])
|
| 98 |
-
body = "\n\n".join(ps[:10])
|
| 99 |
-
return (f"Bản tin AI viết lại: {title}\n\n{lead}\n\n{body}\n\nĐiểm chính:\n{points}").strip()
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
def _topic_image(topic):
|
| 103 |
-
try:
|
| 104 |
-
if hasattr(_main, "_image_for_topic"):
|
| 105 |
-
return _main._image_for_topic(topic)
|
| 106 |
-
except Exception:
|
| 107 |
-
pass
|
| 108 |
-
return "https://image.pollinations.ai/prompt/" + quote("editorial illustration Vietnamese news " + topic, safe="") + "?width=1024&height=576&nologo=true"
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
def _save_post(post):
|
| 112 |
-
try:
|
| 113 |
-
posts = _main._load_wall() if hasattr(_main, "_load_wall") else []
|
| 114 |
-
except Exception:
|
| 115 |
-
posts = []
|
| 116 |
-
posts.insert(0, post)
|
| 117 |
-
try:
|
| 118 |
-
if hasattr(_main, "_save_wall"):
|
| 119 |
-
_main._save_wall(posts)
|
| 120 |
-
except Exception:
|
| 121 |
-
pass
|
| 122 |
-
return post
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
_remove_routes(["/api/url_wall", "/api/topic_post", "/api/rewrite_share", "/"])
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
@app.post("/api/url_wall")
|
| 129 |
-
async def patched_url_wall(request: Request):
|
| 130 |
-
try:
|
| 131 |
-
body = await request.json()
|
| 132 |
-
except Exception:
|
| 133 |
-
body = {}
|
| 134 |
-
url = _safe_text(body.get("url"))
|
| 135 |
-
tone = _safe_text(body.get("tone")) or "tu-nhien"
|
| 136 |
-
if not url:
|
| 137 |
-
return JSONResponse({"error": "missing url"}, status_code=400)
|
| 138 |
-
try:
|
| 139 |
-
data = _ensure_article(url)
|
| 140 |
-
text = _rewrite(data, tone=tone)
|
| 141 |
-
post = {
|
| 142 |
-
"id": hashlib.md5((url + str(time.time())).encode()).hexdigest()[:12],
|
| 143 |
-
"url": url,
|
| 144 |
-
"title": data.get("title") or url,
|
| 145 |
-
"summary": data.get("summary") or "",
|
| 146 |
-
"img": data.get("og_image") or DEFAULT_IMG,
|
| 147 |
-
"text": text or (data.get("summary") or data.get("title") or url),
|
| 148 |
-
"source": data.get("source", "url"),
|
| 149 |
-
"ts": int(time.time()),
|
| 150 |
-
}
|
| 151 |
-
_save_post(post)
|
| 152 |
-
return JSONResponse({"post": post})
|
| 153 |
-
except Exception as e:
|
| 154 |
-
return JSONResponse({"error": "Không tạo được tóm tắt URL", "detail": str(e)[:300]}, status_code=500)
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
@app.post("/api/rewrite_share")
|
| 158 |
-
async def patched_rewrite_share(request: Request):
|
| 159 |
-
return await patched_url_wall(request)
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
@app.post("/api/topic_post")
|
| 163 |
-
async def patched_topic_post(request: Request):
|
| 164 |
-
try:
|
| 165 |
-
body = await request.json()
|
| 166 |
-
except Exception:
|
| 167 |
-
body = {}
|
| 168 |
-
topic = _safe_text(body.get("topic"))
|
| 169 |
-
tone = _safe_text(body.get("tone")) or "tu-nhien"
|
| 170 |
-
if not topic:
|
| 171 |
-
return JSONResponse({"error": "missing topic"}, status_code=400)
|
| 172 |
-
try:
|
| 173 |
-
context = ""
|
| 174 |
-
try:
|
| 175 |
-
if hasattr(_main, "_topic_article_context"):
|
| 176 |
-
context = _main._topic_article_context(topic)
|
| 177 |
-
if not context and hasattr(_main, "_web_context"):
|
| 178 |
-
context = _main._web_context(topic)
|
| 179 |
-
except Exception:
|
| 180 |
-
context = ""
|
| 181 |
-
if not context:
|
| 182 |
-
context = f"Chủ đề: {topic}"
|
| 183 |
-
data = {"title": topic, "summary": context[:420], "og_image": _topic_image(topic), "body": [{"type": "p", "text": context}], "source": "topic", "url": ""}
|
| 184 |
-
text = _rewrite(data, tone=tone)
|
| 185 |
-
post = {
|
| 186 |
-
"id": hashlib.md5((topic + str(time.time())).encode()).hexdigest()[:12],
|
| 187 |
-
"url": "",
|
| 188 |
-
"title": topic,
|
| 189 |
-
"summary": data["summary"],
|
| 190 |
-
"img": data["og_image"] or DEFAULT_IMG,
|
| 191 |
-
"text": text or context,
|
| 192 |
-
"source": "topic",
|
| 193 |
-
"ts": int(time.time()),
|
| 194 |
-
}
|
| 195 |
-
_save_post(post)
|
| 196 |
-
return JSONResponse({"post": post})
|
| 197 |
-
except Exception as e:
|
| 198 |
-
return JSONResponse({"error": "Không tạo được bài theo chủ đề", "detail": str(e)[:300]}, status_code=500)
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
_FRONTEND_PATCH = r'''
|
| 202 |
-
<script>
|
| 203 |
-
(function(){
|
| 204 |
-
async function safeJson(res){
|
| 205 |
-
const text = await res.text();
|
| 206 |
-
try { return JSON.parse(text); }
|
| 207 |
-
catch(e){ return { error: (text || 'Server không trả JSON').slice(0,500) }; }
|
| 208 |
-
}
|
| 209 |
-
window.safeJson = safeJson;
|
| 210 |
-
window.createUrlPost = function(){
|
| 211 |
-
let inp=document.getElementById('ai-url-input');
|
| 212 |
-
let url=(inp&&inp.value||'').trim();
|
| 213 |
-
if(!url){ alert('Dán URL trước'); return; }
|
| 214 |
-
fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})})
|
| 215 |
-
.then(safeJson).then(j=>{
|
| 216 |
-
if(j&&j.post){
|
| 217 |
-
if(!j.post.img) j.post.img='https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg';
|
| 218 |
-
if(!j.post.text) j.post.text=j.post.summary||j.post.title||'Không lấy được nội dung tóm tắt.';
|
| 219 |
-
if(typeof prependWallPost==='function') prependWallPost(j.post);
|
| 220 |
-
alert('Đã tóm tắt URL và đăng lên tường');
|
| 221 |
-
if(inp) inp.value='';
|
| 222 |
-
} else alert((j&&j.error)||'Lỗi URL');
|
| 223 |
-
}).catch(e=>alert('Lỗi URL: '+e.message));
|
| 224 |
-
};
|
| 225 |
-
window.createTopicPost = function(){
|
| 226 |
-
let inp=document.getElementById('ai-topic-input');
|
| 227 |
-
let topic=(inp&&inp.value||'').trim();
|
| 228 |
-
if(!topic){ alert('Nhập chủ đề trước'); return; }
|
| 229 |
-
fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})})
|
| 230 |
-
.then(safeJson).then(j=>{
|
| 231 |
-
if(j&&j.post){
|
| 232 |
-
if(!j.post.img) j.post.img='https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg';
|
| 233 |
-
if(!j.post.text) j.post.text=j.post.summary||j.post.title||'Không lấy được nội dung.';
|
| 234 |
-
if(typeof prependWallPost==='function') prependWallPost(j.post);
|
| 235 |
-
alert('Đã tạo bài và đăng lên tường');
|
| 236 |
-
if(inp) inp.value='';
|
| 237 |
-
} else alert((j&&j.error)||'Lỗi tạo bài');
|
| 238 |
-
}).catch(e=>alert('Lỗi tạo bài: '+e.message));
|
| 239 |
-
};
|
| 240 |
-
window.rewriteCurrentArticle = function(){
|
| 241 |
-
if(!window._currentArticle && typeof _currentArticle!=='undefined') window._currentArticle=_currentArticle;
|
| 242 |
-
let ca = (typeof _currentArticle!=='undefined') ? _currentArticle : window._currentArticle;
|
| 243 |
-
if(!ca || !ca.url){ alert('Chưa có bài viết để rewrite'); return; }
|
| 244 |
-
let tone=document.getElementById('rewrite-tone')?.value||'nghiem-tuc';
|
| 245 |
-
let btn=document.querySelector('.article-actions button.primary');
|
| 246 |
-
if(btn){btn.textContent='Đang rewrite...';btn.disabled=true;}
|
| 247 |
-
fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:ca.url,tone})})
|
| 248 |
-
.then(safeJson).then(j=>{
|
| 249 |
-
if(j&&j.post){
|
| 250 |
-
if(!j.post.img) j.post.img='https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg';
|
| 251 |
-
if(!j.post.text) j.post.text=j.post.summary||j.post.title||'Không lấy được nội dung.';
|
| 252 |
-
let box=document.getElementById('rewrite-result');
|
| 253 |
-
if(box) box.innerHTML='<div class="rewrite-box"><div class="rewrite-title">Đã rewrite và đăng lên Tường AI</div><div class="rewrite-text">'+(j.post.text||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]))+'</div></div>';
|
| 254 |
-
if(typeof prependWallPost==='function') prependWallPost(j.post);
|
| 255 |
-
alert('Đã đăng lên Tường AI');
|
| 256 |
-
} else alert((j&&j.error)||'Không tạo được bài AI');
|
| 257 |
-
}).catch(e=>alert('Lỗi tạo bài AI: '+e.message))
|
| 258 |
-
.finally(()=>{if(btn){btn.textContent='🤖 AI viết lại & đăng tường';btn.disabled=false;}});
|
| 259 |
-
};
|
| 260 |
-
})();
|
| 261 |
-
</script>
|
| 262 |
-
'''
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
@app.get("/")
|
| 266 |
-
async def patched_index():
|
| 267 |
-
try:
|
| 268 |
-
with open("/app/static/index.html", "r", encoding="utf-8") as f:
|
| 269 |
-
html = f.read()
|
| 270 |
-
if "window.safeJson" not in html:
|
| 271 |
-
html = html.replace("</body>", _FRONTEND_PATCH + "</body>")
|
| 272 |
-
return HTMLResponse(content=html)
|
| 273 |
-
except Exception as e:
|
| 274 |
-
return HTMLResponse(content=f"<pre>Index error: {str(e)}</pre>", status_code=500)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
piped_client.py
DELETED
|
@@ -1,258 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
YouTube Shorts Scraper using Piped API
|
| 3 |
-
Piped is a privacy-friendly YouTube proxy that works without JS
|
| 4 |
-
"""
|
| 5 |
-
import requests
|
| 6 |
-
import json
|
| 7 |
-
import time
|
| 8 |
-
import threading
|
| 9 |
-
|
| 10 |
-
_cache = {}
|
| 11 |
-
_lock = threading.Lock()
|
| 12 |
-
CACHE_TTL = 900 # 15 min
|
| 13 |
-
|
| 14 |
-
# Piped API instances (public)
|
| 15 |
-
PIPED_INSTANCES = [
|
| 16 |
-
"https://pipedapi.kavin.rocks",
|
| 17 |
-
"https://pipedapi.adminforge.de",
|
| 18 |
-
"https://api.piped.projectsegfau.lt",
|
| 19 |
-
]
|
| 20 |
-
|
| 21 |
-
UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
|
| 22 |
-
|
| 23 |
-
def _cached(key):
|
| 24 |
-
with _lock:
|
| 25 |
-
if key in _cache and time.time() - _cache[key]['t'] < CACHE_TTL:
|
| 26 |
-
return _cache[key]['d']
|
| 27 |
-
return None
|
| 28 |
-
|
| 29 |
-
def _set_cache(key, data):
|
| 30 |
-
with _lock:
|
| 31 |
-
_cache[key] = {'t': time.time(), 'd': data}
|
| 32 |
-
|
| 33 |
-
def _piped_request(path, params=None):
|
| 34 |
-
"""Try multiple Piped instances"""
|
| 35 |
-
last_err = None
|
| 36 |
-
for base in PIPED_INSTANCES:
|
| 37 |
-
try:
|
| 38 |
-
url = f"{base}{path}"
|
| 39 |
-
r = requests.get(url, params=params, headers=UA, timeout=15)
|
| 40 |
-
if r.status_code == 200:
|
| 41 |
-
return r.json()
|
| 42 |
-
except Exception as e:
|
| 43 |
-
last_err = e
|
| 44 |
-
continue
|
| 45 |
-
raise Exception(f"All Piped instances failed: {last_err}")
|
| 46 |
-
|
| 47 |
-
def get_channel_videos(channel_id, max_videos=200):
|
| 48 |
-
"""Get all videos from a channel using Piped API with pagination"""
|
| 49 |
-
cached = _cached(f'ch_vids_{channel_id}')
|
| 50 |
-
if cached is not None:
|
| 51 |
-
return cached
|
| 52 |
-
|
| 53 |
-
all_videos = []
|
| 54 |
-
page = None
|
| 55 |
-
|
| 56 |
-
while len(all_videos) < max_videos:
|
| 57 |
-
try:
|
| 58 |
-
if page:
|
| 59 |
-
data = _piped_request(f"/channels/{channel_id}/videos", {"nextpage": page})
|
| 60 |
-
else:
|
| 61 |
-
data = _piped_request(f"/channels/{channel_id}/videos")
|
| 62 |
-
|
| 63 |
-
videos = data.get('relatedStreams', [])
|
| 64 |
-
if not videos:
|
| 65 |
-
break
|
| 66 |
-
|
| 67 |
-
all_videos.extend(videos)
|
| 68 |
-
|
| 69 |
-
# Check for next page
|
| 70 |
-
next_page = data.get('nextpage')
|
| 71 |
-
if not next_page or next_page == page:
|
| 72 |
-
break
|
| 73 |
-
page = next_page
|
| 74 |
-
|
| 75 |
-
# Small delay to be polite
|
| 76 |
-
time.sleep(0.3)
|
| 77 |
-
|
| 78 |
-
if len(all_videos) >= max_videos:
|
| 79 |
-
break
|
| 80 |
-
|
| 81 |
-
except Exception as e:
|
| 82 |
-
print(f"Piped pagination error: {e}")
|
| 83 |
-
break
|
| 84 |
-
|
| 85 |
-
result = all_videos[:max_videos]
|
| 86 |
-
_set_cache(f'ch_vids_{channel_id}', result)
|
| 87 |
-
return result
|
| 88 |
-
|
| 89 |
-
def get_vtvnambo_shorts_piped(max_count=50):
|
| 90 |
-
"""Get shorts from VTV Nam Bộ using Piped API"""
|
| 91 |
-
# VTV Nam Bộ channel ID
|
| 92 |
-
channel_id = "UCJ0btJV8qh7J7R2aXb9GmGA"
|
| 93 |
-
|
| 94 |
-
try:
|
| 95 |
-
videos = get_channel_videos(channel_id, 200)
|
| 96 |
-
|
| 97 |
-
shorts = []
|
| 98 |
-
for v in videos:
|
| 99 |
-
title = v.get('title', '')
|
| 100 |
-
vid = v.get('url', '').replace('/watch?v=', '')
|
| 101 |
-
if not vid:
|
| 102 |
-
continue
|
| 103 |
-
|
| 104 |
-
# Filter for shorts: title has #shorts, or duration <= 60s
|
| 105 |
-
duration = v.get('duration', 0)
|
| 106 |
-
is_short = (
|
| 107 |
-
'#shorts' in title.lower() or
|
| 108 |
-
'#short' in title.lower() or
|
| 109 |
-
(duration > 0 and duration <= 60)
|
| 110 |
-
)
|
| 111 |
-
|
| 112 |
-
if is_short:
|
| 113 |
-
shorts.append({
|
| 114 |
-
'id': vid,
|
| 115 |
-
'title': title,
|
| 116 |
-
'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg",
|
| 117 |
-
'channel': 'vtvnambo',
|
| 118 |
-
})
|
| 119 |
-
|
| 120 |
-
if shorts:
|
| 121 |
-
return shorts[:max_count]
|
| 122 |
-
except Exception as e:
|
| 123 |
-
print(f"Piped shorts error: {e}")
|
| 124 |
-
|
| 125 |
-
return []
|
| 126 |
-
|
| 127 |
-
def get_vtvnambo_shorts_rss(max_count=50):
|
| 128 |
-
"""Get shorts from YouTube RSS feed"""
|
| 129 |
-
cached = _cached('vtvnambo_rss')
|
| 130 |
-
if cached is not None:
|
| 131 |
-
return cached
|
| 132 |
-
|
| 133 |
-
from xml.etree import ElementTree as ET
|
| 134 |
-
|
| 135 |
-
# First get channel ID from page
|
| 136 |
-
channel_id = None
|
| 137 |
-
try:
|
| 138 |
-
r = requests.get("https://www.youtube.com/@vtvnambo", headers=UA, timeout=15)
|
| 139 |
-
if r.status_code == 200:
|
| 140 |
-
m = re.search(r'"channelId":"(UC[^"]+)"', r.text)
|
| 141 |
-
if m:
|
| 142 |
-
channel_id = m.group(1)
|
| 143 |
-
except:
|
| 144 |
-
pass
|
| 145 |
-
|
| 146 |
-
if not channel_id:
|
| 147 |
-
channel_id = "UCJ0btJV8qh7J7R2aXb9GmGA" # fallback
|
| 148 |
-
|
| 149 |
-
try:
|
| 150 |
-
url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
|
| 151 |
-
r = requests.get(url, headers=UA, timeout=15)
|
| 152 |
-
if r.status_code != 200:
|
| 153 |
-
return []
|
| 154 |
-
|
| 155 |
-
root = ET.fromstring(r.text)
|
| 156 |
-
ns = {'atom': 'http://www.w3.org/2005/Atom', 'yt': 'http://www.youtube.com/xml/schemas/2015'}
|
| 157 |
-
|
| 158 |
-
shorts = []
|
| 159 |
-
for entry in root.findall('atom:entry', ns)[:max_count * 2]:
|
| 160 |
-
title_el = entry.find('atom:title', ns)
|
| 161 |
-
title = title_el.text if title_el is not None and title_el.text else ''
|
| 162 |
-
|
| 163 |
-
vid_el = entry.find('yt:videoId', ns)
|
| 164 |
-
vid = vid_el.text if vid_el is not None else ''
|
| 165 |
-
if not vid:
|
| 166 |
-
continue
|
| 167 |
-
|
| 168 |
-
is_short = '#shorts' in title.lower() or '#short' in title.lower()
|
| 169 |
-
link_el = entry.find('atom:link', ns)
|
| 170 |
-
link = link_el.get('href', '') if link_el is not None else ''
|
| 171 |
-
if '/shorts/' in link:
|
| 172 |
-
is_short = True
|
| 173 |
-
|
| 174 |
-
if is_short:
|
| 175 |
-
shorts.append({
|
| 176 |
-
'id': vid,
|
| 177 |
-
'title': title,
|
| 178 |
-
'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg",
|
| 179 |
-
'channel': 'vtvnambo',
|
| 180 |
-
})
|
| 181 |
-
|
| 182 |
-
_set_cache('vtvnambo_rss', shorts[:max_count])
|
| 183 |
-
return shorts[:max_count]
|
| 184 |
-
except Exception as e:
|
| 185 |
-
print(f"RSS error: {e}")
|
| 186 |
-
|
| 187 |
-
return []
|
| 188 |
-
|
| 189 |
-
def get_vtvnambo_shorts(max_count=50):
|
| 190 |
-
"""Get all shorts from VTV Nam Bộ. Tries Piped API first, then RSS."""
|
| 191 |
-
cached = _cached('vtvnambo_shorts_v3')
|
| 192 |
-
if cached is not None:
|
| 193 |
-
return cached
|
| 194 |
-
|
| 195 |
-
all_shorts = []
|
| 196 |
-
seen_ids = set()
|
| 197 |
-
|
| 198 |
-
# Method 1: Piped API (most reliable)
|
| 199 |
-
try:
|
| 200 |
-
piped_shorts = get_vtvnambo_shorts_piped(max_count)
|
| 201 |
-
for s in piped_shorts:
|
| 202 |
-
if s['id'] not in seen_ids:
|
| 203 |
-
seen_ids.add(s['id'])
|
| 204 |
-
all_shorts.append(s)
|
| 205 |
-
print(f"Piped API found {len(piped_shorts)} shorts")
|
| 206 |
-
except Exception as e:
|
| 207 |
-
print(f"Piped method failed: {e}")
|
| 208 |
-
|
| 209 |
-
# Method 2: RSS feed
|
| 210 |
-
if len(all_shorts) < 3:
|
| 211 |
-
try:
|
| 212 |
-
rss_shorts = get_vtvnambo_shorts_rss(max_count)
|
| 213 |
-
for s in rss_shorts:
|
| 214 |
-
if s['id'] not in seen_ids:
|
| 215 |
-
seen_ids.add(s['id'])
|
| 216 |
-
all_shorts.append(s)
|
| 217 |
-
print(f"RSS found {len(rss_shorts)} shorts")
|
| 218 |
-
except Exception as e:
|
| 219 |
-
print(f"RSS method failed: {e}")
|
| 220 |
-
|
| 221 |
-
result = all_shorts[:max_count]
|
| 222 |
-
_set_cache('vtvnambo_shorts_v3', result)
|
| 223 |
-
return result
|
| 224 |
-
|
| 225 |
-
def get_wc_related_shorts(max_count=30):
|
| 226 |
-
"""Get World Cup / football related shorts."""
|
| 227 |
-
all_shorts = get_vtvnambo_shorts(max_count * 3)
|
| 228 |
-
|
| 229 |
-
wc_kws = [
|
| 230 |
-
'world cup', 'wc 2026', 'worldcup', 'fifa', 'bóng đá',
|
| 231 |
-
'trận đấu', 'đội tuyển', 'tuyển', 'vòng loại',
|
| 232 |
-
'khoảnh khắc', 'highlights', 'bàn thắng', 'goal',
|
| 233 |
-
'kết quả', 'tỉ số', 'việt nam', 'vn',
|
| 234 |
-
'ngoại hạng', 'premier league', 'champions league',
|
| 235 |
-
'laliga', 'serie a', 'bundesliga', 'ligue 1',
|
| 236 |
-
'copa', 'europa', 'c1', 'c2',
|
| 237 |
-
'messi', 'ronaldo', 'neymar', 'mbappe', 'haaland',
|
| 238 |
-
'v-league', 'vleague', 'bóng đá việt',
|
| 239 |
-
'đội bóng', 'hlv', 'huấn luyện viên',
|
| 240 |
-
'chuyển nhượng', 'transfer',
|
| 241 |
-
'asian cup', 'aff cup', 'sea games',
|
| 242 |
-
'olympic', 'u23', 'u20', 'u17',
|
| 243 |
-
]
|
| 244 |
-
|
| 245 |
-
wc_shorts = []
|
| 246 |
-
for s in all_shorts:
|
| 247 |
-
tl = s.get('title', '').lower()
|
| 248 |
-
if any(k in tl for k in wc_kws):
|
| 249 |
-
wc_shorts.append(s)
|
| 250 |
-
|
| 251 |
-
if not wc_shorts:
|
| 252 |
-
wc_shorts = all_shorts
|
| 253 |
-
|
| 254 |
-
return wc_shorts[:max_count]
|
| 255 |
-
|
| 256 |
-
import re
|
| 257 |
-
# Alias for backward compatibility
|
| 258 |
-
get_vtvnamo_shorts = get_vtvnambo_shorts
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
DELETED
|
@@ -1,16 +0,0 @@
|
|
| 1 |
-
fastapi
|
| 2 |
-
uvicorn
|
| 3 |
-
requests
|
| 4 |
-
beautifulsoup4>=4.12.0
|
| 5 |
-
lxml
|
| 6 |
-
jinja2
|
| 7 |
-
yt-dlp
|
| 8 |
-
huggingface_hub
|
| 9 |
-
gTTS
|
| 10 |
-
pillow
|
| 11 |
-
edge-tts
|
| 12 |
-
python-dateutil
|
| 13 |
-
httpx
|
| 14 |
-
python-multipart
|
| 15 |
-
pycryptodome
|
| 16 |
-
# trigger rebuild 1784359130
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
restart.txt
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
restart v3
|
|
|
|
|
|
restart2.md
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
restart with _run.py fix
|
|
|
|
|
|