Spaces:
Running
Running
Restore 60e310a
#12
by bep40 - opened
This view is limited to 50 files because it contains too many changes. See the raw diff here.
- .dockerignore +0 -0
- .huggingface/main.json +0 -0
- .restart_trigger +0 -1
- CHANGELOG.md +33 -71
- Dockerfile +6 -38
- README.md +2 -22
- REBUILD_TRIGGER.txt +1 -0
- RESTART_TRIGGER.md +0 -6
- TRIGGER_REBUILD_V6.md +1 -0
- _run.py +2 -1
- _static_build_trigger.txt +1 -0
- ai_ext.py +1058 -226
- ai_fix2.py +1 -1
- ai_patch.py +90 -256
- ai_runtime_final6.py +481 -5
- ai_runtime_fix.py +0 -394
- ai_runtime_patch_final.py +0 -78
- app/static/app_v2.js +252 -0
- app_v2_entry.py +0 -0
- app_v2_entry.py.gitigignore +0 -3
- app_v2_entry_hot.py +0 -24
- app_v2_patch.py +0 -111
- auto_scheduler.py +0 -396
- auto_update_sse.py +0 -55
- bongda_proxy.py +148 -17
- logs_route.py +0 -106
- main.py +761 -364
- match_detail_v2.py +48 -2
- patch_ai_hot.py +0 -55
- piped_client.py +0 -258
- rebuild3.md +0 -1
- rebuild_trigger.txt +0 -1
- requirements.txt +1 -1
- restart.txt +0 -1
- restart2.md +0 -1
- rewrite_fix_v2.js +0 -2
- rewrite_slide.py +7 -26
- runtime.txt +0 -0
- shorts_cache.py +0 -86
- shorts_rss_proxy.py +0 -114
- static/app_v2.js +0 -772
- static/app_v2_shorts_fix.js +0 -2
- static/app_v3.js +319 -0
- static/app_v4.js +446 -0
- static/core_1781056782.js +319 -0
- static/fm_fix.css +0 -70
- static/hot_multi.js +16 -90
- static/index.html +706 -1
- static/index_v2.html +0 -80
- static/index_v3.html +46 -0
.dockerignore
DELETED
|
File without changes
|
.huggingface/main.json
DELETED
|
File without changes
|
.restart_trigger
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
Trigger restart: personal_post fix - keywords from opinion + paragraph-per-slide AI
|
|
|
|
|
|
CHANGELOG.md
CHANGED
|
@@ -1,74 +1,36 @@
|
|
| 1 |
-
# VNEWS
|
| 2 |
-
- Changed Short AI feed video share button icon from 📤 (upload/share) to 📥 (download) to distinguish from article share
|
| 3 |
-
|
| 4 |
-
# VNEWS v6.5 - Resilient Shorts Auto-Updater
|
| 5 |
|
| 6 |
## Changes
|
| 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 |
-
|
| 39 |
-
-
|
| 40 |
-
|
| 41 |
-
### Channels monitored
|
| 42 |
-
- baodantri7941 (Dân trí)
|
| 43 |
-
- baosuckhoedoisongboyte (Sức khỏe & đời sống)
|
| 44 |
-
- vtvnambo (VTV Nam Bộ)
|
| 45 |
-
|
| 46 |
-
---
|
| 47 |
-
|
| 48 |
-
# VNEWS v5.1 - Rewrite Fix
|
| 49 |
-
|
| 50 |
-
## Changes
|
| 51 |
-
|
| 52 |
-
### Critical Fix: Rewrite button not creating posts on Tường AI
|
| 53 |
-
**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.
|
| 54 |
-
|
| 55 |
-
**Fix applied**:
|
| 56 |
-
1. **app_v2_entry.py** — Added 3 new endpoints:
|
| 57 |
-
- `POST /api/rewrite_slide` — Fast extractive summary (no AI needed), creates slides from article key points + images, saves to wall
|
| 58 |
-
- `POST /api/rewrite_share` — AI-powered rewrite with extractive fallback, saves to wall
|
| 59 |
-
- `POST /api/url_wall` — URL submission endpoint (alias for rewrite_share)
|
| 60 |
-
- All endpoints use the same `_load_wall_posts()` / `_save_wall_posts()` and `WALL_FILE` path as the existing `/api/wall` endpoint
|
| 61 |
-
|
| 62 |
-
2. **static/index_v2.html** — Added `<script src="/static/rewrite_fix_v2.js"></script>` to load the rewrite fix
|
| 63 |
-
|
| 64 |
-
3. **static/rewrite_fix_v2.js** — New file that overrides `rewriteArticle()` to:
|
| 65 |
-
- Call `/api/rewrite_slide` first (fast, no AI needed)
|
| 66 |
-
- Fallback to `/api/rewrite_share` if slide fails
|
| 67 |
-
- Show slide preview overlay after successful post
|
| 68 |
-
- Use `prependWallPost()` to add the new post to Tường AI
|
| 69 |
-
|
| 70 |
-
### Previous changes (v5)
|
| 71 |
-
- Rewrote match_detail_v2.py with correct event parsing
|
| 72 |
-
- 2-tab layout for match detail (stats + timeline)
|
| 73 |
-
- Fixed _run.py import
|
| 74 |
-
- 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
|
@@ -2,47 +2,15 @@ FROM python:3.12-slim
|
|
| 2 |
|
| 3 |
WORKDIR /app
|
| 4 |
|
| 5 |
-
RUN
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
fonts-dejavu-core \
|
| 9 |
-
fonts-noto \
|
| 10 |
-
fonts-noto-cjk \
|
| 11 |
-
fonts-noto-color-emoji \
|
| 12 |
-
fonts-liberation \
|
| 13 |
-
fonts-freefont-ttf \
|
| 14 |
-
libfreetype6 \
|
| 15 |
-
&& rm -rf /var/lib/apt/lists/* && \
|
| 16 |
-
echo "[BUILD] step1 done"
|
| 17 |
-
|
| 18 |
-
RUN echo "[BUILD] step2: pip base pkgs (bs4/lxml)" && \
|
| 19 |
-
pip install --no-cache-dir "beautifulsoup4>=4.12" lxml && \
|
| 20 |
-
echo "[BUILD] step2 done"
|
| 21 |
-
|
| 22 |
-
RUN echo "[BUILD] step3: pip main pkgs" && \
|
| 23 |
-
pip install --no-cache-dir fastapi uvicorn requests beautifulsoup4 jinja2 yt-dlp huggingface_hub gTTS pillow edge-tts python-dateutil httpx && \
|
| 24 |
-
echo "[BUILD] step3 done"
|
| 25 |
|
| 26 |
COPY requirements.txt .
|
| 27 |
-
RUN
|
| 28 |
-
pip install --no-cache-dir -r requirements.txt || true && \
|
| 29 |
-
echo "[BUILD] step4 done"
|
| 30 |
|
| 31 |
COPY . .
|
| 32 |
EXPOSE 7860
|
| 33 |
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
# Prefer Noto Sans for Vietnamese - it has full diacritic support
|
| 37 |
-
if [ -f /usr/share/fonts/truetype/noto/NotoSans-Regular.ttf ]; then \
|
| 38 |
-
ln -sf /usr/share/fonts/truetype/noto/NotoSans-Regular.ttf /usr/share/fonts/truetype/vn/VNFont.ttf; \
|
| 39 |
-
elif [ -f /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf ]; then \
|
| 40 |
-
ln -sf /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf /usr/share/fonts/truetype/vn/VNFont.ttf; \
|
| 41 |
-
ln -sf /usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf /usr/share/fonts/truetype/vn/VNFont-Bold.ttf; \
|
| 42 |
-
fi; \
|
| 43 |
-
fc-cache -f -v || true; \
|
| 44 |
-
date > /app/.build_done && \
|
| 45 |
-
echo "[BUILD] step5 done"
|
| 46 |
-
|
| 47 |
-
CMD ["uvicorn", "_run:app", "--host", "0.0.0.0", "--port", "7860"]
|
| 48 |
-
# v3.0-vn-font-fix-short-video-2026-07-19
|
|
|
|
| 2 |
|
| 3 |
WORKDIR /app
|
| 4 |
|
| 5 |
+
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg fonts-dejavu-core && rm -rf /var/lib/apt/lists/*
|
| 6 |
+
RUN pip install --no-cache-dir "beautifulsoup4>=4.12" lxml
|
| 7 |
+
RUN pip install --no-cache-dir fastapi uvicorn requests beautifulsoup4 jinja2 yt-dlp huggingface_hub gTTS pillow edge-tts python-dateutil httpx
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
COPY requirements.txt .
|
| 10 |
+
RUN pip install --no-cache-dir -r requirements.txt || true
|
|
|
|
|
|
|
| 11 |
|
| 12 |
COPY . .
|
| 13 |
EXPOSE 7860
|
| 14 |
|
| 15 |
+
LABEL rebuild="vtv-fix-v3"
|
| 16 |
+
CMD ["uvicorn", "_run:app", "--host", "0.0.0.0", "--port", "7860", "--reload"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
README.md
CHANGED
|
@@ -9,25 +9,5 @@ tags:
|
|
| 9 |
- ml-intern
|
| 10 |
---
|
| 11 |
|
| 12 |
-
#
|
| 13 |
-
|
| 14 |
-
**v18 - FIXED VTV2/VTV3/VTV6/VTV9 stream hanging**
|
| 15 |
-
|
| 16 |
-
## 🔧 Changes in v18 (2026-07-06)
|
| 17 |
-
- **VTV2, VTV3, VTV6, VTV9**: Skip expired ssaimh CDN token → immediately fall through to sv2.xemtivitop.com
|
| 18 |
-
- **15+ extraction patterns** for m3u8 URL (up from 5), including: file:, src=, source:, player.src(), hls.loadSource(), href=, `<source src>`, url:, window.location, iframe follow (3 levels deep), base64 decode
|
| 19 |
-
- **Backup CDN** `tv.mediacdn.vn` for VTV2/VTV3/VTV6/VTV9
|
| 20 |
-
- **Fast timeout** 5s for CDN, 12s for PHP endpoints (was 15s each = 60s+ total)
|
| 21 |
-
- **sv2.xemtivitop.com** re-prioritized to check BEFORE xemtv.us
|
| 22 |
-
- **Iframe chain following**: if a PHP page returns an iframe → follow it up to 3 levels to find the m3u8
|
| 23 |
-
|
| 24 |
-
## Features:
|
| 25 |
-
- 📰 News from VnExpress (10 categories) + GenK AI
|
| 26 |
-
- ⚽ Livescore from bongda.com.vn (live, today, upcoming, results, standings)
|
| 27 |
-
- 🎬 Football highlights from xemlaibongda.top (8 leagues)
|
| 28 |
-
- 📺 VTV live channels (VTV1→VTV10, VTV Prime)
|
| 29 |
-
- Priority: ssaimh CDN → sv2.xemtivitop.com → xemtv.us → xemtivitop blogspot → FPTPlay → VTVGo → mediacdn → xemtv.net
|
| 30 |
-
- 🏆 World Cup 2026 (news, fixtures, standings, stats, highlights)
|
| 31 |
-
- 🤖 AI article writing + TTS (multilingual, emotion-aware)
|
| 32 |
-
- 🔍 Topic search (8 news sources)
|
| 33 |
-
- 🎤 TTS: voice selector + emotion selector + speed control
|
|
|
|
| 9 |
- ml-intern
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# bep40/vnews
|
| 13 |
+
<!-- build: 2026-06-12T06:45:00 -->
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
REBUILD_TRIGGER.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
REBUILD_TRIGGER=1781133890
|
RESTART_TRIGGER.md
DELETED
|
@@ -1,6 +0,0 @@
|
|
| 1 |
-
trigger rebuild 2026-07-18T10:35 +0700 - add missing ai/short/ and ai/short-file/ endpoints
|
| 2 |
-
|
| 3 |
-
- Added POST /api/ai/short/{post_id} endpoint (was lost during route cleanup)
|
| 4 |
-
- Added GET /api/ai/short-file/{file_id} endpoint (file serving)
|
| 5 |
-
- Both were supposed to be in ai_patch.py but never existed there
|
| 6 |
-
- Also added FileResponse import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
TRIGGER_REBUILD_V6.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Rebuild trigger v6 - 1780971280.8544343
|
_run.py
CHANGED
|
@@ -1 +1,2 @@
|
|
| 1 |
-
from app_v2_entry import app # v5-stable inline bongda proxy
|
|
|
|
|
|
| 1 |
+
from app_v2_entry import app # v5-stable inline bongda proxy
|
| 2 |
+
# Force reload trigger
|
_static_build_trigger.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Force rebuild - match_detail debug v2
|
ai_ext.py
CHANGED
|
@@ -13,16 +13,7 @@ from bs4 import BeautifulSoup
|
|
| 13 |
from fastapi import Request, Query
|
| 14 |
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
|
| 15 |
|
| 16 |
-
|
| 17 |
-
try:
|
| 18 |
-
from main import app
|
| 19 |
-
except ImportError:
|
| 20 |
-
# Create a minimal FastAPI app for standalone testing
|
| 21 |
-
try:
|
| 22 |
-
from fastapi import FastAPI
|
| 23 |
-
app = FastAPI()
|
| 24 |
-
except Exception:
|
| 25 |
-
app = None
|
| 26 |
|
| 27 |
# Import wall store from main.py so we read/write the SAME file
|
| 28 |
try:
|
|
@@ -50,10 +41,6 @@ except ImportError:
|
|
| 50 |
def _web_context(topic):
|
| 51 |
return ""
|
| 52 |
|
| 53 |
-
# ai_ext alias for backward compatibility
|
| 54 |
-
_load_ai_wall = _load_wall
|
| 55 |
-
_save_ai_wall = _save_wall
|
| 56 |
-
|
| 57 |
try:
|
| 58 |
from huggingface_hub import AsyncInferenceClient
|
| 59 |
except Exception:
|
|
@@ -73,260 +60,1105 @@ except Exception:
|
|
| 73 |
|
| 74 |
|
| 75 |
def _hf_token():
|
| 76 |
-
for k in ("HF_TOKEN", "
|
| 77 |
v = os.getenv(k, "").strip()
|
| 78 |
if v:
|
| 79 |
return v
|
| 80 |
return ""
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
def _clean_text(s: str) -> str:
|
| 84 |
-
"""Clean text for processing."""
|
| 85 |
s = html_lib.unescape(s or "")
|
| 86 |
-
|
| 87 |
-
return s.strip()
|
| 88 |
-
|
| 89 |
|
| 90 |
-
def _domain(
|
| 91 |
-
"""Extract domain from URL."""
|
| 92 |
try:
|
| 93 |
-
return urlparse(
|
| 94 |
except Exception:
|
| 95 |
return ""
|
| 96 |
|
|
|
|
|
|
|
| 97 |
|
| 98 |
-
async def qwen_generate(prompt: str, image_url: str = None, max_tokens: int = 1200) -> str:
|
| 99 |
-
"""Generate text using Llama/Qwen models via Hugging Face Inference API.
|
| 100 |
-
|
| 101 |
-
Prioritizes Llama-3.3-70B for better creative/opinion writing.
|
| 102 |
-
"""
|
| 103 |
-
token = _hf_token()
|
| 104 |
-
errors = []
|
| 105 |
-
|
| 106 |
-
# Try HF router API with multiple models - Llama FIRST for opinion writing
|
| 107 |
-
if token:
|
| 108 |
-
models = [
|
| 109 |
-
os.getenv("QWEN_VL_MODEL", ""),
|
| 110 |
-
"meta-llama/Llama-3.3-70B-Instruct", # FIRST - best for opinion/analysis
|
| 111 |
-
"Qwen/Qwen2.5-VL-7B-Instruct",
|
| 112 |
-
"Qwen/Qwen2.5-72B-Instruct",
|
| 113 |
-
]
|
| 114 |
-
# Deduplicate while preserving order
|
| 115 |
-
seen = set()
|
| 116 |
-
models = [m for m in models if m and m not in seen and not seen.add(m)]
|
| 117 |
-
|
| 118 |
-
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
| 119 |
-
|
| 120 |
-
for model in models:
|
| 121 |
-
try:
|
| 122 |
-
is_vl = "VL" in model and image_url
|
| 123 |
-
if is_vl:
|
| 124 |
-
user_content = [
|
| 125 |
-
{"type": "image_url", "image_url": {"url": image_url}},
|
| 126 |
-
{"type": "text", "text": prompt}
|
| 127 |
-
]
|
| 128 |
-
else:
|
| 129 |
-
user_content = prompt
|
| 130 |
-
|
| 131 |
-
payload = {
|
| 132 |
-
"model": model,
|
| 133 |
-
"messages": [
|
| 134 |
-
{"role": "system", "content": "Bạn là nhà báo phản biện chuyên nghiệp. Luôn viết theo quan điểm cá nhân, phân tích sâu, không sao chép nguyên văn nguồn tin."},
|
| 135 |
-
{"role": "user", "content": user_content},
|
| 136 |
-
],
|
| 137 |
-
"max_tokens": min(int(max_tokens or 2000), 2500),
|
| 138 |
-
"temperature": 0.75,
|
| 139 |
-
"top_p": 0.9,
|
| 140 |
-
}
|
| 141 |
-
|
| 142 |
-
r = requests.post(
|
| 143 |
-
"https://router.huggingface.co/v1/chat/completions",
|
| 144 |
-
headers=headers,
|
| 145 |
-
json=payload,
|
| 146 |
-
timeout=95
|
| 147 |
-
)
|
| 148 |
-
|
| 149 |
-
if r.status_code >= 300:
|
| 150 |
-
errors.append(f"{model}: HTTP {r.status_code}")
|
| 151 |
-
continue
|
| 152 |
-
|
| 153 |
-
j = r.json()
|
| 154 |
-
txt = (j.get("choices", [{}])[0].get("message", {}).get("content") or "").strip()
|
| 155 |
-
|
| 156 |
-
if txt:
|
| 157 |
-
return txt
|
| 158 |
-
|
| 159 |
-
errors.append(f"{model}: empty response")
|
| 160 |
-
|
| 161 |
-
except Exception as e:
|
| 162 |
-
errors.append(f"{model}: {type(e).__name__}")
|
| 163 |
-
|
| 164 |
-
# Fallback: extractive summary from prompt
|
| 165 |
-
LAST_QWEN_ERROR = errors[-3:] if errors else "unknown error"
|
| 166 |
-
return _fallback_summary_from_prompt(prompt, max_units=6)
|
| 167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
break
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
units = []
|
| 182 |
-
for s in sentences:
|
| 183 |
-
s = _clean_text(s)
|
| 184 |
-
if len(s) >= 30:
|
| 185 |
-
units.append(s)
|
| 186 |
-
|
| 187 |
-
if units:
|
| 188 |
-
result_units = units[:max_units]
|
| 189 |
-
return "\n".join("• " + u for u in result_units)
|
| 190 |
-
if text:
|
| 191 |
-
chunks = []
|
| 192 |
-
for i in range(0, min(len(text), max_units * 300), 280):
|
| 193 |
-
chunk = _clean_text(text[i:i+300])
|
| 194 |
-
if chunk and chunk not in chunks:
|
| 195 |
-
chunks.append(chunk)
|
| 196 |
-
if len(chunks) >= max_units:
|
| 197 |
-
break
|
| 198 |
-
if chunks:
|
| 199 |
-
return "\n".join("• " + c for c in chunks)
|
| 200 |
-
return "• Không có đủ nội dung để tóm tắt."
|
| 201 |
-
|
| 202 |
-
# ===== URL scraping & article processing =====
|
| 203 |
-
HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"}
|
| 204 |
|
| 205 |
-
try:
|
| 206 |
-
_shorts_base = "/data" if os.path.isdir("/data") else os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
|
| 207 |
-
except Exception:
|
| 208 |
-
_shorts_base = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
|
| 209 |
-
SHORTS_DIR = os.path.join(_shorts_base, "ai_shorts")
|
| 210 |
-
os.makedirs(SHORTS_DIR, exist_ok=True)
|
| 211 |
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
|
| 217 |
-
|
| 218 |
-
"""
|
| 219 |
-
|
|
|
|
|
|
|
| 220 |
|
|
|
|
|
|
|
|
|
|
| 221 |
|
| 222 |
-
|
| 223 |
-
""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
try:
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
except Exception:
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
|
| 230 |
-
|
| 231 |
-
|
|
|
|
|
|
|
| 232 |
if url:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
try:
|
| 234 |
-
r = requests.get(
|
| 235 |
-
if r.status_code == 200 and len(r.content) >
|
| 236 |
-
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
| 237 |
with open(out_path, "wb") as f:
|
| 238 |
f.write(r.content)
|
|
|
|
|
|
|
| 239 |
return out_path
|
| 240 |
except Exception:
|
| 241 |
pass
|
| 242 |
-
|
| 243 |
try:
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
except Exception:
|
| 250 |
-
font = None
|
| 251 |
-
text = (fallback_title or "VNEWS")[:40]
|
| 252 |
-
try:
|
| 253 |
-
bbox = draw.textbbox((0, 0), text, font=font)
|
| 254 |
-
tw = bbox[2] - bbox[0]
|
| 255 |
-
except Exception:
|
| 256 |
-
tw = len(text) * 24
|
| 257 |
-
draw.text(((1080 - tw) // 2, 330), text, fill=(255, 255, 255), font=font)
|
| 258 |
-
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
| 259 |
-
img.save(out_path, quality=90)
|
| 260 |
-
return out_path
|
| 261 |
except Exception:
|
|
|
|
|
|
|
|
|
|
| 262 |
return out_path
|
|
|
|
| 263 |
|
| 264 |
|
| 265 |
-
def
|
| 266 |
-
"""
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
if t:
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
except Exception as e:
|
| 315 |
-
|
|
|
|
| 316 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
return {
|
| 323 |
-
"id": str(
|
| 324 |
-
"title":
|
| 325 |
-
"
|
| 326 |
-
"
|
| 327 |
-
"
|
| 328 |
-
"
|
| 329 |
-
"sources": sources or [],
|
| 330 |
-
"created": now,
|
| 331 |
-
"created_str": datetime.now(_VN_TZ).strftime("%H:%M %d/%m/%Y"),
|
| 332 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
from fastapi import Request, Query
|
| 14 |
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
|
| 15 |
|
| 16 |
+
from main import app
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
# Import wall store from main.py so we read/write the SAME file
|
| 19 |
try:
|
|
|
|
| 41 |
def _web_context(topic):
|
| 42 |
return ""
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
try:
|
| 45 |
from huggingface_hub import AsyncInferenceClient
|
| 46 |
except Exception:
|
|
|
|
| 60 |
|
| 61 |
|
| 62 |
def _hf_token():
|
| 63 |
+
for k in ("HF_TOKEN", "HUGGINGFACEHUB_API_TOKEN", "HUGGING_FACE_HUB_TOKEN", "HF_API_TOKEN"):
|
| 64 |
v = os.getenv(k, "").strip()
|
| 65 |
if v:
|
| 66 |
return v
|
| 67 |
return ""
|
| 68 |
|
| 69 |
+
HF_TOKEN = _hf_token()
|
| 70 |
+
QWEN_VL_MODEL = os.getenv("QWEN_VL_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct")
|
| 71 |
+
# 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 = {
|
| 81 |
+
"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",
|
| 82 |
+
"Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"
|
| 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 |
+
"model": QWEN_VL_MODEL,
|
| 1135 |
+
"last_error": LAST_QWEN_ERROR,
|
| 1136 |
+
"tts_ready": gTTS is not None or edge_tts is not None,
|
| 1137 |
+
"tts_engine": "edge-tts" if edge_tts else ("gtts" if gTTS else "none"),
|
| 1138 |
+
"tts_voices": {k: v["flag"] + " " + v["name"] for k, v in TTS_VOICES.items()},
|
| 1139 |
+
"tts_voice_count": len(TTS_VOICES),
|
| 1140 |
+
"tts_default_speed": TTS_DEFAULT_SPEED,
|
| 1141 |
+
})
|
| 1142 |
+
|
| 1143 |
+
|
| 1144 |
+
@app.get("/api/ai/voices")
|
| 1145 |
+
def api_ai_voices():
|
| 1146 |
+
"""Return available TTS voices with country/group info."""
|
| 1147 |
+
voices_out = {}
|
| 1148 |
+
for k, v in TTS_VOICES.items():
|
| 1149 |
+
voices_out[k] = {
|
| 1150 |
+
"name": v["name"],
|
| 1151 |
+
"gender": v["gender"],
|
| 1152 |
+
"country": v["country"],
|
| 1153 |
+
"lang": v["lang"],
|
| 1154 |
+
"flag": v["flag"],
|
| 1155 |
+
"label": f"{v['flag']} {v['name']} ({v['gender']})",
|
| 1156 |
+
}
|
| 1157 |
+
return JSONResponse({
|
| 1158 |
+
"voices": voices_out,
|
| 1159 |
+
"default_voice": TTS_DEFAULT_VOICE,
|
| 1160 |
+
"default_speed": TTS_DEFAULT_SPEED,
|
| 1161 |
+
"topic_voice_map": TOPIC_VOICE_MAP,
|
| 1162 |
+
"emotions": {k: {"label": v["label"], "emoji": v["emoji"]} for k, v in EMOTION_PRESETS.items()},
|
| 1163 |
+
"default_emotion": EMOTION_DEFAULT,
|
| 1164 |
+
})
|
ai_fix2.py
CHANGED
|
@@ -321,7 +321,7 @@ async def ai_short_full(post_id: str, request: Request):
|
|
| 321 |
subprocess.run(['ffmpeg','-y','-i',audio,'-filter:a',f'atempo={speed}','-vn',audio_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=220)
|
| 322 |
duration = 45.0
|
| 323 |
try:
|
| 324 |
-
pr = subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:
|
| 325 |
duration = float((pr.stdout or b'45').decode().strip() or 45)
|
| 326 |
except Exception:
|
| 327 |
pass
|
|
|
|
| 321 |
subprocess.run(['ffmpeg','-y','-i',audio,'-filter:a',f'atempo={speed}','-vn',audio_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=220)
|
| 322 |
duration = 45.0
|
| 323 |
try:
|
| 324 |
+
pr = subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:nokey=1',audio_fast], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
|
| 325 |
duration = float((pr.stdout or b'45').decode().strip() or 45)
|
| 326 |
except Exception:
|
| 327 |
pass
|
ai_patch.py
CHANGED
|
@@ -6,7 +6,6 @@ import json
|
|
| 6 |
import html as html_lib
|
| 7 |
import subprocess
|
| 8 |
import requests
|
| 9 |
-
import hashlib
|
| 10 |
import ai_ext as base
|
| 11 |
from ai_ext import app
|
| 12 |
from fastapi import Request
|
|
@@ -42,17 +41,17 @@ def _similar(a, b):
|
|
| 42 |
return len(ta & tb) / max(1, min(len(ta), len(tb))) >= 0.72
|
| 43 |
|
| 44 |
|
| 45 |
-
def _dedupe_units(units, max_units=
|
| 46 |
-
"""Deduplicate units - only skip exact matches to ensure all bullet points are read."""
|
| 47 |
out, seen = [], set()
|
| 48 |
for u in units:
|
| 49 |
u = _clean(re.sub(r"^[-•*\d\.\)\s]+", "", u))
|
| 50 |
if len(u) < 18:
|
| 51 |
continue
|
| 52 |
nu = _norm(u)
|
| 53 |
-
# Only skip exact matches, NOT similar content (to avoid skipping valid bullet points)
|
| 54 |
if nu in seen:
|
| 55 |
continue
|
|
|
|
|
|
|
| 56 |
seen.add(nu)
|
| 57 |
out.append(u)
|
| 58 |
if len(out) >= max_units:
|
|
@@ -60,7 +59,7 @@ def _dedupe_units(units, max_units=25):
|
|
| 60 |
return out
|
| 61 |
|
| 62 |
|
| 63 |
-
def _postprocess_ai_text(text, max_units=
|
| 64 |
text = _clean(text)
|
| 65 |
if not text:
|
| 66 |
return text
|
|
@@ -79,9 +78,10 @@ def _postprocess_ai_text(text, max_units=20):
|
|
| 79 |
raw_lines.append(line)
|
| 80 |
units = []
|
| 81 |
for line in raw_lines:
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
|
|
|
| 85 |
units = _dedupe_units(units, max_units=max_units)
|
| 86 |
if not units:
|
| 87 |
return text[:900]
|
|
@@ -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,30 +321,12 @@ Yêu cầu bắt buộc:
|
|
| 321 |
|
| 322 |
Nội dung bài:
|
| 323 |
{art['raw'][:14000]}"""
|
| 324 |
-
text = await base.qwen_generate(prompt, image_url=art.get('image') or None, max_tokens=
|
| 325 |
-
text = _postprocess_ai_text(text, max_units=
|
| 326 |
src = [art['source']]
|
| 327 |
if 'Nguồn tham khảo:' not in text:
|
| 328 |
text += "\n\n" + _source_line(src)
|
| 329 |
post = base.make_post(art['title'], text, art.get('image') or base.pollinations_image_url(art['title']), art.get('url') or '', 'topic_article', sources=src)
|
| 330 |
-
|
| 331 |
-
# Generate slides for this post so they persist after page reload
|
| 332 |
-
try:
|
| 333 |
-
page_data = _scrape_article_images(art.get('url', ''))
|
| 334 |
-
if page_data and page_data.get('paragraphs'):
|
| 335 |
-
key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12)
|
| 336 |
-
if key_points:
|
| 337 |
-
relevant_imgs = page_data.get('images', [])
|
| 338 |
-
if not relevant_imgs and page_data.get('og_img'):
|
| 339 |
-
relevant_imgs = [page_data['og_img']]
|
| 340 |
-
slides = []
|
| 341 |
-
for i, point in enumerate(key_points):
|
| 342 |
-
img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
|
| 343 |
-
slides.append({'text': point, 'image': img, 'index': i + 1})
|
| 344 |
-
post['slides'] = slides
|
| 345 |
-
except Exception:
|
| 346 |
-
pass
|
| 347 |
-
|
| 348 |
new_posts.append(post)
|
| 349 |
posts = new_posts + posts
|
| 350 |
base._save_ai_wall(posts)
|
|
@@ -365,142 +347,14 @@ async def compat_url_wall(request: Request):
|
|
| 365 |
if len(raw) < 120:
|
| 366 |
return JSONResponse({'error': 'URL không có đủ nội dung để tóm tắt'}, status_code=422)
|
| 367 |
prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
|
| 368 |
-
text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=
|
| 369 |
-
text = _postprocess_ai_text(text, max_units=
|
| 370 |
src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
|
| 371 |
if 'Nguồn tham khảo:' not in text:
|
| 372 |
text += "\n\n" + _source_line(src)
|
| 373 |
post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'url', sources=src)
|
| 374 |
-
|
| 375 |
-
# Generate slides so they persist after page reload
|
| 376 |
-
slides = []
|
| 377 |
-
try:
|
| 378 |
-
page_data = _scrape_article_images(url)
|
| 379 |
-
if page_data and page_data.get('paragraphs'):
|
| 380 |
-
key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12)
|
| 381 |
-
if key_points:
|
| 382 |
-
relevant_imgs = page_data.get('images', [])
|
| 383 |
-
if not relevant_imgs and page_data.get('og_img'):
|
| 384 |
-
relevant_imgs = [page_data['og_img']]
|
| 385 |
-
for i, point in enumerate(key_points):
|
| 386 |
-
img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
|
| 387 |
-
slides.append({'text': point, 'image': img, 'index': i + 1})
|
| 388 |
-
except Exception:
|
| 389 |
-
pass
|
| 390 |
-
post['slides'] = slides
|
| 391 |
-
|
| 392 |
posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
|
| 393 |
-
return JSONResponse({'post': post
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
def _is_relevant_image(img_url, title, text):
|
| 397 |
-
"""Check if an image is relevant to the article content."""
|
| 398 |
-
if not img_url:
|
| 399 |
-
return False
|
| 400 |
-
skip_patterns = ['pixel', 'analytics', 'tracking', '1x1.gif', 'spacer.gif',
|
| 401 |
-
'logo', 'icon', 'avatar', 'emoji', 'smiley', 'sprite',
|
| 402 |
-
'advertisement', 'ad-banner', 'sponsored', 'banner-ads']
|
| 403 |
-
img_lower = img_url.lower()
|
| 404 |
-
for p in skip_patterns:
|
| 405 |
-
if p in img_lower:
|
| 406 |
-
return False
|
| 407 |
-
if not any(img_lower.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif']):
|
| 408 |
-
return False
|
| 409 |
-
return True
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
def _filter_relevant_images(images, title, text, max_images=8):
|
| 413 |
-
"""Filter and rank images by relevance to article content."""
|
| 414 |
-
if not images:
|
| 415 |
-
return []
|
| 416 |
-
seen = set()
|
| 417 |
-
relevant = []
|
| 418 |
-
for img in images:
|
| 419 |
-
if img in seen:
|
| 420 |
-
continue
|
| 421 |
-
seen.add(img)
|
| 422 |
-
if _is_relevant_image(img, title, text):
|
| 423 |
-
relevant.append(img)
|
| 424 |
-
return relevant[:max_images]
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
def _extract_key_points_for_slides(paragraphs, max_points=12):
|
| 428 |
-
"""Extract key points from paragraphs for slides - extracts ALL sentences, not just first one."""
|
| 429 |
-
points = []
|
| 430 |
-
for p in paragraphs:
|
| 431 |
-
if len(points) >= max_points:
|
| 432 |
-
break
|
| 433 |
-
p = _clean(p)
|
| 434 |
-
if not p:
|
| 435 |
-
continue
|
| 436 |
-
# Split paragraph into sentences using Vietnamese + English punctuation - GET ALL SENTENCES
|
| 437 |
-
sentences = re.split(r'(?<=[.!?])\s+(?=[A-ZÀ-Ỹ0-9])', p)
|
| 438 |
-
sentences = [s.strip() for s in sentences if s.strip()]
|
| 439 |
-
|
| 440 |
-
for sentence in sentences:
|
| 441 |
-
if len(points) >= max_points:
|
| 442 |
-
break
|
| 443 |
-
sentence = _clean(sentence)
|
| 444 |
-
if len(sentence) < 30:
|
| 445 |
-
continue
|
| 446 |
-
if any(sentence[:60] in existing for existing in points):
|
| 447 |
-
continue
|
| 448 |
-
if not sentence.endswith(('.', '!', '?')):
|
| 449 |
-
sentence = sentence + '.'
|
| 450 |
-
points.append(sentence)
|
| 451 |
-
return points
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
def _scrape_article_images(url):
|
| 455 |
-
"""Scrape article page and return only relevant images."""
|
| 456 |
-
try:
|
| 457 |
-
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
| 458 |
-
"Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8"}
|
| 459 |
-
r = requests.get(url, headers=headers, timeout=15, allow_redirects=True)
|
| 460 |
-
r.encoding = 'utf-8'
|
| 461 |
-
soup = BeautifulSoup(r.text, 'lxml')
|
| 462 |
-
for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']):
|
| 463 |
-
tag.decompose()
|
| 464 |
-
h1 = soup.find('h1')
|
| 465 |
-
ogt = soup.find('meta', property='og:title')
|
| 466 |
-
title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '')
|
| 467 |
-
ogi = soup.find('meta', property='og:image')
|
| 468 |
-
og_img = ogi.get('content', '') if ogi else ''
|
| 469 |
-
if og_img and og_img.startswith('//'):
|
| 470 |
-
og_img = 'https:' + og_img
|
| 471 |
-
block = None
|
| 472 |
-
for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
|
| 473 |
-
el = soup.select_one(sel)
|
| 474 |
-
if el and len(el.find_all('p')) >= 2:
|
| 475 |
-
block = el
|
| 476 |
-
break
|
| 477 |
-
if not block:
|
| 478 |
-
block = soup.body or soup
|
| 479 |
-
paragraphs = []
|
| 480 |
-
all_images = []
|
| 481 |
-
seen_imgs = set()
|
| 482 |
-
if og_img and og_img not in seen_imgs:
|
| 483 |
-
all_images.append(og_img)
|
| 484 |
-
seen_imgs.add(og_img)
|
| 485 |
-
for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
|
| 486 |
-
if el.name == 'p':
|
| 487 |
-
t = _clean(el.get_text(strip=True))
|
| 488 |
-
if t and len(t) > 40:
|
| 489 |
-
paragraphs.append(t)
|
| 490 |
-
elif el.name in ('figure', 'img'):
|
| 491 |
-
im = el if el.name == 'img' else el.find('img')
|
| 492 |
-
if im:
|
| 493 |
-
src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
|
| 494 |
-
if src and 'base64' not in src:
|
| 495 |
-
if src.startswith('//'):
|
| 496 |
-
src = 'https:' + src
|
| 497 |
-
if src not in seen_imgs:
|
| 498 |
-
all_images.append(src)
|
| 499 |
-
seen_imgs.add(src)
|
| 500 |
-
relevant_images = _filter_relevant_images(all_images, title, ' '.join(paragraphs[:5]))
|
| 501 |
-
return {'title': _clean(title), 'paragraphs': paragraphs, 'images': relevant_images, 'og_img': og_img}
|
| 502 |
-
except Exception:
|
| 503 |
-
return None
|
| 504 |
|
| 505 |
|
| 506 |
@app.post('/api/rewrite_share')
|
|
@@ -517,56 +371,40 @@ async def compat_rewrite_share(request: Request):
|
|
| 517 |
if len(raw) < 120:
|
| 518 |
return JSONResponse({'error': 'Bài viết không đủ nội dung để tóm tắt'}, status_code=422)
|
| 519 |
prompt = _make_summary_prompt(data.get('title', ''), raw, data.get('via', '') or base._domain(url))
|
| 520 |
-
text = await base.qwen_generate(prompt, image_url=data.get('image') or None, max_tokens=
|
| 521 |
-
text = _postprocess_ai_text(text, max_units=
|
| 522 |
src = [{'title': data.get('title'), 'url': url, 'excerpt': raw[:500], 'via': data.get('via') or base._domain(url)}]
|
| 523 |
if 'Nguồn tham khảo:' not in text:
|
| 524 |
text += "\n\n" + _source_line(src)
|
| 525 |
post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'summary', sources=src)
|
| 526 |
-
|
| 527 |
-
# Generate slides with relevant images only
|
| 528 |
-
slides = []
|
| 529 |
-
page_data = _scrape_article_images(url)
|
| 530 |
-
if page_data and page_data.get('paragraphs'):
|
| 531 |
-
key_points = _extract_key_points_for_slides(page_data['paragraphs'], max_points=12)
|
| 532 |
-
if key_points:
|
| 533 |
-
relevant_imgs = page_data.get('images', [])
|
| 534 |
-
if not relevant_imgs and page_data.get('og_img'):
|
| 535 |
-
relevant_imgs = [page_data['og_img']]
|
| 536 |
-
for i, point in enumerate(key_points):
|
| 537 |
-
img = relevant_imgs[i] if i < len(relevant_imgs) else (relevant_imgs[-1] if relevant_imgs else '')
|
| 538 |
-
slides.append({'text': point, 'image': img, 'index': i + 1})
|
| 539 |
-
|
| 540 |
-
# FIX: Save slides into post so they persist after page reload
|
| 541 |
-
post['slides'] = slides
|
| 542 |
posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
|
| 543 |
-
|
| 544 |
-
return JSONResponse({'post': post, 'slides': slides})
|
| 545 |
|
| 546 |
|
| 547 |
def _emotion_script(text, emotion):
|
| 548 |
-
"""Prepend emotion-appropriate prefix to text based on emotion type.
|
| 549 |
-
|
| 550 |
-
NOTE: Prefix is NOT added to avoid cluttering Short AI speech.
|
| 551 |
-
The emotion is still used for voice selection but content is read cleanly.
|
| 552 |
-
"""
|
| 553 |
text = _clean(text)
|
| 554 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 555 |
return text
|
| 556 |
|
| 557 |
|
| 558 |
def _tts_script_smart(post, emotion):
|
| 559 |
-
raw = base._short_script(post)
|
| 560 |
raw = re.sub(r"^[•\-\*]\s*", "", raw, flags=re.M)
|
| 561 |
raw = re.sub(r"\s*\n\s*", ". ", raw)
|
| 562 |
raw = re.sub(r"([\.\!\?])\s*", r"\1\n", raw)
|
| 563 |
raw = re.sub(r"\n{2,}", "\n", raw).strip()
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
raw = raw[:3000]
|
| 568 |
cut = max(raw.rfind("."), raw.rfind("!"), raw.rfind("?"))
|
| 569 |
-
if cut >
|
| 570 |
raw = raw[:cut + 1]
|
| 571 |
return raw
|
| 572 |
|
|
@@ -681,7 +519,7 @@ def _make_short_frame_full(post, img_path, out_path):
|
|
| 681 |
|
| 682 |
|
| 683 |
|
| 684 |
-
def _summary_segments_from_post(post, max_segments=
|
| 685 |
raw = _clean(post.get('text') or post.get('title') or '')
|
| 686 |
raw = re.sub(r'^Bản tin AI viết lại:\s*', '', raw, flags=re.I)
|
| 687 |
raw = re.sub(r'Nguồn tham khảo:.*$', '', raw, flags=re.I|re.S).strip()
|
|
@@ -692,7 +530,7 @@ def _summary_segments_from_post(post, max_segments=25):
|
|
| 692 |
low=ln.lower()
|
| 693 |
if low.startswith(('điểm chính','tiêu đề','sapo','nguồn tham khảo')): continue
|
| 694 |
if len(ln)>=18: lines.append(ln)
|
| 695 |
-
if len(lines)<
|
| 696 |
lines=[]
|
| 697 |
for s in re.split(r'(?<=[\.\!\?])\s+', raw):
|
| 698 |
s=_clean(s)
|
|
@@ -744,8 +582,7 @@ def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='ne
|
|
| 744 |
draw.rounded_rectangle((48,834,260,880), radius=20, fill=(28,70,45))
|
| 745 |
draw.text((66,842),f'Đoạn {idx+1}/{total}',fill=(235,235,235),font=font_small)
|
| 746 |
y=940; maxw=W-96
|
| 747 |
-
|
| 748 |
-
for ln in _wrap_text_px(draw, segment, font_seg, maxw, 18):
|
| 749 |
draw.text((48,y),ln,fill=(255,255,255),font=font_seg)
|
| 750 |
y+=74
|
| 751 |
if y>1500: break
|
|
@@ -757,11 +594,10 @@ def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='ne
|
|
| 757 |
bg.save(out_path, quality=92)
|
| 758 |
|
| 759 |
|
| 760 |
-
def _estimate_audio_duration(path, fallback=
|
| 761 |
-
"""Estimate audio duration with 15s minimum per segment for complete bullet reading."""
|
| 762 |
try:
|
| 763 |
-
pr=subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1:
|
| 764 |
-
return max(
|
| 765 |
except Exception:
|
| 766 |
return fallback
|
| 767 |
|
|
@@ -774,7 +610,7 @@ async def patched_ai_short(post_id: str, request: Request):
|
|
| 774 |
body = {}
|
| 775 |
voice = str(body.get('voice', 'nu')).strip().lower()
|
| 776 |
emotion = str(body.get('emotion', 'neutral')).strip().lower()
|
| 777 |
-
speed = float(body.get('speed', 1.
|
| 778 |
speed = max(0.85, min(1.35, speed))
|
| 779 |
|
| 780 |
posts = base._load_ai_wall()
|
|
@@ -782,7 +618,7 @@ async def patched_ai_short(post_id: str, request: Request):
|
|
| 782 |
if not post:
|
| 783 |
return JSONResponse({'error': 'post not found'}, status_code=404)
|
| 784 |
|
| 785 |
-
segments = _summary_segments_from_post(post, max_segments=
|
| 786 |
seg_hash = hashlib.md5(('|'.join(segments)+voice+emotion+str(speed)).encode('utf-8')).hexdigest()[:8]
|
| 787 |
os.makedirs(base.SHORTS_DIR, exist_ok=True)
|
| 788 |
suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_{seg_hash}_scenes_nosub"
|
|
@@ -805,63 +641,11 @@ async def patched_ai_short(post_id: str, request: Request):
|
|
| 805 |
try:
|
| 806 |
base._download_image(post.get('img'), post.get('title', 'AI news'), img)
|
| 807 |
edge_voice = {
|
| 808 |
-
# Vietnamese
|
| 809 |
-
'vi-vn-hoaimyneural': 'vi-VN-HoaiMyNeural',
|
| 810 |
-
'vi-vn-namminhneural': 'vi-VN-NamMinhNeural',
|
| 811 |
-
'hoaimy': 'vi-VN-HoaiMyNeural',
|
| 812 |
-
'namminh': 'vi-VN-NamMinhNeural',
|
| 813 |
'nam': 'vi-VN-NamMinhNeural',
|
| 814 |
'male': 'vi-VN-NamMinhNeural',
|
| 815 |
'nu': 'vi-VN-HoaiMyNeural',
|
| 816 |
'female': 'vi-VN-HoaiMyNeural',
|
| 817 |
'mien-nam': 'vi-VN-HoaiMyNeural',
|
| 818 |
-
# English - Multilingual
|
| 819 |
-
'en-us-andrewmultilingualneural': 'en-US-AndrewMultilingualNeural',
|
| 820 |
-
'en-au-williammultilingualneural': 'en-AU-WilliamMultilingualNeural',
|
| 821 |
-
'andrew': 'en-US-AndrewMultilingualNeural',
|
| 822 |
-
'en_andrew': 'en-US-AndrewMultilingualNeural',
|
| 823 |
-
'jenny': 'en-US-AndrewMultilingualNeural',
|
| 824 |
-
'en_jenny': 'en-US-AndrewMultilingualNeural',
|
| 825 |
-
# Portuguese - Multilingual (ONLY Thalita)
|
| 826 |
-
'pt-br-thalitamultilingualneural': 'pt-BR-ThalitaMultilingualNeural',
|
| 827 |
-
'thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 828 |
-
'pt_thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 829 |
-
'pt_br_thalita': 'pt-BR-ThalitaMultilingualNeural',
|
| 830 |
-
'pt': 'pt-BR-ThalitaMultilingualNeural',
|
| 831 |
-
'pt_francisco': 'pt-BR-ThalitaMultilingualNeural',
|
| 832 |
-
# French - Multilingual
|
| 833 |
-
'fr-fr-viviennemultilingualneural': 'fr-FR-VivienneMultilingualNeural',
|
| 834 |
-
'fr-fr-remymultilingualneural': 'fr-FR-RemyMultilingualNeural',
|
| 835 |
-
'denise': 'fr-FR-VivienneMultilingualNeural',
|
| 836 |
-
'fr': 'fr-FR-VivienneMultilingualNeural',
|
| 837 |
-
'fr_denise': 'fr-FR-VivienneMultilingualNeural',
|
| 838 |
-
# German - Multilingual
|
| 839 |
-
'de-de-seraphinamultilingualneural': 'de-DE-SeraphinaMultilingualNeural',
|
| 840 |
-
'de-de-florianmultilingualneural': 'de-DE-FlorianMultilingualNeural',
|
| 841 |
-
'katja': 'de-DE-SeraphinaMultilingualNeural',
|
| 842 |
-
'de': 'de-DE-SeraphinaMultilingualNeural',
|
| 843 |
-
'de_katja': 'de-DE-SeraphinaMultilingualNeural',
|
| 844 |
-
# Korean - Multilingual (Hyunsu, NOT SunHee)
|
| 845 |
-
'ko-kr-hyusumultilingualneural': 'ko-KR-HyunsuMultilingualNeural',
|
| 846 |
-
'ko-kr-hyunsuneural': 'ko-KR-HyunsuMultilingualNeural',
|
| 847 |
-
'sunhee': 'ko-KR-HyunsuMultilingualNeural',
|
| 848 |
-
'ko': 'ko-KR-HyunsuMultilingualNeural',
|
| 849 |
-
'ko_sunhee': 'ko-KR-HyunsuMultilingualNeural',
|
| 850 |
-
# Italian - Multilingual
|
| 851 |
-
'it-it-giuseppemultilingualneural': 'it-IT-GiuseppeMultilingualNeural',
|
| 852 |
-
# Spanish (keep for backward compat)
|
| 853 |
-
'ela': 'en-US-AndrewMultilingualNeural',
|
| 854 |
-
'es_ela': 'en-US-AndrewMultilingualNeural',
|
| 855 |
-
'es': 'en-US-AndrewMultilingualNeural',
|
| 856 |
-
'es_carlos': 'en-US-AndrewMultilingualNeural',
|
| 857 |
-
# Japanese (keep for backward compat)
|
| 858 |
-
'nanami': 'en-US-AndrewMultilingualNeural',
|
| 859 |
-
'ja': 'en-US-AndrewMultilingualNeural',
|
| 860 |
-
'ja_nanami': 'en-US-AndrewMultilingualNeural',
|
| 861 |
-
# Chinese (keep for backward compat)
|
| 862 |
-
'xiaochen': 'en-US-AndrewMultilingualNeural',
|
| 863 |
-
'zh': 'en-US-AndrewMultilingualNeural',
|
| 864 |
-
'zh_xiaochen': 'en-US-AndrewMultilingualNeural',
|
| 865 |
}.get(voice, 'vi-VN-HoaiMyNeural')
|
| 866 |
part_files=[]
|
| 867 |
for idx, seg in enumerate(segments):
|
|
@@ -874,13 +658,13 @@ async def patched_ai_short(post_id: str, request: Request):
|
|
| 874 |
try:
|
| 875 |
subprocess.run(['python','-m','edge_tts','--voice',edge_voice,'--text',spoken,'--write-media',aud], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
| 876 |
except Exception:
|
| 877 |
-
tld='com.vn' if voice in ('nu','female','mien-nam'
|
| 878 |
try:
|
| 879 |
base.gTTS(spoken, lang='vi', tld=tld, slow=False).save(aud)
|
| 880 |
except TypeError:
|
| 881 |
base.gTTS(spoken, lang='vi', slow=False).save(aud)
|
| 882 |
subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
|
| 883 |
-
dur=_estimate_audio_duration(aud_fast, fallback=
|
| 884 |
subprocess.run(['ffmpeg','-y','-loop','1','-t',str(dur),'-i',frame,'-i',aud_fast,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k',part], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=150)
|
| 885 |
part_files.append(part)
|
| 886 |
concat=os.path.join(work,'concat.txt')
|
|
@@ -915,3 +699,53 @@ def api_ai_shorts():
|
|
| 915 |
|
| 916 |
|
| 917 |
app.router.routes = [r for r in app.router.routes if not (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set()))]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
import html as html_lib
|
| 7 |
import subprocess
|
| 8 |
import requests
|
|
|
|
| 9 |
import ai_ext as base
|
| 10 |
from ai_ext import app
|
| 11 |
from fastapi import Request
|
|
|
|
| 41 |
return len(ta & tb) / max(1, min(len(ta), len(tb))) >= 0.72
|
| 42 |
|
| 43 |
|
| 44 |
+
def _dedupe_units(units, max_units=7):
|
|
|
|
| 45 |
out, seen = [], set()
|
| 46 |
for u in units:
|
| 47 |
u = _clean(re.sub(r"^[-•*\d\.\)\s]+", "", u))
|
| 48 |
if len(u) < 18:
|
| 49 |
continue
|
| 50 |
nu = _norm(u)
|
|
|
|
| 51 |
if nu in seen:
|
| 52 |
continue
|
| 53 |
+
if any(_similar(u, old) for old in out):
|
| 54 |
+
continue
|
| 55 |
seen.add(nu)
|
| 56 |
out.append(u)
|
| 57 |
if len(out) >= max_units:
|
|
|
|
| 59 |
return out
|
| 60 |
|
| 61 |
|
| 62 |
+
def _postprocess_ai_text(text, max_units=7):
|
| 63 |
text = _clean(text)
|
| 64 |
if not text:
|
| 65 |
return text
|
|
|
|
| 78 |
raw_lines.append(line)
|
| 79 |
units = []
|
| 80 |
for line in raw_lines:
|
| 81 |
+
if len(line) > 260:
|
| 82 |
+
units.extend(re.split(r"(?<=[\.\!\?])\s+(?=[A-ZÀ-Ỹ0-9])", line))
|
| 83 |
+
else:
|
| 84 |
+
units.append(line)
|
| 85 |
units = _dedupe_units(units, max_units=max_units)
|
| 86 |
if not units:
|
| 87 |
return text[:900]
|
|
|
|
| 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)
|
| 329 |
post = base.make_post(art['title'], text, art.get('image') or base.pollinations_image_url(art['title']), art.get('url') or '', 'topic_article', sources=src)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
new_posts.append(post)
|
| 331 |
posts = new_posts + posts
|
| 332 |
base._save_ai_wall(posts)
|
|
|
|
| 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)
|
| 355 |
post = base.make_post(data.get('title') or 'Bài viết', text, data.get('image') or '', url, 'url', sources=src)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
|
| 357 |
+
return JSONResponse({'post': post})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
|
| 359 |
|
| 360 |
@app.post('/api/rewrite_share')
|
|
|
|
| 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:nokey=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
|
@@ -840,10 +840,486 @@ FINAL6E_INJECT = """
|
|
| 840 |
function escE(s){return String(s||'').replace(/[&<>\"']/g,m=>({'&':'&','<':'<','>':'>','\"':'"',"'":'''}[m]));}
|
| 841 |
window.__topicWallE=[];
|
| 842 |
function sourceDetailsHtml(p){let arr=p.source_details||[];if(!arr.length)return '';let h='<div class="source-detail-box"><h3>📚 Nội dung từng nguồn đã dùng</h3>';arr.forEach((s,i)=>{h+=`<div class="source-detail-item"><div class="source-detail-title">${i+1}. ${escE(s.title)}</div><div class="source-detail-meta">${escE(s.via||'Nguồn')}</div><div class="source-detail-content">${escE(s.content||'')}</div>${s.url?`<a href="${escE(s.url)}" target="_blank">Mở nguồn gốc</a>`:''}</div>`});h+='</div>';return h;}
|
| 843 |
-
function renderTopicWallE(){let home=document.getElementById('view-home');if(!home||!window.__topicWallE.length)return;document.getElementById('ai-wall-topic-live')?.remove();let wrap=document.createElement('div');wrap.id='ai-wall-topic-live';wrap.className='ai-wall-topic-live';let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI mới</span><span class="slider-note">
|
| 844 |
-
window.readTopicWallE=function(i){let p=window.__topicWallE[i];if(!p)return;showView('view-article');let imgs=(p.images||[]).filter(Boolean);let gal=imgs.length?'<div class="ai-wall-gallery">'+imgs.slice(0,12).map(u=>`<img src="${escE(u)}" loading="lazy">`).join('')+'</div>':(p.img?`<img class="article-img" src="${escE(p.img)}">`:'');
|
| 845 |
-
window.createTopicPostFinal5=async function(){let inp=document.getElementById('ai-topic-input-final5');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');let btn=document.getElementById('ai-topic-btn-final5');if(btn){btn.disabled=true;btn.textContent='Đang
|
| 846 |
-
setInterval(()=>{document.querySelectorAll('#ai-topic-input-final3,.topic-final3,#ai-topic-input-final4,.topic-final4').forEach(e=>(e.closest('.topic-final3,.topic-final4,.ai-compose-row')||e).remove());let b=document.getElementById('ai-topic-btn-final5');if(b){b.style.display='block';b.textContent='✨ Tạo bài tổng hợp từ web bằng Qwen';}},1200);
|
| 847 |
})();
|
| 848 |
</script>
|
| 849 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 840 |
function escE(s){return String(s||'').replace(/[&<>\"']/g,m=>({'&':'&','<':'<','>':'>','\"':'"',"'":'''}[m]));}
|
| 841 |
window.__topicWallE=[];
|
| 842 |
function sourceDetailsHtml(p){let arr=p.source_details||[];if(!arr.length)return '';let h='<div class="source-detail-box"><h3>📚 Nội dung từng nguồn đã dùng</h3>';arr.forEach((s,i)=>{h+=`<div class="source-detail-item"><div class="source-detail-title">${i+1}. ${escE(s.title)}</div><div class="source-detail-meta">${escE(s.via||'Nguồn')}</div><div class="source-detail-content">${escE(s.content||'')}</div>${s.url?`<a href="${escE(s.url)}" target="_blank">Mở nguồn gốc</a>`:''}</div>`});h+='</div>';return h;}
|
| 843 |
+
function renderTopicWallE(){let home=document.getElementById('view-home');if(!home||!window.__topicWallE.length)return;document.getElementById('ai-wall-topic-live')?.remove();let wrap=document.createElement('div');wrap.id='ai-wall-topic-live';wrap.className='ai-wall-topic-live';let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI mới</span><span class="slider-note">Có nội dung nguồn</span></div><div class="slider-track">';window.__topicWallE.slice(0,20).forEach((p,i)=>{h+=`<div class="wall-item"><div class="wall-thumb">${p.img?`<img src="${escE(p.img)}">`:''}</div><div class="wall-title">${escE(p.title)}</div><div class="wall-text">${escE(p.text)}</div><div class="wall-actions"><button class="primary" onclick="readTopicWallE(${i})">Xem</button></div></div>`});h+='</div>';wrap.innerHTML=h;let comp=document.querySelector('.ai-compose');if(comp)comp.after(wrap);else home.prepend(wrap);}
|
| 844 |
+
window.readTopicWallE=function(i){let p=window.__topicWallE[i];if(!p)return;showView('view-article');let imgs=(p.images||[]).filter(Boolean);let gal=imgs.length?'<div class="ai-wall-gallery">'+imgs.slice(0,12).map(u=>`<img src="${escE(u)}" loading="lazy">`).join('')+'</div>':(p.img?`<img class="article-img" src="${escE(p.img)}">`:'');document.getElementById('view-article').innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${escE(p.title)}</h1>${gal}<p class="article-p" style="white-space:pre-wrap">${escE(p.text)}</p>${sourceDetailsHtml(p)}<div class="article-actions"><button onclick="shareAI?shareAI(${JSON.stringify(p).replace(/\"/g,'"')},false):navigator.clipboard.writeText(location.href)">📤 Chia sẻ</button></div></div>`;window.scrollTo(0,0)};
|
| 845 |
+
window.createTopicPostFinal5=async function(){let inp=document.getElementById('ai-topic-input-final5');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');let btn=document.getElementById('ai-topic-btn-final5');if(btn){btn.disabled=true;btn.textContent='Đang lấy nguồn...'}try{let src=await fetch('/api/topic_sources?topic='+encodeURIComponent(topic)).then(r=>r.json()).catch(()=>null);if(btn&&src)btn.textContent='Đã tìm '+(src.count||0)+' nguồn, đang viết...';let r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');window.__topicWallE.unshift(j.post);if(inp)inp.value='';renderTopicWallE();readTopicWallE(0);}catch(e){alert(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='✨ Tạo bài tổng hợp từ web bằng Qwen'}}};
|
|
|
|
| 846 |
})();
|
| 847 |
</script>
|
| 848 |
+
"""
|
| 849 |
+
|
| 850 |
+
# Override root one last time to append source-details UI.
|
| 851 |
+
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
|
| 852 |
+
@app.get('/')
|
| 853 |
+
async def index_final6_source_details():
|
| 854 |
+
html=f5.f4.f3.f2.f1._load_index_html()
|
| 855 |
+
body=getattr(rt.old,'PATCH_INJECT','')+f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT+FINAL6_INJECT+globals().get('FINAL6_FAST_HOME_INJECT','')+FINAL6E_INJECT
|
| 856 |
+
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|
| 857 |
+
|
| 858 |
+
|
| 859 |
+
# ===== FINAL6F: CLEAN TOPIC OUTPUT + IN-APP SOURCE READER =====
|
| 860 |
+
def _clean_generated_article(text, topic=''):
|
| 861 |
+
"""Remove prompt/instruction leakage from generated topic articles."""
|
| 862 |
+
text=str(text or '').strip()
|
| 863 |
+
bad_patterns=[
|
| 864 |
+
r'^\s*[•\-]*\s*Hãy viết .*',
|
| 865 |
+
r'^\s*[•\-]*\s*Dưới đây là .*',
|
| 866 |
+
r'^\s*[•\-]*\s*Dữ liệu .*',
|
| 867 |
+
r'^\s*[•\-]*\s*NỘI DUNG NGUỒN.*',
|
| 868 |
+
r'^\s*[•\-]*\s*Yêu cầu\s*:.*',
|
| 869 |
+
r'^\s*[•\-]*\s*Tiêu đề mới.*',
|
| 870 |
+
r'^\s*[•\-]*\s*Sapo\s*2.*',
|
| 871 |
+
r'^\s*[•\-]*\s*5\s*[-–]\s*8\s*đoạn.*',
|
| 872 |
+
r'^\s*[•\-]*\s*Không dùng câu.*',
|
| 873 |
+
r'^\s*[•\-]*\s*Cuối bài.*',
|
| 874 |
+
r'^\s*[•\-]*\s*Không liệt kê.*',
|
| 875 |
+
r'^\s*[•\-]*\s*Tổng hợp thành.*',
|
| 876 |
+
r'^\s*[•\-]*\s*Diễn đạt lại.*',
|
| 877 |
+
r'^\s*[•\-]*\s*Tuyệt đối.*',
|
| 878 |
+
]
|
| 879 |
+
out=[]
|
| 880 |
+
for ln in text.splitlines():
|
| 881 |
+
s=ln.strip()
|
| 882 |
+
if not s:
|
| 883 |
+
out.append(ln);continue
|
| 884 |
+
if any(re.search(p,s,re.I) for p in bad_patterns):
|
| 885 |
+
continue
|
| 886 |
+
out.append(ln)
|
| 887 |
+
cleaned='\n'.join(out).strip()
|
| 888 |
+
# If model returned a markdown code/prompt-like block, keep content after first plausible title line.
|
| 889 |
+
cleaned=re.sub(r'^(?:Bài viết|Nội dung bài viết)\s*[::]\s*','',cleaned,flags=re.I).strip()
|
| 890 |
+
# Remove duplicated leading topic instruction if it appears inline.
|
| 891 |
+
cleaned=re.sub(r'Hãy viết MỘT BÀI VIẾT HOÀN CHỈNH[^\n\.]*[\.\n]*','',cleaned,flags=re.I).strip()
|
| 892 |
+
return cleaned or text
|
| 893 |
+
|
| 894 |
+
def _source_article_data(url):
|
| 895 |
+
try:
|
| 896 |
+
r=requests.get(url,headers=UA,timeout=14);r.encoding='utf-8'
|
| 897 |
+
soup=BeautifulSoup(r.text,'lxml')
|
| 898 |
+
h1=soup.find('h1')
|
| 899 |
+
ogt=soup.find('meta',property='og:title')
|
| 900 |
+
ogd=soup.find('meta',property='og:description')
|
| 901 |
+
ogi=soup.find('meta',property='og:image')
|
| 902 |
+
title=clean(h1.get_text(' ',strip=True) if h1 else (ogt.get('content','') if ogt else ''))
|
| 903 |
+
summary=clean(ogd.get('content','') if ogd else '')
|
| 904 |
+
img=ogi.get('content','') if ogi else ''
|
| 905 |
+
except Exception:
|
| 906 |
+
title='';summary='';img=''
|
| 907 |
+
text=_scrape_article_text(url,12000)
|
| 908 |
+
body=[]
|
| 909 |
+
for para in re.split(r'\n+',text or ''):
|
| 910 |
+
para=clean(para)
|
| 911 |
+
if len(para)>35:
|
| 912 |
+
body.append({'type':'p','text':para})
|
| 913 |
+
if len(body)>=80:break
|
| 914 |
+
if not title:title=url
|
| 915 |
+
if not body and summary:body=[{'type':'p','text':summary}]
|
| 916 |
+
return {'title':title,'summary':summary,'og_image':img,'body':body,'source':'topic-source','url':url}
|
| 917 |
+
|
| 918 |
+
@app.get('/api/topic_source_article')
|
| 919 |
+
def api_topic_source_article(url:str=Query(...)):
|
| 920 |
+
if not url.startswith('http'):
|
| 921 |
+
return JSONResponse({'error':'bad url'},status_code=400)
|
| 922 |
+
return JSONResponse(_source_article_data(url))
|
| 923 |
+
|
| 924 |
+
# Override topic generation one last time with output cleaning and source details.
|
| 925 |
+
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/api/topic_post' and 'POST' in getattr(r,'methods',set()))]
|
| 926 |
+
|
| 927 |
+
@app.post('/api/topic_post')
|
| 928 |
+
async def topic_post_clean_final(request:Request):
|
| 929 |
+
body=await request.json();topic=clean(body.get('topic',''))
|
| 930 |
+
if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
|
| 931 |
+
img=_topic_image(topic)
|
| 932 |
+
research=_fast_context(topic) if '_fast_context' in globals() else _web_research_context(topic)
|
| 933 |
+
context=research.get('context','');sources=research.get('sources',[])
|
| 934 |
+
details=_extract_source_details_from_context(context,sources) if '_extract_source_details_from_context' in globals() else []
|
| 935 |
+
if not details:
|
| 936 |
+
# Build details directly from sources/snippets if helper unavailable or empty.
|
| 937 |
+
for s in sources[:8]:
|
| 938 |
+
details.append({'title':s.get('title',''),'url':s.get('url',''),'via':s.get('via',''),'content':s.get('excerpt','') or s.get('snippet','') or ''})
|
| 939 |
+
if not context and not details:
|
| 940 |
+
return JSONResponse({'error':'Không tìm/crawl được đủ nội dung về chủ đề này. Hãy thử chủ đề cụ thể hơn hoặc dùng hashtag gợi ý.'},status_code=422)
|
| 941 |
+
source_brief='\n\n'.join([f"[{i+1}] {d.get('title','')} ({d.get('via','')})\n{d.get('content','')[:1400]}" for i,d in enumerate(details[:8])])
|
| 942 |
+
prompt=f"""Vai trò: biên tập viên VNEWS.
|
| 943 |
+
Nhiệm vụ: viết một bài báo tiếng Việt hoàn chỉnh về chủ đề "{topic}" dựa trên các nguồn bên dưới.
|
| 944 |
+
|
| 945 |
+
Nguồn thu thập:
|
| 946 |
+
{source_brief[:18000]}
|
| 947 |
+
|
| 948 |
+
Quy tắc biên tập:
|
| 949 |
+
1. Chỉ xuất bản bài viết cuối cùng, không nhắc lại yêu cầu, không liệt kê chỉ dẫn.
|
| 950 |
+
2. Không sao chép nguyên văn; hãy tổng hợp và diễn đạt lại.
|
| 951 |
+
3. Bài có tiêu đề, sapo, các đoạn phân tích/bối cảnh/tác động, và mục Nguồn tham khảo ngắn.
|
| 952 |
+
4. Không dùng các câu như "Dưới đây là", "Tôi sẽ", "Yêu cầu".
|
| 953 |
+
"""
|
| 954 |
+
text=None
|
| 955 |
+
try:
|
| 956 |
+
import asyncio
|
| 957 |
+
text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1700),timeout=35)
|
| 958 |
+
except Exception:
|
| 959 |
+
text=None
|
| 960 |
+
if not text or len(text)<350:
|
| 961 |
+
bullets='\n'.join([f"• {d.get('title','')}: {d.get('content','')[:320]}" for d in details[:6]])
|
| 962 |
+
vias=', '.join(sorted({d.get('via','') for d in details if d.get('via')}))
|
| 963 |
+
text=(f"{topic}: tổng hợp những điểm đáng chú ý\n\n"
|
| 964 |
+
f"{topic} đang được nhiều nguồn tin đề cập với các góc nhìn khác nhau. Dựa trên nội dung đã thu thập, có thể rút ra một số điểm chính để người đọc nắm nhanh bối cảnh.\n\n"
|
| 965 |
+
f"{bullets}\n\n"
|
| 966 |
+
f"Nhìn chung, chủ đề này cần được theo dõi thêm ở các khía cạnh: bối cảnh, tác động thực tế, phản ứng của các bên liên quan và các diễn biến mới trong thời gian tới.\n\n"
|
| 967 |
+
f"Nguồn tham khảo: {vias}")
|
| 968 |
+
text=_clean_generated_article(text,topic)
|
| 969 |
+
post=f5.base.make_post(topic,text,img,'','topic_clean_with_sources',sources=[s for s in sources if s.get('url')])
|
| 970 |
+
post['images']=[img]
|
| 971 |
+
post['source_details']=details[:8]
|
| 972 |
+
posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts)
|
| 973 |
+
return JSONResponse({'post':post,'mode':'clean_with_source_details','sources_count':len(details)})
|
| 974 |
+
|
| 975 |
+
FINAL6F_INJECT = """
|
| 976 |
+
<script>
|
| 977 |
+
(function(){
|
| 978 |
+
function escF(s){return String(s||'').replace(/[&<>\\"']/g,m=>({'&':'&','<':'<','>':'>','\\"':'"',"'":'''}[m]));}
|
| 979 |
+
window.readTopicSourceE=async function(url){
|
| 980 |
+
showView('view-article');
|
| 981 |
+
const el=document.getElementById('view-article');
|
| 982 |
+
el.innerHTML='<div class="loading">Đang tải nguồn...</div>';
|
| 983 |
+
try{
|
| 984 |
+
let data=await fetch('/api/topic_source_article?url='+encodeURIComponent(url)).then(r=>r.json());
|
| 985 |
+
if(!data||data.error||!data.body||!data.body.length){throw new Error('Không đọc được nguồn')}
|
| 986 |
+
let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><h1 class="article-title">${escF(data.title)}</h1>`;
|
| 987 |
+
if(data.summary)h+=`<div class="article-summary">${escF(data.summary)}</div>`;
|
| 988 |
+
if(data.og_image)h+=`<img class="article-img" src="${escF(data.og_image)}">`;
|
| 989 |
+
data.body.forEach(b=>{if(b.type==='p')h+=`<p class="article-p">${escF(b.text)}</p>`;else if(b.type==='heading')h+=`<h2 class="article-h2">${escF(b.text)}</h2>`;});
|
| 990 |
+
h+=`<div class="article-actions"><button onclick="window.open('${escF(url)}','_blank')">🔗 Mở gốc</button></div></div>`;
|
| 991 |
+
el.innerHTML=h;window.scrollTo(0,0);
|
| 992 |
+
}catch(e){el.innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="loading">Không đọc được nguồn.<br><a href="${escF(url)}" target="_blank" style="color:#5cb87a">Mở link gốc</a></div>`}
|
| 993 |
+
};
|
| 994 |
+
// Upgrade existing source detail boxes: replace external open behavior by in-app button.
|
| 995 |
+
function enhanceSourceButtons(){document.querySelectorAll('.source-detail-item a[href]').forEach(a=>{let u=a.getAttribute('href');if(!u||a.dataset.vnews)return;a.dataset.vnews='1';a.textContent='Xem trực tiếp trên VNEWS';a.setAttribute('href','javascript:void(0)');a.onclick=function(){readTopicSourceE(u);return false;};});}
|
| 996 |
+
setInterval(enhanceSourceButtons,1000);setTimeout(enhanceSourceButtons,500);
|
| 997 |
+
})();
|
| 998 |
+
</script>
|
| 999 |
+
"""
|
| 1000 |
+
|
| 1001 |
+
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
|
| 1002 |
+
@app.get('/')
|
| 1003 |
+
async def index_final6_clean_links():
|
| 1004 |
+
html=f5.f4.f3.f2.f1._load_index_html()
|
| 1005 |
+
body=getattr(rt.old,'PATCH_INJECT','')+f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT+FINAL6_INJECT+globals().get('FINAL6_FAST_HOME_INJECT','')+globals().get('FINAL6E_INJECT','')+FINAL6F_INJECT
|
| 1006 |
+
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|
| 1007 |
+
|
| 1008 |
+
|
| 1009 |
+
# ===== FINAL6G: SELECTED FAST SOURCES =====
|
| 1010 |
+
# Restrict hot hashtags + topic articles to the requested sources only:
|
| 1011 |
+
# Thethaovanhoa, Dantri, VTV, VnExpress, Vatvostudio, GenK, VNReview.
|
| 1012 |
+
SELECTED_SOURCE_FEEDS=[
|
| 1013 |
+
('VnExpress','https://vnexpress.net/rss/tin-moi-nhat.rss'),
|
| 1014 |
+
('VnExpress Thời sự','https://vnexpress.net/rss/thoi-su.rss'),
|
| 1015 |
+
('VnExpress Thế giới','https://vnexpress.net/rss/the-gioi.rss'),
|
| 1016 |
+
('VnExpress Kinh doanh','https://vnexpress.net/rss/kinh-doanh.rss'),
|
| 1017 |
+
('VnExpress Công nghệ','https://vnexpress.net/rss/so-hoa.rss'),
|
| 1018 |
+
('VnExpress Thể thao','https://vnexpress.net/rss/the-thao.rss'),
|
| 1019 |
+
('Dân trí','https://dantri.com.vn/rss/home.rss'),
|
| 1020 |
+
('Dân trí Xã hội','https://dantri.com.vn/rss/xa-hoi.rss'),
|
| 1021 |
+
('Dân trí Kinh doanh','https://dantri.com.vn/rss/kinh-doanh.rss'),
|
| 1022 |
+
('Dân trí Thể thao','https://dantri.com.vn/rss/the-thao.rss'),
|
| 1023 |
+
('Dân trí Công nghệ','https://dantri.com.vn/rss/suc-manh-so.rss'),
|
| 1024 |
+
('VTV','https://vtv.vn/rss/trang-chu.rss'),
|
| 1025 |
+
('VTV Thời sự','https://vtv.vn/rss/thoi-su.rss'),
|
| 1026 |
+
('VTV Công nghệ','https://vtv.vn/rss/cong-nghe.rss'),
|
| 1027 |
+
('Thể thao văn hóa','https://thethaovanhoa.vn/rss/home.rss'),
|
| 1028 |
+
('Thể thao văn hóa Bóng đá','https://thethaovanhoa.vn/rss/bong-da.rss'),
|
| 1029 |
+
('GenK','https://genk.vn/home.rss'),
|
| 1030 |
+
('GenK AI','https://genk.vn/ai.rss'),
|
| 1031 |
+
('VNReview','https://vnreview.vn/rss/home.rss'),
|
| 1032 |
+
('VNReview Công nghệ','https://vnreview.vn/rss/cong-nghe.rss'),
|
| 1033 |
+
]
|
| 1034 |
+
SELECTED_HOMEPAGES=[
|
| 1035 |
+
('VTV','https://vtv.vn/'),
|
| 1036 |
+
('Thể thao văn hóa','https://thethaovanhoa.vn/'),
|
| 1037 |
+
('GenK','https://genk.vn/'),
|
| 1038 |
+
('VNReview','https://vnreview.vn/'),
|
| 1039 |
+
('Vatvostudio','https://vatvostudio.vn/'),
|
| 1040 |
+
]
|
| 1041 |
+
SELECTED_DOMAINS=['vnexpress.net','dantri.com.vn','vtv.vn','thethaovanhoa.vn','genk.vn','vnreview.vn','vatvostudio.vn']
|
| 1042 |
+
|
| 1043 |
+
def _selected_fetch_rss(feed_name, feed_url, max_items=10):
|
| 1044 |
+
items=[]
|
| 1045 |
+
try:
|
| 1046 |
+
r=requests.get(feed_url,headers=UA,timeout=4);r.encoding='utf-8'
|
| 1047 |
+
if r.status_code>=400:return []
|
| 1048 |
+
soup=BeautifulSoup(r.text,'xml')
|
| 1049 |
+
for it in soup.find_all('item')[:max_items]:
|
| 1050 |
+
title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
|
| 1051 |
+
link=clean(it.find('link').get_text(strip=True) if it.find('link') else '')
|
| 1052 |
+
desc_raw=it.find('description').get_text(' ',strip=True) if it.find('description') else ''
|
| 1053 |
+
ds=BeautifulSoup(desc_raw,'lxml')
|
| 1054 |
+
desc=clean(ds.get_text(' ',strip=True))
|
| 1055 |
+
if title and link:
|
| 1056 |
+
items.append({'title':title,'url':link,'source':feed_name,'snippet':desc})
|
| 1057 |
+
except Exception:pass
|
| 1058 |
+
return items
|
| 1059 |
+
|
| 1060 |
+
def _selected_scrape_homepage(name, url, max_items=10):
|
| 1061 |
+
items=[];seen=set()
|
| 1062 |
+
try:
|
| 1063 |
+
r=requests.get(url,headers=UA,timeout=4);r.encoding='utf-8'
|
| 1064 |
+
if r.status_code>=400:return []
|
| 1065 |
+
soup=BeautifulSoup(r.text,'lxml')
|
| 1066 |
+
base=url.rstrip('/')
|
| 1067 |
+
for a in soup.find_all('a',href=True):
|
| 1068 |
+
href=a.get('href','').strip();title=clean(a.get('title','') or a.get_text(' ',strip=True))
|
| 1069 |
+
if not href or not title or len(title)<18:continue
|
| 1070 |
+
if href.startswith('/'):
|
| 1071 |
+
p=urlparse(url); href=f'{p.scheme}://{p.netloc}{href}'
|
| 1072 |
+
if not href.startswith('http') or href in seen:continue
|
| 1073 |
+
dom=_domain(href)
|
| 1074 |
+
if not any(d in dom for d in SELECTED_DOMAINS):continue
|
| 1075 |
+
if any(x in href.lower() for x in ['#','javascript:','facebook','youtube','tiktok']):continue
|
| 1076 |
+
seen.add(href)
|
| 1077 |
+
items.append({'title':title,'url':href,'source':name,'snippet':''})
|
| 1078 |
+
if len(items)>=max_items:break
|
| 1079 |
+
except Exception:pass
|
| 1080 |
+
return items
|
| 1081 |
+
|
| 1082 |
+
def _fast_rss_pool():
|
| 1083 |
+
now=time.time();key='selected_fast_pool'
|
| 1084 |
+
if key in _FAST_TOPIC_CACHE and now-_FAST_TOPIC_CACHE[key]['t']<600:return _FAST_TOPIC_CACHE[key]['d']
|
| 1085 |
+
pool=[];seen=set()
|
| 1086 |
+
# RSS first: fast and reliable.
|
| 1087 |
+
for name,url in SELECTED_SOURCE_FEEDS:
|
| 1088 |
+
for it in _selected_fetch_rss(name,url,10):
|
| 1089 |
+
if it['url'] not in seen:
|
| 1090 |
+
seen.add(it['url']);pool.append(it)
|
| 1091 |
+
# Homepage fallback for sources with weak/no RSS, especially Vatvostudio.
|
| 1092 |
+
for name,url in SELECTED_HOMEPAGES:
|
| 1093 |
+
for it in _selected_scrape_homepage(name,url,10):
|
| 1094 |
+
if it['url'] not in seen:
|
| 1095 |
+
seen.add(it['url']);pool.append(it)
|
| 1096 |
+
_FAST_TOPIC_CACHE[key]={'t':now,'d':pool}
|
| 1097 |
+
return pool
|
| 1098 |
+
|
| 1099 |
+
def _hot_topics():
|
| 1100 |
+
now=time.time()
|
| 1101 |
+
if _HOT_CACHE['d'] and now-_HOT_CACHE['t']<600:return _HOT_CACHE['d']
|
| 1102 |
+
pool=_fast_rss_pool()
|
| 1103 |
+
freq={};display={}
|
| 1104 |
+
for it in pool[:220]:
|
| 1105 |
+
title=re.sub(r'\s+-\s+.*$','',it.get('title',''))
|
| 1106 |
+
kws=[]
|
| 1107 |
+
for m in re.findall(r'([A-ZĐÀ-Ỹ][A-Za-zÀ-ỹ0-9]+(?:\s+[A-ZĐÀ-ỸA-Za-zÀ-ỹ0-9][A-Za-zÀ-ỹ0-9]+){1,4})',title):
|
| 1108 |
+
if len(m)>=6:kws.append(m)
|
| 1109 |
+
kws+=_keywords_from_title(title)
|
| 1110 |
+
for kw in kws[:5]:
|
| 1111 |
+
words=[w for w in clean(kw).split() if w.lower() not in STOP_WORDS]
|
| 1112 |
+
if len(words)<2:continue
|
| 1113 |
+
kw=' '.join(words[:5])
|
| 1114 |
+
if len(kw)<6 or len(kw)>55:continue
|
| 1115 |
+
key=kw.lower();freq[key]=freq.get(key,0)+1;display[key]=kw
|
| 1116 |
+
topics=[];seen=set()
|
| 1117 |
+
for key,_ in sorted(freq.items(),key=lambda x:x[1],reverse=True):
|
| 1118 |
+
kw=display[key]
|
| 1119 |
+
if key in seen:continue
|
| 1120 |
+
seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
|
| 1121 |
+
if len(topics)>=24:break
|
| 1122 |
+
for kw in ['AI tại Việt Nam','Công nghệ Việt Nam','VTV thời sự','VnExpress kinh doanh','Dân trí xã hội','GenK AI','VNReview công nghệ','Vatvostudio smartphone','Thể thao văn hóa World Cup']:
|
| 1123 |
+
if kw.lower() not in seen:topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
|
| 1124 |
+
_HOT_CACHE.update({'t':now,'d':topics[:24]})
|
| 1125 |
+
return _HOT_CACHE['d']
|
| 1126 |
+
|
| 1127 |
+
def _candidate_urls(topic):
|
| 1128 |
+
seen=set();items=[]
|
| 1129 |
+
scored=[]
|
| 1130 |
+
for it in _fast_rss_pool():
|
| 1131 |
+
sc=_fast_score(topic,it)
|
| 1132 |
+
if sc>0:scored.append((sc,it))
|
| 1133 |
+
for sc,it in sorted(scored,key=lambda x:(x[0],len(x[1].get('snippet',''))),reverse=True)[:14]:
|
| 1134 |
+
if it['url'] not in seen:
|
| 1135 |
+
seen.add(it['url']);items.append(it)
|
| 1136 |
+
# Search only selected sources when RSS lacks matches.
|
| 1137 |
+
if len(items)<6:
|
| 1138 |
+
for dom in SELECTED_DOMAINS:
|
| 1139 |
+
for it in _ddg_search(f'{topic} site:{dom}',4):
|
| 1140 |
+
if it['url'] not in seen:
|
| 1141 |
+
seen.add(it['url']);items.append(it)
|
| 1142 |
+
if len(items)>=12:break
|
| 1143 |
+
return items[:20]
|
| 1144 |
+
|
| 1145 |
+
|
| 1146 |
+
# ===== FINAL6G: SOURCE-LIMITED FAST TOPICS AND FAST HOME =====
|
| 1147 |
+
# Limit hot hashtags/topic context to requested sources and make homepage APIs return quickly.
|
| 1148 |
+
_SOURCE_FEEDS = [
|
| 1149 |
+
('VnExpress','https://vnexpress.net/rss/tin-moi-nhat.rss','vnexpress.net'),
|
| 1150 |
+
('VnExpress Thời sự','https://vnexpress.net/rss/thoi-su.rss','vnexpress.net'),
|
| 1151 |
+
('VnExpress Thế giới','https://vnexpress.net/rss/the-gioi.rss','vnexpress.net'),
|
| 1152 |
+
('VnExpress Kinh doanh','https://vnexpress.net/rss/kinh-doanh.rss','vnexpress.net'),
|
| 1153 |
+
('VnExpress Công nghệ','https://vnexpress.net/rss/so-hoa.rss','vnexpress.net'),
|
| 1154 |
+
('VnExpress Thể thao','https://vnexpress.net/rss/the-thao.rss','vnexpress.net'),
|
| 1155 |
+
('Dân trí','https://dantri.com.vn/rss/home.rss','dantri.com.vn'),
|
| 1156 |
+
('Dân trí Xã hội','https://dantri.com.vn/rss/xa-hoi.rss','dantri.com.vn'),
|
| 1157 |
+
('Dân trí Kinh doanh','https://dantri.com.vn/rss/kinh-doanh.rss','dantri.com.vn'),
|
| 1158 |
+
('Dân trí Thể thao','https://dantri.com.vn/rss/the-thao.rss','dantri.com.vn'),
|
| 1159 |
+
('Dân trí Công nghệ','https://dantri.com.vn/rss/suc-manh-so.rss','dantri.com.vn'),
|
| 1160 |
+
('VTV','https://vtv.vn/rss/trang-chu.rss','vtv.vn'),
|
| 1161 |
+
('VTV Thời sự','https://vtv.vn/rss/thoi-su.rss','vtv.vn'),
|
| 1162 |
+
('GenK','https://genk.vn/rss/home.rss','genk.vn'),
|
| 1163 |
+
('GenK AI','https://genk.vn/ai.rss','genk.vn'),
|
| 1164 |
+
('VnReview','https://vnreview.vn/rss/tin-moi-nhat.rss','vnreview.vn'),
|
| 1165 |
+
('VnReview Công nghệ','https://vnreview.vn/rss/cong-nghe.rss','vnreview.vn'),
|
| 1166 |
+
('Vật Vờ Studio','https://vatvostudio.vn/feed/','vatvostudio.vn'),
|
| 1167 |
+
('Thể thao văn hóa','https://thethaovanhoa.vn/rss/home.rss','thethaovanhoa.vn'),
|
| 1168 |
+
('Thể thao văn hóa World Cup','https://thethaovanhoa.vn/rss/world-cup-2026.rss','thethaovanhoa.vn'),
|
| 1169 |
+
]
|
| 1170 |
+
_SOURCE_CACHE={'t':0,'items':[]}
|
| 1171 |
+
_FAST_ROUTE_CACHE={}
|
| 1172 |
+
|
| 1173 |
+
def _feed_items_source_limited(max_per_feed=10):
|
| 1174 |
+
now=time.time()
|
| 1175 |
+
if _SOURCE_CACHE['items'] and now-_SOURCE_CACHE['t']<600:return _SOURCE_CACHE['items']
|
| 1176 |
+
items=[];seen=set()
|
| 1177 |
+
def one(feed):
|
| 1178 |
+
name,url,dom=feed;out=[]
|
| 1179 |
+
try:
|
| 1180 |
+
r=requests.get(url,headers=UA,timeout=3.5);r.encoding='utf-8'
|
| 1181 |
+
soup=BeautifulSoup(r.text,'xml')
|
| 1182 |
+
for it in soup.find_all('item')[:max_per_feed*2]:
|
| 1183 |
+
title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
|
| 1184 |
+
link=clean(it.find('link').get_text(strip=True) if it.find('link') else '')
|
| 1185 |
+
desc_raw=it.find('description').get_text(' ',strip=True) if it.find('description') else ''
|
| 1186 |
+
ds=BeautifulSoup(desc_raw,'lxml')
|
| 1187 |
+
img='';im=ds.find('img')
|
| 1188 |
+
if im:img=im.get('src','') or im.get('data-src','')
|
| 1189 |
+
desc=clean(ds.get_text(' ',strip=True))[:700]
|
| 1190 |
+
if title and link:
|
| 1191 |
+
out.append({'title':title,'url':link,'link':link,'source':name,'via':name,'domain':dom,'snippet':desc,'img':img})
|
| 1192 |
+
if len(out)>=max_per_feed:break
|
| 1193 |
+
except Exception:pass
|
| 1194 |
+
return out
|
| 1195 |
+
try:
|
| 1196 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 1197 |
+
with ThreadPoolExecutor(max_workers=8) as ex:
|
| 1198 |
+
futs=[ex.submit(one,f) for f in _SOURCE_FEEDS]
|
| 1199 |
+
for f in as_completed(futs,timeout=5.5):
|
| 1200 |
+
try:
|
| 1201 |
+
for it in f.result() or []:
|
| 1202 |
+
if it['url'] not in seen:
|
| 1203 |
+
seen.add(it['url']);items.append(it)
|
| 1204 |
+
except Exception:pass
|
| 1205 |
+
except Exception:
|
| 1206 |
+
for f in _SOURCE_FEEDS[:8]:
|
| 1207 |
+
for it in one(f):
|
| 1208 |
+
if it['url'] not in seen:
|
| 1209 |
+
seen.add(it['url']);items.append(it)
|
| 1210 |
+
_SOURCE_CACHE.update({'t':now,'items':items})
|
| 1211 |
+
return items
|
| 1212 |
+
|
| 1213 |
+
def _score_topic_source(topic,it):
|
| 1214 |
+
toks=[w.lower() for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',topic or '') if len(w)>1 and w.lower() not in STOP_WORDS]
|
| 1215 |
+
hay=(it.get('title','')+' '+it.get('snippet','')+' '+it.get('source','')).lower()
|
| 1216 |
+
if not toks:return 0
|
| 1217 |
+
score=sum((3 if len(t)>3 else 1) for t in toks if t in hay)
|
| 1218 |
+
if topic.lower().strip() and topic.lower().strip() in hay:score+=12
|
| 1219 |
+
return score
|
| 1220 |
+
|
| 1221 |
+
def _hot_topics():
|
| 1222 |
+
now=time.time()
|
| 1223 |
+
if _HOT_CACHE['d'] and now-_HOT_CACHE['t']<600:return _HOT_CACHE['d']
|
| 1224 |
+
freq={};display={}
|
| 1225 |
+
for it in _feed_items_source_limited(8)[:180]:
|
| 1226 |
+
title=re.sub(r'\s+-\s+.*$','',it.get('title',''))
|
| 1227 |
+
kws=[]
|
| 1228 |
+
for m in re.findall(r'([A-ZĐÀ-Ỹ][A-Za-zÀ-ỹ0-9]+(?:\s+[A-ZĐÀ-ỸA-Za-zÀ-ỹ0-9][A-Za-zÀ-ỹ0-9]+){1,4})',title):
|
| 1229 |
+
if len(m)>=6:kws.append(m)
|
| 1230 |
+
kws += _keywords_from_title(title)
|
| 1231 |
+
for kw in kws[:4]:
|
| 1232 |
+
words=[w for w in clean(kw).split() if w.lower() not in STOP_WORDS]
|
| 1233 |
+
if len(words)<2:continue
|
| 1234 |
+
kw=' '.join(words[:5])
|
| 1235 |
+
if 6<=len(kw)<=55:
|
| 1236 |
+
key=kw.lower();freq[key]=freq.get(key,0)+1;display[key]=kw
|
| 1237 |
+
topics=[]
|
| 1238 |
+
for key,_ in sorted(freq.items(),key=lambda x:x[1],reverse=True):
|
| 1239 |
+
kw=display[key];topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
|
| 1240 |
+
if len(topics)>=24:break
|
| 1241 |
+
for kw in ['Giá vàng trong nước','AI tại Việt Nam','Bóng đá Việt Nam','Kinh tế Việt Nam','Công nghệ AI','Vật Vờ Studio','World Cup 2026','Sức khỏe cộng đồng']:
|
| 1242 |
+
if not any(t['topic'].lower()==kw.lower() for t in topics):topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
|
| 1243 |
+
_HOT_CACHE.update({'t':now,'d':topics[:24]})
|
| 1244 |
+
return _HOT_CACHE['d']
|
| 1245 |
+
|
| 1246 |
+
def _fast_context(topic):
|
| 1247 |
+
now=time.time();key='source_limited_ctx:'+topic.lower().strip()
|
| 1248 |
+
if key in _FAST_TOPIC_CACHE and now-_FAST_TOPIC_CACHE[key]['t']<600:return _FAST_TOPIC_CACHE[key]['d']
|
| 1249 |
+
pool=_feed_items_source_limited(12)
|
| 1250 |
+
scored=[]
|
| 1251 |
+
for it in pool:
|
| 1252 |
+
sc=_score_topic_source(topic,it)
|
| 1253 |
+
if sc>0:scored.append((sc,it))
|
| 1254 |
+
if not scored:
|
| 1255 |
+
# Try broader matching by first token only before giving up.
|
| 1256 |
+
first=(_fast_topic_tokens(topic) or [''])[0]
|
| 1257 |
+
if first:
|
| 1258 |
+
for it in pool:
|
| 1259 |
+
if first in (it.get('title','')+' '+it.get('snippet','')).lower():scored.append((1,it))
|
| 1260 |
+
picked=[it for sc,it in sorted(scored,key=lambda x:(x[0],len(x[1].get('snippet',''))),reverse=True)[:8]]
|
| 1261 |
+
if not picked:picked=pool[:6]
|
| 1262 |
+
blocks=[];src=[]
|
| 1263 |
+
for it in picked:
|
| 1264 |
+
content=it.get('snippet','') or it.get('title','')
|
| 1265 |
+
blocks.append(f"NGUỒN: {it.get('source','')}\nTIÊU ĐỀ: {it.get('title','')}\nTÓM TẮT RSS:\n{content}")
|
| 1266 |
+
src.append({'title':it.get('title',''),'url':it.get('url',''),'via':it.get('source',''),'snippet':content})
|
| 1267 |
+
data={'context':'\n\n---\n\n'.join(blocks),'sources':src,'count':len(blocks)}
|
| 1268 |
+
_FAST_TOPIC_CACHE[key]={'t':now,'d':data}
|
| 1269 |
+
return data
|
| 1270 |
+
|
| 1271 |
+
# Override slow search functions to never crawl open web during topic generation.
|
| 1272 |
+
def _web_research_context(topic):
|
| 1273 |
+
return _fast_context(topic)
|
| 1274 |
+
|
| 1275 |
+
def _candidate_urls(topic):
|
| 1276 |
+
return _fast_context(topic).get('sources',[])
|
| 1277 |
+
|
| 1278 |
+
# Fast homepage endpoints from requested source RSS; no slow HTML scrapers.
|
| 1279 |
+
def _fast_homepage_sources():
|
| 1280 |
+
now=time.time();key='home_sources'
|
| 1281 |
+
if key in _FAST_ROUTE_CACHE and now-_FAST_ROUTE_CACHE[key]['t']<600:return _FAST_ROUTE_CACHE[key]['d']
|
| 1282 |
+
groups=[];seen=set()
|
| 1283 |
+
group_map=[('Tin mới','https://vnexpress.net/rss/tin-moi-nhat.rss','vne'),('Thời Sự','https://vnexpress.net/rss/thoi-su.rss','vne'),('Kinh Doanh','https://vnexpress.net/rss/kinh-doanh.rss','vne'),('Công Nghệ','https://vnexpress.net/rss/so-hoa.rss','vne'),('Dân Trí','https://dantri.com.vn/rss/home.rss','dantri'),('GenK','https://genk.vn/rss/home.rss','genk'),('VnReview','https://vnreview.vn/rss/tin-moi-nhat.rss','vnreview')]
|
| 1284 |
+
for g,u,s in group_map:
|
| 1285 |
+
for it in _rss_articles_fast(u,g,s,6) if '_rss_articles_fast' in globals() else []:
|
| 1286 |
+
if it['link'] not in seen:
|
| 1287 |
+
seen.add(it['link']);groups.append(it)
|
| 1288 |
+
_FAST_ROUTE_CACHE[key]={'t':now,'d':groups}
|
| 1289 |
+
return groups
|
| 1290 |
+
|
| 1291 |
+
for _p in ['/api/homepage','/api/dantri_hot','/api/vne_video','/api/highlights','/api/hot_topics','/api/topic_sources']:
|
| 1292 |
+
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)==_p and 'GET' in getattr(r,'methods',set()))]
|
| 1293 |
+
@app.get('/api/homepage')
|
| 1294 |
+
def api_homepage_source_fast():return JSONResponse(_fast_homepage_sources())
|
| 1295 |
+
@app.get('/api/dantri_hot')
|
| 1296 |
+
def api_dantri_hot_source_fast():
|
| 1297 |
+
data=[{**it,'source':'dantri','link':it.get('url') or it.get('link')} for it in _feed_items_source_limited(8) if it.get('domain')=='dantri.com.vn'][:12]
|
| 1298 |
+
return JSONResponse(data)
|
| 1299 |
+
@app.get('/api/vne_video')
|
| 1300 |
+
def api_vne_video_source_fast():
|
| 1301 |
+
return JSONResponse([]) # do not block homepage if VnEgo is slow
|
| 1302 |
+
@app.get('/api/highlights')
|
| 1303 |
+
def api_highlights_source_fast():return JSONResponse([])
|
| 1304 |
+
@app.get('/api/hot_topics')
|
| 1305 |
+
def api_hot_topics_source_fast():return JSONResponse({'topics':_hot_topics(),'sources':'vn_only'})
|
| 1306 |
+
@app.get('/api/topic_sources')
|
| 1307 |
+
def api_topic_sources_source_fast(topic:str=Query(...)):
|
| 1308 |
+
data=_fast_context(clean(topic));return JSONResponse({'count':data.get('count',0),'sources':data.get('sources',[]),'has_context':bool(data.get('context')),'mode':'source_limited_rss'})
|
| 1309 |
+
|
| 1310 |
+
# Override root: include all existing UI but add a script that prevents forced shorts refresh on initial load.
|
| 1311 |
+
ROOT_FAST_INJECT="""
|
| 1312 |
+
<script>
|
| 1313 |
+
(function(){
|
| 1314 |
+
const oldFetch=window.fetch;window.__allowShortRefresh=false;
|
| 1315 |
+
window.fetch=function(url,opts){try{let u=String(url||'');if(u.includes('/api/shorts?refresh=1')&&!window.__allowShortRefresh)url='/api/shorts';}catch(e){}return oldFetch.call(this,url,opts)};
|
| 1316 |
+
setTimeout(()=>{window.__allowShortRefresh=true;},8000);
|
| 1317 |
+
})();
|
| 1318 |
+
</script>
|
| 1319 |
+
"""
|
| 1320 |
+
app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
|
| 1321 |
+
@app.get('/')
|
| 1322 |
+
async def index_final_fast_sources():
|
| 1323 |
+
html=f5.f4.f3.f2.f1._load_index_html()
|
| 1324 |
+
body=getattr(rt.old,'PATCH_INJECT','')+f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT+FINAL6_INJECT+globals().get('FINAL6_FAST_HOME_INJECT','')+globals().get('FINAL6E_INJECT','')+globals().get('FINAL6F_INJECT','')+ROOT_FAST_INJECT
|
| 1325 |
+
return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
|
ai_runtime_fix.py
DELETED
|
@@ -1,394 +0,0 @@
|
|
| 1 |
-
"""VNEWS Short Video Fix - standalone module with clean registration.
|
| 2 |
-
This module MUST be imported LAST to register /api/ai/short endpoints.
|
| 3 |
-
FIX v1: No route filtering issues - registers endpoints unconditionally.
|
| 4 |
-
FIX v2: SSE inline endpoint for auto homepage updates
|
| 5 |
-
"""
|
| 6 |
-
import os
|
| 7 |
-
import re
|
| 8 |
-
import time
|
| 9 |
-
import json
|
| 10 |
-
import sys
|
| 11 |
-
import logging
|
| 12 |
-
import asyncio
|
| 13 |
-
import hashlib
|
| 14 |
-
import subprocess
|
| 15 |
-
import requests
|
| 16 |
-
from datetime import datetime, timezone, timedelta
|
| 17 |
-
from urllib.parse import urlparse
|
| 18 |
-
from fastapi import Request, Query
|
| 19 |
-
from fastapi.responses import JSONResponse, FileResponse
|
| 20 |
-
|
| 21 |
-
# Import dependencies
|
| 22 |
-
try:
|
| 23 |
-
import ai_ext as base
|
| 24 |
-
except ImportError:
|
| 25 |
-
import ai_runtime_final6 as base
|
| 26 |
-
|
| 27 |
-
# Try to import app from various sources
|
| 28 |
-
try:
|
| 29 |
-
from app_v2_entry import app
|
| 30 |
-
except ImportError:
|
| 31 |
-
try:
|
| 32 |
-
from main import app
|
| 33 |
-
except ImportError:
|
| 34 |
-
from ai_runtime_final6 import app
|
| 35 |
-
|
| 36 |
-
_log = logging.getLogger("short_fix")
|
| 37 |
-
_log.setLevel(logging.INFO)
|
| 38 |
-
if not _log.handlers:
|
| 39 |
-
_log.addHandler(logging.StreamHandler(sys.stderr))
|
| 40 |
-
|
| 41 |
-
DATA_DIR = "/data" if os.path.isdir("/data") else "/app/data"
|
| 42 |
-
os.makedirs(DATA_DIR, exist_ok=True)
|
| 43 |
-
SHORTS_DIR = os.path.join(DATA_DIR, "ai_shorts")
|
| 44 |
-
os.makedirs(SHORTS_DIR, exist_ok=True)
|
| 45 |
-
|
| 46 |
-
# ===== VIETNAMESE FONT DETECTION =====
|
| 47 |
-
_VN_FONT_REG = None
|
| 48 |
-
_VN_FONT_BOLD = None
|
| 49 |
-
|
| 50 |
-
def _get_vn_fonts():
|
| 51 |
-
"""Find Vietnamese-supporting fonts."""
|
| 52 |
-
global _VN_FONT_REG, _VN_FONT_BOLD
|
| 53 |
-
if _VN_FONT_REG is not None:
|
| 54 |
-
return _VN_FONT_REG, _VN_FONT_BOLD
|
| 55 |
-
|
| 56 |
-
try:
|
| 57 |
-
from PIL import ImageFont
|
| 58 |
-
except Exception:
|
| 59 |
-
_log.error("PIL not available!")
|
| 60 |
-
return None, None
|
| 61 |
-
|
| 62 |
-
# Priority: Noto > DejaVu > Liberation
|
| 63 |
-
reg_paths = [
|
| 64 |
-
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
|
| 65 |
-
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
| 66 |
-
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
| 67 |
-
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
|
| 68 |
-
]
|
| 69 |
-
bold_paths = [
|
| 70 |
-
"/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf",
|
| 71 |
-
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
| 72 |
-
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
|
| 73 |
-
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
|
| 74 |
-
]
|
| 75 |
-
|
| 76 |
-
for path in reg_paths:
|
| 77 |
-
if os.path.exists(path):
|
| 78 |
-
try:
|
| 79 |
-
_VN_FONT_REG = ImageFont.truetype(path, 40)
|
| 80 |
-
_log.info(f"Found regular font: {path}")
|
| 81 |
-
break
|
| 82 |
-
except:
|
| 83 |
-
continue
|
| 84 |
-
|
| 85 |
-
for path in bold_paths:
|
| 86 |
-
if os.path.exists(path):
|
| 87 |
-
try:
|
| 88 |
-
_VN_FONT_BOLD = ImageFont.truetype(path, 52)
|
| 89 |
-
_log.info(f"Found bold font: {path}")
|
| 90 |
-
break
|
| 91 |
-
except:
|
| 92 |
-
continue
|
| 93 |
-
|
| 94 |
-
if _VN_FONT_REG is None:
|
| 95 |
-
_VN_FONT_REG = ImageFont.load_default()
|
| 96 |
-
if _VN_FONT_BOLD is None:
|
| 97 |
-
_VN_FONT_BOLD = _VN_FONT_REG
|
| 98 |
-
|
| 99 |
-
return _VN_FONT_REG, _VN_FONT_BOLD
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
def _clean(s):
|
| 103 |
-
import html as html_lib
|
| 104 |
-
return re.sub(r"\s+", " ", html_lib.unescape(str(s or ""))).strip()
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
# ===== ROBUST TEXT SEGMENTATION =====
|
| 108 |
-
def _split_into_segments(text, max_segments=10, min_len=30):
|
| 109 |
-
"""Split text into segments - multi strategy."""
|
| 110 |
-
text = _clean(text)
|
| 111 |
-
if not text:
|
| 112 |
-
return []
|
| 113 |
-
|
| 114 |
-
# Strategy 1: bullet points
|
| 115 |
-
lines = text.split('\n')
|
| 116 |
-
segmented = []
|
| 117 |
-
for line in lines:
|
| 118 |
-
line = _clean(line)
|
| 119 |
-
line_bare = re.sub(r'^[•\-\*\d\.\)\s]+', '', line).strip()
|
| 120 |
-
if len(line_bare) > min_len:
|
| 121 |
-
segmented.append(line_bare)
|
| 122 |
-
elif len(line) > min_len:
|
| 123 |
-
segmented.append(line)
|
| 124 |
-
|
| 125 |
-
# Strategy 2: sentences (Vietnamese)
|
| 126 |
-
if len(segmented) < 2:
|
| 127 |
-
sents = re.split(r'(?<=[.!?])\s+(?=[A-Z0-9À-ỸĐ])', text)
|
| 128 |
-
segmented = [s for s in sents if len(_clean(s)) > min_len]
|
| 129 |
-
|
| 130 |
-
# Strategy 3: character chunks
|
| 131 |
-
if not segmented:
|
| 132 |
-
words = text.split()
|
| 133 |
-
for i in range(0, min(len(words), max_segments * 20), 20):
|
| 134 |
-
chunk = ' '.join(words[i:i+20])
|
| 135 |
-
if len(chunk) > min_len:
|
| 136 |
-
segmented.append(chunk)
|
| 137 |
-
|
| 138 |
-
# Strategy 4: fallback
|
| 139 |
-
if not segmented:
|
| 140 |
-
segmented = [text[:300]]
|
| 141 |
-
|
| 142 |
-
return segmented[:max_segments]
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
# ===== SHORT VIDEO GENERATOR =====
|
| 146 |
-
def _gen_short_core(post, work_dir):
|
| 147 |
-
"""Core short generation - returns video path or None."""
|
| 148 |
-
post_id = post.get('id', '')
|
| 149 |
-
text = post.get('text', '') or post.get('title', '')
|
| 150 |
-
|
| 151 |
-
if not post_id or len(text) < 100:
|
| 152 |
-
_log.error(f"Invalid post: id={post_id}, text_len={len(text)}")
|
| 153 |
-
return None
|
| 154 |
-
|
| 155 |
-
segments = _split_into_segments(text, max_segments=10, min_len=30)
|
| 156 |
-
if not segments:
|
| 157 |
-
_log.error("No segments generated")
|
| 158 |
-
return None
|
| 159 |
-
|
| 160 |
-
_log.info(f"Generating short: {len(segments)} segments")
|
| 161 |
-
|
| 162 |
-
seg_hash = hashlib.md5(('|'.join(segments) + 'nu').encode()).hexdigest()[:8]
|
| 163 |
-
suffix = f"_nu_{seg_hash}"
|
| 164 |
-
out_mp4 = os.path.join(work_dir, f"{post_id}{suffix}.mp4")
|
| 165 |
-
|
| 166 |
-
if os.path.exists(out_mp4):
|
| 167 |
-
_log.info(f"Already exists: {out_mp4}")
|
| 168 |
-
return out_mp4
|
| 169 |
-
|
| 170 |
-
# Check dependencies
|
| 171 |
-
try:
|
| 172 |
-
subprocess.run(['ffmpeg', '-version'], capture_output=True, timeout=5)
|
| 173 |
-
except Exception as e:
|
| 174 |
-
_log.error(f"ffmpeg missing: {e}")
|
| 175 |
-
return None
|
| 176 |
-
|
| 177 |
-
# Download image
|
| 178 |
-
img_path = os.path.join(work_dir, 'bg.jpg')
|
| 179 |
-
downloaded = False
|
| 180 |
-
try:
|
| 181 |
-
img_url = post.get('img', '')
|
| 182 |
-
if img_url and img_url.startswith('http'):
|
| 183 |
-
r = requests.get(img_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=12)
|
| 184 |
-
if r.status_code == 200:
|
| 185 |
-
with open(img_path, 'wb') as f:
|
| 186 |
-
f.write(r.content)
|
| 187 |
-
downloaded = True
|
| 188 |
-
except Exception as e:
|
| 189 |
-
_log.warning(f"Image download: {e}")
|
| 190 |
-
|
| 191 |
-
try:
|
| 192 |
-
from PIL import Image, ImageDraw
|
| 193 |
-
has_pil = True
|
| 194 |
-
except:
|
| 195 |
-
has_pil = False
|
| 196 |
-
_log.warning("PIL not available")
|
| 197 |
-
|
| 198 |
-
try:
|
| 199 |
-
from gtts import gTTS
|
| 200 |
-
has_tts = True
|
| 201 |
-
except:
|
| 202 |
-
has_tts = False
|
| 203 |
-
_log.warning("gTTS not available")
|
| 204 |
-
|
| 205 |
-
parts = []
|
| 206 |
-
|
| 207 |
-
for i, seg in enumerate(segments[:10]):
|
| 208 |
-
frame = os.path.join(work_dir, f'frame_{i}.jpg')
|
| 209 |
-
audio = os.path.join(work_dir, f'audio_{i}.mp3')
|
| 210 |
-
part = os.path.join(work_dir, f'part_{i}.mp4')
|
| 211 |
-
|
| 212 |
-
# Create frame
|
| 213 |
-
try:
|
| 214 |
-
if has_pil:
|
| 215 |
-
_make_frame(post, seg, img_path, downloaded, frame)
|
| 216 |
-
else:
|
| 217 |
-
subprocess.run(['ffmpeg', '-y', '-f', 'lavfi', '-i',
|
| 218 |
-
'color=c=black:s=1080x1920:d=1', '-frames:v', '1', frame],
|
| 219 |
-
capture_output=True, timeout=20)
|
| 220 |
-
except Exception as e:
|
| 221 |
-
_log.error(f"Frame error: {e}")
|
| 222 |
-
continue
|
| 223 |
-
|
| 224 |
-
# Create audio
|
| 225 |
-
if has_tts:
|
| 226 |
-
try:
|
| 227 |
-
tts = _clean(seg)[:300]
|
| 228 |
-
gTTS(tts, lang='vi', slow=False).save(audio)
|
| 229 |
-
except Exception as e:
|
| 230 |
-
_log.warning(f"TTS error: {e}")
|
| 231 |
-
audio = None
|
| 232 |
-
|
| 233 |
-
# Combine
|
| 234 |
-
dur = 10
|
| 235 |
-
try:
|
| 236 |
-
cmd = ['ffmpeg', '-y', '-loop', '1', '-t', str(dur), '-i', frame]
|
| 237 |
-
if has_tts and os.path.exists(audio):
|
| 238 |
-
cmd += ['-i', audio, '-shortest']
|
| 239 |
-
else:
|
| 240 |
-
cmd += ['-f', 'lavfi', '-i', 'anullsrc', '-shortest']
|
| 241 |
-
cmd += ['-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p',
|
| 242 |
-
'-c:a', 'aac', '-b:a', '128k', part]
|
| 243 |
-
subprocess.run(cmd, capture_output=True, timeout=120)
|
| 244 |
-
if os.path.exists(part) and os.path.getsize(part) > 5000:
|
| 245 |
-
parts.append(part)
|
| 246 |
-
except Exception as e:
|
| 247 |
-
_log.error(f"Part combine error: {e}")
|
| 248 |
-
|
| 249 |
-
if not parts:
|
| 250 |
-
_log.error("No video parts created!")
|
| 251 |
-
return None
|
| 252 |
-
|
| 253 |
-
# Concatenate
|
| 254 |
-
try:
|
| 255 |
-
concat = os.path.join(work_dir, 'list.txt')
|
| 256 |
-
with open(concat, 'w') as f:
|
| 257 |
-
for p in parts:
|
| 258 |
-
f.write(f"file '{p}'\n")
|
| 259 |
-
subprocess.run(['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', concat, '-c', 'copy', out_mp4],
|
| 260 |
-
capture_output=True, timeout=180)
|
| 261 |
-
_log.info(f"Short created: {out_mp4}")
|
| 262 |
-
return out_mp4
|
| 263 |
-
except Exception as e:
|
| 264 |
-
_log.error(f"Concat error: {e}")
|
| 265 |
-
return None
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
def _make_frame(post, text, img_path, downloaded, out_path):
|
| 269 |
-
"""Create video frame with Vietnamese font."""
|
| 270 |
-
from PIL import Image, ImageDraw
|
| 271 |
-
_get_vn_fonts()
|
| 272 |
-
|
| 273 |
-
W, H = 1080, 1920
|
| 274 |
-
bg = Image.new('RGB', (W, H), (15, 23, 38))
|
| 275 |
-
d = ImageDraw.Draw(bg)
|
| 276 |
-
|
| 277 |
-
# Background image
|
| 278 |
-
if downloaded and os.path.exists(img_path):
|
| 279 |
-
try:
|
| 280 |
-
im = Image.open(img_path).convert('RGB')
|
| 281 |
-
im = im.resize((W, 760))
|
| 282 |
-
bg.paste(im, (0, 0))
|
| 283 |
-
except:
|
| 284 |
-
pass
|
| 285 |
-
|
| 286 |
-
# Title
|
| 287 |
-
d.rectangle([0, 0, W, 100], fill=(25, 118, 210))
|
| 288 |
-
ttl = post.get('title', '')[:50]
|
| 289 |
-
if _VN_FONT_BOLD:
|
| 290 |
-
d.text((W//2, 50), ttl, fill='white', font=_VN_FONT_BOLD, anchor='mm')
|
| 291 |
-
|
| 292 |
-
# Content
|
| 293 |
-
y = 150
|
| 294 |
-
for ln in _wrap_text(d, text[:200], _VN_FONT_REG, 80, 920, 10):
|
| 295 |
-
d.text((80, y), ln, fill='white', font=_VN_FONT_REG)
|
| 296 |
-
y += 55
|
| 297 |
-
|
| 298 |
-
bg.save(out_path, quality=85)
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
def _wrap_text(draw, text, font, x, max_w, max_lines):
|
| 302 |
-
"""Word wrap text."""
|
| 303 |
-
words = text.split()
|
| 304 |
-
lines = []
|
| 305 |
-
cur = []
|
| 306 |
-
for w in words:
|
| 307 |
-
test = ' '.join(cur + [w])
|
| 308 |
-
try:
|
| 309 |
-
w_px = draw.textbbox((0, 0), test, font=font)[2]
|
| 310 |
-
except:
|
| 311 |
-
w_px = len(test) * 22
|
| 312 |
-
if w_px <= max_w:
|
| 313 |
-
cur.append(w)
|
| 314 |
-
else:
|
| 315 |
-
if cur:
|
| 316 |
-
lines.append(' '.join(cur))
|
| 317 |
-
cur = [w]
|
| 318 |
-
if len(lines) >= max_lines:
|
| 319 |
-
break
|
| 320 |
-
if cur and len(lines) < max_lines:
|
| 321 |
-
lines.append(' '.join(cur))
|
| 322 |
-
return lines
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
def _gen_short_sync(post) -> str:
|
| 326 |
-
"""Sync wrapper - returns video URL."""
|
| 327 |
-
work = os.path.join(SHORTS_DIR, f"work_{post.get('id', int(time.time()))}")
|
| 328 |
-
os.makedirs(work, exist_ok=True)
|
| 329 |
-
result = _gen_short_core(post, work)
|
| 330 |
-
if result:
|
| 331 |
-
# Update wall
|
| 332 |
-
try:
|
| 333 |
-
wall = base._load_ai_wall()
|
| 334 |
-
for i, p in enumerate(wall):
|
| 335 |
-
if str(p.get('id')) == str(post.get('id')):
|
| 336 |
-
p['video'] = f'/api/ai/short-file/{post.get("id")}_nu_{hashlib.md5(str(post).encode()).hexdigest()[:8]}'
|
| 337 |
-
wall[i] = p
|
| 338 |
-
break
|
| 339 |
-
base._save_ai_wall(wall)
|
| 340 |
-
# Notify SSE for auto-update
|
| 341 |
-
try:
|
| 342 |
-
from auto_update_sse import notify_new_short
|
| 343 |
-
notify_new_short(post)
|
| 344 |
-
except:
|
| 345 |
-
pass
|
| 346 |
-
except Exception as e:
|
| 347 |
-
_log.warning(f"Wall update: {e}")
|
| 348 |
-
return result
|
| 349 |
-
return ''
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
# ===== REGISTER ENDPOINTS - MUST BE AT MODULE LEVEL =====
|
| 353 |
-
@app.post('/api/ai/short/{post_id}')
|
| 354 |
-
async def api_short_generate(post_id: str, request: Request):
|
| 355 |
-
_log.info(f"POST /api/ai/short/{post_id}")
|
| 356 |
-
wall = base._load_ai_wall()
|
| 357 |
-
post = next((p for p in wall if str(p.get('id')) == str(post_id)), None)
|
| 358 |
-
if not post:
|
| 359 |
-
return JSONResponse({'error': 'Post not found in wall'}, status_code=404)
|
| 360 |
-
|
| 361 |
-
if post.get('video'):
|
| 362 |
-
return JSONResponse({'post': post, 'video': post['video'], 'status': 'done'})
|
| 363 |
-
|
| 364 |
-
loop = asyncio.get_event_loop()
|
| 365 |
-
result = await loop.run_in_executor(None, _gen_short_sync, post)
|
| 366 |
-
|
| 367 |
-
if result:
|
| 368 |
-
# Get the video URL from wall (updated in _gen_short_sync)
|
| 369 |
-
wall = base._load_ai_wall()
|
| 370 |
-
post = next((p for p in wall if str(p.get('id')) == str(post_id)), post)
|
| 371 |
-
return JSONResponse({'post': post, 'video': post.get('video'), 'status': 'done'})
|
| 372 |
-
return JSONResponse({'error': 'Video generation failed'}, status_code=500)
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
@app.get('/api/ai/short-file/{file_id:path}')
|
| 376 |
-
async def api_short_file(file_id: str):
|
| 377 |
-
safe = re.sub(r'[^\w\-.]', '_', file_id)[:100]
|
| 378 |
-
for fname in os.listdir(SHORTS_DIR) if os.path.isdir(SHORTS_DIR) else []:
|
| 379 |
-
if fname.endswith('.mp4') and safe in fname:
|
| 380 |
-
return FileResponse(os.path.join(SHORTS_DIR, fname), media_type='video/mp4')
|
| 381 |
-
return JSONResponse({'error': 'Not found'}, status_code=404)
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
# ===== SSE ENDPOINT FOR AUTO-UPDATE =====
|
| 385 |
-
try:
|
| 386 |
-
from auto_update_sse import sse_events as _sse_handler
|
| 387 |
-
app.add_api_route('/api/events', _sse_handler, methods=['GET'])
|
| 388 |
-
_log.info("SSE endpoint registered at /api/events")
|
| 389 |
-
except Exception as e:
|
| 390 |
-
_log.warning(f"SSE route not loaded: {e}")
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
# Log startup
|
| 394 |
-
_log.info("Short video endpoints registered")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ai_runtime_patch_final.py
DELETED
|
@@ -1,78 +0,0 @@
|
|
| 1 |
-
"""Final patch: homepage fix + AI topics at top + SSE auto-update"""
|
| 2 |
-
import re, json, time
|
| 3 |
-
from fastapi.responses import HTMLResponse, JSONResponse
|
| 4 |
-
from fastapi import Query
|
| 5 |
-
|
| 6 |
-
# Import chain - must be after ai_runtime_final6
|
| 7 |
-
try:
|
| 8 |
-
import ai_runtime_final6 as f6
|
| 9 |
-
from ai_runtime_final6 import app, f5
|
| 10 |
-
from main import rt
|
| 11 |
-
except Exception as e:
|
| 12 |
-
print(f"[ERROR] f6 import: {e}")
|
| 13 |
-
f6 = None
|
| 14 |
-
f5 = None
|
| 15 |
-
rt = None
|
| 16 |
-
|
| 17 |
-
PATCH_CSS_JS = r'''
|
| 18 |
-
<style>
|
| 19 |
-
.short-cmt-panel{position:fixed;bottom:0;left:0;right:0;max-height:55vh;background:#181818;border-radius:16px 16px 0 0;z-index:99999;padding:14px;display:none;overflow-y:auto}.short-cmt-panel.active{display:block}.short-cmt-panel textarea{width:100%;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px;margin:6px 0;min-height:60px}.short-cmt-panel button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin:4px}.cmt-item{background:#222;border-radius:8px;padding:7px;margin:5px 0;color:#ccc;font-size:12px}
|
| 20 |
-
.storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 0}
|
| 21 |
-
button[onclick*="rewriteCurrentArticle"]{display:none!important}
|
| 22 |
-
#ai-short-home,.ai-short-home,.ai-short-card-final{display:none!important}
|
| 23 |
-
</style>
|
| 24 |
-
<div id="short-cmt-panel" class="short-cmt-panel"></div>
|
| 25 |
-
<script>
|
| 26 |
-
(function(){
|
| 27 |
-
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}
|
| 28 |
-
fetch('/api/storage_status').then(r=>r.json()).then(j=>{if(!j.persistent){let h=document.getElementById('view-home');if(h){let w=document.createElement('div');w.className='storage-warn';w.innerHTML='⚠️ Persistent Storage chưa bật. Bật: Space Settings → Persistent Storage → Small.';h.prepend(w);}}});
|
| 29 |
-
|
| 30 |
-
// ===== AI HOT TOPICS PREPEND =====
|
| 31 |
-
const AI_HOT_TOPICS = ['Công nghệ AI', 'World Cup 2026', 'Kinh tế Việt Nam', 'Bóng đá châu Âu'];
|
| 32 |
-
async function ensureHotTopics(){let inp=document.getElementById('ai-topic-input-final5');if(!inp||document.getElementById('hot-topic-row-ai'))return;let row=document.createElement('div');row.id='hot-topic-row-ai';row.style.cssText='display:flex;gap:6px;overflow-x:auto;padding:4px 0;margin:6px 0';let topics=[];try{let j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));topics=j.topics||[];}catch(e){topics=[];}AI_HOT_TOPICS.forEach(ai=>{if(!topics.find(t=>(t.topic||'').toLowerCase()===ai.toLowerCase())){topics.unshift({label:'#'+ai.replace(/\s+/g,''),topic:ai,count:0});}});row.innerHTML=topics.slice(0,14).map(t=>`<button class="hot-chip" style="flex:0 0 auto;background:#222;border:1px solid #333;color:#ddd;border-radius:16px;padding:5px 10px;font-size:11px;cursor:pointer" onclick="document.getElementById('ai-topic-input-final5').value='${esc(t.topic).replace(/'/g,'\\''}';document.getElementById('ai-topic-input-final5').focus();searchTopic('${esc(t.topic).replace(/'/g,'\\''}')">${esc(t.label)}</button>`).join('');inp.insertAdjacentElement('afterend',row);}
|
| 33 |
-
|
| 34 |
-
// ===== SSE AUTO UPDATE =====
|
| 35 |
-
let _sseSource=null;
|
| 36 |
-
function connectSSE(){try{_sseSource=new EventSource('/api/events');_sseSource.onmessage=e=>{try{const d=JSON.parse(e.data);if(d.type==='new_post'||d.type==='new_short'){fetch('/api/ai_wall').then(r=>r.json()).then(j=>{_wallPosts=j.posts||[];if(typeof renderShortAISlide==='function')renderShortAISlide();const track=document.getElementById('ai-wall-track');if(track)_wallPosts.length?track.innerHTML=_wallPosts.slice(0,20).map((p,i)=>makeWallItem(p,i)).join(''):null;});}}catch{}};_sseSource.onerror=()=>setTimeout(connectSSE,5000);}catch(e){}}
|
| 37 |
-
let _lastWallLen=0;
|
| 38 |
-
setInterval(()=>{fetch('/api/ai_wall').then(r=>r.json()).then(j=>{const w=j.posts||[];if(w.length!==_lastWallLen){_lastWallLen=w.length;_wallPosts=w;const track=document.getElementById('ai-wall-track');if(track)w.length?track.innerHTML=w.slice(0,20).map((p,i)=>makeWallItem(p,i)).join(''):null;if(typeof renderShortAISlide==='function')renderShortAISlide();}}).catch(()=>{});},30000);
|
| 39 |
-
connectSSE();
|
| 40 |
-
|
| 41 |
-
// ===== Short AI slide =====
|
| 42 |
-
async function renderShortAISlide(){let home=document.getElementById('view-home');if(!home)return;document.getElementById('short-ai-final-slide')?.remove();let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];let vids=wall.filter(p=>p.video);if(!vids.length)return;let wrap=document.createElement('div');wrap.id='short-ai-final-slide';wrap.className='slider-wrap';wrap.innerHTML='<div class="slider-header"><span class="slider-label">🎬 Short AI</span></div><div class="slider-track">'+vids.slice(0,30).map((p,i)=>`<div class="slider-item shorts-item" onclick="openAIShortFeed(${i})"><div class="slider-thumb shorts-thumb"><video src="${p.video}" muted preload="metadata" style="width:100%;height:100%;object-fit:cover"></video><div class="card-play">▶</div></div><div class="slider-title">${esc(p.title)}</div></div>`).join('')+'</div>';let comp=home.querySelector('.ai-compose');(comp&&comp.nextSibling?comp.parentNode.insertBefore(wrap,comp.nextSibling):home.prepend(wrap));}
|
| 43 |
-
setTimeout(renderShortAISlide,2500);
|
| 44 |
-
|
| 45 |
-
setInterval(ensureHotTopics,2000);
|
| 46 |
-
})();
|
| 47 |
-
</script>
|
| 48 |
-
'''
|
| 49 |
-
|
| 50 |
-
# Register route if possible
|
| 51 |
-
if f6 and app:
|
| 52 |
-
try:
|
| 53 |
-
# Remove duplicate / route to avoid conflict
|
| 54 |
-
original_routes = [r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
|
| 55 |
-
app.router.routes = original_routes
|
| 56 |
-
|
| 57 |
-
@app.get('/')
|
| 58 |
-
async def patch_homepage():
|
| 59 |
-
html = f5.f4.f3.f2.f1._load_index_html() if f5 else "<html><body></body></html>"
|
| 60 |
-
body = ""
|
| 61 |
-
if hasattr(rt,'old') and hasattr(rt.old,'PATCH_INJECT'):
|
| 62 |
-
body += getattr(rt.old,'PATCH_INJECT','')
|
| 63 |
-
if f5:
|
| 64 |
-
body += getattr(f5.f4.f3.f2.f1,'FINAL_INJECT','') if hasattr(f5,'f4') else ''
|
| 65 |
-
body += getattr(f5.f4.f3,'FINAL3_INJECT','') if hasattr(f5,'f4') else ''
|
| 66 |
-
body += getattr(f5.f4,'FINAL4_INJECT','') if hasattr(f5,'f4') else ''
|
| 67 |
-
body += getattr(f5,'FINAL5_INJECT','') if hasattr(f5,'f4') else ''
|
| 68 |
-
body += getattr(f6,'FINAL6_INJECT','') if f6 else ''
|
| 69 |
-
body += getattr(f6,'FINAL6_FAST_HOME_INJECT','') if f6 else ''
|
| 70 |
-
body += getattr(f6,'FINAL6E_INJECT','') if f6 else ''
|
| 71 |
-
body += PATCH_CSS_JS
|
| 72 |
-
if '</body>' in html:
|
| 73 |
-
html = html.replace('</body>', body + '\n</body>')
|
| 74 |
-
else:
|
| 75 |
-
html = html + body
|
| 76 |
-
return HTMLResponse(html)
|
| 77 |
-
except Exception as e:
|
| 78 |
-
print(f"[ERROR] register route: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/static/app_v2.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// === VNEWS Frontend v2 - Full Functions ===
|
| 2 |
+
// Updated: Voice selector + speed control + image gallery + auto voice detect
|
| 3 |
+
|
| 4 |
+
// === LOAD HOME ===
|
| 5 |
+
async function loadHome(){
|
| 6 |
+
const[featured,sh,wall,hlLeagues,ai,wcData]=await Promise.all([
|
| 7 |
+
fetch('/api/livescore/featured').then(r=>r.json()).catch(()=>null),
|
| 8 |
+
fetch('/api/shorts').then(r=>r.json()).catch(()=>[]),
|
| 9 |
+
fetch('/api/wall').then(r=>r.json()).catch(()=>({posts:[]})),
|
| 10 |
+
fetch('/api/highlights/leagues').then(r=>r.json()).catch(()=>({})),
|
| 11 |
+
fetch('/api/genk_ai').then(r=>r.json()).catch(()=>[]),
|
| 12 |
+
fetch('/api/wc2026').then(r=>r.json()).catch(()=>null)
|
| 13 |
+
]);
|
| 14 |
+
_hlLeagueData=hlLeagues;
|
| 15 |
+
_wc2026Data=wcData;
|
| 16 |
+
_shortsData=interleaveShorts(sh||[]);
|
| 17 |
+
_wallPosts=(wall&&wall.posts)||[];
|
| 18 |
+
let h='';
|
| 19 |
+
if(featured&&featured.home){const sc=featured.status==='live'?'':'upcoming';const st=featured.status==='live'?`🔴 ${featured.minute||'LIVE'}`:`⏰ ${featured.time}`;h+=`<div class="featured-match" onclick="openMatch('${featured.event_id}')"><div class="fm-league">${featured.league}</div><div class="fm-teams"><div class="fm-team"><img src="${featured.home_logo}" onerror="this.style.display='none'"><span>${featured.home}</span></div><div class="fm-score">${featured.score||'VS'}</div><div class="fm-team"><img src="${featured.away_logo}" onerror="this.style.display='none'"><span>${featured.away}</span></div></div><div class="fm-status ${sc}">${st}</div></div>`;}
|
| 20 |
+
h+=`<div class="ai-compose"><div class="ai-compose-title">🤖 AI viết bài</div><div class="ai-compose-row"><input id="topic-input" placeholder="Nhập chủ đề..."><button onclick="searchTopic()">Tìm nguồn</button></div><div class="ai-compose-row"><input id="url-input" placeholder="Dán URL bài viết..."><button class="secondary" onclick="rewriteUrl()">Rewrite</button></div><div id="hot-topics" class="hot-topic-row"></div></div>`;
|
| 21 |
+
h+='<div id="hashtag-box"></div>';
|
| 22 |
+
h+=`<div class="ls-section"><div class="ls-header"><h3>⚽ Livescore</h3></div><div class="ls-tabs"><span class="ls-tab active" data-tab="today" onclick="loadLivescore('today')">📅 Hôm nay</span><span class="ls-tab" data-tab="live" onclick="loadLivescore('live')">🔴 Live</span><span class="ls-tab" data-tab="incoming" onclick="loadLivescore('incoming')">⏰ Sắp tới</span><span class="ls-tab" data-tab="results" onclick="loadLivescore('results')">✅ Kết quả</span><span class="ls-tab" data-tab="bxh_nha" onclick="loadLivescore('bxh_nha')">🏆 NHA</span><span class="ls-tab" data-tab="bxh_laliga" onclick="loadLivescore('bxh_laliga')">🏆 La Liga</span></div><div class="ls-content" id="ls-content"><div class="loading">Đang tải...</div></div></div>`;
|
| 23 |
+
h+=`<div id="wc2026-live-section" class="wc2026-section"><div class="wc-header"><h2>🏆 World Cup 2026</h2><span class="wc-live-badge">● LIVE</span></div><div class="wc-tabs"><span class="wc-tab active" onclick="switchWCTab('news')">📰 Tin tức</span><span class="wc-tab" onclick="switchWCTab('fixtures')">📅 Lịch thi đấu</span><span class="wc-tab" onclick="switchWCTab('standings')">🏆 BXH</span><span class="wc-tab" onclick="switchWCTab('highlights')">🎬 Highlight</span><span class="wc-tab" onclick="switchWCTab('stats')">📊 Thống kê</span></div><div class="wc-content" id="wc-content"><div class="loading">Đang tải World Cup 2026...</div></div></div>`;
|
| 24 |
+
const wallPosts=_wallPosts;
|
| 25 |
+
const aiShorts=wallPosts.filter(p=>p.video);
|
| 26 |
+
if(aiShorts.length){h+='<div class="slider-wrap"><div class="slider-header"><span class="slider-label">🎬 Short AI</span></div><div class="slider-track">';aiShorts.slice(0,20).forEach((p,i)=>{h+=`<div class="slider-item shorts-item" onclick="openShortAIFeed(${i})"><div class="slider-thumb shorts-thumb"><video src="${p.video}" muted preload="metadata"></video><div class="card-play">▶</div></div><div class="slider-title">${esc(p.title)}</div></div>`});h+='</div></div>';}
|
| 27 |
+
if(_shortsData.length){h+='<div class="slider-wrap"><div class="slider-header"><span class="slider-label">📱 Shorts Dân trí & SKĐS</span><span class="slider-note">Mới nhất · xen kẽ</span></div><div class="slider-track">';_shortsData.slice(0,30).forEach((a,i)=>{const badge=a.channel==='baosuckhoedoisongboyte'?'SKĐS':'Dân trí';h+=`<div class="slider-item shorts-item" onclick="openYTShortsFeed(${i})"><div class="slider-thumb shorts-thumb">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="slider-title"><span style="color:#f0c040;font-size:8px">${badge}</span> ${esc(a.title)}</div></div>`;});h+='</div></div>';}
|
| 28 |
+
if(wallPosts.length){h+=`<div class="slider-wrap" id="ai-wall-wrap"><div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track" id="ai-wall-track">`;wallPosts.slice(0,20).forEach((p,i)=>{h+=makeWallItem(p,i)});h+='</div></div>';}
|
| 29 |
+
const HL_CONFIG={"world-cup":{name:"World Cup 2026",emoji:"🌍"},"premier-league":{name:"Premier League",emoji:"🏴"},"champions-league":{name:"Champions League",emoji:"⭐"},"la-liga":{name:"La Liga",emoji:"🇪🇸"},"serie-a":{name:"Serie A",emoji:"🇮🇹"},"bundesliga":{name:"Bundesliga",emoji:"🇩🇪"},"friendly":{name:"Giao hữu",emoji:"🤝"}};
|
| 30 |
+
for(const[key,cfg] of Object.entries(HL_CONFIG)){const vids=hlLeagues[key];if(!vids||!vids.length)continue;h+=`<div class="slider-wrap"><div class="slider-header"><span class="slider-label">${cfg.emoji} ${cfg.name}</span></div><div class="slider-track">`;vids.slice(0,8).forEach((a,i)=>{h+=`<div class="slider-item" onclick="openHighlightFeed('${key}',${i})"><div class="slider-thumb">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="slider-title">${esc(a.title)}</div></div>`});h+='</div></div>';}
|
| 31 |
+
if(ai&&ai.length){h+='<div class="slider-wrap"><div class="slider-header"><span class="slider-label">🤖 Ứng dụng AI</span></div><div class="slider-track">';ai.slice(0,12).forEach(a=>{h+=`<div class="slider-item" onclick="readArticle('${esc(a.link)}')"><div class="slider-thumb">${a.img?`<img src="${a.img}">`:''}</div><div class="slider-title">${esc(a.title)}</div></div>`});h+='</div></div>';}
|
| 32 |
+
document.getElementById('view-home').innerHTML=h;
|
| 33 |
+
loadLivescore('today');loadHotTopics();
|
| 34 |
+
if(_wc2026Data)switchWCTab('news');
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// === WALL POST HELPERS ===
|
| 38 |
+
function makeWallItem(p,i){
|
| 39 |
+
const hasVideo = p.video && p.video.length > 0;
|
| 40 |
+
const thumbContent = p.img
|
| 41 |
+
? `<img src="${esc(p.img)}" onerror="this.style.display='none'">`
|
| 42 |
+
: (hasVideo ? `<video src="${esc(p.video)}" muted></video>` : '');
|
| 43 |
+
const videoBadge = hasVideo
|
| 44 |
+
? `<div class="wall-video-badge">🎬</div>`
|
| 45 |
+
: '';
|
| 46 |
+
const videoBtn = hasVideo
|
| 47 |
+
? `<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed(${i})">▶ Xem Short</button>`
|
| 48 |
+
: `<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo('${esc(p.id||i)}',this)">🎬 Tạo Video</button>`;
|
| 49 |
+
|
| 50 |
+
return `<div class="wall-item" id="wall-item-${esc(p.id||i)}">
|
| 51 |
+
<div class="wall-thumb">
|
| 52 |
+
${thumbContent}
|
| 53 |
+
${videoBadge}
|
| 54 |
+
</div>
|
| 55 |
+
<div class="wall-title">${esc(p.title)}</div>
|
| 56 |
+
<div class="wall-text">${esc((p.text||'').slice(0,180))}</div>
|
| 57 |
+
<div class="wall-actions">
|
| 58 |
+
<button class="primary" onclick="readWallPost(${i})">Xem</button>
|
| 59 |
+
${videoBtn}
|
| 60 |
+
</div>
|
| 61 |
+
</div>`;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
// === GENERATE SHORT VIDEO FOR A WALL POST ===
|
| 65 |
+
async function makeShortVideo(postId, btn, voice, speed){
|
| 66 |
+
if(!postId)return;
|
| 67 |
+
const origText = btn ? btn.textContent : '🎬 Tạo Video';
|
| 68 |
+
if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo...';}
|
| 69 |
+
toast('⏳ Đang tạo video shorts...');
|
| 70 |
+
try{
|
| 71 |
+
let url = '/api/ai/short/'+encodeURIComponent(postId);
|
| 72 |
+
const params = [];
|
| 73 |
+
if(voice) params.push('voice='+encodeURIComponent(voice));
|
| 74 |
+
if(speed) params.push('speed='+encodeURIComponent(speed));
|
| 75 |
+
if(params.length) url += '?' + params.join('&');
|
| 76 |
+
const r = await fetch(url, {method:'POST'});
|
| 77 |
+
const j = await r.json();
|
| 78 |
+
if(!r.ok || j.error) throw new Error(j.error||'Lỗi tạo video');
|
| 79 |
+
toast('✅ Đã tạo video shorts!');
|
| 80 |
+
const p = _wallPosts.find(x => String(x.id) === String(postId));
|
| 81 |
+
if(p){
|
| 82 |
+
p.video = j.video;
|
| 83 |
+
const itemId = 'wall-item-'+postId;
|
| 84 |
+
const el = document.getElementById(itemId);
|
| 85 |
+
if(el){
|
| 86 |
+
const idx = _wallPosts.indexOf(p);
|
| 87 |
+
el.outerHTML = makeWallItem(p, idx);
|
| 88 |
+
const newEl = document.getElementById(itemId);
|
| 89 |
+
if(newEl) newEl.className = 'wall-item wall-item-new';
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
refreshShortAISlider();
|
| 93 |
+
}catch(e){
|
| 94 |
+
toast('❌ '+e.message);
|
| 95 |
+
if(btn){btn.disabled=false;btn.textContent=origText;}
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
// Refresh Short AI slider after video generation
|
| 100 |
+
function refreshShortAISlider(){
|
| 101 |
+
const aiShorts = _wallPosts.filter(p=>p.video);
|
| 102 |
+
let shortAISection = document.getElementById('short-ai-section');
|
| 103 |
+
if(aiShorts.length === 0){
|
| 104 |
+
if(shortAISection) shortAISection.remove();
|
| 105 |
+
return;
|
| 106 |
+
}
|
| 107 |
+
if(shortAISection){
|
| 108 |
+
const track = shortAISection.querySelector('.slider-track');
|
| 109 |
+
if(track){
|
| 110 |
+
let h = '';
|
| 111 |
+
aiShorts.slice(0,20).forEach((p,i)=>{
|
| 112 |
+
h+=`<div class="slider-item shorts-item" onclick="openShortAIFeed(${i})"><div class="slider-thumb shorts-thumb"><video src="${esc(p.video)}" muted preload="metadata"></video><div class="card-play">▶</div></div><div class="slider-title">${esc(p.title)}</div></div>`;
|
| 113 |
+
});
|
| 114 |
+
track.innerHTML = h;
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
function prependWallPost(post){
|
| 120 |
+
_wallPosts.unshift(post);
|
| 121 |
+
const track=document.getElementById('ai-wall-track');
|
| 122 |
+
const wrap=document.getElementById('ai-wall-wrap');
|
| 123 |
+
const homeEl=document.getElementById('view-home');
|
| 124 |
+
if(!track||!wrap){
|
| 125 |
+
if(homeEl){
|
| 126 |
+
let insertBefore=homeEl.querySelector('.slider-wrap');
|
| 127 |
+
const newWrap=document.createElement('div');
|
| 128 |
+
newWrap.className='slider-wrap';
|
| 129 |
+
newWrap.id='ai-wall-wrap';
|
| 130 |
+
newWrap.innerHTML=`<div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track" id="ai-wall-track">${makeWallItem(post,0)}</div>`;
|
| 131 |
+
if(insertBefore){
|
| 132 |
+
homeEl.insertBefore(newWrap,insertBefore);
|
| 133 |
+
}else{
|
| 134 |
+
homeEl.appendChild(newWrap);
|
| 135 |
+
}
|
| 136 |
+
const firstItem=newWrap.querySelector('.wall-item');
|
| 137 |
+
if(firstItem)firstItem.className='wall-item wall-item-new';
|
| 138 |
+
}
|
| 139 |
+
return;
|
| 140 |
+
}
|
| 141 |
+
const div=document.createElement('div');
|
| 142 |
+
div.className='wall-item wall-item-new';
|
| 143 |
+
div.id='wall-item-'+(post.id||'new-'+Date.now());
|
| 144 |
+
const hasVideo = post.video && post.video.length > 0;
|
| 145 |
+
const thumbContent = post.img
|
| 146 |
+
? `<img src="${esc(post.img)}" onerror="this.style.display='none'">`
|
| 147 |
+
: (hasVideo ? `<video src="${esc(post.video)}" muted></video>` : '');
|
| 148 |
+
const videoBadge = hasVideo ? `<div class="wall-video-badge">🎬</div>` : '';
|
| 149 |
+
const videoBtn = hasVideo
|
| 150 |
+
? `<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed(0)">▶ Xem Short</button>`
|
| 151 |
+
: `<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo('${esc(post.id)}',this)">🎬 Tạo Video</button>`;
|
| 152 |
+
div.innerHTML=`<div class="wall-thumb">${thumbContent}${videoBadge}</div><div class="wall-title">${esc(post.title)}</div><div class="wall-text">${esc((post.text||'').slice(0,180))}</div><div class="wall-actions"><button class="primary" onclick="readWallPost(0)">Xem</button>${videoBtn}</div>`;
|
| 153 |
+
track.prepend(div);
|
| 154 |
+
track.scrollTo({left:0,behavior:'smooth'});
|
| 155 |
+
if(hasVideo) refreshShortAISlider();
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
// === REST OF FUNCTIONS ===
|
| 159 |
+
let _shortsData=[];
|
| 160 |
+
let _wallPosts=[];
|
| 161 |
+
let _currentView='home';
|
| 162 |
+
function interleaveShorts(shorts){const dt=shorts.filter(s=>s.channel==='baodantri7941');const sk=shorts.filter(s=>s.channel==='baosuckhoedoisongboyte');const result=[];let i=0,j=0;while(i<dt.length||j<sk.length){if(i<dt.length)result.push(dt[i++]);if(j<sk.length)result.push(sk[j++]);}return result;}
|
| 163 |
+
let _htPage=0,_htTopic='';
|
| 164 |
+
async function loadHotTopics(){const j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));const el=document.getElementById('hot-topics');if(!el)return;el.innerHTML=(j.topics||[]).slice(0,18).map(t=>{const topicText=t.topic||t.label.replace(/^#/,'');return`<button class="hot-chip" onclick="searchTopic('${topicText.replace(/'/g,"\\'")}')">${esc(t.label)}</button>`;}).join('');if(j.topics&&j.topics[0]){const firstTopic=j.topics[0].topic||j.topics[0].label.replace(/^#/,'');setTimeout(()=>searchTopic(firstTopic),800);}}
|
| 165 |
+
function searchTopic(topic){if(!topic){topic=document.getElementById('topic-input')?.value.trim();if(!topic){alert('Nhập chủ đề');return;}}document.getElementById('topic-input').value='';_htTopic=topic;_htPage=0;showHashtagSources(topic,0);}
|
| 166 |
+
async function showHashtagSources(topic,page){const box=document.getElementById('hashtag-box');if(!box)return;if(page===0)box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div class="hashtag-loading"><div class="hashtag-spinner"></div>Đang tìm...</div></div>`;try{const r=await fetch(`/api/hashtag/sources?topic=${encodeURIComponent(topic)}&page=${page}`);const j=await r.json();const sources=j.sources||[];if(!sources.length&&page===0){box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div style="color:#888;padding:8px">Không tìm được bài viết liên quan</div></div>`;return;}let h='';if(page===0)h=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)} <span style="font-size:10px;color:#888">(${j.total} bài từ 8 nguồn)</span></h3><div id="ht-list">`;sources.forEach((s,i)=>{const idx=page*8+i;h+=`<div class="hashtag-src-item" onclick="readArticle('${esc(s.url)}')"><div class="hashtag-src-img" id="ht-img-${idx}"></div><div class="hashtag-src-text"><div class="hashtag-src-title">${esc(s.title)}</div><div class="hashtag-src-via">${esc(s.via||'')}</div></div></div>`;});if(page===0){h+=`</div><button class="hashtag-rewrite-btn" onclick="rewriteHashtag('${esc(topic).replace(/'/g,"\\'")}')">🤖 Rewrite AI tổng hợp & đăng tường</button>`;if(j.has_more)h+=`<button class="hashtag-load-more" id="ht-more" onclick="loadMoreHashtag()">Tải thêm ▼</button>`;h+=`</div>`;box.innerHTML=h;}else{document.getElementById('ht-list')?.insertAdjacentHTML('beforeend',h);const btn=document.getElementById('ht-more');if(btn){if(!j.has_more)btn.remove();else{btn.disabled=false;btn.textContent='Tải thêm ▼';}}}sources.forEach((s,i)=>{const idx=page*8+i;if(!s.url)return;fetch('/api/article?url='+encodeURIComponent(s.url)).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){const el=document.getElementById('ht-img-'+idx);if(el)el.innerHTML=`<img src="${esc(d.og_image||d.img)}" onerror="this.style.display='none'">`;}}).catch(()=>{});});}catch(e){box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div style="color:#e74c3c;padding:8px">Lỗi: ${esc(e.message)}</div></div>`;}}
|
| 167 |
+
function loadMoreHashtag(){_htPage++;const btn=document.getElementById('ht-more');if(btn){btn.disabled=true;btn.textContent='Đang tải...';}showHashtagSources(_htTopic,_htPage);}
|
| 168 |
+
async function rewriteHashtag(topic){const btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang tổng hợp...';}try{const r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');toast('✅ Đã đăng Tường AI!');if(btn)btn.textContent='✅ Đăng thành công!';if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message);if(btn){btn.disabled=false;btn.textContent='🤖 Rewrite AI';}}}
|
| 169 |
+
async function loadLivescore(tab){document.querySelectorAll('.ls-tab').forEach(t=>t.classList.remove('active'));document.querySelector(`.ls-tab[data-tab="${tab}"]`)?.classList.add('active');const el=document.getElementById('ls-content');if(!el)return;el.innerHTML='<div class="loading">Đang tải...</div>';let ep='/api/livescore/'+tab;if(tab.startsWith('bxh_'))ep='/api/livescore/standings/'+tab.replace('bxh_','');try{const r=await fetch(ep);const d=await r.json();el.innerHTML=d.html&&d.html.length>50?d.html:'<div class="loading">Không có dữ liệu</div>';bindMatchClicks(el);}catch(e){el.innerHTML='<div class="loading">Lỗi</div>';}}
|
| 170 |
+
function bindMatchClicks(el){el.querySelectorAll('.match-detail').forEach(md=>{md.addEventListener('click',function(e){e.preventDefault();const a=this.querySelector('.status a');if(a){const m=(a.getAttribute('href')||'').match(/\/tran-dau\/(\d+)\//);if(m)openMatch(m[1])}})});el.querySelectorAll('a').forEach(a=>a.addEventListener('click',e=>e.preventDefault()))}
|
| 171 |
+
function openMatch(id){if(!id)return;_currentEventId=id;document.getElementById('match-overlay').classList.add('active');document.body.style.overflow='hidden';loadMatchTab('comm')}
|
| 172 |
+
function closeMatch(){document.getElementById('match-overlay').classList.remove('active');document.body.style.overflow=''}
|
| 173 |
+
async function loadMatchTab(tab){document.querySelectorAll('.mo-tab').forEach(t=>t.classList.remove('active'));document.querySelectorAll('.mo-tab').forEach(t=>{if((tab==='comm'&&t.textContent==='Diễn biến')||(tab==='stats'&&t.textContent==='Thống kê'))t.classList.add('active')});const el=document.getElementById('mo-body');el.innerHTML='<div class="loading">Đang tải...</div>';try{const r=await fetch(tab==='stats'?`/api/match/${_currentEventId}/stats`:`/api/match/${_currentEventId}/commentaries`);const d=await r.json();el.innerHTML=d.html||'<div class="loading">Không có dữ liệu</div>'}catch(e){el.innerHTML='<div class="loading">Lỗi</div>'}}
|
| 174 |
+
async function doInteract(videoId,type){try{const r=await fetch('/api/v2/interact',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:videoId,type})});return await r.json();}catch(e){return null;}}
|
| 175 |
+
async function getInteractions(videoId){try{return await fetch('/api/v2/interactions?id='+encodeURIComponent(videoId)).then(r=>r.json());}catch(e){return{views:0,likes:0,comments:0};}}
|
| 176 |
+
async function getComments(videoId){try{const j=await fetch('/api/v2/comments?id='+encodeURIComponent(videoId)).then(r=>r.json());return j.comments||[];}catch(e){return[];}}
|
| 177 |
+
async function postComment(videoId,text){try{const j=await fetch('/api/v2/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:videoId,text})}).then(r=>r.json());return j.comments||[];}catch(e){return[];}}
|
| 178 |
+
function buildTikTokSlide(opts){return`<div class="tiktok-slide" data-vid="${esc(opts.videoId)}">${opts.vtag}<div class="tiktok-bottom"><span class="badge ${opts.badgeClass||'badge-fpt'}">${opts.badge||''}</span><p class="tiktok-title">${esc(opts.title)}</p></div><div class="tiktok-right"><button class="tiktok-right-btn" onclick="event.stopPropagation();doView('${esc(opts.videoId)}',this)"><div class="icon">👁</div><div class="count" id="vc-${opts.idx}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();doLike('${esc(opts.videoId)}',this)"><div class="icon">❤️</div><div class="count" id="lc-${opts.idx}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();toggleComments('${esc(opts.videoId)}',${opts.idx})"><div class="icon">💬</div><div class="count" id="cc-${opts.idx}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();doShare('${esc(opts.title)}','${esc(opts.shareUrl||'')}','')"><div class="icon">📤</div></button>${opts.extraBtn||''}</div><span class="tiktok-counter">${opts.idx+1}/${opts.total}</span><div class="inline-comments" id="cmt-inline-${opts.idx}" style="display:none"></div></div>`;}
|
| 179 |
+
async function doView(videoId,btn){const j=await doInteract(videoId,'view');if(j){const c=btn.querySelector('.count');if(c)c.textContent=fmtNum(j.views);}}
|
| 180 |
+
async function doLike(videoId,btn){const j=await doInteract(videoId,'like');if(j){const c=btn.querySelector('.count');if(c)c.textContent=fmtNum(j.likes);}}
|
| 181 |
+
function fmtNum(n){if(!n)return'0';if(n>=1000000)return(n/1000000).toFixed(1)+'M';if(n>=1000)return(n/1000).toFixed(1)+'K';return String(n);}
|
| 182 |
+
async function loadCounters(videoIds){for(let i=0;i<videoIds.length;i++){const id=videoIds[i];if(!id)continue;const j=await getInteractions(id);const vc=document.getElementById('vc-'+i);if(vc)vc.textContent=fmtNum(j.views);const lc=document.getElementById('lc-'+i);if(lc)lc.textContent=fmtNum(j.likes);const cc=document.getElementById('cc-'+i);if(cc)cc.textContent=fmtNum(j.comments);}}
|
| 183 |
+
async function toggleComments(videoId,idx){const panel=document.getElementById('cmt-inline-'+idx);if(!panel)return;if(panel.style.display!=='none'){panel.style.display='none';return;}panel.style.display='block';panel.innerHTML='<div style="padding:8px;color:#888;font-size:11px">Đang tải...</div>';const cmts=await getComments(videoId);renderInlineComments(panel,videoId,idx,cmts);}
|
| 184 |
+
function renderInlineComments(panel,videoId,idx,cmts){let h='<div class="inline-cmt-header"><span>💬 Bình luận</span><button onclick="document.getElementById(\'cmt-inline-'+idx+'\').style.display=\'none\'">✕</button></div><div class="inline-cmt-list">';if(cmts.length){cmts.slice(-30).forEach(c=>{h+=`<div class="inline-cmt-item"><span class="inline-cmt-time">${c.time||''}</span>${esc(c.text)}</div>`;});}else{h+='<div style="color:#777;font-size:11px;padding:4px">Chưa có bình luận</div>';}h+=`</div><div class="inline-cmt-input"><input id="cmt-input-${idx}" placeholder="Viết bình luận..." onkeydown="if(event.key==='Enter')submitInlineCmt('${esc(videoId)}',${idx})"><button onclick="submitInlineCmt('${esc(videoId)}',${idx})">Gửi</button></div>`;panel.innerHTML=h;const list=panel.querySelector('.inline-cmt-list');if(list)list.scrollTop=list.scrollHeight;}
|
| 185 |
+
async function submitInlineCmt(videoId,idx){const inp=document.getElementById('cmt-input-'+idx);if(!inp)return;const text=inp.value.trim();if(!text)return;inp.value='';inp.disabled=true;const cmts=await postComment(videoId,text);inp.disabled=false;const panel=document.getElementById('cmt-inline-'+idx);if(panel)renderInlineComments(panel,videoId,idx,cmts);const cc=document.getElementById('cc-'+idx);if(cc)cc.textContent=fmtNum(cmts.length);}
|
| 186 |
+
function initTikTokFeed(){const feed=document.getElementById('tiktok-feed');if(!feed)return;const slides=feed.querySelectorAll('.tiktok-slide');let cur=-1;function act(i){if(i===cur)return;slides.forEach((sl,idx)=>{const v=sl.querySelector('video');const fr=sl.querySelector('iframe');if(idx===i){if(v&&v.dataset.hls&&!v._hls&&typeof Hls!=='undefined'&&Hls.isSupported()){const hls=new Hls();hls.loadSource(v.dataset.hls);hls.attachMedia(v);hls.on(Hls.Events.MANIFEST_PARSED,()=>v.play().catch(()=>{}));v._hls=hls}else if(v)v.play().catch(()=>{});if(fr&&!fr.src&&fr.dataset.ytSrc)fr.src=fr.dataset.ytSrc;const vid=sl.dataset.vid;if(vid&&!sl._viewed){sl._viewed=true;doInteract(vid,'view').then(j=>{if(j){const vc=document.getElementById('vc-'+idx);if(vc)vc.textContent=fmtNum(j.views);}});}}else{if(v){v.pause();if(v._hls){v._hls.destroy();v._hls=null}}if(fr&&fr.src)fr.src=''}});cur=i}let sT;feed.addEventListener('scroll',()=>{clearTimeout(sT);sT=setTimeout(()=>{const rect=feed.getBoundingClientRect(),ctr=rect.top+rect.height/2;let best=-1,bestD=1e9;slides.forEach((sl,i)=>{const d=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(d<bestD){bestD=d;best=i}});if(best>=0)act(best)},150)});setTimeout(()=>act(0),400);slides.forEach(sl=>{const v=sl.querySelector('video');if(v)v.addEventListener('click',e=>{e.preventDefault();v.paused?v.play().catch(()=>{}):v.pause()})});const ids=[...slides].map(sl=>sl.dataset.vid||'');loadCounters(ids);}
|
| 187 |
+
async function openHighlightFeed(league,idx){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải...</div>';let articles=(_hlLeagueData||{})[league]||[];if(!articles.length){try{articles=await fetch('/api/highlights/'+league).then(r=>r.json())}catch(e){articles=[]}}if(!articles.length){el.innerHTML='<div class="loading">Không có video</div>';return}const vids=[];const results=await Promise.all(articles.map(async(a,i)=>{try{const r=await fetch('/api/video_url?url='+encodeURIComponent(a.link));const v=await r.json();if(v&&v.src)return{...a,...v,_idx:i}}catch(e){}return null}));results.forEach(r=>{if(r)vids.push(r)});vids.sort((a,b)=>a._idx-b._idx);if(!vids.length){el.innerHTML='<div class="loading">Không tìm thấy video</div>';return}let ti=vids.findIndex(v=>v._idx===idx);if(ti<0)ti=0;const ordered=ti>0?[...vids.slice(ti),...vids.slice(0,ti)]:vids;let h=`<button class="back-btn" onclick="switchCat('home')">← Highlight</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">`;ordered.forEach((v,i)=>{const isYT=v.type==='youtube',isHLS=!isYT&&v.src?.includes('.m3u8'),poster=v.poster?` poster="${v.poster}"`:'';const vtag=isYT?`<iframe data-yt-src="${v.src}" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture"></iframe>`:isHLS?`<video playsinline preload="none"${poster} data-hls="${v.src}" loop controls></video>`:`<video playsinline preload="none"${poster} loop controls><source src="${v.src}" type="video/mp4"></video>`;const videoId='hl-'+league+'-'+(v.id||v._idx);h+=buildTikTokSlide({vtag,title:v.title,badge:'HL',badgeClass:'badge-fpt',videoId,idx:i,total:ordered.length,shareUrl:v.link||'',extraBtn:`<button class="tiktok-right-btn" onclick="event.stopPropagation();this.closest('.tiktok-slide').classList.toggle('ratio-wide')"><div class="icon">⬜</div><div class="count">16:9</div></button>`});});h+='</div></div>';el.innerHTML=h;initTikTokFeed();}
|
| 188 |
+
async function openYTShortsFeed(startIdx){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải...</div>';const arts=_shortsData.length?_shortsData:await fetch('/api/shorts').then(r=>r.json()).catch(()=>[]);if(!arts.length){el.innerHTML='<div class="loading">Không có shorts</div>';return}const ordered=startIdx>0?[...arts.slice(startIdx),...arts.slice(0,startIdx)]:arts;let h=`<button class="back-btn" onclick="switchCat('home')">← Shorts</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">`;ordered.forEach((v,i)=>{const id=v.id||'';const src=`https://www.youtube.com/embed/${id}?autoplay=1&rel=0&playsinline=1`;const vtag=`<iframe data-yt-src="${src}" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture"></iframe>`;const badge=v.channel==='baosuckhoedoisongboyte'?'SKĐS':'Dân trí';const videoId='yt-'+id;h+=buildTikTokSlide({vtag,title:v.title,badge,badgeClass:'badge-fpt',videoId,idx:i,total:ordered.length,shareUrl:'https://youtube.com/watch?v='+id});});h+='</div></div>';el.innerHTML=h;initTikTokFeed();}
|
| 189 |
+
async function openShortAIFeed(startIdx){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải...</div>';const wall=(await fetch('/api/wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];const vids=wall.filter(p=>p.video);if(!vids.length){el.innerHTML='<div class="loading">Chưa có Short AI</div>';return}const ordered=startIdx>0?[...vids.slice(startIdx),...vids.slice(0,startIdx)]:vids;let h=`<button class="back-btn" onclick="switchCat('home')">← Short AI</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">`;ordered.forEach((p,i)=>{const vtag=`<video src="${p.video}" playsinline loop controls></video>`;const videoId='ai-'+(p.id||i);h+=buildTikTokSlide({vtag,title:p.title,badge:'AI',badgeClass:'badge-ai',videoId,idx:i,total:ordered.length,shareUrl:SPACE});});h+='</div></div>';el.innerHTML=h;initTikTokFeed();}
|
| 190 |
+
async function readArticle(url){showView('view-article');const el=document.getElementById('view-article');el.innerHTML='<div class="loading">Đang tải...</div>';try{const r=await fetch('/api/article?url='+encodeURIComponent(url));const data=await r.json();if(data&&!data.error&&data.body&&data.body.length){_currentArticle={url,data};let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><h1 class="article-title">${esc(data.title)}</h1>`;if(data.summary)h+=`<div class="article-summary">${esc(data.summary)}</div>`;const seen={};data.body.forEach(b=>{if(b.type==='p')h+=`<p class="article-p">${b.text}</p>`;else if(b.type==='img'&&b.src&&!seen[b.src]){seen[b.src]=1;h+=`<img class="article-img" src="${esc(b.src)}" onerror="this.style.display='none'">`}else if(b.type==='heading')h+=`<h2 class="article-h2">${esc(b.text)}</h2>`});h+=`<div class="article-actions"><button class="primary" onclick="rewriteArticle()">🤖 Rewrite AI đăng tường</button><button onclick="doShare('${esc(data.title)}','${esc(url)}','${esc(data.og_image||'')}')">📤</button><button onclick="window.open('${esc(url)}','_blank')">🔗 Gốc</button></div><div class="article-ai-ask"><h3 style="font-size:14px;color:#5cb87a">🤖 Hỏi AI</h3><textarea id="ask-q" placeholder="Hỏi về bài viết..."></textarea><button onclick="askAI()">Hỏi</button><div id="ask-a" class="article-ai-answer"></div></div></div>`;el.innerHTML=h;window.scrollTo(0,0);return;}}catch(e){}el.innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="loading"><p>Không đọc được.</p><a href="${esc(url)}" target="_blank" style="color:#5cb87a">Mở gốc →</a></div>`;}
|
| 191 |
+
async function rewriteArticle(){const url=_currentArticle?.url;if(!url)return;toast('⏳ Đang rewrite...');try{const r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,context:document.querySelector('.article-view')?.innerText?.slice(0,14000)||''})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error);toast('✅ Đã đăng Tường AI!');if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message)}}
|
| 192 |
+
async function rewriteUrl(){const url=document.getElementById('url-input')?.value.trim();if(!url)return alert('Dán URL');toast('⏳ Đang rewrite...');try{const r=await fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error);toast('✅ Đã đăng!');document.getElementById('url-input').value='';if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message)}}
|
| 193 |
+
async function askAI(){const q=document.getElementById('ask-q')?.value.trim();if(!q)return alert('Nhập câu hỏi');const a=document.getElementById('ask-a');a.textContent='Đang hỏi...';try{const r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:_currentArticle?.url||'',question:q,context:document.querySelector('.article-view')?.innerText?.slice(0,12000)||''})});const j=await r.json();a.textContent=j.answer||'Không trả lời được';}catch(e){a.textContent='Lỗi: '+e.message}}
|
| 194 |
+
async function readWallPost(i){const p=_wallPosts[i];if(!p)return;showView('view-article');
|
| 195 |
+
// Build image gallery HTML
|
| 196 |
+
const images = p.images || [];
|
| 197 |
+
let imgGallery = '';
|
| 198 |
+
if(images.length > 0){
|
| 199 |
+
imgGallery = '<div class="article-image-gallery">';
|
| 200 |
+
images.forEach((imgUrl, idx) => {
|
| 201 |
+
if(idx === 0){
|
| 202 |
+
imgGallery += `<img class="article-img article-hero-img" src="${esc(imgUrl)}" onerror="this.style.display='none" loading="eager">`;
|
| 203 |
+
} else {
|
| 204 |
+
if(idx === 1) imgGallery += '<div class="gallery-thumbs">';
|
| 205 |
+
imgGallery += `<div class="gallery-thumb"><img src="${esc(imgUrl)}" onerror="this.parentElement.style.display='none'" loading="lazy"></div>`;
|
| 206 |
+
}
|
| 207 |
+
});
|
| 208 |
+
if(images.length > 1) imgGallery += '</div>';
|
| 209 |
+
imgGallery += '</div>';
|
| 210 |
+
}
|
| 211 |
+
const hasVideo = p.video && p.video.length > 0;
|
| 212 |
+
const voiceOptions = [
|
| 213 |
+
{id:'hoaimy', label:'🎙️ Nữ — Hoài My'},
|
| 214 |
+
{id:'namminh', label:'🎙️ Nam — Nam Minh'},
|
| 215 |
+
];
|
| 216 |
+
let voiceSelector = '';
|
| 217 |
+
if(!hasVideo){
|
| 218 |
+
voiceSelector = `<div class="tts-selector"><div class="tts-selector-label">🎙️ Chọn giọng đọc:</div><div class="tts-voice-btns">`;
|
| 219 |
+
voiceOptions.forEach(v=>{
|
| 220 |
+
voiceSelector += `<button class="tts-voice-btn" onclick="document.querySelectorAll('.tts-voice-btn').forEach(b=>b.classList.remove('active'));this.classList.add('active');document.getElementById('selected-voice').value='${v.id}'">${v.label}</button>`;
|
| 221 |
+
});
|
| 222 |
+
voiceSelector += `</div><div class="tts-speed-row"><span>Tốc độ:</span><select id="selected-speed"><option value="1.0">1.0x — Bình thường</option><option value="1.2" selected>1.2x — Nhanh</option><option value="1.5">1.5x — Rất nhanh</option><option value="0.8">0.8x — Chậm</option></select></div>`;
|
| 223 |
+
voiceSelector += `<input type="hidden" id="selected-voice" value="hoaimy"></div>`;
|
| 224 |
+
}
|
| 225 |
+
document.getElementById('view-article').innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${esc(p.title)}</h1>${imgGallery}<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>${hasVideo?`<video class="article-img" src="${esc(p.video)}" controls playsinline style="max-height:400px"></video>`:''}<div class="article-actions">${hasVideo?`<button onclick="openShortAIFeed(${i})">🎬 Xem Short</button>${voiceSelector}<button class="primary" onclick="makeShortVideo('${esc(p.id)}',this,document.getElementById('selected-voice')?.value,parseFloat(document.getElementById('selected-speed')?.value)||1.2)">🔄 Tạo lại Short</button>`:`${voiceSelector}<button class="primary" onclick="makeShortVideo('${esc(p.id)}',this,document.getElementById('selected-voice')?.value,parseFloat(document.getElementById('selected-speed')?.value)||1.2)">🎬 Tạo Video Shorts</button>`}<button onclick="doShare('${esc(p.title)}','${SPACE}','${esc(p.img||'')}')">📤</button></div></div>`;
|
| 226 |
+
const firstVoiceBtn = document.querySelector('.tts-voice-btn');
|
| 227 |
+
if(firstVoiceBtn) firstVoiceBtn.classList.add('active');
|
| 228 |
+
window.scrollTo(0,0)}
|
| 229 |
+
async function loadNewsTab(){const el=document.getElementById('view-cat');el.innerHTML='<div class="loading">Đang tải...</div>';try{const r=await fetch('/api/homepage');const news=await r.json();if(!news.length){el.innerHTML='<div class="loading">Không có tin</div>';return}const groups={};news.forEach(a=>{if(!groups[a.group])groups[a.group]=[];groups[a.group].push(a)});let h='';for(const[g,arts] of Object.entries(groups)){h+=`<div class="section-title">${g}</div><div class="grid">`;arts.slice(0,6).forEach(a=>{h+=`<div class="card" onclick="readArticle('${esc(a.link)}')"><div class="card-img">${a.img?`<img src="${a.img}">`:''}</div><div class="card-body"><span class="badge badge-vne">${esc(a.source||'VnE')}</span><div class="card-title">${esc(a.title)}</div></div></div>`});h+='</div>'}el.innerHTML=h}catch(e){el.innerHTML='<div class="loading">Lỗi</div>'}}
|
| 230 |
+
async function loadCat(id){const el=document.getElementById('view-cat');el.innerHTML='<div class="loading">Đang tải...</div>';const arts=await fetch('/api/category/'+id).then(r=>r.json()).catch(()=>[]);if(!arts.length){el.innerHTML='<div class="loading">Không có tin</div>';return}let h='<div class="grid">';arts.forEach(a=>{h+=`<div class="card" onclick="readArticle('${esc(a.link)}')"><div class="card-img">${a.img?`<img src="${a.img}">`:''}</div><div class="card-body"><span class="badge badge-vne">${esc(a.source||'')}</span><div class="card-title">${esc(a.title)}</div></div></div>`});h+='</div>';el.innerHTML=h}
|
| 231 |
+
fetch('/api/storage_status').then(r=>r.json()).then(j=>{if(!j.persistent){const home=document.getElementById('view-home');if(home){const w=document.createElement('div');w.className='storage-warn';w.innerHTML='⚠️ Persistent Storage chưa bật.';home.prepend(w)}}}).catch(()=>{});
|
| 232 |
+
|
| 233 |
+
// === AUTO-OPEN SHARE LINKS (/s?url=... sets pending_article) ===
|
| 234 |
+
(function(){
|
| 235 |
+
try{
|
| 236 |
+
const pa=localStorage.getItem('pending_article');
|
| 237 |
+
const pv=localStorage.getItem('pending_video');
|
| 238 |
+
if(pa){
|
| 239 |
+
localStorage.removeItem('pending_article');
|
| 240 |
+
setTimeout(()=>{
|
| 241 |
+
if(typeof readArticle==='function') readArticle(pa);
|
| 242 |
+
},1500);
|
| 243 |
+
}
|
| 244 |
+
if(pv){
|
| 245 |
+
localStorage.removeItem('pending_video');
|
| 246 |
+
try{
|
| 247 |
+
const v=JSON.parse(pv);
|
| 248 |
+
if(v&&v.url) setTimeout(()=>{window.open(v.url,'_blank')},1500);
|
| 249 |
+
}catch(e){}
|
| 250 |
+
}
|
| 251 |
+
}catch(e){}
|
| 252 |
+
})();
|
app_v2_entry.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
app_v2_entry.py.gitigignore
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
.pyc
|
| 2 |
-
__pycache__/
|
| 3 |
-
*.pyc
|
|
|
|
|
|
|
|
|
|
|
|
app_v2_entry_hot.py
DELETED
|
@@ -1,24 +0,0 @@
|
|
| 1 |
-
"""Hot topics patch - makes AI topics always visible at top of HOT list."""
|
| 2 |
-
# This file is imported by app_v2_entry.py
|
| 3 |
-
|
| 4 |
-
# AI topics to prepend to hot topics
|
| 5 |
-
AI_HOT_TOPICS = [
|
| 6 |
-
{'label': '#Công nghệ AI', 'topic': 'Công nghệ AI', 'count': 0},
|
| 7 |
-
{'label': '#World Cup 2026', 'topic': 'World Cup 2026', 'count': 0},
|
| 8 |
-
{'label': '#Kinh tế Việt Nam', 'topic': 'Kinh tế Việt Nam', 'count': 0},
|
| 9 |
-
{'label': '#Bóng đá châu Âu', 'topic': 'Bóng đá châu Âu', 'count': 0},
|
| 10 |
-
{'label': '#Giá vàng', 'topic': 'Giá vàng', 'count': 0},
|
| 11 |
-
{'label': '#Thời tiết', 'topic': 'Thời tiết', 'count': 0},
|
| 12 |
-
]
|
| 13 |
-
|
| 14 |
-
def prepend_ai_hot_topics(topics):
|
| 15 |
-
"""Prepend AI topics to hot topics list, ensuring they're always visible."""
|
| 16 |
-
if not topics:
|
| 17 |
-
return AI_HOT_TOPICS[:]
|
| 18 |
-
# Remove duplicates that already exist
|
| 19 |
-
existing_topics = [t.get('topic', '').lower() for t in topics]
|
| 20 |
-
result = []
|
| 21 |
-
for ai_topic in AI_HOT_TOPICS:
|
| 22 |
-
if ai_topic.get('topic', '').lower() not in existing_topics:
|
| 23 |
-
result.append(ai_topic)
|
| 24 |
-
return result + topics
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app_v2_patch.py
DELETED
|
@@ -1,111 +0,0 @@
|
|
| 1 |
-
"""VNEWS v2 Patch - auto scheduler + status endpoints + keep-alive.
|
| 2 |
-
This is imported by app_v2_entry.py to add auto posting functionality.
|
| 3 |
-
FIX v2: Catch-up scheduler + keep-alive to prevent Space sleep
|
| 4 |
-
"""
|
| 5 |
-
import sys, os, threading, json, time, logging
|
| 6 |
-
from datetime import datetime, timezone, timedelta
|
| 7 |
-
from fastapi import Request
|
| 8 |
-
from fastapi.responses import JSONResponse
|
| 9 |
-
import requests as _req
|
| 10 |
-
|
| 11 |
-
VN_TZ = timezone(timedelta(hours=7))
|
| 12 |
-
LOG = logging.getLogger("app_v2_patch")
|
| 13 |
-
LOG.setLevel(logging.INFO)
|
| 14 |
-
if not LOG.handlers:
|
| 15 |
-
ch = logging.StreamHandler()
|
| 16 |
-
ch.setFormatter(logging.Formatter('%(asctime)s [app_v2_patch] %(levelname)s: %(message)s'))
|
| 17 |
-
LOG.addHandler(ch)
|
| 18 |
-
|
| 19 |
-
# ===== Keep-alive: prevent Space from sleeping =====
|
| 20 |
-
# HF Spaces sleep after ~30 min of inactivity on free tier
|
| 21 |
-
# This thread pings the Space every 10 minutes to keep it alive
|
| 22 |
-
SPACE_URL = "https://bep40-vnews.hf.space"
|
| 23 |
-
|
| 24 |
-
def _keep_alive_loop():
|
| 25 |
-
"""Ping the Space every 10 minutes to prevent sleep."""
|
| 26 |
-
LOG.info(f"🔄 Keep-alive thread started - ping {SPACE_URL} every 10 min")
|
| 27 |
-
while True:
|
| 28 |
-
try:
|
| 29 |
-
time.sleep(600) # 10 minutes
|
| 30 |
-
_req.get(f"{SPACE_URL}/api/scheduler/status",
|
| 31 |
-
headers={"User-Agent": "VNEWS-KeepAlive/1.0"},
|
| 32 |
-
timeout=15)
|
| 33 |
-
LOG.debug("Keep-alive ping OK")
|
| 34 |
-
except Exception as e:
|
| 35 |
-
LOG.warning(f"Keep-alive ping failed (Space may be sleeping): {e}")
|
| 36 |
-
|
| 37 |
-
# Start keep-alive in background
|
| 38 |
-
try:
|
| 39 |
-
_ka_thread = threading.Thread(target=_keep_alive_loop, daemon=True, name="keep-alive")
|
| 40 |
-
_ka_thread.start()
|
| 41 |
-
LOG.info("🔄 Keep-alive started - Space will stay awake")
|
| 42 |
-
except Exception as e:
|
| 43 |
-
LOG.warning(f"Keep-alive start failed: {e}")
|
| 44 |
-
|
| 45 |
-
# ===== Start auto scheduler =====
|
| 46 |
-
try:
|
| 47 |
-
import auto_scheduler as _as
|
| 48 |
-
_as.start_auto_scheduler()
|
| 49 |
-
LOG.info("[auto_scheduler] Started successfully - will post at 7:00, 13:00, 19:00 VN time (with catch-up)")
|
| 50 |
-
except Exception as e:
|
| 51 |
-
LOG.error(f"[auto_scheduler] Start failed: {e}")
|
| 52 |
-
|
| 53 |
-
def register_scheduler_endpoints(app):
|
| 54 |
-
"""Register scheduler status/trigger endpoints on the FastAPI app."""
|
| 55 |
-
|
| 56 |
-
@app.get('/api/scheduler/status')
|
| 57 |
-
def scheduler_status():
|
| 58 |
-
running = any(t.name == 'auto-scheduler' and t.is_alive() for t in threading.enumerate())
|
| 59 |
-
keep_alive = any(t.name == 'keep-alive' and t.is_alive() for t in threading.enumerate())
|
| 60 |
-
|
| 61 |
-
# Load state to show which slots ran today
|
| 62 |
-
today_str = datetime.now(VN_TZ).strftime('%Y-%m-%d')
|
| 63 |
-
state = {}
|
| 64 |
-
try:
|
| 65 |
-
state_file = '/data/scheduler_state.json' if os.path.isdir('/data') else None
|
| 66 |
-
if state_file and os.path.exists(state_file):
|
| 67 |
-
state = json.load(open(state_file, 'r'))
|
| 68 |
-
except:
|
| 69 |
-
pass
|
| 70 |
-
|
| 71 |
-
ran_today = state.get(today_str, {}) if state else {}
|
| 72 |
-
|
| 73 |
-
return JSONResponse({
|
| 74 |
-
"running": running,
|
| 75 |
-
"keep_alive": keep_alive,
|
| 76 |
-
"schedule": "7:00, 13:00, 19:00 VN time",
|
| 77 |
-
"today": today_str,
|
| 78 |
-
"slots_ran_today": ran_today,
|
| 79 |
-
"catch_up_enabled": True,
|
| 80 |
-
"next_run": "7:00, 13:00, or 19:00 VN time (whichever is next)"
|
| 81 |
-
})
|
| 82 |
-
|
| 83 |
-
@app.post('/api/scheduler/trigger')
|
| 84 |
-
async def scheduler_trigger():
|
| 85 |
-
try:
|
| 86 |
-
import auto_scheduler as _as2
|
| 87 |
-
_as2._run_scheduled_posting()
|
| 88 |
-
return JSONResponse({"ok": True, "message": "Scheduled posting triggered manually"})
|
| 89 |
-
except Exception as e:
|
| 90 |
-
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
|
| 91 |
-
|
| 92 |
-
@app.get('/api/scheduler/force')
|
| 93 |
-
def scheduler_force():
|
| 94 |
-
"""Force-run all missed slots immediately. Useful after deploy."""
|
| 95 |
-
try:
|
| 96 |
-
import auto_scheduler as _as2
|
| 97 |
-
_as2._check_missed_slots()
|
| 98 |
-
return JSONResponse({"ok": True, "message": "Missed slots check triggered"})
|
| 99 |
-
except Exception as e:
|
| 100 |
-
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
|
| 101 |
-
|
| 102 |
-
return app
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
# Auto-register on the main app from app_v2_entry
|
| 106 |
-
try:
|
| 107 |
-
from main import app
|
| 108 |
-
register_scheduler_endpoints(app)
|
| 109 |
-
LOG.info("[app_v2_patch] Scheduler endpoints registered: /api/scheduler/status, /api/scheduler/trigger, /api/scheduler/force")
|
| 110 |
-
except Exception as e:
|
| 111 |
-
LOG.error(f"[app_v2_patch] Could not register endpoints: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
auto_scheduler.py
DELETED
|
@@ -1,396 +0,0 @@
|
|
| 1 |
-
"""VNEWS Auto Scheduler - tự động đăng 3 bài rewrite AI + shorts từ 3 chủ đề HOT
|
| 2 |
-
Vào các khung giờ: 7:00, 13:00, 19:00 (giờ Việt Nam)
|
| 3 |
-
Mỗi bài: Rewrite AI từ nguồn báo + short video tự động
|
| 4 |
-
FIX v7: Giữ nguyên tiêu đề gốc từng bài viết + thêm "Tin tóm tắt VNEWS 7h sáng/13h trưa/19h tối" ở đầu text
|
| 5 |
-
"""
|
| 6 |
-
import os, re, json, time, threading, asyncio, logging, random, hashlib, html as html_lib
|
| 7 |
-
from datetime import datetime, timezone, timedelta, date
|
| 8 |
-
from urllib.parse import quote
|
| 9 |
-
import requests
|
| 10 |
-
from bs4 import BeautifulSoup
|
| 11 |
-
|
| 12 |
-
# Import storage for persistent data
|
| 13 |
-
from storage import load_wall_posts, save_wall_posts, DATA_DIR
|
| 14 |
-
|
| 15 |
-
VN_TZ = timezone(timedelta(hours=7))
|
| 16 |
-
LOG = logging.getLogger("auto_scheduler")
|
| 17 |
-
LOG.setLevel(logging.INFO)
|
| 18 |
-
if not LOG.handlers:
|
| 19 |
-
ch = logging.StreamHandler()
|
| 20 |
-
ch.setFormatter(logging.Formatter('%(asctime)s [%(name)s] %(levelname)s: %(message)s'))
|
| 21 |
-
LOG.addHandler(ch)
|
| 22 |
-
|
| 23 |
-
SCHEDULE_TIMES = [(7, 0), (13, 0), (19, 0)]
|
| 24 |
-
SCHEDULE_LABELS = {t: f"{t[0]:02d}:{t[1]:02d}" for t in SCHEDULE_TIMES}
|
| 25 |
-
|
| 26 |
-
os.makedirs(DATA_DIR, exist_ok=True)
|
| 27 |
-
SCHEDULE_STATE_FILE = os.path.join(DATA_DIR, 'scheduler_state.json')
|
| 28 |
-
|
| 29 |
-
def _load_state():
|
| 30 |
-
try:
|
| 31 |
-
if os.path.exists(SCHEDULE_STATE_FILE):
|
| 32 |
-
with open(SCHEDULE_STATE_FILE, 'r') as f: return json.load(f)
|
| 33 |
-
except: pass
|
| 34 |
-
return {}
|
| 35 |
-
|
| 36 |
-
def _save_state(state):
|
| 37 |
-
try:
|
| 38 |
-
tmp = SCHEDULE_STATE_FILE + '.tmp'
|
| 39 |
-
with open(tmp, 'w') as f: json.dump(state, f, ensure_ascii=False)
|
| 40 |
-
os.replace(tmp, SCHEDULE_STATE_FILE)
|
| 41 |
-
except Exception as e: LOG.warning(f"Cannot save state: {e}")
|
| 42 |
-
|
| 43 |
-
_STOP = set('và của các những một được trong với cho tại sau trước khi không người vietnam hôm nay mới nhất nóng tin tức cập nhật theo từ đến là có thì này đã để'.split())
|
| 44 |
-
|
| 45 |
-
def _clean(s):
|
| 46 |
-
s = html_lib.unescape(s or "")
|
| 47 |
-
# FIX: Remove malformed HTML artifacts (truncated tags without closing >)
|
| 48 |
-
s = s.replace('<a href=" src="', '').replace("<a href=' src='", '')
|
| 49 |
-
s = s.replace('<a href=" src=', '').replace("<a href=' src=", '')
|
| 50 |
-
s = re.sub(r'<[^>]+>', '', s) # Remove all HTML tags
|
| 51 |
-
return re.sub(r"\s+", " ", s).strip()
|
| 52 |
-
|
| 53 |
-
def _get_hot_topics():
|
| 54 |
-
freq = {}; display = {}
|
| 55 |
-
feeds = [
|
| 56 |
-
'https://vnexpress.net/rss/tin-moi-nhat.rss',
|
| 57 |
-
'https://dantri.com.vn/rss/home.rss',
|
| 58 |
-
'https://vietnamnet.vn/rss/tin-moi-nhat.rss',
|
| 59 |
-
'https://thanhnien.vn/rss/home.rss',
|
| 60 |
-
'https://tuoitre.vn/rss/tin-moi-nhat.rss',
|
| 61 |
-
'https://genk.vn/rss',
|
| 62 |
-
'https://vnexpress.net/rss/the-thao.rss',
|
| 63 |
-
'https://thethaovanhoa.vn/rss/tin-nong.rss',
|
| 64 |
-
'https://vnexpress.net/rss/kinh-doanh.rss',
|
| 65 |
-
'https://dantri.com.vn/rss/the-gioi.rss',
|
| 66 |
-
]
|
| 67 |
-
for feed_url in feeds:
|
| 68 |
-
try:
|
| 69 |
-
r = requests.get(feed_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=6)
|
| 70 |
-
r.encoding = 'utf-8'
|
| 71 |
-
soup = BeautifulSoup(r.text, 'xml')
|
| 72 |
-
for item in soup.find_all('item')[:12]:
|
| 73 |
-
title = _clean(item.find('title').get_text() if item.find('title') else '')
|
| 74 |
-
if not title: continue
|
| 75 |
-
title = re.sub(r'\s*[-|].*$', '', title)
|
| 76 |
-
words = [w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+', title) if len(w) > 2 and w.lower() not in _STOP]
|
| 77 |
-
if len(words) < 2: continue
|
| 78 |
-
for n in (3, 4, 2):
|
| 79 |
-
for i in range(max(0, len(words) - n + 1)):
|
| 80 |
-
phrase = ' '.join(words[i:i + n])
|
| 81 |
-
if 8 <= len(phrase) <= 45:
|
| 82 |
-
key = phrase.lower()
|
| 83 |
-
freq[key] = freq.get(key, 0) + 1
|
| 84 |
-
display[key] = phrase
|
| 85 |
-
except: continue
|
| 86 |
-
ranked = sorted(freq.items(), key=lambda x: x[1], reverse=True)
|
| 87 |
-
topics = []; seen = set()
|
| 88 |
-
for key, count in ranked:
|
| 89 |
-
kw = display[key]
|
| 90 |
-
is_dup = any(len(set(e.split()) & set(key.split())) / max(len(set(e.split())), len(set(key.split())), 1) > 0.6 for e in seen)
|
| 91 |
-
if is_dup: continue
|
| 92 |
-
seen.add(key)
|
| 93 |
-
topics.append({'label': '#' + re.sub(r'\s+', '', kw.title()), 'topic': kw, 'count': count})
|
| 94 |
-
if len(topics) >= 20: break
|
| 95 |
-
for kw in ['World Cup 2026', 'Kinh tế Việt Nam', 'Bóng đá châu Âu', 'Công nghệ AI', 'Giá vàng', 'Thời tiết']:
|
| 96 |
-
if len(topics) >= 24: break
|
| 97 |
-
if not any(kw.lower() in s for s in seen):
|
| 98 |
-
topics.append({'label': '#' + re.sub(r'\s+', '', kw.title()), 'topic': kw, 'count': 0})
|
| 99 |
-
return topics[:24]
|
| 100 |
-
|
| 101 |
-
_ai_ext = None; _ai_patch = None
|
| 102 |
-
def _get_ai_ext():
|
| 103 |
-
global _ai_ext
|
| 104 |
-
if _ai_ext is None: import ai_ext as m; _ai_ext = m
|
| 105 |
-
return _ai_ext
|
| 106 |
-
def _get_ai_patch():
|
| 107 |
-
global _ai_patch
|
| 108 |
-
if _ai_patch is None: import ai_patch as m; _ai_patch = m
|
| 109 |
-
return _ai_patch
|
| 110 |
-
|
| 111 |
-
_RSS_FEEDS = [
|
| 112 |
-
('https://vnexpress.net/rss/tin-moi-nhat.rss', 'VnExpress'),
|
| 113 |
-
('https://dantri.com.vn/rss/home.rss', 'Dân Trí'),
|
| 114 |
-
('https://vietnamnet.vn/rss/tin-moi-nhat.rss', 'VietNamNet'),
|
| 115 |
-
('https://thanhnien.vn/rss/home.rss', 'Thanh Niên'),
|
| 116 |
-
('https://tuoitre.vn/rss/tin-moi-nhat.rss', 'Tuổi Trẻ'),
|
| 117 |
-
('https://genk.vn/rss', 'GenK'),
|
| 118 |
-
('https://vnexpress.net/rss/the-thao.rss', 'VnExpress'),
|
| 119 |
-
('https://thethaovanhoa.vn/rss/tin-nong.rss', 'TT&VH'),
|
| 120 |
-
('https://vnexpress.net/rss/kinh-doanh.rss', 'VnExpress'),
|
| 121 |
-
('https://dantri.com.vn/rss/the-gioi.rss', 'Dân Trí'),
|
| 122 |
-
]
|
| 123 |
-
|
| 124 |
-
def _search_articles_by_topic(topic, limit=4):
|
| 125 |
-
all_articles = []; seen_urls = set()
|
| 126 |
-
topic_lower = topic.lower()
|
| 127 |
-
topic_words = set(re.findall(r'[A-Za-zÀ-ỹ0-9]+', topic_lower))
|
| 128 |
-
for feed_url, source in _RSS_FEEDS:
|
| 129 |
-
try:
|
| 130 |
-
r = requests.get(feed_url, headers={'User-Agent': 'Mozilla/5.0'}, timeout=6)
|
| 131 |
-
r.encoding = 'utf-8'
|
| 132 |
-
soup = BeautifulSoup(r.text, 'xml')
|
| 133 |
-
for item in soup.find_all('item')[:8]:
|
| 134 |
-
title = _clean(item.find('title').get_text() if item.find('title') else '')
|
| 135 |
-
link = _clean(item.find('link').get_text() if item.find('link') else '')
|
| 136 |
-
desc = _clean(item.find('description').get_text() if item.find('description') else '')
|
| 137 |
-
if not title or not link or link in seen_urls: continue
|
| 138 |
-
seen_urls.add(link)
|
| 139 |
-
title_words = set(re.findall(r'[A-Za-zÀ-ỹ0-9]+', title.lower()))
|
| 140 |
-
overlap = len(topic_words & title_words) if topic_words else 0
|
| 141 |
-
exact_match = topic_lower in title.lower() or topic_lower in desc.lower()
|
| 142 |
-
if exact_match or overlap >= 2:
|
| 143 |
-
img = ''
|
| 144 |
-
encl = item.find('enclosure')
|
| 145 |
-
if encl: img = encl.get('url', '')
|
| 146 |
-
if not img:
|
| 147 |
-
try:
|
| 148 |
-
art_r = requests.get(link, headers={'User-Agent': 'Mozilla/5.0'}, timeout=4)
|
| 149 |
-
art_r.encoding = 'utf-8'
|
| 150 |
-
art_soup = BeautifulSoup(art_r.text, 'lxml')
|
| 151 |
-
ogi = art_soup.find('meta', property='og:image')
|
| 152 |
-
if ogi: img = ogi.get('content', '')
|
| 153 |
-
except: pass
|
| 154 |
-
all_articles.append({'title': title, 'url': link, 'raw': desc or title, 'image': img, 'via': source, 'source': {'title': title, 'url': link, 'excerpt': (desc or title)[:700], 'via': source}})
|
| 155 |
-
if len(all_articles) >= limit: break
|
| 156 |
-
except: continue
|
| 157 |
-
return all_articles[:limit]
|
| 158 |
-
|
| 159 |
-
async def _create_ai_post(topic):
|
| 160 |
-
ai_ext = _get_ai_ext(); ai_patch = _get_ai_patch()
|
| 161 |
-
articles = _search_articles_by_topic(topic, limit=4)
|
| 162 |
-
if not articles:
|
| 163 |
-
LOG.warning(f"No articles for topic: {topic}. Fallback.")
|
| 164 |
-
return await _create_fallback_post(topic, ai_ext, ai_patch)
|
| 165 |
-
posts = []
|
| 166 |
-
# Get schedule time label for text intro (7h sáng, 13h trưa, 19h tối)
|
| 167 |
-
now = datetime.now(VN_TZ)
|
| 168 |
-
hour = now.hour
|
| 169 |
-
time_label = "7h sáng" if hour == 7 else ("13h trưa" if hour == 13 else "19h tối")
|
| 170 |
-
text_intro = f"Tin tóm tắt VNEWS {time_label}"
|
| 171 |
-
wall = ai_ext._load_ai_wall()
|
| 172 |
-
if not isinstance(wall, list): wall = []
|
| 173 |
-
for art in articles:
|
| 174 |
-
try:
|
| 175 |
-
prompt = ai_patch._make_summary_prompt(art.get('title', topic), art.get('raw', ''), art.get('via', ''))
|
| 176 |
-
text = await ai_ext.qwen_generate(prompt, image_url=art.get('image'), max_tokens=1500)
|
| 177 |
-
text = ai_patch._postprocess_ai_text(text, max_units=20)
|
| 178 |
-
src = [art.get('source', {'title': art.get('title', topic), 'url': art.get('url', ''), 'via': art.get('via', '')})]
|
| 179 |
-
# Prepend time label intro to text (giữ nguyên title là tiêu đề gốc của bài báo)
|
| 180 |
-
if text and not text.startswith(text_intro):
|
| 181 |
-
text = f"{text_intro}\n\n{text}"
|
| 182 |
-
if 'Nguồn tham khảo:' not in (text or ''):
|
| 183 |
-
text = (text or '') + "\n\n" + ai_patch._source_line(src)
|
| 184 |
-
img = art.get('image') or ai_ext.pollination_image_url(art.get('title', topic))
|
| 185 |
-
# Dùng art.get('title') GIỮ NGUYÊN tiêu đề gốc từ bài báo
|
| 186 |
-
post = ai_ext.make_post(art.get('title', topic), text, img, art.get('url', ''), 'auto_scheduled', sources=src)
|
| 187 |
-
try:
|
| 188 |
-
page_data = ai_patch._scrape_article_images(art.get('url', ''))
|
| 189 |
-
if page_data and page_data.get('paragraphs'):
|
| 190 |
-
kp = ai_patch._extract_key_points_for_slides(page_data['paragraphs'], max_points=8)
|
| 191 |
-
if kp:
|
| 192 |
-
imgs = page_data.get('images', [])
|
| 193 |
-
if not imgs and page_data.get('og_img'): imgs = [page_data['og_img']]
|
| 194 |
-
slides = []
|
| 195 |
-
for i, pt in enumerate(kp):
|
| 196 |
-
slides.append({'text': pt, 'image': imgs[i] if i < len(imgs) else (imgs[-1] if imgs else ''), 'index': i + 1})
|
| 197 |
-
post['slides'] = slides
|
| 198 |
-
except: pass
|
| 199 |
-
posts.append(post)
|
| 200 |
-
except Exception as e:
|
| 201 |
-
LOG.error(f"Error post: {e}")
|
| 202 |
-
if not posts: return await _create_fallback_post(topic, ai_ext, ai_patch)
|
| 203 |
-
wall = posts + wall
|
| 204 |
-
ai_ext._save_ai_wall(wall)
|
| 205 |
-
for post in posts:
|
| 206 |
-
try: _try_generate_short(post)
|
| 207 |
-
except: pass
|
| 208 |
-
return posts
|
| 209 |
-
|
| 210 |
-
async def _create_fallback_post(topic, ai_ext, ai_patch):
|
| 211 |
-
LOG.info(f"Fallback: {topic}")
|
| 212 |
-
try:
|
| 213 |
-
# Still add time label to fallback posts
|
| 214 |
-
now = datetime.now(VN_TZ)
|
| 215 |
-
hour = now.hour
|
| 216 |
-
time_label = "7h sáng" if hour == 7 else ("13h trưa" if hour == 13 else "19h tối")
|
| 217 |
-
text_intro = f"Tin tóm tắt VNEWS {time_label}"
|
| 218 |
-
text = f"{text_intro}\n\n• {topic} đang là chủ đề nóng hôm nay.\n• Theo dõi VNEWS để cập nhật tin tức mới nhất."
|
| 219 |
-
img = ai_ext.pollination_image_url(topic)
|
| 220 |
-
post = ai_ext.make_post(topic, text, img, '', 'auto_scheduled', sources=[])
|
| 221 |
-
wall = ai_ext._load_ai_wall()
|
| 222 |
-
if not isinstance(wall, list): wall = []
|
| 223 |
-
wall = [post] + wall
|
| 224 |
-
ai_ext._save_ai_wall(wall)
|
| 225 |
-
LOG.info(f"Fallback saved: {topic}")
|
| 226 |
-
return [post]
|
| 227 |
-
except Exception as e:
|
| 228 |
-
LOG.error(f"Fallback failed: {e}")
|
| 229 |
-
return []
|
| 230 |
-
|
| 231 |
-
def _try_generate_short(post):
|
| 232 |
-
post_id = post.get('id', '')
|
| 233 |
-
if not post_id: return
|
| 234 |
-
try:
|
| 235 |
-
ai_ext = _get_ai_ext(); ai_patch = _get_ai_patch()
|
| 236 |
-
if ai_ext.gTTS is None: return
|
| 237 |
-
segments = ai_patch._summary_segments_from_post(post, max_segments=15)
|
| 238 |
-
if not segments: return
|
| 239 |
-
seg_hash = hashlib.md5(('|'.join(segments) + 'nu' + 'neutral' + '1.0').encode('utf-8')).hexdigest()[:8]
|
| 240 |
-
suffix = f"_nu_neutral_1p0_{seg_hash}_scenes_nosub"
|
| 241 |
-
out_mp4 = os.path.join(ai_ext.SHORTS_DIR, ai_ext._safe_name(post_id + suffix) + '.mp4')
|
| 242 |
-
if os.path.exists(out_mp4):
|
| 243 |
-
post['video'] = '/api/ai/short-file/' + post_id + suffix
|
| 244 |
-
wall = ai_ext._load_ai_wall()
|
| 245 |
-
for i, p in enumerate(wall):
|
| 246 |
-
if p.get('id') == post_id: wall[i] = post; break
|
| 247 |
-
ai_ext._save_ai_wall(wall); return
|
| 248 |
-
threading.Thread(target=lambda: _generate_short_worker(post, segments, post_id, suffix, out_mp4), daemon=True).start()
|
| 249 |
-
except Exception as e: LOG.warning(f"Short init: {e}")
|
| 250 |
-
|
| 251 |
-
def _generate_short_worker(post, segments, post_id, suffix, out_mp4):
|
| 252 |
-
import subprocess
|
| 253 |
-
try:
|
| 254 |
-
ai_ext = _get_ai_ext(); ai_patch = _get_ai_patch()
|
| 255 |
-
work = os.path.join(ai_ext.SHORTS_DIR, ai_ext._safe_name(post_id + suffix))
|
| 256 |
-
os.makedirs(work, exist_ok=True)
|
| 257 |
-
img = os.path.join(work, 'image.jpg')
|
| 258 |
-
ai_ext._download_image(post.get('img'), post.get('title', 'AI news'), img)
|
| 259 |
-
part_files = []
|
| 260 |
-
for idx, seg in enumerate(segments[:10]):
|
| 261 |
-
frame = os.path.join(work, f'frame_{idx:02d}.jpg')
|
| 262 |
-
aud = os.path.join(work, f'voice_{idx:02d}.mp3')
|
| 263 |
-
aud_fast = os.path.join(work, f'voice_{idx:02d}_fast.mp3')
|
| 264 |
-
part = os.path.join(work, f'part_{idx:02d}.mp4')
|
| 265 |
-
try: ai_patch._make_scene_frame(post, seg, idx, min(len(segments), 10), img, frame, emotion='neutral')
|
| 266 |
-
except:
|
| 267 |
-
if not os.path.exists(img): continue
|
| 268 |
-
from PIL import Image
|
| 269 |
-
Image.new('RGB', (1080, 1920), (14, 14, 14)).save(frame, quality=85)
|
| 270 |
-
tts_text = re.sub(r'^[•\-\*\d\.\)\s]+', '', seg).strip()
|
| 271 |
-
try: ai_ext.gTTS(tts_text, lang='vi', slow=False).save(aud)
|
| 272 |
-
except:
|
| 273 |
-
try: ai_ext.gTTS(tts_text, lang='vi', tld='com.vn', slow=False).save(aud)
|
| 274 |
-
except: continue
|
| 275 |
-
subprocess.run(['ffmpeg', '-y', '-i', aud, '-filter:a', 'atempo=1.0', '-vn', aud_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90)
|
| 276 |
-
dur = 12.0
|
| 277 |
-
try:
|
| 278 |
-
pr = subprocess.run(['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:no_key=1', aud_fast], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
|
| 279 |
-
dur = max(8.0, float((pr.stdout or b'').decode().strip() or 12.0)) + 0.5
|
| 280 |
-
except: pass
|
| 281 |
-
subprocess.run(['ffmpeg', '-y', '-loop', '1', '-t', str(dur), '-i', frame, '-i', aud_fast, '-shortest', '-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '128k', part], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=150)
|
| 282 |
-
part_files.append(part)
|
| 283 |
-
if part_files:
|
| 284 |
-
concat = os.path.join(work, 'concat.txt')
|
| 285 |
-
with open(concat, 'w', encoding='utf-8') as f:
|
| 286 |
-
for p in part_files: f.write("file '" + p.replace("'", "'\\''") + "'\n")
|
| 287 |
-
subprocess.run(['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', concat, '-c', 'copy', out_mp4], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
|
| 288 |
-
post['video'] = '/api/ai/short-file/' + post_id + suffix
|
| 289 |
-
post['short_voice'] = 'nu'; post['short_emotion'] = 'neutral'; post['short_speed'] = 1.0
|
| 290 |
-
post['short_segments'] = segments; post['short_subtitles'] = False
|
| 291 |
-
wall = ai_ext._load_ai_wall()
|
| 292 |
-
for i, p in enumerate(wall):
|
| 293 |
-
if p.get('id') == post_id: wall[i] = post; break
|
| 294 |
-
ai_ext._save_ai_wall(wall)
|
| 295 |
-
LOG.info(f"Short: {post_id}")
|
| 296 |
-
except Exception as e: LOG.warning(f"Short fail: {e}")
|
| 297 |
-
|
| 298 |
-
def _run_async(coro):
|
| 299 |
-
"""Run async coroutine safely regardless of current event loop state."""
|
| 300 |
-
try:
|
| 301 |
-
loop = asyncio.get_running_loop()
|
| 302 |
-
except RuntimeError:
|
| 303 |
-
return asyncio.run(coro)
|
| 304 |
-
import concurrent.futures
|
| 305 |
-
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
| 306 |
-
return pool.submit(asyncio.run, coro).result(timeout=300)
|
| 307 |
-
|
| 308 |
-
def _run_scheduled_posting():
|
| 309 |
-
LOG.info("=" * 50)
|
| 310 |
-
LOG.info("Scheduler triggered at %s", datetime.now(VN_TZ).strftime('%H:%M %d/%m/%Y'))
|
| 311 |
-
LOG.info("=" * 50)
|
| 312 |
-
try:
|
| 313 |
-
hot_topics = _get_hot_topics()
|
| 314 |
-
if not hot_topics:
|
| 315 |
-
LOG.warning("No hot topics"); return
|
| 316 |
-
selected = []; seen_labels = set()
|
| 317 |
-
for t in hot_topics:
|
| 318 |
-
label = t.get('label', '')
|
| 319 |
-
if label and label not in seen_labels:
|
| 320 |
-
seen_labels.add(label); selected.append(t['topic'])
|
| 321 |
-
if len(selected) >= 3: break
|
| 322 |
-
if len(selected) < 3:
|
| 323 |
-
selected = ['Thời sự Việt Nam', 'Kinh tế Việt Nam', 'Thể thao']
|
| 324 |
-
LOG.info(f"Topics: {selected}")
|
| 325 |
-
async def _do_all():
|
| 326 |
-
results = []
|
| 327 |
-
for topic in selected:
|
| 328 |
-
try:
|
| 329 |
-
posts = await _create_ai_post(topic)
|
| 330 |
-
results.append({'topic': topic, 'posts': len(posts) if posts else 0})
|
| 331 |
-
LOG.info(f"{'✓' if posts else '✗'} {topic}: {len(posts) if posts else 0} posts")
|
| 332 |
-
except Exception as e:
|
| 333 |
-
LOG.error(f"Error {topic}: {e}")
|
| 334 |
-
results.append({'topic': topic, 'posts': 0})
|
| 335 |
-
return results
|
| 336 |
-
results = _run_async(_do_all())
|
| 337 |
-
LOG.info(f"Done: {len(results)} topics")
|
| 338 |
-
for r in results: LOG.info(f" • {r['topic']}: {r['posts']} bài")
|
| 339 |
-
except Exception as e:
|
| 340 |
-
LOG.error(f"Scheduler error: {e}", exc_info=True)
|
| 341 |
-
|
| 342 |
-
def _check_missed_slots():
|
| 343 |
-
try:
|
| 344 |
-
state = _load_state()
|
| 345 |
-
today_str = datetime.now(VN_TZ).strftime('%Y-%m-%d')
|
| 346 |
-
now = datetime.now(VN_TZ); cur_mins = now.hour * 60 + now.minute
|
| 347 |
-
ran = state.get(today_str, {})
|
| 348 |
-
for s in SCHEDULE_TIMES:
|
| 349 |
-
lbl = SCHEDULE_LABELS[s]; sm = s[0] * 60 + s[1]
|
| 350 |
-
if ran.get(lbl): continue
|
| 351 |
-
if cur_mins >= sm:
|
| 352 |
-
LOG.info(f"Catch-up: {lbl}")
|
| 353 |
-
_run_scheduled_posting()
|
| 354 |
-
if today_str not in state: state[today_str] = {}
|
| 355 |
-
state[today_str][lbl] = True; _save_state(state)
|
| 356 |
-
except Exception as e: LOG.error(f"Catch-up: {e}")
|
| 357 |
-
|
| 358 |
-
def _scheduler_loop():
|
| 359 |
-
LOG.info("Scheduler started")
|
| 360 |
-
LOG.info(f"Schedule: {', '.join(f'{h:02d}:{m:02d}' for h,m in SCHEDULE_TIMES)} VN")
|
| 361 |
-
state = _load_state(); today_str = datetime.now(VN_TZ).strftime('%Y-%m-%d')
|
| 362 |
-
ran = state.get(today_str, {})
|
| 363 |
-
now = datetime.now(VN_TZ); cur_mins = now.hour * 60 + now.minute
|
| 364 |
-
for s in SCHEDULE_TIMES:
|
| 365 |
-
lbl = SCHEDULE_LABELS[s]; sm = s[0] * 60 + s[1]
|
| 366 |
-
if ran.get(lbl): LOG.info(f" ✓ {lbl} done"); continue
|
| 367 |
-
if cur_mins >= sm:
|
| 368 |
-
LOG.info(f" → {lbl} missed! Catch-up")
|
| 369 |
-
_run_scheduled_posting()
|
| 370 |
-
if today_str not in state: state[today_str] = {}
|
| 371 |
-
state[today_str][lbl] = True; _save_state(state)
|
| 372 |
-
else: LOG.info(f" ⏩ {lbl} upcoming")
|
| 373 |
-
while True:
|
| 374 |
-
try:
|
| 375 |
-
now = datetime.now(VN_TZ)
|
| 376 |
-
ck = (now.hour, now.minute)
|
| 377 |
-
state = _load_state(); today_str = now.strftime('%Y-%m-%d')
|
| 378 |
-
ran = state.get(today_str, {})
|
| 379 |
-
for s in SCHEDULE_TIMES:
|
| 380 |
-
lbl = SCHEDULE_LABELS[s]
|
| 381 |
-
if ck == s and not ran.get(lbl):
|
| 382 |
-
LOG.info(f"On-time: {lbl}")
|
| 383 |
-
_run_scheduled_posting()
|
| 384 |
-
if today_str not in state: state[today_str] = {}
|
| 385 |
-
state[today_str][lbl] = True; _save_state(state)
|
| 386 |
-
break
|
| 387 |
-
time.sleep(60)
|
| 388 |
-
except Exception as e:
|
| 389 |
-
LOG.error(f"Loop: {e}")
|
| 390 |
-
time.sleep(60)
|
| 391 |
-
|
| 392 |
-
def start_auto_scheduler():
|
| 393 |
-
t = threading.Thread(target=_scheduler_loop, daemon=True, name="auto-scheduler")
|
| 394 |
-
t.start()
|
| 395 |
-
LOG.info("Auto scheduler started")
|
| 396 |
-
return t
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
auto_update_sse.py
DELETED
|
@@ -1,55 +0,0 @@
|
|
| 1 |
-
"""Auto-update SSE endpoint for VNEWS - pushes updates when new posts/shorts published."""
|
| 2 |
-
import asyncio
|
| 3 |
-
import json
|
| 4 |
-
import time
|
| 5 |
-
from fastapi import Request
|
| 6 |
-
from fastapi.responses import StreamingResponse
|
| 7 |
-
|
| 8 |
-
# Connected clients queue
|
| 9 |
-
_clients = []
|
| 10 |
-
_lock = asyncio.Lock()
|
| 11 |
-
|
| 12 |
-
async def _notify_clients(event_type: str, data: dict):
|
| 13 |
-
"""Send notification to all SSE clients."""
|
| 14 |
-
if not _clients:
|
| 15 |
-
return
|
| 16 |
-
msg = f"data: {json.dumps({'type': event_type, 'data': data, 'ts': int(time.time())})}\n\n"
|
| 17 |
-
async with _lock:
|
| 18 |
-
dead = []
|
| 19 |
-
for q in _clients:
|
| 20 |
-
try:
|
| 21 |
-
await q.put_nowait(msg)
|
| 22 |
-
except asyncio.QueueFull:
|
| 23 |
-
pass
|
| 24 |
-
except:
|
| 25 |
-
dead.append(q)
|
| 26 |
-
for q in dead:
|
| 27 |
-
if q in _clients:
|
| 28 |
-
_clients.remove(q)
|
| 29 |
-
|
| 30 |
-
# Public functions to call from other modules
|
| 31 |
-
notify_new_post = lambda post: asyncio.create_task(_notify_clients("new_post", post)) if post else None
|
| 32 |
-
notify_new_short = lambda post: asyncio.create_task(_notify_clients("new_short", post)) if post else None
|
| 33 |
-
|
| 34 |
-
async def sse_events(request: Request):
|
| 35 |
-
"""SSE endpoint for real-time updates on homepage."""
|
| 36 |
-
q = asyncio.Queue(maxsize=10)
|
| 37 |
-
_clients.append(q)
|
| 38 |
-
|
| 39 |
-
async def event_generator():
|
| 40 |
-
try:
|
| 41 |
-
# Send initial connection message
|
| 42 |
-
yield "data: {\"type\":\"connected\",\"ts\":null}\n\n"
|
| 43 |
-
while not await request.is_disconnected():
|
| 44 |
-
try:
|
| 45 |
-
msg = await asyncio.wait_for(q.get(), timeout=25.0)
|
| 46 |
-
yield msg
|
| 47 |
-
except asyncio.TimeoutError:
|
| 48 |
-
yield ":keepalive\n\n"
|
| 49 |
-
except:
|
| 50 |
-
pass
|
| 51 |
-
finally:
|
| 52 |
-
if q in _clients:
|
| 53 |
-
_clients.remove(q)
|
| 54 |
-
|
| 55 |
-
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
bongda_proxy.py
CHANGED
|
@@ -4,6 +4,7 @@ from bs4 import BeautifulSoup
|
|
| 4 |
import re
|
| 5 |
import json
|
| 6 |
|
|
|
|
| 7 |
def _cl(s):
|
| 8 |
return re.sub(r'\s+', ' ', str(s or '')).strip()
|
| 9 |
|
|
@@ -14,18 +15,23 @@ def _normalize_time(raw):
|
|
| 14 |
return t
|
| 15 |
|
| 16 |
def scrape_match_html(event_id, url=None):
|
|
|
|
| 17 |
result = {"event_id": event_id, "found": False, "sections": []}
|
|
|
|
|
|
|
| 18 |
headers = {
|
| 19 |
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
| 20 |
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
| 21 |
"Referer": "https://bongda.com.vn/",
|
| 22 |
}
|
|
|
|
| 23 |
html = None
|
| 24 |
urls_to_try = [url] if url else []
|
| 25 |
urls_to_try += [
|
| 26 |
f"https://bongda.com.vn/tran-dau/{event_id}/centre/",
|
| 27 |
f"https://bongda.com.vn/tran-dau/{event_id}/preview/",
|
| 28 |
]
|
|
|
|
| 29 |
for u in urls_to_try:
|
| 30 |
if not u:
|
| 31 |
continue
|
|
@@ -36,78 +42,203 @@ def scrape_match_html(event_id, url=None):
|
|
| 36 |
break
|
| 37 |
except Exception:
|
| 38 |
continue
|
|
|
|
| 39 |
if not html:
|
| 40 |
return result
|
|
|
|
| 41 |
try:
|
| 42 |
soup = BeautifulSoup(html, 'html.parser')
|
| 43 |
info = {}
|
|
|
|
|
|
|
| 44 |
tel = soup.select_one('.teams')
|
| 45 |
if tel:
|
| 46 |
he = tel.select_one('.team.home')
|
| 47 |
if he:
|
| 48 |
ne = he.select_one('p:not(.logo)') or he.find('p')
|
| 49 |
-
if ne:
|
|
|
|
| 50 |
lo = he.select_one('img')
|
| 51 |
-
if lo:
|
|
|
|
|
|
|
| 52 |
ae = tel.select_one('.team.away')
|
| 53 |
if ae:
|
| 54 |
ne = ae.select_one('p:not(.logo)') or ae.find('p')
|
| 55 |
-
if ne:
|
|
|
|
| 56 |
lo = ae.select_one('img')
|
| 57 |
-
if lo:
|
|
|
|
|
|
|
| 58 |
sc = tel.select_one('.score')
|
| 59 |
if sc:
|
| 60 |
parts = [_cl(p.get_text()) for p in sc.select('p')]
|
| 61 |
-
if len(parts) >= 2:
|
|
|
|
| 62 |
lb = sc.select_one('.label')
|
| 63 |
-
if lb:
|
|
|
|
|
|
|
| 64 |
if info.get('home_team') and info.get('away_team'):
|
| 65 |
result['info'] = info
|
| 66 |
result['found'] = True
|
| 67 |
result['sections'].append('info')
|
| 68 |
else:
|
| 69 |
return result
|
|
|
|
|
|
|
| 70 |
events = []
|
| 71 |
events_div = soup.select_one('.events')
|
| 72 |
if events_div:
|
| 73 |
period = ''
|
| 74 |
for child in events_div.children:
|
| 75 |
-
if not hasattr(child, 'name') or not child.name:
|
|
|
|
| 76 |
cls = ' '.join(child.get('class', []))
|
| 77 |
if 'period' in cls:
|
| 78 |
h2 = child.find('h2')
|
| 79 |
-
if h2:
|
|
|
|
| 80 |
for ev in child.children:
|
| 81 |
-
if not hasattr(ev, 'name') or not ev.name:
|
|
|
|
| 82 |
ev_cls = ' '.join(ev.get('class', []))
|
| 83 |
-
if 'event' not in ev_cls:
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
type_el = ev.select_one('.event-type')
|
| 86 |
if type_el:
|
| 87 |
-
if type_el.select_one('[class*="redcard"]'):
|
| 88 |
-
|
| 89 |
-
elif type_el.select_one('[class*="
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
players_el = ev.select_one('.players')
|
| 92 |
if players_el:
|
| 93 |
time_el = players_el.select_one('.event-time')
|
| 94 |
-
if time_el:
|
|
|
|
|
|
|
|
|
|
| 95 |
text = _cl(players_el.get_text(' ', strip=True).replace(ev_data['time'], '').strip())
|
| 96 |
ev_data['players'] = text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
events.append(ev_data)
|
|
|
|
| 98 |
if events:
|
| 99 |
result['events'] = events
|
| 100 |
result['sections'].append('events')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
except Exception as e:
|
| 102 |
result['error'] = str(e)
|
|
|
|
| 103 |
return result
|
| 104 |
|
|
|
|
|
|
|
| 105 |
from fastapi import Query
|
| 106 |
from fastapi.responses import JSONResponse
|
| 107 |
|
| 108 |
def add_bongda_proxy_endpoint(app):
|
| 109 |
@app.get('/api/proxy/bongda')
|
| 110 |
def proxy_bongda(event_id: int = Query(default=None), url: str = Query(default=None)):
|
|
|
|
| 111 |
if event_id is None:
|
| 112 |
return JSONResponse({'error': 'event_id required'}, status_code=400)
|
| 113 |
-
return JSONResponse(scrape_match_html(event_id, url))
|
|
|
|
| 4 |
import re
|
| 5 |
import json
|
| 6 |
|
| 7 |
+
# Import-safe parsing functions
|
| 8 |
def _cl(s):
|
| 9 |
return re.sub(r'\s+', ' ', str(s or '')).strip()
|
| 10 |
|
|
|
|
| 15 |
return t
|
| 16 |
|
| 17 |
def scrape_match_html(event_id, url=None):
|
| 18 |
+
"""Fast scrape bongda.com.vn for match detail - no external CORS needed."""
|
| 19 |
result = {"event_id": event_id, "found": False, "sections": []}
|
| 20 |
+
|
| 21 |
+
# Fetch HTML
|
| 22 |
headers = {
|
| 23 |
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
| 24 |
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
| 25 |
"Referer": "https://bongda.com.vn/",
|
| 26 |
}
|
| 27 |
+
|
| 28 |
html = None
|
| 29 |
urls_to_try = [url] if url else []
|
| 30 |
urls_to_try += [
|
| 31 |
f"https://bongda.com.vn/tran-dau/{event_id}/centre/",
|
| 32 |
f"https://bongda.com.vn/tran-dau/{event_id}/preview/",
|
| 33 |
]
|
| 34 |
+
|
| 35 |
for u in urls_to_try:
|
| 36 |
if not u:
|
| 37 |
continue
|
|
|
|
| 42 |
break
|
| 43 |
except Exception:
|
| 44 |
continue
|
| 45 |
+
|
| 46 |
if not html:
|
| 47 |
return result
|
| 48 |
+
|
| 49 |
try:
|
| 50 |
soup = BeautifulSoup(html, 'html.parser')
|
| 51 |
info = {}
|
| 52 |
+
|
| 53 |
+
# Teams + score
|
| 54 |
tel = soup.select_one('.teams')
|
| 55 |
if tel:
|
| 56 |
he = tel.select_one('.team.home')
|
| 57 |
if he:
|
| 58 |
ne = he.select_one('p:not(.logo)') or he.find('p')
|
| 59 |
+
if ne:
|
| 60 |
+
info['home_team'] = _cl(ne.get_text())
|
| 61 |
lo = he.select_one('img')
|
| 62 |
+
if lo:
|
| 63 |
+
info['home_logo'] = lo.get('src', '')
|
| 64 |
+
|
| 65 |
ae = tel.select_one('.team.away')
|
| 66 |
if ae:
|
| 67 |
ne = ae.select_one('p:not(.logo)') or ae.find('p')
|
| 68 |
+
if ne:
|
| 69 |
+
info['away_team'] = _cl(ne.get_text())
|
| 70 |
lo = ae.select_one('img')
|
| 71 |
+
if lo:
|
| 72 |
+
info['away_logo'] = lo.get('src', '')
|
| 73 |
+
|
| 74 |
sc = tel.select_one('.score')
|
| 75 |
if sc:
|
| 76 |
parts = [_cl(p.get_text()) for p in sc.select('p')]
|
| 77 |
+
if len(parts) >= 2:
|
| 78 |
+
info['score'] = f"{parts[0]} - {parts[1]}"
|
| 79 |
lb = sc.select_one('.label')
|
| 80 |
+
if lb:
|
| 81 |
+
info['status_label'] = _cl(lb.get_text())
|
| 82 |
+
|
| 83 |
if info.get('home_team') and info.get('away_team'):
|
| 84 |
result['info'] = info
|
| 85 |
result['found'] = True
|
| 86 |
result['sections'].append('info')
|
| 87 |
else:
|
| 88 |
return result
|
| 89 |
+
|
| 90 |
+
# Events parsing
|
| 91 |
events = []
|
| 92 |
events_div = soup.select_one('.events')
|
| 93 |
if events_div:
|
| 94 |
period = ''
|
| 95 |
for child in events_div.children:
|
| 96 |
+
if not hasattr(child, 'name') or not child.name:
|
| 97 |
+
continue
|
| 98 |
cls = ' '.join(child.get('class', []))
|
| 99 |
if 'period' in cls:
|
| 100 |
h2 = child.find('h2')
|
| 101 |
+
if h2:
|
| 102 |
+
period = _cl(h2.get_text())
|
| 103 |
for ev in child.children:
|
| 104 |
+
if not hasattr(ev, 'name') or not ev.name:
|
| 105 |
+
continue
|
| 106 |
ev_cls = ' '.join(ev.get('class', []))
|
| 107 |
+
if 'event' not in ev_cls:
|
| 108 |
+
continue
|
| 109 |
+
|
| 110 |
+
ev_data = {
|
| 111 |
+
'team': 'home' if 'home' in ev_cls else 'away',
|
| 112 |
+
'period': period,
|
| 113 |
+
'type': 'unknown',
|
| 114 |
+
'time': '',
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
# Type detection
|
| 118 |
type_el = ev.select_one('.event-type')
|
| 119 |
if type_el:
|
| 120 |
+
if type_el.select_one('[class*="redcard"]'):
|
| 121 |
+
ev_data['type'] = 'redcard'
|
| 122 |
+
elif type_el.select_one('[class*="yellowcard"]'):
|
| 123 |
+
ev_data['type'] = 'yellowcard'
|
| 124 |
+
elif type_el.select_one('[class*="goal"]'):
|
| 125 |
+
ev_data['type'] = 'goal'
|
| 126 |
+
elif type_el.select_one('[class*="substitution"]'):
|
| 127 |
+
ev_data['type'] = 'substitution'
|
| 128 |
+
|
| 129 |
players_el = ev.select_one('.players')
|
| 130 |
if players_el:
|
| 131 |
time_el = players_el.select_one('.event-time')
|
| 132 |
+
if time_el:
|
| 133 |
+
ev_data['time'] = _normalize_time(time_el.get_text())
|
| 134 |
+
|
| 135 |
+
# Parse player names
|
| 136 |
text = _cl(players_el.get_text(' ', strip=True).replace(ev_data['time'], '').strip())
|
| 137 |
ev_data['players'] = text
|
| 138 |
+
|
| 139 |
+
if ev_data['type'] == 'goal':
|
| 140 |
+
words = text.split()
|
| 141 |
+
if len(words) >= 2:
|
| 142 |
+
ev_data['scorer'] = ' '.join(words[:2])
|
| 143 |
+
elif len(words) == 1:
|
| 144 |
+
ev_data['scorer'] = words[0]
|
| 145 |
+
elif ev_data['type'] == 'substitution':
|
| 146 |
+
words = text.split()
|
| 147 |
+
if len(words) >= 4:
|
| 148 |
+
ev_data['player_out'] = ' '.join(words[:len(words)//2])
|
| 149 |
+
ev_data['player_in'] = ' '.join(words[len(words)//2:])
|
| 150 |
+
elif ev_data['type'] in ('redcard', 'yellowcard'):
|
| 151 |
+
ev_data['player'] = text
|
| 152 |
+
|
| 153 |
events.append(ev_data)
|
| 154 |
+
|
| 155 |
if events:
|
| 156 |
result['events'] = events
|
| 157 |
result['sections'].append('events')
|
| 158 |
+
|
| 159 |
+
# Prediction
|
| 160 |
+
pred = soup.select_one('.prediction-card')
|
| 161 |
+
if pred:
|
| 162 |
+
pred_data = {}
|
| 163 |
+
team_info = pred.select_one('.team-info')
|
| 164 |
+
if team_info:
|
| 165 |
+
teams = team_info.select('.team')
|
| 166 |
+
if len(teams) >= 2:
|
| 167 |
+
pred_data['home_name'] = _cl(teams[0].select_one('.team-name').get_text() or '') if teams[0].select_one('.team-name') else ''
|
| 168 |
+
pred_data['away_name'] = _cl(teams[1].select_one('.team-name').get_text() or '') if teams[1].select_one('.team-name') else ''
|
| 169 |
+
divider = team_info.select_one('.divider')
|
| 170 |
+
if divider:
|
| 171 |
+
pred_data['result'] = _cl(divider.get_text())
|
| 172 |
+
vc = pred.select_one('.vote-count')
|
| 173 |
+
if vc:
|
| 174 |
+
pred_data['vote_count'] = _cl(vc.get_text())
|
| 175 |
+
result['prediction'] = pred_data
|
| 176 |
+
|
| 177 |
+
# Recent matches
|
| 178 |
+
recent = []
|
| 179 |
+
ml = soup.select_one('.matches-list')
|
| 180 |
+
if ml:
|
| 181 |
+
for item in ml.select('.match-detail, .match-item, li'):
|
| 182 |
+
de = item.select_one('.date, .time')
|
| 183 |
+
le = item.select_one('.league')
|
| 184 |
+
he = item.select_one('.home, .team-home')
|
| 185 |
+
ae = item.select_one('.away, .team-away')
|
| 186 |
+
se = item.select_one('.score, .result')
|
| 187 |
+
if he or ae:
|
| 188 |
+
recent.append({
|
| 189 |
+
'date': _cl(de.get_text()) if de else '',
|
| 190 |
+
'league': _cl(le.get_text()) if le else '',
|
| 191 |
+
'home': _cl(he.get_text()) if he else '',
|
| 192 |
+
'away': _cl(ae.get_text()) if ae else '',
|
| 193 |
+
'score': _cl(se.get_text()) if se else 'vs',
|
| 194 |
+
})
|
| 195 |
+
if recent:
|
| 196 |
+
result['recent_matches'] = recent
|
| 197 |
+
result['sections'].append('recent')
|
| 198 |
+
|
| 199 |
+
# H2H stats API
|
| 200 |
+
try:
|
| 201 |
+
api_headers = {
|
| 202 |
+
"User-Agent": "Mozilla/5.0",
|
| 203 |
+
"Accept": "application/json",
|
| 204 |
+
"X-Requested-With": "XMLHttpRequest",
|
| 205 |
+
"Referer": "https://bongda.com.vn/",
|
| 206 |
+
}
|
| 207 |
+
r = requests.get(
|
| 208 |
+
f"https://bongda.com.vn/api/fixtures/h2h-stats?event_id={event_id}",
|
| 209 |
+
headers=api_headers, timeout=10
|
| 210 |
+
)
|
| 211 |
+
if r.status_code == 200:
|
| 212 |
+
ad = r.json()
|
| 213 |
+
if ad.get('status') == 'success' and ad.get('html'):
|
| 214 |
+
asp = BeautifulSoup(ad['html'], 'html.parser')
|
| 215 |
+
ast = {}
|
| 216 |
+
for row in asp.select('li, tr'):
|
| 217 |
+
cells = row.select('td, span, p')
|
| 218 |
+
if len(cells) >= 3:
|
| 219 |
+
lb = _cl(cells[0].get_text())
|
| 220 |
+
if lb:
|
| 221 |
+
ast[lb] = {'home': _cl(cells[1].get_text()), 'away': _cl(cells[2].get_text())}
|
| 222 |
+
if ast:
|
| 223 |
+
result['h2h_stats_parsed'] = ast
|
| 224 |
+
result['sections'].append('h2h_stats')
|
| 225 |
+
except Exception:
|
| 226 |
+
pass
|
| 227 |
+
|
| 228 |
except Exception as e:
|
| 229 |
result['error'] = str(e)
|
| 230 |
+
|
| 231 |
return result
|
| 232 |
|
| 233 |
+
|
| 234 |
+
# FastAPI endpoint (to be added to app_v2_entry.py)
|
| 235 |
from fastapi import Query
|
| 236 |
from fastapi.responses import JSONResponse
|
| 237 |
|
| 238 |
def add_bongda_proxy_endpoint(app):
|
| 239 |
@app.get('/api/proxy/bongda')
|
| 240 |
def proxy_bongda(event_id: int = Query(default=None), url: str = Query(default=None)):
|
| 241 |
+
"""Proxy bongda.com.vn match data - fast server-side scraping."""
|
| 242 |
if event_id is None:
|
| 243 |
return JSONResponse({'error': 'event_id required'}, status_code=400)
|
| 244 |
+
return JSONResponse(scrape_match_html(event_id, url))
|
logs_route.py
DELETED
|
@@ -1,106 +0,0 @@
|
|
| 1 |
-
"""Independent logs page for VNEWS Space.
|
| 2 |
-
Serves /logs (HTML) and /logs.txt (raw) so build/runtime errors are visible
|
| 3 |
-
even when the Hugging Face build-logs tab is stuck/unavailable.
|
| 4 |
-
Mounted from _run.py.
|
| 5 |
-
"""
|
| 6 |
-
import os
|
| 7 |
-
import time
|
| 8 |
-
import json
|
| 9 |
-
import subprocess
|
| 10 |
-
from fastapi import Request
|
| 11 |
-
from fastapi.responses import HTMLResponse, PlainTextResponse
|
| 12 |
-
|
| 13 |
-
try:
|
| 14 |
-
from app_v2_entry import app
|
| 15 |
-
except Exception:
|
| 16 |
-
from main import app
|
| 17 |
-
|
| 18 |
-
BUILD_DONE = "/app/.build_done"
|
| 19 |
-
DATA_DIR = '/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data')
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
def _collect():
|
| 23 |
-
lines = []
|
| 24 |
-
lines.append("=== VNEWS LOGS ===")
|
| 25 |
-
lines.append("generated: " + time.strftime('%Y-%m-%d %H:%M:%S', time.localtime()))
|
| 26 |
-
lines.append("")
|
| 27 |
-
# Build marker
|
| 28 |
-
if os.path.exists(BUILD_DONE):
|
| 29 |
-
lines.append("[BUILD] .build_done exists -> container started OK")
|
| 30 |
-
try:
|
| 31 |
-
lines.append("[BUILD] built at: " + open(BUILD_DONE).read().strip())
|
| 32 |
-
except Exception:
|
| 33 |
-
pass
|
| 34 |
-
else:
|
| 35 |
-
lines.append("[BUILD] WARNING: .build_done MISSING -> uvicorn started before build finished?")
|
| 36 |
-
lines.append("")
|
| 37 |
-
|
| 38 |
-
# Space status from HF runtime file
|
| 39 |
-
try:
|
| 40 |
-
import json as _j
|
| 41 |
-
mj = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.huggingface', 'main.json')
|
| 42 |
-
if os.path.exists(mj):
|
| 43 |
-
lines.append("[RUNTIME] .huggingface/main.json present")
|
| 44 |
-
else:
|
| 45 |
-
lines.append("[RUNTIME] .huggingface/main.json NOT found")
|
| 46 |
-
except Exception as e:
|
| 47 |
-
lines.append("[RUNTIME] error: " + str(e))
|
| 48 |
-
lines.append("")
|
| 49 |
-
|
| 50 |
-
# Data dir contents
|
| 51 |
-
lines.append("[DATA] dir=" + DATA_DIR)
|
| 52 |
-
try:
|
| 53 |
-
if os.path.isdir(DATA_DIR):
|
| 54 |
-
for f in sorted(os.listdir(DATA_DIR)):
|
| 55 |
-
p = os.path.join(DATA_DIR, f)
|
| 56 |
-
lines.append(" - %s (%d bytes)" % (f, os.path.getsize(p)))
|
| 57 |
-
else:
|
| 58 |
-
lines.append(" (data dir missing)")
|
| 59 |
-
except Exception as e:
|
| 60 |
-
lines.append(" error: " + str(e))
|
| 61 |
-
lines.append("")
|
| 62 |
-
|
| 63 |
-
# Recent container logs (stdout) if captured
|
| 64 |
-
log_paths = ["/tmp/vnews_stdout.log", os.path.join(DATA_DIR, "app.log")]
|
| 65 |
-
for lp in log_paths:
|
| 66 |
-
if os.path.exists(lp):
|
| 67 |
-
lines.append("[STDOUT] tail of " + lp + ":")
|
| 68 |
-
try:
|
| 69 |
-
with open(lp, "r", errors="replace") as fh:
|
| 70 |
-
tail = fh.read().splitlines()[-50:]
|
| 71 |
-
for l in tail:
|
| 72 |
-
lines.append(" " + l)
|
| 73 |
-
except Exception as e:
|
| 74 |
-
lines.append(" read error: " + str(e))
|
| 75 |
-
lines.append("")
|
| 76 |
-
|
| 77 |
-
# Environment hints
|
| 78 |
-
lines.append("[ENV] HF_SPACE: " + os.environ.get("HF_SPACE", "?"))
|
| 79 |
-
lines.append("[ENV] SPACE_ID: " + os.environ.get("SPACE_ID", "?"))
|
| 80 |
-
lines.append("[ENV] CUDA/CPU: " + ("gpu" if os.environ.get("CUDA_VISIBLE_DEVICES") else "cpu"))
|
| 81 |
-
lines.append("")
|
| 82 |
-
lines.append("=== END ===")
|
| 83 |
-
return "\n".join(lines)
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
@app.get("/logs")
|
| 87 |
-
def logs_page(request: Request):
|
| 88 |
-
txt = _collect()
|
| 89 |
-
html = (
|
| 90 |
-
"<!DOCTYPE html><html lang='vi'><head><meta charset='utf-8'>"
|
| 91 |
-
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
| 92 |
-
"<title>VNEWS Logs</title>"
|
| 93 |
-
"<style>body{background:#0d1117;color:#c9d1d9;font-family:monospace;padding:16px}"
|
| 94 |
-
"pre{white-space:pre-wrap;word-break:break-word;font-size:13px;line-height:1.5}"
|
| 95 |
-
"a{color:#58a6ff}</style></head><body>"
|
| 96 |
-
"<h2>VNEWS — Build & Runtime Logs</h2>"
|
| 97 |
-
"<p><a href='/logs.txt'>📄 raw text</a> · refresh để cập nhật</p>"
|
| 98 |
-
"<pre>" + txt.replace("&", "&").replace("<", "<").replace(">", ">") + "</pre>"
|
| 99 |
-
"</body></html>"
|
| 100 |
-
)
|
| 101 |
-
return HTMLResponse(html)
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
@app.get("/logs.txt")
|
| 105 |
-
def logs_raw(request: Request):
|
| 106 |
-
return PlainTextResponse(_collect())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
main.py
CHANGED
|
@@ -1,329 +1,325 @@
|
|
| 1 |
-
"""VNEWS - FastAPI backend with livescore + xemlaibongda highlights +
|
| 2 |
-
import re, time, subprocess, json, os, threading
|
| 3 |
import html as html_lib
|
| 4 |
-
from datetime import datetime
|
| 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
|
| 11 |
-
from
|
|
|
|
| 12 |
import requests
|
| 13 |
from bs4 import BeautifulSoup
|
| 14 |
|
| 15 |
app = FastAPI()
|
| 16 |
|
| 17 |
-
# =====
|
| 18 |
-
from wc2026_scraper import get_wc2026_all, scrape_fixtures, scrape_standings, scrape_stats, scrape_wc_news
|
| 19 |
-
|
| 20 |
-
# ===== RATE LIMITING =====
|
| 21 |
-
_rate_limit_data = defaultdict(list)
|
| 22 |
-
_rate_limit_lock = threading.Lock()
|
| 23 |
-
RATE_LIMIT_MAX = 60
|
| 24 |
-
RATE_LIMIT_WINDOW = 60
|
| 25 |
-
|
| 26 |
-
def _check_rate_limit(ip: str) -> bool:
|
| 27 |
-
with _rate_limit_lock:
|
| 28 |
-
now = time.time()
|
| 29 |
-
_rate_limit_data[ip] = [t for t in _rate_limit_data[ip] if now - t < RATE_LIMIT_WINDOW]
|
| 30 |
-
if len(_rate_limit_data[ip]) >= RATE_LIMIT_MAX: return False
|
| 31 |
-
_rate_limit_data[ip].append(now)
|
| 32 |
-
return True
|
| 33 |
-
|
| 34 |
-
@app.middleware("http")
|
| 35 |
-
async def rate_limit_middleware(request: Request, call_next):
|
| 36 |
-
if request.url.path.startswith("/api/"):
|
| 37 |
-
ip = request.client.host
|
| 38 |
-
if not _check_rate_limit(ip): return JSONResponse({"error": "rate limit exceeded"}, status_code=429)
|
| 39 |
-
return await call_next(request)
|
| 40 |
-
|
| 41 |
-
# ===== VTV CHANNELS API =====
|
| 42 |
from vtv_api import router as vtv_router
|
| 43 |
app.include_router(vtv_router)
|
| 44 |
|
| 45 |
HEADERS = {"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36","Accept-Language":"vi-VN,vi;q=0.9,en;q=0.8"}
|
| 46 |
BONGDA_HEADERS = {"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36","Accept-Language":"vi-VN,vi;q=0.9","Referer":"https://bongda.com.vn/lich-thi-dau","X-Requested-With":"XMLHttpRequest"}
|
| 47 |
BASE_BDP = "https://bongdaplus.vn"
|
|
|
|
| 48 |
_cache = {}
|
| 49 |
_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 = {
|
| 56 |
-
"premier-league":{"path":"anh/premier-league","name":"Premier League","emoji":"🏴"},
|
| 57 |
-
"fa-cup":{"path":"anh/fa-cup","name":"FA Cup","emoji":"🏆"},
|
| 58 |
-
"bundesliga":{"path":"duc/bundesliga","name":"Bundesliga","emoji":"🇩🇪"},
|
| 59 |
-
"serie-a":{"path":"italy/serie-a","name":"Serie A","emoji":"🇮🇹"},
|
| 60 |
-
"la-liga":{"path":"tay-ban-nha/la-liga","name":"La Liga","emoji":"🇪🇸"},
|
| 61 |
-
"champions-league":{"path":"cup-chau-au/uefa-champions-league","name":"Champions League","emoji":"⭐"},
|
| 62 |
-
"europa-league":{"path":"cup-chau-au/uefa-europa-league","name":"Europa League","emoji":"🟠"},
|
| 63 |
-
"world-cup":{"path":"the-gioi/world-cup","name":"World Cup 2026","emoji":"🌍"},
|
| 64 |
-
}
|
| 65 |
def _cached(key, fn, ttl=None):
|
| 66 |
-
now=time.time();
|
| 67 |
-
if key in _cache and now-_cache[key]["t"]<t:
|
| 68 |
-
try:
|
| 69 |
-
except:
|
| 70 |
-
_cache[key]={"d":data,"t":now};
|
| 71 |
-
def _get(url,
|
| 72 |
-
h=headers or HEADERS;
|
| 73 |
return BeautifulSoup(r.text,"lxml")
|
| 74 |
def fetch_bongda_api(endpoint):
|
| 75 |
try:
|
| 76 |
-
r=requests.get(f"https://bongda.com.vn{endpoint}",
|
| 77 |
if r.status_code==200:
|
| 78 |
data=r.json()
|
| 79 |
-
if data.get("status")=="success":
|
| 80 |
return ""
|
| 81 |
-
except:
|
| 82 |
-
|
| 83 |
def _parse_match_from_li(li, status_type="live"):
|
| 84 |
match_div=li.select_one("div.match")
|
| 85 |
-
if not match_div:
|
| 86 |
-
home_el=match_div.select_one(".home-team .name");
|
| 87 |
-
if not home_el or not away_el:
|
| 88 |
-
status_el=match_div.select_one(".status a");
|
| 89 |
-
home_logo=match_div.select_one(".home-team .logo img");
|
| 90 |
event_id=""
|
| 91 |
if status_el:
|
| 92 |
-
href=status_el.get("href","");
|
| 93 |
-
if m:
|
| 94 |
-
spans=status_el.find_all("span") if status_el else [];
|
| 95 |
-
if len(spans)>=3:
|
| 96 |
-
if len(spans)>=4:
|
| 97 |
-
if not score and status_el and status_el.select_one(".vs"):
|
| 98 |
league=league_el.get_text(strip=True) if league_el else ""
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
# ===== VIDEO PROXY =====
|
| 102 |
@app.get("/api/proxy/m3u8")
|
| 103 |
def proxy_m3u8(url: str = Query(...)):
|
| 104 |
try:
|
| 105 |
r = requests.get(url, headers=HEADERS, timeout=15)
|
| 106 |
-
if r.status_code != 200:
|
| 107 |
-
lines = r.text.strip().split('\n');
|
| 108 |
for line in lines:
|
| 109 |
-
if line.startswith('#') or not line.strip():
|
| 110 |
-
else:
|
| 111 |
-
return Response(content='\n'.join(rewritten).encode('utf-8'),
|
| 112 |
-
except:
|
| 113 |
|
| 114 |
@app.get("/api/proxy/seg")
|
| 115 |
def proxy_segment(url: str = Query(...)):
|
| 116 |
try:
|
| 117 |
r = requests.get(url, headers=HEADERS, timeout=30)
|
| 118 |
-
if r.status_code != 200:
|
| 119 |
data = r.content
|
| 120 |
-
if len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47:
|
| 121 |
-
return Response(content=data,
|
| 122 |
-
except:
|
| 123 |
|
| 124 |
@app.get("/api/proxy/video")
|
| 125 |
def proxy_video(url: str = Query(...), request: Request = None):
|
| 126 |
try:
|
| 127 |
req_headers = dict(HEADERS)
|
| 128 |
-
if request and request.headers.get("range"):
|
| 129 |
r = requests.get(url, headers=req_headers, timeout=30, stream=True)
|
| 130 |
resp_headers = {"Access-Control-Allow-Origin":"*","Accept-Ranges":"bytes","Content-Type":r.headers.get("Content-Type","video/mp4")}
|
| 131 |
-
if "Content-Range" in r.headers:
|
| 132 |
-
if "Content-Length" in r.headers:
|
| 133 |
-
return StreamingResponse(r.iter_content(chunk_size=256*1024),
|
| 134 |
-
except:
|
| 135 |
|
| 136 |
@app.get("/api/proxy/img")
|
| 137 |
def proxy_img(url: str = Query(...)):
|
|
|
|
| 138 |
try:
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
elif "vncecdn" in _host or "vnexpress" in _host: _referer = "https://vnexpress.net/"
|
| 145 |
-
r = requests.get(url, headers={**HEADERS, "Referer": _referer}, timeout=10)
|
| 146 |
-
if r.status_code != 200: return Response(status_code=502)
|
| 147 |
-
return Response(content=r.content, media_type=r.headers.get("Content-Type", "image/jpeg"), headers={"Cache-Control": "public, max-age=86400", "Access-Control-Allow-Origin": "*"})
|
| 148 |
-
except: return Response(status_code=502)
|
| 149 |
|
| 150 |
# ===== XEMLAIBONGDA HIGHLIGHTS =====
|
| 151 |
def _scrape_xemlaibongda_page(page_path, limit=20):
|
| 152 |
try:
|
| 153 |
url = f"https://xemlaibongda.top/{page_path}" if page_path else "https://xemlaibongda.top/"
|
| 154 |
-
r
|
| 155 |
-
if r.status_code
|
| 156 |
-
r.encoding
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
if
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
if
|
| 169 |
-
if not
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
if p and p.find("img"): img = p.find("img"); break
|
| 173 |
-
p = p.parent if p else None
|
| 174 |
-
if img:
|
| 175 |
-
img_src = (img.get("data-src", "") or img.get("src", "") or img.get("data-lazy", "") or img.get("data-original", "") or img.get("data-thumb", "") or img.get("data-image", ""))
|
| 176 |
-
if img_src.startswith("//"): img_src = "https:" + img_src
|
| 177 |
-
elif img_src.startswith("/"): img_src = "https://xemlaibongda.top" + img_src
|
| 178 |
-
if not img_src:
|
| 179 |
-
p = a.parent
|
| 180 |
-
for _ in range(5):
|
| 181 |
-
if p is None: break
|
| 182 |
-
style = p.get("style", "")
|
| 183 |
-
bg_match = re.search(r'url\(["\']?(.*?)["\']?\)', style)
|
| 184 |
-
if bg_match:
|
| 185 |
-
img_src = bg_match.group(1)
|
| 186 |
-
if img_src.startswith("//"): img_src = "https:" + img_src
|
| 187 |
-
elif img_src.startswith("/"): img_src = "https://xemlaibongda.top" + img_src
|
| 188 |
-
break
|
| 189 |
-
p = p.parent if p else None
|
| 190 |
-
title = ""
|
| 191 |
-
for attr in ["title", "aria-label"]:
|
| 192 |
-
val = a.get(attr, "")
|
| 193 |
-
if val and len(val) >= 5: title = val; break
|
| 194 |
-
if not title:
|
| 195 |
-
for selector in ["h3", "h2", "h4", ".title", ".video-title", "strong"]:
|
| 196 |
-
try:
|
| 197 |
-
el = a.select_one(selector)
|
| 198 |
-
if el: t = el.get_text(strip=True)
|
| 199 |
-
if t and len(t) >= 5: title = t; break
|
| 200 |
-
except: pass
|
| 201 |
-
if not title:
|
| 202 |
-
text = a.get_text(strip=True)
|
| 203 |
-
if text and len(text) >= 5: title = text[:100]
|
| 204 |
-
if not title or len(title) < 3:
|
| 205 |
-
slug = clean_href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
|
| 206 |
-
title = slug.replace("-", " ").replace("_", " ").title()
|
| 207 |
-
title = re.sub(r'\d{4}-\d{2}-\d{2}', '', title).strip()
|
| 208 |
-
if not title or len(title) < 3: continue
|
| 209 |
-
if not img_src:
|
| 210 |
-
slug = clean_href.split("/video/")[-1].rstrip("/").split("/xem-lai/")[-1].rstrip("/")
|
| 211 |
-
img_src = f"https://xemlaibongda.top/uploads/thumb/{slug}.jpg"
|
| 212 |
-
videos.append({"title": title[:100], "link": clean_href, "img": img_src, "source": "xemlaibongda"})
|
| 213 |
-
if len(videos) >= limit: break
|
| 214 |
return videos
|
| 215 |
-
except
|
| 216 |
-
print(f"[xemlaibongda] Error: {e}"); return []
|
| 217 |
|
| 218 |
-
def scrape_xemlaibongda():
|
| 219 |
def scrape_highlights_by_league(league_key):
|
| 220 |
-
if league_key not in HL_LEAGUES:
|
| 221 |
-
return _scrape_xemlaibongda_page(HL_LEAGUES[league_key]["path"],
|
|
|
|
| 222 |
def scrape_all_league_highlights():
|
| 223 |
results = {}
|
| 224 |
-
def _fetch(key):
|
| 225 |
with ThreadPoolExecutor(8) as ex:
|
| 226 |
futs = [ex.submit(_fetch, k) for k in HL_LEAGUES]
|
| 227 |
-
for f in as_completed(futs
|
| 228 |
-
try:
|
| 229 |
-
|
| 230 |
-
|
|
|
|
| 231 |
return results
|
| 232 |
|
| 233 |
def extract_xemlaibongda_video(url):
|
| 234 |
try:
|
| 235 |
-
r=requests.get(url,
|
| 236 |
-
if r.status_code!=200:
|
| 237 |
-
r.encoding="utf-8";
|
| 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","");
|
| 244 |
if not src:
|
| 245 |
source=video.find("source")
|
| 246 |
-
if source:
|
| 247 |
-
if
|
| 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:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
|
| 256 |
# ===== LIVESCORE =====
|
| 257 |
@app.get("/api/livescore/live")
|
| 258 |
-
def api_livescore_live():
|
| 259 |
@app.get("/api/livescore/incoming")
|
| 260 |
-
def api_livescore_incoming():
|
| 261 |
@app.get("/api/livescore/today")
|
| 262 |
def api_livescore_today():
|
| 263 |
-
today=datetime.now(
|
| 264 |
@app.get("/api/livescore/results")
|
| 265 |
def api_livescore_results():
|
| 266 |
-
today=datetime.now(
|
| 267 |
@app.get("/api/livescore/standings/{league}")
|
| 268 |
def api_livescore_standings(league:str):
|
| 269 |
tid=LEAGUE_IDS.get(league,27110);return JSONResponse({"html":_cached(f"ls_bxh_{league}",lambda:fetch_bongda_api(f"/api/league-table/home?tournament_id={tid}&is_detail=True"),ttl=_cache_ttl)})
|
| 270 |
@app.get("/api/livescore/date/{date}")
|
| 271 |
def api_livescore_date(date:str):return JSONResponse({"html":fetch_bongda_api(f"/api/fixtures/get-by-date?date={date}")})
|
| 272 |
-
|
| 273 |
-
@app.get("/api/livescore/updates7d")
|
| 274 |
-
def api_livescore_updates7d():
|
| 275 |
-
"""Aggregate results + incoming matches from past 7 days and next 7 days."""
|
| 276 |
-
def _f():
|
| 277 |
-
from datetime import date as _date
|
| 278 |
-
today = _date.today()
|
| 279 |
-
all_html = []
|
| 280 |
-
# Past 7 days (results)
|
| 281 |
-
for i in range(7, 0, -1):
|
| 282 |
-
d = (today - timedelta(days=i)).strftime("%Y-%m-%d")
|
| 283 |
-
html = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}&status=finished")
|
| 284 |
-
if html and len(html) > 50:
|
| 285 |
-
soup = BeautifulSoup(html, "lxml")
|
| 286 |
-
day_label = (today - timedelta(days=i)).strftime("%d/%m")
|
| 287 |
-
for match in soup.select(".match-detail"):
|
| 288 |
-
dt = soup.new_tag("div", **{"class": "datetime"})
|
| 289 |
-
dt.string = f"📅 {day_label}"
|
| 290 |
-
match.insert(0, dt)
|
| 291 |
-
all_html.append(str(soup))
|
| 292 |
-
# Next 7 days (upcoming)
|
| 293 |
-
for i in range(7):
|
| 294 |
-
d = (today + timedelta(days=i)).strftime("%Y-%m-%d")
|
| 295 |
-
html = fetch_bongda_api(f"/api/fixtures/get-by-date?date={d}")
|
| 296 |
-
if html and len(html) > 50:
|
| 297 |
-
soup = BeautifulSoup(html, "lxml")
|
| 298 |
-
day_label = (today + timedelta(days=i)).strftime("%d/%m")
|
| 299 |
-
for match in soup.select(".match-detail"):
|
| 300 |
-
dt = soup.new_tag("div", **{"class": "datetime"})
|
| 301 |
-
dt.string = f"📅 {day_label}"
|
| 302 |
-
match.insert(0, dt)
|
| 303 |
-
all_html.append(str(soup))
|
| 304 |
-
combined = "<div class='updates7d'>" + "".join(all_html) + "</div>"
|
| 305 |
-
return combined if all_html else ""
|
| 306 |
-
return JSONResponse({"html": _cached("ls_updates7d", _f, ttl=_cache_ttl)})
|
| 307 |
-
|
| 308 |
@app.get("/api/match/{event_id}/commentaries")
|
| 309 |
def api_match_commentaries(event_id:int):return JSONResponse({"html":fetch_bongda_api(f"/api/fixtures/commentaries?event_id={event_id}")})
|
| 310 |
@app.get("/api/match/{event_id}/stats")
|
| 311 |
def api_match_stats(event_id:int):return JSONResponse({"html":fetch_bongda_api(f"/api/event-standing/player-performance?event_id={event_id}")})
|
| 312 |
|
|
|
|
| 313 |
from match_detail_v2 import fetch_match_detail, fetch_match_detail_by_url
|
| 314 |
|
| 315 |
@app.get("/api/match/{event_id}/detail")
|
| 316 |
def api_match_detail(event_id: int, url: str = Query(default="")):
|
|
|
|
| 317 |
try:
|
| 318 |
-
if url:
|
| 319 |
-
|
|
|
|
|
|
|
| 320 |
return JSONResponse(data)
|
| 321 |
-
except Exception as e:
|
|
|
|
| 322 |
|
| 323 |
@app.get("/api/livescore/featured")
|
| 324 |
def api_livescore_featured():
|
| 325 |
def _f():
|
| 326 |
-
sources=[("/api/fixtures/live","live"),("/api/fixtures/get-by-date?date="+datetime.now(
|
| 327 |
for endpoint, stype in sources:
|
| 328 |
html=fetch_bongda_api(endpoint)
|
| 329 |
if not html or len(html)<100:continue
|
|
@@ -341,216 +337,617 @@ def api_livescore_featured():
|
|
| 341 |
return None
|
| 342 |
return JSONResponse(_cached("ls_featured",_f,ttl=30))
|
| 343 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
@app.get("/api/highlights")
|
| 345 |
-
def api_highlights():
|
| 346 |
@app.get("/api/highlights/leagues")
|
| 347 |
-
def api_highlights_leagues():
|
| 348 |
@app.get("/api/highlights/{league}")
|
| 349 |
def api_highlights_league(league:str):
|
| 350 |
-
if league not in HL_LEAGUES:
|
| 351 |
return JSONResponse(_cached(f"hl_{league}",lambda:scrape_highlights_by_league(league),ttl=_cache_ttl))
|
| 352 |
-
|
|
|
|
| 353 |
@app.get("/api/video_url")
|
| 354 |
-
def api_video_url(url:str=Query(...)
|
| 355 |
if "youtube.com" in url or "youtu.be" in url:
|
| 356 |
m=re.search(r'(?:v=|shorts/|youtu\.be/)([a-zA-Z0-9_-]{11})',url)
|
| 357 |
-
if m:
|
| 358 |
if "xemlaibongda.top" in url:
|
| 359 |
v=extract_xemlaibongda_video(url)
|
| 360 |
if v:
|
| 361 |
-
if v["type"]=="hls":
|
| 362 |
-
if not v.get("poster") and img: v["poster"] = img
|
| 363 |
return JSONResponse(v)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
return JSONResponse({"error":"not found"})
|
| 365 |
-
|
| 366 |
-
# ===== WORLD CUP 2026 API =====
|
| 367 |
-
_wc_request_times = []; _wc_rate_limit_lock = threading.Lock()
|
| 368 |
-
_WC_RATE_LIMIT = 10
|
| 369 |
-
def _wc_rate_limit():
|
| 370 |
-
global _wc_request_times
|
| 371 |
-
with _wc_rate_limit_lock:
|
| 372 |
-
now = time.time()
|
| 373 |
-
_wc_request_times = [t for t in _wc_request_times if now - t < 60]
|
| 374 |
-
if len(_wc_request_times) >= _WC_RATE_LIMIT: return False
|
| 375 |
-
_wc_request_times.append(now)
|
| 376 |
-
return True
|
| 377 |
-
|
| 378 |
-
@app.get("/api/wc2026")
|
| 379 |
-
def api_wc2026():
|
| 380 |
-
return JSONResponse(_cached("wc2026", get_wc2026_all, ttl=_cache_ttl))
|
| 381 |
-
|
| 382 |
-
@app.get("/api/wc2026/{tab}")
|
| 383 |
-
def api_wc2026_tab(tab: str):
|
| 384 |
-
valid_tabs = ["news", "fixtures", "standings", "stats", "highlights"]
|
| 385 |
-
if tab not in valid_tabs: return JSONResponse({"error": "invalid tab"}, status_code=400)
|
| 386 |
-
def _fetch_tab():
|
| 387 |
-
if tab == "highlights": return scrape_highlights_by_league("world-cup")
|
| 388 |
-
elif tab == "news": return scrape_wc_news()
|
| 389 |
-
elif tab == "fixtures": return scrape_fixtures()
|
| 390 |
-
elif tab == "standings": return scrape_standings()
|
| 391 |
-
elif tab == "stats": return scrape_stats()
|
| 392 |
-
return []
|
| 393 |
-
return JSONResponse(_cached(f"wc2026_{tab}", _fetch_tab, ttl=_cache_ttl))
|
| 394 |
-
|
| 395 |
@app.get("/api/bdp_videos")
|
| 396 |
def api_bdp_videos():
|
| 397 |
def _f():
|
| 398 |
try:
|
| 399 |
-
soup=_get(f"{BASE_BDP}/video");
|
| 400 |
for a in soup.find_all("a",href=True):
|
| 401 |
href=a.get("href","")
|
| 402 |
if"/video/" not in href or href in("/video/","/video/ban-thang-dep","/video/highlight"):continue
|
| 403 |
-
if not href.startswith("http"):
|
| 404 |
-
if href in seen:
|
| 405 |
title=re.sub(r'^\d{2}:\d{2}','',a.get_text(strip=True)).strip()
|
| 406 |
-
if not title or len(title)<5:
|
| 407 |
img_tag=a.find("img") or(a.parent.find("img") if a.parent else None)
|
| 408 |
img=(img_tag.get("data-src") or img_tag.get("src","")) if img_tag else ""
|
| 409 |
-
seen.add(href);
|
| 410 |
return arts[:20]
|
| 411 |
-
except:
|
| 412 |
return JSONResponse(_cached("bdp_videos",_f))
|
| 413 |
-
|
| 414 |
# ===== NEWS =====
|
| 415 |
-
VNE_CATS={"thoi-su":("https://vnexpress.net/thoi-su","Thời Sự"),"the-gioi":("https://vnexpress.net/the-gioi","Thế Giới"),"kinh-doanh":("https://vnexpress.net/kinh-doanh","Kinh Doanh"),"the-thao":("https://vnexpress.net/the-thao","Thể Thao"),"giai-tri":("https://vnexpress.net/giai-tri","Giải Trí"),"suc-khoe":("https://vnexpress.net/suc-khoe","Sức Khỏe"),"phap-luat":("https://vnexpress.net/phap-luat","Pháp Luật"),"giao-duc":("https://vnexpress.net/giao-duc","Giáo Dục"),"du-lich":("https://vnexpress.net/du-lich","Du Lịch"),"doi-song":("https://vnexpress.net/doi-song","Đời Sống")}
|
| 416 |
-
|
| 417 |
def scrape_vne(cat_url):
|
| 418 |
try:
|
| 419 |
-
soup=_get(cat_url);
|
| 420 |
for it in soup.select("article.item-news")[:15]:
|
| 421 |
a=it.select_one("h2.title-news a") or it.select_one("h3.title-news a")
|
| 422 |
-
if not a:
|
| 423 |
-
t=a.get("title","") or a.get_text(strip=True);
|
| 424 |
-
if not t or not lk:
|
| 425 |
-
im=it.find("img");
|
| 426 |
-
if img and
|
| 427 |
src=it.find("source")
|
| 428 |
-
if src:
|
| 429 |
arts.append({"title":t,"link":lk,"img":img,"source":"vne"})
|
| 430 |
return arts
|
| 431 |
-
except:
|
| 432 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 433 |
def scrape_genk_ai():
|
|
|
|
| 434 |
try:
|
| 435 |
r=requests.get("https://genk.vn/ai.chn",headers=HEADERS,timeout=15)
|
| 436 |
-
if r.status_code!=200:
|
| 437 |
-
r.encoding="utf-8";
|
|
|
|
| 438 |
for a in soup.find_all("a",href=True):
|
| 439 |
href=a.get("href","")
|
| 440 |
-
if not href.endswith(".chn") or href=="/ai.chn":
|
| 441 |
-
if href.startswith("/"):
|
| 442 |
-
if href in seen or "genk.vn" not in href:
|
| 443 |
title=a.get("title","") or a.get_text(strip=True)
|
| 444 |
-
if not title or len(title)<20:
|
| 445 |
-
container=a.parent;
|
| 446 |
for _ in range(6):
|
| 447 |
-
if container is None:
|
| 448 |
for img in container.find_all("img"):
|
| 449 |
s=img.get("data-src","") or img.get("src","")
|
| 450 |
-
if s and "mediacdn" in s and "avatar" not in s and "logo" not in s:
|
| 451 |
-
|
|
|
|
|
|
|
| 452 |
seen.add(href)
|
| 453 |
if not img_src:
|
| 454 |
try:
|
| 455 |
-
og_r=requests.get(href,headers=HEADERS,timeout=8);
|
| 456 |
-
og_soup=BeautifulSoup(og_r.text,"lxml");
|
| 457 |
-
if og_tag:
|
| 458 |
-
except:
|
| 459 |
articles.append({"title":title,"link":href,"img":img_src,"source":"genk"})
|
| 460 |
-
if len(articles)>=30:
|
| 461 |
return articles
|
| 462 |
-
except:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 463 |
|
|
|
|
| 464 |
@app.get("/api/homepage")
|
| 465 |
def api_homepage():
|
| 466 |
def _f():
|
| 467 |
articles=[]
|
| 468 |
with ThreadPoolExecutor(12) as ex:
|
| 469 |
futs={ex.submit(scrape_vne,VNE_CATS[k][0]):VNE_CATS[k][1] for k in["thoi-su","the-gioi","kinh-doanh","the-thao","giai-tri","phap-luat","giao-duc","du-lich","doi-song"]}
|
|
|
|
| 470 |
for f in as_completed(futs):
|
| 471 |
try:
|
| 472 |
-
for a in f.result():
|
| 473 |
-
except:
|
| 474 |
return articles
|
| 475 |
return JSONResponse(_cached("homepage",_f))
|
| 476 |
-
|
| 477 |
@app.get("/api/category/{cat_id}")
|
| 478 |
def api_category(cat_id:str):
|
| 479 |
def _f():
|
| 480 |
-
if cat_id=="
|
| 481 |
-
if cat_id
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
return arts
|
| 485 |
-
return []
|
| 486 |
return JSONResponse(_cached(f"cat_{cat_id}",_f))
|
| 487 |
-
|
| 488 |
@app.get("/api/categories")
|
| 489 |
def api_categories():
|
| 490 |
-
cats=[{"id":"cong-nghe","name":"Công Nghệ","source":"genk"}]
|
| 491 |
-
for k,(u,n) in VNE_CATS.items():
|
| 492 |
return JSONResponse(cats)
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 496 |
try:
|
| 497 |
-
url
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
img = a.find("img") or (a.parent.find("img") if a.parent else None)
|
| 512 |
-
if not img:
|
| 513 |
-
p = a.parent
|
| 514 |
-
for _ in range(5):
|
| 515 |
-
if p and p.find("img"): img = p.find("img"); break
|
| 516 |
-
p = p.parent if p else None
|
| 517 |
-
if img:
|
| 518 |
-
img_src = (img.get("data-src", "") or img.get("src", "") or img.get("data-lazy", "") or img.get("data-original", ""))
|
| 519 |
-
if img_src.startswith("//"): img_src = "https:" + img_src
|
| 520 |
-
elif img_src.startswith("/"): img_src = "https://xemlaibongda.top" + img_src
|
| 521 |
-
title = a.find("h3")
|
| 522 |
-
if not title: title = a.find("h2")
|
| 523 |
-
if not title: title = a.find("strong")
|
| 524 |
-
t = title.get_text(strip=True) if title else ""
|
| 525 |
-
if not t:
|
| 526 |
-
slug = clean.split("/video/")[-1].rstrip("/")
|
| 527 |
-
t = slug.replace("-", " ").title()
|
| 528 |
-
videos.append({"title": t[:100], "link": clean, "img": img_src, "source": "xemlaibongda"})
|
| 529 |
-
if len(videos) >= limit: break
|
| 530 |
-
return JSONResponse({"videos": videos})
|
| 531 |
-
except Exception as e:
|
| 532 |
-
return JSONResponse({"videos": [], "error": str(e)})
|
| 533 |
|
| 534 |
@app.get("/api/article")
|
| 535 |
def api_article(url:str=Query(...)):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 536 |
try:
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
return
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""VNEWS - FastAPI backend with livescore + xemlaibongda highlights + YouTube FPT shorts"""
|
| 2 |
+
import hashlib, re, time, subprocess, json, os, threading
|
| 3 |
import html as html_lib
|
| 4 |
+
from datetime import datetime
|
|
|
|
|
|
|
|
|
|
| 5 |
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 6 |
from fastapi import FastAPI, Query, Request
|
| 7 |
+
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, Response, RedirectResponse
|
| 8 |
+
from fastapi.staticfiles import StaticFiles
|
| 9 |
+
from urllib.parse import unquote, quote, urlencode
|
| 10 |
import requests
|
| 11 |
from bs4 import BeautifulSoup
|
| 12 |
|
| 13 |
app = FastAPI()
|
| 14 |
|
| 15 |
+
# ===== VTV CHANNELS API (VTV1-VTV10 + VTVPrime) =====
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
from vtv_api import router as vtv_router
|
| 17 |
app.include_router(vtv_router)
|
| 18 |
|
| 19 |
HEADERS = {"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36","Accept-Language":"vi-VN,vi;q=0.9,en;q=0.8"}
|
| 20 |
BONGDA_HEADERS = {"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36","Accept-Language":"vi-VN,vi;q=0.9","Referer":"https://bongda.com.vn/lich-thi-dau","X-Requested-With":"XMLHttpRequest"}
|
| 21 |
BASE_BDP = "https://bongdaplus.vn"
|
| 22 |
+
SPACE_URL = "https://bep40-vnews.hf.space"
|
| 23 |
_cache = {}
|
| 24 |
_cache_ttl = 300
|
| 25 |
_cache_ttl_live = 60
|
| 26 |
_cache_ttl_yt = 1800
|
| 27 |
+
SHORTS_FALLBACK = [
|
| 28 |
+
{"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 | #shorts","channel":"baodantri7941"},
|
| 29 |
+
{"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 | #shorts","channel":"baodantri7941"},
|
| 30 |
+
{"id":"tvPewsc2ph4","title":"Tính năng ẩn trên iPhone giúp giảm mỏi mắt | #shorts","channel":"baodantri7941"},
|
| 31 |
+
{"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 | #shorts","channel":"baodantri7941"},
|
| 32 |
+
{"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ù | #shorts","channel":"baodantri7941"},
|
| 33 |
+
{"id":"Htzvwg6iOBM","title":"Xe điện Audi S6 Sportback e-tron có gì đặc biệt? | #shorts","channel":"baodantri7941"},
|
| 34 |
+
{"id":"iMdFmWvYdlo","title":"Cô gái người Nga yêu thời trang và đất nước Việt Nam | #shorts","channel":"baodantri7941"},
|
| 35 |
+
{"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 | #shorts","channel":"baodantri7941"},
|
| 36 |
+
{"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 | #shorts","channel":"baodantri7941"},
|
| 37 |
+
{"id":"VAfgNNgZDRs","title":"Khởi tố 4 đối tượng ném bom xăng vào nhà dân ở Đồng Nai | #shorts","channel":"baodantri7941"},
|
| 38 |
+
{"id":"sBH_-zGh0Xw","title":"Vì sao Times New Roman vẫn nổi tiếng sau hàng chục năm? | #shorts","channel":"baodantri7941"},
|
| 39 |
+
{"id":"woKn5f2bLHM","title":"Quảng Ninh ngập sâu diện rộng sau đợt mưa lớn | #shorts","channel":"baodantri7941"},
|
| 40 |
+
{"id":"bcpgRoxbLPw","title":"Giông lốc quật bay mái tôn ở TP.HCM | #shorts","channel":"baodantri7941"},
|
| 41 |
+
{"id":"ZIIC5osy544","title":"Bé trai Trung Quốc rơi từ tầng 11 vẫn sống sót kỳ diệu | #shorts","channel":"baodantri7941"},
|
| 42 |
+
{"id":"uTMJ49NQpyc","title":"Sau lớp mascot 40kg: Câu chuyện mưu sinh của người trẻ ở TPHCM | #shorts","channel":"baodantri7941"},
|
| 43 |
+
{"id":"7Pd6vZ2Lz1M","title":"Hành động ấm lòng của người đàn ông tham gia tìm kiếm 5 học sinh tử vong ở sông Lô | SKĐS","channel":"baosuckhoedoisongboyte"},
|
| 44 |
+
{"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 | SKĐS","channel":"baosuckhoedoisongboyte"},
|
| 45 |
+
{"id":"IUOprcJyYr4","title":"Phụ nữ táo bón có phải do lười ăn rau? | SKĐS #shorts","channel":"baosuckhoedoisongboyte"},
|
| 46 |
+
{"id":"YY8ojFNE-AU","title":"Quái xế tự quay clip nẹt pô, đánh võng đăng TikTok bị xử lý | SKĐS","channel":"baosuckhoedoisongboyte"},
|
| 47 |
+
{"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 | SKĐS","channel":"baosuckhoedoisongboyte"},
|
| 48 |
+
{"id":"FoxhFyz2skY","title":"Người đàn ông nước ngoài đập phá ô tô, bẻ cần gạt nước ở Đà Nẵng | SKĐS","channel":"baosuckhoedoisongboyte"},
|
| 49 |
+
{"id":"R1oC_I8dFPU","title":"Thanh niên buông tay lái, đứng trên xe máy khi đổ đèo ở Đắk Lắk | SKĐS","channel":"baosuckhoedoisongboyte"},
|
| 50 |
+
{"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 | SKĐS","channel":"baosuckhoedoisongboyte"},
|
| 51 |
+
{"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 | SKĐS","channel":"baosuckhoedoisongboyte"},
|
| 52 |
+
{"id":"pXWt0QbAzRQ","title":"Va chạm giao thông, người phụ nữ lăng mạ tài xế ô tô | SKĐS","channel":"baosuckhoedoisongboyte"},
|
| 53 |
+
{"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 | SKĐS","channel":"baosuckhoedoisongboyte"},
|
| 54 |
+
{"id":"AxhVTQutsuo","title":"Xuất tinh sớm và những hiểu lầm thường gặp | SKĐS #shorts","channel":"baosuckhoedoisongboyte"},
|
| 55 |
+
{"id":"cNy6FgaNxYM","title":"Cô dâu khóc sưng mắt vì 6 chỉ vàng không cánh mà bay trong ngày cưới | SKĐS","channel":"baosuckhoedoisongboyte"},
|
| 56 |
+
{"id":"IDt_S6q59Ro","title":"Chở bạn gái không đội mũ bảo hiểm, thanh niên đấm CSGT | SKĐS","channel":"baosuckhoedoisongboyte"},
|
| 57 |
+
{"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 | SKĐS","channel":"baosuckhoedoisongboyte"}
|
| 58 |
+
]
|
| 59 |
+
for _v in SHORTS_FALLBACK:
|
| 60 |
+
_v["link"]="https://www.youtube.com/watch?v="+_v["id"]
|
| 61 |
+
_v["img"]="https://i.ytimg.com/vi/"+_v["id"]+"/hqdefault.jpg"
|
| 62 |
+
_v["source"]="yt"
|
| 63 |
+
SHORT_STATS_FILE = "/data/short_stats.json" if os.path.isdir("/data") else "/app/short_stats.json"
|
| 64 |
+
_short_lock = threading.Lock()
|
| 65 |
+
def _load_short_db():
|
| 66 |
+
try:
|
| 67 |
+
if os.path.exists(SHORT_STATS_FILE):
|
| 68 |
+
with open(SHORT_STATS_FILE,"r",encoding="utf-8") as f:return json.load(f)
|
| 69 |
+
except:pass
|
| 70 |
+
return {}
|
| 71 |
+
def _save_short_db(db):
|
| 72 |
+
try:
|
| 73 |
+
os.makedirs(os.path.dirname(SHORT_STATS_FILE),exist_ok=True)
|
| 74 |
+
tmp=SHORT_STATS_FILE+".tmp"
|
| 75 |
+
with open(tmp,"w",encoding="utf-8") as f:json.dump(db,f,ensure_ascii=False)
|
| 76 |
+
os.replace(tmp,SHORT_STATS_FILE)
|
| 77 |
+
except:pass
|
| 78 |
+
|
| 79 |
+
def _short_default():return {"views":0,"likes":0,"shares":0,"comments":[]}
|
| 80 |
+
WALL_FILE = "/data/wall_posts.json" if os.path.isdir("/data") else "/app/wall_posts.json"
|
| 81 |
+
def _load_wall():
|
| 82 |
+
try:
|
| 83 |
+
if os.path.exists(WALL_FILE):
|
| 84 |
+
with open(WALL_FILE,"r",encoding="utf-8") as f:return json.load(f)
|
| 85 |
+
except:pass
|
| 86 |
+
return []
|
| 87 |
+
def _save_wall(posts):
|
| 88 |
+
try:
|
| 89 |
+
os.makedirs(os.path.dirname(WALL_FILE),exist_ok=True)
|
| 90 |
+
tmp=WALL_FILE+".tmp"
|
| 91 |
+
with open(tmp,"w",encoding="utf-8") as f:json.dump(posts[:100],f,ensure_ascii=False)
|
| 92 |
+
os.replace(tmp,WALL_FILE)
|
| 93 |
+
except:pass
|
| 94 |
PRIORITY_LEAGUES = ["Ngoại Hạng Anh","FA Cup","Champions League","LaLiga","Copa del Rey","Serie A","Bundesliga","Ligue 1","V-League"]
|
| 95 |
LEAGUE_IDS = {"nha":27110,"laliga":27233,"seriea":27044,"bundesliga":26891,"ligue1":27212}
|
| 96 |
+
HL_LEAGUES = {"premier-league":{"path":"anh/premier-league","name":"Premier League","emoji":"🏴"},"fa-cup":{"path":"anh/fa-cup","name":"FA Cup","emoji":"🏆"},"bundesliga":{"path":"duc/bundesliga","name":"Bundesliga","emoji":"🇩🇪"},"serie-a":{"path":"italy/serie-a","name":"Serie A","emoji":"🇮🇹"},"la-liga":{"path":"tay-ban-nha/la-liga","name":"La Liga","emoji":"🇪🇸"},"champions-league":{"path":"cup-chau-au/uefa-champions-league","name":"Champions League","emoji":"⭐"},"europa-league":{"path":"cup-chau-au/uefa-europa-league","name":"Europa League","emoji":"🟠"},"world-cup":{"path":"the-gioi/world-cup-qualifiers","name":"World Cup 2026","emoji":"🌍"}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
def _cached(key, fn, ttl=None):
|
| 98 |
+
now=time.time();t=ttl or _cache_ttl
|
| 99 |
+
if key in _cache and now-_cache[key]["t"]<t:return _cache[key]["d"]
|
| 100 |
+
try:data=fn()
|
| 101 |
+
except:data=_cache.get(key,{}).get("d",[])
|
| 102 |
+
_cache[key]={"d":data,"t":now};return data
|
| 103 |
+
def _get(url,headers=None):
|
| 104 |
+
h=headers or HEADERS;r=requests.get(url,headers=h,timeout=15);r.encoding="utf-8"
|
| 105 |
return BeautifulSoup(r.text,"lxml")
|
| 106 |
def fetch_bongda_api(endpoint):
|
| 107 |
try:
|
| 108 |
+
r=requests.get(f"https://bongda.com.vn{endpoint}",headers=BONGDA_HEADERS,timeout=10)
|
| 109 |
if r.status_code==200:
|
| 110 |
data=r.json()
|
| 111 |
+
if data.get("status")=="success":return data.get("html","")
|
| 112 |
return ""
|
| 113 |
+
except:return ""
|
|
|
|
| 114 |
def _parse_match_from_li(li, status_type="live"):
|
| 115 |
match_div=li.select_one("div.match")
|
| 116 |
+
if not match_div:return None
|
| 117 |
+
home_el=match_div.select_one(".home-team .name");away_el=match_div.select_one(".away-team .name")
|
| 118 |
+
if not home_el or not away_el:return None
|
| 119 |
+
status_el=match_div.select_one(".status a");league_el=li.find_previous("strong");time_el=match_div.select_one(".match-time")
|
| 120 |
+
home_logo=match_div.select_one(".home-team .logo img");away_logo=match_div.select_one(".away-team .logo img")
|
| 121 |
event_id=""
|
| 122 |
if status_el:
|
| 123 |
+
href=status_el.get("href","");m=re.search(r'/tran-dau/(\d+)/',href)
|
| 124 |
+
if m:event_id=m.group(1)
|
| 125 |
+
spans=status_el.find_all("span") if status_el else [];score="";minute=""
|
| 126 |
+
if len(spans)>=3:score=f"{spans[0].get_text(strip=True)} - {spans[2].get_text(strip=True)}"
|
| 127 |
+
if len(spans)>=4:minute=spans[3].get_text(strip=True)
|
| 128 |
+
if not score and status_el and status_el.select_one(".vs"):score="VS"
|
| 129 |
league=league_el.get_text(strip=True) if league_el else ""
|
| 130 |
+
# Extract full URL with slug for direct scraping
|
| 131 |
+
match_url = ""
|
| 132 |
+
if status_el:
|
| 133 |
+
href = status_el.get("href", "")
|
| 134 |
+
if href:
|
| 135 |
+
match_url = "https://bongda.com.vn" + href if href.startswith("/") else href
|
| 136 |
+
return{"home":home_el.get_text(strip=True),"away":away_el.get_text(strip=True),"score":score or"VS","minute":minute,"league":league,"time":time_el.get_text(strip=True) if time_el else "","event_id":event_id,"url":match_url,"home_logo":home_logo.get("src","") if home_logo else "","away_logo":away_logo.get("src","") if away_logo else "","status":status_type}
|
| 137 |
|
| 138 |
# ===== VIDEO PROXY =====
|
| 139 |
@app.get("/api/proxy/m3u8")
|
| 140 |
def proxy_m3u8(url: str = Query(...)):
|
| 141 |
try:
|
| 142 |
r = requests.get(url, headers=HEADERS, timeout=15)
|
| 143 |
+
if r.status_code != 200:return Response(status_code=502, content="upstream error")
|
| 144 |
+
lines = r.text.strip().split('\n');rewritten = []
|
| 145 |
for line in lines:
|
| 146 |
+
if line.startswith('#') or not line.strip():rewritten.append(line)
|
| 147 |
+
else:rewritten.append("/api/proxy/seg?url=" + quote(line.strip(), safe=""))
|
| 148 |
+
return Response(content='\n'.join(rewritten).encode('utf-8'),media_type="application/vnd.apple.mpegurl",headers={"Access-Control-Allow-Origin":"*","Cache-Control":"public, max-age=300"})
|
| 149 |
+
except:return Response(status_code=502, content="proxy error")
|
| 150 |
|
| 151 |
@app.get("/api/proxy/seg")
|
| 152 |
def proxy_segment(url: str = Query(...)):
|
| 153 |
try:
|
| 154 |
r = requests.get(url, headers=HEADERS, timeout=30)
|
| 155 |
+
if r.status_code != 200:return Response(status_code=502, content="upstream error")
|
| 156 |
data = r.content
|
| 157 |
+
if len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47:data = data[188:]
|
| 158 |
+
return Response(content=data,media_type="video/mp2t",headers={"Access-Control-Allow-Origin":"*","Cache-Control":"public, max-age=3600"})
|
| 159 |
+
except:return Response(status_code=502, content="proxy error")
|
| 160 |
|
| 161 |
@app.get("/api/proxy/video")
|
| 162 |
def proxy_video(url: str = Query(...), request: Request = None):
|
| 163 |
try:
|
| 164 |
req_headers = dict(HEADERS)
|
| 165 |
+
if request and request.headers.get("range"):req_headers["Range"] = request.headers["range"]
|
| 166 |
r = requests.get(url, headers=req_headers, timeout=30, stream=True)
|
| 167 |
resp_headers = {"Access-Control-Allow-Origin":"*","Accept-Ranges":"bytes","Content-Type":r.headers.get("Content-Type","video/mp4")}
|
| 168 |
+
if "Content-Range" in r.headers:resp_headers["Content-Range"] = r.headers["Content-Range"]
|
| 169 |
+
if "Content-Length" in r.headers:resp_headers["Content-Length"] = r.headers["Content-Length"]
|
| 170 |
+
return StreamingResponse(r.iter_content(chunk_size=256*1024),status_code=r.status_code,headers=resp_headers)
|
| 171 |
+
except:return Response(status_code=502, content="proxy error")
|
| 172 |
|
| 173 |
@app.get("/api/proxy/img")
|
| 174 |
def proxy_img(url: str = Query(...)):
|
| 175 |
+
"""Proxy images from sources that block hotlinking (DanTri CDN)."""
|
| 176 |
try:
|
| 177 |
+
r = requests.get(url, headers={**HEADERS, "Referer": "https://dantri.com.vn/"}, timeout=10)
|
| 178 |
+
if r.status_code != 200:return Response(status_code=502)
|
| 179 |
+
ct = r.headers.get("Content-Type", "image/jpeg")
|
| 180 |
+
return Response(content=r.content, media_type=ct, headers={"Cache-Control": "public, max-age=86400", "Access-Control-Allow-Origin": "*"})
|
| 181 |
+
except:return Response(status_code=502)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
|
| 183 |
# ===== XEMLAIBONGDA HIGHLIGHTS =====
|
| 184 |
def _scrape_xemlaibongda_page(page_path, limit=20):
|
| 185 |
try:
|
| 186 |
url = f"https://xemlaibongda.top/{page_path}" if page_path else "https://xemlaibongda.top/"
|
| 187 |
+
r=requests.get(url,headers=HEADERS,timeout=15)
|
| 188 |
+
if r.status_code!=200:return[]
|
| 189 |
+
r.encoding="utf-8";soup=BeautifulSoup(r.text,"lxml");videos=[];seen=set()
|
| 190 |
+
for a in soup.find_all("a",href=True):
|
| 191 |
+
href=a.get("href","")
|
| 192 |
+
if"/video/" not in href:continue
|
| 193 |
+
if not href.startswith("http"):href="https://xemlaibongda.top"+href
|
| 194 |
+
if href in seen:continue
|
| 195 |
+
seen.add(href);slug=href.split("/video/")[-1].rstrip("/")
|
| 196 |
+
title=slug.replace("-"," ").title()
|
| 197 |
+
title=re.sub(r'\d{4}\s*\d{2}\s*\d{2}$','',title).strip()
|
| 198 |
+
title=re.sub(r'\s+V\s+',' vs ',title);title=re.sub(r'\s+Vs\s+',' vs ',title)
|
| 199 |
+
img=a.find("img") or (a.parent.find("img") if a.parent else None)
|
| 200 |
+
img_src=""
|
| 201 |
+
if img:img_src=img.get("data-src","") or img.get("src","") or img.get("data-lazy","")
|
| 202 |
+
if not img_src:img_src=f"https://img.refooty.com/thumbnail/{slug}.webp"
|
| 203 |
+
videos.append({"title":title,"link":href,"img":img_src,"source":"xemlaibongda"})
|
| 204 |
+
if len(videos)>=limit:break
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
return videos
|
| 206 |
+
except:return[]
|
|
|
|
| 207 |
|
| 208 |
+
def scrape_xemlaibongda():return _scrape_xemlaibongda_page("",20)
|
| 209 |
def scrape_highlights_by_league(league_key):
|
| 210 |
+
if league_key not in HL_LEAGUES:return[]
|
| 211 |
+
return _scrape_xemlaibongda_page(HL_LEAGUES[league_key]["path"],20)
|
| 212 |
+
|
| 213 |
def scrape_all_league_highlights():
|
| 214 |
results = {}
|
| 215 |
+
def _fetch(key):return key, scrape_highlights_by_league(key)
|
| 216 |
with ThreadPoolExecutor(8) as ex:
|
| 217 |
futs = [ex.submit(_fetch, k) for k in HL_LEAGUES]
|
| 218 |
+
for f in as_completed(futs):
|
| 219 |
+
try:
|
| 220 |
+
key, vids = f.result()
|
| 221 |
+
if vids:results[key] = vids
|
| 222 |
+
except:pass
|
| 223 |
return results
|
| 224 |
|
| 225 |
def extract_xemlaibongda_video(url):
|
| 226 |
try:
|
| 227 |
+
r=requests.get(url,headers=HEADERS,timeout=15)
|
| 228 |
+
if r.status_code!=200:return None
|
| 229 |
+
r.encoding="utf-8";soup=BeautifulSoup(r.text,"lxml");video=soup.find("video")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
if video:
|
| 231 |
+
src=video.get("src","");poster=video.get("poster","")
|
| 232 |
if not src:
|
| 233 |
source=video.find("source")
|
| 234 |
+
if source:src=source.get("src","")
|
| 235 |
+
if src:return{"src":src,"poster":poster,"type":"hls" if".m3u8" in src else"video"}
|
|
|
|
| 236 |
m3u8s=re.findall(r'(https?://[^\s"\'<>]+\.m3u8)',r.text)
|
| 237 |
+
if m3u8s:
|
| 238 |
+
og=soup.find("meta",property="og:image");poster=og.get("content","") if og else ""
|
| 239 |
+
return{"src":m3u8s[0],"poster":poster,"type":"hls"}
|
| 240 |
return None
|
| 241 |
+
except:return None
|
| 242 |
+
|
| 243 |
+
# ===== YOUTUBE SHORTS =====
|
| 244 |
+
def _yt_channel_shorts(channel, count=15):
|
| 245 |
+
"""Fast scrape YouTube shorts tab without yt-dlp. Returns newest-first IDs/titles."""
|
| 246 |
+
try:
|
| 247 |
+
url=f"https://www.youtube.com/@{channel}/shorts"
|
| 248 |
+
r=requests.get(url,headers={**HEADERS,"Accept-Language":"vi,en;q=0.8"},timeout=15)
|
| 249 |
+
if r.status_code!=200:return[]
|
| 250 |
+
html=r.text
|
| 251 |
+
ids=[];items=[]
|
| 252 |
+
for m in re.finditer(r'"videoId":"([A-Za-z0-9_-]{11})"',html):
|
| 253 |
+
vid=m.group(1)
|
| 254 |
+
if vid in ids:continue
|
| 255 |
+
ids.append(vid)
|
| 256 |
+
snip=html[max(0,m.start()-900):m.start()+1600]
|
| 257 |
+
title=""
|
| 258 |
+
mt=re.search(r'"title":\{"runs":\[\{"text":"([^"]+)"',snip)
|
| 259 |
+
if not mt:mt=re.search(r'"accessibilityText":"([^"]+)"',snip)
|
| 260 |
+
if mt:title=html_lib.unescape(mt.group(1)).replace('\n',' ').strip()
|
| 261 |
+
if not title:title="YouTube Short"
|
| 262 |
+
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})
|
| 263 |
+
if len(items)>=count:break
|
| 264 |
+
return items
|
| 265 |
+
except:return[]
|
| 266 |
+
def scrape_shorts():
|
| 267 |
+
"""Stable shorts feed: fast HTML scrape + static fallback so slide never disappears."""
|
| 268 |
+
vids=[]
|
| 269 |
+
with ThreadPoolExecutor(2) as ex:
|
| 270 |
+
futs=[ex.submit(_yt_channel_shorts,ch,24) for ch in ["baodantri7941","baosuckhoedoisongboyte"]]
|
| 271 |
+
for f in as_completed(futs):
|
| 272 |
+
try:
|
| 273 |
+
r=f.result()
|
| 274 |
+
if r:vids.extend(r)
|
| 275 |
+
except:pass
|
| 276 |
+
merged=[];seen=set()
|
| 277 |
+
for v in vids+SHORTS_FALLBACK:
|
| 278 |
+
vid=v.get("id")
|
| 279 |
+
if not vid or vid in seen:continue
|
| 280 |
+
seen.add(vid);merged.append(v)
|
| 281 |
+
return merged[:40]
|
| 282 |
|
| 283 |
# ===== LIVESCORE =====
|
| 284 |
@app.get("/api/livescore/live")
|
| 285 |
+
def api_livescore_live():return JSONResponse({"html":_cached("ls_live",lambda:fetch_bongda_api("/api/fixtures/live"),ttl=_cache_ttl_live)})
|
| 286 |
@app.get("/api/livescore/incoming")
|
| 287 |
+
def api_livescore_incoming():return JSONResponse({"html":_cached("ls_incoming",lambda:fetch_bongda_api("/api/fixtures/incoming"),ttl=_cache_ttl_live)})
|
| 288 |
@app.get("/api/livescore/today")
|
| 289 |
def api_livescore_today():
|
| 290 |
+
today=datetime.now().strftime("%Y-%m-%d");return JSONResponse({"html":_cached("ls_today",lambda:fetch_bongda_api(f"/api/fixtures/get-by-date?date={today}"),ttl=_cache_ttl)})
|
| 291 |
@app.get("/api/livescore/results")
|
| 292 |
def api_livescore_results():
|
| 293 |
+
today=datetime.now().strftime("%Y-%m-%d");return JSONResponse({"html":_cached("ls_results",lambda:fetch_bongda_api(f"/api/fixtures/get-by-date?date={today}&status=finished"),ttl=_cache_ttl)})
|
| 294 |
@app.get("/api/livescore/standings/{league}")
|
| 295 |
def api_livescore_standings(league:str):
|
| 296 |
tid=LEAGUE_IDS.get(league,27110);return JSONResponse({"html":_cached(f"ls_bxh_{league}",lambda:fetch_bongda_api(f"/api/league-table/home?tournament_id={tid}&is_detail=True"),ttl=_cache_ttl)})
|
| 297 |
@app.get("/api/livescore/date/{date}")
|
| 298 |
def api_livescore_date(date:str):return JSONResponse({"html":fetch_bongda_api(f"/api/fixtures/get-by-date?date={date}")})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
@app.get("/api/match/{event_id}/commentaries")
|
| 300 |
def api_match_commentaries(event_id:int):return JSONResponse({"html":fetch_bongda_api(f"/api/fixtures/commentaries?event_id={event_id}")})
|
| 301 |
@app.get("/api/match/{event_id}/stats")
|
| 302 |
def api_match_stats(event_id:int):return JSONResponse({"html":fetch_bongda_api(f"/api/event-standing/player-performance?event_id={event_id}")})
|
| 303 |
|
| 304 |
+
# ===== MATCH DETAIL (server-side scrape from bongda.com.vn) =====
|
| 305 |
from match_detail_v2 import fetch_match_detail, fetch_match_detail_by_url
|
| 306 |
|
| 307 |
@app.get("/api/match/{event_id}/detail")
|
| 308 |
def api_match_detail(event_id: int, url: str = Query(default="")):
|
| 309 |
+
"""Get full match detail by scraping bongda.com.vn server-side."""
|
| 310 |
try:
|
| 311 |
+
if url:
|
| 312 |
+
data = fetch_match_detail_by_url(url)
|
| 313 |
+
else:
|
| 314 |
+
data = fetch_match_detail(event_id)
|
| 315 |
return JSONResponse(data)
|
| 316 |
+
except Exception as e:
|
| 317 |
+
return JSONResponse({"event_id": event_id, "found": False, "error": str(e)})
|
| 318 |
|
| 319 |
@app.get("/api/livescore/featured")
|
| 320 |
def api_livescore_featured():
|
| 321 |
def _f():
|
| 322 |
+
sources=[("/api/fixtures/live","live"),("/api/fixtures/get-by-date?date="+datetime.now().strftime("%Y-%m-%d"),"today"),("/api/fixtures/incoming","upcoming")]
|
| 323 |
for endpoint, stype in sources:
|
| 324 |
html=fetch_bongda_api(endpoint)
|
| 325 |
if not html or len(html)<100:continue
|
|
|
|
| 337 |
return None
|
| 338 |
return JSONResponse(_cached("ls_featured",_f,ttl=30))
|
| 339 |
|
| 340 |
+
# ===== VIDEO APIs =====
|
| 341 |
+
@app.get("/api/shorts")
|
| 342 |
+
def api_shorts():return JSONResponse(_cached("yt_shorts_v3",scrape_shorts,ttl=_cache_ttl_yt))
|
| 343 |
+
@app.get("/api/short-stats")
|
| 344 |
+
def api_short_stats(ids:str=Query(default="")):
|
| 345 |
+
arr=[x for x in ids.split(",") if x]
|
| 346 |
+
with _short_lock:
|
| 347 |
+
db=_load_short_db();out={}
|
| 348 |
+
for vid in arr:
|
| 349 |
+
st=db.get(vid) or _short_default()
|
| 350 |
+
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]}
|
| 351 |
+
return JSONResponse({"stats":out})
|
| 352 |
+
|
| 353 |
+
@app.post("/api/short-action")
|
| 354 |
+
async def api_short_action(request:Request):
|
| 355 |
+
try:body=await request.json()
|
| 356 |
+
except:body={}
|
| 357 |
+
vid=str(body.get("id","")).strip();action=str(body.get("action","")).strip();txt=str(body.get("text","")).strip()
|
| 358 |
+
if not vid:return JSONResponse({"error":"missing id"},status_code=400)
|
| 359 |
+
with _short_lock:
|
| 360 |
+
db=_load_short_db();st=db.get(vid) or _short_default()
|
| 361 |
+
if action=="view":st["views"]=int(st.get("views",0))+1
|
| 362 |
+
elif action=="like":st["likes"]=int(st.get("likes",0))+1
|
| 363 |
+
elif action=="share":st["shares"]=int(st.get("shares",0))+1
|
| 364 |
+
elif action=="comment" and txt:
|
| 365 |
+
comments=st.get("comments",[])
|
| 366 |
+
comments.insert(0,{"text":txt[:180],"ts":int(time.time())})
|
| 367 |
+
st["comments"]=comments[:80]
|
| 368 |
+
st["updated"]=int(time.time());db[vid]=st;_save_short_db(db)
|
| 369 |
+
out={"views":int(st.get("views",0)),"likes":int(st.get("likes",0)),"shares":int(st.get("shares",0)),"comments":st.get("comments",[])[:80]}
|
| 370 |
+
return JSONResponse({"stats":out})
|
| 371 |
+
|
| 372 |
@app.get("/api/highlights")
|
| 373 |
+
def api_highlights():return JSONResponse(_cached("xemlaibongda_hl",scrape_xemlaibongda,ttl=_cache_ttl))
|
| 374 |
@app.get("/api/highlights/leagues")
|
| 375 |
+
def api_highlights_leagues():return JSONResponse(_cached("hl_leagues",scrape_all_league_highlights,ttl=_cache_ttl))
|
| 376 |
@app.get("/api/highlights/{league}")
|
| 377 |
def api_highlights_league(league:str):
|
| 378 |
+
if league not in HL_LEAGUES:return JSONResponse({"error":"league not found"})
|
| 379 |
return JSONResponse(_cached(f"hl_{league}",lambda:scrape_highlights_by_league(league),ttl=_cache_ttl))
|
| 380 |
+
@app.get("/api/highlights_config")
|
| 381 |
+
def api_highlights_config():return JSONResponse(HL_LEAGUES)
|
| 382 |
@app.get("/api/video_url")
|
| 383 |
+
def api_video_url(url:str=Query(...)):
|
| 384 |
if "youtube.com" in url or "youtu.be" in url:
|
| 385 |
m=re.search(r'(?:v=|shorts/|youtu\.be/)([a-zA-Z0-9_-]{11})',url)
|
| 386 |
+
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"})
|
| 387 |
if "xemlaibongda.top" in url:
|
| 388 |
v=extract_xemlaibongda_video(url)
|
| 389 |
if v:
|
| 390 |
+
if v["type"]=="hls":v["src"]="/api/proxy/m3u8?url="+quote(v["src"],safe="")
|
|
|
|
| 391 |
return JSONResponse(v)
|
| 392 |
+
if "bongdaplus.vn" in url:
|
| 393 |
+
try:
|
| 394 |
+
m=re.search(r'-(\d{6,})\.html',url)
|
| 395 |
+
if m:
|
| 396 |
+
r=requests.get(f"{BASE_BDP}/video-embed/{m.group(1)}.html",headers=HEADERS,timeout=10);r.encoding="utf-8"
|
| 397 |
+
soup=BeautifulSoup(r.text,"lxml");video=soup.select_one("video#videoPlayer")
|
| 398 |
+
if video:
|
| 399 |
+
source=video.find("source");src=source.get("src","") if source else "";poster=video.get("poster","")
|
| 400 |
+
if src:return JSONResponse({"src":"/api/proxy/video?url="+quote(src,safe=""),"poster":poster,"type":"video"})
|
| 401 |
+
except:pass
|
| 402 |
return JSONResponse({"error":"not found"})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
@app.get("/api/bdp_videos")
|
| 404 |
def api_bdp_videos():
|
| 405 |
def _f():
|
| 406 |
try:
|
| 407 |
+
soup=_get(f"{BASE_BDP}/video");arts=[];seen=set()
|
| 408 |
for a in soup.find_all("a",href=True):
|
| 409 |
href=a.get("href","")
|
| 410 |
if"/video/" not in href or href in("/video/","/video/ban-thang-dep","/video/highlight"):continue
|
| 411 |
+
if not href.startswith("http"):href=BASE_BDP+href
|
| 412 |
+
if href in seen:continue
|
| 413 |
title=re.sub(r'^\d{2}:\d{2}','',a.get_text(strip=True)).strip()
|
| 414 |
+
if not title or len(title)<5:continue
|
| 415 |
img_tag=a.find("img") or(a.parent.find("img") if a.parent else None)
|
| 416 |
img=(img_tag.get("data-src") or img_tag.get("src","")) if img_tag else ""
|
| 417 |
+
seen.add(href);arts.append({"title":title,"link":href,"img":img,"source":"bdp"})
|
| 418 |
return arts[:20]
|
| 419 |
+
except:return[]
|
| 420 |
return JSONResponse(_cached("bdp_videos",_f))
|
|
|
|
| 421 |
# ===== NEWS =====
|
|
|
|
|
|
|
| 422 |
def scrape_vne(cat_url):
|
| 423 |
try:
|
| 424 |
+
soup=_get(cat_url);arts=[]
|
| 425 |
for it in soup.select("article.item-news")[:15]:
|
| 426 |
a=it.select_one("h2.title-news a") or it.select_one("h3.title-news a")
|
| 427 |
+
if not a:continue
|
| 428 |
+
t=a.get("title","") or a.get_text(strip=True);lk=a.get("href","")
|
| 429 |
+
if not t or not lk:continue
|
| 430 |
+
im=it.find("img");img=(im.get("data-src") or im.get("src","")) if im else ""
|
| 431 |
+
if img and'blank'in img:
|
| 432 |
src=it.find("source")
|
| 433 |
+
if src:img=src.get("srcset","").split(",")[0].strip().split(" ")[0]
|
| 434 |
arts.append({"title":t,"link":lk,"img":img,"source":"vne"})
|
| 435 |
return arts
|
| 436 |
+
except:return[]
|
| 437 |
+
def scrape_vne_article(url):
|
| 438 |
+
try:
|
| 439 |
+
soup=_get(url);h1=soup.select_one("h1.title-detail");desc=soup.select_one("p.description")
|
| 440 |
+
og=soup.find("meta",property="og:image");og_img=og.get("content","") if og else ""
|
| 441 |
+
cd=soup.select_one("article.fck_detail");body=[]
|
| 442 |
+
if cd:
|
| 443 |
+
for ch in cd.children:
|
| 444 |
+
if not hasattr(ch,'name') or not ch.name:continue
|
| 445 |
+
if ch.name=="p":t=ch.get_text(strip=True);(body.append({"type":"p","text":t}) if t else None)
|
| 446 |
+
elif ch.name=="figure":
|
| 447 |
+
im=ch.find("img")
|
| 448 |
+
if im:s=im.get("data-src") or im.get("src","");body.append({"type":"img","src":s})
|
| 449 |
+
elif ch.name in("h2","h3"):body.append({"type":"heading","text":ch.get_text(strip=True)})
|
| 450 |
+
return{"title":h1.get_text(strip=True) if h1 else "","summary":desc.get_text(strip=True) if desc else "","og_image":og_img,"body":body,"source":"vne","url":url}
|
| 451 |
+
except:return None
|
| 452 |
+
def _scrape_dantri_homepage(cat_filter=None):
|
| 453 |
+
try:
|
| 454 |
+
soup=_get("https://dantri.com.vn/");arts=[];seen=set()
|
| 455 |
+
for a in soup.find_all("a",href=True):
|
| 456 |
+
href=a.get("href","");title=a.get("title","") or a.get_text(strip=True)
|
| 457 |
+
if not title or len(title)<15 or"javascript:" in href:continue
|
| 458 |
+
if not href.startswith("http"):href="https://dantri.com.vn"+href
|
| 459 |
+
if href in seen or not href.endswith(".htm"):continue
|
| 460 |
+
if cat_filter and f"/{cat_filter}/" not in href:continue
|
| 461 |
+
img_tag=a.find("img")
|
| 462 |
+
if not img_tag and a.parent:img_tag=a.parent.find("img")
|
| 463 |
+
img_src=""
|
| 464 |
+
if img_tag:img_src=img_tag.get("data-src","") or img_tag.get("src","")
|
| 465 |
+
if not img_src or "cdn" not in img_src:continue
|
| 466 |
+
proxied_img="/api/proxy/img?url="+quote(img_src,safe="")
|
| 467 |
+
seen.add(href);arts.append({"title":title,"link":href,"img":proxied_img,"source":"dantri"})
|
| 468 |
+
if len(arts)>=15:break
|
| 469 |
+
return arts
|
| 470 |
+
except:return[]
|
| 471 |
+
def scrape_dantri_hot():return _scrape_dantri_homepage()
|
| 472 |
+
def scrape_dantri_congnghe():
|
| 473 |
+
try:
|
| 474 |
+
soup=_get("https://dantri.com.vn/");arts=[];seen=set()
|
| 475 |
+
for a in soup.find_all("a",href=True):
|
| 476 |
+
href=a.get("href","");title=a.get("title","") or a.get_text(strip=True)
|
| 477 |
+
if not title or len(title)<15 or"javascript:" in href:continue
|
| 478 |
+
if not href.startswith("http"):href="https://dantri.com.vn"+href
|
| 479 |
+
if href in seen or not href.endswith(".htm"):continue
|
| 480 |
+
if"/cong-nghe/" not in href:continue
|
| 481 |
+
img_tag=a.find("img")
|
| 482 |
+
if not img_tag and a.parent:img_tag=a.parent.find("img")
|
| 483 |
+
img_src=""
|
| 484 |
+
if img_tag:img_src=img_tag.get("data-src","") or img_tag.get("src","")
|
| 485 |
+
if img_src and "cdn" in img_src:img_src="/api/proxy/img?url="+quote(img_src,safe="")
|
| 486 |
+
else:img_src=""
|
| 487 |
+
seen.add(href);arts.append({"title":title,"link":href,"img":img_src,"source":"dantri"})
|
| 488 |
+
if len(arts)>=15:break
|
| 489 |
+
return arts
|
| 490 |
+
except:return[]
|
| 491 |
def scrape_genk_ai():
|
| 492 |
+
"""Scrape AI articles from genk.vn - readable in-app"""
|
| 493 |
try:
|
| 494 |
r=requests.get("https://genk.vn/ai.chn",headers=HEADERS,timeout=15)
|
| 495 |
+
if r.status_code!=200:return[]
|
| 496 |
+
r.encoding="utf-8";soup=BeautifulSoup(r.text,"lxml")
|
| 497 |
+
articles=[];seen=set()
|
| 498 |
for a in soup.find_all("a",href=True):
|
| 499 |
href=a.get("href","")
|
| 500 |
+
if not href.endswith(".chn") or href=="/ai.chn":continue
|
| 501 |
+
if href.startswith("/"):href="https://genk.vn"+href
|
| 502 |
+
if href in seen or "genk.vn" not in href:continue
|
| 503 |
title=a.get("title","") or a.get_text(strip=True)
|
| 504 |
+
if not title or len(title)<20:continue
|
| 505 |
+
container=a.parent;img_src=""
|
| 506 |
for _ in range(6):
|
| 507 |
+
if container is None:break
|
| 508 |
for img in container.find_all("img"):
|
| 509 |
s=img.get("data-src","") or img.get("src","")
|
| 510 |
+
if s and "mediacdn" in s and "avatar" not in s and "logo" not in s:
|
| 511 |
+
img_src=s;break
|
| 512 |
+
if img_src:break
|
| 513 |
+
container=container.parent
|
| 514 |
seen.add(href)
|
| 515 |
if not img_src:
|
| 516 |
try:
|
| 517 |
+
og_r=requests.get(href,headers=HEADERS,timeout=8);og_r.encoding="utf-8"
|
| 518 |
+
og_soup=BeautifulSoup(og_r.text,"lxml");og_tag=og_soup.find("meta",property="og:image")
|
| 519 |
+
if og_tag:img_src=og_tag.get("content","")
|
| 520 |
+
except:pass
|
| 521 |
articles.append({"title":title,"link":href,"img":img_src,"source":"genk"})
|
| 522 |
+
if len(articles)>=30:break
|
| 523 |
return articles
|
| 524 |
+
except:return[]
|
| 525 |
+
|
| 526 |
+
def scrape_dantri_article(url):
|
| 527 |
+
try:
|
| 528 |
+
r=requests.get(url,headers=HEADERS,timeout=15);r.encoding="utf-8";soup=BeautifulSoup(r.text,"lxml")
|
| 529 |
+
for tag in soup.find_all(["script","style","nav","footer","aside"]):tag.decompose()
|
| 530 |
+
h1=soup.find("h1");og=soup.find("meta",property="og:image");og_img=og.get("content","") if og else ""
|
| 531 |
+
if og_img and "cdnphoto.dantri" in og_img:og_img="/api/proxy/img?url="+quote(og_img,safe="")
|
| 532 |
+
content=soup.select_one("main") or soup.select_one("div.singular-content") or soup.select_one("article");body=[]
|
| 533 |
+
if content:
|
| 534 |
+
for el in content.find_all(["p","h2","h3","figure","img"],recursive=True):
|
| 535 |
+
if el.name=="p":t=el.get_text(strip=True);(body.append({"type":"p","text":t}) if t and len(t)>15 else None)
|
| 536 |
+
elif el.name in("h2","h3"):t=el.get_text(strip=True);(body.append({"type":"heading","text":t}) if t else None)
|
| 537 |
+
elif el.name in("figure","img"):
|
| 538 |
+
im=el if el.name=="img" else el.find("img")
|
| 539 |
+
if im:
|
| 540 |
+
s=im.get("data-src") or im.get("src","")
|
| 541 |
+
if s and"base64" not in s:
|
| 542 |
+
if "cdnphoto.dantri" in s:s="/api/proxy/img?url="+quote(s,safe="")
|
| 543 |
+
body.append({"type":"img","src":s})
|
| 544 |
+
desc="";sapo=soup.select_one("h2.singular-sapo") or soup.select_one("h2[class*=sapo]")
|
| 545 |
+
if not sapo:
|
| 546 |
+
og_desc=soup.find("meta",property="og:description")
|
| 547 |
+
if og_desc:desc=og_desc.get("content","")
|
| 548 |
+
else:desc=sapo.get_text(strip=True)
|
| 549 |
+
return{"title":h1.get_text(strip=True) if h1 else "","summary":desc,"og_image":og_img,"body":body,"source":"dantri","url":url}
|
| 550 |
+
except:return None
|
| 551 |
+
def scrape_bbc_vietnamese():
|
| 552 |
+
try:
|
| 553 |
+
r=requests.get("https://www.bbc.com/vietnamese",headers={"User-Agent":"Mozilla/5.0","Accept-Language":"en-GB"},timeout=15);r.encoding="utf-8"
|
| 554 |
+
soup=BeautifulSoup(r.text,"lxml");arts=[];seen=set()
|
| 555 |
+
for a in soup.select("a[href*='/vietnamese/']"):
|
| 556 |
+
href=a.get("href","")
|
| 557 |
+
if not href or href=="/vietnamese" or href.count("/")<3:continue
|
| 558 |
+
if not href.startswith("http"):href="https://www.bbc.com"+href
|
| 559 |
+
if href in seen:continue
|
| 560 |
+
title=a.get_text(strip=True)
|
| 561 |
+
if not title or len(title)<15 or any(x in title.lower() for x in["đăng nhập","trang chủ","bbc news"]):continue
|
| 562 |
+
img="";container=a.parent
|
| 563 |
+
for _ in range(3):
|
| 564 |
+
if container:
|
| 565 |
+
im=container.find("img")
|
| 566 |
+
if im:img=im.get("src","") or im.get("data-src","");break
|
| 567 |
+
container=container.parent
|
| 568 |
+
seen.add(href);arts.append({"title":title,"link":href,"img":img,"source":"bbc"})
|
| 569 |
+
if len(arts)>=15:break
|
| 570 |
+
return arts
|
| 571 |
+
except:return[]
|
| 572 |
+
def scrape_bbc_article(url):
|
| 573 |
+
try:
|
| 574 |
+
r=requests.get(url,headers={"User-Agent":"Mozilla/5.0","Accept-Language":"en-GB"},timeout=15);r.encoding="utf-8"
|
| 575 |
+
soup=BeautifulSoup(r.text,"lxml");h1=soup.find("h1")
|
| 576 |
+
og=soup.find("meta",property="og:image");og_img=og.get("content","") if og else ""
|
| 577 |
+
body=[]
|
| 578 |
+
for p in soup.select("[data-component='text-block'] p, article p, main p"):
|
| 579 |
+
t=p.get_text(strip=True)
|
| 580 |
+
if t and len(t)>20:body.append({"type":"p","text":t})
|
| 581 |
+
return{"title":h1.get_text(strip=True) if h1 else "","summary":"","og_image":og_img,"body":body,"source":"bbc","url":url}
|
| 582 |
+
except:return None
|
| 583 |
+
|
| 584 |
+
def scrape_ttvh_worldcup():
|
| 585 |
+
"""Scrape all World Cup 2026 articles from The Thao Van Hoa RSS."""
|
| 586 |
+
try:
|
| 587 |
+
r=requests.get("https://thethaovanhoa.vn/rss/world-cup-2026.rss",headers=HEADERS,timeout=15);r.encoding="utf-8"
|
| 588 |
+
soup=BeautifulSoup(r.text,"xml");arts=[];seen=set()
|
| 589 |
+
for it in soup.find_all("item"):
|
| 590 |
+
title=(it.find("title").get_text(strip=True) if it.find("title") else "")
|
| 591 |
+
link=(it.find("link").get_text(strip=True) if it.find("link") else "")
|
| 592 |
+
desc=(it.find("description").get_text(" ",strip=True) if it.find("description") else "")
|
| 593 |
+
img="";ds=BeautifulSoup(desc,"lxml");im=ds.find("img")
|
| 594 |
+
if im:img=im.get("src","") or im.get("data-src","")
|
| 595 |
+
if title and link and link not in seen:
|
| 596 |
+
seen.add(link);arts.append({"title":title,"link":link,"img":img,"source":"ttvh"})
|
| 597 |
+
if arts:return arts
|
| 598 |
+
except:pass
|
| 599 |
+
try:
|
| 600 |
+
soup=_get("https://thethaovanhoa.vn/world-cup-2026.htm");arts=[];seen=set()
|
| 601 |
+
for a in soup.find_all("a",href=True):
|
| 602 |
+
href=a.get("href","")
|
| 603 |
+
if not href.startswith("http"):href="https://thethaovanhoa.vn"+href
|
| 604 |
+
if href in seen or "thethaovanhoa.vn" not in href:continue
|
| 605 |
+
if not re.search(r"/[^/]+-\d{8,}\.htm",href):continue
|
| 606 |
+
title=a.get("title","") or a.get_text(" ",strip=True)
|
| 607 |
+
img=None;p=a
|
| 608 |
+
for _ in range(5):
|
| 609 |
+
if p is None:break
|
| 610 |
+
img=p.find("img")
|
| 611 |
+
if img:break
|
| 612 |
+
p=p.parent
|
| 613 |
+
img_src=""
|
| 614 |
+
if img:
|
| 615 |
+
img_src=img.get("data-src","") or img.get("src","") or img.get("data-original","") or img.get("data-thumb","")
|
| 616 |
+
if len(title)<15:title=img.get("alt","") or img.get("title","") or title
|
| 617 |
+
if not title or len(title)<15:continue
|
| 618 |
+
seen.add(href);arts.append({"title":title,"link":href,"img":img_src,"source":"ttvh"})
|
| 619 |
+
if len(arts)>=24:break
|
| 620 |
+
return arts
|
| 621 |
+
except:return[]
|
| 622 |
+
|
| 623 |
+
def scrape_ttvh_article(url):
|
| 624 |
+
try:
|
| 625 |
+
soup=_get(url);h1=soup.find("h1");og=soup.find("meta",property="og:image");og_img=og.get("content","") if og else ""
|
| 626 |
+
og_title=soup.find("meta",property="og:title");fallback_title=og_title.get("content","") if og_title else ""
|
| 627 |
+
desc_el=soup.find("meta",property="og:description");desc=desc_el.get("content","") if desc_el else ""
|
| 628 |
+
cd=soup.select_one(".detail-content") or soup.select_one(".content-detail") or soup.select_one("article") or soup.select_one("main")
|
| 629 |
+
body=[]
|
| 630 |
+
if cd:
|
| 631 |
+
for el in cd.find_all(["p","h2","h3","figure","img"],recursive=True):
|
| 632 |
+
if el.name=="p":
|
| 633 |
+
t=el.get_text(strip=True)
|
| 634 |
+
if t and len(t)>20 and "Theo dõi" not in t:body.append({"type":"p","text":t})
|
| 635 |
+
elif el.name in ("h2","h3"):
|
| 636 |
+
t=el.get_text(strip=True)
|
| 637 |
+
if t:body.append({"type":"heading","text":t})
|
| 638 |
+
elif el.name in ("figure","img"):
|
| 639 |
+
im=el if el.name=="img" else el.find("img")
|
| 640 |
+
if im:
|
| 641 |
+
src=im.get("data-src") or im.get("src","") or im.get("data-original","")
|
| 642 |
+
if src and "base64" not in src:body.append({"type":"img","src":src})
|
| 643 |
+
if not body and desc:body=[{"type":"p","text":desc}]
|
| 644 |
+
return {"title":h1.get_text(strip=True) if h1 else fallback_title,"summary":desc,"og_image":og_img,"body":body,"source":"ttvh","url":url}
|
| 645 |
+
except:return None
|
| 646 |
|
| 647 |
+
VNE_CATS={"thoi-su":("https://vnexpress.net/thoi-su","Thời Sự"),"the-gioi":("https://vnexpress.net/the-gioi","Thế Giới"),"kinh-doanh":("https://vnexpress.net/kinh-doanh","Kinh Doanh"),"the-thao":("https://vnexpress.net/the-thao","Thể Thao"),"giai-tri":("https://vnexpress.net/giai-tri","Giải Trí"),"suc-khoe":("https://vnexpress.net/suc-khoe","Sức Khỏe"),"phap-luat":("https://vnexpress.net/phap-luat","Pháp Luật"),"giao-duc":("https://vnexpress.net/giao-duc","Giáo Dục"),"du-lich":("https://vnexpress.net/du-lich","Du Lịch"),"doi-song":("https://vnexpress.net/doi-song","Đời Sống")}
|
| 648 |
@app.get("/api/homepage")
|
| 649 |
def api_homepage():
|
| 650 |
def _f():
|
| 651 |
articles=[]
|
| 652 |
with ThreadPoolExecutor(12) as ex:
|
| 653 |
futs={ex.submit(scrape_vne,VNE_CATS[k][0]):VNE_CATS[k][1] for k in["thoi-su","the-gioi","kinh-doanh","the-thao","giai-tri","phap-luat","giao-duc","du-lich","doi-song"]}
|
| 654 |
+
futs[ex.submit(scrape_bbc_vietnamese)]="BBC"
|
| 655 |
for f in as_completed(futs):
|
| 656 |
try:
|
| 657 |
+
for a in f.result():a["group"]=futs[f];articles.append(a)
|
| 658 |
+
except:pass
|
| 659 |
return articles
|
| 660 |
return JSONResponse(_cached("homepage",_f))
|
|
|
|
| 661 |
@app.get("/api/category/{cat_id}")
|
| 662 |
def api_category(cat_id:str):
|
| 663 |
def _f():
|
| 664 |
+
if cat_id=="bbc":return scrape_bbc_vietnamese()
|
| 665 |
+
if cat_id=="cong-nghe":return scrape_genk_ai()
|
| 666 |
+
if cat_id in VNE_CATS:arts=scrape_vne(VNE_CATS[cat_id][0]);[a.update({"group":VNE_CATS[cat_id][1]}) for a in arts];return arts
|
| 667 |
+
return[]
|
|
|
|
|
|
|
| 668 |
return JSONResponse(_cached(f"cat_{cat_id}",_f))
|
|
|
|
| 669 |
@app.get("/api/categories")
|
| 670 |
def api_categories():
|
| 671 |
+
cats=[{"id":"bbc","name":"BBC Tiếng Việt","source":"bbc"},{"id":"cong-nghe","name":"Công Nghệ","source":"genk"}]
|
| 672 |
+
for k,(u,n) in VNE_CATS.items():cats.append({"id":k,"name":n,"source":"vne"})
|
| 673 |
return JSONResponse(cats)
|
| 674 |
+
@app.get("/api/dantri_hot")
|
| 675 |
+
def api_dantri_hot():return JSONResponse(_cached("dantri_hot",scrape_dantri_hot))
|
| 676 |
+
@app.get("/api/genk_ai")
|
| 677 |
+
def api_genk_ai():return JSONResponse(_cached("genk_ai",scrape_genk_ai,ttl=_cache_ttl))
|
| 678 |
+
@app.get("/api/worldcup2026")
|
| 679 |
+
def api_worldcup2026():return JSONResponse(_cached("ttvh_worldcup",scrape_ttvh_worldcup,ttl=_cache_ttl))
|
| 680 |
+
def scrape_genk_article(url):
|
| 681 |
try:
|
| 682 |
+
r=requests.get(url,headers=HEADERS,timeout=15);r.encoding="utf-8";soup=BeautifulSoup(r.text,"lxml")
|
| 683 |
+
h1=soup.find("h1");og=soup.find("meta",property="og:image");og_img=og.get("content","") if og else ""
|
| 684 |
+
og_title=soup.find("meta",property="og:title");fallback_title=og_title.get("content","") if og_title else ""
|
| 685 |
+
desc_el=soup.find("meta",property="og:description");desc=desc_el.get("content","") if desc_el else ""
|
| 686 |
+
cd=soup.select_one(".knc-content");body=[]
|
| 687 |
+
if cd:
|
| 688 |
+
for el in cd.find_all(["p","h2","h3","figure","img"],recursive=True):
|
| 689 |
+
if el.name=="p":t=el.get_text(strip=True);(body.append({"type":"p","text":t}) if t and len(t)>15 else None)
|
| 690 |
+
elif el.name in("h2","h3"):t=el.get_text(strip=True);(body.append({"type":"heading","text":t}) if t else None)
|
| 691 |
+
elif el.name in("figure","img"):
|
| 692 |
+
im=el if el.name=="img" else el.find("img")
|
| 693 |
+
if im:s=im.get("data-src") or im.get("src","");(body.append({"type":"img","src":s}) if s and"base64" not in s else None)
|
| 694 |
+
return{"title":h1.get_text(strip=True) if h1 else "","summary":desc,"og_image":og_img,"body":body,"source":"genk","url":url}
|
| 695 |
+
except:return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 696 |
|
| 697 |
@app.get("/api/article")
|
| 698 |
def api_article(url:str=Query(...)):
|
| 699 |
+
if"vnexpress.net" in url:data=scrape_vne_article(url)
|
| 700 |
+
elif"bbc.com" in url:data=scrape_bbc_article(url)
|
| 701 |
+
elif"dantri.com.vn" in url:data=scrape_dantri_article(url)
|
| 702 |
+
elif"genk.vn" in url:data=scrape_genk_article(url)
|
| 703 |
+
elif"thethaovanhoa.vn" in url:data=scrape_ttvh_article(url)
|
| 704 |
+
else:data=None
|
| 705 |
+
return JSONResponse(data if data else{"error":"not supported"})
|
| 706 |
+
def _web_context(topic):
|
| 707 |
+
"""Collect real web/news context for a topic."""
|
| 708 |
+
bits=[]
|
| 709 |
+
try:
|
| 710 |
+
rss="https://news.google.com/rss/search?q="+quote(topic)+"&hl=vi&gl=VN&ceid=VN:vi"
|
| 711 |
+
r=requests.get(rss,headers=HEADERS,timeout=12);r.encoding="utf-8"
|
| 712 |
+
soup=BeautifulSoup(r.text,"xml")
|
| 713 |
+
for it in soup.find_all("item")[:8]:
|
| 714 |
+
title=it.find("title").get_text(" ",strip=True) if it.find("title") else ""
|
| 715 |
+
src=it.find("source").get_text(" ",strip=True) if it.find("source") else ""
|
| 716 |
+
if title:bits.append((title+(" — "+src if src else ""))[:280])
|
| 717 |
+
except:pass
|
| 718 |
+
if bits:return "\n".join(bits)
|
| 719 |
+
try:
|
| 720 |
+
r=requests.get("https://html.duckduckgo.com/html/?q="+quote(topic),headers=HEADERS,timeout=12);r.encoding="utf-8"
|
| 721 |
+
soup=BeautifulSoup(r.text,"lxml")
|
| 722 |
+
for res in soup.select(".result")[:6]:
|
| 723 |
+
t=res.select_one(".result__title");sn=res.select_one(".result__snippet")
|
| 724 |
+
line=((t.get_text(" ",strip=True) if t else "")+" — "+(sn.get_text(" ",strip=True) if sn else "")).strip(" —")
|
| 725 |
+
if line:bits.append(line[:280])
|
| 726 |
+
except:pass
|
| 727 |
+
return "\n".join(bits)
|
| 728 |
+
|
| 729 |
+
def _jina_read(url):
|
| 730 |
+
try:
|
| 731 |
+
ju="https://r.jina.ai/http://"+url
|
| 732 |
+
r=requests.get(ju,headers=HEADERS,timeout=25);r.encoding="utf-8"
|
| 733 |
+
if r.status_code!=200 or not r.text:return None
|
| 734 |
+
lines=[x.rstrip() for x in r.text.splitlines()]
|
| 735 |
+
title="";img="";body=[];summary=""
|
| 736 |
+
for ln in lines[:40]:
|
| 737 |
+
if ln.startswith("Title:"):title=ln.replace("Title:","",1).strip()
|
| 738 |
+
elif ln.startswith("Image:"):img=ln.replace("Image:","",1).strip()
|
| 739 |
+
elif ln.startswith("Description:"):summary=ln.replace("Description:","",1).strip()
|
| 740 |
+
for ln in lines:
|
| 741 |
+
t=ln.strip()
|
| 742 |
+
if not t or t.startswith(("Title:","URL Source:","Published Time:","Markdown Content:","Image:","Description:")):continue
|
| 743 |
+
if len(t)>40:body.append({"type":"p","text":t})
|
| 744 |
+
if not body and summary:body=[{"type":"p","text":summary}]
|
| 745 |
+
return {"title":title or url,"summary":summary,"og_image":img,"body":body[:80],"source":"jina","url":url}
|
| 746 |
+
except:return None
|
| 747 |
+
|
| 748 |
+
def _scrape_generic_article(url):
|
| 749 |
+
try:
|
| 750 |
+
hdr={**HEADERS,"Accept":"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"}
|
| 751 |
+
r=requests.get(url,headers=hdr,timeout=15);r.encoding="utf-8"
|
| 752 |
+
ct=r.headers.get("content-type","").lower()
|
| 753 |
+
if r.status_code>=400 or "text/html" not in ct:
|
| 754 |
+
jr=_jina_read(url)
|
| 755 |
+
if jr:return jr
|
| 756 |
+
soup=BeautifulSoup(r.text,"lxml")
|
| 757 |
+
for tag in soup.find_all(["script","style","nav","footer","aside","form"]):tag.decompose()
|
| 758 |
+
h1=soup.find("h1")
|
| 759 |
+
ogt=soup.find("meta",property="og:title");title=h1.get_text(strip=True) if h1 else (ogt.get("content","") if ogt else "")
|
| 760 |
+
ogd=soup.find("meta",property="og:description");desc=ogd.get("content","") if ogd else ""
|
| 761 |
+
ogi=soup.find("meta",property="og:image");img=ogi.get("content","") if ogi else ""
|
| 762 |
+
main=soup.find("article") or soup.find("main") or soup.body
|
| 763 |
+
body=[]
|
| 764 |
+
if main:
|
| 765 |
+
for el in main.find_all(["p","h2","h3","figure","img"],recursive=True):
|
| 766 |
+
if el.name=="p":
|
| 767 |
+
t=el.get_text(" ",strip=True)
|
| 768 |
+
if t and len(t)>35:body.append({"type":"p","text":t})
|
| 769 |
+
elif el.name in ("h2","h3"):
|
| 770 |
+
t=el.get_text(" ",strip=True)
|
| 771 |
+
if t:body.append({"type":"heading","text":t})
|
| 772 |
+
elif el.name in ("figure","img"):
|
| 773 |
+
im=el if el.name=="img" else el.find("img")
|
| 774 |
+
if im:
|
| 775 |
+
src=im.get("data-src") or im.get("src","") or im.get("data-original","")
|
| 776 |
+
if src and "base64" not in src:body.append({"type":"img","src":src})
|
| 777 |
+
if not body:
|
| 778 |
+
jr=_jina_read(url)
|
| 779 |
+
if jr and jr.get("body"):return jr
|
| 780 |
+
if not body and desc:body=[{"type":"p","text":desc}]
|
| 781 |
+
return {"title":title or url,"summary":desc,"og_image":img,"body":body,"source":"generic","url":url}
|
| 782 |
+
except:
|
| 783 |
+
return _jina_read(url)
|
| 784 |
+
|
| 785 |
+
def _article_by_url(url):
|
| 786 |
+
if "vnexpress.net" in url:return scrape_vne_article(url)
|
| 787 |
+
if "bbc.com" in url:return scrape_bbc_article(url)
|
| 788 |
+
if "dantri.com.vn" in url:return scrape_dantri_article(url)
|
| 789 |
+
if "genk.vn" in url:return scrape_genk_article(url)
|
| 790 |
+
if "thethaovanhoa.vn" in url:return scrape_ttvh_article(url)
|
| 791 |
+
return _scrape_generic_article(url)
|
| 792 |
+
|
| 793 |
+
def _call_qwen(prompt, max_tokens=1800):
|
| 794 |
+
"""Try Qwen2.5-VL via HF router; return None if unavailable."""
|
| 795 |
+
try:
|
| 796 |
+
token=os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACEHUB_API_TOKEN") or os.environ.get("VAISTUDIO")
|
| 797 |
+
if not token:return None
|
| 798 |
+
headers={"Authorization":"Bearer "+token,"Content-Type":"application/json"}
|
| 799 |
+
payload={"model":"Qwen/Qwen2.5-VL-7B-Instruct","messages":[{"role":"user","content":prompt}],"max_tokens":max_tokens,"temperature":0.7}
|
| 800 |
+
r=requests.post("https://router.huggingface.co/v1/chat/completions",headers=headers,json=payload,timeout=75)
|
| 801 |
+
if r.status_code>=300:return None
|
| 802 |
+
j=r.json();return j.get("choices",[{}])[0].get("message",{}).get("content")
|
| 803 |
+
except:return None
|
| 804 |
+
|
| 805 |
+
def _collect_article_text(data, limit=28000):
|
| 806 |
+
title=(data or {}).get("title","");summary=(data or {}).get("summary","")
|
| 807 |
+
parts=[]
|
| 808 |
+
if summary:parts.append(summary)
|
| 809 |
+
for b in (data or {}).get("body",[]):
|
| 810 |
+
if b.get("type")=="heading":parts.append("## "+b.get("text","") )
|
| 811 |
+
elif b.get("type")=="p":parts.append(b.get("text","") )
|
| 812 |
+
text="\n".join([p.strip() for p in parts if p and p.strip()])
|
| 813 |
+
return title,text[:limit]
|
| 814 |
+
|
| 815 |
+
def _ai_rewrite_article(data,tone="tu-nhien"):
|
| 816 |
+
title,text=_collect_article_text(data)
|
| 817 |
+
prompt=("Bạn là biên tập viên báo điện tử tiếng Việt. Hãy viết lại bài dưới đây bằng ngôn ngữ tự nhiên, mạch lạc, không cắt khúc, không bỏ ý quan trọng. "
|
| 818 |
+
"Giữ đúng sự thật, không bịa, không thêm thông tin ngoài bài. Văn phong: "+tone+". "
|
| 819 |
+
"Đầu ra gồm: tiêu đề hấp dẫn, đoạn sapo 2-3 câu, các đoạn nội dung ngắn dễ đọc, và 3 gạch đầu dòng điểm chính.\n\n"
|
| 820 |
+
"TIÊU ĐỀ GỐC: "+title+"\n\nNỘI DUNG GỐC:\n"+text)
|
| 821 |
+
out=_call_qwen(prompt,2200)
|
| 822 |
+
if out and len(out)>300:return out.strip()
|
| 823 |
+
# Fallback: complete non-truncated rewrite using full collected text chunks
|
| 824 |
+
paras=[p.strip() for p in text.split("\n") if len(p.strip())>30]
|
| 825 |
+
body="\n\n".join(paras[:18])
|
| 826 |
+
bullets="\n".join(["• "+p[:220]+("..." if len(p)>220 else "") for p in paras[:5]])
|
| 827 |
+
return ("Bản tin AI viết lại: "+title+"\n\n"+
|
| 828 |
+
(paras[0] if paras else "")+"\n\n"+body+"\n\nĐiểm chính:\n"+bullets).strip()
|
| 829 |
+
|
| 830 |
+
def _image_for_topic(topic):
|
| 831 |
+
return "https://image.pollinations.ai/prompt/"+quote("editorial illustration, Vietnamese news, "+topic,safe="")+"?width=1024&height=576&nologo=true"
|
| 832 |
+
|
| 833 |
+
def _topic_articles(topic,limit=5):
|
| 834 |
+
items=[];seen=set()
|
| 835 |
try:
|
| 836 |
+
rss="https://news.google.com/rss/search?q="+quote(topic)+"&hl=vi&gl=VN&ceid=VN:vi"
|
| 837 |
+
r=requests.get(rss,headers=HEADERS,timeout=12);r.encoding="utf-8"
|
| 838 |
+
soup=BeautifulSoup(r.text,"xml")
|
| 839 |
+
for it in soup.find_all("item")[:limit*3]:
|
| 840 |
+
title=it.find("title").get_text(" ",strip=True) if it.find("title") else ""
|
| 841 |
+
link=it.find("link").get_text(strip=True) if it.find("link") else ""
|
| 842 |
+
src=it.find("source").get_text(" ",strip=True) if it.find("source") else ""
|
| 843 |
+
if not title or not link or link in seen:continue
|
| 844 |
+
seen.add(link);items.append({"title":title,"link":link,"source":src})
|
| 845 |
+
if len(items)>=limit:break
|
| 846 |
+
except:pass
|
| 847 |
+
return items
|
| 848 |
+
|
| 849 |
+
def _topic_article_context(topic):
|
| 850 |
+
"""Filter readable article sources by topic, then summarize actual article bodies."""
|
| 851 |
+
raw_keys=[k.lower() for k in re.findall(r"[\wÀ-ỹ]+",topic) if len(k)>2]
|
| 852 |
+
# Drop ultra-generic tokens; keep domain words such as giáo/dục, bóng/đá, world/cup.
|
| 853 |
+
stop={"trong","năm","the","and","của","cho","với","một","các","những","hiện","nay"}
|
| 854 |
+
keys=[k for k in raw_keys if k not in stop]
|
| 855 |
+
candidates=[];seen=set()
|
| 856 |
+
def add_items(items):
|
| 857 |
+
for a in items or []:
|
| 858 |
+
link=a.get("link","");title=a.get("title","")
|
| 859 |
+
if not link or link in seen:continue
|
| 860 |
+
seen.add(link);candidates.append(a)
|
| 861 |
+
try:add_items(scrape_genk_ai())
|
| 862 |
+
except:pass
|
| 863 |
+
try:add_items(scrape_dantri_congnghe())
|
| 864 |
+
except:pass
|
| 865 |
+
try:add_items(scrape_ttvh_worldcup())
|
| 866 |
+
except:pass
|
| 867 |
+
scored=[];img=""
|
| 868 |
+
for a in candidates[:40]:
|
| 869 |
+
data=_article_by_url(a.get("link",""))
|
| 870 |
+
if not data or not data.get("body"):continue
|
| 871 |
+
title=data.get("title") or a.get("title","")
|
| 872 |
+
ps=[b.get("text","") for b in data.get("body",[]) if b.get("type")=="p" and len(b.get("text",""))>40]
|
| 873 |
+
excerpt=" ".join(ps)[:1800] or data.get("summary","")
|
| 874 |
+
hay=(title+" "+excerpt).lower()
|
| 875 |
+
score=sum(1 for k in keys if k in hay)
|
| 876 |
+
# Require topic relevance when we have meaningful keys.
|
| 877 |
+
if keys and score==0:continue
|
| 878 |
+
if len(keys)>=2 and score<2 and not any(" ".join(keys[i:i+2]) in hay for i in range(len(keys)-1)):continue
|
| 879 |
+
scored.append((score,title,a.get("link",""),excerpt,data.get("og_image") or a.get("img","") or ""))
|
| 880 |
+
scored=sorted(scored,key=lambda x:x[0],reverse=True)[:5]
|
| 881 |
+
chunks=[]
|
| 882 |
+
for score,title,link,excerpt,im in scored:
|
| 883 |
+
if not img and im:img=im
|
| 884 |
+
chunks.append("BÀI: "+title+"\nURL: "+link+"\nNỘI DUNG LỌC: "+excerpt)
|
| 885 |
+
if chunks:return "\n\n".join(chunks),img
|
| 886 |
+
return _web_context(topic),""
|
| 887 |
+
|
| 888 |
+
def _topic_post_text(topic):
|
| 889 |
+
ctx,img=_topic_article_context(topic)
|
| 890 |
+
prompt=("Bạn là cây bút báo điện tử tiếng Việt. Hãy lọc các thông tin thực tế trong những nguồn dưới đây để viết một bài tóm tắt theo chủ đề: "+topic+
|
| 891 |
+
". Không viết chung chung. Chỉ dùng dữ kiện có trong nguồn; nếu nguồn khác nhau thì tổng hợp khách quan. "
|
| 892 |
+
"Đầu ra gồm: tiêu đề, sapo, các ý chính theo bullet, phần phân tích ngắn và kết luận.\n\nNGUỒN THỰC TẾ:\n"+ctx)
|
| 893 |
+
out=_call_qwen(prompt,1800)
|
| 894 |
+
if out and len(out)>300:return out.strip()
|
| 895 |
+
if ctx:
|
| 896 |
+
return "Bài tóm tắt theo chủ đề: "+topic+"\n\nDữ liệu thực tế đã lọc:\n"+ctx[:3500]+"\n\nTóm tắt: Các nguồn trên cho thấy chủ đề này đang có nhiều diễn biến đáng chú ý. Khi viết bài, nên nêu rõ bối cảnh, các điểm mới, tác động thực tế và những điều còn cần kiểm chứng."
|
| 897 |
+
return "Chưa thu thập được dữ liệu đủ rõ cho chủ đề: "+topic
|
| 898 |
+
|
| 899 |
+
@app.get("/api/wall")
|
| 900 |
+
def api_wall():return JSONResponse({"posts":_load_wall()[:50]})
|
| 901 |
+
|
| 902 |
+
@app.post("/api/rewrite_share")
|
| 903 |
+
async def api_rewrite_share(request:Request):
|
| 904 |
+
try:body=await request.json()
|
| 905 |
+
except:body={}
|
| 906 |
+
url=str(body.get("url","")).strip();tone=str(body.get("tone","tu-nhien")).strip()
|
| 907 |
+
if not url:return JSONResponse({"error":"missing url"},status_code=400)
|
| 908 |
+
data=_article_by_url(url)
|
| 909 |
+
if not data or not data.get("title") or (not data.get("body") and not data.get("summary")):
|
| 910 |
+
return JSONResponse({"error":"Không đọc được bài viết"},status_code=422)
|
| 911 |
+
post={"id":hashlib.md5((url+str(time.time())).encode()).hexdigest()[:12],"url":url,"title":data.get("title",""),"img":data.get("og_image","") or "","text":_ai_rewrite_article(data,tone),"ts":int(time.time()),"source":data.get("source","")}
|
| 912 |
+
posts=_load_wall();posts.insert(0,post);_save_wall(posts)
|
| 913 |
+
return JSONResponse({"post":post})
|
| 914 |
+
|
| 915 |
+
@app.post("/api/topic_post")
|
| 916 |
+
async def api_topic_post(request:Request):
|
| 917 |
+
try:body=await request.json()
|
| 918 |
+
except:body={}
|
| 919 |
+
topic=str(body.get("topic","")).strip()
|
| 920 |
+
if not topic:return JSONResponse({"error":"missing topic"},status_code=400)
|
| 921 |
+
ctx_img=_topic_article_context(topic)[1]
|
| 922 |
+
post={"id":hashlib.md5((topic+str(time.time())).encode()).hexdigest()[:12],"url":"","title":topic,"img":ctx_img or _image_for_topic(topic),"text":_topic_post_text(topic),"ts":int(time.time()),"source":"ai-topic"}
|
| 923 |
+
posts=_load_wall();posts.insert(0,post);_save_wall(posts)
|
| 924 |
+
return JSONResponse({"post":post})
|
| 925 |
+
|
| 926 |
+
@app.post("/api/url_wall")
|
| 927 |
+
async def api_url_wall(request:Request):
|
| 928 |
+
try:body=await request.json()
|
| 929 |
+
except:body={}
|
| 930 |
+
url=str(body.get("url","")).strip()
|
| 931 |
+
if not url:return JSONResponse({"error":"missing url"},status_code=400)
|
| 932 |
+
data=_article_by_url(url)
|
| 933 |
+
if not data or not data.get("title"):
|
| 934 |
+
return JSONResponse({"error":"Không đọc được URL"},status_code=422)
|
| 935 |
+
post={"id":hashlib.md5((url+str(time.time())).encode()).hexdigest()[:12],"url":url,"title":data.get("title",""),"img":data.get("og_image","") or "","text":_ai_rewrite_article(data,"ngan-gon-tu-nhien"),"ts":int(time.time()),"source":data.get("source","")}
|
| 936 |
+
posts=_load_wall();posts.insert(0,post);_save_wall(posts)
|
| 937 |
+
return JSONResponse({"post":post})
|
| 938 |
+
|
| 939 |
+
@app.get("/v")
|
| 940 |
+
async def video_share(url:str=Query(default=""),title:str=Query(default="VNEWS Video"),img:str=Query(default=""),type:str=Query(default="highlights")):
|
| 941 |
+
decoded_url=unquote(url);decoded_title=unquote(title)
|
| 942 |
+
redirect_script=f'<script>localStorage.setItem("pending_video",JSON.stringify({{"url":"{decoded_url}","type":"{type}"}}));location.href="{SPACE_URL}";</script>' if decoded_url else f'<script>location.href="{SPACE_URL}";</script>'
|
| 943 |
+
return HTMLResponse(f'<!DOCTYPE html><html><head><meta charset="utf-8"><title>{decoded_title}</title></head><body style="background:#111;color:#fff;text-align:center;padding:40px"><p>⏳</p>{redirect_script}</body></html>')
|
| 944 |
+
@app.get("/s")
|
| 945 |
+
async def share_redirect(url:str=Query(default=""),title:str=Query(default="VNEWS"),img:str=Query(default="")):
|
| 946 |
+
decoded_url=unquote(url)
|
| 947 |
+
if decoded_url and decoded_url.startswith("http"):
|
| 948 |
+
return RedirectResponse(url=decoded_url, status_code=302)
|
| 949 |
+
return RedirectResponse(url=SPACE_URL, status_code=302)
|
| 950 |
+
@app.get("/")
|
| 951 |
+
async def index():
|
| 952 |
+
with open("/app/static/index.html","r",encoding="utf-8") as f:return HTMLResponse(content=f.read())
|
| 953 |
+
app.mount("/static",StaticFiles(directory="/app/static"),name="static")
|
match_detail_v2.py
CHANGED
|
@@ -36,7 +36,7 @@ def _mk(html):
|
|
| 36 |
return BeautifulSoup(html, 'html.parser')
|
| 37 |
|
| 38 |
|
| 39 |
-
def fetch_html(url, timeout=
|
| 40 |
resp = requests.get(url, headers=HEADERS, timeout=timeout, allow_redirects=True)
|
| 41 |
resp.raise_for_status()
|
| 42 |
return resp.text
|
|
@@ -133,6 +133,39 @@ def parse_events(sp):
|
|
| 133 |
return events
|
| 134 |
|
| 135 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
def fetch_match_detail(event_id: int) -> dict:
|
| 137 |
import concurrent.futures
|
| 138 |
result = {"event_id": event_id, "found": False, "sections": []}
|
|
@@ -155,6 +188,20 @@ def fetch_match_detail(event_id: int) -> dict:
|
|
| 155 |
except Exception:
|
| 156 |
continue
|
| 157 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
if not html:
|
| 159 |
return result
|
| 160 |
|
|
@@ -284,7 +331,6 @@ def fetch_match_detail(event_id: int) -> dict:
|
|
| 284 |
|
| 285 |
|
| 286 |
def fetch_match_detail_by_url(url: str) -> dict:
|
| 287 |
-
import concurrent.futures
|
| 288 |
eid_match = re.search(r'/tran-dau/(\d+)/', url)
|
| 289 |
if not eid_match:
|
| 290 |
return {"event_id": 0, "found": False, "error": "Cannot extract event_id from URL"}
|
|
|
|
| 36 |
return BeautifulSoup(html, 'html.parser')
|
| 37 |
|
| 38 |
|
| 39 |
+
def fetch_html(url, timeout=15):
|
| 40 |
resp = requests.get(url, headers=HEADERS, timeout=timeout, allow_redirects=True)
|
| 41 |
resp.raise_for_status()
|
| 42 |
return resp.text
|
|
|
|
| 133 |
return events
|
| 134 |
|
| 135 |
|
| 136 |
+
def _get_slug_from_api(event_id: int) -> str:
|
| 137 |
+
"""Fetch match slug from bongda API to build full URL."""
|
| 138 |
+
endpoints = [
|
| 139 |
+
f"/api/fixtures/live",
|
| 140 |
+
f"/api/fixtures/get-by-date?date=__today__",
|
| 141 |
+
f"/api/fixtures/incoming",
|
| 142 |
+
]
|
| 143 |
+
import datetime
|
| 144 |
+
today = datetime.date.today().strftime("%Y-%m-%d")
|
| 145 |
+
for ep in endpoints:
|
| 146 |
+
ep = ep.replace("__today__", today)
|
| 147 |
+
try:
|
| 148 |
+
resp = requests.get(
|
| 149 |
+
f"https://bongda.com.vn{ep}",
|
| 150 |
+
headers=API_HEADERS, timeout=6
|
| 151 |
+
)
|
| 152 |
+
if resp.status_code == 200:
|
| 153 |
+
data = resp.json()
|
| 154 |
+
html = data.get("html", "")
|
| 155 |
+
if html:
|
| 156 |
+
sp = _mk(html)
|
| 157 |
+
for li in sp.select("li.match-detail"):
|
| 158 |
+
status_a = li.select_one(".status a")
|
| 159 |
+
if status_a:
|
| 160 |
+
href = status_a.get("href", "")
|
| 161 |
+
m = re.search(r'/tran-dau/(\d+)/', href)
|
| 162 |
+
if m and int(m.group(1)) == event_id:
|
| 163 |
+
return href # e.g. /tran-dau/123/preview/slug-name
|
| 164 |
+
except Exception:
|
| 165 |
+
continue
|
| 166 |
+
return ""
|
| 167 |
+
|
| 168 |
+
|
| 169 |
def fetch_match_detail(event_id: int) -> dict:
|
| 170 |
import concurrent.futures
|
| 171 |
result = {"event_id": event_id, "found": False, "sections": []}
|
|
|
|
| 188 |
except Exception:
|
| 189 |
continue
|
| 190 |
|
| 191 |
+
# If no slug URL worked, try to get slug from API
|
| 192 |
+
if not html:
|
| 193 |
+
slug_path = _get_slug_from_api(event_id)
|
| 194 |
+
if slug_path:
|
| 195 |
+
try:
|
| 196 |
+
resp = requests.get(
|
| 197 |
+
f"https://bongda.com.vn{slug_path}",
|
| 198 |
+
headers=HEADERS, timeout=10, allow_redirects=True
|
| 199 |
+
)
|
| 200 |
+
if resp.status_code == 200 and len(resp.text) > 1000:
|
| 201 |
+
html = resp.text
|
| 202 |
+
except Exception:
|
| 203 |
+
pass
|
| 204 |
+
|
| 205 |
if not html:
|
| 206 |
return result
|
| 207 |
|
|
|
|
| 331 |
|
| 332 |
|
| 333 |
def fetch_match_detail_by_url(url: str) -> dict:
|
|
|
|
| 334 |
eid_match = re.search(r'/tran-dau/(\d+)/', url)
|
| 335 |
if not eid_match:
|
| 336 |
return {"event_id": 0, "found": False, "error": "Cannot extract event_id from URL"}
|
patch_ai_hot.py
DELETED
|
@@ -1,55 +0,0 @@
|
|
| 1 |
-
"""PATCH AI: prepend AI topics to hot list + homepage route fix"""
|
| 2 |
-
import re, json, time
|
| 3 |
-
from fastapi.responses import HTMLResponse
|
| 4 |
-
|
| 5 |
-
# Import at runtime to avoid circular
|
| 6 |
-
try:
|
| 7 |
-
from main import app, rt
|
| 8 |
-
import ai_runtime_final6 as f6
|
| 9 |
-
from ai_runtime_final6 import f5
|
| 10 |
-
except:
|
| 11 |
-
f6, f5, rt = None, None, None
|
| 12 |
-
|
| 13 |
-
# Patch hot_topics to prepend AI topics
|
| 14 |
-
if f6 and hasattr(f6, '_HOT_CACHE') and hasattr(f6, '_hot_topics'):
|
| 15 |
-
_orig_hot = f6._hot_topics
|
| 16 |
-
def _hot_topics_patched():
|
| 17 |
-
topics = _orig_hot()
|
| 18 |
-
# Prepend AI topics to front
|
| 19 |
-
for ai in ['Công nghệ AI', 'World Cup 2026', 'Kinh tế Việt Nam']:
|
| 20 |
-
if not any(ai.lower() == t.get('topic','').lower() for t in topics):
|
| 21 |
-
topics.insert(0, {'label': f'#{ai.replace(" ", "")}', 'topic': ai, 'count': 0})
|
| 22 |
-
return topics[:24]
|
| 23 |
-
f6._hot_topics = f6._HOT_CACHE['d'] = _hot_topics_patched()
|
| 24 |
-
f6._HOT_CACHE['t'] = time.time()
|
| 25 |
-
|
| 26 |
-
PATCH_INJECT = r'''
|
| 27 |
-
<script>
|
| 28 |
-
const AI_HOT_TOPICS = ['Công nghệ AI', 'World Cup 2026', 'Kinh tế Việt Nam', 'Bóng đá châu Âu'];
|
| 29 |
-
async function ensureHotTopics(){let i=document.getElementById('ai-topic-input-final5');if(!i||document.getElementById('ai-hot-row'))return;let r=document.createElement('div');r.id='ai-hot-row';r.style.cssText='display:flex;gap:6px;overflow-x:auto;padding:4px 0;margin:6px 0';let t=[];try{let j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));t=j.topics||[];}catch(e){}AI_HOT_TOPICS.forEach(ai=>{if(!t.find(x=>(x.topic||'').toLowerCase()===ai.toLowerCase())){t.unshift({label:'#'+ai.replace(/\s+/g,''),topic:ai});}});r.innerHTML=t.slice(0,14).map(x=>`<button class="hot-chip" style="flex:0 0 auto;background:#222;border:1px solid #333;color:#ddd;border-radius:16px;padding:5px 10px;font-size:11px;cursor:pointer" onclick="document.getElementById('ai-topic-input-final5').value='${x.topic.replace(/'/g,'\\''}';document.getElementById('ai-topic-input-final5').focus();searchTopic('${x.topic.replace(/'/g,'\\''}')">${x.label}</button>`).join('');i.insertAdjacentElement('afterend',r);}
|
| 30 |
-
setInterval(ensureHotTopics,1500);
|
| 31 |
-
</script>
|
| 32 |
-
'''
|
| 33 |
-
|
| 34 |
-
# Register homepage route
|
| 35 |
-
if app and f5 and f6:
|
| 36 |
-
# Remove old / route
|
| 37 |
-
app.router.routes = [r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
|
| 38 |
-
|
| 39 |
-
@app.get('/')
|
| 40 |
-
async def patch_homepage():
|
| 41 |
-
html = f5.f4.f3.f2.f1._load_index_html() if f5 else "<html><body></body></html>"
|
| 42 |
-
body = (getattr(rt.old,'PATCH_INJECT','') if hasattr(rt,'old') else '') + \
|
| 43 |
-
(getattr(f5.f4.f3.f2.f1,'FINAL_INJECT','') if f5 else '') + \
|
| 44 |
-
(getattr(f5.f4.f3,'FINAL3_INJECT','') if f5 else '') + \
|
| 45 |
-
(getattr(f5.f4,'FINAL4_INJECT','') if f5 else '') + \
|
| 46 |
-
(getattr(f5,'FINAL5_INJECT','') if f5 else '') + \
|
| 47 |
-
(getattr(f6,'FINAL6_INJECT','') or '') + \
|
| 48 |
-
(getattr(f6,'FINAL6_FAST_HOME_INJECT','') or '') + \
|
| 49 |
-
(getattr(f6,'FINAL6E_INJECT','') or '') + \
|
| 50 |
-
PATCH_INJECT
|
| 51 |
-
if '</body>' in html:
|
| 52 |
-
html = html.replace('</body>', body + '\n</body>')
|
| 53 |
-
else:
|
| 54 |
-
html += body
|
| 55 |
-
return HTMLResponse(html)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
piped_client.py
DELETED
|
@@ -1,258 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
YouTube Shorts Scraper using Piped API
|
| 3 |
-
Piped is a privacy-friendly YouTube proxy that works without JS
|
| 4 |
-
"""
|
| 5 |
-
import requests
|
| 6 |
-
import json
|
| 7 |
-
import time
|
| 8 |
-
import threading
|
| 9 |
-
|
| 10 |
-
_cache = {}
|
| 11 |
-
_lock = threading.Lock()
|
| 12 |
-
CACHE_TTL = 900 # 15 min
|
| 13 |
-
|
| 14 |
-
# Piped API instances (public)
|
| 15 |
-
PIPED_INSTANCES = [
|
| 16 |
-
"https://pipedapi.kavin.rocks",
|
| 17 |
-
"https://pipedapi.adminforge.de",
|
| 18 |
-
"https://api.piped.projectsegfau.lt",
|
| 19 |
-
]
|
| 20 |
-
|
| 21 |
-
UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
|
| 22 |
-
|
| 23 |
-
def _cached(key):
|
| 24 |
-
with _lock:
|
| 25 |
-
if key in _cache and time.time() - _cache[key]['t'] < CACHE_TTL:
|
| 26 |
-
return _cache[key]['d']
|
| 27 |
-
return None
|
| 28 |
-
|
| 29 |
-
def _set_cache(key, data):
|
| 30 |
-
with _lock:
|
| 31 |
-
_cache[key] = {'t': time.time(), 'd': data}
|
| 32 |
-
|
| 33 |
-
def _piped_request(path, params=None):
|
| 34 |
-
"""Try multiple Piped instances"""
|
| 35 |
-
last_err = None
|
| 36 |
-
for base in PIPED_INSTANCES:
|
| 37 |
-
try:
|
| 38 |
-
url = f"{base}{path}"
|
| 39 |
-
r = requests.get(url, params=params, headers=UA, timeout=15)
|
| 40 |
-
if r.status_code == 200:
|
| 41 |
-
return r.json()
|
| 42 |
-
except Exception as e:
|
| 43 |
-
last_err = e
|
| 44 |
-
continue
|
| 45 |
-
raise Exception(f"All Piped instances failed: {last_err}")
|
| 46 |
-
|
| 47 |
-
def get_channel_videos(channel_id, max_videos=200):
|
| 48 |
-
"""Get all videos from a channel using Piped API with pagination"""
|
| 49 |
-
cached = _cached(f'ch_vids_{channel_id}')
|
| 50 |
-
if cached is not None:
|
| 51 |
-
return cached
|
| 52 |
-
|
| 53 |
-
all_videos = []
|
| 54 |
-
page = None
|
| 55 |
-
|
| 56 |
-
while len(all_videos) < max_videos:
|
| 57 |
-
try:
|
| 58 |
-
if page:
|
| 59 |
-
data = _piped_request(f"/channels/{channel_id}/videos", {"nextpage": page})
|
| 60 |
-
else:
|
| 61 |
-
data = _piped_request(f"/channels/{channel_id}/videos")
|
| 62 |
-
|
| 63 |
-
videos = data.get('relatedStreams', [])
|
| 64 |
-
if not videos:
|
| 65 |
-
break
|
| 66 |
-
|
| 67 |
-
all_videos.extend(videos)
|
| 68 |
-
|
| 69 |
-
# Check for next page
|
| 70 |
-
next_page = data.get('nextpage')
|
| 71 |
-
if not next_page or next_page == page:
|
| 72 |
-
break
|
| 73 |
-
page = next_page
|
| 74 |
-
|
| 75 |
-
# Small delay to be polite
|
| 76 |
-
time.sleep(0.3)
|
| 77 |
-
|
| 78 |
-
if len(all_videos) >= max_videos:
|
| 79 |
-
break
|
| 80 |
-
|
| 81 |
-
except Exception as e:
|
| 82 |
-
print(f"Piped pagination error: {e}")
|
| 83 |
-
break
|
| 84 |
-
|
| 85 |
-
result = all_videos[:max_videos]
|
| 86 |
-
_set_cache(f'ch_vids_{channel_id}', result)
|
| 87 |
-
return result
|
| 88 |
-
|
| 89 |
-
def get_vtvnambo_shorts_piped(max_count=50):
|
| 90 |
-
"""Get shorts from VTV Nam Bộ using Piped API"""
|
| 91 |
-
# VTV Nam Bộ channel ID
|
| 92 |
-
channel_id = "UCJ0btJV8qh7J7R2aXb9GmGA"
|
| 93 |
-
|
| 94 |
-
try:
|
| 95 |
-
videos = get_channel_videos(channel_id, 200)
|
| 96 |
-
|
| 97 |
-
shorts = []
|
| 98 |
-
for v in videos:
|
| 99 |
-
title = v.get('title', '')
|
| 100 |
-
vid = v.get('url', '').replace('/watch?v=', '')
|
| 101 |
-
if not vid:
|
| 102 |
-
continue
|
| 103 |
-
|
| 104 |
-
# Filter for shorts: title has #shorts, or duration <= 60s
|
| 105 |
-
duration = v.get('duration', 0)
|
| 106 |
-
is_short = (
|
| 107 |
-
'#shorts' in title.lower() or
|
| 108 |
-
'#short' in title.lower() or
|
| 109 |
-
(duration > 0 and duration <= 60)
|
| 110 |
-
)
|
| 111 |
-
|
| 112 |
-
if is_short:
|
| 113 |
-
shorts.append({
|
| 114 |
-
'id': vid,
|
| 115 |
-
'title': title,
|
| 116 |
-
'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg",
|
| 117 |
-
'channel': 'vtvnambo',
|
| 118 |
-
})
|
| 119 |
-
|
| 120 |
-
if shorts:
|
| 121 |
-
return shorts[:max_count]
|
| 122 |
-
except Exception as e:
|
| 123 |
-
print(f"Piped shorts error: {e}")
|
| 124 |
-
|
| 125 |
-
return []
|
| 126 |
-
|
| 127 |
-
def get_vtvnambo_shorts_rss(max_count=50):
|
| 128 |
-
"""Get shorts from YouTube RSS feed"""
|
| 129 |
-
cached = _cached('vtvnambo_rss')
|
| 130 |
-
if cached is not None:
|
| 131 |
-
return cached
|
| 132 |
-
|
| 133 |
-
from xml.etree import ElementTree as ET
|
| 134 |
-
|
| 135 |
-
# First get channel ID from page
|
| 136 |
-
channel_id = None
|
| 137 |
-
try:
|
| 138 |
-
r = requests.get("https://www.youtube.com/@vtvnambo", headers=UA, timeout=15)
|
| 139 |
-
if r.status_code == 200:
|
| 140 |
-
m = re.search(r'"channelId":"(UC[^"]+)"', r.text)
|
| 141 |
-
if m:
|
| 142 |
-
channel_id = m.group(1)
|
| 143 |
-
except:
|
| 144 |
-
pass
|
| 145 |
-
|
| 146 |
-
if not channel_id:
|
| 147 |
-
channel_id = "UCJ0btJV8qh7J7R2aXb9GmGA" # fallback
|
| 148 |
-
|
| 149 |
-
try:
|
| 150 |
-
url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
|
| 151 |
-
r = requests.get(url, headers=UA, timeout=15)
|
| 152 |
-
if r.status_code != 200:
|
| 153 |
-
return []
|
| 154 |
-
|
| 155 |
-
root = ET.fromstring(r.text)
|
| 156 |
-
ns = {'atom': 'http://www.w3.org/2005/Atom', 'yt': 'http://www.youtube.com/xml/schemas/2015'}
|
| 157 |
-
|
| 158 |
-
shorts = []
|
| 159 |
-
for entry in root.findall('atom:entry', ns)[:max_count * 2]:
|
| 160 |
-
title_el = entry.find('atom:title', ns)
|
| 161 |
-
title = title_el.text if title_el is not None and title_el.text else ''
|
| 162 |
-
|
| 163 |
-
vid_el = entry.find('yt:videoId', ns)
|
| 164 |
-
vid = vid_el.text if vid_el is not None else ''
|
| 165 |
-
if not vid:
|
| 166 |
-
continue
|
| 167 |
-
|
| 168 |
-
is_short = '#shorts' in title.lower() or '#short' in title.lower()
|
| 169 |
-
link_el = entry.find('atom:link', ns)
|
| 170 |
-
link = link_el.get('href', '') if link_el is not None else ''
|
| 171 |
-
if '/shorts/' in link:
|
| 172 |
-
is_short = True
|
| 173 |
-
|
| 174 |
-
if is_short:
|
| 175 |
-
shorts.append({
|
| 176 |
-
'id': vid,
|
| 177 |
-
'title': title,
|
| 178 |
-
'img': f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg",
|
| 179 |
-
'channel': 'vtvnambo',
|
| 180 |
-
})
|
| 181 |
-
|
| 182 |
-
_set_cache('vtvnambo_rss', shorts[:max_count])
|
| 183 |
-
return shorts[:max_count]
|
| 184 |
-
except Exception as e:
|
| 185 |
-
print(f"RSS error: {e}")
|
| 186 |
-
|
| 187 |
-
return []
|
| 188 |
-
|
| 189 |
-
def get_vtvnambo_shorts(max_count=50):
|
| 190 |
-
"""Get all shorts from VTV Nam Bộ. Tries Piped API first, then RSS."""
|
| 191 |
-
cached = _cached('vtvnambo_shorts_v3')
|
| 192 |
-
if cached is not None:
|
| 193 |
-
return cached
|
| 194 |
-
|
| 195 |
-
all_shorts = []
|
| 196 |
-
seen_ids = set()
|
| 197 |
-
|
| 198 |
-
# Method 1: Piped API (most reliable)
|
| 199 |
-
try:
|
| 200 |
-
piped_shorts = get_vtvnambo_shorts_piped(max_count)
|
| 201 |
-
for s in piped_shorts:
|
| 202 |
-
if s['id'] not in seen_ids:
|
| 203 |
-
seen_ids.add(s['id'])
|
| 204 |
-
all_shorts.append(s)
|
| 205 |
-
print(f"Piped API found {len(piped_shorts)} shorts")
|
| 206 |
-
except Exception as e:
|
| 207 |
-
print(f"Piped method failed: {e}")
|
| 208 |
-
|
| 209 |
-
# Method 2: RSS feed
|
| 210 |
-
if len(all_shorts) < 3:
|
| 211 |
-
try:
|
| 212 |
-
rss_shorts = get_vtvnambo_shorts_rss(max_count)
|
| 213 |
-
for s in rss_shorts:
|
| 214 |
-
if s['id'] not in seen_ids:
|
| 215 |
-
seen_ids.add(s['id'])
|
| 216 |
-
all_shorts.append(s)
|
| 217 |
-
print(f"RSS found {len(rss_shorts)} shorts")
|
| 218 |
-
except Exception as e:
|
| 219 |
-
print(f"RSS method failed: {e}")
|
| 220 |
-
|
| 221 |
-
result = all_shorts[:max_count]
|
| 222 |
-
_set_cache('vtvnambo_shorts_v3', result)
|
| 223 |
-
return result
|
| 224 |
-
|
| 225 |
-
def get_wc_related_shorts(max_count=30):
|
| 226 |
-
"""Get World Cup / football related shorts."""
|
| 227 |
-
all_shorts = get_vtvnambo_shorts(max_count * 3)
|
| 228 |
-
|
| 229 |
-
wc_kws = [
|
| 230 |
-
'world cup', 'wc 2026', 'worldcup', 'fifa', 'bóng đá',
|
| 231 |
-
'trận đấu', 'đội tuyển', 'tuyển', 'vòng loại',
|
| 232 |
-
'khoảnh khắc', 'highlights', 'bàn thắng', 'goal',
|
| 233 |
-
'kết quả', 'tỉ số', 'việt nam', 'vn',
|
| 234 |
-
'ngoại hạng', 'premier league', 'champions league',
|
| 235 |
-
'laliga', 'serie a', 'bundesliga', 'ligue 1',
|
| 236 |
-
'copa', 'europa', 'c1', 'c2',
|
| 237 |
-
'messi', 'ronaldo', 'neymar', 'mbappe', 'haaland',
|
| 238 |
-
'v-league', 'vleague', 'bóng đá việt',
|
| 239 |
-
'đội bóng', 'hlv', 'huấn luyện viên',
|
| 240 |
-
'chuyển nhượng', 'transfer',
|
| 241 |
-
'asian cup', 'aff cup', 'sea games',
|
| 242 |
-
'olympic', 'u23', 'u20', 'u17',
|
| 243 |
-
]
|
| 244 |
-
|
| 245 |
-
wc_shorts = []
|
| 246 |
-
for s in all_shorts:
|
| 247 |
-
tl = s.get('title', '').lower()
|
| 248 |
-
if any(k in tl for k in wc_kws):
|
| 249 |
-
wc_shorts.append(s)
|
| 250 |
-
|
| 251 |
-
if not wc_shorts:
|
| 252 |
-
wc_shorts = all_shorts
|
| 253 |
-
|
| 254 |
-
return wc_shorts[:max_count]
|
| 255 |
-
|
| 256 |
-
import re
|
| 257 |
-
# Alias for backward compatibility
|
| 258 |
-
get_vtvnamo_shorts = get_vtvnambo_shorts
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
rebuild3.md
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
rebuild
|
|
|
|
|
|
rebuild_trigger.txt
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
TRIGGER_REBUILD=20260719
|
|
|
|
|
|
requirements.txt
CHANGED
|
@@ -11,4 +11,4 @@ pillow
|
|
| 11 |
edge-tts
|
| 12 |
python-dateutil
|
| 13 |
httpx
|
| 14 |
-
#
|
|
|
|
| 11 |
edge-tts
|
| 12 |
python-dateutil
|
| 13 |
httpx
|
| 14 |
+
# vtv-fix-rebuild
|
restart.txt
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
restart
|
|
|
|
|
|
restart2.md
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
restart with _run.py fix
|
|
|
|
|
|
rewrite_fix_v2.js
DELETED
|
@@ -1,2 +0,0 @@
|
|
| 1 |
-
// No-op - all functionality built into app_v2.js
|
| 2 |
-
(function(){})();
|
|
|
|
|
|
|
|
|
rewrite_slide.py
CHANGED
|
@@ -84,36 +84,17 @@ def _scrape_article_full(url):
|
|
| 84 |
return None
|
| 85 |
|
| 86 |
|
| 87 |
-
def _ensure_complete_sentence(text):
|
| 88 |
-
"""Ensure text ends with complete sentence ending."""
|
| 89 |
-
text = _clean(text)
|
| 90 |
-
if not text:
|
| 91 |
-
return text
|
| 92 |
-
# Find last complete sentence ending
|
| 93 |
-
for end_char in ['.', '!', '?']:
|
| 94 |
-
last_pos = text.rfind(end_char)
|
| 95 |
-
if last_pos > len(text) * 0.5: # Keep if ending is in latter half
|
| 96 |
-
return text[:last_pos + 1].strip()
|
| 97 |
-
return text
|
| 98 |
-
|
| 99 |
def _extract_key_points(paragraphs, max_points=5):
|
| 100 |
-
"""Extract key points: take first
|
| 101 |
points = []
|
| 102 |
for p in paragraphs:
|
| 103 |
if len(points) >= max_points: break
|
| 104 |
-
#
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
if len(s) >= 30 and re.search(r'[.!?]$', s):
|
| 111 |
-
sentence = s
|
| 112 |
-
break
|
| 113 |
-
|
| 114 |
-
# If no complete sentence found, take first sentence and ensure it ends
|
| 115 |
-
if not sentence and sentences:
|
| 116 |
-
sentence = _ensure_complete_sentence(sentences[0])
|
| 117 |
|
| 118 |
# Skip if too short or duplicate
|
| 119 |
if len(sentence) < 30: continue
|
|
|
|
| 84 |
return None
|
| 85 |
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
def _extract_key_points(paragraphs, max_points=5):
|
| 88 |
+
"""Extract key points: take first sentence of each significant paragraph."""
|
| 89 |
points = []
|
| 90 |
for p in paragraphs:
|
| 91 |
if len(points) >= max_points: break
|
| 92 |
+
# Take first complete sentence (ends with . ! ?)
|
| 93 |
+
m = re.match(r'^(.+?[.!?])\s', p)
|
| 94 |
+
if m:
|
| 95 |
+
sentence = m.group(1)
|
| 96 |
+
else:
|
| 97 |
+
sentence = p[:150] + ('.' if not p.endswith('.') else '')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
# Skip if too short or duplicate
|
| 100 |
if len(sentence) < 30: continue
|
runtime.txt
DELETED
|
File without changes
|
shorts_cache.py
DELETED
|
@@ -1,86 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
VNEWS Shorts Runtime Cache - External Updater Module
|
| 3 |
-
GitHub Actions fetches YouTube shorts via yt-dlp -> POST to /api/shorts/update
|
| 4 |
-
Space saves to RAM cache + persistent file if /data available
|
| 5 |
-
"""
|
| 6 |
-
import os
|
| 7 |
-
import json
|
| 8 |
-
import time
|
| 9 |
-
import threading
|
| 10 |
-
|
| 11 |
-
# Runtime cache (RAM)
|
| 12 |
-
_shorts_runtime_cache = None
|
| 13 |
-
_shorts_cache_ts = 0
|
| 14 |
-
_shorts_cache_lock = threading.Lock()
|
| 15 |
-
|
| 16 |
-
# Secret for authenticating update requests
|
| 17 |
-
SHORTS_UPDATE_SECRET = os.environ.get("SHORTS_UPDATE_SECRET", "vnews-shorts-2026")
|
| 18 |
-
|
| 19 |
-
# Paths
|
| 20 |
-
SHORTS_CACHE_FILE = "/data/shorts_runtime_cache.json" if os.path.isdir("/data") else "/app/shorts_runtime_cache.json"
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
def get_runtime_cache():
|
| 24 |
-
"""Get cached shorts (from RAM or file fallback)"""
|
| 25 |
-
global _shorts_runtime_cache, _shorts_cache_ts
|
| 26 |
-
with _shorts_cache_lock:
|
| 27 |
-
if _shorts_runtime_cache is not None:
|
| 28 |
-
age = time.time() - _shorts_cache_ts
|
| 29 |
-
if age < 7200: # 2h fresh
|
| 30 |
-
return _shorts_runtime_cache
|
| 31 |
-
|
| 32 |
-
# Try file fallback
|
| 33 |
-
try:
|
| 34 |
-
if os.path.exists(SHORTS_CACHE_FILE):
|
| 35 |
-
with open(SHORTS_CACHE_FILE, "r", encoding="utf-8") as f:
|
| 36 |
-
data = json.load(f)
|
| 37 |
-
age = time.time() - data.get("ts", 0)
|
| 38 |
-
if age < 86400: # 24h stale limit
|
| 39 |
-
items = data.get("items", [])
|
| 40 |
-
with _shorts_cache_lock:
|
| 41 |
-
_shorts_runtime_cache = items
|
| 42 |
-
_shorts_cache_ts = data.get("ts", time.time())
|
| 43 |
-
return items
|
| 44 |
-
except Exception as e:
|
| 45 |
-
print(f"[cache] read error: {e}")
|
| 46 |
-
|
| 47 |
-
return None
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
def set_runtime_cache(items):
|
| 51 |
-
"""Update runtime cache from external data"""
|
| 52 |
-
global _shorts_runtime_cache, _shorts_cache_ts
|
| 53 |
-
ts = time.time()
|
| 54 |
-
with _shorts_cache_lock:
|
| 55 |
-
_shorts_runtime_cache = items
|
| 56 |
-
_shorts_cache_ts = ts
|
| 57 |
-
|
| 58 |
-
# Also write to file (persistent if /data mounted)
|
| 59 |
-
try:
|
| 60 |
-
os.makedirs(os.path.dirname(SHORTS_CACHE_FILE), exist_ok=True)
|
| 61 |
-
payload = {"items": items, "ts": ts, "count": len(items)}
|
| 62 |
-
with open(SHORTS_CACHE_FILE, "w", encoding="utf-8") as f:
|
| 63 |
-
json.dump(payload, f, ensure_ascii=False, indent=2)
|
| 64 |
-
print(f"[cache] saved {len(items)} shorts to {SHORTS_CACHE_FILE}")
|
| 65 |
-
except Exception as e:
|
| 66 |
-
print(f"[cache] write skipped: {e}")
|
| 67 |
-
|
| 68 |
-
return len(items)
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
def get_cache_status():
|
| 72 |
-
"""Return status dict for the cache"""
|
| 73 |
-
cache = None
|
| 74 |
-
with _shorts_cache_lock:
|
| 75 |
-
if _shorts_runtime_cache is not None:
|
| 76 |
-
cache = _shorts_runtime_cache
|
| 77 |
-
age = int(time.time() - _shorts_cache_ts)
|
| 78 |
-
else:
|
| 79 |
-
age = -1
|
| 80 |
-
return {
|
| 81 |
-
"cached": cache is not None,
|
| 82 |
-
"count": len(cache) if cache else 0,
|
| 83 |
-
"age_seconds": age,
|
| 84 |
-
"has_persistent": os.path.isdir("/data"),
|
| 85 |
-
"cache_file_exists": os.path.exists(SHORTS_CACHE_FILE),
|
| 86 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
DELETED
|
@@ -1,772 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* VNEWS Frontend v2 - Shorts Dantri/SKDS removed, VTV Digital CDN
|
| 3 |
-
* v2.8 - Changed short AI feed video share button icon (📥) to distinguish from article share (📤)
|
| 4 |
-
* v2.6 - Fixed share links: doShare now includes post_id for wall posts,
|
| 5 |
-
* readSlidePost has share button, /s + /s/{slug} render slides/video inline
|
| 6 |
-
* v2.5 - Added 'Tạo lại' button on wall cards with video
|
| 7 |
-
* v2.4 - Fixed: prependWallPost detached-element bug, makeShortVideo UI update, slide viewer for rewrite posts
|
| 8 |
-
*/
|
| 9 |
-
// ---- World Cup 2026 tab switcher (was missing -> loadHome() crashed) ----
|
| 10 |
-
function switchWCTab(tab){
|
| 11 |
-
try {
|
| 12 |
-
var el = document.getElementById('wc-content');
|
| 13 |
-
if(!el) return;
|
| 14 |
-
var d = (typeof _wc2026Data !== 'undefined') ? _wc2026Data : null;
|
| 15 |
-
if(!d){ el.innerHTML = '<div class="loading">Không có dữ liệu World Cup 2026</div>'; return; }
|
| 16 |
-
if(tab === 'fixtures'){
|
| 17 |
-
var m = (d.fixtures && d.fixtures.matches) || [];
|
| 18 |
-
var h = '<div class="wc-list">';
|
| 19 |
-
m.slice(0, 40).forEach(function(x){
|
| 20 |
-
h += '<div class="wc-row"><span class="wc-grp">'+esc(x.group||'')+'</span><span class="wc-match">'+esc(x.home||'?')+' '+esc(x.score||'VS')+' '+esc(x.away||'?')+'</span><span class="wc-date">'+esc(x.date_vn||'')+'</span></div>';
|
| 21 |
-
});
|
| 22 |
-
h += '</div>';
|
| 23 |
-
el.innerHTML = m.length ? h : '<div class="loading">Chưa có lịch thi đấu</div>';
|
| 24 |
-
} else if(tab === 'standings'){
|
| 25 |
-
el.innerHTML = '<div class="loading">'+esc((d.standings && (typeof d.standings==='string'?d.standings:JSON.stringify(d.standings)))||'Bảng xếp hạng đang cập nhật')+'</div>';
|
| 26 |
-
} else if(tab === 'highlights'){
|
| 27 |
-
el.innerHTML = '<div class="loading">'+esc((d.highlights && (typeof d.highlights==='string'?d.highlights:JSON.stringify(d.highlights)))||'Highlight đang cập nhật')+'</div>';
|
| 28 |
-
} else if(tab === 'stats'){
|
| 29 |
-
el.innerHTML = '<div class="loading">'+esc((d.stats && (typeof d.stats==='string'?d.stats:JSON.stringify(d.stats)))||'Thống kê đang cập nhật')+'</div>';
|
| 30 |
-
} else {
|
| 31 |
-
var news = (d.news) || [];
|
| 32 |
-
var nh = '<div class="wc-news">';
|
| 33 |
-
(Array.isArray(news)?news:[]).slice(0,15).forEach(function(n){
|
| 34 |
-
nh += '<div class="wc-news-item"><a href="'+esc(n.link||'#')+'" target="_blank" rel="noopener">'+esc(n.title||'')+'</a></div>';
|
| 35 |
-
});
|
| 36 |
-
nh += '</div>';
|
| 37 |
-
el.innerHTML = Array.isArray(news)&&news.length ? nh : '<div class="loading">Đang tải tin World Cup 2026...</div>';
|
| 38 |
-
}
|
| 39 |
-
// update active tab styling
|
| 40 |
-
try {
|
| 41 |
-
document.querySelectorAll('.wc-tab').forEach(function(t){
|
| 42 |
-
var want = (tab==='news') ? 'news' : tab;
|
| 43 |
-
t.classList.toggle('active', t.getAttribute('onclick') && t.getAttribute('onclick').indexOf("'"+want+"'")>=0);
|
| 44 |
-
});
|
| 45 |
-
} catch(e){}
|
| 46 |
-
} catch(e){
|
| 47 |
-
var el2 = document.getElementById('wc-content');
|
| 48 |
-
if(el2) el2.innerHTML = '<div class="loading">Lỗi tải World Cup 2026</div>';
|
| 49 |
-
}
|
| 50 |
-
}
|
| 51 |
-
function _proxyImg(url){
|
| 52 |
-
if(!url || typeof url !== 'string') return '';
|
| 53 |
-
if(url.startsWith('http') && !url.includes(location.host)){
|
| 54 |
-
return '/api/proxy/img?url='+encodeURIComponent(url);
|
| 55 |
-
}
|
| 56 |
-
return url;
|
| 57 |
-
}
|
| 58 |
-
|
| 59 |
-
var _ttsSelections = {};
|
| 60 |
-
|
| 61 |
-
function _fetchWithTimeout(url, ms){
|
| 62 |
-
return new Promise((resolve,reject)=>{
|
| 63 |
-
const ctrl=new AbortController();
|
| 64 |
-
const tid=setTimeout(()=>ctrl.abort(),ms);
|
| 65 |
-
fetch(url,{signal:ctrl.signal}).then(r=>{
|
| 66 |
-
clearTimeout(tid);
|
| 67 |
-
if(!r.ok) return reject(new Error('HTTP '+r.status));
|
| 68 |
-
return r.json();
|
| 69 |
-
}).then(d=>resolve(d)).catch(e=>{clearTimeout(tid);reject(e);});
|
| 70 |
-
});
|
| 71 |
-
}
|
| 72 |
-
|
| 73 |
-
// ===== rewriteUrl: tạo bài rewrite slide AI từ URL bài viết =====
|
| 74 |
-
// Gọi /api/url_wall (backend thực tế), lấy post rồi đẩy lên Tường AI và mở slide viewer.
|
| 75 |
-
async function rewriteUrl(){
|
| 76 |
-
const inp = document.getElementById('url-input');
|
| 77 |
-
const url = (inp && inp.value || '').trim();
|
| 78 |
-
if(!url){ alert('Vui lòng dán URL bài viết'); return; }
|
| 79 |
-
if(!/^https?:\/\//i.test(url)){ alert('URL cần bắt đầu bằng http:// hoặc https://'); return; }
|
| 80 |
-
let btn = null;
|
| 81 |
-
try { btn = inp && inp.parentElement ? inp.parentElement.querySelector('button') : null; } catch(e){}
|
| 82 |
-
const orig = btn ? btn.textContent : 'Rewrite';
|
| 83 |
-
if(btn){ btn.disabled = true; btn.textContent = '⏳ Đang tạo...'; }
|
| 84 |
-
toast('⏳ Đang tạo bài rewrite slide AI...');
|
| 85 |
-
try {
|
| 86 |
-
const r = await fetch('/api/url_wall', {
|
| 87 |
-
method:'POST',
|
| 88 |
-
headers:{'Content-Type':'application/json'},
|
| 89 |
-
body: JSON.stringify({url: url})
|
| 90 |
-
});
|
| 91 |
-
const j = await r.json();
|
| 92 |
-
if(!r.ok || j.error) throw new Error(j.error || 'Lỗi tạo bài');
|
| 93 |
-
if(!j.post) throw new Error('Không tạo được bài');
|
| 94 |
-
if(typeof prependWallPost === 'function'){ prependWallPost(j.post); }
|
| 95 |
-
else if(Array.isArray(_wallPosts)){ _wallPosts.unshift(j.post); }
|
| 96 |
-
if(inp) inp.value = '';
|
| 97 |
-
toast('✅ Đã tạo bài rewrite slide AI!');
|
| 98 |
-
const idx = (_wallPosts || []).indexOf(j.post);
|
| 99 |
-
if(j.post.slides && j.post.slides.length){ readSlidePost(idx >= 0 ? idx : 0); }
|
| 100 |
-
else if(typeof readWallPost === 'function'){ readWallPost(idx >= 0 ? idx : 0); }
|
| 101 |
-
} catch(e){
|
| 102 |
-
toast('❌ ' + e.message);
|
| 103 |
-
} finally {
|
| 104 |
-
if(btn){ btn.disabled = false; btn.textContent = orig; }
|
| 105 |
-
}
|
| 106 |
-
}
|
| 107 |
-
|
| 108 |
-
// ===== shareVideoToApps: gửi file MP4 qua app (chia sẻ video) =====
|
| 109 |
-
async function shareVideoToApps(videoUrl, title){
|
| 110 |
-
if(!videoUrl){ toast('Không có video để chia sẻ'); return; }
|
| 111 |
-
const safeTitle = (title || 'VNEWS Short AI').toString().slice(0, 80);
|
| 112 |
-
if(navigator.canShare && navigator.canShare({files:[]}) !== undefined){
|
| 113 |
-
try {
|
| 114 |
-
const resp = await fetch(videoUrl, {mode:'cors'});
|
| 115 |
-
if(resp && resp.ok){
|
| 116 |
-
const blob = await resp.blob();
|
| 117 |
-
let fname = (videoUrl.split('?')[0].split('/').pop() || 'vnews-short.mp4');
|
| 118 |
-
if(!fname.toLowerCase().endsWith('.mp4')) fname = 'vnews-short.mp4';
|
| 119 |
-
const file = new File([blob], fname, {type: (blob.type && blob.type.indexOf('video')>=0) ? blob.type : 'video/mp4'});
|
| 120 |
-
if(navigator.canShare && navigator.canShare({files:[file]})){
|
| 121 |
-
await navigator.share({files:[file], title: safeTitle, text: safeTitle});
|
| 122 |
-
return;
|
| 123 |
-
}
|
| 124 |
-
}
|
| 125 |
-
} catch(e){ /* fall through to link share */ }
|
| 126 |
-
}
|
| 127 |
-
const _base = (typeof SPACE !== 'undefined' && SPACE) ? SPACE : location.origin;
|
| 128 |
-
const shareUrl = _base + '/s?url=' + encodeURIComponent(videoUrl) + '&title=' + encodeURIComponent(safeTitle);
|
| 129 |
-
if(navigator.share){
|
| 130 |
-
try { await navigator.share({title: safeTitle, text: safeTitle, url: shareUrl}); return; } catch(e){ if(e && e.name === 'AbortError') return; }
|
| 131 |
-
}
|
| 132 |
-
if(navigator.clipboard && navigator.clipboard.writeText){
|
| 133 |
-
try { await navigator.clipboard.writeText(shareUrl); toast('📋 Đã sao chép link video!'); return; } catch(e){}
|
| 134 |
-
}
|
| 135 |
-
try {
|
| 136 |
-
const ta = document.createElement('textarea');
|
| 137 |
-
ta.value = shareUrl; ta.style.position='fixed'; ta.style.left='-9999px'; ta.style.top='-9999px'; ta.style.opacity='0';
|
| 138 |
-
document.body.appendChild(ta); ta.select(); ta.setSelectionRange(0, 99999);
|
| 139 |
-
if(document.execCommand('copy')){ toast('📋 Đã sao chép link video!'); }
|
| 140 |
-
else { prompt('📋 Sao chép link video:', shareUrl); }
|
| 141 |
-
document.body.removeChild(ta);
|
| 142 |
-
} catch(e){ prompt('📋 Sao chép link video:', shareUrl); }
|
| 143 |
-
}
|
| 144 |
-
|
| 145 |
-
async function loadHome(){
|
| 146 |
-
const homeEl = document.getElementById('view-home');
|
| 147 |
-
if(!homeEl) return;
|
| 148 |
-
|
| 149 |
-
homeEl.innerHTML =
|
| 150 |
-
'<div id="home-featured-area"></div>'
|
| 151 |
-
+'<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 class="ai-compose-row" style="margin-top:8px"><textarea id="opinion-input" placeholder="💭 Nhập quan điểm cá nhân của bạn..." rows="2" style="width:100%;padding:8px;border-radius:8px;border:1px solid #333;background:#222;color:#eee;font-size:12px;resize:vertical"></textarea></div><div class="ai-compose-row"><button onclick="openPersonalPostPreview()">📝 Xem trước & Đăng bài quan điểm</button></div><div id="hot-topics" class="hot-topic-row"></div></div>'
|
| 152 |
-
+'<div id="ai-wall-under-compose"></div>'
|
| 153 |
-
+'<div id="hashtag-box"></div>'
|
| 154 |
-
+'<div class="ls-section"><div class="ls-header"><h3>⚽ Livescore</h3></div><div class="ls-tabs"><span class="ls-tab active" data-tab="updates7d" onclick="loadLivescore(\'updates7d\')">📋 Cập nhật</span><span class="ls-tab" data-tab="live" onclick="loadLivescore(\'live\')">🔴 Live</span><span class="ls-tab" data-tab="today" onclick="loadLivescore(\'today\')">📅 Hôm nay</span><span class="ls-tab" data-tab="incoming" onclick="loadLivescore(\'incoming\')">⏰ Sắp tới</span><span class="ls-tab" data-tab="results" onclick="loadLivescore(\'results\')">✅ Kết quả</span><span class="ls-tab" data-tab="bxh_nha" onclick="loadLivescore(\'bxh_nha\')">🏆 NHA</span><span class="ls-tab" data-tab="bxh_laliga" onclick="loadLivescore(\'bxh_laliga\')">🏆 La Liga</span></div><div class="ls-content" id="ls-content"><div class="loading">Đang tải...</div></div></div>'
|
| 155 |
-
+'<div id="wc2026-live-section" class="wc2026-section"><div class="wc-header"><h2>🏆 World Cup 2026</h2><span class="wc-live-badge">● LIVE</span></div><div class="wc-tabs"><span class="wc-tab active" onclick="switchWCTab(\'news\')">📰 Tin tức</span><span class="wc-tab" onclick="switchWCTab(\'fixtures\')">📅 Lịch thi đấu</span><span class="wc-tab" onclick="switchWCTab(\'standings\')">🏆 BXH</span><span class="wc-tab" onclick="switchWCTab(\'highlights\')">🎬 Highlight</span><span class="wc-tab" onclick="switchWCTab(\'stats\')">📊 Thống kê</span></div><div class="wc-content" id="wc-content"><div class="loading">Đang tải World Cup 2026...</div></div></div>'
|
| 156 |
-
+'<div id="home-after-wc"></div>';
|
| 157 |
-
|
| 158 |
-
const afterEl = homeEl.querySelector('#home-after-wc');
|
| 159 |
-
|
| 160 |
-
loadLivescore('updates7d');
|
| 161 |
-
loadHotTopics();
|
| 162 |
-
|
| 163 |
-
const [featuredData, wallData, hlLeagues, wcData] = await Promise.allSettled([
|
| 164 |
-
_fetchWithTimeout('/api/livescore/featured', 5000),
|
| 165 |
-
_fetchWithTimeout('/api/wall', 5000),
|
| 166 |
-
_fetchWithTimeout('/api/highlights/leagues', 10000),
|
| 167 |
-
_fetchWithTimeout('/api/wc2026', 20000),
|
| 168 |
-
]).then(results => results.map(r => r.status === 'fulfilled' ? r.value : null));
|
| 169 |
-
|
| 170 |
-
if(featuredData && featuredData.home){
|
| 171 |
-
const sc=featuredData.status==='live'?'':'upcoming';
|
| 172 |
-
const st=featuredData.status==='live'?`🔴 ${featuredData.minute||'LIVE'}`:`⏰ ${featuredData.time}`;
|
| 173 |
-
const area=document.getElementById('home-featured-area');
|
| 174 |
-
if(area) area.innerHTML=`<div class="featured-match" onclick="openMatch('${featuredData.event_id}')"><div class="fm-league">${featuredData.league}</div><div class="fm-teams"><div class="fm-team"><img src="${_proxyImg(featuredData.home_logo)}" onerror="this.style.display='none'"><span>${featuredData.home}</span></div><div class="fm-score">${featuredData.score||'VS'}</div><div class="fm-team"><img src="${_proxyImg(featuredData.away_logo)}" onerror="this.style.display='none'"><span>${featuredData.away}</span></div></div><div class="fm-status ${sc}">${st}</div></div>`;
|
| 175 |
-
}
|
| 176 |
-
|
| 177 |
-
_wallPosts = (wallData && wallData.posts) || [];
|
| 178 |
-
_hlLeagueData = hlLeagues || {};
|
| 179 |
-
_wc2026Data = wcData;
|
| 180 |
-
|
| 181 |
-
if(wcData) switchWCTab('news');
|
| 182 |
-
|
| 183 |
-
_renderWallIn(afterEl);
|
| 184 |
-
_renderHLIn(afterEl);
|
| 185 |
-
}
|
| 186 |
-
|
| 187 |
-
function _renderSlidesIn(key, label, emoji, vids, afterEl){
|
| 188 |
-
if(!vids||!vids.length||!afterEl) return;
|
| 189 |
-
const wrap=document.createElement('div');
|
| 190 |
-
wrap.className='slider-wrap';
|
| 191 |
-
let h=`<div class="slider-header"><span class="slider-label">${emoji} ${label}</span></div><div class="slider-track">`;
|
| 192 |
-
const isHL = key==='world-cup'||key==='premier-league'||key==='champions-league'||key==='la-liga'||key==='serie-a'||key==='bundesliga'||key==='friendly';
|
| 193 |
-
vids.slice(0,isHL?8:12).forEach((a,i)=>{
|
| 194 |
-
if(isHL){
|
| 195 |
-
h+=`<div class="slider-item" onclick="openHighlightFeed('${key}',${i})"><div class="slider-thumb">${a.img?`<img src="${_proxyImg(a.img)}" loading="lazy">`:''}<div class="card-play">▶</div></div><div class="slider-title">${esc(a.title)}</div></div>`;
|
| 196 |
-
} else {
|
| 197 |
-
h+=`<div class="slider-item" onclick="readArticle('${esc(a.link)}')"><div class="slider-thumb">${a.img?`<img src="${_proxyImg(a.img)}" loading="lazy">`:''}</div><div class="slider-title">${esc(a.title)}</div></div>`;
|
| 198 |
-
}
|
| 199 |
-
});
|
| 200 |
-
h+='</div>';
|
| 201 |
-
wrap.innerHTML=h;
|
| 202 |
-
afterEl.parentNode.insertBefore(wrap, afterEl);
|
| 203 |
-
}
|
| 204 |
-
|
| 205 |
-
function _renderWallIn(afterEl){
|
| 206 |
-
if(!_wallPosts||!_wallPosts.length) return;
|
| 207 |
-
const posts=_wallPosts;
|
| 208 |
-
const wrap=document.createElement('div');
|
| 209 |
-
wrap.className='slider-wrap';wrap.id='ai-wall-wrap';
|
| 210 |
-
let h='<div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track" id="ai-wall-track">';
|
| 211 |
-
posts.slice(0,20).forEach((p,i)=>{h+=makeWallItem(p,i);});
|
| 212 |
-
h+='</div>';wrap.innerHTML=h;
|
| 213 |
-
const target=document.getElementById('ai-wall-under-compose');
|
| 214 |
-
if(target) target.appendChild(wrap);
|
| 215 |
-
else if(afterEl) afterEl.parentNode.insertBefore(wrap,afterEl);
|
| 216 |
-
}
|
| 217 |
-
|
| 218 |
-
function _renderHLIn(afterEl){
|
| 219 |
-
if(!_hlLeagueData||Object.keys(_hlLeagueData).length===0||!afterEl) return;
|
| 220 |
-
const HL_CONFIG={"world-cup":{name:"World Cup 2026",emoji:"🌍"},"premier-league":{name:"Premier League",emoji:"🏴"},"champions-league":{name:"Champions League",emoji:"⭐"},"la-liga":{name:"La Liga",emoji:"🇪🇸"},"serie-a":{name:"Serie A",emoji:"🇮🇹"},"bundesliga":{name:"Bundesliga",emoji:"🇩🇪"},"friendly":{name:"Giao hữu",emoji:"🤝"}};
|
| 221 |
-
for(const[key,cfg] of Object.entries(HL_CONFIG)){
|
| 222 |
-
const vids=_hlLeagueData[key];
|
| 223 |
-
if(!vids||!vids.length) continue;
|
| 224 |
-
_renderSlidesIn(key,cfg.name,cfg.emoji,vids,afterEl);
|
| 225 |
-
}
|
| 226 |
-
}
|
| 227 |
-
|
| 228 |
-
// === WALL POST HELPERS ===
|
| 229 |
-
function makeWallItem(p,i){
|
| 230 |
-
var hasVideo = p.video && p.video.length > 0;
|
| 231 |
-
var thumbContent = p.img
|
| 232 |
-
? '<img src="/api/proxy/img?url='+encodeURIComponent(p.img)+'" loading="lazy" onerror="this.style.display=\'none\'">'
|
| 233 |
-
: (hasVideo ? '<video src="'+esc(p.video)+'" muted></video>' : '');
|
| 234 |
-
var videoBadge = hasVideo ? '<div class="wall-video-badge">🎬</div>' : '';
|
| 235 |
-
var vid = p.id||i;
|
| 236 |
-
var lang = p.language || detectLanguage(p.title + ' ' + (p.text||''));
|
| 237 |
-
var curVoice = p.voice || getAutoVoice(lang);
|
| 238 |
-
var curEmotion = p.emotion || detectEmotion(p.title + ' ' + (p.text||''));
|
| 239 |
-
var selKey = 'inline-' + vid;
|
| 240 |
-
if(!_ttsSelections[selKey]) _ttsSelections[selKey] = {voice: curVoice, emotion: curEmotion};
|
| 241 |
-
var voiceOpts = '';
|
| 242 |
-
VOICE_LIST.forEach(function(v){
|
| 243 |
-
voiceOpts += '<option value="'+v.id+'"'+(v.id===_ttsSelections[selKey].voice?' selected':'')+'>'+v.label+'</option>';
|
| 244 |
-
});
|
| 245 |
-
var emotOpts = '';
|
| 246 |
-
EMOTION_LIST.forEach(function(e){
|
| 247 |
-
emotOpts += '<option value="'+e.id+'"'+(e.id===_ttsSelections[selKey].emotion?' selected':'')+'>'+e.label+'</option>';
|
| 248 |
-
});
|
| 249 |
-
var spd = p.short_speed || '1.2';
|
| 250 |
-
var voiceBar = '<div class="wall-tts-bar" style="margin:6px 0 4px;display:grid;grid-template-columns:1fr auto auto;gap:3px">'
|
| 251 |
-
+'<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">'
|
| 252 |
-
+voiceOpts+'</select>'
|
| 253 |
-
+'<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">'
|
| 254 |
-
+emotOpts+'</select>'
|
| 255 |
-
+'<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)">'
|
| 256 |
-
+'<option value="0.85"'+(spd==='0.85'?' selected':'')+'>0.85x</option>'
|
| 257 |
-
+'<option value="1.0"'+(spd==='1.0'?' selected':'')+'>1.0x</option>'
|
| 258 |
-
+'<option value="1.2"'+(spd==='1.2'?' selected':'')+'>1.2x</option>'
|
| 259 |
-
+'<option value="1.35"'+(spd==='1.35'?' selected':'')+'>1.35x</option>'
|
| 260 |
-
+'</select>'
|
| 261 |
-
+'</div>';
|
| 262 |
-
var makeBtn = hasVideo
|
| 263 |
-
? '<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed('+i+')">▶ Xem Short</button><button class="wall-btn-make" style="margin-left:4px" 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 lại</button>'
|
| 264 |
-
: '<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>';
|
| 265 |
-
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>';
|
| 266 |
-
}
|
| 267 |
-
|
| 268 |
-
async function makeShortVideo(postId, btn, voice, speed, emotion){
|
| 269 |
-
if(!postId)return;
|
| 270 |
-
const origText = btn ? btn.textContent : '🎬 Tạo Video';
|
| 271 |
-
if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo...';}
|
| 272 |
-
toast('⏳ Đang tạo video shorts...');
|
| 273 |
-
if(!voice || !emotion){
|
| 274 |
-
const selKey = 'inline-'+postId;
|
| 275 |
-
const container = document.querySelector('.tts-selector[data-post-id="'+postId+'"]');
|
| 276 |
-
if(container){
|
| 277 |
-
if(_ttsSelections[selKey]){
|
| 278 |
-
voice = voice || _ttsSelections[selKey].voice;
|
| 279 |
-
emotion = emotion || _ttsSelections[selKey].emotion;
|
| 280 |
-
}
|
| 281 |
-
if(!voice){
|
| 282 |
-
const selectedVoiceBtn = container.querySelector('.tts-voice-btn.selected') || container.querySelector('.tts-voice-btn[style*="5cb87a"]') || container.querySelector('.tts-voice-btn');
|
| 283 |
-
voice = selectedVoiceBtn ? selectedVoiceBtn.dataset.voice : 'vi-VN-HoaiMyNeural';
|
| 284 |
-
}
|
| 285 |
-
if(!emotion){
|
| 286 |
-
const selectedEmotionBtn = container.querySelector('.tts-emotion-btn.selected') || container.querySelector('.tts-emotion-btn[style*="5cb87a"]') || container.querySelector('.tts-emotion-btn');
|
| 287 |
-
emotion = selectedEmotionBtn ? selectedEmotionBtn.dataset.emotion : 'neutral';
|
| 288 |
-
}
|
| 289 |
-
const speedSelect = container.querySelector('.tts-speed');
|
| 290 |
-
speed = speedSelect ? parseFloat(speedSelect.value) || 1.2 : (speed || 1.2);
|
| 291 |
-
} else {
|
| 292 |
-
voice = voice || 'vi-VN-HoaiMyNeural';
|
| 293 |
-
emotion = emotion || 'neutral';
|
| 294 |
-
speed = speed || 1.2;
|
| 295 |
-
}
|
| 296 |
-
}
|
| 297 |
-
try{
|
| 298 |
-
const r = await fetch('/api/ai/short/' + encodeURIComponent(postId), {method:'POST',headers:{'Content-Type':'application/json'},body: JSON.stringify({voice:voice, emotion:emotion, speed:speed})});
|
| 299 |
-
const j = await r.json();
|
| 300 |
-
if(!r.ok || j.error) throw new Error(j.error||'Lỗi tạo video');
|
| 301 |
-
toast('✅ Đã tạo video shorts!');
|
| 302 |
-
const p = _wallPosts.find(x => String(x.id) === String(postId));
|
| 303 |
-
if(p){
|
| 304 |
-
p.video = j.video;
|
| 305 |
-
p.voice = j.voice;
|
| 306 |
-
p.emotion = j.emotion;
|
| 307 |
-
const itemId = 'wall-item-'+postId;
|
| 308 |
-
const el = document.getElementById(itemId);
|
| 309 |
-
if(el){
|
| 310 |
-
const idx = _wallPosts.indexOf(p);
|
| 311 |
-
el.insertAdjacentHTML('afterend', makeWallItem(p, idx));
|
| 312 |
-
el.remove();
|
| 313 |
-
}
|
| 314 |
-
}
|
| 315 |
-
}catch(e){
|
| 316 |
-
toast('❌ '+e.message);
|
| 317 |
-
if(btn){btn.disabled=false;btn.textContent=origText;}
|
| 318 |
-
}
|
| 319 |
-
}
|
| 320 |
-
|
| 321 |
-
var VOICE_LIST = [
|
| 322 |
-
{id:'vi-VN-HoaiMyNeural', label:'🎙️ Hoài My (VI)', lang:'vi'},
|
| 323 |
-
{id:'vi-VN-NamMinhNeural', label:'🎙️ Nam Minh (VI)', lang:'vi'},
|
| 324 |
-
{id:'en-US-AndrewMultilingualNeural', label:'🎙️ Andrew (EN)', lang:'en'},
|
| 325 |
-
{id:'en-AU-WilliamMultilingualNeural', label:'🎙️ William (EN)', lang:'en'},
|
| 326 |
-
{id:'pt-BR-ThalitaMultilingualNeural', label:'🎙️ Thalita (PT)', lang:'pt'},
|
| 327 |
-
{id:'fr-FR-VivienneMultilingualNeural', label:'🎙️ Vivienne (FR)', lang:'fr'},
|
| 328 |
-
{id:'fr-FR-RemyMultilingualNeural', label:'🎙️ Rémy (FR)', lang:'fr'},
|
| 329 |
-
{id:'de-DE-SeraphinaMultilingualNeural', label:'🎙️ Seraphina (DE)', lang:'de'},
|
| 330 |
-
{id:'de-DE-FlorianMultilingualNeural', label:'🎙️ Florian (DE)', lang:'de'},
|
| 331 |
-
{id:'ko-KR-HyunsuMultilingualNeural', label:'🎙️ Hyunsu (KO)', lang:'ko'},
|
| 332 |
-
{id:'it-IT-GiuseppeMultilingualNeural', label:'🎙️ Giuseppe (IT)', lang:'it'},
|
| 333 |
-
];
|
| 334 |
-
var EMOTION_LIST = [
|
| 335 |
-
{id:'neutral', label:'😐 Trung tính'},
|
| 336 |
-
{id:'happy', label:'😊 Vui vẻ'},
|
| 337 |
-
{id:'excited', label:'🔥 Hào hứng'},
|
| 338 |
-
{id:'sad', label:'😢 Buồn'},
|
| 339 |
-
{id:'humorous', label:'😂 Hài hước'},
|
| 340 |
-
{id:'serious', label:'⚠️ Nghiêm túc'},
|
| 341 |
-
{id:'urgent', label:'🚨 Khẩn cấp'},
|
| 342 |
-
{id:'warm', label:'💖 Ấm áp'},
|
| 343 |
-
];
|
| 344 |
-
|
| 345 |
-
document.addEventListener('click',function(e){
|
| 346 |
-
var btn = e.target.closest('.tts-voice-btn');
|
| 347 |
-
if(btn){
|
| 348 |
-
var container = btn.closest('.tts-selector');
|
| 349 |
-
if(container){
|
| 350 |
-
var selKey = 'inline-'+container.dataset.postId;
|
| 351 |
-
if(!_ttsSelections[selKey]) _ttsSelections[selKey]={voice:btn.dataset.voice,emotion:'neutral'};
|
| 352 |
-
var allBtns = container.querySelectorAll('.tts-voice-btn');
|
| 353 |
-
for(var i=0;i<allBtns.length;i++){allBtns[i].style.borderColor='#333';allBtns[i].style.background='#222';allBtns[i].classList.remove('selected');}
|
| 354 |
-
btn.style.borderColor='#5cb87a';btn.style.background='#1a2a1f';btn.classList.add('selected');
|
| 355 |
-
_ttsSelections[selKey].voice = btn.dataset.voice;
|
| 356 |
-
}
|
| 357 |
-
return;
|
| 358 |
-
}
|
| 359 |
-
var ebtn = e.target.closest('.tts-emotion-btn');
|
| 360 |
-
if(ebtn){
|
| 361 |
-
var container = ebtn.closest('.tts-selector');
|
| 362 |
-
if(container){
|
| 363 |
-
var selKey = 'inline-'+container.dataset.postId;
|
| 364 |
-
if(!_ttsSelections[selKey]) _ttsSelections[selKey]={voice:'vi-VN-HoaiMyNeural',emotion:ebtn.dataset.emotion};
|
| 365 |
-
var allBtns = container.querySelectorAll('.tts-emotion-btn');
|
| 366 |
-
for(var i=0;i<allBtns.length;i++){allBtns[i].style.borderColor='#333';allBtns[i].style.background='#222';allBtns[i].classList.remove('selected');}
|
| 367 |
-
ebtn.style.borderColor='#5cb87a';ebtn.style.background='#1a2a1f';ebtn.classList.add('selected');
|
| 368 |
-
_ttsSelections[selKey].emotion = ebtn.dataset.emotion;
|
| 369 |
-
}
|
| 370 |
-
return;
|
| 371 |
-
}
|
| 372 |
-
var cbtn = e.target.closest('.tts-create-btn');
|
| 373 |
-
if(cbtn){
|
| 374 |
-
var container = cbtn.closest('.tts-selector');
|
| 375 |
-
if(container){
|
| 376 |
-
var selKey = 'inline-'+container.dataset.postId;
|
| 377 |
-
var selVoice = _ttsSelections[selKey] ? _ttsSelections[selKey].voice : 'vi-VN-HoaiMyNeural';
|
| 378 |
-
var selEmotion = _ttsSelections[selKey] ? _ttsSelections[selKey].emotion : 'neutral';
|
| 379 |
-
var speedSel = container.querySelector('.tts-speed');
|
| 380 |
-
var speed = speedSel ? parseFloat(speedSel.value)||1.2 : 1.2;
|
| 381 |
-
window.makeShortVideo(container.dataset.postId, cbtn, selVoice, speed, selEmotion);
|
| 382 |
-
}
|
| 383 |
-
return;
|
| 384 |
-
}
|
| 385 |
-
});
|
| 386 |
-
function detectLanguage(text){
|
| 387 |
-
if(!text) return 'vi';
|
| 388 |
-
var t=text.toLowerCase(), chars=new Set(t);
|
| 389 |
-
var vnChars='đăâêôơưàảãạáằẳẵặắầẩẫậấèẻẽẹéềễểệếìỉĩịíòỏõọóồổỗộốờởỡợớùủũụúừửữựứỳỷỹỵý';
|
| 390 |
-
var vnCount=0; for(var c of vnChars){if(chars.has(c)) vnCount++;}
|
| 391 |
-
if(vnCount>=2) return 'vi';
|
| 392 |
-
if(chars.has('ñ')||chars.has('¿')||chars.has('¡')) return 'es';
|
| 393 |
-
if(chars.has('ã')||chars.has('õ')) return 'pt';
|
| 394 |
-
var words=t.split(/\s+/);
|
| 395 |
-
var enWords=['the','is','at','which','on','and','or','but','this','that','with','from','have','been'];
|
| 396 |
-
var enCount=words.filter(function(w){return enWords.indexOf(w)>=0;}).length;
|
| 397 |
-
if(enCount>=2) return 'en';
|
| 398 |
-
return 'vi';
|
| 399 |
-
}
|
| 400 |
-
function detectEmotion(text){
|
| 401 |
-
if(!text) return 'neutral';
|
| 402 |
-
var t=text.toLowerCase();
|
| 403 |
-
var kws={
|
| 404 |
-
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'],
|
| 405 |
-
excited:['hào hứng','phấn khích','đột phá','kỷ lục','đỉnh cao','emocionante','increíble','excited','thrilling','unbelievable','awesome','breakthrough'],
|
| 406 |
-
sad:['buồn','đau','mất','thảm họa','khủng hoảng','triste','terrible','sad','unhappy','tragic','painful','death'],
|
| 407 |
-
humorous:['hài hước','buồn cười','haha','đùa','engraçado','gracioso','funny','hilarious','joke','lol'],
|
| 408 |
-
serious:['nghiêm trọng','khẩn cấp','quan trọng','lo ngại','sério','crítico','serious','critical','urgent','severe','crisis'],
|
| 409 |
-
urgent:['khẩn cấp','báo động','ngay lập tức','urgent','breaking','alert','emergency'],
|
| 410 |
-
warm:['ấm áp','tình cảm','yêu thương','warm','love','heart','touching']
|
| 411 |
-
};
|
| 412 |
-
var bestScore=0, bestEmotion='neutral';
|
| 413 |
-
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;}}
|
| 414 |
-
return bestEmotion;
|
| 415 |
-
}
|
| 416 |
-
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';}
|
| 417 |
-
function buildVoiceEmotionSelector(post){
|
| 418 |
-
var lang=post.language||detectLanguage(post.title+' '+(post.text||''));
|
| 419 |
-
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'};
|
| 420 |
-
var _postVoice = post.voice ? (_oldVoiceMap[post.voice] || post.voice) : '';
|
| 421 |
-
var autoVoice= _postVoice || getAutoVoice(lang);
|
| 422 |
-
var autoEmotion=post.emotion||detectEmotion(post.title+' '+(post.text||''));
|
| 423 |
-
var selKey = 'inline-'+post.id;
|
| 424 |
-
if(!_ttsSelections[selKey]){_ttsSelections[selKey] = {voice: autoVoice, emotion: autoEmotion};}
|
| 425 |
-
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">';
|
| 426 |
-
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">';
|
| 427 |
-
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>';});
|
| 428 |
-
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">';
|
| 429 |
-
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>';});
|
| 430 |
-
h+='</div><div style="display:flex;align-items:center;gap:6px;margin-bottom:8px"><span style="font-size:11px;color:#888">⚡ Tốc độ:</span>';
|
| 431 |
-
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>';
|
| 432 |
-
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>';
|
| 433 |
-
return h;
|
| 434 |
-
}
|
| 435 |
-
window.showVoiceEmotionSelector=function(postId,title,text){
|
| 436 |
-
var overlay=document.createElement('div');
|
| 437 |
-
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';
|
| 438 |
-
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';
|
| 439 |
-
var lang=detectLanguage(title+' '+text);var autoEmotion=detectEmotion(title+' '+text);
|
| 440 |
-
var h='<h3 style="color:#5cb87a;margin-bottom:12px;font-size:16px">🎬 Tạo Short AI (ngôn ngữ: '+lang.toUpperCase()+')</h3>';
|
| 441 |
-
h+='<div style="margin-bottom:12px"><div style="color:#aaa;font-size:11px;margin-bottom:6px">🎙️ Chọn giọng đọc:</div>';
|
| 442 |
-
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>';});
|
| 443 |
-
h+='</div><div style="margin-bottom:12px"><div style="color:#aaa;font-size:11px;margin-bottom:6px">😊 Chọn cảm xúc:</div>';
|
| 444 |
-
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>';});
|
| 445 |
-
h+='</div><div style="margin-bottom:12px"><div style="color:#aaa;font-size:11px;margin-bottom:6px">⚡ Tốc độ:</div>';
|
| 446 |
-
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>';
|
| 447 |
-
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>';
|
| 448 |
-
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>';
|
| 449 |
-
h+='<div id="ve-status" style="color:#888;font-size:10px;margin-top:8px;display:none"></div>';
|
| 450 |
-
box.innerHTML=h;overlay.appendChild(box);document.body.appendChild(overlay);
|
| 451 |
-
var selectedVoice=getAutoVoice(lang),selectedEmotion=autoEmotion;
|
| 452 |
-
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;});});
|
| 453 |
-
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;});});
|
| 454 |
-
box.querySelector('#ve-cancel-btn').addEventListener('click',function(){overlay.remove();});
|
| 455 |
-
box.querySelector('#ve-create-btn').addEventListener('click',async function(){
|
| 456 |
-
this.disabled=true;this.textContent='⏳ Đang tạo...';
|
| 457 |
-
box.querySelector('#ve-status').style.display='block';box.querySelector('#ve-status').textContent='Đang tạo video shorts...';
|
| 458 |
-
try{
|
| 459 |
-
var speed=parseFloat(box.querySelector('#ve-speed').value)||1.2;
|
| 460 |
-
if(!_ttsSelections["inline-"+postId]) _ttsSelections["inline-"+postId]={voice:"vi-VN-HoaiMyNeural",emotion:"neutral"};
|
| 461 |
-
_ttsSelections["inline-"+postId].voice=selectedVoice;_ttsSelections["inline-"+postId].emotion=selectedEmotion;
|
| 462 |
-
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})});
|
| 463 |
-
var j=await r.json();
|
| 464 |
-
if(!r.ok||j.error) throw new Error(j.error||'Lỗi tạo video');
|
| 465 |
-
toast('✅ Đã tạo Short AI!');overlay.remove();
|
| 466 |
-
var p=_wallPosts.find(function(x){return String(x.id)===String(postId);});
|
| 467 |
-
if(p){p.video=j.video;p.voice=j.voice;p.emotion=j.emotion;}
|
| 468 |
-
}catch(e){this.disabled=false;this.textContent='🎬 Tạo Short';box.querySelector('#ve-status').textContent='❌ '+e.message;}
|
| 469 |
-
});
|
| 470 |
-
};
|
| 471 |
-
|
| 472 |
-
function prependWallPost(post){
|
| 473 |
-
_wallPosts.unshift(post);
|
| 474 |
-
const track=document.getElementById('ai-wall-track');
|
| 475 |
-
const wrap=document.getElementById('ai-wall-wrap');
|
| 476 |
-
const target=document.getElementById('ai-wall-under-compose');
|
| 477 |
-
if(target && (!track||!wrap)){
|
| 478 |
-
const newWrap=document.createElement('div');
|
| 479 |
-
newWrap.className='slider-wrap';newWrap.id='ai-wall-wrap';
|
| 480 |
-
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>`;
|
| 481 |
-
target.appendChild(newWrap);
|
| 482 |
-
const firstItem=newWrap.querySelector('.wall-item');
|
| 483 |
-
if(firstItem)firstItem.className='wall-item wall-item-new';
|
| 484 |
-
return;
|
| 485 |
-
}
|
| 486 |
-
if(track){
|
| 487 |
-
track.insertAdjacentHTML('afterbegin', makeWallItem(post, 0));
|
| 488 |
-
track.scrollTo({left:0,behavior:'smooth'});
|
| 489 |
-
}
|
| 490 |
-
}
|
| 491 |
-
|
| 492 |
-
let _wallPosts=[];
|
| 493 |
-
let _currentView='home';
|
| 494 |
-
let _currentEventId=null;
|
| 495 |
-
let _currentMatchUrl=null;
|
| 496 |
-
let _htPage=0,_htTopic='';
|
| 497 |
-
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('');}
|
| 498 |
-
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);}
|
| 499 |
-
async function showHashtagSources(topic,page){const box=document.getElementById('hashtag-box');if(!box)return;if(page===0)box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div class="hashtag-loading"><div class="hashtag-spinner"></div>Đang tìm...</div></div>`;try{const r=await fetch(`/api/hashtag/sources?topic=${encodeURIComponent(topic)}&page=${page}`);const j=await r.json();const sources=j.sources||[];if(!sources.length&&page===0){box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div style="color:#888;padding:8px">Không tìm được bài viết liên quan</div></div>`;return;}let h='';if(page===0)h=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)} <span style="font-size:10px;color:#888">(${j.total} bài từ 8 nguồn)</span></h3><div id="ht-list">`;sources.forEach((s,i)=>{const idx=page*8+i;h+=`<div class="hashtag-src-item" onclick="readArticle('${esc(s.url)}')"><div class="hashtag-src-img" id="ht-img-${idx}"></div><div class="hashtag-src-text"><div class="hashtag-src-title">${esc(s.title)}</div><div class="hashtag-src-via">${esc(s.via||'')}</div></div></div>`;});if(page===0){h+=`</div><button class="hashtag-rewrite-btn" onclick="rewriteHashtag('${esc(topic).replace(/'/g,"\\'")}')">🤖 Rewrite AI tổng hợp & đăng tường</button>`;if(j.has_more)h+=`<button class="hashtag-load-more" id="ht-more" onclick="loadMoreHashtag()">Tải thêm ▼</button>`;h+=`</div>`;box.innerHTML=h;}else{document.getElementById('ht-list')?.insertAdjacentHTML('beforeend',h);const btn=document.getElementById('ht-more');if(btn){if(!j.has_more)btn.remove();else{btn.disabled=false;btn.textContent='Tải thêm ▼';}}}sources.forEach((s,i)=>{const idx=page*8+i;if(!s.url)return;const ctrl=new AbortController();setTimeout(()=>ctrl.abort(),4000);fetch('/api/article?url='+encodeURIComponent(s.url),{signal:ctrl.signal}).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){const el=document.getElementById('ht-img-'+idx);if(el)el.innerHTML=`<img src="${_proxyImg(esc(d.og_image||d.img))}" onerror="this.style.display='none'">`;}}).catch(()=>{});});}catch(e){box.innerHTML=`<div class="hashtag-sources"><h3>🔍 ${esc(topic)}</h3><div style="color:#e74c3c;padding:8px">Lỗi: ${esc(e.message)}</div></div>`;}}
|
| 500 |
-
function loadMoreHashtag(){_htPage++;const btn=document.getElementById('ht-more');if(btn){btn.disabled=true;btn.textContent='Đang tải...';}showHashtagSources(_htTopic,_htPage);}
|
| 501 |
-
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';}}}
|
| 502 |
-
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>';}}
|
| 503 |
-
function bindMatchClicks(el){el.querySelectorAll('.match-detail').forEach(md=>{md.style.cursor='pointer';md.addEventListener('click',function(e){const statusA=this.querySelector('.status a');const teamA=this.querySelector('.teams a[href*="/tran-dau/"]');const a = statusA || teamA;if(a){e.preventDefault();e.stopPropagation();const href=a.getAttribute('href')||'';const m=href.match(/\/tran-dau\/(\d+)\//);if(m){const fullUrl=href.startsWith('http')?href:'https://bongda.com.vn'+href;openMatch(m[1],fullUrl);}}});});el.querySelectorAll('a').forEach(a=>{a.addEventListener('click',e=>{e.preventDefault();e.stopPropagation()});});}
|
| 504 |
-
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')}
|
| 505 |
-
function closeMatch(){document.getElementById('match-overlay').classList.remove('active');document.body.style.overflow=''}
|
| 506 |
-
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>'}}
|
| 507 |
-
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]))}
|
| 508 |
-
function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')}
|
| 509 |
-
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)}}
|
| 510 |
-
function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}}
|
| 511 |
-
|
| 512 |
-
// ===== doShare: COPY link to clipboard, then try native share as bonus =====
|
| 513 |
-
function doShare(title,url,img,postId){
|
| 514 |
-
var shareUrl;
|
| 515 |
-
var _base = (typeof SPACE!=='undefined' && SPACE) ? SPACE : location.origin;
|
| 516 |
-
if(postId){
|
| 517 |
-
shareUrl = _base+'/s?post_id='+encodeURIComponent(postId)+'&title='+encodeURIComponent(title);
|
| 518 |
-
} else {
|
| 519 |
-
shareUrl = _base+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||'');
|
| 520 |
-
}
|
| 521 |
-
// Try clipboard API first (modern, works on HTTPS)
|
| 522 |
-
if(navigator.clipboard && navigator.clipboard.writeText){
|
| 523 |
-
navigator.clipboard.writeText(shareUrl).then(function(){
|
| 524 |
-
toast('📋 Đã sao chép link!');
|
| 525 |
-
try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){}
|
| 526 |
-
}).catch(function(){
|
| 527 |
-
// Fallback: execCommand (deprecated but works on some browsers)
|
| 528 |
-
try{
|
| 529 |
-
var ta=document.createElement('textarea');
|
| 530 |
-
ta.value=shareUrl;ta.style.position='fixed';ta.style.left='-9999px';ta.style.top='-9999px';ta.style.opacity='0';
|
| 531 |
-
document.body.appendChild(ta);ta.select();ta.setSelectionRange(0,99999);
|
| 532 |
-
if(document.execCommand('copy')){toast('📋 Đã sao chép link!');}
|
| 533 |
-
else{prompt('📋 Sao chép link:', shareUrl);}
|
| 534 |
-
document.body.removeChild(ta);
|
| 535 |
-
}catch(e){prompt('📋 Sao chép link:', shareUrl);}
|
| 536 |
-
try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){}
|
| 537 |
-
});
|
| 538 |
-
} else {
|
| 539 |
-
// execCommand approach
|
| 540 |
-
try{
|
| 541 |
-
var ta=document.createElement('textarea');
|
| 542 |
-
ta.value=shareUrl;ta.style.position='fixed';ta.style.left='-9999px';ta.style.top='-9999px';ta.style.opacity='0';
|
| 543 |
-
document.body.appendChild(ta);ta.select();ta.setSelectionRange(0,99999);
|
| 544 |
-
if(document.execCommand('copy')){toast('📋 Đã sao chép link!');}
|
| 545 |
-
else{prompt('📋 Sao chép link:', shareUrl);}
|
| 546 |
-
document.body.removeChild(ta);
|
| 547 |
-
}catch(e){prompt('📋 Sao chép link:', shareUrl);}
|
| 548 |
-
try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){}
|
| 549 |
-
}
|
| 550 |
-
}
|
| 551 |
-
|
| 552 |
-
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;}}
|
| 553 |
-
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};}}
|
| 554 |
-
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[];}}
|
| 555 |
-
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[];}}
|
| 556 |
-
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||'')}','','${esc(opts.postId||'')}')"><div class="icon">📤</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();toggle169View('${esc(opts.videoId)}','169-toggle-${opts.idx}')"><div class="icon" id="169-toggle-${opts.idx}">🖥️</div></button>${opts.extraBtn||''}</div><span class="tiktok-counter">${opts.idx+1}/${opts.total}</span><div class="inline-comments" id="cmt-inline-${opts.idx}" style="display:none"></div></div>`;}
|
| 557 |
-
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);}}
|
| 558 |
-
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);}}
|
| 559 |
-
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);}
|
| 560 |
-
async function loadCounters(videoIds){for(let i=0;i<videoIds.length;i++){const id=videoIds[i];if(!id)continue;const j=await getInteractions(id);const vc=document.getElementById('vc-'+i);if(vc)vc.textContent=fmtNum(j.views);const lc=document.getElementById('lc-'+i);if(lc)lc.textContent=fmtNum(j.likes);const cc=document.getElementById('cc-'+i);if(cc)cc.textContent=fmtNum(j.comments);}}
|
| 561 |
-
async function toggleComments(videoId,idx){const panel=document.getElementById('cmt-inline-'+idx);if(!panel)return;if(panel.style.display!=='none'){panel.style.display='none';/* Restore snap alignment */var sl=panel.closest('.tiktok-slide');if(sl)sl.style.scrollSnapAlign='start';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);/* Remove snap so feed can scroll to show comment input */var sl=panel.closest('.tiktok-slide');if(sl)sl.style.scrollSnapAlign='';setTimeout(function(){var feed=document.getElementById('tiktok-feed');if(feed){var slidePos=sl?sl.offsetTop:0;feed.scrollTop=slidePos+200;}/* Focus input */var inp=document.getElementById('cmt-input-'+idx);if(inp)inp.focus();},300);}
|
| 562 |
-
function renderInlineComments(panel,videoId,idx,cmts){let h='<div class="inline-cmt-header"><span>💬 Bình luận</span><button onclick="toggleComments(\''+esc(videoId)+'\','+idx+')">✕</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;}
|
| 563 |
-
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);/* Keep focus on input after submit */var inp2=document.getElementById('cmt-input-'+idx);if(inp2)inp2.focus();}
|
| 564 |
-
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='📺';})}
|
| 565 |
-
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)}
|
| 566 |
-
async function openHighlightFeed(league,idx,forceUrl){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải...</div>';let articles=(_hlLeagueData||{})[league]||[];if(!articles.length){try{articles=await fetch('/api/highlights/'+league).then(r=>r.json())}catch(e){articles=[]}}if(!articles.length){el.innerHTML='<div class="loading">Không có video</div>';return}const vids=[];const results=await Promise.all(articles.map(async(a,i)=>{try{const r=await fetch('/api/video_url?url='+encodeURIComponent(a.link)+'&img='+encodeURIComponent(a.img||''));const v=await r.json();if(v&&v.src){return{_idx:i,title:a.title||v.title||'',link:a.link||'',img:a.img||v.poster||'',src:v.src,type:v.type||'',poster:v.poster||a.img||''}}return null}catch(e){}return null}));results.forEach(r=>{if(r)vids.push(r)});vids.sort((a,b)=>a._idx-b._idx);if(!vids.length){el.innerHTML='<div class="loading">Không tìm thấy video</div>';return}let ti=vids.findIndex(v=>v._idx===idx);if(ti<0)ti=0;const ordered=ti>0?[...vids.slice(ti),...vids.slice(0,ti)]:vids;let h=`<button class="back-btn" onclick="switchCat('home')">← Highlight</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">`;ordered.forEach((v,i)=>{const isYT=v.type==='youtube',isHLS=!isYT&&v.src?.includes('.m3u8'),poster=v.poster?` poster="${esc(v.poster)}"`:'';const vtag=isYT?`<iframe data-yt-src="${v.src}" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture"></iframe>`:isHLS?`<video playsinline preload="none"${poster} data-hls="${v.src}" loop controls></video>`:`<video playsinline preload="none"${poster} loop controls><source src="${v.src}" type="video/mp4"></video>`;const videoId='hl-'+league+'-'+v._idx;h+=buildTikTokSlide({vtag,title:v.title,badge:'HL',badgeClass:'badge-fpt',videoId,idx:i,total:ordered.length,shareUrl:v.link||'',postId:'',extraBtn:`<button class="tiktok-right-btn" onclick="event.stopPropagation();openHighlightFeed('${league}',${(i+1)%ordered.length})"><div class="icon">⏭️</div></button>`})});h+='</div></div>';el.innerHTML=h;setTimeout(()=>initTikTokFeed(),200);}
|
| 567 |
-
async function openYTShortsFeed(idx){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đã xóa Shorts Dân trí/SKĐS</div>';}
|
| 568 |
-
async function openShortAIFeed(idx){showView('view-tiktok');const el=document.getElementById('view-tiktok');if(!_wallPosts||!_wallPosts.length){el.innerHTML='<div class="loading">Không có Short AI</div>';return}const aiPosts=_wallPosts.filter(p=>p.video);if(!aiPosts.length||idx>=aiPosts.length){el.innerHTML='<div class="loading">Không có Short AI</div>';return}const ordered=aiPosts.slice(idx).concat(aiPosts.slice(0,idx));let h=`<button class="back-btn" onclick="switchCat('home')">← Tường AI</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">`;ordered.forEach((p,i)=>{const baseIdx=_wallPosts.indexOf(p);const vtag=p.video?`<video playsinline preload="none" src="${esc(p.video)}" loop controls></video>`:'';h+=buildTikTokSlide({vtag,title:p.title,badge:'Short AI',badgeClass:'badge-ai',videoId:p.id||'ai-'+i,idx:i,total:ordered.length,shareUrl:p.video||'',postId:p.id||'',extraBtn:`<button class="tiktok-right-btn" onclick="event.stopPropagation();shareVideoToApps('${esc(p.video)}','${esc(p.title)}')"><div class="icon">📥</div></button>`})});h+='</div></div>';el.innerHTML=h;setTimeout(()=>initTikTokFeed(),200);}
|
| 569 |
-
function readWallPost(idx){const p=_wallPosts&&_wallPosts[idx];if(!p)return;if(p.slides&&p.slides.length){readSlidePost(idx);return}readArticle(p.url||'','','',p.title,p.text);}
|
| 570 |
-
/** Show rewrite slide viewer - vertical slides with text+image */
|
| 571 |
-
function readSlidePost(idx){const p=_wallPosts[idx];if(!p||!p.slides)return;showView('view-article');const el=document.getElementById('view-article');let h=`<button class="back-btn" onclick="switchCat('home')">← Tường AI</button><div class="slide-viewer" style="padding:12px;max-width:600px;margin:0 auto">`;p.slides.forEach((s,i)=>{h+=`<div class="slide-card" style="background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:16px;margin-bottom:12px"><div class="slide-num" style="color:#5cb87a;font-size:12px;font-weight:700;margin-bottom:6px">Slide ${s.index||i+1}/${p.slides.length}</div>${s.image?`<img src="${_proxyImg(s.image)}" style="width:100%;max-height:300px;object-fit:cover;border-radius:8px;margin-bottom:8px" loading="lazy" onerror="this.style.display=\'none\'">`:''}<p style="color:#ddd;font-size:14px;line-height:1.6;margin:0">${esc(s.text)}</p></div>`;});h+=`</div><div class="article-actions" style="padding:12px;text-align:center"><button onclick="doShare('${esc(p.title)}','${esc(p.url||'')}','${esc(p.img||'')}','${esc(p.id||'')}')" style="background:#2d8659;border:0;color:#fff;padding:8px 16px;border-radius:10px;cursor:pointer;font-size:12px">📤 Chia sẻ bài viết này</button></div>`;el.innerHTML=h;}
|
| 572 |
-
function readNewsTab(tab){loadNewsTab();}
|
| 573 |
-
function loadNewsTab(){const el=document.getElementById('view-cat');if(!el)return;el.innerHTML='<div class="loading">Đang tải tin tức...</div>';fetch('/api/homepage').then(r=>r.json()).then(articles=>{if(!articles||!articles.length){el.innerHTML='<div class="loading">Không có tin</div>';return}let h='<div class="grid">';articles.forEach(a=>{const src=a.source||'vne';const badge=a.group||a.source||'';h+=`<div class="card" onclick="readArticle('${esc(a.link)}')"><div class="card-img">${a.img?`<img src="${_proxyImg(a.img)}" loading="lazy" onerror="this.style.display=\'none\'">`:''}</div><div class="card-body"><span class="badge badge-${src}">${esc(badge)}</span><div class="card-title">${esc(a.title)}</div></div></div>`;});h+='</div>';el.innerHTML=h;}).catch(()=>{el.innerHTML='<div class="loading">Lỗi tải</div>';});}
|
| 574 |
-
|
| 575 |
-
function readArticle(url,title,img,presetTitle,presetText){showView('view-article');const el=document.getElementById('view-article');el.innerHTML='<div class="loading">Đang tải...</div><button class="back-btn" onclick="switchCat(\'home\')">← Quay lại</button>';if(presetTitle){el.innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><h1 class="article-title">${esc(presetTitle)}</h1>${presetText?`<div class="article-summary" style="white-space:pre-wrap">${esc(presetText)}</div>`:''}</div><div class="article-actions"><button onclick="doShare('${esc(presetTitle)}','${esc(url||'')}','')">📤 Chia sẻ</button></div>`;return;}if(!url)return;fetch('/api/article?url='+encodeURIComponent(url)).then(r=>r.json()).then(d=>{let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view">`;if(d.title)h+=`<h1 class="article-title">${esc(d.title)}</h1>`;if(d.summary)h+=`<div class="article-summary">${esc(d.summary)}</div>`;if(d.body)d.body.forEach(b=>{if(b.type==='p')h+=`<p class="article-p">${esc(b.text)}</p>`;else if(b.type==='heading')h+=`<h2 class="article-h2">${esc(b.text)}</h2>`;else if(b.type==='img'&&b.src)h+=`<img class="article-img" src="${_proxyImg(b.src)}" loading="lazy" onerror="this.style.display=\'none\'">`;});h+=`</div><div class="article-actions"><button onclick="doShare('${esc(d.title||'')}','${esc(url)}','${esc(d.og_image||'')}')">📤 Chia sẻ</button><button class="primary" onclick="rewriteSlide('${esc(url)}')">🤖 Slide Rewrite AI</button></div>`;el.innerHTML=h;}).catch(()=>{el.innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><p>Không thể tải bài viết</p></div>`;});}
|
| 576 |
-
async function rewriteSlide(url){if(!url)return;const btn=document.querySelector('.article-actions .primary')||event?.target;if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo slides...';}toast('⏳ Đang tạo slide rewrite...');try{const r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');toast('✅ Đã tạo slide! Đang tạo Short AI...');if(btn)btn.textContent='✅ Đang tạo Short AI...';if(j.post){j.post.slides=j.slides||[];prependWallPost(j.post);}// Show slides immediately if available
|
| 577 |
-
if(j.slides && j.slides.length){showView('view-article');const el=document.getElementById('view-article');let h=`<button class="back-btn" onclick="switchCat('home')">← Tường AI</button><div class="slide-viewer" style="padding:12px;max-width:600px;margin:0 auto">`;j.slides.forEach(s=>{h+=`<div class="slide-card" style="background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:16px;margin-bottom:12px"><div class="slide-num" style="color:#5cb87a;font-size:12px;font-weight:700;margin-bottom:6px">Slide ${s.index}/${j.slides.length}</div>${s.image?`<img src="${s.image.startsWith('/api/')?s.image:'/api/proxy/img?url='+encodeURIComponent(s.image)}" style="width:100%;max-height:300px;object-fit:cover;border-radius:8px;margin-bottom:8px" loading="lazy" onerror="this.style.display='none'">`:''}<p style="color:#ddd;font-size:14px;line-height:1.6;margin:0">${esc(s.text)}</p></div>`;});h+=`</div><div style="text-align:center;padding:12px"><div id="short-ai-status" style="color:#888;font-size:12px">⏳ Đang tạo video Short AI...</div></div>`;el.innerHTML=h;}
|
| 578 |
-
const postId=j.post&&j.post.id;if(postId){setTimeout(async()=>{const sr=await fetch('/api/ai/short/'+encodeURIComponent(postId),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:'vi-VN-HoaiMyNeural',emotion:'neutral',speed:1.2})});const sj=await sr.json();if(sr.ok&&sj.video&&typeof _wallPosts!=='undefined'){const p=_wallPosts.find(x=>String(x.id)===String(postId));if(p){p.video=sj.video;const itemId='wall-item-'+postId;const el2=document.getElementById(itemId);if(el2){const idx=_wallPosts.indexOf(p);el2.insertAdjacentHTML('afterend',makeWallItem(p,idx));el2.remove();}}toast('✅ Short AI đã sẵn sàng!');const statusEl=document.getElementById('short-ai-status');if(statusEl)statusEl.innerHTML='✅ Short AI đẵn sàng! <button class="primary" onclick="openShortAIFeed(0)" style="background:#2d8659;border:0;color:#fff;padding:6px 12px;border-radius:10px;font-size:11px;cursor:pointer;margin-left:8px">▶ Xem ngay</button>';}},500);}if(btn)btn.textContent='✅ Hoàn tất';}catch(e){toast('❌ '+e.message);if(btn){btn.disabled=false;btn.textContent='🤖 Slide Rewrite AI';}}}
|
| 579 |
-
|
| 580 |
-
function downloadVideo(url, title){
|
| 581 |
-
var a = document.createElement('a');
|
| 582 |
-
a.href = url;
|
| 583 |
-
a.download = (title||'video').toString().replace(/[^a-zA-Z0-9_\-\p{L}]/gu,'_').substring(0,60)+'.mp4';
|
| 584 |
-
a.target = '_blank';
|
| 585 |
-
a.rel = 'noopener';
|
| 586 |
-
a.style.display = 'none';
|
| 587 |
-
document.body.appendChild(a);
|
| 588 |
-
a.click();
|
| 589 |
-
document.body.removeChild(a);
|
| 590 |
-
toast('📥 Đang tải video xuống...');
|
| 591 |
-
}
|
| 592 |
-
|
| 593 |
-
// ===== PERSONAL OPINION POST: Viết bài dựa trên quan điểm cá nhân + tin HOT =====
|
| 594 |
-
async function openPersonalPostPreview() {
|
| 595 |
-
const opinion = document.getElementById('opinion-input')?.value.trim();
|
| 596 |
-
if (!opinion || opinion.length < 10) {
|
| 597 |
-
alert('Vui lòng nhập quan điểm cá nhân (ít nhất 10 ký tự)');
|
| 598 |
-
return;
|
| 599 |
-
}
|
| 600 |
-
|
| 601 |
-
// Get selected hot topics from UI (if any)
|
| 602 |
-
const selectedTopics = [];
|
| 603 |
-
if (window._htTopic) {
|
| 604 |
-
selectedTopics.push(window._htTopic);
|
| 605 |
-
}
|
| 606 |
-
|
| 607 |
-
// Get selected sources from hashtag view
|
| 608 |
-
const selectedSources = [];
|
| 609 |
-
document.querySelectorAll('.hashtag-src-item.selected').forEach(el => {
|
| 610 |
-
const idx = parseInt(el.dataset.idx || '0');
|
| 611 |
-
// Would need source tracking
|
| 612 |
-
});
|
| 613 |
-
|
| 614 |
-
const btn = event?.target;
|
| 615 |
-
const origText = btn ? btn.textContent : '';
|
| 616 |
-
if (btn) { btn.disabled = true; btn.textContent = '⏳ Đang tạo preview...'; }
|
| 617 |
-
|
| 618 |
-
try {
|
| 619 |
-
const resp = await fetch('/api/personal_post/preview', {
|
| 620 |
-
method: 'POST',
|
| 621 |
-
headers: { 'Content-Type': 'application/json' },
|
| 622 |
-
body: JSON.stringify({
|
| 623 |
-
opinion: opinion,
|
| 624 |
-
selected_topics: selectedTopics,
|
| 625 |
-
selected_sources: selectedSources
|
| 626 |
-
})
|
| 627 |
-
});
|
| 628 |
-
const data = await resp.json();
|
| 629 |
-
if (!resp.ok || data.error) throw new Error(data.error || 'Lỗi tạo preview');
|
| 630 |
-
|
| 631 |
-
showPersonalPostModal(data.preview, opinion, selectedTopics, selectedSources);
|
| 632 |
-
} catch (e) {
|
| 633 |
-
toast('❌ ' + e.message);
|
| 634 |
-
} finally {
|
| 635 |
-
if (btn) { btn.disabled = false; btn.textContent = origText; }
|
| 636 |
-
}
|
| 637 |
-
}
|
| 638 |
-
|
| 639 |
-
function showPersonalPostModal(preview, originalOpinion, selectedTopics, selectedSources) {
|
| 640 |
-
// Remove existing modal
|
| 641 |
-
const existing = document.getElementById('personal-post-modal');
|
| 642 |
-
if (existing) existing.remove();
|
| 643 |
-
|
| 644 |
-
const modal = document.createElement('div');
|
| 645 |
-
modal.id = 'personal-post-modal';
|
| 646 |
-
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.9);z-index:99999;display:flex;align-items:center;justify-content:center;padding:16px;overflow-y:auto';
|
| 647 |
-
|
| 648 |
-
const slidesHtml = (preview.slides || []).map((s, i) => {
|
| 649 |
-
return '<div class="preview-slide" style="background:#1a1a1a;border:1px solid #2a2a2a;border-radius:12px;padding:16px;margin-bottom:12px;max-width:600px;margin-left:auto;margin-right:auto">'
|
| 650 |
-
+ '<div style="display:flex;gap:8px;align-items:flex-start">'
|
| 651 |
-
+ '<div style="flex:0 0 32px;color:#5cb87a;font-size:12px;font-weight:700;margin-top:4px">Slide ' + (i+1) + '</div>'
|
| 652 |
-
+ '<div style="flex:1;min-width:0">'
|
| 653 |
-
+ (s.image ? '<img src="' + s.image + '" style="width:100%;max-height:200px;object-fit:cover;border-radius:8px;margin-bottom:8px" onerror="this.style.display=\'none\'">' : '')
|
| 654 |
-
+ '<textarea class="preview-slide-text" data-index="' + i + '" style="width:100%;min-height:80px;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px;font-size:13px;resize:vertical;font-family:inherit">' + (s.text || '').replace(/"/g, '"') + '</textarea>'
|
| 655 |
-
+ '</div></div></div>';
|
| 656 |
-
}).join('');
|
| 657 |
-
|
| 658 |
-
const sourcesHtml = (preview.sources || []).map((s, i) => {
|
| 659 |
-
const imgSrc = (preview.images && preview.images[i+1]) ? preview.images[i+1] : '';
|
| 660 |
-
return '<div style="display:flex;gap:8px;padding:8px;background:#202020;border-radius:8px;margin:4px 0;cursor:pointer" onclick="toggleSourceSelection(this, ' + i + ')" data-source-idx="' + i + '" data-url="' + (s.url || '') + '">'
|
| 661 |
-
+ '<div style="flex:0 0 60px;aspect-ratio:16/9;background:#333;border-radius:4px;overflow:hidden">'
|
| 662 |
-
+ (imgSrc ? '<img src="' + imgSrc + '" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display=\'none\'">' : '')
|
| 663 |
-
+ '</div>'
|
| 664 |
-
+ '<div style="flex:1;min-width:0;font-size:11px;color:#ddd">' + (s.title || '') + '</div>'
|
| 665 |
-
+ '<input type="checkbox" class="source-checkbox" style="margin-top:4px" ' + (i < 3 ? 'checked' : '') + '>'
|
| 666 |
-
+ '</div>';
|
| 667 |
-
}).join('');
|
| 668 |
-
|
| 669 |
-
modal.innerHTML = '<div style="background:#1a1a1a;border:2px solid #2d8659;border-radius:16px;padding:20px;max-width:700px;width:100%;max-height:90vh;overflow-y:auto">'
|
| 670 |
-
+ '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">'
|
| 671 |
-
+ '<h3 style="color:#5cb87a;margin:0;font-size:16px">📝 Xem trước bài quan điểm cá nhân</h3>'
|
| 672 |
-
+ '<button onclick="this.closest(\'#personal-post-modal\').remove()" style="background:none;border:0;color:#fff;font-size:24px;cursor:pointer">✕</button>'
|
| 673 |
-
+ '</div>'
|
| 674 |
-
+ '<div style="margin-bottom:12px">'
|
| 675 |
-
+ '<label style="display:block;color:#888;font-size:11px;margin-bottom:4px">Tiêu đề:</label>'
|
| 676 |
-
+ '<input id="preview-title" value="' + (preview.title || '').replace(/"/g, '"') + '" style="width:100%;background:#222;border:1px solid #333;color:#eee;border-radius:8px;padding:8px 12px;font-size:14px;font-weight:700">'
|
| 677 |
-
+ '</div>'
|
| 678 |
-
+ '<div style="margin-bottom:12px">'
|
| 679 |
-
+ '<label style="display:block;color:#888;font-size:11px;margin-bottom:4px">Quan điểm gốc:</label>'
|
| 680 |
-
+ '<textarea id="preview-opinion" readonly style="width:100%;min-height:60px;background:#141414;border:1px solid #2a2a2a;color:#aaa;border-radius:8px;padding:8px 12px;font-size:12px;resize:vertical">' + originalOpinion.replace(/"/g, '"') + '</textarea>'
|
| 681 |
-
+ '</div>'
|
| 682 |
-
+ '<div style="margin-bottom:12px;max-height:300px;overflow-y:auto">'
|
| 683 |
-
+ '<label style="display:block;color:#888;font-size:11px;margin-bottom:8px">Các slide (có thể chỉnh sửa):</label>'
|
| 684 |
-
+ slidesHtml
|
| 685 |
-
+ '</div>'
|
| 686 |
-
+ '<div style="margin-bottom:12px">'
|
| 687 |
-
+ '<label style="display:block;color:#888;font-size:11px;margin-bottom:8px">Nguồn tin tham khảo (bỏ chọn để loại):</label>'
|
| 688 |
-
+ '<div style="max-height:200px;overflow-y:auto">'
|
| 689 |
-
+ (sourcesHtml || '<div style="color:#777;font-size:12px;padding:8px">Không có nguồn tin</div>')
|
| 690 |
-
+ '</div></div>'
|
| 691 |
-
+ '<div style="display:flex;gap:8px">'
|
| 692 |
-
+ '<button onclick="publishPersonalPostFromModal()" style="flex:1;background:#2d8659;border:0;color:#fff;padding:12px;border-radius:12px;font-size:13px;font-weight:700;cursor:pointer">✅ Đăng lên Tường AI</button>'
|
| 693 |
-
+ '<button onclick="this.closest(\'#personal-post-modal\').remove()" style="background:#333;border:0;color:#ccc;padding:12px 16px;border-radius:12px;font-size:12px;cursor:pointer">Hủy</button>'
|
| 694 |
-
+ '</div></div>';
|
| 695 |
-
|
| 696 |
-
document.body.appendChild(modal);
|
| 697 |
-
|
| 698 |
-
// Store preview data for publishing
|
| 699 |
-
window._personalPostPreview = preview;
|
| 700 |
-
window._personalPostOpinion = originalOpinion;
|
| 701 |
-
}
|
| 702 |
-
|
| 703 |
-
function toggleSourceSelection(el, idx) {
|
| 704 |
-
const checkbox = el.querySelector('.source-checkbox');
|
| 705 |
-
checkbox.checked = !checkbox.checked;
|
| 706 |
-
el.style.border = checkbox.checked ? '1px solid #2d8659' : '1px solid transparent';
|
| 707 |
-
el.style.background = checkbox.checked ? '#1a2a1f' : '#202020';
|
| 708 |
-
}
|
| 709 |
-
|
| 710 |
-
async function publishPersonalPostFromModal() {
|
| 711 |
-
const title = document.getElementById('preview-title')?.value.trim();
|
| 712 |
-
if (!title) {
|
| 713 |
-
alert('Vui lòng nhập tiêu đề');
|
| 714 |
-
return;
|
| 715 |
-
}
|
| 716 |
-
|
| 717 |
-
// Collect edited slides
|
| 718 |
-
const slides = [];
|
| 719 |
-
document.querySelectorAll('.preview-slide-text').forEach(ta => {
|
| 720 |
-
const text = ta.value.trim();
|
| 721 |
-
if (text) slides.push({ text, image: '', index: slides.length + 1 });
|
| 722 |
-
});
|
| 723 |
-
|
| 724 |
-
// Collect selected sources
|
| 725 |
-
const sources = [];
|
| 726 |
-
document.querySelectorAll('[data-source-idx]').forEach((el, i) => {
|
| 727 |
-
const checkbox = el.querySelector('.source-checkbox');
|
| 728 |
-
if (checkbox.checked && window._personalPostPreview?.sources?.[i]) {
|
| 729 |
-
sources.push(window._personalPostPreview.sources[i]);
|
| 730 |
-
}
|
| 731 |
-
});
|
| 732 |
-
|
| 733 |
-
const btn = event.target;
|
| 734 |
-
const origText = btn.textContent;
|
| 735 |
-
btn.disabled = true;
|
| 736 |
-
btn.textContent = '⏳ Đang đăng...';
|
| 737 |
-
|
| 738 |
-
try {
|
| 739 |
-
const resp = await fetch('/api/personal_post', {
|
| 740 |
-
method: 'POST',
|
| 741 |
-
headers: { 'Content-Type': 'application/json' },
|
| 742 |
-
body: JSON.stringify({
|
| 743 |
-
opinion: window._personalPostOpinion,
|
| 744 |
-
selected_topics: [],
|
| 745 |
-
selected_sources: sources,
|
| 746 |
-
custom_title: title,
|
| 747 |
-
custom_slides: slides
|
| 748 |
-
})
|
| 749 |
-
});
|
| 750 |
-
const data = await resp.json();
|
| 751 |
-
if (!resp.ok || data.error) throw new Error(data.error || 'Lỗi đăng bài');
|
| 752 |
-
|
| 753 |
-
toast('✅ Đã đăng bài quan điểm lên Tường AI!');
|
| 754 |
-
|
| 755 |
-
// Prepend to wall
|
| 756 |
-
if (data.post && typeof prependWallPost === 'function') {
|
| 757 |
-
prependWallPost(data.post);
|
| 758 |
-
}
|
| 759 |
-
|
| 760 |
-
// Close modal
|
| 761 |
-
document.getElementById('personal-post-modal')?.remove();
|
| 762 |
-
|
| 763 |
-
// Clear opinion input
|
| 764 |
-
document.getElementById('opinion-input').value = '';
|
| 765 |
-
} catch (e) {
|
| 766 |
-
toast('❌ ' + e.message);
|
| 767 |
-
} finally {
|
| 768 |
-
btn.disabled = false;
|
| 769 |
-
btn.textContent = origText;
|
| 770 |
-
}
|
| 771 |
-
}
|
| 772 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/app_v2_shorts_fix.js
DELETED
|
@@ -1,2 +0,0 @@
|
|
| 1 |
-
// No-op - all functionality built into app_v2.js
|
| 2 |
-
(function(){})();
|
|
|
|
|
|
|
|
|
static/app_v3.js
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// === VNEWS Frontend v2 - Full Functions ===
|
| 2 |
+
// Updated: Voice selector + speed control + image gallery + auto voice detect
|
| 3 |
+
|
| 4 |
+
// === LOAD HOME ===
|
| 5 |
+
async function loadHome(){
|
| 6 |
+
const[featured,sh,wall,hlLeagues,ai,wcData]=await Promise.all([
|
| 7 |
+
fetch('/api/livescore/featured').then(r=>r.json()).catch(()=>null),
|
| 8 |
+
fetch('/api/shorts').then(r=>r.json()).catch(()=>[]),
|
| 9 |
+
fetch('/api/wall').then(r=>r.json()).catch(()=>({posts:[]})),
|
| 10 |
+
fetch('/api/highlights/leagues').then(r=>r.json()).catch(()=>({})),
|
| 11 |
+
fetch('/api/genk_ai').then(r=>r.json()).catch(()=>[]),
|
| 12 |
+
fetch('/api/wc2026').then(r=>r.json()).catch(()=>null)
|
| 13 |
+
]);
|
| 14 |
+
_hlLeagueData=hlLeagues;
|
| 15 |
+
_wc2026Data=wcData;
|
| 16 |
+
_shortsData=interleaveShorts(sh||[]);
|
| 17 |
+
_wallPosts=(wall&&wall.posts)||[];
|
| 18 |
+
let h='';
|
| 19 |
+
if(featured&&featured.home){
|
| 20 |
+
const sc=featured.status==='live'?'':'upcoming';
|
| 21 |
+
const st=featured.status==='live'?`🔴 ${featured.minute||'LIVE'}`:`⏰ ${featured.time}`;
|
| 22 |
+
// Safely encode for HTML attribute: escape quotes, angle brackets, ampersands
|
| 23 |
+
const eid = String(featured.event_id||'').replace(/[<>&"']/g,'');
|
| 24 |
+
const mUrl = String(featured.url||'').replace(/[<>&"']/g,'');
|
| 25 |
+
const fHome = String(featured.home||'').replace(/[<>&"']/g,'');
|
| 26 |
+
const fAway = String(featured.away||'').replace(/[<>&"']/g,'');
|
| 27 |
+
const fLeague = String(featured.league||'').replace(/[<>&"']/g,'');
|
| 28 |
+
const fScore = String(featured.score||'VS').replace(/[<>&"']/g,'');
|
| 29 |
+
const fHomeLogo = String(featured.home_logo||'').replace(/[<>&"']/g,'');
|
| 30 |
+
const fAwayLogo = String(featured.away_logo||'').replace(/[<>&"']/g,'');
|
| 31 |
+
const safeTitle = `${fHome} vs ${fAway} — ${fLeague}`;
|
| 32 |
+
h+=`<div class="featured-match" data-event-id="${eid}" data-url="${mUrl}" onclick="openMatch('${eid}','${mUrl}')" title="${safeTitle}">`+
|
| 33 |
+
`<div class="fm-league">${fLeague}</div>`+
|
| 34 |
+
`<div class="fm-teams">`+
|
| 35 |
+
`<div class="fm-team"><img src="${fHomeLogo}" onerror="this.style.display='none'"><span>${fHome}</span></div>`+
|
| 36 |
+
`<div class="fm-score">${fScore}</div>`+
|
| 37 |
+
`<div class="fm-team"><img src="${fAwayLogo}" onerror="this.style.display='none'"><span>${fAway}</span></div>`+
|
| 38 |
+
`</div>`+
|
| 39 |
+
`<div class="fm-status ${sc}">${st}</div>`+
|
| 40 |
+
`</div>`;
|
| 41 |
+
}
|
| 42 |
+
h+=`<div class="ai-compose"><div class="ai-compose-title">🤖 AI viết bài</div><div class="ai-compose-row"><input id="topic-input" placeholder="Nhập chủ đề..."><button onclick="searchTopic()">Tìm nguồn</button></div><div class="ai-compose-row"><input id="url-input" placeholder="Dán URL bài viết..."><button class="secondary" onclick="rewriteUrl()">Rewrite</button></div><div id="hot-topics" class="hot-topic-row"></div></div>`;
|
| 43 |
+
h+='<div id="hashtag-box"></div>';
|
| 44 |
+
h+=`<div class="ls-section"><div class="ls-header"><h3>⚽ Livescore</h3></div><div class="ls-tabs"><span class="ls-tab active" data-tab="today" onclick="loadLivescore('today')">📅 Hôm nay</span><span class="ls-tab" data-tab="live" onclick="loadLivescore('live')">🔴 Live</span><span class="ls-tab" data-tab="incoming" onclick="loadLivescore('incoming')">⏰ Sắp tới</span><span class="ls-tab" data-tab="results" onclick="loadLivescore('results')">✅ Kết quả</span><span class="ls-tab" data-tab="bxh_nha" onclick="loadLivescore('bxh_nha')">🏆 NHA</span><span class="ls-tab" data-tab="bxh_laliga" onclick="loadLivescore('bxh_laliga')">🏆 La Liga</span></div><div class="ls-content" id="ls-content"><div class="loading">Đang tải...</div></div></div>`;
|
| 45 |
+
h+=`<div id="wc2026-live-section" class="wc2026-section"><div class="wc-header"><h2>🏆 World Cup 2026</h2><span class="wc-live-badge">● LIVE</span></div><div class="wc-tabs"><span class="wc-tab active" onclick="switchWCTab('news')">📰 Tin tức</span><span class="wc-tab" onclick="switchWCTab('fixtures')">📅 Lịch thi đấu</span><span class="wc-tab" onclick="switchWCTab('standings')">🏆 BXH</span><span class="wc-tab" onclick="switchWCTab('highlights')">🎬 Highlight</span><span class="wc-tab" onclick="switchWCTab('stats')">📊 Thống kê</span></div><div class="wc-content" id="wc-content"><div class="loading">Đang tải World Cup 2026...</div></div></div>`;
|
| 46 |
+
const wallPosts=_wallPosts;
|
| 47 |
+
const aiShorts=wallPosts.filter(p=>p.video);
|
| 48 |
+
if(aiShorts.length){h+='<div class="slider-wrap"><div class="slider-header"><span class="slider-label">🎬 Short AI</span></div><div class="slider-track">';aiShorts.slice(0,20).forEach((p,i)=>{h+=`<div class="slider-item shorts-item" onclick="openShortAIFeed(${i})"><div class="slider-thumb shorts-thumb"><video src="${p.video}" muted preload="metadata"></video><div class="card-play">▶</div></div><div class="slider-title">${esc(p.title)}</div></div>`});h+='</div></div>';}
|
| 49 |
+
if(_shortsData.length){h+='<div class="slider-wrap"><div class="slider-header"><span class="slider-label">📱 Shorts Dân trí & SKĐS</span><span class="slider-note">Mới nhất · xen kẽ</span></div><div class="slider-track">';_shortsData.slice(0,30).forEach((a,i)=>{const badge=a.channel==='baosuckhoedoisongboyte'?'SKĐS':'Dân trí';h+=`<div class="slider-item shorts-item" onclick="openYTShortsFeed(${i})"><div class="slider-thumb shorts-thumb">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="slider-title"><span style="color:#f0c040;font-size:8px">${badge}</span> ${esc(a.title)}</div></div>`});h+='</div></div>';}
|
| 50 |
+
if(wallPosts.length){h+=`<div class="slider-wrap" id="ai-wall-wrap"><div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track" id="ai-wall-track">`;wallPosts.slice(0,20).forEach((p,i)=>{h+=makeWallItem(p,i)});h+='</div></div>';}
|
| 51 |
+
const HL_CONFIG={"world-cup":{name:"World Cup 2026",emoji:"🌍"},"premier-league":{name:"Premier League",emoji:"🏴"},"champions-league":{name:"Champions League",emoji:"⭐"},"la-liga":{name:"La Liga",emoji:"🇪🇸"},"serie-a":{name:"Serie A",emoji:"🇮🇹"},"bundesliga":{name:"Bundesliga",emoji:"🇩🇪"},"friendly":{name:"Giao hữu",emoji:"🤝"}};
|
| 52 |
+
for(const[key,cfg] of Object.entries(HL_CONFIG)){const vids=hlLeagues[key];if(!vids||!vids.length)continue;h+=`<div class="slider-wrap"><div class="slider-header"><span class="slider-label">${cfg.emoji} ${cfg.name}</span></div><div class="slider-track">`;vids.slice(0,8).forEach((a,i)=>{h+=`<div class="slider-item" onclick="openHighlightFeed('${key}',${i})"><div class="slider-thumb">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="slider-title">${esc(a.title)}</div></div>`});h+='</div></div>';}
|
| 53 |
+
if(ai&&ai.length){h+='<div class="slider-wrap"><div class="slider-header"><span class="slider-label">🤖 Ứng dụng AI</span></div><div class="slider-track">';ai.slice(0,12).forEach(a=>{h+=`<div class="slider-item" onclick="readArticle('${esc(a.link)}')"><div class="slider-thumb">${a.img?`<img src="${a.img}">`:''}</div><div class="slider-title">${esc(a.title)}</div></div>`});h+='</div></div>';}
|
| 54 |
+
document.getElementById('view-home').innerHTML=h;
|
| 55 |
+
loadLivescore('today');loadHotTopics();
|
| 56 |
+
if(_wc2026Data)switchWCTab('news');
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
// === WALL POST HELPERS ===
|
| 60 |
+
function makeWallItem(p,i){
|
| 61 |
+
const hasVideo = p.video && p.video.length > 0;
|
| 62 |
+
const thumbContent = p.img
|
| 63 |
+
? `<img src="${esc(p.img)}" onerror="this.style.display='none'">`
|
| 64 |
+
: (hasVideo ? `<video src="${esc(p.video)}" muted></video>` : '');
|
| 65 |
+
const videoBadge = hasVideo
|
| 66 |
+
? `<div class="wall-video-badge">🎬</div>`
|
| 67 |
+
: '';
|
| 68 |
+
const videoBtn = hasVideo
|
| 69 |
+
? `<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed(${i})">▶ Xem Short</button>`
|
| 70 |
+
: `<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo('${esc(p.id||i)}',this)">🎬 Tạo Video</button>`;
|
| 71 |
+
|
| 72 |
+
return `<div class="wall-item" id="wall-item-${esc(p.id||i)}">
|
| 73 |
+
<div class="wall-thumb">
|
| 74 |
+
${thumbContent}
|
| 75 |
+
${videoBadge}
|
| 76 |
+
</div>
|
| 77 |
+
<div class="wall-title">${esc(p.title)}</div>
|
| 78 |
+
<div class="wall-text">${esc((p.text||'').slice(0,180))}</div>
|
| 79 |
+
<div class="wall-actions">
|
| 80 |
+
<button class="primary" onclick="readWallPost(${i})">Xem</button>
|
| 81 |
+
${videoBtn}
|
| 82 |
+
</div>
|
| 83 |
+
</div>`;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
// === GENERATE SHORT VIDEO FOR A WALL POST ===
|
| 87 |
+
async function makeShortVideo(postId, btn, voice, speed){
|
| 88 |
+
if(!postId)return;
|
| 89 |
+
const origText = btn ? btn.textContent : '🎬 Tạo Video';
|
| 90 |
+
if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo...';}
|
| 91 |
+
toast('⏳ Đang tạo video shorts...');
|
| 92 |
+
try{
|
| 93 |
+
let url = '/api/ai/short/'+encodeURIComponent(postId);
|
| 94 |
+
const params = [];
|
| 95 |
+
if(voice) params.push('voice='+encodeURIComponent(voice));
|
| 96 |
+
if(speed) params.push('speed='+encodeURIComponent(speed));
|
| 97 |
+
if(params.length) url += '?' + params.join('&');
|
| 98 |
+
const r = await fetch(url, {method:'POST'});
|
| 99 |
+
const j = await r.json();
|
| 100 |
+
if(!r.ok || j.error) throw new Error(j.error||'Lỗi tạo video');
|
| 101 |
+
toast('✅ Đã tạo video shorts!');
|
| 102 |
+
const p = _wallPosts.find(x => String(x.id) === String(postId));
|
| 103 |
+
if(p){
|
| 104 |
+
p.video = j.video;
|
| 105 |
+
const itemId = 'wall-item-'+postId;
|
| 106 |
+
const el = document.getElementById(itemId);
|
| 107 |
+
if(el){
|
| 108 |
+
const idx = _wallPosts.indexOf(p);
|
| 109 |
+
el.outerHTML = makeWallItem(p, idx);
|
| 110 |
+
const newEl = document.getElementById(itemId);
|
| 111 |
+
if(newEl) newEl.className = 'wall-item wall-item-new';
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
refreshShortAISlider();
|
| 115 |
+
}catch(e){
|
| 116 |
+
toast('❌ '+e.message);
|
| 117 |
+
if(btn){btn.disabled=false;btn.textContent=origText;}
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
// Refresh Short AI slider after video generation
|
| 122 |
+
function refreshShortAISlider(){
|
| 123 |
+
const aiShorts = _wallPosts.filter(p=>p.video);
|
| 124 |
+
let shortAISection = document.getElementById('short-ai-section');
|
| 125 |
+
if(aiShorts.length === 0){
|
| 126 |
+
if(shortAISection) shortAISection.remove();
|
| 127 |
+
return;
|
| 128 |
+
}
|
| 129 |
+
if(shortAISection){
|
| 130 |
+
const track = shortAISection.querySelector('.slider-track');
|
| 131 |
+
if(track){
|
| 132 |
+
let h = '';
|
| 133 |
+
aiShorts.slice(0,20).forEach((p,i)=>{
|
| 134 |
+
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>`;
|
| 135 |
+
});
|
| 136 |
+
track.innerHTML = h;
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
function prependWallPost(post){
|
| 142 |
+
_wallPosts.unshift(post);
|
| 143 |
+
const track=document.getElementById('ai-wall-track');
|
| 144 |
+
const wrap=document.getElementById('ai-wall-wrap');
|
| 145 |
+
const homeEl=document.getElementById('view-home');
|
| 146 |
+
if(!track||!wrap){
|
| 147 |
+
if(homeEl){
|
| 148 |
+
let insertBefore=homeEl.querySelector('.slider-wrap');
|
| 149 |
+
const newWrap=document.createElement('div');
|
| 150 |
+
newWrap.className='slider-wrap';
|
| 151 |
+
newWrap.id='ai-wall-wrap';
|
| 152 |
+
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>`;
|
| 153 |
+
if(insertBefore){
|
| 154 |
+
homeEl.insertBefore(newWrap,insertBefore);
|
| 155 |
+
}else{
|
| 156 |
+
homeEl.appendChild(newWrap);
|
| 157 |
+
}
|
| 158 |
+
const firstItem=newWrap.querySelector('.wall-item');
|
| 159 |
+
if(firstItem)firstItem.className='wall-item wall-item-new';
|
| 160 |
+
}
|
| 161 |
+
return;
|
| 162 |
+
}
|
| 163 |
+
const div=document.createElement('div');
|
| 164 |
+
div.className='wall-item wall-item-new';
|
| 165 |
+
div.id='wall-item-'+(post.id||'new-'+Date.now());
|
| 166 |
+
const hasVideo = post.video && post.video.length > 0;
|
| 167 |
+
const thumbContent = post.img
|
| 168 |
+
? `<img src="${esc(post.img)}" onerror="this.style.display='none'">`
|
| 169 |
+
: (hasVideo ? `<video src="${esc(post.video)}" muted></video>` : '');
|
| 170 |
+
const videoBadge = hasVideo ? `<div class="wall-video-badge">🎬</div>` : '';
|
| 171 |
+
const videoBtn = hasVideo
|
| 172 |
+
? `<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed(0)">▶ Xem Short</button>`
|
| 173 |
+
: `<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo('${esc(post.id)}',this)">🎬 Tạo Video</button>`;
|
| 174 |
+
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>`;
|
| 175 |
+
track.prepend(div);
|
| 176 |
+
track.scrollTo({left:0,behavior:'smooth'});
|
| 177 |
+
if(hasVideo) refreshShortAISlider();
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
// === REST OF FUNCTIONS ===
|
| 181 |
+
let _shortsData=[];
|
| 182 |
+
let _wallPosts=[];
|
| 183 |
+
let _currentView='home';
|
| 184 |
+
let _currentEventId=null;
|
| 185 |
+
let _currentMatchUrl=null;
|
| 186 |
+
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;}
|
| 187 |
+
let _htPage=0,_htTopic='';
|
| 188 |
+
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);}}
|
| 189 |
+
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);}
|
| 190 |
+
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>`;}}
|
| 191 |
+
function loadMoreHashtag(){_htPage++;const btn=document.getElementById('ht-more');if(btn){btn.disabled=true;btn.textContent='Đang tải...';}showHashtagSources(_htTopic,_htPage);}
|
| 192 |
+
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';}}}
|
| 193 |
+
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>';}}
|
| 194 |
+
function bindMatchClicks(el){
|
| 195 |
+
if(!el) return;
|
| 196 |
+
el.querySelectorAll('.match-detail').forEach(md=>{
|
| 197 |
+
md.style.cursor='pointer';
|
| 198 |
+
// Remove old listeners to avoid duplicates (mark as bound)
|
| 199 |
+
if(md._bound) return;
|
| 200 |
+
md._bound = true;
|
| 201 |
+
md.addEventListener('click',function(e){
|
| 202 |
+
// Don't intercept clicks on interactive elements inside the row
|
| 203 |
+
const tag = e.target.tagName?.toLowerCase();
|
| 204 |
+
if(tag === 'a' || tag === 'button' || tag === 'input') {
|
| 205 |
+
e.preventDefault();
|
| 206 |
+
e.stopPropagation();
|
| 207 |
+
}
|
| 208 |
+
// Find ANY link with /tran-dau/ inside this match-detail row
|
| 209 |
+
const links = this.querySelectorAll('a[href*="/tran-dau/"]');
|
| 210 |
+
let bestA = null;
|
| 211 |
+
links.forEach(a => {
|
| 212 |
+
const href = a.getAttribute('href') || '';
|
| 213 |
+
// Prefer links with both event_id AND slug (fuller URL)
|
| 214 |
+
if(href.match(/\/tran-dau\/\d+\/(centre|preview|quan-cau|video)\//)) {
|
| 215 |
+
bestA = a;
|
| 216 |
+
} else if(!bestA && href.match(/\/tran-dau\/\d+\//)) {
|
| 217 |
+
bestA = a;
|
| 218 |
+
}
|
| 219 |
+
});
|
| 220 |
+
if(!bestA) return;
|
| 221 |
+
e.preventDefault();
|
| 222 |
+
e.stopPropagation();
|
| 223 |
+
const href = bestA.getAttribute('href') || '';
|
| 224 |
+
const m = href.match(/\/tran-dau\/(\d+)\//);
|
| 225 |
+
if(m){
|
| 226 |
+
const fullUrl = href.startsWith('http') ? href : 'https://bongda.com.vn' + href;
|
| 227 |
+
openMatch(m[1], fullUrl);
|
| 228 |
+
}
|
| 229 |
+
});
|
| 230 |
+
});
|
| 231 |
+
// Prevent default navigation on all links inside livescore (but let match-detail click handler work)
|
| 232 |
+
el.querySelectorAll('a').forEach(a=>{
|
| 233 |
+
a.addEventListener('click',e=>{
|
| 234 |
+
e.preventDefault();
|
| 235 |
+
e.stopPropagation();
|
| 236 |
+
});
|
| 237 |
+
});
|
| 238 |
+
}
|
| 239 |
+
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')}
|
| 240 |
+
function closeMatch(){document.getElementById('match-overlay').classList.remove('active');document.body.style.overflow=''}
|
| 241 |
+
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>'}}
|
| 242 |
+
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;}}
|
| 243 |
+
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};}}
|
| 244 |
+
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[];}}
|
| 245 |
+
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[];}}
|
| 246 |
+
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>`;}
|
| 247 |
+
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);}}
|
| 248 |
+
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);}}
|
| 249 |
+
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);}
|
| 250 |
+
async function loadCounters(videoIds){for(let i=0;i<videoIds.length;i++){const id=videoIds[i];if(!id)continue;const j=await getInteractions(id);const vc=document.getElementById('vc-'+i);if(vc)vc.textContent=fmtNum(j.views);const lc=document.getElementById('lc-'+i);if(lc)lc.textContent=fmtNum(j.likes);const cc=document.getElementById('cc-'+i);if(cc)cc.textContent=fmtNum(j.comments);}}
|
| 251 |
+
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);}
|
| 252 |
+
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;}
|
| 253 |
+
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);}
|
| 254 |
+
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)}
|
| 255 |
+
async function openHighlightFeed(league,idx){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải...</div>';let articles=(_hlLeagueData||{})[league]||[];if(!articles.length){try{articles=await fetch('/api/highlights/'+league).then(r=>r.json())}catch(e){articles=[]}}if(!articles.length){el.innerHTML='<div class="loading">Không có video</div>';return}const vids=[];const results=await Promise.all(articles.map(async(a,i)=>{try{const r=await fetch('/api/video_url?url='+encodeURIComponent(a.link));const v=await r.json();if(v&&v.src)return{...a,...v,_idx:i}}catch(e){}return null}));results.forEach(r=>{if(r)vids.push(r)});vids.sort((a,b)=>a._idx-b._idx);if(!vids.length){el.innerHTML='<div class="loading">Không tìm thấy video</div>';return}let ti=vids.findIndex(v=>v._idx===idx);if(ti<0)ti=0;const ordered=ti>0?[...vids.slice(ti),...vids.slice(0,ti)]:vids;let h=`<button class="back-btn" onclick="switchCat('home')">← Highlight</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">`;ordered.forEach((v,i)=>{const isYT=v.type==='youtube',isHLS=!isYT&&v.src?.includes('.m3u8'),poster=v.poster?` poster="${v.poster}"`:'';const vtag=isYT?`<iframe data-yt-src="${v.src}" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture"></iframe>`:isHLS?`<video playsinline preload="none"${poster} data-hls="${v.src}" loop controls></video>`:`<video playsinline preload="none"${poster} loop controls><source src="${v.src}" type="video/mp4"></video>`;const videoId='hl-'+league+'-'+(v.id||v._idx);h+=buildTikTokSlide({vtag,title:v.title,badge:'HL',badgeClass:'badge-fpt',videoId,idx:i,total:ordered.length,shareUrl:v.link||'',extraBtn:`<button class="tiktok-right-btn" onclick="event.stopPropagation();this.closest('.tiktok-slide').classList.toggle('ratio-wide')"><div class="icon">⬜</div><div class="count">16:9</div></button>`});});h+='</div></div>';el.innerHTML=h;initTikTokFeed();}
|
| 256 |
+
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();}
|
| 257 |
+
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();}
|
| 258 |
+
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>`;}
|
| 259 |
+
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)}}
|
| 260 |
+
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)}}
|
| 261 |
+
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}}
|
| 262 |
+
async function readWallPost(i){const p=_wallPosts[i];if(!p)return;showView('view-article');
|
| 263 |
+
const images = p.images || [];
|
| 264 |
+
let imgGallery = '';
|
| 265 |
+
if(images.length > 0){
|
| 266 |
+
imgGallery = '<div class="article-image-gallery">';
|
| 267 |
+
images.forEach((imgUrl, idx) => {
|
| 268 |
+
if(idx === 0){
|
| 269 |
+
imgGallery += `<img class="article-img article-hero-img" src="${esc(imgUrl)}" onerror="this.style.display='none" loading="eager">`;
|
| 270 |
+
} else {
|
| 271 |
+
if(idx === 1) imgGallery += '<div class="gallery-thumbs">';
|
| 272 |
+
imgGallery += `<div class="gallery-thumb"><img src="${esc(imgUrl)}" onerror="this.parentElement.style.display='none'" loading="lazy"></div>`;
|
| 273 |
+
}
|
| 274 |
+
});
|
| 275 |
+
if(images.length > 1) imgGallery += '</div>';
|
| 276 |
+
imgGallery += '</div>';
|
| 277 |
+
}
|
| 278 |
+
const hasVideo = p.video && p.video.length > 0;
|
| 279 |
+
const voiceOptions = [
|
| 280 |
+
{id:'hoaimy', label:'🎙️ Nữ — Hoài My'},
|
| 281 |
+
{id:'namminh', label:'🎙️ Nam — Nam Minh'},
|
| 282 |
+
];
|
| 283 |
+
let voiceSelector = '';
|
| 284 |
+
if(!hasVideo){
|
| 285 |
+
voiceSelector = `<div class="tts-selector"><div class="tts-selector-label">🎙️ Chọn giọng đọc:</div><div class="tts-voice-btns">`;
|
| 286 |
+
voiceOptions.forEach(v=>{
|
| 287 |
+
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>`;
|
| 288 |
+
});
|
| 289 |
+
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>`;
|
| 290 |
+
voiceSelector += `<input type="hidden" id="selected-voice" value="hoaimy"></div>`;
|
| 291 |
+
}
|
| 292 |
+
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>`;
|
| 293 |
+
const firstVoiceBtn = document.querySelector('.tts-voice-btn');
|
| 294 |
+
if(firstVoiceBtn) firstVoiceBtn.classList.add('active');
|
| 295 |
+
window.scrollTo(0,0)}
|
| 296 |
+
async function loadNewsTab(){const el=document.getElementById('view-cat');el.innerHTML='<div class="loading">Đang tải...</div>';try{const r=await fetch('/api/homepage');const news=await r.json();if(!news.length){el.innerHTML='<div class="loading">Không có tin</div>';return}const groups={};news.forEach(a=>{if(!groups[a.group])groups[a.group]=[];groups[a.group].push(a)});let h='';for(const[g,arts] of Object.entries(groups)){h+=`<div class="section-title">${g}</div><div class="grid">`;arts.slice(0,6).forEach(a=>{h+=`<div class="card" onclick="readArticle('${esc(a.link)}')"><div class="card-img">${a.img?`<img src="${a.img}">`:''}</div><div class="card-body"><span class="badge badge-vne">${esc(a.source||'VnE')}</span><div class="card-title">${esc(a.title)}</div></div></div>`});h+='</div>'}el.innerHTML=h}catch(e){el.innerHTML='<div class="loading">Lỗi</div>'}}
|
| 297 |
+
async function loadCat(id){const el=document.getElementById('view-cat');el.innerHTML='<div class="loading">Đang tải...</div>';const arts=await fetch('/api/category/'+id).then(r=>r.json()).catch(()=>[]);if(!arts.length){el.innerHTML='<div class="loading">Không có tin</div>';return}let h='<div class="grid">';arts.forEach(a=>{h+=`<div class="card" onclick="readArticle('${esc(a.link)}')"><div class="card-img">${a.img?`<img src="${a.img}">`:''}</div><div class="card-body"><span class="badge badge-vne">${esc(a.source||'')}</span><div class="card-title">${esc(a.title)}</div></div></div>`});h+='</div>';el.innerHTML=h}
|
| 298 |
+
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(()=>{});
|
| 299 |
+
|
| 300 |
+
// === AUTO-OPEN SHARE LINKS (/s?url=... sets pending_article) ===
|
| 301 |
+
(function(){
|
| 302 |
+
try{
|
| 303 |
+
const pa=localStorage.getItem('pending_article');
|
| 304 |
+
const pv=localStorage.getItem('pending_video');
|
| 305 |
+
if(pa){
|
| 306 |
+
localStorage.removeItem('pending_article');
|
| 307 |
+
setTimeout(()=>{
|
| 308 |
+
if(typeof readArticle==='function') readArticle(pa);
|
| 309 |
+
},1500);
|
| 310 |
+
}
|
| 311 |
+
if(pv){
|
| 312 |
+
localStorage.removeItem('pending_video');
|
| 313 |
+
try{
|
| 314 |
+
const v=JSON.parse(pv);
|
| 315 |
+
if(v&&v.url) setTimeout(()=>{window.open(v.url,'_blank')},1500);
|
| 316 |
+
}catch(e){}
|
| 317 |
+
}
|
| 318 |
+
}catch(e){}
|
| 319 |
+
})();
|
static/app_v4.js
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// === VNEWS Frontend v4 — VTV inline + fixed livescore clicks ===
|
| 2 |
+
|
| 3 |
+
// === VTV CHANNELS CONFIG ===
|
| 4 |
+
const VTV_CHANNELS = [
|
| 5 |
+
{id:'vtv1', name:'VTV1', badge:'Tin tức'},
|
| 6 |
+
{id:'vtv2', name:'VTV2', badge:'Khoa học'},
|
| 7 |
+
{id:'vtv3', name:'VTV3', badge:'Giải trí'},
|
| 8 |
+
{id:'vtv4', name:'VTV4', badge:'Quốc tế'},
|
| 9 |
+
{id:'vtv5', name:'VTV5', badge:'Miền Nam'},
|
| 10 |
+
{id:'vtv6', name:'VTV6', badge:'Thanh niên'},
|
| 11 |
+
{id:'vtv7', name:'VTV7', badge:'Giáo dục'},
|
| 12 |
+
{id:'vtv8', name:'VTV8', badge:'Miền Trung'},
|
| 13 |
+
{id:'vtv9', name:'VTV9', badge:'Miền Bắc'},
|
| 14 |
+
{id:'vtv10', name:'VTV10', badge:'VTV10'},
|
| 15 |
+
{id:'vtvprime', name:'VTVPrime', badge:'Prime'},
|
| 16 |
+
];
|
| 17 |
+
|
| 18 |
+
const VTV_EPG = {
|
| 19 |
+
vtv1:[{t:'06:00',n:'Nhật ký ngày mai'},{t:'07:00',n:'Thời sự sáng'},{t:'12:00',n:'Thời sự trưa'},{t:'19:00',n:'Thời sự tối'},{t:'21:00',n:'Thời sự đêm'}],
|
| 20 |
+
vtv2:[{t:'06:00',n:'Khoa học & CN'},{t:'08:00',n:'Thế giới tự nhiên'},{t:'12:00',n:'Đi tìm giải pháp'},{t:'18:00',n:'Thế giới động vật'},{t:'20:00',n:'Khoa học & Tương lai'}],
|
| 21 |
+
vtv3:[{t:'06:00',n:'Sáng vui'},{t:'08:00',n:'Phim truyện'},{t:'12:00',n:'Âm nhạc'},{t:'18:00',n:'Tạp kỹ thuật số'},{t:'20:00',n:'Phim truyện đặc biệt'}],
|
| 22 |
+
vtv4:[{t:'06:00',n:'News'},{t:'08:00',n:'World News'},{t:'12:00',n:'Midday News'},{t:'18:00',n:'Evening News'},{t:'20:00',n:'World Today'}],
|
| 23 |
+
vtv5:[{t:'06:00',n:'Thời sự miền Nam'},{t:'08:00',n:'Thiếu nhi'},{t:'12:00',n:'Thời sự trưa'},{t:'18:00',n:'Thời sự chiều'},{t:'20:00',n:'Phim truyện'}],
|
| 24 |
+
vtv6:[{t:'06:00',n:'Khởi động'},{t:'08:00',n:'Thanh niên & Sáng tạo'},{t:'12:00',n:'Nhịp sống trẻ'},{t:'18:00',n:'Thời sự trẻ'},{t:'20:00',n:'Đêm nhạc'}],
|
| 25 |
+
vtv7:[{t:'06:00',n:'Giáo dục sáng'},{t:'08:00',n:'Học mọi lúc'},{t:'12:00',n:'Giáo dục trưa'},{t:'18:00',n:'Giáo dục chiều'},{t:'20:00',n:'Tài liệu GD'}],
|
| 26 |
+
vtv8:[{t:'06:00',n:'Thời sự miền Trung'},{t:'08:00',n:'Văn hóa'},{t:'12:00',n:'Thời sự trưa'},{t:'18:00',n:'Thời sự chiều'},{t:'20:00',n:'Phim truyện'}],
|
| 27 |
+
vtv9:[{t:'06:00',n:'Thời sự miền Bắc'},{t:'08:00',n:'Văn hóa'},{t:'12:00',n:'Thời sự trưa'},{t:'18:00',n:'Thời sự chiều'},{t:'20:00',n:'Phim truyện'}],
|
| 28 |
+
vtv10:[{t:'06:00',n:'Thời sự Tây Nam Bộ'},{t:'08:00',n:'Văn hóa đồng bằng'},{t:'12:00',n:'Thời sự trưa'},{t:'18:00',n:'Thời sự chiều'},{t:'20:00',n:'Phim truyện'}],
|
| 29 |
+
vtvprime:[{t:'06:00',n:'Prime Morning'},{t:'08:00',n:'Prime Cinema'},{t:'12:00',n:'Prime News'},{t:'18:00',n:'Prime Evening'},{t:'20:00',n:'Prime Night'}],
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
let _vtvStreams = {};
|
| 33 |
+
let _vtvCurrentCh = null;
|
| 34 |
+
let _vtvHls = null;
|
| 35 |
+
|
| 36 |
+
function buildVTVEPG(chId){
|
| 37 |
+
const epg = VTV_EPG[chId] || [];
|
| 38 |
+
if(!epg.length) return '';
|
| 39 |
+
const curH = new Date().getHours();
|
| 40 |
+
let items = '';
|
| 41 |
+
epg.forEach(item => {
|
| 42 |
+
const itemH = parseInt(item.t.split(':')[0], 10);
|
| 43 |
+
const isNow = itemH <= curH && (itemH + 2) > curH;
|
| 44 |
+
items += `<div class="vtv-epg-item${isNow?' now':''}"><div class="epg-t">${item.t}</div><div class="epg-n">${item.n}</div></div>`;
|
| 45 |
+
});
|
| 46 |
+
return `<div class="vtv-epg" id="vtv-epg"><div class="vtv-epg-header"><span class="vtv-epg-title">📋 Lịch phát sóng</span><button class="vtv-epg-toggle" onclick="_vtvToggleEPG()">Ẩn/Hiện</button></div><div class="vtv-epg-list" id="vtv-epg-list">${items}</div></div>`;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
function _vtvToggleEPG(){
|
| 50 |
+
const list = document.getElementById('vtv-epg-list');
|
| 51 |
+
if(list) list.style.display = list.style.display === 'none' ? 'flex' : 'none';
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
function buildVTVBlockHTML(){
|
| 55 |
+
let tabs = '';
|
| 56 |
+
VTV_CHANNELS.forEach(ch => {
|
| 57 |
+
tabs += `<button class="vtv-tab off" id="vtvt-${ch.id}" onclick="_vtvPlay('${ch.id}')">${ch.name}</button>`;
|
| 58 |
+
});
|
| 59 |
+
return `<div class="vtv-wrap" id="vtv-block">
|
| 60 |
+
<div class="vtv-head"><span class="vtv-title">📺 VTV Trực Tuyến</span><span class="vtv-badge">● LIVE</span></div>
|
| 61 |
+
<div class="vtv-tabs">${tabs}</div>
|
| 62 |
+
<div class="vtv-frame">
|
| 63 |
+
<div class="vtv-load" id="vtv-load"><div class="vtv-spinner"></div>Đang tải danh sách kênh...</div>
|
| 64 |
+
<video id="vtv-player" playsinline muted controls preload="auto" style="display:none"></video>
|
| 65 |
+
<div class="vtv-err" id="vtv-err" style="display:none"><span id="vtv-err-msg">Không thể tải kênh</span><button onclick="_vtvRetry()">Thử lại</button></div>
|
| 66 |
+
</div>
|
| 67 |
+
</div>`;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
async function loadVTVStreams(){
|
| 71 |
+
try {
|
| 72 |
+
const r = await fetch('/api/vtv/streams', {signal: AbortSignal.timeout(10000)});
|
| 73 |
+
if(r.ok){
|
| 74 |
+
const data = await r.json();
|
| 75 |
+
VTV_CHANNELS.forEach(ch => {
|
| 76 |
+
const info = data[ch.id];
|
| 77 |
+
if(info && info.stream_url){
|
| 78 |
+
_vtvStreams[ch.id] = ['/api/proxy/m3u8/vtv?url=' + encodeURIComponent(info.stream_url)];
|
| 79 |
+
} else {
|
| 80 |
+
_vtvStreams[ch.id] = [];
|
| 81 |
+
}
|
| 82 |
+
});
|
| 83 |
+
}
|
| 84 |
+
} catch(e) {
|
| 85 |
+
console.warn('VTV API error:', e);
|
| 86 |
+
}
|
| 87 |
+
VTV_CHANNELS.forEach(ch => {
|
| 88 |
+
const tab = document.getElementById('vtvt-'+ch.id);
|
| 89 |
+
if(tab){
|
| 90 |
+
if(_vtvStreams[ch.id] && _vtvStreams[ch.id].length > 0){
|
| 91 |
+
tab.classList.remove('off');
|
| 92 |
+
tab.textContent = ch.name;
|
| 93 |
+
} else {
|
| 94 |
+
tab.style.opacity = '0.35';
|
| 95 |
+
tab.textContent = ch.name + ' ✕';
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
});
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
function _vtvRetry(){
|
| 102 |
+
if(_vtvCurrentCh) _vtvPlay(_vtvCurrentCh);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
function _vtvPlay(chId){
|
| 106 |
+
const ch = VTV_CHANNELS.find(c => c.id === chId);
|
| 107 |
+
if(!ch) return;
|
| 108 |
+
_vtvCurrentCh = chId;
|
| 109 |
+
document.querySelectorAll('.vtv-tab').forEach(t => t.classList.remove('on'));
|
| 110 |
+
const tab = document.getElementById('vtvt-'+chId);
|
| 111 |
+
if(tab) tab.classList.add('on');
|
| 112 |
+
const video = document.getElementById('vtv-player');
|
| 113 |
+
const errEl = document.getElementById('vtv-err');
|
| 114 |
+
const loadEl = document.getElementById('vtv-load');
|
| 115 |
+
const errMsg = document.getElementById('vtv-err-msg');
|
| 116 |
+
video.style.display = 'none';
|
| 117 |
+
errEl.style.display = 'none';
|
| 118 |
+
loadEl.style.display = 'flex';
|
| 119 |
+
loadEl.innerHTML = '<div class="vtv-spinner"></div>Đang kết nối ' + ch.name + '...';
|
| 120 |
+
if(_vtvHls){ _vtvHls.destroy(); _vtvHls = null; }
|
| 121 |
+
const urls = _vtvStreams[chId] || [];
|
| 122 |
+
if(urls.length === 0){
|
| 123 |
+
loadEl.style.display = 'none';
|
| 124 |
+
errEl.style.display = 'flex';
|
| 125 |
+
errMsg.textContent = chId === 'vtvprime' ? 'VTVPrime: Kênh trả phí.' : ch.name + ': Không tìm thấy luồng.';
|
| 126 |
+
return;
|
| 127 |
+
}
|
| 128 |
+
const epgEl = document.getElementById('vtv-epg');
|
| 129 |
+
if(epgEl) epgEl.remove();
|
| 130 |
+
const frame = document.querySelector('.vtv-frame');
|
| 131 |
+
if(frame){
|
| 132 |
+
const d = document.createElement('div');
|
| 133 |
+
d.innerHTML = buildVTVEPG(chId);
|
| 134 |
+
frame.appendChild(d.firstElementChild);
|
| 135 |
+
}
|
| 136 |
+
_vtvTryPlay(video, urls, 0, ch.name, loadEl, errEl, errMsg);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
function _vtvTryPlay(video, urls, idx, name, loadEl, errEl, errMsg){
|
| 140 |
+
if(idx >= urls.length){
|
| 141 |
+
loadEl.style.display = 'none';
|
| 142 |
+
errEl.style.display = 'flex';
|
| 143 |
+
errMsg.textContent = name + ': Tất cả nguồn lỗi.';
|
| 144 |
+
return;
|
| 145 |
+
}
|
| 146 |
+
const src = urls[idx];
|
| 147 |
+
loadEl.innerHTML = '<div class="vtv-spinner"></div>Đang kết nối ' + name + ' (' + (idx+1) + '/' + urls.length + ')...';
|
| 148 |
+
if(typeof Hls !== 'undefined' && Hls.isSupported()){
|
| 149 |
+
const hls = new Hls({enableWorker:true, lowLatencyMode:true, startLevel:-1, capLevelToPlayerSize:true, maxBufferLength:20});
|
| 150 |
+
_vtvHls = hls;
|
| 151 |
+
hls.loadSource(src);
|
| 152 |
+
hls.attachMedia(video);
|
| 153 |
+
hls.on(Hls.Events.MANIFEST_PARSED, () => { video.play().catch(()=>{}); loadEl.style.display='none'; video.style.display='block'; });
|
| 154 |
+
let recAttempts = 0;
|
| 155 |
+
hls.on(Hls.Events.ERROR, (ev, data) => {
|
| 156 |
+
if(data.fatal){
|
| 157 |
+
if(data.type === Hls.ErrorTypes.NETWORK_ERROR){
|
| 158 |
+
recAttempts++;
|
| 159 |
+
if(recAttempts <= 3){ setTimeout(() => hls.startLoad(), 2000); }
|
| 160 |
+
else { hls.destroy(); _vtvHls = null; _vtvTryPlay(video, urls, idx+1, name, loadEl, errEl, errMsg); }
|
| 161 |
+
} else if(data.type === Hls.ErrorTypes.MEDIA_ERROR){
|
| 162 |
+
try { hls.recoverMediaError(); } catch(e) {}
|
| 163 |
+
} else { hls.destroy(); _vtvHls = null; _vtvTryPlay(video, urls, idx+1, name, loadEl, errEl, errMsg); }
|
| 164 |
+
}
|
| 165 |
+
});
|
| 166 |
+
} else if(video.canPlayType('application/vnd.apple.mpegurl')){
|
| 167 |
+
video.src = src;
|
| 168 |
+
video.addEventListener('loadedmetadata', () => { video.play().catch(()=>{}); loadEl.style.display='none'; video.style.display='block'; }, {once:true});
|
| 169 |
+
video.addEventListener('error', () => { _vtvTryPlay(video, urls, idx+1, name, loadEl, errEl, errMsg); }, {once:true});
|
| 170 |
+
} else {
|
| 171 |
+
loadEl.style.display = 'none'; errEl.style.display = 'flex'; errMsg.textContent = 'Trình duyệt không hỗ trợ HLS';
|
| 172 |
+
}
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
// === LOAD HOME ===
|
| 176 |
+
async function loadHome(){
|
| 177 |
+
const[featured,sh,wall,hlLeagues,ai,wcData]=await Promise.all([
|
| 178 |
+
fetch('/api/livescore/featured').then(r=>r.json()).catch(()=>null),
|
| 179 |
+
fetch('/api/shorts').then(r=>r.json()).catch(()=>[]),
|
| 180 |
+
fetch('/api/wall').then(r=>r.json()).catch(()=>({posts:[]})),
|
| 181 |
+
fetch('/api/highlights/leagues').then(r=>r.json()).catch(()=>({})),
|
| 182 |
+
fetch('/api/genk_ai').then(r=>r.json()).catch(()=>[]),
|
| 183 |
+
fetch('/api/wc2026').then(r=>r.json()).catch(()=>null)
|
| 184 |
+
]);
|
| 185 |
+
_hlLeagueData=hlLeagues;
|
| 186 |
+
_wc2026Data=wcData;
|
| 187 |
+
_shortsData=interleaveShorts(sh||[]);
|
| 188 |
+
_wallPosts=(wall&&wall.posts)||[];
|
| 189 |
+
let h='';
|
| 190 |
+
|
| 191 |
+
// ===== VTV BLOCK — rendered inline, no external JS needed =====
|
| 192 |
+
h += buildVTVBlockHTML();
|
| 193 |
+
|
| 194 |
+
if(featured&&featured.home){
|
| 195 |
+
const sc=featured.status==='live'?'':'upcoming';
|
| 196 |
+
const st=featured.status==='live'?`🔴 ${featured.minute||'LIVE'}`:`⏰ ${featured.time}`;
|
| 197 |
+
const eid = String(featured.event_id||'').replace(/[<>&"']/g,'');
|
| 198 |
+
const mUrl = String(featured.url||'').replace(/[<>&"']/g,'');
|
| 199 |
+
const fHome = String(featured.home||'').replace(/[<>&"']/g,'');
|
| 200 |
+
const fAway = String(featured.away||'').replace(/[<>&"']/g,'');
|
| 201 |
+
const fLeague = String(featured.league||'').replace(/[<>&"']/g,'');
|
| 202 |
+
const fScore = String(featured.score||'VS').replace(/[<>&"']/g,'');
|
| 203 |
+
const fHomeLogo = String(featured.home_logo||'').replace(/[<>&"']/g,'');
|
| 204 |
+
const fAwayLogo = String(featured.away_logo||'').replace(/[<>&"']/g,'');
|
| 205 |
+
h+=`<div class="featured-match" data-event-id="${eid}" data-url="${mUrl}" onclick="openMatch('${eid}','${mUrl}')" title="${fHome} vs ${fAway} — ${fLeague}">`+
|
| 206 |
+
`<div class="fm-league">${fLeague}</div>`+
|
| 207 |
+
`<div class="fm-teams">`+
|
| 208 |
+
`<div class="fm-team"><img src="${fHomeLogo}" onerror="this.style.display='none'"><span>${fHome}</span></div>`+
|
| 209 |
+
`<div class="fm-score">${fScore}</div>`+
|
| 210 |
+
`<div class="fm-team"><img src="${fAwayLogo}" onerror="this.style.display='none'"><span>${fAway}</span></div>`+
|
| 211 |
+
`</div>`+
|
| 212 |
+
`<div class="fm-status ${sc}">${st}</div>`+
|
| 213 |
+
`</div>`;
|
| 214 |
+
}
|
| 215 |
+
h+=`<div class="ai-compose"><div class="ai-compose-title">🤖 AI viết bài</div><div class="ai-compose-row"><input id="topic-input" placeholder="Nhập chủ đề..."><button onclick="searchTopic()">Tìm nguồn</button></div><div class="ai-compose-row"><input id="url-input" placeholder="Dán URL bài viết..."><button class="secondary" onclick="rewriteUrl()">Rewrite</button></div><div id="hot-topics" class="hot-topic-row"></div></div>`;
|
| 216 |
+
h+='<div id="hashtag-box"></div>';
|
| 217 |
+
h+=`<div class="ls-section"><div class="ls-header"><h3>⚽ Livescore</h3></div><div class="ls-tabs"><span class="ls-tab active" data-tab="today" onclick="loadLivescore('today')">📅 Hôm nay</span><span class="ls-tab" data-tab="live" onclick="loadLivescore('live')">🔴 Live</span><span class="ls-tab" data-tab="incoming" onclick="loadLivescore('incoming')">⏰ Sắp tới</span><span class="ls-tab" data-tab="results" onclick="loadLivescore('results')">✅ Kết quả</span><span class="ls-tab" data-tab="bxh_nha" onclick="loadLivescore('bxh_nha')">🏆 NHA</span><span class="ls-tab" data-tab="bxh_laliga" onclick="loadLivescore('bxh_laliga')">🏆 La Liga</span></div><div class="ls-content" id="ls-content"><div class="loading">Đang tải...</div></div></div>`;
|
| 218 |
+
h+=`<div id="wc2026-live-section" class="wc2026-section"><div class="wc-header"><h2>🏆 World Cup 2026</h2><span class="wc-live-badge">● LIVE</span></div><div class="wc-tabs"><span class="wc-tab active" onclick="switchWCTab('news')">📰 Tin tức</span><span class="wc-tab" onclick="switchWCTab('fixtures')">📅 Lịch thi đấu</span><span class="wc-tab" onclick="switchWCTab('standings')">🏆 BXH</span><span class="wc-tab" onclick="switchWCTab('highlights')">🎬 Highlight</span><span class="wc-tab" onclick="switchWCTab('stats')">📊 Thống kê</span></div><div class="wc-content" id="wc-content"><div class="loading">Đang tải World Cup 2026...</div></div></div>`;
|
| 219 |
+
const wallPosts=_wallPosts;
|
| 220 |
+
const aiShorts=wallPosts.filter(p=>p.video);
|
| 221 |
+
if(aiShorts.length){h+='<div class="slider-wrap"><div class="slider-header"><span class="slider-label">🎬 Short AI</span></div><div class="slider-track">';aiShorts.slice(0,20).forEach((p,i)=>{h+=`<div class="slider-item shorts-item" onclick="openShortAIFeed(${i})"><div class="slider-thumb shorts-thumb"><video src="${p.video}" muted preload="metadata"></video><div class="card-play">▶</div></div><div class="slider-title">${esc(p.title)}</div></div>`});h+='</div></div>';}
|
| 222 |
+
if(_shortsData.length){h+='<div class="slider-wrap"><div class="slider-header"><span class="slider-label">📱 Shorts Dân trí & SKĐS</span><span class="slider-note">Mới nhất · xen kẽ</span></div><div class="slider-track">';_shortsData.slice(0,30).forEach((a,i)=>{const badge=a.channel==='baosuckhoedoisongboyte'?'SKĐS':'Dân trí';h+=`<div class="slider-item shorts-item" onclick="openYTShortsFeed(${i})"><div class="slider-thumb shorts-thumb">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="slider-title"><span style="color:#f0c040;font-size:8px">${badge}</span> ${esc(a.title)}</div></div>`});h+='</div></div>';}
|
| 223 |
+
if(wallPosts.length){h+=`<div class="slider-wrap" id="ai-wall-wrap"><div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track" id="ai-wall-track">`;wallPosts.slice(0,20).forEach((p,i)=>{h+=makeWallItem(p,i)});h+='</div></div>';}
|
| 224 |
+
const HL_CONFIG={"world-cup":{name:"World Cup 2026",emoji:"🌍"},"premier-league":{name:"Premier League",emoji:"🏴"},"champions-league":{name:"Champions League",emoji:"⭐"},"la-liga":{name:"La Liga",emoji:"🇪🇸"},"serie-a":{name:"Serie A",emoji:"🇮🇹"},"bundesliga":{name:"Bundesliga",emoji:"🇩🇪"},"friendly":{name:"Giao hữu",emoji:"🤝"}};
|
| 225 |
+
for(const[key,cfg] of Object.entries(HL_CONFIG)){const vids=hlLeagues[key];if(!vids||!vids.length)continue;h+=`<div class="slider-wrap"><div class="slider-header"><span class="slider-label">${cfg.emoji} ${cfg.name}</span></div><div class="slider-track">`;vids.slice(0,8).forEach((a,i)=>{h+=`<div class="slider-item" onclick="openHighlightFeed('${key}',${i})"><div class="slider-thumb">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="slider-title">${esc(a.title)}</div></div>`});h+='</div></div>';}
|
| 226 |
+
if(ai&&ai.length){h+='<div class="slider-wrap"><div class="slider-header"><span class="slider-label">🤖 Ứng dụng AI</span></div><div class="slider-track">';ai.slice(0,12).forEach(a=>{h+=`<div class="slider-item" onclick="readArticle('${esc(a.link)}')"><div class="slider-thumb">${a.img?`<img src="${a.img}">`:''}</div><div class="slider-title">${esc(a.title)}</div></div>`});h+='</div></div>';}
|
| 227 |
+
document.getElementById('view-home').innerHTML=h;
|
| 228 |
+
|
| 229 |
+
// Load VTV streams after DOM is ready
|
| 230 |
+
loadVTVStreams().then(() => {
|
| 231 |
+
const tryOrder = ['vtv6','vtv1','vtv2','vtv3','vtv4','vtv5','vtv7','vtv8','vtv9','vtv10'];
|
| 232 |
+
for(const chId of tryOrder){
|
| 233 |
+
if(_vtvStreams[chId] && _vtvStreams[chId].length > 0){
|
| 234 |
+
setTimeout(() => _vtvPlay(chId), 300);
|
| 235 |
+
return;
|
| 236 |
+
}
|
| 237 |
+
}
|
| 238 |
+
});
|
| 239 |
+
|
| 240 |
+
loadLivescore('today');loadHotTopics();
|
| 241 |
+
if(_wc2026Data)switchWCTab('news');
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
// === WALL POST HELPERS ===
|
| 245 |
+
function makeWallItem(p,i){
|
| 246 |
+
const hasVideo = p.video && p.video.length > 0;
|
| 247 |
+
const thumbContent = p.img ? `<img src="${esc(p.img)}" onerror="this.style.display='none'">` : (hasVideo ? `<video src="${esc(p.video)}" muted></video>` : '');
|
| 248 |
+
const videoBadge = hasVideo ? `<div class="wall-video-badge">🎬</div>` : '';
|
| 249 |
+
const videoBtn = hasVideo
|
| 250 |
+
? `<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed(${i})">▶ Xem Short</button>`
|
| 251 |
+
: `<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo('${esc(p.id||i)}',this)">🎬 Tạo Video</button>`;
|
| 252 |
+
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>`;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
async function makeShortVideo(postId, btn, voice, speed){
|
| 256 |
+
if(!postId)return;
|
| 257 |
+
const origText = btn ? btn.textContent : '🎬 Tạo Video';
|
| 258 |
+
if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo...';}
|
| 259 |
+
toast('⏳ Đang tạo video shorts...');
|
| 260 |
+
try{
|
| 261 |
+
let url = '/api/ai/short/'+encodeURIComponent(postId);
|
| 262 |
+
const params = [];
|
| 263 |
+
if(voice) params.push('voice='+encodeURIComponent(voice));
|
| 264 |
+
if(speed) params.push('speed='+encodeURIComponent(speed));
|
| 265 |
+
if(params.length) url += '?' + params.join('&');
|
| 266 |
+
const r = await fetch(url, {method:'POST'});
|
| 267 |
+
const j = await r.json();
|
| 268 |
+
if(!r.ok || j.error) throw new Error(j.error||'Lỗi tạo video');
|
| 269 |
+
toast('✅ Đã tạo video shorts!');
|
| 270 |
+
const p = _wallPosts.find(x => String(x.id) === String(postId));
|
| 271 |
+
if(p){
|
| 272 |
+
p.video = j.video;
|
| 273 |
+
const itemId = 'wall-item-'+postId;
|
| 274 |
+
const el = document.getElementById(itemId);
|
| 275 |
+
if(el){
|
| 276 |
+
const idx = _wallPosts.indexOf(p);
|
| 277 |
+
el.outerHTML = makeWallItem(p, idx);
|
| 278 |
+
const newEl = document.getElementById(itemId);
|
| 279 |
+
if(newEl) newEl.className = 'wall-item wall-item-new';
|
| 280 |
+
}
|
| 281 |
+
}
|
| 282 |
+
refreshShortAISlider();
|
| 283 |
+
}catch(e){
|
| 284 |
+
toast('❌ '+e.message);
|
| 285 |
+
if(btn){btn.disabled=false;btn.textContent=origText;}
|
| 286 |
+
}
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
function refreshShortAISlider(){
|
| 290 |
+
const aiShorts = _wallPosts.filter(p=>p.video);
|
| 291 |
+
let shortAISection = document.getElementById('short-ai-section');
|
| 292 |
+
if(aiShorts.length === 0){ if(shortAISection) shortAISection.remove(); return; }
|
| 293 |
+
if(shortAISection){
|
| 294 |
+
const track = shortAISection.querySelector('.slider-track');
|
| 295 |
+
if(track){
|
| 296 |
+
let h = '';
|
| 297 |
+
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="${esc(p.video)}" muted preload="metadata"></video><div class="card-play">▶</div></div><div class="slider-title">${esc(p.title)}</div></div>`; });
|
| 298 |
+
track.innerHTML = h;
|
| 299 |
+
}
|
| 300 |
+
}
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
function prependWallPost(post){
|
| 304 |
+
_wallPosts.unshift(post);
|
| 305 |
+
const track=document.getElementById('ai-wall-track');
|
| 306 |
+
const wrap=document.getElementById('ai-wall-wrap');
|
| 307 |
+
const homeEl=document.getElementById('view-home');
|
| 308 |
+
if(!track||!wrap){
|
| 309 |
+
if(homeEl){
|
| 310 |
+
let insertBefore=homeEl.querySelector('.slider-wrap');
|
| 311 |
+
const newWrap=document.createElement('div');
|
| 312 |
+
newWrap.className='slider-wrap'; newWrap.id='ai-wall-wrap';
|
| 313 |
+
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>`;
|
| 314 |
+
if(insertBefore){ homeEl.insertBefore(newWrap,insertBefore); }else{ homeEl.appendChild(newWrap); }
|
| 315 |
+
const firstItem=newWrap.querySelector('.wall-item');
|
| 316 |
+
if(firstItem)firstItem.className='wall-item wall-item-new';
|
| 317 |
+
}
|
| 318 |
+
return;
|
| 319 |
+
}
|
| 320 |
+
const div=document.createElement('div');
|
| 321 |
+
div.className='wall-item wall-item-new';
|
| 322 |
+
div.id='wall-item-'+(post.id||'new-'+Date.now());
|
| 323 |
+
const hasVideo = post.video && post.video.length > 0;
|
| 324 |
+
const thumbContent = post.img ? `<img src="${esc(post.img)}" onerror="this.style.display='none'">` : (hasVideo ? `<video src="${esc(post.video)}" muted></video>` : '');
|
| 325 |
+
const videoBadge = hasVideo ? `<div class="wall-video-badge">🎬</div>` : '';
|
| 326 |
+
const videoBtn = hasVideo
|
| 327 |
+
? `<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed(0)">▶ Xem Short</button>`
|
| 328 |
+
: `<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo('${esc(post.id)}',this)">🎬 Tạo Video</button>`;
|
| 329 |
+
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>`;
|
| 330 |
+
track.prepend(div);
|
| 331 |
+
track.scrollTo({left:0,behavior:'smooth'});
|
| 332 |
+
if(hasVideo) refreshShortAISlider();
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
let _shortsData=[];
|
| 336 |
+
let _wallPosts=[];
|
| 337 |
+
let _currentView='home';
|
| 338 |
+
let _currentEventId=null;
|
| 339 |
+
let _currentMatchUrl=null;
|
| 340 |
+
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;}
|
| 341 |
+
let _htPage=0,_htTopic='';
|
| 342 |
+
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);}}
|
| 343 |
+
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);}
|
| 344 |
+
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>`;}}
|
| 345 |
+
function loadMoreHashtag(){_htPage++;const btn=document.getElementById('ht-more');if(btn){btn.disabled=true;btn.textContent='Đang tải...';}showHashtagSources(_htTopic,_htPage);}
|
| 346 |
+
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';}}}
|
| 347 |
+
|
| 348 |
+
// === LIVESCORE — FIXED: use closest() instead of e.target.tagName === 'a' ===
|
| 349 |
+
async function loadLivescore(tab){
|
| 350 |
+
document.querySelectorAll('.ls-tab').forEach(t=>t.classList.remove('active'));
|
| 351 |
+
document.querySelector(`.ls-tab[data-tab="${tab}"]`)?.classList.add('active');
|
| 352 |
+
const el=document.getElementById('ls-content');
|
| 353 |
+
if(!el)return;
|
| 354 |
+
el.innerHTML='<div class="loading">Đang tải...</div>';
|
| 355 |
+
let ep='/api/livescore/'+tab;
|
| 356 |
+
if(tab.startsWith('bxh_'))ep='/api/livescore/standings/'+tab.replace('bxh_','');
|
| 357 |
+
try{
|
| 358 |
+
const r=await fetch(ep);
|
| 359 |
+
const d=await r.json();
|
| 360 |
+
el.innerHTML=d.html&&d.html.length>50?d.html:'<div class="loading">Không có dữ liệu</div>';
|
| 361 |
+
bindMatchClicks(el);
|
| 362 |
+
}catch(e){el.innerHTML='<div class="loading">Lỗi</div>';}
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
function bindMatchClicks(el){
|
| 366 |
+
if(!el) return;
|
| 367 |
+
el.querySelectorAll('.match-detail').forEach(md=>{
|
| 368 |
+
if(md._bound) return;
|
| 369 |
+
md._bound = true;
|
| 370 |
+
md.style.cursor='pointer';
|
| 371 |
+
md.addEventListener('click',function(e){
|
| 372 |
+
// Find the closest anchor with /tran-dau/ — works even when clicking text inside <a>
|
| 373 |
+
const a = e.target.closest('a[href*="/tran-dau/"]');
|
| 374 |
+
if(!a) return; // No match link found, let it be
|
| 375 |
+
e.preventDefault();
|
| 376 |
+
e.stopPropagation();
|
| 377 |
+
const href = a.getAttribute('href') || '';
|
| 378 |
+
const m = href.match(/\/tran-dau\/(\d+)\//);
|
| 379 |
+
if(m){
|
| 380 |
+
const fullUrl = href.startsWith('http') ? href : 'https://bongda.com.vn' + href;
|
| 381 |
+
openMatch(m[1], fullUrl);
|
| 382 |
+
}
|
| 383 |
+
});
|
| 384 |
+
});
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
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')}
|
| 388 |
+
function closeMatch(){document.getElementById('match-overlay').classList.remove('active');document.body.style.overflow=''}
|
| 389 |
+
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>'}}
|
| 390 |
+
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;}}
|
| 391 |
+
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};}}
|
| 392 |
+
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[];}}
|
| 393 |
+
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[];}}
|
| 394 |
+
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>`;}
|
| 395 |
+
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);}}
|
| 396 |
+
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);}}
|
| 397 |
+
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);}
|
| 398 |
+
async function loadCounters(videoIds){for(let i=0;i<videoIds.length;i++){const id=videoIds[i];if(!id)continue;const j=await getInteractions(id);const vc=document.getElementById('vc-'+i);if(vc)vc.textContent=fmtNum(j.views);const lc=document.getElementById('lc-'+i);if(lc)lc.textContent=fmtNum(j.likes);const cc=document.getElementById('cc-'+i);if(cc)cc.textContent=fmtNum(j.comments);}}
|
| 399 |
+
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);}
|
| 400 |
+
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;}
|
| 401 |
+
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);}
|
| 402 |
+
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)}
|
| 403 |
+
async function openHighlightFeed(league,idx){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải...</div>';let articles=(_hlLeagueData||{})[league]||[];if(!articles.length){try{articles=await fetch('/api/highlights/'+league).then(r=>r.json())}catch(e){articles=[]}}if(!articles.length){el.innerHTML='<div class="loading">Không có video</div>';return}const vids=[];const results=await Promise.all(articles.map(async(a,i)=>{try{const r=await fetch('/api/video_url?url='+encodeURIComponent(a.link));const v=await r.json();if(v&&v.src)return{...a,...v,_idx:i}}catch(e){}return null}));results.forEach(r=>{if(r)vids.push(r)});vids.sort((a,b)=>a._idx-b._idx);if(!vids.length){el.innerHTML='<div class="loading">Không tìm thấy video</div>';return}let ti=vids.findIndex(v=>v._idx===idx);if(ti<0)ti=0;const ordered=ti>0?[...vids.slice(ti),...vids.slice(0,ti)]:vids;let h=`<button class="back-btn" onclick="switchCat('home')">← Highlight</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">`;ordered.forEach((v,i)=>{const isYT=v.type==='youtube',isHLS=!isYT&&v.src?.includes('.m3u8'),poster=v.poster?` poster="${v.poster}"`:'';const vtag=isYT?`<iframe data-yt-src="${v.src}" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture"></iframe>`:isHLS?`<video playsinline preload="none"${poster} data-hls="${v.src}" loop controls></video>`:`<video playsinline preload="none"${poster} loop controls><source src="${v.src}" type="video/mp4"></video>`;const videoId='hl-'+league+'-'+(v.id||v._idx);h+=buildTikTokSlide({vtag,title:v.title,badge:'HL',badgeClass:'badge-fpt',videoId,idx:i,total:ordered.length,shareUrl:v.link||'',extraBtn:`<button class="tiktok-right-btn" onclick="event.stopPropagation();this.closest('.tiktok-slide').classList.toggle('ratio-wide')"><div class="icon">⬜</div><div class="count">16:9</div></button>`});});h+='</div></div>';el.innerHTML=h;initTikTokFeed();}
|
| 404 |
+
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();}
|
| 405 |
+
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();}
|
| 406 |
+
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>`;}
|
| 407 |
+
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)}}
|
| 408 |
+
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)}}
|
| 409 |
+
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}}
|
| 410 |
+
async function readWallPost(i){const p=_wallPosts[i];if(!p)return;showView('view-article');
|
| 411 |
+
const images = p.images || [];
|
| 412 |
+
let imgGallery = '';
|
| 413 |
+
if(images.length > 0){
|
| 414 |
+
imgGallery = '<div class="article-image-gallery">';
|
| 415 |
+
images.forEach((imgUrl, idx) => {
|
| 416 |
+
if(idx === 0){ imgGallery += `<img class="article-img article-hero-img" src="${esc(imgUrl)}" onerror="this.style.display='none" loading="eager">`; }
|
| 417 |
+
else { if(idx === 1) imgGallery += '<div class="gallery-thumbs">'; imgGallery += `<div class="gallery-thumb"><img src="${esc(imgUrl)}" onerror="this.parentElement.style.display='none'" loading="lazy"></div>`; }
|
| 418 |
+
});
|
| 419 |
+
if(images.length > 1) imgGallery += '</div>';
|
| 420 |
+
imgGallery += '</div>';
|
| 421 |
+
}
|
| 422 |
+
const hasVideo = p.video && p.video.length > 0;
|
| 423 |
+
const voiceOptions = [{id:'hoaimy',label:'🎙️ Nữ — Hoài My'},{id:'namminh',label:'🎙️ Nam — Nam Minh'}];
|
| 424 |
+
let voiceSelector = '';
|
| 425 |
+
if(!hasVideo){
|
| 426 |
+
voiceSelector = `<div class="tts-selector"><div class="tts-selector-label">🎙️ Chọn giọng đọc:</div><div class="tts-voice-btns">`;
|
| 427 |
+
voiceOptions.forEach(v=>{ 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>`; });
|
| 428 |
+
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>`;
|
| 429 |
+
voiceSelector += `<input type="hidden" id="selected-voice" value="hoaimy"></div>`;
|
| 430 |
+
}
|
| 431 |
+
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>`;
|
| 432 |
+
const firstVoiceBtn = document.querySelector('.tts-voice-btn');
|
| 433 |
+
if(firstVoiceBtn) firstVoiceBtn.classList.add('active');
|
| 434 |
+
window.scrollTo(0,0)}
|
| 435 |
+
async function loadNewsTab(){const el=document.getElementById('view-cat');el.innerHTML='<div class="loading">Đang tải...</div>';try{const r=await fetch('/api/homepage');const news=await r.json();if(!news.length){el.innerHTML='<div class="loading">Không có tin</div>';return}const groups={};news.forEach(a=>{if(!groups[a.group])groups[a.group]=[];groups[a.group].push(a)});let h='';for(const[g,arts] of Object.entries(groups)){h+=`<div class="section-title">${g}</div><div class="grid">`;arts.slice(0,6).forEach(a=>{h+=`<div class="card" onclick="readArticle('${esc(a.link)}')"><div class="card-img">${a.img?`<img src="${a.img}">`:''}</div><div class="card-body"><span class="badge badge-vne">${esc(a.source||'VnE')}</span><div class="card-title">${esc(a.title)}</div></div></div>`});h+='</div>'}el.innerHTML=h}catch(e){el.innerHTML='<div class="loading">Lỗi</div>'}}
|
| 436 |
+
async function loadCat(id){const el=document.getElementById('view-cat');el.innerHTML='<div class="loading">Đang tải...</div>';const arts=await fetch('/api/category/'+id).then(r=>r.json()).catch(()=>[]);if(!arts.length){el.innerHTML='<div class="loading">Không có tin</div>';return}let h='<div class="grid">';arts.forEach(a=>{h+=`<div class="card" onclick="readArticle('${esc(a.link)}')"><div class="card-img">${a.img?`<img src="${a.img}">`:''}</div><div class="card-body"><span class="badge badge-vne">${esc(a.source||'')}</span><div class="card-title">${esc(a.title)}</div></div></div>`});h+='</div>';el.innerHTML=h}
|
| 437 |
+
fetch('/api/storage_status').then(r=>r.json()).then(j=>{if(!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(()=>{});
|
| 438 |
+
|
| 439 |
+
(function(){
|
| 440 |
+
try{
|
| 441 |
+
const pa=localStorage.getItem('pending_article');
|
| 442 |
+
const pv=localStorage.getItem('pending_video');
|
| 443 |
+
if(pa){ localStorage.removeItem('pending_article'); setTimeout(()=>{ if(typeof readArticle==='function') readArticle(pa); },1500); }
|
| 444 |
+
if(pv){ localStorage.removeItem('pending_video'); try{ const v=JSON.parse(pv); if(v&&v.url) setTimeout(()=>{window.open(v.url,'_blank')},1500); }catch(e){} }
|
| 445 |
+
}catch(e){}
|
| 446 |
+
})();
|
static/core_1781056782.js
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// === VNEWS Frontend v2 - Full Functions ===
|
| 2 |
+
// Updated: Voice selector + speed control + image gallery + auto voice detect
|
| 3 |
+
|
| 4 |
+
// === LOAD HOME ===
|
| 5 |
+
async function loadHome(){
|
| 6 |
+
const[featured,sh,wall,hlLeagues,ai,wcData]=await Promise.all([
|
| 7 |
+
fetch('/api/livescore/featured').then(r=>r.json()).catch(()=>null),
|
| 8 |
+
fetch('/api/shorts').then(r=>r.json()).catch(()=>[]),
|
| 9 |
+
fetch('/api/wall').then(r=>r.json()).catch(()=>({posts:[]})),
|
| 10 |
+
fetch('/api/highlights/leagues').then(r=>r.json()).catch(()=>({})),
|
| 11 |
+
fetch('/api/genk_ai').then(r=>r.json()).catch(()=>[]),
|
| 12 |
+
fetch('/api/wc2026').then(r=>r.json()).catch(()=>null)
|
| 13 |
+
]);
|
| 14 |
+
_hlLeagueData=hlLeagues;
|
| 15 |
+
_wc2026Data=wcData;
|
| 16 |
+
_shortsData=interleaveShorts(sh||[]);
|
| 17 |
+
_wallPosts=(wall&&wall.posts)||[];
|
| 18 |
+
let h='';
|
| 19 |
+
if(featured&&featured.home){
|
| 20 |
+
const sc=featured.status==='live'?'':'upcoming';
|
| 21 |
+
const st=featured.status==='live'?`🔴 ${featured.minute||'LIVE'}`:`⏰ ${featured.time}`;
|
| 22 |
+
// Safely encode for HTML attribute: escape quotes, angle brackets, ampersands
|
| 23 |
+
const eid = String(featured.event_id||'').replace(/[<>&"']/g,'');
|
| 24 |
+
const mUrl = String(featured.url||'').replace(/[<>&"']/g,'');
|
| 25 |
+
const fHome = String(featured.home||'').replace(/[<>&"']/g,'');
|
| 26 |
+
const fAway = String(featured.away||'').replace(/[<>&"']/g,'');
|
| 27 |
+
const fLeague = String(featured.league||'').replace(/[<>&"']/g,'');
|
| 28 |
+
const fScore = String(featured.score||'VS').replace(/[<>&"']/g,'');
|
| 29 |
+
const fHomeLogo = String(featured.home_logo||'').replace(/[<>&"']/g,'');
|
| 30 |
+
const fAwayLogo = String(featured.away_logo||'').replace(/[<>&"']/g,'');
|
| 31 |
+
const safeTitle = `${fHome} vs ${fAway} — ${fLeague}`;
|
| 32 |
+
h+=`<div class="featured-match" data-event-id="${eid}" data-url="${mUrl}" onclick="openMatch('${eid}','${mUrl}')" title="${safeTitle}">`+
|
| 33 |
+
`<div class="fm-league">${fLeague}</div>`+
|
| 34 |
+
`<div class="fm-teams">`+
|
| 35 |
+
`<div class="fm-team"><img src="${fHomeLogo}" onerror="this.style.display='none'"><span>${fHome}</span></div>`+
|
| 36 |
+
`<div class="fm-score">${fScore}</div>`+
|
| 37 |
+
`<div class="fm-team"><img src="${fAwayLogo}" onerror="this.style.display='none'"><span>${fAway}</span></div>`+
|
| 38 |
+
`</div>`+
|
| 39 |
+
`<div class="fm-status ${sc}">${st}</div>`+
|
| 40 |
+
`</div>`;
|
| 41 |
+
}
|
| 42 |
+
h+=`<div class="ai-compose"><div class="ai-compose-title">🤖 AI viết bài</div><div class="ai-compose-row"><input id="topic-input" placeholder="Nhập chủ đề..."><button onclick="searchTopic()">Tìm nguồn</button></div><div class="ai-compose-row"><input id="url-input" placeholder="Dán URL bài viết..."><button class="secondary" onclick="rewriteUrl()">Rewrite</button></div><div id="hot-topics" class="hot-topic-row"></div></div>`;
|
| 43 |
+
h+='<div id="hashtag-box"></div>';
|
| 44 |
+
h+=`<div class="ls-section"><div class="ls-header"><h3>⚽ Livescore</h3></div><div class="ls-tabs"><span class="ls-tab active" data-tab="today" onclick="loadLivescore('today')">📅 Hôm nay</span><span class="ls-tab" data-tab="live" onclick="loadLivescore('live')">🔴 Live</span><span class="ls-tab" data-tab="incoming" onclick="loadLivescore('incoming')">⏰ Sắp tới</span><span class="ls-tab" data-tab="results" onclick="loadLivescore('results')">✅ Kết quả</span><span class="ls-tab" data-tab="bxh_nha" onclick="loadLivescore('bxh_nha')">🏆 NHA</span><span class="ls-tab" data-tab="bxh_laliga" onclick="loadLivescore('bxh_laliga')">🏆 La Liga</span></div><div class="ls-content" id="ls-content"><div class="loading">Đang tải...</div></div></div>`;
|
| 45 |
+
h+=`<div id="wc2026-live-section" class="wc2026-section"><div class="wc-header"><h2>🏆 World Cup 2026</h2><span class="wc-live-badge">● LIVE</span></div><div class="wc-tabs"><span class="wc-tab active" onclick="switchWCTab('news')">📰 Tin tức</span><span class="wc-tab" onclick="switchWCTab('fixtures')">📅 Lịch thi đấu</span><span class="wc-tab" onclick="switchWCTab('standings')">🏆 BXH</span><span class="wc-tab" onclick="switchWCTab('highlights')">🎬 Highlight</span><span class="wc-tab" onclick="switchWCTab('stats')">📊 Thống kê</span></div><div class="wc-content" id="wc-content"><div class="loading">Đang tải World Cup 2026...</div></div></div>`;
|
| 46 |
+
const wallPosts=_wallPosts;
|
| 47 |
+
const aiShorts=wallPosts.filter(p=>p.video);
|
| 48 |
+
if(aiShorts.length){h+='<div class="slider-wrap"><div class="slider-header"><span class="slider-label">🎬 Short AI</span></div><div class="slider-track">';aiShorts.slice(0,20).forEach((p,i)=>{h+=`<div class="slider-item shorts-item" onclick="openShortAIFeed(${i})"><div class="slider-thumb shorts-thumb"><video src="${p.video}" muted preload="metadata"></video><div class="card-play">▶</div></div><div class="slider-title">${esc(p.title)}</div></div>`});h+='</div></div>';}
|
| 49 |
+
if(_shortsData.length){h+='<div class="slider-wrap"><div class="slider-header"><span class="slider-label">📱 Shorts Dân trí & SKĐS</span><span class="slider-note">Mới nhất · xen kẽ</span></div><div class="slider-track">';_shortsData.slice(0,30).forEach((a,i)=>{const badge=a.channel==='baosuckhoedoisongboyte'?'SKĐS':'Dân trí';h+=`<div class="slider-item shorts-item" onclick="openYTShortsFeed(${i})"><div class="slider-thumb shorts-thumb">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="slider-title"><span style="color:#f0c040;font-size:8px">${badge}</span> ${esc(a.title)}</div></div>`});h+='</div></div>';}
|
| 50 |
+
if(wallPosts.length){h+=`<div class="slider-wrap" id="ai-wall-wrap"><div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track" id="ai-wall-track">`;wallPosts.slice(0,20).forEach((p,i)=>{h+=makeWallItem(p,i)});h+='</div></div>';}
|
| 51 |
+
const HL_CONFIG={"world-cup":{name:"World Cup 2026",emoji:"🌍"},"premier-league":{name:"Premier League",emoji:"🏴"},"champions-league":{name:"Champions League",emoji:"⭐"},"la-liga":{name:"La Liga",emoji:"🇪🇸"},"serie-a":{name:"Serie A",emoji:"🇮🇹"},"bundesliga":{name:"Bundesliga",emoji:"🇩🇪"},"friendly":{name:"Giao hữu",emoji:"🤝"}};
|
| 52 |
+
for(const[key,cfg] of Object.entries(HL_CONFIG)){const vids=hlLeagues[key];if(!vids||!vids.length)continue;h+=`<div class="slider-wrap"><div class="slider-header"><span class="slider-label">${cfg.emoji} ${cfg.name}</span></div><div class="slider-track">`;vids.slice(0,8).forEach((a,i)=>{h+=`<div class="slider-item" onclick="openHighlightFeed('${key}',${i})"><div class="slider-thumb">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="slider-title">${esc(a.title)}</div></div>`});h+='</div></div>';}
|
| 53 |
+
if(ai&&ai.length){h+='<div class="slider-wrap"><div class="slider-header"><span class="slider-label">🤖 Ứng dụng AI</span></div><div class="slider-track">';ai.slice(0,12).forEach(a=>{h+=`<div class="slider-item" onclick="readArticle('${esc(a.link)}')"><div class="slider-thumb">${a.img?`<img src="${a.img}">`:''}</div><div class="slider-title">${esc(a.title)}</div></div>`});h+='</div></div>';}
|
| 54 |
+
document.getElementById('view-home').innerHTML=h;
|
| 55 |
+
loadLivescore('today');loadHotTopics();
|
| 56 |
+
if(_wc2026Data)switchWCTab('news');
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
// === WALL POST HELPERS ===
|
| 60 |
+
function makeWallItem(p,i){
|
| 61 |
+
const hasVideo = p.video && p.video.length > 0;
|
| 62 |
+
const thumbContent = p.img
|
| 63 |
+
? `<img src="${esc(p.img)}" onerror="this.style.display='none'">`
|
| 64 |
+
: (hasVideo ? `<video src="${esc(p.video)}" muted></video>` : '');
|
| 65 |
+
const videoBadge = hasVideo
|
| 66 |
+
? `<div class="wall-video-badge">🎬</div>`
|
| 67 |
+
: '';
|
| 68 |
+
const videoBtn = hasVideo
|
| 69 |
+
? `<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed(${i})">▶ Xem Short</button>`
|
| 70 |
+
: `<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo('${esc(p.id||i)}',this)">🎬 Tạo Video</button>`;
|
| 71 |
+
|
| 72 |
+
return `<div class="wall-item" id="wall-item-${esc(p.id||i)}">
|
| 73 |
+
<div class="wall-thumb">
|
| 74 |
+
${thumbContent}
|
| 75 |
+
${videoBadge}
|
| 76 |
+
</div>
|
| 77 |
+
<div class="wall-title">${esc(p.title)}</div>
|
| 78 |
+
<div class="wall-text">${esc((p.text||'').slice(0,180))}</div>
|
| 79 |
+
<div class="wall-actions">
|
| 80 |
+
<button class="primary" onclick="readWallPost(${i})">Xem</button>
|
| 81 |
+
${videoBtn}
|
| 82 |
+
</div>
|
| 83 |
+
</div>`;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
// === GENERATE SHORT VIDEO FOR A WALL POST ===
|
| 87 |
+
async function makeShortVideo(postId, btn, voice, speed){
|
| 88 |
+
if(!postId)return;
|
| 89 |
+
const origText = btn ? btn.textContent : '🎬 Tạo Video';
|
| 90 |
+
if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo...';}
|
| 91 |
+
toast('⏳ Đang tạo video shorts...');
|
| 92 |
+
try{
|
| 93 |
+
let url = '/api/ai/short/'+encodeURIComponent(postId);
|
| 94 |
+
const params = [];
|
| 95 |
+
if(voice) params.push('voice='+encodeURIComponent(voice));
|
| 96 |
+
if(speed) params.push('speed='+encodeURIComponent(speed));
|
| 97 |
+
if(params.length) url += '?' + params.join('&');
|
| 98 |
+
const r = await fetch(url, {method:'POST'});
|
| 99 |
+
const j = await r.json();
|
| 100 |
+
if(!r.ok || j.error) throw new Error(j.error||'Lỗi tạo video');
|
| 101 |
+
toast('✅ Đã tạo video shorts!');
|
| 102 |
+
const p = _wallPosts.find(x => String(x.id) === String(postId));
|
| 103 |
+
if(p){
|
| 104 |
+
p.video = j.video;
|
| 105 |
+
const itemId = 'wall-item-'+postId;
|
| 106 |
+
const el = document.getElementById(itemId);
|
| 107 |
+
if(el){
|
| 108 |
+
const idx = _wallPosts.indexOf(p);
|
| 109 |
+
el.outerHTML = makeWallItem(p, idx);
|
| 110 |
+
const newEl = document.getElementById(itemId);
|
| 111 |
+
if(newEl) newEl.className = 'wall-item wall-item-new';
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
refreshShortAISlider();
|
| 115 |
+
}catch(e){
|
| 116 |
+
toast('❌ '+e.message);
|
| 117 |
+
if(btn){btn.disabled=false;btn.textContent=origText;}
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
// Refresh Short AI slider after video generation
|
| 122 |
+
function refreshShortAISlider(){
|
| 123 |
+
const aiShorts = _wallPosts.filter(p=>p.video);
|
| 124 |
+
let shortAISection = document.getElementById('short-ai-section');
|
| 125 |
+
if(aiShorts.length === 0){
|
| 126 |
+
if(shortAISection) shortAISection.remove();
|
| 127 |
+
return;
|
| 128 |
+
}
|
| 129 |
+
if(shortAISection){
|
| 130 |
+
const track = shortAISection.querySelector('.slider-track');
|
| 131 |
+
if(track){
|
| 132 |
+
let h = '';
|
| 133 |
+
aiShorts.slice(0,20).forEach((p,i)=>{
|
| 134 |
+
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>`;
|
| 135 |
+
});
|
| 136 |
+
track.innerHTML = h;
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
function prependWallPost(post){
|
| 142 |
+
_wallPosts.unshift(post);
|
| 143 |
+
const track=document.getElementById('ai-wall-track');
|
| 144 |
+
const wrap=document.getElementById('ai-wall-wrap');
|
| 145 |
+
const homeEl=document.getElementById('view-home');
|
| 146 |
+
if(!track||!wrap){
|
| 147 |
+
if(homeEl){
|
| 148 |
+
let insertBefore=homeEl.querySelector('.slider-wrap');
|
| 149 |
+
const newWrap=document.createElement('div');
|
| 150 |
+
newWrap.className='slider-wrap';
|
| 151 |
+
newWrap.id='ai-wall-wrap';
|
| 152 |
+
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>`;
|
| 153 |
+
if(insertBefore){
|
| 154 |
+
homeEl.insertBefore(newWrap,insertBefore);
|
| 155 |
+
}else{
|
| 156 |
+
homeEl.appendChild(newWrap);
|
| 157 |
+
}
|
| 158 |
+
const firstItem=newWrap.querySelector('.wall-item');
|
| 159 |
+
if(firstItem)firstItem.className='wall-item wall-item-new';
|
| 160 |
+
}
|
| 161 |
+
return;
|
| 162 |
+
}
|
| 163 |
+
const div=document.createElement('div');
|
| 164 |
+
div.className='wall-item wall-item-new';
|
| 165 |
+
div.id='wall-item-'+(post.id||'new-'+Date.now());
|
| 166 |
+
const hasVideo = post.video && post.video.length > 0;
|
| 167 |
+
const thumbContent = post.img
|
| 168 |
+
? `<img src="${esc(post.img)}" onerror="this.style.display='none'">`
|
| 169 |
+
: (hasVideo ? `<video src="${esc(post.video)}" muted></video>` : '');
|
| 170 |
+
const videoBadge = hasVideo ? `<div class="wall-video-badge">🎬</div>` : '';
|
| 171 |
+
const videoBtn = hasVideo
|
| 172 |
+
? `<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed(0)">▶ Xem Short</button>`
|
| 173 |
+
: `<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo('${esc(post.id)}',this)">🎬 Tạo Video</button>`;
|
| 174 |
+
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>`;
|
| 175 |
+
track.prepend(div);
|
| 176 |
+
track.scrollTo({left:0,behavior:'smooth'});
|
| 177 |
+
if(hasVideo) refreshShortAISlider();
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
// === REST OF FUNCTIONS ===
|
| 181 |
+
let _shortsData=[];
|
| 182 |
+
let _wallPosts=[];
|
| 183 |
+
let _currentView='home';
|
| 184 |
+
let _currentEventId=null;
|
| 185 |
+
let _currentMatchUrl=null;
|
| 186 |
+
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;}
|
| 187 |
+
let _htPage=0,_htTopic='';
|
| 188 |
+
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);}}
|
| 189 |
+
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);}
|
| 190 |
+
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>`;}}
|
| 191 |
+
function loadMoreHashtag(){_htPage++;const btn=document.getElementById('ht-more');if(btn){btn.disabled=true;btn.textContent='Đang tải...';}showHashtagSources(_htTopic,_htPage);}
|
| 192 |
+
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';}}}
|
| 193 |
+
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>';}}
|
| 194 |
+
function bindMatchClicks(el){
|
| 195 |
+
if(!el) return;
|
| 196 |
+
el.querySelectorAll('.match-detail').forEach(md=>{
|
| 197 |
+
md.style.cursor='pointer';
|
| 198 |
+
// Remove old listeners to avoid duplicates (mark as bound)
|
| 199 |
+
if(md._bound) return;
|
| 200 |
+
md._bound = true;
|
| 201 |
+
md.addEventListener('click',function(e){
|
| 202 |
+
// Don't intercept clicks on interactive elements inside the row
|
| 203 |
+
const tag = e.target.tagName?.toLowerCase();
|
| 204 |
+
if(tag === 'a' || tag === 'button' || tag === 'input') {
|
| 205 |
+
e.preventDefault();
|
| 206 |
+
e.stopPropagation();
|
| 207 |
+
}
|
| 208 |
+
// Find ANY link with /tran-dau/ inside this match-detail row
|
| 209 |
+
const links = this.querySelectorAll('a[href*="/tran-dau/"]');
|
| 210 |
+
let bestA = null;
|
| 211 |
+
links.forEach(a => {
|
| 212 |
+
const href = a.getAttribute('href') || '';
|
| 213 |
+
// Prefer links with both event_id AND slug (fuller URL)
|
| 214 |
+
if(href.match(/\/tran-dau\/\d+\/(centre|preview|quan-cau|video)\//)) {
|
| 215 |
+
bestA = a;
|
| 216 |
+
} else if(!bestA && href.match(/\/tran-dau\/\d+\//)) {
|
| 217 |
+
bestA = a;
|
| 218 |
+
}
|
| 219 |
+
});
|
| 220 |
+
if(!bestA) return;
|
| 221 |
+
e.preventDefault();
|
| 222 |
+
e.stopPropagation();
|
| 223 |
+
const href = bestA.getAttribute('href') || '';
|
| 224 |
+
const m = href.match(/\/tran-dau\/(\d+)\//);
|
| 225 |
+
if(m){
|
| 226 |
+
const fullUrl = href.startsWith('http') ? href : 'https://bongda.com.vn' + href;
|
| 227 |
+
openMatch(m[1], fullUrl);
|
| 228 |
+
}
|
| 229 |
+
});
|
| 230 |
+
});
|
| 231 |
+
// Prevent default navigation on all links inside livescore (but let match-detail click handler work)
|
| 232 |
+
el.querySelectorAll('a').forEach(a=>{
|
| 233 |
+
a.addEventListener('click',e=>{
|
| 234 |
+
e.preventDefault();
|
| 235 |
+
e.stopPropagation();
|
| 236 |
+
});
|
| 237 |
+
});
|
| 238 |
+
}
|
| 239 |
+
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')}
|
| 240 |
+
function closeMatch(){document.getElementById('match-overlay').classList.remove('active');document.body.style.overflow=''}
|
| 241 |
+
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>'}}
|
| 242 |
+
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;}}
|
| 243 |
+
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};}}
|
| 244 |
+
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[];}}
|
| 245 |
+
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[];}}
|
| 246 |
+
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>`;}
|
| 247 |
+
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);}}
|
| 248 |
+
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);}}
|
| 249 |
+
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);}
|
| 250 |
+
async function loadCounters(videoIds){for(let i=0;i<videoIds.length;i++){const id=videoIds[i];if(!id)continue;const j=await getInteractions(id);const vc=document.getElementById('vc-'+i);if(vc)vc.textContent=fmtNum(j.views);const lc=document.getElementById('lc-'+i);if(lc)lc.textContent=fmtNum(j.likes);const cc=document.getElementById('cc-'+i);if(cc)cc.textContent=fmtNum(j.comments);}}
|
| 251 |
+
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);}
|
| 252 |
+
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;}
|
| 253 |
+
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);}
|
| 254 |
+
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)}
|
| 255 |
+
async function openHighlightFeed(league,idx){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải...</div>';let articles=(_hlLeagueData||{})[league]||[];if(!articles.length){try{articles=await fetch('/api/highlights/'+league).then(r=>r.json())}catch(e){articles=[]}}if(!articles.length){el.innerHTML='<div class="loading">Không có video</div>';return}const vids=[];const results=await Promise.all(articles.map(async(a,i)=>{try{const r=await fetch('/api/video_url?url='+encodeURIComponent(a.link));const v=await r.json();if(v&&v.src)return{...a,...v,_idx:i}}catch(e){}return null}));results.forEach(r=>{if(r)vids.push(r)});vids.sort((a,b)=>a._idx-b._idx);if(!vids.length){el.innerHTML='<div class="loading">Không tìm thấy video</div>';return}let ti=vids.findIndex(v=>v._idx===idx);if(ti<0)ti=0;const ordered=ti>0?[...vids.slice(ti),...vids.slice(0,ti)]:vids;let h=`<button class="back-btn" onclick="switchCat('home')">← Highlight</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">`;ordered.forEach((v,i)=>{const isYT=v.type==='youtube',isHLS=!isYT&&v.src?.includes('.m3u8'),poster=v.poster?` poster="${v.poster}"`:'';const vtag=isYT?`<iframe data-yt-src="${v.src}" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture"></iframe>`:isHLS?`<video playsinline preload="none"${poster} data-hls="${v.src}" loop controls></video>`:`<video playsinline preload="none"${poster} loop controls><source src="${v.src}" type="video/mp4"></video>`;const videoId='hl-'+league+'-'+(v.id||v._idx);h+=buildTikTokSlide({vtag,title:v.title,badge:'HL',badgeClass:'badge-fpt',videoId,idx:i,total:ordered.length,shareUrl:v.link||'',extraBtn:`<button class="tiktok-right-btn" onclick="event.stopPropagation();this.closest('.tiktok-slide').classList.toggle('ratio-wide')"><div class="icon">⬜</div><div class="count">16:9</div></button>`});});h+='</div></div>';el.innerHTML=h;initTikTokFeed();}
|
| 256 |
+
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();}
|
| 257 |
+
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();}
|
| 258 |
+
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>`;}
|
| 259 |
+
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)}}
|
| 260 |
+
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)}}
|
| 261 |
+
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}}
|
| 262 |
+
async function readWallPost(i){const p=_wallPosts[i];if(!p)return;showView('view-article');
|
| 263 |
+
const images = p.images || [];
|
| 264 |
+
let imgGallery = '';
|
| 265 |
+
if(images.length > 0){
|
| 266 |
+
imgGallery = '<div class="article-image-gallery">';
|
| 267 |
+
images.forEach((imgUrl, idx) => {
|
| 268 |
+
if(idx === 0){
|
| 269 |
+
imgGallery += `<img class="article-img article-hero-img" src="${esc(imgUrl)}" onerror="this.style.display='none" loading="eager">`;
|
| 270 |
+
} else {
|
| 271 |
+
if(idx === 1) imgGallery += '<div class="gallery-thumbs">';
|
| 272 |
+
imgGallery += `<div class="gallery-thumb"><img src="${esc(imgUrl)}" onerror="this.parentElement.style.display='none'" loading="lazy"></div>`;
|
| 273 |
+
}
|
| 274 |
+
});
|
| 275 |
+
if(images.length > 1) imgGallery += '</div>';
|
| 276 |
+
imgGallery += '</div>';
|
| 277 |
+
}
|
| 278 |
+
const hasVideo = p.video && p.video.length > 0;
|
| 279 |
+
const voiceOptions = [
|
| 280 |
+
{id:'hoaimy', label:'🎙️ Nữ — Hoài My'},
|
| 281 |
+
{id:'namminh', label:'🎙️ Nam — Nam Minh'},
|
| 282 |
+
];
|
| 283 |
+
let voiceSelector = '';
|
| 284 |
+
if(!hasVideo){
|
| 285 |
+
voiceSelector = `<div class="tts-selector"><div class="tts-selector-label">🎙️ Chọn giọng đọc:</div><div class="tts-voice-btns">`;
|
| 286 |
+
voiceOptions.forEach(v=>{
|
| 287 |
+
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>`;
|
| 288 |
+
});
|
| 289 |
+
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>`;
|
| 290 |
+
voiceSelector += `<input type="hidden" id="selected-voice" value="hoaimy"></div>`;
|
| 291 |
+
}
|
| 292 |
+
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>`;
|
| 293 |
+
const firstVoiceBtn = document.querySelector('.tts-voice-btn');
|
| 294 |
+
if(firstVoiceBtn) firstVoiceBtn.classList.add('active');
|
| 295 |
+
window.scrollTo(0,0)}
|
| 296 |
+
async function loadNewsTab(){const el=document.getElementById('view-cat');el.innerHTML='<div class="loading">Đang tải...</div>';try{const r=await fetch('/api/homepage');const news=await r.json();if(!news.length){el.innerHTML='<div class="loading">Không có tin</div>';return}const groups={};news.forEach(a=>{if(!groups[a.group])groups[a.group]=[];groups[a.group].push(a)});let h='';for(const[g,arts] of Object.entries(groups)){h+=`<div class="section-title">${g}</div><div class="grid">`;arts.slice(0,6).forEach(a=>{h+=`<div class="card" onclick="readArticle('${esc(a.link)}')"><div class="card-img">${a.img?`<img src="${a.img}">`:''}</div><div class="card-body"><span class="badge badge-vne">${esc(a.source||'VnE')}</span><div class="card-title">${esc(a.title)}</div></div></div>`});h+='</div>'}el.innerHTML=h}catch(e){el.innerHTML='<div class="loading">Lỗi</div>'}}
|
| 297 |
+
async function loadCat(id){const el=document.getElementById('view-cat');el.innerHTML='<div class="loading">Đang tải...</div>';const arts=await fetch('/api/category/'+id).then(r=>r.json()).catch(()=>[]);if(!arts.length){el.innerHTML='<div class="loading">Không có tin</div>';return}let h='<div class="grid">';arts.forEach(a=>{h+=`<div class="card" onclick="readArticle('${esc(a.link)}')"><div class="card-img">${a.img?`<img src="${a.img}">`:''}</div><div class="card-body"><span class="badge badge-vne">${esc(a.source||'')}</span><div class="card-title">${esc(a.title)}</div></div></div>`});h+='</div>';el.innerHTML=h}
|
| 298 |
+
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(()=>{});
|
| 299 |
+
|
| 300 |
+
// === AUTO-OPEN SHARE LINKS (/s?url=... sets pending_article) ===
|
| 301 |
+
(function(){
|
| 302 |
+
try{
|
| 303 |
+
const pa=localStorage.getItem('pending_article');
|
| 304 |
+
const pv=localStorage.getItem('pending_video');
|
| 305 |
+
if(pa){
|
| 306 |
+
localStorage.removeItem('pending_article');
|
| 307 |
+
setTimeout(()=>{
|
| 308 |
+
if(typeof readArticle==='function') readArticle(pa);
|
| 309 |
+
},1500);
|
| 310 |
+
}
|
| 311 |
+
if(pv){
|
| 312 |
+
localStorage.removeItem('pending_video');
|
| 313 |
+
try{
|
| 314 |
+
const v=JSON.parse(pv);
|
| 315 |
+
if(v&&v.url) setTimeout(()=>{window.open(v.url,'_blank')},1500);
|
| 316 |
+
}catch(e){}
|
| 317 |
+
}
|
| 318 |
+
}catch(e){}
|
| 319 |
+
})();
|
static/fm_fix.css
CHANGED
|
@@ -31,76 +31,6 @@
|
|
| 31 |
@keyframes ht-spin{to{transform:rotate(360deg)}}
|
| 32 |
.wall-img-count{position:absolute;bottom:4px;left:4px;background:rgba(0,0,0,.7);color:#fff;font-size:9px;padding:1px 5px;border-radius:4px}
|
| 33 |
#progress-toast{position:fixed!important;bottom:70px!important;left:50%!important;transform:translateX(-50%)!important;background:#2d8659!important;color:#fff!important;padding:10px 20px!important;border-radius:20px!important;font-size:12px!important;z-index:99998!important;box-shadow:0 4px 12px rgba(0,0,0,.4)!important;display:none;white-space:nowrap!important}
|
| 34 |
-
/* === ARTICLE / REWRITE VIEW FIX === *//* === WORLD CUP 2026 SECTION === */
|
| 35 |
-
.wc-section{margin:8px 4px!important;background:#1a1a1a!important;border:1px solid #2a2a2a!important;border-radius:10px!important;overflow:hidden!important}
|
| 36 |
-
.wc-header{padding:10px 12px!important;background:linear-gradient(135deg,#0d1b2b,#1a3a2a 50%,#0b4b8b)!important;display:flex!important;align-items:center!important;justify-content:space-between!important}
|
| 37 |
-
.wc-title{font-size:14px!important;font-weight:800!important;color:#fff!important}
|
| 38 |
-
.wc-tabs{display:flex!important;gap:4px!important;padding:8px 10px!important;background:#1a1a1a!important;overflow-x:auto!important;scrollbar-width:none!important;border-bottom:1px solid #2a2a2a!important}
|
| 39 |
-
.wc-tabs::-webkit-scrollbar{display:none!important}
|
| 40 |
-
.wc-tab{padding:6px 12px!important;background:#222!important;border:1px solid #333!important;border-radius:12px!important;color:#999!important;font-size:10px!important;white-space:nowrap!important;cursor:pointer!important;flex-shrink:0!important}
|
| 41 |
-
.wc-tab.active{background:#0b6bcb!important;border-color:#0b6bcb!important;color:#fff!important;font-weight:700!important}
|
| 42 |
-
.wc-tab-content{padding:10px!important;max-height:500px!important;overflow-y:auto!important}
|
| 43 |
-
.wc-news-list{display:flex!important;flex-direction:column!important;gap:8px!important}
|
| 44 |
-
.wc-news-item{display:flex!important;gap:10px!important;padding:8px!important;background:#202020!important;border-radius:8px!important;cursor:pointer!important}
|
| 45 |
-
.wc-news-item:hover{background:#2a2a2a!important}
|
| 46 |
-
.wc-news-img{flex:0 0 90px!important;aspect-ratio:16/9!important;background:#333!important;border-radius:6px!important;overflow:hidden!important}
|
| 47 |
-
.wc-news-img img{width:100%!important;height:100%!important;object-fit:cover!important}
|
| 48 |
-
.wc-news-text{flex:1!important;min-width:0!important;display:flex!important;flex-direction:column!important;justify-content:space-between!important}
|
| 49 |
-
.wc-news-title{font-size:12px!important;font-weight:700!important;color:#eee!important;display:-webkit-box!important;-webkit-line-clamp:2!important;-webkit-box-orient:vertical!important;overflow:hidden!important}
|
| 50 |
-
.wc-news-source{font-size:10px!important;color:#888!important;margin-top:4px!important}
|
| 51 |
-
|
| 52 |
-
/* World Cup Fixtures */
|
| 53 |
-
.wc-fixtures-list{display:flex!important;flex-direction:column!important;gap:6px!important}
|
| 54 |
-
.wc-match-item{padding:10px!important;background:#202020!important;border-radius:8px!important;border-left:3px solid #333!important}
|
| 55 |
-
.wc-match-item:nth-child(odd){background:#1e1e1e!important}
|
| 56 |
-
.wc-match-date{font-size:10px!important;color:#888!important;margin-bottom:4px!important}
|
| 57 |
-
.wc-match-teams{display:flex!important;align-items:center!important;justify-content:center!important;gap:12px!important}
|
| 58 |
-
.wc-match-teams .wc-team{flex:1!important;font-size:12px!important;color:#ddd!important;text-align:center!important}
|
| 59 |
-
.wc-match-teams .wc-score{font-size:16px!important;font-weight:900!important;color:#f0c040!important;min-width:60px!important;text-align:center!important}
|
| 60 |
-
.wc-score.wc-live{color:#e74c3c!important}
|
| 61 |
-
.wc-score.wc-finished{color:#888!important}
|
| 62 |
-
.wc-score.wc-upcoming{color:#5cb87a!important}
|
| 63 |
-
.wc-match-location{font-size:9px!important;color:#777!important;text-align:center!important;margin-top:4px!important}
|
| 64 |
-
|
| 65 |
-
/* World Cup Standings & Stats Tables */
|
| 66 |
-
.wc-standings-table,.wc-stats-table{font-size:11px!important;color:#ccc!important;overflow-x:auto!important;width:100%!important}
|
| 67 |
-
.wc-standings-table table,.wc-stats-table table{width:100%!important;border-collapse:collapse!important;table-layout:fixed!important}
|
| 68 |
-
.wc-standings-table th,.wc-stats-table th{background:#222!important;color:#999!important;padding:6px 4px!important;font-size:10px!important;border-bottom:1px solid #333!important;text-align:center!important}
|
| 69 |
-
.wc-standings-table th:first-child,.wc-stats-table th:first-child{text-align:left!important}
|
| 70 |
-
.wc-standings-table td,.wc-stats-table td{padding:5px 4px!important;border-bottom:1px solid #1a1a1a!important;text-align:center!important;font-size:11px!important}
|
| 71 |
-
.wc-standings-table td:first-child,.wc-stats-table td:first-child{text-align:left!important}
|
| 72 |
-
.wc-standings-table .pts,.wc-stats-table .pts{font-weight:800!important;color:#f0c040!important}
|
| 73 |
-
.wc-standings-table .team-name,.wc-stats-table .team-name{display:flex!important;align-items:center!important;gap:4px!important}
|
| 74 |
-
.wc-standings-table .team-name img,.wc-stats-table .team-name img{width:16px!important;height:16px!important;object-fit:contain!important}
|
| 75 |
-
|
| 76 |
-
/* World Cup Highlights */
|
| 77 |
-
.wc-highlights-grid{display:grid!important;grid-template-columns:repeat(2,1fr)!important;gap:8px!important}/* World Cup Stats */
|
| 78 |
-
.wc-stats{padding:10px!important}
|
| 79 |
-
.wc-stat-group{margin-bottom:16px!important;padding:10px!important;background:#202020!important;border-radius:8px!important}
|
| 80 |
-
.wc-stat-group h4{font-size:12px!important;color:#5cb87a!important;margin-bottom:8px!important;font-weight:700!important}
|
| 81 |
-
.wc-stat-group .wc-empty{color:#888!important;font-size:11px!important;font-style:italic!important}
|
| 82 |
-
.wc-stat-group table{width:100%!important;border-collapse:collapse!important}
|
| 83 |
-
.wc-stat-group th{background:#2a2a2a!important;color:#999!important;padding:6px 4px!important;font-size:10px!important;text-align:left!important;border-bottom:1px solid #333!important}
|
| 84 |
-
.wc-stat-group td{padding:5px 4px!important;border-bottom:1px solid #1a1a1a!important;font-size:11px!important;color:#ccc!important}
|
| 85 |
-
|
| 86 |
-
/* World Cup Group Standings */
|
| 87 |
-
.wc-group{margin-bottom:16px!important}
|
| 88 |
-
.wc-group h4{font-size:12px!important;color:#f0c040!important;margin-bottom:6px!important;padding:6px 8px!important;background:#222!important;border-radius:6px!important;font-weight:700!important}
|
| 89 |
-
.wc-table{width:100%!important;border-collapse:collapse!important;font-size:11px!important}
|
| 90 |
-
.wc-table th{background:#2a2a2a!important;color:#999!important;padding:6px 4px!important;font-size:10px!important;text-align:center!important;border-bottom:1px solid #333!important}
|
| 91 |
-
.wc-table th:first-child{text-align:left!important}
|
| 92 |
-
.wc-table td{padding:5px 4px!important;border-bottom:1px solid #1a1a1a!important;text-align:center!important;color:#ccc!important}
|
| 93 |
-
.wc-table td:first-child{text-align:left!important;font-weight:600!important}
|
| 94 |
-
.wc-table .pts{font-weight:800!important;color:#f0c040!important}
|
| 95 |
-
|
| 96 |
-
/* World Cup Highlights */
|
| 97 |
-
.wc-highlights-grid{display:grid!important;grid-template-columns:repeat(2,1fr)!important;gap:8px!important}
|
| 98 |
-
.wc-hl-item{cursor:pointer!important}
|
| 99 |
-
.wc-hl-thumb{position:relative!important;aspect-ratio:16/9!important;background:#333!important;border-radius:6px!important;overflow:hidden!important}
|
| 100 |
-
.wc-hl-thumb img{width:100%!important;height:100%!important;object-fit:cover!important}
|
| 101 |
-
.wc-hl-thumb .card-play{position:absolute!important;left:50%!important;top:50%!important;transform:translate(-50%,-50%)!important;width:30px!important;height:30px!important;border-radius:50%!important;background:rgba(0,0,0,.55)!important;display:flex!important;align-items:center!important;justify-content:center!important;color:#fff!important;font-size:12px!important}
|
| 102 |
-
.wc-hl-title{font-size:10px!important;color:#ccc!important;margin-top:4px!important;line-height:1.2!important;display:-webkit-box!important;-webkit-line-clamp:2!important;-webkit-box-orient:vertical!important;overflow:hidden!important}
|
| 103 |
-
|
| 104 |
/* === ARTICLE / REWRITE VIEW FIX === */
|
| 105 |
.article-view{padding:12px 8px 40px!important;max-width:760px!important;margin:0 auto!important}
|
| 106 |
.article-title{font-size:18px!important;font-weight:800!important;line-height:1.3!important;margin-bottom:8px!important;color:#fff!important}
|
|
|
|
| 31 |
@keyframes ht-spin{to{transform:rotate(360deg)}}
|
| 32 |
.wall-img-count{position:absolute;bottom:4px;left:4px;background:rgba(0,0,0,.7);color:#fff;font-size:9px;padding:1px 5px;border-radius:4px}
|
| 33 |
#progress-toast{position:fixed!important;bottom:70px!important;left:50%!important;transform:translateX(-50%)!important;background:#2d8659!important;color:#fff!important;padding:10px 20px!important;border-radius:20px!important;font-size:12px!important;z-index:99998!important;box-shadow:0 4px 12px rgba(0,0,0,.4)!important;display:none;white-space:nowrap!important}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
/* === ARTICLE / REWRITE VIEW FIX === */
|
| 35 |
.article-view{padding:12px 8px 40px!important;max-width:760px!important;margin:0 auto!important}
|
| 36 |
.article-title{font-size:18px!important;font-weight:800!important;line-height:1.3!important;margin-bottom:8px!important;color:#fff!important}
|
static/hot_multi.js
CHANGED
|
@@ -1,35 +1,17 @@
|
|
| 1 |
// === OVERRIDE: loadHotTopics loads from MULTIPLE hashtags ===
|
| 2 |
-
// v3: 5 topics (AI Thế Giới + AI Việt Nam + top 3 hot) + lazy load more
|
| 3 |
// This file loaded AFTER app_v2.js, overrides the function
|
| 4 |
|
| 5 |
-
const HT_AI_TOPICS = ['AI Thế Giới', 'AI Việt Nam'];
|
| 6 |
-
let _htAllSources = [];
|
| 7 |
-
let _htTopics = [];
|
| 8 |
-
let _htPages = 0;
|
| 9 |
-
let _htDates = [];
|
| 10 |
-
|
| 11 |
async function loadHotTopics(){
|
| 12 |
const j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));
|
| 13 |
const el=document.getElementById('hot-topics');if(!el)return;
|
| 14 |
const topics=j.topics||[];
|
| 15 |
-
|
| 16 |
-
// Prepend AI topics that aren't already in the list
|
| 17 |
-
const prepend = HT_AI_TOPICS.filter(ai =>
|
| 18 |
-
!topics.some(t => (t.topic || '').toLowerCase().includes(ai.toLowerCase()))
|
| 19 |
-
).map(ai => ({label: '#' + ai.replace(/\s+/g, ''), topic: ai, count: 0}));
|
| 20 |
-
|
| 21 |
-
const finalTopics = [...prepend, ...topics].slice(0, 18);
|
| 22 |
-
|
| 23 |
-
el.innerHTML=finalTopics.map(t=>{
|
| 24 |
const topicText=t.topic||t.label.replace(/^#/,'');
|
| 25 |
return`<button class="hot-chip" onclick="searchTopic('${topicText.replace(/'/g,"\\'")}')">${esc(t.label)}</button>`;
|
| 26 |
}).join('');
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
if(top5.length>=2){
|
| 31 |
-
_htTopics = top5.map(t=>t.topic||t.label.replace(/^#/,''));
|
| 32 |
-
loadMultiHashtag(_htTopics);
|
| 33 |
}else if(topics.length){
|
| 34 |
searchTopic(topics[0].topic||topics[0].label.replace(/^#/,''));
|
| 35 |
}
|
|
@@ -37,9 +19,6 @@ async function loadHotTopics(){
|
|
| 37 |
|
| 38 |
async function loadMultiHashtag(topicList){
|
| 39 |
const box=document.getElementById('hashtag-box');if(!box)return;
|
| 40 |
-
_htAllSources = [];
|
| 41 |
-
_htPages = 0;
|
| 42 |
-
|
| 43 |
box.innerHTML=`<div class="hashtag-sources"><h3>🔥 Tin HOT tổng hợp</h3><div class="hashtag-loading"><div class="hashtag-spinner"></div>Tổng hợp từ ${topicList.length} chủ đề nóng nhất...</div></div>`;
|
| 44 |
try{
|
| 45 |
const results=await Promise.all(topicList.map(topic=>
|
|
@@ -54,70 +33,17 @@ async function loadMultiHashtag(topicList){
|
|
| 54 |
if(src&&src.url&&!seen.has(src.url)){seen.add(src.url);src._topic=topicList[j];all.push(src);}
|
| 55 |
}
|
| 56 |
}
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
}catch(e){box.innerHTML=`<div class="hashtag-sources"><h3>🔥 Tin HOT</h3><div style="color:#e74c3c;padding:8px">Lỗi tải tin</div></div>`;}
|
| 60 |
}
|
| 61 |
-
|
| 62 |
-
function _renderHT(){
|
| 63 |
-
const box=document.getElementById('hashtag-box');if(!box)return;
|
| 64 |
-
const all = _htAllSources;
|
| 65 |
-
if(!all.length){box.innerHTML=`<div class="hashtag-sources"><h3>🔥 Tin HOT</h3><div style="color:#888;padding:8px">Đang cập nhật...</div></div>`;return;}
|
| 66 |
-
|
| 67 |
-
const perPage = 12;
|
| 68 |
-
const total = all.length;
|
| 69 |
-
const shown = Math.min((_htPages + 1) * perPage, total);
|
| 70 |
-
const visible = all.slice(0, shown);
|
| 71 |
-
|
| 72 |
-
let h=`<div class="hashtag-sources"><h3>🔥 Tin HOT tổng hợp <span style="font-size:10px;color:#888">(${total} bài · ${_htTopics.length} chủ đề)</span></h3><div id="ht-list">`;
|
| 73 |
-
visible.forEach((s,i)=>{
|
| 74 |
-
const dateStr = _htDates[i] ? `<span style="font-size:9px;color:#666;margin-left:4px">${esc(_htDates[i])}</span>` : '';
|
| 75 |
-
h+=`<div class="hashtag-src-item" onclick="readArticle('${esc(s.url)}')"><div class="hashtag-src-img" id="ht-img-${i}"></div><div class="hashtag-src-text"><div class="hashtag-src-title">${esc(s.title)}</div><div class="hashtag-src-via">${esc(s.via||'')}${dateStr} · <span style="color:#f0c040;font-size:9px">#${esc(s._topic||'')}</span></div></div></div>`;
|
| 76 |
-
});
|
| 77 |
-
h+=`</div><div style="display:flex;gap:4px;flex-wrap:wrap;margin-top:8px">`;
|
| 78 |
-
_htTopics.forEach(t=>{h+=`<button class="hot-chip" onclick="searchTopic('${t.replace(/'/g,"\\'")}')" style="font-size:10px">🔍 #${esc(t)}</button>`;});
|
| 79 |
-
h+=`</div>`;
|
| 80 |
-
if(shown < total){
|
| 81 |
-
h+=`<button class="hashtag-load-more" id="ht-load-more" onclick="htLoadMore()">Tải thêm (${total - shown} tin) ▼</button>`;
|
| 82 |
-
}
|
| 83 |
-
h+=`</div>`;
|
| 84 |
-
box.innerHTML=h;
|
| 85 |
-
|
| 86 |
-
// Lazy load images + dates
|
| 87 |
-
visible.forEach((s,i)=>{if(!s.url)return;const ctrl=new AbortController();setTimeout(()=>ctrl.abort(),4000);fetch('/api/article?url='+encodeURIComponent(s.url),{signal:ctrl.signal}).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){const el=document.getElementById('ht-img-'+i);if(el)el.innerHTML=`<img src="${esc(d.og_image||d.img)}" onerror="this.style.display='none'">`;}if(d&&d.date_vn){_htDates[i]=d.date_vn;const viaEl=document.querySelector('#ht-list .hashtag-src-item:nth-child('+(i+1)+') .hashtag-src-via');if(viaEl&&!viaEl.querySelector('.ht-date')){const sp=document.createElement('span');sp.className='ht-date';sp.style.cssText='font-size:9px;color:#666;margin-left:4px';sp.textContent=d.date_vn;viaEl.insertBefore(sp,viaEl.querySelector('span')||null);}}}).catch(()=>{});});
|
| 88 |
-
_htTopic = _htTopics[0];
|
| 89 |
-
}
|
| 90 |
-
|
| 91 |
-
async function htLoadMore(){
|
| 92 |
-
const btn=document.getElementById('ht-load-more');if(!btn)return;
|
| 93 |
-
btn.disabled=true;btn.textContent='Đang tải thêm...';
|
| 94 |
-
const nextPage = _htPages + 1;
|
| 95 |
-
|
| 96 |
-
try{
|
| 97 |
-
// Fetch next page from each topic
|
| 98 |
-
const results=await Promise.all(_htTopics.map(topic=>
|
| 99 |
-
fetch(`/api/hashtag/sources?topic=${encodeURIComponent(topic)}&page=${nextPage}`).then(r=>r.json()).catch(()=>({sources:[]}))
|
| 100 |
-
));
|
| 101 |
-
_htPages = nextPage;
|
| 102 |
-
|
| 103 |
-
// Interleave new sources
|
| 104 |
-
const seen=new Set(_htAllSources.map(s=>s.url));
|
| 105 |
-
const mx=Math.max(...results.map(r=>(r.sources||[]).length));
|
| 106 |
-
const newItems=[];
|
| 107 |
-
for(let i=0;i<mx;i++){
|
| 108 |
-
for(let j=0;j<results.length;j++){
|
| 109 |
-
const src=(results[j].sources||[])[i];
|
| 110 |
-
if(src&&src.url&&!seen.has(src.url)){seen.add(src.url);src._topic=_htTopics[j];newItems.push(src);}
|
| 111 |
-
}
|
| 112 |
-
}
|
| 113 |
-
|
| 114 |
-
if(newItems.length){
|
| 115 |
-
_htAllSources = [..._htAllSources, ...newItems];
|
| 116 |
-
}
|
| 117 |
-
|
| 118 |
-
_renderHT();
|
| 119 |
-
}catch(e){
|
| 120 |
-
btn.disabled=false;btn.textContent='Tải thêm ▼';
|
| 121 |
-
toast('❌ Lỗi tải thêm');
|
| 122 |
-
}
|
| 123 |
-
}
|
|
|
|
| 1 |
// === OVERRIDE: loadHotTopics loads from MULTIPLE hashtags ===
|
|
|
|
| 2 |
// This file loaded AFTER app_v2.js, overrides the function
|
| 3 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
async function loadHotTopics(){
|
| 5 |
const j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));
|
| 6 |
const el=document.getElementById('hot-topics');if(!el)return;
|
| 7 |
const topics=j.topics||[];
|
| 8 |
+
el.innerHTML=topics.slice(0,18).map(t=>{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
const topicText=t.topic||t.label.replace(/^#/,'');
|
| 10 |
return`<button class="hot-chip" onclick="searchTopic('${topicText.replace(/'/g,"\\'")}')">${esc(t.label)}</button>`;
|
| 11 |
}).join('');
|
| 12 |
+
// Load tin HOT = tổng hợp từ TOP 3 hashtag nóng nhất
|
| 13 |
+
if(topics.length>=2){
|
| 14 |
+
loadMultiHashtag(topics.slice(0,3).map(t=>t.topic||t.label.replace(/^#/,'')));
|
|
|
|
|
|
|
|
|
|
| 15 |
}else if(topics.length){
|
| 16 |
searchTopic(topics[0].topic||topics[0].label.replace(/^#/,''));
|
| 17 |
}
|
|
|
|
| 19 |
|
| 20 |
async function loadMultiHashtag(topicList){
|
| 21 |
const box=document.getElementById('hashtag-box');if(!box)return;
|
|
|
|
|
|
|
|
|
|
| 22 |
box.innerHTML=`<div class="hashtag-sources"><h3>🔥 Tin HOT tổng hợp</h3><div class="hashtag-loading"><div class="hashtag-spinner"></div>Tổng hợp từ ${topicList.length} chủ đề nóng nhất...</div></div>`;
|
| 23 |
try{
|
| 24 |
const results=await Promise.all(topicList.map(topic=>
|
|
|
|
| 33 |
if(src&&src.url&&!seen.has(src.url)){seen.add(src.url);src._topic=topicList[j];all.push(src);}
|
| 34 |
}
|
| 35 |
}
|
| 36 |
+
if(!all.length){box.innerHTML=`<div class="hashtag-sources"><h3>🔥 Tin HOT</h3><div style="color:#888;padding:8px">Đang cập nhật...</div></div>`;return;}
|
| 37 |
+
let h=`<div class="hashtag-sources"><h3>🔥 Tin HOT tổng hợp <span style="font-size:10px;color:#888">(${all.length} bài · ${topicList.length} chủ đề)</span></h3><div id="ht-list">`;
|
| 38 |
+
all.slice(0,12).forEach((s,i)=>{
|
| 39 |
+
h+=`<div class="hashtag-src-item" onclick="readArticle('${esc(s.url)}')"><div class="hashtag-src-img" id="ht-img-${i}"></div><div class="hashtag-src-text"><div class="hashtag-src-title">${esc(s.title)}</div><div class="hashtag-src-via">${esc(s.via||'')} · <span style="color:#f0c040;font-size:9px">#${esc(s._topic||'')}</span></div></div></div>`;
|
| 40 |
+
});
|
| 41 |
+
h+=`</div><div style="display:flex;gap:4px;flex-wrap:wrap;margin-top:8px">`;
|
| 42 |
+
topicList.forEach(t=>{h+=`<button class="hot-chip" onclick="searchTopic('${t.replace(/'/g,"\\'")}')" style="font-size:10px">🔍 #${esc(t)}</button>`;});
|
| 43 |
+
h+=`</div></div>`;
|
| 44 |
+
box.innerHTML=h;
|
| 45 |
+
// Lazy load images
|
| 46 |
+
all.slice(0,12).forEach((s,i)=>{if(!s.url)return;fetch('/api/article?url='+encodeURIComponent(s.url)).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){const el=document.getElementById('ht-img-'+i);if(el)el.innerHTML=`<img src="${esc(d.og_image||d.img)}" onerror="this.style.display='none'">`;}}).catch(()=>{});});
|
| 47 |
+
_htTopic=topicList[0];
|
| 48 |
}catch(e){box.innerHTML=`<div class="hashtag-sources"><h3>🔥 Tin HOT</h3><div style="color:#e74c3c;padding:8px">Lỗi tải tin</div></div>`;}
|
| 49 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/index.html
CHANGED
|
@@ -1,2 +1,707 @@
|
|
| 1 |
<!DOCTYPE html>
|
| 2 |
-
<html
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
<!DOCTYPE html>
|
| 2 |
+
<html lang="vi">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
|
| 6 |
+
<title>VNEWS - Tin Tức Việt Nam</title>
|
| 7 |
+
<meta name="description" content="Tin tức tổng hợp, bóng đá trực tiếp, video highlight, AI tóm tắt.">
|
| 8 |
+
<meta property="og:title" content="VNEWS - Tin Tức Việt Nam">
|
| 9 |
+
<meta property="og:image" content="https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg">
|
| 10 |
+
<link rel="canonical" href="https://bep40-vnews.hf.space">
|
| 11 |
+
<link rel="stylesheet" href="/static/wc2026.css">
|
| 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}
|
| 15 |
+
.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}
|
| 16 |
+
.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}
|
| 17 |
+
.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}
|
| 18 |
+
.view{display:none}.view.active{display:block}.loading{text-align:center;padding:30px;color:#777;font-size:12px}
|
| 19 |
+
.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}
|
| 20 |
+
.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}
|
| 21 |
+
.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)}}
|
| 22 |
+
.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}
|
| 23 |
+
.badge{font-size:8px;padding:1px 5px;border-radius:3px;font-weight:700;display:inline-block;margin-bottom:2px;color:#fff}.badge-vne{background:#c0392d}.badge-bbc{background:#b80000}.badge-dt{background:#1565c0}.badge-genk{background:#6a1b9a}.badge-fpt{background:#f26522}.badge-ai{background:#2d8659}.badge-wc{background:#0b6bcb}
|
| 24 |
+
.section-title{font-size:13px;font-weight:800;color:#5cb87a;margin:8px 0 4px;padding-left:8px;border-left:3px solid #5cb87a}
|
| 25 |
+
.back-btn{background:#111;color:#fff;border:none;padding:10px;font-size:12px;width:100%;position:sticky;top:0;z-index:60;cursor:pointer}
|
| 26 |
+
.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}
|
| 27 |
+
.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}
|
| 28 |
+
.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}
|
| 29 |
+
.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}
|
| 30 |
+
.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}
|
| 31 |
+
.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}
|
| 32 |
+
.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}
|
| 33 |
+
.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)}
|
| 34 |
+
.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)}}
|
| 35 |
+
.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}
|
| 36 |
+
#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}
|
| 37 |
+
.storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 4px}
|
| 38 |
+
</style>
|
| 39 |
+
</head>
|
| 40 |
+
<body>
|
| 41 |
+
<div class="header"><h1>📰 VNEWS</h1><p>Tin tức · Bóng đá LIVE · Video · AI · World Cup 2026</p></div>
|
| 42 |
+
<div class="cats" id="cat-bar"></div>
|
| 43 |
+
<div id="view-home" class="view active"><div class="loading">Đang tải...</div></div>
|
| 44 |
+
<div id="view-cat" class="view"></div>
|
| 45 |
+
<div id="view-video" class="view"></div>
|
| 46 |
+
<div id="view-tiktok" class="view"></div>
|
| 47 |
+
<div id="view-article" class="view"></div>
|
| 48 |
+
<div class="match-overlay" id="match-overlay">
|
| 49 |
+
<div class="mo-header"><h3 id="mo-title">Chi tiết trận đấu</h3><button class="mo-close" onclick="closeMatch()">✕</button></div>
|
| 50 |
+
<div class="mo-tabs"><span class="mo-tab active" onclick="loadMatchTab('detail')">📋 Chi tiết</span><span class="mo-tab" onclick="loadMatchTab('comm')">Diễn biến</span><span class="mo-tab" onclick="loadMatchTab('stats')">Thống kê</span></div>
|
| 51 |
+
<div class="mo-body" id="mo-body"><div class="loading">Đang tải...</div></div>
|
| 52 |
+
</div>
|
| 53 |
+
<div id="progress-toast"></div>
|
| 54 |
+
<script>
|
| 55 |
+
var _cats=[],_hlLeagueData={},_currentArticle=null;window._currentEventId='';
|
| 56 |
+
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]))}
|
| 57 |
+
function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')}
|
| 58 |
+
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)}}
|
| 59 |
+
function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}}
|
| 60 |
+
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(()=>{})}
|
| 61 |
+
var SPACE=location.origin;
|
| 62 |
+
</script>
|
| 63 |
+
<script>
|
| 64 |
+
// === VNEWS Frontend v2 - Full Functions ===
|
| 65 |
+
|
| 66 |
+
// === LOAD HOME ===
|
| 67 |
+
async function loadHome(){
|
| 68 |
+
const[featured,sh,wall,hlLeagues,ai,wcData]=await Promise.all([
|
| 69 |
+
fetch('/api/livescore/featured').then(r=>r.json()).catch(()=>null),
|
| 70 |
+
fetch('/api/shorts').then(r=>r.json()).catch(()=>[]),
|
| 71 |
+
fetch('/api/wall').then(r=>r.json()).catch(()=>({posts:[]})),
|
| 72 |
+
fetch('/api/highlights/leagues').then(r=>r.json()).catch(()=>({})),
|
| 73 |
+
fetch('/api/genk_ai').then(r=>r.json()).catch(()=>[]),
|
| 74 |
+
fetch('/api/wc2026').then(r=>r.json()).catch(()=>null)
|
| 75 |
+
]);
|
| 76 |
+
_hlLeagueData=hlLeagues;
|
| 77 |
+
_wc2026Data=wcData;
|
| 78 |
+
_shortsData=interleaveShorts(sh||[]);
|
| 79 |
+
_wallPosts=(wall&&wall.posts)||[];
|
| 80 |
+
let h='';
|
| 81 |
+
if(featured&&featured.home){
|
| 82 |
+
const sc=featured.status==='live'?'':'upcoming';
|
| 83 |
+
const st=featured.status==='live'?`🔴 ${featured.minute||'LIVE'}`:`⏰ ${featured.time}`;
|
| 84 |
+
const eid = String(featured.event_id||'').replace(/[<>&"']/g,'');
|
| 85 |
+
const mUrl = String(featured.url||'').replace(/[<>&"']/g,'');
|
| 86 |
+
const fHome = String(featured.home||'').replace(/[<>&"']/g,'');
|
| 87 |
+
const fAway = String(featured.away||'').replace(/[<>&"']/g,'');
|
| 88 |
+
const fLeague = String(featured.league||'').replace(/[<>&"']/g,'');
|
| 89 |
+
const fScore = String(featured.score||'VS').replace(/[<>&"']/g,'');
|
| 90 |
+
const fHomeLogo = String(featured.home_logo||'').replace(/[<>&"']/g,'');
|
| 91 |
+
const fAwayLogo = String(featured.away_logo||'').replace(/[<>&"']/g,'');
|
| 92 |
+
const safeTitle = `${fHome} vs ${fAway} — ${fLeague}`;
|
| 93 |
+
h+=`<div class="featured-match" data-event-id="${eid}" data-url="${mUrl}" onclick="openMatch('${eid}','${mUrl}')" title="${safeTitle}">`+
|
| 94 |
+
`<div class="fm-league">${fLeague}</div>`+
|
| 95 |
+
`<div class="fm-teams">`+
|
| 96 |
+
`<div class="fm-team"><img src="${fHomeLogo}" onerror="this.style.display='none'"><span>${fHome}</span></div>`+
|
| 97 |
+
`<div class="fm-score">${fScore}</div>`+
|
| 98 |
+
`<div class="fm-team"><img src="${fAwayLogo}" onerror="this.style.display='none'"><span>${fAway}</span></div>`+
|
| 99 |
+
`</div>`+
|
| 100 |
+
`<div class="fm-status ${sc}">${st}</div>`+
|
| 101 |
+
`</div>`;
|
| 102 |
+
}
|
| 103 |
+
h+=`<div class="ai-compose"><div class="ai-compose-title">🤖 AI viết bài</div><div class="ai-compose-row"><input id="topic-input" placeholder="Nhập chủ đề..."><button onclick="searchTopic()">Tìm nguồn</button></div><div class="ai-compose-row"><input id="url-input" placeholder="Dán URL bài viết..."><button class="secondary" onclick="rewriteUrl()">Rewrite</button></div><div id="hot-topics" class="hot-topic-row"></div></div>`;
|
| 104 |
+
h+='<div id="hashtag-box"></div>';
|
| 105 |
+
h+=`<div class="ls-section"><div class="ls-header"><h3>⚽ Livescore</h3></div><div class="ls-tabs"><span class="ls-tab active" data-tab="today" onclick="loadLivescore('today')">📅 Hôm nay</span><span class="ls-tab" data-tab="live" onclick="loadLivescore('live')">🔴 Live</span><span class="ls-tab" data-tab="incoming" onclick="loadLivescore('incoming')">⏰ Sắp tới</span><span class="ls-tab" data-tab="results" onclick="loadLivescore('results')">✅ Kết quả</span><span class="ls-tab" data-tab="bxh_nha" onclick="loadLivescore('bxh_nha')">🏆 NHA</span><span class="ls-tab" data-tab="bxh_laliga" onclick="loadLivescore('bxh_laliga')">🏆 La Liga</span></div><div class="ls-content" id="ls-content"><div class="loading">Đang tải...</div></div></div>`;
|
| 106 |
+
h+=`<div id="wc2026-live-section" class="wc2026-section"><div class="wc-header"><h2>🏆 World Cup 2026</h2><span class="wc-live-badge">● LIVE</span></div><div class="wc-tabs"><span class="wc-tab active" onclick="switchWCTab('news')">📰 Tin tức</span><span class="wc-tab" onclick="switchWCTab('fixtures')">📅 Lịch thi đấu</span><span class="wc-tab" onclick="switchWCTab('standings')">🏆 BXH</span><span class="wc-tab" onclick="switchWCTab('highlights')">🎬 Highlight</span><span class="wc-tab" onclick="switchWCTab('stats')">📊 Thống kê</span></div><div class="wc-content" id="wc-content"><div class="loading">Đang tải World Cup 2026...</div></div></div>`;
|
| 107 |
+
const wallPosts=_wallPosts;
|
| 108 |
+
const aiShorts=wallPosts.filter(p=>p.video);
|
| 109 |
+
if(aiShorts.length){h+='<div class="slider-wrap"><div class="slider-header"><span class="slider-label">🎬 Short AI</span></div><div class="slider-track">';aiShorts.slice(0,20).forEach((p,i)=>{h+=`<div class="slider-item shorts-item" onclick="openShortAIFeed(${i})"><div class="slider-thumb shorts-thumb"><video src="${p.video}" muted preload="metadata"></video><div class="card-play">▶</div></div><div class="slider-title">${esc(p.title)}</div></div>`});h+='</div></div>';}
|
| 110 |
+
if(_shortsData.length){h+='<div class="slider-wrap"><div class="slider-header"><span class="slider-label">📱 Shorts Dân trí & SKĐS</span><span class="slider-note">Mới nhất · xen kẽ</span></div><div class="slider-track">';_shortsData.slice(0,30).forEach((a,i)=>{const badge=a.channel==='baosuckhoedoisongboyte'?'SKĐS':'Dân trí';h+=`<div class="slider-item shorts-item" onclick="openYTShortsFeed(${i})"><div class="slider-thumb shorts-thumb">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="slider-title"><span style="color:#f0c040;font-size:8px">${badge}</span> ${esc(a.title)}</div></div>`});h+='</div></div>';}
|
| 111 |
+
if(wallPosts.length){h+=`<div class="slider-wrap" id="ai-wall-wrap"><div class="slider-header"><span class="slider-label">🧱 Tường AI</span></div><div class="slider-track" id="ai-wall-track">`;wallPosts.slice(0,20).forEach((p,i)=>{h+=makeWallItem(p,i)});h+='</div></div>';}
|
| 112 |
+
const HL_CONFIG={"world-cup":{name:"World Cup 2026",emoji:"🌍"},"premier-league":{name:"Premier League",emoji:"🏴"},"champions-league":{name:"Champions League",emoji:"⭐"},"la-liga":{name:"La Liga",emoji:"🇪🇸"},"serie-a":{name:"Serie A",emoji:"🇮🇹"},"bundesliga":{name:"Bundesliga",emoji:"���🇪"},"friendly":{name:"Giao hữu",emoji:"🤝"}};
|
| 113 |
+
for(const[key,cfg] of Object.entries(HL_CONFIG)){const vids=hlLeagues[key];if(!vids||!vids.length)continue;h+=`<div class="slider-wrap"><div class="slider-header"><span class="slider-label">${cfg.emoji} ${cfg.name}</span></div><div class="slider-track">`;vids.slice(0,8).forEach((a,i)=>{h+=`<div class="slider-item" onclick="openHighlightFeed('${key}',${i})"><div class="slider-thumb">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="slider-title">${esc(a.title)}</div></div>`});h+='</div></div>';}
|
| 114 |
+
if(ai&&ai.length){h+='<div class="slider-wrap"><div class="slider-header"><span class="slider-label">🤖 Ứng dụng AI</span></div><div class="slider-track">';ai.slice(0,12).forEach(a=>{h+=`<div class="slider-item" onclick="readArticle('${esc(a.link)}')"><div class="slider-thumb">${a.img?`<img src="${a.img}">`:''}</div><div class="slider-title">${esc(a.title)}</div></div>`});h+='</div></div>';}
|
| 115 |
+
document.getElementById('view-home').innerHTML=h;
|
| 116 |
+
loadLivescore('today');loadHotTopics();
|
| 117 |
+
if(_wc2026Data)switchWCTab('news');
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
// === WALL POST HELPERS ===
|
| 121 |
+
function makeWallItem(p,i){
|
| 122 |
+
const hasVideo = p.video && p.video.length > 0;
|
| 123 |
+
const thumbContent = p.img
|
| 124 |
+
? `<img src="${esc(p.img)}" onerror="this.style.display='none'">`
|
| 125 |
+
: (hasVideo ? `<video src="${esc(p.video)}" muted></video>` : '');
|
| 126 |
+
const videoBadge = hasVideo
|
| 127 |
+
? `<div class="wall-video-badge">🎬</div>`
|
| 128 |
+
: '';
|
| 129 |
+
const videoBtn = hasVideo
|
| 130 |
+
? `<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed(${i})">▶ Xem Short</button>`
|
| 131 |
+
: `<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo('${esc(p.id||i)}',this)">🎬 Tạo Video</button>`;
|
| 132 |
+
|
| 133 |
+
return `<div class="wall-item" id="wall-item-${esc(p.id||i)}">
|
| 134 |
+
<div class="wall-thumb">
|
| 135 |
+
${thumbContent}
|
| 136 |
+
${videoBadge}
|
| 137 |
+
</div>
|
| 138 |
+
<div class="wall-title">${esc(p.title)}</div>
|
| 139 |
+
<div class="wall-text">${esc((p.text||'').slice(0,180))}</div>
|
| 140 |
+
<div class="wall-actions">
|
| 141 |
+
<button class="primary" onclick="readWallPost(${i})">Xem</button>
|
| 142 |
+
${videoBtn}
|
| 143 |
+
</div>
|
| 144 |
+
</div>`;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
// === GENERATE SHORT VIDEO FOR A WALL POST ===
|
| 148 |
+
async function makeShortVideo(postId, btn, voice, speed){
|
| 149 |
+
if(!postId)return;
|
| 150 |
+
const origText = btn ? btn.textContent : '🎬 Tạo Video';
|
| 151 |
+
if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo...';}
|
| 152 |
+
toast('⏳ Đang tạo video shorts...');
|
| 153 |
+
try{
|
| 154 |
+
let url = '/api/ai/short/'+encodeURIComponent(postId);
|
| 155 |
+
const params = [];
|
| 156 |
+
if(voice) params.push('voice='+encodeURIComponent(voice));
|
| 157 |
+
if(speed) params.push('speed='+encodeURIComponent(speed));
|
| 158 |
+
if(params.length) url += '?' + params.join('&');
|
| 159 |
+
const r = await fetch(url, {method:'POST'});
|
| 160 |
+
const j = await r.json();
|
| 161 |
+
if(!r.ok || j.error) throw new Error(j.error||'Lỗi tạo video');
|
| 162 |
+
toast('✅ Đã tạo video shorts!');
|
| 163 |
+
const p = _wallPosts.find(x => String(x.id) === String(postId));
|
| 164 |
+
if(p){
|
| 165 |
+
p.video = j.video;
|
| 166 |
+
const itemId = 'wall-item-'+postId;
|
| 167 |
+
const el = document.getElementById(itemId);
|
| 168 |
+
if(el){
|
| 169 |
+
const idx = _wallPosts.indexOf(p);
|
| 170 |
+
el.outerHTML = makeWallItem(p, idx);
|
| 171 |
+
const newEl = document.getElementById(itemId);
|
| 172 |
+
if(newEl) newEl.className = 'wall-item wall-item-new';
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
refreshShortAISlider();
|
| 176 |
+
}catch(e){
|
| 177 |
+
toast('❌ '+e.message);
|
| 178 |
+
if(btn){btn.disabled=false;btn.textContent=origText;}
|
| 179 |
+
}
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
// Refresh Short AI slider after video generation
|
| 183 |
+
function refreshShortAISlider(){
|
| 184 |
+
const aiShorts = _wallPosts.filter(p=>p.video);
|
| 185 |
+
let shortAISection = document.getElementById('short-ai-section');
|
| 186 |
+
if(aiShorts.length === 0){
|
| 187 |
+
if(shortAISection) shortAISection.remove();
|
| 188 |
+
return;
|
| 189 |
+
}
|
| 190 |
+
if(shortAISection){
|
| 191 |
+
const track = shortAISection.querySelector('.slider-track');
|
| 192 |
+
if(track){
|
| 193 |
+
let h = '';
|
| 194 |
+
aiShorts.slice(0,20).forEach((p,i)=>{
|
| 195 |
+
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>`;
|
| 196 |
+
});
|
| 197 |
+
track.innerHTML = h;
|
| 198 |
+
}
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
function prependWallPost(post){
|
| 203 |
+
_wallPosts.unshift(post);
|
| 204 |
+
const track=document.getElementById('ai-wall-track');
|
| 205 |
+
const wrap=document.getElementById('ai-wall-wrap');
|
| 206 |
+
const homeEl=document.getElementById('view-home');
|
| 207 |
+
if(!track||!wrap){
|
| 208 |
+
if(homeEl){
|
| 209 |
+
let insertBefore=homeEl.querySelector('.slider-wrap');
|
| 210 |
+
const newWrap=document.createElement('div');
|
| 211 |
+
newWrap.className='slider-wrap';
|
| 212 |
+
newWrap.id='ai-wall-wrap';
|
| 213 |
+
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>`;
|
| 214 |
+
if(insertBefore){
|
| 215 |
+
homeEl.insertBefore(newWrap,insertBefore);
|
| 216 |
+
}else{
|
| 217 |
+
homeEl.appendChild(newWrap);
|
| 218 |
+
}
|
| 219 |
+
const firstItem=newWrap.querySelector('.wall-item');
|
| 220 |
+
if(firstItem)firstItem.className='wall-item wall-item-new';
|
| 221 |
+
}
|
| 222 |
+
return;
|
| 223 |
+
}
|
| 224 |
+
const div=document.createElement('div');
|
| 225 |
+
div.className='wall-item wall-item-new';
|
| 226 |
+
div.id='wall-item-'+(post.id||'new-'+Date.now());
|
| 227 |
+
const hasVideo = post.video && post.video.length > 0;
|
| 228 |
+
const thumbContent = post.img
|
| 229 |
+
? `<img src="${esc(post.img)}" onerror="this.style.display='none'">`
|
| 230 |
+
: (hasVideo ? `<video src="${esc(post.video)}" muted></video>` : '');
|
| 231 |
+
const videoBadge = hasVideo ? `<div class="wall-video-badge">🎬</div>` : '';
|
| 232 |
+
const videoBtn = hasVideo
|
| 233 |
+
? `<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed(0)">▶ Xem Short</button>`
|
| 234 |
+
: `<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo('${esc(post.id)}',this)">🎬 Tạo Video</button>`;
|
| 235 |
+
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>`;
|
| 236 |
+
track.prepend(div);
|
| 237 |
+
track.scrollTo({left:0,behavior:'smooth'});
|
| 238 |
+
if(hasVideo) refreshShortAISlider();
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
// === REST OF FUNCTIONS ===
|
| 242 |
+
let _shortsData=[];
|
| 243 |
+
let _wallPosts=[];
|
| 244 |
+
let _currentView='home';
|
| 245 |
+
let _currentEventId=null;
|
| 246 |
+
let _currentMatchUrl=null;
|
| 247 |
+
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;}
|
| 248 |
+
let _htPage=0,_htTopic='';
|
| 249 |
+
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);}}
|
| 250 |
+
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);}
|
| 251 |
+
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>`;}}
|
| 252 |
+
function loadMoreHashtag(){_htPage++;const btn=document.getElementById('ht-more');if(btn){btn.disabled=true;btn.textContent='Đang tải...';}showHashtagSources(_htTopic,_htPage);}
|
| 253 |
+
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';}}}
|
| 254 |
+
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>';}}
|
| 255 |
+
function bindMatchClicks(el){
|
| 256 |
+
if(!el) return;
|
| 257 |
+
el.querySelectorAll('.match-detail').forEach(md=>{
|
| 258 |
+
md.style.cursor='pointer';
|
| 259 |
+
if(md._bound) return;
|
| 260 |
+
md._bound = true;
|
| 261 |
+
md.addEventListener('click',function(e){
|
| 262 |
+
const tag = e.target.tagName?.toLowerCase();
|
| 263 |
+
if(tag === 'a' || tag === 'button' || tag === 'input') {
|
| 264 |
+
e.preventDefault();
|
| 265 |
+
e.stopPropagation();
|
| 266 |
+
}
|
| 267 |
+
const links = this.querySelectorAll('a[href*="/tran-dau/"]');
|
| 268 |
+
let bestA = null;
|
| 269 |
+
links.forEach(a => {
|
| 270 |
+
const href = a.getAttribute('href') || '';
|
| 271 |
+
if(href.match(/\/tran-dau\/\d+\/(centre|preview|quan-cau|video)\//)) {
|
| 272 |
+
bestA = a;
|
| 273 |
+
} else if(!bestA && href.match(/\/tran-dau\/\d+\//)) {
|
| 274 |
+
bestA = a;
|
| 275 |
+
}
|
| 276 |
+
});
|
| 277 |
+
if(!bestA) return;
|
| 278 |
+
e.preventDefault();
|
| 279 |
+
e.stopPropagation();
|
| 280 |
+
const href = bestA.getAttribute('href') || '';
|
| 281 |
+
const m = href.match(/\/tran-dau\/(\d+)\//);
|
| 282 |
+
if(m){
|
| 283 |
+
const fullUrl = href.startsWith('http') ? href : 'https://bongda.com.vn' + href;
|
| 284 |
+
openMatch(m[1], fullUrl);
|
| 285 |
+
}
|
| 286 |
+
});
|
| 287 |
+
});
|
| 288 |
+
el.querySelectorAll('a').forEach(a=>{
|
| 289 |
+
a.addEventListener('click',e=>{
|
| 290 |
+
e.preventDefault();
|
| 291 |
+
e.stopPropagation();
|
| 292 |
+
});
|
| 293 |
+
});
|
| 294 |
+
}
|
| 295 |
+
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')}
|
| 296 |
+
function closeMatch(){document.getElementById('match-overlay').classList.remove('active');document.body.style.overflow=''}
|
| 297 |
+
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>'}}
|
| 298 |
+
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;}}
|
| 299 |
+
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};}}
|
| 300 |
+
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[];}}
|
| 301 |
+
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[];}}
|
| 302 |
+
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>`;}
|
| 303 |
+
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);}}
|
| 304 |
+
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);}}
|
| 305 |
+
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);}
|
| 306 |
+
async function loadCounters(videoIds){for(let i=0;i<videoIds.length;i++){const id=videoIds[i];if(!id)continue;const j=await getInteractions(id);const vc=document.getElementById('vc-'+i);if(vc)vc.textContent=fmtNum(j.views);const lc=document.getElementById('lc-'+i);if(lc)lc.textContent=fmtNum(j.likes);const cc=document.getElementById('cc-'+i);if(cc)cc.textContent=fmtNum(j.comments);}}
|
| 307 |
+
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);}
|
| 308 |
+
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;}
|
| 309 |
+
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);}
|
| 310 |
+
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)}
|
| 311 |
+
async function openHighlightFeed(league,idx){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải...</div>';let articles=(_hlLeagueData||{})[league]||[];if(!articles.length){try{articles=await fetch('/api/highlights/'+league).then(r=>r.json())}catch(e){articles=[]}}if(!articles.length){el.innerHTML='<div class="loading">Không có video</div>';return}const vids=[];const results=await Promise.all(articles.map(async(a,i)=>{try{const r=await fetch('/api/video_url?url='+encodeURIComponent(a.link));const v=await r.json();if(v&&v.src)return{...a,...v,_idx:i}}catch(e){}return null}));results.forEach(r=>{if(r)vids.push(r)});vids.sort((a,b)=>a._idx-b._idx);if(!vids.length){el.innerHTML='<div class="loading">Không tìm thấy video</div>';return}let ti=vids.findIndex(v=>v._idx===idx);if(ti<0)ti=0;const ordered=ti>0?[...vids.slice(ti),...vids.slice(0,ti)]:vids;let h=`<button class="back-btn" onclick="switchCat('home')">← Highlight</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">`;ordered.forEach((v,i)=>{const isYT=v.type==='youtube',isHLS=!isYT&&v.src?.includes('.m3u8'),poster=v.poster?` poster="${v.poster}"`:'';const vtag=isYT?`<iframe data-yt-src="${v.src}" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture"></iframe>`:isHLS?`<video playsinline preload="none"${poster} data-hls="${v.src}" loop controls></video>`:`<video playsinline preload="none"${poster} loop controls><source src="${v.src}" type="video/mp4"></video>`;const videoId='hl-'+league+'-'+(v.id||v._idx);h+=buildTikTokSlide({vtag,title:v.title,badge:'HL',badgeClass:'badge-fpt',videoId,idx:i,total:ordered.length,shareUrl:v.link||'',extraBtn:`<button class="tiktok-right-btn" onclick="event.stopPropagation();this.closest('.tiktok-slide').classList.toggle('ratio-wide')"><div class="icon">⬜</div><div class="count">16:9</div></button>`});});h+='</div></div>';el.innerHTML=h;initTikTokFeed();}
|
| 312 |
+
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();}
|
| 313 |
+
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();}
|
| 314 |
+
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>`;}
|
| 315 |
+
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)}}
|
| 316 |
+
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)}}
|
| 317 |
+
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}}
|
| 318 |
+
async function readWallPost(i){const p=_wallPosts[i];if(!p)return;showView('view-article');
|
| 319 |
+
const images = p.images || [];
|
| 320 |
+
let imgGallery = '';
|
| 321 |
+
if(images.length > 0){
|
| 322 |
+
imgGallery = '<div class="article-image-gallery">';
|
| 323 |
+
images.forEach((imgUrl, idx) => {
|
| 324 |
+
if(idx === 0){
|
| 325 |
+
imgGallery += `<img class="article-img article-hero-img" src="${esc(imgUrl)}" onerror="this.style.display='none" loading="eager">`;
|
| 326 |
+
} else {
|
| 327 |
+
if(idx === 1) imgGallery += '<div class="gallery-thumbs">';
|
| 328 |
+
imgGallery += `<div class="gallery-thumb"><img src="${esc(imgUrl)}" onerror="this.parentElement.style.display='none'" loading="lazy"></div>`;
|
| 329 |
+
}
|
| 330 |
+
});
|
| 331 |
+
if(images.length > 1) imgGallery += '</div>';
|
| 332 |
+
imgGallery += '</div>';
|
| 333 |
+
}
|
| 334 |
+
const hasVideo = p.video && p.video.length > 0;
|
| 335 |
+
const voiceOptions = [
|
| 336 |
+
{id:'hoaimy', label:'🎙️ Nữ — Hoài My'},
|
| 337 |
+
{id:'namminh', label:'🎙️ Nam — Nam Minh'},
|
| 338 |
+
];
|
| 339 |
+
let voiceSelector = '';
|
| 340 |
+
if(!hasVideo){
|
| 341 |
+
voiceSelector = `<div class="tts-selector"><div class="tts-selector-label">🎙️ Chọn giọng đọc:</div><div class="tts-voice-btns">`;
|
| 342 |
+
voiceOptions.forEach(v=>{
|
| 343 |
+
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>`;
|
| 344 |
+
});
|
| 345 |
+
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>`;
|
| 346 |
+
voiceSelector += `<input type="hidden" id="selected-voice" value="hoaimy"></div>`;
|
| 347 |
+
}
|
| 348 |
+
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>`;
|
| 349 |
+
const firstVoiceBtn = document.querySelector('.tts-voice-btn');
|
| 350 |
+
if(firstVoiceBtn) firstVoiceBtn.classList.add('active');
|
| 351 |
+
window.scrollTo(0,0)}
|
| 352 |
+
async function loadNewsTab(){const el=document.getElementById('view-cat');el.innerHTML='<div class="loading">Đang tải...</div>';try{const r=await fetch('/api/homepage');const news=await r.json();if(!news.length){el.innerHTML='<div class="loading">Không có tin</div>';return}const groups={};news.forEach(a=>{if(!groups[a.group])groups[a.group]=[];groups[a.group].push(a)});let h='';for(const[g,arts] of Object.entries(groups)){h+=`<div class="section-title">${g}</div><div class="grid">`;arts.slice(0,6).forEach(a=>{h+=`<div class="card" onclick="readArticle('${esc(a.link)}')"><div class="card-img">${a.img?`<img src="${a.img}">`:''}</div><div class="card-body"><span class="badge badge-vne">${esc(a.source||'VnE')}</span><div class="card-title">${esc(a.title)}</div></div></div>`});h+='</div>'}el.innerHTML=h}catch(e){el.innerHTML='<div class="loading">Lỗi</div>'}}
|
| 353 |
+
async function loadCat(id){const el=document.getElementById('view-cat');el.innerHTML='<div class="loading">Đang tải...</div>';const arts=await fetch('/api/category/'+id).then(r=>r.json()).catch(()=>[]);if(!arts.length){el.innerHTML='<div class="loading">Không có tin</div>';return}let h='<div class="grid">';arts.forEach(a=>{h+=`<div class="card" onclick="readArticle('${esc(a.link)}')"><div class="card-img">${a.img?`<img src="${a.img}">`:''}</div><div class="card-body"><span class="badge badge-vne">${esc(a.source||'')}</span><div class="card-title">${esc(a.title)}</div></div></div>`});h+='</div>';el.innerHTML=h}
|
| 354 |
+
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(()=>{});
|
| 355 |
+
|
| 356 |
+
// === AUTO-OPEN SHARE LINKS (/s?url=... sets pending_article) ===
|
| 357 |
+
(function(){
|
| 358 |
+
try{
|
| 359 |
+
const pa=localStorage.getItem('pending_article');
|
| 360 |
+
const pv=localStorage.getItem('pending_video');
|
| 361 |
+
if(pa){
|
| 362 |
+
localStorage.removeItem('pending_article');
|
| 363 |
+
setTimeout(()=>{
|
| 364 |
+
if(typeof readArticle==='function') readArticle(pa);
|
| 365 |
+
},1500);
|
| 366 |
+
}
|
| 367 |
+
if(pv){
|
| 368 |
+
localStorage.removeItem('pending_video');
|
| 369 |
+
try{
|
| 370 |
+
const v=JSON.parse(pv);
|
| 371 |
+
if(v&&v.url) setTimeout(()=>{window.open(v.url,'_blank')},1500);
|
| 372 |
+
}catch(e){}
|
| 373 |
+
}
|
| 374 |
+
}catch(e){}
|
| 375 |
+
})();
|
| 376 |
+
</script>
|
| 377 |
+
<script>
|
| 378 |
+
// === VNEWS — VTV1-VTV10 + VTVPrime LIVE CHANNELS + EPG ===
|
| 379 |
+
// Uses backend /api/vtv/streams for stream URLs
|
| 380 |
+
// Default channel: VTV6 | No double-load | EPG schedule from backend
|
| 381 |
+
|
| 382 |
+
(function(){
|
| 383 |
+
if(window._ytLiveLoaded) return;
|
| 384 |
+
window._ytLiveLoaded = true;
|
| 385 |
+
|
| 386 |
+
const CHANNELS = [
|
| 387 |
+
{id:'vtv1', name:'VTV1', badge:'Tin tức'},
|
| 388 |
+
{id:'vtv2', name:'VTV2', badge:'Khoa học'},
|
| 389 |
+
{id:'vtv3', name:'VTV3', badge:'Giải trí'},
|
| 390 |
+
{id:'vtv4', name:'VTV4', badge:'Quốc tế'},
|
| 391 |
+
{id:'vtv5', name:'VTV5', badge:'Miền Nam'},
|
| 392 |
+
{id:'vtv6', name:'VTV6', badge:'Thanh niên'},
|
| 393 |
+
{id:'vtv7', name:'VTV7', badge:'Giáo dục'},
|
| 394 |
+
{id:'vtv8', name:'VTV8', badge:'Miền Trung'},
|
| 395 |
+
{id:'vtv9', name:'VTV9', badge:'Miền Bắc'},
|
| 396 |
+
{id:'vtv10', name:'VTV10', badge:'VTV10'},
|
| 397 |
+
{id:'vtvprime', name:'VTVPrime', badge:'Prime'},
|
| 398 |
+
];
|
| 399 |
+
|
| 400 |
+
// Default EPG fallback (used only if backend EPG unavailable)
|
| 401 |
+
const DEFAULT_EPG = {
|
| 402 |
+
vtv1: [{t:'06:00',n:'Nhật ký ngày mai'},{t:'07:00',n:'Thời sự sáng'},{t:'09:00',n:'Thời sự'},{t:'12:00',n:'Thời sự trưa'},{t:'15:00',n:'Thời sự chiều'},{t:'19:00',n:'Thời sự tối'},{t:'21:00',n:'Thời sự đêm'},{t:'23:00',n:'Nhật ký ngày mai'}],
|
| 403 |
+
vtv2: [{t:'06:00',n:'Khoa học & Công nghệ'},{t:'08:00',n:'Thế giới tự nhiên'},{t:'10:00',n:'Khoa học 360'},{t:'12:00',n:'Đi tìm giải pháp'},{t:'14:00',n:'Sức khỏe & Cuộc sống'},{t:'16:00',n:'Khoa học cho mọi nhà'},{t:'18:00',n:'Thế giới động vật'},{t:'20:00',n:'Khoa học & Tương lai'},{t:'22:00',n:'Tài liệu khoa học'}],
|
| 404 |
+
vtv3: [{t:'06:00',n:'Sáng vui'},{t:'08:00',n:'Phim truyện'},{t:'10:00',n:'Gameshow'},{t:'12:00',n:'Âm nhạc'},{t:'14:00',n:'Phim truyện'},{t:'16:00',n:'Giải trí chiều'},{t:'18:00',n:'Tạp kỹ thuật số'},{t:'20:00',n:'Phim truyện đặc biệt'},{t:'22:00',n:'Đêm giải trí'}],
|
| 405 |
+
vtv4: [{t:'06:00',n:'News'},{t:'08:00',n:'World News'},{t:'10:00',n:'Culture'},{t:'12:00',n:'Midday News'},{t:'14:00',n:'Documentary'},{t:'16:00',n:'Sports'},{t:'18:00',n:'Evening News'},{t:'20:00',n:'World Today'},{t:'22:00',n:'Nightline'}],
|
| 406 |
+
vtv5: [{t:'06:00',n:'Thời sự miền Nam'},{t:'08:00',n:'Chương trình thiếu nhi'},{t:'10:00',n:'Phim truyện'},{t:'12:00',n:'Thời sự trưa'},{t:'14:00',n:'Giải trí'},{t:'16:00',n:'Thể thao miền Nam'},{t:'18:00',n:'Thời sự chiều'},{t:'20:00',n:'Phim truyện'},{t:'22:00',n:'Thời sự tối'}],
|
| 407 |
+
vtv6: [{t:'06:00',n:'Khởi động ngày mới'},{t:'08:00',n:'Thanh niên & Sáng tạo'},{t:'10:00',n:'Thế giới trẻ'},{t:'12:00',n:'Nhịp sống trẻ'},{t:'14:00',n:'Thể thao tuổi trẻ'},{t:'16:00',n:'Giải trí thanh niên'},{t:'18:00',n:'Thời sự trẻ'},{t:'20:00',n:'Đêm nhạc'},{t:'22:00',n:'Thanh niên & Đêm'}],
|
| 408 |
+
vtv7: [{t:'06:00',n:'Giáo dục sáng'},{t:'08:00',n:'Học mọi lúc'},{t:'10:00',n:'Kỹ năng sống'},{t:'12:00',n:'Giáo dục trưa'},{t:'14:00',n:'Học trực tuyến'},{t:'16:00',n:'Thiếu nhi'},{t:'18:00',n:'Giáo dục chiều'},{t:'20:00',n:'Tài liệu giáo dục'},{t:'22:00',n:'Học suốt đời'}],
|
| 409 |
+
vtv8: [{t:'06:00',n:'Thời sự miền Trung'},{t:'08:00',n:'Văn hóa miền Trung'},{t:'10:00',n:'Phim truyện'},{t:'12:00',n:'Thời sự trưa'},{t:'14:00',n:'Giải trí'},{t:'16:00',n:'Thể thao miền Trung'},{t:'18:00',n:'Thời sự chiều'},{t:'20:00',n:'Phim truyện'},{t:'22:00',n:'Thời sự tối'}],
|
| 410 |
+
vtv9: [{t:'06:00',n:'Thời sự miền Bắc'},{t:'08:00',n:'Văn hóa miền Bắc'},{t:'10:00',n:'Phim truyện'},{t:'12:00',n:'Thời sự trưa'},{t:'14:00',n:'Giải trí'},{t:'16:00',n:'Thể thao miền Bắc'},{t:'18:00',n:'Thời sự chiều'},{t:'20:00',n:'Phim truyện'},{t:'22:00',n:'Thời sự tối'}],
|
| 411 |
+
vtv10:[{t:'06:00',n:'Thời sự Tây Nam Bộ'},{t:'08:00',n:'Văn hóa đồng bằng'},{t:'10:00',n:'Phim truyện'},{t:'12:00',n:'Thời sự trưa'},{t:'14:00',n:'Giải trí'},{t:'16:00',n:'Thể thao Tây Nam Bộ'},{t:'18:00',n:'Thời sự chiều'},{t:'20:00',n:'Phim truyện'},{t:'22:00',n:'Thời sự tối'}],
|
| 412 |
+
vtvprime:[{t:'06:00',n:'Prime Morning'},{t:'08:00',n:'Prime Cinema'},{t:'10:00',n:'Prime Sports'},{t:'12:00',n:'Prime News'},{t:'14:00',n:'Prime Drama'},{t:'16:00',n:'Prime Entertainment'},{t:'18:00',n:'Prime Evening'},{t:'20:00',n:'Prime Night'},{t:'22:00',n:'Prime Late'}],
|
| 413 |
+
};
|
| 414 |
+
|
| 415 |
+
// EPG cache fetched from backend
|
| 416 |
+
let _epgData = {};
|
| 417 |
+
|
| 418 |
+
const STREAMS = {};
|
| 419 |
+
let _currentCh = null;
|
| 420 |
+
let _hls = null;
|
| 421 |
+
let _loading = false;
|
| 422 |
+
|
| 423 |
+
const s = document.createElement('style');
|
| 424 |
+
s.textContent = `
|
| 425 |
+
.vtv-wrap{margin:6px 4px;background:#111;border:1px solid #0066cc;border-radius:10px;overflow:hidden}
|
| 426 |
+
.vtv-head{display:flex;align-items:center;gap:8px;padding:8px 10px;background:linear-gradient(90deg,#003366,#1a1a1a)}
|
| 427 |
+
.vtv-title{font-size:13px;font-weight:800;color:#00ccff}
|
| 428 |
+
.vtv-badge{font-size:10px;font-weight:800;color:#00ccff;animation:vtvp 1.3s infinite}
|
| 429 |
+
@keyframes vtvp{0%,100%{opacity:1}50%{opacity:.3}}
|
| 430 |
+
.vtv-tabs{display:flex;gap:4px;padding:6px 8px 8px;overflow-x:auto;scrollbar-width:none;background:#0d1a2a}
|
| 431 |
+
.vtv-tabs::-webkit-scrollbar{display:none}
|
| 432 |
+
.vtv-tab{padding:6px 10px;background:#1a2a3a;border:1px solid #2a3a4a;border-radius:10px;color:#8ab4d8;font-size:10px;cursor:pointer;white-space:nowrap;flex-shrink:0;transition:all .2s}
|
| 433 |
+
.vtv-tab:hover{background:#0b4a7a;color:#fff}
|
| 434 |
+
.vtv-tab.on{background:#0066cc;border-color:#00ccff;color:#fff;font-weight:700}
|
| 435 |
+
.vtv-tab.off{opacity:.35;pointer-events:none}
|
| 436 |
+
.vtv-frame{position:relative;width:100%;aspect-ratio:16/9;background:#000;min-height:180px}
|
| 437 |
+
.vtv-frame video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
|
| 438 |
+
.vtv-err{display:flex;align-items:center;justify-content:center;height:180px;color:#888;font-size:12px;text-align:center;padding:20px;flex-direction:column;gap:8px}
|
| 439 |
+
.vtv-err button{background:#0066cc;border:none;color:#fff;padding:6px 14px;border-radius:8px;font-size:11px;cursor:pointer}
|
| 440 |
+
.vtv-load{display:flex;align-items:center;justify-content:center;height:180px;color:#00ccff;font-size:12px;flex-direction:column;gap:8px}
|
| 441 |
+
.vtv-spinner{width:24px;height:24px;border:2px solid #333;border-top-color:#00ccff;border-radius:50%;animation:vtvspin .8s linear infinite}
|
| 442 |
+
@keyframes vtvspin{to{transform:rotate(360deg)}}
|
| 443 |
+
.vtv-epg{margin:0;padding:6px 10px;background:#0a1628;border-top:1px solid #1a2a3a}
|
| 444 |
+
.vtv-epg-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}
|
| 445 |
+
.vtv-epg-title{font-size:10px;font-weight:700;color:#00ccff}
|
| 446 |
+
.vtv-epg-toggle{background:none;border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:2px 8px;border-radius:6px;cursor:pointer}
|
| 447 |
+
.vtv-epg-list{display:flex;gap:4px;overflow-x:auto;scrollbar-width:none;padding-bottom:4px}
|
| 448 |
+
.vtv-epg-list::-webkit-scrollbar{display:none}
|
| 449 |
+
.vtv-epg-item{flex:0 0 auto;padding:3px 6px;background:#1a2a3a;border-radius:4px;font-size:8px;color:#8ab4d8;white-space:nowrap}
|
| 450 |
+
.vtv-epg-item.now{background:#0066cc;color:#fff;font-weight:700}
|
| 451 |
+
.vtv-epg-item .epg-t{font-size:7px;color:#6a8aaa}
|
| 452 |
+
.vtv-epg-item.now .epg-t{color:#aaccee}
|
| 453 |
+
.vtv-epg-item .epg-n{color:#ccc;font-size:8px}
|
| 454 |
+
.vtv-epg-item.now .epg-n{color:#fff}
|
| 455 |
+
`;
|
| 456 |
+
document.head.appendChild(s);
|
| 457 |
+
|
| 458 |
+
function getCurrentHour(){
|
| 459 |
+
return new Date().getHours();
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
function buildEPGHTML(chId){
|
| 463 |
+
const epg = (_epgData && _epgData[chId]) || DEFAULT_EPG[chId] || [];
|
| 464 |
+
if(!epg.length) return '';
|
| 465 |
+
const curH = getCurrentHour();
|
| 466 |
+
let items = '';
|
| 467 |
+
epg.forEach(item => {
|
| 468 |
+
const itemH = parseInt(item.t.split(':')[0], 10);
|
| 469 |
+
const isNow = itemH <= curH && (itemH + 2) > curH;
|
| 470 |
+
items += `<div class="vtv-epg-item${isNow?' now':''}"><div class="epg-t">${item.t}</div><div class="epg-n">${item.n}</div></div>`;
|
| 471 |
+
});
|
| 472 |
+
return `<div class="vtv-epg" id="vtv-epg">' +
|
| 473 |
+
'<div class="vtv-epg-header"><span class="vtv-epg-title">📋 Lịch phát sóng</span>' +
|
| 474 |
+
'<button class="vtv-epg-toggle" onclick="window._vtvToggleEPG()">Ẩn/Hiện</button></div>' +
|
| 475 |
+
'<div class="vtv-epg-list" id="vtv-epg-list">' + items + '</div></div>';
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
window._vtvToggleEPG = function(){
|
| 479 |
+
const list = document.getElementById('vtv-epg-list');
|
| 480 |
+
if(list) list.style.display = list.style.display === 'none' ? 'flex' : 'none';
|
| 481 |
+
};
|
| 482 |
+
|
| 483 |
+
async function loadAllStreams(){
|
| 484 |
+
if(_loading) return;
|
| 485 |
+
_loading = true;
|
| 486 |
+
const loadEl = document.getElementById('vtv-load');
|
| 487 |
+
if(loadEl) loadEl.innerHTML = '<div class="vtv-spinner"></div>Đang tải danh sách kênh...';
|
| 488 |
+
|
| 489 |
+
try {
|
| 490 |
+
const r = await fetch('/api/vtv/streams', {signal: AbortSignal.timeout(10000)});
|
| 491 |
+
if(r.ok){
|
| 492 |
+
const data = await r.json();
|
| 493 |
+
CHANNELS.forEach(ch => {
|
| 494 |
+
const info = data[ch.id];
|
| 495 |
+
if(info && info.stream_url){
|
| 496 |
+
const url = '/api/proxy/m3u8/vtv?url=' + encodeURIComponent(info.stream_url);
|
| 497 |
+
STREAMS[ch.id] = [url];
|
| 498 |
+
} else {
|
| 499 |
+
STREAMS[ch.id] = [];
|
| 500 |
+
}
|
| 501 |
+
});
|
| 502 |
+
}
|
| 503 |
+
} catch(e) {
|
| 504 |
+
console.warn('VTV API error:', e);
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
// Fetch EPG for all channels in parallel
|
| 508 |
+
try {
|
| 509 |
+
await Promise.all(CHANNELS.map(async ch => {
|
| 510 |
+
try {
|
| 511 |
+
const r = await fetch('/api/vtv/epg/' + ch.id);
|
| 512 |
+
if(r.ok){
|
| 513 |
+
const d = await r.json();
|
| 514 |
+
if(d.schedule && d.schedule.length > 0){
|
| 515 |
+
_epgData[ch.id] = d.schedule;
|
| 516 |
+
}
|
| 517 |
+
}
|
| 518 |
+
} catch(e) {}
|
| 519 |
+
}));
|
| 520 |
+
} catch(e) {}
|
| 521 |
+
|
| 522 |
+
CHANNELS.forEach(ch => {
|
| 523 |
+
const tab = document.getElementById('vtvt-'+ch.id);
|
| 524 |
+
if(tab){
|
| 525 |
+
if(STREAMS[ch.id] && STREAMS[ch.id].length > 0){
|
| 526 |
+
tab.classList.remove('off');
|
| 527 |
+
tab.textContent = ch.name;
|
| 528 |
+
} else {
|
| 529 |
+
tab.style.opacity = '0.35';
|
| 530 |
+
tab.textContent = ch.name + ' ✕';
|
| 531 |
+
}
|
| 532 |
+
}
|
| 533 |
+
});
|
| 534 |
+
_loading = false;
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
function buildBlock(){
|
| 538 |
+
const w = document.createElement('div');
|
| 539 |
+
w.className = 'vtv-wrap';
|
| 540 |
+
w.id = 'vtv-block';
|
| 541 |
+
let tabs = '';
|
| 542 |
+
CHANNELS.forEach(ch => {
|
| 543 |
+
tabs += '<button class="vtv-tab off" id="vtvt-'+ch.id+'" onclick="window._vtvPlay(\''+ch.id+'\')">'+ch.name+'</button>';
|
| 544 |
+
});
|
| 545 |
+
w.innerHTML =
|
| 546 |
+
'<div class="vtv-head"><span class="vtv-title">📺 VTV Trực Tuyến</span><span class="vtv-badge">● LIVE</span></div>' +
|
| 547 |
+
'<div class="vtv-tabs">' + tabs + '</div>' +
|
| 548 |
+
'<div class="vtv-frame">' +
|
| 549 |
+
'<div class="vtv-load" id="vtv-load"><div class="vtv-spinner"></div>Đang tải danh sách kênh...</div>' +
|
| 550 |
+
'<video id="vtv-player" playsinline muted controls preload="auto" style="display:none"></video>' +
|
| 551 |
+
'<div class="vtv-err" id="vtv-err" style="display:none"><span id="vtv-err-msg">Không thể tải kênh</span><button onclick="window._vtvRetry()">Thử lại</button></div>' +
|
| 552 |
+
'</div>';
|
| 553 |
+
return w;
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
function pinBlock(){
|
| 557 |
+
const h = document.getElementById('view-home');
|
| 558 |
+
if(!h || document.getElementById('vtv-block')) return;
|
| 559 |
+
h.insertBefore(buildBlock(), h.firstChild);
|
| 560 |
+
loadAllStreams().then(() => {
|
| 561 |
+
const tryOrder = ['vtv6','vtv1','vtv2','vtv3','vtv4','vtv5','vtv7','vtv8','vtv9','vtv10'];
|
| 562 |
+
for(const chId of tryOrder){
|
| 563 |
+
if(STREAMS[chId] && STREAMS[chId].length > 0){
|
| 564 |
+
setTimeout(() => window._vtvPlay(chId), 300);
|
| 565 |
+
return;
|
| 566 |
+
}
|
| 567 |
+
}
|
| 568 |
+
});
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
window._vtvRetry = function(){
|
| 572 |
+
if(_currentCh) window._vtvPlay(_currentCh);
|
| 573 |
+
};
|
| 574 |
+
|
| 575 |
+
window._vtvPlay = function(chId){
|
| 576 |
+
const ch = CHANNELS.find(c => c.id === chId);
|
| 577 |
+
if(!ch) return;
|
| 578 |
+
_currentCh = chId;
|
| 579 |
+
document.querySelectorAll('.vtv-tab').forEach(t => t.classList.remove('on'));
|
| 580 |
+
const tab = document.getElementById('vtvt-'+chId);
|
| 581 |
+
if(tab) tab.classList.add('on');
|
| 582 |
+
const video = document.getElementById('vtv-player');
|
| 583 |
+
const errEl = document.getElementById('vtv-err');
|
| 584 |
+
const loadEl = document.getElementById('vtv-load');
|
| 585 |
+
const errMsg = document.getElementById('vtv-err-msg');
|
| 586 |
+
video.style.display = 'none';
|
| 587 |
+
errEl.style.display = 'none';
|
| 588 |
+
loadEl.style.display = 'flex';
|
| 589 |
+
loadEl.innerHTML = '<div class="vtv-spinner"></div>Đang kết nối ' + ch.name + '...';
|
| 590 |
+
if(_hls){ _hls.destroy(); _hls = null; }
|
| 591 |
+
const urls = STREAMS[chId] || [];
|
| 592 |
+
if(urls.length === 0){
|
| 593 |
+
loadEl.style.display = 'none';
|
| 594 |
+
errEl.style.display = 'flex';
|
| 595 |
+
if(chId === 'vtvprime'){
|
| 596 |
+
errMsg.textContent = 'VTVPrime: Kênh trả phí, không có luồng mi��n phí.';
|
| 597 |
+
} else {
|
| 598 |
+
errMsg.textContent = ch.name + ': Không tìm thấy luồng. Thử lại sau.';
|
| 599 |
+
}
|
| 600 |
+
return;
|
| 601 |
+
}
|
| 602 |
+
const epgEl = document.getElementById('vtv-epg');
|
| 603 |
+
if(epgEl) epgEl.remove();
|
| 604 |
+
const frame = document.querySelector('.vtv-frame');
|
| 605 |
+
if(frame){
|
| 606 |
+
const epgDiv = document.createElement('div');
|
| 607 |
+
epgDiv.innerHTML = buildEPGHTML(chId);
|
| 608 |
+
frame.appendChild(epgDiv.firstElementChild);
|
| 609 |
+
}
|
| 610 |
+
_tryPlay(video, urls, 0, ch.name, loadEl, errEl, errMsg);
|
| 611 |
+
};
|
| 612 |
+
|
| 613 |
+
function _tryPlay(video, urls, idx, name, loadEl, errEl, errMsg){
|
| 614 |
+
if(idx >= urls.length){
|
| 615 |
+
loadEl.style.display = 'none';
|
| 616 |
+
errEl.style.display = 'flex';
|
| 617 |
+
errMsg.textContent = name + ': Tất cả nguồn đều lỗi. Thử lại sau.';
|
| 618 |
+
return;
|
| 619 |
+
}
|
| 620 |
+
const src = urls[idx];
|
| 621 |
+
const sourceLabel = ' (' + (idx+1) + '/' + urls.length + ')';
|
| 622 |
+
loadEl.innerHTML = '<div class="vtv-spinner"></div>Đang kết nối ' + name + sourceLabel + '...';
|
| 623 |
+
if(typeof Hls !== 'undefined' && Hls.isSupported()){
|
| 624 |
+
const hls = new Hls({
|
| 625 |
+
enableWorker: true,
|
| 626 |
+
lowLatencyMode: false,
|
| 627 |
+
startLevel: -1,
|
| 628 |
+
capLevelToPlayerSize: true,
|
| 629 |
+
maxBufferLength: 60,
|
| 630 |
+
maxMaxBufferLength: 120,
|
| 631 |
+
backBufferLength: 30,
|
| 632 |
+
maxBufferHole: 0.5,
|
| 633 |
+
nudgeOffset: 0.2,
|
| 634 |
+
nudgeMaxRetry: 5,
|
| 635 |
+
fragLoadingMaxRetry: 6,
|
| 636 |
+
fragLoadingRetryDelay: 1000,
|
| 637 |
+
manifestLoadingMaxRetry: 4,
|
| 638 |
+
manifestLoadingRetryDelay: 1000,
|
| 639 |
+
xhrSetup: function(xhr, url){
|
| 640 |
+
if(url.includes('fptplay')){
|
| 641 |
+
xhr.setRequestHeader('Referer', 'https://fptplay.vn/');
|
| 642 |
+
xhr.setRequestHeader('Origin', 'https://fptplay.vn');
|
| 643 |
+
}
|
| 644 |
+
}
|
| 645 |
+
});
|
| 646 |
+
_hls = hls;
|
| 647 |
+
hls.loadSource(src);
|
| 648 |
+
hls.attachMedia(video);
|
| 649 |
+
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
| 650 |
+
video.play().catch(() => {});
|
| 651 |
+
loadEl.style.display = 'none';
|
| 652 |
+
video.style.display = 'block';
|
| 653 |
+
});
|
| 654 |
+
let recoverAttempts = 0;
|
| 655 |
+
hls.on(Hls.Events.ERROR, (ev, data) => {
|
| 656 |
+
if(data.fatal){
|
| 657 |
+
if(data.type === Hls.ErrorTypes.NETWORK_ERROR){
|
| 658 |
+
recoverAttempts++;
|
| 659 |
+
if(recoverAttempts <= 5){
|
| 660 |
+
setTimeout(() => hls.startLoad(), 2000);
|
| 661 |
+
} else {
|
| 662 |
+
hls.destroy();
|
| 663 |
+
_hls = null;
|
| 664 |
+
_tryPlay(video, urls, idx + 1, name, loadEl, errEl, errMsg);
|
| 665 |
+
}
|
| 666 |
+
} else if(data.type === Hls.ErrorTypes.MEDIA_ERROR){
|
| 667 |
+
try { hls.recoverMediaError(); } catch(e) {}
|
| 668 |
+
} else {
|
| 669 |
+
hls.destroy();
|
| 670 |
+
_hls = null;
|
| 671 |
+
_tryPlay(video, urls, idx + 1, name, loadEl, errEl, errMsg);
|
| 672 |
+
}
|
| 673 |
+
}
|
| 674 |
+
});
|
| 675 |
+
} else if(video.canPlayType('application/vnd.apple.mpegurl')){
|
| 676 |
+
video.src = src;
|
| 677 |
+
video.addEventListener('loadedmetadata', () => {
|
| 678 |
+
video.play().catch(() => {});
|
| 679 |
+
loadEl.style.display = 'none';
|
| 680 |
+
video.style.display = 'block';
|
| 681 |
+
}, {once: true});
|
| 682 |
+
video.addEventListener('error', () => {
|
| 683 |
+
_tryPlay(video, urls, idx + 1, name, loadEl, errEl, errMsg);
|
| 684 |
+
}, {once: true});
|
| 685 |
+
} else {
|
| 686 |
+
loadEl.style.display = 'none';
|
| 687 |
+
errEl.style.display = 'flex';
|
| 688 |
+
errMsg.textContent = 'Trình duyệt không hỗ trợ HLS';
|
| 689 |
+
}
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
const orig = window.loadHome;
|
| 693 |
+
if(orig && !orig.__vtvWrapped){
|
| 694 |
+
window.loadHome = async function(){
|
| 695 |
+
const r = await orig.apply(this, arguments);
|
| 696 |
+
try{ pinBlock(); }catch(e){}
|
| 697 |
+
return r;
|
| 698 |
+
};
|
| 699 |
+
window.loadHome.__vtvWrapped = true;
|
| 700 |
+
}
|
| 701 |
+
})();
|
| 702 |
+
</script>
|
| 703 |
+
<!-- hot_multi.js --><script src="/static/hot_multi.js"></script>
|
| 704 |
+
<!-- wc2026_v2.js --><script src="/static/wc2026_v2.js"></script>
|
| 705 |
+
<!-- live_mode.js --><script src="/static/live_mode.js"></script>
|
| 706 |
+
<!-- match_detail_v6.js --><script src="/static/match_detail_v6.js"></script>
|
| 707 |
+
<script>init();</script><!-- v1781058532 --></body></html>
|
static/index_v2.html
DELETED
|
@@ -1,80 +0,0 @@
|
|
| 1 |
-
<!DOCTYPE html>
|
| 2 |
-
<html lang="vi">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="utf-8">
|
| 5 |
-
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
|
| 6 |
-
<title>VNEWS - Tin Tức Việt Nam</title>
|
| 7 |
-
<meta name="description" content="Tin tức tổng hợp, bóng đá trực tiếp, video highlight, AI tóm tắt.">
|
| 8 |
-
<meta property="og:title" content="VNEWS - Tin Tức Việt Nam">
|
| 9 |
-
<meta property="og:image" content="https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg">
|
| 10 |
-
<link rel="canonical" href="https://bep40-vnews.hf.space">
|
| 11 |
-
<link rel="stylesheet" href="/static/wc2026.css">
|
| 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:60%;min-height:140px;background:rgba(18,18,18,.95);border-radius:14px 14px 0 0;z-index:10;overflow:clip;display:flex;flex-direction:column}.inline-cmt-header{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-bottom:1px solid #333;color:#5cb87a;font-size:12px;font-weight:700;flex-shrink:0}.inline-cmt-header button{background:none;border:0;color:#fff;font-size:16px;cursor:pointer}.inline-cmt-list{flex:1 1 auto;overflow-y:auto;padding:6px 10px;max-height:140px;min-height:40px}.inline-cmt-item{background:#222;border-radius:8px;padding:6px 8px;margin:4px 0;color:#ccc;font-size:11px;line-height:1.3}.inline-cmt-time{font-size:9px;color:#777;margin-right:6px}.inline-cmt-input{display:flex;gap:6px;padding:8px 10px;border-top:1px solid #333;flex-shrink:0;position:sticky;bottom:0;background:rgba(18,18,18,.98)}.inline-cmt-input input{flex:1;background:#222;border:1px solid #444;color:#eee;border-radius:16px;padding:7px 12px;font-size:11px;min-height:32px}.inline-cmt-input button{background:#2d8659;border:0;color:#fff;border-radius:16px;padding:7px 12px;font-size:11px;cursor:pointer;min-height:32px}.wc2026-section{margin:6px 4px;background:linear-gradient(135deg,#0d1117,#1a1a3a);border:1px solid #1a3a5a;border-radius:10px;overflow:hidden}.wc-header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:linear-gradient(90deg,#0b2e4a,#1a3a5a)}.wc-header h2{font-size:15px;color:#fff;margin:0}.wc-live-badge{font-size:10px;color:#e74c3c;font-weight:700;animation:wc-pulse 1.5s infinite}@keyframes wc-pulse{0%,100%{opacity:1}50%{opacity:.4}}.wc-tabs{display:flex;gap:4px;padding:8px 10px;overflow-x:auto;scrollbar-width:none}.wc-tabs::-webkit-scrollbar{display:none}.wc-tab{padding:5px 10px;background:#1a2a3a;border:1px solid #2a3a4a;border-radius:12px;color:#8ab4d8;font-size:10px;cursor:pointer;white-space:nowrap;flex-shrink:0}.wc-tab.active{background:#0b6bcb;border-color:#0b6bcb;color:#fff;font-weight:700}.wc-content{padding:8px 10px;max-height:500px;overflow-y:auto}.wc-news-grid{display:flex;flex-direction:column;gap:8px}.wc-news-item{display:flex;gap:8px;padding:8px;background:#1a2030;border-radius:8px;cursor:pointer}.wc-news-item:active{opacity:.8}.wc-news-img{flex:0 0 70px;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#222}.wc-news-img img{width:100%;height:100%;object-fit:cover}.wc-news-text{flex:1;min-width:0}.wc-news-title{font-size:11px;font-weight:700;color:#eee;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wc-news-via{font-size:9px;color:#6a9fca;margin-top:2px}.ls-section{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.ls-header{padding:7px 10px;display:flex;align-items:center;justify-content:space-between}.ls-header h3{color:#f0c040;font-size:13px;font-weight:800}.ls-tabs{display:flex;gap:4px;padding:0 10px 8px;overflow-x:auto;scrollbar-width:none}.ls-tabs::-webkit-scrollbar{display:none}.ls-tab{padding:4px 10px;background:#222;border:1px solid #333;border-radius:12px;color:#999;font-size:10px;white-space:nowrap;cursor:pointer;flex-shrink:0}.ls-tab.active{background:#2d8659;border-color:#2d8659;color:#fff;font-weight:700}.ls-content{max-height:420px;overflow-y:auto;padding:0 6px 8px;font-size:12px;color:#ddd}.ls-content ul{list-style:none;padding:0;margin:0}.ls-content .title-content{display:flex;gap:6px;align-items:center;background:#222;border-radius:4px;margin:4px 0;padding:5px 8px}.ls-content .title-content img{width:18px;height:18px}.ls-content .title-content strong{font-size:11px;color:#ccc}.ls-content .match-detail{padding:6px;border-bottom:1px solid #262626;cursor:pointer}.ls-content .match-detail:hover{background:#1a2a1f}.ls-content .match{display:flex;flex-wrap:wrap;align-items:center;gap:4px}.ls-content .datetime{width:100%;font-size:9px;color:#888}.ls-content .teams{display:flex;width:100%;align-items:center;gap:4px}.ls-content .team{flex:1;display:flex;align-items:center;gap:4px;min-width:0;text-decoration:none}.ls-content .team .name{font-size:11px;color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ls-content .team .logo img{width:18px;height:18px}.ls-content .home-team{justify-content:flex-end;text-align:right}.ls-content .status{flex:0 0 54px;text-align:center}.ls-content .status a{color:#fff;text-decoration:none;font-weight:800;font-size:12px}.ls-content .status .label{font-size:8px;color:#888;display:block}.ls-content .status .label.live{color:#e74c3c}.ls-content .info,.ls-content .btns{display:none}.ls-content table{width:100%;border-collapse:collapse;font-size:11px;color:#ccc}.ls-content table th{background:#222;color:#999;padding:5px 4px;font-size:10px;border-bottom:1px solid #333}.ls-content table td{padding:4px 3px;border-bottom:1px solid #1a1a1a}.ls-content table .team-name{display:flex;align-items:center;gap:4px}.ls-content table .team-name img{width:16px;height:16px}.ls-content table .pts{font-weight:800;color:#f0c040}.match-overlay{position:fixed;inset:0;background:#111;z-index:9999;display:none;flex-direction:column;overflow:auto}.match-overlay.active{display:flex}.mo-header{padding:10px;background:#1a1a1a;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:1}.mo-header h3{font-size:13px;color:#eee}.mo-close{background:none;border:0;color:#fff;font-size:22px;cursor:pointer}.mo-tabs{display:flex;gap:4px;padding:8px 10px;background:#1a1a1a;overflow-x:auto}.mo-tab{padding:5px 12px;background:#222;border:1px solid #333;border-radius:10px;color:#999;font-size:10px;cursor:pointer;white-space:nowrap}.mo-tab.active{background:#2d8659;color:#fff}.mo-body{padding:8px;overflow-x:auto;font-size:12px;color:#ddd}.mo-body ul{list-style:none;padding:0;margin:0}.mo-body li{padding:5px 0;border-bottom:1px solid #222}.featured-match{margin:6px 4px;background:linear-gradient(135deg,#1a2a1f,#0d1117);border:1px solid #2d8659;border-radius:10px;padding:12px;cursor:pointer}.fm-league{text-align:center;color:#5cb87a;font-size:9px;font-weight:700;text-transform:uppercase}.fm-teams{display:flex;align-items:center;justify-content:center;gap:10px;margin-top:6px}.fm-team{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px}.fm-team img{width:32px;height:32px;object-fit:contain}.fm-team span{font-size:10px;color:#ccc;text-align:center}.fm-score{font-size:22px;font-weight:900;min-width:60px;text-align:center;color:#fff}.fm-status{text-align:center;margin-top:6px;font-size:9px;color:#e74c3c;font-weight:700}.fm-status.upcoming{color:#f0c040}.ai-compose{margin:6px 4px;background:#141414;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.ai-compose-title{font-size:13px;font-weight:800;color:#5cb87a;margin-bottom:8px}.ai-compose-row{display:flex;gap:6px;margin-top:6px}.ai-compose input{flex:1;background:#222;border:1px solid #333;color:#eee;border-radius:18px;padding:9px 12px;font-size:12px;min-width:0}.ai-compose button{background:#2d8659;border:0;color:#fff;border-radius:18px;padding:9px 12px;font-size:11px;font-weight:700;cursor:pointer;white-space:nowrap}.ai-compose button.secondary{background:#333}.hot-topic-row{display:flex;gap:6px;overflow-x:auto;padding:4px 0;scrollbar-width:none}.hot-topic-row::-webkit-scrollbar{display:none}.hot-chip{flex:0 0 auto;background:#222;border:1px solid #333;color:#ddd;border-radius:16px;padding:5px 10px;font-size:11px;cursor:pointer;white-space:nowrap}.hot-chip:active{transform:scale(.96)}.hashtag-sources{margin:8px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:10px;padding:10px}.hashtag-sources h3{font-size:13px;color:#5cb87a;margin-bottom:8px}.hashtag-src-item{display:flex;gap:8px;padding:8px;background:#202020;border-radius:8px;margin:6px 0;cursor:pointer}.hashtag-src-item:active{opacity:.8}.hashtag-src-img{flex:0 0 80px;aspect-ratio:16/9;background:#333;border-radius:6px;overflow:hidden}.hashtag-src-img img{width:100%;height:100%;object-fit:cover}.hashtag-src-text{flex:1;min-width:0}.hashtag-src-title{font-size:12px;font-weight:700;color:#eee;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.hashtag-src-via{font-size:10px;color:#888;margin-top:2px}.hashtag-rewrite-btn{width:100%;margin-top:8px;background:#2d8659;border:0;color:#fff;padding:9px;border-radius:10px;font-size:12px;font-weight:700;cursor:pointer}.hashtag-load-more{width:100%;margin-top:8px;background:#222;border:1px solid #333;color:#ccc;padding:9px;border-radius:10px;font-size:12px;cursor:pointer}.hashtag-loading{display:flex;align-items:center;gap:8px;padding:12px;color:#888;font-size:12px}.hashtag-spinner{width:16px;height:16px;border:2px solid #333;border-top-color:#5cb87a;border-radius:50%;animation:ht-spin .8s linear infinite}@keyframes ht-spin{to{transform:rotate(360deg)}}.wall-item{flex:0 0 260px;background:#141414;border:1px solid #2b2b2b;border-radius:10px;padding:8px}.wall-item-new{animation:wall-flash 1.8s ease-out}@keyframes wall-flash{0%{border-color:#f0c040;box-shadow:0 0 18px rgba(240,192,64,.35)}30%{border-color:#f0c040;box-shadow:0 0 12px rgba(240,192,64,.2)}100%{border-color:#2b2b2b;box-shadow:none}}.wall-thumb{width:100%;aspect-ratio:16/9;border-radius:8px;background:#222;overflow:hidden;margin-bottom:6px;position:relative}.wall-thumb img{width:100%;height:100%;object-fit:cover}.wall-video-badge{position:absolute;top:4px;right:4px;background:rgba(45,134,89,.9);color:#fff;font-size:10px;padding:2px 6px;border-radius:6px;font-weight:700}.wall-title{font-size:12px;color:#5cb87a;font-weight:800;line-height:1.3;margin-bottom:4px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.wall-text{font-size:11px;color:#bbb;line-height:1.4;white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden}.wall-actions{display:flex;gap:6px;margin-top:8px}.wall-actions button{flex:1;border:1px solid #333;background:#222;color:#ddd;border-radius:14px;padding:6px 8px;font-size:10px;cursor:pointer}.wall-actions button.primary{background:#2d8659;border-color:#2d8659;color:#fff}#progress-toast{position:fixed;bottom:70px;left:50%;transform:translateX(-50%);background:#2d8659;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;z-index:99998;box-shadow:0 4px 12px rgba(0,0,0,.4);display:none;white-space:nowrap}.storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 4px}
|
| 15 |
-
</style>
|
| 16 |
-
</head>
|
| 17 |
-
<body>
|
| 18 |
-
<div class="header"><h1>📰 VNEWS</h1><p>Tin tức · Bóng đá LIVE · Highlight · AI · World Cup 2026</p></div>
|
| 19 |
-
<div class="cats" id="cat-bar"></div>
|
| 20 |
-
<div id="view-home" class="view active"><div class="loading">Đang tải...</div></div>
|
| 21 |
-
<div id="view-cat" class="view"></div>
|
| 22 |
-
<div id="view-video" class="view"></div>
|
| 23 |
-
<div id="view-tiktok" class="view"></div>
|
| 24 |
-
<div id="view-article" class="view"></div>
|
| 25 |
-
<div class="match-overlay" id="match-overlay">
|
| 26 |
-
<div class="mo-header"><h3 id="mo-title">Chi tiết trận đấu</h3><button class="mo-close" onclick="closeMatch()">✕</button></div>
|
| 27 |
-
<div class="mo-tabs"><span class="mo-tab active" onclick="loadMatchTab('detail')">📋 Chi tiết</span><span class="mo-tab" onclick="loadMatchTab('comm')">Diễn biến</span><span class="mo-tab" onclick="loadMatchTab('stats')">Thống kê</span></div>
|
| 28 |
-
<div class="mo-body" id="mo-body"><div class="loading">Đang tải...</div></div>
|
| 29 |
-
</div>
|
| 30 |
-
<div id="progress-toast"></div>
|
| 31 |
-
<script>
|
| 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('[data-cat="'+id+'"]')?.classList.add('active');document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.querySelectorAll('video').forEach(v=>{v.pause();if(v._hls){v._hls.destroy();v._hls=null}});document.querySelectorAll('iframe[data-yt-src]').forEach(f=>{f.src=''});if(id==='home')document.getElementById('view-home').classList.add('active');else if(id==='news-all'){document.getElementById('view-cat').classList.add('active');loadNewsTab()}else{document.getElementById('view-cat').classList.add('active');loadCat(id)}}
|
| 36 |
-
function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}}
|
| 37 |
-
function doShare(title,url,img,postId){
|
| 38 |
-
var shareUrl;
|
| 39 |
-
if(postId){
|
| 40 |
-
shareUrl = SPACE+'/s?post_id='+encodeURIComponent(postId)+'&title='+encodeURIComponent(title);
|
| 41 |
-
} else {
|
| 42 |
-
shareUrl = SPACE+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||'');
|
| 43 |
-
}
|
| 44 |
-
// Use clipboard API first (modern, works on Chrome mobile 85+)
|
| 45 |
-
if(navigator.clipboard && navigator.clipboard.writeText){
|
| 46 |
-
navigator.clipboard.writeText(shareUrl).then(function(){
|
| 47 |
-
toast('📋 Đã sao chép link!');
|
| 48 |
-
try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){}
|
| 49 |
-
}).catch(function(){
|
| 50 |
-
try{
|
| 51 |
-
var ta=document.createElement('textarea');ta.value=shareUrl;ta.style.position='fixed';ta.style.left='-9999px';ta.style.top='-9999px';ta.style.opacity='0';
|
| 52 |
-
document.body.appendChild(ta);ta.select();ta.setSelectionRange(0,99999);
|
| 53 |
-
if(document.execCommand('copy')){toast('📋 Đã sao chép link!');}else{prompt('📋 Sao chép link:', shareUrl);}
|
| 54 |
-
document.body.removeChild(ta);
|
| 55 |
-
}catch(e){prompt('📋 Sao chép link:', shareUrl);}
|
| 56 |
-
try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){}
|
| 57 |
-
});
|
| 58 |
-
} else {
|
| 59 |
-
try{
|
| 60 |
-
var ta=document.createElement('textarea');ta.value=shareUrl;ta.style.position='fixed';ta.style.left='-9999px';ta.style.top='-9999px';ta.style.opacity='0';
|
| 61 |
-
document.body.appendChild(ta);ta.select();ta.setSelectionRange(0,99999);
|
| 62 |
-
if(document.execCommand('copy')){toast('📋 Đã sao chép link!');}else{prompt('📋 Sao chép link:', shareUrl);}
|
| 63 |
-
document.body.removeChild(ta);
|
| 64 |
-
}catch(e){prompt('📋 Sao chép link:', shareUrl);}
|
| 65 |
-
try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){}
|
| 66 |
-
}
|
| 67 |
-
}
|
| 68 |
-
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)});}
|
| 69 |
-
var SPACE=location.origin;
|
| 70 |
-
</script>
|
| 71 |
-
<script src="/static/app_v2.js?v=20260722"></script>
|
| 72 |
-
<script src="/static/yt_live.js"></script>
|
| 73 |
-
<script src="/static/vtv_init.js"></script>
|
| 74 |
-
<script src="/static/hot_multi.js?v=1"></script>
|
| 75 |
-
<script src="/static/wc2026_v2.js"></script>
|
| 76 |
-
<script src="/static/live_mode.js"></script>
|
| 77 |
-
<script src="/static/match_detail_v6.js"></script>
|
| 78 |
-
<script>init();loadHome();</script>
|
| 79 |
-
</body>
|
| 80 |
-
</html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/index_v3.html
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="vi">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
|
| 6 |
+
<title>VNEWS - Tin Tức Việt Nam</title>
|
| 7 |
+
<meta name="description" content="Tin tức tổng hợp, bóng đá trực tiếp, video highlight, AI tóm tắt.">
|
| 8 |
+
<meta property="og:title" content="VNEWS - Tin Tức Việt Nam">
|
| 9 |
+
<meta property="og:image" content="https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg">
|
| 10 |
+
<link rel="canonical" href="https://bep40-vnews.hf.space">
|
| 11 |
+
<link rel="stylesheet" href="/static/wc2026.css">
|
| 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>
|
| 20 |
+
<div id="view-video" class="view"></div>
|
| 21 |
+
<div id="view-tiktok" class="view"></div>
|
| 22 |
+
<div id="view-article" class="view"></div>
|
| 23 |
+
<div class="match-overlay" id="match-overlay">
|
| 24 |
+
<div class="mo-header"><h3 id="mo-title">Chi tiết trận đấu</h3><button class="mo-close" onclick="closeMatch()">✕</button></div>
|
| 25 |
+
<div class="mo-tabs"><span class="mo-tab active" onclick="loadMatchTab('detail')">📋 Chi tiết</span><span class="mo-tab" onclick="loadMatchTab('comm')">Diễn biến</span><span class="mo-tab" onclick="loadMatchTab('stats')">Thống kê</span></div>
|
| 26 |
+
<div class="mo-body" id="mo-body"><div class="loading">Đang tải...</div></div>
|
| 27 |
+
</div>
|
| 28 |
+
<div id="progress-toast"></div>
|
| 29 |
+
<script>
|
| 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)});await loadHome()}
|
| 37 |
+
var SPACE=location.origin;
|
| 38 |
+
</script>
|
| 39 |
+
<script src="/static/app_v4.js?v=1781060367"></script>
|
| 40 |
+
<script src="/static/hot_multi.js?v=1781059323"></script>
|
| 41 |
+
<script src="/static/wc2026_v2.js?v=1781059323"></script>
|
| 42 |
+
<script src="/static/live_mode.js?v=1781059323"></script>
|
| 43 |
+
<script src="/static/match_detail_v6.js?v=1781059323"></script>
|
| 44 |
+
<script>init();</script>
|
| 45 |
+
</body>
|
| 46 |
+
</html>
|