Spaces:
Running
Running
Restore version 98c133a
#21
by bep40 - opened
- .huggingface/rebuild +0 -1
- .rebuild +1 -1
- .restart_trigger +1 -1
- CHANGELOG.md +33 -68
- Dockerfile +0 -1
- README.md +14 -22
- _run.py +1 -1
- ai_ext.py +1004 -441
- ai_patch.py +88 -215
- ai_runtime_final6.py +348 -803
- app_v2_entry.py +109 -517
- main.py +398 -121
- rewrite_fix_v2.js +0 -2
- shorts_cache.py +0 -86
- shorts_rss_proxy.py +0 -114
- static/app_v2.js +206 -258
- static/app_v2_shorts_fix.js +0 -2
- static/index_v2.html +7 -10
- static/rewrite_fix.js +90 -0
- static/shorts_fresh.js +0 -5
- static/vtv_init.js +0 -349
- static/vtv_init_loader.html +0 -2
- static/wc2026_v2.js +1 -1
- static/yt_live.js +311 -154
- vtv_api.py +458 -328
- vtv_scraper.py +51 -67
- yt_scraper_fixed.py +0 -162
.huggingface/rebuild
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
rebuild
|
|
|
|
|
|
.rebuild
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
rebuilt at 2026-06-18T09:24:34.973630
|
.restart_trigger
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
restart
|
|
|
|
| 1 |
+
# restart trigger
|
CHANGELOG.md
CHANGED
|
@@ -1,71 +1,36 @@
|
|
| 1 |
-
# VNEWS
|
| 2 |
|
| 3 |
## Changes
|
| 4 |
|
| 5 |
-
###
|
| 6 |
-
|
| 7 |
-
-
|
| 8 |
-
-
|
| 9 |
-
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
-
|
| 37 |
-
|
| 38 |
-
### Channels monitored
|
| 39 |
-
- baodantri7941 (Dân trí)
|
| 40 |
-
- baosuckhoedoisongboyte (Sức khỏe & đời sống)
|
| 41 |
-
- vtvnambo (VTV Nam Bộ)
|
| 42 |
-
|
| 43 |
-
---
|
| 44 |
-
|
| 45 |
-
# VNEWS v5.1 - Rewrite Fix
|
| 46 |
-
|
| 47 |
-
## Changes
|
| 48 |
-
|
| 49 |
-
### Critical Fix: Rewrite button not creating posts on Tường AI
|
| 50 |
-
**Root cause**: `_run.py` imports from `app_v2_entry.py`, but the `/api/rewrite_share` endpoint was only defined in `ai_runtime_patch_fast.py` (loaded through `app_entry.py` which is NOT used). The frontend called a non-existent endpoint → 404 → silent failure.
|
| 51 |
-
|
| 52 |
-
**Fix applied**:
|
| 53 |
-
1. **app_v2_entry.py** — Added 3 new endpoints:
|
| 54 |
-
- `POST /api/rewrite_slide` — Fast extractive summary (no AI needed), creates slides from article key points + images, saves to wall
|
| 55 |
-
- `POST /api/rewrite_share` — AI-powered rewrite with extractive fallback, saves to wall
|
| 56 |
-
- `POST /api/url_wall` — URL submission endpoint (alias for rewrite_share)
|
| 57 |
-
- All endpoints use the same `_load_wall_posts()` / `_save_wall_posts()` and `WALL_FILE` path as the existing `/api/wall` endpoint
|
| 58 |
-
|
| 59 |
-
2. **static/index_v2.html** — Added `<script src="/static/rewrite_fix_v2.js"></script>` to load the rewrite fix
|
| 60 |
-
|
| 61 |
-
3. **static/rewrite_fix_v2.js** — New file that overrides `rewriteArticle()` to:
|
| 62 |
-
- Call `/api/rewrite_slide` first (fast, no AI needed)
|
| 63 |
-
- Fallback to `/api/rewrite_share` if slide fails
|
| 64 |
-
- Show slide preview overlay after successful post
|
| 65 |
-
- Use `prependWallPost()` to add the new post to Tường AI
|
| 66 |
-
|
| 67 |
-
### Previous changes (v5)
|
| 68 |
-
- Rewrote match_detail_v2.py with correct event parsing
|
| 69 |
-
- 2-tab layout for match detail (stats + timeline)
|
| 70 |
-
- Fixed _run.py import
|
| 71 |
-
- Dockerfile cache busting
|
|
|
|
| 1 |
+
# VNEWS v5 - Match Detail Fix
|
| 2 |
|
| 3 |
## Changes
|
| 4 |
|
| 5 |
+
### 1. match_detail_v2.py — Rewrote event parser with correct selectors
|
| 6 |
+
- Parse `.events > .period > .event` structure (not old `.timeline`)
|
| 7 |
+
- Extract event type from SVG icons in `.event-type` (goal/redcard/yellowcard/substitution)
|
| 8 |
+
- Parse player names from `.players > div` elements
|
| 9 |
+
- For goals: extract scorer + assist names
|
| 10 |
+
- For substitutions: extract player_out → player_in
|
| 11 |
+
- For cards: extract player name
|
| 12 |
+
- Normalize time format: `45' +2` → `45+2'`
|
| 13 |
+
- Fetch H2H stats from `/api/fixtures/h2h-stats` API
|
| 14 |
+
- Parse prediction card, recent matches, H2H standings
|
| 15 |
+
|
| 16 |
+
### 2. static/match_detail.js — Complete rewrite with 2-tab layout
|
| 17 |
+
- **Tab "Thống kê"**: H2H stats comparison, prediction vote, recent match results
|
| 18 |
+
- **Tab "Diễn biến"**: Detailed timeline with:
|
| 19 |
+
- ⚽ BÀN THẮNG — scorer name + assist
|
| 20 |
+
- 🟥 THỺ ĐỎ — player name
|
| 21 |
+
- 🟨 THỺ VÀNG — player name
|
| 22 |
+
- ↔️ THAY ĐỔI — player_out → player_in
|
| 23 |
+
- Period grouping (H1, H2) with visual headers
|
| 24 |
+
- Team badges (HOME/AWAY) per event
|
| 25 |
+
- Color-coded event icons
|
| 26 |
+
|
| 27 |
+
### 3. app_v2_entry.py — Updated
|
| 28 |
+
- Module cache clearing for fresh match_detail_v2 import on each API call
|
| 29 |
+
- Single clean import for both `/detail` and `/live` endpoints
|
| 30 |
+
- Removed duplicate inline scraping code
|
| 31 |
+
|
| 32 |
+
### 4. _run.py — Fixed import
|
| 33 |
+
- Changed `import match_detail` to `import match_detail_v2`
|
| 34 |
+
|
| 35 |
+
### 5. Dockerfile — Cache busting
|
| 36 |
+
- Added `RUN date > /app/.build_timestamp` to force Docker rebuild
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Dockerfile
CHANGED
|
@@ -12,5 +12,4 @@ RUN pip install --no-cache-dir -r requirements.txt || true
|
|
| 12 |
COPY . .
|
| 13 |
EXPOSE 7860
|
| 14 |
|
| 15 |
-
# v9 - 2026-07-08 - FIX streams: removed expired ssaimh tokens. VTV6 only from sv2. Frontend uses yt_live.js CSS + EPG
|
| 16 |
CMD ["uvicorn", "_run:app", "--host", "0.0.0.0", "--port", "7860", "--reload"]
|
|
|
|
| 12 |
COPY . .
|
| 13 |
EXPOSE 7860
|
| 14 |
|
|
|
|
| 15 |
CMD ["uvicorn", "_run:app", "--host", "0.0.0.0", "--port", "7860", "--reload"]
|
README.md
CHANGED
|
@@ -9,25 +9,17 @@ tags:
|
|
| 9 |
- ml-intern
|
| 10 |
---
|
| 11 |
|
| 12 |
-
#
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
-
|
| 26 |
-
- ⚽ Livescore from bongda.com.vn (live, today, upcoming, results, standings)
|
| 27 |
-
- 🎬 Football highlights from xemlaibongda.top (8 leagues)
|
| 28 |
-
- 📺 VTV live channels (VTV1→VTV10, VTV Prime)
|
| 29 |
-
- Priority: ssaimh CDN → sv2.xemtivitop.com → xemtv.us → xemtivitop blogspot → FPTPlay → VTVGo → mediacdn → xemtv.net
|
| 30 |
-
- 🏆 World Cup 2026 (news, fixtures, standings, stats, highlights)
|
| 31 |
-
- 🤖 AI article writing + TTS (multilingual, emotion-aware)
|
| 32 |
-
- 🔍 Topic search (8 news sources)
|
| 33 |
-
- 🎤 TTS: voice selector + emotion selector + speed control
|
|
|
|
| 9 |
- ml-intern
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# bep40/vnews
|
| 13 |
+
<!-- build: 2026-06-12T06:45:00 -->
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
<!-- rebuild: v6.5-final-1781173463 -->
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_run.py
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
from app_v2_entry import app # v5-stable inline bongda proxy
|
|
|
|
| 1 |
+
from app_v2_entry import app # v5-stable inline bongda proxy
|
ai_ext.py
CHANGED
|
@@ -13,16 +13,7 @@ from bs4 import BeautifulSoup
|
|
| 13 |
from fastapi import Request, Query
|
| 14 |
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
|
| 15 |
|
| 16 |
-
|
| 17 |
-
try:
|
| 18 |
-
from main import app
|
| 19 |
-
except ImportError:
|
| 20 |
-
# Create a minimal FastAPI app for standalone testing
|
| 21 |
-
try:
|
| 22 |
-
from fastapi import FastAPI
|
| 23 |
-
app = FastAPI()
|
| 24 |
-
except Exception:
|
| 25 |
-
app = None
|
| 26 |
|
| 27 |
# Import wall store from main.py so we read/write the SAME file
|
| 28 |
try:
|
|
@@ -50,10 +41,6 @@ except ImportError:
|
|
| 50 |
def _web_context(topic):
|
| 51 |
return ""
|
| 52 |
|
| 53 |
-
# ai_ext alias for backward compatibility
|
| 54 |
-
_load_ai_wall = _load_wall
|
| 55 |
-
_save_ai_wall = _save_wall
|
| 56 |
-
|
| 57 |
try:
|
| 58 |
from huggingface_hub import AsyncInferenceClient
|
| 59 |
except Exception:
|
|
@@ -73,150 +60,21 @@ except Exception:
|
|
| 73 |
|
| 74 |
|
| 75 |
def _hf_token():
|
| 76 |
-
for k in ("HF_TOKEN", "
|
| 77 |
v = os.getenv(k, "").strip()
|
| 78 |
if v:
|
| 79 |
return v
|
| 80 |
return ""
|
| 81 |
|
| 82 |
-
|
| 83 |
-
def _clean_text(s: str) -> str:
|
| 84 |
-
"""Clean text for processing."""
|
| 85 |
-
s = html_lib.unescape(s or "")
|
| 86 |
-
s = re.sub(r"\s+", " ", s)
|
| 87 |
-
return s.strip()
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
def _domain(url: str) -> str:
|
| 91 |
-
"""Extract domain from URL."""
|
| 92 |
-
try:
|
| 93 |
-
return urlparse(url or "").netloc.replace("www.", "")
|
| 94 |
-
except Exception:
|
| 95 |
-
return ""
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
async def qwen_generate(prompt: str, image_url: str = None, max_tokens: int = 1200) -> str:
|
| 99 |
-
"""Generate text using Qwen models via Hugging Face Inference API.
|
| 100 |
-
|
| 101 |
-
This function provides a resilient implementation that:
|
| 102 |
-
1. First tries the SDK-based inference client if available
|
| 103 |
-
2. Falls back to REST API calls to HF router endpoint
|
| 104 |
-
3. Returns a fallback summary if all else fails
|
| 105 |
-
"""
|
| 106 |
-
token = _hf_token()
|
| 107 |
-
errors = []
|
| 108 |
-
|
| 109 |
-
# Try HF router API with multiple models
|
| 110 |
-
if token:
|
| 111 |
-
models = [
|
| 112 |
-
os.getenv("QWEN_VL_MODEL", ""),
|
| 113 |
-
"Qwen/Qwen2.5-VL-7B-Instruct",
|
| 114 |
-
"Qwen/Qwen2.5-VL-3B-Instruct",
|
| 115 |
-
"Qwen/Qwen2.5-7B-Instruct",
|
| 116 |
-
"Qwen/Qwen2.5-3B-Instruct",
|
| 117 |
-
"Qwen/Qwen2.5-1.5B-Instruct",
|
| 118 |
-
"Qwen/Qwen2.5-72B-Instruct",
|
| 119 |
-
"meta-llama/Llama-3.3-70B-Instruct",
|
| 120 |
-
]
|
| 121 |
-
# Deduplicate while preserving order
|
| 122 |
-
seen = set()
|
| 123 |
-
models = [m for m in models if m and m not in seen and not seen.add(m)]
|
| 124 |
-
|
| 125 |
-
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
| 126 |
-
|
| 127 |
-
for model in models:
|
| 128 |
-
try:
|
| 129 |
-
is_vl = "VL" in model and image_url
|
| 130 |
-
if is_vl:
|
| 131 |
-
user_content = [
|
| 132 |
-
{"type": "image_url", "image_url": {"url": image_url}},
|
| 133 |
-
{"type": "text", "text": prompt}
|
| 134 |
-
]
|
| 135 |
-
else:
|
| 136 |
-
user_content = prompt
|
| 137 |
-
|
| 138 |
-
payload = {
|
| 139 |
-
"model": model,
|
| 140 |
-
"messages": [
|
| 141 |
-
{"role": "system", "content": "Bạn là trợ lý AI tiếng Việt. Trả lời tự nhiên, ngắn gọn, chính xác."},
|
| 142 |
-
{"role": "user", "content": user_content},
|
| 143 |
-
],
|
| 144 |
-
"max_tokens": min(int(max_tokens or 900), 1400),
|
| 145 |
-
"temperature": 0.35,
|
| 146 |
-
"top_p": 0.85,
|
| 147 |
-
}
|
| 148 |
-
|
| 149 |
-
r = requests.post(
|
| 150 |
-
"https://router.huggingface.co/v1/chat/completions",
|
| 151 |
-
headers=headers,
|
| 152 |
-
json=payload,
|
| 153 |
-
timeout=95
|
| 154 |
-
)
|
| 155 |
-
|
| 156 |
-
if r.status_code >= 300:
|
| 157 |
-
errors.append(f"{model}: HTTP {r.status_code}")
|
| 158 |
-
continue
|
| 159 |
-
|
| 160 |
-
j = r.json()
|
| 161 |
-
txt = (j.get("choices", [{}])[0].get("message", {}).get("content") or "").strip()
|
| 162 |
-
|
| 163 |
-
if txt:
|
| 164 |
-
return txt
|
| 165 |
-
|
| 166 |
-
errors.append(f"{model}: empty response")
|
| 167 |
-
|
| 168 |
-
except Exception as e:
|
| 169 |
-
errors.append(f"{model}: {type(e).__name__}")
|
| 170 |
-
|
| 171 |
-
# Fallback: extractive summary from prompt
|
| 172 |
-
LAST_QWEN_ERROR = errors[-3:] if errors else "unknown error"
|
| 173 |
-
return _fallback_summary_from_prompt(prompt, max_units=6)
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
def _fallback_summary_from_prompt(prompt: str, max_units: int = 6) -> str:
|
| 177 |
-
"""Generate a simple fallback summary when AI is unavailable."""
|
| 178 |
-
text = prompt or ""
|
| 179 |
-
for marker in ["Nội dung nguồn:", "Nội dung bài:", "Nội dung gốc:", "Nội dung:", "Nguồn/bối cảnh internet:"]:
|
| 180 |
-
if marker in text:
|
| 181 |
-
text = text.split(marker, 1)[1]
|
| 182 |
-
break
|
| 183 |
-
text = re.sub(r"https?://\S+", "", text)
|
| 184 |
-
text = re.sub(r"\s+", " ", text).strip()
|
| 185 |
-
|
| 186 |
-
# Split into sentences - extract ALL valid sentences, not just first few
|
| 187 |
-
sentences = re.split(r"(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])", text)
|
| 188 |
-
units = []
|
| 189 |
-
for s in sentences:
|
| 190 |
-
s = _clean_text(s)
|
| 191 |
-
if len(s) >= 30: # Lower threshold to capture more content
|
| 192 |
-
units.append(s)
|
| 193 |
-
|
| 194 |
-
if units:
|
| 195 |
-
# Take up to max_units valid sentences
|
| 196 |
-
result_units = units[:max_units]
|
| 197 |
-
return "\n".join("• " + u for u in result_units)
|
| 198 |
-
if text:
|
| 199 |
-
# Fallback: take chunks if no sentence boundaries found
|
| 200 |
-
chunks = []
|
| 201 |
-
for i in range(0, min(len(text), max_units * 300), 280):
|
| 202 |
-
chunk = _clean_text(text[i:i+300])
|
| 203 |
-
if chunk and chunk not in chunks:
|
| 204 |
-
chunks.append(chunk)
|
| 205 |
-
if len(chunks) >= max_units:
|
| 206 |
-
break
|
| 207 |
-
if chunks:
|
| 208 |
-
return "\n".join("• " + c for c in chunks)
|
| 209 |
-
return "• Không có đủ nội dung để tóm tắt."
|
| 210 |
-
|
| 211 |
-
|
| 212 |
HF_TOKEN = _hf_token()
|
| 213 |
QWEN_VL_MODEL = os.getenv("QWEN_VL_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct")
|
|
|
|
| 214 |
QWEN_TEXT_MODELS = [m.strip() for m in os.getenv(
|
| 215 |
"QWEN_TEXT_MODELS",
|
| 216 |
"Qwen/Qwen2.5-72B-Instruct,meta-llama/Llama-3.3-70B-Instruct,Qwen/Qwen2.5-7B-Instruct"
|
| 217 |
).split(",") if m.strip()]
|
| 218 |
-
_WORKING_MODEL_TEXT = None
|
| 219 |
-
_WORKING_MODEL_VL = None
|
| 220 |
DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data"
|
| 221 |
SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts")
|
| 222 |
HEADERS = {
|
|
@@ -225,350 +83,1055 @@ HEADERS = {
|
|
| 225 |
}
|
| 226 |
LAST_QWEN_ERROR = ""
|
| 227 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
|
| 229 |
-
#
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
"
|
| 234 |
-
"
|
| 235 |
-
"
|
| 236 |
-
|
| 237 |
-
"
|
| 238 |
-
"
|
| 239 |
-
"
|
| 240 |
-
"
|
| 241 |
-
|
| 242 |
-
"
|
| 243 |
-
|
| 244 |
-
"
|
| 245 |
-
|
| 246 |
-
"
|
| 247 |
-
"
|
| 248 |
-
"
|
| 249 |
-
"jenny": "en-US-AndrewMultilingualNeural",
|
| 250 |
-
# Portuguese - Thalita Multilingual ONLY
|
| 251 |
-
"pt-br-thalitamultilingualneural": "pt-BR-ThalitaMultilingualNeural",
|
| 252 |
-
"pt_thalita": "pt-BR-ThalitaMultilingualNeural",
|
| 253 |
-
"thalita": "pt-BR-ThalitaMultilingualNeural",
|
| 254 |
-
"pt_francisco": "pt-BR-ThalitaMultilingualNeural",
|
| 255 |
-
"pt": "pt-BR-ThalitaMultilingualNeural",
|
| 256 |
-
# French - Multilingual
|
| 257 |
-
"fr-fr-viviennemultilingualneural": "fr-FR-VivienneMultilingualNeural",
|
| 258 |
-
"fr-fr-remymultilingualneural": "fr-FR-RemyMultilingualNeural",
|
| 259 |
-
"fr_denise": "fr-FR-VivienneMultilingualNeural",
|
| 260 |
-
"denise": "fr-FR-VivienneMultilingualNeural",
|
| 261 |
-
"fr": "fr-FR-VivienneMultilingualNeural",
|
| 262 |
-
# German - Multilingual
|
| 263 |
-
"de-de-seraphinamultilingualneural": "de-DE-SeraphinaMultilingualNeural",
|
| 264 |
-
"de-de-florianmultilingualneural": "de-DE-FlorianMultilingualNeural",
|
| 265 |
-
"de_katja": "de-DE-SeraphinaMultilingualNeural",
|
| 266 |
-
"katja": "de-DE-SeraphinaMultilingualNeural",
|
| 267 |
-
"de": "de-DE-SeraphinaMultilingualNeural",
|
| 268 |
-
# Korean - Hyunsu Multilingual (NOT SunHee)
|
| 269 |
-
"ko-kr-hyunsumultilingualneural": "ko-KR-HyunsuMultilingualNeural",
|
| 270 |
-
"ko_sunhee": "ko-KR-HyunsuMultilingualNeural",
|
| 271 |
-
"sunhee": "ko-KR-HyunsuMultilingualNeural",
|
| 272 |
-
"ko": "ko-KR-HyunsuMultilingualNeural",
|
| 273 |
-
# Italian - Multilingual
|
| 274 |
-
"it-it-giuseppemultilingualneural": "it-IT-GiuseppeMultilingualNeural",
|
| 275 |
-
# Spanish (fallback to English multilingual)
|
| 276 |
-
"es_ela": "en-US-AndrewMultilingualNeural",
|
| 277 |
-
"ela": "en-US-AndrewMultilingualNeural",
|
| 278 |
-
"es_carlos": "en-US-AndrewMultilingualNeural",
|
| 279 |
-
"es": "en-US-AndrewMultilingualNeural",
|
| 280 |
-
# Japanese (fallback to English multilingual)
|
| 281 |
-
"ja_nanami": "en-US-AndrewMultilingualNeural",
|
| 282 |
-
"nanami": "en-US-AndrewMultilingualNeural",
|
| 283 |
-
"ja": "en-US-AndrewMultilingualNeural",
|
| 284 |
-
# Chinese (fallback to English multilingual)
|
| 285 |
-
"zh_xiaochen": "en-US-AndrewMultilingualNeural",
|
| 286 |
-
"xiaochen": "en-US-AndrewMultilingualNeural",
|
| 287 |
-
"zh": "en-US-AndrewMultilingualNeural",
|
| 288 |
}
|
| 289 |
|
| 290 |
|
| 291 |
-
def
|
| 292 |
-
"""
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
# News categories - choose appropriate voice
|
| 300 |
-
if any(kw in content for kw in ["kinh tế", "tài chính", "thị trường", "economics", "finance"]):
|
| 301 |
-
return ("jenny", "calm")
|
| 302 |
-
if any(kw in content for kw in ["thiên tai", "bão", "lũ lụt", "cháy nổ", "tai nạn", "disaster", "accident"]):
|
| 303 |
-
return ("thalita", "serious")
|
| 304 |
-
if any(kw in content for kw in ["giải trí", "showbiz", "entertainment", "hài hước"]):
|
| 305 |
-
return ("ela", "happy")
|
| 306 |
-
if any(kw in content for kw in ["công nghệ", "tech", "technology", "ai", "trí tuệ nhân tạo"]):
|
| 307 |
-
return ("katja", "excited")
|
| 308 |
-
|
| 309 |
-
# Default Vietnamese
|
| 310 |
-
return ("hoaimy", "trung_tinh")
|
| 311 |
|
| 312 |
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
with open(out_path, "wb") as f:
|
| 328 |
-
f.write(r.content)
|
| 329 |
-
return True
|
| 330 |
-
except Exception:
|
| 331 |
-
pass
|
| 332 |
-
return False
|
| 333 |
|
| 334 |
|
| 335 |
-
def
|
| 336 |
-
"""
|
| 337 |
-
return
|
| 338 |
|
| 339 |
|
| 340 |
-
#
|
| 341 |
-
|
|
|
|
|
|
|
| 342 |
|
| 343 |
-
def
|
| 344 |
-
"""Load AI wall posts from JSON file (uses wall_posts.json for consistency with app_v2_entry)."""
|
| 345 |
try:
|
| 346 |
-
|
| 347 |
-
with open(WALL_FILE, "r", encoding="utf-8") as f:
|
| 348 |
-
return json.load(f)
|
| 349 |
except Exception:
|
| 350 |
-
|
| 351 |
-
return []
|
| 352 |
|
|
|
|
|
|
|
| 353 |
|
| 354 |
-
def _save_ai_wall(posts):
|
| 355 |
-
"""Save AI wall posts to JSON file (uses wall_posts.json for consistency with app_v2_entry)."""
|
| 356 |
-
try:
|
| 357 |
-
os.makedirs(os.path.dirname(WALL_FILE), exist_ok=True)
|
| 358 |
-
tmp = WALL_FILE + ".tmp"
|
| 359 |
-
with open(tmp, "w", encoding="utf-8") as f:
|
| 360 |
-
json.dump(posts[:100], f, ensure_ascii=False)
|
| 361 |
-
os.replace(tmp, WALL_FILE)
|
| 362 |
-
except Exception:
|
| 363 |
-
pass
|
| 364 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
|
| 366 |
-
#
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
|
| 372 |
-
def _save_wall_posts(posts):
|
| 373 |
-
"""Alias for _save_ai_wall for consistency with app_v2_entry.py."""
|
| 374 |
-
return _save_ai_wall(posts)
|
| 375 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
|
| 377 |
-
def make_post(title: str, text: str, img: str, url: str, kind: str, sources=None):
|
| 378 |
-
"""Create a post dict with standard fields."""
|
| 379 |
return {
|
| 380 |
-
"
|
| 381 |
-
"
|
| 382 |
-
"
|
| 383 |
-
"img": img,
|
| 384 |
-
"url": url,
|
| 385 |
-
"kind": kind,
|
| 386 |
-
"sources": sources or [],
|
| 387 |
-
"ts": int(time.time())
|
| 388 |
}
|
| 389 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
|
| 391 |
-
def _short_script(post) -> str:
|
| 392 |
-
"""Extract clean text for TTS from post."""
|
| 393 |
-
text = post.get("text", "") or post.get("title", "")
|
| 394 |
-
text = re.sub(r"^[•\-\*]\s*", "", text, flags=re.M)
|
| 395 |
-
text = re.sub(r"\s*\n\s*", ". ", text)
|
| 396 |
-
return _clean_text(text)[:2000] # Increased from 1000 to 2000 for full content
|
| 397 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
"""
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
# Extract article body
|
| 424 |
-
block = None
|
| 425 |
-
for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
|
| 426 |
-
el = soup.select_one(sel)
|
| 427 |
-
if el and len(el.find_all('p')) >= 2:
|
| 428 |
-
block = el
|
| 429 |
-
break
|
| 430 |
-
if not block:
|
| 431 |
-
block = soup.body or soup
|
| 432 |
-
|
| 433 |
-
# Extract text from paragraphs
|
| 434 |
-
paragraphs = []
|
| 435 |
-
for el in block.find_all(['p', 'h2', 'h3'], recursive=True):
|
| 436 |
-
t = _clean_text(el.get_text(strip=True))
|
| 437 |
-
if t and len(t) > 40:
|
| 438 |
-
paragraphs.append(t)
|
| 439 |
-
|
| 440 |
-
# Extract images
|
| 441 |
-
images = []
|
| 442 |
-
for el in block.find_all(['figure', 'img'], recursive=True):
|
| 443 |
-
im = el if el.name == 'img' else el.find('img')
|
| 444 |
-
if im:
|
| 445 |
-
src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
|
| 446 |
-
if src and 'base64' not in src:
|
| 447 |
-
if src.startswith('//'):
|
| 448 |
-
src = 'https:' + src
|
| 449 |
-
images.append(src)
|
| 450 |
-
|
| 451 |
-
# Prefer OG image as main image
|
| 452 |
-
image = og_image or (images[0] if images else '')
|
| 453 |
-
|
| 454 |
-
return {
|
| 455 |
-
'title': title,
|
| 456 |
-
'summary': paragraphs[0] if paragraphs else '',
|
| 457 |
-
'text': '\n'.join(paragraphs),
|
| 458 |
-
'image': image,
|
| 459 |
-
'og_image': og_image,
|
| 460 |
-
'via': _domain(url),
|
| 461 |
-
'images': images
|
| 462 |
-
}
|
| 463 |
-
except Exception as e:
|
| 464 |
-
return {'title': url, 'summary': '', 'text': '', 'image': '', 'og_image': '', 'via': _domain(url), 'error': str(e)}
|
| 465 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 466 |
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 470 |
try:
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
for it in soup.find_all('item')[:limit]:
|
| 477 |
-
title = it.find('title').get_text(' ', strip=True) if it.find('title') else ''
|
| 478 |
-
link = it.find('link').get_text(strip=True) if it.find('link') else ''
|
| 479 |
-
if title and link:
|
| 480 |
-
sources.append({'title': title, 'url': link, 'via': _domain(link)})
|
| 481 |
except Exception:
|
| 482 |
pass
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
|
|
|
| 486 |
|
| 487 |
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 494 |
if Image is None:
|
| 495 |
-
|
| 496 |
-
# The caller should handle this case
|
| 497 |
-
return False
|
| 498 |
-
|
| 499 |
W, H = 1080, 1920
|
| 500 |
-
|
| 501 |
-
|
| 502 |
try:
|
| 503 |
im = Image.open(img_path).convert("RGB")
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
if im_ratio > target_ratio:
|
| 509 |
-
new_h = target[1]
|
| 510 |
-
new_w = int(new_h * im_ratio)
|
| 511 |
else:
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
im = im.
|
| 516 |
-
left = (new_w - target[0]) // 2
|
| 517 |
-
top = (new_h - target[1]) // 2
|
| 518 |
-
im = im.crop((left, top, left + target[0], top + target[1]))
|
| 519 |
bg.paste(im, (0, 0))
|
| 520 |
except Exception:
|
| 521 |
pass
|
| 522 |
-
|
| 523 |
draw = ImageDraw.Draw(bg)
|
| 524 |
-
|
| 525 |
try:
|
| 526 |
-
|
| 527 |
-
|
|
|
|
| 528 |
except Exception:
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
text = post.get("text", "")
|
| 542 |
-
text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip()
|
| 543 |
-
body_lines = _wrap_text(draw, text, font_body, maxw, 14)
|
| 544 |
-
for ln in body_lines:
|
| 545 |
-
draw.text((margin, y), ln, fill=(220, 220, 220), font=font_body)
|
| 546 |
-
y += 50
|
| 547 |
-
if y > 1640:
|
| 548 |
-
break
|
| 549 |
-
|
| 550 |
bg.save(out_path, quality=92)
|
| 551 |
-
return
|
| 552 |
|
| 553 |
|
| 554 |
-
def
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 560 |
try:
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 566 |
else:
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
from fastapi import Request, Query
|
| 14 |
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
|
| 15 |
|
| 16 |
+
from main import app
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
# Import wall store from main.py so we read/write the SAME file
|
| 19 |
try:
|
|
|
|
| 41 |
def _web_context(topic):
|
| 42 |
return ""
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
try:
|
| 45 |
from huggingface_hub import AsyncInferenceClient
|
| 46 |
except Exception:
|
|
|
|
| 60 |
|
| 61 |
|
| 62 |
def _hf_token():
|
| 63 |
+
for k in ("HF_TOKEN", "HUGGINGFACEHUB_API_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HF_API_TOKEN"):
|
| 64 |
v = os.getenv(k, "").strip()
|
| 65 |
if v:
|
| 66 |
return v
|
| 67 |
return ""
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
HF_TOKEN = _hf_token()
|
| 70 |
QWEN_VL_MODEL = os.getenv("QWEN_VL_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct")
|
| 71 |
+
# Fast TEXT models for summaries that don't need vision (much faster than the VL model).
|
| 72 |
QWEN_TEXT_MODELS = [m.strip() for m in os.getenv(
|
| 73 |
"QWEN_TEXT_MODELS",
|
| 74 |
"Qwen/Qwen2.5-72B-Instruct,meta-llama/Llama-3.3-70B-Instruct,Qwen/Qwen2.5-7B-Instruct"
|
| 75 |
).split(",") if m.strip()]
|
| 76 |
+
_WORKING_MODEL_TEXT = None # cached last-working text model
|
| 77 |
+
_WORKING_MODEL_VL = None # cached last-working vision model
|
| 78 |
DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data"
|
| 79 |
SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts")
|
| 80 |
HEADERS = {
|
|
|
|
| 83 |
}
|
| 84 |
LAST_QWEN_ERROR = ""
|
| 85 |
|
| 86 |
+
# ===== TTS VOICE CONFIG =====
|
| 87 |
+
# Multilingual neural voices grouped by country/language
|
| 88 |
+
# Format: key -> {id, gender, name, country, lang, flag}
|
| 89 |
+
TTS_VOICES = {
|
| 90 |
+
# === VIETNAM (edge-tts) ===
|
| 91 |
+
"hoaimy": {"id": "vi-VN-HoaiMyNeural", "gender": "female", "name": "Hoài My", "country": "Việt Nam", "lang": "vi", "flag": "🇻🇳", "engine": "edge"},
|
| 92 |
+
"namminh": {"id": "vi-VN-NamMinhNeural", "gender": "male", "name": "Nam Minh", "country": "Việt Nam", "lang": "vi", "flag": "🇻🇳", "engine": "edge"},
|
| 93 |
+
# === gTTS (Google, tiếng Việt cơ bản) ===
|
| 94 |
+
"gtts_vi": {"id": "gtts", "gender": "female", "name": "gTTS Google", "country": "Việt Nam", "lang": "vi", "flag": "🇻🇳", "engine": "gtts"},
|
| 95 |
+
# === MULTILINGUAL (đa ngôn ngữ — đọc được tiếng Việt + nhiều thứ tiếng) ===
|
| 96 |
+
"en_au_william": {"id": "en-AU-WilliamMultilingualNeural", "gender": "male", "name": "William (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
|
| 97 |
+
"en_us_andrew": {"id": "en-US-AndrewMultilingualNeural", "gender": "male", "name": "Andrew (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
|
| 98 |
+
"en_us_ava": {"id": "en-US-AvaMultilingualNeural", "gender": "female", "name": "Ava (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
|
| 99 |
+
"en_us_brian": {"id": "en-US-BrianMultilingualNeural", "gender": "male", "name": "Brian (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
|
| 100 |
+
"en_us_emma": {"id": "en-US-EmmaMultilingualNeural", "gender": "female", "name": "Emma (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
|
| 101 |
+
"fr_vivienne": {"id": "fr-FR-VivienneMultilingualNeural","gender": "female", "name": "Vivienne (Đa NN)","country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
|
| 102 |
+
"fr_remy": {"id": "fr-FR-RemyMultilingualNeural", "gender": "male", "name": "Rémy (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
|
| 103 |
+
"de_seraphina": {"id": "de-DE-SeraphinaMultilingualNeural","gender": "female","name": "Seraphina (Đa NN)","country": "Đa ngôn ngữ","lang": "multi", "flag": "🌐", "engine": "edge"},
|
| 104 |
+
"de_florian": {"id": "de-DE-FlorianMultilingualNeural", "gender": "male", "name": "Florian (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
|
| 105 |
+
"it_giuseppe": {"id": "it-IT-GiuseppeMultilingualNeural","gender": "male", "name": "Giuseppe (Đa NN)","country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
|
| 106 |
+
"ko_hyunsu": {"id": "ko-KR-HyunsuMultilingualNeural", "gender": "male", "name": "Hyunsu (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
|
| 107 |
+
"pt_thalita": {"id": "pt-BR-ThalitaMultilingualNeural", "gender": "female", "name": "Thalita (Đa NN)", "country": "Đa ngôn ngữ", "lang": "multi", "flag": "🌐", "engine": "edge"},
|
| 108 |
+
}
|
| 109 |
+
TTS_DEFAULT_VOICE = "hoaimy"
|
| 110 |
+
TTS_DEFAULT_SPEED = 1.2 # 1.2x speed for faster reading
|
| 111 |
|
| 112 |
+
# Topic -> voice mapping (auto-detect based on topic keywords)
|
| 113 |
+
TOPIC_VOICE_MAP = {
|
| 114 |
+
# Sports -> male voice
|
| 115 |
+
"bóng đá": "namminh", "thể thao": "namminh", "world cup": "namminh",
|
| 116 |
+
"premier league": "namminh", "champions league": "namminh", "la liga": "namminh",
|
| 117 |
+
"serie a": "namminh", "bundesliga": "namminh", "v-league": "namminh",
|
| 118 |
+
"tennis": "namminh", "olympic": "namminh", "f1": "namminh", "moto": "namminh",
|
| 119 |
+
# Lifestyle/Health/Entertainment -> female voice
|
| 120 |
+
"sức khỏe": "hoaimy", "làm đẹp": "hoaimy", "giải trí": "hoaimy",
|
| 121 |
+
"âm nhạc": "hoaimy", "phim": "hoaimy", "thời trang": "hoaimy",
|
| 122 |
+
"ẩm thực": "hoaimy", "du lịch": "hoaimy", "gia đình": "hoaimy",
|
| 123 |
+
"tình yêu": "hoaimy", "hôn nhân": "hoaimy", "mẹ và bé": "hoaimy",
|
| 124 |
+
# Tech/Science -> male voice
|
| 125 |
+
"công nghệ": "namminh", "ai": "namminh", "robot": "namminh",
|
| 126 |
+
"khoa học": "namminh", "vũ trụ": "namminh", "điện thoại": "namminh",
|
| 127 |
+
"laptop": "namminh", "game": "namminh",
|
| 128 |
+
# News/Politics/Economy -> male voice
|
| 129 |
+
"chính trị": "namminh", "kinh tế": "namminh", "tài chính": "namminh",
|
| 130 |
+
"chứng khoán": "namminh", "ngân hàng": "namminh", "thị trường": "namminh",
|
| 131 |
+
"xã hội": "namminh", "pháp luật": "namminh", "giáo dục": "namminh",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
}
|
| 133 |
|
| 134 |
|
| 135 |
+
def _detect_voice_for_topic(title: str, text: str) -> str:
|
| 136 |
+
"""Auto-detect the best voice based on topic keywords."""
|
| 137 |
+
combined = (title + " " + text[:500]).lower()
|
| 138 |
+
for keyword, voice_id in TOPIC_VOICE_MAP.items():
|
| 139 |
+
if keyword in combined:
|
| 140 |
+
return voice_id
|
| 141 |
+
return TTS_DEFAULT_VOICE
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
|
| 144 |
+
# ===== EMOTION (CẢM XÚC) FOR TTS =====
|
| 145 |
+
# edge-tts does NOT support Azure express-as styles, so emotion is simulated
|
| 146 |
+
# with pitch + rate(speed multiplier) + volume. Each preset is a delta.
|
| 147 |
+
# rate_mul multiplies the user/auto speed; pitch is absolute Hz; volume is percent.
|
| 148 |
+
EMOTION_PRESETS = {
|
| 149 |
+
"vui": {"label": "Vui tươi", "emoji": "😊", "rate_mul": 1.06, "pitch": "+15Hz", "volume": "+6%"},
|
| 150 |
+
"hao_hung": {"label": "Hào hứng", "emoji": "🔥", "rate_mul": 1.12, "pitch": "+24Hz", "volume": "+12%"},
|
| 151 |
+
"nghiem": {"label": "Nghiêm túc", "emoji": "📰", "rate_mul": 1.00, "pitch": "-3Hz", "volume": "+0%"},
|
| 152 |
+
"tram": {"label": "Trầm ấm", "emoji": "🌙", "rate_mul": 0.94, "pitch": "-10Hz", "volume": "+0%"},
|
| 153 |
+
"buon": {"label": "Buồn/Xúc động", "emoji": "💧", "rate_mul": 0.88, "pitch": "-18Hz", "volume": "-4%"},
|
| 154 |
+
"trung_tinh":{"label": "Trung tính", "emoji": "🎙️", "rate_mul": 1.00, "pitch": "+0Hz", "volume": "+0%"},
|
| 155 |
+
}
|
| 156 |
+
EMOTION_DEFAULT = "trung_tinh"
|
| 157 |
|
| 158 |
+
# Topic keyword -> emotion. Checked in order; first match wins.
|
| 159 |
+
TOPIC_EMOTION_MAP = {
|
| 160 |
+
# Sports / wins -> excited
|
| 161 |
+
"chiến thắng": "hao_hung", "vô địch": "hao_hung", "world cup": "hao_hung",
|
| 162 |
+
"bóng đá": "hao_hung", "thể thao": "hao_hung", "ghi bàn": "hao_hung",
|
| 163 |
+
"champions league": "hao_hung", "premier league": "hao_hung", "chung kết": "hao_hung",
|
| 164 |
+
"olympic": "hao_hung", "kỷ lục": "hao_hung",
|
| 165 |
+
# Entertainment / lifestyle / good news -> cheerful
|
| 166 |
+
"giải trí": "vui", "âm nhạc": "vui", "phim": "vui", "lễ hội": "vui",
|
| 167 |
+
"du lịch": "vui", "ẩm thực": "vui", "thời trang": "vui", "ra mắt": "vui",
|
| 168 |
+
"khai trương": "vui", "tin vui": "vui", "hạnh phúc": "vui",
|
| 169 |
+
# Sad / accidents / loss -> sad
|
| 170 |
+
"tai nạn": "buon", "qua đời": "buon", "tử vong": "buon", "thiệt mạng": "buon",
|
| 171 |
+
"động đất": "buon", "lũ lụt": "buon", "thiên tai": "buon", "cháy": "buon",
|
| 172 |
+
"tang lễ": "buon", "mất tích": "buon", "thương tâm": "buon",
|
| 173 |
+
# Health / science / calm -> calm warm
|
| 174 |
+
"sức khỏe": "tram", "y tế": "tram", "bệnh": "tram", "dinh dưỡng": "tram",
|
| 175 |
+
"tâm lý": "tram", "thiền": "tram", "giấc ngủ": "tram",
|
| 176 |
+
# News / politics / economy / law -> serious
|
| 177 |
+
"chính trị": "nghiem", "kinh tế": "nghiem", "tài chính": "nghiem",
|
| 178 |
+
"chứng khoán": "nghiem", "pháp luật": "nghiem", "tòa án": "nghiem",
|
| 179 |
+
"ngân hàng": "nghiem", "thị trường": "nghiem", "lạm phát": "nghiem",
|
| 180 |
+
"công nghệ": "nghiem", "ai": "nghiem", "khoa học": "nghiem", "giáo dục": "nghiem",
|
| 181 |
+
}
|
| 182 |
|
| 183 |
+
|
| 184 |
+
def _detect_emotion_for_topic(title: str, text: str) -> str:
|
| 185 |
+
"""Auto-detect emotion (cảm xúc) from topic/content keywords."""
|
| 186 |
+
combined = (title + " " + text[:600]).lower()
|
| 187 |
+
for keyword, emo in TOPIC_EMOTION_MAP.items():
|
| 188 |
+
if keyword in combined:
|
| 189 |
+
return emo
|
| 190 |
+
return EMOTION_DEFAULT
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
|
| 193 |
+
def _detect_voice_emotion(title: str, text: str) -> tuple:
|
| 194 |
+
"""Return (voice_id, emotion_id) auto-chosen for the article's topic."""
|
| 195 |
+
return _detect_voice_for_topic(title, text), _detect_emotion_for_topic(title, text)
|
| 196 |
|
| 197 |
|
| 198 |
+
# ===== TEXT HELPERS =====
|
| 199 |
+
def _clean_text(s: str) -> str:
|
| 200 |
+
s = html_lib.unescape(s or "")
|
| 201 |
+
return re.sub(r"\s+", " ", s).strip()
|
| 202 |
|
| 203 |
+
def _domain(u):
|
|
|
|
| 204 |
try:
|
| 205 |
+
return urlparse(u).netloc.replace("www.", "")
|
|
|
|
|
|
|
| 206 |
except Exception:
|
| 207 |
+
return ""
|
|
|
|
| 208 |
|
| 209 |
+
def _safe_name(s):
|
| 210 |
+
return re.sub(r"[^a-zA-Z0-9_-]+", "_", str(s))[:80]
|
| 211 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
|
| 213 |
+
# ===== CLEAN AI OUTPUT =====
|
| 214 |
+
def _clean_ai_output(text: str) -> str:
|
| 215 |
+
"""Remove markdown artifacts, instruction leakage, and aggressively dedup content."""
|
| 216 |
+
if not text:
|
| 217 |
+
return ""
|
| 218 |
+
# Remove markdown headings, bold, italic, horizontal rules
|
| 219 |
+
text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
|
| 220 |
+
text = re.sub(r'\*\*([^*]+)\*\*', r'\1', text)
|
| 221 |
+
text = re.sub(r'\*([^*]+)\*', r'\1', text)
|
| 222 |
+
text = re.sub(r'^---+\s*$', '', text, flags=re.MULTILINE)
|
| 223 |
+
text = re.sub(r'^[-*_]{3,}\s*$', '', text, flags=re.MULTILINE)
|
| 224 |
+
# Remove common AI instruction leakage phrases (entire line)
|
| 225 |
+
leakage = [
|
| 226 |
+
r'Dưới đây là', r'Theo yêu cầu', r'Tôi sẽ viết', r'Tôi sẽ tóm tắt',
|
| 227 |
+
r'Đây là bài', r'Đây là nội dung', r'Bài viết sau đây',
|
| 228 |
+
r'Nội dung (tóm tắt|chính)', r'Nhiệm vụ', r'Vai trò', r'Tôi là',
|
| 229 |
+
r'Dựa trên.*tôi sẽ', r'Hãy', r'Bạn cần', r'Đọc bài viết',
|
| 230 |
+
r'Tôi xin', r'Xin chào', r'Trân trọng', r'Kính thưa',
|
| 231 |
+
r'Dựa trên.*dưới đây', r'Sau đây là', r'Dưới đây là bài',
|
| 232 |
+
]
|
| 233 |
+
for phrase in leakage:
|
| 234 |
+
text = re.sub(r'^' + phrase + r'[^\n]*\n?', '', text, flags=re.MULTILINE | re.IGNORECASE)
|
| 235 |
+
text = re.sub(r'\n{3,}', '\n\n', text)
|
| 236 |
+
# --- Aggressive dedup: split into sentences, remove any that repeats ---
|
| 237 |
+
# Normalize: collapse whitespace, strip
|
| 238 |
+
def _norm(s):
|
| 239 |
+
return re.sub(r'\s+', ' ', s.strip().lower())
|
| 240 |
|
| 241 |
+
# Split by sentence-ending punctuation (keep delimiters)
|
| 242 |
+
raw_parts = re.split(r'(?<=[.!?])\s+', text.strip())
|
| 243 |
+
seen_sentences = set()
|
| 244 |
+
unique_parts = []
|
| 245 |
+
for part in raw_parts:
|
| 246 |
+
n = _norm(part)
|
| 247 |
+
# Skip near-duplicate: if >70% of an existing seen sentence matches
|
| 248 |
+
is_dup = False
|
| 249 |
+
if n:
|
| 250 |
+
if n in seen_sentences:
|
| 251 |
+
is_dup = True
|
| 252 |
+
else:
|
| 253 |
+
partial = re.sub(r'\W+', '', n)
|
| 254 |
+
for seen in seen_sentences:
|
| 255 |
+
seen_clean = re.sub(r'\W+', '', seen)
|
| 256 |
+
# Check substring match for very similar sentences
|
| 257 |
+
if partial and seen_clean and (
|
| 258 |
+
partial in seen_clean or seen_clean in partial
|
| 259 |
+
):
|
| 260 |
+
shorter = min(len(partial), len(seen_clean))
|
| 261 |
+
longer = max(len(partial), len(seen_clean))
|
| 262 |
+
if shorter > 20 and shorter / longer > 0.75:
|
| 263 |
+
is_dup = True
|
| 264 |
+
break
|
| 265 |
+
if is_dup:
|
| 266 |
+
continue
|
| 267 |
+
if n:
|
| 268 |
+
seen_sentences.add(n)
|
| 269 |
+
unique_parts.append(part)
|
| 270 |
|
| 271 |
+
result = ' '.join(unique_parts).strip()
|
| 272 |
+
# Final pass: remove any remaining consecutive duplicate lines
|
| 273 |
+
lines = result.split('\n')
|
| 274 |
+
final_lines = []
|
| 275 |
+
prev_line = ""
|
| 276 |
+
for line in lines:
|
| 277 |
+
stripped = line.strip()
|
| 278 |
+
if stripped and stripped == prev_line:
|
| 279 |
+
continue
|
| 280 |
+
final_lines.append(line)
|
| 281 |
+
prev_line = stripped
|
| 282 |
+
result = '\n'.join(final_lines).strip()
|
| 283 |
+
return result
|
| 284 |
|
|
|
|
|
|
|
|
|
|
| 285 |
|
| 286 |
+
# ===== EXTRACT ALL IMAGES FROM ARTICLE =====
|
| 287 |
+
def _extract_all_images(soup, base_url: str) -> List[Dict]:
|
| 288 |
+
"""Extract ALL content images from an article page using multi-strategy approach."""
|
| 289 |
+
images = []
|
| 290 |
+
seen_urls = set()
|
| 291 |
+
skip_patterns = [
|
| 292 |
+
"avatar", "icon", "logo", "button", "banner-ad", "tracking",
|
| 293 |
+
"beacon", "pixel", "1x1", "spacer", "emoji", "sprite", "placeholder",
|
| 294 |
+
"advertisement", "ads", "widget", "sidebar", "footer-logo",
|
| 295 |
+
]
|
| 296 |
+
|
| 297 |
+
def _add_image(src: str, alt: str = "", source_tag: str = "img"):
|
| 298 |
+
if not src or src.startswith("data:"):
|
| 299 |
+
return
|
| 300 |
+
abs_url = urljoin(base_url, src.strip())
|
| 301 |
+
if abs_url in seen_urls:
|
| 302 |
+
return
|
| 303 |
+
# Skip non-content images by URL pattern
|
| 304 |
+
if any(p in abs_url.lower() for p in skip_patterns):
|
| 305 |
+
return
|
| 306 |
+
# Skip very small images (likely icons)
|
| 307 |
+
try:
|
| 308 |
+
parsed = urlparse(abs_url)
|
| 309 |
+
path = parsed.path.lower()
|
| 310 |
+
if any(path.endswith(ext) for ext in ['.svg', '.ico', '.gif']):
|
| 311 |
+
return
|
| 312 |
+
except Exception:
|
| 313 |
+
pass
|
| 314 |
+
seen_urls.add(abs_url)
|
| 315 |
+
images.append({"url": abs_url, "alt": alt, "source": source_tag})
|
| 316 |
+
|
| 317 |
+
# Strategy 1: Standard <img> tags with all lazy-load attributes
|
| 318 |
+
for img in soup.find_all("img"):
|
| 319 |
+
src = (img.get("src") or img.get("data-src") or img.get("data-lazy-src") or
|
| 320 |
+
img.get("data-original") or img.get("data-srcset", "").split(",")[0].strip().split(" ")[0])
|
| 321 |
+
_add_image(src, alt=img.get("alt", ""), source_tag="img")
|
| 322 |
+
|
| 323 |
+
# Strategy 2: srcset on <img>
|
| 324 |
+
for img in soup.find_all("img", srcset=True):
|
| 325 |
+
for part in img["srcset"].split(","):
|
| 326 |
+
part = part.strip()
|
| 327 |
+
if part:
|
| 328 |
+
_add_image(part.split(" ")[0], alt=img.get("alt", ""), source_tag="srcset")
|
| 329 |
+
|
| 330 |
+
# Strategy 3: <picture> with <source>
|
| 331 |
+
for picture in soup.find_all("picture"):
|
| 332 |
+
for source in picture.find_all("source"):
|
| 333 |
+
srcset = source.get("srcset", "")
|
| 334 |
+
for part in srcset.split(","):
|
| 335 |
+
part = part.strip()
|
| 336 |
+
if part:
|
| 337 |
+
_add_image(part.split(" ")[0], source_tag="picture/srcset")
|
| 338 |
+
fallback_img = picture.find("img")
|
| 339 |
+
if fallback_img:
|
| 340 |
+
_add_image(
|
| 341 |
+
fallback_img.get("src") or fallback_img.get("data-src"),
|
| 342 |
+
alt=fallback_img.get("alt", ""),
|
| 343 |
+
source_tag="picture/img"
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
# Strategy 4: WordPress CMS patterns
|
| 347 |
+
for img in soup.find_all("img", class_=re.compile(r"wp-image|size-large|size-full|aligncenter")):
|
| 348 |
+
_add_image(img.get("data-src") or img.get("src"),
|
| 349 |
+
alt=img.get("alt", ""), source_tag="wp-image")
|
| 350 |
+
|
| 351 |
+
# Strategy 5: Background images in style attributes
|
| 352 |
+
for tag in soup.find_all(style=re.compile(r"background-image")):
|
| 353 |
+
for m in re.findall(r'url\(["\']?(.*?)["\']?\)', tag.get("style", "")):
|
| 354 |
+
_add_image(m, source_tag="background-style")
|
| 355 |
+
|
| 356 |
+
# Strategy 6: og:image (featured/hero image)
|
| 357 |
+
og_image = soup.find("meta", property="og:image")
|
| 358 |
+
if og_image and og_image.get("content"):
|
| 359 |
+
_add_image(og_image["content"], source_tag="og:image")
|
| 360 |
+
|
| 361 |
+
# Strategy 7: twitter:image
|
| 362 |
+
tw_image = soup.find("meta", attrs={"name": "twitter:image"})
|
| 363 |
+
if tw_image and tw_image.get("content"):
|
| 364 |
+
_add_image(tw_image["content"], source_tag="twitter:image")
|
| 365 |
+
|
| 366 |
+
# Strategy 8: <figure> with <figcaption>
|
| 367 |
+
for figure in soup.find_all("figure"):
|
| 368 |
+
img = figure.find("img")
|
| 369 |
+
if img:
|
| 370 |
+
src = img.get("data-src") or img.get("src")
|
| 371 |
+
figcaption = figure.find("figcaption")
|
| 372 |
+
alt = figcaption.get_text(strip=True) if figcaption else img.get("alt", "")
|
| 373 |
+
_add_image(src, alt=alt, source_tag="figure")
|
| 374 |
+
|
| 375 |
+
# Strategy 9: <a> tags linking to images
|
| 376 |
+
for a in soup.find_all("a", href=True):
|
| 377 |
+
href = a["href"]
|
| 378 |
+
if any(href.lower().endswith(ext) for ext in [".jpg", ".jpeg", ".png", ".webp", ".gif"]):
|
| 379 |
+
_add_image(href, alt=a.get_text(strip=True)[:80], source_tag="link")
|
| 380 |
+
|
| 381 |
+
return images
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
# ===== JINA READER =====
|
| 385 |
+
def _reader_url(target_url: str) -> str:
|
| 386 |
+
safe = quote(target_url, safe=":/?#[]@!$&'()*+,;=%")
|
| 387 |
+
return "https://r.jina.ai/http://" + safe
|
| 388 |
+
|
| 389 |
+
def jina_reader_markdown(url: str) -> str:
|
| 390 |
+
jr = _reader_url(url)
|
| 391 |
+
r = requests.get(jr, headers={"Accept": "text/markdown,text/plain,*/*", "X-Return-Format": "markdown", "User-Agent": "Mozilla/5.0"}, timeout=35)
|
| 392 |
+
r.raise_for_status()
|
| 393 |
+
return r.text or ""
|
| 394 |
+
|
| 395 |
+
def _parse_jina_markdown(md: str, url: str):
|
| 396 |
+
lines = [x.rstrip() for x in (md or "").splitlines()]
|
| 397 |
+
title = ""; first_image = ""; all_images = []; content_lines = []; in_content = False
|
| 398 |
+
for ln in lines:
|
| 399 |
+
if ln.startswith("Title:") and not title:
|
| 400 |
+
title = _clean_text(ln.replace("Title:", "", 1)); continue
|
| 401 |
+
if ln.startswith("URL Source:"):
|
| 402 |
+
continue
|
| 403 |
+
if ln.startswith("Markdown Content:"):
|
| 404 |
+
in_content = True; continue
|
| 405 |
+
# Extract ALL images from markdown 
|
| 406 |
+
for mimg in re.finditer(r'!\[[^\]]*\]\((https?://[^)]+)\)', ln):
|
| 407 |
+
img_url = mimg.group(1)
|
| 408 |
+
if img_url not in all_images:
|
| 409 |
+
all_images.append(img_url)
|
| 410 |
+
if not first_image:
|
| 411 |
+
first_image = img_url
|
| 412 |
+
if in_content or (title and not ln.startswith("Title:")):
|
| 413 |
+
if ln.strip():
|
| 414 |
+
content_lines.append(ln)
|
| 415 |
+
text = "\n".join(content_lines)
|
| 416 |
+
text = re.sub(r'!\[[^\]]*\]\([^)]+\)', '', text)
|
| 417 |
+
paras = []
|
| 418 |
+
for part in re.split(r'\n{2,}|\n(?=#{1,3}\s)', text):
|
| 419 |
+
t = _clean_text(re.sub(r'^#{1,6}\s*', '', part))
|
| 420 |
+
if len(t) >= 40:
|
| 421 |
+
paras.append(t)
|
| 422 |
+
if len(paras) >= 35:
|
| 423 |
+
break
|
| 424 |
+
if not title and paras:
|
| 425 |
+
title = paras[0][:90]
|
| 426 |
+
return {"url": url, "title": title or url, "summary": paras[0] if paras else "",
|
| 427 |
+
"text": "\n".join(paras), "image": first_image,
|
| 428 |
+
"images": all_images, "via": "jina"}
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
# ===== WEB SCRAPE (with full image extraction) =====
|
| 432 |
+
def _best_content_block(soup):
|
| 433 |
+
best, best_score = None, 0
|
| 434 |
+
for el in soup.find_all(["article", "main", "section", "div"]):
|
| 435 |
+
ps = el.find_all("p")
|
| 436 |
+
txt = " ".join(p.get_text(" ", strip=True) for p in ps)
|
| 437 |
+
score = len(ps) * 100 + len(txt)
|
| 438 |
+
cls = " ".join(el.get("class", []))
|
| 439 |
+
if any(k in cls.lower() for k in ["content", "article", "detail", "body", "post", "entry"]):
|
| 440 |
+
score += 800
|
| 441 |
+
if score > best_score:
|
| 442 |
+
best, best_score = el, score
|
| 443 |
+
return best
|
| 444 |
+
|
| 445 |
+
def scrape_any_url_direct(url: str):
|
| 446 |
+
r = requests.get(url, headers=HEADERS, timeout=18)
|
| 447 |
+
if r.status_code in {401, 403, 406, 409, 429, 451, 503}:
|
| 448 |
+
raise RuntimeError(f"blocked status {r.status_code}")
|
| 449 |
+
r.encoding = "utf-8"
|
| 450 |
+
soup = BeautifulSoup(r.text, "lxml")
|
| 451 |
+
for tag in soup.find_all(["script", "style", "nav", "footer", "aside", "form", "noscript"]):
|
| 452 |
+
tag.decompose()
|
| 453 |
+
|
| 454 |
+
# Title
|
| 455 |
+
title = soup.find("h1").get_text(" ", strip=True) if soup.find("h1") else ""
|
| 456 |
+
if not title:
|
| 457 |
+
ogt = soup.find("meta", property="og:title") or soup.find("meta", attrs={"name": "title"})
|
| 458 |
+
title = ogt.get("content", "") if ogt else (soup.title.get_text(strip=True) if soup.title else "")
|
| 459 |
+
|
| 460 |
+
# Summary
|
| 461 |
+
desc_tag = soup.find("meta", property="og:description") or soup.find("meta", attrs={"name": "description"})
|
| 462 |
+
summary = desc_tag.get("content", "") if desc_tag else ""
|
| 463 |
+
|
| 464 |
+
# Featured image (og:image)
|
| 465 |
+
img_tag = soup.find("meta", property="og:image") or soup.find("meta", attrs={"name": "twitter:image"})
|
| 466 |
+
image = img_tag.get("content", "") if img_tag else ""
|
| 467 |
+
if image and image.startswith("//"):
|
| 468 |
+
image = "https:" + image
|
| 469 |
+
|
| 470 |
+
# Extract ALL images from the article
|
| 471 |
+
all_images = _extract_all_images(soup, url)
|
| 472 |
+
image_urls = [img["url"] for img in all_images]
|
| 473 |
+
|
| 474 |
+
# Ensure featured image is first
|
| 475 |
+
if image and image not in image_urls:
|
| 476 |
+
image_urls.insert(0, image)
|
| 477 |
+
elif image in image_urls:
|
| 478 |
+
image_urls.remove(image)
|
| 479 |
+
image_urls.insert(0, image)
|
| 480 |
+
|
| 481 |
+
# Content paragraphs
|
| 482 |
+
block = _best_content_block(soup) or soup
|
| 483 |
+
paras, seen_p = [], set()
|
| 484 |
+
for p in block.find_all("p"):
|
| 485 |
+
t = _clean_text(p.get_text(" ", strip=True))
|
| 486 |
+
if len(t) >= 40 and t not in seen_p:
|
| 487 |
+
seen_p.add(t)
|
| 488 |
+
paras.append(t)
|
| 489 |
+
if len(paras) >= 35:
|
| 490 |
+
break
|
| 491 |
+
|
| 492 |
+
if not title and paras:
|
| 493 |
+
title = paras[0][:90]
|
| 494 |
|
|
|
|
|
|
|
| 495 |
return {
|
| 496 |
+
"url": url, "title": title or url, "summary": paras[0] if paras else "",
|
| 497 |
+
"text": "\n".join(paras), "image": image_urls[0] if image_urls else "",
|
| 498 |
+
"images": image_urls, "via": _domain(url)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 499 |
}
|
| 500 |
|
| 501 |
+
def scrape_any_url(url: str):
|
| 502 |
+
"""Try direct scrape first, fall back to Jina Reader."""
|
| 503 |
+
data = scrape_any_url_direct(url)
|
| 504 |
+
raw_text = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
|
| 505 |
+
if len(raw_text) >= 120:
|
| 506 |
+
return data
|
| 507 |
+
try:
|
| 508 |
+
md = jina_reader_markdown(url)
|
| 509 |
+
if md:
|
| 510 |
+
jr = _parse_jina_markdown(md, url)
|
| 511 |
+
if jr.get("text"):
|
| 512 |
+
if data.get("title") and data["title"] != url:
|
| 513 |
+
jr["title"] = data["title"]
|
| 514 |
+
if data.get("image"):
|
| 515 |
+
jr["image"] = data["image"]
|
| 516 |
+
if data.get("images"):
|
| 517 |
+
jr["images"] = data["images"]
|
| 518 |
+
jr["via"] = data.get("via", _domain(url)) + " + jina"
|
| 519 |
+
return jr
|
| 520 |
+
except Exception:
|
| 521 |
+
pass
|
| 522 |
+
return data
|
| 523 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 524 |
|
| 525 |
+
# ===== POLLINATIONS IMAGE =====
|
| 526 |
+
def pollinations_image_url(topic: str) -> str:
|
| 527 |
+
prompt = "editorial illustration, Vietnamese news, " + topic
|
| 528 |
+
return "https://image.pollinations.ai/prompt/" + quote(prompt, safe="") + "?width=1024&height=576&nologo=true"
|
| 529 |
|
| 530 |
+
|
| 531 |
+
@app.get("/api/ai/probe")
|
| 532 |
+
async def api_ai_probe():
|
| 533 |
+
"""Diagnostic: test which chat models actually work on this token + their latency."""
|
| 534 |
+
import time as _t
|
| 535 |
+
tok = _hf_token()
|
| 536 |
+
out = []
|
| 537 |
+
extra = os.getenv("PROBE_MODELS", "").split(",")
|
| 538 |
+
cand = [m.strip() for m in extra if m.strip()] + [
|
| 539 |
+
"Qwen/Qwen2.5-VL-7B-Instruct", "Qwen/Qwen2.5-VL-3B-Instruct", "Qwen/Qwen2-VL-7B-Instruct",
|
| 540 |
+
"Qwen/Qwen2.5-VL-72B-Instruct", "Qwen/Qwen3-8B", "Qwen/Qwen3-4B", "Qwen/Qwen3-32B",
|
| 541 |
+
"Qwen/Qwen2.5-7B-Instruct-1M", "meta-llama/Llama-3.2-3B-Instruct"]
|
| 542 |
+
seen = set()
|
| 543 |
+
for m in cand:
|
| 544 |
+
if not m or m in seen:
|
| 545 |
+
continue
|
| 546 |
+
seen.add(m)
|
| 547 |
+
t0 = _t.time()
|
| 548 |
+
try:
|
| 549 |
+
c = AsyncInferenceClient(provider="auto", api_key=tok, timeout=40)
|
| 550 |
+
r = await c.chat_completion(model=m, messages=[{"role": "user", "content": "Trả lời đúng 1 từ: xin chào"}], max_tokens=10)
|
| 551 |
+
out.append({"model": m, "ok": True, "sec": round(_t.time() - t0, 1), "txt": (r.choices[0].message.content or "")[:30]})
|
| 552 |
+
except Exception as e:
|
| 553 |
+
out.append({"model": m, "ok": False, "sec": round(_t.time() - t0, 1), "err": (type(e).__name__ + ": " + str(e))[-300:]})
|
| 554 |
+
return JSONResponse({"results": out})
|
| 555 |
+
|
| 556 |
+
|
| 557 |
+
# ===== QWEN AI (strict, concise) =====
|
| 558 |
+
async def qwen_generate(prompt: str, image_url: Optional[str] = None, max_tokens: int = 500, image_urls: Optional[List[str]] = None):
|
| 559 |
+
global LAST_QWEN_ERROR, HF_TOKEN
|
| 560 |
+
HF_TOKEN = _hf_token()
|
| 561 |
+
if not HF_TOKEN:
|
| 562 |
+
LAST_QWEN_ERROR = "Không tìm thấy token"
|
| 563 |
+
return None
|
| 564 |
+
if not AsyncInferenceClient:
|
| 565 |
+
LAST_QWEN_ERROR = "Thiếu huggingface_hub"
|
| 566 |
+
return None
|
| 567 |
+
errors = []; models = []
|
| 568 |
+
has_images = bool(image_urls) or bool(image_url)
|
| 569 |
+
if has_images:
|
| 570 |
+
# Vision needed -> VL models
|
| 571 |
+
candidate = [QWEN_VL_MODEL, "Qwen/Qwen2.5-VL-7B-Instruct", "Qwen/Qwen2.5-VL-3B-Instruct"]
|
| 572 |
+
else:
|
| 573 |
+
# Text-only summary -> FAST text models first, VL only as last resort.
|
| 574 |
+
candidate = QWEN_TEXT_MODELS + [QWEN_VL_MODEL]
|
| 575 |
+
# Use the last-known-working model first to avoid wasting time on unavailable models.
|
| 576 |
+
global _WORKING_MODEL_TEXT, _WORKING_MODEL_VL
|
| 577 |
+
cached_ok = _WORKING_MODEL_VL if has_images else _WORKING_MODEL_TEXT
|
| 578 |
+
if cached_ok and cached_ok in candidate:
|
| 579 |
+
candidate = [cached_ok] + [m for m in candidate if m != cached_ok]
|
| 580 |
+
for m in candidate:
|
| 581 |
+
if m and m not in models:
|
| 582 |
+
models.append(m)
|
| 583 |
+
for model in models:
|
| 584 |
+
try:
|
| 585 |
+
client = AsyncInferenceClient(provider="auto", api_key=HF_TOKEN, timeout=60)
|
| 586 |
+
content = []
|
| 587 |
+
# Collect all images: image_urls list takes priority, fall back to single image_url
|
| 588 |
+
all_img_urls = []
|
| 589 |
+
if image_urls:
|
| 590 |
+
all_img_urls = image_urls[:6] # max 6 images to avoid context overflow
|
| 591 |
+
elif image_url:
|
| 592 |
+
all_img_urls = [image_url]
|
| 593 |
+
for img_u in all_img_urls:
|
| 594 |
+
if img_u and img_u.startswith("http"):
|
| 595 |
+
content.append({"type": "image_url", "image_url": {"url": img_u}})
|
| 596 |
+
content.append({"type": "text", "text": prompt})
|
| 597 |
+
messages = [
|
| 598 |
+
{"role": "system", "content": (
|
| 599 |
+
"Bạn là biên tập viên báo điện tử tiếng Việt. "
|
| 600 |
+
"NHIỆM VỤ: Chỉ TÓM TẮT nội dung, KHÔNG viết lại bài đầy đủ. "
|
| 601 |
+
"QUY TẮC CỨNG: "
|
| 602 |
+
"(1) KHÔNG lặp lại bất kỳ nội dung nào — mỗi ý chỉ xuất hiện ĐÚNG 1 LẦN. "
|
| 603 |
+
"(2) Nếu 2 câu diễn đạt cùng 1 ý → bỏ cây thứ 2. "
|
| 604 |
+
"(3) KHÔNG dùng Markdown (##, **, ---, *). "
|
| 605 |
+
"(4) KHÔNG viết 'Dưới đây là', 'Tôi sẽ', 'Theo yêu cầu', 'Nhiệm vụ', 'Vai trò', 'Đây là bài tóm tắt'. "
|
| 606 |
+
"(5) KHÔNG bịa thông tin ngoài nguồn. "
|
| 607 |
+
"(6) Chỉ viết ĐOẠN VĂN THUẦN, không bullet points. "
|
| 608 |
+
"(7) Tối đa 200 từ. Ngắn gọn, súc tích."
|
| 609 |
+
)},
|
| 610 |
+
{"role": "user", "content": content}
|
| 611 |
+
]
|
| 612 |
+
resp = await client.chat_completion(model=model, messages=messages, max_tokens=max_tokens, temperature=0.3, top_p=0.8)
|
| 613 |
+
txt = (resp.choices[0].message.content or "").strip()
|
| 614 |
+
if txt:
|
| 615 |
+
LAST_QWEN_ERROR = ""
|
| 616 |
+
if has_images: _WORKING_MODEL_VL = model
|
| 617 |
+
else: _WORKING_MODEL_TEXT = model
|
| 618 |
+
return txt
|
| 619 |
+
except Exception as e:
|
| 620 |
+
errors.append(f"{model}: {type(e).__name__}: {str(e)[:220]}")
|
| 621 |
+
LAST_QWEN_ERROR = " | ".join(errors) or "Qwen không trả nội dung."
|
| 622 |
+
print("[qwen errors]", LAST_QWEN_ERROR)
|
| 623 |
+
return None
|
| 624 |
+
|
| 625 |
+
|
| 626 |
+
# ===== TTS GENERATION =====
|
| 627 |
+
async def _generate_tts_edge(text: str, voice_id: str, speed: float, out_path: str, emotion: str = None):
|
| 628 |
+
"""Generate TTS using edge-tts (or gTTS if engine=gtts) with voice, speed & emotion control.
|
| 629 |
+
|
| 630 |
+
Emotion (cảm xúc) is simulated via pitch + rate + volume (edge-tts has no express-as).
|
| 631 |
"""
|
| 632 |
+
vcfg = TTS_VOICES.get(voice_id, TTS_VOICES[TTS_DEFAULT_VOICE])
|
| 633 |
+
# gTTS engine (no voice/speed/emotion control)
|
| 634 |
+
if vcfg.get("engine") == "gtts":
|
| 635 |
+
_generate_tts_gtts(text, out_path)
|
| 636 |
+
return
|
| 637 |
+
if edge_tts is None:
|
| 638 |
+
raise RuntimeError("edge-tts chưa cài đặt")
|
| 639 |
+
voice = vcfg["id"]
|
| 640 |
+
emo = EMOTION_PRESETS.get(emotion or EMOTION_DEFAULT, EMOTION_PRESETS[EMOTION_DEFAULT])
|
| 641 |
+
# Apply emotion rate multiplier on top of base speed
|
| 642 |
+
eff_speed = speed * emo.get("rate_mul", 1.0)
|
| 643 |
+
pct = int(round((eff_speed - 1.0) * 100))
|
| 644 |
+
rate = f"+{pct}%" if pct >= 0 else f"{pct}%"
|
| 645 |
+
pitch = emo.get("pitch", "+0Hz")
|
| 646 |
+
volume = emo.get("volume", "+0%")
|
| 647 |
+
communicate = edge_tts.Communicate(text, voice, rate=rate, pitch=pitch, volume=volume)
|
| 648 |
+
await communicate.save(out_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 649 |
|
| 650 |
+
def _generate_tts_gtts(text: str, out_path: str):
|
| 651 |
+
"""Fallback TTS using gTTS (no voice/speed control)."""
|
| 652 |
+
if gTTS is None:
|
| 653 |
+
raise RuntimeError("gTTS chưa cài đặt")
|
| 654 |
+
gTTS(text, lang="vi").save(out_path)
|
| 655 |
|
| 656 |
+
|
| 657 |
+
# ===== SHORT VIDEO GENERATION (multi-segment: each key point with its own image) =====
|
| 658 |
+
def _download_image(url, fallback_topic, out_path):
|
| 659 |
+
"""Download an image (un-proxying our own /api/proxy/img). Falls back to generated image."""
|
| 660 |
+
if url:
|
| 661 |
+
u = url
|
| 662 |
+
m = re.search(r'/api/proxy/img\?url=(.+)$', u)
|
| 663 |
+
if m:
|
| 664 |
+
from urllib.parse import unquote
|
| 665 |
+
u = unquote(m.group(1))
|
| 666 |
+
try:
|
| 667 |
+
r = requests.get(u, headers={**HEADERS, "Referer": "https://dantri.com.vn/"}, timeout=15)
|
| 668 |
+
if r.status_code == 200 and len(r.content) > 1000:
|
| 669 |
+
with open(out_path, "wb") as f:
|
| 670 |
+
f.write(r.content)
|
| 671 |
+
if Image:
|
| 672 |
+
Image.open(out_path).verify()
|
| 673 |
+
return out_path
|
| 674 |
+
except Exception:
|
| 675 |
+
pass
|
| 676 |
+
gen = pollinations_image_url(fallback_topic)
|
| 677 |
try:
|
| 678 |
+
r = requests.get(gen, headers=HEADERS, timeout=25)
|
| 679 |
+
if r.status_code == 200 and len(r.content) > 1000:
|
| 680 |
+
with open(out_path, "wb") as f:
|
| 681 |
+
f.write(r.content)
|
| 682 |
+
return out_path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 683 |
except Exception:
|
| 684 |
pass
|
| 685 |
+
if Image:
|
| 686 |
+
Image.new("RGB", (1080, 980), (30, 55, 42)).save(out_path)
|
| 687 |
+
return out_path
|
| 688 |
+
raise RuntimeError("Không tạo được ảnh")
|
| 689 |
|
| 690 |
|
| 691 |
+
def _split_keypoint_sentences(text, max_points=6):
|
| 692 |
+
"""Split summary text into key points: prefer bullet markers, else sentences."""
|
| 693 |
+
text = _clean_text(text)
|
| 694 |
+
parts = re.split(r'\s*•\s*', text)
|
| 695 |
+
good = [p.strip() for p in parts if len(p.strip()) > 20]
|
| 696 |
+
if len(good) >= 2:
|
| 697 |
+
# Explicit bullet points: keep each one as-is (never merge).
|
| 698 |
+
return good[:max_points]
|
| 699 |
+
# Fallback: split into sentences and merge orphan short fragments.
|
| 700 |
+
pts = [p.strip() for p in re.split(r'(?<=[.!?])\s+', text) if len(p.strip()) > 20]
|
| 701 |
+
out = []
|
| 702 |
+
for p in pts:
|
| 703 |
+
if out and len(p) < 40:
|
| 704 |
+
out[-1] = (out[-1] + " " + p).strip()
|
| 705 |
+
else:
|
| 706 |
+
out.append(p)
|
| 707 |
+
return out[:max_points] if out else ([text] if text else [])
|
| 708 |
+
|
| 709 |
+
|
| 710 |
+
def _build_keypoints(post, max_points=6):
|
| 711 |
+
"""Return [{text, image}] pairing each key point with its own image."""
|
| 712 |
+
slides = post.get("slides") or []
|
| 713 |
+
images = post.get("images") or ([post.get("img")] if post.get("img") else [])
|
| 714 |
+
images = [i for i in images if i]
|
| 715 |
+
if slides:
|
| 716 |
+
kps = []
|
| 717 |
+
for i, s in enumerate(slides[:max_points]):
|
| 718 |
+
t = _clean_text(s.get("text", ""))
|
| 719 |
+
img = s.get("image") or (images[i] if i < len(images) else (images[-1] if images else ""))
|
| 720 |
+
if t:
|
| 721 |
+
kps.append({"text": t, "image": img})
|
| 722 |
+
if kps:
|
| 723 |
+
return kps
|
| 724 |
+
points = _split_keypoint_sentences(post.get("text", ""), max_points)
|
| 725 |
+
kps = []
|
| 726 |
+
for i, t in enumerate(points):
|
| 727 |
+
img = images[i] if i < len(images) else (images[-1] if images else "")
|
| 728 |
+
kps.append({"text": t, "image": img})
|
| 729 |
+
if not kps:
|
| 730 |
+
kps = [{"text": _clean_text(post.get("title", "")) or "VNEWS", "image": images[0] if images else ""}]
|
| 731 |
+
return kps
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
def _wrap_text(draw, text, font, max_w):
|
| 735 |
+
words = text.split()
|
| 736 |
+
lines, cur = [], ""
|
| 737 |
+
for w in words:
|
| 738 |
+
test = (cur + " " + w).strip()
|
| 739 |
+
if draw.textlength(test, font=font) <= max_w:
|
| 740 |
+
cur = test
|
| 741 |
+
else:
|
| 742 |
+
if cur:
|
| 743 |
+
lines.append(cur)
|
| 744 |
+
cur = w
|
| 745 |
+
if cur:
|
| 746 |
+
lines.append(cur)
|
| 747 |
+
return lines
|
| 748 |
+
|
| 749 |
+
|
| 750 |
+
def _make_segment_frame(title, point_text, img_path, idx, total, out_path):
|
| 751 |
+
"""Render a 1080x1920 vertical frame: image on top, key point text below."""
|
| 752 |
if Image is None:
|
| 753 |
+
raise RuntimeError("Pillow chưa sẵn sàng")
|
|
|
|
|
|
|
|
|
|
| 754 |
W, H = 1080, 1920
|
| 755 |
+
IMG_H = 980
|
| 756 |
+
bg = Image.new("RGB", (W, H), (12, 14, 18))
|
| 757 |
try:
|
| 758 |
im = Image.open(img_path).convert("RGB")
|
| 759 |
+
tr = W / IMG_H
|
| 760 |
+
ir = im.width / im.height
|
| 761 |
+
if ir > tr:
|
| 762 |
+
nh = IMG_H; nw = int(nh * ir)
|
|
|
|
|
|
|
|
|
|
| 763 |
else:
|
| 764 |
+
nw = W; nh = int(nw / ir)
|
| 765 |
+
im = im.resize((nw, nh))
|
| 766 |
+
left = (nw - W) // 2; top = (nh - IMG_H) // 2
|
| 767 |
+
im = im.crop((left, top, left + W, top + IMG_H))
|
|
|
|
|
|
|
|
|
|
| 768 |
bg.paste(im, (0, 0))
|
| 769 |
except Exception:
|
| 770 |
pass
|
|
|
|
| 771 |
draw = ImageDraw.Draw(bg)
|
| 772 |
+
draw.rectangle((0, IMG_H, W, H), fill=(12, 14, 18))
|
| 773 |
try:
|
| 774 |
+
f_label = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 34)
|
| 775 |
+
f_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 46)
|
| 776 |
+
f_point = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 52)
|
| 777 |
except Exception:
|
| 778 |
+
f_label = f_title = f_point = ImageFont.load_default()
|
| 779 |
+
draw.text((54, IMG_H + 24), "VNEWS · Tường AI", fill=(92, 184, 122), font=f_label)
|
| 780 |
+
cnt = f"{idx + 1}/{total}"
|
| 781 |
+
draw.text((W - 54 - draw.textlength(cnt, font=f_label), IMG_H + 24), cnt, fill=(240, 192, 64), font=f_label)
|
| 782 |
+
y = IMG_H + 86
|
| 783 |
+
for ln in _wrap_text(draw, _clean_text(title), f_title, W - 108)[:2]:
|
| 784 |
+
draw.text((54, y), ln, fill=(255, 255, 255), font=f_title)
|
| 785 |
+
y += 56
|
| 786 |
+
y += 16
|
| 787 |
+
for ln in _wrap_text(draw, _clean_text(point_text), f_point, W - 108)[:11]:
|
| 788 |
+
draw.text((54, y), ln, fill=(225, 230, 235), font=f_point)
|
| 789 |
+
y += 64
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 790 |
bg.save(out_path, quality=92)
|
| 791 |
+
return out_path
|
| 792 |
|
| 793 |
|
| 794 |
+
def _ffmpeg_bin():
|
| 795 |
+
return os.environ.get("FFMPEG_BIN", "ffmpeg")
|
| 796 |
+
|
| 797 |
+
|
| 798 |
+
def _audio_duration(path):
|
| 799 |
+
try:
|
| 800 |
+
out = subprocess.run([_ffmpeg_bin(), "-i", path], capture_output=True, text=True, timeout=30).stderr
|
| 801 |
+
m = re.search(r"Duration:\s*(\d+):(\d+):(\d+\.\d+)", out)
|
| 802 |
+
if m:
|
| 803 |
+
h, mi, s = m.groups()
|
| 804 |
+
return int(h) * 3600 + int(mi) * 60 + float(s)
|
| 805 |
+
except Exception:
|
| 806 |
+
pass
|
| 807 |
+
return 0.0
|
| 808 |
+
|
| 809 |
+
|
| 810 |
+
async def _generate_short_video(post, post_id: str, voice_id: str = None, speed: float = None, emotion: str = None) -> str:
|
| 811 |
+
"""Generate a multi-segment MP4 short: each key point shown with its OWN image + narration."""
|
| 812 |
+
try:
|
| 813 |
+
os.makedirs(SHORTS_DIR, exist_ok=True)
|
| 814 |
+
out_mp4 = os.path.join(SHORTS_DIR, _safe_name(post_id) + ".mp4")
|
| 815 |
+
if os.path.exists(out_mp4) and voice_id is None and speed is None and emotion is None:
|
| 816 |
+
return "/api/ai/short-file/" + post_id
|
| 817 |
+
|
| 818 |
+
work = os.path.join(SHORTS_DIR, _safe_name(post_id) + "_work")
|
| 819 |
+
os.makedirs(work, exist_ok=True)
|
| 820 |
+
|
| 821 |
+
title = _clean_text(post.get("title", "")) or "VNEWS"
|
| 822 |
+
kps = _build_keypoints(post)
|
| 823 |
+
|
| 824 |
+
# Resolve voice + emotion (auto from topic, or from post, or explicit args)
|
| 825 |
+
auto_voice, auto_emotion = _detect_voice_emotion(post.get("title", ""), post.get("text", ""))
|
| 826 |
+
if voice_id is None:
|
| 827 |
+
voice_id = post.get("voice") or auto_voice
|
| 828 |
+
if emotion is None:
|
| 829 |
+
emotion = post.get("emotion") or auto_emotion
|
| 830 |
+
if speed is None:
|
| 831 |
+
speed = TTS_DEFAULT_SPEED
|
| 832 |
+
vcfg = TTS_VOICES.get(voice_id, TTS_VOICES[TTS_DEFAULT_VOICE])
|
| 833 |
+
|
| 834 |
+
seg_files = []
|
| 835 |
+
ff = _ffmpeg_bin()
|
| 836 |
+
for i, kp in enumerate(kps):
|
| 837 |
+
img_path = os.path.join(work, f"img{i}.jpg")
|
| 838 |
+
frame_path = os.path.join(work, f"frame{i}.jpg")
|
| 839 |
+
audio_path = os.path.join(work, f"voice{i}.mp3")
|
| 840 |
+
seg_mp4 = os.path.join(work, f"seg{i}.mp4")
|
| 841 |
+
_download_image(kp.get("image", ""), title, img_path)
|
| 842 |
+
_make_segment_frame(title, kp["text"], img_path, i, len(kps), frame_path)
|
| 843 |
+
narration = (title + ". " + kp["text"]) if i == 0 else kp["text"]
|
| 844 |
+
try:
|
| 845 |
+
await _generate_tts_edge(narration, voice_id, speed, audio_path, emotion=emotion)
|
| 846 |
+
except Exception as e:
|
| 847 |
+
print(f"[TTS edge-tts error] {e}, falling back to gTTS")
|
| 848 |
+
if gTTS:
|
| 849 |
+
_generate_tts_gtts(narration, audio_path)
|
| 850 |
+
else:
|
| 851 |
+
return ""
|
| 852 |
+
dur = _audio_duration(audio_path)
|
| 853 |
+
if dur < 1.0:
|
| 854 |
+
dur = 2.0
|
| 855 |
+
cmd = [ff, "-y", "-loop", "1", "-i", frame_path, "-i", audio_path,
|
| 856 |
+
"-c:v", "libx264", "-tune", "stillimage", "-pix_fmt", "yuv420p",
|
| 857 |
+
"-t", f"{dur + 0.4:.2f}", "-c:a", "aac", "-b:a", "128k", "-ar", "44100",
|
| 858 |
+
"-vf", "scale=1080:1920", "-r", "25", seg_mp4]
|
| 859 |
+
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
|
| 860 |
+
seg_files.append(seg_mp4)
|
| 861 |
+
|
| 862 |
+
if not seg_files:
|
| 863 |
+
return ""
|
| 864 |
+
if len(seg_files) == 1:
|
| 865 |
+
os.replace(seg_files[0], out_mp4)
|
| 866 |
+
return "/api/ai/short-file/" + post_id
|
| 867 |
+
|
| 868 |
+
listfile = os.path.join(work, "concat.txt")
|
| 869 |
+
with open(listfile, "w", encoding="utf-8") as f:
|
| 870 |
+
f.write("\n".join(f"file '{s}'" for s in seg_files))
|
| 871 |
+
cmd = [ff, "-y", "-f", "concat", "-safe", "0", "-i", listfile,
|
| 872 |
+
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k", out_mp4]
|
| 873 |
+
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=300)
|
| 874 |
+
return "/api/ai/short-file/" + post_id
|
| 875 |
+
except Exception as e:
|
| 876 |
+
print(f"[short video error] {e}")
|
| 877 |
+
return ""
|
| 878 |
+
|
| 879 |
+
import threading as _threading
|
| 880 |
+
def _spawn_background_video(post):
|
| 881 |
+
"""Generate the short video in a BACKGROUND thread so the rewrite/topic endpoint
|
| 882 |
+
can return immediately. When done, persist the video URL onto the wall post.
|
| 883 |
+
This is the main fix for 'rewrite tu cac nguon tin qua lau' — the user gets the
|
| 884 |
+
text post instantly; the video appears shortly after (or via the 'Tao Video' button)."""
|
| 885 |
+
pid = post.get("id")
|
| 886 |
+
if not pid:
|
| 887 |
+
return
|
| 888 |
+
def _run():
|
| 889 |
try:
|
| 890 |
+
loop = asyncio.new_event_loop()
|
| 891 |
+
asyncio.set_event_loop(loop)
|
| 892 |
+
video_url = loop.run_until_complete(_generate_short_video(post, pid))
|
| 893 |
+
loop.close()
|
| 894 |
+
if video_url:
|
| 895 |
+
posts = _load_wall()
|
| 896 |
+
for i, p in enumerate(posts):
|
| 897 |
+
if str(p.get("id")) == str(pid):
|
| 898 |
+
posts[i]["video"] = video_url
|
| 899 |
+
break
|
| 900 |
+
_save_wall(posts)
|
| 901 |
+
print(f"[bg-video] done {pid} -> {video_url}")
|
| 902 |
+
except Exception as e:
|
| 903 |
+
print(f"[bg-video] error {pid}: {e}")
|
| 904 |
+
_threading.Thread(target=_run, daemon=True).start()
|
| 905 |
+
|
| 906 |
+
# ===== MAKE POST =====
|
| 907 |
+
def make_post(title, text, image, source_url, kind, sources=None, images=None, voice=None, emotion=None):
|
| 908 |
+
# Auto-pick voice + emotion from the article topic when not provided
|
| 909 |
+
auto_voice, auto_emotion = _detect_voice_emotion(title or "", text or "")
|
| 910 |
+
return {
|
| 911 |
+
"id": str(int(time.time() * 1000)) + str(random.randint(100, 999)),
|
| 912 |
+
"title": title, "text": text, "img": image, "url": source_url,
|
| 913 |
+
"kind": kind, "sources": sources or [], "video": "",
|
| 914 |
+
"images": images or [], "ts": int(time.time()),
|
| 915 |
+
"voice": voice or auto_voice,
|
| 916 |
+
"emotion": emotion or auto_emotion,
|
| 917 |
+
}
|
| 918 |
+
|
| 919 |
+
|
| 920 |
+
# ===== SHARED PROMPT BUILDER =====
|
| 921 |
+
def _build_rewrite_prompt(title: str, raw: str, images: List[str] = None) -> str:
|
| 922 |
+
image_info = ""
|
| 923 |
+
if images:
|
| 924 |
+
num = len(images)
|
| 925 |
+
if num == 1:
|
| 926 |
+
image_info = "\n\nBài viết có 1 ảnh minh họa. Hãy tham khảo ảnh để hiểu ngữ cảnh (nếu phù hợp)."
|
| 927 |
else:
|
| 928 |
+
image_info = f"\n\nBài viết có {num} ảnh minh họa. Hãy tham khảo tất cả ảnh để hiểu ngữ cảnh và bổ sung thông tin cho bài viết (nếu phù hợp)."
|
| 929 |
+
|
| 930 |
+
return f"""Tóm tắt bài viết sau thành bài TÓM TẮT đăng Tường AI.
|
| 931 |
+
|
| 932 |
+
QUY TẮC BẮT BUỘC:
|
| 933 |
+
1. Chỉ viết TÓM TẮT các ý chính. KHÔNG sao chép nguyên văn từ bài gốc.
|
| 934 |
+
2. KHÔNG lặp lại bất kỳ nội dung nào. Mỗi thông tin chỉ xuất hiện ĐÚNG 1 LẦN.
|
| 935 |
+
3. Nếu 2 câu nói cùng 1 ý → chỉ giữ 1 câu, bỏ cây còn lại.
|
| 936 |
+
4. KHÔNG dùng Markdown (##, **, ---, *).
|
| 937 |
+
5. KHÔNG viết "Dưới đây là", "Tôi sẽ", "Theo yêu cầu", "Nhiệm vụ", "Vai trò", "Đây là bài tóm tắt".
|
| 938 |
+
6. Viết thành ĐOẠN VĂN THUẦN, mạch lạc, dễ đọc. Không dùng bullet points.
|
| 939 |
+
7. Giữ sự thật, KHÔNG bịa thông tin.
|
| 940 |
+
8. Tối đa 200 từ. Ngắn gọn, đủ ý.{image_info}
|
| 941 |
+
|
| 942 |
+
Tiêu đề gốc: {title}
|
| 943 |
+
|
| 944 |
+
Nội dung gốc:
|
| 945 |
+
{raw[:14000]}"""
|
| 946 |
+
|
| 947 |
+
|
| 948 |
+
def _build_topic_prompt(topic: str, ctx: str) -> str:
|
| 949 |
+
return f"""Viết bài TÓM TẮT NGẮN GỌN về chủ đề: "{topic}".
|
| 950 |
+
|
| 951 |
+
QUY TẮC BẮT BUỘC:
|
| 952 |
+
1. Chỉ viết TÓM TẮT các ý chính từ nguồn. KHÔNG sao chép nguyên văn.
|
| 953 |
+
2. KHÔNG lặp lại bất kỳ nội dung nào. Mỗi thông tin chỉ xuất hiện ĐÚNG 1 LẦN.
|
| 954 |
+
3. Nếu 2 câu nói cùng 1 ý → chỉ giữ 1 câu.
|
| 955 |
+
4. KHÔNG dùng Markdown (##, **, ---, *).
|
| 956 |
+
5. KHÔNG viết "Dưới đây là", "Tôi sẽ", "Theo yêu cầu", "Nhiệm vụ", "Vai trò".
|
| 957 |
+
6. Viết thành ĐOẠN VĂN THU���N, mạch lạc. Không dùng bullet points.
|
| 958 |
+
7. Giữ sự thật, KHÔNG bịa.
|
| 959 |
+
8. Tối đa 200 từ. Ngắn gọn, đủ ý.
|
| 960 |
+
|
| 961 |
+
Nguồn thực tế:
|
| 962 |
+
{ctx[:12000]}"""
|
| 963 |
+
|
| 964 |
+
|
| 965 |
+
# ===== WRITE ENDPOINTS =====
|
| 966 |
+
@app.post("/api/rewrite_share")
|
| 967 |
+
async def api_rewrite_share(request: Request):
|
| 968 |
+
body = await request.json()
|
| 969 |
+
url = _clean_text(body.get("url", ""))
|
| 970 |
+
if not url.startswith("http"):
|
| 971 |
+
return JSONResponse({"error": "missing url"}, status_code=400)
|
| 972 |
+
try:
|
| 973 |
+
data = scrape_any_url(url)
|
| 974 |
+
except Exception as e:
|
| 975 |
+
return JSONResponse({"error": "Không đọc được bài viết: " + str(e)[:180]}, status_code=422)
|
| 976 |
+
raw = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
|
| 977 |
+
if len(raw) < 60:
|
| 978 |
+
return JSONResponse({"error": "Bài viết quá ngắn để tóm tắt"}, status_code=422)
|
| 979 |
+
|
| 980 |
+
images = data.get("images", [])
|
| 981 |
+
prompt = _build_rewrite_prompt(data.get("title", ""), raw, images)
|
| 982 |
+
# Text-only summary for SPEED (images are kept on the post for display + short video).
|
| 983 |
+
text = await qwen_generate(prompt, max_tokens=500)
|
| 984 |
+
if not text:
|
| 985 |
+
return JSONResponse({"error": "Qwen2.5-VL chưa sẵn sàng: " + LAST_QWEN_ERROR}, status_code=503)
|
| 986 |
+
text = _clean_ai_output(text)
|
| 987 |
+
post = make_post(data.get("title") or "Bài viết", text,
|
| 988 |
+
images[0] if images else data.get("image", ""),
|
| 989 |
+
url, "rewrite", images=images)
|
| 990 |
+
|
| 991 |
+
# Save post and return IMMEDIATELY; generate the short video in the background
|
| 992 |
+
# (so rewrite is fast). Video appears on the wall when ready / via 'Tao Video' button.
|
| 993 |
+
posts = _load_wall()
|
| 994 |
+
posts.insert(0, post)
|
| 995 |
+
_save_wall(posts)
|
| 996 |
+
_spawn_background_video(post)
|
| 997 |
+
return JSONResponse({"post": post})
|
| 998 |
+
|
| 999 |
+
|
| 1000 |
+
@app.post("/api/url_wall")
|
| 1001 |
+
async def api_url_wall(request: Request):
|
| 1002 |
+
body = await request.json()
|
| 1003 |
+
url = _clean_text(body.get("url", ""))
|
| 1004 |
+
if not url.startswith("http"):
|
| 1005 |
+
return JSONResponse({"error": "missing url"}, status_code=400)
|
| 1006 |
+
try:
|
| 1007 |
+
data = scrape_any_url(url)
|
| 1008 |
+
except Exception as e:
|
| 1009 |
+
return JSONResponse({"error": "Không scrape được URL: " + str(e)[:180]}, status_code=422)
|
| 1010 |
+
raw = (data.get("summary", "") + "\n" + data.get("text", "")).strip()
|
| 1011 |
+
if len(raw) < 60:
|
| 1012 |
+
return JSONResponse({"error": "URL không có đủ nội dung"}, status_code=422)
|
| 1013 |
+
|
| 1014 |
+
images = data.get("images", [])
|
| 1015 |
+
prompt = _build_rewrite_prompt(data.get("title", ""), raw, images)
|
| 1016 |
+
# Text-only summary for SPEED (images are kept on the post for display + short video).
|
| 1017 |
+
text = await qwen_generate(prompt, max_tokens=500)
|
| 1018 |
+
if not text:
|
| 1019 |
+
return JSONResponse({"error": "Qwen2.5-VL chưa sẵn sàng: " + LAST_QWEN_ERROR}, status_code=503)
|
| 1020 |
+
text = _clean_ai_output(text)
|
| 1021 |
+
post = make_post(data.get("title") or "Bài viết", text,
|
| 1022 |
+
images[0] if images else data.get("image", ""),
|
| 1023 |
+
url, "url", images=images)
|
| 1024 |
+
|
| 1025 |
+
posts = _load_wall()
|
| 1026 |
+
posts.insert(0, post)
|
| 1027 |
+
_save_wall(posts)
|
| 1028 |
+
_spawn_background_video(post)
|
| 1029 |
+
return JSONResponse({"post": post})
|
| 1030 |
+
|
| 1031 |
+
|
| 1032 |
+
@app.post("/api/topic_post")
|
| 1033 |
+
async def api_topic_post(request: Request):
|
| 1034 |
+
body = await request.json()
|
| 1035 |
+
topic = _clean_text(body.get("topic", ""))
|
| 1036 |
+
if not topic:
|
| 1037 |
+
return JSONResponse({"error": "missing topic"}, status_code=400)
|
| 1038 |
+
|
| 1039 |
+
ctx = _web_context(topic)
|
| 1040 |
+
if not ctx:
|
| 1041 |
+
return JSONResponse({"error": "Không lấy được dữ liệu cho chủ đề này"}, status_code=422)
|
| 1042 |
+
|
| 1043 |
+
image = pollinations_image_url(topic)
|
| 1044 |
+
prompt = _build_topic_prompt(topic, ctx)
|
| 1045 |
+
# NOTE: do NOT pass the decorative pollinations image to the VL model — feeding an
|
| 1046 |
+
# image makes Qwen2.5-VL much slower (it must download+process it) with no benefit for
|
| 1047 |
+
# a text summary. We keep the image only for display on the post. This is a major
|
| 1048 |
+
# speed-up for 'rewrite tong hop'. (Text-only inference is several times faster.)
|
| 1049 |
+
text = await qwen_generate(prompt, max_tokens=500)
|
| 1050 |
+
if not text:
|
| 1051 |
+
return JSONResponse({"error": "Qwen2.5-VL chưa sẵn sàng: " + LAST_QWEN_ERROR}, status_code=503)
|
| 1052 |
+
text = _clean_ai_output(text)
|
| 1053 |
+
post = make_post(topic, text, image, "", "topic")
|
| 1054 |
+
|
| 1055 |
+
posts = _load_wall()
|
| 1056 |
+
posts.insert(0, post)
|
| 1057 |
+
_save_wall(posts)
|
| 1058 |
+
_spawn_background_video(post)
|
| 1059 |
+
return JSONResponse({"post": post})
|
| 1060 |
+
|
| 1061 |
+
|
| 1062 |
+
# ===== WALL ENDPOINTS =====
|
| 1063 |
+
@app.get("/api/ai_wall")
|
| 1064 |
+
def api_ai_wall():
|
| 1065 |
+
return JSONResponse({"posts": _load_wall()[:80]})
|
| 1066 |
+
|
| 1067 |
+
@app.get("/api/wall")
|
| 1068 |
+
def api_wall():
|
| 1069 |
+
return JSONResponse({"posts": _load_wall()[:80]})
|
| 1070 |
+
|
| 1071 |
+
|
| 1072 |
+
# ===== SHORT VIDEO ENDPOINT (with voice + speed params) =====
|
| 1073 |
+
@app.post("/api/ai/short/{post_id}")
|
| 1074 |
+
async def api_ai_short(post_id: str, voice: str = Query(default=None), speed: float = Query(default=None), emotion: str = Query(default=None)):
|
| 1075 |
+
"""Generate (or retrieve cached) short video for a wall post.
|
| 1076 |
+
|
| 1077 |
+
Query params:
|
| 1078 |
+
- voice: 'hoaimy' (female) | 'namminh' (male) | auto-detect if not specified
|
| 1079 |
+
- speed: float (default 1.2), e.g. 1.0=normal, 1.2=fast, 0.8=slow
|
| 1080 |
+
- emotion: 'vui'|'hao_hung'|'nghiem'|'tram'|'buon'|'trung_tinh' | auto by topic
|
| 1081 |
+
"""
|
| 1082 |
+
posts = _load_wall()
|
| 1083 |
+
post = next((p for p in posts if str(p.get("id")) == str(post_id)), None)
|
| 1084 |
+
if not post:
|
| 1085 |
+
return JSONResponse({"error": "post not found"}, status_code=404)
|
| 1086 |
+
|
| 1087 |
+
os.makedirs(SHORTS_DIR, exist_ok=True)
|
| 1088 |
+
out_mp4 = os.path.join(SHORTS_DIR, _safe_name(post_id) + ".mp4")
|
| 1089 |
+
|
| 1090 |
+
# If cached and no custom voice/speed/emotion requested, return cached
|
| 1091 |
+
if os.path.exists(out_mp4) and voice is None and speed is None and emotion is None:
|
| 1092 |
+
video_url = "/api/ai/short-file/" + post_id
|
| 1093 |
+
for i, p in enumerate(posts):
|
| 1094 |
+
if str(p.get("id")) == str(post_id):
|
| 1095 |
+
posts[i]["video"] = video_url
|
| 1096 |
+
break
|
| 1097 |
+
_save_wall(posts)
|
| 1098 |
+
return JSONResponse({"video": video_url})
|
| 1099 |
+
|
| 1100 |
+
# Validate params
|
| 1101 |
+
if voice is not None and voice not in TTS_VOICES:
|
| 1102 |
+
return JSONResponse({"error": f"voice không hợp lệ. Chọn: {list(TTS_VOICES.keys())}"}, status_code=400)
|
| 1103 |
+
if emotion is not None and emotion not in EMOTION_PRESETS:
|
| 1104 |
+
return JSONResponse({"error": f"emotion không hợp lệ. Chọn: {list(EMOTION_PRESETS.keys())}"}, status_code=400)
|
| 1105 |
+
|
| 1106 |
+
video_url = await _generate_short_video(post, post_id, voice_id=voice, speed=speed, emotion=emotion)
|
| 1107 |
+
if video_url:
|
| 1108 |
+
for i, p in enumerate(posts):
|
| 1109 |
+
if str(p.get("id")) == str(post_id):
|
| 1110 |
+
posts[i]["video"] = video_url
|
| 1111 |
+
if voice:
|
| 1112 |
+
posts[i]["voice"] = voice
|
| 1113 |
+
if emotion:
|
| 1114 |
+
posts[i]["emotion"] = emotion
|
| 1115 |
+
break
|
| 1116 |
+
_save_wall(posts)
|
| 1117 |
+
return JSONResponse({"video": video_url})
|
| 1118 |
+
return JSONResponse({"error": "Không tạo được shorts"}, status_code=500)
|
| 1119 |
+
|
| 1120 |
+
|
| 1121 |
+
@app.get("/api/ai/short-file/{post_id}")
|
| 1122 |
+
def api_ai_short_file(post_id: str):
|
| 1123 |
+
path = os.path.join(SHORTS_DIR, _safe_name(post_id) + ".mp4")
|
| 1124 |
+
if not os.path.exists(path):
|
| 1125 |
+
return JSONResponse({"error": "not found"}, status_code=404)
|
| 1126 |
+
return FileResponse(path, media_type="video/mp4", filename=f"vnews-ai-{post_id}.mp4")
|
| 1127 |
+
|
| 1128 |
+
|
| 1129 |
+
@app.get("/api/ai/status")
|
| 1130 |
+
def api_ai_status():
|
| 1131 |
+
return JSONResponse({
|
| 1132 |
+
"has_token": bool(_hf_token()),
|
| 1133 |
+
"client_imported": AsyncInferenceClient is not None,
|
| 1134 |
+
"last_error": LAST_QWEN_ERROR,
|
| 1135 |
+
"working_text_model": _WORKING_MODEL_TEXT,
|
| 1136 |
+
"working_vl_model": _WORKING_MODEL_VL,
|
| 1137 |
+
})
|
ai_patch.py
CHANGED
|
@@ -6,7 +6,6 @@ import json
|
|
| 6 |
import html as html_lib
|
| 7 |
import subprocess
|
| 8 |
import requests
|
| 9 |
-
import hashlib
|
| 10 |
import ai_ext as base
|
| 11 |
from ai_ext import app
|
| 12 |
from fastapi import Request
|
|
@@ -42,17 +41,17 @@ def _similar(a, b):
|
|
| 42 |
return len(ta & tb) / max(1, min(len(ta), len(tb))) >= 0.72
|
| 43 |
|
| 44 |
|
| 45 |
-
def _dedupe_units(units, max_units=
|
| 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]
|
|
@@ -268,7 +268,7 @@ async def qwen_generate_resilient(prompt: str, image_url=None, max_tokens: int =
|
|
| 268 |
errors.append("missing HF_TOKEN")
|
| 269 |
base.LAST_QWEN_ERROR = " | ".join(errors[-6:]) or "Qwen unavailable; used extractive fallback"
|
| 270 |
print("[qwen resilient fallback]", base.LAST_QWEN_ERROR)
|
| 271 |
-
return _fallback_summary_from_prompt(prompt, max_units=
|
| 272 |
|
| 273 |
|
| 274 |
if not hasattr(base, "_original_qwen_generate"):
|
|
@@ -321,8 +321,8 @@ Yêu cầu bắt buộc:
|
|
| 321 |
|
| 322 |
Nội dung bài:
|
| 323 |
{art['raw'][:14000]}"""
|
| 324 |
-
text = await base.qwen_generate(prompt, image_url=art.get('image') or None, max_tokens=
|
| 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)
|
|
@@ -347,8 +347,8 @@ async def compat_url_wall(request: Request):
|
|
| 347 |
if len(raw) < 120:
|
| 348 |
return JSONResponse({'error': 'URL không có đủ nội dung để tóm tắt'}, status_code=422)
|
| 349 |
prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
|
| 350 |
-
text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=
|
| 351 |
-
text = _postprocess_ai_text(text, max_units=
|
| 352 |
src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
|
| 353 |
if 'Nguồn tham khảo:' not in text:
|
| 354 |
text += "\n\n" + _source_line(src)
|
|
@@ -357,116 +357,6 @@ async def compat_url_wall(request: Request):
|
|
| 357 |
return JSONResponse({'post': post})
|
| 358 |
|
| 359 |
|
| 360 |
-
def _is_relevant_image(img_url, title, text):
|
| 361 |
-
"""Check if an image is relevant to the article content."""
|
| 362 |
-
if not img_url:
|
| 363 |
-
return False
|
| 364 |
-
skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif',
|
| 365 |
-
'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite',
|
| 366 |
-
'advertisement', 'ad-banner', 'sponsored', 'banner-ads']
|
| 367 |
-
img_lower = img_url.lower()
|
| 368 |
-
for p in skip_patterns:
|
| 369 |
-
if p in img_lower:
|
| 370 |
-
return False
|
| 371 |
-
if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']):
|
| 372 |
-
return False
|
| 373 |
-
return True
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
def _filter_relevant_images(images, title, text, max_images=8):
|
| 377 |
-
"""Filter and rank images by relevance to article content."""
|
| 378 |
-
if not images:
|
| 379 |
-
return []
|
| 380 |
-
seen = set()
|
| 381 |
-
relevant = []
|
| 382 |
-
for img in images:
|
| 383 |
-
if img in seen:
|
| 384 |
-
continue
|
| 385 |
-
seen.add(img)
|
| 386 |
-
if _is_relevant_image(img, title, text):
|
| 387 |
-
relevant.append(img)
|
| 388 |
-
return relevant[:max_images]
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
def _extract_key_points_for_slides(paragraphs, max_points=12):
|
| 392 |
-
"""Extract key points from paragraphs for slides - extracts ALL sentences, not just first one."""
|
| 393 |
-
points = []
|
| 394 |
-
for p in paragraphs:
|
| 395 |
-
if len(points) >= max_points:
|
| 396 |
-
break
|
| 397 |
-
p = _clean(p)
|
| 398 |
-
if not p:
|
| 399 |
-
continue
|
| 400 |
-
# Split paragraph into sentences using Vietnamese + English punctuation - GET ALL SENTENCES
|
| 401 |
-
sentences = re.split(r'(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])', p)
|
| 402 |
-
sentences = [s.strip() for s in sentences if s.strip()]
|
| 403 |
-
|
| 404 |
-
for sentence in sentences:
|
| 405 |
-
if len(points) >= max_points:
|
| 406 |
-
break
|
| 407 |
-
sentence = _clean(sentence)
|
| 408 |
-
if len(sentence) < 30:
|
| 409 |
-
continue
|
| 410 |
-
if any(sentence[:60] in existing for existing in points):
|
| 411 |
-
continue
|
| 412 |
-
if not sentence.endswith(('.', '!', '?')):
|
| 413 |
-
sentence = sentence + '.'
|
| 414 |
-
points.append(sentence)
|
| 415 |
-
return points
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
def _scrape_article_images(url):
|
| 419 |
-
"""Scrape article page and return only relevant images."""
|
| 420 |
-
try:
|
| 421 |
-
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
| 422 |
-
"Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"}
|
| 423 |
-
r = requests.get(url, headers=headers, timeout=15, allow_redirects=True)
|
| 424 |
-
r.encoding = 'utf-8'
|
| 425 |
-
soup = BeautifulSoup(r.text, 'lxml')
|
| 426 |
-
for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
|
| 427 |
-
tag.decompose()
|
| 428 |
-
h1 = soup.find('h1')
|
| 429 |
-
ogt = soup.find('meta', property='og:title')
|
| 430 |
-
title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '')
|
| 431 |
-
ogi = soup.find('meta', property='og:image')
|
| 432 |
-
og_img = ogi.get('content', '') if ogi else ''
|
| 433 |
-
if og_img and og_img.startswith('//'):
|
| 434 |
-
og_img = 'https:' + og_img
|
| 435 |
-
block = None
|
| 436 |
-
for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
|
| 437 |
-
el = soup.select_one(sel)
|
| 438 |
-
if el and len(el.find_all('p')) >= 2:
|
| 439 |
-
block = el
|
| 440 |
-
break
|
| 441 |
-
if not block:
|
| 442 |
-
block = soup.body or soup
|
| 443 |
-
paragraphs = []
|
| 444 |
-
all_images = []
|
| 445 |
-
seen_imgs = set()
|
| 446 |
-
if og_img and og_img not in seen_imgs:
|
| 447 |
-
all_images.append(og_img)
|
| 448 |
-
seen_imgs.add(og_img)
|
| 449 |
-
for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
|
| 450 |
-
if el.name == 'p':
|
| 451 |
-
t = _clean(el.get_text(strip=True))
|
| 452 |
-
if t and len(t) > 40:
|
| 453 |
-
paragraphs.append(t)
|
| 454 |
-
elif el.name in ('figure', 'img'):
|
| 455 |
-
im = el if el.name == 'img' else el.find('img')
|
| 456 |
-
if im:
|
| 457 |
-
src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
|
| 458 |
-
if src and 'base64' not in src:
|
| 459 |
-
if src.startswith('//'):
|
| 460 |
-
src = 'https:' + src
|
| 461 |
-
if src not in seen_imgs:
|
| 462 |
-
all_images.append(src)
|
| 463 |
-
seen_imgs.add(src)
|
| 464 |
-
relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5]))
|
| 465 |
-
return {'title': _clean(title), 'paragraphs': paragraphs, 'images': relevant_images, 'og_img': og_img}
|
| 466 |
-
except Exception:
|
| 467 |
-
return None
|
| 468 |
-
|
| 469 |
-
|
| 470 |
@app.post('/api/rewrite_share')
|
| 471 |
async def compat_rewrite_share(request: Request):
|
| 472 |
body = await request.json()
|
|
@@ -481,53 +371,40 @@ async def compat_rewrite_share(request: Request):
|
|
| 481 |
if len(raw) < 120:
|
| 482 |
return JSONResponse({'error': 'Bài viết không đủ nội dung để tóm tắt'}, status_code=422)
|
| 483 |
prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
|
| 484 |
-
text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=
|
| 485 |
-
text = _postprocess_ai_text(text, max_units=
|
| 486 |
src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
|
| 487 |
if 'Nguồn tham khảo:' not in text:
|
| 488 |
text += "\n\n" + _source_line(src)
|
| 489 |
post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'summary', sources=src)
|
| 490 |
posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
|
| 491 |
-
|
| 492 |
-
# Generate slides with relevant images only
|
| 493 |
-
slides = []
|
| 494 |
-
page_data = _scrape_article_images(url)
|
| 495 |
-
if page_data and page_data.get('paragraphs'):
|
| 496 |
-
key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12)
|
| 497 |
-
if key_points:
|
| 498 |
-
relevant_imgs = page_data.get('images', [])
|
| 499 |
-
if not relevant_imgs and page_data.get('og_img'):
|
| 500 |
-
relevant_imgs = [page_data['og_img']]
|
| 501 |
-
for i, point in enumerate(key_points):
|
| 502 |
-
img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
|
| 503 |
-
slides.append({'text': point, 'image': img, 'index': i + 1})
|
| 504 |
-
|
| 505 |
-
return JSONResponse({'post': post, 'slides': slides})
|
| 506 |
|
| 507 |
|
| 508 |
def _emotion_script(text, emotion):
|
| 509 |
-
"""Prepend emotion-appropriate prefix to text based on emotion type.
|
| 510 |
-
|
| 511 |
-
NOTE: Prefix is NOT added to avoid cluttering Short AI speech.
|
| 512 |
-
The emotion is still used for voice selection but content is read cleanly.
|
| 513 |
-
"""
|
| 514 |
text = _clean(text)
|
| 515 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 516 |
return text
|
| 517 |
|
| 518 |
|
| 519 |
def _tts_script_smart(post, emotion):
|
| 520 |
-
raw = base._short_script(post)
|
| 521 |
raw = re.sub(r"^[•\-\*]\s*", "", raw, flags=re.M)
|
| 522 |
raw = re.sub(r"\s*\n\s*", ". ", raw)
|
| 523 |
raw = re.sub(r"([\.\!\?])\s*", r"\1\n", raw)
|
| 524 |
raw = re.sub(r"\n{2,}", "\n", raw).strip()
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
raw = raw[:3000]
|
| 529 |
cut = max(raw.rfind("."), raw.rfind("!"), raw.rfind("?"))
|
| 530 |
-
if cut >
|
| 531 |
raw = raw[:cut + 1]
|
| 532 |
return raw
|
| 533 |
|
|
@@ -642,7 +519,7 @@ def _make_short_frame_full(post, img_path, out_path):
|
|
| 642 |
|
| 643 |
|
| 644 |
|
| 645 |
-
def _summary_segments_from_post(post, max_segments=
|
| 646 |
raw = _clean(post.get('text') or post.get('title') or '')
|
| 647 |
raw = re.sub(r'^Bản tin AI viết lại:\s*', '', raw, flags=re.I)
|
| 648 |
raw = re.sub(r'Nguồn tham khảo:.*$', '', raw, flags=re.I|re.S).strip()
|
|
@@ -653,7 +530,7 @@ def _summary_segments_from_post(post, max_segments=25):
|
|
| 653 |
low=ln.lower()
|
| 654 |
if low.startswith(('điểm chính','tiêu đề','sapo','nguồn tham khảo')): continue
|
| 655 |
if len(ln)>=18: lines.append(ln)
|
| 656 |
-
if len(lines)<
|
| 657 |
lines=[]
|
| 658 |
for s in re.split(r'(?<=[\.\!\?])\s+', raw):
|
| 659 |
s=_clean(s)
|
|
@@ -705,8 +582,7 @@ def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='ne
|
|
| 705 |
draw.rounded_rectangle((48,834,260,880), radius=20, fill=(28,70,45))
|
| 706 |
draw.text((66,842),f'Đoạn {idx+1}/{total}',fill=(235,235,235),font=font_small)
|
| 707 |
y=940; maxw=W-96
|
| 708 |
-
|
| 709 |
-
for ln in _wrap_text_px(draw, segment, font_seg, maxw, 18):
|
| 710 |
draw.text((48,y),ln,fill=(255,255,255),font=font_seg)
|
| 711 |
y+=74
|
| 712 |
if y>1500: break
|
|
@@ -718,11 +594,10 @@ def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='ne
|
|
| 718 |
bg.save(out_path, quality=92)
|
| 719 |
|
| 720 |
|
| 721 |
-
def _estimate_audio_duration(path, fallback=
|
| 722 |
-
"""Estimate audio duration with 15s minimum per segment for complete bullet reading."""
|
| 723 |
try:
|
| 724 |
pr=subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:no_key=1',path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
|
| 725 |
-
return max(
|
| 726 |
except Exception:
|
| 727 |
return fallback
|
| 728 |
|
|
@@ -735,7 +610,7 @@ async def patched_ai_short(post_id: str, request: Request):
|
|
| 735 |
body = {}
|
| 736 |
voice = str(body.get('voice', 'nu')).strip().lower()
|
| 737 |
emotion = str(body.get('emotion', 'neutral')).strip().lower()
|
| 738 |
-
speed = float(body.get('speed', 1.
|
| 739 |
speed = max(0.85, min(1.35, speed))
|
| 740 |
|
| 741 |
posts = base._load_ai_wall()
|
|
@@ -743,7 +618,7 @@ async def patched_ai_short(post_id: str, request: Request):
|
|
| 743 |
if not post:
|
| 744 |
return JSONResponse({'error': 'post not found'}, status_code=404)
|
| 745 |
|
| 746 |
-
segments = _summary_segments_from_post(post, max_segments=
|
| 747 |
seg_hash = hashlib.md5(('|'.join(segments)+voice+emotion+str(speed)).encode('utf-8')).hexdigest()[:8]
|
| 748 |
os.makedirs(base.SHORTS_DIR, exist_ok=True)
|
| 749 |
suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_{seg_hash}_scenes_nosub"
|
|
@@ -766,63 +641,11 @@ async def patched_ai_short(post_id: str, request: Request):
|
|
| 766 |
try:
|
| 767 |
base._download_image(post.get('img'), post.get('title', 'AI news'), img)
|
| 768 |
edge_voice = {
|
| 769 |
-
# Vietnamese
|
| 770 |
-
'vi-vn-hoaimyneural': 'vi-VN-HoaiMyNeural',
|
| 771 |
-
'vi-vn-namminhneural': 'vi-VN-NamMinhNeural',
|
| 772 |
-
'hoaimy': 'vi-VN-HoaiMyNeural',
|
| 773 |
-
'namminh': 'vi-VN-NamMinhNeural',
|
| 774 |
'nam': 'vi-VN-NamMinhNeural',
|
| 775 |
'male': 'vi-VN-NamMinhNeural',
|
| 776 |
'nu': 'vi-VN-HoaiMyNeural',
|
| 777 |
'female': 'vi-VN-HoaiMyNeural',
|
| 778 |
'mien-nam': 'vi-VN-HoaiMyNeural',
|
| 779 |
-
# English - Multilingual
|
| 780 |
-
'en-us-andrewmultilingualneural': 'en-US-AndrewMultilingualNeural',
|
| 781 |
-
'en-au-williammultilingualneural': 'en-AU-WilliamMultilingualNeural',
|
| 782 |
-
'andrew': 'en-US-AndrewMultilingualNeural',
|
| 783 |
-
'en_andrew': 'en-US-AndrewMultilingualNeural',
|
| 784 |
-
'jenny': 'en-US-AndrewMultilingualNeural',
|
| 785 |
-
'en_jenny': 'en-US-AndrewMultilingualNeural',
|
| 786 |
-
# Portuguese - Multilingual (ONLY Thalita)
|
| 787 |
-
'pt-br-thalitamultilingualneural': 'pt-BR-ThalitaMultilingualNeural',
|
| 788 |
-
'thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 789 |
-
'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 790 |
-
'pt_br_thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 791 |
-
'pt': 'pt-BR-ThalitaMultilingualNeural',
|
| 792 |
-
'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
|
| 793 |
-
# French - Multilingual
|
| 794 |
-
'fr-fr-viviennemultilingualneural': 'fr-FR-VivienneMultilingualNeural',
|
| 795 |
-
'fr-fr-remymultilingualneural': 'fr-FR-RemyMultilingualNeural',
|
| 796 |
-
'denise': 'fr-FR-VivienneMultilingualNeural',
|
| 797 |
-
'fr': 'fr-FR-VivienneMultilingualNeural',
|
| 798 |
-
'fr_denise': 'fr-FR-VivienneMultilingualNeural',
|
| 799 |
-
# German - Multilingual
|
| 800 |
-
'de-de-seraphinamultilingualneural': 'de-DE-SeraphinaMultilingualNeural',
|
| 801 |
-
'de-de-florianmultilingualneural': 'de-DE-FlorianMultilingualNeural',
|
| 802 |
-
'katja': 'de-DE-SeraphinaMultilingualNeural',
|
| 803 |
-
'de': 'de-DE-SeraphinaMultilingualNeural',
|
| 804 |
-
'de_katja': 'de-DE-SeraphinaMultilingualNeural',
|
| 805 |
-
# Korean - Multilingual (Hyunsu, NOT SunHee)
|
| 806 |
-
'ko-kr-hyusumultilingualneural': 'ko-KR-HyunsuMultilingualNeural',
|
| 807 |
-
'ko-kr-hyunsuneural': 'ko-KR-HyunsuMultilingualNeural',
|
| 808 |
-
'sunhee': 'ko-KR-HyunsuMultilingualNeural',
|
| 809 |
-
'ko': 'ko-KR-HyunsuMultilingualNeural',
|
| 810 |
-
'ko_sunhee': 'ko-KR-HyunsuMultilingualNeural',
|
| 811 |
-
# Italian - Multilingual
|
| 812 |
-
'it-it-giuseppemultilingualneural': 'it-IT-GiuseppeMultilingualNeural',
|
| 813 |
-
# Spanish (keep for backward compat)
|
| 814 |
-
'ela': 'en-US-AndrewMultilingualNeural',
|
| 815 |
-
'es_ela': 'en-US-AndrewMultilingualNeural',
|
| 816 |
-
'es': 'en-US-AndrewMultilingualNeural',
|
| 817 |
-
'es_carlos': 'en-US-AndrewMultilingualNeural',
|
| 818 |
-
# Japanese (keep for backward compat)
|
| 819 |
-
'nanami': 'en-US-AndrewMultilingualNeural',
|
| 820 |
-
'ja': 'en-US-AndrewMultilingualNeural',
|
| 821 |
-
'ja_nanami': 'en-US-AndrewMultilingualNeural',
|
| 822 |
-
# Chinese (keep for backward compat)
|
| 823 |
-
'xiaochen': 'en-US-AndrewMultilingualNeural',
|
| 824 |
-
'zh': 'en-US-AndrewMultilingualNeural',
|
| 825 |
-
'zh_xiaochen': 'en-US-AndrewMultilingualNeural',
|
| 826 |
}.get(voice, 'vi-VN-HoaiMyNeural')
|
| 827 |
part_files=[]
|
| 828 |
for idx, seg in enumerate(segments):
|
|
@@ -835,13 +658,13 @@ async def patched_ai_short(post_id: str, request: Request):
|
|
| 835 |
try:
|
| 836 |
subprocess.run(['python','-m','edge_tts','--voice',edge_voice,'--text',spoken,'--write-media',aud], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
| 837 |
except Exception:
|
| 838 |
-
tld='com.vn' if voice in ('nu','female','mien-nam'
|
| 839 |
try:
|
| 840 |
base.gTTS(spoken, lang='vi', tld=tld, slow=False).save(aud)
|
| 841 |
except TypeError:
|
| 842 |
base.gTTS(spoken, lang='vi', slow=False).save(aud)
|
| 843 |
subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
|
| 844 |
-
dur=_estimate_audio_duration(aud_fast, fallback=
|
| 845 |
subprocess.run(['ffmpeg','-y','-loop','1','-t',str(dur),'-i',frame,'-i',aud_fast,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k',part], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=150)
|
| 846 |
part_files.append(part)
|
| 847 |
concat=os.path.join(work,'concat.txt')
|
|
@@ -876,3 +699,53 @@ def api_ai_shorts():
|
|
| 876 |
|
| 877 |
|
| 878 |
app.router.routes = [r for r in app.router.routes if not (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set()))]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
import html as html_lib
|
| 7 |
import subprocess
|
| 8 |
import requests
|
|
|
|
| 9 |
import ai_ext as base
|
| 10 |
from ai_ext import app
|
| 11 |
from fastapi import Request
|
|
|
|
| 41 |
return len(ta & tb) / max(1, min(len(ta), len(tb))) >= 0.72
|
| 42 |
|
| 43 |
|
| 44 |
+
def _dedupe_units(units, max_units=7):
|
|
|
|
| 45 |
out, seen = [], set()
|
| 46 |
for u in units:
|
| 47 |
u = _clean(re.sub(r"^[-•*\d\.\)\s]+", "", u))
|
| 48 |
if len(u) < 18:
|
| 49 |
continue
|
| 50 |
nu = _norm(u)
|
|
|
|
| 51 |
if nu in seen:
|
| 52 |
continue
|
| 53 |
+
if any(_similar(u, old) for old in out):
|
| 54 |
+
continue
|
| 55 |
seen.add(nu)
|
| 56 |
out.append(u)
|
| 57 |
if len(out) >= max_units:
|
|
|
|
| 59 |
return out
|
| 60 |
|
| 61 |
|
| 62 |
+
def _postprocess_ai_text(text, max_units=7):
|
| 63 |
text = _clean(text)
|
| 64 |
if not text:
|
| 65 |
return text
|
|
|
|
| 78 |
raw_lines.append(line)
|
| 79 |
units = []
|
| 80 |
for line in raw_lines:
|
| 81 |
+
if len(line) > 260:
|
| 82 |
+
units.extend(re.split(r"(?<=[\.\!\?])\s+(?=[A-ZÀ-Ỹ0-9])", line))
|
| 83 |
+
else:
|
| 84 |
+
units.append(line)
|
| 85 |
units = _dedupe_units(units, max_units=max_units)
|
| 86 |
if not units:
|
| 87 |
return text[:900]
|
|
|
|
| 268 |
errors.append("missing HF_TOKEN")
|
| 269 |
base.LAST_QWEN_ERROR = " | ".join(errors[-6:]) or "Qwen unavailable; used extractive fallback"
|
| 270 |
print("[qwen resilient fallback]", base.LAST_QWEN_ERROR)
|
| 271 |
+
return _fallback_summary_from_prompt(prompt, max_units=6)
|
| 272 |
|
| 273 |
|
| 274 |
if not hasattr(base, "_original_qwen_generate"):
|
|
|
|
| 321 |
|
| 322 |
Nội dung bài:
|
| 323 |
{art['raw'][:14000]}"""
|
| 324 |
+
text = await base.qwen_generate(prompt, image_url=art.get('image') or None, max_tokens=900)
|
| 325 |
+
text = _postprocess_ai_text(text, max_units=6)
|
| 326 |
src = [art['source']]
|
| 327 |
if 'Nguồn tham khảo:' not in text:
|
| 328 |
text += "\n\n" + _source_line(src)
|
|
|
|
| 347 |
if len(raw) < 120:
|
| 348 |
return JSONResponse({'error': 'URL không có đủ nội dung để tóm tắt'}, status_code=422)
|
| 349 |
prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
|
| 350 |
+
text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=850)
|
| 351 |
+
text = _postprocess_ai_text(text, max_units=6)
|
| 352 |
src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
|
| 353 |
if 'Nguồn tham khảo:' not in text:
|
| 354 |
text += "\n\n" + _source_line(src)
|
|
|
|
| 357 |
return JSONResponse({'post': post})
|
| 358 |
|
| 359 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
@app.post('/api/rewrite_share')
|
| 361 |
async def compat_rewrite_share(request: Request):
|
| 362 |
body = await request.json()
|
|
|
|
| 371 |
if len(raw) < 120:
|
| 372 |
return JSONResponse({'error': 'Bài viết không đủ nội dung để tóm tắt'}, status_code=422)
|
| 373 |
prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
|
| 374 |
+
text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=850)
|
| 375 |
+
text = _postprocess_ai_text(text, max_units=6)
|
| 376 |
src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
|
| 377 |
if 'Nguồn tham khảo:' not in text:
|
| 378 |
text += "\n\n" + _source_line(src)
|
| 379 |
post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'summary', sources=src)
|
| 380 |
posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
|
| 381 |
+
return JSONResponse({'post': post})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
|
| 383 |
|
| 384 |
def _emotion_script(text, emotion):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
text = _clean(text)
|
| 386 |
+
if emotion == 'urgent':
|
| 387 |
+
return 'Tin nhanh. ' + text
|
| 388 |
+
if emotion == 'warm':
|
| 389 |
+
return 'Câu chuyện đáng chú ý. ' + text
|
| 390 |
+
if emotion == 'serious':
|
| 391 |
+
return 'Bản tin nghiêm túc. ' + text
|
| 392 |
+
if emotion == 'energetic':
|
| 393 |
+
return 'Cập nhật nổi bật. ' + text
|
| 394 |
return text
|
| 395 |
|
| 396 |
|
| 397 |
def _tts_script_smart(post, emotion):
|
| 398 |
+
raw = base._short_script(post)
|
| 399 |
raw = re.sub(r"^[•\-\*]\s*", "", raw, flags=re.M)
|
| 400 |
raw = re.sub(r"\s*\n\s*", ". ", raw)
|
| 401 |
raw = re.sub(r"([\.\!\?])\s*", r"\1\n", raw)
|
| 402 |
raw = re.sub(r"\n{2,}", "\n", raw).strip()
|
| 403 |
+
raw = _emotion_script(raw, emotion)
|
| 404 |
+
if len(raw) > 1000:
|
| 405 |
+
raw = raw[:1000]
|
|
|
|
| 406 |
cut = max(raw.rfind("."), raw.rfind("!"), raw.rfind("?"))
|
| 407 |
+
if cut > 350:
|
| 408 |
raw = raw[:cut + 1]
|
| 409 |
return raw
|
| 410 |
|
|
|
|
| 519 |
|
| 520 |
|
| 521 |
|
| 522 |
+
def _summary_segments_from_post(post, max_segments=7):
|
| 523 |
raw = _clean(post.get('text') or post.get('title') or '')
|
| 524 |
raw = re.sub(r'^Bản tin AI viết lại:\s*', '', raw, flags=re.I)
|
| 525 |
raw = re.sub(r'Nguồn tham khảo:.*$', '', raw, flags=re.I|re.S).strip()
|
|
|
|
| 530 |
low=ln.lower()
|
| 531 |
if low.startswith(('điểm chính','tiêu đề','sapo','nguồn tham khảo')): continue
|
| 532 |
if len(ln)>=18: lines.append(ln)
|
| 533 |
+
if len(lines)<2:
|
| 534 |
lines=[]
|
| 535 |
for s in re.split(r'(?<=[\.\!\?])\s+', raw):
|
| 536 |
s=_clean(s)
|
|
|
|
| 582 |
draw.rounded_rectangle((48,834,260,880), radius=20, fill=(28,70,45))
|
| 583 |
draw.text((66,842),f'Đoạn {idx+1}/{total}',fill=(235,235,235),font=font_small)
|
| 584 |
y=940; maxw=W-96
|
| 585 |
+
for ln in _wrap_text_px(draw, segment, font_seg, maxw, 8):
|
|
|
|
| 586 |
draw.text((48,y),ln,fill=(255,255,255),font=font_seg)
|
| 587 |
y+=74
|
| 588 |
if y>1500: break
|
|
|
|
| 594 |
bg.save(out_path, quality=92)
|
| 595 |
|
| 596 |
|
| 597 |
+
def _estimate_audio_duration(path, fallback=4.0):
|
|
|
|
| 598 |
try:
|
| 599 |
pr=subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:no_key=1',path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
|
| 600 |
+
return max(1.5, float((pr.stdout or b'').decode().strip() or fallback))
|
| 601 |
except Exception:
|
| 602 |
return fallback
|
| 603 |
|
|
|
|
| 610 |
body = {}
|
| 611 |
voice = str(body.get('voice', 'nu')).strip().lower()
|
| 612 |
emotion = str(body.get('emotion', 'neutral')).strip().lower()
|
| 613 |
+
speed = float(body.get('speed', 1.2) or 1.2)
|
| 614 |
speed = max(0.85, min(1.35, speed))
|
| 615 |
|
| 616 |
posts = base._load_ai_wall()
|
|
|
|
| 618 |
if not post:
|
| 619 |
return JSONResponse({'error': 'post not found'}, status_code=404)
|
| 620 |
|
| 621 |
+
segments = _summary_segments_from_post(post, max_segments=7)
|
| 622 |
seg_hash = hashlib.md5(('|'.join(segments)+voice+emotion+str(speed)).encode('utf-8')).hexdigest()[:8]
|
| 623 |
os.makedirs(base.SHORTS_DIR, exist_ok=True)
|
| 624 |
suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_{seg_hash}_scenes_nosub"
|
|
|
|
| 641 |
try:
|
| 642 |
base._download_image(post.get('img'), post.get('title', 'AI news'), img)
|
| 643 |
edge_voice = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 644 |
'nam': 'vi-VN-NamMinhNeural',
|
| 645 |
'male': 'vi-VN-NamMinhNeural',
|
| 646 |
'nu': 'vi-VN-HoaiMyNeural',
|
| 647 |
'female': 'vi-VN-HoaiMyNeural',
|
| 648 |
'mien-nam': 'vi-VN-HoaiMyNeural',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 649 |
}.get(voice, 'vi-VN-HoaiMyNeural')
|
| 650 |
part_files=[]
|
| 651 |
for idx, seg in enumerate(segments):
|
|
|
|
| 658 |
try:
|
| 659 |
subprocess.run(['python','-m','edge_tts','--voice',edge_voice,'--text',spoken,'--write-media',aud], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
| 660 |
except Exception:
|
| 661 |
+
tld='com.vn' if voice in ('nu','female','mien-nam') else 'com'
|
| 662 |
try:
|
| 663 |
base.gTTS(spoken, lang='vi', tld=tld, slow=False).save(aud)
|
| 664 |
except TypeError:
|
| 665 |
base.gTTS(spoken, lang='vi', slow=False).save(aud)
|
| 666 |
subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
|
| 667 |
+
dur=_estimate_audio_duration(aud_fast, fallback=4.0)+0.35
|
| 668 |
subprocess.run(['ffmpeg','-y','-loop','1','-t',str(dur),'-i',frame,'-i',aud_fast,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k',part], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=150)
|
| 669 |
part_files.append(part)
|
| 670 |
concat=os.path.join(work,'concat.txt')
|
|
|
|
| 699 |
|
| 700 |
|
| 701 |
app.router.routes = [r for r in app.router.routes if not (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set()))]
|
| 702 |
+
|
| 703 |
+
PATCH_INJECT = r'''
|
| 704 |
+
<style>
|
| 705 |
+
.ai-wall-patched{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}
|
| 706 |
+
.ai-wall-card{flex:0 0 250px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:8px}
|
| 707 |
+
.ai-wall-img{width:100%;aspect-ratio:16/9;background:#222;border-radius:8px;overflow:hidden;margin-bottom:6px}
|
| 708 |
+
.ai-wall-img img{width:100%;height:100%;object-fit:cover}
|
| 709 |
+
.ai-wall-title{font-size:12px;color:#5cb87a;font-weight:800;line-height:1.3;margin-bottom:4px}
|
| 710 |
+
.ai-wall-text{font-size:11px;color:#bbb;line-height:1.45;white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden}
|
| 711 |
+
.ai-wall-actions{display:flex;gap:6px;margin-top:8px}
|
| 712 |
+
.ai-wall-actions button,.ai-wall-actions select{flex:1;border:1px solid #333;background:#222;color:#ddd;border-radius:14px;padding:6px 8px;font-size:10px;min-width:0}
|
| 713 |
+
.ai-wall-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}
|
| 714 |
+
.ai-short-card{flex:0 0 145px}
|
| 715 |
+
.ai-short-video{width:100%;aspect-ratio:9/16;background:#000;border-radius:8px;overflow:hidden}
|
| 716 |
+
.ai-short-video video{width:100%;height:100%;object-fit:cover}
|
| 717 |
+
.ai-short-progress{position:fixed;inset:0;background:rgba(0,0,0,.78);z-index:99999;display:none;align-items:center;justify-content:center;padding:20px}
|
| 718 |
+
.ai-short-progress.active{display:flex}
|
| 719 |
+
.ai-short-box{max-width:420px;width:100%;background:#141414;border:2px solid #2d8659;border-radius:14px;padding:18px;color:#eee;box-shadow:0 0 30px rgba(45,134,89,.35)}
|
| 720 |
+
.ai-short-box h3{color:#5cb87a;margin-bottom:10px}
|
| 721 |
+
.ai-short-step{font-size:13px;line-height:1.55;color:#ccc}
|
| 722 |
+
.ai-short-spinner{width:34px;height:34px;border:4px solid #333;border-top-color:#5cb87a;border-radius:50%;animation:spin 1s linear infinite;margin:10px auto}
|
| 723 |
+
@keyframes spin{to{transform:rotate(360deg)}}
|
| 724 |
+
</style>
|
| 725 |
+
<div id="ai-short-progress" class="ai-short-progress"><div class="ai-short-box"><h3>🎬 Đang tạo Short AI</h3><div class="ai-short-spinner"></div><div class="ai-short-step" id="ai-short-step">Đang chuẩn bị...</div></div></div>
|
| 726 |
+
<script>
|
| 727 |
+
(function(){
|
| 728 |
+
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}
|
| 729 |
+
let patchedWall=[];let aiShorts=[];
|
| 730 |
+
function showProgress(msg){let box=document.getElementById('ai-short-progress');let st=document.getElementById('ai-short-step');if(st)st.innerHTML=msg;if(box)box.classList.add('active');}
|
| 731 |
+
function hideProgress(){document.getElementById('ai-short-progress')?.classList.remove('active');}
|
| 732 |
+
function updateAiLabels(){document.querySelectorAll('.ai-compose-title').forEach(e=>e.textContent='🤖 Tường AI: lọc từng bài theo chủ đề, tóm tắt nội dung bài');document.querySelectorAll('button').forEach(b=>{if((b.textContent||'').includes('AI viết lại'))b.textContent='🤖 Tóm tắt AI & đăng tường';});}
|
| 733 |
+
async function loadPatchedWall(){try{const r=await fetch('/api/ai_wall');const j=await r.json();patchedWall=j.posts||[];renderPatchedWall();updateAiLabels();}catch(e){}try{const r2=await fetch('/api/ai_shorts');const j2=await r2.json();aiShorts=j2.posts||[];renderAiShorts();}catch(e){}}
|
| 734 |
+
function renderAiShorts(){const home=document.getElementById('view-home');if(!home)return;document.getElementById('ai-shorts-patched')?.remove();if(!aiShorts.length)return;let wrap=document.createElement('div');wrap.id='ai-shorts-patched';wrap.className='ai-wall-patched';let h='<div class="slider-header"><span class="slider-label">🎬 Short AI</span><span class="slider-note">Video đã tạo</span></div><div class="slider-track">';aiShorts.slice(0,30).forEach((p,i)=>{h+=`<div class="ai-short-card" onclick="aiReadShortPatched(${i})"><div class="ai-short-video"><video src="${p.video}" muted playsinline preload="metadata"></video></div><div class="slider-title">${esc(p.title)}</div></div>`});h+='</div>';wrap.innerHTML=h;let wall=document.getElementById('ai-wall-patched');if(wall)wall.after(wrap);else home.prepend(wrap);}
|
| 735 |
+
function renderPatchedWall(){const home=document.getElementById('view-home');if(!home)return;document.getElementById('ai-wall-patched')?.remove();if(!patchedWall.length)return;let wrap=document.createElement('div');wrap.id='ai-wall-patched';wrap.className='ai-wall-patched';let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI</span><span class="slider-note">Mỗi nguồn = một bài tóm tắt</span></div><div class="slider-track">';patchedWall.slice(0,30).forEach((p,i)=>{h+=`<div class="ai-wall-card"><div class="ai-wall-img">${p.img?`<img src="${p.img}">`:''}</div><div class="ai-wall-title">${esc(p.title)}</div><div class="ai-wall-text">${esc(p.text)}</div><div class="ai-wall-actions"><button onclick="aiReadWallPatched(${i})">Xem</button><button class="primary" onclick="aiMakeShortPatched(${i})">Shorts</button></div></div>`});h+='</div>';wrap.innerHTML=h;let after=document.querySelector('.ai-compose');if(after)after.after(wrap);else home.prepend(wrap);}
|
| 736 |
+
window.aiReadShortPatched=function(i){const p=aiShorts[i];if(!p)return;showView('view-article');let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">Short AI</span><h1 class="article-title">${esc(p.title)}</h1><video class="article-img" src="${p.video}" controls playsinline autoplay></video><p class="article-p" style="white-space:pre-wrap">${esc(p.text||'')}</p><div class="article-actions"><button onclick="window.open('${p.video}','_blank')">⬇ Mở video</button>${p.url?`<button onclick="window.open('${p.url}','_blank')">🔗 Nguồn</button>`:''}</div></div>`;document.getElementById('view-article').innerHTML=h;window.scrollTo(0,0)};
|
| 737 |
+
window.aiReadWallPatched=function(i){const p=patchedWall[i];if(!p)return;showView('view-article');let sources='';if(p.sources&&p.sources.length){sources='<div class="article-summary"><b>Nguồn tham khảo:</b><br>'+p.sources.slice(0,5).map(s=>`• ${esc(s.title||s.url||'Nguồn')} ${s.url?`(${esc(new URL(s.url).hostname.replace('www.',''))})`:''}`).join('<br>')+'</div>'}let voiceBox=`<div class="article-actions"><select id="ai-short-voice"><option value="nu">Giọng nữ Việt</option><option value="nam">Giọng nam Việt</option><option value="mien-nam">Giọng miền Nam</option></select><select id="ai-short-emotion"><option value="neutral">Trung tính</option><option value="urgent">Tin nhanh</option><option value="warm">Ấm áp</option><option value="serious">Nghiêm túc</option><option value="energetic">Sôi nổi</option></select><button onclick="aiMakeShortPatched(${i})">🎬 Tạo video shorts</button></div>`;let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${esc(p.title)}</h1>${p.img?`<img class="article-img" src="${p.img}">`:''}${sources}<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>${p.video?`<video class="article-img" src="${p.video}" controls playsinline></video>`:''}<div class="article-actions">${p.url?`<button onclick="window.open('${p.url}','_blank')">🔗 Nguồn</button>`:''}</div>${voiceBox}</div>`;document.getElementById('view-article').innerHTML=h;window.scrollTo(0,0)};
|
| 738 |
+
window.aiMakeShortPatched=async function(i){const p=patchedWall[i];if(!p)return;let voice=document.getElementById('ai-short-voice')?.value||'nu';let emotion=document.getElementById('ai-short-emotion')?.value||'neutral';let voiceName={nu:'Giọng nữ Việt',nam:'Giọng nam Việt','mien-nam':'Giọng miền Nam'}[voice]||voice;let emotionName={neutral:'Trung tính',urgent:'Tin nhanh',warm:'Ấm áp',serious:'Nghiêm túc',energetic:'Sôi nổi'}[emotion]||emotion;let ok=confirm(`Quy trình tạo short AI:\n\n1) Dùng ảnh đại diện của bài hoặc tạo ảnh minh họa nếu thiếu.\n2) Rút gọn nội dung tóm tắt thành kịch bản đọc ngắn.\n3) Tự ngắt câu theo dấu câu và xuống dòng hợp lý.\n4) Tạo giọng đọc tiếng Việt: ${voiceName}.\n5) Áp dụng cảm xúc/kịch bản: ${emotionName}.\n6) Tăng tốc giọng đọc 1.2 lần.\n7) Mỗi đoạn tóm tắt sẽ là một cảnh riêng theo thời lượng đọc.\n8) Không thêm phụ đề; video chỉ có chữ cảnh và giọng đọc.\n9) Sau khi xong, video xuất hiện ở slide "Short AI".\n\nQuá trình có thể mất 1-3 phút. Bạn muốn bắt đầu?`);if(!ok)return;try{showProgress(`Bước 1/5: Chuẩn bị ảnh và căn chữ full width...<br>Bước 2/5: Tạo kịch bản, tự ngắt câu/xuống dòng...<br>Bước 3/5: Tạo giọng đọc ${voiceName}, cảm xúc ${emotionName}.<br>Bước 4/5: Tăng tốc 1.2x và ghép từng cảnh riêng, không phụ đề.<br>Bước 5/5: Lưu vào slide "Short AI".`);const r=await fetch('/api/ai/short/'+p.id,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice,emotion,speed:1.2})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');p.video=j.video;hideProgress();alert('Hoàn tất: video shorts đã được tạo và thêm vào slide "Short AI".');aiReadWallPatched(i);loadPatchedWall();}catch(e){hideProgress();alert('Không tạo được shorts: '+e.message)}};
|
| 739 |
+
window.createTopicPost=function(){let inp=document.getElementById('ai-topic-input');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&(j.posts||j.post)){let arr=j.posts||[j.post];patchedWall=arr.concat(patchedWall.filter(x=>!arr.find(y=>y.id===x.id)));renderPatchedWall();if(inp)inp.value='';alert(`Đã lọc và tóm tắt ${arr.length} bài viết theo chủ đề lên Tường AI`);}else alert(j.error||'Lỗi tạo bài')}).catch(e=>alert(e.message||'Lỗi tạo bài'));};
|
| 740 |
+
window.createUrlPost=function(){let inp=document.getElementById('ai-url-input');let url=(inp&&inp.value||'').trim();if(!url)return alert('Dán URL trước');if(!/^https?:\/\//i.test(url))return alert('URL cần bắt đầu bằng http:// hoặc https://');fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&j.post){patchedWall=[j.post].concat(patchedWall.filter(x=>x.id!==j.post.id));renderPatchedWall();if(inp)inp.value='';alert('Đã tóm tắt URL và đăng lên Tường AI');}else alert(j.error||'Lỗi URL')}).catch(e=>alert(e.message||'Lỗi URL'));};
|
| 741 |
+
window.rewriteCurrentArticle=function(){if(!window._currentArticle&&typeof _currentArticle!=='undefined')window._currentArticle=_currentArticle;let cur=window._currentArticle||_currentArticle;if(!cur)return;let btn=document.querySelector('.article-actions button.primary');if(btn){btn.textContent='Đang tóm tắt...';btn.disabled=true}fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:cur.url})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&j.post){document.getElementById('rewrite-result').innerHTML=`<div class="rewrite-box"><div class="rewrite-title">Đã tóm tắt và đăng Tường AI</div><div class="rewrite-text">${esc(j.post.text||'')}</div></div>`;patchedWall=[j.post].concat(patchedWall.filter(x=>x.id!==j.post.id));renderPatchedWall();alert('Đã tóm tắt lên Tường AI');}else alert(j.error||'Không tóm tắt được')}).catch(e=>alert(e.message||'Lỗi tóm tắt')).finally(()=>{if(btn){btn.textContent='🤖 Tóm tắt AI & đăng tường';btn.disabled=false}})};
|
| 742 |
+
setTimeout(loadPatchedWall,1500);setInterval(updateAiLabels,2000);
|
| 743 |
+
})();
|
| 744 |
+
</script>
|
| 745 |
+
'''
|
| 746 |
+
|
| 747 |
+
@app.get('/')
|
| 748 |
+
async def index_patched():
|
| 749 |
+
with open('/app/static/index.html','r',encoding='utf-8') as f:
|
| 750 |
+
html=f.read()
|
| 751 |
+
return HTMLResponse(html.replace('</body>', PATCH_INJECT+'\n</body>'))
|
ai_runtime_final6.py
CHANGED
|
@@ -1,849 +1,394 @@
|
|
| 1 |
-
"""Final6:
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
"""
|
| 5 |
-
import re, time, json, os, threading, html as html_lib
|
| 6 |
-
from urllib.parse import quote, urlparse, parse_qs, unquote
|
| 7 |
-
import requests
|
| 8 |
-
from bs4 import BeautifulSoup
|
| 9 |
import ai_runtime_final5 as f5
|
| 10 |
-
from ai_runtime_final5 import app, rt, HTMLResponse, JSONResponse, Request, Query
|
|
|
|
| 11 |
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
_SHORTS_CACHE_FINAL6={"t":0,"d":[]}
|
| 18 |
-
_TRANSLATE_CACHE_PATH="/data/title_vi_cache.json" if os.path.isdir('/data') else "/app/data/title_vi_cache.json"
|
| 19 |
-
_translate_lock=threading.Lock()
|
| 20 |
-
YOUTUBE_HANDLES=["baodantri7941","baosuckhoedoisongboyte"]
|
| 21 |
-
UA={"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36","Accept-Language":"vi,en;q=0.8"}
|
| 22 |
-
STOP_WORDS=set('và của các những một được trong với cho tại sau trước khi không người việt nam hôm nay mới nhất nóng tin tức cập nhật'.split())
|
| 23 |
-
TRUSTED_SITES=['vnexpress.net','dantri.com.vn','vietnamnet.vn','tuoitre.vn','thanhnien.vn','laodong.vn','vov.vn','vtv.vn','genk.vn','cafef.vn','thethaovanhoa.vn']
|
| 24 |
|
| 25 |
-
def clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or ""))).strip()
|
| 26 |
def _domain(u):
|
| 27 |
-
try:return urlparse(u or '').netloc.replace('www.','')
|
| 28 |
-
except
|
| 29 |
-
|
| 30 |
-
def _load_title_cache():
|
| 31 |
-
try:
|
| 32 |
-
if os.path.exists(_TRANSLATE_CACHE_PATH):
|
| 33 |
-
with open(_TRANSLATE_CACHE_PATH,'r',encoding='utf-8') as f:return json.load(f)
|
| 34 |
-
except Exception:pass
|
| 35 |
-
return {}
|
| 36 |
-
def _save_title_cache(db):
|
| 37 |
-
try:
|
| 38 |
-
os.makedirs(os.path.dirname(_TRANSLATE_CACHE_PATH),exist_ok=True);tmp=_TRANSLATE_CACHE_PATH+'.tmp'
|
| 39 |
-
with open(tmp,'w',encoding='utf-8') as f:json.dump(db,f,ensure_ascii=False)
|
| 40 |
-
os.replace(tmp,_TRANSLATE_CACHE_PATH)
|
| 41 |
-
except Exception:pass
|
| 42 |
-
|
| 43 |
-
def _looks_vietnamese(s):
|
| 44 |
-
s=s or ''
|
| 45 |
-
if re.search(r'[àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ]',s,re.I):return True
|
| 46 |
-
low=' '+s.lower()+' '
|
| 47 |
-
return any(w in low for w in [' và ',' của ',' người ',' tại ',' trong ',' với ',' không ',' được ',' công an ',' bệnh viện ',' học sinh ',' tài xế ',' bóng đá ',' tin tức ',' sức khỏe '])
|
| 48 |
-
def _translate_title_vi(title):
|
| 49 |
-
title=clean(title)
|
| 50 |
-
if not title or _looks_vietnamese(title):return title
|
| 51 |
-
with _translate_lock:
|
| 52 |
-
db=_load_title_cache()
|
| 53 |
-
if title in db:return db[title]
|
| 54 |
-
vi=title
|
| 55 |
-
try:
|
| 56 |
-
r=requests.get('https://translate.googleapis.com/translate_a/single',params={'client':'gtx','sl':'auto','tl':'vi','dt':'t','q':title},headers=UA,timeout=8)
|
| 57 |
-
if r.status_code==200:
|
| 58 |
-
data=r.json();vi=''.join(part[0] for part in data[0] if part and part[0]).strip() or title
|
| 59 |
-
except Exception:pass
|
| 60 |
-
vi=clean(vi)
|
| 61 |
-
with _translate_lock:
|
| 62 |
-
db=_load_title_cache();db[title]=vi;_save_title_cache(db)
|
| 63 |
-
return vi
|
| 64 |
|
| 65 |
-
|
| 66 |
-
def _keywords_from_title(title):
|
| 67 |
-
title=clean(re.sub(r'\s+-\s+.*$','',title))
|
| 68 |
-
words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',title) if len(w)>2 and w.lower() not in STOP_WORDS]
|
| 69 |
-
phrases=[]
|
| 70 |
-
for n in (4,3,2):
|
| 71 |
-
for i in range(0,max(0,len(words)-n+1)):
|
| 72 |
-
ph=' '.join(words[i:i+n]).strip()
|
| 73 |
-
if len(ph)>=8:phrases.append(ph)
|
| 74 |
-
if words:phrases.append(' '.join(words[:5]))
|
| 75 |
-
return phrases[:4]
|
| 76 |
-
|
| 77 |
-
def _hot_topics():
|
| 78 |
-
now=time.time()
|
| 79 |
-
if _HOT_CACHE['d'] and now-_HOT_CACHE['t']<900:return _HOT_CACHE['d']
|
| 80 |
-
topics=[];seen=set()
|
| 81 |
-
feeds=[
|
| 82 |
-
'https://news.google.com/rss?hl=vi&gl=VN&ceid=VN:vi',
|
| 83 |
-
'https://news.google.com/rss/headlines/section/topic/NATION?hl=vi&gl=VN&ceid=VN:vi',
|
| 84 |
-
'https://news.google.com/rss/headlines/section/topic/BUSINESS?hl=vi&gl=VN&ceid=VN:vi',
|
| 85 |
-
'https://news.google.com/rss/headlines/section/topic/SPORTS?hl=vi&gl=VN&ceid=VN:vi',
|
| 86 |
-
'https://news.google.com/rss/headlines/section/topic/TECHNOLOGY?hl=vi&gl=VN&ceid=VN:vi'
|
| 87 |
-
]
|
| 88 |
-
for feed in feeds:
|
| 89 |
-
try:
|
| 90 |
-
r=requests.get(feed,headers=UA,timeout=10);r.encoding='utf-8'
|
| 91 |
-
soup=BeautifulSoup(r.text,'xml')
|
| 92 |
-
for it in soup.find_all('item')[:15]:
|
| 93 |
-
title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
|
| 94 |
-
for kw in _keywords_from_title(title):
|
| 95 |
-
key=kw.lower()
|
| 96 |
-
if key not in seen and len(kw)<=60:
|
| 97 |
-
seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
|
| 98 |
-
if len(topics)>=24:break
|
| 99 |
-
if len(topics)>=24:break
|
| 100 |
-
except Exception:pass
|
| 101 |
-
if len(topics)>=24:break
|
| 102 |
-
for kw in ['AI trong giáo dục','World Cup 2026','kinh tế Việt Nam','biến đổi khí hậu','giá vàng','bóng đá Việt Nam','an ninh mạng','xe điện','sức khỏe tinh thần','thị trường chứng khoán']:
|
| 103 |
-
if kw.lower() not in seen:topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
|
| 104 |
-
_HOT_CACHE.update({'t':now,'d':topics[:24]})
|
| 105 |
-
return _HOT_CACHE['d']
|
| 106 |
-
@app.get('/api/hot_topics')
|
| 107 |
-
def api_hot_topics():return JSONResponse({'topics':_hot_topics()})
|
| 108 |
-
|
| 109 |
-
# ===== Topic web research =====
|
| 110 |
-
def _unwrap_ddg_href(href):
|
| 111 |
-
if not href:return ''
|
| 112 |
-
if href.startswith('//duckduckgo.com/l/?') or 'duckduckgo.com/l/?' in href:
|
| 113 |
-
qs=parse_qs(urlparse('https:'+href if href.startswith('//') else href).query)
|
| 114 |
-
return unquote(qs.get('uddg',[''])[0])
|
| 115 |
-
return href
|
| 116 |
-
|
| 117 |
-
def _ddg_search(query, limit=10):
|
| 118 |
-
items=[];seen=set()
|
| 119 |
try:
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
for res in soup.select('.result'):
|
| 124 |
-
a=res.select_one('.result__title a') or res.find('a',href=True)
|
| 125 |
-
if not a:continue
|
| 126 |
-
link=_unwrap_ddg_href(a.get('href',''));title=clean(a.get_text(' ',strip=True));snippet=clean((res.select_one('.result__snippet') or res).get_text(' ',strip=True))
|
| 127 |
-
if not link.startswith('http') or link in seen:continue
|
| 128 |
-
if any(bad in link for bad in ['duckduckgo.com','youtube.com','facebook.com','tiktok.com','twitter.com','x.com']):continue
|
| 129 |
-
seen.add(link);items.append({'title':title,'url':link,'source':_domain(link),'snippet':snippet})
|
| 130 |
-
if len(items)>=limit:break
|
| 131 |
-
except Exception:pass
|
| 132 |
-
return items
|
| 133 |
|
| 134 |
-
def
|
| 135 |
-
items=[];seen=set()
|
| 136 |
try:
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
|
| 142 |
-
link=clean(it.find('link').get_text(strip=True) if it.find('link') else '')
|
| 143 |
-
src=clean(it.find('source').get_text(' ',strip=True) if it.find('source') else _domain(link))
|
| 144 |
-
if title and link and link not in seen:
|
| 145 |
-
seen.add(link);items.append({'title':title,'url':link,'source':src,'snippet':''})
|
| 146 |
-
if len(items)>=limit:break
|
| 147 |
-
except Exception:pass
|
| 148 |
-
return items
|
| 149 |
-
|
| 150 |
-
def _candidate_urls(topic):
|
| 151 |
-
seen=set();items=[]
|
| 152 |
-
queries=[topic+' tin tức Việt Nam', topic+' phân tích bối cảnh', topic+' site:vnexpress.net OR site:dantri.com.vn OR site:vietnamnet.vn']
|
| 153 |
-
for q in queries:
|
| 154 |
-
for it in _ddg_search(q,8):
|
| 155 |
-
if it['url'] not in seen:
|
| 156 |
-
seen.add(it['url']);items.append(it)
|
| 157 |
-
if len(items)>=12:break
|
| 158 |
-
for site in TRUSTED_SITES[:8]:
|
| 159 |
-
for it in _ddg_search(f'{topic} site:{site}',3):
|
| 160 |
-
if it['url'] not in seen:
|
| 161 |
-
seen.add(it['url']);items.append(it)
|
| 162 |
-
for it in _google_news_items(topic,8):
|
| 163 |
-
if it['url'] not in seen:
|
| 164 |
-
seen.add(it['url']);items.append(it)
|
| 165 |
-
return items[:24]
|
| 166 |
|
| 167 |
-
def
|
| 168 |
try:
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
if len(t)>45 and not any(x in t.lower() for x in ['đăng ký nhận tin','theo dõi chúng tôi','chuyên mục','xem thêm','tin liên quan','advertisement']):ps.append(t)
|
| 183 |
-
if sum(len(x) for x in ps)>max_chars:break
|
| 184 |
-
return '\n'.join(ps)[:max_chars]
|
| 185 |
-
except Exception:return ''
|
| 186 |
|
| 187 |
-
def
|
| 188 |
try:
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
if len(text)<350:text=_jina_read_text(url,max_chars)
|
| 204 |
-
return text
|
| 205 |
|
| 206 |
-
def
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
|
| 212 |
-
def
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
if
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
crawled.append({**it,'text':it['snippet'],'rel':rel,'snippet_only':True})
|
| 224 |
-
crawled=sorted(crawled,key=lambda x:(x.get('rel',0),len(x.get('text',''))),reverse=True)[:6]
|
| 225 |
-
blocks=[];sources=[]
|
| 226 |
-
for it in crawled:
|
| 227 |
-
label='ĐOẠN MÔ TẢ TỪ KẾT QUẢ TÌM KIẾM' if it.get('snippet_only') else 'NỘI DUNG BÀI VIẾT ĐÃ CRAWL'
|
| 228 |
-
blocks.append(f"NGUỒN: {it['source']}\nTIÊU ĐỀ: {it['title']}\n{label}:\n{it['text'][:8500]}")
|
| 229 |
-
sources.append({'title':it['title'],'url':it['url'],'via':it['source']})
|
| 230 |
-
data={'context':'\n\n---\n\n'.join(blocks),'sources':sources[:8],'count':len(blocks)}
|
| 231 |
-
_TOPIC_CACHE[key]={'t':now,'d':data}
|
| 232 |
-
return data
|
| 233 |
|
| 234 |
def _topic_image(topic):
|
| 235 |
-
try:return
|
| 236 |
-
except
|
| 237 |
-
|
| 238 |
-
@app.get('/api/topic_sources')
|
| 239 |
-
def api_topic_sources(topic:str=Query(...)):
|
| 240 |
-
data=_web_research_context(clean(topic))
|
| 241 |
-
return JSONResponse({'count':data.get('count',0),'sources':data.get('sources',[]),'has_context':bool(data.get('context'))})
|
| 242 |
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
|
| 252 |
-
|
|
|
|
|
|
|
| 253 |
|
| 254 |
-
|
| 255 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
"""
|
| 266 |
-
text=await f5.base.qwen_generate(prompt,image_url=img,max_tokens=2800)
|
| 267 |
-
if not text or len(text)<500:
|
| 268 |
-
parts=[]
|
| 269 |
-
for block in context.split('---'):
|
| 270 |
-
body=block.split('NỘI DUNG BÀI VIẾT ĐÃ CRAWL:')[-1].split('ĐOẠN MÔ TẢ TỪ KẾT QUẢ TÌM KIẾM:')[-1].strip()
|
| 271 |
-
if len(body)>120:parts.append(body)
|
| 272 |
-
joined='\n\n'.join(parts)[:8500]
|
| 273 |
-
text=(f"{topic}: những điểm chính cần biết\n\n{topic} đang thu hút sự chú ý vì liên quan đến nhiều khía cạnh thực tế. Tổng hợp từ các nội dung thu thập được, có thể nhìn vấn đề qua bối cảnh, tác động và những điểm cần theo dõi.\n\n"+joined+"\n\nNguồn tham khảo: "+', '.join(sorted({s.get('via','') for s in sources if s.get('via')})))
|
| 274 |
-
post=f5.base.make_post(topic,text,img,'','topic_web_synthesis',sources=[s for s in sources if s.get('url')]);post['images']=[img]
|
| 275 |
-
posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts)
|
| 276 |
-
return JSONResponse({'post':post})
|
| 277 |
|
| 278 |
-
|
| 279 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
try:
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
try:
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
raw=[]
|
| 321 |
-
for h in YOUTUBE_HANDLES:raw.extend(_yt_ytdlp(h,30) or _yt_html(h,30))
|
| 322 |
-
raw.extend(_fallback_shorts())
|
| 323 |
-
seen=set();out=[]
|
| 324 |
-
for v in raw:
|
| 325 |
-
vid=v.get('id') or ''
|
| 326 |
-
if not vid:
|
| 327 |
-
m=re.search(r'(?:v=|shorts/|youtu\.be/)([A-Za-z0-9_-]{11})',v.get('link',''));vid=m.group(1) if m else ''
|
| 328 |
-
title=_translate_title_vi(v.get('title') or 'YouTube Short');key=vid or re.sub(r'\W+','',title.lower())[:80]
|
| 329 |
-
if not key or key in seen:continue
|
| 330 |
-
seen.add(key);item=dict(v);item['id']=vid;item['title']=title
|
| 331 |
-
if vid:item['link']='https://www.youtube.com/watch?v='+vid;item['img']='https://i.ytimg.com/vi/'+vid+'/hqdefault.jpg'
|
| 332 |
-
item['source']='yt';out.append(item)
|
| 333 |
-
if len(out)>=40:break
|
| 334 |
-
_SHORTS_CACHE_FINAL6.update({'t':now,'d':out})
|
| 335 |
-
return JSONResponse(out)
|
| 336 |
|
| 337 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
<style>
|
| 339 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
</style>
|
| 341 |
<script>
|
| 342 |
(function(){
|
| 343 |
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
function
|
| 348 |
-
window.readLiveTopicWall=function(i){let p=liveTopicWall[i];if(!p)return;showView('view-article');let imgs=(p.images||[]).filter(Boolean);let gal=imgs.length?'<div class="ai-wall-gallery">'+imgs.slice(0,12).map(u=>`<img src="${esc(u)}" loading="lazy">`).join('')+'</div>':(p.img?`<img class="article-img" src="${esc(p.img)}">`:'');document.getElementById('view-article').innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${esc(p.title)}</h1>${gal}<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p><div class="article-actions"><button onclick="shareAI?shareAI(${JSON.stringify(p).replace(/"/g,'"')},false):navigator.clipboard.writeText(location.href)">📤 Chia sẻ</button></div></div>`;window.scrollTo(0,0)};
|
| 349 |
-
window.createTopicPostFinal5=async function(){let inp=document.getElementById('ai-topic-input-final5');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');let btn=document.getElementById('ai-topic-btn-final5');if(btn){btn.disabled=true;btn.textContent='Đang tìm nguồn...'}try{let src=await fetch('/api/topic_sources?topic='+encodeURIComponent(topic)).then(r=>r.json()).catch(()=>null);if(btn&&src)btn.textContent='Đã tìm '+(src.count||0)+' nguồn, đang tổng hợp...';let r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');liveTopicWall.unshift(j.post);if(inp)inp.value='';renderLiveTopicWall();readLiveTopicWall(0);alert('Đã tạo bài tổng hợp từ nội dung web và đăng lên Tường AI.');}catch(e){alert(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='✨ Tạo bài tổng hợp từ web bằng Qwen'}}};
|
| 350 |
-
setInterval(()=>{document.querySelectorAll('#ai-topic-input-final3,.topic-final3,#ai-topic-input-final4,.topic-final4').forEach(e=>(e.closest('.topic-final3,.topic-final4,.ai-compose-row')||e).remove());let b=document.getElementById('ai-topic-btn-final5');if(b){b.style.display='block';b.textContent='✨ Tạo bài tổng hợp từ web bằng Qwen';}ensureHotTopics();ensureNewsShortsHome();},1200);setTimeout(()=>{ensureHotTopics();ensureNewsShortsHome();},1200);
|
| 351 |
})();
|
| 352 |
</script>
|
| 353 |
'''
|
| 354 |
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
# ===== FINAL6B: Vietnam hot hashtags + reliable VN RSS/source retrieval =====
|
| 363 |
-
VN_RSS_FEEDS = [
|
| 364 |
-
('VnExpress Thời sự','https://vnexpress.net/rss/thoi-su.rss'),
|
| 365 |
-
('VnExpress Thế giới','https://vnexpress.net/rss/the-gioi.rss'),
|
| 366 |
-
('VnExpress Kinh doanh','https://vnexpress.net/rss/kinh-doanh.rss'),
|
| 367 |
-
('VnExpress Công nghệ','https://vnexpress.net/rss/so-hoa.rss'),
|
| 368 |
-
('VnExpress Thể thao','https://vnexpress.net/rss/the-thao.rss'),
|
| 369 |
-
('VnExpress Giải trí','https://vnexpress.net/rss/giai-tri.rss'),
|
| 370 |
-
('VnExpress Sức khỏe','https://vnexpress.net/rss/suc-khoe.rss'),
|
| 371 |
-
('VnExpress Giáo dục','https://vnexpress.net/rss/giao-duc.rss'),
|
| 372 |
-
('Dân trí Xã hội','https://dantri.com.vn/rss/xa-hoi.rss'),
|
| 373 |
-
('Dân trí Thế giới','https://dantri.com.vn/rss/the-gioi.rss'),
|
| 374 |
-
('Dân trí Kinh doanh','https://dantri.com.vn/rss/kinh-doanh.rss'),
|
| 375 |
-
('Dân trí Sức khỏe','https://dantri.com.vn/rss/suc-khoe.rss'),
|
| 376 |
-
('Dân trí Thể thao','https://dantri.com.vn/rss/the-thao.rss'),
|
| 377 |
-
('Dân trí Công nghệ','https://dantri.com.vn/rss/suc-manh-so.rss'),
|
| 378 |
-
('Vietnamnet Thời sự','https://vietnamnet.vn/thoi-su.rss'),
|
| 379 |
-
('Vietnamnet Kinh doanh','https://vietnamnet.vn/kinh-doanh.rss'),
|
| 380 |
-
('Vietnamnet Công nghệ','https://vietnamnet.vn/cong-nghe.rss'),
|
| 381 |
-
('Vietnamnet Thể thao','https://vietnamnet.vn/the-thao.rss'),
|
| 382 |
-
]
|
| 383 |
-
|
| 384 |
-
def _fetch_rss_items(feed_name, feed_url, max_items=15):
|
| 385 |
-
items=[]
|
| 386 |
-
try:
|
| 387 |
-
r=requests.get(feed_url,headers=UA,timeout=10);r.encoding='utf-8'
|
| 388 |
-
soup=BeautifulSoup(r.text,'xml')
|
| 389 |
-
for it in soup.find_all('item')[:max_items]:
|
| 390 |
-
title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
|
| 391 |
-
link=clean(it.find('link').get_text(strip=True) if it.find('link') else '')
|
| 392 |
-
desc=it.find('description').get_text(' ',strip=True) if it.find('description') else ''
|
| 393 |
-
desc_txt=clean(BeautifulSoup(desc,'lxml').get_text(' ',strip=True))
|
| 394 |
-
if title and link:
|
| 395 |
-
items.append({'title':title,'url':link,'source':feed_name,'snippet':desc_txt})
|
| 396 |
-
except Exception:pass
|
| 397 |
-
return items
|
| 398 |
-
|
| 399 |
-
def _vn_rss_pool():
|
| 400 |
-
now=time.time();key='vn_rss_pool'
|
| 401 |
-
if key in _TOPIC_CACHE and now-_TOPIC_CACHE[key]['t']<600:return _TOPIC_CACHE[key]['d']
|
| 402 |
-
pool=[];seen=set()
|
| 403 |
-
for name,url in VN_RSS_FEEDS:
|
| 404 |
-
for it in _fetch_rss_items(name,url,12):
|
| 405 |
-
if it['url'] not in seen:
|
| 406 |
-
seen.add(it['url']);pool.append(it)
|
| 407 |
-
_TOPIC_CACHE[key]={'t':now,'d':pool}
|
| 408 |
-
return pool
|
| 409 |
-
|
| 410 |
-
def _topic_tokens(topic):
|
| 411 |
-
toks=[w.lower() for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',topic or '') if len(w)>1]
|
| 412 |
-
return [t for t in toks if t not in STOP_WORDS]
|
| 413 |
-
|
| 414 |
-
def _score_topic_item(topic,item):
|
| 415 |
-
toks=_topic_tokens(topic)
|
| 416 |
-
hay=(item.get('title','')+' '+item.get('snippet','')+' '+item.get('source','')).lower()
|
| 417 |
-
if not toks:return 0
|
| 418 |
-
score=0
|
| 419 |
-
for t in toks:
|
| 420 |
-
if t in hay:score+=2 if len(t)>3 else 1
|
| 421 |
-
phrase=topic.lower().strip()
|
| 422 |
-
if phrase and phrase in hay:score+=8
|
| 423 |
-
return score
|
| 424 |
-
|
| 425 |
-
# Override: hashtags must be Việt Nam-focused, using VN news RSS directly.
|
| 426 |
-
def _hot_topics():
|
| 427 |
-
now=time.time()
|
| 428 |
-
if _HOT_CACHE['d'] and now-_HOT_CACHE['t']<600:return _HOT_CACHE['d']
|
| 429 |
-
pool=_vn_rss_pool()
|
| 430 |
-
freq={};display={}
|
| 431 |
-
for it in pool[:180]:
|
| 432 |
-
title=re.sub(r'\s+-\s+.*$','',it.get('title',''))
|
| 433 |
-
# Extract compact Vietnamese hot phrases from current VN headlines.
|
| 434 |
-
kws=[]
|
| 435 |
-
# quoted/name phrases first
|
| 436 |
-
for m in re.findall(r'([A-ZĐÀ-Ỹ][A-Za-zÀ-ỹ0-9]+(?:\s+[A-ZĐÀ-ỸA-Za-zÀ-ỹ0-9][A-Za-zÀ-ỹ0-9]+){1,4})',title):
|
| 437 |
-
if len(m)>=6:kws.append(m)
|
| 438 |
-
kws += _keywords_from_title(title)
|
| 439 |
-
for kw in kws[:5]:
|
| 440 |
-
kw=clean(kw)
|
| 441 |
-
words=[w for w in kw.split() if w.lower() not in STOP_WORDS]
|
| 442 |
-
if len(words)<2:continue
|
| 443 |
-
kw=' '.join(words[:5])
|
| 444 |
-
if len(kw)<6 or len(kw)>55:continue
|
| 445 |
-
key=kw.lower()
|
| 446 |
-
freq[key]=freq.get(key,0)+1
|
| 447 |
-
display[key]=kw
|
| 448 |
-
ranked=sorted(freq.items(),key=lambda x:x[1],reverse=True)
|
| 449 |
-
topics=[];seen=set()
|
| 450 |
-
for key,_ in ranked:
|
| 451 |
-
kw=display[key]
|
| 452 |
-
if key in seen:continue
|
| 453 |
-
seen.add(key)
|
| 454 |
-
label='#'+re.sub(r'\s+','',kw.title())
|
| 455 |
-
topics.append({'label':label,'topic':kw})
|
| 456 |
-
if len(topics)>=24:break
|
| 457 |
-
# VN fallback, not generic global.
|
| 458 |
-
for kw in ['Giá vàng trong nước','Bão và mưa lũ','Bóng đá Việt Nam','Kinh tế Việt Nam','AI tại Việt Nam','Giá xăng dầu','Thị trường chứng khoán Việt Nam','Tuyển Việt Nam','Sức khỏe cộng đồng','An ninh mạng Việt Nam']:
|
| 459 |
-
if kw.lower() not in seen:topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
|
| 460 |
-
_HOT_CACHE.update({'t':now,'d':topics[:24]})
|
| 461 |
-
return _HOT_CACHE['d']
|
| 462 |
-
|
| 463 |
-
def _candidate_urls(topic):
|
| 464 |
-
seen=set();items=[]
|
| 465 |
-
# 1) VN RSS pool relevance is most reliable and has direct URLs.
|
| 466 |
-
scored=[]
|
| 467 |
-
for it in _vn_rss_pool():
|
| 468 |
-
sc=_score_topic_item(topic,it)
|
| 469 |
-
if sc>0:scored.append((sc,it))
|
| 470 |
-
for sc,it in sorted(scored,key=lambda x:x[0],reverse=True)[:12]:
|
| 471 |
-
if it['url'] not in seen:
|
| 472 |
-
seen.add(it['url']);items.append(it)
|
| 473 |
-
# 2) Search trusted web if RSS not enough.
|
| 474 |
-
queries=[topic+' Việt Nam tin tức',topic+' phân tích Việt Nam',topic+' mới nhất']
|
| 475 |
-
for q in queries:
|
| 476 |
-
for it in _ddg_search(q,8):
|
| 477 |
-
if it['url'] not in seen:
|
| 478 |
-
seen.add(it['url']);items.append(it)
|
| 479 |
-
if len(items)>=14:break
|
| 480 |
-
# 3) Google News as supplemental titles/direct links.
|
| 481 |
-
for it in _google_news_items(topic,10):
|
| 482 |
-
if it['url'] not in seen:
|
| 483 |
-
seen.add(it['url']);items.append(it)
|
| 484 |
-
return items[:24]
|
| 485 |
-
|
| 486 |
-
def _web_research_context(topic):
|
| 487 |
-
now=time.time();key='ctx2:'+topic.lower().strip()
|
| 488 |
-
if key in _TOPIC_CACHE and now-_TOPIC_CACHE[key]['t']<900:return _TOPIC_CACHE[key]['d']
|
| 489 |
-
items=_candidate_urls(topic)
|
| 490 |
-
crawled=[]
|
| 491 |
-
for it in items:
|
| 492 |
-
text=_scrape_article_text(it['url'],9000)
|
| 493 |
-
rel=_score_relevance(topic,it.get('title',''),text,it.get('snippet','')) or _score_topic_item(topic,it)
|
| 494 |
-
# If RSS item has good snippet, keep it even when full text blocks.
|
| 495 |
-
if text and len(text)>300 and rel>0:
|
| 496 |
-
crawled.append({**it,'text':text,'rel':rel})
|
| 497 |
-
elif it.get('snippet') and len(it['snippet'])>120 and rel>0:
|
| 498 |
-
crawled.append({**it,'text':it['snippet'],'rel':rel,'snippet_only':True})
|
| 499 |
-
crawled=sorted(crawled,key=lambda x:(x.get('rel',0),len(x.get('text',''))),reverse=True)[:7]
|
| 500 |
-
blocks=[];sources=[]
|
| 501 |
-
for it in crawled:
|
| 502 |
-
label='ĐOẠN MÔ TẢ TỪ RSS/TÌM KIẾM' if it.get('snippet_only') else 'NỘI DUNG BÀI VIẾT ĐÃ CRAWL'
|
| 503 |
-
blocks.append(f"NGUỒN: {it['source']}\nTIÊU ĐỀ: {it['title']}\n{label}:\n{it['text'][:8500]}")
|
| 504 |
-
sources.append({'title':it['title'],'url':it['url'],'via':it['source']})
|
| 505 |
-
data={'context':'\n\n---\n\n'.join(blocks),'sources':sources[:8],'count':len(blocks)}
|
| 506 |
-
_TOPIC_CACHE[key]={'t':now,'d':data}
|
| 507 |
-
return data
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
# ===== FINAL6C: FAST topic generation (RSS cache first, no slow full-page crawling) =====
|
| 511 |
-
import asyncio
|
| 512 |
-
_FAST_TOPIC_CACHE={}
|
| 513 |
-
FAST_RSS_FEEDS=[
|
| 514 |
-
('VnExpress','https://vnexpress.net/rss/tin-moi-nhat.rss'),
|
| 515 |
-
('VnExpress Thời sự','https://vnexpress.net/rss/thoi-su.rss'),
|
| 516 |
-
('VnExpress Thế giới','https://vnexpress.net/rss/the-gioi.rss'),
|
| 517 |
-
('VnExpress Kinh doanh','https://vnexpress.net/rss/kinh-doanh.rss'),
|
| 518 |
-
('VnExpress Công nghệ','https://vnexpress.net/rss/so-hoa.rss'),
|
| 519 |
-
('VnExpress Thể thao','https://vnexpress.net/rss/the-thao.rss'),
|
| 520 |
-
('Dân trí','https://dantri.com.vn/rss/home.rss'),
|
| 521 |
-
('Dân trí Xã hội','https://dantri.com.vn/rss/xa-hoi.rss'),
|
| 522 |
-
('Dân trí Kinh doanh','https://dantri.com.vn/rss/kinh-doanh.rss'),
|
| 523 |
-
('Dân trí Thể thao','https://dantri.com.vn/rss/the-thao.rss'),
|
| 524 |
-
('Dân trí Công nghệ','https://dantri.com.vn/rss/suc-manh-so.rss'),
|
| 525 |
-
('Vietnamnet','https://vietnamnet.vn/rss/tin-moi-nhat.rss'),
|
| 526 |
-
('Vietnamnet Thời sự','https://vietnamnet.vn/thoi-su.rss'),
|
| 527 |
-
('Vietnamnet Kinh doanh','https://vietnamnet.vn/kinh-doanh.rss'),
|
| 528 |
-
('Vietnamnet Công nghệ','https://vietnamnet.vn/cong-nghe.rss'),
|
| 529 |
-
('Vietnamnet Thể thao','https://vietnamnet.vn/the-thao.rss'),
|
| 530 |
-
]
|
| 531 |
-
|
| 532 |
-
def _fast_fetch_rss(feed_name, feed_url, max_items=20):
|
| 533 |
-
items=[]
|
| 534 |
-
try:
|
| 535 |
-
r=requests.get(feed_url,headers=UA,timeout=6);r.encoding='utf-8'
|
| 536 |
-
soup=BeautifulSoup(r.text,'xml')
|
| 537 |
-
for it in soup.find_all('item')[:max_items]:
|
| 538 |
-
title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
|
| 539 |
-
link=clean(it.find('link').get_text(strip=True) if it.find('link') else '')
|
| 540 |
-
desc_raw=it.find('description').get_text(' ',strip=True) if it.find('description') else ''
|
| 541 |
-
desc=clean(BeautifulSoup(desc_raw,'lxml').get_text(' ',strip=True))
|
| 542 |
-
if title and link:
|
| 543 |
-
items.append({'title':title,'url':link,'source':feed_name,'snippet':desc})
|
| 544 |
-
except Exception:pass
|
| 545 |
-
return items
|
| 546 |
-
|
| 547 |
-
def _fast_rss_pool():
|
| 548 |
-
now=time.time();key='fast_rss_pool'
|
| 549 |
-
if key in _FAST_TOPIC_CACHE and now-_FAST_TOPIC_CACHE[key]['t']<600:return _FAST_TOPIC_CACHE[key]['d']
|
| 550 |
-
pool=[];seen=set()
|
| 551 |
-
# Sequential with short timeouts is predictable; RSS is small.
|
| 552 |
-
for name,url in FAST_RSS_FEEDS:
|
| 553 |
-
for it in _fast_fetch_rss(name,url,16):
|
| 554 |
-
if it['url'] not in seen:
|
| 555 |
-
seen.add(it['url']);pool.append(it)
|
| 556 |
-
_FAST_TOPIC_CACHE[key]={'t':now,'d':pool}
|
| 557 |
-
return pool
|
| 558 |
-
|
| 559 |
-
def _fast_topic_tokens(topic):
|
| 560 |
-
toks=[w.lower() for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',topic or '') if len(w)>1]
|
| 561 |
-
return [t for t in toks if t not in STOP_WORDS]
|
| 562 |
-
|
| 563 |
-
def _fast_score(topic,item):
|
| 564 |
-
toks=_fast_topic_tokens(topic)
|
| 565 |
-
hay=(item.get('title','')+' '+item.get('snippet','')+' '+item.get('source','')).lower()
|
| 566 |
-
if not toks:return 0
|
| 567 |
-
score=0
|
| 568 |
-
for t in toks:
|
| 569 |
-
if t in hay:score+=3 if len(t)>3 else 1
|
| 570 |
-
phrase=topic.lower().strip()
|
| 571 |
-
if phrase and phrase in hay:score+=12
|
| 572 |
-
return score
|
| 573 |
-
|
| 574 |
-
def _fast_sources(topic, limit=8):
|
| 575 |
-
pool=_fast_rss_pool()
|
| 576 |
-
scored=[]
|
| 577 |
-
for it in pool:
|
| 578 |
-
sc=_fast_score(topic,it)
|
| 579 |
-
if sc>0:scored.append((sc,it))
|
| 580 |
-
scored=sorted(scored,key=lambda x:(x[0],len(x[1].get('snippet',''))),reverse=True)
|
| 581 |
-
out=[];seen=set()
|
| 582 |
-
for sc,it in scored:
|
| 583 |
-
if it['url'] in seen:continue
|
| 584 |
-
seen.add(it['url']);out.append({**it,'score':sc})
|
| 585 |
-
if len(out)>=limit:break
|
| 586 |
-
# If topic too narrow and no match, use top latest from VN RSS as weak context instead of slow crawling.
|
| 587 |
-
if not out:
|
| 588 |
-
out=pool[:min(limit,8)]
|
| 589 |
-
return out
|
| 590 |
-
|
| 591 |
-
def _fast_context(topic):
|
| 592 |
-
now=time.time();key='fast_ctx:'+topic.lower().strip()
|
| 593 |
-
if key in _FAST_TOPIC_CACHE and now-_FAST_TOPIC_CACHE[key]['t']<600:return _FAST_TOPIC_CACHE[key]['d']
|
| 594 |
-
sources=_fast_sources(topic,8)
|
| 595 |
-
blocks=[];src=[]
|
| 596 |
-
for it in sources:
|
| 597 |
-
text=(it.get('snippet') or '').strip()
|
| 598 |
-
# Use title + RSS description only: fast and reliable.
|
| 599 |
-
blocks.append(f"NGUỒN: {it.get('source','')}\nTIÊU ĐỀ: {it.get('title','')}\nTÓM TẮT RSS:\n{text}")
|
| 600 |
-
src.append({'title':it.get('title',''),'url':it.get('url',''),'via':it.get('source','')})
|
| 601 |
-
data={'context':'\n\n---\n\n'.join(blocks),'sources':src,'count':len(blocks)}
|
| 602 |
-
_FAST_TOPIC_CACHE[key]={'t':now,'d':data}
|
| 603 |
-
return data
|
| 604 |
-
|
| 605 |
-
def _fallback_fast_article(topic, sources):
|
| 606 |
-
lines=[]
|
| 607 |
-
for s in sources[:7]:
|
| 608 |
-
title=s.get('title','')
|
| 609 |
-
if title:lines.append(title)
|
| 610 |
-
body='\n'.join('• '+x for x in lines[:7])
|
| 611 |
-
vias=', '.join(sorted({s.get('via','') for s in sources if s.get('via')}))
|
| 612 |
-
return (f"{topic}: những điểm đáng chú ý\n\n"
|
| 613 |
-
f"{topic} đang là chủ đề được quan tâm trong dòng tin tức hiện nay. Dựa trên các nguồn tin mới nhất, có thể tổng hợp nhanh một số điểm nổi bật để người đọc nắm bối cảnh và theo dõi tiếp diễn biến.\n\n"
|
| 614 |
-
f"Các nguồn tin liên quan cho thấy chủ đề này gắn với những diễn biến sau:\n{body}\n\n"
|
| 615 |
-
f"Nhìn chung, đây là vấn đề cần được theo dõi theo nhiều góc độ: bối cảnh, tác động thực tế, phản ứng của các bên liên quan và những thông tin cập nhật tiếp theo. Người đọc nên đối chiếu thêm các nguồn chính thống khi cần quyết định hoặc đánh giá chi tiết.\n\n"
|
| 616 |
-
f"Nguồn tham khảo: {vias}")
|
| 617 |
-
|
| 618 |
-
# Remove previous slow topic routes and register fast versions last.
|
| 619 |
-
app.router.routes=[r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in {('/api/topic_post','POST'),('/api/topic_sources','GET')})]
|
| 620 |
-
|
| 621 |
-
@app.get('/api/topic_sources')
|
| 622 |
-
def api_topic_sources_fast(topic:str=Query(...)):
|
| 623 |
-
data=_fast_context(clean(topic))
|
| 624 |
-
return JSONResponse({'count':data.get('count',0),'sources':data.get('sources',[]),'has_context':bool(data.get('context')),'mode':'fast_rss'})
|
| 625 |
-
|
| 626 |
-
@app.post('/api/topic_post')
|
| 627 |
-
async def topic_post_fast(request:Request):
|
| 628 |
-
body=await request.json();topic=clean(body.get('topic',''))
|
| 629 |
-
if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
|
| 630 |
-
img=_topic_image(topic)
|
| 631 |
-
research=_fast_context(topic);context=research.get('context','');sources=research.get('sources',[])
|
| 632 |
-
prompt=f"""Bạn là biên tập viên VNEWS. Hãy viết MỘT BÀI VIẾT HOÀN CHỈNH bằng tiếng Việt về chủ đề: {topic}
|
| 633 |
-
|
| 634 |
-
Dữ liệu nhanh từ RSS nguồn Việt Nam:
|
| 635 |
-
{context[:12000]}
|
| 636 |
-
|
| 637 |
-
Yêu cầu:
|
| 638 |
-
- Không liệt kê tiêu đề nguồn thành bài viết.
|
| 639 |
-
- Tổng hợp thành bài báo/tạp chí hoàn chỉnh.
|
| 640 |
-
- Có tiêu đề mới, sapo 2-3 câu, 4-6 đoạn phân tích/bối cảnh/tác động.
|
| 641 |
-
- Diễn đạt lại, không sao chép nguyên văn.
|
| 642 |
-
- Nếu dữ liệu ít, viết thận trọng và nêu các điểm cần theo dõi.
|
| 643 |
-
- Cuối bài có mục Nguồn tham khảo.
|
| 644 |
-
"""
|
| 645 |
-
text=None
|
| 646 |
-
try:
|
| 647 |
-
text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1300),timeout=28)
|
| 648 |
-
except Exception:
|
| 649 |
-
text=None
|
| 650 |
-
if not text or len(text)<350:
|
| 651 |
-
text=_fallback_fast_article(topic,sources)
|
| 652 |
-
post=f5.base.make_post(topic,text,img,'','topic_fast_rss',sources=[s for s in sources if s.get('url')])
|
| 653 |
-
post['images']=[img]
|
| 654 |
-
posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts)
|
| 655 |
-
return JSONResponse({'post':post,'mode':'fast_rss','sources_count':len(sources)})
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
# ===== FINAL6D: FAST HOME LOAD =====
|
| 659 |
-
_FAST_HOME_CACHE={"t":0,"d":[]}
|
| 660 |
-
_FAST_DT_CACHE={"t":0,"d":[]}
|
| 661 |
-
_FAST_VNEGO_CACHE={"t":0,"d":[]}
|
| 662 |
-
_FAST_HL_CACHE={"t":0,"d":[]}
|
| 663 |
-
|
| 664 |
-
def _rss_articles_fast(feed_url, group, source='vne', limit=6):
|
| 665 |
-
out=[]
|
| 666 |
-
try:
|
| 667 |
-
r=requests.get(feed_url,headers=UA,timeout=4);r.encoding='utf-8'
|
| 668 |
-
soup=BeautifulSoup(r.text,'xml')
|
| 669 |
-
for it in soup.find_all('item')[:limit*2]:
|
| 670 |
-
title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
|
| 671 |
-
link=clean(it.find('link').get_text(strip=True) if it.find('link') else '')
|
| 672 |
-
desc_raw=it.find('description').get_text(' ',strip=True) if it.find('description') else ''
|
| 673 |
-
ds=BeautifulSoup(desc_raw,'lxml')
|
| 674 |
-
im=ds.find('img'); img=im.get('src','') if im else ''
|
| 675 |
-
desc=clean(ds.get_text(' ',strip=True))[:160]
|
| 676 |
-
if title and link:
|
| 677 |
-
out.append({'title':title,'link':link,'img':img,'summary':desc,'source':source,'group':group})
|
| 678 |
-
if len(out)>=limit:break
|
| 679 |
-
except Exception:pass
|
| 680 |
-
return out
|
| 681 |
-
|
| 682 |
-
def _fast_homepage():
|
| 683 |
-
now=time.time()
|
| 684 |
-
if _FAST_HOME_CACHE['d'] and now-_FAST_HOME_CACHE['t']<600:return _FAST_HOME_CACHE['d']
|
| 685 |
-
feeds=[('Thời Sự','https://vnexpress.net/rss/thoi-su.rss'),('Thế Giới','https://vnexpress.net/rss/the-gioi.rss'),('Kinh Doanh','https://vnexpress.net/rss/kinh-doanh.rss'),('Công Nghệ','https://vnexpress.net/rss/so-hoa.rss'),('Thể Thao','https://vnexpress.net/rss/the-thao.rss'),('Giải Trí','https://vnexpress.net/rss/giai-tri.rss'),('Sức Khỏe','https://vnexpress.net/rss/suc-khoe.rss'),('Giáo Dục','https://vnexpress.net/rss/giao-duc.rss'),('Pháp Luật','https://vnexpress.net/rss/phap-luat.rss'),('Du Lịch','https://vnexpress.net/rss/du-lich.rss')]
|
| 686 |
-
arts=[]
|
| 687 |
-
try:
|
| 688 |
-
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 689 |
-
with ThreadPoolExecutor(max_workers=6) as ex:
|
| 690 |
-
futs=[ex.submit(_rss_articles_fast,u,g,'vne',6) for g,u in feeds]
|
| 691 |
-
for f in as_completed(futs,timeout=7):
|
| 692 |
-
try:arts.extend(f.result() or [])
|
| 693 |
-
except Exception:pass
|
| 694 |
-
except Exception:
|
| 695 |
-
for g,u in feeds[:5]:arts.extend(_rss_articles_fast(u,g,'vne',4))
|
| 696 |
-
if arts:_FAST_HOME_CACHE.update({'t':now,'d':arts})
|
| 697 |
-
return _FAST_HOME_CACHE['d'] or arts
|
| 698 |
-
|
| 699 |
-
def _fast_dantri_hot():
|
| 700 |
-
now=time.time()
|
| 701 |
-
if _FAST_DT_CACHE['d'] and now-_FAST_DT_CACHE['t']<900:return _FAST_DT_CACHE['d']
|
| 702 |
-
data=_rss_articles_fast('https://dantri.com.vn/rss/home.rss','Tin Nổi Bật','dantri',12)
|
| 703 |
-
if data:_FAST_DT_CACHE.update({'t':now,'d':data})
|
| 704 |
-
return data
|
| 705 |
-
|
| 706 |
-
def _fast_vnego():
|
| 707 |
-
now=time.time()
|
| 708 |
-
if _FAST_VNEGO_CACHE['d'] and now-_FAST_VNEGO_CACHE['t']<900:return _FAST_VNEGO_CACHE['d']
|
| 709 |
-
out=[]
|
| 710 |
-
try:
|
| 711 |
-
r=requests.get('https://vnexpress.net/vne-go',headers=UA,timeout=4);r.encoding='utf-8'
|
| 712 |
-
soup=BeautifulSoup(r.text,'lxml');seen=set()
|
| 713 |
-
for a in soup.find_all('a',href=True):
|
| 714 |
-
href=a.get('href','');title=clean(a.get('title','') or a.get_text(' ',strip=True))
|
| 715 |
-
if not title or len(title)<8 or not href.startswith('http') or href in seen:continue
|
| 716 |
-
if '/vne-go' not in href and '/video/' not in href:continue
|
| 717 |
-
seen.add(href);img='';im=a.find('img') or (a.parent.find('img') if a.parent else None)
|
| 718 |
-
if im:img=im.get('data-src') or im.get('src','')
|
| 719 |
-
out.append({'title':title,'link':href,'img':img,'source':'vne-video'})
|
| 720 |
-
if len(out)>=10:break
|
| 721 |
-
except Exception:pass
|
| 722 |
-
_FAST_VNEGO_CACHE.update({'t':now,'d':out})
|
| 723 |
-
return out
|
| 724 |
-
|
| 725 |
-
def _fast_highlights():
|
| 726 |
-
now=time.time()
|
| 727 |
-
if _FAST_HL_CACHE['d'] and now-_FAST_HL_CACHE['t']<900:return _FAST_HL_CACHE['d']
|
| 728 |
-
_FAST_HL_CACHE.update({'t':now,'d':[]})
|
| 729 |
-
return []
|
| 730 |
-
|
| 731 |
-
for _p in ['/api/homepage','/api/dantri_hot','/api/vne_video','/api/highlights']:
|
| 732 |
-
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)==_p and 'GET' in getattr(r,'methods',set()))]
|
| 733 |
-
@app.get('/api/homepage')
|
| 734 |
-
def api_homepage_fast():return JSONResponse(_fast_homepage())
|
| 735 |
-
@app.get('/api/dantri_hot')
|
| 736 |
-
def api_dantri_hot_fast():return JSONResponse(_fast_dantri_hot())
|
| 737 |
-
@app.get('/api/vne_video')
|
| 738 |
-
def api_vne_video_fast():return JSONResponse(_fast_vnego())
|
| 739 |
-
@app.get('/api/highlights')
|
| 740 |
-
def api_highlights_fast():return JSONResponse(_fast_highlights())
|
| 741 |
-
|
| 742 |
-
FINAL6_FAST_HOME_INJECT = """
|
| 743 |
<script>
|
| 744 |
(function(){
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
setTimeout(()=>{window.__allowShortRefresh=true;},7000);
|
| 749 |
})();
|
| 750 |
</script>
|
| 751 |
-
|
| 752 |
-
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
|
| 753 |
-
@app.get('/')
|
| 754 |
-
async def index_final6_fast_home():
|
| 755 |
-
html=f5.f4.f3.f2.f1._load_index_html()
|
| 756 |
-
body=getattr(rt.old,'PATCH_INJECT','')+f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT+FINAL6_INJECT+FINAL6_FAST_HOME_INJECT
|
| 757 |
-
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
# ===== FINAL6E: SHOW SOURCE CONTENTS IN TOPIC ARTICLE =====
|
| 761 |
-
def _extract_source_details_from_context(context, sources):
|
| 762 |
-
details=[]
|
| 763 |
-
# Map source urls by title for URL/via enrichment
|
| 764 |
-
src_by_title={clean(s.get('title','')):s for s in (sources or [])}
|
| 765 |
-
for block in (context or '').split('---'):
|
| 766 |
-
block=block.strip()
|
| 767 |
-
if not block:continue
|
| 768 |
-
via='';title='';content=''
|
| 769 |
-
m=re.search(r'NGUỒN:\s*(.*)',block)
|
| 770 |
-
if m:via=clean(m.group(1))
|
| 771 |
-
m=re.search(r'TIÊU ĐỀ:\s*(.*)',block)
|
| 772 |
-
if m:title=clean(m.group(1))
|
| 773 |
-
if 'NỘI DUNG BÀI VIẾT ĐÃ CRAWL:' in block:
|
| 774 |
-
content=block.split('NỘI DUNG BÀI VIẾT ĐÃ CRAWL:',1)[1]
|
| 775 |
-
elif 'TÓM TẮT RSS:' in block:
|
| 776 |
-
content=block.split('TÓM TẮT RSS:',1)[1]
|
| 777 |
-
elif 'ĐOẠN MÔ TẢ' in block:
|
| 778 |
-
content=re.split(r'ĐOẠN MÔ TẢ[^:]*:',block,1)[-1]
|
| 779 |
-
content=clean(content)
|
| 780 |
-
if not title and not content:continue
|
| 781 |
-
s=src_by_title.get(title,{})
|
| 782 |
-
details.append({'title':title or s.get('title','Nguồn tham khảo'),'url':s.get('url',''),'via':via or s.get('via',''),'content':content[:1800]})
|
| 783 |
-
if len(details)>=8:break
|
| 784 |
-
return details
|
| 785 |
-
|
| 786 |
-
# Remove prior topic endpoint and register one that stores source_details in post.
|
| 787 |
-
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/api/topic_post' and 'POST' in getattr(r,'methods',set()))]
|
| 788 |
-
|
| 789 |
-
@app.post('/api/topic_post')
|
| 790 |
-
async def topic_post_with_source_contents(request:Request):
|
| 791 |
-
body=await request.json();topic=clean(body.get('topic',''))
|
| 792 |
-
if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
|
| 793 |
-
img=_topic_image(topic)
|
| 794 |
-
research=_fast_context(topic) if '_fast_context' in globals() else _web_research_context(topic)
|
| 795 |
-
context=research.get('context','');sources=research.get('sources',[])
|
| 796 |
-
details=_extract_source_details_from_context(context,sources)
|
| 797 |
-
if not context or not details:
|
| 798 |
-
return JSONResponse({'error':'Không tìm/crawl được đủ nội dung về chủ đề này. Hãy thử chủ đề cụ thể hơn hoặc dùng hashtag gợi ý.'},status_code=422)
|
| 799 |
-
source_brief='\n\n'.join([f"[{i+1}] {d.get('title','')} ({d.get('via','')})\n{d.get('content','')[:1400]}" for i,d in enumerate(details)])
|
| 800 |
-
prompt=f"""Bạn là biên tập viên VNEWS. Hãy viết MỘT BÀI VIẾT HOÀN CHỈNH bằng tiếng Việt về chủ đề: {topic}
|
| 801 |
-
|
| 802 |
-
Dưới đây là nội dung từng nguồn đã thu thập. Hãy tổng hợp ý chính, không sao chép nguyên văn, không biến các tiêu đề thành danh sách.
|
| 803 |
-
|
| 804 |
-
NỘI DUNG NGUỒN:
|
| 805 |
-
{source_brief[:18000]}
|
| 806 |
-
|
| 807 |
-
Yêu cầu:
|
| 808 |
-
- Tiêu đề mới, rõ, hấp dẫn.
|
| 809 |
-
- Sapo 2-3 câu.
|
| 810 |
-
- 5-8 đoạn phân tích/bối cảnh/tác động/điểm cần lưu ý.
|
| 811 |
-
- Không dùng câu "Dưới đây là" hoặc "Tôi sẽ".
|
| 812 |
-
- Cuối bài có mục "Nguồn tham khảo" nêu tên nguồn.
|
| 813 |
-
"""
|
| 814 |
-
text=None
|
| 815 |
-
try:
|
| 816 |
-
import asyncio
|
| 817 |
-
text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1700),timeout=35)
|
| 818 |
-
except Exception:
|
| 819 |
-
text=None
|
| 820 |
-
if not text or len(text)<350:
|
| 821 |
-
bullets='\n'.join([f"• {d['title']}: {d.get('content','')[:320]}" for d in details[:6]])
|
| 822 |
-
vias=', '.join(sorted({d.get('via','') for d in details if d.get('via')}))
|
| 823 |
-
text=(f"{topic}: tổng hợp những điểm đáng chú ý\n\n"
|
| 824 |
-
f"{topic} đang được nhiều nguồn tin đề cập với các góc nhìn khác nhau. Dưới đây là phần tổng hợp nhanh từ những nội dung đã thu thập được.\n\n"
|
| 825 |
-
f"{bullets}\n\n"
|
| 826 |
-
f"Nhìn chung, chủ đề này cần được theo dõi thêm ở các khía cạnh: bối cảnh, tác động thực tế, phản ứng của các bên liên quan và các diễn biến mới trong thời gian tới.\n\n"
|
| 827 |
-
f"Nguồn tham khảo: {vias}")
|
| 828 |
-
post=f5.base.make_post(topic,text,img,'','topic_fast_rss_with_sources',sources=[s for s in sources if s.get('url')])
|
| 829 |
-
post['images']=[img]
|
| 830 |
-
post['source_details']=details
|
| 831 |
-
posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts)
|
| 832 |
-
return JSONResponse({'post':post,'mode':'fast_rss_with_source_details','sources_count':len(details)})
|
| 833 |
|
| 834 |
-
FINAL6E_INJECT =
|
| 835 |
<style>
|
| 836 |
-
|
|
|
|
| 837 |
</style>
|
| 838 |
<script>
|
| 839 |
(function(){
|
| 840 |
-
|
| 841 |
-
window.
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
window.
|
| 845 |
-
window.
|
| 846 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 847 |
})();
|
| 848 |
</script>
|
| 849 |
'''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Final6 runtime: fast homepage, fast shorts, Qwen topic with source details, hashtag sources, rewrite auto-title."""
|
| 2 |
+
import os, re, time, json, hashlib, asyncio, threading, requests
|
| 3 |
+
from urllib.parse import urlparse, quote
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
import ai_runtime_final5 as f5
|
| 5 |
+
from ai_runtime_final5 import app, base, rt, HTMLResponse, JSONResponse, Request, Query
|
| 6 |
+
import html as html_lib
|
| 7 |
|
| 8 |
+
SPACE_URL = "https://bep40-vnews.hf.space"
|
| 9 |
+
SHORT_CHANNELS = ["baodantri7941", "baosuckhoedoisongboyte"]
|
| 10 |
+
YOUTUBE_HANDLES = SHORT_CHANNELS
|
| 11 |
+
DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data"
|
| 12 |
+
os.makedirs(DATA_DIR, exist_ok=True)
|
| 13 |
+
SHORTS_CACHE = {"t": 0, "d": []}
|
| 14 |
+
AI_INTERACTIONS_FILE = os.path.join(DATA_DIR, "ai_interactions.json")
|
| 15 |
+
SHORT_COMMENTS_FILE = os.path.join(DATA_DIR, "short_comments.json")
|
| 16 |
|
| 17 |
+
def clean(s):
|
| 18 |
+
return re.sub(r"\s+", " ", html_lib.unescape(s or "")).strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
|
|
|
| 20 |
def _domain(u):
|
| 21 |
+
try: return urlparse(u or '').netloc.replace('www.', '')
|
| 22 |
+
except: return ''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
+
def _lj(p, d):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
try:
|
| 26 |
+
if os.path.exists(p): return json.load(open(p, 'r', encoding='utf-8'))
|
| 27 |
+
except: pass
|
| 28 |
+
return d
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
+
def _sj(p, d):
|
|
|
|
| 31 |
try:
|
| 32 |
+
os.makedirs(os.path.dirname(p), exist_ok=True)
|
| 33 |
+
open(p + '.tmp', 'w', encoding='utf-8').write(json.dumps(d, ensure_ascii=False))
|
| 34 |
+
os.replace(p + '.tmp', p)
|
| 35 |
+
except: pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
+
def _yt_ytdlp(handle, count=20):
|
| 38 |
try:
|
| 39 |
+
import yt_dlp
|
| 40 |
+
url = f"https://www.youtube.com/@{handle}/shorts"
|
| 41 |
+
opts = {'quiet': True, 'extract_flat': True, 'skip_download': True, 'playlist-end': count, 'ignoreerrors': True, 'no_warnings': True}
|
| 42 |
+
with yt_dlp.YoutubeDL(opts) as ydl:
|
| 43 |
+
info = ydl.extract_info(url, download=False)
|
| 44 |
+
out = []
|
| 45 |
+
for e in (info or {}).get('entries') or []:
|
| 46 |
+
vid = e.get('id') or ''
|
| 47 |
+
if not re.match(r'^[A-Za-z0-9_-]{11}$', vid): continue
|
| 48 |
+
title = e.get('title') or 'YouTube Short'
|
| 49 |
+
out.append({'title': title, 'link': f'https://www.youtube.com/watch?v={vid}', 'img': f'https://i.ytimg.com/vi/{vid}/hqdefault.jpg', 'source': 'yt', 'id': vid, 'channel': handle})
|
| 50 |
+
return out
|
| 51 |
+
except: return []
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
+
def _yt_html(handle, count=20):
|
| 54 |
try:
|
| 55 |
+
r = requests.get(f"https://www.youtube.com/@{handle}/shorts", headers=getattr(base, 'HEADERS', {}), timeout=15)
|
| 56 |
+
ids = []; out = []
|
| 57 |
+
for m in re.finditer(r'"videoId":"([A-Za-z0-9_-]{11})"', r.text):
|
| 58 |
+
vid = m.group(1)
|
| 59 |
+
if vid in ids: continue
|
| 60 |
+
ids.append(vid)
|
| 61 |
+
snip = r.text[max(0, m.start()-1000):m.start()+1800]
|
| 62 |
+
title = 'YouTube Short'
|
| 63 |
+
mt = re.search(r'"title":\{"runs":\[\{"text":"([^"]+)"', snip) or re.search(r'"accessibilityText":"([^"]+)"', snip)
|
| 64 |
+
if mt: title = clean(mt.group(1).replace('\\n', ' '))
|
| 65 |
+
out.append({'title': title, 'link': f'https://www.youtube.com/watch?v={vid}', 'img': f'https://i.ytimg.com/vi/{vid}/hqdefault.jpg', 'source': 'yt', 'id': vid, 'channel': handle})
|
| 66 |
+
if len(out) >= count: break
|
| 67 |
+
return out
|
| 68 |
+
except: return []
|
|
|
|
|
|
|
| 69 |
|
| 70 |
+
def _fallback_shorts():
|
| 71 |
+
out = []; seen = set()
|
| 72 |
+
hard = [
|
| 73 |
+
('Lu_iCQ5YwNM', 'Công an lập hồ sơ xử lý người phụ nữ chửi bới, tát tài xế ô tô | Dân trí', 'baodantri7941'),
|
| 74 |
+
('CwWvijF8BOA', 'Chú rể bật khóc nhận món quà bí mật người cha quá cố gửi 26 năm trước | Dân trí', 'baodantri7941'),
|
| 75 |
+
('7Pd6vZ2Lz1M', 'Hành động ấm lòng trong tìm kiếm học sinh tử vong ở sông Lô | SKĐS', 'baosuckhoedoisongboyte'),
|
| 76 |
+
('SlHLt_ZyPiE', 'Xử phạt người đàn ông xóa số điện thoại cứu hộ trên cao tốc Bắc - Nam | SKĐS', 'baosuckhoedoisongboyte'),
|
| 77 |
+
]
|
| 78 |
+
for vid, title, ch in hard:
|
| 79 |
+
if vid not in seen:
|
| 80 |
+
seen.add(vid)
|
| 81 |
+
out.append({'id': vid, 'title': title, 'channel': ch, 'link': f'https://www.youtube.com/watch?v={vid}', 'img': f'https://i.ytimg.com/vi/{vid}/hqdefault.jpg', 'source': 'yt'})
|
| 82 |
+
return out
|
| 83 |
|
| 84 |
+
def _fresh_shorts():
|
| 85 |
+
items = []; seen = set()
|
| 86 |
+
for ch in YOUTUBE_HANDLES:
|
| 87 |
+
got = _yt_ytdlp(ch, 24) or _yt_html(ch, 24)
|
| 88 |
+
for v in got:
|
| 89 |
+
if v['id'] not in seen:
|
| 90 |
+
seen.add(v['id']); items.append(v)
|
| 91 |
+
for v in _fallback_shorts():
|
| 92 |
+
if v['id'] not in seen:
|
| 93 |
+
seen.add(v['id']); items.append(v)
|
| 94 |
+
return items[:50]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
def _topic_image(topic):
|
| 97 |
+
try: return base.pollinations_image_url(topic)
|
| 98 |
+
except: return "https://image.pollinations.ai/prompt/" + quote("Vietnamese news editorial illustration " + topic) + "?width=1024&height=576&nologo=true"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
+
def _web_research_context(topic, limit=5):
|
| 101 |
+
try:
|
| 102 |
+
ctx, sources = base.web_context(topic, limit=limit)
|
| 103 |
+
return {"context": ctx or "", "sources": sources or []}
|
| 104 |
+
except:
|
| 105 |
+
return {"context": "", "sources": []}
|
| 106 |
+
|
| 107 |
+
def _fast_context(topic, limit=5):
|
| 108 |
+
return _web_research_context(topic, limit)
|
| 109 |
+
|
| 110 |
+
def _extract_source_details_from_context(ctx, sources):
|
| 111 |
+
"""Extract detailed source info from web context for source_details field."""
|
| 112 |
+
details = []
|
| 113 |
+
if not ctx or not sources:
|
| 114 |
+
return details
|
| 115 |
+
# Parse sources from context
|
| 116 |
+
for s in sources[:8]:
|
| 117 |
+
url = s.get('url', '')
|
| 118 |
+
if not url: continue
|
| 119 |
+
details.append({
|
| 120 |
+
'title': s.get('title', ''),
|
| 121 |
+
'url': url,
|
| 122 |
+
'via': s.get('via', _domain(url)),
|
| 123 |
+
'content': s.get('excerpt', s.get('description', ''))
|
| 124 |
+
})
|
| 125 |
+
return details
|
| 126 |
|
| 127 |
+
_bg_home = {"t": 0, "d": []}
|
| 128 |
+
_bg_shorts = {"t": 0, "d": []}
|
| 129 |
+
_bg_lock = False
|
| 130 |
|
| 131 |
+
def _bg():
|
| 132 |
+
global _bg_lock
|
| 133 |
+
if _bg_lock: return
|
| 134 |
+
_bg_lock = True
|
| 135 |
+
try:
|
| 136 |
+
# Fast homepage: just return empty, let frontend handle it
|
| 137 |
+
_bg_home.update({"t": time.time(), "d": []})
|
| 138 |
+
# Shorts
|
| 139 |
+
raw = []
|
| 140 |
+
for h in YOUTUBE_HANDLES:
|
| 141 |
+
raw.extend(_yt_ytdlp(h, 20) or _yt_html(h, 20))
|
| 142 |
+
raw.extend(_fallback_shorts())
|
| 143 |
+
seen = set()
|
| 144 |
+
out = [v for v in raw if v.get('id') and v['id'] not in seen and not seen.add(v['id'])]
|
| 145 |
+
if out: _bg_shorts.update({"t": time.time(), "d": out[:40]})
|
| 146 |
+
except: pass
|
| 147 |
+
finally: _bg_lock = False
|
| 148 |
+
|
| 149 |
+
@app.on_event("startup")
|
| 150 |
+
async def _s():
|
| 151 |
+
threading.Thread(target=_bg, daemon=True).start()
|
| 152 |
+
threading.Thread(target=lambda: [time.sleep(600) or _bg() for _ in iter(int, 1)], daemon=True).start()
|
| 153 |
+
|
| 154 |
+
# Remove endpoints to override
|
| 155 |
+
app.router.routes = [r for r in app.router.routes if not (
|
| 156 |
+
getattr(r, 'path', None) in ('/api/homepage', '/api/shorts', '/api/ai_wall', '/api/topic_post', '/api/article/ask', '/api/topic/rewrite', '/api/rewrite_share', '/api/url_wall', '/api/short/comments', '/api/short/comment', '/api/storage_status', '/') and
|
| 157 |
+
any(m in getattr(r, 'methods', set()) for m in ('GET', 'POST'))
|
| 158 |
+
)]
|
| 159 |
|
| 160 |
+
@app.get('/api/homepage')
|
| 161 |
+
def _h():
|
| 162 |
+
n = time.time()
|
| 163 |
+
if _bg_home['d']:
|
| 164 |
+
if n - _bg_home['t'] > 300: threading.Thread(target=_bg, daemon=True).start()
|
| 165 |
+
return JSONResponse(_bg_home['d'])
|
| 166 |
+
threading.Thread(target=_bg, daemon=True).start()
|
| 167 |
+
return JSONResponse([])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
+
@app.get('/api/shorts')
|
| 170 |
+
def _sh(refresh: int = Query(default=0)):
|
| 171 |
+
n = time.time()
|
| 172 |
+
if _bg_shorts['d'] and (not refresh or n - _bg_shorts['t'] < 120):
|
| 173 |
+
if n - _bg_shorts['t'] > 600: threading.Thread(target=_bg, daemon=True).start()
|
| 174 |
+
return JSONResponse(_bg_shorts['d'])
|
| 175 |
+
data = _fresh_shorts()
|
| 176 |
+
_bg_shorts.update({'t': n, 'd': data})
|
| 177 |
+
return JSONResponse(data)
|
| 178 |
+
|
| 179 |
+
@app.get('/api/ai_wall')
|
| 180 |
+
def _w():
|
| 181 |
+
n = int(time.time())
|
| 182 |
+
return JSONResponse({'posts': [p for p in f5.base._load_ai_wall() if n - int(p.get('ts') or 0) < 86400], 'persistent': os.path.isdir('/data')})
|
| 183 |
+
|
| 184 |
+
@app.get('/api/storage_status')
|
| 185 |
+
def _st():
|
| 186 |
+
return JSONResponse({'persistent': os.path.isdir('/data')})
|
| 187 |
+
|
| 188 |
+
@app.get('/api/short/comments')
|
| 189 |
+
def _gc(id: str = Query(...)):
|
| 190 |
+
return JSONResponse({'comments': _lj(SHORT_COMMENTS_FILE, {}).get(id, [])})
|
| 191 |
+
|
| 192 |
+
@app.post('/api/short/comment')
|
| 193 |
+
async def _pc(request: Request):
|
| 194 |
+
b = await request.json()
|
| 195 |
+
v = str(b.get('id', '')).strip()
|
| 196 |
+
t = clean(b.get('text', ''))
|
| 197 |
+
if not v or not t: return JSONResponse({'error': 'missing'}, status_code=400)
|
| 198 |
+
db = _lj(SHORT_COMMENTS_FILE, {})
|
| 199 |
+
c = db.get(v, [])
|
| 200 |
+
c.insert(0, {'text': t[:300], 'ts': int(time.time())})
|
| 201 |
+
db[v] = c[:100]
|
| 202 |
+
_sj(SHORT_COMMENTS_FILE, db)
|
| 203 |
+
return JSONResponse({'comments': db[v]})
|
| 204 |
+
|
| 205 |
+
@app.post('/api/article/ask')
|
| 206 |
+
async def _ask(request: Request):
|
| 207 |
+
b = await request.json()
|
| 208 |
+
q = clean(b.get('question', ''))
|
| 209 |
+
ctx = clean(b.get('context', ''))
|
| 210 |
+
url = clean(b.get('url', ''))
|
| 211 |
+
if not q: return JSONResponse({'error': 'missing question'}, status_code=400)
|
| 212 |
+
title = ''; raw = ''
|
| 213 |
+
if url:
|
| 214 |
+
try:
|
| 215 |
+
d = f5.base.scrape_any_url(url)
|
| 216 |
+
title = d.get('title', '')
|
| 217 |
+
raw = (d.get('summary', '') + '\n' + d.get('text', '')).strip()
|
| 218 |
+
except: pass
|
| 219 |
+
if not raw: raw = ctx[:12000]
|
| 220 |
+
ans = await f5.base.qwen_generate(
|
| 221 |
+
f'Bạn là VNEWS AI. Nội dung: "{title}"\n{raw[:9000]}\n\nHỏi: "{q}"\n\nTrả lời tự nhiên bằng tiếng Việt.',
|
| 222 |
+
max_tokens=1200)
|
| 223 |
+
return JSONResponse({'answer': ans or 'Chưa trả lời được.', 'title': title})
|
| 224 |
+
|
| 225 |
+
@app.post('/api/rewrite_share')
|
| 226 |
+
@app.post('/api/url_wall')
|
| 227 |
+
async def _rw(request: Request):
|
| 228 |
+
b = await request.json()
|
| 229 |
+
url = clean(b.get('url', ''))
|
| 230 |
+
ctx = clean(b.get('context', ''))
|
| 231 |
+
if not url.startswith('http'): return JSONResponse({'error': 'URL không hợp lệ'}, status_code=400)
|
| 232 |
try:
|
| 233 |
+
d = f5.base.scrape_any_url(url)
|
| 234 |
+
title = d.get('title', '')
|
| 235 |
+
raw = (d.get('summary', '') + '\n' + d.get('text', '')).strip()
|
| 236 |
+
img = d.get('image') or ''
|
| 237 |
+
except:
|
| 238 |
+
title = ''; raw = ctx[:14000]; img = ''
|
| 239 |
+
if len(raw) < 50: return JSONResponse({'error': 'Không đọc được bài'}, status_code=422)
|
| 240 |
+
text = None
|
| 241 |
+
try:
|
| 242 |
+
text = await asyncio.wait_for(f5.base.qwen_generate(
|
| 243 |
+
f'Tóm tắt đăng Tường AI:\nTiêu đề: {title}\n{raw[:14000]}\n\n4-6 ý chính. Cuối ghi nguồn.',
|
| 244 |
+
image_url=img or None, max_tokens=1000), timeout=30)
|
| 245 |
+
except: pass
|
| 246 |
+
if not text or len(text) < 80:
|
| 247 |
+
text = f"Tóm tắt: {title}\n\n{raw[:1200]}\n\nNguồn: {_domain(url)}"
|
| 248 |
+
post = f5.base.make_post(title or 'Bài viết', text, img, url, 'rewrite',
|
| 249 |
+
sources=[{'title': title, 'url': url, 'via': _domain(url)}])
|
| 250 |
+
ps = f5.base._load_ai_wall(); ps.insert(0, post); f5.base._save_ai_wall(ps)
|
| 251 |
+
return JSONResponse({'post': post})
|
| 252 |
+
|
| 253 |
+
@app.post('/api/topic/rewrite')
|
| 254 |
+
async def _tr(request: Request):
|
| 255 |
+
b = await request.json()
|
| 256 |
+
pid = str(b.get('post_id', '')).strip()
|
| 257 |
+
if not pid: return JSONResponse({'error': 'missing post_id'}, status_code=400)
|
| 258 |
+
ps = f5.base._load_ai_wall()
|
| 259 |
+
p = next((x for x in ps if str(x.get('id')) == pid), None)
|
| 260 |
+
if not p: return JSONResponse({'error': 'Bài không tồn tại'}, status_code=404)
|
| 261 |
+
urls = list(dict.fromkeys(
|
| 262 |
+
[s['url'] for s in (p.get('source_details') or []) if s.get('url')] +
|
| 263 |
+
[s['url'] for s in (p.get('sources') or []) if s.get('url')]
|
| 264 |
+
))[:5]
|
| 265 |
+
parts = []
|
| 266 |
+
for u in urls:
|
| 267 |
try:
|
| 268 |
+
d = f5.base.scrape_any_url(u)
|
| 269 |
+
t = d.get('title', '')
|
| 270 |
+
r = (d.get('summary', '') + '\n' + d.get('text', '')).strip()
|
| 271 |
+
if r and len(r) > 150:
|
| 272 |
+
parts.append(f"[{_domain(u)}] {t}\n{r}")
|
| 273 |
+
except: pass
|
| 274 |
+
ac = '\n---\n'.join(parts) if parts else (p.get('text') or '')
|
| 275 |
+
title = p.get('title', '')
|
| 276 |
+
text = None
|
| 277 |
+
try:
|
| 278 |
+
text = await asyncio.wait_for(f5.base.qwen_generate(
|
| 279 |
+
f'Viết lại:\nChủ đề: {title}\n{ac[:16000]}\n\nTiêu đề mới + 4-6 ý + nguồn.',
|
| 280 |
+
image_url=p.get('img'), max_tokens=1200), timeout=35)
|
| 281 |
+
except: pass
|
| 282 |
+
if not text or len(text) < 100:
|
| 283 |
+
text = f"Tóm tắt: {title}\n\n{ac[:1500]}\n\nNguồn: VNEWS AI"
|
| 284 |
+
np = f5.base.make_post('Rewrite: ' + title, text, p.get('img', ''), '', 'rewrite_topic', sources=p.get('sources', []))
|
| 285 |
+
np['images'] = p.get('images', [])
|
| 286 |
+
all_p = f5.base._load_ai_wall(); all_p.insert(0, np); f5.base._save_ai_wall(all_p)
|
| 287 |
+
return JSONResponse({'post': np})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
|
| 289 |
+
@app.post('/api/topic_post')
|
| 290 |
+
async def _tp(request: Request):
|
| 291 |
+
b = await request.json()
|
| 292 |
+
topic = clean(b.get('topic', ''))
|
| 293 |
+
if not topic: return JSONResponse({'error': 'missing topic'}, status_code=400)
|
| 294 |
+
img = _topic_image(topic)
|
| 295 |
+
research = _fast_context(topic)
|
| 296 |
+
ctx = research.get('context', '')
|
| 297 |
+
src = research.get('sources', [])
|
| 298 |
+
det = _extract_source_details_from_context(ctx, src)
|
| 299 |
+
if not ctx or not src:
|
| 300 |
+
return JSONResponse({'error': 'Không tìm được nội dung.'}, status_code=422)
|
| 301 |
+
sb = '\n\n'.join([f"[{i+1}] {d.get('title', '')} ({d.get('via', '')})\n{d.get('content', '')[:1400]}" for i, d in enumerate(det)]) if det else ctx[:18000]
|
| 302 |
+
text = None
|
| 303 |
+
try:
|
| 304 |
+
text = await asyncio.wait_for(f5.base.qwen_generate(
|
| 305 |
+
f'Viết bài tiếng Việt VỀ: "{topic}"\nNGUỒN:\n{sb[:18000]}\nCHỈ viết về "{topic}". 5-8 đoạn. Cuối có nguồn.',
|
| 306 |
+
image_url=img, max_tokens=1700), timeout=35)
|
| 307 |
+
except: pass
|
| 308 |
+
if not text or len(text) < 300:
|
| 309 |
+
text = f"{topic}: tổng hợp\n\n" + '\n'.join([f"• {d['title']}: {d.get('content', '')[:300]}" for d in (det or [])[:6]]) + "\n\nNguồn: " + ', '.join(sorted({d.get('via', '') for d in (det or []) if d.get('via')}))
|
| 310 |
+
post = f5.base.make_post(topic, text, img, '', 'topic_focused', sources=[s for s in src if s.get('url')])
|
| 311 |
+
post['images'] = [img]; post['source_details'] = det
|
| 312 |
+
ps = f5.base._load_ai_wall(); ps.insert(0, post); f5.base._save_ai_wall(ps)
|
| 313 |
+
return JSONResponse({'post': post})
|
| 314 |
+
|
| 315 |
+
FINAL6_INJECT = r'''
|
| 316 |
<style>
|
| 317 |
+
/* Livescore */
|
| 318 |
+
.ls-content{max-height:480px;overflow-y:auto;padding:0 6px 8px;font-size:12px;color:#ddd}
|
| 319 |
+
.ls-content ul{list-style:none;padding:0;margin:0}
|
| 320 |
+
.ls-content .title-content{display:flex;gap:6px;align-items:center;background:#222;border-radius:4px;margin:4px 0;padding:5px 8px}
|
| 321 |
+
.ls-content .title-content img{width:18px;height:18px}
|
| 322 |
+
.ls-content .title-content strong{font-size:11px;color:#ccc}
|
| 323 |
+
.ls-content .match-detail{padding:6px;border-bottom:1px solid #262626;cursor:pointer}
|
| 324 |
+
.ls-content .match-detail:hover{background:#1a2a1f}
|
| 325 |
+
.ls-content .match{display:flex;flex-wrap:wrap;align-items:center;gap:4px}
|
| 326 |
+
.ls-content .datetime{width:100%;font-size:9px;color:#888}
|
| 327 |
+
.ls-content .teams{display:flex;width:100%;align-items:center;gap:4px}
|
| 328 |
+
.ls-content .team{flex:1;display:flex;align-items:center;gap:4px;min-width:0}
|
| 329 |
+
.ls-content .team .name{font-size:11px;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
| 330 |
+
.ls-content .team .logo img{width:18px;height:18px}
|
| 331 |
+
.ls-content .home-team{justify-content:flex-end;text-align:right}
|
| 332 |
+
.ls-content .status{flex:0 0 54px;text-align:center}
|
| 333 |
+
.ls-content .status a{color:#fff;text-decoration:none;font-weight:800;font-size:12px}
|
| 334 |
+
.ls-content .status .label{font-size:8px;color:#888;display:block}
|
| 335 |
+
.ls-content .status .label.live{color:#e74c3c}
|
| 336 |
+
.ls-content .info,.ls-content .btns{display:none}
|
| 337 |
</style>
|
| 338 |
<script>
|
| 339 |
(function(){
|
| 340 |
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}
|
| 341 |
+
// Block slow YouTube refresh on first load
|
| 342 |
+
var _origFetch=window.fetch,_allowRefresh=false;
|
| 343 |
+
window.fetch=function(url,opts){try{if(String(url).indexOf('/api/shorts?refresh=1')>-1&&!_allowRefresh)url='/api/shorts';}catch(e){}return _origFetch.call(this,url,opts);};
|
| 344 |
+
setTimeout(function(){_allowRefresh=true;},8000);
|
|
|
|
|
|
|
|
|
|
| 345 |
})();
|
| 346 |
</script>
|
| 347 |
'''
|
| 348 |
|
| 349 |
+
FINAL6_FAST_HOME_INJECT = r'''
|
| 350 |
+
<style>
|
| 351 |
+
.storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 4px}
|
| 352 |
+
</style>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
<script>
|
| 354 |
(function(){
|
| 355 |
+
fetch('/api/storage_status').then(function(r){return r.json()}).then(function(j){
|
| 356 |
+
if(!j.persistent){var h=document.getElementById('view-home');if(h){var w=document.createElement('div');w.className='storage-warn';w.innerHTML='⚠️ <b>Persistent Storage chưa bật.</b> Bật: Space Settings → Persistent Storage → Small.';h.prepend(w);}}
|
| 357 |
+
});
|
|
|
|
| 358 |
})();
|
| 359 |
</script>
|
| 360 |
+
'''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
|
| 362 |
+
FINAL6E_INJECT = r'''
|
| 363 |
<style>
|
| 364 |
+
/* Kill ALL duplicate slides/walls from old layers */
|
| 365 |
+
#ai-short-home,.ai-short-home,.ai-short-card-final,[id*="ai-shorts-patched"]{display:none!important}
|
| 366 |
</style>
|
| 367 |
<script>
|
| 368 |
(function(){
|
| 369 |
+
// Kill old renderers
|
| 370 |
+
window.renderTopicWallE=function(){};
|
| 371 |
+
window.renderAIShortHome=function(){};
|
| 372 |
+
window.renderAIShorts7=function(){};
|
| 373 |
+
window.renderPatchedWall=function(){};
|
| 374 |
+
window.renderAiShorts=function(){};
|
| 375 |
+
window.renderWall=function(){};
|
| 376 |
+
window.renderAIShorts=function(){};
|
| 377 |
+
window.loadPatchedWall=function(){};
|
| 378 |
+
window.refreshFinalWall3=function(){};
|
| 379 |
+
// Remove duplicate slides
|
| 380 |
+
setInterval(function(){
|
| 381 |
+
document.querySelectorAll('#ai-short-home,.ai-short-home,[id*="ai-shorts-patched"]').forEach(function(el){el.remove()});
|
| 382 |
+
},2000);
|
| 383 |
})();
|
| 384 |
</script>
|
| 385 |
'''
|
| 386 |
+
|
| 387 |
+
@app.get('/')
|
| 388 |
+
async def _index():
|
| 389 |
+
html = f5.f4.f3.f2.f1._load_index_html()
|
| 390 |
+
body = ''
|
| 391 |
+
body += getattr(rt.old, 'PATCH_INJECT', '')
|
| 392 |
+
body += f5.f4.f3.f2.f1.FINAL_INJECT + f5.f4.f3.FINAL3_INJECT + f5.f4.FINAL4_INJECT + f5.FINAL5_INJECT
|
| 393 |
+
body += FINAL6_INJECT + FINAL6_FAST_HOME_INJECT + FINAL6E_INJECT
|
| 394 |
+
return HTMLResponse(html.replace('</body>', body + '\n</body>') if '</body>' in html else html + body)
|
app_v2_entry.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""VNEWS v2 Entry Point - with fast bongda proxy
|
| 2 |
import sys, os
|
| 3 |
from main import app, HEADERS, BONGDA_HEADERS, fetch_bongda_api, HL_LEAGUES
|
| 4 |
|
|
@@ -7,11 +7,6 @@ try:
|
|
| 7 |
except Exception as e:
|
| 8 |
print(f"[WARN] ai_ext import failed: {e}")
|
| 9 |
|
| 10 |
-
try:
|
| 11 |
-
import ai_patch
|
| 12 |
-
except Exception as e:
|
| 13 |
-
print(f"[WARN] ai_patch import failed: {e}")
|
| 14 |
-
|
| 15 |
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response
|
| 16 |
from fastapi.staticfiles import StaticFiles
|
| 17 |
from starlette.routing import Mount
|
|
@@ -327,12 +322,15 @@ def _search_all(topic,limit=36):
|
|
| 327 |
if i<len(s) and s[i].get('url') and s[i]['url'] not in seen:seen.add(s[i]['url']);out.append(s[i])
|
| 328 |
return out[:limit]
|
| 329 |
|
|
|
|
| 330 |
for _path in ['/api/article', '/api/hot_topics', '/api/categories', '/api/storage_status', '/s']:
|
| 331 |
app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)==_path and 'GET' in getattr(r,'methods',set()))]
|
| 332 |
|
|
|
|
| 333 |
_article_cache = {}
|
| 334 |
_article_cache_ttl = 1800
|
| 335 |
|
|
|
|
| 336 |
_art_session = None
|
| 337 |
_art_lock = threading.Lock()
|
| 338 |
def _get_art_session():
|
|
@@ -349,13 +347,17 @@ def _get_art_session():
|
|
| 349 |
return _art_session
|
| 350 |
|
| 351 |
def _scrape_article_fast(url):
|
|
|
|
| 352 |
from urllib.parse import urlparse
|
| 353 |
domain = urlparse(url).netloc
|
| 354 |
sess = _get_art_session()
|
|
|
|
|
|
|
| 355 |
uas = [
|
| 356 |
{"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"},
|
| 357 |
{"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"},
|
| 358 |
]
|
|
|
|
| 359 |
for ua in uas:
|
| 360 |
try:
|
| 361 |
r = sess.get(url, headers=ua, timeout=6, allow_redirects=True)
|
|
@@ -363,8 +365,12 @@ def _scrape_article_fast(url):
|
|
| 363 |
continue
|
| 364 |
r.encoding = 'utf-8'
|
| 365 |
soup = BeautifulSoup(r.text, 'lxml')
|
|
|
|
|
|
|
| 366 |
for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe','.ads','.ad','.banner-ads','.fb-comments','.fb-root','.social-share','.related-news','.tag','.breadcrumb']):
|
| 367 |
tag.decompose()
|
|
|
|
|
|
|
| 368 |
title = summary = og_img = ""
|
| 369 |
ogt = soup.find('meta', property='og:title')
|
| 370 |
if ogt: title = ogt.get('content', '')
|
|
@@ -376,13 +382,15 @@ def _scrape_article_fast(url):
|
|
| 376 |
if og_img.startswith('//'): og_img = 'https:' + og_img
|
| 377 |
h1 = soup.find('h1')
|
| 378 |
if not title and h1: title = h1.get_text(strip=True)[:200]
|
|
|
|
|
|
|
| 379 |
body = []
|
| 380 |
selectors = [
|
| 381 |
-
'.fck_detail', '.sidebar-1',
|
| 382 |
-
'.singular-content', '.dt__content', '.article-content', '.content-detail', '#divNewsContent',
|
| 383 |
-
'.content-detail', '.main-content-detail', '.box-content',
|
| 384 |
-
'.knc-content', '.article-body', '.detail-body',
|
| 385 |
-
'.article-detail', '.detail-content',
|
| 386 |
'article', 'main', '.cms-body', '.article__body', '.post-content',
|
| 387 |
'.entry-content', '#content', '.article-text', '.story-body',
|
| 388 |
]
|
|
@@ -415,6 +423,8 @@ def _scrape_article_fast(url):
|
|
| 415 |
if len(body) >= 2:
|
| 416 |
return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img,
|
| 417 |
'body': body[:50], 'source': domain, 'url': url}
|
|
|
|
|
|
|
| 418 |
if title and (summary or og_img):
|
| 419 |
fallback = []
|
| 420 |
if og_img: fallback.append({'type': 'img', 'src': og_img})
|
|
@@ -422,38 +432,78 @@ def _scrape_article_fast(url):
|
|
| 422 |
if fallback:
|
| 423 |
return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img,
|
| 424 |
'body': fallback, 'source': domain, 'url': url, 'fallback': True}
|
|
|
|
|
|
|
| 425 |
if title:
|
| 426 |
return {'title': _clean(title), 'summary': '', 'og_image': '',
|
| 427 |
'body': [{'type': 'p', 'text': 'Nội dung đang được tải...'}],
|
| 428 |
'source': domain, 'url': url, 'fallback': True}
|
| 429 |
-
|
|
|
|
| 430 |
except Exception:
|
| 431 |
continue
|
|
|
|
| 432 |
return None
|
| 433 |
|
| 434 |
@app.get('/api/article')
|
| 435 |
def api_article_v2(url: str = Query(...)):
|
|
|
|
| 436 |
from urllib.parse import unquote
|
| 437 |
safe_url = unquote(url)
|
|
|
|
| 438 |
try:
|
|
|
|
| 439 |
now = time.time()
|
| 440 |
cached = _article_cache.get(safe_url)
|
| 441 |
if cached and now - cached['t'] < _article_cache_ttl:
|
| 442 |
resp = JSONResponse(cached['d'])
|
| 443 |
resp.headers["Cache-Control"] = "public, max-age=1800"
|
| 444 |
return resp
|
|
|
|
|
|
|
| 445 |
data = _scrape_article_fast(safe_url)
|
|
|
|
| 446 |
if data and data.get('body'):
|
| 447 |
_article_cache[safe_url] = {'d': data, 't': now}
|
| 448 |
resp = JSONResponse(data)
|
| 449 |
resp.headers["Cache-Control"] = "public, max-age=1800"
|
| 450 |
return resp
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
result = {'error': 'Không đọc được', 'url': safe_url}
|
| 452 |
resp = JSONResponse(result)
|
| 453 |
resp.headers["Cache-Control"] = "public, max-age=60"
|
| 454 |
return resp
|
| 455 |
except Exception as e:
|
| 456 |
-
|
|
|
|
|
|
|
| 457 |
|
| 458 |
_hot_cache={'t':0,'d':[]}
|
| 459 |
def _get_hot_topics():
|
|
@@ -506,8 +556,9 @@ def _st():return JSONResponse({'persistent':os.path.isdir('/data') and os.access
|
|
| 506 |
@app.get('/s')
|
| 507 |
async def _sh(url:str='',title:str='',img:str=''):return HTMLResponse(f'<!DOCTYPE html><html><head><meta property="og:title" content="{_clean(title)}"><meta property="og:image" content="{_clean(img)}"><meta http-equiv="refresh" content="0;url={_clean(url) or "/"}"></head><body></body></html>')
|
| 508 |
|
| 509 |
-
from wc2026_scraper import
|
| 510 |
|
|
|
|
| 511 |
_xlb_cache = {}
|
| 512 |
_xlb_lock = threading.Lock()
|
| 513 |
|
|
@@ -645,36 +696,54 @@ async def _pc(request:Request):
|
|
| 645 |
with _il:idb=_lj(IF);idb.setdefault(v,{'views':0,'likes':0,'comments':0});idb[v]['comments']=len(cms);_sj(IF,idb)
|
| 646 |
return JSONResponse({'comments':cms})
|
| 647 |
|
|
|
|
|
|
|
| 648 |
def _load_wall_posts():
|
|
|
|
| 649 |
with _wl_lock:
|
| 650 |
return _lj(WALL_FILE)
|
| 651 |
|
| 652 |
def _save_wall_posts(posts):
|
|
|
|
| 653 |
with _wl_lock:
|
| 654 |
_sj(WALL_FILE, posts)
|
| 655 |
|
| 656 |
@app.get('/api/wall')
|
| 657 |
def api_wall():
|
|
|
|
| 658 |
posts = _load_wall_posts()
|
| 659 |
if not posts:
|
|
|
|
| 660 |
return JSONResponse({"posts": []})
|
| 661 |
return JSONResponse({"posts": posts})
|
| 662 |
|
| 663 |
@app.post('/api/wall')
|
| 664 |
async def api_wall_post(request: Request):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 665 |
content_type = request.headers.get('content-type', '')
|
|
|
|
|
|
|
| 666 |
if 'multipart/form-data' in content_type:
|
| 667 |
try:
|
| 668 |
form = await request.form()
|
| 669 |
except Exception as e:
|
| 670 |
return JSONResponse({"error": f"Form parse error: {str(e)}"}, status_code=400)
|
|
|
|
| 671 |
title = form.get('title', 'Video mới') or 'Video mới'
|
| 672 |
text = form.get('text', '') or ''
|
| 673 |
source = form.get('source', 'vtv_recorder') or 'vtv_recorder'
|
| 674 |
video_file = form.get('video')
|
|
|
|
| 675 |
post_id = str(uuid.uuid4())[:12]
|
| 676 |
video_url = None
|
|
|
|
|
|
|
| 677 |
if video_file and hasattr(video_file, 'filename') and video_file.filename:
|
|
|
|
| 678 |
fname = video_file.filename.lower()
|
| 679 |
if fname.endswith('.mp4'):
|
| 680 |
ext = '.mp4'
|
|
@@ -682,21 +751,31 @@ async def api_wall_post(request: Request):
|
|
| 682 |
ext = '.webm'
|
| 683 |
else:
|
| 684 |
ext = '.webm'
|
|
|
|
| 685 |
video_filename = f"wall_{post_id}{ext}"
|
| 686 |
video_path = os.path.join(WALL_VIDEO_DIR, video_filename)
|
|
|
|
| 687 |
try:
|
|
|
|
| 688 |
content = await video_file.read()
|
| 689 |
if not content:
|
| 690 |
return JSONResponse({"error": "Empty video file"}, status_code=400)
|
|
|
|
|
|
|
| 691 |
with open(video_path, 'wb') as f:
|
| 692 |
f.write(content)
|
|
|
|
| 693 |
file_size_mb = len(content) / 1024 / 1024
|
| 694 |
if file_size_mb > 50:
|
| 695 |
os.remove(video_path)
|
| 696 |
return JSONResponse({"error": f"Video quá lớn ({file_size_mb:.1f}MB). Tối đa 50MB."}, status_code=400)
|
|
|
|
|
|
|
| 697 |
video_url = f"/api/wall/video/{video_filename}"
|
| 698 |
except Exception as e:
|
| 699 |
return JSONResponse({"error": f"Lỗi lưu video: {str(e)}"}, status_code=500)
|
|
|
|
|
|
|
| 700 |
post = {
|
| 701 |
"id": post_id,
|
| 702 |
"title": title[:200],
|
|
@@ -708,21 +787,29 @@ async def api_wall_post(request: Request):
|
|
| 708 |
"created": int(time.time()),
|
| 709 |
"created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
|
| 710 |
}
|
|
|
|
|
|
|
| 711 |
posts = _load_wall_posts()
|
| 712 |
if not isinstance(posts, list):
|
| 713 |
posts = []
|
| 714 |
posts.insert(0, post)
|
|
|
|
| 715 |
posts = posts[:200]
|
| 716 |
_save_wall_posts(posts)
|
|
|
|
| 717 |
return JSONResponse({"post": post, "ok": True})
|
|
|
|
|
|
|
| 718 |
try:
|
| 719 |
body = await request.json()
|
| 720 |
except:
|
| 721 |
body = {}
|
|
|
|
| 722 |
title = body.get('title', 'Bài mới') or 'Bài mới'
|
| 723 |
text = body.get('text', '') or ''
|
| 724 |
img = body.get('img', None)
|
| 725 |
source = body.get('source', 'user') or 'user'
|
|
|
|
| 726 |
post_id = str(uuid.uuid4())[:12]
|
| 727 |
post = {
|
| 728 |
"id": post_id,
|
|
@@ -735,16 +822,20 @@ async def api_wall_post(request: Request):
|
|
| 735 |
"created": int(time.time()),
|
| 736 |
"created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
|
| 737 |
}
|
|
|
|
| 738 |
posts = _load_wall_posts()
|
| 739 |
if not isinstance(posts, list):
|
| 740 |
posts = []
|
| 741 |
posts.insert(0, post)
|
| 742 |
posts = posts[:200]
|
| 743 |
_save_wall_posts(posts)
|
|
|
|
| 744 |
return JSONResponse({"post": post, "ok": True})
|
| 745 |
|
| 746 |
@app.get('/api/wall/video/{filename}')
|
| 747 |
def api_wall_video(filename: str):
|
|
|
|
|
|
|
| 748 |
if '..' in filename or '/' in filename:
|
| 749 |
return Response(status_code=403)
|
| 750 |
video_path = os.path.join(WALL_VIDEO_DIR, filename)
|
|
@@ -756,11 +847,14 @@ def api_wall_video(filename: str):
|
|
| 756 |
|
| 757 |
@app.delete('/api/wall/{post_id}')
|
| 758 |
def api_wall_delete(post_id: str):
|
|
|
|
| 759 |
posts = _load_wall_posts()
|
| 760 |
if not isinstance(posts, list):
|
| 761 |
return JSONResponse({"error": "No posts"}, status_code=404)
|
|
|
|
| 762 |
for i, p in enumerate(posts):
|
| 763 |
if p.get('id') == post_id:
|
|
|
|
| 764 |
if p.get('video'):
|
| 765 |
video_name = p['video'].split('/')[-1]
|
| 766 |
video_path = os.path.join(WALL_VIDEO_DIR, video_name)
|
|
@@ -769,510 +863,8 @@ def api_wall_delete(post_id: str):
|
|
| 769 |
posts.pop(i)
|
| 770 |
_save_wall_posts(posts)
|
| 771 |
return JSONResponse({"ok": True})
|
| 772 |
-
return JSONResponse({"error": "Post not found"}, status_code=404)
|
| 773 |
-
|
| 774 |
-
# ===== LANGUAGE & EMOTION DETECTION =====
|
| 775 |
-
import random as _random2
|
| 776 |
-
from urllib.parse import quote as _quote2
|
| 777 |
-
|
| 778 |
-
_UA_RW = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept-Language': 'vi-VN,vi;q=0.9'}
|
| 779 |
-
|
| 780 |
-
# Unique character markers for language detection
|
| 781 |
-
_UNIQUE_CHARS = {
|
| 782 |
-
'vietnamese': set('đăâêôơưàảãạáằẳẵặắầẩẫậấèẻẽẹéềễểệếìỉĩịíòỏõọóồổỗộốờởỡ���ớùủũụúừửữựứỳỷỹỵý'),
|
| 783 |
-
'spanish': set('ñáéíóúü¿¡'),
|
| 784 |
-
'portuguese': set('ãõçáéíóúâêôà'),
|
| 785 |
-
}
|
| 786 |
-
|
| 787 |
-
_STOPWORDS = {
|
| 788 |
-
'english': {'the', 'is', 'at', 'which', 'on', 'a', 'an', 'and', 'or', 'but', 'in', 'with', 'to', 'for', 'of', 'not', 'no', 'can', 'had', 'have', 'has', 'was', 'were', 'are', 'be', 'been', 'this', 'that', 'it', 'he', 'she', 'they', 'his', 'her', 'my', 'your', 'our', 'we', 'you', 'i'},
|
| 789 |
-
'vietnamese': {'là', 'của', 'và', 'có', 'được', 'cho', 'không', 'với', 'này', 'đó', 'từ', 'trong', 'đã', 'sẽ', 'một', 'các', 'những', 'về', 'tại', 'người', 'năm', 'đến', 'ra', 'lại', 'như', 'khi', 'để', 'rất', 'cũng', 'mà', 'nếu', 'sau', 'trên', 'theo', 'vì', 'do', 'nên', 'thì', 'mình', 'tôi', 'bạn', 'anh', 'chị', 'em'},
|
| 790 |
-
'portuguese': {'de', 'um', 'que', 'e', 'do', 'da', 'em', 'para', 'com', 'não', 'uma', 'os', 'no', 'se', 'na', 'por', 'mais', 'as', 'dos', 'como', 'mas', 'ao', 'ele', 'das', 'tem', 'seu', 'sua', 'ou', 'quando', 'muito', 'nos', 'já', 'eu', 'também', 'só', 'pelo', 'pela', 'até', 'isso', 'ela', 'entre', 'depois', 'sem', 'mesmo', 'aos', 'são', 'está', 'ter', 'ser', 'foi', 'era', 'há', 'estão', 'você', 'nós', 'eles', 'elas'},
|
| 791 |
-
'spanish': {'de', 'que', 'el', 'en', 'y', 'a', 'los', 'del', 'se', 'las', 'por', 'un', 'para', 'con', 'no', 'una', 'su', 'al', 'es', 'lo', 'como', 'más', 'pero', 'sus', 'le', 'ya', 'o', 'fue', 'este', 'ha', 'si', 'porque', 'esta', 'son', 'entre', 'está', 'cuando', 'muy', 'sin', 'sobre', 'ser', 'también', 'me', 'hasta', 'hay', 'donde', 'han', 'quien', 'están', 'desde', 'todo', 'nos', 'durante', 'todos', 'uno', 'les', 'ni', 'contra', 'otros', 'fueron', 'ese', 'eso', 'ante', 'ellos', 'yo', 'tú', 'él', 'ella', 'nosotros', 'usted', 'ustedes'},
|
| 792 |
-
}
|
| 793 |
-
|
| 794 |
-
def detect_language(text):
|
| 795 |
-
"""Detect language from text content using stopword + character analysis."""
|
| 796 |
-
if not text:
|
| 797 |
-
return 'vietnamese'
|
| 798 |
-
text_lower = text.lower()
|
| 799 |
-
text_chars = set(text_lower)
|
| 800 |
-
|
| 801 |
-
# Strong signal: Vietnamese unique characters
|
| 802 |
-
vn_chars = len(text_chars & _UNIQUE_CHARS['vietnamese'])
|
| 803 |
-
if vn_chars >= 2:
|
| 804 |
-
return 'vietnamese'
|
| 805 |
-
|
| 806 |
-
# Spanish unique chars (ñ, ¿, ¡)
|
| 807 |
-
es_chars = len(text_chars & _UNIQUE_CHARS['spanish'])
|
| 808 |
-
pt_chars = len(text_chars & _UNIQUE_CHARS['portuguese'])
|
| 809 |
-
|
| 810 |
-
# Stopword scoring
|
| 811 |
-
words = set(re.findall(r'\b\w+\b', text_lower))
|
| 812 |
-
scores = {}
|
| 813 |
-
for lang, stops in _STOPWORDS.items():
|
| 814 |
-
scores[lang] = len(words & stops) / max(len(stops), 1)
|
| 815 |
-
|
| 816 |
-
# Disambiguate Portuguese vs Spanish
|
| 817 |
-
pt_markers = {'não', 'pelo', 'pela', 'isso', 'há', 'estão', 'num', 'numa', 'tenho', 'posso', 'você', 'nós', 'eles', 'elas', 'também', 'muito', 'já', 'só', 'até', 'entre', 'depois', 'sem', 'mesmo', 'aos', 'serão'}
|
| 818 |
-
es_markers = {'pero', 'está', 'están', 'porque', 'también', 'hasta', 'donde', 'quien', 'fue', 'son', 'fueron', 'ese', 'eso', 'ante', 'ellos', 'ella', 'nosotros', 'usted', 'ustedes', 'tú', 'él', 'desde', 'todo', 'durante', 'todos', 'uno', 'les', 'ni', 'contra', 'otros', 'fueron'}
|
| 819 |
-
|
| 820 |
-
pt_overlap = len(words & pt_markers)
|
| 821 |
-
es_overlap = len(words & es_markers)
|
| 822 |
-
|
| 823 |
-
if scores.get('portuguese', 0) > 0 and pt_overlap > es_overlap:
|
| 824 |
-
return 'portuguese'
|
| 825 |
-
if scores.get('spanish', 0) > 0 and es_overlap > pt_overlap:
|
| 826 |
-
return 'spanish'
|
| 827 |
-
if scores.get('english', 0) > 0.15:
|
| 828 |
-
return 'english'
|
| 829 |
-
|
| 830 |
-
best = max(scores, key=scores.get)
|
| 831 |
-
return best if scores[best] > 0.05 else 'vietnamese'
|
| 832 |
-
|
| 833 |
-
# Emotion keyword-based detection
|
| 834 |
-
_EMOTION_KEYWORDS = {
|
| 835 |
-
'happy': {
|
| 836 |
-
'en': ['happy', 'joy', 'wonderful', 'great', 'amazing', 'fantastic', 'love', 'excellent', 'beautiful', 'glad', 'delighted', 'pleased', 'cheerful', 'celebrate', 'victory', 'win', 'success'],
|
| 837 |
-
'pt': ['feliz', 'alegria', 'maravilhoso', 'ótimo', 'incrível', 'fantástico', 'amor', 'excelente', 'lindo', 'contente', 'encantado', 'vitória', 'sucesso'],
|
| 838 |
-
'es': ['feliz', 'alegria', 'maravilloso', 'genial', 'increíble', 'fantástico', 'amor', 'excelente', 'hermoso', 'contento', 'encantado', 'victoria', 'éxito'],
|
| 839 |
-
'vi': ['vui', 'hạnh phúc', 'tuyệt vời', 'tuyệt', 'ý nghĩa', 'đẹp', 'thích', 'yêu', 'vui vẻ', 'hân hoan', 'phấn khích', 'chiến thắng', 'thành công'],
|
| 840 |
-
},
|
| 841 |
-
'sad': {
|
| 842 |
-
'en': ['sad', 'unhappy', 'terrible', 'awful', 'horrible', 'miserable', 'depressed', 'grief', 'sorrow', 'tragic', 'unfortunate', 'painful', 'death', 'die', 'kill'],
|
| 843 |
-
'pt': ['triste', 'infeliz', 'terrível', 'horrível', 'miserável', 'deprimido', 'dor', 'trágico', 'infelizmente', 'penoso', 'morte', 'morrer'],
|
| 844 |
-
'es': ['triste', 'infeliz', 'terrible', 'horrible', 'miserable', 'deprimido', 'dolor', 'trágico', 'desafortunado', 'penoso', 'muerte', 'morir'],
|
| 845 |
-
'vi': ['buồn', 'không vui', 'tồi tệ', 'kinh khủng', 'đau khổ', 'đau buồn', 'bi thương', 'khốn nạn', 'đau đớn', 'thảm họa', 'chết', 'mất'],
|
| 846 |
-
},
|
| 847 |
-
'excited': {
|
| 848 |
-
'en': ['excited', 'thrilling', 'amazing', 'wow', 'incredible', 'unbelievable', 'awesome', 'exhilarating', 'electrifying', 'breathtaking', 'breakthrough', 'record'],
|
| 849 |
-
'pt': ['animado', 'emocionante', 'incrível', 'impressionante', 'sensacional', 'eletrizante', 'empolgante', 'recorde'],
|
| 850 |
-
'es': ['emocionante', 'increíble', 'impresionante', 'sensacional', 'electrizante', 'emocionado', 'entusiasmado', 'récord'],
|
| 851 |
-
'vi': ['hào hứng', 'phấn khích', 'thú vị', 'tuyệt cú mèo', 'đỉnh cao', 'ngoạn mục', 'sục sôi', 'kỷ lục', 'đột phá'],
|
| 852 |
-
},
|
| 853 |
-
'humorous': {
|
| 854 |
-
'en': ['funny', 'hilarious', 'joke', 'laugh', 'comedy', 'humor', 'amusing', 'witty', 'sarcastic', 'ironic', 'ridiculous', 'absurd', 'lol', 'haha'],
|
| 855 |
-
'pt': ['engraçado', 'hilário', 'piada', 'rir', 'comédia', 'humor', 'divertido', 'irônico', 'ridículo', 'absurdo', 'kkk'],
|
| 856 |
-
'es': ['gracioso', 'hilarante', 'broma', 'risa', 'comedia', 'humor', 'divertido', 'irónico', 'ridículo', 'absurdo', 'jaja'],
|
| 857 |
-
'vi': ['hài hước', 'buồn cười', 'đùa', 'cười', 'hài', 'vui nhộn', 'hóm hỉnh', 'mỉa mai', 'lố bịch', 'vô lý', 'haha'],
|
| 858 |
-
},
|
| 859 |
-
'serious': {
|
| 860 |
-
'en': ['serious', 'critical', 'important', 'urgent', 'severe', 'grave', 'significant', 'crucial', 'vital', 'essential', 'alarming', 'concerning', 'crisis', 'war', 'conflict'],
|
| 861 |
-
'pt': ['sério', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'essencial', 'preocupante', 'crise', 'guerra', 'conflito'],
|
| 862 |
-
'es': ['serio', 'crítico', 'importante', 'urgente', 'grave', 'significativo', 'crucial', 'vital', 'esencial', 'preocupante', 'crisis', 'guerra', 'conflicto'],
|
| 863 |
-
'vi': ['nghiêm trọng', 'quan trọng', 'khẩn cấp', 'nghiêm túc', 'đáng kể', 'thiết yếu', 'cần thiết', 'báo động', 'lo ngại', 'khủng hoảng', 'chiến tranh', 'xung đột'],
|
| 864 |
-
},
|
| 865 |
-
}
|
| 866 |
-
|
| 867 |
-
def detect_emotion(text, language='vietnamese'):
|
| 868 |
-
"""Detect emotion from text using keyword matching."""
|
| 869 |
-
if not text:
|
| 870 |
-
return 'neutral'
|
| 871 |
-
text_lower = text.lower()
|
| 872 |
-
|
| 873 |
-
scores = {}
|
| 874 |
-
for emotion, lang_keywords in _EMOTION_KEYWORDS.items():
|
| 875 |
-
keywords = lang_keywords.get(language, lang_keywords.get('en', []))
|
| 876 |
-
score = sum(1 for kw in keywords if kw in text_lower)
|
| 877 |
-
scores[emotion] = score
|
| 878 |
-
|
| 879 |
-
if max(scores.values()) == 0:
|
| 880 |
-
return 'neutral'
|
| 881 |
-
|
| 882 |
-
return max(scores, key=scores.get)
|
| 883 |
-
|
| 884 |
-
def detect_language_and_emotion(title, text):
|
| 885 |
-
"""Detect both language and emotion from article content."""
|
| 886 |
-
combined = f"{title} {text}"
|
| 887 |
-
lang = detect_language(combined)
|
| 888 |
-
emotion = detect_emotion(combined, lang)
|
| 889 |
-
return lang, emotion
|
| 890 |
-
|
| 891 |
-
# Voice selection based on language and emotion (using MultilingualNeural voices)
|
| 892 |
-
VOICE_BY_LANG_EMOTION = {
|
| 893 |
-
'vietnamese': {
|
| 894 |
-
'happy': ('vi-VN-HoaiMyNeural', 'vui'),
|
| 895 |
-
'sad': ('vi-VN-NamMinhNeural', 'buồn'),
|
| 896 |
-
'excited': ('vi-VN-HoaiMyNeural', 'hào hứng'),
|
| 897 |
-
'humorous': ('vi-VN-HoaiMyNeural', 'vui'),
|
| 898 |
-
'serious': ('vi-VN-NamMinhNeural', 'nghiêm túc'),
|
| 899 |
-
'neutral': ('vi-VN-HoaiMyNeural', 'trung_tinh'),
|
| 900 |
-
},
|
| 901 |
-
'portuguese': {
|
| 902 |
-
'happy': ('pt-BR-ThalitaMultilingualNeural', 'feliz'),
|
| 903 |
-
'sad': ('pt-BR-ThalitaMultilingualNeural', 'triste'),
|
| 904 |
-
'excited': ('pt-BR-ThalitaMultilingualNeural', 'animado'),
|
| 905 |
-
'humorous': ('pt-BR-ThalitaMultilingualNeural', 'engraçado'),
|
| 906 |
-
'serious': ('pt-BR-ThalitaMultilingualNeural', 'sério'),
|
| 907 |
-
'neutral': ('pt-BR-ThalitaMultilingualNeural', 'neutro'),
|
| 908 |
-
},
|
| 909 |
-
'english': {
|
| 910 |
-
'happy': ('en-US-AndrewMultilingualNeural', 'happy'),
|
| 911 |
-
'sad': ('en-AU-WilliamMultilingualNeural', 'sad'),
|
| 912 |
-
'excited': ('en-US-AndrewMultilingualNeural', 'excited'),
|
| 913 |
-
'humorous': ('en-US-AndrewMultilingualNeural', 'funny'),
|
| 914 |
-
'serious': ('en-AU-WilliamMultilingualNeural', 'serious'),
|
| 915 |
-
'neutral': ('en-US-AndrewMultilingualNeural', 'neutral'),
|
| 916 |
-
},
|
| 917 |
-
'french': {
|
| 918 |
-
'happy': ('fr-FR-VivienneMultilingualNeural', 'heureux'),
|
| 919 |
-
'sad': ('fr-FR-RemyMultilingualNeural', 'triste'),
|
| 920 |
-
'excited': ('fr-FR-VivienneMultilingualNeural', 'excité'),
|
| 921 |
-
'humorous': ('fr-FR-VivienneMultilingualNeural', 'drôle'),
|
| 922 |
-
'serious': ('fr-FR-RemyMultilingualNeural', 'sérieux'),
|
| 923 |
-
'neutral': ('fr-FR-VivienneMultilingualNeural', 'neutre'),
|
| 924 |
-
},
|
| 925 |
-
'german': {
|
| 926 |
-
'happy': ('de-DE-SeraphinaMultilingualNeural', 'glücklich'),
|
| 927 |
-
'sad': ('de-DE-FlorianMultilingualNeural', 'traurig'),
|
| 928 |
-
'excited': ('de-DE-SeraphinaMultilingualNeural', 'aufgeregt'),
|
| 929 |
-
'humorous': ('de-DE-SeraphinaMultilingualNeural', 'lustig'),
|
| 930 |
-
'serious': ('de-DE-FlorianMultilingualNeural', 'ernst'),
|
| 931 |
-
'neutral': ('de-DE-SeraphinaMultilingualNeural', 'neutral'),
|
| 932 |
-
},
|
| 933 |
-
'korean': {
|
| 934 |
-
'happy': ('ko-KR-HyunsuMultilingualNeural', '행복'),
|
| 935 |
-
'sad': ('ko-KR-HyunsuMultilingualNeural', '슬픔'),
|
| 936 |
-
'excited': ('ko-KR-HyunsuMultilingualNeural', '흥분'),
|
| 937 |
-
'humorous': ('ko-KR-HyunsuMultilingualNeural', '유쾌'),
|
| 938 |
-
'serious': ('ko-KR-HyunsuMultilingualNeural', '진지'),
|
| 939 |
-
'neutral': ('ko-KR-HyunsuMultilingualNeural', '중립'),
|
| 940 |
-
},
|
| 941 |
-
'italian': {
|
| 942 |
-
'happy': ('it-IT-GiuseppeMultilingualNeural', 'felice'),
|
| 943 |
-
'sad': ('it-IT-GiuseppeMultilingualNeural', 'triste'),
|
| 944 |
-
'excited': ('it-IT-GiuseppeMultilingualNeural', 'emozionato'),
|
| 945 |
-
'humorous': ('it-IT-GiuseppeMultilingualNeural', 'divertente'),
|
| 946 |
-
'serious': ('it-IT-GiuseppeMultilingualNeural', 'serio'),
|
| 947 |
-
'neutral': ('it-IT-GiuseppeMultilingualNeural', 'neutro'),
|
| 948 |
-
},
|
| 949 |
-
}
|
| 950 |
-
|
| 951 |
-
# All valid voice IDs (new MultilingualNeural format)
|
| 952 |
-
VALID_VOICES = {
|
| 953 |
-
'vi-VN-HoaiMyNeural', 'vi-VN-NamMinhNeural',
|
| 954 |
-
'en-US-AndrewMultilingualNeural', 'en-AU-WilliamMultilingualNeural',
|
| 955 |
-
'pt-BR-ThalitaMultilingualNeural',
|
| 956 |
-
'fr-FR-VivienneMultilingualNeural', 'fr-FR-RemyMultilingualNeural',
|
| 957 |
-
'de-DE-SeraphinaMultilingualNeural', 'de-DE-FlorianMultilingualNeural',
|
| 958 |
-
'ko-KR-HyunsuMultilingualNeural',
|
| 959 |
-
'it-IT-GiuseppeMultilingualNeural',
|
| 960 |
-
}
|
| 961 |
-
|
| 962 |
-
def get_voice_for_content(title, text, preferred_voice=None):
|
| 963 |
-
"""Get appropriate voice based on content language and emotion."""
|
| 964 |
-
# Accept the new MultilingualNeural voices directly
|
| 965 |
-
if preferred_voice and preferred_voice in VALID_VOICES:
|
| 966 |
-
return preferred_voice
|
| 967 |
-
|
| 968 |
-
# Also accept old shorthand voice IDs and map them to new format
|
| 969 |
-
old_voice_map = {
|
| 970 |
-
'hoaimy': 'vi-VN-HoaiMyNeural',
|
| 971 |
-
'namminh': 'vi-VN-NamMinhNeural',
|
| 972 |
-
'andrew': 'en-US-AndrewMultilingualNeural',
|
| 973 |
-
'jenny': 'en-US-AndrewMultilingualNeural',
|
| 974 |
-
'thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 975 |
-
'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 976 |
-
'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
|
| 977 |
-
'ela': 'en-US-AndrewMultilingualNeural',
|
| 978 |
-
'es_carlos': 'en-US-AndrewMultilingualNeural',
|
| 979 |
-
'denise': 'fr-FR-VivienneMultilingualNeural',
|
| 980 |
-
'katja': 'de-DE-SeraphinaMultilingualNeural',
|
| 981 |
-
'nanami': 'en-US-AndrewMultilingualNeural',
|
| 982 |
-
'sunhee': 'ko-KR-HyunsuMultilingualNeural',
|
| 983 |
-
'xiaochen': 'en-US-AndrewMultilingualNeural',
|
| 984 |
-
}
|
| 985 |
-
if preferred_voice and preferred_voice in old_voice_map:
|
| 986 |
-
return old_voice_map[preferred_voice]
|
| 987 |
-
|
| 988 |
-
lang, emotion = detect_language_and_emotion(title, text)
|
| 989 |
-
lang_map = VOICE_BY_LANG_EMOTION.get(lang, VOICE_BY_LANG_EMOTION['vietnamese'])
|
| 990 |
-
voice, _ = lang_map.get(emotion, lang_map['neutral'])
|
| 991 |
-
return voice
|
| 992 |
-
|
| 993 |
-
|
| 994 |
-
def _is_relevant_image(img_url, title, text):
|
| 995 |
-
"""Check if an image is relevant to the article content."""
|
| 996 |
-
if not img_url:
|
| 997 |
-
return False
|
| 998 |
-
skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif',
|
| 999 |
-
'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite',
|
| 1000 |
-
'advertisement', 'ad-banner', 'sponsored', 'banner-ads']
|
| 1001 |
-
img_lower = img_url.lower()
|
| 1002 |
-
for p in skip_patterns:
|
| 1003 |
-
if p in img_lower:
|
| 1004 |
-
return False
|
| 1005 |
-
if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']):
|
| 1006 |
-
return False
|
| 1007 |
-
return True
|
| 1008 |
-
|
| 1009 |
-
|
| 1010 |
-
def _filter_relevant_images(images, title, text, max_images=8):
|
| 1011 |
-
"""Filter and rank images by relevance to article content."""
|
| 1012 |
-
if not images:
|
| 1013 |
-
return []
|
| 1014 |
-
seen = set()
|
| 1015 |
-
relevant = []
|
| 1016 |
-
for img in images:
|
| 1017 |
-
if img in seen:
|
| 1018 |
-
continue
|
| 1019 |
-
seen.add(img)
|
| 1020 |
-
if _is_relevant_image(img, title, text):
|
| 1021 |
-
relevant.append(img)
|
| 1022 |
-
return relevant[:max_images]
|
| 1023 |
-
|
| 1024 |
-
|
| 1025 |
-
def _scrape_article_for_rewrite(url):
|
| 1026 |
-
"""Scrape article: extract title, paragraphs, RELEVANT images, OG image."""
|
| 1027 |
-
try:
|
| 1028 |
-
r = req.get(url, headers=_UA_RW, timeout=15, allow_redirects=True)
|
| 1029 |
-
r.encoding = 'utf-8'
|
| 1030 |
-
soup = BeautifulSoup(r.text, 'lxml')
|
| 1031 |
-
for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
|
| 1032 |
-
tag.decompose()
|
| 1033 |
-
h1 = soup.find('h1')
|
| 1034 |
-
ogt = soup.find('meta', property='og:title')
|
| 1035 |
-
title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '')
|
| 1036 |
-
ogi = soup.find('meta', property='og:image')
|
| 1037 |
-
og_img = ogi.get('content', '') if ogi else ''
|
| 1038 |
-
if og_img and og_img.startswith('//'):
|
| 1039 |
-
og_img = 'https:' + og_img
|
| 1040 |
-
block = None
|
| 1041 |
-
for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
|
| 1042 |
-
el = soup.select_one(sel)
|
| 1043 |
-
if el and len(el.find_all('p')) >= 2:
|
| 1044 |
-
block = el
|
| 1045 |
-
break
|
| 1046 |
-
if not block:
|
| 1047 |
-
block = soup.body or soup
|
| 1048 |
-
paragraphs = []
|
| 1049 |
-
all_images = []
|
| 1050 |
-
seen_imgs = set()
|
| 1051 |
-
if og_img and og_img not in seen_imgs:
|
| 1052 |
-
all_images.append(og_img)
|
| 1053 |
-
seen_imgs.add(og_img)
|
| 1054 |
-
for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
|
| 1055 |
-
if el.name == 'p':
|
| 1056 |
-
t = _clean(el.get_text(strip=True))
|
| 1057 |
-
if t and len(t) > 40:
|
| 1058 |
-
paragraphs.append(t)
|
| 1059 |
-
elif el.name in ('figure', 'img'):
|
| 1060 |
-
im = el if el.name == 'img' else el.find('img')
|
| 1061 |
-
if im:
|
| 1062 |
-
src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
|
| 1063 |
-
if src and 'base64' not in src:
|
| 1064 |
-
if src.startswith('//'):
|
| 1065 |
-
src = 'https:' + src
|
| 1066 |
-
if src not in seen_imgs:
|
| 1067 |
-
all_images.append(src)
|
| 1068 |
-
seen_imgs.add(src)
|
| 1069 |
-
# Filter to relevant images only
|
| 1070 |
-
relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5]))
|
| 1071 |
-
return {'title': _clean(title), 'paragraphs': paragraphs, 'images': relevant_images, 'og_img': og_img}
|
| 1072 |
-
except Exception:
|
| 1073 |
-
return None
|
| 1074 |
-
|
| 1075 |
-
|
| 1076 |
-
def _extract_key_points_rw(paragraphs, max_points=5):
|
| 1077 |
-
"""Extract key points from paragraphs - extracts ALL sentences, not just first one.
|
| 1078 |
-
|
| 1079 |
-
Fixes: Original regex `^(.+?[.!?])\s` only captured first sentence per paragraph.
|
| 1080 |
-
Now splits on all sentence boundaries and takes valid sentences until max_points.
|
| 1081 |
-
"""
|
| 1082 |
-
points = []
|
| 1083 |
-
|
| 1084 |
-
for p in paragraphs:
|
| 1085 |
-
if len(points) >= max_points:
|
| 1086 |
-
break
|
| 1087 |
-
|
| 1088 |
-
p = _clean(p)
|
| 1089 |
-
if not p:
|
| 1090 |
-
continue
|
| 1091 |
-
|
| 1092 |
-
# Split paragraph into sentences using Vietnamese + English punctuation
|
| 1093 |
-
sentences = re.split(r'(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])', p)
|
| 1094 |
-
sentences = [s.strip() for s in sentences if s.strip()]
|
| 1095 |
-
|
| 1096 |
-
for sentence in sentences:
|
| 1097 |
-
if len(points) >= max_points:
|
| 1098 |
-
break
|
| 1099 |
-
|
| 1100 |
-
# Clean sentence - remove extra whitespace
|
| 1101 |
-
sentence = _clean(sentence)
|
| 1102 |
-
|
| 1103 |
-
if len(sentence) < 30:
|
| 1104 |
-
continue
|
| 1105 |
-
|
| 1106 |
-
# Check for duplicates
|
| 1107 |
-
if any(sentence[:60] in existing for existing in points):
|
| 1108 |
-
continue
|
| 1109 |
-
|
| 1110 |
-
# Ensure sentence ends with punctuation
|
| 1111 |
-
if not sentence.endswith(('.', '!', '?')):
|
| 1112 |
-
sentence = sentence + '.'
|
| 1113 |
-
|
| 1114 |
-
points.append(sentence)
|
| 1115 |
-
|
| 1116 |
-
# If no valid sentences found, take chunks from raw text
|
| 1117 |
-
if not points:
|
| 1118 |
-
raw = '\n'.join(paragraphs)
|
| 1119 |
-
for i in range(0, min(len(raw), max_points * 300), 280):
|
| 1120 |
-
chunk = _clean(raw[i:i+280])
|
| 1121 |
-
if len(chunk) >= 30 and chunk not in points:
|
| 1122 |
-
points.append(chunk + ('.' if not chunk.endswith('.') else ''))
|
| 1123 |
-
if len(points) >= max_points:
|
| 1124 |
-
break
|
| 1125 |
-
|
| 1126 |
-
return points
|
| 1127 |
-
|
| 1128 |
-
|
| 1129 |
-
@app.post("/api/rewrite_slide")
|
| 1130 |
-
async def api_rewrite_slide(request: Request):
|
| 1131 |
-
"""Fast rewrite as SLIDES - no AI needed, instant response."""
|
| 1132 |
-
body = await request.json()
|
| 1133 |
-
url = _clean(body.get("url", ""))
|
| 1134 |
-
context = body.get("context", "")
|
| 1135 |
-
preferred_voice = body.get("voice", "") # Accept custom voice selection
|
| 1136 |
-
if not url and not context:
|
| 1137 |
-
return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
|
| 1138 |
-
data = None
|
| 1139 |
-
if url and url.startswith("http"):
|
| 1140 |
-
data = _scrape_article_for_rewrite(url)
|
| 1141 |
-
if not data and context:
|
| 1142 |
-
paragraphs = [_clean(p) for p in context.split('\n') if len(_clean(p)) > 40]
|
| 1143 |
-
data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
|
| 1144 |
-
if not data or not data.get('paragraphs'):
|
| 1145 |
-
return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
|
| 1146 |
-
points = _extract_key_points_rw(data['paragraphs'], max_points=12)
|
| 1147 |
-
if not points:
|
| 1148 |
-
return JSONResponse({"error": "Không tìm được ý chính"}, status_code=422)
|
| 1149 |
-
images = data.get('images', [])
|
| 1150 |
-
slides = []
|
| 1151 |
-
for i, point in enumerate(points):
|
| 1152 |
-
img = images[i] if i < len(images) else (images[-1] if images else '')
|
| 1153 |
-
if img and 'cdnphoto.dantri' in img:
|
| 1154 |
-
img = '/api/proxy/img?url=' + _quote2(img, safe='')
|
| 1155 |
-
slides.append({'text': point, 'image': img, 'index': i + 1})
|
| 1156 |
-
summary_text = '\n\n'.join([f"• {s['text']}" for s in slides])
|
| 1157 |
-
|
| 1158 |
-
# Auto-detect language and emotion
|
| 1159 |
-
lang, emotion = detect_language_and_emotion(data['title'], summary_text)
|
| 1160 |
-
# Use preferred voice if provided, otherwise auto-detect
|
| 1161 |
-
voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], summary_text)
|
| 1162 |
-
|
| 1163 |
-
post = {
|
| 1164 |
-
"id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
|
| 1165 |
-
"title": data['title'],
|
| 1166 |
-
"text": summary_text,
|
| 1167 |
-
"img": images[0] if images else '',
|
| 1168 |
-
"url": url,
|
| 1169 |
-
"kind": "slide_summary",
|
| 1170 |
-
"slides": slides,
|
| 1171 |
-
"images": images[:10],
|
| 1172 |
-
"video": "",
|
| 1173 |
-
"voice": voice,
|
| 1174 |
-
"emotion": emotion,
|
| 1175 |
-
"language": lang,
|
| 1176 |
-
"ts": int(time.time())
|
| 1177 |
-
}
|
| 1178 |
-
posts = _load_wall_posts()
|
| 1179 |
-
posts.insert(0, post)
|
| 1180 |
-
_save_wall_posts(posts)
|
| 1181 |
-
return JSONResponse({"post": post, "slides": slides})
|
| 1182 |
-
|
| 1183 |
-
|
| 1184 |
-
@app.post("/api/rewrite_share")
|
| 1185 |
-
async def api_rewrite_share(request: Request):
|
| 1186 |
-
"""Rewrite article and post to Tường AI with SLIDES + AI text."""
|
| 1187 |
-
body = await request.json()
|
| 1188 |
-
url = _clean(body.get("url", ""))
|
| 1189 |
-
ctx = _clean(body.get("context", ""))
|
| 1190 |
-
preferred_voice = body.get("voice", "") # Accept custom voice selection
|
| 1191 |
-
if not url and not ctx:
|
| 1192 |
-
return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
|
| 1193 |
-
data = None
|
| 1194 |
-
if url and url.startswith("http"):
|
| 1195 |
-
data = _scrape_article_for_rewrite(url)
|
| 1196 |
-
if not data and ctx:
|
| 1197 |
-
paragraphs = [_clean(p) for p in ctx.split('\n') if len(_clean(p)) > 40]
|
| 1198 |
-
data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
|
| 1199 |
-
if not data or not data.get('paragraphs'):
|
| 1200 |
-
return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
|
| 1201 |
-
raw_text = '\n'.join(data['paragraphs'])
|
| 1202 |
-
if len(raw_text) < 50:
|
| 1203 |
-
raw_text = ctx[:14000]
|
| 1204 |
-
if len(raw_text) < 50:
|
| 1205 |
-
return JSONResponse({"error": "Bài viết quá ngắn"}, status_code=422)
|
| 1206 |
-
domain = ''
|
| 1207 |
-
try:
|
| 1208 |
-
from urllib.parse import urlparse
|
| 1209 |
-
domain = urlparse(url).netloc.replace('www.', '')
|
| 1210 |
-
except:
|
| 1211 |
-
pass
|
| 1212 |
-
|
| 1213 |
-
# Generate AI summary text
|
| 1214 |
-
ai_text = None
|
| 1215 |
-
try:
|
| 1216 |
-
import ai_ext
|
| 1217 |
-
if hasattr(ai_ext, 'qwen_generate'):
|
| 1218 |
-
prompt = f'Tóm tắt đăng Tường AI:\nTiêu đề: {data["title"]}\n{raw_text[:14000]}\n\n4-6 ý chính. Cuối ghi nguồn.'
|
| 1219 |
-
ai_text = await ai_ext.qwen_generate(prompt, max_tokens=1000)
|
| 1220 |
-
except Exception:
|
| 1221 |
-
pass
|
| 1222 |
-
if not ai_text or len(ai_text) < 80:
|
| 1223 |
-
key_pts = _extract_key_points_rw(data['paragraphs'], max_points=12)
|
| 1224 |
-
if key_pts:
|
| 1225 |
-
ai_text = '\n\n'.join([f"• {p}" for p in key_pts])
|
| 1226 |
-
else:
|
| 1227 |
-
ai_text = f"Tóm tắt: {data['title']}\n\n{raw_text[:1200]}\n\nNguồn: {domain}"
|
| 1228 |
-
|
| 1229 |
-
# Build slides from key points (FIX: include slides in rewrite_share too!)
|
| 1230 |
-
points = _extract_key_points_rw(data['paragraphs'], max_points=12)
|
| 1231 |
-
images = data.get('images', [])
|
| 1232 |
-
slides = []
|
| 1233 |
-
for i, point in enumerate(points):
|
| 1234 |
-
img = images[i] if i < len(images) else (images[-1] if images else '')
|
| 1235 |
-
if img and 'cdnphoto.dantri' in img:
|
| 1236 |
-
img = '/api/proxy/img?url=' + _quote2(img, safe='')
|
| 1237 |
-
slides.append({'text': point, 'image': img, 'index': i + 1})
|
| 1238 |
-
|
| 1239 |
-
# Auto-detect language and emotion
|
| 1240 |
-
lang, emotion = detect_language_and_emotion(data['title'], ai_text)
|
| 1241 |
-
# Use preferred voice if provided, otherwise auto-detect
|
| 1242 |
-
voice = preferred_voice if preferred_voice else get_voice_for_content(data['title'], ai_text)
|
| 1243 |
-
|
| 1244 |
-
post = {
|
| 1245 |
-
"id": str(int(time.time() * 1000)) + str(_random2.randint(100, 999)),
|
| 1246 |
-
"title": data['title'],
|
| 1247 |
-
"text": ai_text,
|
| 1248 |
-
"img": images[0] if images else '',
|
| 1249 |
-
"url": url,
|
| 1250 |
-
"kind": "rewrite",
|
| 1251 |
-
"slides": slides,
|
| 1252 |
-
"images": images[:10],
|
| 1253 |
-
"video": "",
|
| 1254 |
-
"voice": voice,
|
| 1255 |
-
"emotion": emotion,
|
| 1256 |
-
"language": lang,
|
| 1257 |
-
"ts": int(time.time())
|
| 1258 |
-
}
|
| 1259 |
-
posts = _load_wall_posts()
|
| 1260 |
-
posts.insert(0, post)
|
| 1261 |
-
_save_wall_posts(posts)
|
| 1262 |
-
return JSONResponse({"post": post, "slides": slides})
|
| 1263 |
-
|
| 1264 |
-
|
| 1265 |
-
@app.post("/api/url_wall")
|
| 1266 |
-
async def api_url_wall(request: Request):
|
| 1267 |
-
"""Submit URL to add to Tường AI."""
|
| 1268 |
-
body = await request.json()
|
| 1269 |
-
url = _clean(body.get("url", ""))
|
| 1270 |
-
if not url or not url.startswith('http'):
|
| 1271 |
-
return JSONResponse({"error": "URL không hợp lệ"}, status_code=400)
|
| 1272 |
-
# Reuse rewrite_share logic
|
| 1273 |
-
req._body = json.dumps({"url": url}).encode()
|
| 1274 |
-
return await api_rewrite_share(request)
|
| 1275 |
|
|
|
|
| 1276 |
|
| 1277 |
def _bg():
|
| 1278 |
time.sleep(15)
|
|
|
|
| 1 |
+
"""VNEWS v2 Entry Point - with fast bongda proxy"""
|
| 2 |
import sys, os
|
| 3 |
from main import app, HEADERS, BONGDA_HEADERS, fetch_bongda_api, HL_LEAGUES
|
| 4 |
|
|
|
|
| 7 |
except Exception as e:
|
| 8 |
print(f"[WARN] ai_ext import failed: {e}")
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response
|
| 11 |
from fastapi.staticfiles import StaticFiles
|
| 12 |
from starlette.routing import Mount
|
|
|
|
| 322 |
if i<len(s) and s[i].get('url') and s[i]['url'] not in seen:seen.add(s[i]['url']);out.append(s[i])
|
| 323 |
return out[:limit]
|
| 324 |
|
| 325 |
+
# Remove main.py routes that app_v2_entry overrides (main.py registers first, FastAPI uses first match)
|
| 326 |
for _path in ['/api/article', '/api/hot_topics', '/api/categories', '/api/storage_status', '/s']:
|
| 327 |
app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)==_path and 'GET' in getattr(r,'methods',set()))]
|
| 328 |
|
| 329 |
+
# ===== Article cache (TTL 30 min, keyed by URL) =====
|
| 330 |
_article_cache = {}
|
| 331 |
_article_cache_ttl = 1800
|
| 332 |
|
| 333 |
+
# Dedicated session for article scraping (no rate limiter — we only scrape one article at a time per request)
|
| 334 |
_art_session = None
|
| 335 |
_art_lock = threading.Lock()
|
| 336 |
def _get_art_session():
|
|
|
|
| 347 |
return _art_session
|
| 348 |
|
| 349 |
def _scrape_article_fast(url):
|
| 350 |
+
"""Fast article scrape — single request, no rate limiter, fail fast with OG fallback."""
|
| 351 |
from urllib.parse import urlparse
|
| 352 |
domain = urlparse(url).netloc
|
| 353 |
sess = _get_art_session()
|
| 354 |
+
|
| 355 |
+
# Try mobile UA first (lighter HTML), then desktop
|
| 356 |
uas = [
|
| 357 |
{"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"},
|
| 358 |
{"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"},
|
| 359 |
]
|
| 360 |
+
|
| 361 |
for ua in uas:
|
| 362 |
try:
|
| 363 |
r = sess.get(url, headers=ua, timeout=6, allow_redirects=True)
|
|
|
|
| 365 |
continue
|
| 366 |
r.encoding = 'utf-8'
|
| 367 |
soup = BeautifulSoup(r.text, 'lxml')
|
| 368 |
+
|
| 369 |
+
# Remove junk
|
| 370 |
for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe','.ads','.ad','.banner-ads','.fb-comments','.fb-root','.social-share','.related-news','.tag','.breadcrumb']):
|
| 371 |
tag.decompose()
|
| 372 |
+
|
| 373 |
+
# Extract OG meta
|
| 374 |
title = summary = og_img = ""
|
| 375 |
ogt = soup.find('meta', property='og:title')
|
| 376 |
if ogt: title = ogt.get('content', '')
|
|
|
|
| 382 |
if og_img.startswith('//'): og_img = 'https:' + og_img
|
| 383 |
h1 = soup.find('h1')
|
| 384 |
if not title and h1: title = h1.get_text(strip=True)[:200]
|
| 385 |
+
|
| 386 |
+
# Try to find article body
|
| 387 |
body = []
|
| 388 |
selectors = [
|
| 389 |
+
'.fck_detail', '.sidebar-1', # VnExpress
|
| 390 |
+
'.singular-content', '.dt__content', '.article-content', '.content-detail', '#divNewsContent', # Dân Trí
|
| 391 |
+
'.content-detail', '.main-content-detail', '.box-content', # Tuổi Trẻ
|
| 392 |
+
'.knc-content', '.article-body', '.detail-body', # Kenh14/GenK
|
| 393 |
+
'.article-detail', '.detail-content', # Thanh Niên
|
| 394 |
'article', 'main', '.cms-body', '.article__body', '.post-content',
|
| 395 |
'.entry-content', '#content', '.article-text', '.story-body',
|
| 396 |
]
|
|
|
|
| 423 |
if len(body) >= 2:
|
| 424 |
return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img,
|
| 425 |
'body': body[:50], 'source': domain, 'url': url}
|
| 426 |
+
|
| 427 |
+
# No body found — use OG meta as fallback
|
| 428 |
if title and (summary or og_img):
|
| 429 |
fallback = []
|
| 430 |
if og_img: fallback.append({'type': 'img', 'src': og_img})
|
|
|
|
| 432 |
if fallback:
|
| 433 |
return {'title': _clean(title), 'summary': _clean(summary), 'og_image': og_img,
|
| 434 |
'body': fallback, 'source': domain, 'url': url, 'fallback': True}
|
| 435 |
+
|
| 436 |
+
# Got HTML but no body and no OG — return title at least
|
| 437 |
if title:
|
| 438 |
return {'title': _clean(title), 'summary': '', 'og_image': '',
|
| 439 |
'body': [{'type': 'p', 'text': 'Nội dung đang được tải...'}],
|
| 440 |
'source': domain, 'url': url, 'fallback': True}
|
| 441 |
+
|
| 442 |
+
break # Got 200 but no content at all — don't retry other UA
|
| 443 |
except Exception:
|
| 444 |
continue
|
| 445 |
+
|
| 446 |
return None
|
| 447 |
|
| 448 |
@app.get('/api/article')
|
| 449 |
def api_article_v2(url: str = Query(...)):
|
| 450 |
+
"""Scrape article and return JSON for VNEWS SPA. Fast, cached, with fallback."""
|
| 451 |
from urllib.parse import unquote
|
| 452 |
safe_url = unquote(url)
|
| 453 |
+
|
| 454 |
try:
|
| 455 |
+
# Check cache first
|
| 456 |
now = time.time()
|
| 457 |
cached = _article_cache.get(safe_url)
|
| 458 |
if cached and now - cached['t'] < _article_cache_ttl:
|
| 459 |
resp = JSONResponse(cached['d'])
|
| 460 |
resp.headers["Cache-Control"] = "public, max-age=1800"
|
| 461 |
return resp
|
| 462 |
+
|
| 463 |
+
# Fetch fresh — use fast scraper for ALL sites (simpler, more reliable)
|
| 464 |
data = _scrape_article_fast(safe_url)
|
| 465 |
+
|
| 466 |
if data and data.get('body'):
|
| 467 |
_article_cache[safe_url] = {'d': data, 't': now}
|
| 468 |
resp = JSONResponse(data)
|
| 469 |
resp.headers["Cache-Control"] = "public, max-age=1800"
|
| 470 |
return resp
|
| 471 |
+
|
| 472 |
+
# Last resort: try RSS fallback
|
| 473 |
+
try:
|
| 474 |
+
from main import _fetch_rss_fallback
|
| 475 |
+
from urllib.parse import urlparse as _up
|
| 476 |
+
rss_data = _fetch_rss_fallback(safe_url, _up(safe_url).netloc)
|
| 477 |
+
if rss_data and rss_data.get('title'):
|
| 478 |
+
body = []
|
| 479 |
+
if rss_data.get('og_image'):
|
| 480 |
+
body.append({'type': 'img', 'src': rss_data['og_image']})
|
| 481 |
+
if rss_data.get('summary'):
|
| 482 |
+
sentences = re.split(r'(?<=[.!?])\s+', rss_data['summary'])
|
| 483 |
+
for s in sentences[:10]:
|
| 484 |
+
if len(s.strip()) > 20:
|
| 485 |
+
body.append({'type': 'p', 'text': s.strip()})
|
| 486 |
+
if body:
|
| 487 |
+
result = {
|
| 488 |
+
'title': rss_data['title'], 'summary': rss_data['summary'][:500],
|
| 489 |
+
'og_image': rss_data.get('og_image', ''), 'body': body[:50],
|
| 490 |
+
'source': 'rss', 'url': safe_url, 'fallback': True, 'rss': True
|
| 491 |
+
}
|
| 492 |
+
_article_cache[safe_url] = {'d': result, 't': now}
|
| 493 |
+
resp = JSONResponse(result)
|
| 494 |
+
resp.headers["Cache-Control"] = "public, max-age=600"
|
| 495 |
+
return resp
|
| 496 |
+
except Exception:
|
| 497 |
+
pass
|
| 498 |
+
|
| 499 |
result = {'error': 'Không đọc được', 'url': safe_url}
|
| 500 |
resp = JSONResponse(result)
|
| 501 |
resp.headers["Cache-Control"] = "public, max-age=60"
|
| 502 |
return resp
|
| 503 |
except Exception as e:
|
| 504 |
+
import traceback
|
| 505 |
+
tb = traceback.format_exc()
|
| 506 |
+
return JSONResponse({'error': f'Server error: {str(e)[:100]}', 'trace': tb[-500:], 'url': safe_url}, status_code=200)
|
| 507 |
|
| 508 |
_hot_cache={'t':0,'d':[]}
|
| 509 |
def _get_hot_topics():
|
|
|
|
| 556 |
@app.get('/s')
|
| 557 |
async def _sh(url:str='',title:str='',img:str=''):return HTMLResponse(f'<!DOCTYPE html><html><head><meta property="og:title" content="{_clean(title)}"><meta property="og:image" content="{_clean(img)}"><meta http-equiv="refresh" content="0;url={_clean(url) or "/"}"></head><body></body></html>')
|
| 558 |
|
| 559 |
+
from wc2026_scraper import(scrape_summary,scrape_fixtures,scrape_standings,scrape_stats,scrape_wc_news,scrape_road_to_wc,get_wc2026_all,scrape_history,scrape_h2h,scrape_lineups,scrape_match_detail)
|
| 560 |
|
| 561 |
+
# === XEMLAIBONGDA PROXY (CORS workaround for WC highlights) ===
|
| 562 |
_xlb_cache = {}
|
| 563 |
_xlb_lock = threading.Lock()
|
| 564 |
|
|
|
|
| 696 |
with _il:idb=_lj(IF);idb.setdefault(v,{'views':0,'likes':0,'comments':0});idb[v]['comments']=len(cms);_sj(IF,idb)
|
| 697 |
return JSONResponse({'comments':cms})
|
| 698 |
|
| 699 |
+
# ===== WALL / SHORT AI ENDPOINTS =====
|
| 700 |
+
|
| 701 |
def _load_wall_posts():
|
| 702 |
+
"""Load wall posts from JSON file."""
|
| 703 |
with _wl_lock:
|
| 704 |
return _lj(WALL_FILE)
|
| 705 |
|
| 706 |
def _save_wall_posts(posts):
|
| 707 |
+
"""Save wall posts to JSON file."""
|
| 708 |
with _wl_lock:
|
| 709 |
_sj(WALL_FILE, posts)
|
| 710 |
|
| 711 |
@app.get('/api/wall')
|
| 712 |
def api_wall():
|
| 713 |
+
"""Get all wall posts."""
|
| 714 |
posts = _load_wall_posts()
|
| 715 |
if not posts:
|
| 716 |
+
# Return empty list, not error
|
| 717 |
return JSONResponse({"posts": []})
|
| 718 |
return JSONResponse({"posts": posts})
|
| 719 |
|
| 720 |
@app.post('/api/wall')
|
| 721 |
async def api_wall_post(request: Request):
|
| 722 |
+
"""
|
| 723 |
+
Create a wall post. Supports:
|
| 724 |
+
- JSON body: {title, text, img, source}
|
| 725 |
+
- Multipart form: title, text, source + video file upload
|
| 726 |
+
"""
|
| 727 |
content_type = request.headers.get('content-type', '')
|
| 728 |
+
|
| 729 |
+
# Handle multipart upload (video file)
|
| 730 |
if 'multipart/form-data' in content_type:
|
| 731 |
try:
|
| 732 |
form = await request.form()
|
| 733 |
except Exception as e:
|
| 734 |
return JSONResponse({"error": f"Form parse error: {str(e)}"}, status_code=400)
|
| 735 |
+
|
| 736 |
title = form.get('title', 'Video mới') or 'Video mới'
|
| 737 |
text = form.get('text', '') or ''
|
| 738 |
source = form.get('source', 'vtv_recorder') or 'vtv_recorder'
|
| 739 |
video_file = form.get('video')
|
| 740 |
+
|
| 741 |
post_id = str(uuid.uuid4())[:12]
|
| 742 |
video_url = None
|
| 743 |
+
|
| 744 |
+
# Save video file if provided
|
| 745 |
if video_file and hasattr(video_file, 'filename') and video_file.filename:
|
| 746 |
+
# Determine extension
|
| 747 |
fname = video_file.filename.lower()
|
| 748 |
if fname.endswith('.mp4'):
|
| 749 |
ext = '.mp4'
|
|
|
|
| 751 |
ext = '.webm'
|
| 752 |
else:
|
| 753 |
ext = '.webm'
|
| 754 |
+
|
| 755 |
video_filename = f"wall_{post_id}{ext}"
|
| 756 |
video_path = os.path.join(WALL_VIDEO_DIR, video_filename)
|
| 757 |
+
|
| 758 |
try:
|
| 759 |
+
# Read file content
|
| 760 |
content = await video_file.read()
|
| 761 |
if not content:
|
| 762 |
return JSONResponse({"error": "Empty video file"}, status_code=400)
|
| 763 |
+
|
| 764 |
+
# Save to disk
|
| 765 |
with open(video_path, 'wb') as f:
|
| 766 |
f.write(content)
|
| 767 |
+
|
| 768 |
file_size_mb = len(content) / 1024 / 1024
|
| 769 |
if file_size_mb > 50:
|
| 770 |
os.remove(video_path)
|
| 771 |
return JSONResponse({"error": f"Video quá lớn ({file_size_mb:.1f}MB). Tối đa 50MB."}, status_code=400)
|
| 772 |
+
|
| 773 |
+
# URL to access the video
|
| 774 |
video_url = f"/api/wall/video/{video_filename}"
|
| 775 |
except Exception as e:
|
| 776 |
return JSONResponse({"error": f"Lỗi lưu video: {str(e)}"}, status_code=500)
|
| 777 |
+
|
| 778 |
+
# Create post
|
| 779 |
post = {
|
| 780 |
"id": post_id,
|
| 781 |
"title": title[:200],
|
|
|
|
| 787 |
"created": int(time.time()),
|
| 788 |
"created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
|
| 789 |
}
|
| 790 |
+
|
| 791 |
+
# Save to wall
|
| 792 |
posts = _load_wall_posts()
|
| 793 |
if not isinstance(posts, list):
|
| 794 |
posts = []
|
| 795 |
posts.insert(0, post)
|
| 796 |
+
# Keep max 200 posts
|
| 797 |
posts = posts[:200]
|
| 798 |
_save_wall_posts(posts)
|
| 799 |
+
|
| 800 |
return JSONResponse({"post": post, "ok": True})
|
| 801 |
+
|
| 802 |
+
# Handle JSON body (text-only post)
|
| 803 |
try:
|
| 804 |
body = await request.json()
|
| 805 |
except:
|
| 806 |
body = {}
|
| 807 |
+
|
| 808 |
title = body.get('title', 'Bài mới') or 'Bài mới'
|
| 809 |
text = body.get('text', '') or ''
|
| 810 |
img = body.get('img', None)
|
| 811 |
source = body.get('source', 'user') or 'user'
|
| 812 |
+
|
| 813 |
post_id = str(uuid.uuid4())[:12]
|
| 814 |
post = {
|
| 815 |
"id": post_id,
|
|
|
|
| 822 |
"created": int(time.time()),
|
| 823 |
"created_str": time.strftime('%H:%M %d/%m/%Y', time.localtime()),
|
| 824 |
}
|
| 825 |
+
|
| 826 |
posts = _load_wall_posts()
|
| 827 |
if not isinstance(posts, list):
|
| 828 |
posts = []
|
| 829 |
posts.insert(0, post)
|
| 830 |
posts = posts[:200]
|
| 831 |
_save_wall_posts(posts)
|
| 832 |
+
|
| 833 |
return JSONResponse({"post": post, "ok": True})
|
| 834 |
|
| 835 |
@app.get('/api/wall/video/{filename}')
|
| 836 |
def api_wall_video(filename: str):
|
| 837 |
+
"""Serve a wall video file."""
|
| 838 |
+
# Security: prevent path traversal
|
| 839 |
if '..' in filename or '/' in filename:
|
| 840 |
return Response(status_code=403)
|
| 841 |
video_path = os.path.join(WALL_VIDEO_DIR, filename)
|
|
|
|
| 847 |
|
| 848 |
@app.delete('/api/wall/{post_id}')
|
| 849 |
def api_wall_delete(post_id: str):
|
| 850 |
+
"""Delete a wall post and its video."""
|
| 851 |
posts = _load_wall_posts()
|
| 852 |
if not isinstance(posts, list):
|
| 853 |
return JSONResponse({"error": "No posts"}, status_code=404)
|
| 854 |
+
|
| 855 |
for i, p in enumerate(posts):
|
| 856 |
if p.get('id') == post_id:
|
| 857 |
+
# Delete video file if exists
|
| 858 |
if p.get('video'):
|
| 859 |
video_name = p['video'].split('/')[-1]
|
| 860 |
video_path = os.path.join(WALL_VIDEO_DIR, video_name)
|
|
|
|
| 863 |
posts.pop(i)
|
| 864 |
_save_wall_posts(posts)
|
| 865 |
return JSONResponse({"ok": True})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 866 |
|
| 867 |
+
return JSONResponse({"error": "Post not found"}, status_code=404)
|
| 868 |
|
| 869 |
def _bg():
|
| 870 |
time.sleep(15)
|
main.py
CHANGED
|
@@ -1,10 +1,10 @@
|
|
| 1 |
-
"""VNEWS - FastAPI backend with livescore + xemlaibongda highlights + VTV
|
| 2 |
import re, time, subprocess, json, os, threading
|
| 3 |
import html as html_lib
|
| 4 |
from datetime import datetime, timezone, timedelta
|
| 5 |
from collections import defaultdict
|
| 6 |
|
| 7 |
-
VN_TZ = timezone(timedelta(hours=
|
| 8 |
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 9 |
from fastapi import FastAPI, Query, Request
|
| 10 |
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, Response
|
|
@@ -14,29 +14,38 @@ from bs4 import BeautifulSoup
|
|
| 14 |
|
| 15 |
app = FastAPI()
|
| 16 |
|
|
|
|
|
|
|
| 17 |
# ===== WORLD CUP 2026 SCRAPER =====
|
| 18 |
from wc2026_scraper import get_wc2026_all, scrape_fixtures, scrape_standings, scrape_stats, scrape_wc_news
|
| 19 |
|
| 20 |
# ===== RATE LIMITING =====
|
| 21 |
-
_rate_limit_data = defaultdict(list)
|
| 22 |
_rate_limit_lock = threading.Lock()
|
| 23 |
-
RATE_LIMIT_MAX = 60
|
| 24 |
-
RATE_LIMIT_WINDOW = 60
|
| 25 |
|
| 26 |
def _check_rate_limit(ip: str) -> bool:
|
|
|
|
| 27 |
with _rate_limit_lock:
|
| 28 |
now = time.time()
|
|
|
|
| 29 |
_rate_limit_data[ip] = [t for t in _rate_limit_data[ip] if now - t < RATE_LIMIT_WINDOW]
|
| 30 |
-
if len(_rate_limit_data[ip]) >= RATE_LIMIT_MAX:
|
|
|
|
| 31 |
_rate_limit_data[ip].append(now)
|
| 32 |
return True
|
| 33 |
|
| 34 |
@app.middleware("http")
|
| 35 |
async def rate_limit_middleware(request: Request, call_next):
|
|
|
|
|
|
|
| 36 |
if request.url.path.startswith("/api/"):
|
| 37 |
ip = request.client.host
|
| 38 |
-
if not _check_rate_limit(ip):
|
| 39 |
-
|
|
|
|
|
|
|
| 40 |
|
| 41 |
# ===== VTV CHANNELS API =====
|
| 42 |
from vtv_api import router as vtv_router
|
|
@@ -50,6 +59,63 @@ _cache_ttl = 300
|
|
| 50 |
_cache_ttl_live = 60
|
| 51 |
_cache_ttl_yt = 1800
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
PRIORITY_LEAGUES = ["Ngoại Hạng Anh","FA Cup","Champions League","LaLiga","Copa del Rey","Serie A","Bundesliga","Ligue 1","V-League"]
|
| 54 |
LEAGUE_IDS = {"nha":27110,"laliga":27233,"seriea":27044,"bundesliga":26891,"ligue1":27212}
|
| 55 |
HL_LEAGUES = {
|
|
@@ -136,84 +202,215 @@ def proxy_video(url: str = Query(...), request: Request = None):
|
|
| 136 |
@app.get("/api/proxy/img")
|
| 137 |
def proxy_img(url: str = Query(...)):
|
| 138 |
try:
|
| 139 |
-
|
| 140 |
-
_u = urlparse(url); _host = _u.netloc.lower()
|
| 141 |
-
_referer = "https://dantri.com.vn/"
|
| 142 |
-
if "refooty" in _host or "xemlaibongda" in _host: _referer = "https://xemlaibongda.top/"
|
| 143 |
-
elif "ytimg" in _host or "youtube" in _host: _referer = "https://www.youtube.com/"
|
| 144 |
-
elif "vncecdn" in _host or "vnexpress" in _host: _referer = "https://vnexpress.net/"
|
| 145 |
-
r = requests.get(url, headers={**HEADERS, "Referer": _referer}, timeout=10)
|
| 146 |
if r.status_code != 200: return Response(status_code=502)
|
| 147 |
-
|
|
|
|
| 148 |
except: return Response(status_code=502)
|
| 149 |
|
| 150 |
# ===== XEMLAIBONGDA HIGHLIGHTS =====
|
| 151 |
def _scrape_xemlaibongda_page(page_path, limit=20):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
try:
|
| 153 |
url = f"https://xemlaibongda.top/{page_path}" if page_path else "https://xemlaibongda.top/"
|
| 154 |
r = requests.get(url, headers=HEADERS, timeout=15)
|
| 155 |
-
if r.status_code != 200:
|
|
|
|
| 156 |
r.encoding = "utf-8"
|
| 157 |
soup = BeautifulSoup(r.text, "lxml")
|
| 158 |
-
videos = []
|
|
|
|
|
|
|
| 159 |
for a in soup.find_all("a", href=True):
|
| 160 |
href = a.get("href", "")
|
| 161 |
-
if "/video/" not in href and "/xem-lai/" not in href:
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
clean_href = href.split("?")[0].split("#")[0]
|
| 164 |
-
if clean_href in seen:
|
|
|
|
| 165 |
seen.add(clean_href)
|
|
|
|
|
|
|
| 166 |
img_src = ""
|
| 167 |
img = a.find("img")
|
| 168 |
-
if not img and a.parent:
|
|
|
|
| 169 |
if not img:
|
| 170 |
p = a.parent
|
| 171 |
for _ in range(4):
|
| 172 |
-
if p and p.find("img"):
|
| 173 |
-
|
| 174 |
-
if img:
|
| 175 |
-
img_src = (img.get("data-src", "") or img.get("src", "") or img.get("data-lazy", "") or img.get("data-original", "") or img.get("data-thumb", "") or img.get("data-image", ""))
|
| 176 |
-
if img_src.startswith("//"): img_src = "https:" + img_src
|
| 177 |
-
elif img_src.startswith("/"): img_src = "https://xemlaibongda.top" + img_src
|
| 178 |
-
if not img_src:
|
| 179 |
-
p = a.parent
|
| 180 |
-
for _ in range(5):
|
| 181 |
-
if p is None: break
|
| 182 |
-
style = p.get("style", "")
|
| 183 |
-
bg_match = re.search(r'url\(["\']?(.*?)["\']?\)', style)
|
| 184 |
-
if bg_match:
|
| 185 |
-
img_src = bg_match.group(1)
|
| 186 |
-
if img_src.startswith("//"): img_src = "https:" + img_src
|
| 187 |
-
elif img_src.startswith("/"): img_src = "https://xemlaibongda.top" + img_src
|
| 188 |
break
|
| 189 |
p = p.parent if p else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
title = ""
|
|
|
|
| 191 |
for attr in ["title", "aria-label"]:
|
| 192 |
val = a.get(attr, "")
|
| 193 |
-
if val and len(val) >= 5:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
if not title:
|
| 195 |
for selector in ["h3", "h2", "h4", ".title", ".video-title", "strong"]:
|
| 196 |
try:
|
| 197 |
el = a.select_one(selector)
|
| 198 |
-
if el:
|
| 199 |
-
|
| 200 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
if not title:
|
| 202 |
text = a.get_text(strip=True)
|
| 203 |
-
if text and len(text) >= 5:
|
|
|
|
|
|
|
|
|
|
| 204 |
if not title or len(title) < 3:
|
| 205 |
slug = clean_href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
|
| 206 |
title = slug.replace("-", " ").replace("_", " ").title()
|
| 207 |
title = re.sub(r'\d{4}-\d{2}-\d{2}', '', title).strip()
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
if not img_src:
|
| 210 |
slug = clean_href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
|
| 211 |
img_src = f"https://xemlaibongda.top/uploads/thumb/{slug}.jpg"
|
| 212 |
-
|
| 213 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
return videos
|
| 215 |
except Exception as e:
|
| 216 |
-
print(f"[xemlaibongda] Error: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
|
| 218 |
def scrape_xemlaibongda(): return _scrape_xemlaibongda_page("", 20)
|
| 219 |
def scrape_highlights_by_league(league_key):
|
|
@@ -225,34 +422,95 @@ def scrape_all_league_highlights():
|
|
| 225 |
with ThreadPoolExecutor(8) as ex:
|
| 226 |
futs = [ex.submit(_fetch, k) for k in HL_LEAGUES]
|
| 227 |
for f in as_completed(futs, timeout=25):
|
| 228 |
-
try:
|
| 229 |
-
|
| 230 |
-
|
|
|
|
| 231 |
return results
|
| 232 |
|
| 233 |
def extract_xemlaibongda_video(url):
|
| 234 |
try:
|
| 235 |
r=requests.get(url, headers=HEADERS, timeout=15)
|
| 236 |
if r.status_code!=200: return None
|
| 237 |
-
r.encoding="utf-8"; soup=BeautifulSoup(r.text,"lxml")
|
| 238 |
-
og=soup.find("meta",property="og:image")
|
| 239 |
-
og_poster=og.get("content","") if og else ""
|
| 240 |
-
if og_poster.startswith("//"): og_poster="https:"+og_poster
|
| 241 |
-
video=soup.find("video")
|
| 242 |
if video:
|
| 243 |
src=video.get("src",""); poster=video.get("poster","")
|
| 244 |
if not src:
|
| 245 |
source=video.find("source")
|
| 246 |
if source: src=source.get("src","")
|
| 247 |
-
if not poster: poster=og_poster
|
| 248 |
if src: return{"src":src,"poster":poster,"type":"hls" if".m3u8" in src else"video"}
|
| 249 |
m3u8s=re.findall(r'(https?://[^\s"\'<>]+\.m3u8)',r.text)
|
| 250 |
-
if m3u8s:
|
| 251 |
-
|
| 252 |
-
|
| 253 |
return None
|
| 254 |
except: return None
|
| 255 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
# ===== LIVESCORE =====
|
| 257 |
@app.get("/api/livescore/live")
|
| 258 |
def api_livescore_live(): return JSONResponse({"html":_cached("ls_live",lambda:fetch_bongda_api("/api/fixtures/live"),ttl=_cache_ttl_live)})
|
|
@@ -305,6 +563,41 @@ def api_livescore_featured():
|
|
| 305 |
return None
|
| 306 |
return JSONResponse(_cached("ls_featured",_f,ttl=30))
|
| 307 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
@app.get("/api/highlights")
|
| 309 |
def api_highlights(): return JSONResponse(_cached("xemlaibongda_hl",scrape_xemlaibongda,ttl=_cache_ttl))
|
| 310 |
@app.get("/api/highlights/leagues")
|
|
@@ -315,7 +608,7 @@ def api_highlights_league(league:str):
|
|
| 315 |
return JSONResponse(_cached(f"hl_{league}",lambda:scrape_highlights_by_league(league),ttl=_cache_ttl))
|
| 316 |
|
| 317 |
@app.get("/api/video_url")
|
| 318 |
-
def api_video_url(url:str=Query(...)
|
| 319 |
if "youtube.com" in url or "youtu.be" in url:
|
| 320 |
m=re.search(r'(?:v=|shorts/|youtu\.be/)([a-zA-Z0-9_-]{11})',url)
|
| 321 |
if m: vid=m.group(1); return JSONResponse({"src":f"https://www.youtube.com/embed/{vid}?autoplay=1&rel=0&enablejsapi=1","poster":f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg","type":"youtube"})
|
|
@@ -323,39 +616,69 @@ def api_video_url(url:str=Query(...), img:str=Query(default="")):
|
|
| 323 |
v=extract_xemlaibongda_video(url)
|
| 324 |
if v:
|
| 325 |
if v["type"]=="hls": v["src"]="/api/proxy/m3u8?url="+quote(v["src"],safe="")
|
| 326 |
-
if not v.get("poster") and img: v["poster"] = img
|
| 327 |
return JSONResponse(v)
|
| 328 |
return JSONResponse({"error":"not found"})
|
| 329 |
|
| 330 |
# ===== WORLD CUP 2026 API =====
|
| 331 |
-
|
| 332 |
-
|
|
|
|
|
|
|
|
|
|
| 333 |
def _wc_rate_limit():
|
|
|
|
| 334 |
global _wc_request_times
|
| 335 |
with _wc_rate_limit_lock:
|
| 336 |
now = time.time()
|
|
|
|
| 337 |
_wc_request_times = [t for t in _wc_request_times if now - t < 60]
|
| 338 |
-
if len(_wc_request_times) >= _WC_RATE_LIMIT:
|
|
|
|
| 339 |
_wc_request_times.append(now)
|
| 340 |
return True
|
| 341 |
|
| 342 |
@app.get("/api/wc2026")
|
| 343 |
def api_wc2026():
|
|
|
|
| 344 |
return JSONResponse(_cached("wc2026", get_wc2026_all, ttl=_cache_ttl))
|
| 345 |
|
| 346 |
@app.get("/api/wc2026/{tab}")
|
| 347 |
def api_wc2026_tab(tab: str):
|
|
|
|
| 348 |
valid_tabs = ["news", "fixtures", "standings", "stats", "highlights"]
|
| 349 |
-
if tab not in valid_tabs:
|
|
|
|
|
|
|
| 350 |
def _fetch_tab():
|
| 351 |
-
if tab == "highlights":
|
| 352 |
-
|
| 353 |
-
elif tab == "
|
| 354 |
-
|
| 355 |
-
elif tab == "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
return []
|
|
|
|
| 357 |
return JSONResponse(_cached(f"wc2026_{tab}", _fetch_tab, ttl=_cache_ttl))
|
| 358 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
@app.get("/api/bdp_videos")
|
| 360 |
def api_bdp_videos():
|
| 361 |
def _f():
|
|
@@ -412,7 +735,8 @@ def scrape_genk_ai():
|
|
| 412 |
for img in container.find_all("img"):
|
| 413 |
s=img.get("data-src","") or img.get("src","")
|
| 414 |
if s and "mediacdn" in s and "avatar" not in s and "logo" not in s: img_src=s; break
|
| 415 |
-
if img_src: break
|
|
|
|
| 416 |
seen.add(href)
|
| 417 |
if not img_src:
|
| 418 |
try:
|
|
@@ -455,57 +779,9 @@ def api_categories():
|
|
| 455 |
for k,(u,n) in VNE_CATS.items(): cats.append({"id":k,"name":n,"source":"vne"})
|
| 456 |
return JSONResponse(cats)
|
| 457 |
|
| 458 |
-
@app.get("/api/proxy/xlb")
|
| 459 |
-
def api_xlb(path: str = Query(default=""), limit: int = Query(default=20)):
|
| 460 |
-
try:
|
| 461 |
-
url = f"https://xemlaibongda.top/{path}" if path else "https://xemlaibongda.top/"
|
| 462 |
-
r = requests.get(url, headers=HEADERS, timeout=15)
|
| 463 |
-
if r.status_code != 200: return JSONResponse({"videos": []})
|
| 464 |
-
r.encoding = "utf-8"
|
| 465 |
-
soup = BeautifulSoup(r.text, "lxml")
|
| 466 |
-
videos, seen = [], set()
|
| 467 |
-
for a in soup.find_all("a", href=True):
|
| 468 |
-
href = a.get("href", "")
|
| 469 |
-
if "/video/" not in href and "/xem-lai/" not in href: continue
|
| 470 |
-
if not href.startswith("http"): href = "https://xemlaibongda.top" + href
|
| 471 |
-
clean = href.split("?")[0].split("#")[0]
|
| 472 |
-
if clean in seen: continue
|
| 473 |
-
seen.add(clean)
|
| 474 |
-
img_src = ""
|
| 475 |
-
img = a.find("img") or (a.parent.find("img") if a.parent else None)
|
| 476 |
-
if not img:
|
| 477 |
-
p = a.parent
|
| 478 |
-
for _ in range(5):
|
| 479 |
-
if p and p.find("img"): img = p.find("img"); break
|
| 480 |
-
p = p.parent if p else None
|
| 481 |
-
if img:
|
| 482 |
-
img_src = (img.get("data-src", "") or img.get("src", "") or img.get("data-lazy", "") or img.get("data-original", ""))
|
| 483 |
-
if img_src.startswith("//"): img_src = "https:" + img_src
|
| 484 |
-
elif img_src.startswith("/"): img_src = "https://xemlaibongda.top" + img_src
|
| 485 |
-
title = a.find("h3")
|
| 486 |
-
if not title: title = a.find("h2")
|
| 487 |
-
if not title: title = a.find("strong")
|
| 488 |
-
t = title.get_text(strip=True) if title else ""
|
| 489 |
-
if not t:
|
| 490 |
-
slug = clean.split("/video/")[-1].rstrip("/")
|
| 491 |
-
t = slug.replace("-", " ").title()
|
| 492 |
-
videos.append({"title": t[:100], "link": clean, "img": img_src, "source": "xemlaibongda"})
|
| 493 |
-
if len(videos) >= limit: break
|
| 494 |
-
return JSONResponse({"videos": videos})
|
| 495 |
-
except Exception as e:
|
| 496 |
-
return JSONResponse({"videos": [], "error": str(e)})
|
| 497 |
-
|
| 498 |
@app.get("/api/article")
|
| 499 |
def api_article(url:str=Query(...)):
|
| 500 |
-
|
| 501 |
-
r2 = requests.get(url, headers=HEADERS, timeout=10)
|
| 502 |
-
if r2.status_code == 200:
|
| 503 |
-
r2.encoding = "utf-8"
|
| 504 |
-
soup = BeautifulSoup(r2.text, "lxml")
|
| 505 |
-
og = soup.find("meta", property="og:image")
|
| 506 |
-
return JSONResponse({"og_image": og.get("content", "") if og else ""})
|
| 507 |
-
except: pass
|
| 508 |
-
return JSONResponse({"og_image": ""})
|
| 509 |
|
| 510 |
@app.get("/api/storage_status")
|
| 511 |
def api_storage_status():
|
|
@@ -517,4 +793,5 @@ def api_hot_topics():
|
|
| 517 |
|
| 518 |
@app.get("/", response_class=HTMLResponse)
|
| 519 |
async def root():
|
| 520 |
-
return HTMLResponse("<h1>VNEWS
|
|
|
|
|
|
| 1 |
+
"""VNEWS - FastAPI backend with livescore + xemlaibongda highlights + YouTube VTV shorts"""
|
| 2 |
import re, time, subprocess, json, os, threading
|
| 3 |
import html as html_lib
|
| 4 |
from datetime import datetime, timezone, timedelta
|
| 5 |
from collections import defaultdict
|
| 6 |
|
| 7 |
+
VN_TZ = timezone(timedelta(hours=7))
|
| 8 |
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 9 |
from fastapi import FastAPI, Query, Request
|
| 10 |
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, Response
|
|
|
|
| 14 |
|
| 15 |
app = FastAPI()
|
| 16 |
|
| 17 |
+
# ===== RATE LIMITING =====app = FastAPI()
|
| 18 |
+
|
| 19 |
# ===== WORLD CUP 2026 SCRAPER =====
|
| 20 |
from wc2026_scraper import get_wc2026_all, scrape_fixtures, scrape_standings, scrape_stats, scrape_wc_news
|
| 21 |
|
| 22 |
# ===== RATE LIMITING =====
|
| 23 |
+
_rate_limit_data = defaultdict(list) # {ip: [timestamp1, timestamp2, ...]}
|
| 24 |
_rate_limit_lock = threading.Lock()
|
| 25 |
+
RATE_LIMIT_MAX = 60 # Max requests per minute per IP
|
| 26 |
+
RATE_LIMIT_WINDOW = 60 # seconds
|
| 27 |
|
| 28 |
def _check_rate_limit(ip: str) -> bool:
|
| 29 |
+
"""Kiểm tra rate limit, return True nếu OK, False nếu bị limit"""
|
| 30 |
with _rate_limit_lock:
|
| 31 |
now = time.time()
|
| 32 |
+
# Xóa các request cũ
|
| 33 |
_rate_limit_data[ip] = [t for t in _rate_limit_data[ip] if now - t < RATE_LIMIT_WINDOW]
|
| 34 |
+
if len(_rate_limit_data[ip]) >= RATE_LIMIT_MAX:
|
| 35 |
+
return False
|
| 36 |
_rate_limit_data[ip].append(now)
|
| 37 |
return True
|
| 38 |
|
| 39 |
@app.middleware("http")
|
| 40 |
async def rate_limit_middleware(request: Request, call_next):
|
| 41 |
+
"""Middleware để kiểm tra rate limit"""
|
| 42 |
+
# Chỉ rate limit API endpoints
|
| 43 |
if request.url.path.startswith("/api/"):
|
| 44 |
ip = request.client.host
|
| 45 |
+
if not _check_rate_limit(ip):
|
| 46 |
+
return JSONResponse({"error": "rate limit exceeded"}, status_code=429)
|
| 47 |
+
response = await call_next(request)
|
| 48 |
+
return response
|
| 49 |
|
| 50 |
# ===== VTV CHANNELS API =====
|
| 51 |
from vtv_api import router as vtv_router
|
|
|
|
| 59 |
_cache_ttl_live = 60
|
| 60 |
_cache_ttl_yt = 1800
|
| 61 |
|
| 62 |
+
# ===== VTV NAM BO SHORTS FALLBACK =====
|
| 63 |
+
SHORTS_FALLBACK = [
|
| 64 |
+
{"id":"nqlLH6chLRo","title":"Tin nóng VTV Nam Bộ | #shorts","channel":"vtvnambo"},
|
| 65 |
+
{"id":"E7Kq0v3hG6w","title":"VTV Nam Bộ - Tin tức miền Nam | #shorts","channel":"vtvnambo"},
|
| 66 |
+
{"id":"Lu_iCQ5YwNM","title":"Công an lập hồ sơ xử lý người phụ nữ chửi bới tát nam tài xế ô tô ở Hà Nội","channel":"baodantri7941"},
|
| 67 |
+
{"id":"CwWvijF8BOA","title":"Chú rể Ninh Bình bật khóc nhận món quà bí mật người cha quá cố gửi 26 năm trước","channel":"baodantri7941"},
|
| 68 |
+
{"id":"tvPewsc2ph4","title":"Tính năng ẩn trên iPhone giúp giảm mỏi mắt","channel":"baodantri7941"},
|
| 69 |
+
{"id":"b1Nxzv9ixlU","title":"Y án 3 năm tù với nữ tài xế uống 8 lon bia lái xe tông chủ tịch xã tử vong","channel":"baodantri7941"},
|
| 70 |
+
{"id":"Xp5eTwAZAis","title":"Người đánh hàng xóm tại chung cư ở Hà Nội bị tuyên hơn 4 tháng tù","channel":"baodantri7941"},
|
| 71 |
+
{"id":"Htzvwg6iOBM","title":"Xe điện Audi S6 Sportback e-tron có gì đặc biệt?","channel":"baodantri7941"},
|
| 72 |
+
{"id":"iMdFmWvYdlo","title":"Cô gái người Nga yêu thời trang và đất nước Việt Nam","channel":"baodantri7941"},
|
| 73 |
+
{"id":"IVaRc6moEv8","title":"Người nông dân Trung Quốc đột quỵ bệnh viện giúp bán sạch 4 tấn táo","channel":"baodantri7941"},
|
| 74 |
+
{"id":"uVxqPxToItU","title":"Công an vào cuộc vụ người phụ nữ chửi bới hành hung tài xế ô tô ở Hà Nội","channel":"baodantri7941"},
|
| 75 |
+
{"id":"VAfgNNgZDRs","title":"Khởi tố 4 đối tượng ném bom xăng vào nhà dân ở Đồng Nai","channel":"baodantri7941"},
|
| 76 |
+
{"id":"sBH_-zGh0Xw","title":"Vì sao Times New Roman vẫn nổi tiếng sau hàng chục năm?","channel":"baodantri7941"},
|
| 77 |
+
{"id":"woKn5f2bLHM","title":"Quảng Ninh ngập sâu diện rộng sau đợt mưa lớn","channel":"baodantri7941"},
|
| 78 |
+
{"id":"bcpgRoxbLPw","title":"Giông lốc quật bay mái tôn ở TP.HCM","channel":"baodantri7941"},
|
| 79 |
+
{"id":"ZIIC5osy544","title":"Bé trai Trung Quốc rơi từ tầng 11 vẫn sống sót kỳ diệu","channel":"baodantri7941"},
|
| 80 |
+
{"id":"uTMJ49NQpyc","title":"Sau lớp mascot 40kg Câu chuyện mưu sinh của người trẻ ở TPHCM","channel":"baodantri7941"},
|
| 81 |
+
{"id":"7Pd6vZ2Lz1M","title":"Hành động ấm lòng của người đàn ông tìm kiếm 5 học sinh tử vong ở sông Lô","channel":"baosuckhoedoisongboyte"},
|
| 82 |
+
{"id":"SlHLt_ZyPiE","title":"Xử phạt người đàn ông xóa số điện thoại cứu hộ trên cao tốc Bắc Nam","channel":"baosuckhoedoisongboyte"},
|
| 83 |
+
{"id":"IUOprcJyYr4","title":"Phụ nữ táo bón có phải do lười ăn rau?","channel":"baosuckhoedoisongboyte"},
|
| 84 |
+
{"id":"YY8ojFNE-AU","title":"Quái xế tự quay clip nẹt pô đánh võng đăng TikTok bị xử lý","channel":"baosuckhoedoisongboyte"},
|
| 85 |
+
{"id":"OV7_oGdQGII","title":"Bố cô dâu khóc sụt sùi rồi quẩy cực sung gây bão mạng","channel":"baosuckhoedoisongboyte"},
|
| 86 |
+
{"id":"FoxhFyz2skY","title":"Người đàn ông nước ngoài đập phá ô tô bẻ cần gạt nước ở Đà Nẵng","channel":"baosuckhoedoisongboyte"},
|
| 87 |
+
{"id":"R1oC_I8dFPU","title":"Thanh niên buông tay lái đứng trên xe máy khi đổ đèo ở Đắk Lắk","channel":"baosuckhoedoisongboyte"},
|
| 88 |
+
{"id":"U0Ft6ChWAIo","title":"Cô giáo kể phút tháo chạy khỏi xe khách trước khi bị lũ vò nát ở Cao Bằng","channel":"baosuckhoedoisongboyte"},
|
| 89 |
+
{"id":"hH0ANeze_4E","title":"Liên tiếp hàng chục con bò bị sét đánh chết trong ngày mưa dông","channel":"baosuckhoedoisongboyte"},
|
| 90 |
+
{"id":"pXWt0QbAzRQ","title":"Va chạm giao thông người phụ nữ lăng mạ tài xế ô tô","channel":"baosuckhoedoisongboyte"},
|
| 91 |
+
{"id":"UWWLPY1OYt4","title":"CSGT chặn xe khách khống chế đối tượng cướp dây chuyền tại Gia Lai","channel":"baosuckhoedoisongboyte"},
|
| 92 |
+
{"id":"AxhVTQutsuo","title":"Xuất tinh sớm và những hiểu lầm thường gặp","channel":"baosuckhoedoisongboyte"},
|
| 93 |
+
{"id":"cNy6FgaNxYM","title":"Cô dâu khóc sưng mắt vì 6 chỉ vàng không cánh mày bay trong ngày cưới","channel":"baosuckhoedoisongboyte"},
|
| 94 |
+
{"id":"IDt_S6q59Ro","title":"Chở bạn gái không đội mũ bảo hiểm thanh niên đấm CSGT","channel":"baosuckhoedoisongboyte"},
|
| 95 |
+
{"id":"LFxJ9Ik6W0A","title":"Mệnh lệnh từ trái tim CSGT Hà Nội mở đường đưa bé 5 tháng tuổi đi cấp cứu","channel":"baosuckhoedoisongboyte"},
|
| 96 |
+
]
|
| 97 |
+
for _v in SHORTS_FALLBACK:
|
| 98 |
+
_v.setdefault("link", "https://www.youtube.com/watch?v="+_v["id"])
|
| 99 |
+
_v.setdefault("img", "https://i.ytimg.com/vi/"+_v["id"]+"/hqdefault.jpg")
|
| 100 |
+
_v.setdefault("source", "yt")
|
| 101 |
+
|
| 102 |
+
SHORT_STATS_FILE = "/data/short_stats.json" if os.path.isdir("/data") else "/app/short_stats.json"
|
| 103 |
+
_short_lock = threading.Lock()
|
| 104 |
+
def _load_short_db():
|
| 105 |
+
try:
|
| 106 |
+
if os.path.exists(SHORT_STATS_FILE):
|
| 107 |
+
with open(SHORT_STATS_FILE,"r",encoding="utf-8") as f: return json.load(f)
|
| 108 |
+
except: pass
|
| 109 |
+
return {}
|
| 110 |
+
def _save_short_db(db):
|
| 111 |
+
try:
|
| 112 |
+
os.makedirs(os.path.dirname(SHORT_STATS_FILE), exist_ok=True)
|
| 113 |
+
tmp = SHORT_STATS_FILE + ".tmp"
|
| 114 |
+
with open(tmp,"w",encoding="utf-8") as f: json.dump(db, f, ensure_ascii=False)
|
| 115 |
+
os.replace(tmp, SHORT_STATS_FILE)
|
| 116 |
+
except: pass
|
| 117 |
+
def _short_default(): return {"views":0,"likes":0,"shares":0,"comments":[]}
|
| 118 |
+
|
| 119 |
PRIORITY_LEAGUES = ["Ngoại Hạng Anh","FA Cup","Champions League","LaLiga","Copa del Rey","Serie A","Bundesliga","Ligue 1","V-League"]
|
| 120 |
LEAGUE_IDS = {"nha":27110,"laliga":27233,"seriea":27044,"bundesliga":26891,"ligue1":27212}
|
| 121 |
HL_LEAGUES = {
|
|
|
|
| 202 |
@app.get("/api/proxy/img")
|
| 203 |
def proxy_img(url: str = Query(...)):
|
| 204 |
try:
|
| 205 |
+
r = requests.get(url, headers={**HEADERS, "Referer": "https://dantri.com.vn/"}, timeout=10)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
if r.status_code != 200: return Response(status_code=502)
|
| 207 |
+
ct = r.headers.get("Content-Type", "image/jpeg")
|
| 208 |
+
return Response(content=r.content, media_type=ct, headers={"Cache-Control": "public, max-age=86400", "Access-Control-Allow-Origin": "*"})
|
| 209 |
except: return Response(status_code=502)
|
| 210 |
|
| 211 |
# ===== XEMLAIBONGDA HIGHLIGHTS =====
|
| 212 |
def _scrape_xemlaibongda_page(page_path, limit=20):
|
| 213 |
+
"""
|
| 214 |
+
Scrape video từ xemlaibongda.top - Simple & Reliable
|
| 215 |
+
Dùng logic cũ đã test, không fetch từng trang (tránh timeout)
|
| 216 |
+
"""
|
| 217 |
try:
|
| 218 |
url = f"https://xemlaibongda.top/{page_path}" if page_path else "https://xemlaibongda.top/"
|
| 219 |
r = requests.get(url, headers=HEADERS, timeout=15)
|
| 220 |
+
if r.status_code != 200:
|
| 221 |
+
return []
|
| 222 |
r.encoding = "utf-8"
|
| 223 |
soup = BeautifulSoup(r.text, "lxml")
|
| 224 |
+
videos = []
|
| 225 |
+
seen = set()
|
| 226 |
+
|
| 227 |
for a in soup.find_all("a", href=True):
|
| 228 |
href = a.get("href", "")
|
| 229 |
+
if "/video/" not in href and "/xem-lai/" not in href:
|
| 230 |
+
continue
|
| 231 |
+
|
| 232 |
+
if not href.startswith("http"):
|
| 233 |
+
href = "https://xemlaibongda.top" + href
|
| 234 |
+
|
| 235 |
+
# Bỏ query params
|
| 236 |
clean_href = href.split("?")[0].split("#")[0]
|
| 237 |
+
if clean_href in seen:
|
| 238 |
+
continue
|
| 239 |
seen.add(clean_href)
|
| 240 |
+
|
| 241 |
+
# ===== Lấy THUMBNAIL =====
|
| 242 |
img_src = ""
|
| 243 |
img = a.find("img")
|
| 244 |
+
if not img and a.parent:
|
| 245 |
+
img = a.parent.find("img")
|
| 246 |
if not img:
|
| 247 |
p = a.parent
|
| 248 |
for _ in range(4):
|
| 249 |
+
if p and p.find("img"):
|
| 250 |
+
img = p.find("img")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
break
|
| 252 |
p = p.parent if p else None
|
| 253 |
+
|
| 254 |
+
if img:
|
| 255 |
+
img_src = (img.get("data-src", "") or img.get("src", "") or
|
| 256 |
+
img.get("data-lazy", "") or img.get("data-original", ""))
|
| 257 |
+
if img_src.startswith("//"):
|
| 258 |
+
img_src = "https:" + img_src
|
| 259 |
+
elif img_src.startswith("/"):
|
| 260 |
+
img_src = "https://xemlaibongda.top" + img_src
|
| 261 |
+
|
| 262 |
+
# ===== Lấy TITLE =====
|
| 263 |
title = ""
|
| 264 |
+
# Thử attribute
|
| 265 |
for attr in ["title", "aria-label"]:
|
| 266 |
val = a.get(attr, "")
|
| 267 |
+
if val and len(val) >= 5:
|
| 268 |
+
title = val
|
| 269 |
+
break
|
| 270 |
+
|
| 271 |
+
# Thử các selector
|
| 272 |
if not title:
|
| 273 |
for selector in ["h3", "h2", "h4", ".title", ".video-title", "strong"]:
|
| 274 |
try:
|
| 275 |
el = a.select_one(selector)
|
| 276 |
+
if el:
|
| 277 |
+
t = el.get_text(strip=True)
|
| 278 |
+
if len(t) >= 5:
|
| 279 |
+
title = t
|
| 280 |
+
break
|
| 281 |
+
except:
|
| 282 |
+
pass
|
| 283 |
+
|
| 284 |
+
# Thử text content
|
| 285 |
if not title:
|
| 286 |
text = a.get_text(strip=True)
|
| 287 |
+
if text and len(text) >= 5:
|
| 288 |
+
title = text[:100]
|
| 289 |
+
|
| 290 |
+
# Fallback: tạo title từ slug
|
| 291 |
if not title or len(title) < 3:
|
| 292 |
slug = clean_href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
|
| 293 |
title = slug.replace("-", " ").replace("_", " ").title()
|
| 294 |
title = re.sub(r'\d{4}-\d{2}-\d{2}', '', title).strip()
|
| 295 |
+
|
| 296 |
+
if not title or len(title) < 3:
|
| 297 |
+
continue
|
| 298 |
+
|
| 299 |
+
# Fallback thumbnail từ slug
|
| 300 |
if not img_src:
|
| 301 |
slug = clean_href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
|
| 302 |
img_src = f"https://xemlaibongda.top/uploads/thumb/{slug}.jpg"
|
| 303 |
+
|
| 304 |
+
videos.append({
|
| 305 |
+
"title": title[:100],
|
| 306 |
+
"link": clean_href,
|
| 307 |
+
"img": img_src,
|
| 308 |
+
"source": "xemlaibongda"
|
| 309 |
+
})
|
| 310 |
+
|
| 311 |
+
if len(videos) >= limit:
|
| 312 |
+
break
|
| 313 |
+
|
| 314 |
return videos
|
| 315 |
except Exception as e:
|
| 316 |
+
print(f"[xemlaibongda] Error: {e}")
|
| 317 |
+
return []
|
| 318 |
+
|
| 319 |
+
def _extract_img_src(a_tag):
|
| 320 |
+
"""Extract image URL từ thẻ <a> và parent elements"""
|
| 321 |
+
img = a_tag.find("img")
|
| 322 |
+
if not img and a_tag.parent:
|
| 323 |
+
img = a_tag.parent.find("img")
|
| 324 |
+
if not img:
|
| 325 |
+
p = a_tag.parent
|
| 326 |
+
for _ in range(5): # Tìm sâu hơn
|
| 327 |
+
if p and p.find("img"):
|
| 328 |
+
img = p.find("img")
|
| 329 |
+
break
|
| 330 |
+
p = p.parent if p else None
|
| 331 |
+
|
| 332 |
+
if not img:
|
| 333 |
+
return ""
|
| 334 |
+
|
| 335 |
+
# Thử tất cả các attribute có thể chứa img URL
|
| 336 |
+
attrs = ["data-src", "src", "data-lazy", "data-original", "data-srcset", "data-thumb", "data-image"]
|
| 337 |
+
for attr in attrs:
|
| 338 |
+
val = img.get(attr, "")
|
| 339 |
+
if val:
|
| 340 |
+
if attr == "data-srcset":
|
| 341 |
+
val = val.split(",")[0].strip().split(" ")[0]
|
| 342 |
+
break
|
| 343 |
+
else:
|
| 344 |
+
val = ""
|
| 345 |
+
|
| 346 |
+
# Thử background-image từ style
|
| 347 |
+
if not val:
|
| 348 |
+
style = img.get("style", "") or img.get("data-bg", "")
|
| 349 |
+
bg_match = re.search(r'url\(["\']?(.*?)["\']?\)', style)
|
| 350 |
+
if bg_match:
|
| 351 |
+
val = bg_match.group(1)
|
| 352 |
+
|
| 353 |
+
# Normalize URL
|
| 354 |
+
if val.startswith("//"):
|
| 355 |
+
val = "https:" + val
|
| 356 |
+
elif val.startswith("/"):
|
| 357 |
+
val = "https://xemlaibongda.top" + val
|
| 358 |
+
|
| 359 |
+
return val
|
| 360 |
+
|
| 361 |
+
def _extract_title(a_tag, href):
|
| 362 |
+
"""Extract title từ thẻ <a> và child/parent elements"""
|
| 363 |
+
title = ""
|
| 364 |
+
|
| 365 |
+
# 1. Thử các selector phổ biến cho title
|
| 366 |
+
title_selectors = [
|
| 367 |
+
"h3", "h2", "h4", "h5",
|
| 368 |
+
".title", ".post-title", ".entry-title", ".video-title",
|
| 369 |
+
".card-title", ".item-title", ".news-title",
|
| 370 |
+
"span.title", "strong", "b",
|
| 371 |
+
".name", ".caption"
|
| 372 |
+
]
|
| 373 |
+
for tag in title_selectors:
|
| 374 |
+
try:
|
| 375 |
+
t = a_tag.select_one(tag) if hasattr(a_tag, 'select_one') else None
|
| 376 |
+
if t:
|
| 377 |
+
title = t.get_text(" ", strip=True)
|
| 378 |
+
if len(title) >= 3:
|
| 379 |
+
return title
|
| 380 |
+
except:
|
| 381 |
+
pass
|
| 382 |
+
|
| 383 |
+
# 2. Thử attribute của <a>
|
| 384 |
+
for attr in ["title", "aria-label", "data-title"]:
|
| 385 |
+
val = a_tag.get(attr, "")
|
| 386 |
+
if val and len(val) >= 3:
|
| 387 |
+
return val
|
| 388 |
+
|
| 389 |
+
# 3. Thử alt text của img
|
| 390 |
+
img = a_tag.find("img")
|
| 391 |
+
if img:
|
| 392 |
+
alt = img.get("alt", "")
|
| 393 |
+
if alt and len(alt) >= 3:
|
| 394 |
+
return alt
|
| 395 |
+
|
| 396 |
+
# 4. Thử text content của <a> (loại bỏ quá dài)
|
| 397 |
+
text = a_tag.get_text(" ", strip=True)
|
| 398 |
+
if text and len(text) >= 3:
|
| 399 |
+
# Lấy dòng đầu tiên nếu có nhiều dòng
|
| 400 |
+
lines = [l.strip() for l in text.split("\n") if l.strip()]
|
| 401 |
+
if lines:
|
| 402 |
+
first_line = lines[0]
|
| 403 |
+
if len(first_line) >= 3:
|
| 404 |
+
return first_line[:100]
|
| 405 |
+
|
| 406 |
+
# 5. Fallback: tạo title từ slug
|
| 407 |
+
slug = href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
|
| 408 |
+
title = slug.replace("-", " ").replace("_", " ")
|
| 409 |
+
title = re.sub(r'\d{4}-\d{2}-\d{2}', '', title).strip()
|
| 410 |
+
if title:
|
| 411 |
+
return title.title()
|
| 412 |
+
|
| 413 |
+
return ""
|
| 414 |
|
| 415 |
def scrape_xemlaibongda(): return _scrape_xemlaibongda_page("", 20)
|
| 416 |
def scrape_highlights_by_league(league_key):
|
|
|
|
| 422 |
with ThreadPoolExecutor(8) as ex:
|
| 423 |
futs = [ex.submit(_fetch, k) for k in HL_LEAGUES]
|
| 424 |
for f in as_completed(futs, timeout=25):
|
| 425 |
+
try:
|
| 426 |
+
key, vids = f.result()
|
| 427 |
+
if vids: results[key] = vids
|
| 428 |
+
except: pass
|
| 429 |
return results
|
| 430 |
|
| 431 |
def extract_xemlaibongda_video(url):
|
| 432 |
try:
|
| 433 |
r=requests.get(url, headers=HEADERS, timeout=15)
|
| 434 |
if r.status_code!=200: return None
|
| 435 |
+
r.encoding="utf-8"; soup=BeautifulSoup(r.text,"lxml"); video=soup.find("video")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 436 |
if video:
|
| 437 |
src=video.get("src",""); poster=video.get("poster","")
|
| 438 |
if not src:
|
| 439 |
source=video.find("source")
|
| 440 |
if source: src=source.get("src","")
|
|
|
|
| 441 |
if src: return{"src":src,"poster":poster,"type":"hls" if".m3u8" in src else"video"}
|
| 442 |
m3u8s=re.findall(r'(https?://[^\s"\'<>]+\.m3u8)',r.text)
|
| 443 |
+
if m3u8s:
|
| 444 |
+
og=soup.find("meta",property="og:image"); poster=og.get("content","") if og else ""
|
| 445 |
+
return{"src":m3u8s[0],"poster":poster,"type":"hls"}
|
| 446 |
return None
|
| 447 |
except: return None
|
| 448 |
|
| 449 |
+
# ===== YOUTUBE SHORTS SCRAPING =====
|
| 450 |
+
def _yt_channel_shorts_requests(channel, count=15):
|
| 451 |
+
try:
|
| 452 |
+
url=f"https://www.youtube.com/@{channel}/shorts"
|
| 453 |
+
r=requests.get(url, headers={**HEADERS,"Accept-Language":"vi,en;q=0.8"}, timeout=15)
|
| 454 |
+
if r.status_code!=200: return []
|
| 455 |
+
html=r.text; ids=[]; items=[]
|
| 456 |
+
for m in re.finditer(r'"videoId":"([A-Za-z0-9_-]{11})"',html):
|
| 457 |
+
vid=m.group(1)
|
| 458 |
+
if vid in ids: continue
|
| 459 |
+
ids.append(vid)
|
| 460 |
+
snip=html[max(0,m.start()-900):m.start()+1600]
|
| 461 |
+
title=""
|
| 462 |
+
mt=re.search(r'"title":\{"runs":\[\{"text":"([^"]+)"',snip)
|
| 463 |
+
if not mt: mt=re.search(r'"accessibilityText":"([^"]+)"',snip)
|
| 464 |
+
if mt: title=html_lib.unescape(mt.group(1)).replace('\n',' ').strip()
|
| 465 |
+
if not title: title="YouTube Short"
|
| 466 |
+
items.append({"title":title,"link":f"https://www.youtube.com/watch?v={vid}","img":f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg","source":"yt","id":vid,"channel":channel})
|
| 467 |
+
if len(items)>=count: break
|
| 468 |
+
return items
|
| 469 |
+
except: return []
|
| 470 |
+
|
| 471 |
+
def scrape_shorts():
|
| 472 |
+
vids=[]
|
| 473 |
+
with ThreadPoolExecutor(3) as ex:
|
| 474 |
+
futs=[ex.submit(_yt_channel_shorts_requests,ch,24) for ch in ["baodantri7941","baosuckhoedoisongboyte","vtvnambo"]]
|
| 475 |
+
for f in as_completed(futs):
|
| 476 |
+
try:
|
| 477 |
+
r=f.result()
|
| 478 |
+
if r: vids.extend(r)
|
| 479 |
+
except: pass
|
| 480 |
+
merged=[]; seen=set()
|
| 481 |
+
for v in vids:
|
| 482 |
+
vid=v.get("id")
|
| 483 |
+
if not vid or vid in seen: continue
|
| 484 |
+
seen.add(vid); merged.append(v)
|
| 485 |
+
for v in SHORTS_FALLBACK:
|
| 486 |
+
vid=v.get("id")
|
| 487 |
+
if not vid or vid in seen: continue
|
| 488 |
+
seen.add(vid); merged.append(v)
|
| 489 |
+
return merged[:60]
|
| 490 |
+
|
| 491 |
+
# ===== VTV NAM BO & WC SHORTS - using yt-dlp =====
|
| 492 |
+
from yt_scraper import get_vtvnambo_shorts, get_wc_related_shorts
|
| 493 |
+
|
| 494 |
+
@app.get("/api/shorts/vtvnamo")
|
| 495 |
+
def api_shorts_vtvnamo(count: int = Query(default=50, le=100)):
|
| 496 |
+
items = get_vtvnambo_shorts(count)
|
| 497 |
+
if not items:
|
| 498 |
+
items = [v for v in SHORTS_FALLBACK if v.get("channel") == "vtvnambo"]
|
| 499 |
+
nql = [v for v in items if v.get("id") == "nqlLH6chLRo"]
|
| 500 |
+
rest = [v for v in items if v.get("id") != "nqlLH6chLRo"]
|
| 501 |
+
items = nql + rest
|
| 502 |
+
return JSONResponse(items)
|
| 503 |
+
|
| 504 |
+
@app.get("/api/shorts/wc")
|
| 505 |
+
def api_shorts_wc(count: int = Query(default=50, le=100)):
|
| 506 |
+
items = get_wc_related_shorts(count)
|
| 507 |
+
if not items:
|
| 508 |
+
items = [v for v in SHORTS_FALLBACK if v.get("channel") == "vtvnambo"]
|
| 509 |
+
nql = [v for v in items if v.get("id") == "nqlLH6chLRo"]
|
| 510 |
+
rest = [v for v in items if v.get("id") != "nqlLH6chLRo"]
|
| 511 |
+
items = nql + rest
|
| 512 |
+
return JSONResponse(items)
|
| 513 |
+
|
| 514 |
# ===== LIVESCORE =====
|
| 515 |
@app.get("/api/livescore/live")
|
| 516 |
def api_livescore_live(): return JSONResponse({"html":_cached("ls_live",lambda:fetch_bongda_api("/api/fixtures/live"),ttl=_cache_ttl_live)})
|
|
|
|
| 563 |
return None
|
| 564 |
return JSONResponse(_cached("ls_featured",_f,ttl=30))
|
| 565 |
|
| 566 |
+
@app.get("/api/shorts")
|
| 567 |
+
def api_shorts(channel: str = Query(default="")):
|
| 568 |
+
if channel == "vtvnambo": return api_shorts_vtvnamo()
|
| 569 |
+
if channel == "wc": return api_shorts_wc()
|
| 570 |
+
return JSONResponse(_cached("yt_shorts_v3",scrape_shorts,ttl=_cache_ttl_yt))
|
| 571 |
+
|
| 572 |
+
@app.get("/api/short-stats")
|
| 573 |
+
def api_short_stats(ids:str=Query(default="")):
|
| 574 |
+
arr=[x for x in ids.split(",") if x]
|
| 575 |
+
with _short_lock:
|
| 576 |
+
db=_load_short_db();out={}
|
| 577 |
+
for vid in arr:
|
| 578 |
+
st=db.get(vid) or _short_default()
|
| 579 |
+
out[vid]={"views":int(st.get("views",0)),"likes":int(st.get("likes",0)),"shares":int(st.get("shares",0)),"comments":st.get("comments",[])[:80]}
|
| 580 |
+
return JSONResponse({"stats":out})
|
| 581 |
+
|
| 582 |
+
@app.post("/api/short-action")
|
| 583 |
+
async def api_short_action(request:Request):
|
| 584 |
+
try: body=await request.json()
|
| 585 |
+
except: body={}
|
| 586 |
+
vid=str(body.get("id","")).strip(); action=str(body.get("action","")).strip(); txt=str(body.get("text","")).strip()
|
| 587 |
+
if not vid: return JSONResponse({"error":"missing id"},status_code=400)
|
| 588 |
+
with _short_lock:
|
| 589 |
+
db=_load_short_db(); st=db.get(vid) or _short_default()
|
| 590 |
+
if action=="view": st["views"]=int(st.get("views",0))+1
|
| 591 |
+
elif action=="like": st["likes"]=int(st.get("likes",0))+1
|
| 592 |
+
elif action=="share": st["shares"]=int(st.get("shares",0))+1
|
| 593 |
+
elif action=="comment" and txt:
|
| 594 |
+
comments=st.get("comments",[])
|
| 595 |
+
comments.insert(0,{"text":txt[:180],"ts":int(time.time())})
|
| 596 |
+
st["comments"]=comments[:80]
|
| 597 |
+
st["updated"]=int(time.time()); db[vid]=st; _save_short_db(db)
|
| 598 |
+
out={"views":int(st.get("views",0)),"likes":int(st.get("likes",0)),"shares":int(st.get("shares",0)),"comments":st.get("comments",[])[:80]}
|
| 599 |
+
return JSONResponse({"stats":out})
|
| 600 |
+
|
| 601 |
@app.get("/api/highlights")
|
| 602 |
def api_highlights(): return JSONResponse(_cached("xemlaibongda_hl",scrape_xemlaibongda,ttl=_cache_ttl))
|
| 603 |
@app.get("/api/highlights/leagues")
|
|
|
|
| 608 |
return JSONResponse(_cached(f"hl_{league}",lambda:scrape_highlights_by_league(league),ttl=_cache_ttl))
|
| 609 |
|
| 610 |
@app.get("/api/video_url")
|
| 611 |
+
def api_video_url(url:str=Query(...)):
|
| 612 |
if "youtube.com" in url or "youtu.be" in url:
|
| 613 |
m=re.search(r'(?:v=|shorts/|youtu\.be/)([a-zA-Z0-9_-]{11})',url)
|
| 614 |
if m: vid=m.group(1); return JSONResponse({"src":f"https://www.youtube.com/embed/{vid}?autoplay=1&rel=0&enablejsapi=1","poster":f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg","type":"youtube"})
|
|
|
|
| 616 |
v=extract_xemlaibongda_video(url)
|
| 617 |
if v:
|
| 618 |
if v["type"]=="hls": v["src"]="/api/proxy/m3u8?url="+quote(v["src"],safe="")
|
|
|
|
| 619 |
return JSONResponse(v)
|
| 620 |
return JSONResponse({"error":"not found"})
|
| 621 |
|
| 622 |
# ===== WORLD CUP 2026 API =====
|
| 623 |
+
# Rate limiting cho WC API
|
| 624 |
+
_wc_request_times = []
|
| 625 |
+
_wc_rate_limit_lock = threading.Lock()
|
| 626 |
+
_WC_RATE_LIMIT = 10 # Max 10 requests per minute
|
| 627 |
+
|
| 628 |
def _wc_rate_limit():
|
| 629 |
+
"""Kiểm tra rate limit cho WC API"""
|
| 630 |
global _wc_request_times
|
| 631 |
with _wc_rate_limit_lock:
|
| 632 |
now = time.time()
|
| 633 |
+
# Xóa các request cũ hơn 60 giây
|
| 634 |
_wc_request_times = [t for t in _wc_request_times if now - t < 60]
|
| 635 |
+
if len(_wc_request_times) >= _WC_RATE_LIMIT:
|
| 636 |
+
return False
|
| 637 |
_wc_request_times.append(now)
|
| 638 |
return True
|
| 639 |
|
| 640 |
@app.get("/api/wc2026")
|
| 641 |
def api_wc2026():
|
| 642 |
+
"""Trả về tất cả dữ liệu World Cup 2026"""
|
| 643 |
return JSONResponse(_cached("wc2026", get_wc2026_all, ttl=_cache_ttl))
|
| 644 |
|
| 645 |
@app.get("/api/wc2026/{tab}")
|
| 646 |
def api_wc2026_tab(tab: str):
|
| 647 |
+
"""Trả về từng tab của World Cup"""
|
| 648 |
valid_tabs = ["news", "fixtures", "standings", "stats", "highlights"]
|
| 649 |
+
if tab not in valid_tabs:
|
| 650 |
+
return JSONResponse({"error": "invalid tab"}, status_code=400)
|
| 651 |
+
|
| 652 |
def _fetch_tab():
|
| 653 |
+
if tab == "highlights":
|
| 654 |
+
return scrape_highlights_by_league("world-cup")
|
| 655 |
+
elif tab == "news":
|
| 656 |
+
return scrape_wc_news()
|
| 657 |
+
elif tab == "fixtures":
|
| 658 |
+
return scrape_fixtures()
|
| 659 |
+
elif tab == "standings":
|
| 660 |
+
return scrape_standings()
|
| 661 |
+
elif tab == "stats":
|
| 662 |
+
return scrape_stats()
|
| 663 |
return []
|
| 664 |
+
|
| 665 |
return JSONResponse(_cached(f"wc2026_{tab}", _fetch_tab, ttl=_cache_ttl))
|
| 666 |
|
| 667 |
+
# Note: WC functions (scrape_wc_news, scrape_fixtures, scrape_stats, scrape_standings)
|
| 668 |
+
# are imported from wc2026_scraper.py at the top of this file
|
| 669 |
+
|
| 670 |
+
@app.get("/api/video_url")
|
| 671 |
+
def api_video_url(url:str=Query(...)):
|
| 672 |
+
if "youtube.com" in url or "youtu.be" in url:
|
| 673 |
+
m=re.search(r'(?:v=|shorts/|youtu\.be/)([a-zA-Z0-9_-]{11})',url)
|
| 674 |
+
if m: vid=m.group(1); return JSONResponse({"src":f"https://www.youtube.com/embed/{vid}?autoplay=1&rel=0&enablejsapi=1","poster":f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg","type":"youtube"})
|
| 675 |
+
if "xemlaibongda.top" in url:
|
| 676 |
+
v=extract_xemlaibongda_video(url)
|
| 677 |
+
if v:
|
| 678 |
+
if v["type"]=="hls": v["src"]="/api/proxy/m3u8?url="+quote(v["src"],safe="")
|
| 679 |
+
return JSONResponse(v)
|
| 680 |
+
return JSONResponse({"error":"not found"})
|
| 681 |
+
|
| 682 |
@app.get("/api/bdp_videos")
|
| 683 |
def api_bdp_videos():
|
| 684 |
def _f():
|
|
|
|
| 735 |
for img in container.find_all("img"):
|
| 736 |
s=img.get("data-src","") or img.get("src","")
|
| 737 |
if s and "mediacdn" in s and "avatar" not in s and "logo" not in s: img_src=s; break
|
| 738 |
+
if img_src: break
|
| 739 |
+
container=container.parent
|
| 740 |
seen.add(href)
|
| 741 |
if not img_src:
|
| 742 |
try:
|
|
|
|
| 779 |
for k,(u,n) in VNE_CATS.items(): cats.append({"id":k,"name":n,"source":"vne"})
|
| 780 |
return JSONResponse(cats)
|
| 781 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 782 |
@app.get("/api/article")
|
| 783 |
def api_article(url:str=Query(...)):
|
| 784 |
+
return JSONResponse({"error":"not supported"})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 785 |
|
| 786 |
@app.get("/api/storage_status")
|
| 787 |
def api_storage_status():
|
|
|
|
| 793 |
|
| 794 |
@app.get("/", response_class=HTMLResponse)
|
| 795 |
async def root():
|
| 796 |
+
return HTMLResponse("<h1>VNEWS</h1><p>Running</p>")
|
| 797 |
+
# v14 rebuild 2026-06-18T12:21:41.503230
|
rewrite_fix_v2.js
DELETED
|
@@ -1,2 +0,0 @@
|
|
| 1 |
-
// No-op - all functionality built into app_v2.js
|
| 2 |
-
(function(){})();
|
|
|
|
|
|
|
|
|
shorts_cache.py
DELETED
|
@@ -1,86 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
VNEWS Shorts Runtime Cache - External Updater Module
|
| 3 |
-
GitHub Actions fetches YouTube shorts via yt-dlp -> POST to /api/shorts/update
|
| 4 |
-
Space saves to RAM cache + persistent file if /data available
|
| 5 |
-
"""
|
| 6 |
-
import os
|
| 7 |
-
import json
|
| 8 |
-
import time
|
| 9 |
-
import threading
|
| 10 |
-
|
| 11 |
-
# Runtime cache (RAM)
|
| 12 |
-
_shorts_runtime_cache = None
|
| 13 |
-
_shorts_cache_ts = 0
|
| 14 |
-
_shorts_cache_lock = threading.Lock()
|
| 15 |
-
|
| 16 |
-
# Secret for authenticating update requests
|
| 17 |
-
SHORTS_UPDATE_SECRET = os.environ.get("SHORTS_UPDATE_SECRET", "vnews-shorts-2026")
|
| 18 |
-
|
| 19 |
-
# Paths
|
| 20 |
-
SHORTS_CACHE_FILE = "/data/shorts_runtime_cache.json" if os.path.isdir("/data") else "/app/shorts_runtime_cache.json"
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
def get_runtime_cache():
|
| 24 |
-
"""Get cached shorts (from RAM or file fallback)"""
|
| 25 |
-
global _shorts_runtime_cache, _shorts_cache_ts
|
| 26 |
-
with _shorts_cache_lock:
|
| 27 |
-
if _shorts_runtime_cache is not None:
|
| 28 |
-
age = time.time() - _shorts_cache_ts
|
| 29 |
-
if age < 7200: # 2h fresh
|
| 30 |
-
return _shorts_runtime_cache
|
| 31 |
-
|
| 32 |
-
# Try file fallback
|
| 33 |
-
try:
|
| 34 |
-
if os.path.exists(SHORTS_CACHE_FILE):
|
| 35 |
-
with open(SHORTS_CACHE_FILE, "r", encoding="utf-8") as f:
|
| 36 |
-
data = json.load(f)
|
| 37 |
-
age = time.time() - data.get("ts", 0)
|
| 38 |
-
if age < 86400: # 24h stale limit
|
| 39 |
-
items = data.get("items", [])
|
| 40 |
-
with _shorts_cache_lock:
|
| 41 |
-
_shorts_runtime_cache = items
|
| 42 |
-
_shorts_cache_ts = data.get("ts", time.time())
|
| 43 |
-
return items
|
| 44 |
-
except Exception as e:
|
| 45 |
-
print(f"[cache] read error: {e}")
|
| 46 |
-
|
| 47 |
-
return None
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
def set_runtime_cache(items):
|
| 51 |
-
"""Update runtime cache from external data"""
|
| 52 |
-
global _shorts_runtime_cache, _shorts_cache_ts
|
| 53 |
-
ts = time.time()
|
| 54 |
-
with _shorts_cache_lock:
|
| 55 |
-
_shorts_runtime_cache = items
|
| 56 |
-
_shorts_cache_ts = ts
|
| 57 |
-
|
| 58 |
-
# Also write to file (persistent if /data mounted)
|
| 59 |
-
try:
|
| 60 |
-
os.makedirs(os.path.dirname(SHORTS_CACHE_FILE), exist_ok=True)
|
| 61 |
-
payload = {"items": items, "ts": ts, "count": len(items)}
|
| 62 |
-
with open(SHORTS_CACHE_FILE, "w", encoding="utf-8") as f:
|
| 63 |
-
json.dump(payload, f, ensure_ascii=False, indent=2)
|
| 64 |
-
print(f"[cache] saved {len(items)} shorts to {SHORTS_CACHE_FILE}")
|
| 65 |
-
except Exception as e:
|
| 66 |
-
print(f"[cache] write skipped: {e}")
|
| 67 |
-
|
| 68 |
-
return len(items)
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
def get_cache_status():
|
| 72 |
-
"""Return status dict for the cache"""
|
| 73 |
-
cache = None
|
| 74 |
-
with _shorts_cache_lock:
|
| 75 |
-
if _shorts_runtime_cache is not None:
|
| 76 |
-
cache = _shorts_runtime_cache
|
| 77 |
-
age = int(time.time() - _shorts_cache_ts)
|
| 78 |
-
else:
|
| 79 |
-
age = -1
|
| 80 |
-
return {
|
| 81 |
-
"cached": cache is not None,
|
| 82 |
-
"count": len(cache) if cache else 0,
|
| 83 |
-
"age_seconds": age,
|
| 84 |
-
"has_persistent": os.path.isdir("/data"),
|
| 85 |
-
"cache_file_exists": os.path.exists(SHORTS_CACHE_FILE),
|
| 86 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
shorts_rss_proxy.py
DELETED
|
@@ -1,114 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
YouTube RSS Proxy - Fetches YouTube channel RSS feeds server-side
|
| 3 |
-
Avoids CORS issues when client tries to fetch YouTube directly
|
| 4 |
-
"""
|
| 5 |
-
import requests as req
|
| 6 |
-
from fastapi import Query
|
| 7 |
-
from fastapi.responses import Response
|
| 8 |
-
|
| 9 |
-
HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
|
| 10 |
-
|
| 11 |
-
YOUTUBE_CHANNELS = {
|
| 12 |
-
"baodantri7941": "UC_x5TKhOgd6GhYvv5z4I3jg",
|
| 13 |
-
"baosuckhoedoisongboyte": "UCBsY5fXTQLkF_JnH9kLkL4g",
|
| 14 |
-
}
|
| 15 |
-
|
| 16 |
-
def setup_rss_proxy(app):
|
| 17 |
-
"""Add RSS proxy endpoints to the FastAPI app"""
|
| 18 |
-
|
| 19 |
-
@app.get("/api/proxy/rss")
|
| 20 |
-
def proxy_rss(url: str = Query(...)):
|
| 21 |
-
"""Proxy YouTube RSS feed to avoid CORS"""
|
| 22 |
-
try:
|
| 23 |
-
r = req.get(url, headers=HEADERS, timeout=15)
|
| 24 |
-
if r.status_code == 200:
|
| 25 |
-
return Response(
|
| 26 |
-
content=r.content,
|
| 27 |
-
media_type="application/xml",
|
| 28 |
-
headers={"Access-Control-Allow-Origin": "*"}
|
| 29 |
-
)
|
| 30 |
-
return Response(status_code=r.status_code)
|
| 31 |
-
except Exception as e:
|
| 32 |
-
return Response(status_code=502, content=str(e))
|
| 33 |
-
|
| 34 |
-
@app.get("/api/shorts/rss")
|
| 35 |
-
def shorts_via_rss():
|
| 36 |
-
"""Get shorts from YouTube RSS feeds server-side"""
|
| 37 |
-
import xml.etree.ElementTree as ET
|
| 38 |
-
import html as html_lib
|
| 39 |
-
import re
|
| 40 |
-
|
| 41 |
-
shorts = []
|
| 42 |
-
seen = set()
|
| 43 |
-
|
| 44 |
-
for handle, channel_id in YOUTUBE_CHANNELS.items():
|
| 45 |
-
try:
|
| 46 |
-
rss_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
|
| 47 |
-
r = req.get(rss_url, headers=HEADERS, timeout=15)
|
| 48 |
-
if r.status_code != 200:
|
| 49 |
-
continue
|
| 50 |
-
|
| 51 |
-
root = ET.fromstring(r.text)
|
| 52 |
-
ns = {
|
| 53 |
-
'atom': 'http://www.w3.org/2005/Atom',
|
| 54 |
-
'yt': 'http://www.youtube.com/xml/schemas/2015',
|
| 55 |
-
'media': 'http://search.yahoo.com/mrss/'
|
| 56 |
-
}
|
| 57 |
-
|
| 58 |
-
for entry in root.findall('atom:entry', ns)[:30]:
|
| 59 |
-
title_el = entry.find('atom:title', ns)
|
| 60 |
-
title = html_lib.unescape(title_el.text) if title_el is not None and title_el.text else ''
|
| 61 |
-
|
| 62 |
-
link_el = entry.find('atom:link', ns)
|
| 63 |
-
link = link_el.get('href', '') if link_el is not None else ''
|
| 64 |
-
|
| 65 |
-
vid_el = entry.find('yt:videoId', ns)
|
| 66 |
-
vid = vid_el.text if vid_el is not None else ''
|
| 67 |
-
|
| 68 |
-
if not vid:
|
| 69 |
-
m = re.search(r'(?:v=|shorts/)([A-Za-z0-9_-]{11})', link)
|
| 70 |
-
if m:
|
| 71 |
-
vid = m.group(1)
|
| 72 |
-
|
| 73 |
-
if not vid or vid in seen:
|
| 74 |
-
continue
|
| 75 |
-
|
| 76 |
-
# Check if it's a short
|
| 77 |
-
is_short = '#shorts' in title.lower() or '#short' in title.lower() or '/shorts/' in link
|
| 78 |
-
|
| 79 |
-
if not is_short:
|
| 80 |
-
desc_el = entry.find('media:description', ns)
|
| 81 |
-
if desc_el is not None and desc_el.text:
|
| 82 |
-
if '#shorts' in desc_el.text.lower():
|
| 83 |
-
is_short = True
|
| 84 |
-
|
| 85 |
-
if not is_short:
|
| 86 |
-
continue
|
| 87 |
-
|
| 88 |
-
seen.add(vid)
|
| 89 |
-
|
| 90 |
-
# Get thumbnail
|
| 91 |
-
thumb = f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg"
|
| 92 |
-
media_group = entry.find('media:group', ns)
|
| 93 |
-
if media_group is not None:
|
| 94 |
-
thumb_el = media_group.find('media:thumbnail', ns)
|
| 95 |
-
if thumb_el is not None:
|
| 96 |
-
thumb = thumb_el.get('url', thumb)
|
| 97 |
-
|
| 98 |
-
shorts.append({
|
| 99 |
-
'id': vid,
|
| 100 |
-
'title': title.replace('#shorts', '').replace('#short', '').strip()[:120],
|
| 101 |
-
'img': thumb,
|
| 102 |
-
'link': f'https://www.youtube.com/shorts/{vid}',
|
| 103 |
-
'channel': handle,
|
| 104 |
-
'source': 'yt'
|
| 105 |
-
})
|
| 106 |
-
|
| 107 |
-
if len(shorts) >= 40:
|
| 108 |
-
break
|
| 109 |
-
|
| 110 |
-
except Exception as e:
|
| 111 |
-
print(f"RSS error for {handle}: {e}")
|
| 112 |
-
continue
|
| 113 |
-
|
| 114 |
-
return {"shorts": shorts, "count": len(shorts)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/app_v2.js
CHANGED
|
@@ -1,17 +1,6 @@
|
|
| 1 |
-
/
|
| 2 |
-
* VNEWS Frontend v2 - Shorts Dantri/SKDS removed, VTV Digital CDN
|
| 3 |
-
* v2.4 - Fixed: prependWallPost detached-element bug, makeShortVideo UI update, slide viewer for rewrite posts
|
| 4 |
-
*/
|
| 5 |
-
function _proxyImg(url){
|
| 6 |
-
if(!url || typeof url !== 'string') return '';
|
| 7 |
-
if(url.startsWith('http') && !url.includes(location.host)){
|
| 8 |
-
return '/api/proxy/img?url='+encodeURIComponent(url);
|
| 9 |
-
}
|
| 10 |
-
return url;
|
| 11 |
-
}
|
| 12 |
-
|
| 13 |
-
var _ttsSelections = {};
|
| 14 |
|
|
|
|
| 15 |
function _fetchWithTimeout(url, ms){
|
| 16 |
return new Promise((resolve,reject)=>{
|
| 17 |
const ctrl=new AbortController();
|
|
@@ -28,6 +17,7 @@ async function loadHome(){
|
|
| 28 |
const homeEl = document.getElementById('view-home');
|
| 29 |
if(!homeEl) return;
|
| 30 |
|
|
|
|
| 31 |
homeEl.innerHTML =
|
| 32 |
'<div id="home-featured-area"></div>'
|
| 33 |
+'<div class="ai-compose"><div class="ai-compose-title">🤖 AI viết bài</div><div class="ai-compose-row"><input id="topic-input" placeholder="Nhập chủ đề..."><button onclick="searchTopic()">Tìm nguồn</button></div><div class="ai-compose-row"><input id="url-input" placeholder="Dán URL bài viết..."><button class="secondary" onclick="rewriteUrl()">Rewrite</button></div><div id="hot-topics" class="hot-topic-row"></div></div>'
|
|
@@ -38,31 +28,42 @@ async function loadHome(){
|
|
| 38 |
|
| 39 |
const afterEl = homeEl.querySelector('#home-after-wc');
|
| 40 |
|
|
|
|
| 41 |
loadLivescore('today');
|
| 42 |
loadHotTopics();
|
| 43 |
|
| 44 |
-
|
|
|
|
| 45 |
_fetchWithTimeout('/api/livescore/featured', 5000),
|
|
|
|
| 46 |
_fetchWithTimeout('/api/wall', 5000),
|
| 47 |
_fetchWithTimeout('/api/highlights/leagues', 10000),
|
|
|
|
| 48 |
_fetchWithTimeout('/api/wc2026', 8000),
|
| 49 |
]).then(results => results.map(r => r.status === 'fulfilled' ? r.value : null));
|
| 50 |
|
|
|
|
| 51 |
if(featuredData && featuredData.home){
|
| 52 |
const sc=featuredData.status==='live'?'':'upcoming';
|
| 53 |
const st=featuredData.status==='live'?`🔴 ${featuredData.minute||'LIVE'}`:`⏰ ${featuredData.time}`;
|
| 54 |
const area=document.getElementById('home-featured-area');
|
| 55 |
-
if(area) area.innerHTML=`<div class="featured-match" onclick="openMatch('${featuredData.event_id}')"><div class="fm-league">${featuredData.league}</div><div class="fm-teams"><div class="fm-team"><img src="${
|
| 56 |
}
|
| 57 |
|
|
|
|
|
|
|
| 58 |
_wallPosts = (wallData && wallData.posts) || [];
|
| 59 |
_hlLeagueData = hlLeagues || {};
|
| 60 |
_wc2026Data = wcData;
|
| 61 |
|
|
|
|
| 62 |
if(wcData) switchWCTab('news');
|
| 63 |
|
|
|
|
|
|
|
| 64 |
_renderWallIn(afterEl);
|
| 65 |
_renderHLIn(afterEl);
|
|
|
|
| 66 |
}
|
| 67 |
|
| 68 |
function _renderSlidesIn(key, label, emoji, vids, afterEl){
|
|
@@ -73,9 +74,9 @@ function _renderSlidesIn(key, label, emoji, vids, afterEl){
|
|
| 73 |
const isHL = key==='world-cup'||key==='premier-league'||key==='champions-league'||key==='la-liga'||key==='serie-a'||key==='bundesliga'||key==='friendly';
|
| 74 |
vids.slice(0,isHL?8:12).forEach((a,i)=>{
|
| 75 |
if(isHL){
|
| 76 |
-
h+=`<div class="slider-item" onclick="openHighlightFeed('${key}',${i})"><div class="slider-thumb">${a.img?`<img src="${
|
| 77 |
} else {
|
| 78 |
-
h+=`<div class="slider-item" onclick="readArticle('${esc(a.link)}')"><div class="slider-thumb">${a.img?`<img src="${
|
| 79 |
}
|
| 80 |
});
|
| 81 |
h+='</div>';
|
|
@@ -83,9 +84,33 @@ function _renderSlidesIn(key, label, emoji, vids, afterEl){
|
|
| 83 |
afterEl.parentNode.insertBefore(wrap, afterEl);
|
| 84 |
}
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
function _renderWallIn(afterEl){
|
| 87 |
if(!_wallPosts||!_wallPosts.length||!afterEl) return;
|
| 88 |
const posts=_wallPosts;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
const wrap=document.createElement('div');
|
| 90 |
wrap.className='slider-wrap';wrap.id='ai-wall-wrap';
|
| 91 |
let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track" id="ai-wall-track">';
|
|
@@ -106,247 +131,69 @@ function _renderHLIn(afterEl){
|
|
| 106 |
|
| 107 |
// === WALL POST HELPERS ===
|
| 108 |
function makeWallItem(p,i){
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
?
|
| 112 |
-
: (hasVideo ?
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
var selKey = 'inline-' + vid;
|
| 119 |
-
if(!_ttsSelections[selKey]) _ttsSelections[selKey] = {voice: curVoice, emotion: curEmotion};
|
| 120 |
-
var voiceOpts = '';
|
| 121 |
-
VOICE_LIST.forEach(function(v){
|
| 122 |
-
voiceOpts += '<option value="'+v.id+'"'+(v.id===_ttsSelections[selKey].voice?' selected':'')+'>'+v.label+'</option>';
|
| 123 |
-
});
|
| 124 |
-
var emotOpts = '';
|
| 125 |
-
EMOTION_LIST.forEach(function(e){
|
| 126 |
-
emotOpts += '<option value="'+e.id+'"'+(e.id===_ttsSelections[selKey].emotion?' selected':'')+'>'+e.label+'</option>';
|
| 127 |
-
});
|
| 128 |
-
var spd = p.short_speed || '1.2';
|
| 129 |
-
var voiceBar = '<div class="wall-tts-bar" style="margin:6px 0 4px;display:grid;grid-template-columns:1fr auto auto;gap:3px">'
|
| 130 |
-
+'<select class="wvs" data-selkey="'+selKey+'" style="background:#1a1a1a;border:1px solid #333;color:#ccc;padding:3px;border-radius:6px;font-size:9px;min-width:0" onchange="_ttsSelections[this.dataset.selkey].voice=this.value">'
|
| 131 |
-
+voiceOpts+'</select>'
|
| 132 |
-
+'<select class="wes" data-selkey="'+selKey+'" style="background:#1a1a1a;border:1px solid #333;color:#ccc;padding:3px;border-radius:6px;font-size:9px;min-width:0" onchange="_ttsSelections[this.dataset.selkey].emotion=this.value">'
|
| 133 |
-
+emotOpts+'</select>'
|
| 134 |
-
+'<select class="wss" data-selkey="'+selKey+'" style="background:#1a1a1a;border:1px solid #333;color:#ccc;padding:3px;border-radius:6px;font-size:9px;min-width:0" onchange="_ttsSelections[this.dataset.selkey].speed=parseFloat(this.value)">'
|
| 135 |
-
+'<option value="0.85"'+(spd==='0.85'?' selected':'')+'>0.85x</option>'
|
| 136 |
-
+'<option value="1.0"'+(spd==='1.0'?' selected':'')+'>1.0x</option>'
|
| 137 |
-
+'<option value="1.2"'+(spd==='1.2'?' selected':'')+'>1.2x</option>'
|
| 138 |
-
+'<option value="1.35"'+(spd==='1.35'?' selected':'')+'>1.35x</option>'
|
| 139 |
-
+'</select>'
|
| 140 |
-
+'</div>';
|
| 141 |
-
var makeBtn = hasVideo
|
| 142 |
-
? '<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed('+i+')">▶ Xem Short</button>'
|
| 143 |
-
: '<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo(\''+esc(vid)+'\',this,_ttsSelections[\'inline-'+esc(vid)+'\'].voice,parseFloat(document.querySelector(\'.wss[data-selkey=inline-'+esc(vid)+']\').value)||1.2,_ttsSelections[\'inline-'+esc(vid)+'\'].emotion)">🎬 Tạo Short</button>';
|
| 144 |
-
return '<div class="wall-item" id="wall-item-'+esc(vid)+'"><div class="wall-thumb">'+thumbContent+videoBadge+'</div><div class="wall-title">'+esc(p.title)+'</div><div class="wall-text">'+esc((p.text||'').slice(0,180))+'</div>'+voiceBar+'<div class="wall-actions"><button class="primary" onclick="readWallPost('+i+')">Xem</button>'+makeBtn+'</div></div>';
|
| 145 |
}
|
| 146 |
|
| 147 |
-
async function makeShortVideo(postId, btn, voice, speed
|
| 148 |
if(!postId)return;
|
| 149 |
const origText = btn ? btn.textContent : '🎬 Tạo Video';
|
| 150 |
if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo...';}
|
| 151 |
toast('⏳ Đang tạo video shorts...');
|
| 152 |
-
if(!voice || !emotion){
|
| 153 |
-
const selKey = 'inline-'+postId;
|
| 154 |
-
const container = document.querySelector('.tts-selector[data-post-id="'+postId+'"]');
|
| 155 |
-
if(container){
|
| 156 |
-
if(_ttsSelections[selKey]){
|
| 157 |
-
voice = voice || _ttsSelections[selKey].voice;
|
| 158 |
-
emotion = emotion || _ttsSelections[selKey].emotion;
|
| 159 |
-
}
|
| 160 |
-
if(!voice){
|
| 161 |
-
const selectedVoiceBtn = container.querySelector('.tts-voice-btn.selected') || container.querySelector('.tts-voice-btn[style*="5cb87a"]') || container.querySelector('.tts-voice-btn');
|
| 162 |
-
voice = selectedVoiceBtn ? selectedVoiceBtn.dataset.voice : 'vi-VN-HoaiMyNeural';
|
| 163 |
-
}
|
| 164 |
-
if(!emotion){
|
| 165 |
-
const selectedEmotionBtn = container.querySelector('.tts-emotion-btn.selected') || container.querySelector('.tts-emotion-btn[style*="5cb87a"]') || container.querySelector('.tts-emotion-btn');
|
| 166 |
-
emotion = selectedEmotionBtn ? selectedEmotionBtn.dataset.emotion : 'neutral';
|
| 167 |
-
}
|
| 168 |
-
const speedSelect = container.querySelector('.tts-speed');
|
| 169 |
-
speed = speedSelect ? parseFloat(speedSelect.value) || 1.2 : (speed || 1.2);
|
| 170 |
-
} else {
|
| 171 |
-
voice = voice || 'vi-VN-HoaiMyNeural';
|
| 172 |
-
emotion = emotion || 'neutral';
|
| 173 |
-
speed = speed || 1.2;
|
| 174 |
-
}
|
| 175 |
-
}
|
| 176 |
try{
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
const j = await r.json();
|
| 179 |
if(!r.ok || j.error) throw new Error(j.error||'Lỗi tạo video');
|
| 180 |
toast('✅ Đã tạo video shorts!');
|
| 181 |
const p = _wallPosts.find(x => String(x.id) === String(postId));
|
| 182 |
if(p){
|
| 183 |
p.video = j.video;
|
| 184 |
-
p.voice = j.voice;
|
| 185 |
-
p.emotion = j.emotion;
|
| 186 |
const itemId = 'wall-item-'+postId;
|
| 187 |
const el = document.getElementById(itemId);
|
| 188 |
if(el){
|
| 189 |
const idx = _wallPosts.indexOf(p);
|
| 190 |
-
el.
|
| 191 |
-
|
|
|
|
| 192 |
}
|
| 193 |
}
|
|
|
|
| 194 |
}catch(e){
|
| 195 |
toast('❌ '+e.message);
|
| 196 |
if(btn){btn.disabled=false;btn.textContent=origText;}
|
| 197 |
}
|
| 198 |
}
|
| 199 |
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
{id:'pt-BR-ThalitaMultilingualNeural', label:'🎙️ Thalita (PT)', lang:'pt'},
|
| 206 |
-
{id:'fr-FR-VivienneMultilingualNeural', label:'🎙️ Vivienne (FR)', lang:'fr'},
|
| 207 |
-
{id:'fr-FR-RemyMultilingualNeural', label:'🎙️ Rémy (FR)', lang:'fr'},
|
| 208 |
-
{id:'de-DE-SeraphinaMultilingualNeural', label:'🎙️ Seraphina (DE)', lang:'de'},
|
| 209 |
-
{id:'de-DE-FlorianMultilingualNeural', label:'🎙️ Florian (DE)', lang:'de'},
|
| 210 |
-
{id:'ko-KR-HyunsuMultilingualNeural', label:'🎙️ Hyunsu (KO)', lang:'ko'},
|
| 211 |
-
{id:'it-IT-GiuseppeMultilingualNeural', label:'🎙️ Giuseppe (IT)', lang:'it'},
|
| 212 |
-
];
|
| 213 |
-
var EMOTION_LIST = [
|
| 214 |
-
{id:'neutral', label:'😐 Trung tính'},
|
| 215 |
-
{id:'happy', label:'😊 Vui vẻ'},
|
| 216 |
-
{id:'excited', label:'🔥 Hào hứng'},
|
| 217 |
-
{id:'sad', label:'😢 Buồn'},
|
| 218 |
-
{id:'humorous', label:'😂 Hài hước'},
|
| 219 |
-
{id:'serious', label:'⚠️ Nghiêm túc'},
|
| 220 |
-
{id:'urgent', label:'🚨 Khẩn cấp'},
|
| 221 |
-
{id:'warm', label:'💖 Ấm áp'},
|
| 222 |
-
];
|
| 223 |
-
|
| 224 |
-
document.addEventListener('click',function(e){
|
| 225 |
-
var btn = e.target.closest('.tts-voice-btn');
|
| 226 |
-
if(btn){
|
| 227 |
-
var container = btn.closest('.tts-selector');
|
| 228 |
-
if(container){
|
| 229 |
-
var selKey = 'inline-'+container.dataset.postId;
|
| 230 |
-
if(!_ttsSelections[selKey]) _ttsSelections[selKey]={voice:btn.dataset.voice,emotion:'neutral'};
|
| 231 |
-
var allBtns = container.querySelectorAll('.tts-voice-btn');
|
| 232 |
-
for(var i=0;i<allBtns.length;i++){allBtns[i].style.borderColor='#333';allBtns[i].style.background='#222';allBtns[i].classList.remove('selected');}
|
| 233 |
-
btn.style.borderColor='#5cb87a';btn.style.background='#1a2a1f';btn.classList.add('selected');
|
| 234 |
-
_ttsSelections[selKey].voice = btn.dataset.voice;
|
| 235 |
-
}
|
| 236 |
return;
|
| 237 |
}
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
ebtn.style.borderColor='#5cb87a';ebtn.style.background='#1a2a1f';ebtn.classList.add('selected');
|
| 247 |
-
_ttsSelections[selKey].emotion = ebtn.dataset.emotion;
|
| 248 |
}
|
| 249 |
-
return;
|
| 250 |
}
|
| 251 |
-
var cbtn = e.target.closest('.tts-create-btn');
|
| 252 |
-
if(cbtn){
|
| 253 |
-
var container = cbtn.closest('.tts-selector');
|
| 254 |
-
if(container){
|
| 255 |
-
var selKey = 'inline-'+container.dataset.postId;
|
| 256 |
-
var selVoice = _ttsSelections[selKey] ? _ttsSelections[selKey].voice : 'vi-VN-HoaiMyNeural';
|
| 257 |
-
var selEmotion = _ttsSelections[selKey] ? _ttsSelections[selKey].emotion : 'neutral';
|
| 258 |
-
var speedSel = container.querySelector('.tts-speed');
|
| 259 |
-
var speed = speedSel ? parseFloat(speedSel.value)||1.2 : 1.2;
|
| 260 |
-
window.makeShortVideo(container.dataset.postId, cbtn, selVoice, speed, selEmotion);
|
| 261 |
-
}
|
| 262 |
-
return;
|
| 263 |
-
}
|
| 264 |
-
});
|
| 265 |
-
function detectLanguage(text){
|
| 266 |
-
if(!text) return 'vi';
|
| 267 |
-
var t=text.toLowerCase(), chars=new Set(t);
|
| 268 |
-
var vnChars='đăâêôơưàảãạáằẳẵặắầẩẫậấèẻẽẹéềễểệếìỉĩịíòỏõọóồổỗộốờởỡợớùủũụúừửữựứỳỷỹỵý';
|
| 269 |
-
var vnCount=0; for(var c of vnChars){if(chars.has(c)) vnCount++;}
|
| 270 |
-
if(vnCount>=2) return 'vi';
|
| 271 |
-
if(chars.has('ñ')||chars.has('¿')||chars.has('¡')) return 'es';
|
| 272 |
-
if(chars.has('ã')||chars.has('õ')) return 'pt';
|
| 273 |
-
var words=t.split(/\s+/);
|
| 274 |
-
var enWords=['the','is','at','which','on','and','or','but','this','that','with','from','have','been'];
|
| 275 |
-
var enCount=words.filter(function(w){return enWords.indexOf(w)>=0;}).length;
|
| 276 |
-
if(enCount>=2) return 'en';
|
| 277 |
-
return 'vi';
|
| 278 |
-
}
|
| 279 |
-
function detectEmotion(text){
|
| 280 |
-
if(!text) return 'neutral';
|
| 281 |
-
var t=text.toLowerCase();
|
| 282 |
-
var kws={
|
| 283 |
-
happy:['vui','hạnh phúc','tuyệt','thành công','chiến thắng','feliz','maravilloso','happy','joy','wonderful','great','amazing','love','excellent'],
|
| 284 |
-
excited:['hào hứng','phấn khích','đột phá','kỷ lục','đỉnh cao','emocionante','increíble','excited','thrilling','unbelievable','awesome','breakthrough'],
|
| 285 |
-
sad:['buồn','đau','mất','thảm họa','khủng hoảng','triste','terrible','sad','unhappy','tragic','painful','death'],
|
| 286 |
-
humorous:['hài hước','buồn cười','haha','đùa','engraçado','gracioso','funny','hilarious','joke','lol'],
|
| 287 |
-
serious:['nghiêm trọng','kh���n cấp','quan trọng','lo ngại','sério','crítico','serious','critical','urgent','severe','crisis'],
|
| 288 |
-
urgent:['khẩn cấp','báo động','ngay lập tức','urgent','breaking','alert','emergency'],
|
| 289 |
-
warm:['ấm áp','tình cảm','yêu thương','warm','love','heart','touching']
|
| 290 |
-
};
|
| 291 |
-
var bestScore=0, bestEmotion='neutral';
|
| 292 |
-
for(var em in kws){var score=0; for(var kw of kws[em]){if(t.indexOf(kw)>=0) score++;} if(score>bestScore){bestScore=score;bestEmotion=em;}}
|
| 293 |
-
return bestEmotion;
|
| 294 |
-
}
|
| 295 |
-
function getAutoVoice(lang){var map={vi:'vi-VN-HoaiMyNeural',pt:'pt-BR-ThalitaMultilingualNeural',en:'en-US-AndrewMultilingualNeural',fr:'fr-FR-VivienneMultilingualNeural',de:'de-DE-SeraphinaMultilingualNeural',ko:'ko-KR-HyunsuMultilingualNeural',it:'it-IT-GiuseppeMultilingualNeural'};return map[lang]||'vi-VN-HoaiMyNeural';}
|
| 296 |
-
function buildVoiceEmotionSelector(post){
|
| 297 |
-
var lang=post.language||detectLanguage(post.title+' '+(post.text||''));
|
| 298 |
-
var _oldVoiceMap = {'hoaimy':'vi-VN-HoaiMyNeural','namminh':'vi-VN-NamMinhNeural','andrew':'en-US-AndrewMultilingualNeural','jenny':'en-US-AndrewMultilingualNeural','thalita':'pt-BR-ThalitaMultilingualNeural','pt_thalita':'pt-BR-ThalitaMultilingualNeural','vivienne':'fr-FR-VivienneMultilingualNeural','remy':'fr-FR-RemyMultilingualNeural','seraphina':'de-DE-SeraphinaMultilingualNeural','florian':'de-DE-FlorianMultilingualNeural','sunhee':'ko-KR-HyunsuMultilingualNeural','hyunsu':'ko-KR-HyunsuMultilingualNeural','giuseppe':'it-IT-GiuseppeMultilingualNeural','ela':'en-US-AndrewMultilingualNeural','denise':'fr-FR-VivienneMultilingualNeural','katja':'de-DE-SeraphinaMultilingualNeural','nanami':'en-US-AndrewMultilingualNeural','xiaochen':'en-US-AndrewMultilingualNeural','es_carlos':'en-US-AndrewMultilingualNeural','pt_francisco':'pt-BR-ThalitaMultilingualNeural'};
|
| 299 |
-
var _postVoice = post.voice ? (_oldVoiceMap[post.voice] || post.voice) : '';
|
| 300 |
-
var autoVoice= _postVoice || getAutoVoice(lang);
|
| 301 |
-
var autoEmotion=post.emotion||detectEmotion(post.title+' '+(post.text||''));
|
| 302 |
-
var selKey = 'inline-'+post.id;
|
| 303 |
-
if(!_ttsSelections[selKey]){_ttsSelections[selKey] = {voice: autoVoice, emotion: autoEmotion};}
|
| 304 |
-
var h='<div class="tts-selector" data-post-id="'+post.id+'" style="margin-top:10px;padding:10px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:10px">';
|
| 305 |
-
h+='<div style="font-size:11px;color:#888;margin-bottom:6px">🎙️ Giọng đọc (ngôn ngữ: '+lang.toUpperCase()+'):</div><div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px">';
|
| 306 |
-
VOICE_LIST.forEach(function(v){var sel=v.id===_ttsSelections[selKey].voice?'border-color:#5cb87a;background:#1a2a1f':'border-color:#333;background:#222';h+='<button class="tts-voice-btn '+(v.id===_ttsSelections[selKey].voice?'selected':'')+'" data-voice="'+v.id+'" data-post-id="'+post.id+'" style="'+sel+';border:1px solid;color:#ccc;padding:4px 8px;border-radius:10px;font-size:10px;cursor:pointer">'+v.label+'</button>';});
|
| 307 |
-
h+='</div><div style="font-size:11px;color:#888;margin-bottom:6px">😊 Cảm xúc:</div><div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px">';
|
| 308 |
-
EMOTION_LIST.forEach(function(e){var sel=e.id===_ttsSelections[selKey].emotion?'border-color:#5cb87a;background:#1a2a1f':'border-color:#333;background:#222';h+='<button class="tts-emotion-btn '+(e.id===_ttsSelections[selKey].emotion?'selected':'')+'" data-emotion="'+e.id+'" data-post-id="'+post.id+'" style="'+sel+';border:1px solid;color:#ccc;padding:4px 8px;border-radius:10px;font-size:10px;cursor:pointer">'+e.label+'</button>';});
|
| 309 |
-
h+='</div><div style="display:flex;align-items:center;gap:6px;margin-bottom:8px"><span style="font-size:11px;color:#888">⚡ Tốc độ:</span>';
|
| 310 |
-
h+='<select class="tts-speed" style="background:#222;border:1px solid #333;color:#ccc;padding:3px 8px;border-radius:8px;font-size:10px"><option value="0.85">0.85x Chậm</option><option value="1.0">1.0x Bình thường</option><option value="1.2" selected>1.2x Nhanh</option><option value="1.35">1.35x Rất nhanh</option></select></div>';
|
| 311 |
-
h+='<button class="tts-create-btn" style="width:100%;background:#2d8659;border:0;color:#fff;padding:8px;border-radius:10px;font-size:11px;font-weight:700;cursor:pointer">🎬 Tạo Short AI</button></div>';
|
| 312 |
-
return h;
|
| 313 |
}
|
| 314 |
-
window.showVoiceEmotionSelector=function(postId,title,text){
|
| 315 |
-
var overlay=document.createElement('div');
|
| 316 |
-
overlay.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:99999;display:flex;align-items:center;justify-content:center;padding:16px';
|
| 317 |
-
var box=document.createElement('div');box.style.cssText='background:#1a1a1a;border:2px solid #2d8659;border-radius:16px;padding:20px;max-width:400px;width:100%;max-height:80vh;overflow-y:auto';
|
| 318 |
-
var lang=detectLanguage(title+' '+text);var autoEmotion=detectEmotion(title+' '+text);
|
| 319 |
-
var h='<h3 style="color:#5cb87a;margin-bottom:12px;font-size:16px">🎬 Tạo Short AI (ngôn ngữ: '+lang.toUpperCase()+')</h3>';
|
| 320 |
-
h+='<div style="margin-bottom:12px"><div style="color:#aaa;font-size:11px;margin-bottom:6px">🎙️ Chọn giọng đọc:</div>';
|
| 321 |
-
VOICE_LIST.forEach(function(v){var sel=v.id===getAutoVoice(lang)?'border-color:#5cb87a;background:#1a2a1f':'border-color:#333;background:#222';h+='<button class="ve-voice-btn" data-voice="'+v.id+'" style="display:inline-block;'+sel+';border:1px solid;color:#ccc;padding:5px 10px;border-radius:12px;font-size:10px;margin:2px;cursor:pointer">'+v.label+'</button>';});
|
| 322 |
-
h+='</div><div style="margin-bottom:12px"><div style="color:#aaa;font-size:11px;margin-bottom:6px">😊 Chọn cảm xúc:</div>';
|
| 323 |
-
EMOTION_LIST.forEach(function(e){var sel=e.id===autoEmotion?'border-color:#5cb87a;background:#1a2a1f':'border-color:#333;background:#222';h+='<button class="ve-emotion-btn" data-emotion="'+e.id+'" style="display:inline-block;'+sel+';border:1px solid;color:#ccc;padding:5px 10px;border-radius:12px;font-size:10px;margin:2px;cursor:pointer">'+e.label+'</button>';});
|
| 324 |
-
h+='</div><div style="margin-bottom:12px"><div style="color:#aaa;font-size:11px;margin-bottom:6px">⚡ Tốc độ:</div>';
|
| 325 |
-
h+='<select id="ve-speed" style="background:#222;border:1px solid #333;color:#ccc;padding:6px 12px;border-radius:10px;font-size:11px"><option value="0.85">0.85x Chậm</option><option value="1.0">1.0x Bình thường</option><option value="1.2" selected>1.2x Nhanh</option><option value="1.35">1.35x Rất nhanh</option></select></div>';
|
| 326 |
-
h+='<div style="display:flex;gap:8px"><button id="ve-create-btn" style="flex:1;background:#2d8659;border:0;color:#fff;padding:10px;border-radius:12px;font-size:12px;font-weight:700;cursor:pointer">🎬 Tạo Short</button>';
|
| 327 |
-
h+='<button id="ve-cancel-btn" style="background:#333;border:0;color:#ccc;padding:10px 16px;border-radius:12px;font-size:11px;cursor:pointer">✕</button></div>';
|
| 328 |
-
h+='<div id="ve-status" style="color:#888;font-size:10px;margin-top:8px;display:none"></div>';
|
| 329 |
-
box.innerHTML=h;overlay.appendChild(box);document.body.appendChild(overlay);
|
| 330 |
-
var selectedVoice=getAutoVoice(lang),selectedEmotion=autoEmotion;
|
| 331 |
-
box.querySelectorAll('.ve-voice-btn').forEach(function(btn){btn.addEventListener('click',function(){box.querySelectorAll('.ve-voice-btn').forEach(function(b){b.style.borderColor='#333';b.style.background='#222';});this.style.borderColor='#5cb87a';this.style.background='#1a2a1f';selectedVoice=this.dataset.voice;});});
|
| 332 |
-
box.querySelectorAll('.ve-emotion-btn').forEach(function(btn){btn.addEventListener('click',function(){box.querySelectorAll('.ve-emotion-btn').forEach(function(b){b.style.borderColor='#333';b.style.background='#222';});this.style.borderColor='#5cb87a';this.style.background='#1a2a1f';selectedEmotion=this.dataset.emotion;});});
|
| 333 |
-
box.querySelector('#ve-cancel-btn').addEventListener('click',function(){overlay.remove();});
|
| 334 |
-
box.querySelector('#ve-create-btn').addEventListener('click',async function(){
|
| 335 |
-
this.disabled=true;this.textContent='⏳ Đang tạo...';
|
| 336 |
-
box.querySelector('#ve-status').style.display='block';box.querySelector('#ve-status').textContent='Đang tạo video shorts...';
|
| 337 |
-
try{
|
| 338 |
-
var speed=parseFloat(box.querySelector('#ve-speed').value)||1.2;
|
| 339 |
-
if(!_ttsSelections["inline-"+postId]) _ttsSelections["inline-"+postId]={voice:"vi-VN-HoaiMyNeural",emotion:"neutral"};
|
| 340 |
-
_ttsSelections["inline-"+postId].voice=selectedVoice;_ttsSelections["inline-"+postId].emotion=selectedEmotion;
|
| 341 |
-
var r=await fetch('/api/ai/short/'+encodeURIComponent(postId),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:selectedVoice,emotion:selectedEmotion,speed:speed})});
|
| 342 |
-
var j=await r.json();
|
| 343 |
-
if(!r.ok||j.error) throw new Error(j.error||'Lỗi tạo video');
|
| 344 |
-
toast('✅ Đã tạo Short AI!');overlay.remove();
|
| 345 |
-
var p=_wallPosts.find(function(x){return String(x.id)===String(postId);});
|
| 346 |
-
if(p){p.video=j.video;p.voice=j.voice;p.emotion=j.emotion;}
|
| 347 |
-
}catch(e){this.disabled=false;this.textContent='🎬 Tạo Short';box.querySelector('#ve-status').textContent='❌ '+e.message;}
|
| 348 |
-
});
|
| 349 |
-
};
|
| 350 |
|
| 351 |
function prependWallPost(post){
|
| 352 |
_wallPosts.unshift(post);
|
|
@@ -357,7 +204,8 @@ function prependWallPost(post){
|
|
| 357 |
if(homeEl){
|
| 358 |
let insertBefore=homeEl.querySelector('.slider-wrap');
|
| 359 |
const newWrap=document.createElement('div');
|
| 360 |
-
newWrap.className='slider-wrap';
|
|
|
|
| 361 |
newWrap.innerHTML=`<div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track" id="ai-wall-track">${makeWallItem(post,0)}</div>`;
|
| 362 |
if(insertBefore) homeEl.insertBefore(newWrap,insertBefore);
|
| 363 |
else homeEl.appendChild(newWrap);
|
|
@@ -366,35 +214,68 @@ function prependWallPost(post){
|
|
| 366 |
}
|
| 367 |
return;
|
| 368 |
}
|
| 369 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
track.scrollTo({left:0,behavior:'smooth'});
|
|
|
|
| 371 |
}
|
| 372 |
|
|
|
|
|
|
|
| 373 |
let _wallPosts=[];
|
| 374 |
let _currentView='home';
|
| 375 |
let _currentEventId=null;
|
| 376 |
let _currentMatchUrl=null;
|
|
|
|
| 377 |
let _htPage=0,_htTopic='';
|
| 378 |
-
async function loadHotTopics(){const j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));const el=document.getElementById('hot-topics');if(!el)return;el.innerHTML=(j.topics||[]).slice(0,18).map(t=>{const topicText=t.topic||t.label.replace(/^#/,'');return`<button class="hot-chip" onclick="searchTopic('${topicText.replace(/'/g,"\\'")}')">${esc(t.label)}</button>`;}).join('');}
|
| 379 |
function searchTopic(topic){if(!topic){topic=document.getElementById('topic-input')?.value.trim();if(!topic){alert('Nhập chủ đề');return;}}document.getElementById('topic-input').value='';_htTopic=topic;_htPage=0;showHashtagSources(topic,0);}
|
| 380 |
-
async function showHashtagSources(topic,page){const box=document.getElementById('hashtag-box');if(!box)return;if(page===0)box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div class="hashtag-loading"><div class="hashtag-spinner"></div>Đang tìm...</div></div>`;try{const r=await fetch(`/api/hashtag/sources?topic=${encodeURIComponent(topic)}&page=${page}`);const j=await r.json();const sources=j.sources||[];if(!sources.length&&page===0){box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div style="color:#888;padding:8px">Không tìm được bài viết liên quan</div></div>`;return;}let h='';if(page===0)h=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)} <span style="font-size:10px;color:#888">(${j.total} bài từ 8 nguồn)</span></h3><div id="ht-list">`;sources.forEach((s,i)=>{const idx=page*8+i;h+=`<div class="hashtag-src-item" onclick="readArticle('${esc(s.url)}')"><div class="hashtag-src-img" id="ht-img-${idx}"></div><div class="hashtag-src-text"><div class="hashtag-src-title">${esc(s.title)}</div><div class="hashtag-src-via">${esc(s.via||'')}</div></div></div>`;});if(page===0){h+=`</div><button class="hashtag-rewrite-btn" onclick="rewriteHashtag('${esc(topic).replace(/'/g,"\\'")}')">🤖 Rewrite AI tổng hợp & đăng tường</button>`;if(j.has_more)h+=`<button class="hashtag-load-more" id="ht-more" onclick="loadMoreHashtag()">Tải thêm ▼</button>`;h+=`</div>`;box.innerHTML=h;}else{document.getElementById('ht-list')?.insertAdjacentHTML('beforeend',h);const btn=document.getElementById('ht-more');if(btn){if(!j.has_more)btn.remove();else{btn.disabled=false;btn.textContent='Tải thêm ▼';}}}sources.forEach((s,i)=>{const idx=page*8+i;if(!s.url)return;
|
| 381 |
function loadMoreHashtag(){_htPage++;const btn=document.getElementById('ht-more');if(btn){btn.disabled=true;btn.textContent='Đang tải...';}showHashtagSources(_htTopic,_htPage);}
|
| 382 |
async function rewriteHashtag(topic){const btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang tổng hợp...';}try{const r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');toast('✅ Đã đăng Tường AI!');if(btn)btn.textContent='✅ Đăng thành công!';if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message);if(btn){btn.disabled=false;btn.textContent='🤖 Rewrite AI';}}}
|
| 383 |
async function loadLivescore(tab){document.querySelectorAll('.ls-tab').forEach(t=>t.classList.remove('active'));document.querySelector(`.ls-tab[data-tab="${tab}"]`)?.classList.add('active');const el=document.getElementById('ls-content');if(!el)return;el.innerHTML='<div class="loading">Đang tải...</div>';let ep='/api/livescore/'+tab;if(tab.startsWith('bxh_'))ep='/api/livescore/standings/'+tab.replace('bxh_','');try{const r=await fetch(ep);const d=await r.json();el.innerHTML=d.html&&d.html.length>50?d.html:'<div class="loading">Không có dữ liệu</div>';bindMatchClicks(el);}catch(e){el.innerHTML='<div class="loading">Lỗi</div>';}}
|
| 384 |
-
function bindMatchClicks(el){
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
function openMatch(id,url){if(!id)return;_currentEventId=id;if(url)_currentMatchUrl=url;document.getElementById('match-overlay').classList.add('active');document.body.style.overflow='hidden';loadMatchTab('detail')}
|
| 386 |
function closeMatch(){document.getElementById('match-overlay').classList.remove('active');document.body.style.overflow=''}
|
| 387 |
async function loadMatchTab(tab){document.querySelectorAll('.mo-tab').forEach(t=>t.classList.remove('active'));document.querySelectorAll('.mo-tab').forEach(t=>{if((tab==='comm'&&t.textContent==='Diễn biến')||(tab==='stats'&&t.textContent==='Thống kê')||(tab==='detail'&&t.textContent.includes('Chi tiết')))t.classList.add('active')});const el=document.getElementById('mo-body');if(!el)return;el.innerHTML='<div class="loading">Đang tải...</div>';try{let apiUrl;if(tab==='stats')apiUrl=`/api/match/${_currentEventId}/stats`;else if(tab==='comm')apiUrl=`/api/match/${_currentEventId}/commentaries`;else{apiUrl=`/api/match/${_currentEventId}/detail`;if(_currentMatchUrl)apiUrl+='?url='+encodeURIComponent(_currentMatchUrl)}const r=await fetch(apiUrl);if(!r.ok){el.innerHTML='<div class="loading">Lỗi máy chủ ('+r.status+')</div>';return}const d=await r.json();if(d.error){el.innerHTML='<div class="loading">'+esc(d.error)+'</div>';return}if(tab==='detail'&&typeof renderMatchDetail==='function'){renderMatchDetail(el,d);return}el.innerHTML=d.html||'<div class="loading">Không có dữ liệu</div>'}catch(e){el.innerHTML='<div class="loading">Lỗi</div>'}}
|
| 388 |
-
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]))}
|
| 389 |
-
function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')}
|
| 390 |
-
function switchCat(id){document.querySelectorAll('.cat').forEach(c=>c.classList.remove('active'));document.querySelector(`[data-cat="${id}"]`)?.classList.add('active');document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.querySelectorAll('video').forEach(v=>{v.pause();if(v._hls){v._hls.destroy();v._hls=null}});document.querySelectorAll('iframe[data-yt-src]').forEach(f=>{f.src=''});if(id==='home')document.getElementById('view-home').classList.add('active');else if(id==='news-all'){document.getElementById('view-cat').classList.add('active');loadNewsTab()}else{document.getElementById('view-cat').classList.add('active');loadCat(id)}}
|
| 391 |
-
function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}}
|
| 392 |
-
function doShare(title,url,img){const shareUrl=SPACE+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||'');if(navigator.share)navigator.share({title,url:shareUrl}).catch(()=>{});else navigator.clipboard.writeText(shareUrl).then(()=>toast('Đã sao chép!')).catch(()=>{})}
|
| 393 |
async function doInteract(videoId,type){try{const r=await fetch('/api/v2/interact',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:videoId,type})});return await r.json();}catch(e){return null;}}
|
| 394 |
async function getInteractions(videoId){try{return await fetch('/api/v2/interactions?id='+encodeURIComponent(videoId)).then(r=>r.json());}catch(e){return{views:0,likes:0,comments:0};}}
|
| 395 |
async function getComments(videoId){try{const j=await fetch('/api/v2/comments?id='+encodeURIComponent(videoId)).then(r=>r.json());return j.comments||[];}catch(e){return[];}}
|
| 396 |
async function postComment(videoId,text){try{const j=await fetch('/api/v2/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:videoId,text})}).then(r=>r.json());return j.comments||[];}catch(e){return[];}}
|
| 397 |
-
function buildTikTokSlide(opts){return`<div class="tiktok-slide" data-vid="${esc(opts.videoId)}">${opts.vtag}<div class="tiktok-bottom"><span class="badge ${opts.badgeClass||'badge-fpt'}">${opts.badge||''}</span><p class="tiktok-title">${esc(opts.title)}</p></div><div class="tiktok-right"><button class="tiktok-right-btn" onclick="event.stopPropagation();doView('${esc(opts.videoId)}',this)"><div class="icon">👁</div><div class="count" id="vc-${opts.idx}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();doLike('${esc(opts.videoId)}',this)"><div class="icon">❤️</div><div class="count" id="lc-${opts.idx}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();toggleComments('${esc(opts.videoId)}',${opts.idx})"><div class="icon">💬</div><div class="count" id="cc-${opts.idx}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();doShare('${esc(opts.title)}','${esc(opts.shareUrl||'')}','')"><div class="icon">📤</div></button>
|
| 398 |
async function doView(videoId,btn){const j=await doInteract(videoId,'view');if(j){const c=btn.querySelector('.count');if(c)c.textContent=fmtNum(j.views);}}
|
| 399 |
async function doLike(videoId,btn){const j=await doInteract(videoId,'like');if(j){const c=btn.querySelector('.count');if(c)c.textContent=fmtNum(j.likes);}}
|
| 400 |
function fmtNum(n){if(!n)return'0';if(n>=1000000)return(n/1000000).toFixed(1)+'M';if(n>=1000)return(n/1000).toFixed(1)+'K';return String(n);}
|
|
@@ -402,24 +283,91 @@ async function loadCounters(videoIds){for(let i=0;i<videoIds.length;i++){const i
|
|
| 402 |
async function toggleComments(videoId,idx){const panel=document.getElementById('cmt-inline-'+idx);if(!panel)return;if(panel.style.display!=='none'){panel.style.display='none';return;}panel.style.display='block';panel.innerHTML='<div style="padding:8px;color:#888;font-size:11px">Đang tải...</div>';const cmts=await getComments(videoId);renderInlineComments(panel,videoId,idx,cmts);}
|
| 403 |
function renderInlineComments(panel,videoId,idx,cmts){let h='<div class="inline-cmt-header"><span>💬 Bình luận</span><button onclick="document.getElementById(\'cmt-inline-'+idx+'\').style.display=\'none\'">✕</button></div><div class="inline-cmt-list">';if(cmts.length){cmts.slice(-30).forEach(c=>{h+=`<div class="inline-cmt-item"><span class="inline-cmt-time">${c.time||''}</span>${esc(c.text)}</div>`;});}else{h+='<div style="color:#777;font-size:11px;padding:4px">Chưa có bình luận</div>';}h+=`</div><div class="inline-cmt-input"><input id="cmt-input-${idx}" placeholder="Viết bình luận..." onkeydown="if(event.key==='Enter')submitInlineCmt('${esc(videoId)}',${idx})"><button onclick="submitInlineCmt('${esc(videoId)}',${idx})">Gửi</button></div>`;panel.innerHTML=h;const list=panel.querySelector('.inline-cmt-list');if(list)list.scrollTop=list.scrollHeight;}
|
| 404 |
async function submitInlineCmt(videoId,idx){const inp=document.getElementById('cmt-input-'+idx);if(!inp)return;const text=inp.value.trim();if(!text)return;inp.value='';inp.disabled=true;const cmts=await postComment(videoId,text);inp.disabled=false;const panel=document.getElementById('cmt-inline-'+idx);if(panel)renderInlineComments(panel,videoId,idx,cmts);const cc=document.getElementById('cc-'+idx);if(cc)cc.textContent=fmtNum(cmts.length);}
|
| 405 |
-
function toggle169View(videoId,iconId){const slide=videoId?document.querySelector(`.tiktok-slide[data-vid="${videoId}"]`):null;if(slide){slide.classList.toggle('ratio-wide');const iconEl=iconId?document.getElementById(iconId):null;if(iconEl)iconEl.textContent=slide.classList.contains('ratio-wide')?'📺':'🖥️';else{const btn=slide.querySelector('.tiktok-right-btn .icon');if(btn)btn.textContent=slide.classList.contains('ratio-wide')?'📺':'🖥️';}return}document.querySelectorAll('.tiktok-slide.ratio-wide').forEach(s=>s.classList.remove('ratio-wide'));document.querySelectorAll('.tiktok-slide').forEach(s=>s.classList.add('ratio-wide'));document.querySelectorAll('.tiktok-right-btn .icon').forEach(b=>{if(b.textContent==='🖥️')b.textContent='📺';})}
|
| 406 |
function initTikTokFeed(){const feed=document.getElementById('tiktok-feed');if(!feed)return;const slides=feed.querySelectorAll('.tiktok-slide');let cur=-1;function act(i){if(i===cur)return;slides.forEach((sl,idx)=>{const v=sl.querySelector('video');const fr=sl.querySelector('iframe');if(idx===i){if(v&&v.dataset.hls&&!v._hls&&typeof Hls!=='undefined'&&Hls.isSupported()){const hls=new Hls();hls.loadSource(v.dataset.hls);hls.attachMedia(v);hls.on(Hls.Events.MANIFEST_PARSED,()=>v.play().catch(()=>{}));v._hls=hls}else if(v)v.play().catch(()=>{});if(fr&&!fr.src&&fr.dataset.ytSrc)fr.src=fr.dataset.ytSrc;const vid=sl.dataset.vid;if(vid&&!sl._viewed){sl._viewed=true;doInteract(vid,'view').then(j=>{if(j){const vc=document.getElementById('vc-'+idx);if(vc)vc.textContent=fmtNum(j.views);}});}}else{if(v){v.pause();if(v._hls){v._hls.destroy();v._hls=null}}if(fr&&fr.src)fr.src=''}});cur=i}let sT;feed.addEventListener('scroll',()=>{clearTimeout(sT);sT=setTimeout(()=>{const rect=feed.getBoundingClientRect(),ctr=rect.top+rect.height/2;let best=-1,bestD=1e9;slides.forEach((sl,i)=>{const d=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(d<bestD){bestD=d;best=i}});if(best>=0)act(best)},150)});setTimeout(()=>act(0),400);slides.forEach(sl=>{const v=sl.querySelector('video');if(v)v.addEventListener('click',e=>{e.preventDefault();v.paused?v.play().catch(()=>{}):v.pause()})});const ids=[...slides].map(sl=>sl.dataset.vid||'');loadCounters(ids)}
|
| 407 |
-
async function openHighlightFeed(league,idx,forceUrl){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải...</div>';let articles=(_hlLeagueData||{})[league]||[];if(!articles.length){try{articles=await fetch('/api/highlights/'+league).then(r=>r.json())}catch(e){articles=[]}}if(!articles.length){el.innerHTML='<div class="loading">Không có video</div>';return}const vids=[];const results=await Promise.all(articles.map(async(a,i)=>{try{const r=await fetch('/api/video_url?url='+encodeURIComponent(a.link)
|
| 408 |
-
async function openYTShortsFeed(
|
| 409 |
-
async function openShortAIFeed(
|
| 410 |
-
function
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 421 |
|
| 422 |
-
function
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// === VNEWS Frontend v2 - Optimized for speed ===
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
+
// === LOAD HOME - Fast: immediate shell + parallel fetch ===
|
| 4 |
function _fetchWithTimeout(url, ms){
|
| 5 |
return new Promise((resolve,reject)=>{
|
| 6 |
const ctrl=new AbortController();
|
|
|
|
| 17 |
const homeEl = document.getElementById('view-home');
|
| 18 |
if(!homeEl) return;
|
| 19 |
|
| 20 |
+
// Build shell IMMEDIATELY — no skeleton, no delay
|
| 21 |
homeEl.innerHTML =
|
| 22 |
'<div id="home-featured-area"></div>'
|
| 23 |
+'<div class="ai-compose"><div class="ai-compose-title">🤖 AI viết bài</div><div class="ai-compose-row"><input id="topic-input" placeholder="Nhập chủ đề..."><button onclick="searchTopic()">Tìm nguồn</button></div><div class="ai-compose-row"><input id="url-input" placeholder="Dán URL bài viết..."><button class="secondary" onclick="rewriteUrl()">Rewrite</button></div><div id="hot-topics" class="hot-topic-row"></div></div>'
|
|
|
|
| 28 |
|
| 29 |
const afterEl = homeEl.querySelector('#home-after-wc');
|
| 30 |
|
| 31 |
+
// Start critical loads immediately
|
| 32 |
loadLivescore('today');
|
| 33 |
loadHotTopics();
|
| 34 |
|
| 35 |
+
// Fetch all data in parallel with shorter timeouts
|
| 36 |
+
const [featuredData, shortsData, wallData, hlLeagues, aiData, wcData] = await Promise.allSettled([
|
| 37 |
_fetchWithTimeout('/api/livescore/featured', 5000),
|
| 38 |
+
_fetchWithTimeout('/api/shorts', 8000),
|
| 39 |
_fetchWithTimeout('/api/wall', 5000),
|
| 40 |
_fetchWithTimeout('/api/highlights/leagues', 10000),
|
| 41 |
+
_fetchWithTimeout('/api/genk_ai', 8000),
|
| 42 |
_fetchWithTimeout('/api/wc2026', 8000),
|
| 43 |
]).then(results => results.map(r => r.status === 'fulfilled' ? r.value : null));
|
| 44 |
|
| 45 |
+
// Render featured match
|
| 46 |
if(featuredData && featuredData.home){
|
| 47 |
const sc=featuredData.status==='live'?'':'upcoming';
|
| 48 |
const st=featuredData.status==='live'?`🔴 ${featuredData.minute||'LIVE'}`:`⏰ ${featuredData.time}`;
|
| 49 |
const area=document.getElementById('home-featured-area');
|
| 50 |
+
if(area) area.innerHTML=`<div class="featured-match" onclick="openMatch('${featuredData.event_id}')"><div class="fm-league">${featuredData.league}</div><div class="fm-teams"><div class="fm-team"><img src="${featuredData.home_logo}" onerror="this.style.display='none'"><span>${featuredData.home}</span></div><div class="fm-score">${featuredData.score||'VS'}</div><div class="fm-team"><img src="${featuredData.away_logo}" onerror="this.style.display='none'"><span>${featuredData.away}</span></div></div><div class="fm-status ${sc}">${st}</div></div>`;
|
| 51 |
}
|
| 52 |
|
| 53 |
+
// Store globally
|
| 54 |
+
_shortsData = shortsData || [];
|
| 55 |
_wallPosts = (wallData && wallData.posts) || [];
|
| 56 |
_hlLeagueData = hlLeagues || {};
|
| 57 |
_wc2026Data = wcData;
|
| 58 |
|
| 59 |
+
// Render WC if data arrived
|
| 60 |
if(wcData) switchWCTab('news');
|
| 61 |
|
| 62 |
+
// Render sections into the after-wc area
|
| 63 |
+
_renderShortsIn(afterEl);
|
| 64 |
_renderWallIn(afterEl);
|
| 65 |
_renderHLIn(afterEl);
|
| 66 |
+
if(aiData && aiData.length) _renderSlidesIn('ai-articles','Ứng dụng AI','🤖',aiData,afterEl);
|
| 67 |
}
|
| 68 |
|
| 69 |
function _renderSlidesIn(key, label, emoji, vids, afterEl){
|
|
|
|
| 74 |
const isHL = key==='world-cup'||key==='premier-league'||key==='champions-league'||key==='la-liga'||key==='serie-a'||key==='bundesliga'||key==='friendly';
|
| 75 |
vids.slice(0,isHL?8:12).forEach((a,i)=>{
|
| 76 |
if(isHL){
|
| 77 |
+
h+=`<div class="slider-item" onclick="openHighlightFeed('${key}',${i})"><div class="slider-thumb">${a.img?`<img src="${a.img}" loading="lazy">`:''}<div class="card-play">▶</div></div><div class="slider-title">${esc(a.title)}</div></div>`;
|
| 78 |
} else {
|
| 79 |
+
h+=`<div class="slider-item" onclick="readArticle('${esc(a.link)}')"><div class="slider-thumb">${a.img?`<img src="${a.img}" loading="lazy">`:''}</div><div class="slider-title">${esc(a.title)}</div></div>`;
|
| 80 |
}
|
| 81 |
});
|
| 82 |
h+='</div>';
|
|
|
|
| 84 |
afterEl.parentNode.insertBefore(wrap, afterEl);
|
| 85 |
}
|
| 86 |
|
| 87 |
+
function _renderShortsIn(afterEl){
|
| 88 |
+
if(!_shortsData||!_shortsData.length||!afterEl) return;
|
| 89 |
+
const mixed=interleaveShorts(_shortsData);
|
| 90 |
+
if(!mixed.length) return;
|
| 91 |
+
const wrap=document.createElement('div');
|
| 92 |
+
wrap.className='slider-wrap';
|
| 93 |
+
let h=`<div class="slider-header"><span class="slider-label">📱 Shorts Dân trí & SKĐS</span><span class="slider-note">Mới nhất · xen kẽ</span></div><div class="slider-track">`;
|
| 94 |
+
mixed.slice(0,30).forEach((a,i)=>{
|
| 95 |
+
const badge=a.channel==='baosuckhoedoisongboyte'?'SKĐS':'Dân trí';
|
| 96 |
+
h+=`<div class="slider-item shorts-item" onclick="openYTShortsFeed(${i})"><div class="slider-thumb shorts-thumb">${a.img?`<img src="${a.img}" loading="lazy">`:''}<div class="card-play">▶</div></div><div class="slider-title"><span style="color:#f0c040;font-size:8px">${badge}</span> ${esc(a.title)}</div></div>`;
|
| 97 |
+
});
|
| 98 |
+
h+='</div>';wrap.innerHTML=h;
|
| 99 |
+
afterEl.parentNode.insertBefore(wrap,afterEl);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
function _renderWallIn(afterEl){
|
| 103 |
if(!_wallPosts||!_wallPosts.length||!afterEl) return;
|
| 104 |
const posts=_wallPosts;
|
| 105 |
+
const aiShorts=posts.filter(p=>p.video);
|
| 106 |
+
if(aiShorts.length){
|
| 107 |
+
const wrap=document.createElement('div');
|
| 108 |
+
wrap.className='slider-wrap';
|
| 109 |
+
let h='<div class="slider-header"><span class="slider-label">🎬 Short AI</span></div><div class="slider-track">';
|
| 110 |
+
aiShorts.slice(0,20).forEach((p,i)=>{h+=`<div class="slider-item shorts-item" onclick="openShortAIFeed(${i})"><div class="slider-thumb shorts-thumb"><video src="${p.video}" muted preload="metadata"></video><div class="card-play">▶</div></div><div class="slider-title">${esc(p.title)}</div></div>`;});
|
| 111 |
+
h+='</div>';wrap.innerHTML=h;
|
| 112 |
+
afterEl.parentNode.insertBefore(wrap,afterEl);
|
| 113 |
+
}
|
| 114 |
const wrap=document.createElement('div');
|
| 115 |
wrap.className='slider-wrap';wrap.id='ai-wall-wrap';
|
| 116 |
let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track" id="ai-wall-track">';
|
|
|
|
| 131 |
|
| 132 |
// === WALL POST HELPERS ===
|
| 133 |
function makeWallItem(p,i){
|
| 134 |
+
const hasVideo = p.video && p.video.length > 0;
|
| 135 |
+
const thumbContent = p.img
|
| 136 |
+
? `<img src="${esc(p.img)}" loading="lazy" onerror="this.style.display='none'">`
|
| 137 |
+
: (hasVideo ? `<video src="${esc(p.video)}" muted></video>` : '');
|
| 138 |
+
const videoBadge = hasVideo ? `<div class="wall-video-badge">🎬</div>` : '';
|
| 139 |
+
const videoBtn = hasVideo
|
| 140 |
+
? `<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed(${i})">▶ Xem Short</button>`
|
| 141 |
+
: `<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo('${esc(p.id||i)}',this)">🎬 Tạo Video</button>`;
|
| 142 |
+
return `<div class="wall-item" id="wall-item-${esc(p.id||i)}"><div class="wall-thumb">${thumbContent}${videoBadge}</div><div class="wall-title">${esc(p.title)}</div><div class="wall-text">${esc((p.text||'').slice(0,180))}</div><div class="wall-actions"><button class="primary" onclick="readWallPost(${i})">Xem</button>${videoBtn}</div></div>`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
}
|
| 144 |
|
| 145 |
+
async function makeShortVideo(postId, btn, voice, speed){
|
| 146 |
if(!postId)return;
|
| 147 |
const origText = btn ? btn.textContent : '🎬 Tạo Video';
|
| 148 |
if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo...';}
|
| 149 |
toast('⏳ Đang tạo video shorts...');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
try{
|
| 151 |
+
let url = '/api/ai/short/'+encodeURIComponent(postId);
|
| 152 |
+
const params = [];
|
| 153 |
+
if(voice) params.push('voice='+encodeURIComponent(voice));
|
| 154 |
+
if(speed) params.push('speed='+encodeURIComponent(speed));
|
| 155 |
+
if(params.length) url += '?' + params.join('&');
|
| 156 |
+
const r = await fetch(url, {method:'POST'});
|
| 157 |
const j = await r.json();
|
| 158 |
if(!r.ok || j.error) throw new Error(j.error||'Lỗi tạo video');
|
| 159 |
toast('✅ Đã tạo video shorts!');
|
| 160 |
const p = _wallPosts.find(x => String(x.id) === String(postId));
|
| 161 |
if(p){
|
| 162 |
p.video = j.video;
|
|
|
|
|
|
|
| 163 |
const itemId = 'wall-item-'+postId;
|
| 164 |
const el = document.getElementById(itemId);
|
| 165 |
if(el){
|
| 166 |
const idx = _wallPosts.indexOf(p);
|
| 167 |
+
el.outerHTML = makeWallItem(p, idx);
|
| 168 |
+
const newEl = document.getElementById(itemId);
|
| 169 |
+
if(newEl) newEl.className = 'wall-item wall-item-new';
|
| 170 |
}
|
| 171 |
}
|
| 172 |
+
refreshShortAISlider();
|
| 173 |
}catch(e){
|
| 174 |
toast('❌ '+e.message);
|
| 175 |
if(btn){btn.disabled=false;btn.textContent=origText;}
|
| 176 |
}
|
| 177 |
}
|
| 178 |
|
| 179 |
+
function refreshShortAISlider(){
|
| 180 |
+
const aiShorts = _wallPosts.filter(p=>p.video);
|
| 181 |
+
let shortAISection = document.getElementById('short-ai-section');
|
| 182 |
+
if(aiShorts.length === 0){
|
| 183 |
+
if(shortAISection) shortAISection.remove();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
return;
|
| 185 |
}
|
| 186 |
+
if(shortAISection){
|
| 187 |
+
const track = shortAISection.querySelector('.slider-track');
|
| 188 |
+
if(track){
|
| 189 |
+
let h = '';
|
| 190 |
+
aiShorts.slice(0,20).forEach((p,i)=>{
|
| 191 |
+
h+=`<div class="slider-item shorts-item" onclick="openShortAIFeed(${i})"><div class="slider-thumb shorts-thumb"><video src="${esc(p.video)}" muted preload="metadata"></video><div class="card-play">▶</div></div><div class="slider-title">${esc(p.title)}</div></div>`;
|
| 192 |
+
});
|
| 193 |
+
track.innerHTML = h;
|
|
|
|
|
|
|
| 194 |
}
|
|
|
|
| 195 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
|
| 198 |
function prependWallPost(post){
|
| 199 |
_wallPosts.unshift(post);
|
|
|
|
| 204 |
if(homeEl){
|
| 205 |
let insertBefore=homeEl.querySelector('.slider-wrap');
|
| 206 |
const newWrap=document.createElement('div');
|
| 207 |
+
newWrap.className='slider-wrap';
|
| 208 |
+
newWrap.id='ai-wall-wrap';
|
| 209 |
newWrap.innerHTML=`<div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track" id="ai-wall-track">${makeWallItem(post,0)}</div>`;
|
| 210 |
if(insertBefore) homeEl.insertBefore(newWrap,insertBefore);
|
| 211 |
else homeEl.appendChild(newWrap);
|
|
|
|
| 214 |
}
|
| 215 |
return;
|
| 216 |
}
|
| 217 |
+
const div=document.createElement('div');
|
| 218 |
+
div.className='wall-item wall-item-new';
|
| 219 |
+
div.id='wall-item-'+(post.id||'new-'+Date.now());
|
| 220 |
+
const hasVideo = post.video && post.video.length > 0;
|
| 221 |
+
const thumbContent = post.img
|
| 222 |
+
? `<img src="${esc(post.img)}" loading="lazy" onerror="this.style.display='none'">`
|
| 223 |
+
: (hasVideo ? `<video src="${esc(post.video)}" muted></video>` : '');
|
| 224 |
+
const videoBadge = hasVideo ? `<div class="wall-video-badge">🎬</div>` : '';
|
| 225 |
+
const videoBtn = hasVideo
|
| 226 |
+
? `<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed(0)">▶ Xem Short</button>`
|
| 227 |
+
: `<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo('${esc(post.id)}',this)">🎬 Tạo Video</button>`;
|
| 228 |
+
div.innerHTML=`<div class="wall-thumb">${thumbContent}${videoBadge}</div><div class="wall-title">${esc(post.title)}</div><div class="wall-text">${esc((post.text||'').slice(0,180))}</div><div class="wall-actions"><button class="primary" onclick="readWallPost(0)">Xem</button>${videoBtn}</div>`;
|
| 229 |
+
track.prepend(div);
|
| 230 |
track.scrollTo({left:0,behavior:'smooth'});
|
| 231 |
+
if(hasVideo) refreshShortAISlider();
|
| 232 |
}
|
| 233 |
|
| 234 |
+
// === REST OF FUNCTIONS ===
|
| 235 |
+
let _shortsData=[];
|
| 236 |
let _wallPosts=[];
|
| 237 |
let _currentView='home';
|
| 238 |
let _currentEventId=null;
|
| 239 |
let _currentMatchUrl=null;
|
| 240 |
+
function interleaveShorts(shorts){const dt=shorts.filter(s=>s.channel==='baodantri7941');const sk=shorts.filter(s=>s.channel==='baosuckhoedoisongboyte');const result=[];let i=0,j=0;while(i<dt.length||j<sk.length){if(i<dt.length)result.push(dt[i++]);if(j<sk.length)result.push(sk[j++]);}return result;}
|
| 241 |
let _htPage=0,_htTopic='';
|
| 242 |
+
async function loadHotTopics(){const j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));const el=document.getElementById('hot-topics');if(!el)return;el.innerHTML=(j.topics||[]).slice(0,18).map(t=>{const topicText=t.topic||t.label.replace(/^#/,'');return`<button class="hot-chip" onclick="searchTopic('${topicText.replace(/'/g,"\\'")}')">${esc(t.label)}</button>`;}).join('');if(j.topics&&j.topics[0]){const firstTopic=j.topics[0].topic||j.topics[0].label.replace(/^#/,'');setTimeout(()=>searchTopic(firstTopic),800);}}
|
| 243 |
function searchTopic(topic){if(!topic){topic=document.getElementById('topic-input')?.value.trim();if(!topic){alert('Nhập chủ đề');return;}}document.getElementById('topic-input').value='';_htTopic=topic;_htPage=0;showHashtagSources(topic,0);}
|
| 244 |
+
async function showHashtagSources(topic,page){const box=document.getElementById('hashtag-box');if(!box)return;if(page===0)box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div class="hashtag-loading"><div class="hashtag-spinner"></div>Đang tìm...</div></div>`;try{const r=await fetch(`/api/hashtag/sources?topic=${encodeURIComponent(topic)}&page=${page}`);const j=await r.json();const sources=j.sources||[];if(!sources.length&&page===0){box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div style="color:#888;padding:8px">Không tìm được bài viết liên quan</div></div>`;return;}let h='';if(page===0)h=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)} <span style="font-size:10px;color:#888">(${j.total} bài từ 8 nguồn)</span></h3><div id="ht-list">`;sources.forEach((s,i)=>{const idx=page*8+i;h+=`<div class="hashtag-src-item" onclick="readArticle('${esc(s.url)}')"><div class="hashtag-src-img" id="ht-img-${idx}"></div><div class="hashtag-src-text"><div class="hashtag-src-title">${esc(s.title)}</div><div class="hashtag-src-via">${esc(s.via||'')}</div></div></div>`;});if(page===0){h+=`</div><button class="hashtag-rewrite-btn" onclick="rewriteHashtag('${esc(topic).replace(/'/g,"\\'")}')">🤖 Rewrite AI tổng hợp & đăng tường</button>`;if(j.has_more)h+=`<button class="hashtag-load-more" id="ht-more" onclick="loadMoreHashtag()">Tải thêm ▼</button>`;h+=`</div>`;box.innerHTML=h;}else{document.getElementById('ht-list')?.insertAdjacentHTML('beforeend',h);const btn=document.getElementById('ht-more');if(btn){if(!j.has_more)btn.remove();else{btn.disabled=false;btn.textContent='Tải thêm ▼';}}}sources.forEach((s,i)=>{const idx=page*8+i;if(!s.url)return;fetch('/api/article?url='+encodeURIComponent(s.url)).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){const el=document.getElementById('ht-img-'+idx);if(el)el.innerHTML=`<img src="${esc(d.og_image||d.img)}" onerror="this.style.display='none'">`;}}).catch(()=>{});});}catch(e){box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div style="color:#e74c3c;padding:8px">Lỗi: ${esc(e.message)}</div></div>`;}}
|
| 245 |
function loadMoreHashtag(){_htPage++;const btn=document.getElementById('ht-more');if(btn){btn.disabled=true;btn.textContent='Đang tải...';}showHashtagSources(_htTopic,_htPage);}
|
| 246 |
async function rewriteHashtag(topic){const btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang tổng hợp...';}try{const r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');toast('✅ Đã đăng Tường AI!');if(btn)btn.textContent='✅ Đăng thành công!';if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message);if(btn){btn.disabled=false;btn.textContent='🤖 Rewrite AI';}}}
|
| 247 |
async function loadLivescore(tab){document.querySelectorAll('.ls-tab').forEach(t=>t.classList.remove('active'));document.querySelector(`.ls-tab[data-tab="${tab}"]`)?.classList.add('active');const el=document.getElementById('ls-content');if(!el)return;el.innerHTML='<div class="loading">Đang tải...</div>';let ep='/api/livescore/'+tab;if(tab.startsWith('bxh_'))ep='/api/livescore/standings/'+tab.replace('bxh_','');try{const r=await fetch(ep);const d=await r.json();el.innerHTML=d.html&&d.html.length>50?d.html:'<div class="loading">Không có dữ liệu</div>';bindMatchClicks(el);}catch(e){el.innerHTML='<div class="loading">Lỗi</div>';}}
|
| 248 |
+
function bindMatchClicks(el){
|
| 249 |
+
el.querySelectorAll('.match-detail').forEach(md=>{
|
| 250 |
+
md.style.cursor='pointer';
|
| 251 |
+
md.addEventListener('click',function(e){
|
| 252 |
+
const statusA=this.querySelector('.status a');
|
| 253 |
+
const teamA=this.querySelector('.teams a[href*="/tran-dau/"]');
|
| 254 |
+
const a = statusA || teamA;
|
| 255 |
+
if(a){
|
| 256 |
+
e.preventDefault();
|
| 257 |
+
e.stopPropagation();
|
| 258 |
+
const href=a.getAttribute('href')||'';
|
| 259 |
+
const m=href.match(/\/tran-dau\/(\d+)\//);
|
| 260 |
+
if(m){
|
| 261 |
+
const fullUrl=href.startsWith('http')?href:'https://bongda.com.vn'+href;
|
| 262 |
+
openMatch(m[1],fullUrl);
|
| 263 |
+
}
|
| 264 |
+
}
|
| 265 |
+
});
|
| 266 |
+
});
|
| 267 |
+
el.querySelectorAll('a').forEach(a=>{
|
| 268 |
+
a.addEventListener('click',e=>{e.preventDefault();e.stopPropagation()});
|
| 269 |
+
});
|
| 270 |
+
}
|
| 271 |
function openMatch(id,url){if(!id)return;_currentEventId=id;if(url)_currentMatchUrl=url;document.getElementById('match-overlay').classList.add('active');document.body.style.overflow='hidden';loadMatchTab('detail')}
|
| 272 |
function closeMatch(){document.getElementById('match-overlay').classList.remove('active');document.body.style.overflow=''}
|
| 273 |
async function loadMatchTab(tab){document.querySelectorAll('.mo-tab').forEach(t=>t.classList.remove('active'));document.querySelectorAll('.mo-tab').forEach(t=>{if((tab==='comm'&&t.textContent==='Diễn biến')||(tab==='stats'&&t.textContent==='Thống kê')||(tab==='detail'&&t.textContent.includes('Chi tiết')))t.classList.add('active')});const el=document.getElementById('mo-body');if(!el)return;el.innerHTML='<div class="loading">Đang tải...</div>';try{let apiUrl;if(tab==='stats')apiUrl=`/api/match/${_currentEventId}/stats`;else if(tab==='comm')apiUrl=`/api/match/${_currentEventId}/commentaries`;else{apiUrl=`/api/match/${_currentEventId}/detail`;if(_currentMatchUrl)apiUrl+='?url='+encodeURIComponent(_currentMatchUrl)}const r=await fetch(apiUrl);if(!r.ok){el.innerHTML='<div class="loading">Lỗi máy chủ ('+r.status+')</div>';return}const d=await r.json();if(d.error){el.innerHTML='<div class="loading">'+esc(d.error)+'</div>';return}if(tab==='detail'&&typeof renderMatchDetail==='function'){renderMatchDetail(el,d);return}el.innerHTML=d.html||'<div class="loading">Không có dữ liệu</div>'}catch(e){el.innerHTML='<div class="loading">Lỗi</div>'}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
async function doInteract(videoId,type){try{const r=await fetch('/api/v2/interact',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:videoId,type})});return await r.json();}catch(e){return null;}}
|
| 275 |
async function getInteractions(videoId){try{return await fetch('/api/v2/interactions?id='+encodeURIComponent(videoId)).then(r=>r.json());}catch(e){return{views:0,likes:0,comments:0};}}
|
| 276 |
async function getComments(videoId){try{const j=await fetch('/api/v2/comments?id='+encodeURIComponent(videoId)).then(r=>r.json());return j.comments||[];}catch(e){return[];}}
|
| 277 |
async function postComment(videoId,text){try{const j=await fetch('/api/v2/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:videoId,text})}).then(r=>r.json());return j.comments||[];}catch(e){return[];}}
|
| 278 |
+
function buildTikTokSlide(opts){return`<div class="tiktok-slide" data-vid="${esc(opts.videoId)}">${opts.vtag}<div class="tiktok-bottom"><span class="badge ${opts.badgeClass||'badge-fpt'}">${opts.badge||''}</span><p class="tiktok-title">${esc(opts.title)}</p></div><div class="tiktok-right"><button class="tiktok-right-btn" onclick="event.stopPropagation();doView('${esc(opts.videoId)}',this)"><div class="icon">👁</div><div class="count" id="vc-${opts.idx}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();doLike('${esc(opts.videoId)}',this)"><div class="icon">❤️</div><div class="count" id="lc-${opts.idx}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();toggleComments('${esc(opts.videoId)}',${opts.idx})"><div class="icon">💬</div><div class="count" id="cc-${opts.idx}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();doShare('${esc(opts.title)}','${esc(opts.shareUrl||'')}','')"><div class="icon">📤</div></button>${opts.extraBtn||''}</div><span class="tiktok-counter">${opts.idx+1}/${opts.total}</span><div class="inline-comments" id="cmt-inline-${opts.idx}" style="display:none"></div></div>`;}
|
| 279 |
async function doView(videoId,btn){const j=await doInteract(videoId,'view');if(j){const c=btn.querySelector('.count');if(c)c.textContent=fmtNum(j.views);}}
|
| 280 |
async function doLike(videoId,btn){const j=await doInteract(videoId,'like');if(j){const c=btn.querySelector('.count');if(c)c.textContent=fmtNum(j.likes);}}
|
| 281 |
function fmtNum(n){if(!n)return'0';if(n>=1000000)return(n/1000000).toFixed(1)+'M';if(n>=1000)return(n/1000).toFixed(1)+'K';return String(n);}
|
|
|
|
| 283 |
async function toggleComments(videoId,idx){const panel=document.getElementById('cmt-inline-'+idx);if(!panel)return;if(panel.style.display!=='none'){panel.style.display='none';return;}panel.style.display='block';panel.innerHTML='<div style="padding:8px;color:#888;font-size:11px">Đang tải...</div>';const cmts=await getComments(videoId);renderInlineComments(panel,videoId,idx,cmts);}
|
| 284 |
function renderInlineComments(panel,videoId,idx,cmts){let h='<div class="inline-cmt-header"><span>💬 Bình luận</span><button onclick="document.getElementById(\'cmt-inline-'+idx+'\').style.display=\'none\'">✕</button></div><div class="inline-cmt-list">';if(cmts.length){cmts.slice(-30).forEach(c=>{h+=`<div class="inline-cmt-item"><span class="inline-cmt-time">${c.time||''}</span>${esc(c.text)}</div>`;});}else{h+='<div style="color:#777;font-size:11px;padding:4px">Chưa có bình luận</div>';}h+=`</div><div class="inline-cmt-input"><input id="cmt-input-${idx}" placeholder="Viết bình luận..." onkeydown="if(event.key==='Enter')submitInlineCmt('${esc(videoId)}',${idx})"><button onclick="submitInlineCmt('${esc(videoId)}',${idx})">Gửi</button></div>`;panel.innerHTML=h;const list=panel.querySelector('.inline-cmt-list');if(list)list.scrollTop=list.scrollHeight;}
|
| 285 |
async function submitInlineCmt(videoId,idx){const inp=document.getElementById('cmt-input-'+idx);if(!inp)return;const text=inp.value.trim();if(!text)return;inp.value='';inp.disabled=true;const cmts=await postComment(videoId,text);inp.disabled=false;const panel=document.getElementById('cmt-inline-'+idx);if(panel)renderInlineComments(panel,videoId,idx,cmts);const cc=document.getElementById('cc-'+idx);if(cc)cc.textContent=fmtNum(cmts.length);}
|
|
|
|
| 286 |
function initTikTokFeed(){const feed=document.getElementById('tiktok-feed');if(!feed)return;const slides=feed.querySelectorAll('.tiktok-slide');let cur=-1;function act(i){if(i===cur)return;slides.forEach((sl,idx)=>{const v=sl.querySelector('video');const fr=sl.querySelector('iframe');if(idx===i){if(v&&v.dataset.hls&&!v._hls&&typeof Hls!=='undefined'&&Hls.isSupported()){const hls=new Hls();hls.loadSource(v.dataset.hls);hls.attachMedia(v);hls.on(Hls.Events.MANIFEST_PARSED,()=>v.play().catch(()=>{}));v._hls=hls}else if(v)v.play().catch(()=>{});if(fr&&!fr.src&&fr.dataset.ytSrc)fr.src=fr.dataset.ytSrc;const vid=sl.dataset.vid;if(vid&&!sl._viewed){sl._viewed=true;doInteract(vid,'view').then(j=>{if(j){const vc=document.getElementById('vc-'+idx);if(vc)vc.textContent=fmtNum(j.views);}});}}else{if(v){v.pause();if(v._hls){v._hls.destroy();v._hls=null}}if(fr&&fr.src)fr.src=''}});cur=i}let sT;feed.addEventListener('scroll',()=>{clearTimeout(sT);sT=setTimeout(()=>{const rect=feed.getBoundingClientRect(),ctr=rect.top+rect.height/2;let best=-1,bestD=1e9;slides.forEach((sl,i)=>{const d=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(d<bestD){bestD=d;best=i}});if(best>=0)act(best)},150)});setTimeout(()=>act(0),400);slides.forEach(sl=>{const v=sl.querySelector('video');if(v)v.addEventListener('click',e=>{e.preventDefault();v.paused?v.play().catch(()=>{}):v.pause()})});const ids=[...slides].map(sl=>sl.dataset.vid||'');loadCounters(ids)}
|
| 287 |
+
async function openHighlightFeed(league,idx,forceUrl){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải...</div>';let articles=(_hlLeagueData||{})[league]||[];if(!articles.length){try{articles=await fetch('/api/highlights/'+league).then(r=>r.json())}catch(e){articles=[]}}if(!articles.length){el.innerHTML='<div class="loading">Không có video</div>';return}const vids=[];const results=await Promise.all(articles.map(async(a,i)=>{try{const r=await fetch('/api/video_url?url='+encodeURIComponent(a.link));const v=await r.json();if(v&&v.src)return{...a,...v,_idx:i}}catch(e){}return null}));results.forEach(r=>{if(r)vids.push(r)});vids.sort((a,b)=>a._idx-b._idx);if(!vids.length){el.innerHTML='<div class="loading">Không tìm thấy video</div>';return}let ti=vids.findIndex(v=>v._idx===idx);if(ti<0)ti=0;const ordered=ti>0?[...vids.slice(ti),...vids.slice(0,ti)]:vids;let h=`<button class="back-btn" onclick="switchCat('home')">← Highlight</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">`;ordered.forEach((v,i)=>{const isYT=v.type==='youtube',isHLS=!isYT&&v.src?.includes('.m3u8'),poster=v.poster?` poster="${v.poster}"`:'';const vtag=isYT?`<iframe data-yt-src="${v.src}" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture"></iframe>`:isHLS?`<video playsinline preload="none"${poster} data-hls="${v.src}" loop controls></video>`:`<video playsinline preload="none"${poster} loop controls><source src="${v.src}" type="video/mp4"></video>`;const videoId='hl-'+league+'-'+(v.id||v._idx);h+=buildTikTokSlide({vtag,title:v.title,badge:'HL',badgeClass:'badge-fpt',videoId,idx:i,total:ordered.length,shareUrl:v.link||'',extraBtn:`<button class="tiktok-right-btn" onclick="event.stopPropagation();this.closest('.tiktok-slide').classList.toggle('ratio-wide')"><div class="icon">⬜</div><div class="count">16:9</div></button>`});});h+='</div></div>';el.innerHTML=h;initTikTokFeed();}
|
| 288 |
+
async function openYTShortsFeed(startIdx){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải...</div>';const arts=_shortsData.length?_shortsData:await fetch('/api/shorts').then(r=>r.json()).catch(()=>[]);if(!arts.length){el.innerHTML='<div class="loading">Không có shorts</div>';return}const ordered=startIdx>0?[...arts.slice(startIdx),...arts.slice(0,startIdx)]:arts;let h=`<button class="back-btn" onclick="switchCat('home')">← Shorts</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">`;ordered.forEach((v,i)=>{const id=v.id||'';const src=`https://www.youtube.com/embed/${id}?autoplay=1&rel=0&playsinline=1`;const vtag=`<iframe data-yt-src="${src}" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture"></iframe>`;const badge=v.channel==='baosuckhoedoisongboyte'?'SKĐS':'Dân trí';const videoId='yt-'+id;h+=buildTikTokSlide({vtag,title:v.title,badge,badgeClass:'badge-fpt',videoId,idx:i,total:ordered.length,shareUrl:'https://youtube.com/watch?v='+id});});h+='</div></div>';el.innerHTML=h;initTikTokFeed();}
|
| 289 |
+
async function openShortAIFeed(startIdx){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải...</div>';const wall=(await fetch('/api/wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];const vids=wall.filter(p=>p.video);if(!vids.length){el.innerHTML='<div class="loading">Chưa có Short AI</div>';return}const ordered=startIdx>0?[...vids.slice(startIdx),...vids.slice(0,startIdx)]:vids;let h=`<button class="back-btn" onclick="switchCat('home')">← Short AI</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">`;ordered.forEach((p,i)=>{const vtag=`<video src="${p.video}" playsinline loop controls></video>`;const videoId='ai-'+(p.id||i);h+=buildTikTokSlide({vtag,title:p.title,badge:'AI',badgeClass:'badge-ai',videoId,idx:i,total:ordered.length,shareUrl:SPACE});});h+='</div></div>';el.innerHTML=h;initTikTokFeed();}
|
| 290 |
+
async function readArticle(url){showView('view-article');const el=document.getElementById('view-article');el.innerHTML='<div class="loading">Đang tải...</div>';try{const r=await fetch('/api/article?url='+encodeURIComponent(url));const data=await r.json();if(data&&!data.error&&data.body&&data.body.length){_currentArticle={url,data};let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><h1 class="article-title">${esc(data.title)}</h1>`;if(data.summary)h+=`<div class="article-summary">${esc(data.summary)}</div>`;const seen={};data.body.forEach(b=>{if(b.type==='p')h+=`<p class="article-p">${b.text}</p>`;else if(b.type==='img'&&b.src&&!seen[b.src]){seen[b.src]=1;h+=`<img class="article-img" src="${esc(b.src)}" onerror="this.style.display='none'">`}else if(b.type==='heading')h+=`<h2 class="article-h2">${esc(b.text)}</h2>`});h+=`<div class="article-actions"><button class="primary" onclick="rewriteArticle()">🤖 Rewrite AI đăng tường</button><button onclick="doShare('${esc(data.title)}','${esc(url)}','${esc(data.og_image||'')}')">📤</button><button onclick="window.open('${esc(url)}','_blank')">🔗 Gốc</button></div><div class="article-ai-ask"><h3 style="font-size:14px;color:#5cb87a">🤖 Hỏi AI</h3><textarea id="ask-q" placeholder="Hỏi về bài viết..."></textarea><button onclick="askAI()">Hỏi</button><div id="ask-a" class="article-ai-answer"></div></div></div>`;el.innerHTML=h;window.scrollTo(0,0);return;}}catch(e){}el.innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="loading"><p>Không đọc được.</p><a href="${esc(url)}" target="_blank" style="color:#5cb87a">Mở gốc →</a></div>`;}
|
| 291 |
+
async function rewriteArticle(){const url=_currentArticle?.url;if(!url)return;toast('⏳ Đang rewrite...');try{const r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,context:document.querySelector('.article-view')?.innerText?.slice(0,14000)||''})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error);toast('✅ Đã đăng Tường AI!');if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message)}}
|
| 292 |
+
async function rewriteUrl(){const url=document.getElementById('url-input')?.value.trim();if(!url)return alert('Dán URL');toast('⏳ Đang rewrite...');try{const r=await fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error);toast('✅ Đã đăng!');document.getElementById('url-input').value='';if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message)}}
|
| 293 |
+
async function askAI(){const q=document.getElementById('ask-q')?.value.trim();if(!q)return alert('Nhập câu hỏi');const a=document.getElementById('ask-a');a.textContent='Đang hỏi...';try{const r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:_currentArticle?.url||'',question:q,context:document.querySelector('.article-view')?.innerText?.slice(0,12000)||''})});const j=await r.json();a.textContent=j.answer||'Không trả lời được';}catch(e){a.textContent='Lỗi: '+e.message}}
|
| 294 |
+
async function readWallPost(i){const p=_wallPosts[i];if(!p)return;showView('view-article');
|
| 295 |
+
const images = p.images || [];
|
| 296 |
+
let imgGallery = '';
|
| 297 |
+
if(images.length > 0){
|
| 298 |
+
imgGallery = '<div class="article-image-gallery">';
|
| 299 |
+
images.forEach((imgUrl, imgIdx) => {
|
| 300 |
+
if(imgIdx === 0){
|
| 301 |
+
imgGallery += `<img class="article-img article-hero-img" src="${esc(imgUrl)}" onerror="this.style.display='none" loading="eager">`;
|
| 302 |
+
} else {
|
| 303 |
+
if(imgIdx === 1) imgGallery += '<div class="gallery-thumbs">';
|
| 304 |
+
imgGallery += `<div class="gallery-thumb"><img src="${esc(imgUrl)}" onerror="this.parentElement.style.display='none'" loading="lazy"></div>`;
|
| 305 |
+
}
|
| 306 |
+
});
|
| 307 |
+
if(images.length > 1) imgGallery += '</div>';
|
| 308 |
+
imgGallery += '</div>';
|
| 309 |
+
}
|
| 310 |
+
const hasVideo = p.video && p.video.length > 0;
|
| 311 |
+
const voiceOptions = [
|
| 312 |
+
{id:'hoaimy', label:'🎙️ Nữ — Hoài My'},
|
| 313 |
+
{id:'namminh', label:'🎙️ Nam — Nam Minh'},
|
| 314 |
+
];
|
| 315 |
+
let voiceSelector = '';
|
| 316 |
+
if(!hasVideo){
|
| 317 |
+
voiceSelector = `<div class="tts-selector"><div class="tts-selector-label">🎙️ Chọn giọng đọc:</div><div class="tts-voice-btns">`;
|
| 318 |
+
voiceOptions.forEach(v=>{
|
| 319 |
+
voiceSelector += `<button class="tts-voice-btn" onclick="document.querySelectorAll('.tts-voice-btn').forEach(b=>b.classList.remove('active'));this.classList.add('active');document.getElementById('selected-voice').value='${v.id}'">${v.label}</button>`;
|
| 320 |
+
});
|
| 321 |
+
voiceSelector += `</div><div class="tts-speed-row"><span>Tốc độ:</span><select id="selected-speed"><option value="1.0">1.0x — Bình thường</option><option value="1.2" selected>1.2x — Nhanh</option><option value="1.5">1.5x — Rất nhanh</option><option value="0.8">0.8x — Chậm</option></select></div>`;
|
| 322 |
+
voiceSelector += `<input type="hidden" id="selected-voice" value="hoaimy"></div>`;
|
| 323 |
+
}
|
| 324 |
+
document.getElementById('view-article').innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${esc(p.title)}</h1>${imgGallery}<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>${hasVideo?`<video class="article-img" src="${esc(p.video)}" controls playsinline style="max-height:400px"></video>`:''}<div class="article-actions">${hasVideo?`<button onclick="openShortAIFeed(${i})">🎬 Xem Short</button>${voiceSelector}<button class="primary" onclick="makeShortVideo('${esc(p.id)}',this,document.getElementById('selected-voice')?.value,parseFloat(document.getElementById('selected-speed')?.value)||1.2)">🔄 Tạo lại Short</button>`:`${voiceSelector}<button class="primary" onclick="makeShortVideo('${esc(p.id)}',this,document.getElementById('selected-voice')?.value,parseFloat(document.getElementById('selected-speed')?.value)||1.2)">🎬 Tạo Video Shorts</button>`}<button onclick="doShare('${esc(p.title)}','${SPACE}','${esc(p.img||'')}')">📤</button></div></div>`;
|
| 325 |
+
const firstVoiceBtn = document.querySelector('.tts-voice-btn');
|
| 326 |
+
if(firstVoiceBtn) firstVoiceBtn.classList.add('active');
|
| 327 |
+
window.scrollTo(0,0)}
|
| 328 |
+
async function loadNewsTab(){const el=document.getElementById('view-cat');el.innerHTML='<div class="loading">Đang tải...</div>';try{const r=await fetch('/api/homepage');const news=await r.json();if(!news.length){el.innerHTML='<div class="loading">Không có tin</div>';return}const groups={};news.forEach(a=>{if(!groups[a.group])groups[a.group]=[];groups[a.group].push(a)});let h='';for(const[g,arts]of Object.entries(groups)){h+=`<div class="section-title">${g}</div><div class="grid">`;arts.slice(0,6).forEach(a=>{h+=`<div class="card" onclick="readArticle('${esc(a.link)}')"><div class="card-img">${a.img?`<img src="${a.img}" loading="lazy">`:''}</div><div class="card-body"><span class="badge badge-vne">${esc(a.source||'VnE')}</span><div class="card-title">${esc(a.title)}</div></div></div>`});h+='</div>'}el.innerHTML=h}catch(e){el.innerHTML='<div class="loading">Lỗi</div>'}}
|
| 329 |
+
async function loadCat(id){const el=document.getElementById('view-cat');el.innerHTML='<div class="loading">Đang tải...</div>';const arts=await fetch('/api/category/'+id).then(r=>r.json()).catch(()=>[]);if(!arts.length){el.innerHTML='<div class="loading">Không có tin</div>';return}let h='<div class="grid">';arts.forEach(a=>{h+=`<div class="card" onclick="readArticle('${esc(a.link)}')"><div class="card-img">${a.img?`<img src="${a.img}" loading="lazy">`:''}</div><div class="card-body"><span class="badge badge-vne">${esc(a.source||'')}</span><div class="card-title">${esc(a.title)}</div></div></div>`});h+='</div>';el.innerHTML=h}
|
| 330 |
+
fetch('/api/storage_status').then(r=>r.json()).then(j=>{if(!j.persistent){const home=document.getElementById('view-home');if(home){const w=document.createElement('div');w.className='storage-warn';w.innerHTML='⚠️ Persistent Storage chưa bật.';home.prepend(w)}}}).catch(()=>{});
|
| 331 |
+
|
| 332 |
+
(function(){
|
| 333 |
+
try{
|
| 334 |
+
var hash = window.location.hash;
|
| 335 |
+
if(hash && hash.length > 1){
|
| 336 |
+
var articleUrl = decodeURIComponent(hash.substring(1));
|
| 337 |
+
if(articleUrl.startsWith('http')){
|
| 338 |
+
history.replaceState(null, '', window.location.pathname);
|
| 339 |
+
setTimeout(function(){
|
| 340 |
+
if(typeof readArticle==='function') readArticle(articleUrl);
|
| 341 |
+
}, 1500);
|
| 342 |
+
}
|
| 343 |
+
}
|
| 344 |
+
}catch(e){}
|
| 345 |
+
})();
|
| 346 |
|
| 347 |
+
(function(){
|
| 348 |
+
try{
|
| 349 |
+
const pa=localStorage.getItem('pending_article');
|
| 350 |
+
const pv=localStorage.getItem('pending_video');
|
| 351 |
+
if(pa){
|
| 352 |
+
localStorage.removeItem('pending_article');
|
| 353 |
+
setTimeout(()=>{
|
| 354 |
+
if(typeof readArticle==='function') readArticle(pa);
|
| 355 |
+
},1500);
|
| 356 |
+
}
|
| 357 |
+
if(pv){
|
| 358 |
+
localStorage.removeItem('pending_video');
|
| 359 |
+
try{
|
| 360 |
+
const v=JSON.parse(pv);
|
| 361 |
+
if(v&&v.url) setTimeout(()=>{window.open(v.url,'_blank')},1500);
|
| 362 |
+
}catch(e){}
|
| 363 |
+
}
|
| 364 |
+
}catch(e){}
|
| 365 |
+
})();
|
| 366 |
+
|
| 367 |
+
if (document.readyState === 'loading') {
|
| 368 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 369 |
+
if (typeof loadHome === 'function') loadHome();
|
| 370 |
+
});
|
| 371 |
+
} else {
|
| 372 |
+
if (typeof loadHome === 'function') loadHome();
|
| 373 |
+
}
|
static/app_v2_shorts_fix.js
DELETED
|
@@ -1,2 +0,0 @@
|
|
| 1 |
-
// No-op - all functionality built into app_v2.js
|
| 2 |
-
(function(){})();
|
|
|
|
|
|
|
|
|
static/index_v2.html
CHANGED
|
@@ -10,12 +10,10 @@
|
|
| 10 |
<link rel="canonical" href="https://bep40-vnews.hf.space">
|
| 11 |
<link rel="stylesheet" href="/static/wc2026.css">
|
| 12 |
<script src="https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js"></script>
|
| 13 |
-
<style>
|
| 14 |
-
*{box-sizing:border-box;margin:0;padding:0}body{background:#111;color:#eee;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;overflow-x:hidden}.header{background:linear-gradient(135deg,#0d1117,#1a3a2a 50%,#8b7500);padding:12px;text-align:center}.header h1{font-size:18px;color:#fff}.header p{font-size:10px;color:#aaa}.cats{display:flex;overflow-x:auto;background:#1a1a1a;border-bottom:1px solid #333;padding:0 4px;position:sticky;top:0;z-index:50;scrollbar-width:none}.cats::-webkit-scrollbar{display:none}.cat{padding:9px 11px;color:#888;font-size:11px;white-space:nowrap;border-bottom:2px solid transparent;cursor:pointer;flex-shrink:0}.cat.active{color:#5cb87a;border-bottom-color:#5cb87a;font-weight:700}.view{display:none}.view.active{display:block}.loading{text-align:center;padding:30px;color:#777;font-size:12px}.slider-wrap{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.slider-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.slider-label{color:#f0c040;font-size:13px;font-weight:800}.slider-note{font-size:10px;color:#777}.slider-track{display:flex;overflow-x:auto;gap:8px;padding:4px 10px 10px;scrollbar-width:none}.slider-track::-webkit-scrollbar{display:none}.slider-item{flex:0 0 160px;cursor:pointer}.slider-thumb{position:relative;width:100%;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#333}.slider-thumb img,.slider-thumb video{width:100%;height:100%;object-fit:cover}.slider-title{font-size:10px;color:#ccc;margin-top:3px;line-height:1.2;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.card-play{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:30px;height:30px;border-radius:50%;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;color:#fff;font-size:12px}.grid{display:grid;grid-template-columns:repeat(2,1fr);gap:6px;padding:6px 4px}@media(min-width:650px){.grid{grid-template-columns:repeat(3,1fr)}}.card{background:#1a1a1a;border:1px solid #222;border-radius:8px;overflow:hidden;cursor:pointer}.card-img{position:relative;aspect-ratio:16/9;background:#333}.card-img img{width:100%;height:100%;object-fit:cover}.card-body{padding:6px 8px}.card-title{font-size:11px;line-height:1.35;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.badge{font-size:8px;padding:1px 5px;border-radius:3px;font-weight:700;display:inline-block;margin-bottom:2px;color:#fff}.badge-vne{background:#c0392b}.badge-genk{background:#6a1b9a}.badge-ai{background:#2d8659}.badge-wc{background:#0b6bcb}.section-title{font-size:13px;font-weight:800;color:#5cb87a;margin:8px 0 4px;padding-left:8px;border-left:3px solid #5cb87a}.back-btn{background:#111;color:#fff;border:none;padding:10px;font-size:12px;width:100%;position:sticky;top:0;z-index:60;cursor:pointer}.article-view{padding:12px 8px 40px;max-width:760px;margin:0 auto}.article-title{font-size:18px;font-weight:800;line-height:1.3;margin-bottom:8px}.article-summary{background:#1a2a1f;border-left:3px solid #2d8659;padding:10px;margin-bottom:14px;color:#ccc;font-size:13px}.article-p{font-size:14px;line-height:1.7;color:#ccc;margin-bottom:10px}.article-img{width:100%;border-radius:6px;margin:10px 0}.article-h2{font-size:16px;margin:16px 0 8px;color:#eee}.article-actions{display:flex;gap:8px;flex-wrap:wrap;border-top:1px solid #333;margin-top:16px;padding-top:10px}.article-actions button{background:#1a1a1a;border:1px solid #333;color:#ccc;padding:7px 12px;border-radius:14px;font-size:11px;cursor:pointer}.article-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}.article-ai-ask{margin-top:12px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.article-ai-ask textarea{width:100%;min-height:60px;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px;font-size:12px}.article-ai-ask button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin-top:6px;font-size:11px;cursor:pointer}.article-ai-answer{white-space:pre-wrap;color:#ccc;font-size:13px;line-height:1.55;margin-top:8px}.tiktok-container{width:100%;height:80vh;max-height:680px;min-height:400px;background:#000}.tiktok-feed{height:100%;overflow-y:scroll;scroll-snap-type:y mandatory;scrollbar-width:none}.tiktok-feed::-webkit-scrollbar{display:none}.tiktok-slide{height:80vh;max-height:680px;min-height:400px;scroll-snap-align:start;position:relative;background:#000;display:flex;align-items:center;justify-content:center}.tiktok-slide video,.tiktok-slide iframe{width:100%;height:100%;object-fit:cover;border:none}.tiktok-slide.ratio-wide video,.tiktok-slide.ratio-wide iframe{object-fit:contain}.tiktok-bottom{position:absolute;bottom:0;left:0;right:60px;padding:12px 10px 16px;background:linear-gradient(transparent,rgba(0,0,0,.85));z-index:3}.tiktok-title{font-size:12px;color:#fff}.tiktok-counter{position:absolute;top:8px;left:8px;background:rgba(0,0,0,.5);font-size:9px;padding:2px 7px;border-radius:8px;color:#fff;z-index:4}.tiktok-right{position:absolute;right:8px;bottom:100px;display:flex;flex-direction:column;align-items:center;gap:14px;z-index:5}.tiktok-right-btn{display:flex;flex-direction:column;align-items:center;gap:2px;background:none;border:0;color:#fff;cursor:pointer;font-size:10px}.tiktok-right-btn .icon{width:42px;height:42px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:20px}.tiktok-right-btn .count{font-size:10px;color:#ddd}.inline-comments{position:absolute;bottom:0;left:0;right:0;max-height:50%;background:rgba(18,18,18,.95);border-radius:14px 14px 0 0;z-index:10;overflow:hidden;display:flex;flex-direction:column}.inline-cmt-header{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-bottom:1px solid #333;color:#5cb87a;font-size:12px;font-weight:700}.inline-cmt-header button{background:none;border:0;color:#fff;font-size:16px;cursor:pointer}.inline-cmt-list{flex:1;overflow-y:auto;padding:6px 10px;max-height:180px}.inline-cmt-item{background:#222;border-radius:8px;padding:6px 8px;margin:4px 0;color:#ccc;font-size:11px;line-height:1.3}.inline-cmt-time{font-size:9px;color:#777;margin-right:6px}.inline-cmt-input{display:flex;gap:6px;padding:8px 10px;border-top:1px solid #333}.inline-cmt-input input{flex:1;background:#222;border:1px solid #444;color:#eee;border-radius:16px;padding:7px 12px;font-size:11px}.inline-cmt-input button{background:#2d8659;border:0;color:#fff;border-radius:16px;padding:7px 12px;font-size:11px;cursor:pointer}.wc2026-section{margin:6px 4px;background:linear-gradient(135deg,#0d1117,#1a1a3a);border:1px solid #1a3a5a;border-radius:10px;overflow:hidden}.wc-header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:linear-gradient(90deg,#0b2e4a,#1a3a5a)}.wc-header h2{font-size:15px;color:#fff;margin:0}.wc-live-badge{font-size:10px;color:#e74c3c;font-weight:700;animation:wc-pulse 1.5s infinite}@keyframes wc-pulse{0%,100%{opacity:1}50%{opacity:.4}}.wc-tabs{display:flex;gap:4px;padding:8px 10px;overflow-x:auto;scrollbar-width:none}.wc-tabs::-webkit-scrollbar{display:none}.wc-tab{padding:5px 10px;background:#1a2a3a;border:1px solid #2a3a4a;border-radius:12px;color:#8ab4d8;font-size:10px;cursor:pointer;white-space:nowrap;flex-shrink:0}.wc-tab.active{background:#0b6bcb;border-color:#0b6bcb;color:#fff;font-weight:700}.wc-content{padding:8px 10px;max-height:500px;overflow-y:auto}.wc-news-grid{display:flex;flex-direction:column;gap:8px}.wc-news-item{display:flex;gap:8px;padding:8px;background:#1a2030;border-radius:8px;cursor:pointer}.wc-news-item:active{opacity:.8}.wc-news-img{flex:0 0 70px;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#222}.wc-news-img img{width:100%;height:100%;object-fit:cover}.wc-news-text{flex:1;min-width:0}.wc-news-title{font-size:11px;font-weight:700;color:#eee;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wc-news-via{font-size:9px;color:#6a9fca;margin-top:2px}.ls-section{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.ls-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.ls-header h3{color:#f0c040;font-size:13px;font-weight:800}.ls-tabs{display:flex;gap:4px;padding:0 10px 8px;overflow-x:auto;scrollbar-width:none}.ls-tabs::-webkit-scrollbar{display:none}.ls-tab{padding:4px 10px;background:#222;border:1px solid #333;border-radius:12px;color:#999;font-size:10px;white-space:nowrap;cursor:pointer;flex-shrink:0}.ls-tab.active{background:#2d8659;border-color:#2d8659;color:#fff;font-weight:700}.ls-content{max-height:420px;overflow-y:auto;padding:0 6px 8px;font-size:12px;color:#ddd}.ls-content ul{list-style:none;padding:0;margin:0}.ls-content .title-content{display:flex;gap:6px;align-items:center;background:#222;border-radius:4px;margin:4px 0;padding:5px 8px}.ls-content .title-content img{width:18px;height:18px}.ls-content .title-content strong{font-size:11px;color:#ccc}.ls-content .match-detail{padding:6px;border-bottom:1px solid #262626;cursor:pointer}.ls-content .match-detail:hover{background:#1a2a1f}.ls-content .match{display:flex;flex-wrap:wrap;align-items:center;gap:4px}.ls-content .datetime{width:100%;font-size:9px;color:#888}.ls-content .teams{display:flex;width:100%;align-items:center;gap:4px}.ls-content .team{flex:1;display:flex;align-items:center;gap:4px;min-width:0;text-decoration:none}.ls-content .team .name{font-size:11px;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ls-content .team .logo img{width:18px;height:18px}.ls-content .home-team{justify-content:flex-end;text-align:right}.ls-content .status{flex:0 0 54px;text-align:center}.ls-content .status a{color:#fff;text-decoration:none;font-weight:800;font-size:12px}.ls-content .status .label{font-size:8px;color:#888;display:block}.ls-content .status .label.live{color:#e74c3c}.ls-content .info,.ls-content .btns{display:none}.ls-content table{width:100%;border-collapse:collapse;font-size:11px;color:#ccc}.ls-content table th{background:#222;color:#999;padding:5px 4px;font-size:10px;border-bottom:1px solid #333}.ls-content table td{padding:4px 3px;border-bottom:1px solid #1a1a1a}.ls-content table .team-name{display:flex;align-items:center;gap:4px}.ls-content table .team-name img{width:16px;height:16px}.ls-content table .pts{font-weight:800;color:#f0c040}.match-overlay{position:fixed;inset:0;background:#111;z-index:9999;display:none;flex-direction:column;overflow:auto}.match-overlay.active{display:flex}.mo-header{padding:10px;background:#1a1a1a;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:1}.mo-header h3{font-size:13px;color:#eee}.mo-close{background:none;border:0;color:#fff;font-size:22px;cursor:pointer}.mo-tabs{display:flex;gap:4px;padding:8px 10px;background:#1a1a1a;overflow-x:auto}.mo-tab{padding:5px 12px;background:#222;border:1px solid #333;border-radius:10px;color:#999;font-size:10px;cursor:pointer;white-space:nowrap}.mo-tab.active{background:#2d8659;color:#fff}.mo-body{padding:8px;overflow-x:auto;font-size:12px;color:#ddd}.mo-body ul{list-style:none;padding:0;margin:0}.mo-body li{padding:5px 0;border-bottom:1px solid #222}.featured-match{margin:6px 4px;background:linear-gradient(135deg,#1a2a1f,#0d1117);border:1px solid #2d8659;border-radius:10px;padding:12px;cursor:pointer}.fm-league{text-align:center;color:#5cb87a;font-size:9px;font-weight:700;text-transform:uppercase}.fm-teams{display:flex;align-items:center;justify-content:center;gap:10px;margin-top:6px}.fm-team{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px}.fm-team img{width:32px;height:32px;object-fit:contain}.fm-team span{font-size:10px;color:#ccc;text-align:center}.fm-score{font-size:22px;font-weight:900;min-width:60px;text-align:center;color:#fff}.fm-status{text-align:center;margin-top:6px;font-size:9px;color:#e74c3c;font-weight:700}.fm-status.upcoming{color:#f0c040}.ai-compose{margin:6px 4px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.ai-compose-title{font-size:13px;font-weight:800;color:#5cb87a;margin-bottom:8px}.ai-compose-row{display:flex;gap:6px;margin-top:6px}.ai-compose input{flex:1;background:#222;border:1px solid #333;color:#eee;border-radius:18px;padding:9px 12px;font-size:12px;min-width:0}.ai-compose button{background:#2d8659;border:0;color:#fff;border-radius:18px;padding:9px 12px;font-size:11px;font-weight:700;cursor:pointer;white-space:nowrap}.ai-compose button.secondary{background:#333}.hot-topic-row{display:flex;gap:6px;overflow-x:auto;padding:4px 0;scrollbar-width:none}.hot-topic-row::-webkit-scrollbar{display:none}.hot-chip{flex:0 0 auto;background:#222;border:1px solid #333;color:#ddd;border-radius:16px;padding:5px 10px;font-size:11px;cursor:pointer;white-space:nowrap}.hot-chip:active{transform:scale(.96)}.hashtag-sources{margin:8px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.hashtag-sources h3{font-size:13px;color:#5cb87a;margin-bottom:8px}.hashtag-src-item{display:flex;gap:8px;padding:8px;background:#202020;border-radius:8px;margin:6px 0;cursor:pointer}.hashtag-src-item:active{opacity:.8}.hashtag-src-img{flex:0 0 80px;aspect-ratio:16/9;background:#333;border-radius:6px;overflow:hidden}.hashtag-src-img img{width:100%;height:100%;object-fit:cover}.hashtag-src-text{flex:1;min-width:0}.hashtag-src-title{font-size:12px;font-weight:700;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.hashtag-src-via{font-size:10px;color:#888;margin-top:2px}.hashtag-rewrite-btn{width:100%;margin-top:8px;background:#2d8659;border:0;color:#fff;padding:9px;border-radius:10px;font-size:12px;font-weight:700;cursor:pointer}.hashtag-load-more{width:100%;margin-top:8px;background:#222;border:1px solid #333;color:#ccc;padding:9px;border-radius:10px;font-size:12px;cursor:pointer}.hashtag-loading{display:flex;align-items:center;gap:8px;padding:12px;color:#888;font-size:12px}.hashtag-spinner{width:16px;height:16px;border:2px solid #333;border-top-color:#5cb87a;border-radius:50%;animation:ht-spin .8s linear infinite}@keyframes ht-spin{to{transform:rotate(360deg)}}.wall-item{flex:0 0 260px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:8px}.wall-item-new{animation:wall-flash 1.8s ease-out}@keyframes wall-flash{0%{border-color:#f0c040;box-shadow:0 0 18px rgba(240,192,64,.35)}30%{border-color:#f0c040;box-shadow:0 0 12px rgba(240,192,64,.2)}100%{border-color:#2b2b2b;box-shadow:none}}.wall-thumb{width:100%;aspect-ratio:16/9;border-radius:8px;background:#222;overflow:hidden;margin-bottom:6px;position:relative}.wall-thumb img{width:100%;height:100%;object-fit:cover}.wall-video-badge{position:absolute;top:4px;right:4px;background:rgba(45,134,89,.9);color:#fff;font-size:10px;padding:2px 6px;border-radius:6px;font-weight:700}.wall-title{font-size:12px;color:#5cb87a;font-weight:800;line-height:1.3;margin-bottom:4px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wall-text{font-size:11px;color:#bbb;line-height:1.4;white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden}.wall-actions{display:flex;gap:6px;margin-top:8px}.wall-actions button{flex:1;border:1px solid #333;background:#222;color:#ddd;border-radius:14px;padding:6px 8px;font-size:10px;cursor:pointer}.wall-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}#progress-toast{position:fixed;bottom:70px;left:50%;transform:translateX(-50%);background:#2d8659;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;z-index:99998;box-shadow:0 4px 12px rgba(0,0,0,.4);display:none;white-space:nowrap}.storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 4px}
|
| 15 |
-
</style>
|
| 16 |
</head>
|
| 17 |
<body>
|
| 18 |
-
<div class="header"><h1>📰 VNEWS</h1><p>Tin tức · Bóng đá LIVE ·
|
| 19 |
<div class="cats" id="cat-bar"></div>
|
| 20 |
<div id="view-home" class="view active"><div class="loading">Đang tải...</div></div>
|
| 21 |
<div id="view-cat" class="view"></div>
|
|
@@ -32,19 +30,18 @@
|
|
| 32 |
var _cats=[],_hlLeagueData={},_currentArticle=null;window._currentEventId='';
|
| 33 |
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]))}
|
| 34 |
function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')}
|
| 35 |
-
function switchCat(id){document.querySelectorAll('.cat').forEach(c=>c.classList.remove('active'));document.querySelector(
|
| 36 |
function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}}
|
| 37 |
function doShare(title,url,img){const shareUrl=SPACE+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||'');if(navigator.share)navigator.share({title,url:shareUrl}).catch(()=>{});else navigator.clipboard.writeText(shareUrl).then(()=>alert('Đã sao chép!')).catch(()=>{})}
|
| 38 |
-
async function init(){_cats=await fetch('/api/categories').then(r=>r.json()).catch(()=>[]);let bar='<div class="cat active" data-cat="home">🏠</div><div class="cat" data-cat="news-all">📰 Tin tức</div>';_cats.forEach(c=>{bar+=
|
| 39 |
var SPACE=location.origin;
|
| 40 |
</script>
|
| 41 |
-
<script src="/static/app_v2.js
|
| 42 |
<script src="/static/yt_live.js"></script>
|
| 43 |
-
<script src="/static/
|
| 44 |
-
<script src="/static/hot_multi.js?v=1"></script>
|
| 45 |
<script src="/static/wc2026_v2.js"></script>
|
| 46 |
<script src="/static/live_mode.js"></script>
|
| 47 |
<script src="/static/match_detail_v6.js"></script>
|
| 48 |
-
<script>init();
|
| 49 |
</body>
|
| 50 |
</html>
|
|
|
|
| 10 |
<link rel="canonical" href="https://bep40-vnews.hf.space">
|
| 11 |
<link rel="stylesheet" href="/static/wc2026.css">
|
| 12 |
<script src="https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js"></script>
|
| 13 |
+
<style>*{box-sizing:border-box;margin:0;padding:0}body{background:#111;color:#eee;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;overflow-x:hidden}.header{background:linear-gradient(135deg,#0d1117,#1a3a2a 50%,#8b7500);padding:12px;text-align:center}.header h1{font-size:18px;color:#fff}.header p{font-size:10px;color:#aaa}.cats{display:flex;overflow-x:auto;background:#1a1a1a;border-bottom:1px solid #333;padding:0 4px;position:sticky;top:0;z-index:50;scrollbar-width:none}.cats::-webkit-scrollbar{display:none}.cat{padding:9px 11px;color:#888;font-size:11px;white-space:nowrap;border-bottom:2px solid transparent;cursor:pointer;flex-shrink:0}.cat.active{color:#5cb87a;border-bottom-color:#5cb87a;font-weight:700}.view{display:none}.view.active{display:block}.loading{text-align:center;padding:30px;color:#777;font-size:12px}.slider-wrap{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.slider-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.slider-label{color:#f0c040;font-size:13px;font-weight:800}.slider-note{font-size:10px;color:#777}.slider-track{display:flex;overflow-x:auto;gap:8px;padding:4px 10px 10px;scrollbar-width:none}.slider-track::-webkit-scrollbar{display:none}.slider-item{flex:0 0 160px;cursor:pointer}.shorts-item{flex:0 0 110px!important}.slider-thumb{position:relative;width:100%;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#333}.shorts-thumb{aspect-ratio:3/4!important;border-radius:8px!important}.slider-thumb img,.slider-thumb video{width:100%;height:100%;object-fit:cover}.slider-title{font-size:10px;color:#ccc;margin-top:3px;line-height:1.2;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.card-play{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:30px;height:30px;border-radius:50%;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;color:#fff;font-size:12px}.grid{display:grid;grid-template-columns:repeat(2,1fr);gap:6px;padding:6px 4px}@media(min-width:650px){.grid{grid-template-columns:repeat(3,1fr)}}.card{background:#1a1a1a;border:1px solid #222;border-radius:8px;overflow:hidden;cursor:pointer}.card-img{position:relative;aspect-ratio:16/9;background:#333}.card-img img{width:100%;height:100%;object-fit:cover}.card-body{padding:6px 8px}.card-title{font-size:11px;line-height:1.35;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.badge{font-size:8px;padding:1px 5px;border-radius:3px;font-weight:700;display:inline-block;margin-bottom:2px;color:#fff}.badge-vne{background:#c0392b}.badge-bbc{background:#b80000}.badge-dt{background:#1565c0}.badge-genk{background:#6a1b9a}.badge-fpt{background:#f26522}.badge-ai{background:#2d8659}.badge-wc{background:#0b6bcb}.section-title{font-size:13px;font-weight:800;color:#5cb87a;margin:8px 0 4px;padding-left:8px;border-left:3px solid #5cb87a}.back-btn{background:#111;color:#fff;border:none;padding:10px;font-size:12px;width:100%;position:sticky;top:0;z-index:60;cursor:pointer}.article-view{padding:12px 8px 40px;max-width:760px;margin:0 auto}.article-title{font-size:18px;font-weight:800;line-height:1.3;margin-bottom:8px}.article-summary{background:#1a2a1f;border-left:3px solid #2d8659;padding:10px;margin-bottom:14px;color:#ccc;font-size:13px}.article-p{font-size:14px;line-height:1.7;color:#ccc;margin-bottom:10px}.article-img{width:100%;border-radius:6px;margin:10px 0}.article-h2{font-size:16px;margin:16px 0 8px;color:#eee}.article-actions{display:flex;gap:8px;flex-wrap:wrap;border-top:1px solid #333;margin-top:16px;padding-top:10px}.article-actions button{background:#1a1a1a;border:1px solid #333;color:#ccc;padding:7px 12px;border-radius:14px;font-size:11px;cursor:pointer}.article-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}.article-ai-ask{margin-top:12px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.article-ai-ask textarea{width:100%;min-height:60px;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px;font-size:12px}.article-ai-ask button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin-top:6px;font-size:11px;cursor:pointer}.article-ai-answer{white-space:pre-wrap;color:#ccc;font-size:13px;line-height:1.55;margin-top:8px}.tiktok-container{width:100%;height:80vh;max-height:680px;min-height:400px;background:#000}.tiktok-feed{height:100%;overflow-y:scroll;scroll-snap-type:y mandatory;scrollbar-width:none}.tiktok-feed::-webkit-scrollbar{display:none}.tiktok-slide{height:80vh;max-height:680px;min-height:400px;scroll-snap-align:start;position:relative;background:#000;display:flex;align-items:center;justify-content:center}.tiktok-slide video,.tiktok-slide iframe{width:100%;height:100%;object-fit:cover;border:none}.tiktok-slide.ratio-wide video,.tiktok-slide.ratio-wide iframe{object-fit:contain}.tiktok-bottom{position:absolute;bottom:0;left:0;right:60px;padding:12px 10px 16px;background:linear-gradient(transparent,rgba(0,0,0,.85));z-index:3}.tiktok-title{font-size:12px;color:#fff}.tiktok-counter{position:absolute;top:8px;left:8px;background:rgba(0,0,0,.5);font-size:9px;padding:2px 7px;border-radius:8px;color:#fff;z-index:4}.tiktok-right{position:absolute;right:8px;bottom:100px;display:flex;flex-direction:column;align-items:center;gap:14px;z-index:5}.tiktok-right-btn{display:flex;flex-direction:column;align-items:center;gap:2px;background:none;border:0;color:#fff;cursor:pointer;font-size:10px}.tiktok-right-btn .icon{width:42px;height:42px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:20px}.tiktok-right-btn .count{font-size:10px;color:#ddd}.inline-comments{position:absolute;bottom:0;left:0;right:0;max-height:50%;background:rgba(18,18,18,.95);border-radius:14px 14px 0 0;z-index:10;overflow:hidden;display:flex;flex-direction:column}.inline-cmt-header{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-bottom:1px solid #333;color:#5cb87a;font-size:12px;font-weight:700}.inline-cmt-header button{background:none;border:0;color:#fff;font-size:16px;cursor:pointer}.inline-cmt-list{flex:1;overflow-y:auto;padding:6px 10px;max-height:180px}.inline-cmt-item{background:#222;border-radius:8px;padding:6px 8px;margin:4px 0;color:#ccc;font-size:11px;line-height:1.3}.inline-cmt-time{font-size:9px;color:#777;margin-right:6px}.inline-cmt-input{display:flex;gap:6px;padding:8px 10px;border-top:1px solid #333}.inline-cmt-input input{flex:1;background:#222;border:1px solid #444;color:#eee;border-radius:16px;padding:7px 12px;font-size:11px}.inline-cmt-input button{background:#2d8659;border:0;color:#fff;border-radius:16px;padding:7px 12px;font-size:11px;cursor:pointer}.wc2026-section{margin:6px 4px;background:linear-gradient(135deg,#0d1117,#1a1a3a);border:1px solid #1a3a5a;border-radius:10px;overflow:hidden}.wc-header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:linear-gradient(90deg,#0b2e4a,#1a3a5a)}.wc-header h2{font-size:15px;color:#fff;margin:0}.wc-live-badge{font-size:10px;color:#e74c3c;font-weight:700;animation:wc-pulse 1.5s infinite}@keyframes wc-pulse{0%,100%{opacity:1}50%{opacity:.4}}.wc-tabs{display:flex;gap:4px;padding:8px 10px;overflow-x:auto;scrollbar-width:none}.wc-tabs::-webkit-scrollbar{display:none}.wc-tab{padding:5px 10px;background:#1a2a3a;border:1px solid #2a3a4a;border-radius:12px;color:#8ab4d8;font-size:10px;cursor:pointer;white-space:nowrap;flex-shrink:0}.wc-tab.active{background:#0b6bcb;border-color:#0b6bcb;color:#fff;font-weight:700}.wc-content{padding:8px 10px;max-height:500px;overflow-y:auto}.wc-news-grid{display:flex;flex-direction:column;gap:8px}.wc-news-item{display:flex;gap:8px;padding:8px;background:#1a2030;border-radius:8px;cursor:pointer}.wc-news-item:active{opacity:.8}.wc-news-img{flex:0 0 70px;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#222}.wc-news-img img{width:100%;height:100%;object-fit:cover}.wc-news-text{flex:1;min-width:0}.wc-news-title{font-size:11px;font-weight:700;color:#eee;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wc-news-via{font-size:9px;color:#6a9fca;margin-top:2px}.ls-section{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.ls-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.ls-header h3{color:#f0c040;font-size:13px;font-weight:800}.ls-tabs{display:flex;gap:4px;padding:0 10px 8px;overflow-x:auto;scrollbar-width:none}.ls-tabs::-webkit-scrollbar{display:none}.ls-tab{padding:4px 10px;background:#222;border:1px solid #333;border-radius:12px;color:#999;font-size:10px;white-space:nowrap;cursor:pointer;flex-shrink:0}.ls-tab.active{background:#2d8659;border-color:#2d8659;color:#fff;font-weight:700}.ls-content{max-height:420px;overflow-y:auto;padding:0 6px 8px;font-size:12px;color:#ddd}.ls-content ul{list-style:none;padding:0;margin:0}.ls-content .title-content{display:flex;gap:6px;align-items:center;background:#222;border-radius:4px;margin:4px 0;padding:5px 8px}.ls-content .title-content img{width:18px;height:18px}.ls-content .title-content strong{font-size:11px;color:#ccc}.ls-content .match-detail{padding:6px;border-bottom:1px solid #262626;cursor:pointer}.ls-content .match-detail:hover{background:#1a2a1f}.ls-content .match{display:flex;flex-wrap:wrap;align-items:center;gap:4px}.ls-content .datetime{width:100%;font-size:9px;color:#888}.ls-content .teams{display:flex;width:100%;align-items:center;gap:4px}.ls-content .team{flex:1;display:flex;align-items:center;gap:4px;min-width:0;text-decoration:none}.ls-content .team .name{font-size:11px;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ls-content .team .logo img{width:18px;height:18px}.ls-content .home-team{justify-content:flex-end;text-align:right}.ls-content .status{flex:0 0 54px;text-align:center}.ls-content .status a{color:#fff;text-decoration:none;font-weight:800;font-size:12px}.ls-content .status .label{font-size:8px;color:#888;display:block}.ls-content .status .label.live{color:#e74c3c}.ls-content .info,.ls-content .btns{display:none}.ls-content table{width:100%;border-collapse:collapse;font-size:11px;color:#ccc}.ls-content table th{background:#222;color:#999;padding:5px 4px;font-size:10px;border-bottom:1px solid #333}.ls-content table td{padding:4px 3px;border-bottom:1px solid #1a1a1a}.ls-content table .team-name{display:flex;align-items:center;gap:4px}.ls-content table .team-name img{width:16px;height:16px}.ls-content table .pts{font-weight:800;color:#f0c040}.match-overlay{position:fixed;inset:0;background:#111;z-index:9999;display:none;flex-direction:column;overflow:auto}.match-overlay.active{display:flex}.mo-header{padding:10px;background:#1a1a1a;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:1}.mo-header h3{font-size:13px;color:#eee}.mo-close{background:none;border:0;color:#fff;font-size:22px;cursor:pointer}.mo-tabs{display:flex;gap:4px;padding:8px 10px;background:#1a1a1a;overflow-x:auto}.mo-tab{padding:5px 12px;background:#222;border:1px solid #333;border-radius:10px;color:#999;font-size:10px;cursor:pointer;white-space:nowrap}.mo-tab.active{background:#2d8659;color:#fff}.mo-body{padding:8px;overflow-x:auto;font-size:12px;color:#ddd}.mo-body ul{list-style:none;padding:0;margin:0}.mo-body li{padding:5px 0;border-bottom:1px solid #222}.featured-match{margin:6px 4px;background:linear-gradient(135deg,#1a2a1f,#0d1117);border:1px solid #2d8659;border-radius:10px;padding:12px;cursor:pointer}.fm-league{text-align:center;color:#5cb87a;font-size:9px;font-weight:700;text-transform:uppercase}.fm-teams{display:flex;align-items:center;justify-content:center;gap:10px;margin-top:6px}.fm-team{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px}.fm-team img{width:32px;height:32px;object-fit:contain}.fm-team span{font-size:10px;color:#ccc;text-align:center}.fm-score{font-size:22px;font-weight:900;min-width:60px;text-align:center;color:#fff}.fm-status{text-align:center;margin-top:6px;font-size:9px;color:#e74c3c;font-weight:700}.fm-status.upcoming{color:#f0c040}.ai-compose{margin:6px 4px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.ai-compose-title{font-size:13px;font-weight:800;color:#5cb87a;margin-bottom:8px}.ai-compose-row{display:flex;gap:6px;margin-top:6px}.ai-compose input{flex:1;background:#222;border:1px solid #333;color:#eee;border-radius:18px;padding:9px 12px;font-size:12px;min-width:0}.ai-compose button{background:#2d8659;border:0;color:#fff;border-radius:18px;padding:9px 12px;font-size:11px;font-weight:700;cursor:pointer;white-space:nowrap}.ai-compose button.secondary{background:#333}.hot-topic-row{display:flex;gap:6px;overflow-x:auto;padding:4px 0;scrollbar-width:none}.hot-topic-row::-webkit-scrollbar{display:none}.hot-chip{flex:0 0 auto;background:#222;border:1px solid #333;color:#ddd;border-radius:16px;padding:5px 10px;font-size:11px;cursor:pointer;white-space:nowrap}.hot-chip:active{transform:scale(.96)}.hashtag-sources{margin:8px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.hashtag-sources h3{font-size:13px;color:#5cb87a;margin-bottom:8px}.hashtag-src-item{display:flex;gap:8px;padding:8px;background:#202020;border-radius:8px;margin:6px 0;cursor:pointer}.hashtag-src-item:active{opacity:.8}.hashtag-src-img{flex:0 0 80px;aspect-ratio:16/9;background:#333;border-radius:6px;overflow:hidden}.hashtag-src-img img{width:100%;height:100%;object-fit:cover}.hashtag-src-text{flex:1;min-width:0}.hashtag-src-title{font-size:12px;font-weight:700;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.hashtag-src-via{font-size:10px;color:#888;margin-top:2px}.hashtag-rewrite-btn{width:100%;margin-top:8px;background:#2d8659;border:0;color:#fff;padding:9px;border-radius:10px;font-size:12px;font-weight:700;cursor:pointer}.hashtag-load-more{width:100%;margin-top:8px;background:#222;border:1px solid #333;color:#ccc;padding:9px;border-radius:10px;font-size:12px;cursor:pointer}.hashtag-loading{display:flex;align-items:center;gap:8px;padding:12px;color:#888;font-size:12px}.hashtag-spinner{width:16px;height:16px;border:2px solid #333;border-top-color:#5cb87a;border-radius:50%;animation:ht-spin .8s linear infinite}@keyframes ht-spin{to{transform:rotate(360deg)}}.wall-item{flex:0 0 260px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:8px}.wall-item-new{animation:wall-flash 1.8s ease-out}@keyframes wall-flash{0%{border-color:#f0c040;box-shadow:0 0 18px rgba(240,192,64,.35)}30%{border-color:#f0c040;box-shadow:0 0 12px rgba(240,192,64,.2)}100%{border-color:#2b2b2b;box-shadow:none}}.wall-thumb{width:100%;aspect-ratio:16/9;border-radius:8px;background:#222;overflow:hidden;margin-bottom:6px;position:relative}.wall-thumb img{width:100%;height:100%;object-fit:cover}.wall-video-badge{position:absolute;top:4px;right:4px;background:rgba(45,134,89,.9);color:#fff;font-size:10px;padding:2px 6px;border-radius:6px;font-weight:700}.wall-title{font-size:12px;color:#5cb87a;font-weight:800;line-height:1.3;margin-bottom:4px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wall-text{font-size:11px;color:#bbb;line-height:1.4;white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden}.wall-actions{display:flex;gap:6px;margin-top:8px}.wall-actions button{flex:1;border:1px solid #333;background:#222;color:#ddd;border-radius:14px;padding:6px 8px;font-size:10px;cursor:pointer}.wall-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}#progress-toast{position:fixed;bottom:70px;left:50%;transform:translateX(-50%);background:#2d8659;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;z-index:99998;box-shadow:0 4px 12px rgba(0,0,0,.4);display:none;white-space:nowrap}.storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 4px}</style>
|
|
|
|
|
|
|
| 14 |
</head>
|
| 15 |
<body>
|
| 16 |
+
<div class="header"><h1>📰 VNEWS</h1><p>Tin tức · Bóng đá LIVE · Video · AI · World Cup 2026</p></div>
|
| 17 |
<div class="cats" id="cat-bar"></div>
|
| 18 |
<div id="view-home" class="view active"><div class="loading">Đang tải...</div></div>
|
| 19 |
<div id="view-cat" class="view"></div>
|
|
|
|
| 30 |
var _cats=[],_hlLeagueData={},_currentArticle=null;window._currentEventId='';
|
| 31 |
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]))}
|
| 32 |
function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')}
|
| 33 |
+
function switchCat(id){document.querySelectorAll('.cat').forEach(c=>c.classList.remove('active'));document.querySelector(`[data-cat="${id}"]`)?.classList.add('active');document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.querySelectorAll('video').forEach(v=>{v.pause();if(v._hls){v._hls.destroy();v._hls=null}});document.querySelectorAll('iframe[data-yt-src]').forEach(f=>{f.src=''});if(id==='home')document.getElementById('view-home').classList.add('active');else if(id==='news-all'){document.getElementById('view-cat').classList.add('active');loadNewsTab()}else{document.getElementById('view-cat').classList.add('active');loadCat(id)}}
|
| 34 |
function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}}
|
| 35 |
function doShare(title,url,img){const shareUrl=SPACE+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||'');if(navigator.share)navigator.share({title,url:shareUrl}).catch(()=>{});else navigator.clipboard.writeText(shareUrl).then(()=>alert('Đã sao chép!')).catch(()=>{})}
|
| 36 |
+
async function init(){_cats=await fetch('/api/categories').then(r=>r.json()).catch(()=>[]);let bar='<div class="cat active" data-cat="home">🏠</div><div class="cat" data-cat="news-all">📰 Tin tức</div>';_cats.forEach(c=>{bar+=`<div class="cat" data-cat="${c.id}">${c.name}</div>`});document.getElementById('cat-bar').innerHTML=bar;document.querySelectorAll('.cat').forEach(t=>{t.onclick=()=>switchCat(t.dataset.cat)});}
|
| 37 |
var SPACE=location.origin;
|
| 38 |
</script>
|
| 39 |
+
<script src="/static/app_v2.js"></script>
|
| 40 |
<script src="/static/yt_live.js"></script>
|
| 41 |
+
<script src="/static/hot_multi.js"></script>
|
|
|
|
| 42 |
<script src="/static/wc2026_v2.js"></script>
|
| 43 |
<script src="/static/live_mode.js"></script>
|
| 44 |
<script src="/static/match_detail_v6.js"></script>
|
| 45 |
+
<script>init();</script>
|
| 46 |
</body>
|
| 47 |
</html>
|
static/rewrite_fix.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Fix rewriteArticle - call correct endpoint
|
| 2 |
+
// This file patches the rewriteArticle function to use /api/rewrite_slide instead of /api/rewrite_share
|
| 3 |
+
|
| 4 |
+
(function(){
|
| 5 |
+
// Override rewriteArticle to call /api/rewrite_slide
|
| 6 |
+
const origRewrite = window.rewriteArticle;
|
| 7 |
+
window.rewriteArticle = async function(){
|
| 8 |
+
const url = _currentArticle?.url;
|
| 9 |
+
if(!url) return;
|
| 10 |
+
toast('⏳ Đang tạo slide tóm tắt...');
|
| 11 |
+
try {
|
| 12 |
+
const r = await fetch('/api/rewrite_slide', {
|
| 13 |
+
method: 'POST',
|
| 14 |
+
headers: {'Content-Type': 'application/json'},
|
| 15 |
+
body: JSON.stringify({url, context: document.querySelector('.article-view')?.innerText?.slice(0,14000) || ''})
|
| 16 |
+
});
|
| 17 |
+
const j = await r.json();
|
| 18 |
+
if (!r.ok || j.error) throw new Error(j.error);
|
| 19 |
+
toast('✅ Đã đăng Tường AI!');
|
| 20 |
+
if (j.post) prependWallPost(j.post);
|
| 21 |
+
// Navigate to the new post on Tường AI (home). Slide overlay (if any) stays on top.
|
| 22 |
+
if (j.post && typeof goToWallPost === 'function') goToWallPost(j.post.id);
|
| 23 |
+
// Show slides preview
|
| 24 |
+
if (j.slides && j.slides.length) {
|
| 25 |
+
showSlidePreview(j.slides, j.post?.title || '');
|
| 26 |
+
}
|
| 27 |
+
} catch(e) {
|
| 28 |
+
// Fallback: try /api/rewrite_share (old endpoint from ai_ext)
|
| 29 |
+
try {
|
| 30 |
+
const r2 = await fetch('/api/rewrite_share', {
|
| 31 |
+
method: 'POST',
|
| 32 |
+
headers: {'Content-Type': 'application/json'},
|
| 33 |
+
body: JSON.stringify({url, context: document.querySelector('.article-view')?.innerText?.slice(0,14000) || ''})
|
| 34 |
+
});
|
| 35 |
+
const j2 = await r2.json();
|
| 36 |
+
if (r2.ok && !j2.error) {
|
| 37 |
+
toast('✅ Đã đăng Tường AI!');
|
| 38 |
+
if (j2.post) prependWallPost(j2.post);
|
| 39 |
+
if (j2.post && typeof goToWallPost === 'function') goToWallPost(j2.post.id);
|
| 40 |
+
return;
|
| 41 |
+
}
|
| 42 |
+
} catch(e2) {}
|
| 43 |
+
toast('❌ ' + e.message);
|
| 44 |
+
}
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
// Show slides as fullscreen overlay
|
| 48 |
+
window.showSlidePreview = function(slides, title) {
|
| 49 |
+
if (!slides || !slides.length) return;
|
| 50 |
+
const overlay = document.createElement('div');
|
| 51 |
+
overlay.id = 'slide-preview';
|
| 52 |
+
overlay.style.cssText = 'position:fixed;inset:0;background:#000;z-index:99999;display:flex;flex-direction:column;overflow:hidden';
|
| 53 |
+
|
| 54 |
+
let currentSlide = 0;
|
| 55 |
+
function renderSlide(idx) {
|
| 56 |
+
const s = slides[idx];
|
| 57 |
+
overlay.innerHTML = `
|
| 58 |
+
<div style="position:absolute;top:10px;left:10px;right:10px;display:flex;justify-content:space-between;align-items:center;z-index:2">
|
| 59 |
+
<button onclick="document.getElementById('slide-preview').remove()" style="background:rgba(0,0,0,.6);border:0;color:#fff;padding:8px 14px;border-radius:20px;font-size:12px;cursor:pointer">✕ Đóng</button>
|
| 60 |
+
<span style="color:#fff;font-size:11px;background:rgba(0,0,0,.6);padding:4px 10px;border-radius:10px">${idx+1}/${slides.length}</span>
|
| 61 |
+
</div>
|
| 62 |
+
<div style="flex:1;display:flex;align-items:center;justify-content:center;padding:20px">
|
| 63 |
+
${s.image ? `<img src="${esc(s.image)}" style="max-width:100%;max-height:60vh;border-radius:10px;object-fit:contain" onerror="this.style.display='none'">` : ''}
|
| 64 |
+
</div>
|
| 65 |
+
<div style="padding:16px 20px;background:linear-gradient(transparent,rgba(0,0,0,.9));min-height:100px">
|
| 66 |
+
<p style="color:#fff;font-size:14px;line-height:1.6">${esc(s.text)}</p>
|
| 67 |
+
</div>
|
| 68 |
+
<div style="display:flex;gap:10px;padding:10px 20px 20px;justify-content:center">
|
| 69 |
+
<button onclick="prevSlide()" style="background:#333;border:0;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;cursor:pointer" ${idx===0?'disabled style="opacity:.3"':''}>← Trước</button>
|
| 70 |
+
<button onclick="nextSlide()" style="background:#2d8659;border:0;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;cursor:pointer" ${idx===slides.length-1?'disabled style="opacity:.3"':''}>Tiếp →</button>
|
| 71 |
+
</div>
|
| 72 |
+
`;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
window.nextSlide = function() { if (currentSlide < slides.length - 1) { currentSlide++; renderSlide(currentSlide); } };
|
| 76 |
+
window.prevSlide = function() { if (currentSlide > 0) { currentSlide--; renderSlide(currentSlide); } };
|
| 77 |
+
|
| 78 |
+
renderSlide(0);
|
| 79 |
+
document.body.appendChild(overlay);
|
| 80 |
+
|
| 81 |
+
// Swipe support
|
| 82 |
+
let startX = 0;
|
| 83 |
+
overlay.addEventListener('touchstart', e => { startX = e.touches[0].clientX; });
|
| 84 |
+
overlay.addEventListener('touchend', e => {
|
| 85 |
+
const diff = e.changedTouches[0].clientX - startX;
|
| 86 |
+
if (diff < -50) nextSlide();
|
| 87 |
+
else if (diff > 50) prevSlide();
|
| 88 |
+
});
|
| 89 |
+
};
|
| 90 |
+
})();
|
static/shorts_fresh.js
DELETED
|
@@ -1,5 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* VNEWS Shorts — REMOVED per user request
|
| 3 |
-
* Dantri/SKDS slides removed from homepage entirely
|
| 4 |
-
*/
|
| 5 |
-
(function(){'use strict';console.log('[Shorts] Disabled - Dantri/SKDS removed');})();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/vtv_init.js
DELETED
|
@@ -1,349 +0,0 @@
|
|
| 1 |
-
// ===== VNEWS VTV Player v6 — Professional UI + Real-time EPG + Auto-refresh =====
|
| 2 |
-
// Timezone: +10 GMT (as requested)
|
| 3 |
-
(function() {
|
| 4 |
-
if (window._vtvInitLoaded) return;
|
| 5 |
-
window._vtvInitLoaded = true;
|
| 6 |
-
|
| 7 |
-
var CHANNELS = [
|
| 8 |
-
{id:'vtv1',name:'VTV1',badge:'Tin tức'},{id:'vtv2',name:'VTV2',badge:'Khoa học'},
|
| 9 |
-
{id:'vtv3',name:'VTV3',badge:'Giải trí'},{id:'vtv4',name:'VTV4',badge:'Quốc Tế'},
|
| 10 |
-
{id:'vtv5',name:'VTV5',badge:'Miền Nam'},{id:'vtv6',name:'VTV6',badge:'Thanh Niên'},
|
| 11 |
-
{id:'vtv7',name:'VTV7',badge:'Giáo Dục'},{id:'vtv8',name:'VTV8',badge:'Miền Trung'},
|
| 12 |
-
{id:'vtv9',name:'VTV9',badge:'Miền Bắc'},{id:'vtv10',name:'VTV10',badge:'Cần Thơ'},
|
| 13 |
-
{id:'vtvprime',name:'VTVPrime',badge:'Prime'},
|
| 14 |
-
];
|
| 15 |
-
|
| 16 |
-
var _hlsInst = null;
|
| 17 |
-
var _currentCh = null;
|
| 18 |
-
var _epgTimer = null;
|
| 19 |
-
var _epgData = [];
|
| 20 |
-
var _timeTimer = null;
|
| 21 |
-
|
| 22 |
-
// ===== CSS chuyên nghiệp =====
|
| 23 |
-
(function injectCSS() {
|
| 24 |
-
if (document.getElementById('vtv-pro-css')) return;
|
| 25 |
-
var s = document.createElement('style');
|
| 26 |
-
s.id = 'vtv-pro-css';
|
| 27 |
-
s.textContent = [
|
| 28 |
-
'#vtv-player-section{display:block!important;margin:8px 0 10px;background:linear-gradient(180deg,#0a0e17,#111622);border:1px solid rgba(0,150,255,.15);border-radius:14px;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}',
|
| 29 |
-
'#vtv-player-section .vtv-pro-head{display:flex;align-items:center;gap:10px;padding:10px 14px;background:linear-gradient(90deg,#0a1628,rgba(0,102,204,.08))}',
|
| 30 |
-
'#vtv-player-section .vtv-pro-logo{width:22px;height:22px;border-radius:50%;background:linear-gradient(135deg,#0066cc,#00ccff);display:flex;align-items:center;justify-content:center;font-size:11px;color:#fff;font-weight:800;flex-shrink:0}',
|
| 31 |
-
'#vtv-player-section .vtv-pro-title{font-size:13px;font-weight:700;color:#e8eaed;letter-spacing:.3px}',
|
| 32 |
-
'#vtv-player-section .vtv-pro-live{font-size:9px;font-weight:700;color:#00cc88;background:rgba(0,204,136,.12);padding:2px 8px;border-radius:10px;display:flex;align-items:center;gap:4px;margin-left:auto}',
|
| 33 |
-
'#vtv-player-section .vtv-pro-live-dot{width:5px;height:5px;border-radius:50%;background:#00cc88;animation:vtv-pro-pulse 1.2s infinite}',
|
| 34 |
-
'@keyframes vtv-pro-pulse{0%,100%{opacity:1}50%{opacity:.3}}',
|
| 35 |
-
'#vtv-player-section .vtv-pro-tabs{display:flex;flex-wrap:wrap;gap:2px;padding:6px 10px 8px;overflow-x:auto;scrollbar-width:none}',
|
| 36 |
-
'#vtv-player-section .vtv-pro-tabs::-webkit-scrollbar{display:none}',
|
| 37 |
-
'#vtv-player-section .vtv-pro-tab{padding:5px 10px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.06);border-radius:8px;color:#9aa0a6;font-size:10px;font-weight:500;cursor:pointer;white-space:nowrap;flex-shrink:0;transition:all .2s ease}',
|
| 38 |
-
'#vtv-player-section .vtv-pro-tab:hover{background:rgba(0,102,204,.1);border-color:rgba(0,102,204,.3);color:#e8eaed}',
|
| 39 |
-
'#vtv-player-section .vtv-pro-tab.on{background:rgba(0,102,204,.2);border-color:#0066cc;color:#fff;font-weight:600}',
|
| 40 |
-
'#vtv-player-section .vtv-pro-tab .b{font-size:7px;opacity:.5;display:block;margin-top:1px}',
|
| 41 |
-
'#vtv-player-section .vtv-pro-frame{position:relative;width:100%;aspect-ratio:16/9;background:#000;min-height:200px;border-top:1px solid rgba(255,255,255,.04)}',
|
| 42 |
-
'#vtv-player-section .vtv-pro-frame video{position:absolute;inset:0;width:100%!important;height:100%!important;object-fit:contain;background:#000}',
|
| 43 |
-
'#vtv-player-section .vtv-pro-load{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;min-height:200px;gap:12px;color:#9aa0a6;font-size:12px}',
|
| 44 |
-
'#vtv-player-section .vtv-pro-spinner{width:28px;height:28px;border:2px solid rgba(255,255,255,.06);border-top-color:#0066cc;border-radius:50%;animation:vtv-pro-spin .7s linear infinite}',
|
| 45 |
-
'@keyframes vtv-pro-spin{to{transform:rotate(360deg)}}',
|
| 46 |
-
'#vtv-player-section .vtv-pro-err{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;min-height:200px;gap:10px;color:#9aa0a6;font-size:12px;text-align:center;padding:20px}',
|
| 47 |
-
'#vtv-player-section .vtv-pro-err .icon{font-size:28px;opacity:.5}',
|
| 48 |
-
'#vtv-player-section .vtv-pro-err .msg{color:#9aa0a6}',
|
| 49 |
-
'#vtv-player-section .vtv-pro-err button{background:rgba(0,102,204,.15);border:1px solid rgba(0,102,204,.3);color:#8ab4f8;padding:7px 16px;border-radius:8px;font-size:11px;cursor:pointer;transition:all .15s}',
|
| 50 |
-
'#vtv-player-section .vtv-pro-err button:hover{background:rgba(0,102,204,.25)}',
|
| 51 |
-
'#vtv-player-section .vtv-pro-controls{display:flex;align-items:center;gap:6px;padding:6px 12px;background:rgba(0,0,0,.3);border-top:1px solid rgba(255,255,255,.04)}',
|
| 52 |
-
'#vtv-player-section .vtv-pro-btn{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.08);color:#9aa0a6;font-size:10px;padding:4px 10px;border-radius:6px;cursor:pointer;transition:all .15s;display:flex;align-items:center;gap:4px}',
|
| 53 |
-
'#vtv-player-section .vtv-pro-btn:hover{background:rgba(0,102,204,.12);color:#e8eaed}',
|
| 54 |
-
'#vtv-player-section .vtv-pro-btn.active{background:rgba(0,102,204,.2);border-color:#0066cc;color:#8ab4f8}',
|
| 55 |
-
'#vtv-player-section .vtv-pro-epg{border-top:1px solid rgba(255,255,255,.04);background:rgba(0,0,0,.15)}',
|
| 56 |
-
'#vtv-player-section .vtv-pro-epg-hdr{display:flex;align-items:center;justify-content:space-between;padding:8px 12px 4px}',
|
| 57 |
-
'#vtv-player-section .vtv-pro-epg-title{font-size:10px;font-weight:600;color:#9aa0a6;letter-spacing:.5px;text-transform:uppercase}',
|
| 58 |
-
'#vtv-player-section .vtv-pro-epg-time{font-size:9px;color:#5f6368;font-variant-numeric:tabular-nums}',
|
| 59 |
-
'#vtv-player-section .vtv-pro-epg-list{padding:2px 8px 10px;display:flex;flex-direction:column;gap:2px;max-height:200px;overflow-y:auto;scrollbar-width:thin}',
|
| 60 |
-
'#vtv-player-section .vtv-pro-epg-list::-webkit-scrollbar{width:3px}',
|
| 61 |
-
'#vtv-player-section .vtv-pro-epg-list::-webkit-scrollbar-thumb{background:rgba(255,255,255,.08);border-radius:3px}',
|
| 62 |
-
'#vtv-player-section .vtv-pro-epg-row{display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;transition:all .2s;border-left:2px solid transparent;font-size:11px}',
|
| 63 |
-
'#vtv-player-section .vtv-pro-epg-row:hover{background:rgba(255,255,255,.03)}',
|
| 64 |
-
'#vtv-player-section .vtv-pro-epg-row.now{border-left-color:#00cc88;background:rgba(0,204,136,.06)}',
|
| 65 |
-
'#vtv-player-section .vtv-pro-epg-row.passed{opacity:.35}',
|
| 66 |
-
'#vtv-player-section .vtv-pro-epg-row .t{font-size:10px;color:#5f6368;min-width:38px;font-variant-numeric:tabular-nums}',
|
| 67 |
-
'#vtv-player-section .vtv-pro-epg-row.now .t{color:#00cc88;font-weight:600}',
|
| 68 |
-
'#vtv-player-section .vtv-pro-epg-row .n{color:#e8eaed;line-height:1.3}',
|
| 69 |
-
'#vtv-player-section .vtv-pro-epg-row.now .n{color:#fff;font-weight:500}',
|
| 70 |
-
'#vtv-player-section .vtv-pro-epg-row .bar{flex:0 0 2px;height:12px;border-radius:1px;background:rgba(255,255,255,.08)}',
|
| 71 |
-
'#vtv-player-section .vtv-pro-epg-row.now .bar{background:#00cc88}',
|
| 72 |
-
'#vtv-player-section .vtv-pro-epg-empty{color:#5f6368;font-size:11px;padding:12px 14px;text-align:center}',
|
| 73 |
-
'#vtv-player-section .vtv-pro-epg-load{color:#5f6368;font-size:11px;padding:12px 14px;text-align:center;display:flex;align-items:center;justify-content:center;gap:6px}',
|
| 74 |
-
].join('');
|
| 75 |
-
document.head.appendChild(s);
|
| 76 |
-
})();
|
| 77 |
-
|
| 78 |
-
function proxyUrl(url) {
|
| 79 |
-
if (!url) return url;
|
| 80 |
-
if (url.indexOf(location.origin) === 0) return url;
|
| 81 |
-
if (url.indexOf('/api/proxy/') === 0) return url;
|
| 82 |
-
return '/api/proxy/m3u8/vtv?url=' + encodeURIComponent(url);
|
| 83 |
-
}
|
| 84 |
-
|
| 85 |
-
function formatTime(d) {
|
| 86 |
-
var h = d.getHours(), m = d.getMinutes();
|
| 87 |
-
return (h < 10 ? '0' : '') + h + ':' + (m < 10 ? '0' : '') + m;
|
| 88 |
-
}
|
| 89 |
-
|
| 90 |
-
function getVNTime() {
|
| 91 |
-
// Timezone +10 GMT
|
| 92 |
-
var now = new Date();
|
| 93 |
-
return new Date(now.getTime() + 10 * 3600000);
|
| 94 |
-
}
|
| 95 |
-
|
| 96 |
-
// ===== EPG real-time =====
|
| 97 |
-
function loadEPG(chId) {
|
| 98 |
-
var epgEl = document.getElementById('vtv-pro-epg-body');
|
| 99 |
-
if (!epgEl) return;
|
| 100 |
-
epgEl.innerHTML = '<div class="vtv-pro-epg-load"><div class="vtv-pro-spinner" style="width:12px;height:12px;border-width:1.5px;flex-shrink:0"></div>Đang tải lịch...</div>';
|
| 101 |
-
var xhr = new XMLHttpRequest();
|
| 102 |
-
xhr.open('GET', '/api/vtv/epg/' + chId, true);
|
| 103 |
-
xhr.timeout = 15000;
|
| 104 |
-
xhr.onload = function() {
|
| 105 |
-
try {
|
| 106 |
-
var data = JSON.parse(xhr.responseText);
|
| 107 |
-
_epgData = data.programs || [];
|
| 108 |
-
if (_epgData.length === 0) {
|
| 109 |
-
epgEl.innerHTML = '<div class="vtv-pro-epg-empty">Không có lịch phát sóng</div>';
|
| 110 |
-
} else {
|
| 111 |
-
renderEPG();
|
| 112 |
-
}
|
| 113 |
-
} catch(e) {
|
| 114 |
-
epgEl.innerHTML = '<div class="vtv-pro-epg-empty">Không có lịch phát sóng</div>';
|
| 115 |
-
}
|
| 116 |
-
};
|
| 117 |
-
xhr.onerror = xhr.ontimeout = function() {
|
| 118 |
-
epgEl.innerHTML = '<div class="vtv-pro-epg-empty">Không tải được lịch phát sóng</div>';
|
| 119 |
-
};
|
| 120 |
-
xhr.send();
|
| 121 |
-
}
|
| 122 |
-
|
| 123 |
-
function renderEPG() {
|
| 124 |
-
var epgEl = document.getElementById('vtv-pro-epg-body');
|
| 125 |
-
if (!epgEl) return;
|
| 126 |
-
if (!_epgData || !_epgData.length) {
|
| 127 |
-
epgEl.innerHTML = '<div class="vtv-pro-epg-empty">Chưa có lịch phát sóng</div>';
|
| 128 |
-
return;
|
| 129 |
-
}
|
| 130 |
-
var vnNow = getVNTime();
|
| 131 |
-
var nowStr = formatTime(vnNow);
|
| 132 |
-
|
| 133 |
-
_epgData.sort(function(a, b) { return a.time.localeCompare(b.time); });
|
| 134 |
-
|
| 135 |
-
// Client-side now detection using +10 timezone
|
| 136 |
-
var foundNow = false;
|
| 137 |
-
for (var i = 0; i < _epgData.length; i++) {
|
| 138 |
-
_epgData[i].now = false;
|
| 139 |
-
var p = _epgData[i];
|
| 140 |
-
var next = _epgData[i + 1];
|
| 141 |
-
if (p.time <= nowStr && (!next || next.time > nowStr)) {
|
| 142 |
-
p.now = true;
|
| 143 |
-
p.end_time = next ? next.time : '';
|
| 144 |
-
foundNow = true;
|
| 145 |
-
}
|
| 146 |
-
}
|
| 147 |
-
|
| 148 |
-
// If nothing is "now", highlight the upcoming one
|
| 149 |
-
if (!foundNow) {
|
| 150 |
-
for (var i = 0; i < _epgData.length; i++) {
|
| 151 |
-
if (_epgData[i].time > nowStr) {
|
| 152 |
-
_epgData[i].now = true;
|
| 153 |
-
_epgData[i].end_time = (_epgData[i+1] || {}).time || '';
|
| 154 |
-
break;
|
| 155 |
-
}
|
| 156 |
-
}
|
| 157 |
-
}
|
| 158 |
-
|
| 159 |
-
var html = '';
|
| 160 |
-
for (var i = 0; i < _epgData.length; i++) {
|
| 161 |
-
var p = _epgData[i];
|
| 162 |
-
var cls = 'vtv-pro-epg-row';
|
| 163 |
-
if (p.now) cls += ' now';
|
| 164 |
-
else if (p.time < nowStr) cls += ' passed';
|
| 165 |
-
var extra = '';
|
| 166 |
-
if (p.end_time) {
|
| 167 |
-
extra = ' → ' + p.end_time;
|
| 168 |
-
} else if (i < _epgData.length - 1) {
|
| 169 |
-
extra = ' → ' + _epgData[i + 1].time;
|
| 170 |
-
}
|
| 171 |
-
html += '<div class="' + cls + '">' +
|
| 172 |
-
'<span class="t">' + p.time + extra + '</span>' +
|
| 173 |
-
'<span class="bar"></span>' +
|
| 174 |
-
'<span class="n">' + p.title + '</span>' +
|
| 175 |
-
'</div>';
|
| 176 |
-
}
|
| 177 |
-
epgEl.innerHTML = html;
|
| 178 |
-
|
| 179 |
-
var nowEl = epgEl.querySelector('.now');
|
| 180 |
-
if (nowEl) {
|
| 181 |
-
nowEl.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
| 182 |
-
}
|
| 183 |
-
}
|
| 184 |
-
|
| 185 |
-
function startEpgRefresh() {
|
| 186 |
-
if (_epgTimer) clearInterval(_epgTimer);
|
| 187 |
-
_epgTimer = setInterval(function() {
|
| 188 |
-
if (_currentCh) {
|
| 189 |
-
loadEPG(_currentCh);
|
| 190 |
-
}
|
| 191 |
-
}, 30000);
|
| 192 |
-
}
|
| 193 |
-
|
| 194 |
-
function updateClock() {
|
| 195 |
-
var timeEl = document.getElementById('vtv-pro-time');
|
| 196 |
-
if (timeEl) {
|
| 197 |
-
timeEl.textContent = formatTime(getVNTime()) + ' GMT+10';
|
| 198 |
-
}
|
| 199 |
-
}
|
| 200 |
-
|
| 201 |
-
// ===== Switch channel =====
|
| 202 |
-
window._vtvProSwitch = function(chId) {
|
| 203 |
-
if (_hlsInst) { try { _hlsInst.destroy(); } catch(e){} _hlsInst = null; }
|
| 204 |
-
_currentCh = chId;
|
| 205 |
-
|
| 206 |
-
var tabs = document.querySelectorAll('#vtv-player-section .vtv-pro-tab');
|
| 207 |
-
for (var i = 0; i < tabs.length; i++) {
|
| 208 |
-
tabs[i].className = 'vtv-pro-tab' + (tabs[i].getAttribute('data-ch') === chId ? ' on' : '');
|
| 209 |
-
}
|
| 210 |
-
|
| 211 |
-
updateClock();
|
| 212 |
-
if (_timeTimer) clearInterval(_timeTimer);
|
| 213 |
-
_timeTimer = setInterval(updateClock, 60000);
|
| 214 |
-
|
| 215 |
-
var frame = document.getElementById('vtv-pro-frame');
|
| 216 |
-
if (!frame) return;
|
| 217 |
-
frame.innerHTML = '<div class="vtv-pro-load"><div class="vtv-pro-spinner"></div><span>Đang kết nối ' + chId.toUpperCase() + '...</span></div>';
|
| 218 |
-
|
| 219 |
-
loadEPG(chId);
|
| 220 |
-
startEpgRefresh();
|
| 221 |
-
|
| 222 |
-
var xhr = new XMLHttpRequest();
|
| 223 |
-
xhr.open('GET', '/api/vtv/stream/' + chId, true);
|
| 224 |
-
xhr.timeout = 30000;
|
| 225 |
-
xhr.onload = function() {
|
| 226 |
-
try {
|
| 227 |
-
var data = JSON.parse(xhr.responseText);
|
| 228 |
-
var url = data.stream_url;
|
| 229 |
-
if (!url) {
|
| 230 |
-
frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">📡</div><div class="msg">Kênh đang tạm ngừng phát sóng</div><button onclick="_vtvProSwitch(\''+chId+'\')">Thử lại</button></div>';
|
| 231 |
-
return;
|
| 232 |
-
}
|
| 233 |
-
url = proxyUrl(url);
|
| 234 |
-
playStream(url, frame);
|
| 235 |
-
} catch(e) {
|
| 236 |
-
frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">⚠️</div><div class="msg">Lỗi tải dữ liệu</div><button onclick="_vtvProSwitch(\''+chId+'\')">Thử lại</button></div>';
|
| 237 |
-
}
|
| 238 |
-
};
|
| 239 |
-
xhr.onerror = xhr.ontimeout = function() {
|
| 240 |
-
frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">🔌</div><div class="msg">Mất kết nối máy chủ</div><button onclick="_vtvProSwitch(\''+chId+'\')">Thử lại</button></div>';
|
| 241 |
-
};
|
| 242 |
-
xhr.send();
|
| 243 |
-
};
|
| 244 |
-
|
| 245 |
-
function playStream(url, frame) {
|
| 246 |
-
var video = document.createElement('video');
|
| 247 |
-
video.id = 'vtv-pro-video';
|
| 248 |
-
video.style.cssText = 'width:100%;height:100%;object-fit:contain';
|
| 249 |
-
video.setAttribute('controls', '');
|
| 250 |
-
video.setAttribute('autoplay', '');
|
| 251 |
-
video.setAttribute('playsinline', '');
|
| 252 |
-
frame.innerHTML = '';
|
| 253 |
-
frame.appendChild(video);
|
| 254 |
-
|
| 255 |
-
if (typeof Hls !== 'undefined' && Hls.isSupported()) {
|
| 256 |
-
var hls = new Hls({ debug: false, enableWorker: true, maxBufferLength: 30 });
|
| 257 |
-
hls.loadSource(url);
|
| 258 |
-
hls.attachMedia(video);
|
| 259 |
-
hls.on(Hls.Events.MANIFEST_PARSED, function() {
|
| 260 |
-
video.play().catch(function(){});
|
| 261 |
-
});
|
| 262 |
-
hls.on(Hls.Events.ERROR, function(ev, data) {
|
| 263 |
-
if (data.fatal) {
|
| 264 |
-
hls.destroy();
|
| 265 |
-
_hlsInst = null;
|
| 266 |
-
frame.innerHTML = '<div class="vtv-pro-err"><div class="icon">📹</div><div class="msg">Lỗi phát video</div><button onclick="_vtvProSwitch(\''+_currentCh+'\')">Thử lại</button></div>';
|
| 267 |
-
}
|
| 268 |
-
});
|
| 269 |
-
_hlsInst = hls;
|
| 270 |
-
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
| 271 |
-
video.src = url;
|
| 272 |
-
} else {
|
| 273 |
-
frame.innerHTML = '<div class="vtv-pro-err"><div class="msg">Trình duyệt không hỗ trợ HLS</div></div>';
|
| 274 |
-
}
|
| 275 |
-
}
|
| 276 |
-
|
| 277 |
-
// ===== HTML builder =====
|
| 278 |
-
function buildHTML() {
|
| 279 |
-
var tabs = '';
|
| 280 |
-
for (var i = 0; i < CHANNELS.length; i++) {
|
| 281 |
-
var ch = CHANNELS[i];
|
| 282 |
-
tabs += '<span class="vtv-pro-tab" data-ch="'+ch.id+'" onclick="_vtvProSwitch(\''+ch.id+'\')">' +
|
| 283 |
-
ch.name + '<span class="b">'+ch.badge+'</span></span>';
|
| 284 |
-
}
|
| 285 |
-
return '<div id="vtv-player-section">' +
|
| 286 |
-
'<div class="vtv-pro-head">' +
|
| 287 |
-
'<div class="vtv-pro-logo">V</div>' +
|
| 288 |
-
'<span class="vtv-pro-title">VTV Player</span>' +
|
| 289 |
-
'<span class="vtv-pro-live"><span class="vtv-pro-live-dot"></span>TRỰC TIẾP</span>' +
|
| 290 |
-
'</div>' +
|
| 291 |
-
'<div class="vtv-pro-tabs">'+tabs+'</div>' +
|
| 292 |
-
'<div class="vtv-pro-frame" id="vtv-pro-frame">' +
|
| 293 |
-
'<div class="vtv-pro-load"><div class="vtv-pro-spinner"></div><span>Chọn kênh để xem trực tiếp</span></div>' +
|
| 294 |
-
'</div>' +
|
| 295 |
-
'<div class="vtv-pro-controls">' +
|
| 296 |
-
'<span id="vtv-pro-time" class="vtv-pro-btn" style="background:none;border:none;font-size:10px;color:#5f6368;margin-right:auto"></span>' +
|
| 297 |
-
'</div>' +
|
| 298 |
-
'<div class="vtv-pro-epg">' +
|
| 299 |
-
'<div class="vtv-pro-epg-hdr">' +
|
| 300 |
-
'<span class="vtv-pro-epg-title">Lịch phát sóng</span>' +
|
| 301 |
-
'</div>' +
|
| 302 |
-
'<div class="vtv-pro-epg-list" id="vtv-pro-epg-body">' +
|
| 303 |
-
'<div class="vtv-pro-epg-empty">Chọn kênh để xem lịch phát sóng</div>' +
|
| 304 |
-
'</div>' +
|
| 305 |
-
'</div>' +
|
| 306 |
-
'</div>';
|
| 307 |
-
}
|
| 308 |
-
|
| 309 |
-
function inject() {
|
| 310 |
-
var homeEl = document.getElementById('view-home');
|
| 311 |
-
if (!homeEl || document.getElementById('vtv-player-section')) return;
|
| 312 |
-
|
| 313 |
-
var featured = document.getElementById('home-featured-area') || homeEl.querySelector('.featured-match, .fm-section, .slider-wrap');
|
| 314 |
-
if (featured && featured.parentNode) {
|
| 315 |
-
featured.insertAdjacentHTML('afterend', buildHTML());
|
| 316 |
-
} else if (homeEl.firstChild) {
|
| 317 |
-
homeEl.insertAdjacentHTML('afterbegin', buildHTML());
|
| 318 |
-
}
|
| 319 |
-
|
| 320 |
-
// Auto-load VTV3 as default
|
| 321 |
-
setTimeout(function() {
|
| 322 |
-
if (window._vtvProSwitch) {
|
| 323 |
-
window._vtvProSwitch('vtv3');
|
| 324 |
-
}
|
| 325 |
-
}, 500);
|
| 326 |
-
}
|
| 327 |
-
|
| 328 |
-
// Hook into loadHome
|
| 329 |
-
var orig = window.loadHome;
|
| 330 |
-
if (typeof orig === 'function') {
|
| 331 |
-
window.loadHome = function() {
|
| 332 |
-
var r = orig.apply(this, arguments);
|
| 333 |
-
if (r && typeof r.then === 'function') {
|
| 334 |
-
return r.then(function(v) { setTimeout(inject, 2000); return v; });
|
| 335 |
-
} else {
|
| 336 |
-
setTimeout(inject, 2000);
|
| 337 |
-
return r;
|
| 338 |
-
}
|
| 339 |
-
};
|
| 340 |
-
} else {
|
| 341 |
-
(function waitAndInject() {
|
| 342 |
-
if (document.getElementById('view-home') && !document.getElementById('vtv-player-section')) {
|
| 343 |
-
inject();
|
| 344 |
-
} else if (!document.getElementById('vtv-player-section')) {
|
| 345 |
-
setTimeout(waitAndInject, 1000);
|
| 346 |
-
}
|
| 347 |
-
})();
|
| 348 |
-
}
|
| 349 |
-
})();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/vtv_init_loader.html
DELETED
|
@@ -1,2 +0,0 @@
|
|
| 1 |
-
<script src="/static/yt_live.js"></script>
|
| 2 |
-
<script src="/static/vtv_init.js"></script>
|
|
|
|
|
|
|
|
|
static/wc2026_v2.js
CHANGED
|
@@ -314,4 +314,4 @@ function startWCLiveRefresh(){
|
|
| 314 |
}catch(e){}
|
| 315 |
},90000);
|
| 316 |
}
|
| 317 |
-
setTimeout(startWCLiveRefresh,5000);
|
|
|
|
| 314 |
}catch(e){}
|
| 315 |
},90000);
|
| 316 |
}
|
| 317 |
+
setTimeout(startWCLiveRefresh,5000);
|
static/yt_live.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
| 1 |
-
// === VNEWS — VTV LIVE + Inline Recorder
|
| 2 |
-
// Features: PiP, mini-player, INLINE RECORDER
|
| 3 |
-
//
|
| 4 |
-
// FIX v11: Allow clicking off tabs (always clickable), better stream status handling
|
| 5 |
|
| 6 |
(function(){
|
| 7 |
if(window._ytLiveLoaded) return;
|
|
@@ -15,161 +14,319 @@
|
|
| 15 |
{id:'vtv9',name:'VTV9',badge:'Miền Bắc'},{id:'vtv10',name:'VTV10',badge:'Cần Thơ'},
|
| 16 |
{id:'vtvprime',name:'VTVPrime',badge:'Prime'},
|
| 17 |
];
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
const
|
| 21 |
-
vtv1: 'https://package.vtvgo.vn/channel/vtv1-1,1.html',
|
| 22 |
-
vtv2: 'https://package.vtvgo.vn/channel/vtv2-1,2.html',
|
| 23 |
-
vtv3: 'https://package.vtvgo.vn/channel/vtv3-1,3.html',
|
| 24 |
-
vtv4: 'https://package.vtvgo.vn/channel/vtv4-1,4.html',
|
| 25 |
-
vtv5: 'https://package.vtvgo.vn/channel/vtv5-1,5.html',
|
| 26 |
-
vtv6: 'https://package.vtvgo.vn/channel/vtv6-1,13.html',
|
| 27 |
-
vtv7: 'https://package.vtvgo.vn/channel/vtv7-1,27.html',
|
| 28 |
-
vtv8: 'https://package.vtvgo.vn/channel/vtv8-1,36.html',
|
| 29 |
-
vtv9: 'https://package.vtvgo.vn/channel/vtv9-1,39.html',
|
| 30 |
-
vtv10: 'https://package.vtvgo.vn/channel/vtv10-1,6.html',
|
| 31 |
-
};
|
| 32 |
-
|
| 33 |
-
const DEFAULT_CHANNEL = 'vtv3';
|
| 34 |
-
const NEEDS_PROXY = /fptplay\.net|vtvgo\.vn/;
|
| 35 |
const STREAMS = {};
|
| 36 |
let _currentCh = null, _hls = null, _loading = false, _blockInserted = false;
|
| 37 |
-
let _streamsLoaded = false, _pipActive = false, _miniActive = false, _vtvPinned = false
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
-
// ===== RECORDER STATE =====
|
| 40 |
const _rec = {
|
| 41 |
active: false, startTime: null, endTime: null,
|
| 42 |
isRecording: false, recorder: null, chunks: [], blob: null,
|
| 43 |
-
ratio: '
|
|
|
|
| 44 |
};
|
| 45 |
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// === VNEWS — VTV LIVE + Inline Recorder v15 ===
|
| 2 |
+
// Features: DVR 5-min rewind (liveDurationInfinity), PiP, mini-player, INLINE RECORDER
|
| 3 |
+
// v15: Use liveDurationInfinity:true → HLS.js treats live as VOD, no auto-snap. User seeks freely in buffer.
|
|
|
|
| 4 |
|
| 5 |
(function(){
|
| 6 |
if(window._ytLiveLoaded) return;
|
|
|
|
| 14 |
{id:'vtv9',name:'VTV9',badge:'Miền Bắc'},{id:'vtv10',name:'VTV10',badge:'Cần Thơ'},
|
| 15 |
{id:'vtvprime',name:'VTVPrime',badge:'Prime'},
|
| 16 |
];
|
| 17 |
+
const DEFAULT_CHANNEL = 'vtv6';
|
| 18 |
+
const DVR_BUFFER_SECONDS = 300;
|
| 19 |
+
const NEEDS_PROXY = /fptplay\.net/;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
const STREAMS = {};
|
| 21 |
let _currentCh = null, _hls = null, _loading = false, _blockInserted = false;
|
| 22 |
+
let _streamsLoaded = false, _pipActive = false, _miniActive = false, _vtvPinned = false;
|
| 23 |
+
let _dvrSeekBarTimer = null;
|
| 24 |
+
let _dvrUserSeeking = false;
|
| 25 |
+
let _dvrAtLiveEdge = true;
|
| 26 |
|
|
|
|
| 27 |
const _rec = {
|
| 28 |
active: false, startTime: null, endTime: null,
|
| 29 |
isRecording: false, recorder: null, chunks: [], blob: null,
|
| 30 |
+
stream: null, tracks: [], ratio: '16:9', aiTitle: null, aiTags: [],
|
| 31 |
+
_ratioCrop: null, _aiAborted: false
|
| 32 |
};
|
| 33 |
|
| 34 |
+
function fmtSec(s){const m=Math.floor(s/60),sec=Math.floor(s%60);return m+':'+(sec<10?'0':'')+sec;}
|
| 35 |
+
function $(id){return document.getElementById(id);}
|
| 36 |
+
|
| 37 |
+
async function loadAllStreams(){
|
| 38 |
+
if(_streamsLoaded) return;
|
| 39 |
+
_streamsLoaded = true;
|
| 40 |
+
try{
|
| 41 |
+
const r=await fetch('/api/vtv/streams');
|
| 42 |
+
if(!r.ok) return;
|
| 43 |
+
const data=await r.json();
|
| 44 |
+
for(const ch of CHANNELS){
|
| 45 |
+
if(data[ch.id]&&data[ch.id].stream_url){
|
| 46 |
+
let u=data[ch.id].stream_url;
|
| 47 |
+
if(NEEDS_PROXY.test(u)) u='/api/proxy/m3u8/vtv?url='+encodeURIComponent(u);
|
| 48 |
+
STREAMS[ch.id]=[u];
|
| 49 |
+
} else STREAMS[ch.id]=[];
|
| 50 |
+
}
|
| 51 |
+
}catch(e){}
|
| 52 |
+
CHANNELS.forEach(ch=>{
|
| 53 |
+
const t=document.getElementById('vtvt-'+ch.id);
|
| 54 |
+
if(t){
|
| 55 |
+
if(STREAMS[ch.id]&&STREAMS[ch.id].length>0){t.classList.remove('off');t.textContent=ch.name;}
|
| 56 |
+
else{t.style.opacity='0.35';t.textContent=ch.name+' ✕';}
|
| 57 |
+
}
|
| 58 |
+
});
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
function createDVRBar(){
|
| 62 |
+
const player=$('vtv-player-wrap');
|
| 63 |
+
if(!player) return;
|
| 64 |
+
const old=$('vtv-dvr-bar');
|
| 65 |
+
if(old) old.remove();
|
| 66 |
+
const bar=document.createElement('div');
|
| 67 |
+
bar.id='vtv-dvr-bar';
|
| 68 |
+
bar.innerHTML='<div class="vtv-dvr-track"><div class="vtv-dvr-buffer" id="vtv-dvr-buffer"></div><div class="vtv-dvr-live-dot" id="vtv-dvr-live-dot"></div><div class="vtv-dvr-handle" id="vtv-dvr-handle"></div></div><div class="vtv-dvr-controls"><span class="vtv-dvr-time" id="vtv-dvr-time">LIVE</span><button class="vtv-dvr-live-btn" id="vtv-dvr-live-btn">● LIVE</button></div>';
|
| 69 |
+
player.appendChild(bar);
|
| 70 |
+
return bar;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
function updateDVRUI(){
|
| 74 |
+
const video=$('vtv-player');
|
| 75 |
+
if(!video||!_hls) return;
|
| 76 |
+
const track=$('vtv-dvr-track');
|
| 77 |
+
const buffer=$('vtv-dvr-buffer');
|
| 78 |
+
const handle=$('vtv-dvr-handle');
|
| 79 |
+
const liveDot=$('vtv-dvr-live-dot');
|
| 80 |
+
const timeLabel=$('vtv-dvr-time');
|
| 81 |
+
if(!track||!buffer||!handle) return;
|
| 82 |
+
const curTime=video.currentTime;
|
| 83 |
+
const duration=video.duration;
|
| 84 |
+
if(!duration||!isFinite(duration)||duration<=0) return;
|
| 85 |
+
const bufferStart=Math.max(0,duration-DVR_BUFFER_SECONDS);
|
| 86 |
+
const bufferWidth=((duration-bufferStart)/DVR_BUFFER_SECONDS)*100;
|
| 87 |
+
const handlePos=((curTime-bufferStart)/(duration-bufferStart))*100;
|
| 88 |
+
buffer.style.width=Math.min(100,bufferWidth)+'%';
|
| 89 |
+
buffer.style.left=(100-Math.min(100,bufferWidth))+'%';
|
| 90 |
+
liveDot.style.left='100%';
|
| 91 |
+
handle.style.left=Math.max(0,Math.min(100,handlePos))+'%';
|
| 92 |
+
const behind=duration-curTime;
|
| 93 |
+
if(behind<3){timeLabel.textContent='LIVE';timeLabel.style.color='#e74c3c';}
|
| 94 |
+
else{timeLabel.textContent='-'+fmtSec(behind);timeLabel.style.color='#8ab4d8';}
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
function seekFromTrack(clientX,isRelease){
|
| 98 |
+
const video=$('vtv-player');
|
| 99 |
+
if(!video||!_hls) return;
|
| 100 |
+
const track=$('vtv-dvr-track');
|
| 101 |
+
if(!track) return;
|
| 102 |
+
const rect=track.getBoundingClientRect();
|
| 103 |
+
const pct=Math.max(0,Math.min(1,(clientX-rect.left)/rect.width));
|
| 104 |
+
const duration=video.duration;
|
| 105 |
+
if(!duration||!isFinite(duration)||duration<=0) return;
|
| 106 |
+
const bufferStart=Math.max(0,duration-DVR_BUFFER_SECONDS);
|
| 107 |
+
const seekTime=bufferStart+pct*(duration-bufferStart);
|
| 108 |
+
_dvrUserSeeking=!isRelease;
|
| 109 |
+
_dvrAtLiveEdge=(duration-seekTime<2);
|
| 110 |
+
video.currentTime=seekTime;
|
| 111 |
+
if(isRelease){video.play().catch(()=>{});}
|
| 112 |
+
else{video.pause();}
|
| 113 |
+
updateDVRUI();
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
function setupDVRBar(){
|
| 117 |
+
const video=$('vtv-player');
|
| 118 |
+
if(!video) return;
|
| 119 |
+
createDVRBar();
|
| 120 |
+
const track=$('vtv-dvr-track');
|
| 121 |
+
const handle=$('vtv-dvr-handle');
|
| 122 |
+
const liveBtn=$('vtv-dvr-live-btn');
|
| 123 |
+
if(!track||!handle) return;
|
| 124 |
+
let isDragging=false;
|
| 125 |
+
track.addEventListener('click',function(e){seekFromTrack(e.clientX,true);});
|
| 126 |
+
track.addEventListener('touchstart',function(e){e.preventDefault();seekFromTrack(e.touches[0].clientX,false);},{passive:false});
|
| 127 |
+
track.addEventListener('touchend',function(e){e.preventDefault();seekFromTrack(e.changedTouches[0].clientX,true);},{passive:false});
|
| 128 |
+
handle.addEventListener('mousedown',function(e){e.preventDefault();e.stopPropagation();isDragging=true;_dvrUserSeeking=true;});
|
| 129 |
+
handle.addEventListener('touchstart',function(e){e.preventDefault();e.stopPropagation();isDragging=true;_dvrUserSeeking=true;},{passive:false});
|
| 130 |
+
document.addEventListener('mousemove',function(e){if(isDragging){e.preventDefault();seekFromTrack(e.clientX,false);}});
|
| 131 |
+
document.addEventListener('touchmove',function(e){if(isDragging){e.preventDefault();seekFromTrack(e.touches[0].clientX,false);}},{passive:false});
|
| 132 |
+
document.addEventListener('mouseup',function(e){if(isDragging){isDragging=false;_dvrUserSeeking=false;seekFromTrack(e.clientX,true);}});
|
| 133 |
+
document.addEventListener('touchend',function(e){if(isDragging){isDragging=false;_dvrUserSeeking=false;if(e.changedTouches[0])seekFromTrack(e.changedTouches[0].clientX,true);}});
|
| 134 |
+
liveBtn.addEventListener('click',function(){
|
| 135 |
+
const v=$('vtv-player');
|
| 136 |
+
if(v&&_hls){
|
| 137 |
+
_dvrUserSeeking=false;_dvrAtLiveEdge=true;
|
| 138 |
+
const dur=v.duration;
|
| 139 |
+
if(dur&&isFinite(dur)&&dur>0){v.currentTime=dur;}
|
| 140 |
+
v.play().catch(()=>{});
|
| 141 |
+
updateDVRUI();
|
| 142 |
+
}
|
| 143 |
+
});
|
| 144 |
+
if(_dvrSeekBarTimer) clearInterval(_dvrSeekBarTimer);
|
| 145 |
+
_dvrSeekBarTimer=setInterval(updateDVRUI,1000);
|
| 146 |
+
video.addEventListener('timeupdate',function(){
|
| 147 |
+
if(!_hls) return;
|
| 148 |
+
const dur=video.duration;
|
| 149 |
+
if(!dur||!isFinite(dur)) return;
|
| 150 |
+
_dvrAtLiveEdge=((dur-video.currentTime)<2);
|
| 151 |
+
});
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
function showDVRBar(){const bar=$('vtv-dvr-bar');if(bar) bar.classList.add('visible');}
|
| 155 |
+
|
| 156 |
+
function setupPinButton(){
|
| 157 |
+
const block=document.getElementById('vtv-block');
|
| 158 |
+
if(!block) return;
|
| 159 |
+
let pinBtn=block.querySelector('.vtv-pin-btn');
|
| 160 |
+
if(!pinBtn){
|
| 161 |
+
pinBtn=document.createElement('button');
|
| 162 |
+
pinBtn.className='vtv-pin-btn';pinBtn.textContent='📌';pinBtn.title='Ghim khung xem';
|
| 163 |
+
block.style.position='relative';block.appendChild(pinBtn);
|
| 164 |
+
}
|
| 165 |
+
pinBtn.onclick=function(){
|
| 166 |
+
_vtvPinned=!_vtvPinned;
|
| 167 |
+
pinBtn.style.color=_vtvPinned?'#e74c3c':'#8ab4d8';
|
| 168 |
+
if(_vtvPinned){block.style.position='fixed';block.style.bottom='12px';block.style.right='12px';block.style.width='360px';block.style.zIndex='9999';block.style.borderRadius='12px';block.style.boxShadow='0 4px 24px rgba(0,0,0,0.5)';}
|
| 169 |
+
else{block.style.position='';block.style.bottom='';block.style.right='';block.style.width='';block.style.zIndex='';block.style.borderRadius='';block.style.boxShadow='';}
|
| 170 |
+
};
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
async function togglePiP(){
|
| 174 |
+
const video=$('vtv-player');
|
| 175 |
+
if(!video) return;
|
| 176 |
+
try{
|
| 177 |
+
if(document.pictureInPictureElement===video){await document.exitPictureInPicture();_pipActive=false;}
|
| 178 |
+
else{await video.requestPictureInPicture();_pipActive=true;}
|
| 179 |
+
}catch(e){}
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
function activateMiniPlayer(){
|
| 183 |
+
const video=$('vtv-player');
|
| 184 |
+
if(!video||!video.src) return;
|
| 185 |
+
_miniActive=true;
|
| 186 |
+
const mini=document.createElement('div');
|
| 187 |
+
mini.id='vtv-mini-player';
|
| 188 |
+
mini.innerHTML='<div class="vtv-mini-header"><span id="vtv-mini-ch">VTV</span><span id="vtv-mini-epg"></span><button id="vtv-mini-close" style="background:none;border:none;color:#e74c3c;font-size:18px;cursor:pointer;">✕</button></div><div class="vtv-mini-body"><div class="vtv-mini-loading">Đang tải...</div></div>';
|
| 189 |
+
document.body.appendChild(mini);
|
| 190 |
+
const mv=mini.querySelector('.vtv-mini-body');
|
| 191 |
+
video.style.display='none';mv.appendChild(video);
|
| 192 |
+
video.style.display='block';video.style.width='100%';video.style.height='100%';
|
| 193 |
+
mini.querySelector('.vtv-mini-loading').remove();
|
| 194 |
+
$('vtv-mini-close').onclick=closeMiniPlayer;
|
| 195 |
+
if(_currentCh){fetch('/api/vtv/epg/'+_currentCh).then(r=>r.json()).then(d=>{const p=d.programs||[];const n=p.find(x=>x.now);$('vtv-mini-epg').textContent=n?n.time+' '+n.title:(p[0]?p[0].time+' '+p[0].title:'');}).catch(()=>{});}
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
function closeMiniPlayer(){
|
| 199 |
+
if(!_miniActive) return;
|
| 200 |
+
_miniActive=false;
|
| 201 |
+
const mini=$('vtv-mini-player');
|
| 202 |
+
if(!mini) return;
|
| 203 |
+
const video=$('vtv-player');
|
| 204 |
+
if(video){mini.querySelector('.vtv-mini-body').appendChild(video);video.style.display='block';}
|
| 205 |
+
mini.remove();
|
| 206 |
+
const wrap=$('vtv-player-wrap');
|
| 207 |
+
if(wrap&&video){wrap.appendChild(video);video.style.display='block';}
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
function initRecorder(){
|
| 211 |
+
const block=document.getElementById('vtv-block');
|
| 212 |
+
if(!block) return;
|
| 213 |
+
let recBtn=block.querySelector('.vtv-rec-btn');
|
| 214 |
+
if(!recBtn){
|
| 215 |
+
recBtn=document.createElement('button');
|
| 216 |
+
recBtn.className='vtv-rec-btn';recBtn.textContent='⏺ REC';recBtn.title='Quay màn hình';
|
| 217 |
+
block.style.position='relative';block.appendChild(recBtn);
|
| 218 |
+
}
|
| 219 |
+
recBtn.onclick=function(){
|
| 220 |
+
if(!_rec.active){
|
| 221 |
+
_rec.active=true;recBtn.style.color='#e74c3c';recBtn.textContent='⏹ STOP';
|
| 222 |
+
_rec.chunks=[];_rec.blob=null;
|
| 223 |
+
const video=$('vtv-player');
|
| 224 |
+
if(video&&video.captureStream){
|
| 225 |
+
_rec.stream=video.captureStream();_rec.tracks=_rec.stream.getTracks();
|
| 226 |
+
try{
|
| 227 |
+
_rec.recorder=new MediaRecorder(_rec.stream,{mimeType:'video/webm;codecs=vp9'});
|
| 228 |
+
_rec.recorder.ondataavailable=function(e){if(e.data.size>0)_rec.chunks.push(e.data);};
|
| 229 |
+
_rec.recorder.onstop=function(){_rec.blob=new Blob(_rec.chunks,{type:'video/webm'});_rec.chunks=[];_rec.tracks.forEach(t=>t.stop());_rec.stream=null;showRecordPreview(_rec.blob);};
|
| 230 |
+
_rec.recorder.start(1000);_rec.isRecording=true;_rec.startTime=Date.now();
|
| 231 |
+
}catch(e){_rec.tracks.forEach(t=>t.stop());_rec.stream=null;_rec.active=false;recBtn.style.color='';recBtn.textContent='⏺ REC';}
|
| 232 |
+
}
|
| 233 |
+
}else{
|
| 234 |
+
if(_rec.isRecording&&_rec.recorder&&_rec.recorder.state==='recording'){_rec.recorder.stop();_rec.isRecording=false;_rec.endTime=Date.now();}
|
| 235 |
+
_rec.active=false;recBtn.style.color='';recBtn.textContent='⏺ REC';
|
| 236 |
+
}
|
| 237 |
+
};
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
function showRecordPreview(blob){
|
| 241 |
+
const url=URL.createObjectURL(blob);
|
| 242 |
+
const div=document.createElement('div');
|
| 243 |
+
div.style.cssText='position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:99999;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;';
|
| 244 |
+
div.innerHTML='<video src="'+url+'" controls style="max-width:80%;max-height:60%;border-radius:8px;"></video><div style="display:flex;gap:8px;"><button id="rec-upload" style="padding:8px 16px;background:#2ecc71;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:14px;">📤 Upload</button><button id="rec-close" style="padding:8px 16px;background:#e74c3c;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:14px;">✕ Đóng</button></div>';
|
| 245 |
+
document.body.appendChild(div);
|
| 246 |
+
$('rec-close').onclick=function(){URL.revokeObjectURL(url);div.remove();};
|
| 247 |
+
$('rec-upload').onclick=function(){const v=div.querySelector('video');v.pause();uploadRecording(blob,div,url);};
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
async function uploadRecording(blob,div,url){
|
| 251 |
+
const btn=$('rec-upload');btn.textContent='⏳ Đang upload...';btn.disabled=true;
|
| 252 |
+
try{const fd=new FormData();fd.append('file',blob,'recording.webm');const r=await fetch('/api/upload',{method:'POST',body:fd});const d=await r.json();if(d.ok){btn.textContent='✅ Upload thành công!';setTimeout(()=>{URL.revokeObjectURL(url);div.remove();},1500);}else{btn.textContent='❌ Lỗi: '+(d.error||'unknown');btn.disabled=false;}}catch(e){btn.textContent='❌ Lỗi kết nối';btn.disabled=false;}
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
function switchChannel(chId){
|
| 256 |
+
if(_loading) return;
|
| 257 |
+
_loading=true;
|
| 258 |
+
const ch=CHANNELS.find(c=>c.id===chId);
|
| 259 |
+
if(!ch){_loading=false;return;}
|
| 260 |
+
_currentCh=chId;
|
| 261 |
+
const video=$('vtv-player');
|
| 262 |
+
const loadEl=$('vtv-loading');
|
| 263 |
+
const errEl=$('vtv-error');
|
| 264 |
+
const errMsg=$('vtv-error-msg');
|
| 265 |
+
if(!video){_loading=false;return;}
|
| 266 |
+
video.style.display='none';
|
| 267 |
+
if(loadEl) loadEl.style.display='flex';
|
| 268 |
+
if(errEl) errEl.style.display='none';
|
| 269 |
+
if(_hls){_hls.destroy();_hls=null;}
|
| 270 |
+
_dvrAtLiveEdge=true;
|
| 271 |
+
const oldBar=$('vtv-dvr-bar');
|
| 272 |
+
if(oldBar) oldBar.remove();
|
| 273 |
+
if(_dvrSeekBarTimer){clearInterval(_dvrSeekBarTimer);_dvrSeekBarTimer=null;}
|
| 274 |
+
const urls=STREAMS[chId]||[];
|
| 275 |
+
if(!urls.length){if(loadEl)loadEl.style.display='none';if(errEl){errEl.style.display='flex';errMsg.textContent=chId==='vtvprime'?'VTVPrime: Kênh trả phí.':ch.name+': Không có luồng.';}_loading=false;return;}
|
| 276 |
+
_tryPlay(video,urls,0,ch.name,loadEl,errEl,errMsg);
|
| 277 |
+
loadEpg(chId);
|
| 278 |
+
if(_miniActive){$('vtv-mini-ch').textContent=ch.name;fetch('/api/vtv/epg/'+_currentCh).then(r=>r.json()).then(d=>{const p=d.programs||[];const n=p.find(x=>x.now);$('vtv-mini-epg').textContent=n?n.time+' '+n.title:(p[0]?p[0].time+' '+p[0].title:'');}).catch(()=>{});}
|
| 279 |
+
setTimeout(()=>{setupDVRBar();showDVRBar();},2000);
|
| 280 |
+
_loading=false;
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
function loadEpg(chId){
|
| 284 |
+
fetch('/api/vtv/epg/'+chId).then(r=>r.json()).then(d=>{const p=d.programs||[];const n=p.find(x=>x.now);const el=$('vtv-epg-now');if(el) el.textContent=n?n.time+' '+n.title:(p[0]?p[0].time+' '+p[0].title:'');}).catch(()=>{});
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
function _tryPlay(video,urls,idx,name,loadEl,errEl,errMsg){
|
| 288 |
+
if(idx>=urls.length){if(loadEl)loadEl.style.display='none';if(errEl){errEl.style.display='flex';errMsg.textContent=name+': Tất cả nguồn lỗi.';}return;}
|
| 289 |
+
const src=urls[idx];
|
| 290 |
+
if(loadEl) loadEl.innerHTML='<div class="vtv-spinner"></div>Kết nối '+name+' ('+(idx+1)+'/'+urls.length+')...';
|
| 291 |
+
if(typeof Hls!=='undefined'&&Hls.isSupported()){
|
| 292 |
+
const hls=new Hls({
|
| 293 |
+
enableWorker:true,lowLatencyMode:false,startLevel:0,capLevelToPlayerSize:true,
|
| 294 |
+
maxBufferLength:120,maxMaxBufferLength:180,
|
| 295 |
+
liveDurationInfinity:true,
|
| 296 |
+
liveBackBufferLength:DVR_BUFFER_SECONDS,
|
| 297 |
+
fragLoadingTimeOut:10000,manifestLoadingTimeOut:10000
|
| 298 |
+
});
|
| 299 |
+
_hls=hls; hls.loadSource(src); hls.attachMedia(video);
|
| 300 |
+
hls.on(Hls.Events.MANIFEST_PARSED,(ev,data)=>{
|
| 301 |
+
loadEl.style.display='none';video.style.display='block';
|
| 302 |
+
const dur=video.duration;
|
| 303 |
+
if(dur&&isFinite(dur)&&dur>0){video.currentTime=dur;}
|
| 304 |
+
video.play().catch(()=>{});
|
| 305 |
+
});
|
| 306 |
+
let rec=0; hls.on(Hls.Events.ERROR,(ev,data)=>{if(data.fatal){if(data.type===Hls.ErrorTypes.NETWORK_ERROR){rec++;if(rec<=3)setTimeout(()=>hls.startLoad(),2000);else{hls.destroy();_hls=null;_tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);}}else if(data.type===Hls.ErrorTypes.MEDIA_ERROR){try{hls.recoverMediaError();}catch(e){}}else{hls.destroy();_hls=null;_tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);}}});
|
| 307 |
+
}else if(video.canPlayType('application/vnd.apple.mpegurl')){
|
| 308 |
+
video.src=src;
|
| 309 |
+
video.addEventListener('loadedmetadata',()=>{video.play().catch(()=>{});loadEl.style.display='none';video.style.display='block';},{once:true});
|
| 310 |
+
video.addEventListener('error',()=>{_tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);},{once:true});
|
| 311 |
+
}else{if(loadEl)loadEl.style.display='none';if(errEl){errEl.style.display='flex';errMsg.textContent='Không hỗ trợ HLS';}}
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
const _origShowView=window.showView;
|
| 315 |
+
window.showView=function(id){if(id==='view-home'&&_miniActive)closeMiniPlayer();else if(id!=='view-home'&&_currentCh&&STREAMS[_currentCh]&&STREAMS[_currentCh].length>0&&!_miniActive)activateMiniPlayer();return _origShowView.apply(this,arguments);};
|
| 316 |
+
const _origReadArticle=window.readArticle; if(_origReadArticle){window.readArticle=function(url){if(_currentCh&&STREAMS[_currentCh]&&STREAMS[_currentCh].length>0&&!_miniActive)activateMiniPlayer();return _origReadArticle.apply(this,arguments);};}
|
| 317 |
+
const _origSwitchCat=window.switchCat; if(_origSwitchCat){window.switchCat=function(id){if(id==='home'){if(_miniActive)closeMiniPlayer();}else if(_currentCh&&STREAMS[_currentCh]&&STREAMS[_currentCh].length>0&&!_miniActive)activateMiniPlayer();return _origSwitchCat.apply(this,arguments);};}
|
| 318 |
+
|
| 319 |
+
const _origLoadHome=window.loadHome;
|
| 320 |
+
if(_origLoadHome&&!_origLoadHome.__vtvWrapped){
|
| 321 |
+
window.loadHome=async function(){const old=document.getElementById('vtv-block');if(old)old.remove();_blockInserted=false;const r=await _origLoadHome.apply(this,arguments);try{pinBlock();}catch(e){}return r;};
|
| 322 |
+
window.loadHome.__vtvWrapped=true;
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
window._vtvPlay=switchChannel;
|
| 326 |
+
window._vtvPiP=togglePiP;
|
| 327 |
+
|
| 328 |
+
loadAllStreams().then(()=>{setTimeout(()=>{window._vtvPlay(DEFAULT_CHANNEL);},300);});
|
| 329 |
+
|
| 330 |
+
setTimeout(()=>{const pipBtn=document.getElementById('vtv-pip-btn');if(pipBtn) pipBtn.onclick=togglePiP;setupPinButton();initRecorder();},1000);
|
| 331 |
+
|
| 332 |
+
})();
|
vtv_api.py
CHANGED
|
@@ -1,341 +1,272 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
from fastapi import APIRouter, Query
|
| 5 |
from fastapi.responses import JSONResponse, Response
|
| 6 |
from bs4 import BeautifulSoup
|
| 7 |
from datetime import datetime, timedelta, timezone
|
| 8 |
|
| 9 |
-
VN_TZ = timezone(timedelta(hours=
|
|
|
|
| 10 |
router = APIRouter()
|
| 11 |
|
| 12 |
UA = {
|
| 13 |
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
| 14 |
"Accept-Language": "vi-VN,vi;q=0.9",
|
| 15 |
-
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
| 16 |
-
"Sec-Fetch-Dest": "document",
|
| 17 |
-
"Sec-Fetch-Mode": "navigate",
|
| 18 |
-
"Sec-Fetch-Site": "none",
|
| 19 |
-
"Upgrade-Insecure-Requests": "1",
|
| 20 |
}
|
| 21 |
|
| 22 |
-
# =====
|
| 23 |
-
|
| 24 |
-
"vtv1": "https://
|
| 25 |
-
"vtv2": "https://
|
| 26 |
-
"vtv3": "https://
|
| 27 |
-
"vtv4": "https://
|
| 28 |
-
"vtv5": "https://
|
| 29 |
-
"vtv6": "https://
|
| 30 |
-
"vtv7": "https://
|
| 31 |
-
"vtv8": "https://
|
| 32 |
-
"vtv9": "https://
|
| 33 |
-
"vtv10": "https://
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
}
|
| 35 |
|
| 36 |
CHANNEL_NAMES = {
|
| 37 |
-
"vtv1":"VTV1",
|
| 38 |
-
"
|
| 39 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
}
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
_CACHE_TTL = 90
|
| 46 |
|
| 47 |
-
def _cached(
|
| 48 |
-
with
|
| 49 |
-
if
|
| 50 |
-
return
|
| 51 |
return None
|
| 52 |
-
def _set_cache(k, d):
|
| 53 |
-
with _lock:
|
| 54 |
-
_cache[k] = {'t': time.time(), 'd': d}
|
| 55 |
|
| 56 |
-
|
|
|
|
|
|
|
| 57 |
|
| 58 |
-
def
|
| 59 |
-
"""Extract m3u8 URL from sv2.xemtivitop.com PHP obfuscated JS"""
|
| 60 |
if not html:
|
| 61 |
return None
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
# Method 1: Direct m3u8 URL
|
| 66 |
-
for m in re.finditer(r'(https?://[^\s"\'<>]+\.m3u8[^\s"\'<>]*)', html):
|
| 67 |
-
url = m.group(1).strip().rstrip('.,;)\'\"')
|
| 68 |
-
if len(url) > 20 and url not in urls:
|
| 69 |
-
urls.append(url)
|
| 70 |
-
|
| 71 |
-
# Method 2: file: "..." pattern
|
| 72 |
-
for m in re.finditer(r"""['"]file['"]\s*:\s*['"]([^'"]*\.m3u8[^'"]*)['"]""", html, re.IGNORECASE):
|
| 73 |
url = m.group(1).strip()
|
| 74 |
-
if
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
# Method 3: src: "..." or source: "..."
|
| 80 |
-
for m in re.finditer(r"""['"]src['"]\s*:\s*['"]([^'"]*\.m3u8[^'"]*)['"]""", html, re.IGNORECASE):
|
| 81 |
url = m.group(1).strip()
|
| 82 |
-
if
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
decoded = base64.b64decode(m.group(1)).decode('utf-8', errors='ignore')
|
| 91 |
-
if '.m3u8' in decoded:
|
| 92 |
-
um = re.search(r'(https?://[^\s"\'<>]+\.m3u8[^\s"\'<>]*)', decoded)
|
| 93 |
-
if um and um.group(1) not in urls:
|
| 94 |
-
urls.append(um.group(1))
|
| 95 |
-
except:
|
| 96 |
-
pass
|
| 97 |
-
|
| 98 |
-
# Method 5: Script analysis + String.fromCharCode
|
| 99 |
try:
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
url = 'https:' + url
|
| 107 |
-
if url not in urls and len(url) > 20:
|
| 108 |
-
urls.append(url)
|
| 109 |
-
|
| 110 |
-
fcc_matches = re.findall(r'String\.fromCharCode\s*\(([^)]+)\)', script)
|
| 111 |
-
if fcc_matches:
|
| 112 |
-
for fcc in fcc_matches:
|
| 113 |
-
try:
|
| 114 |
-
codes = [int(x.strip()) for x in fcc.split(',') if x.strip().isdigit()]
|
| 115 |
-
decoded = ''.join(chr(c) for c in codes)
|
| 116 |
-
if '.m3u8' in decoded:
|
| 117 |
-
um = re.search(r'(https?://[^\s"\'<>]+\.m3u8[^\s"\'<>]*)', decoded)
|
| 118 |
-
if um and um.group(1) not in urls:
|
| 119 |
-
urls.append(um.group(1))
|
| 120 |
-
except:
|
| 121 |
-
pass
|
| 122 |
except:
|
| 123 |
pass
|
| 124 |
-
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
try:
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
if src not in urls:
|
| 134 |
-
urls.append(src)
|
| 135 |
-
for source in vid.find_all('source'):
|
| 136 |
-
s = source.get('src', '')
|
| 137 |
-
if s and '.m3u8' in s:
|
| 138 |
-
if s.startswith('//'):
|
| 139 |
-
s = 'https:' + s
|
| 140 |
-
if s not in urls:
|
| 141 |
-
urls.append(s)
|
| 142 |
-
|
| 143 |
-
# Method 7: iframe source
|
| 144 |
-
for iframe in soup.find_all('iframe'):
|
| 145 |
-
src = iframe.get('src', '')
|
| 146 |
-
if src and '.m3u8' in src:
|
| 147 |
-
if src.startswith('//'):
|
| 148 |
-
src = 'https:' + src
|
| 149 |
-
if src not in urls:
|
| 150 |
-
urls.append(src)
|
| 151 |
except:
|
| 152 |
pass
|
| 153 |
-
|
| 154 |
-
# Method 8: "link" variable
|
| 155 |
-
for m in re.finditer(r"""['"]?link['"]?\s*[:=]\s*['"]([^'"]*)['"]""", html, re.IGNORECASE):
|
| 156 |
-
url = m.group(1).strip()
|
| 157 |
-
if '.m3u8' in url:
|
| 158 |
-
if url.startswith('//'):
|
| 159 |
-
url = 'https:' + url
|
| 160 |
-
if url not in urls and len(url) > 20:
|
| 161 |
-
urls.append(url)
|
| 162 |
-
|
| 163 |
-
# Deduplicate
|
| 164 |
-
seen = set()
|
| 165 |
-
unique_urls = []
|
| 166 |
-
for u in urls:
|
| 167 |
-
u_clean = u.split('?')[0].split('#')[0]
|
| 168 |
-
if u_clean not in seen:
|
| 169 |
-
seen.add(u_clean)
|
| 170 |
-
unique_urls.append(u)
|
| 171 |
-
|
| 172 |
-
return unique_urls[0] if unique_urls else None
|
| 173 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
|
| 175 |
-
def
|
|
|
|
| 176 |
if not url:
|
| 177 |
return None
|
| 178 |
try:
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
r = requests.get(url, headers=h, timeout=timeout, allow_redirects=True, verify=False)
|
| 183 |
-
if r.status_code == 200 and '#EXTM3U' in r.text[:500]:
|
| 184 |
return url
|
| 185 |
except:
|
| 186 |
pass
|
| 187 |
return None
|
| 188 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
|
| 190 |
def fetch_vtv_stream(channel_id):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
channel_id = channel_id.lower().strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
cached = _cached(channel_id)
|
| 193 |
if cached is not None:
|
| 194 |
return cached
|
| 195 |
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
r = requests.get(php_url, headers=h, timeout=15, allow_redirects=True, verify=False)
|
| 203 |
-
if r.status_code == 200:
|
| 204 |
-
extracted = _extract_from_js_obfuscation(r.text)
|
| 205 |
-
if extracted:
|
| 206 |
-
verified = verify_hls(extracted, "https://sv2.xemtivitop.com/", timeout=10)
|
| 207 |
-
if verified:
|
| 208 |
-
result = verified
|
| 209 |
-
else:
|
| 210 |
-
result = extracted
|
| 211 |
-
except Exception as e:
|
| 212 |
-
print(f"[vtv_api] sv2 error for {channel_id}: {e}")
|
| 213 |
-
|
| 214 |
-
_set_cache(channel_id, result)
|
| 215 |
-
return result
|
| 216 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
|
| 218 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
'vtv9': 'vtv9', 'vtv10': 'vtv10', 'vtvprime': 'vtvprime',
|
| 226 |
-
}
|
| 227 |
-
epg_ch = epg_map.get(channel_id)
|
| 228 |
-
if not epg_ch:
|
| 229 |
-
return {"programs": [], "channel": channel_id, "date": ""}
|
| 230 |
-
|
| 231 |
-
today = datetime.now(VN_TZ).strftime("%Y-%m-%d")
|
| 232 |
-
cache_key = f"epg_{epg_ch}_{today}"
|
| 233 |
-
cached = _cached(cache_key)
|
| 234 |
-
if cached is not None:
|
| 235 |
-
return cached
|
| 236 |
-
|
| 237 |
-
programs = []
|
| 238 |
-
|
| 239 |
-
try:
|
| 240 |
-
h = {"User-Agent": UA["User-Agent"], "Accept-Language": "vi-VN,vi;q=0.9"}
|
| 241 |
-
|
| 242 |
-
# Try channel-specific page first, then main
|
| 243 |
-
ch_url = f"https://vtv.vn/lich-phat-song-{epg_ch}.htm"
|
| 244 |
-
r = requests.get(ch_url, headers=h, timeout=10)
|
| 245 |
-
r.encoding = 'utf-8'
|
| 246 |
-
|
| 247 |
-
if r.status_code != 200:
|
| 248 |
-
ch_url = "https://vtv.vn/lich-phat-song.htm"
|
| 249 |
-
r = requests.get(ch_url, headers=h, timeout=10)
|
| 250 |
-
r.encoding = 'utf-8'
|
| 251 |
-
|
| 252 |
-
if r.status_code == 200:
|
| 253 |
-
soup = BeautifulSoup(r.text, 'lxml')
|
| 254 |
-
|
| 255 |
-
# Pattern 1: JSON data in <script>
|
| 256 |
-
for script in soup.find_all('script'):
|
| 257 |
-
text = script.string or ''
|
| 258 |
-
if 'time' in text.lower() and ('title' in text.lower() or 'program' in text.lower()):
|
| 259 |
-
for m in re.finditer(r'\[.*?\]', text, re.DOTALL):
|
| 260 |
-
try:
|
| 261 |
-
data = json.loads(m.group(0))
|
| 262 |
-
if isinstance(data, list) and len(data) > 0:
|
| 263 |
-
for item in data:
|
| 264 |
-
if isinstance(item, dict) and 'time' in item:
|
| 265 |
-
programs.append({
|
| 266 |
-
'time': item.get('time', ''),
|
| 267 |
-
'title': item.get('title', item.get('name', item.get('program', ''))),
|
| 268 |
-
})
|
| 269 |
-
except:
|
| 270 |
-
pass
|
| 271 |
-
|
| 272 |
-
# Pattern 2: Tables
|
| 273 |
-
for table in soup.find_all('table'):
|
| 274 |
-
for row in table.find_all('tr'):
|
| 275 |
-
cells = row.find_all(['td', 'th'])
|
| 276 |
-
if len(cells) >= 2:
|
| 277 |
-
time_text = cells[0].get_text(strip=True)
|
| 278 |
-
title_text = cells[1].get_text(strip=True)
|
| 279 |
-
if re.match(r'^\d{1,2}:\d{2}', time_text) and len(title_text) >= 3:
|
| 280 |
-
programs.append({'time': time_text[:5], 'title': title_text})
|
| 281 |
-
|
| 282 |
-
# Pattern 3: Time-stamped text elements
|
| 283 |
-
for el in soup.find_all(['div', 'li', 'p', 'span', 'td']):
|
| 284 |
-
text = el.get_text(strip=True)
|
| 285 |
-
tm = re.match(r'^(\d{1,2}:\d{2})\s*[-–—:]\s*(.+)', text)
|
| 286 |
-
if tm and len(tm.group(2)) >= 3:
|
| 287 |
-
programs.append({'time': tm.group(1), 'title': tm.group(2).strip()})
|
| 288 |
-
|
| 289 |
-
# Pattern 4: Schedule blocks by class
|
| 290 |
-
for cls_pattern in [r'schedule', r'program', r'lich', r'epg', r'timeline', r'table']:
|
| 291 |
-
for el in soup.find_all(class_=re.compile(cls_pattern, re.I)):
|
| 292 |
-
for item in el.find_all(['li', 'div', 'p']):
|
| 293 |
-
text = item.get_text(strip=True)
|
| 294 |
-
tm = re.match(r'^(\d{1,2}:\d{2})\s*[-–—:]\s*(.+)', text)
|
| 295 |
-
if tm and len(tm.group(2)) >= 3:
|
| 296 |
-
programs.append({'time': tm.group(1), 'title': tm.group(2).strip()})
|
| 297 |
-
|
| 298 |
-
except Exception as e:
|
| 299 |
-
print(f"[vtv_api] EPG error for {channel_id}: {e}")
|
| 300 |
-
|
| 301 |
-
# Fallback: vtvgo.vn API
|
| 302 |
-
if len(programs) < 3:
|
| 303 |
-
try:
|
| 304 |
-
api_url = f"https://vtvgo.vn/api/schedule?channel={epg_ch}&date={today}"
|
| 305 |
-
r = requests.get(api_url, headers={"User-Agent": UA["User-Agent"]}, timeout=10)
|
| 306 |
-
if r.status_code == 200:
|
| 307 |
-
data = r.json()
|
| 308 |
-
items = data if isinstance(data, list) else data.get('data', data.get('schedule', []))
|
| 309 |
-
for item in items:
|
| 310 |
-
if isinstance(item, dict):
|
| 311 |
-
programs.append({
|
| 312 |
-
'time': item.get('time', item.get('start_time', ''))[:5],
|
| 313 |
-
'title': item.get('title', item.get('name', item.get('program', ''))),
|
| 314 |
-
})
|
| 315 |
-
except:
|
| 316 |
-
pass
|
| 317 |
-
|
| 318 |
-
# Deduplicate and sort
|
| 319 |
-
seen = set()
|
| 320 |
-
unique = []
|
| 321 |
-
for p in programs:
|
| 322 |
-
key = f"{p['time']}|{p['title']}"
|
| 323 |
-
if key not in seen:
|
| 324 |
-
seen.add(key)
|
| 325 |
-
unique.append(p)
|
| 326 |
-
unique.sort(key=lambda x: x['time'])
|
| 327 |
-
|
| 328 |
-
result = {
|
| 329 |
-
"programs": unique[:50],
|
| 330 |
-
"channel": channel_id,
|
| 331 |
-
"date": today,
|
| 332 |
-
"timezone": "+10",
|
| 333 |
-
}
|
| 334 |
-
_set_cache(cache_key, result)
|
| 335 |
-
return result
|
| 336 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
|
| 338 |
-
|
|
|
|
| 339 |
|
| 340 |
@router.get("/api/vtv/streams")
|
| 341 |
def api_vtv_streams():
|
|
@@ -345,7 +276,6 @@ def api_vtv_streams():
|
|
| 345 |
result[ch_id] = {"name": CHANNEL_NAMES[ch_id], "stream_url": stream_url, "status": "ok" if stream_url else "offline"}
|
| 346 |
return JSONResponse(result)
|
| 347 |
|
| 348 |
-
|
| 349 |
@router.get("/api/vtv/stream/{channel_id}")
|
| 350 |
def api_vtv_stream(channel_id: str):
|
| 351 |
stream_url = fetch_vtv_stream(channel_id)
|
|
@@ -353,64 +283,264 @@ def api_vtv_stream(channel_id: str):
|
|
| 353 |
return JSONResponse({"stream_url": stream_url, "status": "ok"})
|
| 354 |
return JSONResponse({"error": "stream not found", "status": "offline"}, status_code=404)
|
| 355 |
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
|
|
|
|
|
|
|
|
|
| 367 |
|
| 368 |
@router.get("/api/proxy/m3u8/vtv")
|
| 369 |
-
def
|
| 370 |
try:
|
| 371 |
-
|
| 372 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
if r.status_code != 200:
|
| 374 |
return Response(status_code=502, content="upstream error")
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
lines = r.text.strip().split('\n')
|
| 378 |
rewritten = []
|
|
|
|
| 379 |
for line in lines:
|
| 380 |
-
|
| 381 |
-
if
|
| 382 |
rewritten.append(line)
|
| 383 |
-
elif stripped.startswith('http'):
|
| 384 |
-
rewritten.append(f"/api/proxy/seg/vtv?url={urllib.parse.quote(stripped, safe='')}")
|
| 385 |
else:
|
| 386 |
-
seg_url =
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
)
|
| 394 |
except Exception as e:
|
| 395 |
-
return Response(status_code=502, content=
|
| 396 |
-
|
| 397 |
|
| 398 |
@router.get("/api/proxy/seg/vtv")
|
| 399 |
-
def
|
| 400 |
try:
|
| 401 |
-
|
| 402 |
-
|
|
|
|
|
|
|
|
|
|
| 403 |
if r.status_code != 200:
|
| 404 |
return Response(status_code=502, content="upstream error")
|
| 405 |
-
|
| 406 |
data = r.content
|
| 407 |
if len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47:
|
| 408 |
data = data[188:]
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
except Exception as e:
|
| 416 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
VTV Channels API - Backend endpoints for VTV1-VTV10 + VTVPrime
|
| 3 |
+
Fetches stream URLs from xemtv.us PHP endpoints (primary)
|
| 4 |
+
Fallback: FPTPlay CDN → VTVGo CDN → xemtv.net (legacy)
|
| 5 |
+
EPG data scraped from https://vtv.vn/lich-phat-song.htm
|
| 6 |
+
"""
|
| 7 |
+
import re, time, threading
|
| 8 |
+
import requests
|
| 9 |
from fastapi import APIRouter, Query
|
| 10 |
from fastapi.responses import JSONResponse, Response
|
| 11 |
from bs4 import BeautifulSoup
|
| 12 |
from datetime import datetime, timedelta, timezone
|
| 13 |
|
| 14 |
+
VN_TZ = timezone(timedelta(hours=7))
|
| 15 |
+
|
| 16 |
router = APIRouter()
|
| 17 |
|
| 18 |
UA = {
|
| 19 |
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
| 20 |
"Accept-Language": "vi-VN,vi;q=0.9",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
}
|
| 22 |
|
| 23 |
+
# ===== PRIMARY: xemtv.us (new domain, works 2025-2026) =====
|
| 24 |
+
XEMTV_US_ENDPOINTS = {
|
| 25 |
+
"vtv1": "https://xemtv.us/tv/vtv1.php",
|
| 26 |
+
"vtv2": "https://xemtv.us/tv/vtv2.php",
|
| 27 |
+
"vtv3": "https://xemtv.us/tv/vtv3.php",
|
| 28 |
+
"vtv4": "https://xemtv.us/tv/vtv4.php",
|
| 29 |
+
"vtv5": "https://xemtv.us/tv/vtv5.php",
|
| 30 |
+
"vtv6": "https://xemtv.us/tv/vtv6.php",
|
| 31 |
+
"vtv7": "https://xemtv.us/tv/vtv7.php",
|
| 32 |
+
"vtv8": "https://xemtv.us/tv/vtv8.php",
|
| 33 |
+
"vtv9": "https://xemtv.us/tv/vtv9.php",
|
| 34 |
+
"vtv10": "https://xemtv.us/tv/vtv10.php",
|
| 35 |
+
"vtvprime": "https://xemtv.us/tv/vtvprime.php",
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
# ===== LEGACY: xemtv.net (may return 403, keep as last resort) =====
|
| 39 |
+
XEMTV_LEGACY_ENDPOINTS = {
|
| 40 |
+
"vtv1": "https://hd.xemtv.net/kenh/vtv1.php",
|
| 41 |
+
"vtv2": "https://hd.xemtv.net/kenh/vtv2.php",
|
| 42 |
+
"vtv3": "https://hd.xemtv.net/kenh/vtv3.php",
|
| 43 |
+
"vtv4": "https://hd.xemtv.net/kenh/vtv4.php",
|
| 44 |
+
"vtv5": "https://hd.xemtv.net/kenh/vtv5.php",
|
| 45 |
+
"vtv6": "https://hd.xemtv.net/kenh/vtv6.php",
|
| 46 |
+
"vtv7": "https://hd.xemtv.net/kenh/vtv7.php",
|
| 47 |
+
"vtv8": "https://hd.xemtv.net/kenh/vtv8.php",
|
| 48 |
+
"vtv9": "https://hd.xemtv.net/kenh/vtv9.php",
|
| 49 |
+
"vtv10": "https://hd.xemtv.net/kenh/vtv10.php",
|
| 50 |
+
"vtvprime": "https://hd.xemtv.net/kenh/vtvprime.php",
|
| 51 |
}
|
| 52 |
|
| 53 |
CHANNEL_NAMES = {
|
| 54 |
+
"vtv1": "VTV1",
|
| 55 |
+
"vtv2": "VTV2",
|
| 56 |
+
"vtv3": "VTV3",
|
| 57 |
+
"vtv4": "VTV4",
|
| 58 |
+
"vtv5": "VTV5",
|
| 59 |
+
"vtv6": "VTV6",
|
| 60 |
+
"vtv7": "VTV7",
|
| 61 |
+
"vtv8": "VTV8",
|
| 62 |
+
"vtv9": "VTV9",
|
| 63 |
+
"vtv10": "VTV10",
|
| 64 |
+
"vtvprime": "VTVPrime",
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
# ===== FALLBACK 1: FPTPlay CDN (new URLs 2025-2026) =====
|
| 68 |
+
FPTPLAY_URLS = {
|
| 69 |
+
"vtv1": "https://live-a.fptplay53.net/live/media/vtv1/live247-hls-avc/index.m3u8",
|
| 70 |
+
"vtv2": "https://live-a.fptplay53.net/live/media/vtv2/live247-hls-avc/index.m3u8",
|
| 71 |
+
"vtv3": "https://live-a.fptplay53.net/live/media/vtv3/live247-hls-avc/index.m3u8",
|
| 72 |
+
"vtv4": "https://live-a.fptplay53.net/live/media/vtv4/live247-hls-avc/index.m3u8",
|
| 73 |
+
"vtv5": "https://live-a.fptplay53.net/live/media/vtv5/live247-hls-avc/index.m3u8",
|
| 74 |
+
"vtv6": "https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/index.m3u8",
|
| 75 |
+
"vtv7": "https://live-a.fptplay53.net/live/media/vtv7/live247-hls-avc/index.m3u8",
|
| 76 |
+
"vtv8": "https://live-a.fptplay53.net/live/media/vtv8/live-hls-avc/index.m3u8",
|
| 77 |
+
"vtv9": "https://live-a.fptplay53.net/live/media/vtv9/live247-hls-avc/index.m3u8",
|
| 78 |
+
"vtv10": "https://live-a.fptplay53.net/live/media/vtv10/live247-hls-avc/index.m3u8",
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
# ===== FALLBACK 2: VTVGo CDN =====
|
| 82 |
+
VTVGO_FAILOVER = {
|
| 83 |
+
"vtv1": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8",
|
| 84 |
+
"vtv2": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv2-manifest.m3u8",
|
| 85 |
+
"vtv3": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv3-manifest.m3u8",
|
| 86 |
+
"vtv4": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv4-manifest.m3u8",
|
| 87 |
+
"vtv5": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv5-manifest.m3u8",
|
| 88 |
+
"vtv6": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv6-manifest.m3u8",
|
| 89 |
+
"vtv7": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv7-manifest.m3u8",
|
| 90 |
+
"vtv8": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv8-manifest.m3u8",
|
| 91 |
+
"vtv9": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv9-manifest.m3u8",
|
| 92 |
}
|
| 93 |
|
| 94 |
+
_vtv_cache = {}
|
| 95 |
+
_vtv_lock = threading.Lock()
|
| 96 |
+
_CACHE_TTL = 180
|
|
|
|
| 97 |
|
| 98 |
+
def _cached(key):
|
| 99 |
+
with _vtv_lock:
|
| 100 |
+
if key in _vtv_cache and time.time() - _vtv_cache[key]['t'] < _CACHE_TTL:
|
| 101 |
+
return _vtv_cache[key]['d']
|
| 102 |
return None
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
+
def _set_cache(key, data):
|
| 105 |
+
with _vtv_lock:
|
| 106 |
+
_vtv_cache[key] = {'t': time.time(), 'd': data}
|
| 107 |
|
| 108 |
+
def extract_m3u8_from_html(html):
|
|
|
|
| 109 |
if not html:
|
| 110 |
return None
|
| 111 |
+
m = re.search(r"file\s*:\s*['\"]([^'\"]*\.m3u8[^'\"]*)['\"]", html, re.IGNORECASE)
|
| 112 |
+
if m:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
url = m.group(1).strip()
|
| 114 |
+
if len(url) > 20:
|
| 115 |
+
return url
|
| 116 |
+
m = re.search(r"(https?://[^\s\"'<>\\]+\.m3u8[^\s\"'<>\\]*)", html, re.IGNORECASE)
|
| 117 |
+
if m:
|
|
|
|
|
|
|
|
|
|
| 118 |
url = m.group(1).strip()
|
| 119 |
+
if len(url) > 20:
|
| 120 |
+
return url
|
| 121 |
+
return None
|
| 122 |
+
|
| 123 |
+
def fetch_xemtv_us_stream(channel_id):
|
| 124 |
+
php_url = XEMTV_US_ENDPOINTS.get(channel_id)
|
| 125 |
+
if not php_url:
|
| 126 |
+
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
try:
|
| 128 |
+
headers = {**UA, "Referer": "https://xemtv.us/"}
|
| 129 |
+
r = requests.get(php_url, headers=headers, timeout=15, allow_redirects=True, verify=False)
|
| 130 |
+
if r.status_code == 200:
|
| 131 |
+
m3u8 = extract_m3u8_from_html(r.text)
|
| 132 |
+
if m3u8:
|
| 133 |
+
return m3u8
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
except:
|
| 135 |
pass
|
| 136 |
+
return None
|
| 137 |
+
|
| 138 |
+
def fetch_xemtv_legacy_stream(channel_id):
|
| 139 |
+
php_url = XEMTV_LEGACY_ENDPOINTS.get(channel_id)
|
| 140 |
+
if not php_url:
|
| 141 |
+
return None
|
| 142 |
try:
|
| 143 |
+
headers = {**UA, "Referer": "https://hd.xemtv.net/"}
|
| 144 |
+
r = requests.get(php_url, headers=headers, timeout=15, allow_redirects=True, verify=False)
|
| 145 |
+
if r.status_code == 200:
|
| 146 |
+
m3u8 = extract_m3u8_from_html(r.text)
|
| 147 |
+
if m3u8:
|
| 148 |
+
return m3u8
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
except:
|
| 150 |
pass
|
| 151 |
+
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
+
def fetch_fptplay_stream(channel_id):
|
| 154 |
+
url = FPTPLAY_URLS.get(channel_id)
|
| 155 |
+
if not url:
|
| 156 |
+
return None
|
| 157 |
+
try:
|
| 158 |
+
headers = {
|
| 159 |
+
"User-Agent": UA["User-Agent"],
|
| 160 |
+
"Referer": "https://fptplay.vn/",
|
| 161 |
+
"Origin": "https://fptplay.vn",
|
| 162 |
+
}
|
| 163 |
+
r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=False)
|
| 164 |
+
if r.status_code == 200 and '#EXTM3U' in r.text[:200]:
|
| 165 |
+
return url
|
| 166 |
+
except:
|
| 167 |
+
pass
|
| 168 |
+
return None
|
| 169 |
|
| 170 |
+
def fetch_vtvgo_stream(channel_id):
|
| 171 |
+
url = VTVGO_FAILOVER.get(channel_id)
|
| 172 |
if not url:
|
| 173 |
return None
|
| 174 |
try:
|
| 175 |
+
headers = {**UA, "Referer": "https://vtvgo.vn/"}
|
| 176 |
+
r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=False)
|
| 177 |
+
if r.status_code == 200 and '#EXTM3U' in r.text[:200]:
|
|
|
|
|
|
|
| 178 |
return url
|
| 179 |
except:
|
| 180 |
pass
|
| 181 |
return None
|
| 182 |
|
| 183 |
+
def normalize_fptplay_url(url):
|
| 184 |
+
"""Replace old/broken FPTPlay URLs with new working ones"""
|
| 185 |
+
if not url:
|
| 186 |
+
return url
|
| 187 |
+
old_to_new = {
|
| 188 |
+
"https://live.fptplay53.net/fnxch2/vtv1hd_abr.smil/chunklist.m3u8":
|
| 189 |
+
"https://live-a.fptplay53.net/live/media/vtv1/live247-hls-avc/index.m3u8",
|
| 190 |
+
"https://live.fptplay53.net/fnxch2/vtv2hd_abr.smil/chunklist.m3u8":
|
| 191 |
+
"https://live-a.fptplay53.net/live/media/vtv2/live247-hls-avc/index.m3u8",
|
| 192 |
+
"https://live.fptplay53.net/fnxch2/vtv3hd_abr.smil/chunklist.m3u8":
|
| 193 |
+
"https://live-a.fptplay53.net/live/media/vtv3/live247-hls-avc/index.m3u8",
|
| 194 |
+
"https://live.fptplay53.net/fnxch2/vtv4hd_abr.smil/chunklist.m3u8":
|
| 195 |
+
"https://live-a.fptplay53.net/live/media/vtv4/live247-hls-avc/index.m3u8",
|
| 196 |
+
"https://live.fptplay53.net/fnxhd1/vtv5hd_vhls.smil/chunklist.m3u8":
|
| 197 |
+
"https://live-a.fptplay53.net/live/media/vtv5/live247-hls-avc/index.m3u8",
|
| 198 |
+
"https://live.fptplay53.net/fnxhd1/vtv6hd_vhls.smil/chunklist.m3u8":
|
| 199 |
+
"https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/index.m3u8",
|
| 200 |
+
"https://live.fptplay53.net/fnxhd1/vtv7hd_vhls.smil/chunklist_b5000000.m3u8":
|
| 201 |
+
"https://live-a.fptplay53.net/live/media/vtv7/live247-hls-avc/index.m3u8",
|
| 202 |
+
"https://live.fptplay53.net/epzhd1/vtv8hd_vhls.smil/c.hunklist.m3u8":
|
| 203 |
+
"https://live-a.fptplay53.net/live/media/vtv8/live-hls-avc/index.m3u8",
|
| 204 |
+
"https://live.fptplay53.net/epzhd1/vtv8hd_vhls.smil/chunklist.m3u8":
|
| 205 |
+
"https://live-a.fptplay53.net/live/media/vtv8/live-hls-avc/index.m3u8",
|
| 206 |
+
"https://live.fptplay53.net/fnxhd1/vtv9hd_vhls.smil/chunklist.m3u8":
|
| 207 |
+
"https://live-a.fptplay53.net/live/media/vtv9/live247-hls-avc/index.m3u8",
|
| 208 |
+
"https://live.fptplay53.net/fnxhd1/vtv10hd_vhls.smil/chunklist.m3u8":
|
| 209 |
+
"https://live-a.fptplay53.net/live/media/vtv10/live247-hls-avc/index.m3u8",
|
| 210 |
+
"https://live-a.fptplay53.net/live/media/VTV5HD/live_hls_avc/index.m3u8":
|
| 211 |
+
"https://live-a.fptplay53.net/live/media/vtv5/live247-hls-avc/index.m3u8",
|
| 212 |
+
}
|
| 213 |
+
return old_to_new.get(url, url)
|
| 214 |
|
| 215 |
def fetch_vtv_stream(channel_id):
|
| 216 |
+
"""Fetch VTV stream with multi-source fallback chain:
|
| 217 |
+
1. xemtv.us (primary - new domain, most reliable)
|
| 218 |
+
2. FPTPlay CDN (fallback - new URLs)
|
| 219 |
+
3. VTVGo CDN (fallback)
|
| 220 |
+
4. xemtv.net legacy (last resort)
|
| 221 |
+
"""
|
| 222 |
channel_id = channel_id.lower().strip()
|
| 223 |
+
name_map = {
|
| 224 |
+
'vtvct': 'vtv10', 'vtv-can-tho': 'vtv10', 'vtv can tho': 'vtv10',
|
| 225 |
+
'vtv_can_tho': 'vtv10', 'cantho': 'vtv10',
|
| 226 |
+
'vietnam_vtv1': 'vtv1', 'vietnam_vtv2': 'vtv2', 'vietnam_vtv3': 'vtv3',
|
| 227 |
+
'vietnam_vtv4': 'vtv4', 'vietnam_vtv5': 'vtv5', 'vietnam_vtv6': 'vtv6',
|
| 228 |
+
'vietnam_vtv7': 'vtv7', 'vietnam_vtv8': 'vtv8', 'vietnam_vtv9': 'vtv9',
|
| 229 |
+
}
|
| 230 |
+
channel_id = name_map.get(channel_id, channel_id)
|
| 231 |
cached = _cached(channel_id)
|
| 232 |
if cached is not None:
|
| 233 |
return cached
|
| 234 |
|
| 235 |
+
if channel_id == 'vtvprime':
|
| 236 |
+
url = fetch_xemtv_us_stream('vtvprime') or fetch_xemtv_legacy_stream('vtvprime')
|
| 237 |
+
if url:
|
| 238 |
+
url = normalize_fptplay_url(url)
|
| 239 |
+
_set_cache(channel_id, url)
|
| 240 |
+
return url
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
|
| 242 |
+
# Source 1: xemtv.us (primary)
|
| 243 |
+
url = fetch_xemtv_us_stream(channel_id)
|
| 244 |
+
if url:
|
| 245 |
+
url = normalize_fptplay_url(url)
|
| 246 |
+
_set_cache(channel_id, url)
|
| 247 |
+
return url
|
| 248 |
|
| 249 |
+
# Source 2: FPTPlay CDN
|
| 250 |
+
url = fetch_fptplay_stream(channel_id)
|
| 251 |
+
if url:
|
| 252 |
+
_set_cache(channel_id, url)
|
| 253 |
+
return url
|
| 254 |
|
| 255 |
+
# Source 3: VTVGo CDN
|
| 256 |
+
url = fetch_vtvgo_stream(channel_id)
|
| 257 |
+
if url:
|
| 258 |
+
_set_cache(channel_id, url)
|
| 259 |
+
return url
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
|
| 261 |
+
# Source 4: xemtv.net legacy (last resort)
|
| 262 |
+
url = fetch_xemtv_legacy_stream(channel_id)
|
| 263 |
+
if url:
|
| 264 |
+
url = normalize_fptplay_url(url)
|
| 265 |
+
_set_cache(channel_id, url)
|
| 266 |
+
return url
|
| 267 |
|
| 268 |
+
_set_cache(channel_id, None)
|
| 269 |
+
return None
|
| 270 |
|
| 271 |
@router.get("/api/vtv/streams")
|
| 272 |
def api_vtv_streams():
|
|
|
|
| 276 |
result[ch_id] = {"name": CHANNEL_NAMES[ch_id], "stream_url": stream_url, "status": "ok" if stream_url else "offline"}
|
| 277 |
return JSONResponse(result)
|
| 278 |
|
|
|
|
| 279 |
@router.get("/api/vtv/stream/{channel_id}")
|
| 280 |
def api_vtv_stream(channel_id: str):
|
| 281 |
stream_url = fetch_vtv_stream(channel_id)
|
|
|
|
| 283 |
return JSONResponse({"stream_url": stream_url, "status": "ok"})
|
| 284 |
return JSONResponse({"error": "stream not found", "status": "offline"}, status_code=404)
|
| 285 |
|
| 286 |
+
@router.get("/api/proxy/page")
|
| 287 |
+
def proxy_page(url: str = Query(...)):
|
| 288 |
+
try:
|
| 289 |
+
headers = {**UA}
|
| 290 |
+
if "xemtv.us" in url:
|
| 291 |
+
headers["Referer"] = "https://xemtv.us/"
|
| 292 |
+
elif "xemtv.net" in url:
|
| 293 |
+
headers["Referer"] = "https://hd.xemtv.net/"
|
| 294 |
+
r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=False)
|
| 295 |
+
if r.status_code != 200:
|
| 296 |
+
return Response(status_code=502, content="upstream error")
|
| 297 |
+
return Response(content=r.text.encode("utf-8"), media_type="text/html; charset=utf-8", headers={"Access-Control-Allow-Origin": "*"})
|
| 298 |
+
except:
|
| 299 |
+
return Response(status_code=502, content="proxy error")
|
| 300 |
|
| 301 |
@router.get("/api/proxy/m3u8/vtv")
|
| 302 |
+
def proxy_vtv_m3u8(url: str = Query(...)):
|
| 303 |
try:
|
| 304 |
+
headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
|
| 305 |
+
if "fptplay" in url:
|
| 306 |
+
headers["Referer"] = "https://fptplay.vn/"
|
| 307 |
+
headers["Origin"] = "https://fptplay.vn"
|
| 308 |
+
elif "xemtv" in url:
|
| 309 |
+
headers["Referer"] = "https://xemtv.us/"
|
| 310 |
+
elif "vtvgo" in url or "vtvdigital" in url:
|
| 311 |
+
headers["Referer"] = "https://vtvgo.vn/"
|
| 312 |
+
r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=False)
|
| 313 |
if r.status_code != 200:
|
| 314 |
return Response(status_code=502, content="upstream error")
|
| 315 |
+
content = r.text
|
| 316 |
+
lines = content.split('\n')
|
|
|
|
| 317 |
rewritten = []
|
| 318 |
+
base_url = url.rsplit('/', 1)[0] + '/'
|
| 319 |
for line in lines:
|
| 320 |
+
line = line.strip()
|
| 321 |
+
if not line or line.startswith('#'):
|
| 322 |
rewritten.append(line)
|
|
|
|
|
|
|
| 323 |
else:
|
| 324 |
+
seg_url = line
|
| 325 |
+
if not seg_url.startswith('http'):
|
| 326 |
+
seg_url = base_url + seg_url
|
| 327 |
+
if seg_url.endswith('.m3u8'):
|
| 328 |
+
rewritten.append("/api/proxy/m3u8/vtv?url=" + requests.utils.quote(seg_url, safe=""))
|
| 329 |
+
else:
|
| 330 |
+
rewritten.append("/api/proxy/seg/vtv?url=" + requests.utils.quote(seg_url, safe=""))
|
| 331 |
+
return Response(content='\n'.join(rewritten).encode("utf-8"), media_type="application/vnd.apple.mpegurl", headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "no-cache"})
|
| 332 |
except Exception as e:
|
| 333 |
+
return Response(status_code=502, content="proxy error: " + str(e))
|
|
|
|
| 334 |
|
| 335 |
@router.get("/api/proxy/seg/vtv")
|
| 336 |
+
def proxy_vtv_segment(url: str = Query(...)):
|
| 337 |
try:
|
| 338 |
+
headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
|
| 339 |
+
if "fptplay" in url:
|
| 340 |
+
headers["Referer"] = "https://fptplay.vn/"
|
| 341 |
+
headers["Origin"] = "https://fptplay.vn"
|
| 342 |
+
r = requests.get(url, headers=headers, timeout=30, allow_redirects=True, verify=False)
|
| 343 |
if r.status_code != 200:
|
| 344 |
return Response(status_code=502, content="upstream error")
|
|
|
|
| 345 |
data = r.content
|
| 346 |
if len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47:
|
| 347 |
data = data[188:]
|
| 348 |
+
return Response(content=data, media_type="video/mp2t", headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600"})
|
| 349 |
+
except:
|
| 350 |
+
return Response(status_code=502, content="proxy error")
|
| 351 |
+
|
| 352 |
+
_epg_cache = {}
|
| 353 |
+
_epg_cache_time = 0
|
| 354 |
+
_EPG_CACHE_TTL = 600 # Giảm từ 30 phút xuống 10 phút, đảm bảo dữ liệu mới
|
| 355 |
+
|
| 356 |
+
VTV_CHANNEL_MAP = {
|
| 357 |
+
"vtv1": "vtv1", "vtv2": "vtv2", "vtv3": "vtv3", "vtv4": "vtv4",
|
| 358 |
+
"vtv5": "vtv5", "vtv5-tay-nam-bo": "vtv5", "vtv5-tay-nguyen": "vtv5",
|
| 359 |
+
"vtv7": "vtv7", "vtv8": "vtv8", "vtv6": "vtv6", "vtv9": "vtv9",
|
| 360 |
+
"vtv-can-tho": "vtv10",
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
def _fetch_epg_from_vtv():
|
| 364 |
+
global _epg_cache, _epg_cache_time
|
| 365 |
+
now_ts = time.time()
|
| 366 |
+
if _epg_cache and now_ts - _epg_cache_time < _EPG_CACHE_TTL:
|
| 367 |
+
return _epg_cache
|
| 368 |
+
epg_data = {}
|
| 369 |
+
# Lấy thời gian VN hiện tại để truyền vào parse_time
|
| 370 |
+
now_vn = datetime.now(VN_TZ)
|
| 371 |
+
try:
|
| 372 |
+
headers = {
|
| 373 |
+
"User-Agent": UA["User-Agent"], "Accept-Language": "vi-VN,vi;q=0.9",
|
| 374 |
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
| 375 |
+
"Referer": "https://vtv.vn/",
|
| 376 |
+
}
|
| 377 |
+
r = requests.get("https://vtv.vn/lich-phat-song.htm", headers=headers, timeout=20)
|
| 378 |
+
if r.status_code != 200:
|
| 379 |
+
return epg_data
|
| 380 |
+
r.encoding = "utf-8"
|
| 381 |
+
soup = BeautifulSoup(r.text, "lxml")
|
| 382 |
+
channel_order = []
|
| 383 |
+
list_channel = soup.find(class_=re.compile(r'list-channel'))
|
| 384 |
+
if list_channel:
|
| 385 |
+
for link in list_channel.find_all('a', href=re.compile(r'truyen-hinh-truc-tuyen/([^.]+)\.htm')):
|
| 386 |
+
ch_id = re.search(r'truyen-hinh-truc-tuyen/([^.]+)\.htm', link.get('href', ''))
|
| 387 |
+
if ch_id:
|
| 388 |
+
channel_order.append(ch_id.group(1))
|
| 389 |
+
if not channel_order:
|
| 390 |
+
for link in soup.find_all('a', href=re.compile(r'truyen-hinh-truc-tuyen/([^.]+)\.htm')):
|
| 391 |
+
ch_id = re.search(r'truyen-hinh-truc-tuyen/([^.]+)\.htm', link.get('href', ''))
|
| 392 |
+
if ch_id and ch_id.group(1) not in channel_order:
|
| 393 |
+
channel_order.append(ch_id.group(1))
|
| 394 |
+
prog_containers = soup.find_all('ul', class_=re.compile(r'\bprograms\b'))
|
| 395 |
+
for i, container in enumerate(prog_containers):
|
| 396 |
+
if i >= len(channel_order):
|
| 397 |
+
break
|
| 398 |
+
vtv_ch_id = channel_order[i]
|
| 399 |
+
our_ch_id = VTV_CHANNEL_MAP.get(vtv_ch_id, vtv_ch_id)
|
| 400 |
+
if our_ch_id not in epg_data:
|
| 401 |
+
epg_data[our_ch_id] = []
|
| 402 |
+
for li in container.find_all('li', class_=re.compile(r'\bprogram\b')):
|
| 403 |
+
time_span = li.find('span', class_=re.compile(r'\btime\b'))
|
| 404 |
+
title_span = li.find('span', class_=re.compile(r'\btitle\b'))
|
| 405 |
+
genre_a = li.find('a', class_=re.compile(r'\bgenre\b'))
|
| 406 |
+
time_str = time_span.get_text(strip=True) if time_span else ""
|
| 407 |
+
title = ""
|
| 408 |
+
if genre_a:
|
| 409 |
+
title = genre_a.get_text(strip=True)
|
| 410 |
+
if not title and title_span:
|
| 411 |
+
title = title_span.get_text(strip=True)
|
| 412 |
+
if not time_str or not title:
|
| 413 |
+
continue
|
| 414 |
+
# Truyền reference_date để xử lý quy tắc ngày truyền hình VTV
|
| 415 |
+
start_dt = _parse_time(time_str, reference_date=now_vn)
|
| 416 |
+
if not start_dt:
|
| 417 |
+
continue
|
| 418 |
+
epg_data[our_ch_id].append({"time": time_str[:5], "title": title[:80], "start_dt": start_dt})
|
| 419 |
+
for ch_id in epg_data:
|
| 420 |
+
epg_data[ch_id].sort(key=lambda x: x.get("start_dt") or datetime.min)
|
| 421 |
+
seen = set()
|
| 422 |
+
unique = []
|
| 423 |
+
for p in epg_data[ch_id]:
|
| 424 |
+
key = (p["time"], p["title"])
|
| 425 |
+
if key not in seen:
|
| 426 |
+
seen.add(key)
|
| 427 |
+
unique.append(p)
|
| 428 |
+
epg_data[ch_id] = unique
|
| 429 |
except Exception as e:
|
| 430 |
+
print(f"EPG vtv.vn error: {e}")
|
| 431 |
+
_epg_cache = epg_data
|
| 432 |
+
_epg_cache_time = now_ts
|
| 433 |
+
return epg_data
|
| 434 |
+
|
| 435 |
+
def _parse_time(time_str, reference_date=None):
|
| 436 |
+
"""
|
| 437 |
+
Parse giờ từ lịch phát sóng VTV (đã là giờ VN UTC+7) sang datetime có timezone.
|
| 438 |
+
|
| 439 |
+
VTV hiển thị lịch theo ngày dương lịch (không phải ngày truyền hình).
|
| 440 |
+
Ví dụ: Lịch ngày 17/06 sẽ hiển thị tất cả chương trình từ 00:00 đến 23:59 ngày 17/06.
|
| 441 |
+
|
| 442 |
+
Logic:
|
| 443 |
+
- Giờ 00:00-04:59: Có thể là đêm khuya của ngày hôm trước HOẶC sáng sớm của ngày mới
|
| 444 |
+
- Giờ 05:00-23:59: Luôn thuộc ngày hiện tại
|
| 445 |
+
"""
|
| 446 |
+
if not time_str:
|
| 447 |
+
return None
|
| 448 |
+
time_str = time_str.strip().replace("h", ":").replace("H", ":")
|
| 449 |
+
m = re.search(r'(\d{1,2}):(\d{2})', time_str)
|
| 450 |
+
if m:
|
| 451 |
+
try:
|
| 452 |
+
hour, minute = int(m.group(1)), int(m.group(2))
|
| 453 |
+
|
| 454 |
+
if reference_date:
|
| 455 |
+
base_date = reference_date
|
| 456 |
+
else:
|
| 457 |
+
# Lấy ngày hiện tại theo giờ VN (UTC+7)
|
| 458 |
+
now_vn = datetime.now(VN_TZ)
|
| 459 |
+
base_date = now_vn
|
| 460 |
+
|
| 461 |
+
from datetime import timedelta
|
| 462 |
+
|
| 463 |
+
# Xác định ngày cho giờ program
|
| 464 |
+
if hour < 5:
|
| 465 |
+
# Giờ 00:00-04:59: Cần xem giờ hiện tại để quyết định
|
| 466 |
+
if base_date.hour < 5:
|
| 467 |
+
# Nếu hiện tại cũng < 5:00 (đang trong khoảng sáng sớm)
|
| 468 |
+
# → Giờ program thuộc CÙNG NGÀY với giờ hiện tại
|
| 469 |
+
tv_date = base_date
|
| 470 |
+
else:
|
| 471 |
+
# Nếu hiện tại >= 5:00 (đã qua sáng sớm)
|
| 472 |
+
# → Giờ 00:00-04:59 thuộc NGÀY HÔM TRƯỚC
|
| 473 |
+
tv_date = base_date - timedelta(days=1)
|
| 474 |
+
else:
|
| 475 |
+
# Giờ 05:00-23:59: Luôn thuộc ngày hiện tại
|
| 476 |
+
tv_date = base_date
|
| 477 |
+
|
| 478 |
+
# Tạo datetime với giờ từ lịch
|
| 479 |
+
result = tv_date.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
| 480 |
+
|
| 481 |
+
# Đảm bảo result có timezone
|
| 482 |
+
if result.tzinfo is None:
|
| 483 |
+
result = result.replace(tzinfo=VN_TZ)
|
| 484 |
+
return result
|
| 485 |
+
except:
|
| 486 |
+
pass
|
| 487 |
+
return None
|
| 488 |
+
|
| 489 |
+
def _get_epg_for_channel(channel_id):
|
| 490 |
+
epg_data = _fetch_epg_from_vtv()
|
| 491 |
+
programmes = epg_data.get(channel_id, [])
|
| 492 |
+
if not programmes:
|
| 493 |
+
return []
|
| 494 |
+
now = datetime.now(VN_TZ)
|
| 495 |
+
today = now.date()
|
| 496 |
+
result = []
|
| 497 |
+
|
| 498 |
+
# Lọc chỉ lấy programs của ngày hôm nay (theo lịch VTV)
|
| 499 |
+
today_programmes = []
|
| 500 |
+
for p in programmes:
|
| 501 |
+
start_dt = p.get("start_dt")
|
| 502 |
+
if start_dt and start_dt.date() == today:
|
| 503 |
+
today_programmes.append(p)
|
| 504 |
+
|
| 505 |
+
# Nếu không có programs cho hôm nay, dùng tất cả (fallback)
|
| 506 |
+
if not today_programmes:
|
| 507 |
+
today_programmes = programmes
|
| 508 |
+
|
| 509 |
+
for i, p in enumerate(today_programmes):
|
| 510 |
+
start_dt = p.get("start_dt")
|
| 511 |
+
stop_dt = None
|
| 512 |
+
if i + 1 < len(today_programmes):
|
| 513 |
+
stop_dt = today_programmes[i + 1].get("start_dt")
|
| 514 |
+
is_now = False
|
| 515 |
+
if start_dt:
|
| 516 |
+
if stop_dt:
|
| 517 |
+
is_now = start_dt <= now < stop_dt
|
| 518 |
+
else:
|
| 519 |
+
is_now = start_dt <= now
|
| 520 |
+
end_time = ""
|
| 521 |
+
if stop_dt:
|
| 522 |
+
end_time = stop_dt.strftime("%H:%M")
|
| 523 |
+
result.append({"time": p["time"], "title": p["title"], "end_time": end_time, "now": is_now})
|
| 524 |
+
return result
|
| 525 |
+
|
| 526 |
+
@router.get("/api/vtv/epg/{channel_id}")
|
| 527 |
+
def api_vtv_epg(channel_id: str):
|
| 528 |
+
channel_id = channel_id.lower().strip()
|
| 529 |
+
if channel_id not in CHANNEL_NAMES:
|
| 530 |
+
return JSONResponse({"error": "channel not found"}, status_code=404)
|
| 531 |
+
programs = _get_epg_for_channel(channel_id)
|
| 532 |
+
return JSONResponse({
|
| 533 |
+
"channel": channel_id, "channel_name": CHANNEL_NAMES.get(channel_id, channel_id),
|
| 534 |
+
"date": datetime.now(VN_TZ).strftime("%Y-%m-%d"), "programs": programs,
|
| 535 |
+
})
|
| 536 |
+
|
| 537 |
+
@router.get("/api/vtv/epg")
|
| 538 |
+
def api_vtv_epg_refresh():
|
| 539 |
+
global _epg_cache, _epg_cache_time
|
| 540 |
+
_epg_cache = {}
|
| 541 |
+
_epg_cache_time = 0
|
| 542 |
+
epg_data = _fetch_epg_from_vtv()
|
| 543 |
+
return JSONResponse({
|
| 544 |
+
"status": "refreshed", "channels": len(epg_data),
|
| 545 |
+
"total_programmes": sum(len(v) for v in epg_data),
|
| 546 |
+
})
|
vtv_scraper.py
CHANGED
|
@@ -1,11 +1,8 @@
|
|
| 1 |
"""
|
| 2 |
-
VTV Channels Scraper
|
| 3 |
-
Fetches stream URLs from
|
| 4 |
"""
|
| 5 |
import requests, re, time, threading
|
| 6 |
-
from datetime import datetime, timedelta, timezone
|
| 7 |
-
|
| 8 |
-
VN_TZ = timezone(timedelta(hours=17))
|
| 9 |
|
| 10 |
UA = {
|
| 11 |
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
|
@@ -13,9 +10,6 @@ UA = {
|
|
| 13 |
"Referer": "https://hd.xemtv.net/",
|
| 14 |
}
|
| 15 |
|
| 16 |
-
# ===== PRIMARY CDN SOURCES (Optimized for stability) =====
|
| 17 |
-
# Priority order: FPTPlay > VTVGo > MediaCDN
|
| 18 |
-
|
| 19 |
XEMTV_PHP_ENDPOINTS = {
|
| 20 |
"vtv1": "https://hd.xemtv.net/kenh/vtv1.php",
|
| 21 |
"vtv2": "https://hd.xemtv.net/kenh/vtv2.php",
|
|
@@ -32,34 +26,30 @@ XEMTV_PHP_ENDPOINTS = {
|
|
| 32 |
CHANNEL_NAMES = {
|
| 33 |
"vtv1": "VTV1", "vtv2": "VTV2", "vtv3": "VTV3", "vtv4": "VTV4",
|
| 34 |
"vtv5": "VTV5", "vtv6": "VTV6", "vtv7": "VTV7", "vtv8": "VTV8",
|
| 35 |
-
"vtv9": "VTV9", "vtv10": "
|
| 36 |
}
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
"
|
| 42 |
-
"
|
| 43 |
-
"
|
| 44 |
-
"
|
| 45 |
-
"
|
| 46 |
-
"
|
| 47 |
-
"
|
| 48 |
-
"
|
| 49 |
-
"
|
| 50 |
-
"
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
"
|
| 54 |
-
"
|
| 55 |
-
"
|
| 56 |
-
"
|
| 57 |
-
"
|
| 58 |
-
"
|
| 59 |
-
"vtv7_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv7-manifest.m3u8",
|
| 60 |
-
"vtv8_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv8-manifest.m3u8",
|
| 61 |
-
"vtv9_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv9-manifest.m3u8",
|
| 62 |
-
"vtv10_fb": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv10-manifest.m3u8",
|
| 63 |
}
|
| 64 |
|
| 65 |
_vtv_cache = {}
|
|
@@ -77,34 +67,22 @@ def _set_cache(key, data):
|
|
| 77 |
_vtv_cache[key] = {'t': time.time(), 'd': data}
|
| 78 |
|
| 79 |
def extract_m3u8_from_html(html):
|
| 80 |
-
if not html:
|
| 81 |
-
|
| 82 |
m = re.search(r"file\s*:\s*['\"]([^'\"]*\.m3u8[^'\"]*)['\"]", html, re.IGNORECASE)
|
| 83 |
if m:
|
| 84 |
url = m.group(1).strip()
|
| 85 |
-
if len(url) > 20:
|
| 86 |
-
|
| 87 |
m = re.search(r"(https?://[^\s\"'<>\\]+\.m3u8[^\s\"'<>\\]*)", html, re.IGNORECASE)
|
| 88 |
if m:
|
| 89 |
url = m.group(1).strip()
|
| 90 |
-
if len(url) > 20:
|
| 91 |
-
return None
|
| 92 |
-
|
| 93 |
-
def verify_cdn(url, referer="", timeout=8):
|
| 94 |
-
"""Quick verify CDN is working"""
|
| 95 |
-
if not url: return None
|
| 96 |
-
try:
|
| 97 |
-
r = requests.get(url, headers={"User-Agent": UA["User-Agent"], "Referer": referer}, timeout=timeout, allow_redirects=True, verify=False)
|
| 98 |
-
if r.status_code == 200 and '#EXTM3U' in r.text[:500]:
|
| 99 |
return url
|
| 100 |
-
except: pass
|
| 101 |
return None
|
| 102 |
|
| 103 |
def fetch_vtv_stream(channel_id):
|
| 104 |
-
"""Fetch VTV stream with priority order for maximum stability"""
|
| 105 |
channel_id = channel_id.lower().strip()
|
| 106 |
-
|
| 107 |
-
# Normalize channel ID
|
| 108 |
name_map = {
|
| 109 |
'vtvct': 'vtv10', 'vtv-can-tho': 'vtv10', 'vtv can tho': 'vtv10',
|
| 110 |
'vtv_can_tho': 'vtv10', 'cantho': 'vtv10', 'cần thơ': 'vtv10',
|
|
@@ -113,35 +91,41 @@ def fetch_vtv_stream(channel_id):
|
|
| 113 |
'vietnam_vtv7': 'vtv7', 'vietnam_vtv8': 'vtv8', 'vietnam_vtv9': 'vtv9',
|
| 114 |
}
|
| 115 |
channel_id = name_map.get(channel_id, channel_id)
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
|
|
|
| 122 |
php_url = XEMTV_PHP_ENDPOINTS.get(channel_id)
|
| 123 |
if php_url:
|
| 124 |
try:
|
| 125 |
r = requests.get(php_url, headers=UA, timeout=15, allow_redirects=True, verify=False)
|
| 126 |
if r.status_code == 200:
|
| 127 |
m3u8 = extract_m3u8_from_html(r.text)
|
| 128 |
-
if m3u8:
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
| 136 |
return None
|
| 137 |
|
| 138 |
def get_all_vtv_streams():
|
| 139 |
channels = []
|
| 140 |
-
for ch_id in
|
| 141 |
stream_url = fetch_vtv_stream(ch_id)
|
| 142 |
channels.append({
|
| 143 |
'id': ch_id,
|
| 144 |
'name': CHANNEL_NAMES.get(ch_id, ch_id.upper()),
|
| 145 |
'stream_url': stream_url,
|
| 146 |
})
|
| 147 |
-
return channels
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
VTV Channels Scraper
|
| 3 |
+
Fetches stream URLs from hd.xemtv.net PHP endpoints for VTV1-VTV10 + VTV Cần Thơ
|
| 4 |
"""
|
| 5 |
import requests, re, time, threading
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
UA = {
|
| 8 |
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
|
|
|
| 10 |
"Referer": "https://hd.xemtv.net/",
|
| 11 |
}
|
| 12 |
|
|
|
|
|
|
|
|
|
|
| 13 |
XEMTV_PHP_ENDPOINTS = {
|
| 14 |
"vtv1": "https://hd.xemtv.net/kenh/vtv1.php",
|
| 15 |
"vtv2": "https://hd.xemtv.net/kenh/vtv2.php",
|
|
|
|
| 26 |
CHANNEL_NAMES = {
|
| 27 |
"vtv1": "VTV1", "vtv2": "VTV2", "vtv3": "VTV3", "vtv4": "VTV4",
|
| 28 |
"vtv5": "VTV5", "vtv6": "VTV6", "vtv7": "VTV7", "vtv8": "VTV8",
|
| 29 |
+
"vtv9": "VTV9", "vtv10": "VTV Cần Thơ",
|
| 30 |
}
|
| 31 |
|
| 32 |
+
CDN_FALLBACK = {
|
| 33 |
+
"vtv1": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8",
|
| 34 |
+
"vtv2": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv2-manifest.m3u8",
|
| 35 |
+
"vtv3": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv3-manifest.m3u8",
|
| 36 |
+
"vtv4": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv4-manifest.m3u8",
|
| 37 |
+
"vtv5": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv5-manifest.m3u8",
|
| 38 |
+
"vtv6": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv6-manifest.m3u8",
|
| 39 |
+
"vtv7": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv7-manifest.m3u8",
|
| 40 |
+
"vtv8": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv8-manifest.m3u8",
|
| 41 |
+
"vtv9": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv9-manifest.m3u8",
|
| 42 |
+
"vtv10": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv10-manifest.m3u8",
|
| 43 |
+
"_fpt_vtv1": "https://live.fptplay53.net/fnxch2/vtv1hd_abr.smil/chunklist.m3u8",
|
| 44 |
+
"_fpt_vtv2": "https://live.fptplay53.net/fnxch2/vtv2hd_abr.smil/chunklist.m3u8",
|
| 45 |
+
"_fpt_vtv3": "https://live.fptplay53.net/fnxch2/vtv3hd_abr.smil/chunklist.m3u8",
|
| 46 |
+
"_fpt_vtv4": "https://live.fptplay53.net/fnxch2/vtv4hd_abr.smil/chunklist.m3u8",
|
| 47 |
+
"_fpt_vtv5": "https://live-a.fptplay53.net/live/media/VTV5HD/live_hls_avc/index.m3u8",
|
| 48 |
+
"_fpt_vtv6": "https://live.fptplay53.net/fnxch2/vtv6hd_abr.smil/chunklist.m3u8",
|
| 49 |
+
"_fpt_vtv7": "https://live.fptplay53.net/fnxhd1/vtv7hd_vhls.smil/chunklist_b5000000.m3u8",
|
| 50 |
+
"_fpt_vtv8": "https://live.fptplay53.net/epzhd1/vtv8hd_vhls.smil/chunklist.m3u8",
|
| 51 |
+
"_fpt_vtv9": "https://live.fptplay53.net/fnxhd1/vtv9hd_vhls.smil/chunklist.m3u8",
|
| 52 |
+
"_fpt_vtv10": "https://live.fptplay53.net/fnxch2/vtvcantho_abr.smil/chunklist.m3u8",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
}
|
| 54 |
|
| 55 |
_vtv_cache = {}
|
|
|
|
| 67 |
_vtv_cache[key] = {'t': time.time(), 'd': data}
|
| 68 |
|
| 69 |
def extract_m3u8_from_html(html):
|
| 70 |
+
if not html:
|
| 71 |
+
return None
|
| 72 |
m = re.search(r"file\s*:\s*['\"]([^'\"]*\.m3u8[^'\"]*)['\"]", html, re.IGNORECASE)
|
| 73 |
if m:
|
| 74 |
url = m.group(1).strip()
|
| 75 |
+
if len(url) > 20:
|
| 76 |
+
return url
|
| 77 |
m = re.search(r"(https?://[^\s\"'<>\\]+\.m3u8[^\s\"'<>\\]*)", html, re.IGNORECASE)
|
| 78 |
if m:
|
| 79 |
url = m.group(1).strip()
|
| 80 |
+
if len(url) > 20:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
return url
|
|
|
|
| 82 |
return None
|
| 83 |
|
| 84 |
def fetch_vtv_stream(channel_id):
|
|
|
|
| 85 |
channel_id = channel_id.lower().strip()
|
|
|
|
|
|
|
| 86 |
name_map = {
|
| 87 |
'vtvct': 'vtv10', 'vtv-can-tho': 'vtv10', 'vtv can tho': 'vtv10',
|
| 88 |
'vtv_can_tho': 'vtv10', 'cantho': 'vtv10', 'cần thơ': 'vtv10',
|
|
|
|
| 91 |
'vietnam_vtv7': 'vtv7', 'vietnam_vtv8': 'vtv8', 'vietnam_vtv9': 'vtv9',
|
| 92 |
}
|
| 93 |
channel_id = name_map.get(channel_id, channel_id)
|
| 94 |
+
cached = _cached(channel_id)
|
| 95 |
+
if cached:
|
| 96 |
+
return cached
|
| 97 |
+
vtvgourl = CDN_FALLBACK.get(channel_id)
|
| 98 |
+
if vtvgourl:
|
| 99 |
+
_set_cache(channel_id, vtvgourl)
|
| 100 |
+
return vtvgourl
|
| 101 |
php_url = XEMTV_PHP_ENDPOINTS.get(channel_id)
|
| 102 |
if php_url:
|
| 103 |
try:
|
| 104 |
r = requests.get(php_url, headers=UA, timeout=15, allow_redirects=True, verify=False)
|
| 105 |
if r.status_code == 200:
|
| 106 |
m3u8 = extract_m3u8_from_html(r.text)
|
| 107 |
+
if m3u8:
|
| 108 |
+
_set_cache(channel_id, m3u8)
|
| 109 |
+
return m3u8
|
| 110 |
+
except:
|
| 111 |
+
pass
|
| 112 |
+
fpt_key = f"_fpt_{channel_id}"
|
| 113 |
+
fpt_url = CDN_FALLBACK.get(fpt_key)
|
| 114 |
+
if fpt_url:
|
| 115 |
+
_set_cache(channel_id, fpt_url)
|
| 116 |
+
return fpt_url
|
| 117 |
return None
|
| 118 |
|
| 119 |
def get_all_vtv_streams():
|
| 120 |
channels = []
|
| 121 |
+
for ch_id, php_url in XEMTV_PHP_ENDPOINTS.items():
|
| 122 |
stream_url = fetch_vtv_stream(ch_id)
|
| 123 |
channels.append({
|
| 124 |
'id': ch_id,
|
| 125 |
'name': CHANNEL_NAMES.get(ch_id, ch_id.upper()),
|
| 126 |
'stream_url': stream_url,
|
| 127 |
})
|
| 128 |
+
return channels
|
| 129 |
+
|
| 130 |
+
XEMTV_CHANNELS = {v: k for k, v in CHANNEL_NAMES.items()}
|
| 131 |
+
CDN_STREAMS = {v: k for k, v in CDN_FALLBACK.items()}
|
yt_scraper_fixed.py
DELETED
|
@@ -1,162 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
YouTube Shorts Scraper using yt-dlp (already installed on Space)
|
| 3 |
-
Optimized for fast load with long cache + fallback
|
| 4 |
-
"""
|
| 5 |
-
import subprocess
|
| 6 |
-
import json
|
| 7 |
-
import time
|
| 8 |
-
import threading
|
| 9 |
-
import os
|
| 10 |
-
import re as re_mod
|
| 11 |
-
|
| 12 |
-
_cache = {}
|
| 13 |
-
_lock = threading.Lock()
|
| 14 |
-
CACHE_TTL = 1800 # 30 min cache - longer to reduce timeout issues
|
| 15 |
-
|
| 16 |
-
def _get_cached(key):
|
| 17 |
-
"""Get cached data if still valid"""
|
| 18 |
-
with _lock:
|
| 19 |
-
if key in _cache:
|
| 20 |
-
entry = _cache[key]
|
| 21 |
-
if time.time() - entry['t'] < CACHE_TTL:
|
| 22 |
-
return entry['d']
|
| 23 |
-
return None
|
| 24 |
-
|
| 25 |
-
def _set_cached(key, data):
|
| 26 |
-
"""Set cache with timestamp"""
|
| 27 |
-
with _lock:
|
| 28 |
-
_cache[key] = {'t': time.time(), 'd': data}
|
| 29 |
-
|
| 30 |
-
def run_yt_dlp(args, timeout=45):
|
| 31 |
-
"""Run yt-dlp and return parsed JSON lines - with shorter timeout"""
|
| 32 |
-
try:
|
| 33 |
-
result = subprocess.run(
|
| 34 |
-
["yt-dlp"] + args,
|
| 35 |
-
capture_output=True, text=True, timeout=timeout
|
| 36 |
-
)
|
| 37 |
-
if result.returncode != 0 and not result.stdout.strip():
|
| 38 |
-
return []
|
| 39 |
-
lines = result.stdout.strip().split('\n')
|
| 40 |
-
items = []
|
| 41 |
-
for line in lines:
|
| 42 |
-
line = line.strip()
|
| 43 |
-
if not line:
|
| 44 |
-
continue
|
| 45 |
-
try:
|
| 46 |
-
items.append(json.loads(line))
|
| 47 |
-
except json.JSONDecodeError:
|
| 48 |
-
continue
|
| 49 |
-
return items
|
| 50 |
-
except subprocess.TimeoutExpired:
|
| 51 |
-
print("yt-dlp timeout (this is OK - using fallback)")
|
| 52 |
-
return []
|
| 53 |
-
except FileNotFoundError:
|
| 54 |
-
print("yt-dlp not found - using fallback")
|
| 55 |
-
return []
|
| 56 |
-
except Exception as e:
|
| 57 |
-
print(f"yt-dlp exception: {e}")
|
| 58 |
-
return []
|
| 59 |
-
|
| 60 |
-
def get_channel_shorts_fast(channel_username, max_count=25):
|
| 61 |
-
"""Get shorts fast - prioritize /shorts page only to avoid timeout"""
|
| 62 |
-
shorts = []
|
| 63 |
-
|
| 64 |
-
url = f"https://www.youtube.com/@{channel_username}/shorts"
|
| 65 |
-
items = run_yt_dlp([
|
| 66 |
-
"--dump-json",
|
| 67 |
-
"--flat-playlist",
|
| 68 |
-
"--no-download",
|
| 69 |
-
"--playlist-end", str(max_count),
|
| 70 |
-
"--no-check-certificates",
|
| 71 |
-
"--quiet", # Reduce output for speed
|
| 72 |
-
"--no-warnings",
|
| 73 |
-
url
|
| 74 |
-
], timeout=35) # Increased timeout but still reasonable
|
| 75 |
-
|
| 76 |
-
seen_ids = set()
|
| 77 |
-
for item in items:
|
| 78 |
-
vid = item.get('id', '')
|
| 79 |
-
if not vid or vid in seen_ids:
|
| 80 |
-
continue
|
| 81 |
-
seen_ids.add(vid)
|
| 82 |
-
|
| 83 |
-
title = item.get('title', f'{channel_username} Short')
|
| 84 |
-
|
| 85 |
-
shorts.append({
|
| 86 |
-
'id': vid,
|
| 87 |
-
'title': title,
|
| 88 |
-
'channel': channel_username,
|
| 89 |
-
'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg",
|
| 90 |
-
})
|
| 91 |
-
|
| 92 |
-
if len(shorts) >= max_count:
|
| 93 |
-
break
|
| 94 |
-
|
| 95 |
-
return shorts
|
| 96 |
-
|
| 97 |
-
def get_dantri_shorts(max_count=25):
|
| 98 |
-
"""Get Dantri shorts - fast, no fallback needed, cache for 30min"""
|
| 99 |
-
cached = _get_cached('dantri_shorts_yt')
|
| 100 |
-
if cached is not None:
|
| 101 |
-
return cached
|
| 102 |
-
|
| 103 |
-
shorts = get_channel_shorts_fast('baodantri7941', max_count)
|
| 104 |
-
|
| 105 |
-
if shorts:
|
| 106 |
-
_set_cached('dantri_shorts_yt', shorts)
|
| 107 |
-
return shorts
|
| 108 |
-
|
| 109 |
-
# Fallback to static list if scrape fails
|
| 110 |
-
return [
|
| 111 |
-
{"id":"Lu_iCQ5YwNM","title":"Công an lập hồ sơ xử lý người phụ nữ chửi bới tát nam tài xế ô tô ở Hà Nội","channel":"baodantri7941"},
|
| 112 |
-
{"id":"CwWvijF8BOA","title":"Chú rể Ninh Bình bật khóc nhận món quà bí mật người cha","channel":"baodantri7941"},
|
| 113 |
-
]
|
| 114 |
-
|
| 115 |
-
def get_skds_shorts(max_count=25):
|
| 116 |
-
"""Get SKĐS shorts - fast, no fallback needed, cache for 30min"""
|
| 117 |
-
cached = _get_cached('skds_shorts_yt')
|
| 118 |
-
if cached is not None:
|
| 119 |
-
return cached
|
| 120 |
-
|
| 121 |
-
shorts = get_channel_shorts_fast('baosuckhoedoisongboyte', max_count)
|
| 122 |
-
|
| 123 |
-
if shorts:
|
| 124 |
-
_set_cached('skds_shorts_yt', shorts)
|
| 125 |
-
return shorts
|
| 126 |
-
|
| 127 |
-
# Fallback to static list if scrape fails
|
| 128 |
-
return [
|
| 129 |
-
{"id":"7Pd6vZ2Lz1M","title":"Hành động ấm lòng của người đàn ông tìm kiếm 5 học sinh tử vong","channel":"baosuckhoedoisongboyte"},
|
| 130 |
-
{"id":"SlHLt_ZyPiE","title":"Xử phạt người đàn ông xóa số điện thoại cứu hộ trên cao tốc Bắc Nam","channel":"baosuckhoedoisongboyte"},
|
| 131 |
-
]
|
| 132 |
-
|
| 133 |
-
def get_dantri_skds_shorts(max_count=50):
|
| 134 |
-
"""Get interleaved Dantri + SKĐS shorts - optimized with separate caching"""
|
| 135 |
-
# Get each channel's shorts separately (allows partial fallback)
|
| 136 |
-
dantri = get_dantri_shorts(max_count // 2 + 10)
|
| 137 |
-
skds = get_skds_shorts(max_count // 2 + 10)
|
| 138 |
-
|
| 139 |
-
# Interleave them
|
| 140 |
-
result = []
|
| 141 |
-
seen = set()
|
| 142 |
-
i, j = 0, 0
|
| 143 |
-
|
| 144 |
-
while (i < len(dantri) or j < len(skds)) and len(result) < max_count:
|
| 145 |
-
if i < len(dantri):
|
| 146 |
-
item = dantri[i]
|
| 147 |
-
if item.get('id') not in seen:
|
| 148 |
-
seen.add(item.get('id'))
|
| 149 |
-
result.append(item)
|
| 150 |
-
i += 1
|
| 151 |
-
if j < len(skds):
|
| 152 |
-
item = skds[j]
|
| 153 |
-
if item.get('id') not in seen:
|
| 154 |
-
seen.add(item.get('id'))
|
| 155 |
-
result.append(item)
|
| 156 |
-
j += 1
|
| 157 |
-
|
| 158 |
-
return result
|
| 159 |
-
|
| 160 |
-
# For backward compatibility
|
| 161 |
-
get_vtvnambo_shorts = get_dantri_skds_shorts
|
| 162 |
-
get_wc_related_shorts = get_dantri_skds_shorts
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|