Spaces:
Running
Running
Fix rewrite: dedup sentences, complete sentence trim, merge images from direct+Jina scrape
#1
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 +8 -41
- README.md +2 -47
- RESTART_TRIGGER.md +0 -7
- _run.py +0 -1
- ai_ext.py +944 -223
- ai_fix2.py +1 -1
- ai_patch.py +78 -256
- ai_runtime_final.py +43 -2
- ai_runtime_final6.py +481 -5
- ai_runtime_fix.py +0 -394
- ai_runtime_patch_final.py +0 -78
- ai_short_v2.py +0 -2083
- app/static/app_v2.js +231 -0
- 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 +1 -1
- 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
- restart_trigger.md +0 -14
- rewrite_fix_v2.js +0 -2
- rewrite_slide.py +0 -204
- runtime.txt +0 -0
- shorts_cache.py +0 -86
.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
CHANGED
|
@@ -1,48 +1,15 @@
|
|
| 1 |
-
FROM python:3.
|
| 2 |
|
| 3 |
WORKDIR /app
|
| 4 |
|
| 5 |
-
RUN
|
| 6 |
-
|
| 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 |
-
|
| 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 |
-
|
| 48 |
-
#
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
|
| 3 |
WORKDIR /app
|
| 4 |
|
| 5 |
+
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg fonts-dejavu-core && rm -rf /var/lib/apt/lists/*
|
| 6 |
+
RUN pip install --no-cache-dir fastapi uvicorn requests beautifulsoup4 lxml jinja2 yt-dlp huggingface_hub gTTS pillow edge-tts
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
COPY . .
|
|
|
|
| 9 |
|
| 10 |
+
EXPOSE 7860
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
+
# CRITICAL: Use app_v2_entry.py which imports ONLY main.py (core APIs)
|
| 13 |
+
# This bypasses the entire injection chain (app_run → app_final → ai_runtime_final6)
|
| 14 |
+
# Guarantees index_v2.html is served clean without old JS patches
|
| 15 |
+
CMD ["uvicorn", "app_v2_entry:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -9,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,7 +0,0 @@
|
|
| 1 |
-
trigger rebuild v20260827fptwall
|
| 2 |
-
|
| 3 |
-
- FPT wall cards now use .wall-thumb 100% width/height (16:9), identical to Short AI cards
|
| 4 |
-
- FPT player opens a scrollable vertical feed interleaving FPT Play + Short AI videos, newest-first
|
| 5 |
-
- 16:9 <-> 9:16 ratio toggle (🖥️/📺) works on football highlight slides and Tường AI slide player
|
| 6 |
-
- Cache-busting: app_v2.js?v=20260827fptwall
|
| 7 |
-
- FORCE REBUILD: this commit retriggers the Space container with the latest static/app_v2.js
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_run.py
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
from app_v2_entry import app # v5-stable inline bongda proxy
|
|
|
|
|
|
ai_ext.py
CHANGED
|
@@ -13,16 +13,7 @@ from bs4 import BeautifulSoup
|
|
| 13 |
from fastapi import Request, Query
|
| 14 |
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
|
| 15 |
|
| 16 |
-
|
| 17 |
-
try:
|
| 18 |
-
from main import app
|
| 19 |
-
except ImportError:
|
| 20 |
-
# Create a minimal FastAPI app for standalone testing
|
| 21 |
-
try:
|
| 22 |
-
from fastapi import FastAPI
|
| 23 |
-
app = FastAPI()
|
| 24 |
-
except Exception:
|
| 25 |
-
app = None
|
| 26 |
|
| 27 |
# Import wall store from main.py so we read/write the SAME file
|
| 28 |
try:
|
|
@@ -50,10 +41,6 @@ except ImportError:
|
|
| 50 |
def _web_context(topic):
|
| 51 |
return ""
|
| 52 |
|
| 53 |
-
# ai_ext alias for backward compatibility
|
| 54 |
-
_load_ai_wall = _load_wall
|
| 55 |
-
_save_ai_wall = _save_wall
|
| 56 |
-
|
| 57 |
try:
|
| 58 |
from huggingface_hub import AsyncInferenceClient
|
| 59 |
except Exception:
|
|
@@ -73,260 +60,994 @@ except Exception:
|
|
| 73 |
|
| 74 |
|
| 75 |
def _hf_token():
|
| 76 |
-
for k in ("HF_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 |
-
|
| 87 |
-
return s.strip()
|
| 88 |
|
| 89 |
-
|
| 90 |
-
def _domain(url: str) -> str:
|
| 91 |
-
"""Extract domain from URL."""
|
| 92 |
try:
|
| 93 |
-
return urlparse(
|
| 94 |
except Exception:
|
| 95 |
return ""
|
| 96 |
|
|
|
|
|
|
|
| 97 |
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
Prioritizes Llama-3.3-70B for better creative/opinion writing.
|
| 102 |
"""
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 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 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
if text:
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
break
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
|
| 202 |
-
|
| 203 |
-
|
| 204 |
|
| 205 |
-
|
| 206 |
-
|
| 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 |
-
|
| 213 |
-
from datetime import datetime, timezone, timedelta
|
| 214 |
-
_VN_TZ = timezone(timedelta(hours=7))
|
| 215 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
|
| 222 |
-
|
| 223 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
try:
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
except Exception:
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
|
| 229 |
|
| 230 |
-
|
| 231 |
-
|
| 232 |
if url:
|
| 233 |
try:
|
| 234 |
r = requests.get(url, headers=HEADERS, timeout=15)
|
| 235 |
-
if r.status_code == 200 and len(r.content) >
|
| 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 |
-
|
| 243 |
try:
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 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
|
| 266 |
-
|
| 267 |
-
if
|
| 268 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
try:
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 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 |
-
|
|
|
|
| 316 |
|
| 317 |
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
import random as _r2
|
| 321 |
-
now = int(time.time() * 1000)
|
| 322 |
return {
|
| 323 |
-
"id": str(
|
| 324 |
-
"title":
|
| 325 |
-
"
|
| 326 |
-
"
|
| 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 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
from fastapi import Request, Query
|
| 14 |
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
|
| 15 |
|
| 16 |
+
from main import app
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
# Import wall store from main.py so we read/write the SAME file
|
| 19 |
try:
|
|
|
|
| 41 |
def _web_context(topic):
|
| 42 |
return ""
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
try:
|
| 45 |
from huggingface_hub import AsyncInferenceClient
|
| 46 |
except Exception:
|
|
|
|
| 60 |
|
| 61 |
|
| 62 |
def _hf_token():
|
| 63 |
+
for k in ("HF_TOKEN", "HUGGINGFACEHUB_API_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HF_API_TOKEN"):
|
| 64 |
v = os.getenv(k, "").strip()
|
| 65 |
if v:
|
| 66 |
return v
|
| 67 |
return ""
|
| 68 |
|
| 69 |
+
HF_TOKEN = _hf_token()
|
| 70 |
+
QWEN_VL_MODEL = os.getenv("QWEN_VL_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct")
|
| 71 |
+
DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data"
|
| 72 |
+
SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts")
|
| 73 |
+
HEADERS = {
|
| 74 |
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
| 75 |
+
"Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"
|
| 76 |
+
}
|
| 77 |
+
LAST_QWEN_ERROR = ""
|
| 78 |
+
|
| 79 |
+
# ===== TTS VOICE CONFIG =====
|
| 80 |
+
TTS_VOICES = {
|
| 81 |
+
"hoaimy": {"id": "vi-VN-HoaiMyNeural", "gender": "female", "name": "Hoài My", "country": "Việt Nam", "lang": "vi", "flag": "🇻🇳"},
|
| 82 |
+
"namminh": {"id": "vi-VN-NamMinhNeural", "gender": "male", "name": "Nam Minh", "country": "Việt Nam", "lang": "vi", "flag": "🇻🇳"},
|
| 83 |
+
"en_us_f": {"id": "en-US-AvaNeural", "gender": "female", "name": "Ava", "country": "Mỹ", "lang": "en", "flag": "🇺🇸"},
|
| 84 |
+
"en_us_m": {"id": "en-US-BrianNeural", "gender": "male", "name": "Brian", "country": "Mỹ", "lang": "en", "flag": "🇺🇸"},
|
| 85 |
+
"en_gb_f": {"id": "en-GB-SoniaNeural", "gender": "female", "name": "Sonia", "country": "Anh", "lang": "en", "flag": "🇬🇧"},
|
| 86 |
+
"en_gb_m": {"id": "en-GB-RyanNeural", "gender": "male", "name": "Ryan", "country": "Anh", "lang": "en", "flag": "🇬🇧"},
|
| 87 |
+
"zh_cn_f": {"id": "zh-CN-XiaoxiaoNeural", "gender": "female", "name": "Xiaoxiao", "country": "Trung Quốc", "lang": "zh", "flag": "🇨🇳"},
|
| 88 |
+
"zh_cn_m": {"id": "zh-CN-YunyangNeural", "gender": "male", "name": "Yunyang", "country": "Trung Quốc", "lang": "zh", "flag": "🇨🇳"},
|
| 89 |
+
"zh_hk_f": {"id": "zh-HK-HiuGaaiNeural", "gender": "female", "name": "HiuGaai", "country": "Hồng Kông", "lang": "zh", "flag": "🇭🇰"},
|
| 90 |
+
"ja_jp_f": {"id": "ja-JP-NanamiNeural", "gender": "female", "name": "Nanami", "country": "Nhật Bản", "lang": "ja", "flag": "🇯🇵"},
|
| 91 |
+
"ja_jp_m": {"id": "ja-JP-KeitaNeural", "gender": "male", "name": "Keita", "country": "Nhật Bản", "lang": "ja", "flag": "🇯🇵"},
|
| 92 |
+
"ko_kr_f": {"id": "ko-KR-SunHiNeural", "gender": "female", "name": "Sun-Hi", "country": "Hàn Quốc", "lang": "ko", "flag": "🇰🇷"},
|
| 93 |
+
"ko_kr_m": {"id": "ko-KR-InJoonNeural", "gender": "male", "name": "InJoon", "country": "Hàn Quốc", "lang": "ko", "flag": "🇰🇷"},
|
| 94 |
+
"fr_fr_f": {"id": "fr-FR-DeniseNeural", "gender": "female", "name": "Denise", "country": "Pháp", "lang": "fr", "flag": "🇫🇷"},
|
| 95 |
+
"fr_fr_m": {"id": "fr-FR-HenriNeural", "gender": "male", "name": "Henri", "country": "Pháp", "lang": "fr", "flag": "🇫🇷"},
|
| 96 |
+
"de_de_f": {"id": "de-DE-KatjaNeural", "gender": "female", "name": "Katja", "country": "Đức", "lang": "de", "flag": "🇩🇪"},
|
| 97 |
+
"de_de_m": {"id": "de-DE-KillianNeural", "gender": "male", "name": "Killian", "country": "Đức", "lang": "de", "flag": "🇩🇪"},
|
| 98 |
+
"es_es_f": {"id": "es-ES-XimenaNeural", "gender": "female", "name": "Ximena", "country": "Tây Ban Nha", "lang": "es", "flag": "🇪🇸"},
|
| 99 |
+
"es_mx_m": {"id": "es-MX-JorgeNeural", "gender": "male", "name": "Jorge", "country": "Mexico", "lang": "es", "flag": "🇲🇽"},
|
| 100 |
+
"th_th_f": {"id": "th-TH-PremwadeeNeural", "gender": "female", "name": "Premwadee", "country": "Thái Lan", "lang": "th", "flag": "🇹🇭"},
|
| 101 |
+
"th_th_m": {"id": "th-TH-NiwatNeural", "gender": "male", "name": "Niwat", "country": "Thái Lan", "lang": "th", "flag": "🇹🇭"},
|
| 102 |
+
"hi_in_f": {"id": "hi-IN-SwaraNeural", "gender": "female", "name": "Swara", "country": "Ấn Độ", "lang": "hi", "flag": "🇮🇳"},
|
| 103 |
+
"hi_in_m": {"id": "hi-IN-MadhurNeural", "gender": "male", "name": "Madhur", "country": "Ấn Độ", "lang": "hi", "flag": "🇮🇳"},
|
| 104 |
+
}
|
| 105 |
+
TTS_DEFAULT_VOICE = "hoaimy"
|
| 106 |
+
TTS_DEFAULT_SPEED = 1.2
|
| 107 |
+
|
| 108 |
+
TOPIC_VOICE_MAP = {
|
| 109 |
+
"bóng đá": "namminh", "thể thao": "namminh", "world cup": "namminh",
|
| 110 |
+
"premier league": "namminh", "champions league": "namminh", "la liga": "namminh",
|
| 111 |
+
"serie a": "namminh", "bundesliga": "namminh", "v-league": "namminh",
|
| 112 |
+
"tennis": "namminh", "olympic": "namminh", "f1": "namminh", "moto": "namminh",
|
| 113 |
+
"sức khỏe": "hoaimy", "làm đẹp": "hoaimy", "giải trí": "hoaimy",
|
| 114 |
+
"âm nhạc": "hoaimy", "phim": "hoaimy", "thời trang": "hoaimy",
|
| 115 |
+
"ẩm thực": "hoaimy", "du lịch": "hoaimy", "gia đình": "hoaimy",
|
| 116 |
+
"tình yêu": "hoaimy", "hôn nhân": "hoaimy", "mẹ và bé": "hoaimy",
|
| 117 |
+
"công nghệ": "namminh", "ai": "namminh", "robot": "namminh",
|
| 118 |
+
"khoa học": "namminh", "vũ trụ": "namminh", "điện thoại": "namminh",
|
| 119 |
+
"laptop": "namminh", "game": "namminh",
|
| 120 |
+
"chính trị": "namminh", "kinh tế": "namminh", "tài chính": "namminh",
|
| 121 |
+
"chứng khoán": "namminh", "ngân hàng": "namminh", "thị trường": "namminh",
|
| 122 |
+
"xã hội": "namminh", "pháp luật": "namminh", "giáo dục": "namminh",
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _detect_voice_for_topic(title: str, text: str) -> str:
|
| 127 |
+
combined = (title + " " + text[:500]).lower()
|
| 128 |
+
for keyword, voice_id in TOPIC_VOICE_MAP.items():
|
| 129 |
+
if keyword in combined:
|
| 130 |
+
return voice_id
|
| 131 |
+
return TTS_DEFAULT_VOICE
|
| 132 |
+
|
| 133 |
|
| 134 |
+
# ===== TEXT HELPERS =====
|
| 135 |
def _clean_text(s: str) -> str:
|
|
|
|
| 136 |
s = html_lib.unescape(s or "")
|
| 137 |
+
return re.sub(r"\s+", " ", s).strip()
|
|
|
|
| 138 |
|
| 139 |
+
def _domain(u):
|
|
|
|
|
|
|
| 140 |
try:
|
| 141 |
+
return urlparse(u).netloc.replace("www.", "")
|
| 142 |
except Exception:
|
| 143 |
return ""
|
| 144 |
|
| 145 |
+
def _safe_name(s):
|
| 146 |
+
return re.sub(r"[^a-zA-Z0-9_-]+", "_", str(s))[:80]
|
| 147 |
|
| 148 |
+
|
| 149 |
+
# ===== CLEAN AI OUTPUT — COMPLETE REWRITE =====
|
| 150 |
+
def _clean_ai_output(text: str) -> str:
|
|
|
|
| 151 |
"""
|
| 152 |
+
Aggressively clean AI output:
|
| 153 |
+
1. Remove markdown artifacts
|
| 154 |
+
2. Remove instruction leakage / meta-commentary
|
| 155 |
+
3. Split into sentences, deduplicate (exact + near-match)
|
| 156 |
+
4. Trim at complete sentence boundary (no mid-sentence cuts)
|
| 157 |
+
5. Remove consecutive duplicate lines
|
| 158 |
+
"""
|
| 159 |
+
if not text:
|
| 160 |
+
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
|
| 162 |
+
# --- Step 1: Remove markdown ---
|
| 163 |
+
text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
|
| 164 |
+
text = re.sub(r'\*\*([^*]+)\*\*', r'\1', text)
|
| 165 |
+
text = re.sub(r'\*([^*]+)\*', r'\1', text)
|
| 166 |
+
text = re.sub(r'^---+\s*$', '', text, flags=re.MULTILINE)
|
| 167 |
+
text = re.sub(r'^[-*_]{3,}\s*$', '', text, flags=re.MULTILINE)
|
| 168 |
|
| 169 |
+
# --- Step 2: Remove instruction leakage (entire lines) ---
|
| 170 |
+
leakage_patterns = [
|
| 171 |
+
r'Dưới đây là', r'Theo yêu cầu', r'Tôi sẽ viết', r'Tôi sẽ tóm tắt',
|
| 172 |
+
r'Đây là bài', r'Đây là nội dung', r'Bài viết sau đây',
|
| 173 |
+
r'Nội dung (tóm tắt|chính)', r'Nhiệm vụ', r'Vai trò', r'Tôi là',
|
| 174 |
+
r'Dựa trên.*tôi sẽ', r'Hãy', r'Bạn cần', r'Đọc bài viết',
|
| 175 |
+
r'Tôi xin', r'Xin chào', r'Trân trọng', r'Kính thưa',
|
| 176 |
+
r'Dựa trên.*dưới đây', r'Sau đây là', r'Dưới đây là bài',
|
| 177 |
+
r'Dựa trên.*tôi đã', r'Theo nội dung', r'Tóm tắt như sau',
|
| 178 |
+
r'Dưới đây là bài viết', r'Đây là phần', r'Bài tóm tắt',
|
| 179 |
+
r'Nội dung tóm tắt', r'Đoạn văn sau', r'Viết lại.*sau',
|
| 180 |
+
]
|
| 181 |
+
for phrase in leakage_patterns:
|
| 182 |
+
text = re.sub(r'^[^\n]*' + phrase + r'[^\n]*\n?', '', text, flags=re.MULTILINE | re.IGNORECASE)
|
| 183 |
+
|
| 184 |
+
# Remove lines that are just meta-instructions
|
| 185 |
+
text = re.sub(r'^[^\n]*(?:QUY TẮC|bắt buộc|tối đa|không dùng)[^\n]*\n?', '', text, flags=re.MULTILINE | re.IGNORECASE)
|
| 186 |
+
|
| 187 |
+
text = re.sub(r'\n{3,}', '\n\n', text)
|
| 188 |
+
text = text.strip()
|
| 189 |
+
|
| 190 |
+
if not text:
|
| 191 |
+
return ""
|
| 192 |
+
|
| 193 |
+
# --- Step 3: Split into sentences & deduplicate ---
|
| 194 |
+
# Vietnamese sentences end with . ! ? or end of string
|
| 195 |
+
# We split on sentence-ending punctuation followed by space or end
|
| 196 |
+
raw_parts = re.split(r'(?<=[.!?])\s+', text.strip())
|
| 197 |
+
|
| 198 |
+
def _norm(s):
|
| 199 |
+
"""Normalize for comparison: lowercase, collapse whitespace, remove punctuation."""
|
| 200 |
+
s = re.sub(r'\s+', ' ', s.strip().lower())
|
| 201 |
+
return re.sub(r'[^\w\s]', '', s)
|
| 202 |
+
|
| 203 |
+
seen_sentences = set()
|
| 204 |
+
unique_parts = []
|
| 205 |
+
|
| 206 |
+
for part in raw_parts:
|
| 207 |
+
n = _norm(part)
|
| 208 |
+
if not n or len(n) < 5:
|
| 209 |
+
# Very short fragments — keep them if they're not empty
|
| 210 |
+
if part.strip():
|
| 211 |
+
unique_parts.append(part)
|
| 212 |
+
continue
|
| 213 |
+
|
| 214 |
+
# Check exact match
|
| 215 |
+
if n in seen_sentences:
|
| 216 |
+
continue
|
| 217 |
+
|
| 218 |
+
# Check near-duplicate: substring match
|
| 219 |
+
is_dup = False
|
| 220 |
+
n_clean = re.sub(r'\s+', '', n)
|
| 221 |
+
for seen in list(seen_sentences):
|
| 222 |
+
seen_clean = re.sub(r'\s+', '', seen)
|
| 223 |
+
if not n_clean or not seen_clean:
|
| 224 |
+
continue
|
| 225 |
+
# One is substring of the other
|
| 226 |
+
if n_clean in seen_clean or seen_clean in n_clean:
|
| 227 |
+
shorter = min(len(n_clean), len(seen_clean))
|
| 228 |
+
longer = max(len(n_clean), len(seen_clean))
|
| 229 |
+
if shorter > 15 and shorter / longer > 0.7:
|
| 230 |
+
is_dup = True
|
| 231 |
break
|
| 232 |
+
# High word overlap
|
| 233 |
+
else:
|
| 234 |
+
words_n = set(n.split())
|
| 235 |
+
words_s = set(seen.split())
|
| 236 |
+
if len(words_n) >= 3 and len(words_s) >= 3:
|
| 237 |
+
overlap = len(words_n & words_s)
|
| 238 |
+
ratio = overlap / min(len(words_n), len(words_s))
|
| 239 |
+
if ratio > 0.75:
|
| 240 |
+
is_dup = True
|
| 241 |
+
break
|
| 242 |
|
| 243 |
+
if is_dup:
|
| 244 |
+
continue
|
| 245 |
|
| 246 |
+
seen_sentences.add(n)
|
| 247 |
+
unique_parts.append(part)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
|
| 249 |
+
result = ' '.join(unique_parts).strip()
|
|
|
|
|
|
|
| 250 |
|
| 251 |
+
# --- Step 4: Remove consecutive duplicate lines ---
|
| 252 |
+
lines = result.split('\n')
|
| 253 |
+
final_lines = []
|
| 254 |
+
prev_line = ""
|
| 255 |
+
for line in lines:
|
| 256 |
+
stripped = line.strip()
|
| 257 |
+
if stripped and stripped == prev_line:
|
| 258 |
+
continue
|
| 259 |
+
final_lines.append(line)
|
| 260 |
+
prev_line = stripped
|
| 261 |
+
result = '\n'.join(final_lines).strip()
|
| 262 |
|
| 263 |
+
# --- Step 5: Trim at complete sentence boundary ---
|
| 264 |
+
# Find the last sentence-ending punctuation and cut there
|
| 265 |
+
# This prevents mid-sentence truncation
|
| 266 |
+
if result:
|
| 267 |
+
# Find all sentence-ending positions
|
| 268 |
+
end_matches = list(re.finditer(r'[.!?]\s*$', result))
|
| 269 |
+
if not end_matches:
|
| 270 |
+
# No ending punctuation — try to find the last complete sentence
|
| 271 |
+
last_end = -1
|
| 272 |
+
for m in re.finditer(r'[.!?](?=\s|$)', result):
|
| 273 |
+
last_end = m.end()
|
| 274 |
+
if last_end > 0:
|
| 275 |
+
result = result[:last_end].strip()
|
| 276 |
+
# else: already ends with punctuation, good
|
| 277 |
|
| 278 |
+
# --- Step 6: Final cleanup ---
|
| 279 |
+
# Remove any trailing incomplete sentence fragments after last punctuation
|
| 280 |
+
# e.g. "Đây là câu 1. Đây là câu 2. Và một" → cut "Và một"
|
| 281 |
+
last_period = result.rfind('.')
|
| 282 |
+
last_exclaim = result.rfind('!')
|
| 283 |
+
last_question = result.rfind('?')
|
| 284 |
+
last_end = max(last_period, last_exclaim, last_question)
|
| 285 |
+
if last_end > 0:
|
| 286 |
+
after = result[last_end + 1:].strip()
|
| 287 |
+
# If what follows the last sentence end is a short fragment (< 20 chars, no ending punct), cut it
|
| 288 |
+
if after and len(after) < 20 and not re.search(r'[.!?]$', after):
|
| 289 |
+
result = result[:last_end + 1].strip()
|
| 290 |
|
| 291 |
+
return result
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
# ===== EXTRACT ALL IMAGES FROM ARTICLE — ENHANCED =====
|
| 295 |
+
def _extract_all_images(soup, base_url: str) -> List[Dict]:
|
| 296 |
+
"""Extract ALL content images from an article page using multi-strategy approach."""
|
| 297 |
+
images = []
|
| 298 |
+
seen_urls = set()
|
| 299 |
+
skip_patterns = [
|
| 300 |
+
"avatar", "icon", "logo", "button", "banner-ad", "tracking",
|
| 301 |
+
"beacon", "pixel", "1x1", "spacer", "emoji", "sprite", "placeholder",
|
| 302 |
+
"advertisement", "ads", "widget", "sidebar", "footer-logo",
|
| 303 |
+
"social", "share", "comment-avatar", "user-avatar",
|
| 304 |
+
]
|
| 305 |
+
|
| 306 |
+
def _add_image(src: str, alt: str = "", source_tag: str = "img"):
|
| 307 |
+
if not src or src.startswith("data:"):
|
| 308 |
+
return
|
| 309 |
+
# Normalize URL
|
| 310 |
+
src = src.strip()
|
| 311 |
+
if src.startswith("//"):
|
| 312 |
+
src = "https:" + src
|
| 313 |
+
abs_url = urljoin(base_url, src)
|
| 314 |
+
if abs_url in seen_urls:
|
| 315 |
+
return
|
| 316 |
+
# Skip non-content images by URL pattern
|
| 317 |
+
if any(p in abs_url.lower() for p in skip_patterns):
|
| 318 |
+
return
|
| 319 |
+
# Skip very small images (likely icons)
|
| 320 |
+
try:
|
| 321 |
+
parsed = urlparse(abs_url)
|
| 322 |
+
path = parsed.path.lower()
|
| 323 |
+
if any(path.endswith(ext) for ext in ['.svg', '.ico']):
|
| 324 |
+
return
|
| 325 |
+
# Skip if URL contains dimension patterns like 16x16, 32x32
|
| 326 |
+
if re.search(r'/\d{1,3}x\d{1,3}/', path) or re.search(r'-\d{1,3}x\d{1,3}\.', path):
|
| 327 |
+
return
|
| 328 |
+
except Exception:
|
| 329 |
+
pass
|
| 330 |
+
seen_urls.add(abs_url)
|
| 331 |
+
images.append({"url": abs_url, "alt": alt, "source": source_tag})
|
| 332 |
+
|
| 333 |
+
# Strategy 1: Standard <img> tags with all lazy-load attributes
|
| 334 |
+
for img in soup.find_all("img"):
|
| 335 |
+
src = (img.get("src") or img.get("data-src") or img.get("data-lazy-src") or
|
| 336 |
+
img.get("data-original") or img.get("data-srcset", "").split(",")[0].strip().split(" ")[0])
|
| 337 |
+
_add_image(src, alt=img.get("alt", ""), source_tag="img")
|
| 338 |
+
|
| 339 |
+
# Strategy 2: srcset on <img>
|
| 340 |
+
for img in soup.find_all("img", srcset=True):
|
| 341 |
+
for part in img["srcset"].split(","):
|
| 342 |
+
part = part.strip()
|
| 343 |
+
if part:
|
| 344 |
+
_add_image(part.split(" ")[0], alt=img.get("alt", ""), source_tag="srcset")
|
| 345 |
+
|
| 346 |
+
# Strategy 3: <picture> with <source>
|
| 347 |
+
for picture in soup.find_all("picture"):
|
| 348 |
+
for source in picture.find_all("source"):
|
| 349 |
+
srcset = source.get("srcset", "")
|
| 350 |
+
for part in srcset.split(","):
|
| 351 |
+
part = part.strip()
|
| 352 |
+
if part:
|
| 353 |
+
_add_image(part.split(" ")[0], source_tag="picture/srcset")
|
| 354 |
+
fallback_img = picture.find("img")
|
| 355 |
+
if fallback_img:
|
| 356 |
+
_add_image(
|
| 357 |
+
fallback_img.get("src") or fallback_img.get("data-src"),
|
| 358 |
+
alt=fallback_img.get("alt", ""),
|
| 359 |
+
source_tag="picture/img"
|
| 360 |
+
)
|
| 361 |
+
|
| 362 |
+
# Strategy 4: WordPress CMS patterns
|
| 363 |
+
for img in soup.find_all("img", class_=re.compile(r"wp-image|size-large|size-full|aligncenter")):
|
| 364 |
+
_add_image(img.get("data-src") or img.get("src"),
|
| 365 |
+
alt=img.get("alt", ""), source_tag="wp-image")
|
| 366 |
+
|
| 367 |
+
# Strategy 5: Background images in style attributes
|
| 368 |
+
for tag in soup.find_all(style=re.compile(r"background-image")):
|
| 369 |
+
for m in re.findall(r'url\(["\']?(.*?)["\']?\)', tag.get("style", "")):
|
| 370 |
+
_add_image(m, source_tag="background-style")
|
| 371 |
+
|
| 372 |
+
# Strategy 6: og:image (featured/hero image)
|
| 373 |
+
og_image = soup.find("meta", property="og:image")
|
| 374 |
+
if og_image and og_image.get("content"):
|
| 375 |
+
_add_image(og_image["content"], source_tag="og:image")
|
| 376 |
+
|
| 377 |
+
# Strategy 7: twitter:image
|
| 378 |
+
tw_image = soup.find("meta", attrs={"name": "twitter:image"})
|
| 379 |
+
if tw_image and tw_image.get("content"):
|
| 380 |
+
_add_image(tw_image["content"], source_tag="twitter:image")
|
| 381 |
+
|
| 382 |
+
# Strategy 8: <figure> with <figcaption>
|
| 383 |
+
for figure in soup.find_all("figure"):
|
| 384 |
+
img = figure.find("img")
|
| 385 |
+
if img:
|
| 386 |
+
src = img.get("data-src") or img.get("src")
|
| 387 |
+
figcaption = figure.find("figcaption")
|
| 388 |
+
alt = figcaption.get_text(strip=True) if figcaption else img.get("alt", "")
|
| 389 |
+
_add_image(src, alt=alt, source_tag="figure")
|
| 390 |
+
|
| 391 |
+
# Strategy 9: <a> tags linking to images
|
| 392 |
+
for a in soup.find_all("a", href=True):
|
| 393 |
+
href = a["href"]
|
| 394 |
+
if any(href.lower().endswith(ext) for ext in [".jpg", ".jpeg", ".png", ".webp"]):
|
| 395 |
+
_add_image(href, alt=a.get_text(strip=True)[:80], source_tag="link")
|
| 396 |
+
|
| 397 |
+
# Strategy 10: VnExpress-specific — fig-parent / fig-image containers
|
| 398 |
+
for fig_parent in soup.find_all(class_=re.compile(r"fig-parent|fig-image|image|thumb")):
|
| 399 |
+
img = fig_parent.find("img")
|
| 400 |
+
if img:
|
| 401 |
+
src = img.get("data-src") or img.get("src") or img.get("data-original")
|
| 402 |
+
_add_image(src, alt=img.get("alt", ""), source_tag="fig-parent")
|
| 403 |
+
# Also check for background-image in fig-parent
|
| 404 |
+
style = fig_parent.get("style", "")
|
| 405 |
+
for m in re.findall(r'url\(["\']?(.*?)["\']?\)', style):
|
| 406 |
+
_add_image(m, source_tag="fig-parent-bg")
|
| 407 |
+
|
| 408 |
+
# Strategy 11: Dân Trí / VietnamNet — image in .content-detail, .article-body
|
| 409 |
+
for container in soup.find_all(class_=re.compile(r"content-detail|article-body|article_content|detail-content|singular-content|fck_detail|knc-content|cms-body|article__body")):
|
| 410 |
+
for img in container.find_all("img"):
|
| 411 |
+
src = img.get("data-src") or img.get("src") or img.get("data-original")
|
| 412 |
+
_add_image(src, alt=img.get("alt", ""), source_tag="content-block-img")
|
| 413 |
+
|
| 414 |
+
# Strategy 12: Generic — any <img> inside <article> or <main>
|
| 415 |
+
for scope in soup.find_all(["article", "main"]):
|
| 416 |
+
for img in scope.find_all("img"):
|
| 417 |
+
src = img.get("data-src") or img.get("src") or img.get("data-original")
|
| 418 |
+
_add_image(src, alt=img.get("alt", ""), source_tag="article-scope")
|
| 419 |
+
|
| 420 |
+
return images
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
# ===== JINA READER =====
|
| 424 |
+
def _reader_url(target_url: str) -> str:
|
| 425 |
+
safe = quote(target_url, safe=":/?#[]@!$&'()*+,;=%")
|
| 426 |
+
return "https://r.jina.ai/http://" + safe
|
| 427 |
+
|
| 428 |
+
def jina_reader_markdown(url: str) -> str:
|
| 429 |
+
jr = _reader_url(url)
|
| 430 |
+
r = requests.get(jr, headers={"Accept": "text/markdown,text/plain,*/*", "X-Return-Format": "markdown", "User-Agent": "Mozilla/5.0"}, timeout=35)
|
| 431 |
+
r.raise_for_status()
|
| 432 |
+
return r.text or ""
|
| 433 |
+
|
| 434 |
+
def _parse_jina_markdown(md: str, url: str):
|
| 435 |
+
lines = [x.rstrip() for x in (md or "").splitlines()]
|
| 436 |
+
title = ""; first_image = ""; all_images = []; content_lines = []; in_content = False
|
| 437 |
+
for ln in lines:
|
| 438 |
+
if ln.startswith("Title:") and not title:
|
| 439 |
+
title = _clean_text(ln.replace("Title:", "", 1)); continue
|
| 440 |
+
if ln.startswith("URL Source:"):
|
| 441 |
+
continue
|
| 442 |
+
if ln.startswith("Markdown Content:"):
|
| 443 |
+
in_content = True; continue
|
| 444 |
+
# Extract ALL images from markdown 
|
| 445 |
+
for mimg in re.finditer(r'!\[[^\]]*\]\((https?://[^)]+)\)', ln):
|
| 446 |
+
img_url = mimg.group(1)
|
| 447 |
+
if img_url not in all_images:
|
| 448 |
+
all_images.append(img_url)
|
| 449 |
+
if not first_image:
|
| 450 |
+
first_image = img_url
|
| 451 |
+
if in_content or (title and not ln.startswith("Title:")):
|
| 452 |
+
if ln.strip():
|
| 453 |
+
content_lines.append(ln)
|
| 454 |
+
text = "\n".join(content_lines)
|
| 455 |
+
text = re.sub(r'!\[[^\]]*\]\([^)]+\)', '', text)
|
| 456 |
+
paras = []
|
| 457 |
+
for part in re.split(r'\n{2,}|\n(?=#{1,3}\s)', text):
|
| 458 |
+
t = _clean_text(re.sub(r'^#{1,6}\s*', '', part))
|
| 459 |
+
if len(t) >= 40:
|
| 460 |
+
paras.append(t)
|
| 461 |
+
if len(paras) >= 35:
|
| 462 |
+
break
|
| 463 |
+
if not title and paras:
|
| 464 |
+
title = paras[0][:90]
|
| 465 |
+
return {"url": url, "title": title or url, "summary": paras[0] if paras else "",
|
| 466 |
+
"text": "\n".join(paras), "image": first_image,
|
| 467 |
+
"images": all_images, "via": "jina"}
|
| 468 |
+
|
| 469 |
+
|
| 470 |
+
# ===== WEB SCRAPE (with full image extraction) =====
|
| 471 |
+
def _best_content_block(soup):
|
| 472 |
+
best, best_score = None, 0
|
| 473 |
+
for el in soup.find_all(["article", "main", "section", "div"]):
|
| 474 |
+
ps = el.find_all("p")
|
| 475 |
+
txt = " ".join(p.get_text(" ", strip=True) for p in ps)
|
| 476 |
+
score = len(ps) * 100 + len(txt)
|
| 477 |
+
cls = " ".join(el.get("class", []))
|
| 478 |
+
if any(k in cls.lower() for k in ["content", "article", "detail", "body", "post", "entry"]):
|
| 479 |
+
score += 800
|
| 480 |
+
if score > best_score:
|
| 481 |
+
best, best_score = el, score
|
| 482 |
+
return best
|
| 483 |
+
|
| 484 |
+
def scrape_any_url_direct(url: str):
|
| 485 |
+
r = requests.get(url, headers=HEADERS, timeout=18)
|
| 486 |
+
if r.status_code in {401, 403, 406, 409, 429, 451, 503}:
|
| 487 |
+
raise RuntimeError(f"blocked status {r.status_code}")
|
| 488 |
+
r.encoding = "utf-8"
|
| 489 |
+
soup = BeautifulSoup(r.text, "lxml")
|
| 490 |
+
for tag in soup.find_all(["script", "style", "nav", "footer", "aside", "form", "noscript"]):
|
| 491 |
+
tag.decompose()
|
| 492 |
+
|
| 493 |
+
# Title
|
| 494 |
+
title = soup.find("h1").get_text(" ", strip=True) if soup.find("h1") else ""
|
| 495 |
+
if not title:
|
| 496 |
+
ogt = soup.find("meta", property="og:title") or soup.find("meta", attrs={"name": "title"})
|
| 497 |
+
title = ogt.get("content", "") if ogt else (soup.title.get_text(strip=True) if soup.title else "")
|
| 498 |
+
|
| 499 |
+
# Summary
|
| 500 |
+
desc_tag = soup.find("meta", property="og:description") or soup.find("meta", attrs={"name": "description"})
|
| 501 |
+
summary = desc_tag.get("content", "") if desc_tag else ""
|
| 502 |
+
|
| 503 |
+
# Featured image (og:image)
|
| 504 |
+
img_tag = soup.find("meta", property="og:image") or soup.find("meta", attrs={"name": "twitter:image"})
|
| 505 |
+
image = img_tag.get("content", "") if img_tag else ""
|
| 506 |
+
if image and image.startswith("//"):
|
| 507 |
+
image = "https:" + image
|
| 508 |
+
|
| 509 |
+
# Extract ALL images from the article
|
| 510 |
+
all_images = _extract_all_images(soup, url)
|
| 511 |
+
image_urls = [img["url"] for img in all_images]
|
| 512 |
+
|
| 513 |
+
# Ensure featured image is first
|
| 514 |
+
if image and image not in image_urls:
|
| 515 |
+
image_urls.insert(0, image)
|
| 516 |
+
elif image in image_urls:
|
| 517 |
+
image_urls.remove(image)
|
| 518 |
+
image_urls.insert(0, image)
|
| 519 |
+
|
| 520 |
+
# Content paragraphs
|
| 521 |
+
block = _best_content_block(soup) or soup
|
| 522 |
+
paras, seen_p = [], set()
|
| 523 |
+
for p in block.find_all("p"):
|
| 524 |
+
t = _clean_text(p.get_text(" ", strip=True))
|
| 525 |
+
if len(t) >= 40 and t not in seen_p:
|
| 526 |
+
seen_p.add(t)
|
| 527 |
+
paras.append(t)
|
| 528 |
+
if len(paras) >= 35:
|
| 529 |
+
break
|
| 530 |
+
|
| 531 |
+
if not title and paras:
|
| 532 |
+
title = paras[0][:90]
|
| 533 |
+
|
| 534 |
+
return {
|
| 535 |
+
"url": url, "title": title or url, "summary": paras[0] if paras else "",
|
| 536 |
+
"text": "\n".join(paras), "image": image_urls[0] if image_urls else "",
|
| 537 |
+
"images": image_urls, "via": _domain(url)
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
def scrape_any_url(url: str):
|
| 541 |
+
"""Try direct scrape first, fall back to Jina Reader.
|
| 542 |
+
|
| 543 |
+
KEY FIX: When falling back to Jina, MERGE images from both sources
|
| 544 |
+
instead of overwriting. Direct scrape often finds more inline images.
|
| 545 |
+
"""
|
| 546 |
+
data = scrape_any_url_direct(url)
|
| 547 |
+
raw_text = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
|
| 548 |
+
direct_images = data.get("images", [])
|
| 549 |
+
|
| 550 |
+
if len(raw_text) >= 120:
|
| 551 |
+
# Direct scrape got enough content — but still try Jina for more images
|
| 552 |
+
# Only use Jina if direct scrape found very few images
|
| 553 |
+
if len(direct_images) < 3:
|
| 554 |
+
try:
|
| 555 |
+
md = jina_reader_markdown(url)
|
| 556 |
+
if md:
|
| 557 |
+
jr = _parse_jina_markdown(md, url)
|
| 558 |
+
if jr.get("images"):
|
| 559 |
+
# MERGE: add Jina images that aren't already in direct scrape
|
| 560 |
+
existing = set(direct_images)
|
| 561 |
+
for img_url in jr["images"]:
|
| 562 |
+
if img_url not in existing:
|
| 563 |
+
direct_images.append(img_url)
|
| 564 |
+
existing.add(img_url)
|
| 565 |
+
data["images"] = direct_images
|
| 566 |
+
except Exception:
|
| 567 |
+
pass
|
| 568 |
+
return data
|
| 569 |
+
|
| 570 |
+
# Direct scrape didn't get enough content — fall back to Jina
|
| 571 |
try:
|
| 572 |
+
md = jina_reader_markdown(url)
|
| 573 |
+
if md:
|
| 574 |
+
jr = _parse_jina_markdown(md, url)
|
| 575 |
+
if jr.get("text"):
|
| 576 |
+
# Keep direct scrape's title/images if Jina's are worse
|
| 577 |
+
if data.get("title") and data["title"] != url:
|
| 578 |
+
jr["title"] = data["title"]
|
| 579 |
+
# MERGE images: direct scrape images + Jina images
|
| 580 |
+
merged_images = list(direct_images) # start with direct scrape images
|
| 581 |
+
existing = set(merged_images)
|
| 582 |
+
for img_url in jr.get("images", []):
|
| 583 |
+
if img_url not in existing:
|
| 584 |
+
merged_images.append(img_url)
|
| 585 |
+
existing.add(img_url)
|
| 586 |
+
jr["images"] = merged_images
|
| 587 |
+
# Use direct scrape's image as first if available
|
| 588 |
+
if data.get("image"):
|
| 589 |
+
jr["image"] = data["image"]
|
| 590 |
+
jr["via"] = data.get("via", _domain(url)) + " + jina"
|
| 591 |
+
return jr
|
| 592 |
except Exception:
|
| 593 |
+
pass
|
| 594 |
+
return data
|
| 595 |
+
|
| 596 |
+
|
| 597 |
+
# ===== POLLINATIONS IMAGE =====
|
| 598 |
+
def pollinations_image_url(topic: str) -> str:
|
| 599 |
+
prompt = "editorial illustration, Vietnamese news, " + topic
|
| 600 |
+
return "https://image.pollinations.ai/prompt/" + quote(prompt, safe="") + "?width=1024&height=576&nologo=true"
|
| 601 |
+
|
| 602 |
+
|
| 603 |
+
# ===== QWEN AI (strict, concise) =====
|
| 604 |
+
async def qwen_generate(prompt: str, image_url: Optional[str] = None, max_tokens: int = 700, image_urls: Optional[List[str]] = None):
|
| 605 |
+
global LAST_QWEN_ERROR, HF_TOKEN
|
| 606 |
+
HF_TOKEN = _hf_token()
|
| 607 |
+
if not HF_TOKEN:
|
| 608 |
+
LAST_QWEN_ERROR = "Không tìm thấy token"
|
| 609 |
+
return None
|
| 610 |
+
if not AsyncInferenceClient:
|
| 611 |
+
LAST_QWEN_ERROR = "Thiếu huggingface_hub"
|
| 612 |
+
return None
|
| 613 |
+
errors = []; models = []
|
| 614 |
+
for m in [QWEN_VL_MODEL, "Qwen/Qwen2.5-VL-7B-Instruct", "Qwen/Qwen2.5-VL-3B-Instruct"]:
|
| 615 |
+
if m and m not in models:
|
| 616 |
+
models.append(m)
|
| 617 |
+
for model in models:
|
| 618 |
+
try:
|
| 619 |
+
client = AsyncInferenceClient(provider="auto", api_key=HF_TOKEN, timeout=90)
|
| 620 |
+
content = []
|
| 621 |
+
# Collect all images: image_urls list takes priority, fall back to single image_url
|
| 622 |
+
all_img_urls = []
|
| 623 |
+
if image_urls:
|
| 624 |
+
all_img_urls = image_urls[:6] # max 6 images to avoid context overflow
|
| 625 |
+
elif image_url:
|
| 626 |
+
all_img_urls = [image_url]
|
| 627 |
+
for img_u in all_img_urls:
|
| 628 |
+
if img_u and img_u.startswith("http"):
|
| 629 |
+
content.append({"type": "image_url", "image_url": {"url": img_u}})
|
| 630 |
+
content.append({"type": "text", "text": prompt})
|
| 631 |
+
messages = [
|
| 632 |
+
{"role": "system", "content": (
|
| 633 |
+
"Bạn là biên tập viên báo điện tử tiếng Việt. "
|
| 634 |
+
"NHIỆM VỤ: Chỉ TÓM TẮT nội dung, KHÔNG viết lại bài đầy đủ. "
|
| 635 |
+
"QUY TẮC CỨNG: "
|
| 636 |
+
"(1) KHÔNG lặp lại bất kỳ nội dung nào — mỗi ý chỉ xuất hiện ĐÚNG 1 LẦN. "
|
| 637 |
+
"(2) Nếu 2 câu diễn đạt cùng 1 ý → bỏ cây thứ 2. "
|
| 638 |
+
"(3) KHÔNG dùng Markdown (##, **, ---, *). "
|
| 639 |
+
"(4) KHÔNG viết 'Dưới đây là', 'Tôi sẽ', 'Theo yêu cầu', 'Nhiệm vụ', 'Vai trò', 'Đây là bài tóm tắt'. "
|
| 640 |
+
"(5) KHÔNG bịa thông tin ngoài nguồn. "
|
| 641 |
+
"(6) Chỉ viết ĐOẠN VĂN THUẦN, không bullet points. "
|
| 642 |
+
"(7) Tối đa 200 từ. Ngắn gọn, súc tích. "
|
| 643 |
+
"(8) KHÔNG sao chép nguyên văn từ bài gốc. Viết lại bằng cách diễn đạt khác hoàn toàn."
|
| 644 |
+
)},
|
| 645 |
+
{"role": "user", "content": content}
|
| 646 |
+
]
|
| 647 |
+
resp = await client.chat_completion(model=model, messages=messages, max_tokens=max_tokens, temperature=0.3, top_p=0.8)
|
| 648 |
+
txt = (resp.choices[0].message.content or "").strip()
|
| 649 |
+
if txt:
|
| 650 |
+
LAST_QWEN_ERROR = ""
|
| 651 |
+
return txt
|
| 652 |
+
except Exception as e:
|
| 653 |
+
errors.append(f"{model}: {type(e).__name__}: {str(e)[:220]}")
|
| 654 |
+
LAST_QWEN_ERROR = " | ".join(errors) or "Qwen không trả nội dung."
|
| 655 |
+
print("[qwen errors]", LAST_QWEN_ERROR)
|
| 656 |
+
return None
|
| 657 |
+
|
| 658 |
+
|
| 659 |
+
# ===== TTS GENERATION =====
|
| 660 |
+
async def _generate_tts_edge(text: str, voice_id: str, speed: float, out_path: str):
|
| 661 |
+
if edge_tts is None:
|
| 662 |
+
raise RuntimeError("edge-tts chưa cài đặt")
|
| 663 |
+
voice = TTS_VOICES.get(voice_id, TTS_VOICES[TTS_DEFAULT_VOICE])["id"]
|
| 664 |
+
pct = int((speed - 1.0) * 100)
|
| 665 |
+
rate = f"+{pct}%" if pct >= 0 else f"{pct}%"
|
| 666 |
+
communicate = edge_tts.Communicate(text, voice, rate=rate)
|
| 667 |
+
await communicate.save(out_path)
|
| 668 |
+
|
| 669 |
+
def _generate_tts_gtts(text: str, out_path: str):
|
| 670 |
+
if gTTS is None:
|
| 671 |
+
raise RuntimeError("gTTS chưa cài đặt")
|
| 672 |
+
gTTS(text, lang="vi").save(out_path)
|
| 673 |
|
| 674 |
|
| 675 |
+
# ===== SHORT VIDEO GENERATION =====
|
| 676 |
+
def _download_image(url, fallback_topic, out_path):
|
| 677 |
if url:
|
| 678 |
try:
|
| 679 |
r = requests.get(url, headers=HEADERS, timeout=15)
|
| 680 |
+
if r.status_code == 200 and len(r.content) > 1000:
|
|
|
|
| 681 |
with open(out_path, "wb") as f:
|
| 682 |
f.write(r.content)
|
| 683 |
return out_path
|
| 684 |
except Exception:
|
| 685 |
pass
|
| 686 |
+
gen = pollinations_image_url(fallback_topic)
|
| 687 |
try:
|
| 688 |
+
r = requests.get(gen, headers=HEADERS, timeout=25)
|
| 689 |
+
if r.status_code == 200 and len(r.content) > 1000:
|
| 690 |
+
with open(out_path, "wb") as f:
|
| 691 |
+
f.write(r.content)
|
| 692 |
+
return out_path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 693 |
except Exception:
|
| 694 |
+
pass
|
| 695 |
+
if Image:
|
| 696 |
+
Image.new("RGB", (1080, 860), (30, 55, 42)).save(out_path)
|
| 697 |
return out_path
|
| 698 |
+
raise RuntimeError("Không tạo được ảnh")
|
| 699 |
|
| 700 |
+
def _make_short_frame(post, img_path, out_path):
|
| 701 |
+
if Image is None:
|
| 702 |
+
raise RuntimeError("Pillow chưa sẵn sàng")
|
| 703 |
+
W, H = 1080, 1920
|
| 704 |
+
bg = Image.new("RGB", (W, H), (14, 14, 14))
|
| 705 |
+
try:
|
| 706 |
+
im = Image.open(img_path).convert("RGB")
|
| 707 |
+
target = (1080, 860)
|
| 708 |
+
im_ratio = im.width / im.height
|
| 709 |
+
target_ratio = target[0] / target[1]
|
| 710 |
+
if im_ratio > target_ratio:
|
| 711 |
+
new_h = target[1]; new_w = int(new_h * im_ratio)
|
| 712 |
+
else:
|
| 713 |
+
new_w = target[0]; new_h = int(new_w / im_ratio)
|
| 714 |
+
im = im.resize((new_w, new_h))
|
| 715 |
+
left = (new_w - target[0]) // 2; top = (new_h - target[1]) // 2
|
| 716 |
+
im = im.crop((left, top, left + target[0], top + target[1]))
|
| 717 |
+
bg.paste(im, (0, 0))
|
| 718 |
+
except Exception:
|
| 719 |
+
pass
|
| 720 |
+
draw = ImageDraw.Draw(bg)
|
| 721 |
+
try:
|
| 722 |
+
font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 52)
|
| 723 |
+
font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 40)
|
| 724 |
+
font_label = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 32)
|
| 725 |
+
except Exception:
|
| 726 |
+
font_title = font_body = font_label = None
|
| 727 |
+
draw.rectangle((0, 780, W, H), fill=(14, 14, 14))
|
| 728 |
+
draw.text((54, 830), "VNEWS · Tường AI", fill=(92, 184, 122), font=font_label)
|
| 729 |
+
title = post.get("title", "")
|
| 730 |
+
words = title.split(); lines_t = []; cur = ""
|
| 731 |
+
for w in words:
|
| 732 |
+
if len(cur) + len(w) + 1 <= 24:
|
| 733 |
+
cur = (cur + " " + w).strip()
|
| 734 |
+
else:
|
| 735 |
+
if cur: lines_t.append(cur)
|
| 736 |
+
cur = w
|
| 737 |
+
if cur: lines_t.append(cur)
|
| 738 |
+
draw.multiline_text((54, 900), "\n".join(lines_t[:3]), fill=(255, 255, 255), font=font_title, spacing=10)
|
| 739 |
+
body_text = post.get("text", "")
|
| 740 |
+
words_b = body_text.split(); lines_b = []; cur_b = ""
|
| 741 |
+
for w in words_b:
|
| 742 |
+
if len(cur_b) + len(w) + 1 <= 34:
|
| 743 |
+
cur_b = (cur_b + " " + w).strip()
|
| 744 |
+
else:
|
| 745 |
+
if cur_b: lines_b.append(cur_b)
|
| 746 |
+
cur_b = w
|
| 747 |
+
if len(lines_b) >= 10:
|
| 748 |
+
break
|
| 749 |
+
if cur_b and len(lines_b) < 10: lines_b.append(cur_b)
|
| 750 |
+
draw.multiline_text((54, 1120), "\n".join(lines_b), fill=(220, 220, 220), font=font_body, spacing=12)
|
| 751 |
+
bg.save(out_path, quality=92)
|
| 752 |
|
| 753 |
+
def _short_script(post, max_chars=700):
|
| 754 |
+
txt = _clean_text(post.get("text", ""))
|
| 755 |
+
if len(txt) > max_chars:
|
| 756 |
+
# Trim at complete sentence
|
| 757 |
+
trimmed = txt[:max_chars]
|
| 758 |
+
last_end = -1
|
| 759 |
+
for m in re.finditer(r'[.!?](?=\s|$)', trimmed):
|
| 760 |
+
last_end = m.end()
|
| 761 |
+
if last_end > 0:
|
| 762 |
+
txt = trimmed[:last_end]
|
| 763 |
+
else:
|
| 764 |
+
txt = trimmed.rsplit(" ", 1)[0] + "."
|
| 765 |
+
title = _clean_text(post.get("title", ""))
|
| 766 |
+
return f"{title}. {txt}"
|
| 767 |
+
|
| 768 |
+
async def _generate_short_video(post, post_id: str, voice_id: str = None, speed: float = None) -> str:
|
| 769 |
try:
|
| 770 |
+
os.makedirs(SHORTS_DIR, exist_ok=True)
|
| 771 |
+
out_mp4 = os.path.join(SHORTS_DIR, _safe_name(post_id) + ".mp4")
|
| 772 |
+
if os.path.exists(out_mp4):
|
| 773 |
+
return "/api/ai/short-file/" + post_id
|
| 774 |
+
|
| 775 |
+
work = os.path.join(SHORTS_DIR, _safe_name(post_id))
|
| 776 |
+
os.makedirs(work, exist_ok=True)
|
| 777 |
+
img_path = os.path.join(work, "image.jpg")
|
| 778 |
+
frame_path = os.path.join(work, "frame.jpg")
|
| 779 |
+
audio_path = os.path.join(work, "voice.mp3")
|
| 780 |
+
|
| 781 |
+
_download_image(post.get("img"), post.get("title", "AI news"), img_path)
|
| 782 |
+
_make_short_frame(post, img_path, frame_path)
|
| 783 |
+
script = _short_script(post)
|
| 784 |
+
|
| 785 |
+
if voice_id is None:
|
| 786 |
+
voice_id = _detect_voice_for_topic(post.get("title", ""), post.get("text", ""))
|
| 787 |
+
if speed is None:
|
| 788 |
+
speed = TTS_DEFAULT_SPEED
|
| 789 |
+
|
| 790 |
+
try:
|
| 791 |
+
await _generate_tts_edge(script, voice_id, speed, audio_path)
|
| 792 |
+
except Exception as e:
|
| 793 |
+
print(f"[TTS edge-tts error] {e}, falling back to gTTS")
|
| 794 |
+
if gTTS:
|
| 795 |
+
_generate_tts_gtts(script, audio_path)
|
| 796 |
+
else:
|
| 797 |
+
return ""
|
| 798 |
+
|
| 799 |
+
cmd = ["ffmpeg", "-y", "-loop", "1", "-i", frame_path, "-i", audio_path,
|
| 800 |
+
"-shortest", "-c:v", "libx264", "-tune", "stillimage", "-pix_fmt", "yuv420p",
|
| 801 |
+
"-c:a", "aac", "-b:a", "128k", "-vf", "scale=1080:1920", out_mp4]
|
| 802 |
+
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
|
| 803 |
+
return "/api/ai/short-file/" + post_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 804 |
except Exception as e:
|
| 805 |
+
print(f"[short video error] {e}")
|
| 806 |
+
return ""
|
| 807 |
|
| 808 |
|
| 809 |
+
# ===== MAKE POST =====
|
| 810 |
+
def make_post(title, text, image, source_url, kind, sources=None, images=None):
|
|
|
|
|
|
|
| 811 |
return {
|
| 812 |
+
"id": str(int(time.time() * 1000)) + str(random.randint(100, 999)),
|
| 813 |
+
"title": title, "text": text, "img": image, "url": source_url,
|
| 814 |
+
"kind": kind, "sources": sources or [], "video": "",
|
| 815 |
+
"images": images or [], "ts": int(time.time())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 816 |
}
|
| 817 |
+
|
| 818 |
+
|
| 819 |
+
# ===== SHARED PROMPT BUILDER — STRICT REWRITE =====
|
| 820 |
+
def _build_rewrite_prompt(title: str, raw: str, images: List[str] = None) -> str:
|
| 821 |
+
image_info = ""
|
| 822 |
+
if images:
|
| 823 |
+
num = len(images)
|
| 824 |
+
if num == 1:
|
| 825 |
+
image_info = "\n\nBài viết có 1 ảnh minh họa. Hãy tham khảo ảnh để hiểu ngữ cảnh (nếu phù hợp)."
|
| 826 |
+
else:
|
| 827 |
+
image_info = f"\n\nBài viết có {num} ảnh minh họa. Hãy tham khảo tất cả ảnh để hiểu ngữ cảnh và bổ sung thông tin cho bài viết (nếu phù hợp)."
|
| 828 |
+
|
| 829 |
+
return f"""Đọc bài viết dưới đây và viết một bài TÓM TẮT NGẮN trên Tường AI.
|
| 830 |
+
|
| 831 |
+
⚠️ QUY TẮC CỨNG — VI PHẠM = BỎ QUA:
|
| 832 |
+
1. KHÔNG sao chép nguyên văn bất kỳ câu nào từ bài gốc. Phải diễn đạt LẠI hoàn toàn bằng ngôn ngữ riêng.
|
| 833 |
+
2. KHÔNG lặp lại bất kỳ thông tin nào. Mỗi ý chỉ xuất hiện ĐÚNG 1 LẦN.
|
| 834 |
+
3. Nếu 2 câu diễn đạt cùng 1 ý → chỉ giữ 1 câu, bỏ cây còn lại.
|
| 835 |
+
4. KHÔNG dùng Markdown (##, **, ---, *).
|
| 836 |
+
5. KHÔNG viết "Dưới đây là", "Tôi sẽ", "Theo yêu cầu", "Nhiệm vụ", "Vai trò", "Đây là bài tóm tắt", "Sau đây là", "Dựa trên".
|
| 837 |
+
6. Viết thành ĐOẠN VĂN THUẦN, mạch lạc, dễ đọc. Không dùng bullet points.
|
| 838 |
+
7. Giữ sự thật, KHÔNG bịa thông tin.
|
| 839 |
+
8. Tối đa 200 từ. Ngắn gọn, đủ ý.
|
| 840 |
+
9. Mỗi câu phải trọn vẹn, không bị ngắt giữa chừng.{image_info}
|
| 841 |
+
|
| 842 |
+
Tiêu đề gốc: {title}
|
| 843 |
+
|
| 844 |
+
Nội dung gốc:
|
| 845 |
+
{raw[:14000]}
|
| 846 |
+
|
| 847 |
+
Chỉ viết phần tóm tắt, không thêm gì khác:"""
|
| 848 |
+
|
| 849 |
+
|
| 850 |
+
def _build_topic_prompt(topic: str, ctx: str) -> str:
|
| 851 |
+
return f"""Viết bài TÓM TẮT NGẮN GỌN về chủ đề: "{topic}".
|
| 852 |
+
|
| 853 |
+
⚠️ QUY TẮC CỨNG:
|
| 854 |
+
1. KHÔNG sao chép nguyên văn từ nguồn. Phải diễn đạt LẠI hoàn toàn.
|
| 855 |
+
2. KHÔNG lặp lại bất kỳ nội dung nào. Mỗi thông tin chỉ xuất hiện ĐÚNG 1 LẦN.
|
| 856 |
+
3. Nếu 2 câu nói cùng 1 ý → chỉ giữ 1 câu.
|
| 857 |
+
4. KHÔNG dùng Markdown (##, **, ---, *).
|
| 858 |
+
5. KHÔNG viết "Dưới đây là", "Tôi sẽ", "Theo yêu cầu", "Nhiệm vụ", "Vai trò".
|
| 859 |
+
6. Viết thành ĐOẠN VĂN THUẦN, mạch lạc. Không dùng bullet points.
|
| 860 |
+
7. Giữ sự thật, KHÔNG bịa.
|
| 861 |
+
8. Tối đa 200 từ. Ngắn gọn, đủ ý.
|
| 862 |
+
9. Mỗi câu phải trọn vẹn.
|
| 863 |
+
|
| 864 |
+
Nguồn thực tế:
|
| 865 |
+
{ctx[:12000]}
|
| 866 |
+
|
| 867 |
+
Chỉ viết phần tóm tắt, không thêm gì khác:"""
|
| 868 |
+
|
| 869 |
+
|
| 870 |
+
# ===== WRITE ENDPOINTS =====
|
| 871 |
+
@app.post("/api/rewrite_share")
|
| 872 |
+
async def api_rewrite_share(request: Request):
|
| 873 |
+
body = await request.json()
|
| 874 |
+
url = _clean_text(body.get("url", ""))
|
| 875 |
+
if not url.startswith("http"):
|
| 876 |
+
return JSONResponse({"error": "missing url"}, status_code=400)
|
| 877 |
+
try:
|
| 878 |
+
data = scrape_any_url(url)
|
| 879 |
+
except Exception as e:
|
| 880 |
+
return JSONResponse({"error": "Không đọc được bài viết: " + str(e)[:180]}, status_code=422)
|
| 881 |
+
raw = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
|
| 882 |
+
if len(raw) < 60:
|
| 883 |
+
return JSONResponse({"error": "Bài viết quá ngắn để tóm tắt"}, status_code=422)
|
| 884 |
+
|
| 885 |
+
images = data.get("images", [])
|
| 886 |
+
prompt = _build_rewrite_prompt(data.get("title", ""), raw, images)
|
| 887 |
+
text = await qwen_generate(prompt, image_urls=images if images else None, max_tokens=700)
|
| 888 |
+
if not text:
|
| 889 |
+
return JSONResponse({"error": "Qwen2.5-VL chưa sẵn sàng: " + LAST_QWEN_ERROR}, status_code=503)
|
| 890 |
+
text = _clean_ai_output(text)
|
| 891 |
+
post = make_post(data.get("title") or "Bài viết", text,
|
| 892 |
+
images[0] if images else data.get("image", ""),
|
| 893 |
+
url, "rewrite", images=images)
|
| 894 |
+
|
| 895 |
+
# Auto-generate short video
|
| 896 |
+
video_url = await _generate_short_video(post, post["id"])
|
| 897 |
+
if video_url:
|
| 898 |
+
post["video"] = video_url
|
| 899 |
+
|
| 900 |
+
posts = _load_wall()
|
| 901 |
+
posts.insert(0, post)
|
| 902 |
+
_save_wall(posts)
|
| 903 |
+
return JSONResponse({"post": post})
|
| 904 |
+
|
| 905 |
+
|
| 906 |
+
@app.post("/api/url_wall")
|
| 907 |
+
async def api_url_wall(request: Request):
|
| 908 |
+
body = await request.json()
|
| 909 |
+
url = _clean_text(body.get("url", ""))
|
| 910 |
+
if not url.startswith("http"):
|
| 911 |
+
return JSONResponse({"error": "missing url"}, status_code=400)
|
| 912 |
+
try:
|
| 913 |
+
data = scrape_any_url(url)
|
| 914 |
+
except Exception as e:
|
| 915 |
+
return JSONResponse({"error": "Không scrape được URL: " + str(e)[:180]}, status_code=422)
|
| 916 |
+
raw = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
|
| 917 |
+
if len(raw) < 60:
|
| 918 |
+
return JSONResponse({"error": "URL không có đủ nội dung"}, status_code=422)
|
| 919 |
+
|
| 920 |
+
images = data.get("images", [])
|
| 921 |
+
prompt = _build_rewrite_prompt(data.get("title", ""), raw, images)
|
| 922 |
+
text = await qwen_generate(prompt, image_urls=images if images else None, max_tokens=700)
|
| 923 |
+
if not text:
|
| 924 |
+
return JSONResponse({"error": "Qwen2.5-VL chưa sẵn sàng: " + LAST_QWEN_ERROR}, status_code=503)
|
| 925 |
+
text = _clean_ai_output(text)
|
| 926 |
+
post = make_post(data.get("title") or "Bài viết", text,
|
| 927 |
+
images[0] if images else data.get("image", ""),
|
| 928 |
+
url, "url", images=images)
|
| 929 |
+
|
| 930 |
+
video_url = await _generate_short_video(post, post["id"])
|
| 931 |
+
if video_url:
|
| 932 |
+
post["video"] = video_url
|
| 933 |
+
|
| 934 |
+
posts = _load_wall()
|
| 935 |
+
posts.insert(0, post)
|
| 936 |
+
_save_wall(posts)
|
| 937 |
+
return JSONResponse({"post": post})
|
| 938 |
+
|
| 939 |
+
|
| 940 |
+
@app.post("/api/topic_post")
|
| 941 |
+
async def api_topic_post(request: Request):
|
| 942 |
+
body = await request.json()
|
| 943 |
+
topic = _clean_text(body.get("topic", ""))
|
| 944 |
+
if not topic:
|
| 945 |
+
return JSONResponse({"error": "missing topic"}, status_code=400)
|
| 946 |
+
|
| 947 |
+
ctx = _web_context(topic)
|
| 948 |
+
if not ctx:
|
| 949 |
+
return JSONResponse({"error": "Không lấy được dữ liệu cho chủ đề này"}, status_code=422)
|
| 950 |
+
|
| 951 |
+
image = pollinations_image_url(topic)
|
| 952 |
+
prompt = _build_topic_prompt(topic, ctx)
|
| 953 |
+
text = await qwen_generate(prompt, image_url=image, max_tokens=700)
|
| 954 |
+
if not text:
|
| 955 |
+
return JSONResponse({"error": "Qwen2.5-VL chưa sẵn sàng: " + LAST_QWEN_ERROR}, status_code=503)
|
| 956 |
+
text = _clean_ai_output(text)
|
| 957 |
+
post = make_post(topic, text, image, "", "topic")
|
| 958 |
+
|
| 959 |
+
video_url = await _generate_short_video(post, post["id"])
|
| 960 |
+
if video_url:
|
| 961 |
+
post["video"] = video_url
|
| 962 |
+
|
| 963 |
+
posts = _load_wall()
|
| 964 |
+
posts.insert(0, post)
|
| 965 |
+
_save_wall(posts)
|
| 966 |
+
return JSONResponse({"post": post})
|
| 967 |
+
|
| 968 |
+
|
| 969 |
+
# ===== WALL ENDPOINTS =====
|
| 970 |
+
@app.get("/api/ai_wall")
|
| 971 |
+
def api_ai_wall():
|
| 972 |
+
return JSONResponse({"posts": _load_wall()[:80]})
|
| 973 |
+
|
| 974 |
+
@app.get("/api/wall")
|
| 975 |
+
def api_wall():
|
| 976 |
+
return JSONResponse({"posts": _load_wall()[:80]})
|
| 977 |
+
|
| 978 |
+
|
| 979 |
+
# ===== SHORT VIDEO ENDPOINT =====
|
| 980 |
+
@app.post("/api/ai/short/{post_id}")
|
| 981 |
+
async def api_ai_short(post_id: str, voice: str = Query(default=None), speed: float = Query(default=None)):
|
| 982 |
+
posts = _load_wall()
|
| 983 |
+
post = next((p for p in posts if str(p.get("id")) == str(post_id)), None)
|
| 984 |
+
if not post:
|
| 985 |
+
return JSONResponse({"error": "post not found"}, status_code=404)
|
| 986 |
+
|
| 987 |
+
os.makedirs(SHORTS_DIR, exist_ok=True)
|
| 988 |
+
out_mp4 = os.path.join(SHORTS_DIR, _safe_name(post_id) + ".mp4")
|
| 989 |
+
|
| 990 |
+
if os.path.exists(out_mp4) and voice is None and speed is None:
|
| 991 |
+
video_url = "/api/ai/short-file/" + post_id
|
| 992 |
+
for i, p in enumerate(posts):
|
| 993 |
+
if str(p.get("id")) == str(post_id):
|
| 994 |
+
posts[i]["video"] = video_url
|
| 995 |
+
break
|
| 996 |
+
_save_wall(posts)
|
| 997 |
+
return JSONResponse({"video": video_url})
|
| 998 |
+
|
| 999 |
+
if voice is not None and voice not in TTS_VOICES:
|
| 1000 |
+
return JSONResponse({"error": f"voice không hợp lệ. Chọn: {list(TTS_VOICES.keys())}"}, status_code=400)
|
| 1001 |
+
|
| 1002 |
+
video_url = await _generate_short_video(post, post_id, voice_id=voice, speed=speed)
|
| 1003 |
+
if video_url:
|
| 1004 |
+
for i, p in enumerate(posts):
|
| 1005 |
+
if str(p.get("id")) == str(post_id):
|
| 1006 |
+
posts[i]["video"] = video_url
|
| 1007 |
+
break
|
| 1008 |
+
_save_wall(posts)
|
| 1009 |
+
return JSONResponse({"video": video_url})
|
| 1010 |
+
return JSONResponse({"error": "Không tạo được shorts"}, status_code=500)
|
| 1011 |
+
|
| 1012 |
+
|
| 1013 |
+
@app.get("/api/ai/short-file/{post_id}")
|
| 1014 |
+
def api_ai_short_file(post_id: str):
|
| 1015 |
+
path = os.path.join(SHORTS_DIR, _safe_name(post_id) + ".mp4")
|
| 1016 |
+
if not os.path.exists(path):
|
| 1017 |
+
return JSONResponse({"error": "not found"}, status_code=404)
|
| 1018 |
+
return FileResponse(path, media_type="video/mp4", filename=f"vnews-ai-{post_id}.mp4")
|
| 1019 |
+
|
| 1020 |
+
|
| 1021 |
+
@app.get("/api/ai/status")
|
| 1022 |
+
def api_ai_status():
|
| 1023 |
+
return JSONResponse({
|
| 1024 |
+
"has_token": bool(_hf_token()),
|
| 1025 |
+
"client_imported": AsyncInferenceClient is not None,
|
| 1026 |
+
"model": QWEN_VL_MODEL,
|
| 1027 |
+
"last_error": LAST_QWEN_ERROR,
|
| 1028 |
+
"tts_ready": gTTS is not None or edge_tts is not None,
|
| 1029 |
+
"tts_engine": "edge-tts" if edge_tts else ("gtts" if gTTS else "none"),
|
| 1030 |
+
"tts_voices": {k: v["flag"] + " " + v["name"] for k, v in TTS_VOICES.items()},
|
| 1031 |
+
"tts_voice_count": len(TTS_VOICES),
|
| 1032 |
+
"tts_default_speed": TTS_DEFAULT_SPEED,
|
| 1033 |
+
})
|
| 1034 |
+
|
| 1035 |
+
|
| 1036 |
+
@app.get("/api/ai/voices")
|
| 1037 |
+
def api_ai_voices():
|
| 1038 |
+
voices_out = {}
|
| 1039 |
+
for k, v in TTS_VOICES.items():
|
| 1040 |
+
voices_out[k] = {
|
| 1041 |
+
"name": v["name"],
|
| 1042 |
+
"gender": v["gender"],
|
| 1043 |
+
"country": v["country"],
|
| 1044 |
+
"lang": v["lang"],
|
| 1045 |
+
"flag": v["flag"],
|
| 1046 |
+
"label": f"{v['flag']} {v['name']} ({v['gender']})",
|
| 1047 |
+
}
|
| 1048 |
+
return JSONResponse({
|
| 1049 |
+
"voices": voices_out,
|
| 1050 |
+
"default_voice": TTS_DEFAULT_VOICE,
|
| 1051 |
+
"default_speed": TTS_DEFAULT_SPEED,
|
| 1052 |
+
"topic_voice_map": TOPIC_VOICE_MAP,
|
| 1053 |
+
})
|
ai_fix2.py
CHANGED
|
@@ -321,7 +321,7 @@ async def ai_short_full(post_id: str, request: Request):
|
|
| 321 |
subprocess.run(['ffmpeg','-y','-i',audio,'-filter:a',f'atempo={speed}','-vn',audio_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=220)
|
| 322 |
duration = 45.0
|
| 323 |
try:
|
| 324 |
-
pr = subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:
|
| 325 |
duration = float((pr.stdout or b'45').decode().strip() or 45)
|
| 326 |
except Exception:
|
| 327 |
pass
|
|
|
|
| 321 |
subprocess.run(['ffmpeg','-y','-i',audio,'-filter:a',f'atempo={speed}','-vn',audio_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=220)
|
| 322 |
duration = 45.0
|
| 323 |
try:
|
| 324 |
+
pr = subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:nokey=1',audio_fast], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
|
| 325 |
duration = float((pr.stdout or b'45').decode().strip() or 45)
|
| 326 |
except Exception:
|
| 327 |
pass
|
ai_patch.py
CHANGED
|
@@ -6,7 +6,6 @@ import json
|
|
| 6 |
import html as html_lib
|
| 7 |
import subprocess
|
| 8 |
import requests
|
| 9 |
-
import hashlib
|
| 10 |
import ai_ext as base
|
| 11 |
from ai_ext import app
|
| 12 |
from fastapi import Request
|
|
@@ -42,17 +41,17 @@ def _similar(a, b):
|
|
| 42 |
return len(ta & tb) / max(1, min(len(ta), len(tb))) >= 0.72
|
| 43 |
|
| 44 |
|
| 45 |
-
def _dedupe_units(units, max_units=
|
| 46 |
-
"""Deduplicate units - only skip exact matches to ensure all bullet points are read."""
|
| 47 |
out, seen = [], set()
|
| 48 |
for u in units:
|
| 49 |
u = _clean(re.sub(r"^[-•*\d\.\)\s]+", "", u))
|
| 50 |
if len(u) < 18:
|
| 51 |
continue
|
| 52 |
nu = _norm(u)
|
| 53 |
-
# Only skip exact matches, NOT similar content (to avoid skipping valid bullet points)
|
| 54 |
if nu in seen:
|
| 55 |
continue
|
|
|
|
|
|
|
| 56 |
seen.add(nu)
|
| 57 |
out.append(u)
|
| 58 |
if len(out) >= max_units:
|
|
@@ -60,7 +59,7 @@ def _dedupe_units(units, max_units=25):
|
|
| 60 |
return out
|
| 61 |
|
| 62 |
|
| 63 |
-
def _postprocess_ai_text(text, max_units=
|
| 64 |
text = _clean(text)
|
| 65 |
if not text:
|
| 66 |
return text
|
|
@@ -79,9 +78,10 @@ def _postprocess_ai_text(text, max_units=20):
|
|
| 79 |
raw_lines.append(line)
|
| 80 |
units = []
|
| 81 |
for line in raw_lines:
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
|
|
|
| 85 |
units = _dedupe_units(units, max_units=max_units)
|
| 86 |
if not units:
|
| 87 |
return text[:900]
|
|
@@ -194,6 +194,7 @@ def _topic_source_articles(topic, limit=5):
|
|
| 194 |
break
|
| 195 |
except Exception:
|
| 196 |
continue
|
|
|
|
| 197 |
if not out:
|
| 198 |
for s in (sources or _direct_news_rss(topic, 6))[:limit]:
|
| 199 |
title = s.get("title") or topic
|
|
@@ -268,7 +269,7 @@ async def qwen_generate_resilient(prompt: str, image_url=None, max_tokens: int =
|
|
| 268 |
errors.append("missing HF_TOKEN")
|
| 269 |
base.LAST_QWEN_ERROR = " | ".join(errors[-6:]) or "Qwen unavailable; used extractive fallback"
|
| 270 |
print("[qwen resilient fallback]", base.LAST_QWEN_ERROR)
|
| 271 |
-
return _fallback_summary_from_prompt(prompt, max_units=
|
| 272 |
|
| 273 |
|
| 274 |
if not hasattr(base, "_original_qwen_generate"):
|
|
@@ -321,30 +322,12 @@ Yêu cầu bắt buộc:
|
|
| 321 |
|
| 322 |
Nội dung bài:
|
| 323 |
{art['raw'][:14000]}"""
|
| 324 |
-
text = await base.qwen_generate(prompt, image_url=art.get('image') or None, max_tokens=
|
| 325 |
-
text = _postprocess_ai_text(text, max_units=
|
| 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)
|
|
@@ -365,142 +348,14 @@ async def compat_url_wall(request: Request):
|
|
| 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=
|
| 369 |
-
text = _postprocess_ai_text(text, max_units=
|
| 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
|
| 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')
|
|
@@ -517,56 +372,40 @@ async def compat_rewrite_share(request: Request):
|
|
| 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=
|
| 521 |
-
text = _postprocess_ai_text(text, max_units=
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 555 |
return text
|
| 556 |
|
| 557 |
|
| 558 |
def _tts_script_smart(post, emotion):
|
| 559 |
-
raw = base._short_script(post)
|
| 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 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
raw = raw[:3000]
|
| 568 |
cut = max(raw.rfind("."), raw.rfind("!"), raw.rfind("?"))
|
| 569 |
-
if cut >
|
| 570 |
raw = raw[:cut + 1]
|
| 571 |
return raw
|
| 572 |
|
|
@@ -681,7 +520,8 @@ def _make_short_frame_full(post, img_path, out_path):
|
|
| 681 |
|
| 682 |
|
| 683 |
|
| 684 |
-
def _summary_segments_from_post(post, max_segments=
|
|
|
|
| 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()
|
|
@@ -692,7 +532,7 @@ def _summary_segments_from_post(post, max_segments=25):
|
|
| 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)<
|
| 696 |
lines=[]
|
| 697 |
for s in re.split(r'(?<=[\.\!\?])\s+', raw):
|
| 698 |
s=_clean(s)
|
|
@@ -702,12 +542,14 @@ def _summary_segments_from_post(post, max_segments=25):
|
|
| 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)
|
|
@@ -717,6 +559,7 @@ def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='ne
|
|
| 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)
|
|
@@ -736,6 +579,7 @@ def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='ne
|
|
| 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)
|
|
@@ -744,8 +588,7 @@ def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='ne
|
|
| 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 |
-
|
| 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
|
|
@@ -757,24 +600,24 @@ def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='ne
|
|
| 757 |
bg.save(out_path, quality=92)
|
| 758 |
|
| 759 |
|
| 760 |
-
def _estimate_audio_duration(path, fallback=
|
| 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:
|
| 764 |
-
return max(
|
| 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.
|
| 778 |
speed = max(0.85, min(1.35, speed))
|
| 779 |
|
| 780 |
posts = base._load_ai_wall()
|
|
@@ -782,7 +625,7 @@ async def patched_ai_short(post_id: str, request: Request):
|
|
| 782 |
if not post:
|
| 783 |
return JSONResponse({'error': 'post not found'}, status_code=404)
|
| 784 |
|
| 785 |
-
segments = _summary_segments_from_post(post, max_segments=
|
| 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"
|
|
@@ -805,63 +648,11 @@ async def patched_ai_short(post_id: str, request: Request):
|
|
| 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):
|
|
@@ -874,13 +665,14 @@ async def patched_ai_short(post_id: str, request: Request):
|
|
| 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'
|
| 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=
|
|
|
|
| 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')
|
|
@@ -915,3 +707,33 @@ def api_ai_shorts():
|
|
| 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()))]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
import html as html_lib
|
| 7 |
import subprocess
|
| 8 |
import requests
|
|
|
|
| 9 |
import ai_ext as base
|
| 10 |
from ai_ext import app
|
| 11 |
from fastapi import Request
|
|
|
|
| 41 |
return len(ta & tb) / max(1, min(len(ta), len(tb))) >= 0.72
|
| 42 |
|
| 43 |
|
| 44 |
+
def _dedupe_units(units, max_units=7):
|
|
|
|
| 45 |
out, seen = [], set()
|
| 46 |
for u in units:
|
| 47 |
u = _clean(re.sub(r"^[-•*\d\.\)\s]+", "", u))
|
| 48 |
if len(u) < 18:
|
| 49 |
continue
|
| 50 |
nu = _norm(u)
|
|
|
|
| 51 |
if nu in seen:
|
| 52 |
continue
|
| 53 |
+
if any(_similar(u, old) for old in out):
|
| 54 |
+
continue
|
| 55 |
seen.add(nu)
|
| 56 |
out.append(u)
|
| 57 |
if len(out) >= max_units:
|
|
|
|
| 59 |
return out
|
| 60 |
|
| 61 |
|
| 62 |
+
def _postprocess_ai_text(text, max_units=7):
|
| 63 |
text = _clean(text)
|
| 64 |
if not text:
|
| 65 |
return text
|
|
|
|
| 78 |
raw_lines.append(line)
|
| 79 |
units = []
|
| 80 |
for line in raw_lines:
|
| 81 |
+
if len(line) > 260:
|
| 82 |
+
units.extend(re.split(r"(?<=[\.\!\?])\s+(?=[A-ZÀ-Ỹ0-9])", line))
|
| 83 |
+
else:
|
| 84 |
+
units.append(line)
|
| 85 |
units = _dedupe_units(units, max_units=max_units)
|
| 86 |
if not units:
|
| 87 |
return text[:900]
|
|
|
|
| 194 |
break
|
| 195 |
except Exception:
|
| 196 |
continue
|
| 197 |
+
# Fallback to headlines/snippets only if no full body works.
|
| 198 |
if not out:
|
| 199 |
for s in (sources or _direct_news_rss(topic, 6))[:limit]:
|
| 200 |
title = s.get("title") or topic
|
|
|
|
| 269 |
errors.append("missing HF_TOKEN")
|
| 270 |
base.LAST_QWEN_ERROR = " | ".join(errors[-6:]) or "Qwen unavailable; used extractive fallback"
|
| 271 |
print("[qwen resilient fallback]", base.LAST_QWEN_ERROR)
|
| 272 |
+
return _fallback_summary_from_prompt(prompt, max_units=6)
|
| 273 |
|
| 274 |
|
| 275 |
if not hasattr(base, "_original_qwen_generate"):
|
|
|
|
| 322 |
|
| 323 |
Nội dung bài:
|
| 324 |
{art['raw'][:14000]}"""
|
| 325 |
+
text = await base.qwen_generate(prompt, image_url=art.get('image') or None, max_tokens=900)
|
| 326 |
+
text = _postprocess_ai_text(text, max_units=6)
|
| 327 |
src = [art['source']]
|
| 328 |
if 'Nguồn tham khảo:' not in text:
|
| 329 |
text += "\n\n" + _source_line(src)
|
| 330 |
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
new_posts.append(post)
|
| 332 |
posts = new_posts + posts
|
| 333 |
base._save_ai_wall(posts)
|
|
|
|
| 348 |
if len(raw) < 120:
|
| 349 |
return JSONResponse({'error': 'URL không có đủ nội dung để tóm tắt'}, status_code=422)
|
| 350 |
prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
|
| 351 |
+
text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=850)
|
| 352 |
+
text = _postprocess_ai_text(text, max_units=6)
|
| 353 |
src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
|
| 354 |
if 'Nguồn tham khảo:' not in text:
|
| 355 |
text += "\n\n" + _source_line(src)
|
| 356 |
post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'url', sources=src)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
|
| 358 |
+
return JSONResponse({'post': post})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
|
| 360 |
|
| 361 |
@app.post('/api/rewrite_share')
|
|
|
|
| 372 |
if len(raw) < 120:
|
| 373 |
return JSONResponse({'error': 'Bài viết không đủ nội dung để tóm tắt'}, status_code=422)
|
| 374 |
prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
|
| 375 |
+
text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=850)
|
| 376 |
+
text = _postprocess_ai_text(text, max_units=6)
|
| 377 |
src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
|
| 378 |
if 'Nguồn tham khảo:' not in text:
|
| 379 |
text += "\n\n" + _source_line(src)
|
| 380 |
post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'summary', sources=src)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
|
| 382 |
+
return JSONResponse({'post': post})
|
|
|
|
| 383 |
|
| 384 |
|
| 385 |
def _emotion_script(text, emotion):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
text = _clean(text)
|
| 387 |
+
if emotion == 'urgent':
|
| 388 |
+
return 'Tin nhanh. ' + text
|
| 389 |
+
if emotion == 'warm':
|
| 390 |
+
return 'Câu chuyện đáng chú ý. ' + text
|
| 391 |
+
if emotion == 'serious':
|
| 392 |
+
return 'Bản tin nghiêm túc. ' + text
|
| 393 |
+
if emotion == 'energetic':
|
| 394 |
+
return 'Cập nhật nổi bật. ' + text
|
| 395 |
return text
|
| 396 |
|
| 397 |
|
| 398 |
def _tts_script_smart(post, emotion):
|
| 399 |
+
raw = base._short_script(post)
|
| 400 |
raw = re.sub(r"^[•\-\*]\s*", "", raw, flags=re.M)
|
| 401 |
raw = re.sub(r"\s*\n\s*", ". ", raw)
|
| 402 |
raw = re.sub(r"([\.\!\?])\s*", r"\1\n", raw)
|
| 403 |
raw = re.sub(r"\n{2,}", "\n", raw).strip()
|
| 404 |
+
raw = _emotion_script(raw, emotion)
|
| 405 |
+
if len(raw) > 1000:
|
| 406 |
+
raw = raw[:1000]
|
|
|
|
| 407 |
cut = max(raw.rfind("."), raw.rfind("!"), raw.rfind("?"))
|
| 408 |
+
if cut > 350:
|
| 409 |
raw = raw[:cut + 1]
|
| 410 |
return raw
|
| 411 |
|
|
|
|
| 520 |
|
| 521 |
|
| 522 |
|
| 523 |
+
def _summary_segments_from_post(post, max_segments=7):
|
| 524 |
+
"""Only use current post summary. Each bullet/paragraph becomes one short scene."""
|
| 525 |
raw = _clean(post.get('text') or post.get('title') or '')
|
| 526 |
raw = re.sub(r'^Bản tin AI viết lại:\s*', '', raw, flags=re.I)
|
| 527 |
raw = re.sub(r'Nguồn tham khảo:.*$', '', raw, flags=re.I|re.S).strip()
|
|
|
|
| 532 |
low=ln.lower()
|
| 533 |
if low.startswith(('điểm chính','tiêu đề','sapo','nguồn tham khảo')): continue
|
| 534 |
if len(ln)>=18: lines.append(ln)
|
| 535 |
+
if len(lines)<2:
|
| 536 |
lines=[]
|
| 537 |
for s in re.split(r'(?<=[\.\!\?])\s+', raw):
|
| 538 |
s=_clean(s)
|
|
|
|
| 542 |
|
| 543 |
|
| 544 |
def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='neutral'):
|
| 545 |
+
"""Create one 9:16 frame for one summary segment. No subtitle layer."""
|
| 546 |
if Image is None:
|
| 547 |
return _make_short_frame_full(post, img_path, out_path)
|
| 548 |
W,H=1080,1920
|
| 549 |
bg=Image.new('RGB',(W,H),(10,10,10))
|
| 550 |
try:
|
| 551 |
im=Image.open(img_path).convert('RGB')
|
| 552 |
+
# background cover
|
| 553 |
ratio=im.width/max(1,im.height); target=W/H
|
| 554 |
if ratio>target:
|
| 555 |
nh=H; nw=int(nh*ratio)
|
|
|
|
| 559 |
cover=cover.crop((left,top,left+W,top+H))
|
| 560 |
bg.paste(cover,(0,0))
|
| 561 |
bg=Image.blend(bg, Image.new('RGB',(W,H),(0,0,0)), 0.50)
|
| 562 |
+
# top hero image
|
| 563 |
hero_h=720; target=W/hero_h
|
| 564 |
if ratio>target:
|
| 565 |
nh=hero_h; nw=int(nh*ratio)
|
|
|
|
| 579 |
except Exception:
|
| 580 |
font_brand=font_small=font_seg=font_title=None
|
| 581 |
draw.rectangle((0,680,W,H), fill=(12,12,12))
|
| 582 |
+
# progress bars
|
| 583 |
dot_x=48; dot_y=742
|
| 584 |
for i in range(total):
|
| 585 |
fill=(92,184,122) if i==idx else (70,70,70)
|
|
|
|
| 588 |
draw.rounded_rectangle((48,834,260,880), radius=20, fill=(28,70,45))
|
| 589 |
draw.text((66,842),f'Đoạn {idx+1}/{total}',fill=(235,235,235),font=font_small)
|
| 590 |
y=940; maxw=W-96
|
| 591 |
+
for ln in _wrap_text_px(draw, segment, font_seg, maxw, 8):
|
|
|
|
| 592 |
draw.text((48,y),ln,fill=(255,255,255),font=font_seg)
|
| 593 |
y+=74
|
| 594 |
if y>1500: break
|
|
|
|
| 600 |
bg.save(out_path, quality=92)
|
| 601 |
|
| 602 |
|
| 603 |
+
def _estimate_audio_duration(path, fallback=4.0):
|
|
|
|
| 604 |
try:
|
| 605 |
+
pr=subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:nokey=1',path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
|
| 606 |
+
return max(1.5, float((pr.stdout or b'').decode().strip() or fallback))
|
| 607 |
except Exception:
|
| 608 |
return fallback
|
| 609 |
|
| 610 |
|
| 611 |
@app.post('/api/ai/short/{post_id}')
|
| 612 |
async def patched_ai_short(post_id: str, request: Request):
|
| 613 |
+
"""Create 9:16 short from separate timed summary segments; no subtitles."""
|
| 614 |
try:
|
| 615 |
body = await request.json()
|
| 616 |
except Exception:
|
| 617 |
body = {}
|
| 618 |
voice = str(body.get('voice', 'nu')).strip().lower()
|
| 619 |
emotion = str(body.get('emotion', 'neutral')).strip().lower()
|
| 620 |
+
speed = float(body.get('speed', 1.2) or 1.2)
|
| 621 |
speed = max(0.85, min(1.35, speed))
|
| 622 |
|
| 623 |
posts = base._load_ai_wall()
|
|
|
|
| 625 |
if not post:
|
| 626 |
return JSONResponse({'error': 'post not found'}, status_code=404)
|
| 627 |
|
| 628 |
+
segments = _summary_segments_from_post(post, max_segments=7)
|
| 629 |
seg_hash = hashlib.md5(('|'.join(segments)+voice+emotion+str(speed)).encode('utf-8')).hexdigest()[:8]
|
| 630 |
os.makedirs(base.SHORTS_DIR, exist_ok=True)
|
| 631 |
suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_{seg_hash}_scenes_nosub"
|
|
|
|
| 648 |
try:
|
| 649 |
base._download_image(post.get('img'), post.get('title', 'AI news'), img)
|
| 650 |
edge_voice = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 651 |
'nam': 'vi-VN-NamMinhNeural',
|
| 652 |
'male': 'vi-VN-NamMinhNeural',
|
| 653 |
'nu': 'vi-VN-HoaiMyNeural',
|
| 654 |
'female': 'vi-VN-HoaiMyNeural',
|
| 655 |
'mien-nam': 'vi-VN-HoaiMyNeural',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 656 |
}.get(voice, 'vi-VN-HoaiMyNeural')
|
| 657 |
part_files=[]
|
| 658 |
for idx, seg in enumerate(segments):
|
|
|
|
| 665 |
try:
|
| 666 |
subprocess.run(['python','-m','edge_tts','--voice',edge_voice,'--text',spoken,'--write-media',aud], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
| 667 |
except Exception:
|
| 668 |
+
tld='com.vn' if voice in ('nu','female','mien-nam') else 'com'
|
| 669 |
try:
|
| 670 |
base.gTTS(spoken, lang='vi', tld=tld, slow=False).save(aud)
|
| 671 |
except TypeError:
|
| 672 |
base.gTTS(spoken, lang='vi', slow=False).save(aud)
|
| 673 |
subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
|
| 674 |
+
dur=_estimate_audio_duration(aud_fast, fallback=4.0)+0.35
|
| 675 |
+
# No subtitles: scene text is part of frame only.
|
| 676 |
subprocess.run(['ffmpeg','-y','-loop','1','-t',str(dur),'-i',frame,'-i',aud_fast,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k',part], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=150)
|
| 677 |
part_files.append(part)
|
| 678 |
concat=os.path.join(work,'concat.txt')
|
|
|
|
| 707 |
|
| 708 |
|
| 709 |
app.router.routes = [r for r in app.router.routes if not (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set()))]
|
| 710 |
+
|
| 711 |
+
PATCH_INJECT = r'''
|
| 712 |
+
<style>.ai-wall-patched{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.ai-wall-card{flex:0 0 250px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:8px}.ai-wall-img{width:100%;aspect-ratio:16/9;background:#222;border-radius:8px;overflow:hidden;margin-bottom:6px}.ai-wall-img img{width:100%;height:100%;object-fit:cover}.ai-wall-title{font-size:12px;color:#5cb87a;font-weight:800;line-height:1.3;margin-bottom:4px}.ai-wall-text{font-size:11px;color:#bbb;line-height:1.45;white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden}.ai-wall-actions{display:flex;gap:6px;margin-top:8px}.ai-wall-actions button,.ai-wall-actions select{flex:1;border:1px solid #333;background:#222;color:#ddd;border-radius:14px;padding:6px 8px;font-size:10px;min-width:0}.ai-wall-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}.ai-short-card{flex:0 0 145px}.ai-short-video{width:100%;aspect-ratio:9/16;background:#000;border-radius:8px;overflow:hidden}.ai-short-video video{width:100%;height:100%;object-fit:cover}.ai-short-progress{position:fixed;inset:0;background:rgba(0,0,0,.78);z-index:99999;display:none;align-items:center;justify-content:center;padding:20px}.ai-short-progress.active{display:flex}.ai-short-box{max-width:420px;width:100%;background:#141414;border:2px solid #2d8659;border-radius:14px;padding:18px;color:#eee;box-shadow:0 0 30px rgba(45,134,89,.35)}.ai-short-box h3{color:#5cb87a;margin-bottom:10px}.ai-short-step{font-size:13px;line-height:1.55;color:#ccc}.ai-short-spinner{width:34px;height:34px;border:4px solid #333;border-top-color:#5cb87a;border-radius:50%;animation:spin 1s linear infinite;margin:10px auto}@keyframes spin{to{transform:rotate(360deg)}}</style>
|
| 713 |
+
<div id="ai-short-progress" class="ai-short-progress"><div class="ai-short-box"><h3>🎬 Đang tạo Short AI</h3><div class="ai-short-spinner"></div><div class="ai-short-step" id="ai-short-step">Đang chuẩn bị...</div></div></div>
|
| 714 |
+
<script>
|
| 715 |
+
(function(){
|
| 716 |
+
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}
|
| 717 |
+
let patchedWall=[];let aiShorts=[];
|
| 718 |
+
function showProgress(msg){let box=document.getElementById('ai-short-progress');let st=document.getElementById('ai-short-step');if(st)st.innerHTML=msg;if(box)box.classList.add('active');}
|
| 719 |
+
function hideProgress(){document.getElementById('ai-short-progress')?.classList.remove('active');}
|
| 720 |
+
function updateAiLabels(){document.querySelectorAll('.ai-compose-title').forEach(e=>e.textContent='🤖 Tường AI: lọc từng bài theo chủ đề, tóm tắt nội dung bài');document.querySelectorAll('button').forEach(b=>{if((b.textContent||'').includes('AI viết lại'))b.textContent='🤖 Tóm tắt AI & đăng tường';});}
|
| 721 |
+
async function loadPatchedWall(){try{const r=await fetch('/api/ai_wall');const j=await r.json();patchedWall=j.posts||[];renderPatchedWall();updateAiLabels();}catch(e){}try{const r2=await fetch('/api/ai_shorts');const j2=await r2.json();aiShorts=j2.posts||[];renderAiShorts();}catch(e){}}
|
| 722 |
+
function renderAiShorts(){const home=document.getElementById('view-home');if(!home)return;document.getElementById('ai-shorts-patched')?.remove();if(!aiShorts.length)return;let wrap=document.createElement('div');wrap.id='ai-shorts-patched';wrap.className='ai-wall-patched';let h='<div class="slider-header"><span class="slider-label">🎬 Short AI</span><span class="slider-note">Video đã tạo</span></div><div class="slider-track">';aiShorts.slice(0,30).forEach((p,i)=>{h+=`<div class="ai-short-card" onclick="aiReadShortPatched(${i})"><div class="ai-short-video"><video src="${p.video}" muted playsinline preload="metadata"></video></div><div class="slider-title">${esc(p.title)}</div></div>`});h+='</div>';wrap.innerHTML=h;let wall=document.getElementById('ai-wall-patched');if(wall)wall.after(wrap);else home.prepend(wrap);}
|
| 723 |
+
function renderPatchedWall(){const home=document.getElementById('view-home');if(!home)return;document.getElementById('ai-wall-patched')?.remove();if(!patchedWall.length)return;let wrap=document.createElement('div');wrap.id='ai-wall-patched';wrap.className='ai-wall-patched';let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI</span><span class="slider-note">Mỗi nguồn = một bài tóm tắt</span></div><div class="slider-track">';patchedWall.slice(0,30).forEach((p,i)=>{h+=`<div class="ai-wall-card"><div class="ai-wall-img">${p.img?`<img src="${p.img}">`:''}</div><div class="ai-wall-title">${esc(p.title)}</div><div class="ai-wall-text">${esc(p.text)}</div><div class="ai-wall-actions"><button onclick="aiReadWallPatched(${i})">Xem</button><button class="primary" onclick="aiMakeShortPatched(${i})">Shorts</button></div></div>`});h+='</div>';wrap.innerHTML=h;let after=document.querySelector('.ai-compose');if(after)after.after(wrap);else home.prepend(wrap);}
|
| 724 |
+
window.aiReadShortPatched=function(i){const p=aiShorts[i];if(!p)return;showView('view-article');let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">Short AI</span><h1 class="article-title">${esc(p.title)}</h1><video class="article-img" src="${p.video}" controls playsinline autoplay></video><p class="article-p" style="white-space:pre-wrap">${esc(p.text||'')}</p><div class="article-actions"><button onclick="window.open('${p.video}','_blank')">⬇ Mở video</button>${p.url?`<button onclick="window.open('${p.url}','_blank')">🔗 Nguồn</button>`:''}</div></div>`;document.getElementById('view-article').innerHTML=h;window.scrollTo(0,0)};
|
| 725 |
+
window.aiReadWallPatched=function(i){const p=patchedWall[i];if(!p)return;showView('view-article');let sources='';if(p.sources&&p.sources.length){sources='<div class="article-summary"><b>Nguồn tham khảo:</b><br>'+p.sources.slice(0,5).map(s=>`• ${esc(s.title||s.url||'Nguồn')} ${s.url?`(${esc(new URL(s.url).hostname.replace('www.',''))})`:''}`).join('<br>')+'</div>'}let voiceBox=`<div class="article-actions"><select id="ai-short-voice"><option value="nu">Giọng nữ Việt</option><option value="nam">Giọng nam Việt</option><option value="mien-nam">Giọng miền Nam</option></select><select id="ai-short-emotion"><option value="neutral">Trung tính</option><option value="urgent">Tin nhanh</option><option value="warm">Ấm áp</option><option value="serious">Nghiêm túc</option><option value="energetic">Sôi nổi</option></select><button onclick="aiMakeShortPatched(${i})">🎬 Tạo video shorts</button></div>`;let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${esc(p.title)}</h1>${p.img?`<img class="article-img" src="${p.img}">`:''}${sources}<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>${p.video?`<video class="article-img" src="${p.video}" controls playsinline></video>`:''}<div class="article-actions">${p.url?`<button onclick="window.open('${p.url}','_blank')">🔗 Nguồn</button>`:''}</div>${voiceBox}</div>`;document.getElementById('view-article').innerHTML=h;window.scrollTo(0,0)};
|
| 726 |
+
window.aiMakeShortPatched=async function(i){const p=patchedWall[i];if(!p)return;let voice=document.getElementById('ai-short-voice')?.value||'nu';let emotion=document.getElementById('ai-short-emotion')?.value||'neutral';let voiceName={nu:'Giọng nữ Việt',nam:'Giọng nam Việt','mien-nam':'Giọng miền Nam'}[voice]||voice;let emotionName={neutral:'Trung tính',urgent:'Tin nhanh',warm:'Ấm áp',serious:'Nghiêm túc',energetic:'Sôi nổi'}[emotion]||emotion;let ok=confirm(`Quy trình tạo short AI:\n\n1) Dùng ảnh đại diện của bài hoặc tạo ảnh minh họa nếu thiếu.\n2) Rút gọn nội dung tóm tắt thành kịch bản đọc ngắn.\n3) Tự ngắt câu theo dấu câu và xuống dòng hợp lý.\n4) Tạo giọng đọc tiếng Việt: ${voiceName}.\n5) Áp dụng cảm xúc/kịch bản: ${emotionName}.\n6) Tăng tốc giọng đọc 1.2 lần.\n7) Mỗi đoạn tóm tắt sẽ là một cảnh riêng theo thời lượng đọc.\n8) Không thêm phụ đề; video chỉ có chữ cảnh và giọng đọc.\n9) Sau khi xong, video xuất hiện ở slide “Short AI”.\n\nQuá trình có thể mất 1-3 phút. Bạn muốn bắt đầu?`);if(!ok)return;try{showProgress(`Bước 1/5: Chuẩn bị ảnh và căn chữ full width...<br>Bước 2/5: Tạo kịch bản, tự ngắt câu/xuống dòng...<br>Bước 3/5: Tạo giọng đọc ${voiceName}, cảm xúc ${emotionName}.<br>Bước 4/5: Tăng tốc 1.2x và ghép từng cảnh riêng, không phụ đề.<br>Bước 5/5: Lưu vào slide “Short AI”.`);const r=await fetch('/api/ai/short/'+p.id,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice,emotion,speed:1.2})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');p.video=j.video;hideProgress();alert('Hoàn tất: video shorts đã được tạo và thêm vào slide “Short AI”.');aiReadWallPatched(i);loadPatchedWall();}catch(e){hideProgress();alert('Không tạo được shorts: '+e.message)}};
|
| 727 |
+
window.createTopicPost=function(){let inp=document.getElementById('ai-topic-input');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&(j.posts||j.post)){let arr=j.posts||[j.post];patchedWall=arr.concat(patchedWall.filter(x=>!arr.find(y=>y.id===x.id)));renderPatchedWall();if(inp)inp.value='';alert(`Đã lọc và tóm tắt ${arr.length} bài viết theo chủ đề lên Tường AI`);}else alert(j.error||'Lỗi tạo bài')}).catch(e=>alert(e.message||'Lỗi tạo bài'));};
|
| 728 |
+
window.createUrlPost=function(){let inp=document.getElementById('ai-url-input');let url=(inp&&inp.value||'').trim();if(!url)return alert('Dán URL trước');if(!/^https?:\/\//i.test(url))return alert('URL cần bắt đầu bằng http:// hoặc https://');fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&j.post){patchedWall=[j.post].concat(patchedWall.filter(x=>x.id!==j.post.id));renderPatchedWall();if(inp)inp.value='';alert('Đã tóm tắt URL và đăng lên Tường AI');}else alert(j.error||'Lỗi URL')}).catch(e=>alert(e.message||'Lỗi URL'));};
|
| 729 |
+
window.rewriteCurrentArticle=function(){if(!window._currentArticle&&typeof _currentArticle!=='undefined')window._currentArticle=_currentArticle;let cur=window._currentArticle||_currentArticle;if(!cur)return;let btn=document.querySelector('.article-actions button.primary');if(btn){btn.textContent='Đang tóm tắt...';btn.disabled=true}fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:cur.url})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&j.post){document.getElementById('rewrite-result').innerHTML=`<div class="rewrite-box"><div class="rewrite-title">Đã tóm tắt và đăng Tường AI</div><div class="rewrite-text">${esc(j.post.text||'')}</div></div>`;patchedWall=[j.post].concat(patchedWall.filter(x=>x.id!==j.post.id));renderPatchedWall();alert('Đã tóm tắt lên Tường AI');}else alert(j.error||'Không tóm tắt được')}).catch(e=>alert(e.message||'Lỗi tóm tắt')).finally(()=>{if(btn){btn.textContent='🤖 Tóm tắt AI & đăng tường';btn.disabled=false}})};
|
| 730 |
+
setTimeout(loadPatchedWall,1500);setInterval(updateAiLabels,2000);
|
| 731 |
+
})();
|
| 732 |
+
</script>
|
| 733 |
+
'''
|
| 734 |
+
|
| 735 |
+
@app.get('/')
|
| 736 |
+
async def index_patched():
|
| 737 |
+
with open('/app/static/index.html','r',encoding='utf-8') as f:
|
| 738 |
+
html=f.read()
|
| 739 |
+
return HTMLResponse(html.replace('</body>', PATCH_INJECT+'\n</body>'))
|
ai_runtime_final.py
CHANGED
|
@@ -297,7 +297,7 @@ async def final_short(post_id:str,request:Request):
|
|
| 297 |
clips.append(clip)
|
| 298 |
lf=os.path.join(work,'list.txt')
|
| 299 |
with open(lf,'w',encoding='utf-8') as f:
|
| 300 |
-
for c in clips:f.write("file '"+c.replace("","'\\''")
|
| 301 |
subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',lf,'-c','copy',out],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=240)
|
| 302 |
post['video']='/api/ai/short-file/'+post_id+suffix;post['short_subtitles']=False;post['short_segments']=segs;post['short_speed']=speed;base._save_ai_wall(posts)
|
| 303 |
return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False})
|
|
@@ -309,7 +309,48 @@ def ai_wall_share(post:str=Query(default=''), short:int=Query(default=0)):
|
|
| 309 |
if not p:return HTMLResponse(f'<script>location.href="{SPACE_URL}"</script>')
|
| 310 |
title=p.get('title') or 'VNEWS AI';img=p.get('img') or DEFAULT_IMG
|
| 311 |
desc=(p.get('text') or '')[:220]
|
| 312 |
-
return HTMLResponse(f'<!doctype html><html><head><meta charset="utf-8"><title>{title}</title><meta property="og:title" content="{title}"><meta property="og:description" content="{desc}"><meta property="og:image" content="{img}"><meta property="og:type" content="article"><meta name="twitter:card" content="summary_large_image"></head><body><script>localStorage.setItem('pending_ai_post','{post}');location.href='{SPACE_URL}'</script></body></html>')
|
| 313 |
|
| 314 |
FINAL_INJECT = r'''
|
| 315 |
<style>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
clips.append(clip)
|
| 298 |
lf=os.path.join(work,'list.txt')
|
| 299 |
with open(lf,'w',encoding='utf-8') as f:
|
| 300 |
+
for c in clips:f.write("file '"+c.replace("'","'\\''")+"'\n")
|
| 301 |
subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',lf,'-c','copy',out],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=240)
|
| 302 |
post['video']='/api/ai/short-file/'+post_id+suffix;post['short_subtitles']=False;post['short_segments']=segs;post['short_speed']=speed;base._save_ai_wall(posts)
|
| 303 |
return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False})
|
|
|
|
| 309 |
if not p:return HTMLResponse(f'<script>location.href="{SPACE_URL}"</script>')
|
| 310 |
title=p.get('title') or 'VNEWS AI';img=p.get('img') or DEFAULT_IMG
|
| 311 |
desc=(p.get('text') or '')[:220]
|
| 312 |
+
return HTMLResponse(f'''<!doctype html><html><head><meta charset="utf-8"><title>{title}</title><meta property="og:title" content="{title}"><meta property="og:description" content="{desc}"><meta property="og:image" content="{img}"><meta property="og:type" content="article"><meta name="twitter:card" content="summary_large_image"></head><body><script>localStorage.setItem('pending_ai_post','{post}');location.href='{SPACE_URL}'</script></body></html>''')
|
| 313 |
|
| 314 |
FINAL_INJECT = r'''
|
| 315 |
<style>
|
| 316 |
+
#ai-topic-input{display:none!important}button[onclick*="createTopicPost"],*[onclick*="createTopicPost"]{display:none!important}.ai-topic-row,.topic-row,.ai-compose-topic{display:none!important}.ai-compose{width:calc(100% - 8px)!important}.ai-compose-row:has(#ai-url-input){display:flex!important;flex-direction:column!important;gap:8px!important;width:100%!important}.ai-compose-row:has(#ai-url-input) button{width:100%!important;display:block!important}.ai-compose-row:has(#ai-url-input) input,#ai-url-input{display:block!important;width:100%!important;max-width:none!important;flex:1 1 100%!important;box-sizing:border-box!important}.ai-url-only-note{font-size:11px;color:#888;margin:5px 0 8px;width:100%}.ai-wall-gallery{display:grid;grid-template-columns:repeat(2,1fr);gap:6px;margin:10px 0}.ai-wall-gallery img{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:8px;background:#222}.ai-wall-gallery img:first-child{grid-column:1/-1}.ai-wall-final{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}
|
| 317 |
+
</style>
|
| 318 |
+
<script>
|
| 319 |
+
(function(){
|
| 320 |
+
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}
|
| 321 |
+
let finalWall=[];
|
| 322 |
+
function hideTopicControls(){document.querySelectorAll('#ai-topic-input').forEach(e=>{let row=e.closest('.ai-compose-topic,.topic-row,.ai-topic-row')||e.parentElement;if(row&&!row.querySelector('#ai-url-input'))row.style.display='none';else e.style.display='none';});document.querySelectorAll('button,a').forEach(b=>{let t=(b.textContent||'').toLowerCase();let oc=b.getAttribute('onclick')||'';if(oc.includes('createTopicPost')||t.includes('chủ đề'))b.style.display='none';});let url=document.getElementById('ai-url-input');if(url&&!document.getElementById('ai-url-only-note')){let n=document.createElement('div');n.id='ai-url-only-note';n.className='ai-url-only-note';n.textContent='Dán URL bài viết; ảnh chỉ lấy từ nội dung bài, không lấy bài liên quan/quảng cáo.';url.insertAdjacentElement('afterend',n);}}
|
| 323 |
+
function voiceOptions(){return `<option value="nu">Nữ Việt - Hoài My</option><option value="nam">Nam Việt - Nam Minh</option><option value="nu-truyen-cam">Nữ Việt truyền cảm</option><option value="nu-tin-nhanh">Nữ Việt tin nhanh</option><option value="nam-tram">Nam Việt trầm</option><option value="nam-ban-tin">Nam Việt bản tin</option><option value="nam-nang-dong">Nam Việt năng động</option>`}
|
| 324 |
+
function addVoiceOptions(){document.querySelectorAll('#ai-short-voice, select[id*=voice]').forEach(sel=>{let old=sel.value||'nu';sel.innerHTML=voiceOptions();sel.value=[...sel.options].some(o=>o.value===old)?old:'nu';});}
|
| 325 |
+
function shareAI(p,isShort){let url=location.origin+'/aw?post='+encodeURIComponent(p.id)+(isShort?'&short=1':'');let title=(isShort?'🎬 Short AI: ':'🧱 Tường AI: ')+(p.title||'VNEWS');if(navigator.share)navigator.share({title,url}).catch(()=>{});else navigator.clipboard.writeText(url).then(()=>alert('Đã sao chép link chia sẻ!')).catch(()=>{});}
|
| 326 |
+
function galleryHtml(p){let imgs=(p.images||[]).filter(Boolean);if(!imgs.length&&p.img)imgs=[p.img];if(!imgs.length)return '';return '<div class="ai-wall-gallery">'+imgs.slice(0,12).map(u=>`<img src="${esc(u)}" loading="lazy">`).join('')+'</div>';}
|
| 327 |
+
async function loadWall(){try{finalWall=(await (await fetch('/api/ai_wall')).json()).posts||[];renderWall();let pend=localStorage.getItem('pending_ai_post');if(pend){localStorage.removeItem('pending_ai_post');let idx=finalWall.findIndex(p=>String(p.id)===String(pend));if(idx>=0)setTimeout(()=>readFinalWall(idx),300);}}catch(e){}}
|
| 328 |
+
function renderWall(){let home=document.getElementById('view-home');if(!home||!finalWall.length)return;document.getElementById('ai-wall-final')?.remove();let wrap=document.createElement('div');wrap.id='ai-wall-final';wrap.className='ai-wall-final';let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI</span><span class="slider-note">URL summary</span></div><div class="slider-track">';finalWall.slice(0,30).forEach((p,i)=>{h+=`<div class="wall-item"><div class="wall-thumb">${p.img?`<img src="${esc(p.img)}">`:''}</div><div class="wall-title">${esc(p.title)}</div><div class="wall-text">${esc(p.text)}</div><div class="wall-actions"><button class="primary" onclick="readFinalWall(${i})">Xem</button><button onclick="shareFinalWall(${i})">📤 Chia sẻ</button>${p.video?`<button onclick="readFinalShort(${i})">Short</button>`:''}</div></div>`});h+='</div>';wrap.innerHTML=h;let compose=document.querySelector('.ai-compose');if(compose)compose.after(wrap);else home.prepend(wrap);}
|
| 329 |
+
window.shareFinalWall=function(i){let p=finalWall[i];if(p)shareAI(p,false)};
|
| 330 |
+
window.shareFinalShort=function(i){let p=finalWall[i];if(p)shareAI(p,true)};
|
| 331 |
+
window.readFinalWall=window.aiReadWallPatched=window.aiReadWall=function(i){let p=finalWall[i]||(window.patchedWall||window.aiWall||[])[i];if(!p)return;showView('view-article');let sources='';if(p.sources&&p.sources.length){sources='<div class="article-summary"><b>Nguồn tham khảo:</b><br>'+p.sources.slice(0,5).map(s=>`• ${esc(s.title||s.url||'Nguồn')} ${s.url?`(${esc(new URL(s.url).hostname.replace('www.',''))})`:''}`).join('<br>')+'</div>'}let voiceBox=`<div class="article-actions"><select id="ai-short-voice">${voiceOptions()}</select><select id="ai-short-emotion"><option value="neutral">Trung tính</option><option value="urgent">Tin nhanh</option><option value="warm">Ấm áp</option><option value="serious">Nghiêm túc</option><option value="energetic">Sôi nổi</option></select><button onclick="makeFinalShort(${i})">🎬 Tạo short</button><button onclick="shareFinalWall(${i})">📤 Chia sẻ bài</button>${p.video?`<button onclick="shareFinalShort(${i})">📤 Chia sẻ short</button>`:''}</div>`;let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${esc(p.title)}</h1>${galleryHtml(p)}${sources}<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>${p.video?`<video class="article-img" src="${p.video}" controls playsinline></video>`:''}<div class="article-actions">${p.url?`<button onclick="window.open('${p.url}','_blank')">🔗 Nguồn</button>`:''}</div>${voiceBox}</div>`;document.getElementById('view-article').innerHTML=h;addVoiceOptions();window.scrollTo(0,0)};
|
| 332 |
+
window.readFinalShort=function(i){let p=finalWall[i];if(!p||!p.video)return;showView('view-article');document.getElementById('view-article').innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">Short AI</span><h1 class="article-title">${esc(p.title)}</h1><video class="article-img" src="${p.video}" controls playsinline autoplay></video><div class="article-actions"><button onclick="shareFinalShort(${i})">📤 Chia sẻ short</button><button onclick="readFinalWall(${i})">Xem bài tường</button></div></div>`;window.scrollTo(0,0)};
|
| 333 |
+
window.makeFinalShort=window.aiMakeShortPatched=async function(i){let p=finalWall[i]||(window.patchedWall||window.aiWall||[])[i];if(!p)return;let voice=document.getElementById('ai-short-voice')?.value||'nu';let emotion=document.getElementById('ai-short-emotion')?.value||'neutral';let btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang tạo...'}try{let r=await fetch('/api/ai/short/'+p.id,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice,emotion,speed:1.2})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi tạo short');p.video=j.video;let idx=finalWall.findIndex(x=>x.id===p.id);if(idx<0){finalWall.unshift(p);idx=0}renderWall();readFinalWall(idx);alert('Đã tạo short AI và cập nhật ngay trên tường.');}catch(e){alert('Không tạo được short: '+e.message)}finally{if(btn){btn.disabled=false;btn.textContent='🎬 Tạo short'}}};
|
| 334 |
+
window.createTopicPost=function(){alert('Đã tắt ô nhập chủ đề. Vui lòng dán URL bài viết.');};
|
| 335 |
+
window.createUrlPost=async function(){let inp=document.getElementById('ai-url-input');let url=(inp&&inp.value||'').trim();if(!url)return alert('Dán URL trước');if(!/^https?:\/\//i.test(url))return alert('URL cần bắt đầu bằng http:// hoặc https://');let btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang tóm tắt...'}try{let r=await fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi URL');finalWall.unshift(j.post);if(inp)inp.value='';renderWall();readFinalWall(0);alert('Đã đăng tường AI, không cần tải lại trang.');}catch(e){alert(e.message||'Lỗi URL')}finally{if(btn){btn.disabled=false;btn.textContent='Chèn URL'}}};
|
| 336 |
+
setTimeout(()=>{hideTopicControls();addVoiceOptions();loadWall();},300);setInterval(()=>{hideTopicControls();addVoiceOptions();},1200);
|
| 337 |
+
})();
|
| 338 |
+
</script>
|
| 339 |
+
'''
|
| 340 |
+
|
| 341 |
+
def _load_index_html():
|
| 342 |
+
try:
|
| 343 |
+
with open('/app/static/index.html','r',encoding='utf-8') as f:html=f.read()
|
| 344 |
+
except Exception:html=''
|
| 345 |
+
if '<!DOCTYPE html>' not in html or '<div id="view-home"' not in html:
|
| 346 |
+
try:
|
| 347 |
+
r=requests.get(RESTORE_INDEX_URL,timeout=20)
|
| 348 |
+
if r.status_code==200 and '<!DOCTYPE html>' in r.text:html=r.text
|
| 349 |
+
except Exception:pass
|
| 350 |
+
if '<!DOCTYPE html>' not in html:html='<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>VNEWS</title></head><body><div id="view-home">VNEWS</div></body></html>'
|
| 351 |
+
return html
|
| 352 |
+
|
| 353 |
+
@app.get('/')
|
| 354 |
+
async def index_final():
|
| 355 |
+
html=_load_index_html();body=getattr(rt.old,'PATCH_INJECT','') + FINAL_INJECT
|
| 356 |
+
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|
ai_runtime_final6.py
CHANGED
|
@@ -840,10 +840,486 @@ FINAL6E_INJECT = """
|
|
| 840 |
function escE(s){return String(s||'').replace(/[&<>\"']/g,m=>({'&':'&','<':'<','>':'>','\"':'"',"'":'''}[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_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_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
CHANGED
|
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
CHANGED
|
@@ -35,7 +35,7 @@ window.makeShortFromPost=async function(pid,btn){
|
|
| 35 |
var r=await fetch('/api/ai/short/'+pid,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:'nu',emotion:'neutral',speed:1.2})});
|
| 36 |
var j=await r.json();
|
| 37 |
if(!r.ok||j.error)throw new Error(j.error||'Lỗi');
|
| 38 |
-
showShortProgress('✅
|
| 39 |
setTimeout(hideShortProgress,3000);
|
| 40 |
if(typeof renderShortAISlide==='function')renderShortAISlide();
|
| 41 |
}catch(e){
|
|
|
|
| 35 |
var r=await fetch('/api/ai/short/'+pid,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:'nu',emotion:'neutral',speed:1.2})});
|
| 36 |
var j=await r.json();
|
| 37 |
if(!r.ok||j.error)throw new Error(j.error||'Lỗi');
|
| 38 |
+
showShortProgress('✅ Đã tạo Short AI!');
|
| 39 |
setTimeout(hideShortProgress,3000);
|
| 40 |
if(typeof renderShortAISlide==='function')renderShortAISlide();
|
| 41 |
}catch(e){
|
piped_client.py
DELETED
|
@@ -1,258 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
YouTube Shorts Scraper using Piped API
|
| 3 |
-
Piped is a privacy-friendly YouTube proxy that works without JS
|
| 4 |
-
"""
|
| 5 |
-
import requests
|
| 6 |
-
import json
|
| 7 |
-
import time
|
| 8 |
-
import threading
|
| 9 |
-
|
| 10 |
-
_cache = {}
|
| 11 |
-
_lock = threading.Lock()
|
| 12 |
-
CACHE_TTL = 900 # 15 min
|
| 13 |
-
|
| 14 |
-
# Piped API instances (public)
|
| 15 |
-
PIPED_INSTANCES = [
|
| 16 |
-
"https://pipedapi.kavin.rocks",
|
| 17 |
-
"https://pipedapi.adminforge.de",
|
| 18 |
-
"https://api.piped.projectsegfau.lt",
|
| 19 |
-
]
|
| 20 |
-
|
| 21 |
-
UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
|
| 22 |
-
|
| 23 |
-
def _cached(key):
|
| 24 |
-
with _lock:
|
| 25 |
-
if key in _cache and time.time() - _cache[key]['t'] < CACHE_TTL:
|
| 26 |
-
return _cache[key]['d']
|
| 27 |
-
return None
|
| 28 |
-
|
| 29 |
-
def _set_cache(key, data):
|
| 30 |
-
with _lock:
|
| 31 |
-
_cache[key] = {'t': time.time(), 'd': data}
|
| 32 |
-
|
| 33 |
-
def _piped_request(path, params=None):
|
| 34 |
-
"""Try multiple Piped instances"""
|
| 35 |
-
last_err = None
|
| 36 |
-
for base in PIPED_INSTANCES:
|
| 37 |
-
try:
|
| 38 |
-
url = f"{base}{path}"
|
| 39 |
-
r = requests.get(url, params=params, headers=UA, timeout=15)
|
| 40 |
-
if r.status_code == 200:
|
| 41 |
-
return r.json()
|
| 42 |
-
except Exception as e:
|
| 43 |
-
last_err = e
|
| 44 |
-
continue
|
| 45 |
-
raise Exception(f"All Piped instances failed: {last_err}")
|
| 46 |
-
|
| 47 |
-
def get_channel_videos(channel_id, max_videos=200):
|
| 48 |
-
"""Get all videos from a channel using Piped API with pagination"""
|
| 49 |
-
cached = _cached(f'ch_vids_{channel_id}')
|
| 50 |
-
if cached is not None:
|
| 51 |
-
return cached
|
| 52 |
-
|
| 53 |
-
all_videos = []
|
| 54 |
-
page = None
|
| 55 |
-
|
| 56 |
-
while len(all_videos) < max_videos:
|
| 57 |
-
try:
|
| 58 |
-
if page:
|
| 59 |
-
data = _piped_request(f"/channels/{channel_id}/videos", {"nextpage": page})
|
| 60 |
-
else:
|
| 61 |
-
data = _piped_request(f"/channels/{channel_id}/videos")
|
| 62 |
-
|
| 63 |
-
videos = data.get('relatedStreams', [])
|
| 64 |
-
if not videos:
|
| 65 |
-
break
|
| 66 |
-
|
| 67 |
-
all_videos.extend(videos)
|
| 68 |
-
|
| 69 |
-
# Check for next page
|
| 70 |
-
next_page = data.get('nextpage')
|
| 71 |
-
if not next_page or next_page == page:
|
| 72 |
-
break
|
| 73 |
-
page = next_page
|
| 74 |
-
|
| 75 |
-
# Small delay to be polite
|
| 76 |
-
time.sleep(0.3)
|
| 77 |
-
|
| 78 |
-
if len(all_videos) >= max_videos:
|
| 79 |
-
break
|
| 80 |
-
|
| 81 |
-
except Exception as e:
|
| 82 |
-
print(f"Piped pagination error: {e}")
|
| 83 |
-
break
|
| 84 |
-
|
| 85 |
-
result = all_videos[:max_videos]
|
| 86 |
-
_set_cache(f'ch_vids_{channel_id}', result)
|
| 87 |
-
return result
|
| 88 |
-
|
| 89 |
-
def get_vtvnambo_shorts_piped(max_count=50):
|
| 90 |
-
"""Get shorts from VTV Nam Bộ using Piped API"""
|
| 91 |
-
# VTV Nam Bộ channel ID
|
| 92 |
-
channel_id = "UCJ0btJV8qh7J7R2aXb9GmGA"
|
| 93 |
-
|
| 94 |
-
try:
|
| 95 |
-
videos = get_channel_videos(channel_id, 200)
|
| 96 |
-
|
| 97 |
-
shorts = []
|
| 98 |
-
for v in videos:
|
| 99 |
-
title = v.get('title', '')
|
| 100 |
-
vid = v.get('url', '').replace('/watch?v=', '')
|
| 101 |
-
if not vid:
|
| 102 |
-
continue
|
| 103 |
-
|
| 104 |
-
# Filter for shorts: title has #shorts, or duration <= 60s
|
| 105 |
-
duration = v.get('duration', 0)
|
| 106 |
-
is_short = (
|
| 107 |
-
'#shorts' in title.lower() or
|
| 108 |
-
'#short' in title.lower() or
|
| 109 |
-
(duration > 0 and duration <= 60)
|
| 110 |
-
)
|
| 111 |
-
|
| 112 |
-
if is_short:
|
| 113 |
-
shorts.append({
|
| 114 |
-
'id': vid,
|
| 115 |
-
'title': title,
|
| 116 |
-
'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg",
|
| 117 |
-
'channel': 'vtvnambo',
|
| 118 |
-
})
|
| 119 |
-
|
| 120 |
-
if shorts:
|
| 121 |
-
return shorts[:max_count]
|
| 122 |
-
except Exception as e:
|
| 123 |
-
print(f"Piped shorts error: {e}")
|
| 124 |
-
|
| 125 |
-
return []
|
| 126 |
-
|
| 127 |
-
def get_vtvnambo_shorts_rss(max_count=50):
|
| 128 |
-
"""Get shorts from YouTube RSS feed"""
|
| 129 |
-
cached = _cached('vtvnambo_rss')
|
| 130 |
-
if cached is not None:
|
| 131 |
-
return cached
|
| 132 |
-
|
| 133 |
-
from xml.etree import ElementTree as ET
|
| 134 |
-
|
| 135 |
-
# First get channel ID from page
|
| 136 |
-
channel_id = None
|
| 137 |
-
try:
|
| 138 |
-
r = requests.get("https://www.youtube.com/@vtvnambo", headers=UA, timeout=15)
|
| 139 |
-
if r.status_code == 200:
|
| 140 |
-
m = re.search(r'"channelId":"(UC[^"]+)"', r.text)
|
| 141 |
-
if m:
|
| 142 |
-
channel_id = m.group(1)
|
| 143 |
-
except:
|
| 144 |
-
pass
|
| 145 |
-
|
| 146 |
-
if not channel_id:
|
| 147 |
-
channel_id = "UCJ0btJV8qh7J7R2aXb9GmGA" # fallback
|
| 148 |
-
|
| 149 |
-
try:
|
| 150 |
-
url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
|
| 151 |
-
r = requests.get(url, headers=UA, timeout=15)
|
| 152 |
-
if r.status_code != 200:
|
| 153 |
-
return []
|
| 154 |
-
|
| 155 |
-
root = ET.fromstring(r.text)
|
| 156 |
-
ns = {'atom': 'http://www.w3.org/2005/Atom', 'yt': 'http://www.youtube.com/xml/schemas/2015'}
|
| 157 |
-
|
| 158 |
-
shorts = []
|
| 159 |
-
for entry in root.findall('atom:entry', ns)[:max_count * 2]:
|
| 160 |
-
title_el = entry.find('atom:title', ns)
|
| 161 |
-
title = title_el.text if title_el is not None and title_el.text else ''
|
| 162 |
-
|
| 163 |
-
vid_el = entry.find('yt:videoId', ns)
|
| 164 |
-
vid = vid_el.text if vid_el is not None else ''
|
| 165 |
-
if not vid:
|
| 166 |
-
continue
|
| 167 |
-
|
| 168 |
-
is_short = '#shorts' in title.lower() or '#short' in title.lower()
|
| 169 |
-
link_el = entry.find('atom:link', ns)
|
| 170 |
-
link = link_el.get('href', '') if link_el is not None else ''
|
| 171 |
-
if '/shorts/' in link:
|
| 172 |
-
is_short = True
|
| 173 |
-
|
| 174 |
-
if is_short:
|
| 175 |
-
shorts.append({
|
| 176 |
-
'id': vid,
|
| 177 |
-
'title': title,
|
| 178 |
-
'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg",
|
| 179 |
-
'channel': 'vtvnambo',
|
| 180 |
-
})
|
| 181 |
-
|
| 182 |
-
_set_cache('vtvnambo_rss', shorts[:max_count])
|
| 183 |
-
return shorts[:max_count]
|
| 184 |
-
except Exception as e:
|
| 185 |
-
print(f"RSS error: {e}")
|
| 186 |
-
|
| 187 |
-
return []
|
| 188 |
-
|
| 189 |
-
def get_vtvnambo_shorts(max_count=50):
|
| 190 |
-
"""Get all shorts from VTV Nam Bộ. Tries Piped API first, then RSS."""
|
| 191 |
-
cached = _cached('vtvnambo_shorts_v3')
|
| 192 |
-
if cached is not None:
|
| 193 |
-
return cached
|
| 194 |
-
|
| 195 |
-
all_shorts = []
|
| 196 |
-
seen_ids = set()
|
| 197 |
-
|
| 198 |
-
# Method 1: Piped API (most reliable)
|
| 199 |
-
try:
|
| 200 |
-
piped_shorts = get_vtvnambo_shorts_piped(max_count)
|
| 201 |
-
for s in piped_shorts:
|
| 202 |
-
if s['id'] not in seen_ids:
|
| 203 |
-
seen_ids.add(s['id'])
|
| 204 |
-
all_shorts.append(s)
|
| 205 |
-
print(f"Piped API found {len(piped_shorts)} shorts")
|
| 206 |
-
except Exception as e:
|
| 207 |
-
print(f"Piped method failed: {e}")
|
| 208 |
-
|
| 209 |
-
# Method 2: RSS feed
|
| 210 |
-
if len(all_shorts) < 3:
|
| 211 |
-
try:
|
| 212 |
-
rss_shorts = get_vtvnambo_shorts_rss(max_count)
|
| 213 |
-
for s in rss_shorts:
|
| 214 |
-
if s['id'] not in seen_ids:
|
| 215 |
-
seen_ids.add(s['id'])
|
| 216 |
-
all_shorts.append(s)
|
| 217 |
-
print(f"RSS found {len(rss_shorts)} shorts")
|
| 218 |
-
except Exception as e:
|
| 219 |
-
print(f"RSS method failed: {e}")
|
| 220 |
-
|
| 221 |
-
result = all_shorts[:max_count]
|
| 222 |
-
_set_cache('vtvnambo_shorts_v3', result)
|
| 223 |
-
return result
|
| 224 |
-
|
| 225 |
-
def get_wc_related_shorts(max_count=30):
|
| 226 |
-
"""Get World Cup / football related shorts."""
|
| 227 |
-
all_shorts = get_vtvnambo_shorts(max_count * 3)
|
| 228 |
-
|
| 229 |
-
wc_kws = [
|
| 230 |
-
'world cup', 'wc 2026', 'worldcup', 'fifa', 'bóng đá',
|
| 231 |
-
'trận đấu', 'đội tuyển', 'tuyển', 'vòng loại',
|
| 232 |
-
'khoảnh khắc', 'highlights', 'bàn thắng', 'goal',
|
| 233 |
-
'kết quả', 'tỉ số', 'việt nam', 'vn',
|
| 234 |
-
'ngoại hạng', 'premier league', 'champions league',
|
| 235 |
-
'laliga', 'serie a', 'bundesliga', 'ligue 1',
|
| 236 |
-
'copa', 'europa', 'c1', 'c2',
|
| 237 |
-
'messi', 'ronaldo', 'neymar', 'mbappe', 'haaland',
|
| 238 |
-
'v-league', 'vleague', 'bóng đá việt',
|
| 239 |
-
'đội bóng', 'hlv', 'huấn luyện viên',
|
| 240 |
-
'chuyển nhượng', 'transfer',
|
| 241 |
-
'asian cup', 'aff cup', 'sea games',
|
| 242 |
-
'olympic', 'u23', 'u20', 'u17',
|
| 243 |
-
]
|
| 244 |
-
|
| 245 |
-
wc_shorts = []
|
| 246 |
-
for s in all_shorts:
|
| 247 |
-
tl = s.get('title', '').lower()
|
| 248 |
-
if any(k in tl for k in wc_kws):
|
| 249 |
-
wc_shorts.append(s)
|
| 250 |
-
|
| 251 |
-
if not wc_shorts:
|
| 252 |
-
wc_shorts = all_shorts
|
| 253 |
-
|
| 254 |
-
return wc_shorts[:max_count]
|
| 255 |
-
|
| 256 |
-
import re
|
| 257 |
-
# Alias for backward compatibility
|
| 258 |
-
get_vtvnamo_shorts = get_vtvnambo_shorts
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
rebuild3.md
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
rebuild
|
|
|
|
|
|
rebuild_trigger.txt
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
rebuild: Fri Aug 28 11:15:45 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 |
-
rebuild 1787553359
|
|
|
|
|
|
restart2.md
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
restart with _run.py fix
|
|
|
|
|
|
restart_trigger.md
DELETED
|
@@ -1,14 +0,0 @@
|
|
| 1 |
-
# VNEWS Space Restart Trigger
|
| 2 |
-
|
| 3 |
-
## Version: v20260822d
|
| 4 |
-
|
| 5 |
-
### All fixes applied:
|
| 6 |
-
1. **Scrolling fix**: `-webkit-overflow-scrolling: touch` + `scroll-behavior: smooth` on `.tiktok-feed`
|
| 7 |
-
2. **Thumbnail fix**: `.yt-thumb-wrap` with background image for YouTube iframes, YouTube hqdefault fallback
|
| 8 |
-
3. **Lazy loading**: IntersectionObserver to unload slides far from viewport
|
| 9 |
-
4. **SHORT HOT video looping**: Video plays at full duration instead of being trimmed to narration length
|
| 10 |
-
5. **CSS consistency**: Same poster/thumbnail styling for all TikTok slides
|
| 11 |
-
6. **Homepage loading**: Retry logic for `scrape_vne`, bongdaplus.vn as primary source, faster xemlaibongda timeout
|
| 12 |
-
7. **YouTube SSL error**: Increased yt-dlp retries (5), socket_timeout 30s, YouTube embed fallback
|
| 13 |
-
|
| 14 |
-
Last restart: 20260822d
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
rewrite_fix_v2.js
DELETED
|
@@ -1,2 +0,0 @@
|
|
| 1 |
-
// No-op - all functionality built into app_v2.js
|
| 2 |
-
(function(){})();
|
|
|
|
|
|
|
|
|
rewrite_slide.py
DELETED
|
@@ -1,204 +0,0 @@
|
|
| 1 |
-
"""Fast rewrite as slides - no AI needed, extracts key points + images from article."""
|
| 2 |
-
from main import app
|
| 3 |
-
from fastapi import Request
|
| 4 |
-
from fastapi.responses import JSONResponse
|
| 5 |
-
import requests, re, time, random, json, os
|
| 6 |
-
from bs4 import BeautifulSoup
|
| 7 |
-
from urllib.parse import quote
|
| 8 |
-
|
| 9 |
-
UA = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept-Language': 'vi-VN,vi;q=0.9'}
|
| 10 |
-
|
| 11 |
-
try:
|
| 12 |
-
from main import _load_wall, _save_wall
|
| 13 |
-
except:
|
| 14 |
-
_data_dir = "/data" if os.path.isdir("/data") else "/app/data"
|
| 15 |
-
_wall_file = os.path.join(_data_dir, "wall_posts.json")
|
| 16 |
-
def _load_wall():
|
| 17 |
-
try:
|
| 18 |
-
if os.path.exists(_wall_file):
|
| 19 |
-
with open(_wall_file, 'r', encoding='utf-8') as f: return json.load(f)
|
| 20 |
-
except: pass
|
| 21 |
-
return []
|
| 22 |
-
def _save_wall(posts):
|
| 23 |
-
try:
|
| 24 |
-
os.makedirs(os.path.dirname(_wall_file), exist_ok=True)
|
| 25 |
-
with open(_wall_file+'.tmp', 'w', encoding='utf-8') as f: json.dump(posts[:100], f, ensure_ascii=False)
|
| 26 |
-
os.replace(_wall_file+'.tmp', _wall_file)
|
| 27 |
-
except: pass
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
def _clean(s): return re.sub(r'\s+', ' ', str(s or '')).strip()
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
def _scrape_article_full(url):
|
| 34 |
-
"""Scrape article: extract paragraphs + ALL images."""
|
| 35 |
-
try:
|
| 36 |
-
r = requests.get(url, headers=UA, timeout=15, allow_redirects=True)
|
| 37 |
-
r.encoding = 'utf-8'
|
| 38 |
-
soup = BeautifulSoup(r.text, 'lxml')
|
| 39 |
-
for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']): tag.decompose()
|
| 40 |
-
|
| 41 |
-
# Title
|
| 42 |
-
h1 = soup.find('h1')
|
| 43 |
-
ogt = soup.find('meta', property='og:title')
|
| 44 |
-
title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '')
|
| 45 |
-
|
| 46 |
-
# OG image
|
| 47 |
-
ogi = soup.find('meta', property='og:image')
|
| 48 |
-
og_img = ogi.get('content', '') if ogi else ''
|
| 49 |
-
if og_img and og_img.startswith('//'): og_img = 'https:' + og_img
|
| 50 |
-
|
| 51 |
-
# Find content block
|
| 52 |
-
block = None
|
| 53 |
-
for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
|
| 54 |
-
el = soup.select_one(sel)
|
| 55 |
-
if el and len(el.find_all('p')) >= 2: block = el; break
|
| 56 |
-
if not block: block = soup.body or soup
|
| 57 |
-
|
| 58 |
-
# Extract paragraphs and images IN ORDER
|
| 59 |
-
paragraphs = []
|
| 60 |
-
images = []
|
| 61 |
-
seen_imgs = set()
|
| 62 |
-
|
| 63 |
-
if og_img and og_img not in seen_imgs:
|
| 64 |
-
images.append(og_img)
|
| 65 |
-
seen_imgs.add(og_img)
|
| 66 |
-
|
| 67 |
-
for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
|
| 68 |
-
if el.name == 'p':
|
| 69 |
-
t = _clean(el.get_text(strip=True))
|
| 70 |
-
if t and len(t) > 40:
|
| 71 |
-
paragraphs.append(t)
|
| 72 |
-
elif el.name in ('figure', 'img'):
|
| 73 |
-
im = el if el.name == 'img' else el.find('img')
|
| 74 |
-
if im:
|
| 75 |
-
src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
|
| 76 |
-
if src and 'base64' not in src:
|
| 77 |
-
if src.startswith('//'): src = 'https:' + src
|
| 78 |
-
if src not in seen_imgs:
|
| 79 |
-
images.append(src)
|
| 80 |
-
seen_imgs.add(src)
|
| 81 |
-
|
| 82 |
-
return {'title': _clean(title), 'paragraphs': paragraphs, 'images': images, 'og_img': og_img}
|
| 83 |
-
except Exception as e:
|
| 84 |
-
return None
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
def _ensure_complete_sentence(text):
|
| 88 |
-
"""Ensure text ends with complete sentence ending."""
|
| 89 |
-
text = _clean(text)
|
| 90 |
-
if not text:
|
| 91 |
-
return text
|
| 92 |
-
# Find last complete sentence ending
|
| 93 |
-
for end_char in ['.', '!', '?']:
|
| 94 |
-
last_pos = text.rfind(end_char)
|
| 95 |
-
if last_pos > len(text) * 0.5: # Keep if ending is in latter half
|
| 96 |
-
return text[:last_pos + 1].strip()
|
| 97 |
-
return text
|
| 98 |
-
|
| 99 |
-
def _extract_key_points(paragraphs, max_points=5):
|
| 100 |
-
"""Extract key points: take first COMPLETE sentence of each significant paragraph."""
|
| 101 |
-
points = []
|
| 102 |
-
for p in paragraphs:
|
| 103 |
-
if len(points) >= max_points: break
|
| 104 |
-
# Find ALL sentence endings and take the first complete sentence
|
| 105 |
-
# that ends with . ! or ?
|
| 106 |
-
sentences = re.split(r'(?<=[.!?])\s+', p)
|
| 107 |
-
sentence = ""
|
| 108 |
-
for s in sentences:
|
| 109 |
-
s = _clean(s)
|
| 110 |
-
if len(s) >= 30 and re.search(r'[.!?]$', s):
|
| 111 |
-
sentence = s
|
| 112 |
-
break
|
| 113 |
-
|
| 114 |
-
# If no complete sentence found, take first sentence and ensure it ends
|
| 115 |
-
if not sentence and sentences:
|
| 116 |
-
sentence = _ensure_complete_sentence(sentences[0])
|
| 117 |
-
|
| 118 |
-
# Skip if too short or duplicate
|
| 119 |
-
if len(sentence) < 30: continue
|
| 120 |
-
if any(sentence[:50] in existing for existing in points): continue
|
| 121 |
-
|
| 122 |
-
points.append(sentence)
|
| 123 |
-
|
| 124 |
-
return points
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
@app.post("/api/rewrite_slide")
|
| 128 |
-
async def api_rewrite_slide(request: Request):
|
| 129 |
-
"""
|
| 130 |
-
Fast rewrite as SLIDES:
|
| 131 |
-
- Extract key points from article (1 sentence each, full and complete)
|
| 132 |
-
- Pair each point with an image from the article
|
| 133 |
-
- Return as slides array for frontend to display
|
| 134 |
-
- Save to Tường AI
|
| 135 |
-
NO AI NEEDED - instant response.
|
| 136 |
-
"""
|
| 137 |
-
body = await request.json()
|
| 138 |
-
url = _clean(body.get("url", ""))
|
| 139 |
-
context = body.get("context", "")
|
| 140 |
-
|
| 141 |
-
if not url and not context:
|
| 142 |
-
return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
|
| 143 |
-
|
| 144 |
-
# Scrape article
|
| 145 |
-
data = None
|
| 146 |
-
if url and url.startswith("http"):
|
| 147 |
-
data = _scrape_article_full(url)
|
| 148 |
-
|
| 149 |
-
if not data and context:
|
| 150 |
-
# Use context passed from frontend
|
| 151 |
-
paragraphs = [_clean(p) for p in context.split('\n') if len(_clean(p)) > 40]
|
| 152 |
-
data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
|
| 153 |
-
|
| 154 |
-
if not data or not data.get('paragraphs'):
|
| 155 |
-
return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
|
| 156 |
-
|
| 157 |
-
# Extract key points
|
| 158 |
-
points = _extract_key_points(data['paragraphs'], max_points=6)
|
| 159 |
-
if not points:
|
| 160 |
-
return JSONResponse({"error": "Không tìm được ý chính"}, status_code=422)
|
| 161 |
-
|
| 162 |
-
# Build slides: pair each point with an image
|
| 163 |
-
images = data.get('images', [])
|
| 164 |
-
slides = []
|
| 165 |
-
for i, point in enumerate(points):
|
| 166 |
-
img = images[i] if i < len(images) else (images[-1] if images else '')
|
| 167 |
-
# Proxy dantri images
|
| 168 |
-
if img and 'cdnphoto.dantri' in img:
|
| 169 |
-
img = '/api/proxy/img?url=' + quote(img, safe='')
|
| 170 |
-
slides.append({
|
| 171 |
-
'text': point,
|
| 172 |
-
'image': img,
|
| 173 |
-
'index': i + 1
|
| 174 |
-
})
|
| 175 |
-
|
| 176 |
-
# Create post for Tường AI
|
| 177 |
-
summary_text = '\n\n'.join([f"• {s['text']}" for s in slides])
|
| 178 |
-
# Auto voice + emotion based on topic (reuse ai_ext detector if available)
|
| 179 |
-
try:
|
| 180 |
-
from ai_ext import _detect_voice_emotion
|
| 181 |
-
_voice, _emotion = _detect_voice_emotion(data['title'], summary_text)
|
| 182 |
-
except Exception:
|
| 183 |
-
_voice, _emotion = "hoaimy", "trung_tinh"
|
| 184 |
-
post = {
|
| 185 |
-
"id": str(int(time.time() * 1000)) + str(random.randint(100, 999)),
|
| 186 |
-
"title": data['title'],
|
| 187 |
-
"text": summary_text,
|
| 188 |
-
"img": images[0] if images else '',
|
| 189 |
-
"url": url,
|
| 190 |
-
"kind": "slide_summary",
|
| 191 |
-
"slides": slides,
|
| 192 |
-
"images": images[:10],
|
| 193 |
-
"video": "",
|
| 194 |
-
"voice": _voice,
|
| 195 |
-
"emotion": _emotion,
|
| 196 |
-
"ts": int(time.time())
|
| 197 |
-
}
|
| 198 |
-
|
| 199 |
-
# Save to wall
|
| 200 |
-
posts = _load_wall()
|
| 201 |
-
posts.insert(0, post)
|
| 202 |
-
_save_wall(posts)
|
| 203 |
-
|
| 204 |
-
return JSONResponse({"post": post, "slides": slides})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
runtime.txt
DELETED
|
File without changes
|
shorts_cache.py
DELETED
|
@@ -1,86 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
VNEWS Shorts Runtime Cache - External Updater Module
|
| 3 |
-
GitHub Actions fetches YouTube shorts via yt-dlp -> POST to /api/shorts/update
|
| 4 |
-
Space saves to RAM cache + persistent file if /data available
|
| 5 |
-
"""
|
| 6 |
-
import os
|
| 7 |
-
import json
|
| 8 |
-
import time
|
| 9 |
-
import threading
|
| 10 |
-
|
| 11 |
-
# Runtime cache (RAM)
|
| 12 |
-
_shorts_runtime_cache = None
|
| 13 |
-
_shorts_cache_ts = 0
|
| 14 |
-
_shorts_cache_lock = threading.Lock()
|
| 15 |
-
|
| 16 |
-
# Secret for authenticating update requests
|
| 17 |
-
SHORTS_UPDATE_SECRET = os.environ.get("SHORTS_UPDATE_SECRET", "vnews-shorts-2026")
|
| 18 |
-
|
| 19 |
-
# Paths
|
| 20 |
-
SHORTS_CACHE_FILE = "/data/shorts_runtime_cache.json" if os.path.isdir("/data") else "/app/shorts_runtime_cache.json"
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
def get_runtime_cache():
|
| 24 |
-
"""Get cached shorts (from RAM or file fallback)"""
|
| 25 |
-
global _shorts_runtime_cache, _shorts_cache_ts
|
| 26 |
-
with _shorts_cache_lock:
|
| 27 |
-
if _shorts_runtime_cache is not None:
|
| 28 |
-
age = time.time() - _shorts_cache_ts
|
| 29 |
-
if age < 7200: # 2h fresh
|
| 30 |
-
return _shorts_runtime_cache
|
| 31 |
-
|
| 32 |
-
# Try file fallback
|
| 33 |
-
try:
|
| 34 |
-
if os.path.exists(SHORTS_CACHE_FILE):
|
| 35 |
-
with open(SHORTS_CACHE_FILE, "r", encoding="utf-8") as f:
|
| 36 |
-
data = json.load(f)
|
| 37 |
-
age = time.time() - data.get("ts", 0)
|
| 38 |
-
if age < 86400: # 24h stale limit
|
| 39 |
-
items = data.get("items", [])
|
| 40 |
-
with _shorts_cache_lock:
|
| 41 |
-
_shorts_runtime_cache = items
|
| 42 |
-
_shorts_cache_ts = data.get("ts", time.time())
|
| 43 |
-
return items
|
| 44 |
-
except Exception as e:
|
| 45 |
-
print(f"[cache] read error: {e}")
|
| 46 |
-
|
| 47 |
-
return None
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
def set_runtime_cache(items):
|
| 51 |
-
"""Update runtime cache from external data"""
|
| 52 |
-
global _shorts_runtime_cache, _shorts_cache_ts
|
| 53 |
-
ts = time.time()
|
| 54 |
-
with _shorts_cache_lock:
|
| 55 |
-
_shorts_runtime_cache = items
|
| 56 |
-
_shorts_cache_ts = ts
|
| 57 |
-
|
| 58 |
-
# Also write to file (persistent if /data mounted)
|
| 59 |
-
try:
|
| 60 |
-
os.makedirs(os.path.dirname(SHORTS_CACHE_FILE), exist_ok=True)
|
| 61 |
-
payload = {"items": items, "ts": ts, "count": len(items)}
|
| 62 |
-
with open(SHORTS_CACHE_FILE, "w", encoding="utf-8") as f:
|
| 63 |
-
json.dump(payload, f, ensure_ascii=False, indent=2)
|
| 64 |
-
print(f"[cache] saved {len(items)} shorts to {SHORTS_CACHE_FILE}")
|
| 65 |
-
except Exception as e:
|
| 66 |
-
print(f"[cache] write skipped: {e}")
|
| 67 |
-
|
| 68 |
-
return len(items)
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
def get_cache_status():
|
| 72 |
-
"""Return status dict for the cache"""
|
| 73 |
-
cache = None
|
| 74 |
-
with _shorts_cache_lock:
|
| 75 |
-
if _shorts_runtime_cache is not None:
|
| 76 |
-
cache = _shorts_runtime_cache
|
| 77 |
-
age = int(time.time() - _shorts_cache_ts)
|
| 78 |
-
else:
|
| 79 |
-
age = -1
|
| 80 |
-
return {
|
| 81 |
-
"cached": cache is not None,
|
| 82 |
-
"count": len(cache) if cache else 0,
|
| 83 |
-
"age_seconds": age,
|
| 84 |
-
"has_persistent": os.path.isdir("/data"),
|
| 85 |
-
"cache_file_exists": os.path.exists(SHORTS_CACHE_FILE),
|
| 86 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|