bep40 commited on
Commit
85f8b9c
·
verified ·
1 Parent(s): 3ef17e9

Restore to commit 6c739c9

Browse files

Khôi phục toàn bộ code về commit 6c739c9

This view is limited to 50 files because it contains too many changes.   See raw diff
.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
CHANGELOG.md ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg fonts-dejavu-core && rm -rf /var/lib/apt/lists/*
6
+ RUN pip install --no-cache-dir "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
+ CMD ["uvicorn", "_run:app", "--host", "0.0.0.0", "--port", "7860", "--reload"]
README.md ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # VNEWS
2
+
3
+ Trigger rebuild after bug fixes.
TRIGGER_REBUILD_V6.md ADDED
@@ -0,0 +1 @@
 
 
1
+ # Rebuild trigger v6 - 1780971280.8544343
_run.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from app_v2_entry import app # v5-stable inline bongda proxy
_static_build_trigger.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ # Force rebuild - match_detail debug v2
ai_ext.py ADDED
@@ -0,0 +1,1164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """VNEWS AI Extension - rewrite + auto short video generation.
2
+ Imported by app_v2_entry.py to register /api/rewrite_share, /api/topic_post,
3
+ /api/ai_wall, /api/wall, /api/ai/short endpoints on the main FastAPI app.
4
+
5
+ Uses main.py's WALL_FILE (wall_posts.json) for unified data store.
6
+ TTS: edge-tts (HoaiMy female, NamMinh male) with speed control + gTTS fallback.
7
+ """
8
+ import os, re, json, time, random, html as html_lib, subprocess, asyncio
9
+ from urllib.parse import quote_plus, quote, urlparse, urljoin
10
+ from typing import Optional, List, Dict
11
+ import requests
12
+ from bs4 import BeautifulSoup
13
+ from fastapi import Request, Query
14
+ from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
15
+
16
+ from main import app
17
+
18
+ # Import wall store from main.py so we read/write the SAME file
19
+ try:
20
+ from main import _load_wall, _save_wall, _web_context # noqa: F401
21
+ except ImportError:
22
+ _data_dir = "/data" if os.path.isdir("/data") else "/app/data"
23
+ _wall_file = os.path.join(_data_dir, "wall_posts.json")
24
+ def _load_wall():
25
+ try:
26
+ if os.path.exists(_wall_file):
27
+ with open(_wall_file, "r", encoding="utf-8") as f:
28
+ return json.load(f)
29
+ except Exception:
30
+ pass
31
+ return []
32
+ def _save_wall(posts):
33
+ try:
34
+ os.makedirs(os.path.dirname(_wall_file), exist_ok=True)
35
+ tmp = _wall_file + ".tmp"
36
+ with open(tmp, "w", encoding="utf-8") as f:
37
+ json.dump(posts[:100], f, ensure_ascii=False)
38
+ os.replace(tmp, _wall_file)
39
+ except Exception:
40
+ pass
41
+ def _web_context(topic):
42
+ return ""
43
+
44
+ try:
45
+ from huggingface_hub import AsyncInferenceClient
46
+ except Exception:
47
+ AsyncInferenceClient = None
48
+ try:
49
+ from gtts import gTTS
50
+ except Exception:
51
+ gTTS = None
52
+ try:
53
+ from PIL import Image, ImageDraw, ImageFont
54
+ except Exception:
55
+ Image = ImageDraw = ImageFont = None
56
+ try:
57
+ import edge_tts
58
+ except Exception:
59
+ edge_tts = None
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 ![alt](url)
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 ADDED
@@ -0,0 +1,366 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, re, subprocess, html as html_lib, json
2
+ from urllib.parse import quote_plus, urlparse, parse_qs, unquote
3
+ import requests
4
+ import ai_patch as prev
5
+ from ai_patch import app
6
+ from fastapi import Request
7
+ from fastapi.responses import JSONResponse, HTMLResponse, FileResponse
8
+
9
+ base = prev.base
10
+
11
+
12
+ def clean(s):
13
+ return re.sub(r"\s+", " ", html_lib.unescape(s or "")).strip()
14
+
15
+
16
+ def _is_real_article_text(raw):
17
+ raw = clean(raw)
18
+ if len(raw) < 500:
19
+ return False
20
+ # Reject search-result/title-only pages: need several real sentences.
21
+ sentences = re.split(r"(?<=[\.\!\?])\s+", raw)
22
+ long_sentences = [s for s in sentences if len(s) > 45]
23
+ return len(long_sentences) >= 5
24
+
25
+
26
+ def _extract_ddg_url(href):
27
+ if not href:
28
+ return ""
29
+ if href.startswith("//"):
30
+ href = "https:" + href
31
+ if "duckduckgo.com/l/" in href:
32
+ try:
33
+ qs = parse_qs(urlparse(href).query)
34
+ if qs.get("uddg"):
35
+ return unquote(qs["uddg"][0])
36
+ except Exception:
37
+ pass
38
+ return href
39
+
40
+
41
+ def _ddg_article_urls(topic, limit=12):
42
+ urls = []
43
+ try:
44
+ q = quote_plus(topic + " tin tức bài viết phân tích")
45
+ r = requests.get("https://html.duckduckgo.com/html/?q=" + q, headers=base.HEADERS, timeout=18)
46
+ r.encoding = "utf-8"
47
+ from bs4 import BeautifulSoup
48
+ soup = BeautifulSoup(r.text, "lxml")
49
+ for a in soup.select("a.result__a"):
50
+ u = _extract_ddg_url(a.get("href", ""))
51
+ if not u.startswith("http"):
52
+ continue
53
+ if any(bad in u for bad in ["google.com", "youtube.com", "facebook.com", "x.com", "twitter.com"]):
54
+ continue
55
+ if u not in urls:
56
+ urls.append(u)
57
+ if len(urls) >= limit:
58
+ break
59
+ except Exception:
60
+ pass
61
+ return urls
62
+
63
+
64
+ def _rss_article_urls(topic, limit=10):
65
+ out = []
66
+ try:
67
+ url = "https://news.google.com/rss/search?q=" + quote_plus(topic) + "&hl=vi&gl=VN&ceid=VN:vi"
68
+ r = requests.get(url, headers=base.HEADERS, timeout=15)
69
+ r.encoding = "utf-8"
70
+ from bs4 import BeautifulSoup
71
+ soup = BeautifulSoup(r.text, "xml")
72
+ for it in soup.find_all("item")[:limit]:
73
+ title = it.find("title").get_text(" ", strip=True) if it.find("title") else ""
74
+ link = it.find("link").get_text(strip=True) if it.find("link") else ""
75
+ src = it.find("source").get_text(" ", strip=True) if it.find("source") else base._domain(link)
76
+ if title and link:
77
+ out.append({"title": title, "url": link, "via": src, "excerpt": title})
78
+ except Exception:
79
+ pass
80
+ return out
81
+
82
+
83
+ def _topic_source_articles(topic, limit=5):
84
+ """Scrape actual article bodies. Do not accept title-only sources."""
85
+ candidates = []
86
+ seen = set()
87
+
88
+ # 1) DuckDuckGo actual result URLs are usually more directly scrapable.
89
+ for u in _ddg_article_urls(topic, limit=14):
90
+ if u not in seen:
91
+ seen.add(u)
92
+ candidates.append({"url": u, "title": "", "via": base._domain(u)})
93
+
94
+ # 2) Add base web_context sources.
95
+ try:
96
+ _ctx, srcs = base.web_context(topic, limit=8)
97
+ for s in srcs or []:
98
+ u = s.get("url") or ""
99
+ if u.startswith("http") and u not in seen:
100
+ seen.add(u)
101
+ candidates.append(s)
102
+ except Exception:
103
+ pass
104
+
105
+ # 3) Google News RSS fallback last.
106
+ for s in _rss_article_urls(topic, limit=10):
107
+ u = s.get("url") or ""
108
+ if u.startswith("http") and u not in seen:
109
+ seen.add(u)
110
+ candidates.append(s)
111
+
112
+ out = []
113
+ for s in candidates[:24]:
114
+ url = s.get("url") or ""
115
+ try:
116
+ page = base.scrape_any_url(url)
117
+ raw = (page.get("summary", "") + "\n" + page.get("text", "")).strip()
118
+ if not _is_real_article_text(raw):
119
+ continue
120
+ title = page.get("title") or s.get("title") or url
121
+ via = page.get("via") or s.get("via") or base._domain(url)
122
+ out.append({
123
+ "title": title,
124
+ "url": url,
125
+ "raw": raw,
126
+ "image": page.get("image") or "",
127
+ "via": via,
128
+ "source": {"title": title, "url": url, "excerpt": raw[:700], "via": via}
129
+ })
130
+ if len(out) >= limit:
131
+ break
132
+ except Exception:
133
+ continue
134
+ return out[:limit]
135
+
136
+
137
+ def sentence_split(text):
138
+ text = re.sub(r"^[•\-\*]\s*", "", text or "", flags=re.M)
139
+ text = re.sub(r"\n+", ". ", text)
140
+ parts = []
141
+ for s in re.split(r"(?<=[\.\!\?])\s+", text):
142
+ s = clean(s)
143
+ if len(s) >= 8:
144
+ parts.append(s)
145
+ return parts
146
+
147
+
148
+ def srt_time(sec):
149
+ ms = int((sec - int(sec)) * 1000)
150
+ sec = int(sec)
151
+ return f"{sec//3600:02d}:{(sec%3600)//60:02d}:{sec%60:02d},{ms:03d}"
152
+
153
+
154
+ def parse_timecode(t):
155
+ # 00:00:01.234 or 00:00:01,234
156
+ t = t.replace(',', '.')
157
+ parts = t.split(':')
158
+ if len(parts) == 3:
159
+ return int(parts[0])*3600 + int(parts[1])*60 + float(parts[2])
160
+ if len(parts) == 2:
161
+ return int(parts[0])*60 + float(parts[1])
162
+ return float(parts[0])
163
+
164
+
165
+ def convert_vtt_to_scaled_srt(vtt_path, srt_path, speed=1.2):
166
+ try:
167
+ txt = open(vtt_path, 'r', encoding='utf-8').read().splitlines()
168
+ cues = []
169
+ i = 0
170
+ while i < len(txt):
171
+ line = txt[i].strip()
172
+ if '-->' in line:
173
+ a, b = [x.strip().split()[0] for x in line.split('-->')[:2]]
174
+ start = parse_timecode(a) / speed
175
+ end = parse_timecode(b) / speed
176
+ i += 1
177
+ texts = []
178
+ while i < len(txt) and txt[i].strip():
179
+ texts.append(txt[i].strip())
180
+ i += 1
181
+ s = clean(' '.join(texts))
182
+ if s:
183
+ cues.append((start, end, s))
184
+ i += 1
185
+ if not cues:
186
+ return False
187
+ with open(srt_path, 'w', encoding='utf-8') as f:
188
+ for idx, (st, en, s) in enumerate(cues, 1):
189
+ if en <= st:
190
+ en = st + 1.2
191
+ f.write(f"{idx}\n{srt_time(st)} --> {srt_time(en)}\n{s}\n\n")
192
+ return True
193
+ except Exception:
194
+ return False
195
+
196
+
197
+ def write_weighted_srt(script, path, total_duration):
198
+ subs = sentence_split(script)
199
+ if not subs:
200
+ subs = [clean(script)[:140] or "VNEWS"]
201
+ total_chars = max(1, sum(len(x) for x in subs))
202
+ usable = max(2.0, float(total_duration) - 1.0)
203
+ cur = 0.5
204
+ with open(path, "w", encoding="utf-8") as f:
205
+ for i, s in enumerate(subs, 1):
206
+ dur = max(1.8, min(7.0, usable * len(s) / total_chars))
207
+ start = cur
208
+ end = min(total_duration - 0.15, cur + dur)
209
+ cur = end + 0.18
210
+ f.write(f"{i}\n{srt_time(start)} --> {srt_time(end)}\n{s}\n\n")
211
+ if cur >= total_duration - 0.2:
212
+ break
213
+
214
+
215
+ def tts_script_full(post, emotion):
216
+ title = clean(post.get("title", ""))
217
+ text = clean(post.get("text", ""))
218
+ text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip()
219
+ prefix = {
220
+ "urgent": "Tin nhanh.",
221
+ "warm": "Câu chuyện đáng chú ý.",
222
+ "serious": "Bản tin nghiêm túc.",
223
+ "energetic": "Cập nhật nổi bật.",
224
+ }.get(emotion, "")
225
+ script = f"{prefix} {title}. {text}".strip()
226
+ # Keep complete wall summary. Only trim pathological payloads, on sentence boundary.
227
+ if len(script) > 3600:
228
+ tmp = script[:3600]
229
+ cut = max(tmp.rfind("."), tmp.rfind("!"), tmp.rfind("?"))
230
+ script = tmp[:cut + 1] if cut > 1600 else tmp
231
+ script = re.sub(r"([\.\!\?])\s*", r"\1\n", script)
232
+ script = re.sub(r"\n{2,}", "\n", script).strip()
233
+ return script
234
+
235
+
236
+ _PATCH = {('/api/topic_post','POST'),('/api/ai/short/{post_id}','POST'),('/api/ai/short-file/{file_id}','GET'),('/','GET')}
237
+ app.router.routes = [r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in _PATCH)]
238
+
239
+
240
+ @app.post('/api/topic_post')
241
+ async def topic_post_aggregate(request: Request):
242
+ body = await request.json()
243
+ topic = base._clean_text(body.get('topic',''))
244
+ if not topic:
245
+ return JSONResponse({'error':'missing topic'}, status_code=400)
246
+ articles = _topic_source_articles(topic, limit=5)
247
+ if not articles:
248
+ return JSONResponse({'error':'Không scrape được nội dung bài viết thật cho chủ đề này. Hãy thử chủ đề cụ thể hơn hoặc dán URL trực tiếp.'}, status_code=422)
249
+ source_blocks = []
250
+ sources = []
251
+ image = ""
252
+ for i, art in enumerate(articles, 1):
253
+ raw = art.get('raw','')
254
+ source_blocks.append(f"[Nguồn {i}] {art.get('title','')} ({art.get('via','')})\n{raw[:3000]}")
255
+ sources.append(art.get('source') or {'title': art.get('title'), 'url': art.get('url'), 'via': art.get('via'), 'excerpt': raw[:600]})
256
+ if not image and art.get('image'):
257
+ image = art.get('image')
258
+ ctx = "\n\n".join(source_blocks)
259
+ prompt = f"""Bạn là biên tập viên tổng hợp tin tức tiếng Việt.
260
+
261
+ Chủ đề: {topic}
262
+
263
+ NHIỆM VỤ:
264
+ - Đọc nội dung của TẤT CẢ các bài nguồn bên dưới.
265
+ - Tổng hợp thành 1 bản tóm tắt chung duy nhất, giống cách tóm tắt qua URL.
266
+ - Không tạo mỗi tiêu đề thành một bài riêng.
267
+ - Không chỉ liệt kê tiêu đề; phải dựa vào nội dung trong từng bài.
268
+ - Không lặp ý giữa các nguồn.
269
+ - Tối đa 6 gạch đầu dòng, mỗi dòng 1 câu rõ ràng.
270
+ - Nếu các nguồn có góc nhìn khác nhau, gộp lại thành ý tổng hợp.
271
+ - Cuối cùng thêm dòng: Nguồn tham khảo: tên website.
272
+
273
+ Nội dung nguồn:
274
+ {ctx[:16000]}"""
275
+ text = await prev.base.qwen_generate(prompt, image_url=image or None, max_tokens=1100)
276
+ text = prev._postprocess_ai_text(text, max_units=7)
277
+ if 'Nguồn tham khảo:' not in text:
278
+ text += '\n\n' + prev._source_line(sources)
279
+ post = base.make_post('Tổng hợp: ' + topic, text, image or base.pollinations_image_url(topic), '', 'topic_aggregate', sources=sources[:5])
280
+ posts = base._load_ai_wall(); posts.insert(0, post); base._save_ai_wall(posts)
281
+ return JSONResponse({'post': post, 'count_sources': len(sources)})
282
+
283
+
284
+ @app.post('/api/ai/short/{post_id}')
285
+ async def ai_short_full(post_id: str, request: Request):
286
+ try:
287
+ body = await request.json()
288
+ except Exception:
289
+ body = {}
290
+ voice = str(body.get('voice','nu')).lower().strip()
291
+ emotion = str(body.get('emotion','neutral')).lower().strip()
292
+ speed = max(0.85, min(1.35, float(body.get('speed', 1.2) or 1.2)))
293
+ posts = base._load_ai_wall()
294
+ post = next((p for p in posts if str(p.get('id')) == str(post_id)), None)
295
+ if not post:
296
+ return JSONResponse({'error':'post not found'}, status_code=404)
297
+ os.makedirs(base.SHORTS_DIR, exist_ok=True)
298
+ suffix = f"_{voice}_{emotion}_{str(speed).replace('.', 'p')}_fullv2"
299
+ out_mp4 = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix) + '.mp4')
300
+ if os.path.exists(out_mp4):
301
+ post['video'] = '/api/ai/short-file/' + post_id + suffix
302
+ base._save_ai_wall(posts)
303
+ return JSONResponse({'video': post['video'], 'speed': speed, 'subtitles': True})
304
+ work = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix)); os.makedirs(work, exist_ok=True)
305
+ img = os.path.join(work,'image.jpg'); frame = os.path.join(work,'frame.jpg'); audio = os.path.join(work,'voice.mp3'); audio_fast=os.path.join(work,'voice_fast.mp3'); srt=os.path.join(work,'subtitles.srt'); vtt=os.path.join(work,'subtitles.vtt')
306
+ try:
307
+ base._download_image(post.get('img'), post.get('title','AI news'), img)
308
+ prev._make_short_frame_full(post, img, frame)
309
+ script = tts_script_full(post, emotion)
310
+ edge_voice = {'nam':'vi-VN-NamMinhNeural','male':'vi-VN-NamMinhNeural','nu':'vi-VN-HoaiMyNeural','female':'vi-VN-HoaiMyNeural','mien-nam':'vi-VN-HoaiMyNeural'}.get(voice,'vi-VN-HoaiMyNeural')
311
+ used_edge = False
312
+ try:
313
+ subprocess.run(['python','-m','edge_tts','--voice',edge_voice,'--text',script,'--write-media',audio,'--write-subtitles',vtt], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=260)
314
+ used_edge = True
315
+ except Exception:
316
+ tld = 'com.vn' if voice in ('nu','female','mien-nam') else 'com'
317
+ try:
318
+ base.gTTS(script, lang='vi', tld=tld, slow=False).save(audio)
319
+ except TypeError:
320
+ base.gTTS(script, lang='vi', slow=False).save(audio)
321
+ subprocess.run(['ffmpeg','-y','-i',audio,'-filter:a',f'atempo={speed}','-vn',audio_fast], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=220)
322
+ duration = 45.0
323
+ try:
324
+ pr = subprocess.run(['ffprobe','-v','error','-show_entries','format=duration','-of','default=noprint_wrappers=1: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
328
+ if used_edge and os.path.exists(vtt):
329
+ ok = convert_vtt_to_scaled_srt(vtt, srt, speed=speed)
330
+ if not ok:
331
+ write_weighted_srt(script, srt, duration)
332
+ else:
333
+ write_weighted_srt(script, srt, duration)
334
+ vf = "scale=1080:1920,subtitles='{}':force_style='FontName=DejaVu Sans,FontSize=16,PrimaryColour=&H00FFFFFF,OutlineColour=&HAA000000,BorderStyle=1,Outline=1.5,Shadow=0,Alignment=2,MarginV=42'".format(srt.replace("'", "\\'"))
335
+ cmd = ['ffmpeg','-y','-loop','1','-i',frame,'-i',audio_fast,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k','-vf',vf,out_mp4]
336
+ subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=420)
337
+ post['video'] = '/api/ai/short-file/' + post_id + suffix
338
+ post['short_voice'] = voice; post['short_emotion'] = emotion; post['short_speed'] = speed; post['short_subtitles'] = True
339
+ base._save_ai_wall(posts)
340
+ return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': True, 'duration': duration})
341
+ except Exception as e:
342
+ return JSONResponse({'error':'Không tạo được shorts: '+str(e)[:180]}, status_code=500)
343
+
344
+
345
+ @app.get('/api/ai/short-file/{file_id}')
346
+ def ai_short_file_full(file_id: str):
347
+ path = os.path.join(base.SHORTS_DIR, base._safe_name(file_id) + '.mp4')
348
+ if not os.path.exists(path):
349
+ return JSONResponse({'error':'not found'}, status_code=404)
350
+ return FileResponse(path, media_type='video/mp4', filename=f'vnews-ai-{file_id}.mp4')
351
+
352
+
353
+ app.router.routes = [r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
354
+
355
+ @app.get('/')
356
+ async def index_fix2():
357
+ with open('/app/static/index.html','r',encoding='utf-8') as f:
358
+ html = f.read()
359
+ inject = prev.PATCH_INJECT + r'''
360
+ <script>
361
+ (function(){
362
+ window.createTopicPost=function(){let inp=document.getElementById('ai-topic-input');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&j.post){window.location.reload();alert('Đã tổng hợp NỘI DUNG các bài nguồn thành 1 bản tóm tắt trên Tường AI');}else alert(j.error||'Lỗi tạo bài')}).catch(e=>alert(e.message||'Lỗi tạo bài'));};
363
+ })();
364
+ </script>
365
+ '''
366
+ return HTMLResponse(html.replace('</body>', inject+'\n</body>'))
ai_patch.py ADDED
@@ -0,0 +1,751 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import time
4
+ import random
5
+ import json
6
+ import html as html_lib
7
+ import subprocess
8
+ import requests
9
+ import ai_ext as base
10
+ from ai_ext import app
11
+ from fastapi import Request
12
+ from fastapi.responses import JSONResponse, HTMLResponse, FileResponse
13
+ from bs4 import BeautifulSoup
14
+ from urllib.parse import quote_plus
15
+
16
+ try:
17
+ from PIL import Image, ImageDraw, ImageFont
18
+ except Exception:
19
+ Image = ImageDraw = ImageFont = None
20
+
21
+
22
+ def _clean(s):
23
+ s = html_lib.unescape(s or "")
24
+ s = re.sub(r"[ \t]+", " ", s)
25
+ s = re.sub(r"\n{3,}", "\n\n", s)
26
+ return s.strip()
27
+
28
+
29
+ def _norm(s):
30
+ s = s.lower()
31
+ s = re.sub(r"[^\wÀ-ỹ\s]", " ", s)
32
+ s = re.sub(r"\s+", " ", s).strip()
33
+ return s
34
+
35
+
36
+ def _similar(a, b):
37
+ ta = set(_norm(a).split())
38
+ tb = set(_norm(b).split())
39
+ if not ta or not tb:
40
+ return False
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:
58
+ break
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
66
+ drop_prefixes = (
67
+ "dưới đây", "sau đây", "bài viết", "tôi sẽ", "mình sẽ",
68
+ "tóm tắt bài", "tiêu đề:", "sapo:", "nội dung:", "kết luận:"
69
+ )
70
+ raw_lines = []
71
+ for line in re.split(r"\n+", text):
72
+ line = _clean(line)
73
+ if not line:
74
+ continue
75
+ low = line.lower().strip()
76
+ if any(low.startswith(p) and len(line) < 80 for p in drop_prefixes):
77
+ continue
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]
88
+ title = ""
89
+ if raw_lines and len(raw_lines[0]) <= 90 and not raw_lines[0].startswith(("-", "•", "*")):
90
+ title = raw_lines[0]
91
+ units = [u for u in units if not _similar(u, title)]
92
+ body = "\n".join("• " + u for u in units[:max_units])
93
+ return (title + "\n\n" + body).strip() if title else body
94
+
95
+
96
+ def _fallback_summary_from_prompt(prompt, max_units=6):
97
+ text = prompt or ""
98
+ for marker in ["Nội dung nguồn:", "Nội dung bài:", "Nội dung gốc:", "Nội dung:", "Nguồn/bối cảnh internet:"]:
99
+ if marker in text:
100
+ text = text.split(marker, 1)[1]
101
+ break
102
+ text = re.sub(r"https?://\S+", "", text)
103
+ text = re.sub(r"\s+", " ", text).strip()
104
+ sentences = re.split(r"(?<=[\.\!\?])\s+(?=[A-ZÀ-Ỹ0-9])", text)
105
+ candidates = []
106
+ for s in sentences:
107
+ s = _clean(s)
108
+ if 45 <= len(s) <= 260:
109
+ candidates.append(s)
110
+ units = _dedupe_units(candidates, max_units=max_units)
111
+ if units:
112
+ return "\n".join("• " + u for u in units)
113
+ if text:
114
+ return "• " + text[:700].rsplit(" ", 1)[0]
115
+ return "• Không có đủ nội dung nguồn để tóm tắt."
116
+
117
+
118
+ def _source_line(sources):
119
+ names = []
120
+ for s in (sources or [])[:5]:
121
+ via = s.get("via") or base._domain(s.get("url", "")) or s.get("title", "")
122
+ if via and via not in names:
123
+ names.append(via)
124
+ return "Nguồn tham khảo: " + ", ".join(names[:5]) if names else "Nguồn tham khảo: tổng hợp internet"
125
+
126
+
127
+ def _make_summary_prompt(title, raw, source_hint=""):
128
+ return f"""Bạn là biên tập viên tóm tắt tin tức tiếng Việt.
129
+
130
+ NHIỆM VỤ BẮT BUỘC:
131
+ - Chỉ TÓM TẮT nội dung chính, KHÔNG viết lại toàn bộ bài.
132
+ - Không lặp lại cùng một ý, cùng một câu, cùng một chi tiết.
133
+ - Không thêm thông tin ngoài nguồn.
134
+ - Tối đa 5 gạch đầu dòng, mỗi gạch đầu dòng 1 câu ngắn.
135
+ - Nếu bài có số liệu/nhân vật/thời điểm quan trọng thì giữ lại.
136
+ - Không viết phần mở bài dài, không viết văn kể lại.
137
+
138
+ Tiêu đề nguồn: {title}
139
+ Nguồn: {source_hint}
140
+
141
+ Nội dung nguồn:
142
+ {raw[:14000]}
143
+ """
144
+
145
+
146
+ def _direct_news_rss(topic, limit=10):
147
+ out = []
148
+ try:
149
+ url = "https://news.google.com/rss/search?q=" + quote_plus(topic) + "&hl=vi&gl=VN&ceid=VN:vi"
150
+ r = requests.get(url, headers=base.HEADERS, timeout=15)
151
+ r.encoding = "utf-8"
152
+ soup = BeautifulSoup(r.text, "xml")
153
+ for it in soup.find_all("item")[:limit]:
154
+ title = it.find("title").get_text(" ", strip=True) if it.find("title") else ""
155
+ link = it.find("link").get_text(strip=True) if it.find("link") else ""
156
+ src = it.find("source").get_text(" ", strip=True) if it.find("source") else base._domain(link)
157
+ if title and link:
158
+ out.append({"title": title, "url": link, "via": src, "excerpt": title})
159
+ except Exception:
160
+ pass
161
+ return out
162
+
163
+
164
+ def _topic_source_articles(topic, limit=5):
165
+ """Return actual scraped article bodies for a topic. Each source becomes one Wall AI post."""
166
+ try:
167
+ _ctx, sources = base.web_context(topic, limit=limit)
168
+ except Exception:
169
+ sources = []
170
+ if not sources:
171
+ sources = _direct_news_rss(topic, limit=10)
172
+ out, seen = [], set()
173
+ for s in (sources or [])[:limit * 3]:
174
+ url = s.get("url") or ""
175
+ if not url.startswith("http") or url in seen:
176
+ continue
177
+ seen.add(url)
178
+ try:
179
+ page = base.scrape_any_url(url)
180
+ raw = (page.get("summary", "") + "\n" + page.get("text", "")).strip()
181
+ if len(raw) < 180:
182
+ continue
183
+ title = page.get("title") or s.get("title") or url
184
+ via = page.get("via") or s.get("via") or base._domain(url)
185
+ out.append({
186
+ "title": title,
187
+ "url": url,
188
+ "raw": raw,
189
+ "image": page.get("image") or "",
190
+ "via": via,
191
+ "source": {"title": title, "url": url, "excerpt": raw[:700], "via": via}
192
+ })
193
+ if len(out) >= limit:
194
+ break
195
+ except Exception:
196
+ continue
197
+ if not out:
198
+ for s in (sources or _direct_news_rss(topic, 6))[:limit]:
199
+ title = s.get("title") or topic
200
+ excerpt = s.get("excerpt") or s.get("description") or s.get("content") or title
201
+ url = s.get("url", "")
202
+ via = s.get("via") or base._domain(url)
203
+ out.append({
204
+ "title": title,
205
+ "url": url,
206
+ "raw": excerpt,
207
+ "image": base.pollinations_image_url(title),
208
+ "via": via,
209
+ "source": {"title": title, "url": url, "excerpt": excerpt[:700], "via": via}
210
+ })
211
+ return out[:limit]
212
+
213
+
214
+ async def qwen_generate_resilient(prompt: str, image_url=None, max_tokens: int = 1200):
215
+ errors = []
216
+ token = base._hf_token()
217
+ try:
218
+ original = getattr(base, "_original_qwen_generate", None)
219
+ if original:
220
+ txt = await original(prompt, image_url=image_url, max_tokens=max_tokens)
221
+ if txt:
222
+ base.LAST_QWEN_ERROR = ""
223
+ return txt
224
+ if getattr(base, "LAST_QWEN_ERROR", ""):
225
+ errors.append("sdk: " + str(base.LAST_QWEN_ERROR)[:260])
226
+ except Exception as e:
227
+ errors.append(f"sdk: {type(e).__name__}: {str(e)[:260]}")
228
+ if token:
229
+ models = []
230
+ for m in [
231
+ os.getenv("QWEN_VL_MODEL", ""),
232
+ "Qwen/Qwen2.5-VL-7B-Instruct",
233
+ "Qwen/Qwen2.5-VL-3B-Instruct",
234
+ "Qwen/Qwen2.5-7B-Instruct",
235
+ "Qwen/Qwen2.5-3B-Instruct",
236
+ "Qwen/Qwen2.5-1.5B-Instruct",
237
+ ]:
238
+ if m and m not in models:
239
+ models.append(m)
240
+ headers = {"Authorization": "Bearer " + token, "Content-Type": "application/json"}
241
+ for model in models:
242
+ try:
243
+ is_vl = "VL" in model and bool(image_url)
244
+ user_content = ([{"type": "image_url", "image_url": {"url": image_url}}, {"type": "text", "text": prompt}] if is_vl else prompt)
245
+ payload = {
246
+ "model": model,
247
+ "messages": [
248
+ {"role": "system", "content": "Bạn là biên tập viên AI tiếng Việt. Chỉ tóm tắt súc tích nội dung nguồn, không viết lại toàn bài, không lặp ý, không bịa chi tiết."},
249
+ {"role": "user", "content": user_content},
250
+ ],
251
+ "max_tokens": min(int(max_tokens or 900), 1400),
252
+ "temperature": 0.35,
253
+ "top_p": 0.85,
254
+ }
255
+ r = requests.post("https://router.huggingface.co/v1/chat/completions", headers=headers, json=payload, timeout=95)
256
+ if r.status_code >= 300:
257
+ errors.append(f"{model}: HTTP {r.status_code} {r.text[:180]}")
258
+ continue
259
+ j = r.json()
260
+ txt = (j.get("choices", [{}])[0].get("message", {}).get("content") or "").strip()
261
+ if txt:
262
+ base.LAST_QWEN_ERROR = ""
263
+ return txt
264
+ errors.append(f"{model}: empty response")
265
+ except Exception as e:
266
+ errors.append(f"{model}: {type(e).__name__}: {str(e)[:220]}")
267
+ else:
268
+ errors.append("missing HF_TOKEN")
269
+ base.LAST_QWEN_ERROR = " | ".join(errors[-6:]) or "Qwen unavailable; used extractive fallback"
270
+ print("[qwen resilient fallback]", base.LAST_QWEN_ERROR)
271
+ return _fallback_summary_from_prompt(prompt, max_units=6)
272
+
273
+
274
+ if not hasattr(base, "_original_qwen_generate"):
275
+ base._original_qwen_generate = base.qwen_generate
276
+ base.qwen_generate = qwen_generate_resilient
277
+
278
+
279
+ @app.get('/api/wall')
280
+ def compat_wall():
281
+ return JSONResponse({'posts': base._load_ai_wall()[:80]})
282
+
283
+
284
+ _PATCHED_PATHS = {
285
+ ('/api/topic_post', 'POST'),
286
+ ('/api/url_wall', 'POST'),
287
+ ('/api/rewrite_share', 'POST'),
288
+ ('/api/ai/short/{post_id}', 'POST'),
289
+ }
290
+ app.router.routes = [
291
+ r for r in app.router.routes
292
+ if not any(getattr(r, 'path', None) == p and m in getattr(r, 'methods', set()) for p, m in _PATCHED_PATHS)
293
+ ]
294
+
295
+
296
+ @app.post('/api/topic_post')
297
+ async def compat_topic_post(request: Request):
298
+ body = await request.json()
299
+ topic = base._clean_text(body.get('topic', ''))
300
+ if not topic:
301
+ return JSONResponse({'error': 'missing topic'}, status_code=400)
302
+ articles = _topic_source_articles(topic, limit=4)
303
+ if not articles:
304
+ return JSONResponse({'error': 'Không lấy được bài viết nguồn cho chủ đề này.'}, status_code=422)
305
+ new_posts = []
306
+ posts = base._load_ai_wall()
307
+ for art in articles:
308
+ prompt = f"""Tóm tắt RIÊNG bài viết nguồn sau để đăng Tường AI.
309
+
310
+ Chủ đề lọc: {topic}
311
+ Tiêu đề bài nguồn: {art['title']}
312
+ Nguồn: {art['via']}
313
+
314
+ Yêu cầu bắt buộc:
315
+ - Tóm tắt nội dung trong BÀI VIẾT này, không chỉ tiêu đề.
316
+ - Không trộn với bài khác.
317
+ - Không viết lại toàn bộ bài.
318
+ - Không lặp ý.
319
+ - 4-6 gạch đầu dòng, mỗi dòng 1 câu rõ ràng.
320
+ - Giữ số liệu/nhân vật/thời điểm quan trọng nếu có.
321
+
322
+ Nội dung bài:
323
+ {art['raw'][:14000]}"""
324
+ text = await base.qwen_generate(prompt, image_url=art.get('image') or None, max_tokens=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)
333
+ return JSONResponse({'post': new_posts[0], 'posts': new_posts, 'count': len(new_posts)})
334
+
335
+
336
+ @app.post('/api/url_wall')
337
+ async def compat_url_wall(request: Request):
338
+ body = await request.json()
339
+ url = base._clean_text(body.get('url', ''))
340
+ if not url.startswith('http'):
341
+ return JSONResponse({'error': 'missing url'}, status_code=400)
342
+ try:
343
+ data = base.scrape_any_url(url)
344
+ except Exception as e:
345
+ return JSONResponse({'error': 'Không scrape được URL: ' + str(e)[:180]}, status_code=422)
346
+ raw = (data.get('summary', '') + '\n' + data.get('text', '')).strip()
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')
361
+ async def compat_rewrite_share(request: Request):
362
+ body = await request.json()
363
+ url = base._clean_text(body.get('url', ''))
364
+ if not url.startswith('http'):
365
+ return JSONResponse({'error': 'missing url'}, status_code=400)
366
+ try:
367
+ data = base.scrape_any_url(url)
368
+ except Exception as e:
369
+ return JSONResponse({'error': 'Không đọc được bài viết: ' + str(e)[:180]}, status_code=422)
370
+ raw = (data.get('summary', '') + '\n' + data.get('text', '')).strip()
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
+
411
+
412
+ def _split_subtitle_sentences(script):
413
+ parts = []
414
+ for line in script.splitlines():
415
+ line = _clean(line)
416
+ if not line:
417
+ continue
418
+ for s in re.split(r"(?<=[\.\!\?])\s+", line):
419
+ s = _clean(s)
420
+ if 8 <= len(s) <= 140:
421
+ parts.append(s)
422
+ return parts[:12]
423
+
424
+
425
+ def _srt_time(sec):
426
+ ms = int((sec - int(sec)) * 1000)
427
+ sec = int(sec)
428
+ h = sec // 3600
429
+ m = (sec % 3600) // 60
430
+ s = sec % 60
431
+ return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
432
+
433
+
434
+ def _write_srt(script, path, total_duration=30):
435
+ subs = _split_subtitle_sentences(script)
436
+ if not subs:
437
+ subs = [script[:120]]
438
+ dur = max(2.2, min(5.0, total_duration / max(1, len(subs))))
439
+ cur = 0.3
440
+ with open(path, 'w', encoding='utf-8') as f:
441
+ for i, s in enumerate(subs, 1):
442
+ start = cur
443
+ end = cur + dur
444
+ cur = end + 0.15
445
+ f.write(f"{i}\n{_srt_time(start)} --> {_srt_time(end)}\n{s}\n\n")
446
+
447
+
448
+ def _wrap_text_px(draw, text, font, max_width, max_lines):
449
+ words = _clean(text).split()
450
+ lines, cur = [], ""
451
+ for w in words:
452
+ test = (cur + " " + w).strip()
453
+ try:
454
+ width = draw.textbbox((0, 0), test, font=font)[2]
455
+ except Exception:
456
+ width = len(test) * 20
457
+ if width <= max_width:
458
+ cur = test
459
+ else:
460
+ if cur:
461
+ lines.append(cur)
462
+ cur = w
463
+ if len(lines) >= max_lines:
464
+ break
465
+ if cur and len(lines) < max_lines:
466
+ lines.append(cur)
467
+ return lines
468
+
469
+
470
+ def _make_short_frame_full(post, img_path, out_path):
471
+ if Image is None:
472
+ return base._make_short_frame(post, img_path, out_path)
473
+ W, H = 1080, 1920
474
+ bg = Image.new("RGB", (W, H), (14, 14, 14))
475
+ try:
476
+ im = Image.open(img_path).convert("RGB")
477
+ target = (1080, 760)
478
+ im_ratio = im.width / im.height
479
+ target_ratio = target[0] / target[1]
480
+ if im_ratio > target_ratio:
481
+ new_h = target[1]
482
+ new_w = int(new_h * im_ratio)
483
+ else:
484
+ new_w = target[0]
485
+ new_h = int(new_w / im_ratio)
486
+ im = im.resize((new_w, new_h))
487
+ left = (new_w - target[0]) // 2
488
+ top = (new_h - target[1]) // 2
489
+ im = im.crop((left, top, left + target[0], top + target[1]))
490
+ bg.paste(im, (0, 0))
491
+ except Exception:
492
+ pass
493
+ draw = ImageDraw.Draw(bg)
494
+ try:
495
+ font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 54)
496
+ font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 38)
497
+ font_label = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 30)
498
+ except Exception:
499
+ font_title = font_body = font_label = None
500
+ draw.rectangle((0, 720, W, H), fill=(14, 14, 14))
501
+ margin = 48
502
+ maxw = W - margin * 2
503
+ draw.text((margin, 770), "VNEWS · Tường AI", fill=(92, 184, 122), font=font_label)
504
+ y = 830
505
+ for ln in _wrap_text_px(draw, post.get("title", ""), font_title, maxw, 4):
506
+ draw.text((margin, y), ln, fill=(255, 255, 255), font=font_title)
507
+ y += 66
508
+ y += 18
509
+ text = post.get("text", "")
510
+ text = re.sub(r"Nguồn tham khảo:.*", "", text, flags=re.S).strip()
511
+ body_lines = _wrap_text_px(draw, text, font_body, maxw, 14)
512
+ for ln in body_lines:
513
+ draw.text((margin, y), ln, fill=(220, 220, 220), font=font_body)
514
+ y += 50
515
+ if y > 1640:
516
+ break
517
+ bg.save(out_path, quality=92)
518
+
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()
526
+ lines=[]
527
+ for ln in raw.splitlines():
528
+ ln=_clean(re.sub(r'^[•\-\*\d\.\)\s]+','',ln))
529
+ if not ln: continue
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)
537
+ if len(s)>=25: lines.append(s)
538
+ segs=_dedupe_units(lines, max_units=max_segments)
539
+ return segs[:max_segments] if segs else [post.get('title','Bản tin VNEWS')]
540
+
541
+
542
+ def _make_scene_frame(post, segment, idx, total, img_path, out_path, emotion='neutral'):
543
+ if Image is None:
544
+ return _make_short_frame_full(post, img_path, out_path)
545
+ W,H=1080,1920
546
+ bg=Image.new('RGB',(W,H),(10,10,10))
547
+ try:
548
+ im=Image.open(img_path).convert('RGB')
549
+ ratio=im.width/max(1,im.height); target=W/H
550
+ if ratio>target:
551
+ nh=H; nw=int(nh*ratio)
552
+ else:
553
+ nw=W; nh=int(nw/ratio)
554
+ cover=im.resize((nw,nh)); left=(nw-W)//2; top=(nh-H)//2
555
+ cover=cover.crop((left,top,left+W,top+H))
556
+ bg.paste(cover,(0,0))
557
+ bg=Image.blend(bg, Image.new('RGB',(W,H),(0,0,0)), 0.50)
558
+ hero_h=720; target=W/hero_h
559
+ if ratio>target:
560
+ nh=hero_h; nw=int(nh*ratio)
561
+ else:
562
+ nw=W; nh=int(nw/ratio)
563
+ hero=im.resize((nw,nh)); left=(nw-W)//2; top=(nh-hero_h)//2
564
+ hero=hero.crop((left,top,left+W,top+hero_h))
565
+ bg.paste(hero,(0,0))
566
+ except Exception:
567
+ pass
568
+ draw=ImageDraw.Draw(bg)
569
+ try:
570
+ font_brand=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',34)
571
+ font_small=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',28)
572
+ font_seg=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',58)
573
+ font_title=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',34)
574
+ except Exception:
575
+ font_brand=font_small=font_seg=font_title=None
576
+ draw.rectangle((0,680,W,H), fill=(12,12,12))
577
+ dot_x=48; dot_y=742
578
+ for i in range(total):
579
+ fill=(92,184,122) if i==idx else (70,70,70)
580
+ draw.rounded_rectangle((dot_x+i*38,dot_y,dot_x+i*38+24,dot_y+10), radius=5, fill=fill)
581
+ draw.text((48,780),'VNEWS AI SHORT',fill=(110,231,143),font=font_brand)
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
589
+ y2=1640
590
+ draw.line((48,y2-22,W-48,y2-22),fill=(70,70,70),width=2)
591
+ for ln in _wrap_text_px(draw, post.get('title',''), font_title, maxw, 3):
592
+ draw.text((48,y2),ln,fill=(220,220,220),font=font_title)
593
+ y2+=46
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
+
604
+
605
+ @app.post('/api/ai/short/{post_id}')
606
+ async def patched_ai_short(post_id: str, request: Request):
607
+ try:
608
+ body = await request.json()
609
+ except Exception:
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()
617
+ post = next((p for p in posts if str(p.get('id')) == str(post_id)), None)
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"
625
+ out_mp4 = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix) + '.mp4')
626
+ if os.path.exists(out_mp4):
627
+ post['video'] = '/api/ai/short-file/' + post_id + suffix
628
+ post['short_voice'] = voice
629
+ post['short_emotion'] = emotion
630
+ post['short_speed'] = speed
631
+ post['short_segments'] = segments
632
+ post['short_subtitles'] = False
633
+ base._save_ai_wall(posts)
634
+ return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': False, 'segments': segments})
635
+ if base.gTTS is None:
636
+ return JSONResponse({'error': 'gTTS chưa sẵn sàng'}, status_code=503)
637
+
638
+ work = os.path.join(base.SHORTS_DIR, base._safe_name(post_id + suffix))
639
+ os.makedirs(work, exist_ok=True)
640
+ img = os.path.join(work, 'image.jpg')
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):
652
+ frame=os.path.join(work,f'frame_{idx:02d}.jpg')
653
+ aud=os.path.join(work,f'voice_{idx:02d}.mp3')
654
+ aud_fast=os.path.join(work,f'voice_{idx:02d}_fast.mp3')
655
+ part=os.path.join(work,f'part_{idx:02d}.mp4')
656
+ _make_scene_frame(post, seg, idx, len(segments), img, frame, emotion=emotion)
657
+ spoken=_emotion_script(seg, emotion)
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')
671
+ with open(concat,'w',encoding='utf-8') as f:
672
+ for p in part_files:
673
+ f.write("file '" + p.replace("'", "'\\''") + "'\n")
674
+ subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',concat,'-c','copy',out_mp4], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180)
675
+ post['video'] = '/api/ai/short-file/' + post_id + suffix
676
+ post['short_voice'] = voice
677
+ post['short_emotion'] = emotion
678
+ post['short_speed'] = speed
679
+ post['short_segments'] = segments
680
+ post['short_subtitles'] = False
681
+ base._save_ai_wall(posts)
682
+ return JSONResponse({'video': post['video'], 'voice': voice, 'emotion': emotion, 'speed': speed, 'subtitles': False, 'segments': segments})
683
+ except Exception as e:
684
+ return JSONResponse({'error': 'Không tạo được shorts: ' + str(e)[:220]}, status_code=500)
685
+
686
+
687
+ @app.get('/api/ai/short-file/{file_id}')
688
+ def patched_ai_short_file(file_id: str):
689
+ path = os.path.join(base.SHORTS_DIR, base._safe_name(file_id) + '.mp4')
690
+ if not os.path.exists(path):
691
+ return JSONResponse({'error': 'not found'}, status_code=404)
692
+ return FileResponse(path, media_type='video/mp4', filename=f'vnews-ai-{file_id}.mp4')
693
+
694
+
695
+ @app.get('/api/ai_shorts')
696
+ def api_ai_shorts():
697
+ posts = [p for p in base._load_ai_wall() if p.get('video')]
698
+ return JSONResponse({'posts': posts[:80]})
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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.py ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, re, subprocess, json, time, hashlib
2
+ import ai_patch as old
3
+ from ai_patch import app
4
+ import ai_ext as base
5
+ from fastapi import Request
6
+ from fastapi.responses import JSONResponse, HTMLResponse, FileResponse
7
+ try:
8
+ from PIL import Image, ImageDraw, ImageFont
9
+ except Exception:
10
+ Image = ImageDraw = ImageFont = None
11
+
12
+
13
+ def clean(s):
14
+ import html as html_lib
15
+ return re.sub(r"\s+", " ", html_lib.unescape(s or "")).strip()
16
+
17
+
18
+ def _domain(url):
19
+ try:
20
+ from urllib.parse import urlparse
21
+ return urlparse(url or '').netloc.replace('www.','')
22
+ except Exception:
23
+ return ''
24
+
25
+
26
+ def _strip_bullet_prefix(s):
27
+ # remove bullets, numbered prefixes, leading dots commonly produced by AI summaries
28
+ return clean(re.sub(r'^[\s•\-\*·▪▫●○\d\.\)\(]+', '', s or ''))
29
+
30
+
31
+ def source_line(sources):
32
+ names=[]
33
+ for s in (sources or [])[:5]:
34
+ via=s.get('via') or _domain(s.get('url','')) or s.get('title','')
35
+ if via and via not in names:names.append(via)
36
+ return 'Nguồn tham khảo: '+', '.join(names[:5]) if names else 'Nguồn tham khảo: tổng hợp internet'
37
+
38
+
39
+ def _source_badge(post):
40
+ sources=post.get('sources') or []
41
+ for s in sources:
42
+ via=s.get('via') or _domain(s.get('url',''))
43
+ if via:return via
44
+ return _domain(post.get('url','')) or post.get('source') or 'VNEWS'
45
+
46
+
47
+ def _collect_all_images(data):
48
+ imgs=[]
49
+ def add(u):
50
+ u=(u or '').strip()
51
+ if not u or u.startswith('data:') or 'base64' in u:return
52
+ if u.startswith('//'):u='https:'+u
53
+ if u not in imgs:imgs.append(u)
54
+ add(data.get('image') or data.get('og_image') or data.get('img'))
55
+ for u in data.get('images') or []:add(u)
56
+ for b in data.get('body') or []:
57
+ if isinstance(b,dict) and b.get('type')=='img':add(b.get('src'))
58
+ return imgs[:20]
59
+
60
+
61
+ def _scrape_url_with_images(url):
62
+ data=base.scrape_any_url(url)
63
+ # extra pass: collect every useful image from original HTML, because some readers only return one image
64
+ try:
65
+ import requests
66
+ from bs4 import BeautifulSoup
67
+ r=requests.get(url,headers=base.HEADERS,timeout=18);r.encoding='utf-8'
68
+ soup=BeautifulSoup(r.text,'lxml')
69
+ extra=[]
70
+ for im in soup.find_all('img'):
71
+ src=im.get('data-src') or im.get('data-original') or im.get('data-lazy-src') or im.get('src') or ''
72
+ if src.startswith('//'):src='https:'+src
73
+ if src and 'base64' not in src and src not in extra:
74
+ # skip tiny icons/logos as much as possible
75
+ low=src.lower()
76
+ if any(x in low for x in ['logo','icon','avatar','sprite']):
77
+ continue
78
+ extra.append(src)
79
+ if len(extra)>=20:break
80
+ data['images']=_collect_all_images(data)+[u for u in extra if u not in _collect_all_images(data)]
81
+ except Exception:
82
+ data['images']=_collect_all_images(data)
83
+ data['images']=_collect_all_images(data)
84
+ if data['images'] and not data.get('image'):
85
+ data['image']=data['images'][0]
86
+ return data
87
+
88
+
89
+ def rich_context(topic, limit=5):
90
+ try: ctx,sources=base.web_context(topic, limit=limit)
91
+ except Exception: ctx,sources='',[]
92
+ rich=[];rs=[];seen=set()
93
+ for s in (sources or [])[:limit*2]:
94
+ url=s.get('url') or ''
95
+ if not url.startswith('http') or url in seen:continue
96
+ seen.add(url)
97
+ try:
98
+ data=base.scrape_any_url(url)
99
+ raw=(data.get('summary','')+'\n'+data.get('text','')).strip()
100
+ if len(raw)<180:continue
101
+ title=data.get('title') or s.get('title') or url
102
+ via=data.get('via') or s.get('via') or _domain(url)
103
+ rich.append(f"### {title} ({via})\n{raw[:2600]}")
104
+ rs.append({'title':title,'url':url,'excerpt':raw[:700],'via':via})
105
+ if len(rich)>=limit:break
106
+ except Exception:continue
107
+ if rich:return '\n\n'.join(rich),rs
108
+ return ctx or f'Chủ đề: {topic}', sources or []
109
+
110
+
111
+ def postprocess(text):
112
+ if hasattr(old,'_postprocess_ai_text'):
113
+ out=old._postprocess_ai_text(text, max_units=7)
114
+ else:
115
+ out=clean(text)
116
+ # keep wall text readable, but ensure short generation later won't show bullets
117
+ return out
118
+
119
+
120
+ # Remove old routes we must override.
121
+ _PATCH={('/api/topic_post','POST'),('/api/url_wall','POST'),('/api/rewrite_share','POST'),('/api/ai/url','POST'),('/api/ai/short/{post_id}','POST'),('/api/ai/short-file/{file_id}','GET'),('/','GET')}
122
+ app.router.routes=[r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in _PATCH)]
123
+
124
+
125
+ @app.post('/api/url_wall')
126
+ async def url_wall_only(request:Request):
127
+ body=await request.json();url=base._clean_text(body.get('url',''))
128
+ if not url.startswith('http'):return JSONResponse({'error':'missing url'},status_code=400)
129
+ try:data=_scrape_url_with_images(url)
130
+ except Exception as e:return JSONResponse({'error':'Không scrape được URL: '+str(e)[:180]},status_code=422)
131
+ raw=(data.get('summary','')+'\n'+data.get('text','')).strip()
132
+ if len(raw)<120:return JSONResponse({'error':'URL không có đủ nội dung để tóm tắt'},status_code=422)
133
+ prompt=f"""Tóm tắt bài viết nguồn dưới đây để đăng lên Tường AI VNEWS.
134
+
135
+ Yêu cầu bắt buộc:
136
+ - Chỉ tóm tắt nội dung chính, không viết lại toàn bộ bài.
137
+ - Ngắn gọn, cụ thể, dễ hiểu.
138
+ - Không lặp lại ý và không thêm chi tiết ngoài nguồn.
139
+ - Tối đa 5 ý chính hoặc 2 đoạn ngắn.
140
+ - Tránh dùng dấu đầu dòng nếu không thật cần thiết.
141
+
142
+ Tiêu đề gốc: {data.get('title','')}
143
+ Nguồn: {data.get('via','') or _domain(url)}
144
+ Nội dung gốc:
145
+ {raw[:16000]}"""
146
+ text=await base.qwen_generate(prompt,image_url=(data.get('image') or None),max_tokens=900)
147
+ if not text:text=old._fallback_summary_from_prompt(prompt,max_units=5) if hasattr(old,'_fallback_summary_from_prompt') else raw[:900]
148
+ text=postprocess(text)
149
+ src=[{'title':data.get('title'), 'url':url, 'excerpt':raw[:500], 'via':data.get('via') or _domain(url)}]
150
+ if 'Nguồn tham khảo:' not in text:text+='\n\n'+source_line(src)
151
+ images=_collect_all_images(data)
152
+ post=base.make_post(data.get('title') or 'Bài viết',text,images[0] if images else (data.get('image') or ''),url,'url',sources=src)
153
+ post['images']=images
154
+ posts=base._load_ai_wall();posts.insert(0,post);base._save_ai_wall(posts)
155
+ return JSONResponse({'post':post})
156
+
157
+
158
+ @app.post('/api/rewrite_share')
159
+ async def rewrite_share_url_only(request:Request):
160
+ return await url_wall_only(request)
161
+
162
+
163
+ @app.post('/api/ai/url')
164
+ async def ai_url_compat(request:Request):
165
+ return await url_wall_only(request)
166
+
167
+
168
+ @app.post('/api/topic_post')
169
+ async def topic_disabled(request:Request):
170
+ return JSONResponse({'error':'Đã tắt tạo bài theo chủ đề. Vui lòng dán URL bài viết để AI tóm tắt.'},status_code=410)
171
+
172
+
173
+ def split_segments(post,max_segments=8):
174
+ text=clean(post.get('text') or post.get('title') or '')
175
+ text=re.sub(r'Nguồn tham khảo:.*$','',text,flags=re.I|re.S).strip()
176
+ lines=[]
177
+ for ln in text.splitlines():
178
+ ln=_strip_bullet_prefix(ln)
179
+ if len(ln)>=18:lines.append(ln)
180
+ if len(lines)<2:
181
+ lines=[_strip_bullet_prefix(s) for s in re.split(r'(?<=[\.\!\?])\s+',text) if len(_strip_bullet_prefix(s))>=25]
182
+ segs=[];cur=''
183
+ for ln in lines:
184
+ ln=_strip_bullet_prefix(ln)
185
+ if not ln:continue
186
+ if len(cur)+len(ln)<180:cur=(cur+' '+ln).strip()
187
+ else:
188
+ if cur:segs.append(_strip_bullet_prefix(cur))
189
+ cur=ln
190
+ if cur:segs.append(_strip_bullet_prefix(cur))
191
+ return segs[:max_segments] or [_strip_bullet_prefix(post.get('title','VNEWS'))]
192
+
193
+
194
+ def wrap_text(draw,text,font,maxw,max_lines):
195
+ words=clean(text).split();lines=[];cur=''
196
+ for w in words:
197
+ test=(cur+' '+w).strip()
198
+ try:width=draw.textbbox((0,0),test,font=font)[2]
199
+ except Exception:width=len(test)*20
200
+ if width<=maxw:cur=test
201
+ else:
202
+ if cur:lines.append(cur)
203
+ cur=w
204
+ if len(lines)>=max_lines:break
205
+ if cur and len(lines)<max_lines:lines.append(cur)
206
+ return lines
207
+
208
+
209
+ def _draw_center(draw, lines, font, y, fill, W, line_h):
210
+ for ln in lines:
211
+ try:
212
+ box=draw.textbbox((0,0),ln,font=font);tw=box[2]-box[0]
213
+ except Exception:
214
+ tw=len(ln)*24
215
+ x=max(30,(W-tw)//2)
216
+ draw.text((x,y),ln,fill=fill,font=font)
217
+ y+=line_h
218
+ return y
219
+
220
+
221
+ def make_frame(post,seg,idx,total,img_path,out_path):
222
+ if Image is None:raise RuntimeError('Pillow not ready')
223
+ W,H=1080,1920;bg=Image.new('RGB',(W,H),(12,12,12))
224
+ hero_h=760
225
+ try:
226
+ im=Image.open(img_path).convert('RGB');ratio=im.width/max(1,im.height)
227
+ target=(W,hero_h);tr=target[0]/target[1]
228
+ if ratio>tr:nh=target[1];nw=int(nh*ratio)
229
+ else:nw=target[0];nh=int(nw/ratio)
230
+ im=im.resize((nw,nh));left=(nw-target[0])//2;top=(nh-target[1])//2
231
+ bg.paste(im.crop((left,top,left+target[0],top+target[1])),(0,0))
232
+ except Exception:pass
233
+ draw=ImageDraw.Draw(bg)
234
+ try:
235
+ fb=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',58)
236
+ ft=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',38)
237
+ fs=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',30)
238
+ fsmall=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',28)
239
+ except Exception:fb=ft=fs=fsmall=None
240
+ # source badge on top image corner
241
+ badge='Nguồn: '+_source_badge(post)
242
+ try:
243
+ b=draw.textbbox((0,0),badge,font=fsmall);bw=b[2]-b[0];bh=b[3]-b[1]
244
+ except Exception:
245
+ bw=len(badge)*16;bh=34
246
+ bx=W-bw-42;by=24
247
+ draw.rounded_rectangle((bx-16,by-8,W-24,by+bh+14),radius=18,fill=(0,0,0,170))
248
+ draw.text((bx,by),badge,fill=(255,255,255),font=fsmall)
249
+ # bottom text area
250
+ draw.rectangle((0,hero_h-20,W,H),fill=(12,12,12))
251
+ # progress bars centered
252
+ total_w=total*38-14;start=(W-total_w)//2
253
+ for i in range(total):
254
+ fill=(92,184,122) if i==idx else (70,70,70)
255
+ draw.rounded_rectangle((start+i*38,820,start+i*38+24,832),radius=6,fill=fill)
256
+ brand='VNEWS AI SHORT'
257
+ try:
258
+ bb=draw.textbbox((0,0),brand,font=ft);tx=(W-(bb[2]-bb[0]))//2
259
+ except Exception:tx=360
260
+ draw.text((tx,870),brand,fill=(110,231,143),font=ft)
261
+ clean_seg=_strip_bullet_prefix(seg)
262
+ lines=wrap_text(draw,clean_seg,fb,W-120,8)
263
+ block_h=len(lines)*74
264
+ y=max(980, 1250-block_h//2)
265
+ _draw_center(draw,lines,fb,y,(255,255,255),W,74)
266
+ # small title centered near bottom
267
+ title_lines=wrap_text(draw,_strip_bullet_prefix(post.get('title','')),fs,W-120,3)
268
+ y2=1640
269
+ draw.line((80,y2-26,W-80,y2-26),fill=(70,70,70),width=2)
270
+ _draw_center(draw,title_lines,fs,y2,(220,220,220),W,42)
271
+ bg.save(out_path,quality=92)
272
+
273
+
274
+ def make_tts(text,voice,out_path):
275
+ v={'nam':'vi-VN-NamMinhNeural','male':'vi-VN-NamMinhNeural','nu':'vi-VN-HoaiMyNeural','female':'vi-VN-HoaiMyNeural','mien-nam':'vi-VN-HoaiMyNeural'}.get(voice,'vi-VN-HoaiMyNeural')
276
+ text=_strip_bullet_prefix(text)
277
+ try:subprocess.run(['python','-m','edge_tts','--voice',v,'--text',text,'--write-media',out_path],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=160)
278
+ except Exception:
279
+ tld='com.vn' if voice in ('nu','female','mien-nam') else 'com'
280
+ try:base.gTTS(text,lang='vi',tld=tld,slow=False).save(out_path)
281
+ except TypeError:base.gTTS(text,lang='vi',slow=False).save(out_path)
282
+
283
+
284
+ @app.post('/api/ai/short/{post_id}')
285
+ async def short_segments(post_id:str,request:Request):
286
+ try:body=await request.json()
287
+ except Exception:body={}
288
+ voice=str(body.get('voice','nu')).lower().strip();emotion=str(body.get('emotion','neutral')).lower().strip();speed=max(0.85,min(1.35,float(body.get('speed',1.2) or 1.2)))
289
+ posts=base._load_ai_wall();post=next((p for p in posts if str(p.get('id'))==str(post_id)),None)
290
+ if not post:return JSONResponse({'error':'post not found'},status_code=404)
291
+ segs=split_segments(post,8)
292
+ os.makedirs(base.SHORTS_DIR,exist_ok=True);suffix=f'_{voice}_{emotion}_{str(speed).replace(".","p")}_centered_source_nobullet'
293
+ out=os.path.join(base.SHORTS_DIR,base._safe_name(post_id+suffix)+'.mp4')
294
+ if os.path.exists(out):post['video']='/api/ai/short-file/'+post_id+suffix;base._save_ai_wall(posts);return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False})
295
+ work=os.path.join(base.SHORTS_DIR,base._safe_name(post_id+suffix));os.makedirs(work,exist_ok=True)
296
+ img=os.path.join(work,'image.jpg');base._download_image(post.get('img'),post.get('title','AI news'),img)
297
+ clips=[]
298
+ try:
299
+ for i,seg in enumerate(segs):
300
+ frame=os.path.join(work,f'f{i}.jpg');aud=os.path.join(work,f'a{i}.mp3');aud2=os.path.join(work,f'a{i}_fast.mp3');clip=os.path.join(work,f'c{i}.mp4')
301
+ seg=_strip_bullet_prefix(seg)
302
+ make_frame(post,seg,i,len(segs),img,frame)
303
+ prefix={'urgent':'Tin nhanh.','warm':'Câu chuyện đáng chú ý.','serious':'Bản tin nghiêm túc.','energetic':'Cập nhật nổi bật.'}.get(emotion,'')
304
+ spoken=(prefix+' '+seg).strip() if i==0 and prefix else seg
305
+ make_tts(spoken,voice,aud)
306
+ subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud2],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=120)
307
+ subprocess.run(['ffmpeg','-y','-loop','1','-i',frame,'-i',aud2,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k','-vf','scale=1080:1920',clip],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=180)
308
+ clips.append(clip)
309
+ lf=os.path.join(work,'list.txt')
310
+ with open(lf,'w',encoding='utf-8') as f:
311
+ for c in clips:f.write("file '{}".format(c.replace("'","'\\''"))+"'\n")
312
+ subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',lf,'-c','copy',out],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=240)
313
+ post['video']='/api/ai/short-file/'+post_id+suffix;post['short_subtitles']=False;post['short_segments']=segs;post['short_speed']=speed;base._save_ai_wall(posts)
314
+ return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False})
315
+ except Exception as e:return JSONResponse({'error':'Không tạo được shorts: '+str(e)[:200]},status_code=500)
316
+
317
+
318
+ @app.get('/api/ai/short-file/{file_id}')
319
+ def short_file(file_id:str):
320
+ path=os.path.join(base.SHORTS_DIR,base._safe_name(file_id)+'.mp4')
321
+ if not os.path.exists(path):return JSONResponse({'error':'not found'},status_code=404)
322
+ return FileResponse(path,media_type='video/mp4',filename=f'vnews-ai-{file_id}.mp4')
323
+
324
+
325
+ # Rebuild / with old UI injection plus final UI overrides.
326
+ app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
327
+ @app.get('/')
328
+ async def index_runtime():
329
+ with open('/app/static/index.html','r',encoding='utf-8') as f:html=f.read()
330
+ inject=getattr(old,'PATCH_INJECT','')+r'''
331
+ <style>
332
+ /* Hide old topic UI, keep URL input only */
333
+ #ai-topic-input{display:none!important}
334
+ #ai-topic-input,*[onclick*="createTopicPost"]{display:none!important}
335
+ .ai-topic-row,.topic-row,.ai-compose-topic{display:none!important}
336
+ .ai-wall-gallery{display:grid;grid-template-columns:repeat(2,1fr);gap:6px;margin:10px 0}.ai-wall-gallery img{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:8px;background:#222}.ai-wall-gallery img:first-child{grid-column:1/-1}.ai-url-only-note{font-size:11px;color:#888;margin:5px 0 8px}
337
+ </style>
338
+ <script>
339
+ (function(){
340
+ function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
341
+ function hideTopicControls(){
342
+ document.querySelectorAll('#ai-topic-input').forEach(e=>{let p=e.closest('.ai-compose,.ai-compose-topic,.topic-row,div'); if(p&&p.querySelector('#ai-url-input')) e.style.display='none'; else if(p) p.style.display='none';});
343
+ document.querySelectorAll('button').forEach(b=>{let t=(b.textContent||'').toLowerCase();let oc=b.getAttribute('onclick')||'';if(oc.includes('createTopicPost')||t.includes('chủ đề'))b.style.display='none';});
344
+ let url=document.getElementById('ai-url-input'); if(url&&!document.getElementById('ai-url-only-note')){let n=document.createElement('div');n.id='ai-url-only-note';n.className='ai-url-only-note';n.textContent='Dán URL bài viết để AI tóm tắt và lấy ảnh từ bài.';url.insertAdjacentElement('afterend',n);}
345
+ }
346
+ window.createTopicPost=function(){alert('Đã tắt ô nhập chủ đề. Vui lòng dán URL bài viết.');};
347
+ window.createUrlPost=function(){let inp=document.getElementById('ai-url-input');let url=(inp&&inp.value||'').trim();if(!url)return alert('Dán URL trước');if(!/^https?:\/\//i.test(url))return alert('URL cần bắt đầu bằng http:// hoặc https://');fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})}).then(r=>r.json().then(j=>({ok:r.ok,j}))).then(({ok,j})=>{if(ok&&j.post){if(typeof prependWallPost==='function')prependWallPost(j.post);if(window.patchedWall)window.patchedWall=[j.post].concat(window.patchedWall||[]);if(inp)inp.value='';alert('Đã tóm tắt URL, lấy ảnh trong bài và đăng lên Tường AI');location.reload();}else alert(j.error||'Lỗi URL')}).catch(e=>alert(e.message||'Lỗi URL'));};
348
+ function galleryHtml(p){let imgs=(p.images||[]).filter(Boolean);if(!imgs.length&&p.img)imgs=[p.img];if(!imgs.length)return '';return '<div class="ai-wall-gallery">'+imgs.slice(0,12).map(u=>`<img src="${esc(u)}" loading="lazy">`).join('')+'</div>';}
349
+ function patchReaders(){
350
+ let oldRead=window.aiReadWallPatched||window.aiReadWall;
351
+ window.aiReadWallPatched=window.aiReadWall=function(i){let arr=window.patchedWall||window.aiWall||[];let p=arr[i];if(!p&&oldRead)return oldRead(i);if(!p)return;showView('view-article');let sources='';if(p.sources&&p.sources.length){sources='<div class="article-summary"><b>Nguồn tham khảo:</b><br>'+p.sources.slice(0,5).map(s=>`• ${esc(s.title||s.url||'Nguồn')} ${s.url?`(${esc(new URL(s.url).hostname.replace('www.',''))})`:''}`).join('<br>')+'</div>'}let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${esc(p.title)}</h1>${galleryHtml(p)}${sources}<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>${p.video?`<video class="article-img" src="${p.video}" controls playsinline></video>`:''}<div class="article-actions">${p.url?`<button onclick="window.open('${p.url}','_blank')">🔗 Nguồn</button>`:''}<button onclick="aiMakeShortPatched?aiMakeShortPatched(${i}):aiMakeShort(${i})">🎬 Tạo video shorts</button></div></div>`;document.getElementById('view-article').innerHTML=h;window.scrollTo(0,0);};
352
+ }
353
+ setInterval(hideTopicControls,1000);setTimeout(hideTopicControls,300);setTimeout(patchReaders,1600);
354
+ })();
355
+ </script>
356
+ '''
357
+ return HTMLResponse(html.replace('</body>',inject+'\n</body>') if '</body>' in html else html+inject)
ai_runtime_final.py ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Final runtime overrides for VNEWS AI UI, article-only images, shareable AI wall, and robust Vietnamese shorts."""
2
+ import os, re, requests, subprocess, time
3
+ from urllib.parse import urlparse, quote
4
+ import ai_runtime as rt
5
+ from ai_runtime import app
6
+ import ai_ext as base
7
+ from fastapi import Request, Query
8
+ from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
9
+ try:
10
+ from PIL import Image, ImageDraw, ImageFont
11
+ except Exception:
12
+ Image = ImageDraw = ImageFont = None
13
+
14
+ RESTORE_INDEX_URL = "https://huggingface.co/spaces/bep40/vnews/raw/restore-33c3dda/static/index.html"
15
+ SPACE_URL = "https://bep40-vnews.hf.space"
16
+ DEFAULT_IMG = "https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg"
17
+
18
+ # Only voices that support Vietnamese reliably. Extra labels map to these Vietnamese neural voices.
19
+ VN_VOICES = {
20
+ "nu": "vi-VN-HoaiMyNeural", "female": "vi-VN-HoaiMyNeural", "hoaimy": "vi-VN-HoaiMyNeural",
21
+ "nu-tre": "vi-VN-HoaiMyNeural", "nu-truyen-cam": "vi-VN-HoaiMyNeural", "nu-tin-nhanh": "vi-VN-HoaiMyNeural",
22
+ "nam": "vi-VN-NamMinhNeural", "male": "vi-VN-NamMinhNeural", "namminh": "vi-VN-NamMinhNeural",
23
+ "nam-tram": "vi-VN-NamMinhNeural", "nam-ban-tin": "vi-VN-NamMinhNeural", "nam-nang-dong": "vi-VN-NamMinhNeural",
24
+ }
25
+
26
+
27
+ def clean(s):
28
+ import html as html_lib
29
+ return re.sub(r"\s+", " ", html_lib.unescape(s or "")).strip()
30
+
31
+
32
+ def _domain(url):
33
+ try:return urlparse(url or '').netloc.replace('www.','')
34
+ except Exception:return ''
35
+
36
+
37
+ def _strip_bullet_prefix(s):
38
+ return clean(re.sub(r'^[\s•\-\*·▪▫●○\d\.\)\(]+', '', s or ''))
39
+
40
+
41
+ def _source_badge_url_first(post):
42
+ d=_domain(post.get('url',''))
43
+ if d:return d
44
+ for s in post.get('sources') or []:
45
+ d=_domain(s.get('url',''))
46
+ if d:return d
47
+ return 'VNEWS'
48
+
49
+
50
+ def _abs_url(src, base_url):
51
+ if not src:return ''
52
+ src=src.strip()
53
+ if src.startswith('//'):return 'https:'+src
54
+ if src.startswith('/'):
55
+ try:
56
+ p=urlparse(base_url);return f'{p.scheme}://{p.netloc}{src}'
57
+ except Exception:return src
58
+ return src
59
+
60
+
61
+ def _article_content_block(soup):
62
+ for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe']):tag.decompose()
63
+ # Aggressively remove related/ad/recommend containers before image collection.
64
+ bad_re=re.compile(r'(related|relate|recommend|suggest|sidebar|ads|advert|popular|more|xem-them|xemthem|tin-lien-quan|tinlienquan|doc-them|docthem|other-news|news-other|article-related|box-tin|box_related|story-related|recommend-news|same-category|cate-list|news-list|most-view|banner|qc|quang-cao|sponsor)',re.I)
65
+ for el in list(soup.find_all(True)):
66
+ cls=' '.join(el.get('class',[])); eid=el.get('id',''); role=el.get('role','')
67
+ if bad_re.search(cls) or bad_re.search(eid) or bad_re.search(role):
68
+ el.decompose()
69
+ selectors=['article','main article','.article-content','.article__body','.article-body','.article-detail','.detail-content','.content-detail','.singular-content','.news-content','.post-content','.entry-content','.knc-content','.fck_detail','.cms-body','.story-body','[class*=article-content]','[class*=detail-content]','[class*=singular-content]']
70
+ for sel in selectors:
71
+ el=soup.select_one(sel)
72
+ if el and (len(el.find_all('p'))>=2 or len(el.find_all(['figure','picture','img']))>=1):return el
73
+ best=None;score=0
74
+ for el in soup.find_all(['article','main','section','div']):
75
+ ps=el.find_all('p');imgs=el.find_all('img');txt=' '.join(p.get_text(' ',strip=True) for p in ps)
76
+ sc=len(ps)*120+len(imgs)*10+min(len(txt),4500)
77
+ cls=' '.join(el.get('class',[])).lower()
78
+ if any(k in cls for k in ['article','content','detail','post','entry','story']):sc+=800
79
+ if sc>score:best=el;score=sc
80
+ return best or soup
81
+
82
+
83
+ def _image_is_likely_article(im, src):
84
+ low=(src or '').lower()
85
+ if not src or src.startswith('data:') or 'base64' in low:return False
86
+ if any(x in low for x in ['logo','icon','avatar','sprite','banner','ads','advert','tracking','pixel','social','share','author','thumb-related']):return False
87
+ alt=(im.get('alt') or im.get('title') or '').lower()
88
+ if any(x in alt for x in ['logo','avatar','quảng cáo','advertisement','banner']):return False
89
+ try:
90
+ w=int(re.sub(r'\D','',str(im.get('width') or '0')) or 0);h=int(re.sub(r'\D','',str(im.get('height') or '0')) or 0)
91
+ if (w and w<220) or (h and h<140):return False
92
+ except Exception:pass
93
+ return True
94
+
95
+
96
+ def _article_only_images(url):
97
+ """Collect images only inside main article content. If uncertain, return fewer/no images rather than related/ad images."""
98
+ imgs=[]
99
+ try:
100
+ from bs4 import BeautifulSoup
101
+ r=requests.get(url,headers=getattr(base,'HEADERS',{}),timeout=18);r.encoding='utf-8'
102
+ soup=BeautifulSoup(r.text,'lxml')
103
+ block=_article_content_block(soup)
104
+ candidates=[]
105
+ # Prefer figure/picture under article body; then direct img in body.
106
+ for el in block.find_all(['figure','picture'],recursive=True):
107
+ im=el.find('img')
108
+ if im:candidates.append(im)
109
+ for im in block.find_all('img',recursive=True):
110
+ if im not in candidates:candidates.append(im)
111
+ seen=set()
112
+ for im in candidates:
113
+ src=(im.get('data-src') or im.get('data-original') or im.get('data-lazy-src') or im.get('data-srcset') or im.get('srcset') or im.get('src') or '')
114
+ if ',' in src:src=src.split(',')[0].strip().split(' ')[0]
115
+ else:src=src.strip().split(' ')[0]
116
+ src=_abs_url(src,url)
117
+ if src in seen or not _image_is_likely_article(im,src):continue
118
+ # parent text guard: skip images from any remaining related block
119
+ parent_txt=' '.join((im.parent.get('class',[]) if im.parent else []))+' '+(im.parent.get('id','') if im.parent else '')
120
+ if re.search(r'(related|recommend|tin-lien-quan|doc-them|xem-them|popular|ads|banner)',parent_txt,re.I):continue
121
+ seen.add(src);imgs.append(src)
122
+ if len(imgs)>=20:break
123
+ # Use og:image ONLY as article main image fallback when no body image found.
124
+ if not imgs:
125
+ og=soup.find('meta',property='og:image') or soup.find('meta',attrs={'name':'twitter:image'})
126
+ if og:
127
+ src=_abs_url(og.get('content',''),url)
128
+ if src and 'logo' not in src.lower() and 'banner' not in src.lower():imgs.append(src)
129
+ except Exception:pass
130
+ return imgs[:20]
131
+
132
+
133
+ def _scrape_url_article_only(url):
134
+ data=base.scrape_any_url(url)
135
+ imgs=_article_only_images(url)
136
+ data['images']=imgs
137
+ if imgs:data['image']=imgs[0]
138
+ else:data['image']=''
139
+ return data
140
+
141
+
142
+ def _blank_image(path, title='VNEWS'):
143
+ if Image is None:return None
144
+ im=Image.new('RGB',(1080,760),(24,48,36));draw=ImageDraw.Draw(im)
145
+ try:f=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',48)
146
+ except Exception:f=None
147
+ draw.text((60,330),clean(title)[:40] or 'VNEWS',fill=(255,255,255),font=f)
148
+ im.save(path,quality=90);return path
149
+
150
+
151
+ def _download_image_safe(url, fallback_title, out_path):
152
+ if url:
153
+ try:
154
+ r=requests.get(url,headers=getattr(base,'HEADERS',{}),timeout=18)
155
+ if r.status_code==200 and len(r.content)>1200:
156
+ with open(out_path,'wb') as f:f.write(r.content)
157
+ # verify PIL opens it
158
+ if Image:
159
+ Image.open(out_path).verify()
160
+ return out_path
161
+ except Exception:pass
162
+ try:
163
+ return base._download_image('',fallback_title,out_path)
164
+ except Exception:
165
+ return _blank_image(out_path,fallback_title)
166
+
167
+
168
+ def final_make_tts(text,voice,out_path):
169
+ text=_strip_bullet_prefix(text) or 'Bản tin VNEWS.'
170
+ # Only Vietnamese voices. Unknown choices fall back to Vietnamese female.
171
+ edge_voice=VN_VOICES.get(str(voice or '').lower().strip(), 'vi-VN-HoaiMyNeural')
172
+ for ev in [edge_voice, 'vi-VN-HoaiMyNeural', 'vi-VN-NamMinhNeural']:
173
+ try:
174
+ subprocess.run(['python','-m','edge_tts','--voice',ev,'--text',text,'--write-media',out_path],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=180)
175
+ if os.path.exists(out_path) and os.path.getsize(out_path)>1000:return out_path
176
+ except Exception:pass
177
+ try:
178
+ base.gTTS(text,lang='vi',tld='com.vn',slow=False).save(out_path)
179
+ if os.path.exists(out_path) and os.path.getsize(out_path)>1000:return out_path
180
+ except Exception:pass
181
+ # Last-resort silent audio guarantees short generation succeeds.
182
+ subprocess.run(['ffmpeg','-y','-f','lavfi','-i','anullsrc=channel_layout=stereo:sample_rate=44100','-t','3','-q:a','9','-acodec','libmp3lame',out_path],stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=30)
183
+ return out_path
184
+
185
+
186
+ def _draw_center(draw, lines, font, y, fill, W, line_h):
187
+ for ln in lines:
188
+ try:box=draw.textbbox((0,0),ln,font=font);tw=box[2]-box[0]
189
+ except Exception:tw=len(ln)*24
190
+ draw.text((max(30,(W-tw)//2),y),ln,fill=fill,font=font);y+=line_h
191
+ return y
192
+
193
+
194
+ def final_make_frame(post,seg,idx,total,img_path,out_path):
195
+ if Image is None:return rt.make_frame(post,seg,idx,total,img_path,out_path)
196
+ W,H=1080,1920;hero_h=760;bg=Image.new('RGB',(W,H),(12,12,12))
197
+ try:
198
+ im=Image.open(img_path).convert('RGB');ratio=im.width/max(1,im.height);tr=W/hero_h
199
+ if ratio>tr:nh=hero_h;nw=int(nh*ratio)
200
+ else:nw=W;nh=int(nw/ratio)
201
+ im=im.resize((nw,nh));left=(nw-W)//2;top=(nh-hero_h)//2;bg.paste(im.crop((left,top,left+W,top+hero_h)),(0,0))
202
+ except Exception:pass
203
+ draw=ImageDraw.Draw(bg)
204
+ try:
205
+ fb=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',58);ft=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',38);fs=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',30);fsmall=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',28)
206
+ except Exception:fb=ft=fs=fsmall=None
207
+ badge='Nguồn: '+_source_badge_url_first(post)
208
+ try:b=draw.textbbox((0,0),badge,font=fsmall);bw=b[2]-b[0];bh=b[3]-b[1]
209
+ except Exception:bw=len(badge)*16;bh=34
210
+ bx=W-bw-42;by=24;draw.rounded_rectangle((bx-16,by-8,W-24,by+bh+14),radius=18,fill=(0,0,0));draw.text((bx,by),badge,fill=(255,255,255),font=fsmall)
211
+ draw.rectangle((0,hero_h-20,W,H),fill=(12,12,12))
212
+ total=max(1,total);total_w=total*38-14;start=(W-total_w)//2
213
+ for i in range(total):draw.rounded_rectangle((start+i*38,820,start+i*38+24,832),radius=6,fill=(92,184,122) if i==idx else (70,70,70))
214
+ brand='VNEWS AI SHORT'
215
+ try:bb=draw.textbbox((0,0),brand,font=ft);tx=(W-(bb[2]-bb[0]))//2
216
+ except Exception:tx=360
217
+ draw.text((tx,870),brand,fill=(110,231,143),font=ft)
218
+ seg=_strip_bullet_prefix(seg);lines=rt.wrap_text(draw,seg,fb,W-120,8);y=max(980,1250-(len(lines)*74)//2);_draw_center(draw,lines,fb,y,(255,255,255),W,74)
219
+ title_lines=rt.wrap_text(draw,_strip_bullet_prefix(post.get('title','')),fs,W-120,3);y2=1640;draw.line((80,y2-26,W-80,y2-26),fill=(70,70,70),width=2);_draw_center(draw,title_lines,fs,y2,(220,220,220),W,42)
220
+ bg.save(out_path,quality=92)
221
+
222
+ # Monkey patches for old functions.
223
+ rt.make_frame=final_make_frame;rt.make_tts=final_make_tts;rt._source_badge=_source_badge_url_first
224
+
225
+ # Override endpoints.
226
+ _PATCH={('/api/url_wall','POST'),('/api/rewrite_share','POST'),('/api/ai/url','POST'),('/api/ai/short/{post_id}','POST'),('/','GET'),('/aw','GET')}
227
+ app.router.routes=[r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in _PATCH)]
228
+
229
+ @app.post('/api/url_wall')
230
+ async def final_url_wall(request:Request):
231
+ body=await request.json();url=base._clean_text(body.get('url',''))
232
+ if not url.startswith('http'):return JSONResponse({'error':'missing url'},status_code=400)
233
+ try:data=_scrape_url_article_only(url)
234
+ except Exception as e:return JSONResponse({'error':'Không scrape được URL: '+str(e)[:180]},status_code=422)
235
+ raw=(data.get('summary','')+'\n'+data.get('text','')).strip()
236
+ if len(raw)<120:return JSONResponse({'error':'URL không có đủ nội dung để tóm tắt'},status_code=422)
237
+ prompt=f"""Tóm tắt bài viết nguồn dưới đây để đăng lên Tường AI VNEWS.
238
+
239
+ Yêu cầu:
240
+ - Chỉ tóm tắt nội dung chính, không viết lại toàn bộ bài.
241
+ - Ngắn gọn, cụ thể, dễ hiểu.
242
+ - Không lặp ý, không thêm chi tiết ngoài nguồn.
243
+ - Tối đa 5 ý chính hoặc 2 đoạn ngắn.
244
+ - Hạn chế dùng dấu đầu dòng.
245
+
246
+ Tiêu đề gốc: {data.get('title','')}
247
+ Nguồn: {_domain(url)}
248
+ Nội dung gốc:
249
+ {raw[:16000]}"""
250
+ text=await base.qwen_generate(prompt,image_url=(data.get('image') or None),max_tokens=900)
251
+ if not text:text=rt.old._fallback_summary_from_prompt(prompt,max_units=5) if hasattr(rt.old,'_fallback_summary_from_prompt') else raw[:900]
252
+ text=rt.postprocess(text) if hasattr(rt,'postprocess') else text
253
+ src=[{'title':data.get('title'), 'url':url, 'excerpt':raw[:500], 'via':_domain(url)}]
254
+ if 'Nguồn tham khảo:' not in text:text+='\n\n'+rt.source_line(src)
255
+ imgs=data.get('images') or []
256
+ post=base.make_post(data.get('title') or 'Bài viết',text,imgs[0] if imgs else '',url,'url',sources=src)
257
+ post['images']=imgs
258
+ posts=base._load_ai_wall();posts.insert(0,post);base._save_ai_wall(posts)
259
+ return JSONResponse({'post':post})
260
+
261
+ @app.post('/api/rewrite_share')
262
+ async def final_rewrite_share(request:Request):return await final_url_wall(request)
263
+ @app.post('/api/ai/url')
264
+ async def final_ai_url(request:Request):return await final_url_wall(request)
265
+
266
+ @app.post('/api/ai/short/{post_id}')
267
+ async def final_short(post_id:str,request:Request):
268
+ try:body=await request.json()
269
+ except Exception:body={}
270
+ voice=str(body.get('voice','nu')).lower().strip();emotion=str(body.get('emotion','neutral')).lower().strip();speed=max(0.85,min(1.35,float(body.get('speed',1.2) or 1.2)))
271
+ posts=base._load_ai_wall();post=next((p for p in posts if str(p.get('id'))==str(post_id)),None)
272
+ if not post:return JSONResponse({'error':'post not found'},status_code=404)
273
+ segs=rt.split_segments(post,8) if hasattr(rt,'split_segments') else [_strip_bullet_prefix(post.get('text') or post.get('title') or 'VNEWS')]
274
+ imgs=[u for u in (post.get('images') or []) if u] or ([post.get('img')] if post.get('img') else [])
275
+ os.makedirs(base.SHORTS_DIR,exist_ok=True);suffix=f'_{voice}_{emotion}_{str(speed).replace(".","p")}_articleimgs_vivoice'
276
+ out=os.path.join(base.SHORTS_DIR,base._safe_name(post_id+suffix)+'.mp4')
277
+ if os.path.exists(out):
278
+ post['video']='/api/ai/short-file/'+post_id+suffix;base._save_ai_wall(posts);return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False})
279
+ work=os.path.join(base.SHORTS_DIR,base._safe_name(post_id+suffix));os.makedirs(work,exist_ok=True)
280
+ clips=[]
281
+ try:
282
+ for i,seg in enumerate(segs):
283
+ img_url=imgs[i % len(imgs)] if imgs else ''
284
+ img=os.path.join(work,f'image_{i}.jpg');frame=os.path.join(work,f'f{i}.jpg');aud=os.path.join(work,f'a{i}.mp3');aud2=os.path.join(work,f'a{i}_fast.mp3');clip=os.path.join(work,f'c{i}.mp4')
285
+ _download_image_safe(img_url,post.get('title','AI news'),img)
286
+ seg=_strip_bullet_prefix(seg);final_make_frame(post,seg,i,len(segs),img,frame)
287
+ prefix={'urgent':'Tin nhanh.','warm':'Câu chuyện đáng chú ý.','serious':'Bản tin nghiêm túc.','energetic':'Cập nhật nổi bật.'}.get(emotion,'')
288
+ spoken=(prefix+' '+seg).strip() if i==0 and prefix else seg
289
+ final_make_tts(spoken,voice,aud)
290
+ try:subprocess.run(['ffmpeg','-y','-i',aud,'-filter:a',f'atempo={speed}','-vn',aud2],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=120)
291
+ except Exception:aud2=aud
292
+ try:
293
+ subprocess.run(['ffmpeg','-y','-loop','1','-i',frame,'-i',aud2,'-shortest','-c:v','libx264','-tune','stillimage','-pix_fmt','yuv420p','-c:a','aac','-b:a','128k','-vf','scale=1080:1920',clip],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=180)
294
+ except Exception:
295
+ # last-resort visual-only 4s clip
296
+ subprocess.run(['ffmpeg','-y','-loop','1','-t','4','-i',frame,'-f','lavfi','-i','anullsrc=channel_layout=stereo:sample_rate=44100','-shortest','-c:v','libx264','-pix_fmt','yuv420p','-c:a','aac','-vf','scale=1080:1920',clip],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=120)
297
+ clips.append(clip)
298
+ lf=os.path.join(work,'list.txt')
299
+ with open(lf,'w',encoding='utf-8') as f:
300
+ for c in clips:f.write("file '"+c.replace("","'\\''"))+"'\n")
301
+ subprocess.run(['ffmpeg','-y','-f','concat','-safe','0','-i',lf,'-c','copy',out],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=240)
302
+ post['video']='/api/ai/short-file/'+post_id+suffix;post['short_subtitles']=False;post['short_segments']=segs;post['short_speed']=speed;base._save_ai_wall(posts)
303
+ return JSONResponse({'video':post['video'],'segments':len(segs),'subtitles':False})
304
+ except Exception as e:return JSONResponse({'error':'Không tạo được shorts: '+str(e)[:220]},status_code=500)
305
+
306
+ @app.get('/aw')
307
+ def ai_wall_share(post:str=Query(default=''), short:int=Query(default=0)):
308
+ posts=base._load_ai_wall();p=next((x for x in posts if str(x.get('id'))==str(post)),None)
309
+ if not p:return HTMLResponse(f'<script>location.href="{SPACE_URL}"</script>')
310
+ title=p.get('title') or 'VNEWS AI';img=p.get('img') or DEFAULT_IMG
311
+ desc=(p.get('text') or '')[:220]
312
+ return HTMLResponse(f'<!doctype html><html><head><meta charset="utf-8"><title>{title}</title><meta property="og:title" content="{title}"><meta property="og:description" content="{desc}"><meta property="og:image" content="{img}"><meta property="og:type" content="article"><meta name="twitter:card" content="summary_large_image"></head><body><script>localStorage.setItem('pending_ai_post','{post}');location.href='{SPACE_URL}'</script></body></html>')
313
+
314
+ FINAL_INJECT = r'''
315
+ <style>
ai_runtime_final2.py ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Final2: improve article-image detection without over-filtering real article images."""
2
+ import re, requests
3
+ from urllib.parse import urlparse
4
+ import ai_runtime_final as f1
5
+ from ai_runtime_final import app, base, rt, HTMLResponse, JSONResponse, Request, Query
6
+
7
+
8
+ def _domain(url):
9
+ try:return urlparse(url or '').netloc.replace('www.','')
10
+ except Exception:return ''
11
+
12
+
13
+ def _abs_url(src, base_url):
14
+ if not src:return ''
15
+ src=src.strip()
16
+ if src.startswith('//'):return 'https:'+src
17
+ if src.startswith('/'):
18
+ try:
19
+ p=urlparse(base_url);return f'{p.scheme}://{p.netloc}{src}'
20
+ except Exception:return src
21
+ return src
22
+
23
+ BAD_RE=re.compile(r'(related|relate|recommend|suggest|sidebar|ads|advert|popular|xem-them|xemthem|tin-lien-quan|tinlienquan|doc-them|docthem|other-news|news-other|article-related|box-tin|box_related|story-related|recommend-news|same-category|cate-list|most-view|banner|qc|quang-cao|sponsor|social|share|comment|author|newsletter)',re.I)
24
+ GOOD_RE=re.compile(r'(article|content|detail|body|post|entry|story|fck|cms|singular|main|news)',re.I)
25
+ IMG_EXT_RE=re.compile(r'\.(jpg|jpeg|png|webp|avif)(\?|$)',re.I)
26
+ ARTICLE_LINK_RE=re.compile(r'\.(html|htm|shtml|tpo|chn)(\?|$)|/\d{4}/|post\d+|article',re.I)
27
+
28
+
29
+ def _clean_soup(soup):
30
+ for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe']):
31
+ tag.decompose()
32
+
33
+
34
+ def _find_article_block(soup):
35
+ """Find the article body first; do not delete suspected related blocks before finding it."""
36
+ selectors=[
37
+ 'article', 'main article',
38
+ '.article-content','.article__content','.article__body','.article-body','.article-detail','.article__detail',
39
+ '.detail-content','.content-detail','.singular-content','.news-content','.post-content','.entry-content',
40
+ '.knc-content','.fck_detail','.cms-body','.story-body','.maincontent','.main-content',
41
+ '[class*=article-content]','[class*=article__content]','[class*=detail-content]','[class*=singular-content]',
42
+ '[class*=cms-body]','[class*=story-body]'
43
+ ]
44
+ for sel in selectors:
45
+ el=soup.select_one(sel)
46
+ if el and (len(el.find_all('p'))>=2 or len(el.find_all(['figure','picture','img']))>=1):
47
+ return el
48
+ best=None;best_score=0
49
+ for el in soup.find_all(['article','main','section','div']):
50
+ cls=' '.join(el.get('class',[]));eid=el.get('id','')
51
+ if BAD_RE.search(cls+' '+eid) and not GOOD_RE.search(cls+' '+eid):
52
+ continue
53
+ ps=el.find_all('p');imgs=el.find_all('img')
54
+ text=' '.join(p.get_text(' ',strip=True) for p in ps)
55
+ long_ps=sum(1 for p in ps if len(p.get_text(' ',strip=True))>40)
56
+ score=long_ps*180+len(ps)*40+min(len(text),5000)+len(imgs)*25
57
+ if GOOD_RE.search(cls+' '+eid):score+=800
58
+ if score>best_score:
59
+ best=el;best_score=score
60
+ return best or soup
61
+
62
+
63
+ def _ancestor_bad(im, block):
64
+ node=im
65
+ while node and node is not block:
66
+ if getattr(node,'name',None) in ['aside','nav','footer']:
67
+ return True
68
+ cls=' '.join(node.get('class',[])) if hasattr(node,'get') else ''
69
+ eid=node.get('id','') if hasattr(node,'get') else ''
70
+ if BAD_RE.search(cls+' '+eid):
71
+ return True
72
+ node=getattr(node,'parent',None)
73
+ return False
74
+
75
+
76
+ def _image_anchor_penalty(im, page_url):
77
+ a=im.find_parent('a')
78
+ if not a:return 0
79
+ href=_abs_url(a.get('href',''),page_url)
80
+ if not href:return 0
81
+ # If anchor opens the image itself, do not penalize.
82
+ if IMG_EXT_RE.search(href):return 0
83
+ # If anchor points to another article, it is probably related content.
84
+ try:
85
+ p1=urlparse(page_url);p2=urlparse(href)
86
+ if href!=page_url and ARTICLE_LINK_RE.search(href) and (p2.path!=p1.path):
87
+ return -100
88
+ except Exception:pass
89
+ return -10
90
+
91
+
92
+ def _near_article_text_score(im):
93
+ score=0
94
+ # caption/figcaption is strong sign of article image
95
+ fig=im.find_parent('figure')
96
+ if fig:
97
+ score+=5
98
+ cap=fig.find('figcaption')
99
+ if cap and len(cap.get_text(' ',strip=True))>10:score+=4
100
+ if im.find_parent('picture'):score+=2
101
+ # paragraph around image
102
+ parent=im.parent
103
+ for node in [parent, getattr(parent,'parent',None) if parent else None, fig]:
104
+ if not node:continue
105
+ ps=node.find_all('p') if hasattr(node,'find_all') else []
106
+ if any(len(p.get_text(' ',strip=True))>40 for p in ps):score+=3;break
107
+ # sibling paragraph near figure/image
108
+ holder=fig or parent
109
+ if holder:
110
+ for sib in [holder.find_previous_sibling(), holder.find_next_sibling()]:
111
+ if sib and len(sib.get_text(' ',strip=True))>40:
112
+ score+=2
113
+ break
114
+ return score
115
+
116
+
117
+ def _image_score(im, src, block, page_url):
118
+ low=(src or '').lower()
119
+ if not src or src.startswith('data:') or 'base64' in low:return -999
120
+ if any(x in low for x in ['logo','icon','avatar','sprite','tracking','pixel','social','share','author']):return -999
121
+ if _ancestor_bad(im,block):return -999
122
+ score=0
123
+ # Explicit dimensions: only reject truly tiny images; if missing dimensions, allow.
124
+ try:
125
+ w=int(re.sub(r'\D','',str(im.get('width') or '0')) or 0);h=int(re.sub(r'\D','',str(im.get('height') or '0')) or 0)
126
+ if (w and w<120) or (h and h<90):return -999
127
+ if w>=500 or h>=300:score+=3
128
+ except Exception:pass
129
+ alt=(im.get('alt') or im.get('title') or '').lower()
130
+ if any(x in alt for x in ['logo','avatar','quảng cáo','advertisement','banner']):return -999
131
+ cls=' '.join(im.get('class',[]));eid=im.get('id','')
132
+ if BAD_RE.search(cls+' '+eid):return -999
133
+ if GOOD_RE.search(cls+' '+eid):score+=2
134
+ score+=_near_article_text_score(im)
135
+ score+=_image_anchor_penalty(im,page_url)
136
+ if any(x in low for x in ['cdn','photo','image','media','upload','thumb','avatar']):score+=1
137
+ # Tienphong and many VN papers use lazy/data src without figure; still accept if inside article block.
138
+ if im.find_parent(['article','main']) or GOOD_RE.search(' '.join(block.get('class',[]))+' '+block.get('id','')):score+=3
139
+ return score
140
+
141
+
142
+ def _extract_img_src(im, page_url):
143
+ src=(im.get('data-src') or im.get('data-original') or im.get('data-lazy-src') or im.get('data-srcset') or im.get('srcset') or im.get('src') or '')
144
+ if ',' in src:src=src.split(',')[0].strip().split(' ')[0]
145
+ else:src=src.strip().split(' ')[0]
146
+ return _abs_url(src,page_url)
147
+
148
+
149
+ def _article_only_images(url):
150
+ imgs=[]
151
+ try:
152
+ from bs4 import BeautifulSoup
153
+ r=requests.get(url,headers=getattr(base,'HEADERS',{}),timeout=18);r.encoding='utf-8'
154
+ soup=BeautifulSoup(r.text,'lxml')
155
+ _clean_soup(soup)
156
+ block=_find_article_block(soup)
157
+ candidates=[]
158
+ for el in block.find_all(['figure','picture'],recursive=True):
159
+ im=el.find('img')
160
+ if im and im not in candidates:candidates.append(im)
161
+ for im in block.find_all('img',recursive=True):
162
+ if im not in candidates:candidates.append(im)
163
+ scored=[];seen=set()
164
+ for im in candidates:
165
+ src=_extract_img_src(im,url)
166
+ if not src or src in seen:continue
167
+ seen.add(src)
168
+ sc=_image_score(im,src,block,url)
169
+ if sc>=2:
170
+ scored.append((sc,src))
171
+ # Keep original article order but only for scored images, filtering duplicate URLs.
172
+ good=set(src for sc,src in sorted(scored,reverse=True) if sc>=2)
173
+ for im in candidates:
174
+ src=_extract_img_src(im,url)
175
+ if src in good and src not in imgs:imgs.append(src)
176
+ if len(imgs)>=20:break
177
+ # Fallback: og:image is usually article main image, and better than no image.
178
+ if not imgs:
179
+ og=soup.find('meta',property='og:image') or soup.find('meta',attrs={'name':'twitter:image'})
180
+ if og:
181
+ src=_abs_url(og.get('content',''),url)
182
+ if src and not any(x in src.lower() for x in ['logo','icon','avatar','sprite']):imgs.append(src)
183
+ except Exception:pass
184
+ return imgs[:20]
185
+
186
+
187
+ def _scrape_url_article_only(url):
188
+ data=base.scrape_any_url(url)
189
+ imgs=_article_only_images(url)
190
+ data['images']=imgs
191
+ data['image']=imgs[0] if imgs else ''
192
+ return data
193
+
194
+ # Override the functions used by inherited endpoints.
195
+ f1._article_only_images=_article_only_images
196
+ f1._scrape_url_article_only=_scrape_url_article_only
197
+
198
+ # Replace URL endpoints to use improved extraction.
199
+ _PATCH={('/api/url_wall','POST'),('/api/rewrite_share','POST'),('/api/ai/url','POST'),('/','GET')}
200
+ app.router.routes=[r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in _PATCH)]
201
+
202
+ @app.post('/api/url_wall')
203
+ async def final2_url_wall(request:Request):
204
+ body=await request.json();url=base._clean_text(body.get('url',''))
205
+ if not url.startswith('http'):return JSONResponse({'error':'missing url'},status_code=400)
206
+ try:data=_scrape_url_article_only(url)
207
+ except Exception as e:return JSONResponse({'error':'Không scrape được URL: '+str(e)[:180]},status_code=422)
208
+ raw=(data.get('summary','')+'\n'+data.get('text','')).strip()
209
+ if len(raw)<120:return JSONResponse({'error':'URL không có đủ nội dung để tóm tắt'},status_code=422)
210
+ prompt=f"""Tóm tắt bài viết nguồn dưới đây để đăng lên Tường AI VNEWS.
211
+
212
+ Yêu cầu:
213
+ - Chỉ tóm tắt nội dung chính, không viết lại toàn bộ bài.
214
+ - Ngắn gọn, cụ thể, dễ hiểu.
215
+ - Không lặp ý, không thêm chi tiết ngoài nguồn.
216
+ - Tối đa 5 ý chính hoặc 2 đoạn ngắn.
217
+ - Hạn chế dùng dấu đầu dòng.
218
+
219
+ Tiêu đề gốc: {data.get('title','')}
220
+ Nguồn: {_domain(url)}
221
+ Nội dung gốc:
222
+ {raw[:16000]}"""
223
+ text=await base.qwen_generate(prompt,image_url=(data.get('image') or None),max_tokens=900)
224
+ if not text:text=rt.old._fallback_summary_from_prompt(prompt,max_units=5) if hasattr(rt.old,'_fallback_summary_from_prompt') else raw[:900]
225
+ text=rt.postprocess(text) if hasattr(rt,'postprocess') else text
226
+ src=[{'title':data.get('title'), 'url':url, 'excerpt':raw[:500], 'via':_domain(url)}]
227
+ if 'Nguồn tham khảo:' not in text:text+='\n\n'+rt.source_line(src)
228
+ imgs=data.get('images') or []
229
+ post=base.make_post(data.get('title') or 'Bài viết',text,imgs[0] if imgs else '',url,'url',sources=src)
230
+ post['images']=imgs
231
+ posts=base._load_ai_wall();posts.insert(0,post);base._save_ai_wall(posts)
232
+ return JSONResponse({'post':post})
233
+
234
+ @app.post('/api/rewrite_share')
235
+ async def final2_rewrite_share(request:Request):return await final2_url_wall(request)
236
+ @app.post('/api/ai/url')
237
+ async def final2_ai_url(request:Request):return await final2_url_wall(request)
238
+
239
+ @app.get('/')
240
+ async def index_final2():
241
+ html=f1._load_index_html();body=getattr(rt.old,'PATCH_INJECT','') + f1.FINAL_INJECT
242
+ return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
ai_runtime_final3.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Final3 runtime: Qwen topic posts, robust YouTube shorts, TikTok-style actions for Shorts and Short AI."""
2
+ import os, re, time, json, hashlib, requests
3
+ from urllib.parse import quote, urlparse
4
+ import ai_runtime_final2 as f2
5
+ from ai_runtime_final2 import app, base, rt, HTMLResponse, JSONResponse, Request, Query
6
+
7
+ SPACE_URL="https://bep40-vnews.hf.space"
8
+ SHORT_CHANNELS=["baodantri7941","baosuckhoedoisongboyte"]
9
+ _SHORTS_CACHE={"t":0,"d":[]}
10
+ AI_INTERACTIONS_FILE="/data/ai_interactions.json" if os.path.isdir('/data') else "/app/data/ai_interactions.json"
11
+
12
+
13
+ def clean(s):
14
+ import html as html_lib
15
+ return re.sub(r"\s+"," ",html_lib.unescape(s or "")).strip()
16
+
17
+
18
+ def _domain(u):
19
+ try:return urlparse(u or '').netloc.replace('www.','')
20
+ except Exception:return ''
21
+
22
+
23
+ def _load_json(path,default):
24
+ try:
25
+ if os.path.exists(path):
26
+ with open(path,'r',encoding='utf-8') as f:return json.load(f)
27
+ except Exception:pass
28
+ return default
29
+
30
+
31
+ def _save_json(path,data):
32
+ try:
33
+ os.makedirs(os.path.dirname(path),exist_ok=True);tmp=path+'.tmp'
34
+ with open(tmp,'w',encoding='utf-8') as f:json.dump(data,f,ensure_ascii=False)
35
+ os.replace(tmp,path)
36
+ except Exception:pass
37
+
38
+
39
+ def _youtube_shorts_ytdlp(handle,count=20):
40
+ try:
41
+ import yt_dlp
42
+ url=f"https://www.youtube.com/@{handle}/shorts"
43
+ opts={'quiet':True,'extract_flat':True,'skip_download':True,'playlistend':count,'ignoreerrors':True,'no_warnings':True}
44
+ with yt_dlp.YoutubeDL(opts) as ydl:
45
+ info=ydl.extract_info(url,download=False)
46
+ out=[]
47
+ for e in (info or {}).get('entries') or []:
48
+ vid=e.get('id') or ''
49
+ if not re.match(r'^[A-Za-z0-9_-]{11}$',vid):continue
50
+ title=e.get('title') or 'YouTube Short'
51
+ out.append({'title':title,'link':f'https://www.youtube.com/watch?v={vid}','img':f'https://i.ytimg.com/vi/{vid}/hqdefault.jpg','source':'yt','id':vid,'channel':handle})
52
+ return out
53
+ except Exception:return []
54
+
55
+
56
+ def _youtube_shorts_html(handle,count=20):
57
+ try:
58
+ html=requests.get(f"https://www.youtube.com/@{handle}/shorts",headers=getattr(base,'HEADERS',{}),timeout=15).text
59
+ ids=[];out=[]
60
+ for m in re.finditer(r'"videoId":"([A-Za-z0-9_-]{11})"',html):
61
+ vid=m.group(1)
62
+ if vid in ids:continue
63
+ ids.append(vid)
64
+ snip=html[max(0,m.start()-1000):m.start()+1800]
65
+ title='YouTube Short'
66
+ mt=re.search(r'"title":\{"runs":\[\{"text":"([^"]+)"',snip) or re.search(r'"accessibilityText":"([^"]+)"',snip)
67
+ if mt:title=clean(mt.group(1).replace('\\n',' '))
68
+ out.append({'title':title,'link':f'https://www.youtube.com/watch?v={vid}','img':f'https://i.ytimg.com/vi/{vid}/hqdefault.jpg','source':'yt','id':vid,'channel':handle})
69
+ if len(out)>=count:break
70
+ return out
71
+ except Exception:return []
72
+
73
+
74
+ def _fresh_shorts():
75
+ items=[];seen=set()
76
+ for ch in SHORT_CHANNELS:
77
+ got=_youtube_shorts_ytdlp(ch,24) or _youtube_shorts_html(ch,24)
78
+ for v in got:
79
+ if v['id'] not in seen:
80
+ seen.add(v['id']);items.append(v)
81
+ # fallback from main if live scrape fails
82
+ try:
83
+ for v in getattr(rt.old.base if hasattr(rt.old,'base') else rt,'SHORTS_FALLBACK',[]) or []:
84
+ vid=v.get('id')
85
+ if vid and vid not in seen:
86
+ seen.add(vid);items.append(v)
87
+ except Exception:pass
88
+ return items[:50]
89
+
90
+
91
+ def _topic_image(topic):
92
+ try:return base.pollinations_image_url(topic)
93
+ except Exception:return "https://image.pollinations.ai/prompt/"+quote("Vietnamese news editorial illustration "+topic)+"?width=1024&height=576&nologo=true"
94
+
95
+ # Remove old endpoints/root to override.
96
+ _PATCH={('/api/shorts','GET'),('/api/topic_post','POST'),('/api/ai/interact','POST'),('/','GET')}
97
+ app.router.routes=[r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in _PATCH)]
98
+
99
+ @app.get('/api/shorts')
100
+ def api_shorts_final3(refresh:int=Query(default=0)):
101
+ now=time.time()
102
+ if not refresh and _SHORTS_CACHE['d'] and now-_SHORTS_CACHE['t']<900:
103
+ return JSONResponse(_SHORTS_CACHE['d'])
104
+ data=_fresh_shorts()
105
+ _SHORTS_CACHE.update({'t':now,'d':data})
106
+ return JSONResponse(data)
107
+
108
+ @app.post('/api/topic_post')
109
+ async def topic_post_qwen(request:Request):
110
+ body=await request.json();topic=clean(body.get('topic',''))
111
+ if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
112
+ img=_topic_image(topic)
113
+ prompt=f"""Bạn là biên tập viên VNEWS. Dựa trên kiến thức tổng quát của bạn, hãy tạo một bài đăng Tường AI bằng tiếng Việt về chủ đề: {topic}
114
+
115
+ Yêu cầu:
116
+ - Viết như một bài tin/tạp chí ngắn, có tiêu đề hấp dẫn.
117
+ - 1 đoạn mở đầu 2 câu.
118
+ - 4-6 ý chính rõ ràng, không lan man.
119
+ - Nếu chủ đề là thể thao/c��ng nghệ/xã hội, hãy viết có bối cảnh và nhận định.
120
+ - Không khẳng định số liệu thời sự mới nếu không chắc; dùng cách diễn đạt thận trọng.
121
+ - Cuối bài thêm dòng: Nguồn tham khảo: Qwen2.5-VL / kiến thức tổng hợp.
122
+ """
123
+ text=await base.qwen_generate(prompt,image_url=img,max_tokens=1100)
124
+ if not text:
125
+ text=f"{topic}\n\nĐây là bài gợi ý do AI tạo dựa trên kiến thức tổng hợp. Nội dung cung cấp bối cảnh, các điểm đáng chú ý và góc nhìn tham khảo về chủ đề này.\n\nNguồn tham khảo: Qwen2.5-VL / kiến thức tổng hợp."
126
+ post=base.make_post(topic,text,img,'','topic_qwen',sources=[{'title':'Qwen2.5-VL / kiến thức tổng hợp','url':'','via':'Qwen2.5-VL'}])
127
+ post['images']=[img]
128
+ posts=base._load_ai_wall();posts.insert(0,post);base._save_ai_wall(posts)
129
+ return JSONResponse({'post':post})
130
+
131
+ @app.post('/api/ai/interact')
132
+ async def ai_interact(request:Request):
133
+ body=await request.json();pid=str(body.get('id','')).strip();kind=str(body.get('kind','wall')).strip();action=str(body.get('action','')).strip();text=clean(body.get('text',''))
134
+ if not pid:return JSONResponse({'error':'missing id'},status_code=400)
135
+ db=_load_json(AI_INTERACTIONS_FILE,{})
136
+ key=kind+':'+pid
137
+ st=db.get(key) or {'views':0,'likes':0,'comments':[],'asks':[]}
138
+ if action=='view':st['views']=int(st.get('views',0))+1
139
+ elif action=='like':st['likes']=int(st.get('likes',0))+1
140
+ elif action=='comment' and text:
141
+ st.setdefault('comments',[]).insert(0,{'text':text[:240],'ts':int(time.time())});st['comments']=st['comments'][:80]
142
+ elif action=='ask' and text:
143
+ posts=base._load_ai_wall();p=next((x for x in posts if str(x.get('id'))==pid),{})
144
+ prompt=f"""Trả lời ngắn bằng tiếng Việt cho câu hỏi của người xem về nội dung này.
145
+ Tiêu đề: {p.get('title','')}
146
+ Nội dung: {(p.get('text') or '')[:4000]}
147
+ Câu hỏi: {text}
148
+ """
149
+ ans=await base.qwen_generate(prompt,max_tokens=500)
150
+ if not ans:ans='AI chưa trả lời được lúc này. Bạn thử hỏi lại ngắn gọn hơn.'
151
+ st.setdefault('asks',[]).insert(0,{'q':text[:240],'a':ans[:1000],'ts':int(time.time())});st['asks']=st['asks'][:50]
152
+ db[key]=st;_save_json(AI_INTERACTIONS_FILE,db)
153
+ return JSONResponse({'stats':st})
154
+
155
+ FINAL3_INJECT = r'''
156
+ <style>
157
+ .ai-compose-row.topic-final3{display:flex!important;flex-direction:column!important;gap:8px!important;width:100%!important}.ai-compose-row.topic-final3 input,.ai-compose-row.topic-final3 button{width:100%!important;box-sizing:border-box!important}.short-action-panel{position:absolute;right:8px;bottom:92px;display:flex;flex-direction:column;gap:12px;z-index:20}.short-action-btn{background:none;border:0;color:#fff;text-align:center;font-size:10px}.short-action-btn .ico{width:44px;height:44px;border-radius:50%;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;font-size:21px;margin:auto}.short-modal{position:fixed;inset:auto 0 0 0;max-height:60vh;background:#181818;border-radius:16px 16px 0 0;z-index:99999;padding:14px;display:none;overflow:auto}.short-modal.active{display:block}.short-modal textarea,.short-modal input{width:100%;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px;margin:6px 0}.short-modal button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin:4px}.ai-short-home{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.ai-short-card-final{flex:0 0 120px}.ai-short-card-final video{width:100%;aspect-ratio:9/16;object-fit:cover;background:#000;border-radius:8px}
158
+ </style>
159
+ <div id="short-modal" class="short-modal"></div>
160
+ <script>
161
+ (function(){
162
+ let finalWall3=[];let currentShortCtx=null;
163
+ function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
164
+ function ensureTopicBox(){let comp=document.querySelector('.ai-compose');if(!comp)return;if(!document.getElementById('ai-topic-input-final3')){let row=document.createElement('div');row.className='ai-compose-row topic-final3';row.innerHTML='<input id="ai-topic-input-final3" placeholder="Nhập chủ đề để Qwen2.5VL gợi ý bài đăng lên Tường AI..."><button onclick="createTopicPostFinal3()">✨ Tạo bài theo chủ đề bằng Qwen</button>';comp.insertBefore(row,comp.firstChild.nextSibling);} }
165
+ window.createTopicPostFinal3=async function(){let inp=document.getElementById('ai-topic-input-final3');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');let btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang tạo...'}try{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');finalWall3.unshift(j.post);if(window.finalWall)window.finalWall.unshift(j.post);if(inp)inp.value='';renderAIShortHome();if(window.renderWall)window.renderWall();alert('Đã tạo bài chủ đề và đăng lên Tường AI, không reload.');}catch(e){alert(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='✨ Tạo bài theo chủ đề bằng Qwen'}}};
166
+ async function refreshFinalWall3(){try{finalWall3=(await (await fetch('/api/ai_wall')).json()).posts||[];renderAIShortHome();}catch(e){}}
167
+ function renderAIShortHome(){let home=document.getElementById('view-home');if(!home)return;document.getElementById('ai-short-home')?.remove();let vids=finalWall3.filter(p=>p.video);if(!vids.length)return;let wrap=document.createElement('div');wrap.id='ai-short-home';wrap.className='ai-short-home';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">';vids.slice(0,30).forEach((p,i)=>{h+=`<div class="ai-short-card-final" onclick="openAIShortFeed(${i})"><video src="${p.video}" muted playsinline preload="metadata"></video><div class="slider-title">${esc(p.title)}</div></div>`});h+='</div>';wrap.innerHTML=h;let after=document.getElementById('ai-wall-final')||document.querySelector('.ai-compose');if(after)after.after(wrap);else home.prepend(wrap);}
168
+ window.openAIShortFeed=function(start){let vids=finalWall3.filter(p=>p.video);if(!vids.length)return;showView('view-tiktok');let h='<button class="back-btn" onclick="switchCat(\'home\')">← Short AI</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">';let ordered=start>0?vids.slice(start).concat(vids.slice(0,start)):vids;ordered.forEach((p,i)=>{h+=`<div class="tiktok-slide" data-kind="ai" data-id="${p.id}"><video src="${p.video}" playsinline controls loop></video><div class="tiktok-bottom"><span class="badge badge-ai">AI</span><p class="tiktok-title">${esc(p.title)}</p></div>${actionPanel('ai',p.id,i)}<span class="tiktok-counter">${i+1}/${ordered.length}</span></div>`});h+='</div></div>';document.getElementById('view-tiktok').innerHTML=h;initActionFeed();}
169
+ function actionPanel(kind,id,i){return `<div class="short-action-panel"><button class="short-action-btn" onclick="shortAct('${kind}','${id}','view')"><div class="ico">👁</div><span id="v-${kind}-${id}">0</span></button><button class="short-action-btn" onclick="shortAct('${kind}','${id}','like')"><div class="ico">❤️</div><span id="l-${kind}-${id}">0</span></button><button class="short-action-btn" onclick="openCommentBox('${kind}','${id}')"><div class="ico">💬</div><span>BL</span></button><button class="short-action-btn" onclick="openAskBox('${kind}','${id}')"><div class="ico">🤖</div><span>Hỏi</span></button><button class="short-action-btn" onclick="shareShortCtx('${kind}','${id}')"><div class="ico">📤</div><span>Share</span></button></div>`}
170
+ window.shortAct=async function(kind,id,action,text=''){let url=kind==='yt'?'/api/short-action':'/api/ai/interact';let body=kind==='yt'?{id,action,text}:{id,kind:'short',action,text};let r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});let j=await r.json();let st=j.stats||j;let v=document.getElementById(`v-${kind}-${id}`),l=document.getElementById(`l-${kind}-${id}`);if(v&&st.views!=null)v.textContent=st.views;if(l&&st.likes!=null)l.textContent=st.likes;return st;}
171
+ window.openCommentBox=function(kind,id){let m=document.getElementById('short-modal');m.innerHTML=`<h3>💬 Bình luận</h3><textarea id="short-comment-text" placeholder="Nhập bình luận..."></textarea><button onclick="submitShortComment('${kind}','${id}')">Gửi</button><button onclick="closeShortModal()">Đóng</button>`;m.classList.add('active')}
172
+ window.submitShortComment=async function(kind,id){let t=document.getElementById('short-comment-text').value.trim();if(!t)return;await shortAct(kind,id,'comment',t);alert('Đã gửi bình luận');closeShortModal()}
173
+ window.openAskBox=function(kind,id){let m=document.getElementById('short-modal');m.innerHTML=`<h3>🤖 Hỏi AI</h3><input id="short-ask-text" placeholder="Bạn muốn hỏi gì về nội dung này?"><div id="short-answer"></div><button onclick="submitShortAsk('${kind}','${id}')">Hỏi</button><button onclick="closeShortModal()">Đóng</button>`;m.classList.add('active')}
174
+ window.submitShortAsk=async function(kind,id){let t=document.getElementById('short-ask-text').value.trim();if(!t)return;if(kind==='yt'){document.getElementById('short-answer').innerHTML='AI chỉ hỗ trợ trả lời sâu cho Short AI/Tường AI.';return}let st=await shortAct(kind,id,'ask',t);let a=(st.asks&&st.asks[0]&&st.asks[0].a)||'Chưa có trả lời';document.getElementById('short-answer').innerHTML='<p style="white-space:pre-wrap;color:#ccc">'+esc(a)+'</p>'}
175
+ window.closeShortModal=function(){document.getElementById('short-modal').classList.remove('active')}
176
+ window.shareShortCtx=function(kind,id){if(kind==='ai'){let p=finalWall3.find(x=>x.id===id);if(p){let url=location.origin+'/aw?post='+encodeURIComponent(id)+'&short=1';if(navigator.share)navigator.share({title:'🎬 Short AI: '+p.title,url}).catch(()=>{});else navigator.clipboard.writeText(url).then(()=>alert('Đã sao chép link!'));}}else{let url='https://www.youtube.com/watch?v='+id;if(navigator.share)navigator.share({title:'Shorts VNEWS',url}).catch(()=>{});else navigator.clipboard.writeText(url).then(()=>alert('Đã sao chép link!'));}}
177
+ function initActionFeed(){let feed=document.getElementById('tiktok-feed');if(!feed)return;let slides=feed.querySelectorAll('.tiktok-slide');let cur=-1;function act(i){if(i===cur)return;slides.forEach((sl,idx)=>{let v=sl.querySelector('video');let fr=sl.querySelector('iframe');if(idx===i){if(v)v.play().catch(()=>{});if(fr&&!fr.src&&fr.dataset.ytSrc)fr.src=fr.dataset.ytSrc;let kind=sl.dataset.kind,id=sl.dataset.id;if(kind&&id)shortAct(kind,id,'view').catch(()=>{})}else{if(v)v.pause();if(fr&&fr.src)fr.src=''}});cur=i}let t;feed.addEventListener('scroll',()=>{clearTimeout(t);t=setTimeout(()=>{let rect=feed.getBoundingClientRect(),ctr=rect.top+rect.height/2,b=-1,d=1e9;slides.forEach((sl,i)=>{let dd=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(dd<d){d=dd;b=i}});if(b>=0)act(b)},150)});setTimeout(()=>act(0),300)}
178
+ // Override openTikTok for regular YouTube shorts with same action layout.
179
+ window.openTikTok=async function(type,startIdx){showView('view-tiktok');let arts= type==='shorts'? await fetch('/api/shorts?refresh=1').then(r=>r.json()).catch(()=>[]) : await fetch(type==='highlights'?'/api/highlights':'/api/bdp_videos').then(r=>r.json()).catch(()=>[]);if(type!=='shorts'&&window.buildTikTokPlayer)return window.buildTikTokPlayer(arts,startIdx,type);let ordered=startIdx>0?arts.slice(startIdx).concat(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)=>{let id=v.id||((v.link||'').match(/v=([A-Za-z0-9_-]{11})/)||[])[1]||String(i);let src='https://www.youtube.com/embed/'+id+'?autoplay=1&rel=0&playsinline=1';h+=`<div class="tiktok-slide" data-kind="yt" data-id="${id}"><iframe data-yt-src="${src}" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture"></iframe><div class="tiktok-bottom"><span class="badge badge-fpt">YT</span><p class="tiktok-title">${esc(v.title)}</p></div>${actionPanel('yt',id,i)}<span class="tiktok-counter">${i+1}/${ordered.length}</span></div>`});h+='</div></div>';document.getElementById('view-tiktok').innerHTML=h;initActionFeed();}
180
+ // Patch make short: update home Short AI slide without reload.
181
+ let oldMake=window.makeFinalShort||window.aiMakeShortPatched;
182
+ window.makeFinalShort=window.aiMakeShortPatched=async function(i){let arr=finalWall3.length?finalWall3:(window.finalWall||[]);let p=arr[i];if(!p&&oldMake)return oldMake(i);if(!p)return;let voice=document.getElementById('ai-short-voice')?.value||'nu';let emotion=document.getElementById('ai-short-emotion')?.value||'neutral';let btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang tạo...'}try{let r=await fetch('/api/ai/short/'+p.id,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice,emotion,speed:1.2})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi tạo short');p.video=j.video;let idx=finalWall3.findIndex(x=>x.id===p.id);if(idx<0)finalWall3.unshift(p);renderAIShortHome();if(window.renderWall)window.renderWall();alert('Đã tạo short và thêm vào slide Short AI, không reload.');}catch(e){alert(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='🎬 Tạo short'}}}
183
+ setTimeout(()=>{ensureTopicBox();refreshFinalWall3();},700);setInterval(ensureTopicBox,1500);
184
+ })();
185
+ </script>
186
+ '''
187
+
188
+ @app.get('/')
189
+ async def index_final3():
190
+ html=f2.f1._load_index_html();body=getattr(rt.old,'PATCH_INJECT','') + f2.f1.FINAL_INJECT + FINAL3_INJECT
191
+ return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
ai_runtime_final4.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Final4 runtime: fix topic button visibility, shorts home feed, AI asking for videos/articles."""
2
+ import re, time, json, os, requests
3
+ from urllib.parse import urlparse
4
+ import ai_runtime_final3 as f3
5
+ from ai_runtime_final3 import app, base, rt, HTMLResponse, JSONResponse, Request, Query
6
+ try:
7
+ import main as main_mod
8
+ except Exception:
9
+ main_mod=None
10
+
11
+ AI_INTERACTIONS_FILE=f3.AI_INTERACTIONS_FILE
12
+ _SHORTS_CACHE={"t":0,"d":[]}
13
+ SHORT_CHANNELS=f3.SHORT_CHANNELS
14
+
15
+
16
+ def clean(s):
17
+ import html as html_lib
18
+ return re.sub(r"\s+"," ",html_lib.unescape(s or "")).strip()
19
+
20
+
21
+ def _domain(u):
22
+ try:return urlparse(u or '').netloc.replace('www.','')
23
+ except Exception:return ''
24
+
25
+
26
+ def _load_json(path,default):
27
+ try:
28
+ if os.path.exists(path):
29
+ with open(path,'r',encoding='utf-8') as f:return json.load(f)
30
+ except Exception:pass
31
+ return default
32
+
33
+
34
+ def _save_json(path,data):
35
+ try:
36
+ os.makedirs(os.path.dirname(path),exist_ok=True);tmp=path+'.tmp'
37
+ with open(tmp,'w',encoding='utf-8') as f:json.dump(data,f,ensure_ascii=False)
38
+ os.replace(tmp,path)
39
+ except Exception:pass
40
+
41
+
42
+ def _fallback_shorts():
43
+ out=[];seen=set()
44
+ candidates=[]
45
+ try:candidates+=(getattr(main_mod,'SHORTS_FALLBACK',[]) or [])
46
+ except Exception:pass
47
+ try:candidates+=(getattr(rt,'SHORTS_FALLBACK',[]) or [])
48
+ except Exception:pass
49
+ # hard fallback if imports fail
50
+ hard=[('Lu_iCQ5YwNM','Công an lập hồ sơ xử lý người phụ nữ chửi bới, tát tài xế ô tô | Dân trí','baodantri7941'),('CwWvijF8BOA','Chú rể bật khóc nhận món quà bí mật người cha quá cố gửi 26 năm trước | Dân trí','baodantri7941'),('7Pd6vZ2Lz1M','Hành động ấm lòng trong tìm kiếm học sinh tử vong ở sông Lô | SKĐS','baosuckhoedoisongboyte'),('SlHLt_ZyPiE','Xử phạt người đàn ông xóa số điện thoại cứu hộ trên cao tốc Bắc - Nam | SKĐS','baosuckhoedoisongboyte')]
51
+ for vid,title,ch in hard:
52
+ candidates.append({'id':vid,'title':title,'channel':ch,'link':'https://www.youtube.com/watch?v='+vid,'img':'https://i.ytimg.com/vi/'+vid+'/hqdefault.jpg','source':'yt'})
53
+ for v in candidates:
54
+ vid=v.get('id') or ''
55
+ if vid and vid not in seen:
56
+ seen.add(vid)
57
+ if not v.get('link'):v['link']='https://www.youtube.com/watch?v='+vid
58
+ if not v.get('img'):v['img']='https://i.ytimg.com/vi/'+vid+'/hqdefault.jpg'
59
+ v['source']='yt';out.append(v)
60
+ return out
61
+
62
+
63
+ def _fresh_shorts():
64
+ items=[];seen=set()
65
+ for ch in SHORT_CHANNELS:
66
+ got=f3._youtube_shorts_ytdlp(ch,24) or f3._youtube_shorts_html(ch,24)
67
+ for v in got:
68
+ vid=v.get('id')
69
+ if vid and vid not in seen:
70
+ seen.add(vid);items.append(v)
71
+ for v in _fallback_shorts():
72
+ vid=v.get('id')
73
+ if vid and vid not in seen:
74
+ seen.add(vid);items.append(v)
75
+ return items[:60]
76
+
77
+ # Remove endpoints/root to override.
78
+ _PATCH={('/api/shorts','GET'),('/api/ai/interact','POST'),('/api/article/ask','POST'),('/','GET')}
79
+ app.router.routes=[r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in _PATCH)]
80
+
81
+ @app.get('/api/shorts')
82
+ def api_shorts_final4(refresh:int=Query(default=0)):
83
+ now=time.time()
84
+ if not refresh and _SHORTS_CACHE['d'] and now-_SHORTS_CACHE['t']<900:return JSONResponse(_SHORTS_CACHE['d'])
85
+ data=_fresh_shorts()
86
+ _SHORTS_CACHE.update({'t':now,'d':data})
87
+ return JSONResponse(data)
88
+
89
+ @app.post('/api/ai/interact')
90
+ async def ai_interact_final4(request:Request):
91
+ body=await request.json();pid=str(body.get('id','')).strip();kind=str(body.get('kind','wall')).strip();action=str(body.get('action','')).strip();text=clean(body.get('text',''));context=clean(body.get('context',''));title=clean(body.get('title',''))
92
+ if not pid:return JSONResponse({'error':'missing id'},status_code=400)
93
+ db=_load_json(AI_INTERACTIONS_FILE,{})
94
+ key=kind+':'+pid
95
+ st=db.get(key) or {'views':0,'likes':0,'comments':[],'asks':[]}
96
+ if action=='view':st['views']=int(st.get('views',0))+1
97
+ elif action=='like':st['likes']=int(st.get('likes',0))+1
98
+ elif action=='comment' and text:
99
+ st.setdefault('comments',[]).insert(0,{'text':text[:240],'ts':int(time.time())});st['comments']=st['comments'][:80]
100
+ elif action=='ask' and text:
101
+ if kind in ('ai','short','wall'):
102
+ posts=base._load_ai_wall();p=next((x for x in posts if str(x.get('id'))==pid),{})
103
+ title=title or p.get('title','');context=context or (p.get('text') or '')
104
+ # For YouTube shorts, frontend sends title/context because AI cannot watch video.
105
+ if not context:context=title or pid
106
+ prompt=f"""Bạn là trợ lý VNEWS. Trả lời chi tiết bằng tiếng Việt dựa trên thông tin có sẵn về video/bài viết.
107
+
108
+ Tiêu đề/ngữ cảnh: {title}
109
+ Nội dung mô tả: {context[:5000]}
110
+
111
+ Câu hỏi người dùng: {text}
112
+
113
+ Yêu cầu:
114
+ - Nếu là video YouTube/Shorts và chỉ có tiêu đề, hãy nói rõ rằng bạn suy luận từ tiêu đề/mô tả, không khẳng định đã xem video.
115
+ - Trả lời cụ thể, có giải thích, không quá ngắn.
116
+ """
117
+ ans=await base.qwen_generate(prompt,max_tokens=900)
118
+ if not ans:ans='AI chưa trả lời được lúc này. Bạn thử hỏi lại cụ thể hơn.'
119
+ st.setdefault('asks',[]).insert(0,{'q':text[:240],'a':ans[:1500],'ts':int(time.time())});st['asks']=st['asks'][:50]
120
+ db[key]=st;_save_json(AI_INTERACTIONS_FILE,db)
121
+ return JSONResponse({'stats':st})
122
+
123
+ @app.post('/api/article/ask')
124
+ async def article_ask(request:Request):
125
+ body=await request.json();url=clean(body.get('url',''));question=clean(body.get('question',''))
126
+ if not question:return JSONResponse({'error':'missing question'},status_code=400)
127
+ title='';raw=''
128
+ try:
129
+ data=None
130
+ if url and hasattr(f3.f2.f1,'_scrape_url_article_only'):
131
+ data=f3.f2.f1._scrape_url_article_only(url)
132
+ if not data and url:data=base.scrape_any_url(url)
133
+ if data:
134
+ title=data.get('title','');raw=(data.get('summary','')+'\n'+data.get('text','')).strip()
135
+ except Exception:pass
136
+ context=raw[:12000] if raw else clean(body.get('context',''))[:12000]
137
+ prompt=f"""Bạn là trợ lý đọc hiểu bài viết của VNEWS. Hãy trả lời chi tiết câu hỏi của người dùng dựa trên bài viết.
138
+
139
+ Tiêu đề bài: {title}
140
+ Nội dung bài:
141
+ {context}
142
+
143
+ Câu hỏi: {question}
144
+
145
+ Yêu cầu:
146
+ - Trả lời bằng tiếng Việt.
147
+ - Dựa sát nội dung bài, nếu bài không có thông tin thì nói rõ.
148
+ - Giải thích chi tiết, có gạch đầu dòng khi hữu ích.
149
+ """
150
+ ans=await base.qwen_generate(prompt,max_tokens=1200)
151
+ if not ans:ans='AI chưa trả lời được lúc này. Bạn thử hỏi lại hoặc rút gọn câu hỏi.'
152
+ return JSONResponse({'answer':ans,'title':title})
153
+
154
+ FINAL4_INJECT = r'''
155
+ <style>
156
+ /* Ensure topic Qwen button is visible; earlier patches hide any button containing “chủ đề”. */
157
+ .topic-final4{display:flex!important;flex-direction:column!important;gap:8px!important;width:100%!important;margin-top:6px}.topic-final4 input,.topic-final4 button{display:block!important;width:100%!important;box-sizing:border-box!important}.topic-final4 button{background:#2d8659!important;color:#fff!important;border:0!important;border-radius:18px!important;padding:9px 12px!important;font-size:11px!important;font-weight:700!important}.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:70px;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px}.article-ai-ask button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin-top:6px}.article-ai-answer{white-space:pre-wrap;color:#ccc;font-size:13px;line-height:1.55;margin-top:8px}.ai-compose-row:has(#ai-url-input){display:flex!important;flex-direction:column!important}.ai-compose-row:has(#ai-url-input) input,.ai-compose-row:has(#ai-url-input) button{width:100%!important}
158
+ </style>
159
+ <script>
160
+ (function(){
161
+ function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
162
+ let shortsMap={};
163
+ function ensureTopicButtonFinal4(){let comp=document.querySelector('.ai-compose');if(!comp)return;if(!document.getElementById('ai-topic-input-final4')){let row=document.createElement('div');row.className='topic-final4';row.innerHTML='<input id="ai-topic-input-final4" placeholder="Nhập chủ đề để Qwen2.5VL tạo bài lên Tường AI..."><button id="ai-topic-btn-final4" onclick="createTopicPostFinal4()">✨ Tạo bài bằng Qwen</button>';comp.insertBefore(row,comp.firstChild.nextSibling);}let b=document.getElementById('ai-topic-btn-final4');if(b){b.style.display='block';b.textContent='✨ Tạo bài bằng Qwen';}}
164
+ window.createTopicPostFinal4=async function(){let inp=document.getElementById('ai-topic-input-final4');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');let btn=document.getElementById('ai-topic-btn-final4');if(btn){btn.disabled=true;btn.textContent='Đang tạo...'}try{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');if(window.finalWall)window.finalWall.unshift(j.post);if(window.finalWall3)window.finalWall3.unshift(j.post);if(inp)inp.value='';if(window.renderWall)window.renderWall();alert('Đã tạo bài bằng Qwen và đăng lên Tường AI.');}catch(e){alert(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='✨ Tạo bài bằng Qwen'}}};
165
+ // Guarantee Shorts slide appears on home even if previous loadHome missed it.
166
+ async function ensureShortsHome(){let home=document.getElementById('view-home');if(!home||document.getElementById('shorts-final4'))return;let sh=await fetch('/api/shorts?refresh=1').then(r=>r.json()).catch(()=>[]);if(!sh.length)return;let wrap=document.createElement('div');wrap.id='shorts-final4';wrap.className='slider-wrap';let h='<div class="slider-header"><span class="slider-label">📱 Shorts Dân trí & SKĐS</span><span class="slider-note">Cập nhật YouTube</span></div><div class="slider-track">';sh.slice(0,30).forEach((a,i)=>{shortsMap[a.id]=a;h+=`<div class="slider-item shorts-item" onclick="openTikTok('shorts',${i})"><div class="slider-thumb shorts-thumb">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="slider-title">${esc(a.title)}</div></div>`});h+='</div>';wrap.innerHTML=h;let after=document.querySelector('.ai-compose')||home.firstChild;if(after)after.after(wrap);else home.prepend(wrap);}
167
+ // Patch ask for YouTube shorts: AI receives title/context.
168
+ let oldShortAct=window.shortAct;
169
+ window.shortAct=async function(kind,id,action,text=''){let meta=shortsMap[id]||{};let url='/api/ai/interact';let body={id,kind:kind==='yt'?'yt':kind,action,text,title:meta.title||'',context:meta.title?('Video Shorts YouTube từ kênh '+(meta.channel||'')+'. Tiêu đề: '+meta.title):''};let r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});let j=await r.json();let st=j.stats||j;let v=document.getElementById(`v-${kind}-${id}`),l=document.getElementById(`l-${kind}-${id}`);if(v&&st.views!=null)v.textContent=st.views;if(l&&st.likes!=null)l.textContent=st.likes;return st;};
170
+ window.submitShortAsk=async function(kind,id){let t=document.getElementById('short-ask-text').value.trim();if(!t)return;let st=await shortAct(kind,id,'ask',t);let a=(st.asks&&st.asks[0]&&st.asks[0].a)||'Chưa có trả lời';document.getElementById('short-answer').innerHTML='<p style="white-space:pre-wrap;color:#ccc">'+esc(a)+'</p>';};
171
+ // Patch openTikTok to populate shortsMap.
172
+ let oldOpenTikTok=window.openTikTok;
173
+ window.openTikTok=async function(type,startIdx){if(type==='shorts'){let arts=await fetch('/api/shorts?refresh=1').then(r=>r.json()).catch(()=>[]);arts.forEach(a=>{if(a.id)shortsMap[a.id]=a});}return oldOpenTikTok?oldOpenTikTok(type,startIdx):null;};
174
+ function addArticleAskBox(){let view=document.getElementById('view-article');if(!view||document.getElementById('article-ai-ask'))return;let art=view.querySelector('.article-view');if(!art)return;let box=document.createElement('div');box.id='article-ai-ask';box.className='article-ai-ask';box.innerHTML='<h3 style="font-size:14px;color:#5cb87a;margin-bottom:6px">🤖 Hỏi AI về bài viết</h3><textarea id="article-ai-question" placeholder="Nhập câu hỏi cần AI trả lời chi tiết về bài viết..."></textarea><button onclick="askArticleAI()">Hỏi AI</button><div id="article-ai-answer" class="article-ai-answer"></div>';art.appendChild(box);}
175
+ window.askArticleAI=async function(){let q=document.getElementById('article-ai-question')?.value.trim();if(!q)return alert('Nhập câu hỏi trước');let ans=document.getElementById('article-ai-answer');ans.textContent='Đang hỏi AI...';let url=(window._currentArticle&&window._currentArticle.url)||((typeof _currentArticle!=='undefined'&&_currentArticle.url)||'');let context=document.querySelector('.article-view')?.innerText||'';try{let r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,question:q,context})});let j=await r.json();ans.textContent=j.answer||j.error||'Không có trả lời';}catch(e){ans.textContent='Lỗi hỏi AI: '+e.message}}
176
+ let oldReadArticle=window.readArticle;if(oldReadArticle){window.readArticle=async function(){let ret=await oldReadArticle.apply(this,arguments);setTimeout(addArticleAskBox,700);return ret;}}
177
+ setTimeout(()=>{ensureTopicButtonFinal4();ensureShortsHome();},1000);setInterval(()=>{ensureTopicButtonFinal4();if(document.getElementById('view-home')?.classList.contains('active'))ensureShortsHome();addArticleAskBox();},2000);
178
+ })();
179
+ </script>
180
+ '''
181
+
182
+ @app.get('/')
183
+ async def index_final4():
184
+ html=f3.f2.f1._load_index_html();body=getattr(rt.old,'PATCH_INJECT','')+f3.f2.f1.FINAL_INJECT+f3.FINAL3_INJECT+FINAL4_INJECT
185
+ return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
ai_runtime_final5.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Final5 runtime: remove duplicate topic box, improve Qwen topic knowledge output, fix Shorts direct playback."""
2
+ import re, time
3
+ from urllib.parse import quote
4
+ import ai_runtime_final4 as f4
5
+ from ai_runtime_final4 import app, base, rt, HTMLResponse, JSONResponse, Request, Query
6
+
7
+ # Remove topic/root endpoints to override.
8
+ _PATCH={('/api/topic_post','POST'),('/','GET')}
9
+ app.router.routes=[r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in _PATCH)]
10
+
11
+ def clean(s):
12
+ import html as html_lib
13
+ return re.sub(r"\s+"," ",html_lib.unescape(s or "")).strip()
14
+
15
+ def _topic_image(topic):
16
+ try:return base.pollinations_image_url(topic)
17
+ except Exception:return "https://image.pollinations.ai/prompt/"+quote("Vietnamese educational editorial illustration "+topic)+"?width=1024&height=576&nologo=true"
18
+
19
+ @app.post('/api/topic_post')
20
+ async def topic_post_knowledge(request:Request):
21
+ body=await request.json();topic=clean(body.get('topic',''))
22
+ if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
23
+ img=_topic_image(topic)
24
+ prompt=f"""Người dùng muốn đăng một bài trên Tường AI về chủ đề: "{topic}".
25
+
26
+ Hãy viết NGAY nội dung kiến thức/thông tin hữu ích về chủ đề đó, không lập dàn ý chung chung, không nói "có thể viết", không hướng dẫn cách viết.
27
+
28
+ Yêu cầu đầu ra:
29
+ - Tiêu đề hấp dẫn, cụ thể.
30
+ - 1 đoạn mở đầu giải thích trực tiếp chủ đề là gì/vì sao đáng chú ý.
31
+ - 5-7 đoạn hoặc ý chính cung cấp kiến thức thực chất, ví dụ, bối cảnh, tác động, hiểu lầm thường gặp, điểm cần lưu ý.
32
+ - Nếu chủ đề là thể thao, hãy nói về bối cảnh, nhân vật/đội bóng, ý nghĩa chiến thuật hoặc lịch sử liên quan.
33
+ - Nếu chủ đề là công nghệ/khoa học/xã hội, hãy giải thích khái niệm, ứng dụng, rủi ro/lợi ích, ví dụ thực tế.
34
+ - Không bịa số liệu thời sự mới; nếu không chắc, dùng cách nói thận trọng.
35
+ - Viết như bài đăng hoàn chỉnh để đọc được ngay.
36
+ - Cuối bài thêm: Nguồn tham khảo: Qwen2.5-VL / kiến thức tổng hợp.
37
+ """
38
+ text=await base.qwen_generate(prompt,image_url=img,max_tokens=1400)
39
+ if not text:
40
+ text=f"{topic}\n\n{topic} là một chủ đề có nhiều khía cạnh cần nhìn từ bối cảnh, ý nghĩa thực tế và tác động đối với người quan tâm. Bài viết này tóm lược các điểm quan trọng nhất để người đọc hiểu nhanh vấn đề, thay vì chỉ liệt kê tiêu đề hoặc dàn ý.\n\nNguồn tham khảo: Qwen2.5-VL / kiến thức tổng hợp."
41
+ post=base.make_post(topic,text,img,'','topic_qwen',sources=[{'title':'Qwen2.5-VL / kiến thức tổng hợp','url':'','via':'Qwen2.5-VL'}])
42
+ post['images']=[img]
43
+ posts=base._load_ai_wall();posts.insert(0,post);base._save_ai_wall(posts)
44
+ return JSONResponse({'post':post})
45
+
46
+ FINAL5_INJECT=r'''
47
+ <style>
48
+ /* Keep exactly one topic input */
49
+ #ai-topic-input-final3,.ai-compose-row.topic-final3,#ai-topic-input-final4,.topic-final4{display:none!important}.topic-final5{display:flex!important;flex-direction:column!important;gap:8px!important;width:100%!important;margin-top:6px}.topic-final5 input,.topic-final5 button{display:block!important;width:100%!important;box-sizing:border-box!important}.topic-final5 button{background:#2d8659!important;color:#fff!important;border:0!important;border-radius:18px!important;padding:9px 12px!important;font-size:11px!important;font-weight:700!important}
50
+ </style>
51
+ <script>
52
+ (function(){
53
+ function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
54
+ let shortsFinal5=[];
55
+ function removeDuplicateTopicBoxes(){document.querySelectorAll('#ai-topic-input-final3,.topic-final3,#ai-topic-input-final4,.topic-final4').forEach(e=>{let row=e.closest('.topic-final3,.topic-final4,.ai-compose-row')||e;e.remove?row.remove():row.style.display='none'});let comp=document.querySelector('.ai-compose');if(!comp)return;if(!document.getElementById('ai-topic-input-final5')){let row=document.createElement('div');row.className='topic-final5';row.innerHTML='<input id="ai-topic-input-final5" placeholder="Bạn muốn AI viết kiến thức về chủ đề gì? Ví dụ: thần đồng Arsenal, AI trong giáo dục, biến đổi khí hậu..."><button id="ai-topic-btn-final5" onclick="createTopicPostFinal5()">✨ Tạo bài kiến thức bằng Qwen</button>';comp.insertBefore(row,comp.firstChild.nextSibling);} }
56
+ window.createTopicPostFinal5=async function(){let inp=document.getElementById('ai-topic-input-final5');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');let btn=document.getElementById('ai-topic-btn-final5');if(btn){btn.disabled=true;btn.textContent='Đang tạo bài...'}try{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');if(window.finalWall)window.finalWall.unshift(j.post);if(window.finalWall3)window.finalWall3.unshift(j.post);if(inp)inp.value='';if(window.renderWall)window.renderWall();if(window.renderAIShortHome)window.renderAIShortHome();alert('Đã tạo bài kiến thức và đăng lên Tường AI.');}catch(e){alert(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='✨ Tạo bài kiến thức bằng Qwen'}}};
57
+ async function loadShortsFinal5(){shortsFinal5=await fetch('/api/shorts?refresh=1').then(r=>r.json()).catch(()=>[]);return shortsFinal5;}
58
+ function actionPanel(kind,id){return `<div class="short-action-panel"><button class="short-action-btn" onclick="shortAct('${kind}','${id}','view')"><div class="ico">👁</div><span id="v-${kind}-${id}">0</span></button><button class="short-action-btn" onclick="shortAct('${kind}','${id}','like')"><div class="ico">❤️</div><span id="l-${kind}-${id}">0</span></button><button class="short-action-btn" onclick="openCommentBox('${kind}','${id}')"><div class="ico">💬</div><span>BL</span></button><button class="short-action-btn" onclick="openAskBox('${kind}','${id}')"><div class="ico">🤖</div><span>Hỏi</span></button><button class="short-action-btn" onclick="shareShortCtx('${kind}','${id}')"><div class="ico">📤</div><span>Share</span></button></div>`}
59
+ window.openShortsFinal5=async function(startIdx){let arts=shortsFinal5.length?shortsFinal5:await loadShortsFinal5();if(!arts.length)return alert('Chưa tải được Shorts');let ordered=startIdx>0?arts.slice(startIdx).concat(arts.slice(0,startIdx)):arts;showView('view-tiktok');let h='<button class="back-btn" onclick="switchCat(\'home\')">← Shorts Dân trí & SKĐS</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">';ordered.forEach((v,i)=>{let id=v.id||((v.link||'').match(/v=([A-Za-z0-9_-]{11})/)||[])[1]||String(i);let src='https://www.youtube.com/embed/'+id+'?autoplay=1&rel=0&playsinline=1';h+=`<div class="tiktok-slide" data-kind="yt" data-id="${id}" data-title="${esc(v.title)}" data-channel="${esc(v.channel||'')}"><iframe data-yt-src="${src}" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture"></iframe><div class="tiktok-bottom"><span class="badge badge-fpt">YT</span><p class="tiktok-title">${esc(v.title)}</p></div>${actionPanel('yt',id)}<span class="tiktok-counter">${i+1}/${ordered.length}</span></div>`});h+='</div></div>';document.getElementById('view-tiktok').innerHTML=h;initShortsFeedFinal5();}
60
+ function initShortsFeedFinal5(){let feed=document.getElementById('tiktok-feed');if(!feed)return;let slides=feed.querySelectorAll('.tiktok-slide');let cur=-1;function act(i){if(i===cur)return;slides.forEach((sl,idx)=>{let fr=sl.querySelector('iframe');let v=sl.querySelector('video');if(idx===i){if(fr&&!fr.src&&fr.dataset.ytSrc)fr.src=fr.dataset.ytSrc;if(v)v.play().catch(()=>{});shortAct(sl.dataset.kind,sl.dataset.id,'view').catch(()=>{})}else{if(fr&&fr.src)fr.src='';if(v)v.pause();}});cur=i}let t;feed.addEventListener('scroll',()=>{clearTimeout(t);t=setTimeout(()=>{let rect=feed.getBoundingClientRect(),ctr=rect.top+rect.height/2,b=-1,d=1e9;slides.forEach((sl,i)=>{let dd=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(dd<d){d=dd;b=i}});if(b>=0)act(b)},130)});setTimeout(()=>act(0),250)}
61
+ function patchShortsHomeClick(){let home=document.getElementById('view-home');if(!home)return;document.querySelectorAll('#shorts-final4 .slider-item').forEach((el,i)=>{el.setAttribute('onclick',`openShortsFinal5(${i})`)});document.querySelectorAll('.slider-wrap .slider-label').forEach(label=>{if((label.textContent||'').includes('Shorts')){let wrap=label.closest('.slider-wrap');wrap?.querySelectorAll('.slider-item').forEach((el,i)=>el.setAttribute('onclick',`openShortsFinal5(${i})`));}})}
62
+ let oldOpen=window.openTikTok;window.openTikTok=function(type,startIdx){if(type==='shorts')return openShortsFinal5(startIdx||0);return oldOpen?oldOpen(type,startIdx):null;};
63
+ // Make YouTube ask AI receive title/channel from slide dataset.
64
+ let oldShortAct=window.shortAct;window.shortAct=async function(kind,id,action,text=''){let slide=document.querySelector(`.tiktok-slide[data-id="${id}"]`);let title=slide?.dataset.title||'';let channel=slide?.dataset.channel||'';let body={id,kind:kind==='yt'?'yt':kind,action,text,title,context:title?('Video Shorts YouTube từ kênh '+channel+'. Tiêu đề: '+title):''};let r=await fetch('/api/ai/interact',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});let j=await r.json();let st=j.stats||j;let v=document.getElementById(`v-${kind}-${id}`),l=document.getElementById(`l-${kind}-${id}`);if(v&&st.views!=null)v.textContent=st.views;if(l&&st.likes!=null)l.textContent=st.likes;return st;};
65
+ setTimeout(async()=>{removeDuplicateTopicBoxes();await loadShortsFinal5();patchShortsHomeClick();},900);setInterval(()=>{removeDuplicateTopicBoxes();patchShortsHomeClick();},1800);
66
+ })();
67
+ </script>
68
+ '''
69
+
70
+ @app.get('/')
71
+ async def index_final5():
72
+ html=f4.f3.f2.f1._load_index_html();body=getattr(rt.old,'PATCH_INJECT','')+f4.f3.f2.f1.FINAL_INJECT+f4.f3.FINAL3_INJECT+f4.FINAL4_INJECT+FINAL5_INJECT
73
+ return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
ai_runtime_final6.py ADDED
@@ -0,0 +1,1325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Final6: robust topic synthesis, stable shorts, hot topic hashtags.
2
+
3
+ This runtime intentionally overrides only the topic/shorts/root endpoints from the restored app.
4
+ """
5
+ import re, time, json, os, threading, html as html_lib
6
+ from urllib.parse import quote, urlparse, parse_qs, unquote
7
+ import requests
8
+ from bs4 import BeautifulSoup
9
+ import ai_runtime_final5 as f5
10
+ from ai_runtime_final5 import app, rt, HTMLResponse, JSONResponse, Request, Query
11
+
12
+ _PATCH={('/api/topic_post','POST'),('/api/shorts','GET'),('/api/hot_topics','GET'),('/api/topic_sources','GET'),('/','GET')}
13
+ app.router.routes=[r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in _PATCH)]
14
+
15
+ _TOPIC_CACHE={}
16
+ _HOT_CACHE={"t":0,"d":[]}
17
+ _SHORTS_CACHE_FINAL6={"t":0,"d":[]}
18
+ _TRANSLATE_CACHE_PATH="/data/title_vi_cache.json" if os.path.isdir('/data') else "/app/data/title_vi_cache.json"
19
+ _translate_lock=threading.Lock()
20
+ YOUTUBE_HANDLES=["baodantri7941","baosuckhoedoisongboyte"]
21
+ UA={"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36","Accept-Language":"vi,en;q=0.8"}
22
+ STOP_WORDS=set('và của các những một được trong với cho tại sau trước khi không người việt nam hôm nay mới nhất nóng tin tức cập nhật'.split())
23
+ TRUSTED_SITES=['vnexpress.net','dantri.com.vn','vietnamnet.vn','tuoitre.vn','thanhnien.vn','laodong.vn','vov.vn','vtv.vn','genk.vn','cafef.vn','thethaovanhoa.vn']
24
+
25
+ def clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or ""))).strip()
26
+ def _domain(u):
27
+ try:return urlparse(u or '').netloc.replace('www.','')
28
+ except Exception:return ''
29
+
30
+ def _load_title_cache():
31
+ try:
32
+ if os.path.exists(_TRANSLATE_CACHE_PATH):
33
+ with open(_TRANSLATE_CACHE_PATH,'r',encoding='utf-8') as f:return json.load(f)
34
+ except Exception:pass
35
+ return {}
36
+ def _save_title_cache(db):
37
+ try:
38
+ os.makedirs(os.path.dirname(_TRANSLATE_CACHE_PATH),exist_ok=True);tmp=_TRANSLATE_CACHE_PATH+'.tmp'
39
+ with open(tmp,'w',encoding='utf-8') as f:json.dump(db,f,ensure_ascii=False)
40
+ os.replace(tmp,_TRANSLATE_CACHE_PATH)
41
+ except Exception:pass
42
+
43
+ def _looks_vietnamese(s):
44
+ s=s or ''
45
+ if re.search(r'[àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ]',s,re.I):return True
46
+ low=' '+s.lower()+' '
47
+ return any(w in low for w in [' và ',' của ',' người ',' tại ',' trong ',' với ',' không ',' được ',' công an ',' bệnh viện ',' học sinh ',' tài xế ',' bóng đá ',' tin tức ',' sức khỏe '])
48
+ def _translate_title_vi(title):
49
+ title=clean(title)
50
+ if not title or _looks_vietnamese(title):return title
51
+ with _translate_lock:
52
+ db=_load_title_cache()
53
+ if title in db:return db[title]
54
+ vi=title
55
+ try:
56
+ r=requests.get('https://translate.googleapis.com/translate_a/single',params={'client':'gtx','sl':'auto','tl':'vi','dt':'t','q':title},headers=UA,timeout=8)
57
+ if r.status_code==200:
58
+ data=r.json();vi=''.join(part[0] for part in data[0] if part and part[0]).strip() or title
59
+ except Exception:pass
60
+ vi=clean(vi)
61
+ with _translate_lock:
62
+ db=_load_title_cache();db[title]=vi;_save_title_cache(db)
63
+ return vi
64
+
65
+ # ===== Hot topics / hashtags =====
66
+ def _keywords_from_title(title):
67
+ title=clean(re.sub(r'\s+-\s+.*$','',title))
68
+ words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',title) if len(w)>2 and w.lower() not in STOP_WORDS]
69
+ phrases=[]
70
+ for n in (4,3,2):
71
+ for i in range(0,max(0,len(words)-n+1)):
72
+ ph=' '.join(words[i:i+n]).strip()
73
+ if len(ph)>=8:phrases.append(ph)
74
+ if words:phrases.append(' '.join(words[:5]))
75
+ return phrases[:4]
76
+
77
+ def _hot_topics():
78
+ now=time.time()
79
+ if _HOT_CACHE['d'] and now-_HOT_CACHE['t']<900:return _HOT_CACHE['d']
80
+ topics=[];seen=set()
81
+ feeds=[
82
+ 'https://news.google.com/rss?hl=vi&gl=VN&ceid=VN:vi',
83
+ 'https://news.google.com/rss/headlines/section/topic/NATION?hl=vi&gl=VN&ceid=VN:vi',
84
+ 'https://news.google.com/rss/headlines/section/topic/BUSINESS?hl=vi&gl=VN&ceid=VN:vi',
85
+ 'https://news.google.com/rss/headlines/section/topic/SPORTS?hl=vi&gl=VN&ceid=VN:vi',
86
+ 'https://news.google.com/rss/headlines/section/topic/TECHNOLOGY?hl=vi&gl=VN&ceid=VN:vi'
87
+ ]
88
+ for feed in feeds:
89
+ try:
90
+ r=requests.get(feed,headers=UA,timeout=10);r.encoding='utf-8'
91
+ soup=BeautifulSoup(r.text,'xml')
92
+ for it in soup.find_all('item')[:15]:
93
+ title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
94
+ for kw in _keywords_from_title(title):
95
+ key=kw.lower()
96
+ if key not in seen and len(kw)<=60:
97
+ seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
98
+ if len(topics)>=24:break
99
+ if len(topics)>=24:break
100
+ except Exception:pass
101
+ if len(topics)>=24:break
102
+ for kw in ['AI trong giáo dục','World Cup 2026','kinh tế Việt Nam','biến đổi khí hậu','giá vàng','bóng đá Việt Nam','an ninh mạng','xe điện','sức khỏe tinh thần','thị trường chứng khoán']:
103
+ if kw.lower() not in seen:topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
104
+ _HOT_CACHE.update({'t':now,'d':topics[:24]})
105
+ return _HOT_CACHE['d']
106
+ @app.get('/api/hot_topics')
107
+ def api_hot_topics():return JSONResponse({'topics':_hot_topics()})
108
+
109
+ # ===== Topic web research =====
110
+ def _unwrap_ddg_href(href):
111
+ if not href:return ''
112
+ if href.startswith('//duckduckgo.com/l/?') or 'duckduckgo.com/l/?' in href:
113
+ qs=parse_qs(urlparse('https:'+href if href.startswith('//') else href).query)
114
+ return unquote(qs.get('uddg',[''])[0])
115
+ return href
116
+
117
+ def _ddg_search(query, limit=10):
118
+ items=[];seen=set()
119
+ try:
120
+ url='https://html.duckduckgo.com/html/?q='+quote(query)
121
+ r=requests.get(url,headers=UA,timeout=14);r.encoding='utf-8'
122
+ soup=BeautifulSoup(r.text,'lxml')
123
+ for res in soup.select('.result'):
124
+ a=res.select_one('.result__title a') or res.find('a',href=True)
125
+ if not a:continue
126
+ link=_unwrap_ddg_href(a.get('href',''));title=clean(a.get_text(' ',strip=True));snippet=clean((res.select_one('.result__snippet') or res).get_text(' ',strip=True))
127
+ if not link.startswith('http') or link in seen:continue
128
+ if any(bad in link for bad in ['duckduckgo.com','youtube.com','facebook.com','tiktok.com','twitter.com','x.com']):continue
129
+ seen.add(link);items.append({'title':title,'url':link,'source':_domain(link),'snippet':snippet})
130
+ if len(items)>=limit:break
131
+ except Exception:pass
132
+ return items
133
+
134
+ def _google_news_items(topic, limit=8):
135
+ items=[];seen=set()
136
+ try:
137
+ rss='https://news.google.com/rss/search?q='+quote(topic)+'&hl=vi&gl=VN&ceid=VN:vi'
138
+ r=requests.get(rss,headers=UA,timeout=12);r.encoding='utf-8'
139
+ soup=BeautifulSoup(r.text,'xml')
140
+ for it in soup.find_all('item')[:limit*2]:
141
+ title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
142
+ link=clean(it.find('link').get_text(strip=True) if it.find('link') else '')
143
+ src=clean(it.find('source').get_text(' ',strip=True) if it.find('source') else _domain(link))
144
+ if title and link and link not in seen:
145
+ seen.add(link);items.append({'title':title,'url':link,'source':src,'snippet':''})
146
+ if len(items)>=limit:break
147
+ except Exception:pass
148
+ return items
149
+
150
+ def _candidate_urls(topic):
151
+ seen=set();items=[]
152
+ queries=[topic+' tin tức Việt Nam', topic+' phân tích bối cảnh', topic+' site:vnexpress.net OR site:dantri.com.vn OR site:vietnamnet.vn']
153
+ for q in queries:
154
+ for it in _ddg_search(q,8):
155
+ if it['url'] not in seen:
156
+ seen.add(it['url']);items.append(it)
157
+ if len(items)>=12:break
158
+ for site in TRUSTED_SITES[:8]:
159
+ for it in _ddg_search(f'{topic} site:{site}',3):
160
+ if it['url'] not in seen:
161
+ seen.add(it['url']);items.append(it)
162
+ for it in _google_news_items(topic,8):
163
+ if it['url'] not in seen:
164
+ seen.add(it['url']);items.append(it)
165
+ return items[:24]
166
+
167
+ def _extract_article_text_bs(url, max_chars=9000):
168
+ try:
169
+ r=requests.get(url,headers=UA,timeout=16,allow_redirects=True)
170
+ if r.status_code>=400:return ''
171
+ r.encoding='utf-8';soup=BeautifulSoup(r.text,'lxml')
172
+ for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe','svg']):tag.decompose()
173
+ candidates=[]
174
+ for sel in ['article','main','.article-content','.detail-content','.singular-content','.fck_detail','.content-detail','.entry-content','.story-body','.knc-content']:
175
+ el=soup.select_one(sel)
176
+ if el:candidates.append(el)
177
+ if not candidates:candidates=[soup.body or soup]
178
+ best=max(candidates,key=lambda el:len(el.find_all('p')) if el else 0)
179
+ ps=[]
180
+ for el in best.find_all(['p','h2','h3'],recursive=True):
181
+ t=clean(el.get_text(' ',strip=True))
182
+ if len(t)>45 and not any(x in t.lower() for x in ['đăng ký nhận tin','theo dõi chúng tôi','chuyên mục','xem thêm','tin liên quan','advertisement']):ps.append(t)
183
+ if sum(len(x) for x in ps)>max_chars:break
184
+ return '\n'.join(ps)[:max_chars]
185
+ except Exception:return ''
186
+
187
+ def _jina_read_text(url, max_chars=9000):
188
+ try:
189
+ ju='https://r.jina.ai/http://'+url
190
+ r=requests.get(ju,headers=UA,timeout=28);r.encoding='utf-8'
191
+ if r.status_code!=200 or not r.text:return ''
192
+ lines=[]
193
+ for ln in r.text.splitlines():
194
+ t=clean(ln)
195
+ if not t or t.startswith(('Title:','URL Source:','Published Time:','Markdown Content:','Image:','Description:')):continue
196
+ if len(t)>45:lines.append(t)
197
+ if sum(len(x) for x in lines)>max_chars:break
198
+ return '\n'.join(lines)[:max_chars]
199
+ except Exception:return ''
200
+
201
+ def _scrape_article_text(url, max_chars=9000):
202
+ text=_extract_article_text_bs(url,max_chars)
203
+ if len(text)<350:text=_jina_read_text(url,max_chars)
204
+ return text
205
+
206
+ def _score_relevance(topic, title, text, snippet=''):
207
+ keys=[w.lower() for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',topic) if len(w)>2 and w.lower() not in STOP_WORDS]
208
+ hay=(title+' '+snippet+' '+text[:2500]).lower()
209
+ if not keys:return 1
210
+ return sum(1 for k in keys if k in hay)
211
+
212
+ def _web_research_context(topic):
213
+ now=time.time();key=topic.lower().strip()
214
+ if key in _TOPIC_CACHE and now-_TOPIC_CACHE[key]['t']<900:return _TOPIC_CACHE[key]['d']
215
+ items=_candidate_urls(topic)
216
+ crawled=[]
217
+ for it in items:
218
+ text=_scrape_article_text(it['url'],9000)
219
+ rel=_score_relevance(topic,it.get('title',''),text,it.get('snippet',''))
220
+ if text and len(text)>300 and rel>0:
221
+ crawled.append({**it,'text':text,'rel':rel})
222
+ elif it.get('snippet') and rel>0:
223
+ crawled.append({**it,'text':it['snippet'],'rel':rel,'snippet_only':True})
224
+ crawled=sorted(crawled,key=lambda x:(x.get('rel',0),len(x.get('text',''))),reverse=True)[:6]
225
+ blocks=[];sources=[]
226
+ for it in crawled:
227
+ label='ĐOẠN MÔ TẢ TỪ KẾT QUẢ TÌM KIẾM' if it.get('snippet_only') else 'NỘI DUNG BÀI VIẾT ĐÃ CRAWL'
228
+ blocks.append(f"NGUỒN: {it['source']}\nTIÊU ĐỀ: {it['title']}\n{label}:\n{it['text'][:8500]}")
229
+ sources.append({'title':it['title'],'url':it['url'],'via':it['source']})
230
+ data={'context':'\n\n---\n\n'.join(blocks),'sources':sources[:8],'count':len(blocks)}
231
+ _TOPIC_CACHE[key]={'t':now,'d':data}
232
+ return data
233
+
234
+ def _topic_image(topic):
235
+ try:return f5.base.pollinations_image_url(topic)
236
+ except Exception:return 'https://image.pollinations.ai/prompt/'+quote('Vietnamese editorial illustration, '+topic)+'?width=1024&height=576&nologo=true'
237
+
238
+ @app.get('/api/topic_sources')
239
+ def api_topic_sources(topic:str=Query(...)):
240
+ data=_web_research_context(clean(topic))
241
+ return JSONResponse({'count':data.get('count',0),'sources':data.get('sources',[]),'has_context':bool(data.get('context'))})
242
+
243
+ @app.post('/api/topic_post')
244
+ async def topic_post_synthesis(request:Request):
245
+ body=await request.json();topic=clean(body.get('topic',''))
246
+ if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
247
+ img=_topic_image(topic);research=_web_research_context(topic);context=research.get('context','');sources=research.get('sources',[])
248
+ if not context or research.get('count',0)==0:
249
+ 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)
250
+ prompt=f"""Bạn là biên tập viên VNEWS. Người dùng chọn chủ đề: "{topic}".
251
+
252
+ Dưới đây là NỘI DUNG các bài viết/đoạn mô tả đã crawl từ internet. Hãy đọc hiểu và TỔNG HỢP thành MỘT BÀI VIẾT HOÀN CHỈNH. Tuyệt đối không bê nguyên văn, không xếp danh sách tiêu đề thành bài viết, không viết kiểu trả lời chat.
253
+
254
+ DỮ LIỆU CRAWL:
255
+ {context[:30000]}
256
+
257
+ Yêu cầu bắt buộc:
258
+ - Viết bằng tiếng Việt, văn phong báo điện tử/tạp chí.
259
+ - Tiêu đề mới, rõ, hấp dẫn.
260
+ - Sapo 2-3 câu nêu vấn đề chính.
261
+ - 5-8 đoạn nội dung tổng hợp: bối cảnh, diễn biến/khái niệm, phân tích, tác động, điểm cần lưu ý.
262
+ - Dùng thông tin từ nội dung đã crawl để tổng hợp ý; nếu chỉ có mô tả tìm kiếm thì viết thận trọng.
263
+ - KHÔNG liệt kê các tiêu đề nguồn. KHÔNG mở đầu bằng "Dưới đây là" hay "Tôi sẽ".
264
+ - Cuối bài thêm mục "Nguồn tham khảo" gồm tên nguồn ngắn gọn.
265
+ """
266
+ text=await f5.base.qwen_generate(prompt,image_url=img,max_tokens=2800)
267
+ if not text or len(text)<500:
268
+ parts=[]
269
+ for block in context.split('---'):
270
+ body=block.split('NỘI DUNG BÀI VIẾT ĐÃ CRAWL:')[-1].split('ĐOẠN MÔ TẢ TỪ KẾT QUẢ TÌM KIẾM:')[-1].strip()
271
+ if len(body)>120:parts.append(body)
272
+ joined='\n\n'.join(parts)[:8500]
273
+ text=(f"{topic}: những điểm chính cần biết\n\n{topic} đang thu hút sự chú ý vì liên quan đến nhiều khía cạnh thực tế. Tổng hợp từ các nội dung thu thập được, có thể nhìn vấn đề qua bối cảnh, tác động và những điểm cần theo dõi.\n\n"+joined+"\n\nNguồn tham khảo: "+', '.join(sorted({s.get('via','') for s in sources if s.get('via')})))
274
+ post=f5.base.make_post(topic,text,img,'','topic_web_synthesis',sources=[s for s in sources if s.get('url')]);post['images']=[img]
275
+ posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts)
276
+ return JSONResponse({'post':post})
277
+
278
+ # ===== Stable newest Dantri/SKDS Shorts =====
279
+ def _yt_ytdlp(handle,count=30):
280
+ try:
281
+ import yt_dlp
282
+ urls=[f'https://www.youtube.com/@{handle}/shorts',f'https://www.youtube.com/@{handle}/videos']
283
+ out=[];seen=set();opts={'quiet':True,'extract_flat':True,'skip_download':True,'playlistend':count,'ignoreerrors':True,'no_warnings':True,'extractor_args':{'youtube':{'player_client':['web']}}}
284
+ for url in urls:
285
+ with yt_dlp.YoutubeDL(opts) as ydl:info=ydl.extract_info(url,download=False)
286
+ for e in (info or {}).get('entries') or []:
287
+ vid=e.get('id') or ''
288
+ if not re.match(r'^[A-Za-z0-9_-]{11}$',vid) or vid in seen:continue
289
+ title=e.get('title') or 'YouTube Short'
290
+ if url.endswith('/videos') and '#short' not in title.lower() and 'shorts' not in title.lower():continue
291
+ seen.add(vid);out.append({'title':title,'link':'https://www.youtube.com/watch?v='+vid,'img':'https://i.ytimg.com/vi/'+vid+'/hqdefault.jpg','source':'yt','id':vid,'channel':handle})
292
+ if len(out)>=count:break
293
+ if len(out)>=count:break
294
+ return out
295
+ except Exception:return []
296
+ def _yt_html(handle,count=30):
297
+ out=[];seen=set()
298
+ for suffix in ['shorts','videos']:
299
+ try:
300
+ r=requests.get(f'https://www.youtube.com/@{handle}/{suffix}',headers=UA,timeout=15);html=r.text
301
+ for m in re.finditer(r'"videoId":"([A-Za-z0-9_-]{11})"',html):
302
+ vid=m.group(1)
303
+ if vid in seen:continue
304
+ snip=html[max(0,m.start()-1200):m.start()+2200];title='YouTube Short'
305
+ mt=re.search(r'"title":\{"runs":\[\{"text":"([^"]+)"',snip) or re.search(r'"accessibilityText":"([^"]+)"',snip)
306
+ if mt:title=clean(mt.group(1).replace('\\n',' '))
307
+ if suffix=='videos' and '#short' not in title.lower() and 'shorts' not in title.lower():continue
308
+ seen.add(vid);out.append({'title':title,'link':'https://www.youtube.com/watch?v='+vid,'img':'https://i.ytimg.com/vi/'+vid+'/hqdefault.jpg','source':'yt','id':vid,'channel':handle})
309
+ if len(out)>=count:break
310
+ except Exception:pass
311
+ if len(out)>=count:break
312
+ return out[:count]
313
+ def _fallback_shorts():
314
+ try:return f5._fallback_shorts()
315
+ except Exception:return []
316
+ @app.get('/api/shorts')
317
+ def api_shorts_final6(refresh:int=Query(default=0)):
318
+ now=time.time()
319
+ if not refresh and _SHORTS_CACHE_FINAL6['d'] and now-_SHORTS_CACHE_FINAL6['t']<600:return JSONResponse(_SHORTS_CACHE_FINAL6['d'])
320
+ raw=[]
321
+ for h in YOUTUBE_HANDLES:raw.extend(_yt_ytdlp(h,30) or _yt_html(h,30))
322
+ raw.extend(_fallback_shorts())
323
+ seen=set();out=[]
324
+ for v in raw:
325
+ vid=v.get('id') or ''
326
+ if not vid:
327
+ m=re.search(r'(?:v=|shorts/|youtu\.be/)([A-Za-z0-9_-]{11})',v.get('link',''));vid=m.group(1) if m else ''
328
+ title=_translate_title_vi(v.get('title') or 'YouTube Short');key=vid or re.sub(r'\W+','',title.lower())[:80]
329
+ if not key or key in seen:continue
330
+ seen.add(key);item=dict(v);item['id']=vid;item['title']=title
331
+ if vid:item['link']='https://www.youtube.com/watch?v='+vid;item['img']='https://i.ytimg.com/vi/'+vid+'/hqdefault.jpg'
332
+ item['source']='yt';out.append(item)
333
+ if len(out)>=40:break
334
+ _SHORTS_CACHE_FINAL6.update({'t':now,'d':out})
335
+ return JSONResponse(out)
336
+
337
+ FINAL6_INJECT=r'''
338
+ <style>
339
+ #ai-topic-input-final3,.topic-final3,#ai-topic-input-final4,.topic-final4{display:none!important}.topic-final5{display:flex!important}.ai-wall-topic-live{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;overflow:hidden}.hot-topic-row{display:flex;gap:6px;overflow-x:auto;padding:4px 0}.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}.hot-chip:active{transform:scale(.96)}.topic-source-note{font-size:10px;color:#777;margin-top:4px;line-height:1.3}
340
+ </style>
341
+ <script>
342
+ (function(){
343
+ function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
344
+ let liveTopicWall=[];
345
+ async function ensureHotTopics(){let inp=document.getElementById('ai-topic-input-final5');if(!inp||document.getElementById('hot-topic-row-final6'))return;let row=document.createElement('div');row.id='hot-topic-row-final6';row.className='hot-topic-row';row.innerHTML='<span style="color:#777;font-size:11px;padding:5px 0">Đang tải từ khóa nóng...</span>';inp.insertAdjacentElement('afterend',row);let note=document.createElement('div');note.id='topic-source-note';note.className='topic-source-note';note.textContent='AI sẽ tìm nhiều nguồn, crawl nội dung bài viết rồi tổng hợp thành bài mới.';row.insertAdjacentElement('afterend',note);let j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));let topics=j.topics||[];row.innerHTML=topics.slice(0,18).map(t=>`<button class="hot-chip" onclick="document.getElementById('ai-topic-input-final5').value='${esc(t.topic).replace(/'/g,'\\\'')}';document.getElementById('ai-topic-input-final5').focus();">${esc(t.label)}</button>`).join('')||'';}
346
+ async function ensureNewsShortsHome(){if(!document.getElementById('view-home')?.classList.contains('active'))return;let labels=[...document.querySelectorAll('.slider-wrap .slider-label')];let wraps=labels.filter(l=>/shorts|short /i.test(l.textContent||'')&&!/short ai/i.test(l.textContent||'')).map(l=>l.closest('.slider-wrap')).filter(Boolean);wraps.forEach((w,i)=>{if(i>0)w.remove();});let w=wraps[0];if(w){let seen=new Set();[...w.querySelectorAll('.slider-item')].forEach(it=>{let img=it.querySelector('img')?.src||'';let tt=(it.querySelector('.slider-title')?.textContent||'').trim().toLowerCase();let k=img||tt;if(k&&seen.has(k))it.remove();else if(k)seen.add(k);});if(w.querySelectorAll('.slider-item').length>=6)return;w.remove();}let sh=await fetch('/api/shorts?refresh=1').then(r=>r.json()).catch(()=>[]);if(!sh.length)return;let wrap=document.createElement('div');wrap.className='slider-wrap';wrap.id='shorts-final6-stable';let h='<div class="slider-header"><span class="slider-label">📱 Shorts Dân trí & SKĐS</span><span class="slider-note">Mới nhất</span></div><div class="slider-track">';sh.slice(0,30).forEach((a,i)=>{h+=`<div class="slider-item shorts-item" onclick="openTikTok('shorts',${i})"><div class="slider-thumb shorts-thumb">${a.img?`<img src="${esc(a.img)}">`:''}<div class="card-play">▶</div></div><div class="slider-title">${esc(a.title)}</div></div>`});h+='</div>';wrap.innerHTML=h;let comp=document.querySelector('.ai-compose')||document.getElementById('view-home').firstChild;if(comp)comp.after(wrap);else document.getElementById('view-home').prepend(wrap);}
347
+ function renderLiveTopicWall(){let home=document.getElementById('view-home');if(!home||!liveTopicWall.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">Tổng hợp từ web</span></div><div class="slider-track">';liveTopicWall.slice(0,20).forEach((p,i)=>{h+=`<div class="wall-item"><div class="wall-thumb">${p.img?`<img src="${esc(p.img)}">`:''}</div><div class="wall-title">${esc(p.title)}</div><div class="wall-text">${esc(p.text)}</div><div class="wall-actions"><button class="primary" onclick="readLiveTopicWall(${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);}
348
+ window.readLiveTopicWall=function(i){let p=liveTopicWall[i];if(!p)return;showView('view-article');let imgs=(p.images||[]).filter(Boolean);let gal=imgs.length?'<div class="ai-wall-gallery">'+imgs.slice(0,12).map(u=>`<img src="${esc(u)}" loading="lazy">`).join('')+'</div>':(p.img?`<img class="article-img" src="${esc(p.img)}">`:'');document.getElementById('view-article').innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">AI</span><h1 class="article-title">${esc(p.title)}</h1>${gal}<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p><div class="article-actions"><button onclick="shareAI?shareAI(${JSON.stringify(p).replace(/"/g,'&quot;')},false):navigator.clipboard.writeText(location.href)">📤 Chia sẻ</button></div></div>`;window.scrollTo(0,0)};
349
+ window.createTopicPostFinal5=async function(){let inp=document.getElementById('ai-topic-input-final5');let topic=(inp&&inp.value||'').trim();if(!topic)return alert('Nhập chủ đề trước');let btn=document.getElementById('ai-topic-btn-final5');if(btn){btn.disabled=true;btn.textContent='Đang tìm nguồn...'}try{let src=await fetch('/api/topic_sources?topic='+encodeURIComponent(topic)).then(r=>r.json()).catch(()=>null);if(btn&&src)btn.textContent='Đã tìm '+(src.count||0)+' nguồn, đang tổng hợp...';let r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');liveTopicWall.unshift(j.post);if(inp)inp.value='';renderLiveTopicWall();readLiveTopicWall(0);alert('Đã tạo bài tổng hợp từ nội dung web và đăng lên Tường AI.');}catch(e){alert(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='✨ Tạo bài tổng hợp từ web bằng Qwen'}}};
350
+ setInterval(()=>{document.querySelectorAll('#ai-topic-input-final3,.topic-final3,#ai-topic-input-final4,.topic-final4').forEach(e=>(e.closest('.topic-final3,.topic-final4,.ai-compose-row')||e).remove());let b=document.getElementById('ai-topic-btn-final5');if(b){b.style.display='block';b.textContent='✨ Tạo b��i tổng hợp từ web bằng Qwen';}ensureHotTopics();ensureNewsShortsHome();},1200);setTimeout(()=>{ensureHotTopics();ensureNewsShortsHome();},1200);
351
+ })();
352
+ </script>
353
+ '''
354
+
355
+ @app.get('/')
356
+ async def index_final6():
357
+ html=f5.f4.f3.f2.f1._load_index_html()
358
+ 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
359
+ return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
360
+
361
+
362
+ # ===== FINAL6B: Vietnam hot hashtags + reliable VN RSS/source retrieval =====
363
+ VN_RSS_FEEDS = [
364
+ ('VnExpress Thời sự','https://vnexpress.net/rss/thoi-su.rss'),
365
+ ('VnExpress Thế giới','https://vnexpress.net/rss/the-gioi.rss'),
366
+ ('VnExpress Kinh doanh','https://vnexpress.net/rss/kinh-doanh.rss'),
367
+ ('VnExpress Công nghệ','https://vnexpress.net/rss/so-hoa.rss'),
368
+ ('VnExpress Thể thao','https://vnexpress.net/rss/the-thao.rss'),
369
+ ('VnExpress Giải trí','https://vnexpress.net/rss/giai-tri.rss'),
370
+ ('VnExpress Sức khỏe','https://vnexpress.net/rss/suc-khoe.rss'),
371
+ ('VnExpress Giáo dục','https://vnexpress.net/rss/giao-duc.rss'),
372
+ ('Dân trí Xã hội','https://dantri.com.vn/rss/xa-hoi.rss'),
373
+ ('Dân trí Thế giới','https://dantri.com.vn/rss/the-gioi.rss'),
374
+ ('Dân trí Kinh doanh','https://dantri.com.vn/rss/kinh-doanh.rss'),
375
+ ('Dân trí Sức khỏe','https://dantri.com.vn/rss/suc-khoe.rss'),
376
+ ('Dân trí Thể thao','https://dantri.com.vn/rss/the-thao.rss'),
377
+ ('Dân trí Công nghệ','https://dantri.com.vn/rss/suc-manh-so.rss'),
378
+ ('Vietnamnet Thời sự','https://vietnamnet.vn/thoi-su.rss'),
379
+ ('Vietnamnet Kinh doanh','https://vietnamnet.vn/kinh-doanh.rss'),
380
+ ('Vietnamnet Công nghệ','https://vietnamnet.vn/cong-nghe.rss'),
381
+ ('Vietnamnet Thể thao','https://vietnamnet.vn/the-thao.rss'),
382
+ ]
383
+
384
+ def _fetch_rss_items(feed_name, feed_url, max_items=15):
385
+ items=[]
386
+ try:
387
+ r=requests.get(feed_url,headers=UA,timeout=10);r.encoding='utf-8'
388
+ soup=BeautifulSoup(r.text,'xml')
389
+ for it in soup.find_all('item')[:max_items]:
390
+ title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
391
+ link=clean(it.find('link').get_text(strip=True) if it.find('link') else '')
392
+ desc=it.find('description').get_text(' ',strip=True) if it.find('description') else ''
393
+ desc_txt=clean(BeautifulSoup(desc,'lxml').get_text(' ',strip=True))
394
+ if title and link:
395
+ items.append({'title':title,'url':link,'source':feed_name,'snippet':desc_txt})
396
+ except Exception:pass
397
+ return items
398
+
399
+ def _vn_rss_pool():
400
+ now=time.time();key='vn_rss_pool'
401
+ if key in _TOPIC_CACHE and now-_TOPIC_CACHE[key]['t']<600:return _TOPIC_CACHE[key]['d']
402
+ pool=[];seen=set()
403
+ for name,url in VN_RSS_FEEDS:
404
+ for it in _fetch_rss_items(name,url,12):
405
+ if it['url'] not in seen:
406
+ seen.add(it['url']);pool.append(it)
407
+ _TOPIC_CACHE[key]={'t':now,'d':pool}
408
+ return pool
409
+
410
+ def _topic_tokens(topic):
411
+ toks=[w.lower() for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',topic or '') if len(w)>1]
412
+ return [t for t in toks if t not in STOP_WORDS]
413
+
414
+ def _score_topic_item(topic,item):
415
+ toks=_topic_tokens(topic)
416
+ hay=(item.get('title','')+' '+item.get('snippet','')+' '+item.get('source','')).lower()
417
+ if not toks:return 0
418
+ score=0
419
+ for t in toks:
420
+ if t in hay:score+=2 if len(t)>3 else 1
421
+ phrase=topic.lower().strip()
422
+ if phrase and phrase in hay:score+=8
423
+ return score
424
+
425
+ # Override: hashtags must be Việt Nam-focused, using VN news RSS directly.
426
+ def _hot_topics():
427
+ now=time.time()
428
+ if _HOT_CACHE['d'] and now-_HOT_CACHE['t']<600:return _HOT_CACHE['d']
429
+ pool=_vn_rss_pool()
430
+ freq={};display={}
431
+ for it in pool[:180]:
432
+ title=re.sub(r'\s+-\s+.*$','',it.get('title',''))
433
+ # Extract compact Vietnamese hot phrases from current VN headlines.
434
+ kws=[]
435
+ # quoted/name phrases first
436
+ for m in re.findall(r'([A-ZĐÀ-Ỹ][A-Za-zÀ-ỹ0-9]+(?:\s+[A-ZĐÀ-ỸA-Za-zÀ-ỹ0-9][A-Za-zÀ-ỹ0-9]+){1,4})',title):
437
+ if len(m)>=6:kws.append(m)
438
+ kws += _keywords_from_title(title)
439
+ for kw in kws[:5]:
440
+ kw=clean(kw)
441
+ words=[w for w in kw.split() if w.lower() not in STOP_WORDS]
442
+ if len(words)<2:continue
443
+ kw=' '.join(words[:5])
444
+ if len(kw)<6 or len(kw)>55:continue
445
+ key=kw.lower()
446
+ freq[key]=freq.get(key,0)+1
447
+ display[key]=kw
448
+ ranked=sorted(freq.items(),key=lambda x:x[1],reverse=True)
449
+ topics=[];seen=set()
450
+ for key,_ in ranked:
451
+ kw=display[key]
452
+ if key in seen:continue
453
+ seen.add(key)
454
+ label='#'+re.sub(r'\s+','',kw.title())
455
+ topics.append({'label':label,'topic':kw})
456
+ if len(topics)>=24:break
457
+ # VN fallback, not generic global.
458
+ for kw in ['Giá vàng trong nước','Bão và mưa lũ','Bóng đá Việt Nam','Kinh tế Việt Nam','AI tại Việt Nam','Giá xăng dầu','Thị trường chứng khoán Việt Nam','Tuyển Việt Nam','Sức khỏe cộng đồng','An ninh mạng Việt Nam']:
459
+ if kw.lower() not in seen:topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw})
460
+ _HOT_CACHE.update({'t':now,'d':topics[:24]})
461
+ return _HOT_CACHE['d']
462
+
463
+ def _candidate_urls(topic):
464
+ seen=set();items=[]
465
+ # 1) VN RSS pool relevance is most reliable and has direct URLs.
466
+ scored=[]
467
+ for it in _vn_rss_pool():
468
+ sc=_score_topic_item(topic,it)
469
+ if sc>0:scored.append((sc,it))
470
+ for sc,it in sorted(scored,key=lambda x:x[0],reverse=True)[:12]:
471
+ if it['url'] not in seen:
472
+ seen.add(it['url']);items.append(it)
473
+ # 2) Search trusted web if RSS not enough.
474
+ queries=[topic+' Việt Nam tin tức',topic+' phân tích Việt Nam',topic+' mới nhất']
475
+ for q in queries:
476
+ for it in _ddg_search(q,8):
477
+ if it['url'] not in seen:
478
+ seen.add(it['url']);items.append(it)
479
+ if len(items)>=14:break
480
+ # 3) Google News as supplemental titles/direct links.
481
+ for it in _google_news_items(topic,10):
482
+ if it['url'] not in seen:
483
+ seen.add(it['url']);items.append(it)
484
+ return items[:24]
485
+
486
+ def _web_research_context(topic):
487
+ now=time.time();key='ctx2:'+topic.lower().strip()
488
+ if key in _TOPIC_CACHE and now-_TOPIC_CACHE[key]['t']<900:return _TOPIC_CACHE[key]['d']
489
+ items=_candidate_urls(topic)
490
+ crawled=[]
491
+ for it in items:
492
+ text=_scrape_article_text(it['url'],9000)
493
+ rel=_score_relevance(topic,it.get('title',''),text,it.get('snippet','')) or _score_topic_item(topic,it)
494
+ # If RSS item has good snippet, keep it even when full text blocks.
495
+ if text and len(text)>300 and rel>0:
496
+ crawled.append({**it,'text':text,'rel':rel})
497
+ elif it.get('snippet') and len(it['snippet'])>120 and rel>0:
498
+ crawled.append({**it,'text':it['snippet'],'rel':rel,'snippet_only':True})
499
+ crawled=sorted(crawled,key=lambda x:(x.get('rel',0),len(x.get('text',''))),reverse=True)[:7]
500
+ blocks=[];sources=[]
501
+ for it in crawled:
502
+ label='ĐOẠN MÔ TẢ TỪ RSS/TÌM KIẾM' if it.get('snippet_only') else 'NỘI DUNG BÀI VIẾT ĐÃ CRAWL'
503
+ blocks.append(f"NGUỒN: {it['source']}\nTIÊU ĐỀ: {it['title']}\n{label}:\n{it['text'][:8500]}")
504
+ sources.append({'title':it['title'],'url':it['url'],'via':it['source']})
505
+ data={'context':'\n\n---\n\n'.join(blocks),'sources':sources[:8],'count':len(blocks)}
506
+ _TOPIC_CACHE[key]={'t':now,'d':data}
507
+ return data
508
+
509
+
510
+ # ===== FINAL6C: FAST topic generation (RSS cache first, no slow full-page crawling) =====
511
+ import asyncio
512
+ _FAST_TOPIC_CACHE={}
513
+ FAST_RSS_FEEDS=[
514
+ ('VnExpress','https://vnexpress.net/rss/tin-moi-nhat.rss'),
515
+ ('VnExpress Thời sự','https://vnexpress.net/rss/thoi-su.rss'),
516
+ ('VnExpress Thế giới','https://vnexpress.net/rss/the-gioi.rss'),
517
+ ('VnExpress Kinh doanh','https://vnexpress.net/rss/kinh-doanh.rss'),
518
+ ('VnExpress Công nghệ','https://vnexpress.net/rss/so-hoa.rss'),
519
+ ('VnExpress Thể thao','https://vnexpress.net/rss/the-thao.rss'),
520
+ ('Dân trí','https://dantri.com.vn/rss/home.rss'),
521
+ ('Dân trí Xã hội','https://dantri.com.vn/rss/xa-hoi.rss'),
522
+ ('Dân trí Kinh doanh','https://dantri.com.vn/rss/kinh-doanh.rss'),
523
+ ('Dân trí Thể thao','https://dantri.com.vn/rss/the-thao.rss'),
524
+ ('Dân trí Công nghệ','https://dantri.com.vn/rss/suc-manh-so.rss'),
525
+ ('Vietnamnet','https://vietnamnet.vn/rss/tin-moi-nhat.rss'),
526
+ ('Vietnamnet Thời sự','https://vietnamnet.vn/thoi-su.rss'),
527
+ ('Vietnamnet Kinh doanh','https://vietnamnet.vn/kinh-doanh.rss'),
528
+ ('Vietnamnet Công nghệ','https://vietnamnet.vn/cong-nghe.rss'),
529
+ ('Vietnamnet Thể thao','https://vietnamnet.vn/the-thao.rss'),
530
+ ]
531
+
532
+ def _fast_fetch_rss(feed_name, feed_url, max_items=20):
533
+ items=[]
534
+ try:
535
+ r=requests.get(feed_url,headers=UA,timeout=6);r.encoding='utf-8'
536
+ soup=BeautifulSoup(r.text,'xml')
537
+ for it in soup.find_all('item')[:max_items]:
538
+ title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
539
+ link=clean(it.find('link').get_text(strip=True) if it.find('link') else '')
540
+ desc_raw=it.find('description').get_text(' ',strip=True) if it.find('description') else ''
541
+ desc=clean(BeautifulSoup(desc_raw,'lxml').get_text(' ',strip=True))
542
+ if title and link:
543
+ items.append({'title':title,'url':link,'source':feed_name,'snippet':desc})
544
+ except Exception:pass
545
+ return items
546
+
547
+ def _fast_rss_pool():
548
+ now=time.time();key='fast_rss_pool'
549
+ if key in _FAST_TOPIC_CACHE and now-_FAST_TOPIC_CACHE[key]['t']<600:return _FAST_TOPIC_CACHE[key]['d']
550
+ pool=[];seen=set()
551
+ # Sequential with short timeouts is predictable; RSS is small.
552
+ for name,url in FAST_RSS_FEEDS:
553
+ for it in _fast_fetch_rss(name,url,16):
554
+ if it['url'] not in seen:
555
+ seen.add(it['url']);pool.append(it)
556
+ _FAST_TOPIC_CACHE[key]={'t':now,'d':pool}
557
+ return pool
558
+
559
+ def _fast_topic_tokens(topic):
560
+ toks=[w.lower() for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',topic or '') if len(w)>1]
561
+ return [t for t in toks if t not in STOP_WORDS]
562
+
563
+ def _fast_score(topic,item):
564
+ toks=_fast_topic_tokens(topic)
565
+ hay=(item.get('title','')+' '+item.get('snippet','')+' '+item.get('source','')).lower()
566
+ if not toks:return 0
567
+ score=0
568
+ for t in toks:
569
+ if t in hay:score+=3 if len(t)>3 else 1
570
+ phrase=topic.lower().strip()
571
+ if phrase and phrase in hay:score+=12
572
+ return score
573
+
574
+ def _fast_sources(topic, limit=8):
575
+ pool=_fast_rss_pool()
576
+ scored=[]
577
+ for it in pool:
578
+ sc=_fast_score(topic,it)
579
+ if sc>0:scored.append((sc,it))
580
+ scored=sorted(scored,key=lambda x:(x[0],len(x[1].get('snippet',''))),reverse=True)
581
+ out=[];seen=set()
582
+ for sc,it in scored:
583
+ if it['url'] in seen:continue
584
+ seen.add(it['url']);out.append({**it,'score':sc})
585
+ if len(out)>=limit:break
586
+ # If topic too narrow and no match, use top latest from VN RSS as weak context instead of slow crawling.
587
+ if not out:
588
+ out=pool[:min(limit,8)]
589
+ return out
590
+
591
+ def _fast_context(topic):
592
+ now=time.time();key='fast_ctx:'+topic.lower().strip()
593
+ if key in _FAST_TOPIC_CACHE and now-_FAST_TOPIC_CACHE[key]['t']<600:return _FAST_TOPIC_CACHE[key]['d']
594
+ sources=_fast_sources(topic,8)
595
+ blocks=[];src=[]
596
+ for it in sources:
597
+ text=(it.get('snippet') or '').strip()
598
+ # Use title + RSS description only: fast and reliable.
599
+ blocks.append(f"NGUỒN: {it.get('source','')}\nTIÊU ĐỀ: {it.get('title','')}\nTÓM TẮT RSS:\n{text}")
600
+ src.append({'title':it.get('title',''),'url':it.get('url',''),'via':it.get('source','')})
601
+ data={'context':'\n\n---\n\n'.join(blocks),'sources':src,'count':len(blocks)}
602
+ _FAST_TOPIC_CACHE[key]={'t':now,'d':data}
603
+ return data
604
+
605
+ def _fallback_fast_article(topic, sources):
606
+ lines=[]
607
+ for s in sources[:7]:
608
+ title=s.get('title','')
609
+ if title:lines.append(title)
610
+ body='\n'.join('• '+x for x in lines[:7])
611
+ vias=', '.join(sorted({s.get('via','') for s in sources if s.get('via')}))
612
+ return (f"{topic}: những điểm đáng chú ý\n\n"
613
+ f"{topic} đang là chủ đề được quan tâm trong dòng tin tức hiện nay. Dựa trên các nguồn tin mới nhất, có thể tổng hợp nhanh một số điểm nổi bật để người đọc nắm bối cảnh và theo dõi tiếp diễn biến.\n\n"
614
+ f"Các nguồn tin liên quan cho thấy chủ đề này gắn với những diễn biến sau:\n{body}\n\n"
615
+ f"Nhìn chung, đây là vấn đề cần được theo dõi theo nhiều góc độ: bối cảnh, tác động thực tế, phản ứng của các bên liên quan và những thông tin cập nhật tiếp theo. Người đọc nên đối chiếu thêm các nguồn chính thống khi cần quyết định hoặc đánh giá chi tiết.\n\n"
616
+ f"Nguồn tham khảo: {vias}")
617
+
618
+ # Remove previous slow topic routes and register fast versions last.
619
+ app.router.routes=[r for r in app.router.routes if not any(getattr(r,'path',None)==p and m in getattr(r,'methods',set()) for p,m in {('/api/topic_post','POST'),('/api/topic_sources','GET')})]
620
+
621
+ @app.get('/api/topic_sources')
622
+ def api_topic_sources_fast(topic:str=Query(...)):
623
+ data=_fast_context(clean(topic))
624
+ return JSONResponse({'count':data.get('count',0),'sources':data.get('sources',[]),'has_context':bool(data.get('context')),'mode':'fast_rss'})
625
+
626
+ @app.post('/api/topic_post')
627
+ async def topic_post_fast(request:Request):
628
+ body=await request.json();topic=clean(body.get('topic',''))
629
+ if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
630
+ img=_topic_image(topic)
631
+ research=_fast_context(topic);context=research.get('context','');sources=research.get('sources',[])
632
+ prompt=f"""Bạn là biên tập viên VNEWS. Hãy viết MỘT BÀI VIẾT HOÀN CHỈNH bằng tiếng Việt về chủ đề: {topic}
633
+
634
+ Dữ liệu nhanh từ RSS nguồn Việt Nam:
635
+ {context[:12000]}
636
+
637
+ Yêu cầu:
638
+ - Không liệt kê tiêu đề nguồn thành bài viết.
639
+ - Tổng hợp thành bài báo/tạp chí hoàn chỉnh.
640
+ - Có tiêu đề mới, sapo 2-3 câu, 4-6 đoạn phân tích/bối cảnh/tác động.
641
+ - Diễn đạt lại, không sao chép nguyên văn.
642
+ - Nếu dữ liệu ít, viết thận trọng và nêu các điểm cần theo dõi.
643
+ - Cuối bài có mục Nguồn tham khảo.
644
+ """
645
+ text=None
646
+ try:
647
+ text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1300),timeout=28)
648
+ except Exception:
649
+ text=None
650
+ if not text or len(text)<350:
651
+ text=_fallback_fast_article(topic,sources)
652
+ post=f5.base.make_post(topic,text,img,'','topic_fast_rss',sources=[s for s in sources if s.get('url')])
653
+ post['images']=[img]
654
+ posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts)
655
+ return JSONResponse({'post':post,'mode':'fast_rss','sources_count':len(sources)})
656
+
657
+
658
+ # ===== FINAL6D: FAST HOME LOAD =====
659
+ _FAST_HOME_CACHE={"t":0,"d":[]}
660
+ _FAST_DT_CACHE={"t":0,"d":[]}
661
+ _FAST_VNEGO_CACHE={"t":0,"d":[]}
662
+ _FAST_HL_CACHE={"t":0,"d":[]}
663
+
664
+ def _rss_articles_fast(feed_url, group, source='vne', limit=6):
665
+ out=[]
666
+ try:
667
+ r=requests.get(feed_url,headers=UA,timeout=4);r.encoding='utf-8'
668
+ soup=BeautifulSoup(r.text,'xml')
669
+ for it in soup.find_all('item')[:limit*2]:
670
+ title=clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
671
+ link=clean(it.find('link').get_text(strip=True) if it.find('link') else '')
672
+ desc_raw=it.find('description').get_text(' ',strip=True) if it.find('description') else ''
673
+ ds=BeautifulSoup(desc_raw,'lxml')
674
+ im=ds.find('img'); img=im.get('src','') if im else ''
675
+ desc=clean(ds.get_text(' ',strip=True))[:160]
676
+ if title and link:
677
+ out.append({'title':title,'link':link,'img':img,'summary':desc,'source':source,'group':group})
678
+ if len(out)>=limit:break
679
+ except Exception:pass
680
+ return out
681
+
682
+ def _fast_homepage():
683
+ now=time.time()
684
+ if _FAST_HOME_CACHE['d'] and now-_FAST_HOME_CACHE['t']<600:return _FAST_HOME_CACHE['d']
685
+ feeds=[('Thời Sự','https://vnexpress.net/rss/thoi-su.rss'),('Thế Giới','https://vnexpress.net/rss/the-gioi.rss'),('Kinh Doanh','https://vnexpress.net/rss/kinh-doanh.rss'),('Công Nghệ','https://vnexpress.net/rss/so-hoa.rss'),('Thể Thao','https://vnexpress.net/rss/the-thao.rss'),('Giải Trí','https://vnexpress.net/rss/giai-tri.rss'),('Sức Khỏe','https://vnexpress.net/rss/suc-khoe.rss'),('Giáo Dục','https://vnexpress.net/rss/giao-duc.rss'),('Pháp Luật','https://vnexpress.net/rss/phap-luat.rss'),('Du Lịch','https://vnexpress.net/rss/du-lich.rss')]
686
+ arts=[]
687
+ try:
688
+ from concurrent.futures import ThreadPoolExecutor, as_completed
689
+ with ThreadPoolExecutor(max_workers=6) as ex:
690
+ futs=[ex.submit(_rss_articles_fast,u,g,'vne',6) for g,u in feeds]
691
+ for f in as_completed(futs,timeout=7):
692
+ try:arts.extend(f.result() or [])
693
+ except Exception:pass
694
+ except Exception:
695
+ for g,u in feeds[:5]:arts.extend(_rss_articles_fast(u,g,'vne',4))
696
+ if arts:_FAST_HOME_CACHE.update({'t':now,'d':arts})
697
+ return _FAST_HOME_CACHE['d'] or arts
698
+
699
+ def _fast_dantri_hot():
700
+ now=time.time()
701
+ if _FAST_DT_CACHE['d'] and now-_FAST_DT_CACHE['t']<900:return _FAST_DT_CACHE['d']
702
+ data=_rss_articles_fast('https://dantri.com.vn/rss/home.rss','Tin Nổi Bật','dantri',12)
703
+ if data:_FAST_DT_CACHE.update({'t':now,'d':data})
704
+ return data
705
+
706
+ def _fast_vnego():
707
+ now=time.time()
708
+ if _FAST_VNEGO_CACHE['d'] and now-_FAST_VNEGO_CACHE['t']<900:return _FAST_VNEGO_CACHE['d']
709
+ out=[]
710
+ try:
711
+ r=requests.get('https://vnexpress.net/vne-go',headers=UA,timeout=4);r.encoding='utf-8'
712
+ soup=BeautifulSoup(r.text,'lxml');seen=set()
713
+ for a in soup.find_all('a',href=True):
714
+ href=a.get('href','');title=clean(a.get('title','') or a.get_text(' ',strip=True))
715
+ if not title or len(title)<8 or not href.startswith('http') or href in seen:continue
716
+ if '/vne-go' not in href and '/video/' not in href:continue
717
+ seen.add(href);img='';im=a.find('img') or (a.parent.find('img') if a.parent else None)
718
+ if im:img=im.get('data-src') or im.get('src','')
719
+ out.append({'title':title,'link':href,'img':img,'source':'vne-video'})
720
+ if len(out)>=10:break
721
+ except Exception:pass
722
+ _FAST_VNEGO_CACHE.update({'t':now,'d':out})
723
+ return out
724
+
725
+ def _fast_highlights():
726
+ now=time.time()
727
+ if _FAST_HL_CACHE['d'] and now-_FAST_HL_CACHE['t']<900:return _FAST_HL_CACHE['d']
728
+ _FAST_HL_CACHE.update({'t':now,'d':[]})
729
+ return []
730
+
731
+ for _p in ['/api/homepage','/api/dantri_hot','/api/vne_video','/api/highlights']:
732
+ app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)==_p and 'GET' in getattr(r,'methods',set()))]
733
+ @app.get('/api/homepage')
734
+ def api_homepage_fast():return JSONResponse(_fast_homepage())
735
+ @app.get('/api/dantri_hot')
736
+ def api_dantri_hot_fast():return JSONResponse(_fast_dantri_hot())
737
+ @app.get('/api/vne_video')
738
+ def api_vne_video_fast():return JSONResponse(_fast_vnego())
739
+ @app.get('/api/highlights')
740
+ def api_highlights_fast():return JSONResponse(_fast_highlights())
741
+
742
+ FINAL6_FAST_HOME_INJECT = """
743
+ <script>
744
+ (function(){
745
+ const oldFetch=window.fetch;
746
+ window.__allowShortRefresh=false;
747
+ 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)};
748
+ setTimeout(()=>{window.__allowShortRefresh=true;},7000);
749
+ })();
750
+ </script>
751
+ """
752
+ app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
753
+ @app.get('/')
754
+ async def index_final6_fast_home():
755
+ html=f5.f4.f3.f2.f1._load_index_html()
756
+ body=getattr(rt.old,'PATCH_INJECT','')+f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT+FINAL6_INJECT+FINAL6_FAST_HOME_INJECT
757
+ return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
758
+
759
+
760
+ # ===== FINAL6E: SHOW SOURCE CONTENTS IN TOPIC ARTICLE =====
761
+ def _extract_source_details_from_context(context, sources):
762
+ details=[]
763
+ # Map source urls by title for URL/via enrichment
764
+ src_by_title={clean(s.get('title','')):s for s in (sources or [])}
765
+ for block in (context or '').split('---'):
766
+ block=block.strip()
767
+ if not block:continue
768
+ via='';title='';content=''
769
+ m=re.search(r'NGUỒN:\s*(.*)',block)
770
+ if m:via=clean(m.group(1))
771
+ m=re.search(r'TIÊU ĐỀ:\s*(.*)',block)
772
+ if m:title=clean(m.group(1))
773
+ if 'NỘI DUNG BÀI VIẾT ĐÃ CRAWL:' in block:
774
+ content=block.split('NỘI DUNG BÀI VIẾT ĐÃ CRAWL:',1)[1]
775
+ elif 'TÓM TẮT RSS:' in block:
776
+ content=block.split('TÓM TẮT RSS:',1)[1]
777
+ elif 'ĐOẠN MÔ TẢ' in block:
778
+ content=re.split(r'ĐOẠN MÔ TẢ[^:]*:',block,1)[-1]
779
+ content=clean(content)
780
+ if not title and not content:continue
781
+ s=src_by_title.get(title,{})
782
+ details.append({'title':title or s.get('title','Nguồn tham khảo'),'url':s.get('url',''),'via':via or s.get('via',''),'content':content[:1800]})
783
+ if len(details)>=8:break
784
+ return details
785
+
786
+ # Remove prior topic endpoint and register one that stores source_details in post.
787
+ app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/api/topic_post' and 'POST' in getattr(r,'methods',set()))]
788
+
789
+ @app.post('/api/topic_post')
790
+ async def topic_post_with_source_contents(request:Request):
791
+ body=await request.json();topic=clean(body.get('topic',''))
792
+ if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
793
+ img=_topic_image(topic)
794
+ research=_fast_context(topic) if '_fast_context' in globals() else _web_research_context(topic)
795
+ context=research.get('context','');sources=research.get('sources',[])
796
+ details=_extract_source_details_from_context(context,sources)
797
+ if not context or not details:
798
+ return JSONResponse({'error':'Không tìm/crawl được đủ nội dung về chủ đề này. Hãy thử chủ đề cụ thể hơn hoặc dùng hashtag gợi ý.'},status_code=422)
799
+ source_brief='\n\n'.join([f"[{i+1}] {d.get('title','')} ({d.get('via','')})\n{d.get('content','')[:1400]}" for i,d in enumerate(details)])
800
+ prompt=f"""Bạn là biên tập viên VNEWS. Hãy viết MỘT BÀI VIẾT HOÀN CHỈNH bằng tiếng Việt về chủ đề: {topic}
801
+
802
+ Dưới đây là nội dung từng nguồn đã thu thập. Hãy tổng hợp ý chính, không sao chép nguyên văn, không biến các tiêu đề thành danh sách.
803
+
804
+ NỘI DUNG NGUỒN:
805
+ {source_brief[:18000]}
806
+
807
+ Yêu cầu:
808
+ - Tiêu đề mới, rõ, hấp dẫn.
809
+ - Sapo 2-3 câu.
810
+ - 5-8 đoạn phân tích/bối cảnh/tác động/điểm cần lưu ý.
811
+ - Không dùng câu "Dưới đây là" hoặc "Tôi sẽ".
812
+ - Cuối bài có mục "Nguồn tham khảo" nêu tên nguồn.
813
+ """
814
+ text=None
815
+ try:
816
+ import asyncio
817
+ text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1700),timeout=35)
818
+ except Exception:
819
+ text=None
820
+ if not text or len(text)<350:
821
+ bullets='\n'.join([f"• {d['title']}: {d.get('content','')[:320]}" for d in details[:6]])
822
+ vias=', '.join(sorted({d.get('via','') for d in details if d.get('via')}))
823
+ text=(f"{topic}: tổng hợp những điểm đáng chú ý\n\n"
824
+ f"{topic} đang được nhiều nguồn tin đề cập với các góc nhìn khác nhau. Dưới đây là phần tổng hợp nhanh từ những nội dung đã thu thập được.\n\n"
825
+ f"{bullets}\n\n"
826
+ f"Nhìn chung, chủ đề này cần được theo dõi thêm ở các khía cạnh: bối cảnh, tác động thực tế, phản ứng của các bên liên quan và các diễn biến mới trong thời gian tới.\n\n"
827
+ f"Nguồn tham khảo: {vias}")
828
+ post=f5.base.make_post(topic,text,img,'','topic_fast_rss_with_sources',sources=[s for s in sources if s.get('url')])
829
+ post['images']=[img]
830
+ post['source_details']=details
831
+ posts=f5.base._load_ai_wall();posts.insert(0,post);f5.base._save_ai_wall(posts)
832
+ return JSONResponse({'post':post,'mode':'fast_rss_with_source_details','sources_count':len(details)})
833
+
834
+ FINAL6E_INJECT = """
835
+ <style>
836
+ .source-detail-box{margin-top:14px;background:#151515;border:1px solid #2b2b2b;border-radius:10px;padding:10px}.source-detail-box h3{font-size:14px;color:#5cb87a;margin-bottom:8px}.source-detail-item{background:#202020;border-radius:8px;padding:9px;margin:7px 0}.source-detail-title{font-size:12px;font-weight:700;color:#eee;line-height:1.35}.source-detail-meta{font-size:10px;color:#888;margin:3px 0}.source-detail-content{font-size:12px;color:#bbb;line-height:1.5;white-space:pre-wrap;max-height:220px;overflow:auto}.source-detail-item a{color:#5cb87a;font-size:11px;text-decoration:none}
837
+ </style>
838
+ <script>
839
+ (function(){
840
+ function escE(s){return String(s||'').replace(/[&<>\"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',"'":'&#39;'}[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,'&quot;')},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=>({'&':'&amp;','<':'&lt;','>':'&gt;','\\"':'&quot;',"'":'&#39;'}[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_patch_fast.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Final patch v2: fix topic rewrite, remove duplicate short slide, full short interaction buttons."""
2
+ import re, threading, time, json, os, asyncio
3
+ import ai_runtime_final6 as f6
4
+ from ai_runtime_final6 import app, rt, f5, HTMLResponse, JSONResponse, Request, Query
5
+ import html as html_lib
6
+ from urllib.parse import urlparse
7
+
8
+ def clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or ""))).strip()
9
+ def _domain(u):
10
+ try:return urlparse(u or '').netloc.replace('www.','')
11
+ except:return ''
12
+ DATA_DIR="/data" if os.path.isdir('/data') else "/app/data"
13
+ os.makedirs(DATA_DIR,exist_ok=True)
14
+ SHORT_COMMENTS_FILE=os.path.join(DATA_DIR,'short_comments.json')
15
+ TTL_24H=86400;HAS_PERSISTENT=os.path.isdir('/data')
16
+ def _lj(p,d):
17
+ try:
18
+ if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8'))
19
+ except:pass
20
+ return d
21
+ def _sj(p,d):
22
+ try:os.makedirs(os.path.dirname(p),exist_ok=True);open(p+'.tmp','w',encoding='utf-8').write(json.dumps(d,ensure_ascii=False));os.replace(p+'.tmp',p)
23
+ except:pass
24
+ def _cleanup():
25
+ n=int(time.time());ps=f5.base._load_ai_wall();f=[p for p in ps if n-int(p.get('ts') or 0)<TTL_24H]
26
+ if len(f)<len(ps):f5.base._save_ai_wall(f)
27
+ def _scrape(url,mc=8000):
28
+ try:d=f5.base.scrape_any_url(url);return(d.get('title',''),((d.get('summary','')+'\n'+d.get('text','')).strip())[:mc],d.get('image') or d.get('og_image') or '')
29
+ except:return('','','')
30
+ _bg_home={"t":0,"d":[]};_bg_shorts={"t":0,"d":[]};_bg_lock=False
31
+ def _bg():
32
+ global _bg_lock
33
+ if _bg_lock:return
34
+ _bg_lock=True
35
+ try:
36
+ if hasattr(f6,'_fast_homepage'):d=f6._fast_homepage();(_bg_home.update({"t":time.time(),"d":d}) if d else None)
37
+ raw=[];[raw.extend(f6._yt_ytdlp(h,20) or f6._yt_html(h,20)) for h in f6.YOUTUBE_HANDLES];raw.extend(f6._fallback_shorts())
38
+ seen=set();out=[v for v in raw if v.get('id') and v['id'] not in seen and not seen.add(v['id'])]
39
+ if out:_bg_shorts.update({"t":time.time(),"d":out[:40]})
40
+ _cleanup()
41
+ except:pass
42
+ finally:_bg_lock=False
43
+ @app.on_event("startup")
44
+ async def _s():threading.Thread(target=_bg,daemon=True).start()
45
+ threading.Thread(target=lambda:[time.sleep(600) or _bg() for _ in iter(int,1)],daemon=True).start()
46
+ app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None) in ('/api/homepage','/api/shorts','/api/ai_wall','/api/topic_post','/api/article/ask','/api/topic/rewrite','/api/rewrite_share','/api/url_wall','/api/short/comments','/api/short/comment','/api/storage_status','/') and any(m in getattr(r,'methods',set()) for m in ('GET','POST')))]
47
+ @app.get('/api/homepage')
48
+ def _h():
49
+ n=time.time()
50
+ if _bg_home['d']:(threading.Thread(target=_bg,daemon=True).start() if n-_bg_home['t']>300 else None);return JSONResponse(_bg_home['d'])
51
+ if hasattr(f6,'_fast_homepage'):d=f6._fast_homepage();_bg_home.update({"t":n,"d":d or []});return JSONResponse(d or [])
52
+ return JSONResponse([])
53
+ @app.get('/api/shorts')
54
+ def _sh(refresh:int=Query(default=0)):
55
+ n=time.time()
56
+ if _bg_shorts['d'] and (not refresh or n-_bg_shorts['t']<120):(threading.Thread(target=_bg,daemon=True).start() if n-_bg_shorts['t']>600 else None);return JSONResponse(_bg_shorts['d'])
57
+ return f6.api_shorts_final6(refresh=refresh) if hasattr(f6,'api_shorts_final6') else JSONResponse([])
58
+ @app.get('/api/ai_wall')
59
+ def _w():n=int(time.time());return JSONResponse({'posts':[p for p in f5.base._load_ai_wall() if n-int(p.get('ts') or 0)<TTL_24H],'persistent':HAS_PERSISTENT})
60
+ @app.get('/api/storage_status')
61
+ def _st():return JSONResponse({'persistent':HAS_PERSISTENT})
62
+ @app.get('/api/short/comments')
63
+ def _gc(id:str=Query(...)):return JSONResponse({'comments':_lj(SHORT_COMMENTS_FILE,{}).get(id,[])})
64
+ @app.post('/api/short/comment')
65
+ async def _pc(request:Request):
66
+ b=await request.json();v=str(b.get('id','')).strip();t=clean(b.get('text',''))
67
+ if not v or not t:return JSONResponse({'error':'missing'},status_code=400)
68
+ db=_lj(SHORT_COMMENTS_FILE,{});c=db.get(v,[]);c.insert(0,{'text':t[:300],'ts':int(time.time())});db[v]=c[:100];_sj(SHORT_COMMENTS_FILE,db);return JSONResponse({'comments':db[v]})
69
+ @app.post('/api/article/ask')
70
+ async def _ask(request:Request):
71
+ b=await request.json();q=clean(b.get('question',''));ctx=clean(b.get('context',''));url=clean(b.get('url',''))
72
+ if not q:return JSONResponse({'error':'missing question'},status_code=400)
73
+ title='';raw=''
74
+ if url:title,raw,_=_scrape(url,10000)
75
+ if not raw:raw=ctx[:12000]
76
+ ans=await f5.base.qwen_generate(f'Bạn là VNEWS AI. Nội dung: "{title}"\n{raw[:9000]}\n\nHỏi: "{q}"\n\nTrả lời tự nhiên bằng tiếng Việt.',max_tokens=1200)
77
+ return JSONResponse({'answer':ans or 'Chưa trả lời được.','title':title})
78
+ @app.post('/api/rewrite_share')
79
+ @app.post('/api/url_wall')
80
+ async def _rw(request:Request):
81
+ b=await request.json();url=clean(b.get('url',''));ctx=clean(b.get('context',''))
82
+ if not url.startswith('http'):return JSONResponse({'error':'URL không hợp lệ'},status_code=400)
83
+ title,raw,img=_scrape(url,14000)
84
+ if len(raw)<50:raw=ctx[:14000]
85
+ if len(raw)<50:return JSONResponse({'error':'Không đọc được bài'},status_code=422)
86
+ text=None
87
+ try:text=await asyncio.wait_for(f5.base.qwen_generate(f'Tóm tắt đăng Tường AI:\nTiêu đề: {title}\n{raw[:14000]}\n\n4-6 ý chính. Cuối ghi nguồn.',image_url=img or None,max_tokens=1000),timeout=30)
88
+ except:pass
89
+ if not text or len(text)<80:text=f"Tóm tắt: {title}\n\n{raw[:1200]}\n\nNguồn: {_domain(url)}"
90
+ post=f5.base.make_post(title or 'Bài viết',text,img,url,'rewrite',sources=[{'title':title,'url':url,'via':_domain(url)}])
91
+ ps=f5.base._load_ai_wall();ps.insert(0,post);f5.base._save_ai_wall(ps);return JSONResponse({'post':post})
92
+ @app.post('/api/topic/rewrite')
93
+ async def _tr(request:Request):
94
+ b=await request.json();pid=str(b.get('post_id','')).strip()
95
+ if not pid:return JSONResponse({'error':'missing post_id'},status_code=400)
96
+ ps=f5.base._load_ai_wall();p=next((x for x in ps if str(x.get('id'))==pid),None)
97
+ if not p:return JSONResponse({'error':'Bài không tồn tại'},status_code=404)
98
+ urls=list(dict.fromkeys([s['url'] for s in (p.get('source_details') or []) if s.get('url')]+[s['url'] for s in (p.get('sources') or []) if s.get('url')]))[:5]
99
+ parts=[]
100
+ for u in urls:t,r,_=_scrape(u,6000);(parts.append(f"[{_domain(u)}] {t}\n{r}") if r and len(r)>150 else None)
101
+ ac='\n---\n'.join(parts) if parts else (p.get('text') or '')
102
+ title=p.get('title','')
103
+ text=None
104
+ try:text=await asyncio.wait_for(f5.base.qwen_generate(f'Viết lại:\nChủ đề: {title}\n{ac[:16000]}\n\nTiêu đề mới + 4-6 ý + nguồn.',image_url=p.get('img'),max_tokens=1200),timeout=35)
105
+ except:pass
106
+ if not text or len(text)<100:text=f"Tóm tắt: {title}\n\n{ac[:1500]}\n\nNguồn: VNEWS AI"
107
+ np=f5.base.make_post('Rewrite: '+title,text,p.get('img',''),'','rewrite_topic',sources=p.get('sources',[]));np['images']=p.get('images',[])
108
+ all_p=f5.base._load_ai_wall();all_p.insert(0,np);f5.base._save_ai_wall(all_p);return JSONResponse({'post':np})
109
+ @app.post('/api/topic_post')
110
+ async def _tp(request:Request):
111
+ b=await request.json();topic=clean(b.get('topic',''))
112
+ if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
113
+ img=f6._topic_image(topic);research=f6._fast_context(topic) if hasattr(f6,'_fast_context') else f6._web_research_context(topic)
114
+ ctx=research.get('context','');src=research.get('sources',[]);det=f6._extract_source_details_from_context(ctx,src) if hasattr(f6,'_extract_source_details_from_context') else []
115
+ if not ctx or not src:return JSONResponse({'error':'Không tìm được nội dung.'},status_code=422)
116
+ sb='\n\n'.join([f"[{i+1}] {d.get('title','')} ({d.get('via','')})\n{d.get('content','')[:1400]}" for i,d in enumerate(det)]) if det else ctx[:18000]
117
+ text=None
118
+ try:text=await asyncio.wait_for(f5.base.qwen_generate(f'Viết bài tiếng Việt VỀ: "{topic}"\nNGUỒN:\n{sb[:18000]}\nCHỈ viết về "{topic}". 5-8 đoạn. Cuối có nguồn.',image_url=img,max_tokens=1700),timeout=35)
119
+ except:pass
120
+ if not text or len(text)<300:text=f"{topic}: tổng hợp\n\n"+'\n'.join([f"• {d['title']}: {d.get('content','')[:300]}" for d in (det or [])[:6]])+"\n\nNguồn: "+', '.join(sorted({d.get('via','') for d in (det or []) if d.get('via')}))
121
+ post=f5.base.make_post(topic,text,img,'','topic_focused',sources=[s for s in src if s.get('url')]);post['images']=[img];post['source_details']=det
122
+ ps=f5.base._load_ai_wall();ps.insert(0,post);f5.base._save_ai_wall(ps);return JSONResponse({'post':post})
123
+
124
+ PATCH_INJECT=r'''
125
+ <style>
126
+ .short-cmt-panel{position:fixed;bottom:0;left:0;right:0;max-height:55vh;background:#181818;border-radius:16px 16px 0 0;z-index:99999;padding:14px;display:none;overflow-y:auto}.short-cmt-panel.active{display:block}.short-cmt-panel textarea{width:100%;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px;margin:6px 0;min-height:60px}.short-cmt-panel button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin:4px}.cmt-item{background:#222;border-radius:8px;padding:7px;margin:5px 0;color:#ccc;font-size:12px}
127
+ .source-detail-box{margin-top:14px;background:#151515;border:1px solid #2b2b2b;border-radius:10px;padding:10px}.source-detail-item{background:#202020;border-radius:8px;padding:9px;margin:7px 0;cursor:pointer}.source-detail-title{font-size:12px;font-weight:700;color:#eee}.source-detail-content{font-size:12px;color:#bbb;line-height:1.5;white-space:pre-wrap;max-height:120px;overflow:hidden}.source-detail-item img{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:6px;margin-bottom:6px;background:#222}.source-vnews-btn{display:inline-block;margin-top:6px;background:#2d8659;color:#fff;padding:5px 10px;border-radius:12px;font-size:11px;font-weight:700}
128
+ .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}.article-ai-ask button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin-top:6px}.article-ai-answer{white-space:pre-wrap;color:#ccc;font-size:13px;line-height:1.55;margin-top:8px}
129
+ .storage-warn{background:#332200;border:1px solid #664400;color:#ffcc00;padding:8px 12px;border-radius:8px;font-size:11px;margin:6px 4px}
130
+ button[onclick*="rewriteCurrentArticle"]{display:none!important}
131
+ /* Hide ALL old Short AI slides from previous layers */
132
+ #ai-short-home,.ai-short-home,.ai-short-card-final{display:none!important}
133
+ .source-detail-box a[target="_blank"]{display:none!important}
134
+ </style>
135
+ <div id="short-cmt-panel" class="short-cmt-panel"></div>
136
+ <script>
137
+ (function(){
138
+ function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
139
+ 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='⚠️ <b>Persistent Storage chưa bật.</b> Bật: Space Settings → Persistent Storage → Small.';h.prepend(w);}}});
140
+
141
+ // === Short AI Slide on homepage (same as Dantri shorts) ===
142
+ 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');if(comp&&comp.nextSibling)comp.parentNode.insertBefore(wrap,comp.nextSibling);else home.prepend(wrap);}
143
+ setTimeout(renderShortAISlide,2500);
144
+
145
+ // === Source Details ===
146
+ function renderSourceDetails(post,container){let det=post.source_details||[];if(!det.length)return;container.querySelectorAll('.source-detail-box').forEach(e=>e.remove());let box=document.createElement('div');box.className='source-detail-box';box.innerHTML='<h3 style="font-size:14px;color:#5cb87a;margin-bottom:8px">📚 Bài nguồn</h3>'+det.map((s,i)=>`<div class="source-detail-item" data-url="${esc(s.url||'')}"><div class="source-detail-title">${i+1}. ${esc(s.title)}</div><div class="source-detail-content">${esc((s.content||'').slice(0,300))}</div><span class="source-vnews-btn">📖 Xem trên VNEWS</span></div>`).join('');container.appendChild(box);box.querySelectorAll('.source-detail-item').forEach(el=>{el.onclick=function(){let u=el.dataset.url;if(u&&typeof readArticle==='function')readArticle(u);}});det.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)){let items=box.querySelectorAll('.source-detail-item');if(items[i]){let img=document.createElement('img');img.src=d.og_image||d.img;img.loading='lazy';img.onerror=function(){this.style.display='none'};items[i].prepend(img);}}}).catch(()=>{});});}
147
+
148
+ // === AI Wall Post View ===
149
+ async function readAIWallPost(i){let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];let p=wall[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">AI</span><h1 class="article-title">${esc(p.title)}</h1>${p.img?`<img class="article-img" src="${p.img}">`:''}`;h+=`<p class="article-p" style="white-space:pre-wrap">${esc(p.text)}</p>`;h+=`<div class="article-actions"><button class="primary" onclick="doRewriteTopic(this,'${esc(p.id)}')">🤖 Rewrite AI đăng tường</button>${p.video?`<button onclick="openAIShortFeed(${i})">🎬 Xem Short</button>`:''}<button onclick="doShare('${esc(p.title)}','${location.origin}','${esc(p.img||'')}')">📤</button></div>`;h+=`<div class="article-ai-ask"><h3 style="font-size:14px;color:#5cb87a">🤖 Hỏi AI</h3><textarea id="article-ai-q" placeholder="Hỏi về nội dung..."></textarea><button onclick="askAIWall(${i})">Hỏi</button><div id="article-ai-ans" class="article-ai-answer"></div></div></div>`;document.getElementById('view-article').innerHTML=h;let art=document.querySelector('.article-view');if(art)renderSourceDetails(p,art);window.scrollTo(0,0);}
150
+ window.readAIWallPost=readAIWallPost;window.aiReadWallPatched=window.aiReadWall=window.readWallPost=function(i){readAIWallPost(i)};
151
+
152
+ // === Short AI Feed: FULL interaction buttons like Dantri Shorts ===
153
+ window.openAIShortFeed=async function(startIdx){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 alert('Chưa có Short AI');let ordered=startIdx>0?vids.slice(startIdx).concat(vids.slice(0,startIdx)):vids;showView('view-tiktok');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)=>{h+=`<div class="tiktok-slide" data-id="${p.id}"><video src="${p.video}" playsinline loop></video><div class="tiktok-bottom"><span class="badge badge-ai">AI Short</span><p class="tiktok-title">${esc(p.title)}</p></div><div class="tiktok-right"><button class="tiktok-right-btn" onclick="event.stopPropagation()"><div class="icon">👁</div><div class="count">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();likeShort('${p.id}',this)"><div class="icon">❤️</div><div class="count">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();openShortComments('${p.id}')"><div class="icon">💬</div><div class="count" id="cc-${p.id}">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();shareShort('${esc(p.title)}')"><div class="icon">📤</div><div class="count">Share</div></button></div><span class="tiktok-counter">${i+1}/${ordered.length}</span></div>`});h+='</div></div>';document.getElementById('view-tiktok').innerHTML=h;initShortFeed();ordered.forEach(p=>{fetch('/api/short/comments?id='+encodeURIComponent(p.id)).then(r=>r.json()).then(j=>{let el=document.getElementById('cc-'+p.id);if(el)el.textContent=(j.comments||[]).length}).catch(()=>{});});}
154
+ window.likeShort=function(id,btn){let c=btn.querySelector('.count');c.textContent=parseInt(c.textContent||0)+1;}
155
+ window.shareShort=function(title){if(navigator.share)navigator.share({title,url:location.href}).catch(()=>{});else{navigator.clipboard.writeText(location.href);alert('Đã sao chép link!');}}
156
+ function initShortFeed(){let feed=document.getElementById('tiktok-feed');if(!feed)return;let slides=feed.querySelectorAll('.tiktok-slide');let cur=-1;function act(i){if(i===cur)return;slides.forEach((sl,idx)=>{let v=sl.querySelector('video');let fr=sl.querySelector('iframe');if(idx===i){if(v)v.play().catch(()=>{});if(fr&&!fr.src&&fr.dataset.ytSrc)fr.src=fr.dataset.ytSrc;}else{if(v)v.pause();if(fr&&fr.src)fr.src='';}});cur=i}let t;feed.addEventListener('scroll',()=>{clearTimeout(t);t=setTimeout(()=>{let rect=feed.getBoundingClientRect(),ctr=rect.top+rect.height/2,b=-1,d=1e9;slides.forEach((sl,i)=>{let dd=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(dd<d){d=dd;b=i}});if(b>=0)act(b)},130)});setTimeout(()=>act(0),300);slides.forEach(sl=>{let v=sl.querySelector('video');if(v)v.addEventListener('click',e=>{e.preventDefault();v.paused?v.play().catch(()=>{}):v.pause()})});}
157
+
158
+ // === Handlers ===
159
+ window.doRewriteTopic=async function(btn,pid){btn.disabled=true;btn.textContent='Đang rewrite...';try{let r=await fetch('/api/topic/rewrite',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({post_id:pid})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');alert('Rewrite thành công!');showRewriteResult(j.post);}catch(e){alert(e.message)}finally{btn.disabled=false;btn.textContent='🤖 Rewrite AI đăng tường';}};
160
+ window.doRewriteArticle=async function(btn){let url=(window._currentArticle&&window._currentArticle.url)||'';if(!url){let a=document.querySelector('#view-article a[href*="://"]');if(a)url=a.href;}if(!url){let text=document.querySelector('.article-view')?.innerText?.slice(0,14000)||'';if(text.length<100){alert('Không tìm được nội dung để rewrite');return;}btn.disabled=true;btn.textContent='Đang rewrite...';try{let r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:'https://vnews.local/inline',context:text})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error);alert('Rewrite thành công!');showRewriteResult(j.post);}catch(e){alert(e.message)}finally{btn.disabled=false;btn.textContent='🤖 Rewrite AI đăng tường';}return;}btn.disabled=true;btn.textContent='Đang rewrite...';try{let ctx=document.querySelector('.article-view')?.innerText?.slice(0,14000)||'';let r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,context:ctx})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');alert('Rewrite thành công!');showRewriteResult(j.post);}catch(e){alert(e.message)}finally{btn.disabled=false;btn.textContent='🤖 Rewrite AI đăng tường';}};
161
+ function showRewriteResult(post){if(!post)return;showView('view-article');document.getElementById('view-article').innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><span class="badge badge-ai">Rewrite</span><h1 class="article-title">${esc(post.title)}</h1>${post.img?`<img class="article-img" src="${post.img}">`:''}` +`<p class="article-p" style="white-space:pre-wrap">${esc(post.text)}</p><div class="article-actions"><button class="primary" onclick="makeShortFromPost('${esc(post.id)}',this)">🎬 Tạo Short AI</button><button onclick="doShare('${esc(post.title)}','${location.origin}','${esc(post.img||'')}')">📤</button></div></div>`;window.scrollTo(0,0);}
162
+ window.makeShortFromPost=async function(pid,btn){if(btn){btn.disabled=true;btn.textContent='Đang tạo...';}try{let r=await fetch('/api/ai/short/'+pid,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:'nu',emotion:'neutral',speed:1.2})});let j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');alert('Đã tạo Short AI!');renderShortAISlide();}catch(e){alert(e.message)}finally{if(btn){btn.disabled=false;btn.textContent='🎬 Tạo Short AI';}}};
163
+ window.rewriteCurrentArticle=function(){let btn=document.querySelector('[data-rw-article]');if(btn)doRewriteArticle(btn);};
164
+ window.askAIWall=async function(i){let q=document.getElementById('article-ai-q')?.value.trim();if(!q)return alert('Nhập câu hỏi');document.getElementById('article-ai-ans').textContent='Đang hỏi...';let wall=(await fetch('/api/ai_wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];let p=wall[i]||{};let ctx=(p.text||'');for(let s of (p.source_details||[]))ctx+='\n'+(s.content||'');try{let r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({question:q,context:ctx.slice(0,12000)})});let j=await r.json();document.getElementById('article-ai-ans').textContent=j.answer||'Không trả lời được';}catch(e){document.getElementById('article-ai-ans').textContent='Lỗi: '+e.message}};
165
+ window.askArticleAI=async function(){let q=document.getElementById('article-ai-question')?.value.trim();if(!q)return alert('Nhập câu hỏi');let a=document.getElementById('article-ai-answer');a.textContent='Đang hỏi...';let url=(window._currentArticle&&window._currentArticle.url)||'';let ctx=document.querySelector('.article-view')?.innerText?.slice(0,12000)||'';try{let r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,question:q,context:ctx})});let j=await r.json();a.textContent=j.answer||'Không trả lời được';}catch(e){a.textContent='Lỗi: '+e.message}};
166
+ window.openShortComments=async function(id){let panel=document.getElementById('short-cmt-panel');let j=await fetch('/api/short/comments?id='+encodeURIComponent(id)).then(r=>r.json()).catch(()=>({comments:[]}));panel.innerHTML=`<h3 style="color:#5cb87a">💬 Bình luận</h3><div id="cmt-list">${(j.comments||[]).map(c=>`<div class="cmt-item">${esc(c.text)}</div>`).join('')||'<div class="cmt-item" style="color:#777">Chưa có</div>'}</div><textarea id="cmt-text" placeholder="Bình luận..."></textarea><button onclick="submitShortCmt('${esc(id)}')">Gửi</button><button onclick="document.getElementById('short-cmt-panel').classList.remove('active')">Đóng</button>`;panel.classList.add('active');}
167
+ window.submitShortCmt=async function(id){let t=document.getElementById('cmt-text')?.value.trim();if(!t)return;let j=await fetch('/api/short/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,text:t})}).then(r=>r.json()).catch(()=>({comments:[]}));document.getElementById('cmt-list').innerHTML=(j.comments||[]).map(c=>`<div class="cmt-item">${esc(c.text)}</div>`).join('');document.getElementById('cmt-text').value='';let el=document.getElementById('cc-'+id);if(el)el.textContent=(j.comments||[]).length;}
168
+
169
+ // === Patch regular articles ===
170
+ function patchArticle(){let art=document.querySelector('#view-article .article-view');if(!art)return;art.querySelectorAll('button[onclick*="rewriteCurrentArticle"],[data-rewrite],.rewrite-injected').forEach(e=>e.remove());art.querySelectorAll('.article-ai-ask').forEach((e,i)=>{if(i>0)e.remove();});if(!art.querySelector('[data-rw-article]')){let a=art.querySelector('.article-actions');if(a){let b=document.createElement('button');b.className='primary';b.setAttribute('data-rw-article','1');b.textContent='🤖 Rewrite AI đăng tường';b.onclick=function(){doRewriteArticle(b)};a.insertBefore(b,a.firstChild);}}if(!art.querySelector('.article-ai-ask')){let box=document.createElement('div');box.className='article-ai-ask';box.innerHTML='<h3 style="font-size:14px;color:#5cb87a">🤖 Hỏi AI</h3><textarea id="article-ai-question" placeholder="Hỏi..."></textarea><button onclick="askArticleAI()">Hỏi</button><div id="article-ai-answer" class="article-ai-answer"></div>';art.appendChild(box);}}
171
+ function patchShortBtns(){document.querySelectorAll('.tiktok-slide').forEach(sl=>{if(sl.dataset.cmtDone)return;sl.dataset.cmtDone='1';let id=sl.dataset.id||'';if(!id)return;let r=sl.querySelector('.tiktok-right');if(!r||r.querySelector('[data-cmt]'))return;let b=document.createElement('button');b.className='tiktok-right-btn';b.setAttribute('data-cmt','1');b.innerHTML='<div class="icon">💬</div><div class="count">0</div>';b.onclick=function(e){e.stopPropagation();openShortComments(id);};r.appendChild(b);fetch('/api/short/comments?id='+encodeURIComponent(id)).then(r=>r.json()).then(j=>{b.querySelector('.count').textContent=(j.comments||[]).length}).catch(()=>{});});}
172
+ function patchOldSourceLinks(){document.querySelectorAll('.source-detail-item a[target="_blank"],.source-detail-item a[href]').forEach(a=>{if(a.dataset.p7)return;a.dataset.p7='1';let url=a.href||'';a.removeAttribute('target');a.removeAttribute('href');a.textContent='📖 Xem trên VNEWS';a.className='source-vnews-btn';a.style.cursor='pointer';a.onclick=function(e){e.preventDefault();e.stopPropagation();if(url&&typeof readArticle==='function')readArticle(url);}});}
173
+
174
+ let oldRA=window.readArticle;if(oldRA){window.readArticle=async function(){let ret=await oldRA.apply(this,arguments);setTimeout(patchArticle,500);return ret;}}
175
+ let _hl=false;function dH(){if(_hl)return;_hl=true;setTimeout(()=>{if(typeof ensureHotTopics==='function')ensureHotTopics();if(typeof ensureNewsShortsHome==='function')ensureNewsShortsHome();},4000);}
176
+ if(document.readyState==='complete')dH();else window.addEventListener('load',dH);
177
+ setInterval(()=>{patchArticle();patchShortBtns();patchOldSourceLinks();},1500);
178
+ })();
179
+ </script>
180
+ '''
181
+
182
+ @app.get('/')
183
+ async def _index():
184
+ html=f5.f4.f3.f2.f1._load_index_html()
185
+ 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
186
+ body+=getattr(f6,'FINAL6_INJECT','');body+=getattr(f6,'FINAL6_FAST_HOME_INJECT','');body+=getattr(f6,'FINAL6E_INJECT','')
187
+ body+=PATCH_INJECT
188
+ return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
app_clean.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ VNEWS Clean Backend - serves static/index_v2.html directly.
3
+ No injection layers. All APIs from existing modules preserved.
4
+ Comments feature REMOVED per user request.
5
+ """
6
+ import sys, os
7
+
8
+ # Import the full chain which registers all API endpoints on the FastAPI app
9
+ from app_main import app, _search_all, _clean
10
+
11
+ # Now override the root '/' to serve our clean frontend
12
+ from fastapi import Query, Request
13
+ from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
14
+ from fastapi.staticfiles import StaticFiles
15
+ import os
16
+
17
+ # Remove old '/' route
18
+ app.router.routes = [r for r in app.router.routes if not (
19
+ getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set())
20
+ )]
21
+
22
+ # Remove comment endpoints (user requested removal)
23
+ app.router.routes = [r for r in app.router.routes if not (
24
+ getattr(r, 'path', None) in ('/api/short/comments', '/api/short/comment')
25
+ )]
26
+
27
+ # Mount static files
28
+ STATIC_DIR = os.path.join(os.path.dirname(__file__), 'static')
29
+ app.mount('/static', StaticFiles(directory=STATIC_DIR), name='static')
30
+
31
+ @app.get('/')
32
+ async def serve_index():
33
+ """Serve the clean v2 frontend - single HTML file, no injection."""
34
+ index_path = os.path.join(STATIC_DIR, 'index_v2.html')
35
+ if os.path.exists(index_path):
36
+ return FileResponse(index_path, media_type='text/html')
37
+ return HTMLResponse('<h1>VNEWS</h1><p>index_v2.html not found</p>', status_code=500)
38
+
39
+ # Keep /api/hashtag/sources using direct search (not Google News)
40
+ # This was already overridden in app_main.py with _search_all
41
+ # Just make sure it's accessible
42
+
43
+ # Storage status endpoint
44
+ @app.get('/api/storage_status')
45
+ def storage_status():
46
+ """Check if persistent storage is enabled."""
47
+ data_dir = '/data'
48
+ persistent = os.path.isdir(data_dir) and os.access(data_dir, os.W_OK)
49
+ return JSONResponse({'persistent': persistent, 'path': data_dir})
50
+
51
+ # Categories for the tab bar
52
+ @app.get('/api/categories')
53
+ def get_categories():
54
+ """Return category list for frontend tab bar."""
55
+ return JSONResponse([]) # Categories moved into News tab, homepage shows media content
56
+
57
+ # Share page
58
+ @app.get('/s')
59
+ async def share_page(url: str = '', title: str = '', img: str = ''):
60
+ """OG share page for social media."""
61
+ html = f'''<!DOCTYPE html><html><head>
62
+ <meta property="og:title" content="{_clean(title)}">
63
+ <meta property="og:url" content="{_clean(url)}">
64
+ <meta property="og:image" content="{_clean(img)}">
65
+ <meta property="og:type" content="article">
66
+ <meta property="og:site_name" content="VNEWS">
67
+ <meta http-equiv="refresh" content="0;url={_clean(url) or '/'}">
68
+ </head><body>Redirecting...</body></html>'''
69
+ return HTMLResponse(html)
app_entry.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Wrapper: load main patch then inject extra fixes for tiktok-right position, kill duplicate slides, progress toast."""
2
+ from ai_runtime_patch_fast import *
3
+ from ai_runtime_patch_fast import app, f5, f6, rt, PATCH_INJECT
4
+ from patch_extra import EXTRA_FIX
5
+ from fastapi.responses import HTMLResponse
6
+
7
+ # Remove old root and re-register with EXTRA_FIX appended.
8
+ app.router.routes=[r for r in app.router.routes if not (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))]
9
+
10
+ @app.get('/')
11
+ async def _index_final():
12
+ html=f5.f4.f3.f2.f1._load_index_html()
13
+ body=getattr(rt.old,'PATCH_INJECT','')+f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT
14
+ body+=getattr(f6,'FINAL6_INJECT','');body+=getattr(f6,'FINAL6_FAST_HOME_INJECT','');body+=getattr(f6,'FINAL6E_INJECT','')
15
+ body+=PATCH_INJECT
16
+ body+=EXTRA_FIX
17
+ return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
app_final.py ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Final wrapper with complete highlight override including interaction buttons.
2
+ PLUS: Hashtag inline sources on homepage with rewrite button."""
3
+ import json, os, time
4
+ from app_patch_unified import *
5
+ from app_patch_unified import app, UNIFIED_INJECT, f5, f6, rt, PATCH_INJECT
6
+ from fastapi.responses import HTMLResponse, JSONResponse
7
+ from fastapi import Request, Query
8
+
9
+ DATA_DIR="/data" if os.path.isdir('/data') else "/app/data"
10
+ os.makedirs(DATA_DIR,exist_ok=True)
11
+ HL_STATS_FILE=os.path.join(DATA_DIR,'highlight_stats.json')
12
+
13
+ def _load_hl():
14
+ try:
15
+ if os.path.exists(HL_STATS_FILE):return json.load(open(HL_STATS_FILE,'r',encoding='utf-8'))
16
+ except:pass
17
+ return {}
18
+ def _save_hl(db):
19
+ try:open(HL_STATS_FILE+'.tmp','w',encoding='utf-8').write(json.dumps(db,ensure_ascii=False));os.replace(HL_STATS_FILE+'.tmp',HL_STATS_FILE)
20
+ except:pass
21
+
22
+ app.router.routes=[r for r in app.router.routes if not (
23
+ (getattr(r,'path',None)=='/api/highlight/interact' and 'POST' in getattr(r,'methods',set())) or
24
+ (getattr(r,'path',None)=='/api/highlight/stats' and 'GET' in getattr(r,'methods',set())) or
25
+ (getattr(r,'path',None)=='/api/hashtag/sources' and 'GET' in getattr(r,'methods',set())) or
26
+ (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))
27
+ )]
28
+
29
+ @app.post('/api/highlight/interact')
30
+ async def _hl_act(request:Request):
31
+ b=await request.json();vid=str(b.get('id','')).strip();action=str(b.get('action','')).strip()
32
+ if not vid or action not in ('view','like','share'):return JSONResponse({'error':'invalid'},status_code=400)
33
+ db=_load_hl();st=db.get(vid,{'views':0,'likes':0,'shares':0})
34
+ st[action+'s']=st.get(action+'s',0)+1
35
+ db[vid]=st;_save_hl(db);return JSONResponse({'stats':st})
36
+
37
+ @app.get('/api/highlight/stats')
38
+ def _hl_stats(ids:str=Query(default='')):
39
+ db=_load_hl();out={}
40
+ for vid in ids.split(','):
41
+ vid=vid.strip()
42
+ if vid:out[vid]=db.get(vid,{'views':0,'likes':0,'shares':0})
43
+ return JSONResponse({'stats':out})
44
+
45
+ @app.get('/api/hashtag/sources')
46
+ def _hashtag_sources(topic:str=Query(...)):
47
+ """Return sources for a hashtag topic to display inline on homepage."""
48
+ research=f6._fast_context(topic) if hasattr(f6,'_fast_context') else f6._web_research_context(topic)
49
+ sources=research.get('sources',[])
50
+ # Add og:image for each source
51
+ from ai_runtime_patch_fast import _scrape
52
+ for s in sources[:6]:
53
+ if s.get('url') and not s.get('img'):
54
+ try:_,_,img=_scrape(s['url'],500)
55
+ except:img=''
56
+ s['img']=img if img and len(img)>20 else ''
57
+ return JSONResponse({'sources':sources[:6],'topic':topic})
58
+
59
+ # PRE_KILL fix
60
+ UNIFIED_INJECT_FIXED = UNIFIED_INJECT.replace(
61
+ """Object.defineProperty(window,'renderAIShorts7',{get:function(){return function(){}},set:function(){},configurable:true});""",
62
+ """Object.defineProperty(window,'renderAIShorts7',{get:function(){return function(){}},set:function(){},configurable:true});
63
+ Object.defineProperty(window,'renderPatchedWall',{get:function(){return function(){}},set:function(){},configurable:true});
64
+ Object.defineProperty(window,'renderAiShorts',{get:function(){return function(){}},set:function(){},configurable:true});
65
+ Object.defineProperty(window,'renderWall',{get:function(){return function(){}},set:function(){},configurable:true});
66
+ Object.defineProperty(window,'renderAIShorts',{get:function(){return function(){}},set:function(){},configurable:true});
67
+ Object.defineProperty(window,'loadPatchedWall',{get:function(){return function(){}},set:function(){},configurable:true});
68
+ Object.defineProperty(window,'refreshFinalWall3',{get:function(){return function(){}},set:function(){},configurable:true});"""
69
+ )
70
+
71
+ # Fix highlight fetch
72
+ UNIFIED_INJECT_FIXED = UNIFIED_INJECT_FIXED.replace(
73
+ "var articles=(window._hlLeagueData||{})[league]||[];\n if(!articles.length){el.innerHTML=",
74
+ "var articles=(window._hlLeagueData||{})[league]||[];\n if(!articles.length){try{var _r=await fetch('/api/highlights/'+league);articles=await _r.json();if(!Array.isArray(articles))articles=[];}catch(e){articles=[];}}\n if(!articles.length){el.innerHTML="
75
+ )
76
+
77
+ # Highlight full override (same as 5a5b626)
78
+ HIGHLIGHT_FULL_OVERRIDE = r'''
79
+ <style>
80
+ .tiktok-slide.ratio-wide video,.tiktok-slide.ratio-wide iframe{object-fit:contain!important}
81
+ .hl-ask-panel{position:fixed;bottom:0;left:0;right:0;max-height:50vh;background:#181818;border-radius:16px 16px 0 0;z-index:99999;padding:14px;display:none;overflow-y:auto}.hl-ask-panel.active{display:block}.hl-ask-panel textarea,.hl-ask-panel input{width:100%;background:#222;border:1px solid #444;color:#eee;border-radius:10px;padding:9px;margin:6px 0}.hl-ask-panel button{background:#2d8659;border:0;color:#fff;border-radius:10px;padding:8px 12px;margin:4px}.hl-ask-answer{white-space:pre-wrap;color:#ccc;font-size:12px;margin-top:8px}
82
+ .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}
83
+ </style>
84
+ <div id="hl-ask-panel" class="hl-ask-panel"></div>
85
+ <script>
86
+ (function(){
87
+ function esc(s){return String(s||'').replace(/[&<>"']/g,function(m){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]});}
88
+
89
+ // === HASHTAG INLINE: click hashtag → show sources on homepage + rewrite button ===
90
+ window.showHashtagSources=async function(topic){
91
+ var home=document.getElementById('view-home');if(!home)return;
92
+ document.getElementById('hashtag-sources-box')?.remove();
93
+ var box=document.createElement('div');box.id='hashtag-sources-box';box.className='hashtag-sources';
94
+ box.innerHTML='<h3>🔍 '+esc(topic)+'</h3><div style="color:#888;font-size:11px">Đang tìm nguồn...</div>';
95
+ var compose=home.querySelector('.ai-compose');
96
+ if(compose)compose.after(box);else home.prepend(box);
97
+ try{
98
+ var r=await fetch('/api/hashtag/sources?topic='+encodeURIComponent(topic));
99
+ var j=await r.json();var sources=j.sources||[];
100
+ if(!sources.length){box.innerHTML='<h3>🔍 '+esc(topic)+'</h3><div style="color:#888;font-size:12px">Không tìm được nguồn</div>';return;}
101
+ var h='<h3>🔍 '+esc(topic)+' <span style="font-size:10px;color:#888">('+sources.length+' nguồn)</span></h3>';
102
+ sources.forEach(function(s){
103
+ h+='<div class="hashtag-src-item" onclick="if(typeof readArticle===\'function\')readArticle(\''+esc(s.url||'')+'\')">';
104
+ h+='<div class="hashtag-src-img">'+(s.img?'<img src="'+esc(s.img)+'" onerror="this.style.display=\'none\'">':'')+'</div>';
105
+ h+='<div class="hashtag-src-text"><div class="hashtag-src-title">'+esc(s.title)+'</div><div class="hashtag-src-via">'+esc(s.via||s.source||'')+'</div></div>';
106
+ h+='</div>';
107
+ });
108
+ h+='<button class="hashtag-rewrite-btn" onclick="rewriteHashtagTopic(\''+esc(topic)+'\')">🤖 Rewrite AI tổng hợp nguồn & đăng tường</button>';
109
+ box.innerHTML=h;
110
+ }catch(e){box.innerHTML='<h3>🔍 '+esc(topic)+'</h3><div style="color:#e74c3c;font-size:12px">Lỗi: '+esc(e.message)+'</div>';}
111
+ };
112
+
113
+ window.rewriteHashtagTopic=async function(topic){
114
+ var btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang tổng hợp...';}
115
+ try{
116
+ var r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic:topic})});
117
+ var j=await r.json();
118
+ if(!r.ok||j.error)throw new Error(j.error||'Lỗi');
119
+ if(btn)btn.textContent='✅ Đã đăng lên Tường AI!';
120
+ setTimeout(function(){document.getElementById('hashtag-sources-box')?.remove();},2000);
121
+ }catch(e){
122
+ if(btn){btn.disabled=false;btn.textContent='❌ '+e.message;}
123
+ }
124
+ };
125
+
126
+ // Override hashtag chip click to use showHashtagSources instead of topic input
127
+ setTimeout(function(){
128
+ document.querySelectorAll('.hot-chip').forEach(function(chip){
129
+ chip.onclick=function(e){
130
+ e.preventDefault();e.stopPropagation();
131
+ var topic=chip.textContent.replace(/^#/,'').trim();
132
+ if(topic)showHashtagSources(topic);
133
+ };
134
+ });
135
+ },3000);
136
+ // Re-patch after hot topics load
137
+ setInterval(function(){
138
+ document.querySelectorAll('.hot-chip:not([data-patched])').forEach(function(chip){
139
+ chip.dataset.patched='1';
140
+ chip.onclick=function(e){
141
+ e.preventDefault();e.stopPropagation();
142
+ var topic=chip.textContent.replace(/^#/,'').trim();
143
+ if(topic)showHashtagSources(topic);
144
+ };
145
+ });
146
+ },2000);
147
+
148
+ // === FULL openLeaguePlayer override (same as before) ===
149
+ window.openLeaguePlayer=async function(league,idx){
150
+ showView('view-tiktok');document.querySelectorAll('.cat').forEach(function(x){x.classList.remove('active')});
151
+ var el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải highlight...</div>';
152
+ var cfg=(window.HL_CONFIG||{})[league]||{name:league,emoji:'🎬'};
153
+ var articles=(window._hlLeagueData||{})[league]||[];
154
+ if(!articles.length){try{var resp=await fetch('/api/highlights/'+league);articles=await resp.json();if(!Array.isArray(articles))articles=[];}catch(e){articles=[];}}
155
+ if(!articles.length){el.innerHTML='<div class="loading">Không có video</div>';return;}
156
+ var vids=[];var results=await Promise.all(articles.map(async function(a,i){try{var r=await fetch('/api/video_url?url='+encodeURIComponent(a.link));var v=await r.json();if(v&&v.src)return Object.assign({},a,v,{_idx:i});}catch(e){}return null;}));results.forEach(function(r){if(r)vids.push(r);});vids.sort(function(a,b){return a._idx-b._idx;});
157
+ if(!vids.length){el.innerHTML='<div class="loading">Không tìm thấy video</div>';return;}
158
+ var ti=vids.findIndex(function(v){return v._idx===idx;});if(ti<0)ti=0;var ordered=ti>0?vids.slice(ti).concat(vids.slice(0,ti)):vids;
159
+ var h='<button class="back-btn" onclick="switchCat(\'home\')">← '+esc(cfg.emoji)+' '+esc(cfg.name)+'</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">';
160
+ ordered.forEach(function(v,i){var hlid=encodeURIComponent(v.link||v.title);var isYT=v.type==='youtube';var isHLS=!isYT&&v.src&&v.src.indexOf('.m3u8')>-1;var poster=v.poster?' poster="'+v.poster+'"':'';var vtag=isYT?'<iframe data-yt-src="'+v.src+'" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture" style="width:100%;height:100%;border:none"></iframe>':isHLS?'<video playsinline preload="none"'+poster+' data-hls="'+v.src+'" loop controls style="width:100%;height:100%;object-fit:cover"></video>':'<video playsinline preload="none"'+poster+' loop controls style="width:100%;height:100%;object-fit:cover"><source src="'+v.src+'" type="video/mp4"></video>';h+='<div class="tiktok-slide" id="tslide-'+i+'" data-hlid="'+hlid+'">'+vtag+'<div class="tiktok-bottom"><span class="badge badge-fpt">'+esc(cfg.name)+'</span><p class="tiktok-title">'+esc(v.title)+'</p></div><div class="tiktok-right"><button class="tiktok-right-btn" onclick="event.stopPropagation();hlAct(this,\'view\')"><div class="icon">👁</div><div class="count" data-a="views">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();hlAct(this,\'like\')"><div class="icon">❤️</div><div class="count" data-a="likes">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();openHlComments(\''+hlid+'\')"><div class="icon">💬</div><div class="count">BL</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();openHlAsk(\''+hlid+'\',\''+esc(v.title)+'\')"><div class="icon">🤖</div><div class="count">Hỏi</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();hlAct(this,\'share\');if(typeof doShareVideo===\'function\')doShareVideo(\''+esc(v.title)+'\',\''+esc(v.link||'')+'\',\''+esc(v.poster||v.img||'')+'\',\'highlights\')"><div class="icon">📤</div><div class="count" data-a="shares">0</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();toggleHlRatio(this)"><div class="icon">⬜</div><div class="count">16:9</div></button></div><span class="tiktok-counter">'+(i+1)+'/'+ordered.length+'</span></div>';});
161
+ h+='</div></div>';el.innerHTML=h;
162
+ var feed=document.getElementById('tiktok-feed');if(!feed)return;var slides=feed.querySelectorAll('.tiktok-slide');var cur=-1;
163
+ function act(i){if(i===cur)return;slides.forEach(function(sl,idx){var v=sl.querySelector('video');var fr=sl.querySelector('iframe');if(idx===i){if(v&&v.dataset.hls){if(!v._hls&&typeof Hls!=='undefined'&&Hls.isSupported()){var hls=new Hls();hls.loadSource(v.dataset.hls);hls.attachMedia(v);hls.on(Hls.Events.MANIFEST_PARSED,function(){v.play().catch(function(){});});v._hls=hls;}else if(v._hls)v.play().catch(function(){});}else if(v)v.play().catch(function(){});if(fr&&!fr.src&&fr.dataset.ytSrc)fr.src=fr.dataset.ytSrc;hlAct(sl.querySelector('.tiktok-right .tiktok-right-btn'),'view');}else{if(v){v.pause();if(v._hls){v._hls.destroy();v._hls=null;}}if(fr&&fr.src)fr.src='';}});cur=i;}
164
+ var sT;feed.addEventListener('scroll',function(){clearTimeout(sT);sT=setTimeout(function(){var rect=feed.getBoundingClientRect(),ctr=rect.top+rect.height/2,best=-1,bestD=1e9;slides.forEach(function(sl,i){var d=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(d<bestD){bestD=d;best=i;}});if(best>=0)act(best);},150);});
165
+ setTimeout(function(){act(0);},400);slides.forEach(function(sl){var v=sl.querySelector('video');if(v)v.addEventListener('click',function(e){e.preventDefault();v.paused?v.play().catch(function(){}):v.pause();});});
166
+ var ids=[];slides.forEach(function(sl){if(sl.dataset.hlid)ids.push(sl.dataset.hlid);});
167
+ if(ids.length)fetch('/api/highlight/stats?ids='+ids.join(',')).then(function(r){return r.json()}).then(function(j){var stats=j.stats||{};slides.forEach(function(sl){var st=stats[sl.dataset.hlid];if(!st)return;var r=sl.querySelector('.tiktok-right');if(!r)return;var vc=r.querySelector('[data-a="views"]');if(vc)vc.textContent=st.views||0;var lc=r.querySelector('[data-a="likes"]');if(lc)lc.textContent=st.likes||0;var sc=r.querySelector('[data-a="shares"]');if(sc)sc.textContent=st.shares||0;});}).catch(function(){});
168
+ };
169
+ window.hlAct=async function(btn,action){var slide=btn?btn.closest('.tiktok-slide'):null;var id=slide?slide.dataset.hlid:'';if(!id)return;try{var r=await fetch('/api/highlight/interact',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:id,action:action})});var j=await r.json();if(j.stats&&slide){var right=slide.querySelector('.tiktok-right');if(right){var vc=right.querySelector('[data-a="views"]');if(vc)vc.textContent=j.stats.views||0;var lc=right.querySelector('[data-a="likes"]');if(lc)lc.textContent=j.stats.likes||0;var sc=right.querySelector('[data-a="shares"]');if(sc)sc.textContent=j.stats.shares||0;}}}catch(e){}};
170
+ window.toggleHlRatio=function(btn){var slide=btn.closest('.tiktok-slide');if(!slide)return;slide.classList.toggle('ratio-wide');var label=btn.querySelector('.count');if(label)label.textContent=slide.classList.contains('ratio-wide')?'1:1':'16:9';};
171
+ window.openHlComments=async function(id){var panel=document.getElementById('hl-ask-panel');var j=await fetch('/api/short/comments?id='+id).then(function(r){return r.json()}).catch(function(){return{comments:[]}});var cmts=j.comments||[];panel.innerHTML='<h3 style="color:#5cb87a;font-size:14px">💬 Bình luận</h3><div id="hl-cmt-list">'+(cmts.map(function(c){return'<div style="background:#222;border-radius:8px;padding:7px;margin:5px 0;color:#ccc;font-size:12px">'+esc(c.text)+'</div>'}).join('')||'<div style="color:#777;font-size:12px">Chưa có</div>')+'</div><textarea id="hl-cmt-text" placeholder="Bình luận..."></textarea><button onclick="submitHlCmt(\''+id+'\')">Gửi</button><button onclick="document.getElementById(\'hl-ask-panel\').classList.remove(\'active\')">Đóng</button>';panel.classList.add('active');};
172
+ window.submitHlCmt=async function(id){var t=document.getElementById('hl-cmt-text');if(!t||!t.value.trim())return;var j=await fetch('/api/short/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:id,text:t.value.trim()})}).then(function(r){return r.json()}).catch(function(){return{comments:[]}});document.getElementById('hl-cmt-list').innerHTML=(j.comments||[]).map(function(c){return'<div style="background:#222;border-radius:8px;padding:7px;margin:5px 0;color:#ccc;font-size:12px">'+esc(c.text)+'</div>'}).join('');t.value='';};
173
+ window.openHlAsk=function(id,title){var panel=document.getElementById('hl-ask-panel');panel.innerHTML='<h3 style="color:#5cb87a;font-size:14px">🤖 Hỏi AI</h3><input id="hl-ask-q" placeholder="Hỏi về: '+esc(title)+'..."><div id="hl-ask-ans" class="hl-ask-answer"></div><button onclick="submitHlAsk(\''+id+'\',\''+esc(title)+'\')">Hỏi</button><button onclick="document.getElementById(\'hl-ask-panel\').classList.remove(\'active\')">Đóng</button>';panel.classList.add('active');};
174
+ window.submitHlAsk=async function(id,title){var q=document.getElementById('hl-ask-q');if(!q||!q.value.trim())return;var ans=document.getElementById('hl-ask-ans');ans.textContent='Đang hỏi...';try{var r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({question:q.value.trim(),context:'Video highlight: '+decodeURIComponent(title||id)})});var j=await r.json();ans.textContent=j.answer||'Không trả lời được';}catch(e){ans.textContent='Lỗi: '+e.message}};
175
+ })();
176
+ </script>
177
+ '''
178
+
179
+ EXTRA_WALL_FIX = r'''
180
+ <style>[data-wall-live="1"]{display:none!important}</style>
181
+ <script>
182
+ (function(){
183
+ var _wc=setInterval(function(){
184
+ var home=document.getElementById('view-home');if(!home||!home.classList.contains('active'))return;
185
+ var has=document.getElementById('short-ai-final-slide');
186
+ if(!has&&typeof renderShortAISlide==='function')renderShortAISlide();
187
+ if(!document.querySelector('.slider-wrap[data-wall-live]')){
188
+ fetch('/api/ai_wall').then(function(r){return r.json()}).then(function(j){
189
+ var posts=(j&&j.posts)||[];if(!posts.length)return;
190
+ if(typeof window._serverWall!=='undefined')window._serverWall=posts;
191
+ if(typeof prependWallPost==='function')prependWallPost(posts[0]);
192
+ }).catch(function(){});
193
+ }
194
+ },4000);
195
+ setTimeout(function(){clearInterval(_wc);},30000);
196
+ })();
197
+ </script>
198
+ '''
199
+
200
+ @app.get('/')
201
+ async def _index_fixed():
202
+ html=f5.f4.f3.f2.f1._load_index_html()
203
+ body=''
204
+ body+=getattr(rt.old,'PATCH_INJECT','')
205
+ body+=f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT
206
+ body+=getattr(f6,'FINAL6_INJECT','')
207
+ body+=getattr(f6,'FINAL6_FAST_HOME_INJECT','')
208
+ body+=getattr(f6,'FINAL6E_INJECT','')
209
+ body+=PATCH_INJECT
210
+ body+=UNIFIED_INJECT_FIXED
211
+ body+=HIGHLIGHT_FULL_OVERRIDE
212
+ body+=EXTRA_WALL_FIX
213
+ return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
app_main.py ADDED
@@ -0,0 +1,283 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """VNEWS v2 - Clean frontend. CRITICAL: removes ALL old routes before registering new ones."""
2
+ from app_run import *
3
+ from app_run import app, f5, f6, rt, PATCH_INJECT, UNIFIED_INJECT_FIXED, HIGHLIGHT_FULL_OVERRIDE, EXTRA_WALL_FIX, FAST_HASHTAG_JS
4
+ from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response
5
+ from fastapi.staticfiles import StaticFiles
6
+ from fastapi import Query, Request
7
+ import requests as req
8
+ from urllib.parse import quote
9
+ from bs4 import BeautifulSoup
10
+ import re, html as html_lib, os, json, threading, time
11
+ from concurrent.futures import ThreadPoolExecutor, as_completed
12
+
13
+ def _clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or ""))).strip()
14
+ _STOP_WORDS=set('và của các những một được trong với cho tại sau trước khi không người việt nam hôm nay mới nhất nóng tin tức cập nhật theo từ đến là có thì'.split())
15
+
16
+ def _relevance_score(topic, title):
17
+ topic_lower = topic.lower().strip();title_lower = (title or '').lower()
18
+ if topic_lower in title_lower: return 10
19
+ topic_words = [w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+', topic_lower) if len(w) > 1 and w not in _STOP_WORDS]
20
+ if not topic_words: return 0
21
+ matched = sum(1 for w in topic_words if w in title_lower)
22
+ ratio = matched / len(topic_words) if topic_words else 0
23
+ return int(ratio * 8) if ratio >= 0.6 else 0
24
+
25
+ def _search_vnexpress(topic,limit=8):
26
+ items=[]
27
+ try:
28
+ r=req.get(f"https://timkiem.vnexpress.net/?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
29
+ for art in soup.select('article.item-news')[:limit]:
30
+ a=art.select_one('h2 a, h3 a')
31
+ if a and a.get('href'):items.append({'title':_clean(a.get('title','') or a.get_text(strip=True)),'url':a['href'],'via':'VnExpress'})
32
+ except:pass
33
+ return items
34
+ def _search_dantri(topic,limit=8):
35
+ items=[]
36
+ try:
37
+ r=req.get(f"https://dantri.com.vn/tim-kiem/{quote(topic)}.htm",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
38
+ for a in soup.select('h3 a[href], .article-title a[href]')[:limit*2]:
39
+ t=_clean(a.get_text(strip=True));href=a.get('href','')
40
+ if t and len(t)>15:
41
+ if not href.startswith('http'):href='https://dantri.com.vn'+href
42
+ if 'dantri.com.vn' in href:items.append({'title':t,'url':href,'via':'Dân Trí'})
43
+ if len(items)>=limit:break
44
+ except:pass
45
+ return items
46
+ def _search_vietnamnet(topic,limit=6):
47
+ items=[]
48
+ try:
49
+ r=req.get(f"https://vietnamnet.vn/tim-kiem?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
50
+ for a in soup.select('h3 a[href], .horizontalPost__main-title a')[:limit*2]:
51
+ t=_clean(a.get_text(strip=True));href=a.get('href','')
52
+ if t and len(t)>15:
53
+ if not href.startswith('http'):href='https://vietnamnet.vn'+href
54
+ if 'vietnamnet.vn' in href:items.append({'title':t,'url':href,'via':'VietNamNet'})
55
+ if len(items)>=limit:break
56
+ except:pass
57
+ return items
58
+ def _search_all(topic, limit=40):
59
+ all_items=[]
60
+ with ThreadPoolExecutor(5) as ex:
61
+ futs=[ex.submit(_search_vnexpress,topic,10),ex.submit(_search_dantri,topic,10),ex.submit(_search_vietnamnet,topic,8)]
62
+ for f in as_completed(futs,timeout=12):
63
+ try:all_items.extend(f.result())
64
+ except:pass
65
+ seen=set();unique=[]
66
+ for i in all_items:
67
+ if i.get('url') and i['url'] not in seen:seen.add(i['url']);unique.append(i)
68
+ return unique[:limit]
69
+
70
+ # Remove old routes
71
+ app.router.routes = [r for r in app.router.routes if not (
72
+ (getattr(r, 'path', None) == '/' and 'GET' in getattr(r, 'methods', set())) or
73
+ (getattr(r, 'path', None) == '/api/hashtag/sources' and 'GET' in getattr(r, 'methods', set())) or
74
+ (getattr(r, 'path', None) in ('/api/short/comments', '/api/short/comment'))
75
+ )]
76
+ app.routes[:] = [r for r in app.routes if not (
77
+ hasattr(r, 'path') and getattr(r, 'path', None) == '/' and
78
+ hasattr(r, 'methods') and 'GET' in getattr(r, 'methods', set())
79
+ )]
80
+
81
+ STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
82
+
83
+ @app.get('/api/hashtag/sources')
84
+ def _ht(topic:str=Query(...), page:int=Query(default=0)):
85
+ all_items=_search_all(topic, 40)
86
+ scored = [(s,item) for item in all_items if (s:=_relevance_score(topic, item.get('title','')))>0]
87
+ scored.sort(key=lambda x: x[0], reverse=True)
88
+ filtered = [item for _, item in scored]
89
+ if len(filtered) < 3: filtered = all_items
90
+ per_page=6;start=page*per_page;end=start+per_page
91
+ return JSONResponse({'sources':filtered[start:end],'topic':topic,'page':page,'has_more':end<len(filtered),'total':len(filtered)})
92
+
93
+ @app.get('/api/categories')
94
+ def _categories():return JSONResponse([])
95
+ @app.get('/api/storage_status')
96
+ def _storage():return JSONResponse({'persistent':os.path.isdir('/data') and os.access('/data', os.W_OK)})
97
+ @app.get('/s')
98
+ async def _share(url:str='',title:str='',img:str=''):
99
+ return HTMLResponse(f'<!DOCTYPE html><html><head><meta property="og:title" content="{_clean(title)}"><meta property="og:image" content="{_clean(img)}"><meta http-equiv="refresh" content="0;url={_clean(url) or "/"}"></head><body>Redirecting...</body></html>')
100
+
101
+ @app.get('/api/proxy/page')
102
+ def proxy_page(url: str = Query(...)):
103
+ try:
104
+ r = req.get(url, headers={'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36','Accept-Language':'vi-VN,vi;q=0.9','Referer':'https://hd.xemtv.net/'}, timeout=15)
105
+ return HTMLResponse(content=r.text)
106
+ except:
107
+ return HTMLResponse(content='', status_code=502)
108
+
109
+ @app.get('/api/proxy/hls')
110
+ def proxy_hls(url: str = Query(...)):
111
+ try:
112
+ headers = {
113
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
114
+ 'Accept': '*/*',
115
+ 'Accept-Language': 'vi-VN,vi;q=0.9',
116
+ 'Referer': 'https://fptplay.vn/',
117
+ 'Origin': 'https://fptplay.vn',
118
+ }
119
+ r = req.get(url, headers=headers, timeout=15)
120
+ content_type = r.headers.get('Content-Type', 'application/vnd.apple.mpegurl')
121
+ text = r.text
122
+ base_url = url.rsplit('/', 1)[0] + '/'
123
+ def _rewrite_url(m):
124
+ seg_url = m.group(0)
125
+ if seg_url.startswith('http'):
126
+ return '/api/proxy/seg?url=' + quote(seg_url, safe='')
127
+ elif seg_url.startswith('/'):
128
+ return '/api/proxy/seg?url=' + quote(base_url.rsplit('/', 2)[0] + seg_url, safe='')
129
+ else:
130
+ return '/api/proxy/seg?url=' + quote(base_url + seg_url, safe='')
131
+ text = re.sub(r'https?://[^\s"\'<>]+\.(ts|m3u8)[^\s"\'<>]*', _rewrite_url, text)
132
+ return HTMLResponse(content=text, media_type=content_type)
133
+ except:
134
+ return HTMLResponse(content='', status_code=502)
135
+
136
+ @app.get('/api/proxy/seg')
137
+ def proxy_seg(url: str = Query(...)):
138
+ try:
139
+ headers = {
140
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
141
+ 'Referer': 'https://fptplay.vn/',
142
+ 'Origin': 'https://fptplay.vn',
143
+ }
144
+ r = req.get(url, headers=headers, timeout=15)
145
+ content_type = r.headers.get('Content-Type', 'video/MP2T')
146
+ return Response(content=r.content, media_type=content_type)
147
+ except:
148
+ return Response(content=b'', status_code=502)
149
+
150
+ # Interactions
151
+ DATA_DIR = '/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data')
152
+ os.makedirs(DATA_DIR, exist_ok=True)
153
+ INTERACTIONS_FILE = os.path.join(DATA_DIR, 'interactions_v2.json')
154
+ COMMENTS_FILE = os.path.join(DATA_DIR, 'comments_v2.json')
155
+ _interact_lock = threading.Lock()
156
+ _comment_lock = threading.Lock()
157
+ def _load_json(path):
158
+ try:
159
+ if os.path.exists(path):
160
+ with open(path,'r',encoding='utf-8') as f:return json.load(f)
161
+ except:pass
162
+ return {}
163
+ def _save_json(path, data):
164
+ try:
165
+ tmp=path+'.tmp'
166
+ with open(tmp,'w',encoding='utf-8') as f:json.dump(data,f,ensure_ascii=False)
167
+ os.replace(tmp,path)
168
+ except:pass
169
+
170
+ @app.post('/api/v2/interact')
171
+ async def api_interact(request:Request):
172
+ body=await request.json();vid=str(body.get('id','')).strip();itype=str(body.get('type','')).strip()
173
+ if not vid or itype not in('view','like'):return JSONResponse({'error':'invalid'},status_code=400)
174
+ with _interact_lock:
175
+ db=_load_json(INTERACTIONS_FILE)
176
+ if vid not in db:db[vid]={'views':0,'likes':0,'comments':0}
177
+ db[vid][itype+'s']=db[vid].get(itype+'s',0)+1
178
+ _save_json(INTERACTIONS_FILE,db);return JSONResponse(db[vid])
179
+ @app.get('/api/v2/interactions')
180
+ def api_get_interactions(id:str=Query(...)):
181
+ with _interact_lock:return JSONResponse(_load_json(INTERACTIONS_FILE).get(id.strip(),{'views':0,'likes':0,'comments':0}))
182
+ @app.get('/api/v2/comments')
183
+ def api_get_comments(id:str=Query(...)):
184
+ with _comment_lock:return JSONResponse({'comments':_load_json(COMMENTS_FILE).get(id.strip(),[])})
185
+ @app.post('/api/v2/comment')
186
+ async def api_post_comment(request:Request):
187
+ body=await request.json();vid=str(body.get('id','')).strip();text=str(body.get('text','')).strip()[:500]
188
+ if not vid or not text:return JSONResponse({'error':'invalid'},status_code=400)
189
+ comment={'text':text,'time':time.strftime('%H:%M %d/%m',time.localtime()),'ts':int(time.time())}
190
+ with _comment_lock:
191
+ db=_load_json(COMMENTS_FILE)
192
+ if vid not in db:db[vid]=[]
193
+ db[vid].append(comment)
194
+ if len(db[vid])>200:db[vid]=db[vid][-200:]
195
+ _save_json(COMMENTS_FILE,db);comments=db[vid]
196
+ with _interact_lock:
197
+ idb=_load_json(INTERACTIONS_FILE)
198
+ if vid not in idb:idb[vid]={'views':0,'likes':0,'comments':0}
199
+ idb[vid]['comments']=len(comments);_save_json(INTERACTIONS_FILE,idb)
200
+ return JSONResponse({'comments':comments})
201
+
202
+ # World Cup 2026 API
203
+ from wc2026_scraper import (
204
+ scrape_summary, scrape_fixtures, scrape_standings, scrape_stats,
205
+ scrape_wc_news, scrape_road_to_wc, get_wc2026_all,
206
+ scrape_history, scrape_h2h, scrape_lineups, scrape_match_detail
207
+ )
208
+
209
+ @app.get('/api/wc2026')
210
+ def api_wc2026_all():return JSONResponse(get_wc2026_all())
211
+ @app.get('/api/wc2026/summary')
212
+ def api_wc2026_summary():return JSONResponse(scrape_summary())
213
+ @app.get('/api/wc2026/fixtures')
214
+ def api_wc2026_fixtures():return JSONResponse(scrape_fixtures())
215
+ @app.get('/api/wc2026/standings')
216
+ def api_wc2026_standings():return JSONResponse(scrape_standings())
217
+ @app.get('/api/wc2026/stats')
218
+ def api_wc2026_stats():return JSONResponse(scrape_stats())
219
+ @app.get('/api/wc2026/history')
220
+ def api_wc2026_history():return JSONResponse(scrape_history())
221
+ @app.get('/api/wc2026/news')
222
+ def api_wc2026_news():return JSONResponse(scrape_wc_news())
223
+ @app.get('/api/wc2026/road')
224
+ def api_wc2026_road():return JSONResponse(scrape_road_to_wc())
225
+ @app.get('/api/wc2026/h2h/{event_id}')
226
+ def api_wc2026_h2h(event_id:int):return JSONResponse(scrape_h2h(event_id))
227
+ @app.get('/api/wc2026/lineups/{event_id}')
228
+ def api_wc2026_lineups(event_id:int):return JSONResponse(scrape_lineups(event_id))
229
+ @app.get('/api/wc2026/match/{event_id}')
230
+ def api_wc2026_match(event_id:int):return JSONResponse(scrape_match_detail(event_id))
231
+
232
+ # Match Detail API (for any match from bongda.com.vn)
233
+ from match_detail import fetch_match_detail, fetch_match_detail_by_url, _bongda_api
234
+
235
+ @app.get('/api/match/{event_id}/detail')
236
+ def api_match_detail(event_id: int, url: str = Query(default=None)):
237
+ """Get complete match detail. Optional 'url' param with full bongda URL (with slug) for HTML scraping."""
238
+ if url:
239
+ return JSONResponse(fetch_match_detail_by_url(url))
240
+ return JSONResponse(fetch_match_detail(event_id))
241
+
242
+ @app.get('/api/match/{event_id}/commentaries')
243
+ def api_match_commentaries(event_id: int):
244
+ """Get match commentaries from bongda API."""
245
+ comm = _bongda_api("/api/fixtures/commentaries", {"event_id": event_id})
246
+ if comm and comm.get("status") == "success":
247
+ html = comm.get("html", "")
248
+ if html and len(html.strip()) > 10:
249
+ return JSONResponse({"html": html})
250
+ return JSONResponse({"html": ""})
251
+
252
+ @app.get('/api/match/{event_id}/stats')
253
+ def api_match_stats(event_id: int):
254
+ """Get match player performance stats from bongda API."""
255
+ perf = _bongda_api("/api/event-standing/player-performance", {"event_id": event_id})
256
+ if perf and perf.get("status") == "success":
257
+ html = perf.get("html", "")
258
+ if html and len(html.strip()) > 10:
259
+ return JSONResponse({"html": html})
260
+ return JSONResponse({"html": ""})
261
+
262
+ @app.get('/api/match/detail')
263
+ def api_match_detail_by_url(url: str = Query(...)):
264
+ """Get match detail by full bongda.com.vn URL."""
265
+ return JSONResponse(fetch_match_detail_by_url(url))
266
+
267
+ def _wc2026_bg_refresh():
268
+ time.sleep(10)
269
+ while True:
270
+ try:get_wc2026_all()
271
+ except:pass
272
+ time.sleep(90)
273
+ threading.Thread(target=_wc2026_bg_refresh,daemon=True).start()
274
+
275
+ # Serve frontend
276
+ @app.get('/')
277
+ async def _index_v2():
278
+ index_path = os.path.join(STATIC_DIR, 'index_v2.html')
279
+ if os.path.exists(index_path):
280
+ return FileResponse(index_path, media_type='text/html')
281
+ return HTMLResponse('<html><body><h1>VNEWS v2</h1><p>index_v2.html not found</p></body></html>')
282
+
283
+ app.mount('/static', StaticFiles(directory=STATIC_DIR), name='vnews_static')
app_patch_unified.py ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ VNEWS Unified Patch v2
3
+ ======================
4
+ Single file replacing app_entry.py + patch_extra.py functionality.
5
+ No conflicts, no duplicate slides, no DOM destruction.
6
+
7
+ Features:
8
+ 1. Tường AI persistent (fix FINAL6E destroying DOM)
9
+ 2. Source details with image + description + "Xem trên VNEWS"
10
+ 3. Highlight = TikTok fullheight 1:1 crop center with interaction buttons
11
+ 4. Rewrite auto-title, no "xem trên VNEWS" junk
12
+ 5. Topic post uses source og:image instead of AI image
13
+ 6. Fast homepage load (non-blocking)
14
+ """
15
+ from ai_runtime_patch_fast import *
16
+ from ai_runtime_patch_fast import app, f5, f6, rt, PATCH_INJECT, _scrape, _domain, clean, _bg, _bg_home, _bg_shorts
17
+ from fastapi.responses import HTMLResponse, JSONResponse
18
+ from fastapi import Request, Query
19
+ import asyncio, re, threading, time
20
+
21
+ DEFAULT_IMG = "https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg"
22
+
23
+ # ============================================================
24
+ # REMOVE ALL CONFLICTING ROUTES — we redefine them cleanly
25
+ # ============================================================
26
+ _OVERRIDE_PATHS = {'/api/homepage','/api/shorts','/api/topic_post','/api/topic/rewrite','/api/rewrite_share','/api/url_wall','/'}
27
+ app.router.routes = [r for r in app.router.routes if not (getattr(r,'path',None) in _OVERRIDE_PATHS and any(m in getattr(r,'methods',set()) for m in ('GET','POST')))]
28
+
29
+ # ============================================================
30
+ # FAST HOMEPAGE + SHORTS (non-blocking)
31
+ # ============================================================
32
+ @app.get('/api/homepage')
33
+ def _homepage():
34
+ if _bg_home['d']:
35
+ if time.time()-_bg_home['t']>300:threading.Thread(target=_bg,daemon=True).start()
36
+ return JSONResponse(_bg_home['d'])
37
+ threading.Thread(target=_bg,daemon=True).start()
38
+ return JSONResponse([])
39
+
40
+ @app.get('/api/shorts')
41
+ def _shorts(refresh:int=Query(default=0)):
42
+ if _bg_shorts['d']:
43
+ if time.time()-_bg_shorts['t']>600:threading.Thread(target=_bg,daemon=True).start()
44
+ return JSONResponse(_bg_shorts['d'])
45
+ threading.Thread(target=_bg,daemon=True).start()
46
+ return JSONResponse([])
47
+
48
+ # ============================================================
49
+ # HELPERS
50
+ # ============================================================
51
+ def _extract_title(text):
52
+ if not text:return 'Bài viết AI'
53
+ lines=[l.strip() for l in text.strip().split('\n') if l.strip()]
54
+ if lines:
55
+ first=re.sub(r'^[#*\-•\d\.\)\s]+','',lines[0]).strip()
56
+ if 10<=len(first)<=120:return first
57
+ return lines[0][:100] if lines else 'Bài viết AI'
58
+
59
+ def _clean_text(text):
60
+ if not text:return text
61
+ for junk in ['xem trên VNEWS','Xem trên VNEWS','📖 Xem trên VNEWS','đọc trên VNEWS','Đọc trên VNEWS','Mở nguồn gốc','mở nguồn gốc','📖 Đọc trên']:
62
+ text=text.replace(junk,'')
63
+ return re.sub(r'\n{3,}','\n\n',text).strip()
64
+
65
+ def _source_image(sources, details):
66
+ for s in (details or [])+(sources or []):
67
+ url=s.get('url','')
68
+ if not url:continue
69
+ try:_,_,img=_scrape(url,500)
70
+ except:img=''
71
+ if img and 'pollinations' not in img and len(img)>20:return img
72
+ return ''
73
+
74
+ def _ensure_img(img):
75
+ return img if (img and len(img)>20 and img.startswith('http')) else DEFAULT_IMG
76
+
77
+ # ============================================================
78
+ # TOPIC POST (source image instead of AI image)
79
+ # ============================================================
80
+ @app.post('/api/topic_post')
81
+ async def _topic(request:Request):
82
+ b=await request.json();topic=clean(b.get('topic',''))
83
+ if not topic:return JSONResponse({'error':'missing topic'},status_code=400)
84
+ research=f6._fast_context(topic) if hasattr(f6,'_fast_context') else f6._web_research_context(topic)
85
+ ctx=research.get('context','');src=research.get('sources',[])
86
+ det=f6._extract_source_details_from_context(ctx,src) if hasattr(f6,'_extract_source_details_from_context') else []
87
+ if not ctx or not src:return JSONResponse({'error':'Không tìm được nội dung.'},status_code=422)
88
+ img=_ensure_img(_source_image(src,det) or f6._topic_image(topic))
89
+ sb='\n\n'.join([f"[{i+1}] {d.get('title','')} ({d.get('via','')})\n{d.get('content','')[:1400]}" for i,d in enumerate(det)]) if det else ctx[:18000]
90
+ text=None
91
+ try:text=await asyncio.wait_for(f5.base.qwen_generate(f'Viết bài tiếng Việt VỀ: "{topic}"\nNGUỒN:\n{sb[:18000]}\nCHỈ viết về "{topic}". 5-8 đoạn. Cuối có nguồn.',image_url=img,max_tokens=1700),timeout=35)
92
+ except:pass
93
+ if not text or len(text)<300:
94
+ text=f"{topic}: tổng hợp\n\n"+'\n'.join([f"• {d['title']}: {d.get('content','')[:300]}" for d in (det or [])[:6]])+"\n\nNguồn: "+', '.join(sorted({d.get('via','') for d in (det or []) if d.get('via')}))
95
+ text=_clean_text(text)
96
+ post=f5.base.make_post(topic,text,img,'','topic_focused',sources=[s for s in src if s.get('url')])
97
+ post['images']=[img];post['source_details']=det
98
+ ps=f5.base._load_ai_wall();ps.insert(0,post);f5.base._save_ai_wall(ps)
99
+ return JSONResponse({'post':post})
100
+
101
+ # ============================================================
102
+ # REWRITE (auto-title, clean text)
103
+ # ============================================================
104
+ @app.post('/api/rewrite_share')
105
+ @app.post('/api/url_wall')
106
+ async def _rewrite(request:Request):
107
+ b=await request.json();url=clean(b.get('url',''));ctx=clean(b.get('context',''))
108
+ if not url.startswith('http'):return JSONResponse({'error':'URL không hợp lệ'},status_code=400)
109
+ title,raw,img=_scrape(url,14000)
110
+ if len(raw)<50:raw=ctx[:14000]
111
+ if len(raw)<50:return JSONResponse({'error':'Không đọc được bài'},status_code=422)
112
+ img=_ensure_img(img)
113
+ prompt=f"""Tóm tắt bài viết thành bản tin ngắn. Dòng đầu tiên là tiêu đề mới hấp dẫn (tự đặt, không copy gốc).
114
+
115
+ Tiêu đề gốc: {title}
116
+ Nội dung:
117
+ {raw[:14000]}
118
+
119
+ Yêu cầu:
120
+ - Dòng 1: Tiêu đề MỚI ngắn gọn hấp dẫn.
121
+ - Tiếp: 4-6 ý chính.
122
+ - Cuối: nguồn.
123
+ - KHÔNG viết bất kỳ cụm điều hướng nào."""
124
+ text=None
125
+ try:text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1000),timeout=30)
126
+ except:pass
127
+ if not text or len(text)<80:text=f"{title}\n\n{raw[:1200]}\n\nNguồn: {_domain(url)}"
128
+ text=_clean_text(text)
129
+ ai_title=_extract_title(text)
130
+ lines=text.strip().split('\n')
131
+ body='\n'.join(lines[1:]).strip() if lines and lines[0].strip()==ai_title else text
132
+ post=f5.base.make_post(ai_title,_clean_text(body),img,url,'rewrite',sources=[{'title':title,'url':url,'via':_domain(url)}])
133
+ ps=f5.base._load_ai_wall();ps.insert(0,post);f5.base._save_ai_wall(ps)
134
+ return JSONResponse({'post':post})
135
+
136
+ @app.post('/api/topic/rewrite')
137
+ async def _topic_rewrite(request:Request):
138
+ b=await request.json();pid=str(b.get('post_id','')).strip()
139
+ if not pid:return JSONResponse({'error':'missing post_id'},status_code=400)
140
+ ps=f5.base._load_ai_wall();p=next((x for x in ps if str(x.get('id'))==pid),None)
141
+ if not p:return JSONResponse({'error':'Bài không tồn tại'},status_code=404)
142
+ urls=list(dict.fromkeys([s['url'] for s in (p.get('source_details') or []) if s.get('url')]+[s['url'] for s in (p.get('sources') or []) if s.get('url')]))[:5]
143
+ parts=[];best_img=''
144
+ for u in urls:
145
+ t,r,uimg=_scrape(u,6000)
146
+ if r and len(r)>150:parts.append(f"[{_domain(u)}] {t}\n{r}")
147
+ if not best_img and uimg and len(uimg)>20:best_img=uimg
148
+ ac='\n---\n'.join(parts) if parts else (p.get('text') or '')
149
+ img=_ensure_img(best_img or p.get('img',''))
150
+ prompt=f"""Viết lại thành bản tóm tắt mới. Dòng đầu là tiêu đề mới hấp dẫn.
151
+
152
+ Chủ đề: {p.get('title','')}
153
+ Nguồn:
154
+ {ac[:16000]}
155
+
156
+ Yêu cầu: Dòng 1 = tiêu đề mới. Tiếp: 4-6 ý. Cuối: nguồn. KHÔNG viết cụm điều hướng."""
157
+ text=None
158
+ try:text=await asyncio.wait_for(f5.base.qwen_generate(prompt,image_url=img,max_tokens=1200),timeout=35)
159
+ except:pass
160
+ if not text or len(text)<100:text=f"Tóm tắt: {p.get('title','')}\n\n{ac[:1500]}\n\nNguồn: VNEWS AI"
161
+ text=_clean_text(text)
162
+ ai_title=_extract_title(text)
163
+ lines=text.strip().split('\n')
164
+ body='\n'.join(lines[1:]).strip() if lines and lines[0].strip()==ai_title else text
165
+ np=f5.base.make_post(ai_title,_clean_text(body),img,'','rewrite_topic',sources=p.get('sources',[]));np['images']=[img]
166
+ all_p=f5.base._load_ai_wall();all_p.insert(0,np);f5.base._save_ai_wall(all_p)
167
+ return JSONResponse({'post':np})
168
+
169
+ # ============================================================
170
+ # UNIFIED INJECT: everything in one clean block
171
+ # ============================================================
172
+ UNIFIED_INJECT = r'''
173
+ <script>
174
+ // === PRE-KILL: prevent old code from destroying Tường AI and Short AI slides ===
175
+ Object.defineProperty(window,'renderTopicWallE',{get:function(){return function(){}},set:function(){},configurable:true});
176
+ Object.defineProperty(window,'renderAIShortHome',{get:function(){return function(){}},set:function(){},configurable:true});
177
+ Object.defineProperty(window,'renderAIShorts7',{get:function(){return function(){}},set:function(){},configurable:true});
178
+ </script>
179
+ <style>
180
+ /* Tiktok right panel for shorts/highlights */
181
+ .tiktok-slide{position:relative!important}
182
+ .tiktok-right{position:absolute!important;right:8px!important;bottom:100px!important;display:flex!important;flex-direction:column!important;align-items:center!important;gap:14px!important;z-index:5!important}
183
+ .tiktok-right-btn{display:flex!important;flex-direction:column!important;align-items:center!important;gap:2px!important;background:none!important;border:0!important;color:#fff!important;cursor:pointer!important}
184
+ .tiktok-right-btn .icon{width:42px!important;height:42px!important;border-radius:50%!important;background:rgba(255,255,255,.12)!important;display:flex!important;align-items:center!important;justify-content:center!important;font-size:20px!important}
185
+ .tiktok-right-btn .count{font-size:10px!important;color:#ddd!important}
186
+ /* Highlight: TikTok feed with 1:1 crop center */
187
+ .tiktok-slide video{object-fit:cover!important}
188
+ /* Hide duplicate slides/walls from old layers */
189
+ #ai-short-home,.ai-short-home,.ai-short-card-final,[id*="ai-shorts-patched"]{display:none!important}
190
+ /* Progress toast */
191
+ #short-progress-toast{position:fixed;bottom:70px;left:50%;transform:translateX(-50%);background:#2d8659;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;z-index:99998;box-shadow:0 4px 12px rgba(0,0,0,.4);display:none}
192
+ /* Source details */
193
+ .source-detail-box{margin-top:14px;background:#151515;border:1px solid #2b2b2b;border-radius:10px;padding:10px}
194
+ .source-detail-item{background:#202020;border-radius:8px;padding:9px;margin:7px 0;cursor:pointer}
195
+ .source-detail-item:active{opacity:.8}
196
+ .source-detail-title{font-size:12px;font-weight:700;color:#eee}
197
+ .source-detail-content{font-size:11px;color:#bbb;line-height:1.4;max-height:80px;overflow:hidden;margin-top:4px}
198
+ .source-detail-item img{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:6px;margin-bottom:6px;background:#222}
199
+ .source-vnews-btn{display:inline-block;margin-top:6px;background:#2d8659;color:#fff;padding:4px 10px;border-radius:10px;font-size:10px;font-weight:700}
200
+ /* Livescore */
201
+ .ls-content{max-height:480px;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}.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,.mo-body table{width:100%;border-collapse:collapse;font-size:11px;color:#ccc}.ls-content table th,.mo-body table th{background:#222;color:#999;padding:5px 4px;font-size:10px;border-bottom:1px solid #333}.ls-content table td,.mo-body table td{padding:4px 3px;border-bottom:1px solid #1a1a1a}.ls-content table .team-name,.mo-body table .team-name{display:flex;align-items:center;gap:4px}.ls-content table .team-name img,.mo-body table .team-name img{width:16px;height:16px}.ls-content table .pts{font-weight:800;color:#f0c040}.mo-body{padding:8px;font-size:12px;color:#ddd}.mo-body ul{list-style:none;padding:0}.mo-body li{padding:5px 0;border-bottom:1px solid #222}
202
+ </style>
203
+ <div id="short-progress-toast"></div>
204
+ <script>
205
+ (function(){
206
+ function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
207
+
208
+ // === Progress toast ===
209
+ window.showShortProgress=function(msg){var t=document.getElementById('short-progress-toast');if(t){t.textContent=msg;t.style.display='block';}};
210
+ window.hideShortProgress=function(){var t=document.getElementById('short-progress-toast');if(t)t.style.display='none';};
211
+ window.makeShortFromPost=async function(pid,btn){
212
+ showShortProgress('⏳ Đang tạo Short AI...');if(btn){btn.disabled=true;btn.textContent='Đang tạo...';}
213
+ try{var r=await fetch('/api/ai/short/'+pid,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:'nu',emotion:'neutral',speed:1.2})});var j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');showShortProgress('✅ Đã tạo!');setTimeout(hideShortProgress,3000);if(typeof renderShortAISlide==='function')renderShortAISlide();}catch(e){showShortProgress('❌ '+e.message);setTimeout(hideShortProgress,4000);}finally{if(btn){btn.disabled=false;btn.textContent='🎬 Tạo Short AI';}}
214
+ };
215
+
216
+ // === Remove duplicate slides ===
217
+ setInterval(function(){document.querySelectorAll('#ai-short-home,.ai-short-home,[id*="ai-shorts-patched"]').forEach(function(el){if(el.id!=='short-ai-final-slide')el.remove();});},3000);
218
+
219
+ // === Override openLeaguePlayer: TikTok vertical feed, 1:1 crop center ===
220
+ window.openLeaguePlayer=async function(league,idx){
221
+ showView('view-tiktok');document.querySelectorAll('.cat').forEach(x=>x.classList.remove('active'));
222
+ var el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải...</div>';
223
+ var cfg=(window.HL_CONFIG||{})[league]||{name:league,emoji:'🎬'};
224
+ var articles=(window._hlLeagueData||{})[league]||[];
225
+ if(!articles.length){el.innerHTML='<div class="loading">Không có video</div>';return;}
226
+ var vids=[];
227
+ var results=await Promise.all(articles.map(async function(a,i){try{var r=await fetch('/api/video_url?url='+encodeURIComponent(a.link));var v=await r.json();if(v&&v.src)return Object.assign({},a,v,{_idx:i});}catch(e){}return null;}));
228
+ results.forEach(function(r){if(r)vids.push(r);});
229
+ vids.sort(function(a,b){return a._idx-b._idx;});
230
+ if(!vids.length){el.innerHTML='<div class="loading">Không tìm thấy video</div>';return;}
231
+ var ti=vids.findIndex(function(v){return v._idx===idx;});if(ti<0)ti=0;
232
+ var ordered=ti>0?vids.slice(ti).concat(vids.slice(0,ti)):vids;
233
+ var h='<button class="back-btn" onclick="switchCat(\'home\')">← '+cfg.emoji+' '+cfg.name+'</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">';
234
+ ordered.forEach(function(v,i){
235
+ var isYT=v.type==='youtube';var isHLS=!isYT&&v.src&&v.src.indexOf('.m3u8')>-1;
236
+ var poster=v.poster?' poster="'+v.poster+'"':'';
237
+ var vtag=isYT?'<iframe data-yt-src="'+v.src+'" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture" style="width:100%;height:100%;border:none"></iframe>':isHLS?'<video playsinline preload="none"'+poster+' data-hls="'+v.src+'" loop controls style="width:100%;height:100%;object-fit:cover"></video>':'<video playsinline preload="none"'+poster+' loop controls style="width:100%;height:100%;object-fit:cover"><source src="'+v.src+'" type="video/mp4"></video>';
238
+ h+='<div class="tiktok-slide" id="tslide-'+i+'">'+vtag+'<div class="tiktok-bottom"><span class="badge badge-fpt">'+esc(cfg.name)+'</span><p class="tiktok-title">'+esc(v.title)+'</p></div><div class="tiktok-right"><button class="tiktok-right-btn" onclick="event.stopPropagation()"><div class="icon">👁</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation()"><div class="icon">❤️</div></button><button class="tiktok-right-btn" onclick="event.stopPropagation();if(typeof doShareVideo===\'function\')doShareVideo(\''+esc(v.title)+'\',\''+esc(v.link||'')+'\',\''+esc(v.poster||v.img||'')+'\',\'highlights\')"><div class="icon">📤</div></button></div><span class="tiktok-counter">'+(i+1)+'/'+ordered.length+'</span></div>';
239
+ });
240
+ h+='</div></div>';el.innerHTML=h;
241
+ // Init feed
242
+ var feed=document.getElementById('tiktok-feed');if(!feed)return;
243
+ var slides=feed.querySelectorAll('.tiktok-slide');var cur=-1;
244
+ function act(i){if(i===cur)return;slides.forEach(function(sl,idx){var v=sl.querySelector('video');var fr=sl.querySelector('iframe');if(idx===i){if(v&&v.dataset.hls){if(!v._hls&&typeof Hls!=='undefined'&&Hls.isSupported()){var hls=new Hls();hls.loadSource(v.dataset.hls);hls.attachMedia(v);hls.on(Hls.Events.MANIFEST_PARSED,function(){v.play().catch(function(){});});v._hls=hls;}else if(v._hls)v.play().catch(function(){});}else if(v)v.play().catch(function(){});if(fr&&!fr.src&&fr.dataset.ytSrc)fr.src=fr.dataset.ytSrc;}else{if(v){v.pause();if(v._hls){v._hls.destroy();v._hls=null;}}if(fr&&fr.src)fr.src='';}});cur=i;}
245
+ var sT;feed.addEventListener('scroll',function(){clearTimeout(sT);sT=setTimeout(function(){var rect=feed.getBoundingClientRect(),ctr=rect.top+rect.height/2,best=-1,bestD=1e9;slides.forEach(function(sl,i){var d=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(d<bestD){bestD=d;best=i;}});if(best>=0)act(best);},150);});
246
+ setTimeout(function(){act(0);},400);
247
+ slides.forEach(function(sl){var v=sl.querySelector('video');if(v)v.addEventListener('click',function(e){e.preventDefault();v.paused?v.play().catch(function(){}):v.pause();});});
248
+ };
249
+
250
+ // === Block slow YouTube refresh on first load ===
251
+ var _origFetch=window.fetch,_allowRefresh=false;
252
+ window.fetch=function(url,opts){try{if(String(url).indexOf('/api/shorts?refresh=1')>-1&&!_allowRefresh)url='/api/shorts';}catch(e){}return _origFetch.call(this,url,opts);};
253
+ setTimeout(function(){_allowRefresh=true;},8000);
254
+ })();
255
+ </script>
256
+ '''
257
+
258
+ # ============================================================
259
+ # ROOT ROUTE: inject order matters
260
+ # ============================================================
261
+ @app.get('/')
262
+ async def _index():
263
+ html = f5.f4.f3.f2.f1._load_index_html()
264
+ # Inject order: PRE_KILL (in UNIFIED) → old injects → PATCH_INJECT → UNIFIED
265
+ body = ''
266
+ body += getattr(rt.old,'PATCH_INJECT','')
267
+ body += f5.f4.f3.f2.f1.FINAL_INJECT + f5.f4.f3.FINAL3_INJECT + f5.f4.FINAL4_INJECT + f5.FINAL5_INJECT
268
+ body += getattr(f6,'FINAL6_INJECT','')
269
+ body += getattr(f6,'FINAL6_FAST_HOME_INJECT','')
270
+ body += getattr(f6,'FINAL6E_INJECT','') # Keep it — our PRE_KILL in UNIFIED neutralizes its destructive parts
271
+ body += PATCH_INJECT
272
+ body += UNIFIED_INJECT # This goes LAST and contains PRE_KILL at the TOP (runs first in browser)
273
+ return HTMLResponse(html.replace('</body>', body + '\n</body>') if '</body>' in html else html + body)
app_run.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Wrapper: hashtag via Google News with pagination, strict relevance, load more."""
2
+ from app_final import *
3
+ from app_final import app, f6, f5, rt, PATCH_INJECT, UNIFIED_INJECT_FIXED, HIGHLIGHT_FULL_OVERRIDE, EXTRA_WALL_FIX
4
+ from fastapi.responses import HTMLResponse, JSONResponse
5
+ from fastapi import Query, Request
6
+ import requests as req
7
+ from urllib.parse import quote
8
+ from bs4 import BeautifulSoup
9
+ import re, html as html_lib
10
+
11
+ def _clean(s):return re.sub(r"\s+"," ",html_lib.unescape(str(s or ""))).strip()
12
+
13
+ def _follow_redirect(url):
14
+ try:
15
+ r=req.head(url,allow_redirects=True,timeout=10,headers={'User-Agent':'Mozilla/5.0'})
16
+ return r.url
17
+ except:
18
+ try:r=req.get(url,allow_redirects=True,timeout=10,headers={'User-Agent':'Mozilla/5.0'},stream=True);u=r.url;r.close();return u
19
+ except:return url
20
+
21
+ def _scrape_any_article(url):
22
+ if 'news.google.com' in url or 'google.com/rss' in url:url=_follow_redirect(url)
23
+ try:
24
+ r=req.get(url,headers={'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36','Accept-Language':'vi-VN,vi;q=0.9,en;q=0.8'},timeout=15,allow_redirects=True)
25
+ r.encoding='utf-8';soup=BeautifulSoup(r.text,'lxml')
26
+ for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript','iframe']):tag.decompose()
27
+ h1=soup.find('h1');ogt=soup.find('meta',property='og:title')
28
+ title=(h1.get_text(' ',strip=True) if h1 else '') or (ogt.get('content','') if ogt else '') or (soup.title.get_text(strip=True) if soup.title else '')
29
+ ogd=soup.find('meta',property='og:description') or soup.find('meta',attrs={'name':'description'})
30
+ summary=ogd.get('content','') if ogd else ''
31
+ ogi=soup.find('meta',property='og:image') or soup.find('meta',attrs={'name':'twitter:image'})
32
+ og_image=ogi.get('content','') if ogi else ''
33
+ if og_image and og_image.startswith('//'):og_image='https:'+og_image
34
+ selectors=['article','main','.article-content','.detail-content','.singular-content','.fck_detail','.content-detail','.entry-content','.story-body','.knc-content','.cms-body']
35
+ block=None
36
+ for sel in selectors:
37
+ el=soup.select_one(sel)
38
+ if el and len(el.find_all('p'))>=2:block=el;break
39
+ if not block:
40
+ best=None;best_score=0
41
+ for el in soup.find_all(['article','main','section','div']):
42
+ ps=el.find_all('p');score=len(ps)*100+sum(len(p.get_text())for p in ps[:10])
43
+ if score>best_score:best=el;best_score=score
44
+ block=best or soup.body or soup
45
+ body=[]
46
+ for el in block.find_all(['p','h2','h3','figure','img'],recursive=True):
47
+ if el.name=='p':
48
+ t=_clean(el.get_text(' ',strip=True))
49
+ if len(t)>30:body.append({'type':'p','text':t})
50
+ elif el.name in ('h2','h3'):
51
+ t=_clean(el.get_text(' ',strip=True))
52
+ if t:body.append({'type':'heading','text':t})
53
+ elif el.name in ('figure','img'):
54
+ im=el if el.name=='img' else el.find('img')
55
+ if im:
56
+ src=im.get('data-src') or im.get('data-original') or im.get('src') or ''
57
+ if src and 'base64' not in src:
58
+ if src.startswith('//'):src='https:'+src
59
+ body.append({'type':'img','src':src})
60
+ if not body and summary:body=[{'type':'p','text':summary}]
61
+ return {'title':_clean(title),'summary':_clean(summary),'og_image':og_image,'body':body[:50],'source':'generic','url':url}
62
+ except:return None
63
+
64
+ def _google_news_search_all(topic, limit=30):
65
+ """Get ALL results from Google News RSS for a topic — no filtering here, filter in endpoint."""
66
+ items=[]
67
+ try:
68
+ url='https://news.google.com/rss/search?q='+quote(topic)+'&hl=vi&gl=VN&ceid=VN:vi'
69
+ r=req.get(url,headers={'User-Agent':'Mozilla/5.0'},timeout=10);r.encoding='utf-8'
70
+ soup=BeautifulSoup(r.text,'xml')
71
+ for it in soup.find_all('item')[:limit]:
72
+ title=_clean(it.find('title').get_text(' ',strip=True) if it.find('title') else '')
73
+ link=_clean(it.find('link').get_text(strip=True) if it.find('link') else '')
74
+ src=_clean(it.find('source').get_text(' ',strip=True) if it.find('source') else '')
75
+ pub=_clean(it.find('pubDate').get_text(strip=True) if it.find('pubDate') else '')
76
+ if not title or not link:continue
77
+ items.append({'title':title,'url':link,'via':src,'snippet':'','pubDate':pub})
78
+ except:pass
79
+ return items
80
+
81
+ def _filter_relevant(items, topic):
82
+ """Strict filter: topic keywords MUST appear in title."""
83
+ topic_lower=topic.lower()
84
+ topic_words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',topic_lower) if len(w)>2]
85
+ filtered=[]
86
+ for s in items:
87
+ title_lower=s.get('title','').lower()
88
+ # Whole phrase match OR majority of words match
89
+ if topic_lower in title_lower:
90
+ filtered.append(s);continue
91
+ if topic_words:
92
+ match=sum(1 for w in topic_words if w in title_lower)
93
+ if match>=len(topic_words)*0.6:
94
+ filtered.append(s)
95
+ return filtered
96
+
97
+ # Override endpoints
98
+ app.router.routes=[r for r in app.router.routes if not (
99
+ (getattr(r,'path',None)=='/api/hashtag/sources' and 'GET' in getattr(r,'methods',set())) or
100
+ (getattr(r,'path',None)=='/api/article' and 'GET' in getattr(r,'methods',set())) or
101
+ (getattr(r,'path',None)=='/' and 'GET' in getattr(r,'methods',set()))
102
+ )]
103
+
104
+ @app.get('/api/article')
105
+ def _article_universal(url:str=Query(...)):
106
+ data=_scrape_any_article(url)
107
+ if data and data.get('body'):return JSONResponse(data)
108
+ from main import scrape_vne_article,scrape_bbc_article,scrape_dantri_article,scrape_genk_article,scrape_ttvh_article
109
+ if 'vnexpress.net' in url:d=scrape_vne_article(url)
110
+ elif 'bbc.com' in url:d=scrape_bbc_article(url)
111
+ elif 'dantri.com.vn' in url:d=scrape_dantri_article(url)
112
+ elif 'genk.vn' in url:d=scrape_genk_article(url)
113
+ elif 'thethaovanhoa.vn' in url:d=scrape_ttvh_article(url)
114
+ else:d=None
115
+ if d and d.get('body'):return JSONResponse(d)
116
+ return JSONResponse({'error':'Không đọc được bài viết','url':url})
117
+
118
+ @app.get('/api/hashtag/sources')
119
+ def _hashtag_paged(topic:str=Query(...),page:int=Query(default=0)):
120
+ """Google News search with pagination. page=0 returns first 6, page=1 returns next 6, etc."""
121
+ all_items=_google_news_search_all(topic,30)
122
+ filtered=_filter_relevant(all_items,topic)
123
+ # If strict filter too harsh, fallback to all
124
+ if len(filtered)<3:filtered=all_items
125
+ per_page=6;start=page*per_page;end=start+per_page
126
+ page_items=filtered[start:end]
127
+ has_more=end<len(filtered)
128
+ return JSONResponse({'sources':page_items,'topic':topic,'page':page,'has_more':has_more,'total':len(filtered)})
129
+
130
+ FAST_HASHTAG_JS = r'''
131
+ <style>
132
+ .hashtag-loading{display:flex;align-items:center;gap:8px;padding:12px;color:#888;font-size:12px}
133
+ .hashtag-spinner{width:16px;height:16px;border:2px solid #333;border-top-color:#5cb87a;border-radius:50%;animation:ht-spin .8s linear infinite}
134
+ @keyframes ht-spin{to{transform:rotate(360deg)}}
135
+ .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-load-more:active{opacity:.7}
136
+ </style>
137
+ <script>
138
+ (function(){
139
+ function esc(s){return String(s||'').replace(/[&<>"']/g,function(m){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]});}
140
+ var _htPage=0,_htTopic='',_htImgIdx=0;
141
+
142
+ window.readArticle=async function(url){
143
+ showView('view-article');var el=document.getElementById('view-article');el.innerHTML='<div class="loading">Đang tải...</div>';
144
+ try{var r=await fetch('/api/article?url='+encodeURIComponent(url));var data=await r.json();
145
+ if(data&&!data.error&&data.body&&data.body.length){window._currentArticle={url:url,data:data};var 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>';var seen={};data.body.forEach(function(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="doRewriteArticle(this)">🤖 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="article-ai-question" placeholder="Hỏi..."></textarea><button onclick="askArticleAI()">Hỏi</button><div id="article-ai-answer" class="article-ai-answer"></div></div></div>';el.innerHTML=h;window.scrollTo(0,0);return;}}catch(e){}
146
+ 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>';
147
+ };
148
+ window.doRewriteArticle=async function(btn){var url=(window._currentArticle&&window._currentArticle.url)||'';if(!url){alert('Không có URL');return;}var ctx=document.querySelector('.article-view')?.innerText?.slice(0,14000)||'';btn.disabled=true;btn.textContent='Đang rewrite...';try{var r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:url,context:ctx})});var j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');alert('Đã đăng Tường AI!');}catch(e){alert(e.message);}finally{btn.disabled=false;btn.textContent='🤖 Rewrite AI đăng tường';}};
149
+ window.askArticleAI=async function(){var q=document.getElementById('article-ai-question')?.value.trim();if(!q)return alert('Nhập câu hỏi');var a=document.getElementById('article-ai-answer');a.textContent='Đang hỏi...';var url=(window._currentArticle&&window._currentArticle.url)||'';var ctx=document.querySelector('.article-view')?.innerText?.slice(0,12000)||'';try{var r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:url,question:q,context:ctx})});var j=await r.json();a.textContent=j.answer||'Không trả lời được';}catch(e){a.textContent='Lỗi: '+e.message}};
150
+
151
+ function renderSources(sources,append){
152
+ var list=document.getElementById('hashtag-src-list');if(!list)return;
153
+ var h='';
154
+ sources.forEach(function(s){
155
+ var idx=_htImgIdx++;
156
+ h+='<div class="hashtag-src-item" onclick="readArticle(\''+esc(s.url||'')+'\')">';
157
+ h+='<div class="hashtag-src-img" id="ht-img-'+idx+'"></div>';
158
+ h+='<div class="hashtag-src-text"><div class="hashtag-src-title">'+esc(s.title)+'</div><div class="hashtag-src-via">'+esc(s.via||'')+(s.pubDate?' · '+esc(s.pubDate.split(',')[0]||''):'')+'</div></div>';
159
+ h+='</div>';
160
+ // Lazy load image
161
+ setTimeout(function(){fetch('/api/article?url='+encodeURIComponent(s.url)).then(function(r){return r.json()}).then(function(d){if(d&&(d.og_image||d.img)){var el=document.getElementById('ht-img-'+idx);if(el)el.innerHTML='<img src="'+esc(d.og_image||d.img)+'" onerror="this.style.display=\'none\'" loading="lazy">';}}).catch(function(){});},idx*500);
162
+ });
163
+ if(append)list.insertAdjacentHTML('beforeend',h);else list.innerHTML=h;
164
+ }
165
+
166
+ window.showHashtagSources=async function(topic){
167
+ _htTopic=topic;_htPage=0;_htImgIdx=0;
168
+ var home=document.getElementById('view-home');if(!home)return;
169
+ document.getElementById('hashtag-sources-box')?.remove();
170
+ var box=document.createElement('div');box.id='hashtag-sources-box';box.className='hashtag-sources';
171
+ box.innerHTML='<h3>🔍 '+esc(topic)+'</h3><div class="hashtag-loading"><div class="hashtag-spinner"></div>Đang tìm bài viết mới nhất...</div>';
172
+ var compose=home.querySelector('.ai-compose');
173
+ if(compose)compose.after(box);else home.prepend(box);
174
+ box.scrollIntoView({behavior:'smooth',block:'start'});
175
+ try{
176
+ var r=await fetch('/api/hashtag/sources?topic='+encodeURIComponent(topic)+'&page=0');
177
+ var j=await r.json();var sources=j.sources||[];
178
+ if(!sources.length){box.innerHTML='<h3>🔍 '+esc(topic)+'</h3><div style="color:#888;font-size:12px;padding:8px">Không tìm được bài viết liên quan</div>';return;}
179
+ var h='<h3>🔍 '+esc(topic)+' <span style="font-size:10px;color:#888">('+j.total+' bài mới nhất từ Google News)</span></h3>';
180
+ h+='<div id="hashtag-src-list"></div>';
181
+ h+='<button class="hashtag-rewrite-btn" onclick="rewriteHashtagTopic(\''+esc(topic)+'\')">🤖 Rewrite AI tổng hợp & đăng tường</button>';
182
+ if(j.has_more)h+='<button class="hashtag-load-more" id="ht-load-more" onclick="loadMoreSources()">Tải thêm bài viết ▼</button>';
183
+ box.innerHTML=h;
184
+ renderSources(sources,false);
185
+ }catch(e){box.innerHTML='<h3>🔍 '+esc(topic)+'</h3><div style="color:#e74c3c;font-size:12px;padding:8px">Lỗi: '+esc(e.message)+'</div>';}
186
+ };
187
+
188
+ window.loadMoreSources=async function(){
189
+ _htPage++;var btn=document.getElementById('ht-load-more');
190
+ if(btn){btn.textContent='Đang tải...';btn.disabled=true;}
191
+ try{
192
+ var r=await fetch('/api/hashtag/sources?topic='+encodeURIComponent(_htTopic)+'&page='+_htPage);
193
+ var j=await r.json();var sources=j.sources||[];
194
+ renderSources(sources,true);
195
+ if(!j.has_more&&btn)btn.remove();
196
+ else if(btn){btn.textContent='Tải thêm bài viết ▼';btn.disabled=false;}
197
+ }catch(e){if(btn){btn.textContent='Lỗi, thử lại';btn.disabled=false;}}
198
+ };
199
+
200
+ window.rewriteHashtagTopic=async function(topic){var btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang tổng hợp...';}try{var r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic:topic})});var j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');if(btn)btn.textContent='✅ Đã đăng!';setTimeout(function(){document.getElementById('hashtag-sources-box')?.remove();},2000);}catch(e){if(btn){btn.disabled=false;btn.textContent='❌ '+e.message;}}};
201
+ window.createTopicPost=function(){var inp=document.getElementById('ai-topic-input');var topic=(inp&&inp.value||'').trim();if(!topic){alert('Nhập chủ đề');return;}showHashtagSources(topic);if(inp)inp.value='';};
202
+ window.createTopicPostFinal5=function(){var inp=document.getElementById('ai-topic-input-final5')||document.getElementById('ai-topic-input');var topic=(inp&&inp.value||'').trim();if(!topic){alert('Nhập chủ đề');return;}showHashtagSources(topic);if(inp)inp.value='';};
203
+ })();
204
+ </script>
205
+ '''
206
+
207
+ @app.get('/')
208
+ async def _index_run():
209
+ html=f5.f4.f3.f2.f1._load_index_html()
210
+ body=''
211
+ body+=getattr(rt.old,'PATCH_INJECT','')
212
+ body+=f5.f4.f3.f2.f1.FINAL_INJECT+f5.f4.f3.FINAL3_INJECT+f5.f4.FINAL4_INJECT+f5.FINAL5_INJECT
213
+ body+=getattr(f6,'FINAL6_INJECT','')
214
+ body+=getattr(f6,'FINAL6_FAST_HOME_INJECT','')
215
+ body+=getattr(f6,'FINAL6E_INJECT','')
216
+ body+=PATCH_INJECT
217
+ body+=UNIFIED_INJECT_FIXED
218
+ body+=HIGHLIGHT_FULL_OVERRIDE
219
+ body+=EXTRA_WALL_FIX
220
+ body+=FAST_HASHTAG_JS
221
+ return HTMLResponse(html.replace('</body>',body+'\n</body>') if '</body>' in html else html+body)
app_v2_entry.py ADDED
@@ -0,0 +1,736 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """VNEWS v2 Entry Point - with fast bongda proxy"""
2
+ import sys, os
3
+ from main import app, HEADERS, BONGDA_HEADERS, fetch_bongda_api, HL_LEAGUES
4
+
5
+ try:
6
+ import ai_ext
7
+ except Exception as e:
8
+ print(f"[WARN] ai_ext import failed: {e}")
9
+
10
+ from fastapi.responses import HTMLResponse, JSONResponse, FileResponse, Response
11
+ from fastapi.staticfiles import StaticFiles
12
+ from starlette.routing import Mount
13
+ from fastapi import Query, Request
14
+ import requests as req
15
+ from bs4 import BeautifulSoup
16
+ import re, html as html_lib, json, threading, time
17
+ from concurrent.futures import ThreadPoolExecutor, as_completed
18
+ from urllib.parse import quote
19
+
20
+ HL_LEAGUES['friendly'] = {"path": "giai-khac/friendly", "name": "Giao hữu", "emoji": "🤝"}
21
+
22
+ STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
23
+ app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)=='/' and hasattr(r,'methods') and 'GET' in getattr(r,'methods',set()))]
24
+ app.routes[:]=[r for r in app.routes if not isinstance(r, Mount)]
25
+ app.router.routes=[r for r in app.router.routes if not isinstance(r, Mount)]
26
+
27
+ def _clean(s): return re.sub(r"\s+"," ",html_lib.unescape(str(s or""))).strip()
28
+
29
+ # Cache for match details (5 min TTL)
30
+ _match_cache = {}
31
+
32
+ # === FAST BONGDA PROXY ENDPOINT ===
33
+ def _get_match_detail(event_id, slug=None):
34
+ """Internal function to scrape match detail from bongda.com.vn"""
35
+ headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "text/html", "Referer": "https://bongda.com.vn/"}
36
+
37
+ if slug:
38
+ url = f"https://bongda.com.vn/tran-dau/{event_id}/centre/{slug}"
39
+ else:
40
+ url = f"https://bongda.com.vn/tran-dau/{event_id}"
41
+
42
+ resp = req.get(url, headers=headers, timeout=15, allow_redirects=True)
43
+ if resp.status_code != 200:
44
+ return None
45
+
46
+ soup = BeautifulSoup(resp.text, 'html.parser')
47
+ result = {"event_id": event_id, "found": False, "sections": []}
48
+ info = {}
49
+
50
+ tel = soup.select_one('.teams')
51
+ if tel:
52
+ he = tel.select_one('.team.home')
53
+ if he:
54
+ p_tags = [p for p in he.select('p') if not p.get('class') or 'logo' not in p.get('class', [])]
55
+ if p_tags: info['home_team'] = _clean(p_tags[0].get_text())
56
+ lo = he.select_one('img')
57
+ if lo: info['home_logo'] = lo.get('src', '')
58
+ ae = tel.select_one('.team.away')
59
+ if ae:
60
+ p_tags = ae.select('p')
61
+ team_ps = [p for p in p_tags if not p.get('class') or 'logo' not in p.get('class', [])]
62
+ if team_ps: info['away_team'] = _clean(team_ps[-1].get_text())
63
+ lo = ae.select_one('img')
64
+ if lo: info['away_logo'] = lo.get('src', '')
65
+ sc = tel.select_one('.score')
66
+ if sc:
67
+ parts = [_clean(p.get_text()) for p in sc.select('p')]
68
+ if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}"
69
+ lb = sc.select_one('.label')
70
+ if lb: info['status_label'] = _clean(lb.get_text())
71
+
72
+ if info.get('home_team') and info.get('away_team'):
73
+ result['info'] = info
74
+ result['found'] = True
75
+ result['sections'].append('info')
76
+
77
+ events = []
78
+ for ev in soup.select('.events .period .event'):
79
+ ev_cls = ' '.join(ev.get('class', []))
80
+ ev_data = {'team': 'home' if 'home' in ev_cls else 'away', 'period': '', 'type': 'unknown', 'time': '', 'players': ''}
81
+
82
+ parent = ev.parent
83
+ if parent:
84
+ h2 = parent.find('h2')
85
+ if h2: ev_data['period'] = _clean(h2.get_text())
86
+
87
+ if ev.select_one('[class*="goal"]'): ev_data['type'] = 'goal'
88
+ elif ev.select_one('[class*="redcard"]'): ev_data['type'] = 'redcard'
89
+ elif ev.select_one('[class*="yellowcard"]'): ev_data['type'] = 'yellowcard'
90
+ elif ev.select_one('[class*="substitution"]'): ev_data['type'] = 'substitution'
91
+
92
+ players_el = ev.select_one('.players')
93
+ if players_el:
94
+ pl_text = _clean(players_el.get_text(' ', strip=True))
95
+ m = re.match(r"(\d+)'(.*)", pl_text)
96
+ if m:
97
+ ev_data['time'] = f"{m.group(1)}'"
98
+ ev_data['players'] = m.group(2)
99
+ else:
100
+ ev_data['players'] = pl_text
101
+ events.append(ev_data)
102
+
103
+ if events:
104
+ result['events'] = events
105
+ result['sections'].append('events')
106
+
107
+ pred = soup.select_one('.prediction-card')
108
+ if pred:
109
+ team_info = pred.select_one('.team-info')
110
+ if team_info:
111
+ teams = team_info.select('.team')
112
+ pred_data = {}
113
+ if len(teams) >= 2:
114
+ pred_data['home_name'] = _clean(teams[0].select_one('.team-name').get_text()) if teams[0].select_one('.team-name') else ''
115
+ pred_data['away_name'] = _clean(teams[1].select_one('.team-name').get_text()) if teams[1].select_one('.team-name') else ''
116
+ divider = team_info.select_one('.divider')
117
+ if divider: pred_data['result'] = _clean(divider.get_text())
118
+ vc = pred.select_one('.vote-count')
119
+ if vc: pred_data['vote_count'] = _clean(vc.get_text())
120
+ result['prediction'] = pred_data
121
+
122
+ recent = []
123
+ ml = soup.select_one('.matches-list')
124
+ if ml:
125
+ for item in ml.select('.match-detail, .match-item, li'):
126
+ de = item.select_one('.date, .time')
127
+ le = item.select_one('.league')
128
+ he_item = item.select_one('.home, .team-home')
129
+ ae_item = item.select_one('.away, .team-away')
130
+ se = item.select_one('.score, .result')
131
+ if he_item or ae_item:
132
+ recent.append({'date': _clean(de.get_text()) if de else '', 'league': _clean(le.get_text()) if le else '', 'home': _clean(he_item.get_text()) if he_item else '', 'away': _clean(ae_item.get_text()) if ae_item else '', 'score': _clean(se.get_text()) if se else 'vs'})
133
+ if recent:
134
+ result['recent_matches'] = recent
135
+ result['sections'].append('recent')
136
+
137
+ try:
138
+ api_h = {"User-Agent": "Mozilla/5.0", "Accept": "application/json", "X-Requested-With": "XMLHttpRequest", "Referer": "https://bongda.com.vn/"}
139
+ ar = req.get(f"https://bongda.com.vn/api/fixtures/h2h-stats?event_id={event_id}", headers=api_h, timeout=10)
140
+ if ar.status_code == 200:
141
+ ad = ar.json()
142
+ if ad.get('status') == 'success' and ad.get('html'):
143
+ asp = BeautifulSoup(ad['html'], 'html.parser')
144
+ ast = {}
145
+ for row in asp.select('li, tr'):
146
+ cells = row.select('td, span, p')
147
+ if len(cells) >= 3:
148
+ lb = _clean(cells[0].get_text())
149
+ if lb: ast[lb] = {'home': _clean(cells[1].get_text()), 'away': _clean(cells[2].get_text())}
150
+ if ast:
151
+ result['h2h_stats_parsed'] = ast
152
+ result['sections'].append('h2h_stats')
153
+ except: pass
154
+
155
+ return result
156
+
157
+ @app.get('/api/proxy/bongda')
158
+ def proxy_bongda(event_id: int = Query(default=None), slug: str = Query(default=None)):
159
+ if event_id is None:
160
+ return JSONResponse({'error': 'event_id required'}, status_code=400)
161
+
162
+ cache_key = f"{event_id}_{slug}"
163
+ now = time.time()
164
+ cached = _match_cache.get(cache_key)
165
+ if cached and now - cached.get('_ts', 0) < 300:
166
+ return JSONResponse(cached)
167
+
168
+ try:
169
+ result = _get_match_detail(event_id, slug)
170
+ if result:
171
+ result['_ts'] = now
172
+ _match_cache[cache_key] = result
173
+ return JSONResponse(result)
174
+ except Exception as e:
175
+ err = {"event_id": event_id, "found": False, "error": str(e), "_ts": now}
176
+ _match_cache[cache_key] = err
177
+ return JSONResponse(err)
178
+
179
+ return JSONResponse({"event_id": event_id, "found": False})
180
+
181
+ @app.get('/api/match/{event_id}/detail')
182
+ def api_match_detail(event_id: int, url: str = Query(default=None)):
183
+ # Try to extract slug from url if provided
184
+ slug = None
185
+ if url:
186
+ m = re.match(r'.+/tran-dau/\d+/(?:centre|preview)/(.+)', url)
187
+ if m:
188
+ slug = m.group(1)
189
+
190
+ cache_key = f"{event_id}_{slug or ''}"
191
+ now = time.time()
192
+ cached = _match_cache.get(cache_key)
193
+ if cached and now - cached.get('_ts', 0) < 300:
194
+ return JSONResponse(cached)
195
+
196
+ try:
197
+ # If no slug, try to find it from homepage
198
+ if not slug:
199
+ try:
200
+ home_r = req.get("https://bongda.com.vn/", headers={"User-Agent": "Mozilla/5.0"}, timeout=10)
201
+ if home_r.status_code == 200:
202
+ home_soup = BeautifulSoup(home_r.text, 'html.parser')
203
+ for a in home_soup.select(f'a[href*="/tran-dau/{event_id}/"]'):
204
+ href = a.get('href', '')
205
+ m = re.match(r'/tran-dau/\d+/(?:centre|preview)/(.+)', href)
206
+ if m:
207
+ slug = m.group(1)
208
+ cache_key = f"{event_id}_{slug}"
209
+ break
210
+ except: pass
211
+
212
+ result = _get_match_detail(event_id, slug)
213
+ if result:
214
+ result['_ts'] = now
215
+ _match_cache[cache_key] = result
216
+ return JSONResponse(result)
217
+ except Exception as e:
218
+ err = {"event_id": event_id, "found": False, "error": str(e), "_ts": now}
219
+ _match_cache[cache_key] = err
220
+ return JSONResponse(err)
221
+
222
+ return JSONResponse({"event_id": event_id, "found": False})
223
+
224
+ # === Rest of endpoints (existing) ===
225
+ _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 việt nam hôm nay mới nhất nóng tin tức cập nhật theo từ đến là có thì này đã để'.split())
226
+
227
+ def _has_kw(topic,title):
228
+ tl=topic.lower();tt=(title or'').lower()
229
+ if tl in tt:return True
230
+ words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',tl) if len(w)>2 and w not in _STOP]
231
+ if not words:return True
232
+ return any(w in tt for w in words)
233
+
234
+ def _s_vnexpress(topic,limit=8):
235
+ items=[]
236
+ try:
237
+ r=req.get(f"https://timkiem.vnexpress.net/?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
238
+ for art in soup.select('article.item-news')[:limit]:
239
+ a=art.select_one('h2 a, h3 a')
240
+ if a and a.get('href'):
241
+ t=_clean(a.get('title','') or a.get_text(strip=True))
242
+ if _has_kw(topic,t):items.append({'title':t,'url':a['href'],'via':'VnExpress'})
243
+ except:pass
244
+ return items
245
+
246
+ def _s_dantri(topic,limit=8):
247
+ items=[]
248
+ try:
249
+ r=req.get(f"https://dantri.com.vn/tim-kiem/{quote(topic)}.htm",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
250
+ for a in soup.select('h3 a[href], .article-title a[href]')[:limit*2]:
251
+ t=_clean(a.get_text(strip=True));href=a.get('href','')
252
+ if t and len(t)>15 and _has_kw(topic,t):
253
+ if not href.startswith('http'):href='https://dantri.com.vn'+href
254
+ items.append({'title':t,'url':href,'via':'Dân Trí'})
255
+ if len(items)>=limit:break
256
+ except:pass
257
+ return items
258
+
259
+ def _s_vietnamnet(topic,limit=6):
260
+ items=[]
261
+ try:
262
+ r=req.get(f"https://vietnamnet.vn/tim-kiem?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
263
+ for a in soup.select('h3 a[href], .vnn-title a')[:limit*2]:
264
+ t=_clean(a.get_text(strip=True));href=a.get('href','')
265
+ if t and len(t)>15 and _has_kw(topic,t):
266
+ if not href.startswith('http'):href='https://vietnamnet.vn'+href
267
+ items.append({'title':t,'url':href,'via':'VietNamNet'})
268
+ if len(items)>=limit:break
269
+ except:pass
270
+ return items
271
+
272
+ def _s_bongda(topic,limit=5):
273
+ items=[]
274
+ try:
275
+ r=req.get(f"https://bongda.com.vn/tim-kiem.html?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=8);soup=BeautifulSoup(r.text,'lxml')
276
+ for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
277
+ t=_clean(a.get_text(strip=True));href=a.get('href','')
278
+ if t and len(t)>15 and _has_kw(topic,t):
279
+ if not href.startswith('http'):href='https://bongda.com.vn'+href
280
+ items.append({'title':t,'url':href,'via':'Bóng Đá'})
281
+ if len(items)>=limit:break
282
+ except:pass
283
+ return items
284
+
285
+ def _s_genk(topic,limit=5):
286
+ items=[]
287
+ try:
288
+ r=req.get(f"https://genk.vn/tim-kiem?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=8);soup=BeautifulSoup(r.text,'lxml')
289
+ for a in soup.select('a[href$=".chn"]')[:limit*3]:
290
+ t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
291
+ if t and len(t)>15 and _has_kw(topic,t):
292
+ if href.startswith('/'):href='https://genk.vn'+href
293
+ items.append({'title':t,'url':href,'via':'GenK'})
294
+ if len(items)>=limit:break
295
+ except:pass
296
+ return items
297
+
298
+ def _s_thanhnien(topic,limit=6):
299
+ items=[]
300
+ try:
301
+ r=req.get(f"https://thanhnien.vn/tim-kiem?q={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
302
+ for a in soup.select('h3 a[href], .box-title a')[:limit*2]:
303
+ t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
304
+ if t and len(t)>15 and _has_kw(topic,t):
305
+ if not href.startswith('http'):href='https://thanhnien.vn'+href
306
+ items.append({'title':t,'url':href,'via':'Thanh Niên'})
307
+ if len(items)>=limit:break
308
+ except:pass
309
+ return items
310
+
311
+ def _s_tuoitre(topic,limit=6):
312
+ items=[]
313
+ try:
314
+ r=req.get(f"https://tuoitre.vn/tim-kiem.htm?keywords={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=10);soup=BeautifulSoup(r.text,'lxml')
315
+ for a in soup.select('h3 a[href], .box-title-text a')[:limit*2]:
316
+ t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
317
+ if t and len(t)>15 and _has_kw(topic,t):
318
+ if not href.startswith('http'):href='https://tuoitre.vn'+href
319
+ items.append({'title':t,'url':href,'via':'Tuổi Trẻ'})
320
+ if len(items)>=limit:break
321
+ except:pass
322
+ return items
323
+
324
+ def _s_thethaovanhoa(topic,limit=5):
325
+ items=[]
326
+ try:
327
+ r=req.get(f"https://thethaovanhoa.vn/tim-kiem.htm?keyword={quote(topic)}",headers={'User-Agent':'Mozilla/5.0'},timeout=8);soup=BeautifulSoup(r.text,'lxml')
328
+ for a in soup.select('h3 a[href], .title a[href]')[:limit*2]:
329
+ t=_clean(a.get('title','') or a.get_text(strip=True));href=a.get('href','')
330
+ if t and len(t)>15 and _has_kw(topic,t):
331
+ if not href.startswith('http'):href='https://thethaovanhoa.vn'+href
332
+ items.append({'title':t,'url':href,'via':'TT&VH'})
333
+ if len(items)>=limit:break
334
+ except:pass
335
+ return items
336
+
337
+ def _search_all(topic,limit=36):
338
+ results={}
339
+ with ThreadPoolExecutor(8) as ex:
340
+ futs={ex.submit(_s_vnexpress,topic,8):'vne',ex.submit(_s_dantri,topic,8):'dt',ex.submit(_s_vietnamnet,topic,6):'vnn',ex.submit(_s_bongda,topic,5):'bd',ex.submit(_s_genk,topic,5):'gk',ex.submit(_s_thanhnien,topic,6):'tn',ex.submit(_s_tuoitre,topic,6):'tt',ex.submit(_s_thethaovanhoa,topic,5):'tvh'}
341
+ for f in as_completed(futs,timeout=14):
342
+ try:results[futs[f]]=f.result()
343
+ except:results[futs[f]]=[]
344
+ srcs=list(results.values());out=[];seen=set()
345
+ for i in range(max((len(s) for s in srcs),default=0)):
346
+ for s in srcs:
347
+ if i<len(s) and s[i].get('url') and s[i]['url'] not in seen:seen.add(s[i]['url']);out.append(s[i])
348
+ return out[:limit]
349
+
350
+ app.router.routes=[r for r in app.router.routes if not(getattr(r,'path',None)=='/api/article' and 'GET' in getattr(r,'methods',set()))]
351
+
352
+ def _scrape_generic(url):
353
+ try:
354
+ r=req.get(url,headers={'User-Agent':'Mozilla/5.0','Accept-Language':'vi-VN,vi;q=0.9'},timeout=15,allow_redirects=True);r.encoding='utf-8';soup=BeautifulSoup(r.text,'lxml')
355
+ for tag in soup.find_all(['script','style','nav','footer','aside','form','noscript']):tag.decompose()
356
+ h1=soup.find('h1');ogt=soup.find('meta',property='og:title');title=(h1.get_text(strip=True) if h1 else '')or(ogt.get('content','') if ogt else '')
357
+ ogd=soup.find('meta',property='og:description');summary=ogd.get('content','') if ogd else ''
358
+ ogi=soup.find('meta',property='og:image');og_img=ogi.get('content','') if ogi else ''
359
+ if og_img and og_img.startswith('//'):og_img='https:'+og_img
360
+ block=None
361
+ for sel in['article','.singular-content','.detail-content','.fck_detail','.content-detail','.knc-content','main','.cms-body','.article__body']:
362
+ el=soup.select_one(sel)
363
+ if el and len(el.find_all('p'))>=2:block=el;break
364
+ if not block:block=soup.body or soup
365
+ body=[]
366
+ for el in block.find_all(['p','h2','h3','figure','img'],recursive=True):
367
+ if el.name=='p':t=el.get_text(strip=True);(body.append({'type':'p','text':t}) if t and len(t)>30 else None)
368
+ elif el.name in('h2','h3'):t=el.get_text(strip=True);(body.append({'type':'heading','text':t}) if t else None)
369
+ elif el.name in('figure','img'):
370
+ im=el if el.name=='img' else el.find('img')
371
+ if im:src=im.get('data-src') or im.get('src') or'';(body.append({'type':'img','src':'https:'+src if src.startswith('//') else src}) if src and'base64' not in src else None)
372
+ if not body and summary:body=[{'type':'p','text':summary}]
373
+ return{'title':_clean(title),'summary':_clean(summary),'og_image':og_img,'body':body[:50],'source':'generic','url':url}
374
+ except:return None
375
+
376
+ @app.get('/api/article')
377
+ def api_article_v2(url:str=Query(...)):
378
+ from main import scrape_vne_article,scrape_bbc_article,scrape_dantri_article,scrape_genk_article,scrape_ttvh_article
379
+ if 'vnexpress.net' in url:data=scrape_vne_article(url)
380
+ elif 'bbc.com' in url:data=scrape_bbc_article(url)
381
+ elif 'dantri.com.vn' in url:data=scrape_dantri_article(url)
382
+ elif 'genk.vn' in url:data=scrape_genk_article(url)
383
+ elif 'thethaovanhoa.vn' in url:data=scrape_ttvh_article(url)
384
+ else:data=_scrape_generic(url)
385
+ if data and data.get('body'):return JSONResponse(data)
386
+ return JSONResponse(data if data else{'error':'Không đọc được','url':url})
387
+
388
+ _hot_cache={'t':0,'d':[]}
389
+ def _get_hot_topics():
390
+ now=time.time()
391
+ if _hot_cache['d'] and now-_hot_cache['t']<600:return _hot_cache['d']
392
+ freq={};display={}
393
+ feeds=['https://vnexpress.net/rss/tin-moi-nhat.rss','https://dantri.com.vn/rss/home.rss','https://vietnamnet.vn/rss/tin-moi-nhat.rss','https://thanhnien.vn/rss/home.rss','https://tuoitre.vn/rss/tin-moi-nhat.rss','https://genk.vn/rss','https://vnexpress.net/rss/the-thao.rss','https://thethaovanhoa.vn/rss/tin-nong.rss']
394
+ for feed_url in feeds:
395
+ try:
396
+ r=req.get(feed_url,headers={'User-Agent':'Mozilla/5.0'},timeout=6);r.encoding='utf-8';soup=BeautifulSoup(r.text,'xml')
397
+ for item in soup.find_all('item')[:12]:
398
+ title=_clean(item.find('title').get_text() if item.find('title') else '')
399
+ if not title:continue
400
+ title=re.sub(r'\s*[-|].*$','',title);words=[w for w in re.findall(r'[A-Za-zÀ-ỹ0-9]+',title) if len(w)>2 and w.lower() not in _STOP]
401
+ if len(words)<2:continue
402
+ for n in(3,4,2):
403
+ for i in range(max(0,len(words)-n+1)):
404
+ phrase=' '.join(words[i:i+n])
405
+ if 8<=len(phrase)<=45:key=phrase.lower();freq[key]=freq.get(key,0)+1;display[key]=phrase
406
+ except:continue
407
+ ranked=sorted(freq.items(),key=lambda x:x[1],reverse=True);topics=[];seen=set()
408
+ for key,count in ranked:
409
+ 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)
410
+ if is_dup:continue
411
+ seen.add(key);topics.append({'label':'#'+re.sub(r'\s+','',display[key].title()),'topic':display[key],'count':count})
412
+ if len(topics)>=20:break
413
+ 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']:
414
+ if len(topics)>=24:break
415
+ if not any(kw.lower() in s for s in seen):topics.append({'label':'#'+re.sub(r'\s+','',kw.title()),'topic':kw,'count':0})
416
+ _hot_cache.update({'t':now,'d':topics[:24]});return topics[:24]
417
+
418
+ @app.get('/api/hot_topics')
419
+ def api_hot_topics():return JSONResponse({'topics':_get_hot_topics()})
420
+ @app.get('/')
421
+ async def serve_index():
422
+ p=os.path.join(STATIC_DIR,'index_v3.html')
423
+ if os.path.exists(p):return FileResponse(p,media_type='text/html')
424
+ p2=os.path.join(STATIC_DIR,'index_v2.html')
425
+ if os.path.exists(p2):return FileResponse(p2,media_type='text/html')
426
+ return HTMLResponse('<h1>VNEWS</h1>')
427
+ @app.get('/api/hashtag/sources')
428
+ def _ht(topic:str=Query(...),page:int=Query(default=0)):
429
+ items=_search_all(topic,36);per_page=8;start=page*per_page;end=start+per_page
430
+ return JSONResponse({'sources':items[start:end],'topic':topic,'page':page,'has_more':end<len(items),'total':len(items)})
431
+ @app.get('/api/categories')
432
+ def _cat():return JSONResponse([])
433
+ @app.get('/api/storage_status')
434
+ def _st():return JSONResponse({'persistent':os.path.isdir('/data') and os.access('/data',os.W_OK)})
435
+ @app.get('/s')
436
+ async def _sh(url:str='',title:str='',img:str=''):return HTMLResponse(f'<!DOCTYPE html><html><head><meta property="og:title" content="{_clean(title)}"><meta property="og:image" content="{_clean(img)}"><meta http-equiv="refresh" content="0;url={_clean(url) or "/"}"></head><body></body></html>')
437
+
438
+ from wc2026_scraper import(scrape_summary,scrape_fixtures,scrape_standings,scrape_stats,scrape_wc_news,scrape_road_to_wc,get_wc2026_all,scrape_history,scrape_h2h,scrape_lineups,scrape_match_detail)
439
+ @app.get('/api/wc2026')
440
+ def _w():return JSONResponse(get_wc2026_all())
441
+ @app.get('/api/wc2026/fixtures')
442
+ def _wf():return JSONResponse(scrape_fixtures())
443
+ @app.get('/api/wc2026/standings')
444
+ def _ws():return JSONResponse(scrape_standings())
445
+ @app.get('/api/wc2026/stats')
446
+ def _wst():return JSONResponse(scrape_stats())
447
+ @app.get('/api/wc2026/history')
448
+ def _whi():return JSONResponse(scrape_history())
449
+ @app.get('/api/wc2026/news')
450
+ def _wn():return JSONResponse(scrape_wc_news())
451
+ @app.get('/api/wc2026/road')
452
+ def _wr():return JSONResponse(scrape_road_to_wc())
453
+ @app.get('/api/wc2026/h2h/{eid}')
454
+ def _wh2(eid:int):return JSONResponse(scrape_h2h(eid))
455
+ @app.get('/api/wc2026/lineups/{eid}')
456
+ def _wl(eid:int):return JSONResponse(scrape_lineups(eid))
457
+ @app.get('/api/wc2026/match/{eid}')
458
+ def _wm(eid:int):return JSONResponse(scrape_match_detail(eid))
459
+
460
+ DATA_DIR='/data' if os.path.isdir('/data') else os.path.join(os.path.dirname(os.path.abspath(__file__)),'data')
461
+ os.makedirs(DATA_DIR,exist_ok=True);IF=os.path.join(DATA_DIR,'interactions_v2.json');CF=os.path.join(DATA_DIR,'comments_v2.json')
462
+ _il=threading.Lock();_cl=threading.Lock()
463
+ def _lj(p):
464
+ try:
465
+ if os.path.exists(p):return json.load(open(p,'r',encoding='utf-8'))
466
+ except:pass
467
+ return{}
468
+ def _sj(p,d):
469
+ try:open(p+'.tmp','w',encoding='utf-8').write(json.dumps(d,ensure_ascii=False));os.replace(p+'.tmp',p)
470
+ except:pass
471
+
472
+ @app.post('/api/v2/interact')
473
+ async def _int(request:Request):
474
+ b=await request.json();v=str(b.get('id','')).strip();t=str(b.get('type','')).strip()
475
+ if not v or t not in('view','like'):return JSONResponse({'error':'x'},status_code=400)
476
+ with _il:db=_lj(IF);db.setdefault(v,{'views':0,'likes':0,'comments':0});db[v][t+'s']+=1;_sj(IF,db);return JSONResponse(db[v])
477
+
478
+ @app.get('/api/v2/interactions')
479
+ def _gi(id:str=Query(...)):
480
+ with _il:return JSONResponse(_lj(IF).get(id.strip(),{'views':0,'likes':0,'comments':0}))
481
+
482
+ @app.get('/api/v2/comments')
483
+ def _gc(id:str=Query(...)):
484
+ with _cl:return JSONResponse({'comments':_lj(CF).get(id.strip(),[])})
485
+
486
+ @app.post('/api/v2/comment')
487
+ async def _pc(request:Request):
488
+ b=await request.json();v=str(b.get('id','')).strip();tx=str(b.get('text','')).strip()[:500]
489
+ if not v or not tx:return JSONResponse({'error':'x'},status_code=400)
490
+ c={'text':tx,'time':time.strftime('%H:%M %d/%m',time.localtime()),'ts':int(time.time())}
491
+ with _cl:db=_lj(CF);db.setdefault(v,[]);db[v].append(c);db[v]=db[v][-200:];_sj(CF,db);cms=db[v]
492
+ with _il:idb=_lj(IF);idb.setdefault(v,{'views':0,'likes':0,'comments':0});idb[v]['comments']=len(cms);_sj(IF,idb)
493
+ return JSONResponse({'comments':cms})
494
+
495
+ # ============================================================
496
+ # TEAM STATS — scrape bongda.com.vn /doi-bong/ page, render inline
497
+ # ============================================================
498
+ def _scrape_team_page(team_path):
499
+ """Scrape team stats from bongda.com.vn team page."""
500
+ url = f"https://bongda.com.vn/doi-bong/{team_path}"
501
+ headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "text/html", "Referer": "https://bongda.com.vn/"}
502
+ try:
503
+ r = req.get(url, headers=headers, timeout=15, allow_redirects=True)
504
+ if r.status_code != 200:
505
+ return None
506
+ soup = BeautifulSoup(r.text, 'html.parser')
507
+ except:
508
+ return None
509
+
510
+ team = {"name": "", "logo": "", "league": "", "country": "", "stats": {}, "recent": [], "squad": [], "found": False}
511
+
512
+ h1 = soup.select_one('h1')
513
+ if h1:
514
+ team['name'] = _clean(h1.get_text())
515
+ team['found'] = True
516
+
517
+ logo_el = soup.select_one('.team-logo img, .club-logo img, img.team-logo, .team-header img')
518
+ if logo_el:
519
+ team['logo'] = logo_el.get('src', '')
520
+
521
+ info_el = soup.select_one('.team-info, .club-info, .team-header')
522
+ if info_el:
523
+ txt = info_el.get_text()
524
+ if 'việt nam' in txt.lower() or 'vietnam' in txt.lower():
525
+ team['country'] = 'Việt Nam'
526
+
527
+ for li in soup.select('li.match-detail, .match-list .match-item, .recent-matches li'):
528
+ home_el = li.select_one('.home-team .name, .team-home')
529
+ away_el = li.select_one('.away-team .name, .team-away')
530
+ if not home_el or not away_el:
531
+ continue
532
+ home = _clean(home_el.get_text())
533
+ away = _clean(away_el.get_text())
534
+ status_el = li.select_one('.status a, .score')
535
+ score = ""
536
+ event_id = ""
537
+ match_url = ""
538
+ if status_el:
539
+ href = status_el.get('href', '')
540
+ if href:
541
+ match_url = 'https://bongda.com.vn' + href if href.startswith('/') else href
542
+ m = re.search(r'/tran-dau/(\d+)/', href)
543
+ if m:
544
+ event_id = m.group(1)
545
+ spans = status_el.find_all('span')
546
+ if len(spans) >= 3:
547
+ score = f"{spans[0].get_text(strip=True)} - {spans[2].get_text(strip=True)}"
548
+ elif status_el.select_one('.vs'):
549
+ score = 'VS'
550
+ time_el = li.select_one('.match-time, .time, .date')
551
+ mt = _clean(time_el.get_text()) if time_el else ''
552
+ team['recent'].append({
553
+ 'home': home, 'away': away, 'score': score, 'time': mt,
554
+ 'event_id': event_id, 'url': match_url
555
+ })
556
+ if len(team['recent']) >= 10:
557
+ break
558
+
559
+ standings_rows = []
560
+ for tr in soup.select('.standings-table tr, .league-table tr, table tbody tr'):
561
+ cells = tr.select('td')
562
+ if len(cells) >= 5:
563
+ pos = _clean(cells[0].get_text())
564
+ tname = _clean(cells[1].get_text()) if len(cells) > 1 else ''
565
+ played = _clean(cells[2].get_text()) if len(cells) > 2 else ''
566
+ gd = _clean(cells[3].get_text()) if len(cells) > 3 else ''
567
+ pts = _clean(cells[4].get_text()) if len(cells) > 4 else ''
568
+ if pos and tname and pos.isdigit():
569
+ standings_rows.append({'pos': pos, 'team': tname, 'played': played, 'gd': gd, 'pts': pts})
570
+ if standings_rows:
571
+ team['standings'] = standings_rows
572
+
573
+ for p in soup.select('.player-item, .squad-item, .player-card'):
574
+ pname_el = p.select_one('.player-name, .name, h3, h4')
575
+ pname = _clean(pname_el.get_text()) if pname_el else ''
576
+ if not pname:
577
+ continue
578
+ ppos_el = p.select_one('.position, .pos, .player-pos')
579
+ ppos = _clean(ppos_el.get_text()) if ppos_el else ''
580
+ team['squad'].append({'name': pname, 'position': ppos})
581
+
582
+ breadcrumb = soup.select_one('.breadcrumb, .breadcrumbs')
583
+ if breadcrumb:
584
+ for a in breadcrumb.select('a'):
585
+ lt = _clean(a.get_text())
586
+ if lt and lt.lower() != 'trang chủ':
587
+ team['league'] = lt
588
+ break
589
+
590
+ return team
591
+
592
+
593
+ @app.get("/api/team/{path:path}")
594
+ async def api_team_stats(path: str):
595
+ data = _scrape_team_page(path)
596
+ if data and data.get('found'):
597
+ return JSONResponse(data)
598
+ return JSONResponse({"found": False, "error": "Không tìm thấy đội bóng"})
599
+
600
+
601
+ def _render_team_page(path: str):
602
+ """Render full HTML team stats page."""
603
+ data = _scrape_team_page(path)
604
+ if not data or not data.get('found'):
605
+ from fastapi.responses import RedirectResponse
606
+ return RedirectResponse(f"https://bongda.com.vn/doi-bong/{path}", status_code=302)
607
+
608
+ team = data
609
+ name = _clean(team.get('name') or 'Đội bóng')
610
+ logo = _clean(team.get('logo') or '')
611
+ league = _clean(team.get('league') or '')
612
+ country = _clean(team.get('country') or '')
613
+
614
+ recent_html = ''
615
+ if team.get('recent'):
616
+ for m in team['recent'][:10]:
617
+ eid = _clean(m.get('event_id', ''))
618
+ murl = _clean(m.get('url', ''))
619
+ sc = _clean(m.get('score', 'VS'))
620
+ mt = _clean(m.get('time', ''))
621
+ home = _clean(m.get('home', ''))
622
+ away = _clean(m.get('away', ''))
623
+ onclick = f'onclick="openMatch(\'{eid}\',\'{murl}\')"' if eid else ''
624
+ recent_html += f'<div class="tm-match" {onclick} style="cursor:pointer">'
625
+ recent_html += f'<span class="tm-match-time">{mt}</span>'
626
+ recent_html += f'<span class="tm-match-teams">{home} <span class="tm-score">{sc}</span> {away}</span>'
627
+ recent_html += '</div>'
628
+
629
+ standings_html = ''
630
+ if team.get('standings'):
631
+ standings_html = '<table class="tm-standings"><thead><tr><th>#</th><th>Đội</th><th>Trận</th><th>HS</th><th>Điểm</th></tr></thead><tbody>'
632
+ for row in team['standings']:
633
+ pos = _clean(row.get('pos', ''))
634
+ tname = _clean(row.get('team', ''))
635
+ played = _clean(row.get('played', ''))
636
+ gd = _clean(row.get('gd', ''))
637
+ pts = _clean(row.get('pts', ''))
638
+ highlight = ' class="tm-highlight"' if name and tname and name.lower()[:6] in tname.lower()[:6] else ''
639
+ standings_html += f'<tr{highlight}><td>{pos}</td><td>{tname}</td><td>{played}</td><td>{gd}</td><td>{pts}</td></tr>'
640
+ standings_html += '</tbody></table>'
641
+
642
+ squad_html = ''
643
+ if team.get('squad'):
644
+ for p in team['squad'][:30]:
645
+ pnm = _clean(p.get('name', ''))
646
+ pps = _clean(p.get('position', ''))
647
+ if not pnm:
648
+ continue
649
+ squad_html += f'<div class="tm-player"><span class="tm-player-name">{pnm}</span><span class="tm-player-pos">{pps}</span></div>'
650
+
651
+ logo_html = f'<img src="{logo}" class="tm-logo" alt="{name}" onerror="this.style.display=\'none\'">' if logo else ''
652
+
653
+ sections_html = ''
654
+ sections_html += '<div class="tm-section"><div class="tm-section-title">📋 Trận đấu gần nhất</div>'
655
+ sections_html += recent_html or '<div class="tm-nodata">Không có dữ liệu trận đấu</div>'
656
+ sections_html += '</div>'
657
+
658
+ if standings_html:
659
+ sections_html += '<div class="tm-section"><div class="tm-section-title">🏆 Bảng xếp hạng</div>' + standings_html + '</div>'
660
+
661
+ if squad_html:
662
+ sections_html += '<div class="tm-section"><div class="tm-section-title">👥 Đội hình</div><div>' + squad_html + '</div></div>'
663
+
664
+ html = f'''<!DOCTYPE html>
665
+ <html lang="vi">
666
+ <head>
667
+ <meta charset="utf-8">
668
+ <meta name="viewport" content="width=device-width,initial-scale=1">
669
+ <title>⚽ {name} — Thống kê | VNEWS</title>
670
+ <meta property="og:title" content="⚽ {name} — Thống kê | VNEWS">
671
+ <style>
672
+ *{{margin:0;padding:0;box-sizing:border-box}}body{{background:#0d1117;color:#e0e0e0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif}}
673
+ .tm-header{{background:linear-gradient(135deg,#1a2a1f,#0d1117);border-bottom:2px solid #2d8659;padding:16px;text-align:center}}
674
+ .tm-top{{display:flex;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap}}
675
+ .tm-logo{{width:64px;height:64px;object-fit:contain;border-radius:50%;border:3px solid #2d8659;background:#111}}
676
+ .tm-name{{font-size:24px;font-weight:900;color:#fff}}
677
+ .tm-badge{{font-size:12px;color:#888;margin-top:4px}}
678
+ .tm-badge span{{background:#1a2a1f;color:#5cb87a;padding:2px 8px;border-radius:4px;margin:0 2px}}
679
+ .tm-nav{{background:#161b22;padding:8px 16px;display:flex;gap:8px;align-items:center;border-bottom:1px solid #21262d}}
680
+ .tm-nav a{{color:#5cb87a;text-decoration:none;font-size:13px}}
681
+ .tm-nav a:hover{{text-decoration:underline}}
682
+ .tm-container{{max-width:900px;margin:0 auto;padding:16px}}
683
+ .tm-section{{margin-bottom:20px}}
684
+ .tm-section-title{{font-size:14px;font-weight:800;color:#5cb87a;margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid #21262d}}
685
+ .tm-match{{display:flex;align-items:center;gap:10px;padding:10px 12px;background:#161b22;border-radius:6px;margin-bottom:4px;font-size:13px;border:1px solid #21262d;transition:background .2s}}
686
+ .tm-match:hover{{background:#1c2128;border-color:#2d8659}}
687
+ .tm-match-time{{font-size:11px;color:#888;min-width:60px}}
688
+ .tm-match-teams{{flex:1;color:#e0e0e0}}
689
+ .tm-score{{color:#f0c040;font-weight:800}}
690
+ .tm-standings{{width:100%;border-collapse:collapse;font-size:13px}}
691
+ .tm-standings th{{background:#1a2a1f;color:#5cb87a;padding:8px;text-align:left;font-size:11px;border-bottom:1px solid #2d8659}}
692
+ .tm-standings td{{padding:8px;border-bottom:1px solid #21262d}}
693
+ .tm-standings tr:hover td{{background:#161b22}}
694
+ .tm-highlight td{{background:#1a2a1f;border-left:3px solid #5cb87a}}
695
+ .tm-player{{display:inline-flex;align-items:center;gap:6px;background:#161b22;padding:5px 10px;border-radius:5px;margin:3px;font-size:12px;border:1px solid #21262d}}
696
+ .tm-player-name{{color:#e0e0e0}}
697
+ .tm-player-pos{{color:#888;font-size:10px}}
698
+ .tm-back{{display:inline-block;margin-bottom:12px;color:#5cb87a;text-decoration:none;font-size:14px}}
699
+ .tm-back:hover{{text-decoration:underline}}
700
+ .tm-nodata{{text-align:center;color:#666;padding:20px;font-size:13px}}
701
+ </style>
702
+ </head>
703
+ <body>
704
+ <div class="tm-header"><div class="tm-top">{logo_html}<div><div class="tm-name">⚽ {name}</div><div class="tm-badge">{league and f"<span>🏆 {league}</span>"}{country and f"<span>📍 {country}</span>"}<span>📡 VNEWS</span></div></div></div></div>
705
+ <div class="tm-nav"><a href="/" class="tm-back">← Quay về VNEWS</a> <a href="https://bongda.com.vn/doi-bong/{path}" target="_blank">Xem trên Bongda.com.vn ↗</a></div>
706
+ <div class="tm-container">{sections_html}</div>
707
+ <script>function openMatch(id,url){{if(!id)return;window.location.href='/?match='+id+(url?'&url='+encodeURIComponent(url):'');}}</script>
708
+ </body></html>'''
709
+ return HTMLResponse(html)
710
+
711
+
712
+ @app.get("/doi-bong/{path:path}")
713
+ async def page_team_stats(path: str):
714
+ return _render_team_page(path)
715
+
716
+
717
+ @app.get("/giai-dau/{path:path}")
718
+ async def page_league_internal(path: str):
719
+ from fastapi.responses import RedirectResponse
720
+ return RedirectResponse(f"https://bongda.com.vn/giai-dau/{path}", status_code=302)
721
+
722
+
723
+ @app.get("/cau-thu/{path:path}")
724
+ async def redirect_cau_thu(path: str):
725
+ from fastapi.responses import RedirectResponse
726
+ return RedirectResponse(f"https://bongda.com.vn/cau-thu/{path}", status_code=302)
727
+
728
+ def _bg():
729
+ time.sleep(15)
730
+ while True:
731
+ try:get_wc2026_all()
732
+ except:pass
733
+ time.sleep(90)
734
+ threading.Thread(target=_bg,daemon=True).start()
735
+
736
+ app.mount('/static',StaticFiles(directory=STATIC_DIR),name='vnews_static')
app_v2_entry_test.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ VNEWS App v2 - Main application with match detail API
3
+ """
4
+ import os, json, re, time, asyncio, hashlib, logging, threading, importlib, sys
5
+ from datetime import datetime, timezone, timedelta
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import httpx
10
+ import requests
11
+ from fastapi import FastAPI, HTTPException, Query
12
+ from fastapi.responses import JSONResponse, FileResponse, HTMLResponse
13
+ from fastapi.staticfiles import StaticFiles
14
+ from fastapi.templating import Jinja2Templates
15
+
16
+ # ... (rest of app_v2_entry.py content)
app_v2_entry_v2.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ VNEWS App v2 - Main application with match detail API
3
+ """
4
+ import os, json, re, time, asyncio, hashlib, logging, threading, importlib
5
+ from datetime import datetime, timezone, timedelta
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import httpx
10
+ import requests
11
+ from fastapi import FastAPI, HTTPException, Query
12
+ from fastapi.responses import JSONResponse, FileResponse, HTMLResponse
13
+ from fastapi.staticfiles import StaticFiles
14
+ from fastapi.templating import Jinja2Templates
15
+
16
+ # ... (rest of app_v2_entry.py content)
bongda_proxy.py ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """VNEWS — Bongda Proxy Endpoint (for fast match detail loading)"""
2
+ import requests
3
+ from bs4 import BeautifulSoup
4
+ import re
5
+ import json
6
+
7
+ # Import-safe parsing functions
8
+ def _cl(s):
9
+ return re.sub(r'\s+', ' ', str(s or '')).strip()
10
+
11
+ def _normalize_time(raw):
12
+ t = _cl(raw)
13
+ t = re.sub(r"(\d+)'\s*\+(\d+)", r"\1+\2'", t)
14
+ t = t.replace("''", "'")
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
38
+ try:
39
+ resp = requests.get(u, headers=headers, timeout=15, allow_redirects=True)
40
+ if resp.status_code == 200 and len(resp.text) > 1000:
41
+ html = resp.text
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))
main.py ADDED
@@ -0,0 +1,966 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
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
326
+ soup=BeautifulSoup(html,"lxml");all_matches=[]
327
+ for li in soup.select("li.match-detail"):
328
+ match=_parse_match_from_li(li, stype)
329
+ if not match or not match["event_id"]:continue
330
+ if stype=="today" and "KT" in match.get("minute",""):continue
331
+ all_matches.append(match)
332
+ if not all_matches:continue
333
+ for pl in PRIORITY_LEAGUES:
334
+ for match in all_matches:
335
+ if pl in match["league"]:return match
336
+ return all_matches[0]
337
+ # Fallback: scrape bongda.com.vn homepage HTML directly
338
+ try:
339
+ r = requests.get("https://bongda.com.vn/", headers={"User-Agent": "Mozilla/5.0"}, timeout=10)
340
+ if r.status_code == 200:
341
+ soup = BeautifulSoup(r.text, "lxml")
342
+ for li in soup.select("li.match-detail"):
343
+ match = _parse_match_from_li(li, "today")
344
+ if match and match.get("event_id"):
345
+ return match
346
+ except:
347
+ pass
348
+ return None
349
+ result = _f()
350
+ if result:
351
+ return JSONResponse(result)
352
+ return JSONResponse({"home": "", "away": "", "score": "VS", "league": "", "event_id": "", "url": "", "status": "no_match"})
353
+
354
+ # ===== VIDEO APIs =====
355
+ @app.get("/api/shorts")
356
+ def api_shorts():return JSONResponse(_cached("yt_shorts_v3",scrape_shorts,ttl=_cache_ttl_yt))
357
+ @app.get("/api/short-stats")
358
+ def api_short_stats(ids:str=Query(default="")):
359
+ arr=[x for x in ids.split(",") if x]
360
+ with _short_lock:
361
+ db=_load_short_db();out={}
362
+ for vid in arr:
363
+ st=db.get(vid) or _short_default()
364
+ 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]}
365
+ return JSONResponse({"stats":out})
366
+
367
+ @app.post("/api/short-action")
368
+ async def api_short_action(request:Request):
369
+ try:body=await request.json()
370
+ except:body={}
371
+ vid=str(body.get("id","")).strip();action=str(body.get("action","")).strip();txt=str(body.get("text","")).strip()
372
+ if not vid:return JSONResponse({"error":"missing id"},status_code=400)
373
+ with _short_lock:
374
+ db=_load_short_db();st=db.get(vid) or _short_default()
375
+ if action=="view":st["views"]=int(st.get("views",0))+1
376
+ elif action=="like":st["likes"]=int(st.get("likes",0))+1
377
+ elif action=="share":st["shares"]=int(st.get("shares",0))+1
378
+ elif action=="comment" and txt:
379
+ comments=st.get("comments",[])
380
+ comments.insert(0,{"text":txt[:180],"ts":int(time.time())})
381
+ st["comments"]=comments[:80]
382
+ st["updated"]=int(time.time());db[vid]=st;_save_short_db(db)
383
+ out={"views":int(st.get("views",0)),"likes":int(st.get("likes",0)),"shares":int(st.get("shares",0)),"comments":st.get("comments",[])[:80]}
384
+ return JSONResponse({"stats":out})
385
+
386
+ @app.get("/api/highlights")
387
+ def api_highlights():return JSONResponse(_cached("xemlaibongda_hl",scrape_xemlaibongda,ttl=_cache_ttl))
388
+ @app.get("/api/highlights/leagues")
389
+ def api_highlights_leagues():return JSONResponse(_cached("hl_leagues",scrape_all_league_highlights,ttl=_cache_ttl))
390
+ @app.get("/api/highlights/{league}")
391
+ def api_highlights_league(league:str):
392
+ if league not in HL_LEAGUES:return JSONResponse({"error":"league not found"})
393
+ return JSONResponse(_cached(f"hl_{league}",lambda:scrape_highlights_by_league(league),ttl=_cache_ttl))
394
+ @app.get("/api/highlights_config")
395
+ def api_highlights_config():return JSONResponse(HL_LEAGUES)
396
+ @app.get("/api/video_url")
397
+ def api_video_url(url:str=Query(...)):
398
+ if "youtube.com" in url or "youtu.be" in url:
399
+ m=re.search(r'(?:v=|shorts/|youtu\.be/)([a-zA-Z0-9_-]{11})',url)
400
+ 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"})
401
+ if "xemlaibongda.top" in url:
402
+ v=extract_xemlaibongda_video(url)
403
+ if v:
404
+ if v["type"]=="hls":v["src"]="/api/proxy/m3u8?url="+quote(v["src"],safe="")
405
+ return JSONResponse(v)
406
+ if "bongdaplus.vn" in url:
407
+ try:
408
+ m=re.search(r'-(\d{6,})\.html',url)
409
+ if m:
410
+ r=requests.get(f"{BASE_BDP}/video-embed/{m.group(1)}.html",headers=HEADERS,timeout=10);r.encoding="utf-8"
411
+ soup=BeautifulSoup(r.text,"lxml");video=soup.select_one("video#videoPlayer")
412
+ if video:
413
+ source=video.find("source");src=source.get("src","") if source else "";poster=video.get("poster","")
414
+ if src:return JSONResponse({"src":"/api/proxy/video?url="+quote(src,safe=""),"poster":poster,"type":"video"})
415
+ except:pass
416
+ return JSONResponse({"error":"not found"})
417
+ @app.get("/api/bdp_videos")
418
+ def api_bdp_videos():
419
+ def _f():
420
+ try:
421
+ soup=_get(f"{BASE_BDP}/video");arts=[];seen=set()
422
+ for a in soup.find_all("a",href=True):
423
+ href=a.get("href","")
424
+ if"/video/" not in href or href in("/video/","/video/ban-thang-dep","/video/highlight"):continue
425
+ if not href.startswith("http"):href=BASE_BDP+href
426
+ if href in seen:continue
427
+ title=re.sub(r'^\d{2}:\d{2}','',a.get_text(strip=True)).strip()
428
+ if not title or len(title)<5:continue
429
+ img_tag=a.find("img") or(a.parent.find("img") if a.parent else None)
430
+ img=(img_tag.get("data-src") or img_tag.get("src","")) if img_tag else ""
431
+ seen.add(href);arts.append({"title":title,"link":href,"img":img,"source":"bdp"})
432
+ return arts[:20]
433
+ except:return[]
434
+ return JSONResponse(_cached("bdp_videos",_f))
435
+ # ===== NEWS =====
436
+ def scrape_vne(cat_url):
437
+ try:
438
+ soup=_get(cat_url);arts=[]
439
+ for it in soup.select("article.item-news")[:15]:
440
+ a=it.select_one("h2.title-news a") or it.select_one("h3.title-news a")
441
+ if not a:continue
442
+ t=a.get("title","") or a.get_text(strip=True);lk=a.get("href","")
443
+ if not t or not lk:continue
444
+ im=it.find("img");img=(im.get("data-src") or im.get("src","")) if im else ""
445
+ if img and'blank'in img:
446
+ src=it.find("source")
447
+ if src:img=src.get("srcset","").split(",")[0].strip().split(" ")[0]
448
+ arts.append({"title":t,"link":lk,"img":img,"source":"vne"})
449
+ return arts
450
+ except:return[]
451
+ def scrape_vne_article(url):
452
+ try:
453
+ soup=_get(url);h1=soup.select_one("h1.title-detail");desc=soup.select_one("p.description")
454
+ og=soup.find("meta",property="og:image");og_img=og.get("content","") if og else ""
455
+ cd=soup.select_one("article.fck_detail");body=[]
456
+ if cd:
457
+ for ch in cd.children:
458
+ if not hasattr(ch,'name') or not ch.name:continue
459
+ if ch.name=="p":t=ch.get_text(strip=True);(body.append({"type":"p","text":t}) if t else None)
460
+ elif ch.name=="figure":
461
+ im=ch.find("img")
462
+ if im:s=im.get("data-src") or im.get("src","");body.append({"type":"img","src":s})
463
+ elif ch.name in("h2","h3"):body.append({"type":"heading","text":ch.get_text(strip=True)})
464
+ 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}
465
+ except:return None
466
+ def _scrape_dantri_homepage(cat_filter=None):
467
+ try:
468
+ soup=_get("https://dantri.com.vn/");arts=[];seen=set()
469
+ for a in soup.find_all("a",href=True):
470
+ href=a.get("href","");title=a.get("title","") or a.get_text(strip=True)
471
+ if not title or len(title)<15 or"javascript:" in href:continue
472
+ if not href.startswith("http"):href="https://dantri.com.vn"+href
473
+ if href in seen or not href.endswith(".htm"):continue
474
+ if cat_filter and f"/{cat_filter}/" not in href:continue
475
+ img_tag=a.find("img")
476
+ if not img_tag and a.parent:img_tag=a.parent.find("img")
477
+ img_src=""
478
+ if img_tag:img_src=img_tag.get("data-src","") or img_tag.get("src","")
479
+ if not img_src or "cdn" not in img_src:continue
480
+ proxied_img="/api/proxy/img?url="+quote(img_src,safe="")
481
+ seen.add(href);arts.append({"title":title,"link":href,"img":proxied_img,"source":"dantri"})
482
+ if len(arts)>=15:break
483
+ return arts
484
+ except:return[]
485
+ def scrape_dantri_hot():return _scrape_dantri_homepage()
486
+ def scrape_dantri_congnghe():
487
+ try:
488
+ soup=_get("https://dantri.com.vn/");arts=[];seen=set()
489
+ for a in soup.find_all("a",href=True):
490
+ href=a.get("href","");title=a.get("title","") or a.get_text(strip=True)
491
+ if not title or len(title)<15 or"javascript:" in href:continue
492
+ if not href.startswith("http"):href="https://dantri.com.vn"+href
493
+ if href in seen or not href.endswith(".htm"):continue
494
+ if"/cong-nghe/" not in href:continue
495
+ img_tag=a.find("img")
496
+ if not img_tag and a.parent:img_tag=a.parent.find("img")
497
+ img_src=""
498
+ if img_tag:img_src=img_tag.get("data-src","") or img_tag.get("src","")
499
+ if img_src and "cdn" in img_src:img_src="/api/proxy/img?url="+quote(img_src,safe="")
500
+ else:img_src=""
501
+ seen.add(href);arts.append({"title":title,"link":href,"img":img_src,"source":"dantri"})
502
+ if len(arts)>=15:break
503
+ return arts
504
+ except:return[]
505
+ def scrape_genk_ai():
506
+ """Scrape AI articles from genk.vn - readable in-app"""
507
+ try:
508
+ r=requests.get("https://genk.vn/ai.chn",headers=HEADERS,timeout=15)
509
+ if r.status_code!=200:return[]
510
+ r.encoding="utf-8";soup=BeautifulSoup(r.text,"lxml")
511
+ articles=[];seen=set()
512
+ for a in soup.find_all("a",href=True):
513
+ href=a.get("href","")
514
+ if not href.endswith(".chn") or href=="/ai.chn":continue
515
+ if href.startswith("/"):href="https://genk.vn"+href
516
+ if href in seen or "genk.vn" not in href:continue
517
+ title=a.get("title","") or a.get_text(strip=True)
518
+ if not title or len(title)<20:continue
519
+ container=a.parent;img_src=""
520
+ for _ in range(6):
521
+ if container is None:break
522
+ for img in container.find_all("img"):
523
+ s=img.get("data-src","") or img.get("src","")
524
+ if s and "mediacdn" in s and "avatar" not in s and "logo" not in s:
525
+ img_src=s;break
526
+ if img_src:break
527
+ container=container.parent
528
+ seen.add(href)
529
+ if not img_src:
530
+ try:
531
+ og_r=requests.get(href,headers=HEADERS,timeout=8);og_r.encoding="utf-8"
532
+ og_soup=BeautifulSoup(og_r.text,"lxml");og_tag=og_soup.find("meta",property="og:image")
533
+ if og_tag:img_src=og_tag.get("content","")
534
+ except:pass
535
+ articles.append({"title":title,"link":href,"img":img_src,"source":"genk"})
536
+ if len(articles)>=30:break
537
+ return articles
538
+ except:return[]
539
+
540
+ def scrape_dantri_article(url):
541
+ try:
542
+ r=requests.get(url,headers=HEADERS,timeout=15);r.encoding="utf-8";soup=BeautifulSoup(r.text,"lxml")
543
+ for tag in soup.find_all(["script","style","nav","footer","aside"]):tag.decompose()
544
+ h1=soup.find("h1");og=soup.find("meta",property="og:image");og_img=og.get("content","") if og else ""
545
+ if og_img and "cdnphoto.dantri" in og_img:og_img="/api/proxy/img?url="+quote(og_img,safe="")
546
+ content=soup.select_one("main") or soup.select_one("div.singular-content") or soup.select_one("article");body=[]
547
+ if content:
548
+ for el in content.find_all(["p","h2","h3","figure","img"],recursive=True):
549
+ if el.name=="p":t=el.get_text(strip=True);(body.append({"type":"p","text":t}) if t and len(t)>15 else None)
550
+ elif el.name in("h2","h3"):t=el.get_text(strip=True);(body.append({"type":"heading","text":t}) if t else None)
551
+ elif el.name in("figure","img"):
552
+ im=el if el.name=="img" else el.find("img")
553
+ if im:
554
+ s=im.get("data-src") or im.get("src","")
555
+ if s and"base64" not in s:
556
+ if "cdnphoto.dantri" in s:s="/api/proxy/img?url="+quote(s,safe="")
557
+ body.append({"type":"img","src":s})
558
+ desc="";sapo=soup.select_one("h2.singular-sapo") or soup.select_one("h2[class*=sapo]")
559
+ if not sapo:
560
+ og_desc=soup.find("meta",property="og:description")
561
+ if og_desc:desc=og_desc.get("content","")
562
+ else:desc=sapo.get_text(strip=True)
563
+ return{"title":h1.get_text(strip=True) if h1 else "","summary":desc,"og_image":og_img,"body":body,"source":"dantri","url":url}
564
+ except:return None
565
+ def scrape_bbc_vietnamese():
566
+ try:
567
+ r=requests.get("https://www.bbc.com/vietnamese",headers={"User-Agent":"Mozilla/5.0","Accept-Language":"en-GB"},timeout=15);r.encoding="utf-8"
568
+ soup=BeautifulSoup(r.text,"lxml");arts=[];seen=set()
569
+ for a in soup.select("a[href*='/vietnamese/']"):
570
+ href=a.get("href","")
571
+ if not href or href=="/vietnamese" or href.count("/")<3:continue
572
+ if not href.startswith("http"):href="https://www.bbc.com"+href
573
+ if href in seen:continue
574
+ title=a.get_text(strip=True)
575
+ if not title or len(title)<15 or any(x in title.lower() for x in["đăng nhập","trang chủ","bbc news"]):continue
576
+ img="";container=a.parent
577
+ for _ in range(3):
578
+ if container:
579
+ im=container.find("img")
580
+ if im:img=im.get("src","") or im.get("data-src","");break
581
+ container=container.parent
582
+ seen.add(href);arts.append({"title":title,"link":href,"img":img,"source":"bbc"})
583
+ if len(arts)>=15:break
584
+ return arts
585
+ except:return[]
586
+ def scrape_bbc_article(url):
587
+ try:
588
+ r=requests.get(url,headers={"User-Agent":"Mozilla/5.0","Accept-Language":"en-GB"},timeout=15);r.encoding="utf-8"
589
+ soup=BeautifulSoup(r.text,"lxml");h1=soup.find("h1")
590
+ og=soup.find("meta",property="og:image");og_img=og.get("content","") if og else ""
591
+ body=[]
592
+ for p in soup.select("[data-component='text-block'] p, article p, main p"):
593
+ t=p.get_text(strip=True)
594
+ if t and len(t)>20:body.append({"type":"p","text":t})
595
+ return{"title":h1.get_text(strip=True) if h1 else "","summary":"","og_image":og_img,"body":body,"source":"bbc","url":url}
596
+ except:return None
597
+
598
+ def scrape_ttvh_worldcup():
599
+ """Scrape all World Cup 2026 articles from The Thao Van Hoa RSS."""
600
+ try:
601
+ r=requests.get("https://thethaovanhoa.vn/rss/world-cup-2026.rss",headers=HEADERS,timeout=15);r.encoding="utf-8"
602
+ soup=BeautifulSoup(r.text,"xml");arts=[];seen=set()
603
+ for it in soup.find_all("item"):
604
+ title=(it.find("title").get_text(strip=True) if it.find("title") else "")
605
+ link=(it.find("link").get_text(strip=True) if it.find("link") else "")
606
+ desc=(it.find("description").get_text(" ",strip=True) if it.find("description") else "")
607
+ img="";ds=BeautifulSoup(desc,"lxml");im=ds.find("img")
608
+ if im:img=im.get("src","") or im.get("data-src","")
609
+ if title and link and link not in seen:
610
+ seen.add(link);arts.append({"title":title,"link":link,"img":img,"source":"ttvh"})
611
+ if arts:return arts
612
+ except:pass
613
+ try:
614
+ soup=_get("https://thethaovanhoa.vn/world-cup-2026.htm");arts=[];seen=set()
615
+ for a in soup.find_all("a",href=True):
616
+ href=a.get("href","")
617
+ if not href.startswith("http"):href="https://thethaovanhoa.vn"+href
618
+ if href in seen or "thethaovanhoa.vn" not in href:continue
619
+ if not re.search(r"/[^/]+-\d{8,}\.htm",href):continue
620
+ title=a.get("title","") or a.get_text(" ",strip=True)
621
+ img=None;p=a
622
+ for _ in range(5):
623
+ if p is None:break
624
+ img=p.find("img")
625
+ if img:break
626
+ p=p.parent
627
+ img_src=""
628
+ if img:
629
+ img_src=img.get("data-src","") or img.get("src","") or img.get("data-original","") or img.get("data-thumb","")
630
+ if len(title)<15:title=img.get("alt","") or img.get("title","") or title
631
+ if not title or len(title)<15:continue
632
+ seen.add(href);arts.append({"title":title,"link":href,"img":img_src,"source":"ttvh"})
633
+ if len(arts)>=24:break
634
+ return arts
635
+ except:return[]
636
+
637
+ def scrape_ttvh_article(url):
638
+ try:
639
+ soup=_get(url);h1=soup.find("h1");og=soup.find("meta",property="og:image");og_img=og.get("content","") if og else ""
640
+ og_title=soup.find("meta",property="og:title");fallback_title=og_title.get("content","") if og_title else ""
641
+ desc_el=soup.find("meta",property="og:description");desc=desc_el.get("content","") if desc_el else ""
642
+ cd=soup.select_one(".detail-content") or soup.select_one(".content-detail") or soup.select_one("article") or soup.select_one("main")
643
+ body=[]
644
+ if cd:
645
+ for el in cd.find_all(["p","h2","h3","figure","img"],recursive=True):
646
+ if el.name=="p":
647
+ t=el.get_text(strip=True)
648
+ if t and len(t)>20 and "Theo dõi" not in t:body.append({"type":"p","text":t})
649
+ elif el.name in ("h2","h3"):
650
+ t=el.get_text(strip=True)
651
+ if t:body.append({"type":"heading","text":t})
652
+ elif el.name in ("figure","img"):
653
+ im=el if el.name=="img" else el.find("img")
654
+ if im:
655
+ src=im.get("data-src") or im.get("src","") or im.get("data-original","")
656
+ if src and "base64" not in src:body.append({"type":"img","src":src})
657
+ if not body and desc:body=[{"type":"p","text":desc}]
658
+ return {"title":h1.get_text(strip=True) if h1 else fallback_title,"summary":desc,"og_image":og_img,"body":body,"source":"ttvh","url":url}
659
+ except:return None
660
+
661
+ 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")}
662
+ @app.get("/api/homepage")
663
+ def api_homepage():
664
+ def _f():
665
+ articles=[]
666
+ with ThreadPoolExecutor(12) as ex:
667
+ 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"]}
668
+ futs[ex.submit(scrape_bbc_vietnamese)]="BBC"
669
+ for f in as_completed(futs):
670
+ try:
671
+ for a in f.result():a["group"]=futs[f];articles.append(a)
672
+ except:pass
673
+ return articles
674
+ return JSONResponse(_cached("homepage",_f))
675
+ @app.get("/api/category/{cat_id}")
676
+ def api_category(cat_id:str):
677
+ def _f():
678
+ if cat_id=="bbc":return scrape_bbc_vietnamese()
679
+ if cat_id=="cong-nghe":return scrape_genk_ai()
680
+ 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
681
+ return[]
682
+ return JSONResponse(_cached(f"cat_{cat_id}",_f))
683
+ @app.get("/api/categories")
684
+ def api_categories():
685
+ cats=[{"id":"bbc","name":"BBC Tiếng Việt","source":"bbc"},{"id":"cong-nghe","name":"Công Nghệ","source":"genk"}]
686
+ for k,(u,n) in VNE_CATS.items():cats.append({"id":k,"name":n,"source":"vne"})
687
+ return JSONResponse(cats)
688
+ @app.get("/api/dantri_hot")
689
+ def api_dantri_hot():return JSONResponse(_cached("dantri_hot",scrape_dantri_hot))
690
+ @app.get("/api/genk_ai")
691
+ def api_genk_ai():return JSONResponse(_cached("genk_ai",scrape_genk_ai,ttl=_cache_ttl))
692
+ @app.get("/api/worldcup2026")
693
+ def api_worldcup2026():return JSONResponse(_cached("ttvh_worldcup",scrape_ttvh_worldcup,ttl=_cache_ttl))
694
+ def scrape_genk_article(url):
695
+ try:
696
+ r=requests.get(url,headers=HEADERS,timeout=15);r.encoding="utf-8";soup=BeautifulSoup(r.text,"lxml")
697
+ h1=soup.find("h1");og=soup.find("meta",property="og:image");og_img=og.get("content","") if og else ""
698
+ og_title=soup.find("meta",property="og:title");fallback_title=og_title.get("content","") if og_title else ""
699
+ desc_el=soup.find("meta",property="og:description");desc=desc_el.get("content","") if desc_el else ""
700
+ cd=soup.select_one(".knc-content");body=[]
701
+ if cd:
702
+ for el in cd.find_all(["p","h2","h3","figure","img"],recursive=True):
703
+ if el.name=="p":t=el.get_text(strip=True);(body.append({"type":"p","text":t}) if t and len(t)>15 else None)
704
+ elif el.name in("h2","h3"):t=el.get_text(strip=True);(body.append({"type":"heading","text":t}) if t else None)
705
+ elif el.name in("figure","img"):
706
+ im=el if el.name=="img" else el.find("img")
707
+ 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)
708
+ return{"title":h1.get_text(strip=True) if h1 else "","summary":desc,"og_image":og_img,"body":body,"source":"genk","url":url}
709
+ except:return None
710
+
711
+ @app.get("/api/article")
712
+ def api_article(url:str=Query(...)):
713
+ if"vnexpress.net" in url:data=scrape_vne_article(url)
714
+ elif"bbc.com" in url:data=scrape_bbc_article(url)
715
+ elif"dantri.com.vn" in url:data=scrape_dantri_article(url)
716
+ elif"genk.vn" in url:data=scrape_genk_article(url)
717
+ elif"thethaovanhoa.vn" in url:data=scrape_ttvh_article(url)
718
+ else:data=None
719
+ return JSONResponse(data if data else{"error":"not supported"})
720
+ def _web_context(topic):
721
+ """Collect real web/news context for a topic."""
722
+ bits=[]
723
+ try:
724
+ rss="https://news.google.com/rss/search?q="+quote(topic)+"&hl=vi&gl=VN&ceid=VN:vi"
725
+ r=requests.get(rss,headers=HEADERS,timeout=12);r.encoding="utf-8"
726
+ soup=BeautifulSoup(r.text,"xml")
727
+ for it in soup.find_all("item")[:8]:
728
+ title=it.find("title").get_text(" ",strip=True) if it.find("title") else ""
729
+ src=it.find("source").get_text(" ",strip=True) if it.find("source") else ""
730
+ if title:bits.append((title+(" — "+src if src else ""))[:280])
731
+ except:pass
732
+ if bits:return "\n".join(bits)
733
+ try:
734
+ r=requests.get("https://html.duckduckgo.com/html/?q="+quote(topic),headers=HEADERS,timeout=12);r.encoding="utf-8"
735
+ soup=BeautifulSoup(r.text,"lxml")
736
+ for res in soup.select(".result")[:6]:
737
+ t=res.select_one(".result__title");sn=res.select_one(".result__snippet")
738
+ line=((t.get_text(" ",strip=True) if t else "")+" — "+(sn.get_text(" ",strip=True) if sn else "")).strip(" —")
739
+ if line:bits.append(line[:280])
740
+ except:pass
741
+ return "\n".join(bits)
742
+
743
+ def _jina_read(url):
744
+ try:
745
+ ju="https://r.jina.ai/http://"+url
746
+ r=requests.get(ju,headers=HEADERS,timeout=25);r.encoding="utf-8"
747
+ if r.status_code!=200 or not r.text:return None
748
+ lines=[x.rstrip() for x in r.text.splitlines()]
749
+ title="";img="";body=[];summary=""
750
+ for ln in lines[:40]:
751
+ if ln.startswith("Title:"):title=ln.replace("Title:","",1).strip()
752
+ elif ln.startswith("Image:"):img=ln.replace("Image:","",1).strip()
753
+ elif ln.startswith("Description:"):summary=ln.replace("Description:","",1).strip()
754
+ for ln in lines:
755
+ t=ln.strip()
756
+ if not t or t.startswith(("Title:","URL Source:","Published Time:","Markdown Content:","Image:","Description:")):continue
757
+ if len(t)>40:body.append({"type":"p","text":t})
758
+ if not body and summary:body=[{"type":"p","text":summary}]
759
+ return {"title":title or url,"summary":summary,"og_image":img,"body":body[:80],"source":"jina","url":url}
760
+ except:return None
761
+
762
+ def _scrape_generic_article(url):
763
+ try:
764
+ hdr={**HEADERS,"Accept":"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"}
765
+ r=requests.get(url,headers=hdr,timeout=15);r.encoding="utf-8"
766
+ ct=r.headers.get("content-type","").lower()
767
+ if r.status_code>=400 or "text/html" not in ct:
768
+ jr=_jina_read(url)
769
+ if jr:return jr
770
+ soup=BeautifulSoup(r.text,"lxml")
771
+ for tag in soup.find_all(["script","style","nav","footer","aside","form"]):tag.decompose()
772
+ h1=soup.find("h1")
773
+ ogt=soup.find("meta",property="og:title");title=h1.get_text(strip=True) if h1 else (ogt.get("content","") if ogt else "")
774
+ ogd=soup.find("meta",property="og:description");desc=ogd.get("content","") if ogd else ""
775
+ ogi=soup.find("meta",property="og:image");img=ogi.get("content","") if ogi else ""
776
+ main=soup.find("article") or soup.find("main") or soup.body
777
+ body=[]
778
+ if main:
779
+ for el in main.find_all(["p","h2","h3","figure","img"],recursive=True):
780
+ if el.name=="p":
781
+ t=el.get_text(" ",strip=True)
782
+ if t and len(t)>35:body.append({"type":"p","text":t})
783
+ elif el.name in ("h2","h3"):
784
+ t=el.get_text(" ",strip=True)
785
+ if t:body.append({"type":"heading","text":t})
786
+ elif el.name in ("figure","img"):
787
+ im=el if el.name=="img" else el.find("img")
788
+ if im:
789
+ src=im.get("data-src") or im.get("src","") or im.get("data-original","")
790
+ if src and "base64" not in src:body.append({"type":"img","src":src})
791
+ if not body:
792
+ jr=_jina_read(url)
793
+ if jr and jr.get("body"):return jr
794
+ if not body and desc:body=[{"type":"p","text":desc}]
795
+ return {"title":title or url,"summary":desc,"og_image":img,"body":body,"source":"generic","url":url}
796
+ except:
797
+ return _jina_read(url)
798
+
799
+ def _article_by_url(url):
800
+ if "vnexpress.net" in url:return scrape_vne_article(url)
801
+ if "bbc.com" in url:return scrape_bbc_article(url)
802
+ if "dantri.com.vn" in url:return scrape_dantri_article(url)
803
+ if "genk.vn" in url:return scrape_genk_article(url)
804
+ if "thethaovanhoa.vn" in url:return scrape_ttvh_article(url)
805
+ return _scrape_generic_article(url)
806
+
807
+ def _call_qwen(prompt, max_tokens=1800):
808
+ """Try Qwen2.5-VL via HF router; return None if unavailable."""
809
+ try:
810
+ token=os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACEHUB_API_TOKEN") or os.environ.get("VAISTUDIO")
811
+ if not token:return None
812
+ headers={"Authorization":"Bearer "+token,"Content-Type":"application/json"}
813
+ payload={"model":"Qwen/Qwen2.5-VL-7B-Instruct","messages":[{"role":"user","content":prompt}],"max_tokens":max_tokens,"temperature":0.7}
814
+ r=requests.post("https://router.huggingface.co/v1/chat/completions",headers=headers,json=payload,timeout=75)
815
+ if r.status_code>=300:return None
816
+ j=r.json();return j.get("choices",[{}])[0].get("message",{}).get("content")
817
+ except:return None
818
+
819
+ def _collect_article_text(data, limit=28000):
820
+ title=(data or {}).get("title","");summary=(data or {}).get("summary","")
821
+ parts=[]
822
+ if summary:parts.append(summary)
823
+ for b in (data or {}).get("body",[]):
824
+ if b.get("type")=="heading":parts.append("## "+b.get("text","") )
825
+ elif b.get("type")=="p":parts.append(b.get("text","") )
826
+ text="\n".join([p.strip() for p in parts if p and p.strip()])
827
+ return title,text[:limit]
828
+
829
+ def _ai_rewrite_article(data,tone="tu-nhien"):
830
+ title,text=_collect_article_text(data)
831
+ 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. "
832
+ "Giữ đúng sự thật, không bịa, không thêm thông tin ngoài bài. Văn phong: "+tone+". "
833
+ "Đầ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"
834
+ "TIÊU ĐỀ GỐC: "+title+"\n\nNỘI DUNG GỐC:\n"+text)
835
+ out=_call_qwen(prompt,2200)
836
+ if out and len(out)>300:return out.strip()
837
+ # Fallback: complete non-truncated rewrite using full collected text chunks
838
+ paras=[p.strip() for p in text.split("\n") if len(p.strip())>30]
839
+ body="\n\n".join(paras[:18])
840
+ bullets="\n".join(["• "+p[:220]+("..." if len(p)>220 else "") for p in paras[:5]])
841
+ return ("Bản tin AI viết lại: "+title+"\n\n"+
842
+ (paras[0] if paras else "")+"\n\n"+body+"\n\nĐiểm chính:\n"+bullets).strip()
843
+
844
+ def _image_for_topic(topic):
845
+ return "https://image.pollinations.ai/prompt/"+quote("editorial illustration, Vietnamese news, "+topic,safe="")+"?width=1024&height=576&nologo=true"
846
+
847
+ def _topic_articles(topic,limit=5):
848
+ items=[];seen=set()
849
+ try:
850
+ rss="https://news.google.com/rss/search?q="+quote(topic)+"&hl=vi&gl=VN&ceid=VN:vi"
851
+ r=requests.get(rss,headers=HEADERS,timeout=12);r.encoding="utf-8"
852
+ soup=BeautifulSoup(r.text,"xml")
853
+ for it in soup.find_all("item")[:limit*3]:
854
+ title=it.find("title").get_text(" ",strip=True) if it.find("title") else ""
855
+ link=it.find("link").get_text(strip=True) if it.find("link") else ""
856
+ src=it.find("source").get_text(" ",strip=True) if it.find("source") else ""
857
+ if not title or not link or link in seen:continue
858
+ seen.add(link);items.append({"title":title,"link":link,"source":src})
859
+ if len(items)>=limit:break
860
+ except:pass
861
+ return items
862
+
863
+ def _topic_article_context(topic):
864
+ """Filter readable article sources by topic, then summarize actual article bodies."""
865
+ raw_keys=[k.lower() for k in re.findall(r"[\wÀ-ỹ]+",topic) if len(k)>2]
866
+ # Drop ultra-generic tokens; keep domain words such as giáo/dục, bóng/đá, world/cup.
867
+ stop={"trong","năm","the","and","của","cho","với","một","các","những","hiện","nay"}
868
+ keys=[k for k in raw_keys if k not in stop]
869
+ candidates=[];seen=set()
870
+ def add_items(items):
871
+ for a in items or []:
872
+ link=a.get("link","");title=a.get("title","")
873
+ if not link or link in seen:continue
874
+ seen.add(link);candidates.append(a)
875
+ try:add_items(scrape_genk_ai())
876
+ except:pass
877
+ try:add_items(scrape_dantri_congnghe())
878
+ except:pass
879
+ try:add_items(scrape_ttvh_worldcup())
880
+ except:pass
881
+ scored=[];img=""
882
+ for a in candidates[:40]:
883
+ data=_article_by_url(a.get("link",""))
884
+ if not data or not data.get("body"):continue
885
+ title=data.get("title") or a.get("title","")
886
+ ps=[b.get("text","") for b in data.get("body",[]) if b.get("type")=="p" and len(b.get("text",""))>40]
887
+ excerpt=" ".join(ps)[:1800] or data.get("summary","")
888
+ hay=(title+" "+excerpt).lower()
889
+ score=sum(1 for k in keys if k in hay)
890
+ # Require topic relevance when we have meaningful keys.
891
+ if keys and score==0:continue
892
+ 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
893
+ scored.append((score,title,a.get("link",""),excerpt,data.get("og_image") or a.get("img","") or ""))
894
+ scored=sorted(scored,key=lambda x:x[0],reverse=True)[:5]
895
+ chunks=[]
896
+ for score,title,link,excerpt,im in scored:
897
+ if not img and im:img=im
898
+ chunks.append("BÀI: "+title+"\nURL: "+link+"\nNỘI DUNG LỌC: "+excerpt)
899
+ if chunks:return "\n\n".join(chunks),img
900
+ return _web_context(topic),""
901
+
902
+ def _topic_post_text(topic):
903
+ ctx,img=_topic_article_context(topic)
904
+ 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+
905
+ ". 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. "
906
+ "Đầ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)
907
+ out=_call_qwen(prompt,1800)
908
+ if out and len(out)>300:return out.strip()
909
+ if ctx:
910
+ 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."
911
+ return "Chưa thu thập được dữ liệu đủ rõ cho chủ đề: "+topic
912
+
913
+ @app.get("/api/wall")
914
+ def api_wall():return JSONResponse({"posts":_load_wall()[:50]})
915
+
916
+ @app.post("/api/rewrite_share")
917
+ async def api_rewrite_share(request:Request):
918
+ try:body=await request.json()
919
+ except:body={}
920
+ url=str(body.get("url","")).strip();tone=str(body.get("tone","tu-nhien")).strip()
921
+ if not url:return JSONResponse({"error":"missing url"},status_code=400)
922
+ data=_article_by_url(url)
923
+ if not data or not data.get("title") or (not data.get("body") and not data.get("summary")):
924
+ return JSONResponse({"error":"Không đọc được bài viết"},status_code=422)
925
+ 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","")}
926
+ posts=_load_wall();posts.insert(0,post);_save_wall(posts)
927
+ return JSONResponse({"post":post})
928
+
929
+ @app.post("/api/topic_post")
930
+ async def api_topic_post(request:Request):
931
+ try:body=await request.json()
932
+ except:body={}
933
+ topic=str(body.get("topic","")).strip()
934
+ if not topic:return JSONResponse({"error":"missing topic"},status_code=400)
935
+ ctx_img=_topic_article_context(topic)[1]
936
+ 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"}
937
+ posts=_load_wall();posts.insert(0,post);_save_wall(posts)
938
+ return JSONResponse({"post":post})
939
+
940
+ @app.post("/api/url_wall")
941
+ async def api_url_wall(request:Request):
942
+ try:body=await request.json()
943
+ except:body={}
944
+ url=str(body.get("url","")).strip()
945
+ if not url:return JSONResponse({"error":"missing url"},status_code=400)
946
+ data=_article_by_url(url)
947
+ if not data or not data.get("title"):
948
+ return JSONResponse({"error":"Không đọc được URL"},status_code=422)
949
+ 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","")}
950
+ posts=_load_wall();posts.insert(0,post);_save_wall(posts)
951
+ return JSONResponse({"post":post})
952
+
953
+ @app.get("/v")
954
+ async def video_share(url:str=Query(default=""),title:str=Query(default="VNEWS Video"),img:str=Query(default=""),type:str=Query(default="highlights")):
955
+ decoded_url=unquote(url);decoded_title=unquote(title)
956
+ 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>'
957
+ 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>')
958
+ @app.get("/s")
959
+ async def share_redirect(url:str=Query(default=""),title:str=Query(default="VNEWS"),img:str=Query(default="")):
960
+ decoded_url=unquote(url)
961
+ redirect_script=f'<script>localStorage.setItem("pending_article","{decoded_url}");location.href="{SPACE_URL}";</script>' if decoded_url else f'<script>location.href="{SPACE_URL}";</script>'
962
+ return HTMLResponse(f'<!DOCTYPE html><html><head><meta charset="utf-8"><title>{unquote(title)}</title></head><body>{redirect_script}</body></html>')
963
+ @app.get("/")
964
+ async def index():
965
+ with open("/app/static/index.html","r",encoding="utf-8") as f:return HTMLResponse(content=f.read())
966
+ app.mount("/static",StaticFiles(directory="/app/static"),name="static")
main_patch.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # PATCH: Add these 2 lines to main.py right after "app = FastAPI()"
2
+ # Line 1: from vtv_api import router as vtv_router
3
+ # Line 2: app.include_router(vtv_router)
4
+ #
5
+ # This enables the VTV1-VTV10 + VTVPrime stream endpoints:
6
+ # GET /api/vtv/streams - Get all channel streams
7
+ # GET /api/vtv/stream/{id} - Get specific channel stream
8
+ # GET /api/proxy/page?url=... - Proxy web pages (for xemtv PHP scraping)
match_detail.py ADDED
@@ -0,0 +1,309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Match Detail Scraper for bongda.com.vn
3
+ """
4
+ import requests, re, json, time, threading
5
+ from bs4 import BeautifulSoup
6
+
7
+ def _sp(html):
8
+ try:
9
+ return BeautifulSoup(html, 'lxml')
10
+ except:
11
+ return BeautifulSoup(html, 'html.parser')
12
+
13
+ BH = {
14
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
15
+ "Accept": "application/json, text/javascript, */*; q=0.01",
16
+ "Referer": "https://bongda.com.vn/",
17
+ "X-Requested-With": "XMLHttpRequest",
18
+ }
19
+ HH = {
20
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
21
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
22
+ "Referer": "https://bongda.com.vn/",
23
+ }
24
+
25
+ def _cl(s):
26
+ return re.sub(r'\s+', ' ', str(s or '')).strip()
27
+
28
+ def _api(ep, params=None):
29
+ try:
30
+ url = f"https://bongda.com.vn{ep}"
31
+ if params:
32
+ url += "?" + "&".join(f"{k}={v}" for k, v in params.items())
33
+ r = requests.get(url, headers=BH, timeout=15)
34
+ if r.status_code == 200:
35
+ try: return r.json()
36
+ except: pass
37
+ except: pass
38
+ return None
39
+
40
+ def _get_teams(soup):
41
+ info = {}
42
+ tel = soup.select_one('.teams')
43
+ if not tel:
44
+ return info
45
+ he = tel.select_one('.team.home, .home-team')
46
+ if he:
47
+ ne = he.select_one('p:not(.logo)') or he.find('p')
48
+ if ne: info['home_team'] = _cl(ne.get_text())
49
+ lo = he.select_one('img')
50
+ if lo: info['home_logo'] = lo.get('src', '')
51
+ le = he if he.name == 'a' else he.find('a')
52
+ if le and le.get('href'):
53
+ m = re.search(r'/doi-bong/(\d+)/', le['href'])
54
+ if m: info['home_team_id'] = m.group(1)
55
+ ae = tel.select_one('.team.away, .away-team')
56
+ if ae:
57
+ ne = ae.select_one('p:not(.logo)') or ae.find('p')
58
+ if ne: info['away_team'] = _cl(ne.get_text())
59
+ lo = ae.select_one('img')
60
+ if lo: info['away_logo'] = lo.get('src', '')
61
+ le = ae if ae.name == 'a' else ae.find('a')
62
+ if le and le.get('href'):
63
+ m = re.search(r'/doi-bong/(\d+)/', le['href'])
64
+ if m: info['away_team_id'] = m.group(1)
65
+ sc = tel.select_one('.score')
66
+ if sc:
67
+ parts = [_cl(p.get_text()) for p in sc.select('p')]
68
+ if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}"
69
+ lb = sc.select_one('.label')
70
+ if lb: info['status_label'] = _cl(lb.get_text())
71
+ return info
72
+
73
+ def _get_timeline(soup):
74
+ tl = []
75
+ el = soup.select_one('.timeline')
76
+ if not el: return tl
77
+ half = ''
78
+ for c in el.children:
79
+ if not hasattr(c, 'name') or not c.name: continue
80
+ t = _cl(c.get_text())
81
+ if not t: continue
82
+ if t in ['H1','H2','Hiệp 1','Hiệp 2']:
83
+ half = t; continue
84
+ m = re.match(r"(\d+'\+?\d*)", t)
85
+ if m:
86
+ tl.append({'time': m.group(1), 'text': t[m.end():].strip(), 'half': half})
87
+ elif len(t) > 5:
88
+ tl.append({'time': '', 'text': t, 'half': half})
89
+ return tl
90
+
91
+ def _get_events(soup):
92
+ evts = []
93
+ for el in soup.select('.event'):
94
+ e = {}
95
+ cl = ' '.join(el.get('class', []))
96
+ e['team'] = 'home' if 'home' in cl else ('away' if 'away' in cl else '')
97
+ ps = [_cl(p.get_text()) for p in el.select('p')]
98
+ ps = [p for p in ps if p]
99
+ if ps: e['players'] = ps
100
+ tl = el.select_one('.time, .minute, span')
101
+ if tl: e['time'] = _cl(tl.get_text())
102
+ evts.append(e)
103
+ return evts
104
+
105
+ def _get_stats(soup):
106
+ st = {}
107
+ for sel in ['.match-stats','[class*="stats"]']:
108
+ el = soup.select_one(sel)
109
+ if el and len(str(el)) > 50:
110
+ for row in el.select('li,tr,.stat-row'):
111
+ cells = row.select('td,span,p')
112
+ if len(cells) >= 3:
113
+ lb = _cl(cells[0].get_text())
114
+ if lb: st[lb] = {'home': _cl(cells[1].get_text()), 'away': _cl(cells[2].get_text())}
115
+ if st: break
116
+ return st
117
+
118
+ def _get_h2h(soup):
119
+ h2h = {'matches': [], 'stats': {}}
120
+ for sel in ['.head-to-head','[class*="h2h"]']:
121
+ el = soup.select_one(sel)
122
+ if el and len(str(el)) > 50:
123
+ for it in el.select('li,tr,.match-item'):
124
+ m = {}
125
+ cells = it.select('td,span,p')
126
+ if len(cells) >= 3:
127
+ m['date'] = _cl(cells[0].get_text())
128
+ m['home'] = _cl(cells[1].get_text())
129
+ m['score'] = _cl(cells[2].get_text())
130
+ if m.get('home'):
131
+ if len(cells) > 3: m['away'] = _cl(cells[3].get_text())
132
+ h2h['matches'].append(m)
133
+ if h2h['matches']: break
134
+ return h2h
135
+
136
+ def _get_form(soup):
137
+ f = {'home': [], 'away': []}
138
+ for sel in ['.form-guide','[class*="form"]']:
139
+ el = soup.select_one(sel)
140
+ if el and len(str(el)) > 50:
141
+ items = el.select('li,.form-item,tr')
142
+ for it in items[:10]:
143
+ t = _cl(it.get_text())
144
+ if t: f['home'].append({'text': t})
145
+ for it in items[10:20]:
146
+ t = _cl(it.get_text())
147
+ if t: f['away'].append({'text': t})
148
+ break
149
+ return f
150
+
151
+ def _get_info(soup):
152
+ info = {}
153
+ mi = soup.select_one('.match-info')
154
+ if mi:
155
+ te = mi.select_one('.times,li')
156
+ if te: info['datetime'] = _cl(te.get_text())
157
+ le = soup.select_one('.league,.tournament,[class*="league"]')
158
+ if le: info['league'] = _cl(le.get_text())
159
+ return info
160
+
161
+ def _scrape(url):
162
+ print(f"[DEBUG] _scrape: {url[:80]}", flush=True)
163
+ try:
164
+ r = requests.get(url, headers=HH, timeout=15, allow_redirects=True)
165
+ print(f"[DEBUG] HTTP={r.status_code}", flush=True)
166
+ if r.status_code != 200:
167
+ return False, {}
168
+ sp = _sp(r.text)
169
+ d = {}
170
+
171
+ teams = _get_teams(sp)
172
+ print(f"[DEBUG] teams={teams}", flush=True)
173
+ if teams: d['info'] = teams
174
+
175
+ mi = _get_info(sp)
176
+ if mi:
177
+ d.setdefault('info', {}).update(mi)
178
+
179
+ tl = _get_timeline(sp)
180
+ if tl:
181
+ d['timeline'] = tl
182
+ d['commentaries_html'] = '\n'.join([f"{t.get('time','')} {t.get('text','')}" for t in tl])
183
+
184
+ ev = _get_events(sp)
185
+ if ev: d['events'] = ev
186
+
187
+ st = _get_stats(sp)
188
+ if st:
189
+ d['stats_parsed'] = st
190
+ d['stats_html'] = str(st)
191
+
192
+ h2h = _get_h2h(sp)
193
+ if h2h.get('matches'): d['h2h_matches'] = h2h['matches']
194
+ if h2h.get('stats'): d['h2h_stats'] = h2h['stats']
195
+
196
+ if '/preview/' in url:
197
+ fm = _get_form(sp)
198
+ if fm.get('home'): d['home_form'] = fm['home']
199
+ if fm.get('away'): d['away_form'] = fm['away']
200
+
201
+ print(f"[DEBUG] success keys={list(d.keys())}", flush=True)
202
+ return True, d
203
+ except Exception as e:
204
+ import traceback
205
+ print(f"[DEBUG] error: {e}", flush=True)
206
+ traceback.print_exc()
207
+ return False, {}
208
+
209
+ def fetch_match_detail_by_url(url):
210
+ m = re.search(r'/tran-dau/(\d+)/', url)
211
+ if not m: return {"error": "Could not extract event_id", "found": False}
212
+ event_id = int(m.group(1))
213
+ res = {"event_id": event_id, "found": False, "sections": []}
214
+ _fetch_api(event_id, res)
215
+ ok, d = _scrape(url)
216
+ print(f"[DEBUG] by_url: ok={ok} d_keys={list(d.keys())}", flush=True)
217
+ if ok: _merge(res, d)
218
+ return res
219
+
220
+ def fetch_match_detail(event_id):
221
+ print(f"[DEBUG] fetch_match_detail({event_id})", flush=True)
222
+ res = {"event_id": event_id, "found": False, "sections": []}
223
+ _fetch_api(event_id, res)
224
+
225
+ for pt in ["centre", "preview"]:
226
+ url = f"https://bongda.com.vn/tran-dau/{event_id}/{pt}/"
227
+ ok, d = _scrape(url)
228
+ print(f"[DEBUG] {pt}: ok={ok}", flush=True)
229
+ if ok:
230
+ _merge(res, d)
231
+ if res.get("found"): break
232
+
233
+ print(f"[DEBUG] final: found={res['found']} sections={res['sections']}", flush=True)
234
+ return res
235
+
236
+ def _fetch_api(eid, res):
237
+ pm = _api("/api/event-standing/pre-match", {"event_id": eid})
238
+ res["pre_match"] = pm
239
+ res["pre_match_html"] = pm.get("html","") if pm and pm.get("status")=="success" and len(pm.get("html","").strip())>10 else ""
240
+
241
+ hm = _api("/api/fixtures/h2h-match", {"event_id": eid})
242
+ res["h2h_match"] = hm
243
+ if hm and hm.get("status")=="success":
244
+ h = hm.get("html","")
245
+ if len(h.strip())>10:
246
+ res["h2h_html"] = h
247
+ res["sections"].append("h2h")
248
+ else: res["h2h_html"] = ""
249
+
250
+ hs = _api("/api/fixtures/h2h-stats", {"event_id": eid})
251
+ res["h2h_stats"] = hs
252
+ if hs and hs.get("status")=="success":
253
+ h = hs.get("html","")
254
+ if len(h.strip())>10:
255
+ res["h2h_stats_html"] = h
256
+ res["sections"].append("h2h_stats")
257
+ try:
258
+ sp = _sp(h)
259
+ stats = {}
260
+ for row in sp.select('li,tr,.stat-row'):
261
+ cells = row.select('td,span,p')
262
+ if len(cells)>=3:
263
+ lb = _cl(cells[0].get_text())
264
+ if lb: stats[lb] = {'home': _cl(cells[1].get_text()), 'away': _cl(cells[2].get_text())}
265
+ if stats: res["h2h_stats_parsed"] = stats
266
+ except: pass
267
+ else: res["h2h_stats_html"] = ""
268
+
269
+ pf = _api("/api/event-standing/player-performance", {"event_id": eid})
270
+ res["performance"] = pf
271
+ if pf and pf.get("status")=="success" and len(pf.get("html","").strip())>10:
272
+ res["stats_html"] = pf["html"]
273
+ res["sections"].append("stats")
274
+ else: res["stats_html"] = ""
275
+
276
+ cm = _api("/api/fixtures/commentaries", {"event_id": eid})
277
+ if cm and cm.get("status")=="success" and len(cm.get("html","").strip())>10:
278
+ res["commentaries_html"] = cm["html"]
279
+ res["sections"].append("commentaries")
280
+ elif not res.get("commentaries_html"): res["commentaries_html"] = ""
281
+
282
+ def _merge(res, d):
283
+ if d.get("info"):
284
+ res.setdefault("info", {}).update(d["info"])
285
+ res["found"] = True
286
+ if "info" not in res["sections"]: res["sections"].append("info")
287
+ if d.get("timeline"):
288
+ res["timeline"] = d["timeline"]
289
+ if not res.get("commentaries_html"): res["commentaries_html"] = d.get("commentaries_html","")
290
+ res["sections"].append("commentaries")
291
+ if d.get("events"):
292
+ res["events"] = d["events"]
293
+ res["sections"].append("events")
294
+ if d.get("stats_parsed"):
295
+ res["stats_parsed"] = d["stats_parsed"]
296
+ if not res.get("stats_html"): res["stats_html"] = d.get("stats_html","")
297
+ res["sections"].append("stats")
298
+ if d.get("h2h_matches"):
299
+ res["h2h"] = d["h2h_matches"]
300
+ res["sections"].append("h2h")
301
+ if d.get("h2h_stats"):
302
+ res["h2h_stats_parsed"] = d["h2h_stats"]
303
+ res["sections"].append("h2h_stats")
304
+ if d.get("home_form"):
305
+ res["home_form"] = d["home_form"]
306
+ res["sections"].append("home_form")
307
+ if d.get("away_form"):
308
+ res["away_form"] = d["away_form"]
309
+ res["sections"].append("away_form")
match_detail_v2.py ADDED
@@ -0,0 +1,464 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """VNEWS — Match Detail Parser v2 (html.parser only, no lxml dependency)"""
2
+ import re
3
+ import requests
4
+ from bs4 import BeautifulSoup
5
+
6
+ HEADERS = {
7
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
8
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
9
+ "Accept-Language": "vi-VN,vi;q=0.9",
10
+ "Referer": "https://bongda.com.vn/",
11
+ }
12
+
13
+ API_HEADERS = {
14
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
15
+ "Accept": "application/json, text/javascript, */*; q=0.01",
16
+ "X-Requested-With": "XMLHttpRequest",
17
+ "Referer": "https://bongda.com.vn/",
18
+ }
19
+
20
+
21
+ def _cl(s):
22
+ return re.sub(r'\s+', ' ', str(s or '')).strip()
23
+
24
+
25
+ def _normalize_time(raw):
26
+ t = _cl(raw)
27
+ if not t:
28
+ return t
29
+ t = re.sub(r"(\d+)'\s*\+(\d+)", r"\1+\2'", t)
30
+ t = t.replace("''", "'")
31
+ return t
32
+
33
+
34
+ def _mk(html):
35
+ """Parse HTML using html.parser (lxml may not be available)."""
36
+ return BeautifulSoup(html, 'html.parser')
37
+
38
+
39
+ def fetch_html(url, timeout=15):
40
+ resp = requests.get(url, headers=HEADERS, timeout=timeout, allow_redirects=True)
41
+ resp.raise_for_status()
42
+ return resp.text
43
+
44
+
45
+ def parse_events(sp):
46
+ """Parse .events > .period > .event structure."""
47
+ events = []
48
+ events_div = sp.select_one('.events')
49
+ if not events_div:
50
+ return events
51
+
52
+ current_period = ''
53
+ for child in events_div.children:
54
+ if not hasattr(child, 'name') or not child.name:
55
+ continue
56
+ cls_str = ' '.join(child.get('class', []) if child.get('class') else [])
57
+
58
+ if 'period' in cls_str:
59
+ h2 = child.find('h2')
60
+ if h2:
61
+ current_period = _cl(h2.get_text())
62
+
63
+ for ev in child.children:
64
+ if not hasattr(ev, 'name') or not ev.name:
65
+ continue
66
+ ev_cls_str = ' '.join(ev.get('class', []) if ev.get('class') else [])
67
+ if 'event' not in ev_cls_str:
68
+ continue
69
+
70
+ team = 'home' if 'home' in ev_cls_str else 'away'
71
+ ev_data = {
72
+ 'team': team, 'period': current_period, 'type': 'unknown',
73
+ 'time': '', 'players': '', 'player_in': '', 'player_out': '',
74
+ 'scorer': '', 'assist': '', 'card_type': '', 'player': '',
75
+ }
76
+
77
+ type_el = ev.select_one('.event-type')
78
+ if type_el:
79
+ if type_el.select_one('[class*="redcard"]'):
80
+ ev_data['type'] = 'redcard'; ev_data['card_type'] = 'red'
81
+ elif type_el.select_one('[class*="yellowcard"]'):
82
+ ev_data['type'] = 'yellowcard'; ev_data['card_type'] = 'yellow'
83
+ elif type_el.select_one('[class*="goal"]'):
84
+ ev_data['type'] = 'goal'
85
+ elif type_el.select_one('[class*="substitution"]'):
86
+ ev_data['type'] = 'substitution'
87
+ else:
88
+ for rect in type_el.select('svg rect'):
89
+ if rect.get('fill') == '#E20007':
90
+ ev_data['type'] = 'redcard'; ev_data['card_type'] = 'red'; break
91
+ if ev_data['type'] == 'unknown':
92
+ for circle in type_el.select('svg circle'):
93
+ if circle.get('fill') == 'white' and circle.get('r') == '8':
94
+ ev_data['type'] = 'goal'; break
95
+ if ev_data['type'] == 'unknown' and ev.select_one('.players.subst'):
96
+ ev_data['type'] = 'substitution'
97
+
98
+ players_el = ev.select_one('.players')
99
+ if players_el and ev_data['type'] == 'unknown':
100
+ pcls = ' '.join(players_el.get('class', []) if players_el.get('class') else [])
101
+ if 'goal' in pcls: ev_data['type'] = 'goal'
102
+ elif 'card' in pcls: ev_data['type'] = 'redcard'; ev_data['card_type'] = 'red'
103
+ elif 'subst' in pcls: ev_data['type'] = 'substitution'
104
+
105
+ if players_el:
106
+ time_el = players_el.select_one('.event-time')
107
+ if time_el:
108
+ ev_data['time'] = _normalize_time(time_el.get_text())
109
+ ev_data['players'] = _cl(players_el.get_text(' ', strip=True))
110
+
111
+ texts = []
112
+ for d in players_el.find_all('div', recursive=False):
113
+ t = _cl(d.get_text())
114
+ if t and t != ev_data['time']:
115
+ texts.append(t)
116
+ for p in players_el.find_all('p', recursive=False):
117
+ t = _cl(p.get_text())
118
+ if t and t not in texts:
119
+ texts.append(t)
120
+
121
+ if ev_data['type'] == 'substitution':
122
+ if len(texts) >= 2:
123
+ ev_data['player_out'] = texts[0]; ev_data['player_in'] = texts[1]
124
+ elif len(texts) == 1:
125
+ ev_data['player_in'] = texts[0]
126
+ elif ev_data['type'] == 'goal':
127
+ if len(texts) >= 1: ev_data['scorer'] = texts[0]
128
+ if len(texts) >= 2: ev_data['assist'] = texts[1]
129
+ elif ev_data['type'] in ('redcard', 'yellowcard'):
130
+ if texts: ev_data['player'] = ' '.join(texts)
131
+
132
+ events.append(ev_data)
133
+ return events
134
+
135
+
136
+ def _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": []}
172
+
173
+ html = None
174
+ base = f"https://bongda.com.vn/tran-dau/{event_id}"
175
+ urls = [base + suffix for suffix in ['/centre/', '/preview/', '/bao-cao-nhanh/']]
176
+
177
+ # Try all URLs in parallel, take first success
178
+ with concurrent.futures.ThreadPoolExecutor(max_workers=3) as ex:
179
+ futures = {ex.submit(requests.get, url, headers=HEADERS, timeout=8, allow_redirects=True): url for url in urls}
180
+ for future in concurrent.futures.as_completed(futures, timeout=12):
181
+ try:
182
+ resp = future.result()
183
+ if resp.status_code == 200 and len(resp.text) > 1000:
184
+ html = resp.text
185
+ for f in futures:
186
+ f.cancel()
187
+ break
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
+
208
+ sp = _mk(html)
209
+ info = {}
210
+
211
+ tel = sp.select_one('.teams')
212
+ if tel:
213
+ he = tel.select_one('.team.home') or tel.select_one('[class*="home"]')
214
+ if he:
215
+ ne = he.select_one('p:not(.logo)') or he.find('p')
216
+ if ne: info['home_team'] = _cl(ne.get_text())
217
+ lo = he.select_one('img')
218
+ if lo: info['home_logo'] = lo.get('src', '')
219
+
220
+ ae = tel.select_one('.team.away') or tel.select_one('[class*="away"]')
221
+ if ae:
222
+ ne = ae.select_one('p:not(.logo)') or ae.find('p')
223
+ if ne: info['away_team'] = _cl(ne.get_text())
224
+ lo = ae.select_one('img')
225
+ if lo: info['away_logo'] = lo.get('src', '')
226
+
227
+ sc = tel.select_one('.score')
228
+ if sc:
229
+ parts = [_cl(p.get_text()) for p in sc.select('p')]
230
+ if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}"
231
+ lb = sc.select_one('.label')
232
+ if lb: info['status_label'] = _cl(lb.get_text())
233
+
234
+ if info.get('home_team') and info.get('away_team'):
235
+ result['info'] = info
236
+ result['found'] = True
237
+ result['sections'].append('info')
238
+ else:
239
+ return result
240
+
241
+ mi = sp.select_one('.match-info')
242
+ if mi:
243
+ for sel in ['.times', 'li']:
244
+ el = mi.select_one(sel)
245
+ if el:
246
+ t = _cl(el.get_text())
247
+ if t: info.setdefault('datetime', t); break
248
+
249
+ events = parse_events(sp)
250
+ if events:
251
+ result['events'] = events
252
+ result['sections'].append('events')
253
+
254
+ pred = sp.select_one('.prediction-card')
255
+ if pred:
256
+ pred_data = {}
257
+ team_info = pred.select_one('.team-info')
258
+ if team_info:
259
+ teams = team_info.select('.team')
260
+ if len(teams) >= 2:
261
+ pred_data['home_name'] = _cl(teams[0].select_one('.team-name').get_text()) if teams[0].select_one('.team-name') else ''
262
+ pred_data['away_name'] = _cl(teams[1].select_one('.team-name').get_text()) if teams[1].select_one('.team-name') else ''
263
+ divider = team_info.select_one('.divider')
264
+ if divider: pred_data['result'] = _cl(divider.get_text())
265
+ vote_count = pred.select_one('.vote-count')
266
+ if vote_count: pred_data['vote_count'] = _cl(vote_count.get_text())
267
+ result['prediction'] = pred_data
268
+
269
+ try:
270
+ ar = requests.get(
271
+ f"https://bongda.com.vn/api/fixtures/h2h-stats?event_id={event_id}",
272
+ headers=API_HEADERS, timeout=6
273
+ )
274
+ if ar.status_code == 200:
275
+ ad = ar.json()
276
+ if ad.get('status') == 'success' and ad.get('html'):
277
+ asp = _mk(ad['html'])
278
+ ast = {}
279
+ for row in asp.select('li, tr, .stat-row'):
280
+ cells = row.select('td, span, p')
281
+ if len(cells) >= 3:
282
+ lb = _cl(cells[0].get_text())
283
+ if lb: ast[lb] = {'home': _cl(cells[1].get_text()), 'away': _cl(cells[2].get_text())}
284
+ if ast: result['h2h_stats_parsed'] = ast; result['sections'].append('h2h_stats')
285
+ except Exception:
286
+ pass
287
+
288
+ h2h_data = []
289
+ h2h_el = sp.select_one('.h2h-standings')
290
+ if h2h_el:
291
+ rows = h2h_el.select('.ranking-table tbody tr, .leaderboard tr')
292
+ for row in rows:
293
+ cells = row.select('td')
294
+ if len(cells) >= 4:
295
+ logo = row.select_one('img')
296
+ name_el = row.select_one('.team-name, p.link, .name')
297
+ h2h_data.append({
298
+ 'pos': _cl(cells[0].get_text()),
299
+ 'logo': logo.get('src', '') if logo else '',
300
+ 'name': _cl(name_el.get_text()) if name_el else '',
301
+ 'played': _cl(cells[1].get_text()) if len(cells) > 1 else '',
302
+ 'wins': _cl(cells[2].get_text()) if len(cells) > 2 else '',
303
+ 'draws': _cl(cells[3].get_text()) if len(cells) > 3 else '',
304
+ 'losses': _cl(cells[4].get_text()) if len(cells) > 4 else '',
305
+ 'gf': _cl(cells[5].get_text()) if len(cells) > 5 else '',
306
+ 'ga': _cl(cells[6].get_text()) if len(cells) > 6 else '',
307
+ 'points': _cl(cells[8].get_text()) if len(cells) > 8 else '',
308
+ })
309
+ if h2h_data: result['h2h_standings'] = h2h_data; result['sections'].append('h2h_standings')
310
+
311
+ recent_matches = []
312
+ matches_list = sp.select_one('.matches-list')
313
+ if matches_list:
314
+ for item in matches_list.select('.match-detail, .match-item, li'):
315
+ date_el = item.select_one('.date, .time, .match-time')
316
+ league_el = item.select_one('.league')
317
+ home_el = item.select_one('.home, .team-home')
318
+ away_el = item.select_one('.away, .team-away')
319
+ score_el = item.select_one('.score, .result')
320
+ if home_el or away_el:
321
+ recent_matches.append({
322
+ 'date': _cl(date_el.get_text()) if date_el else '',
323
+ 'league': _cl(league_el.get_text()) if league_el else '',
324
+ 'home': _cl(home_el.get_text()) if home_el else '',
325
+ 'away': _cl(away_el.get_text()) if away_el else '',
326
+ 'score': _cl(score_el.get_text()) if score_el else 'vs',
327
+ })
328
+ if recent_matches: result['recent_matches'] = recent_matches; result['sections'].append('recent')
329
+
330
+ return result
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"}
337
+ event_id = int(eid_match.group(1))
338
+ result = {"event_id": event_id, "found": False, "sections": []}
339
+
340
+ html = None
341
+ try:
342
+ resp = requests.get(url, headers=HEADERS, timeout=8, allow_redirects=True)
343
+ if resp.status_code == 200 and len(resp.text) > 1000:
344
+ html = resp.text
345
+ except Exception:
346
+ pass
347
+
348
+ if not html:
349
+ return fetch_match_detail(event_id)
350
+
351
+ sp = _mk(html)
352
+ info = {}
353
+
354
+ tel = sp.select_one('.teams')
355
+ if tel:
356
+ he = tel.select_one('.team.home') or tel.select_one('[class*="home"]')
357
+ if he:
358
+ ne = he.select_one('p:not(.logo)') or he.find('p')
359
+ if ne: info['home_team'] = _cl(ne.get_text())
360
+ lo = he.select_one('img')
361
+ if lo: info['home_logo'] = lo.get('src', '')
362
+ ae = tel.select_one('.team.away') or tel.select_one('[class*="away"]')
363
+ if ae:
364
+ ne = ae.select_one('p:not(.logo)') or ae.find('p')
365
+ if ne: info['away_team'] = _cl(ne.get_text())
366
+ lo = ae.select_one('img')
367
+ if lo: info['away_logo'] = lo.get('src', '')
368
+ sc = tel.select_one('.score')
369
+ if sc:
370
+ parts = [_cl(p.get_text()) for p in sc.select('p')]
371
+ if len(parts) >= 2: info['score'] = f"{parts[0]} - {parts[1]}"
372
+ lb = sc.select_one('.label')
373
+ if lb: info['status_label'] = _cl(lb.get_text())
374
+
375
+ if info.get('home_team') and info.get('away_team'):
376
+ result['info'] = info; result['found'] = True; result['sections'].append('info')
377
+ else:
378
+ return fetch_match_detail(event_id)
379
+
380
+ mi = sp.select_one('.match-info')
381
+ if mi:
382
+ te = mi.select_one('.times, li')
383
+ if te: info.setdefault('datetime', _cl(te.get_text()))
384
+
385
+ events = parse_events(sp)
386
+ if events: result['events'] = events; result['sections'].append('events')
387
+
388
+ pred = sp.select_one('.prediction-card')
389
+ if pred:
390
+ pred_data = {}
391
+ team_info = pred.select_one('.team-info')
392
+ if team_info:
393
+ teams = team_info.select('.team')
394
+ if len(teams) >= 2:
395
+ pred_data['home_name'] = _cl(teams[0].select_one('.team-name').get_text()) if teams[0].select_one('.team-name') else ''
396
+ pred_data['away_name'] = _cl(teams[1].select_one('.team-name').get_text()) if teams[1].select_one('.team-name') else ''
397
+ divider = team_info.select_one('.divider')
398
+ if divider: pred_data['result'] = _cl(divider.get_text())
399
+ vote_count = pred.select_one('.vote-count')
400
+ if vote_count: pred_data['vote_count'] = _cl(vote_count.get_text())
401
+ result['prediction'] = pred_data
402
+
403
+ try:
404
+ ar = requests.get(
405
+ f"https://bongda.com.vn/api/fixtures/h2h-stats?event_id={event_id}",
406
+ headers=API_HEADERS, timeout=6
407
+ )
408
+ if ar.status_code == 200:
409
+ ad = ar.json()
410
+ if ad.get('status') == 'success' and ad.get('html'):
411
+ asp = _mk(ad['html'])
412
+ ast = {}
413
+ for row in asp.select('li, tr, .stat-row'):
414
+ cells = row.select('td, span, p')
415
+ if len(cells) >= 3:
416
+ lb = _cl(cells[0].get_text())
417
+ if lb: ast[lb] = {'home': _cl(cells[1].get_text()), 'away': _cl(cells[2].get_text())}
418
+ if ast: result['h2h_stats_parsed'] = ast; result['sections'].append('h2h_stats')
419
+ except Exception:
420
+ pass
421
+
422
+ h2h_data = []
423
+ h2h_el = sp.select_one('.h2h-standings')
424
+ if h2h_el:
425
+ rows = h2h_el.select('.ranking-table tbody tr, .leaderboard tr')
426
+ for row in rows:
427
+ cells = row.select('td')
428
+ if len(cells) >= 4:
429
+ logo = row.select_one('img')
430
+ name_el = row.select_one('.team-name, p.link, .name')
431
+ h2h_data.append({
432
+ 'pos': _cl(cells[0].get_text()),
433
+ 'logo': logo.get('src', '') if logo else '',
434
+ 'name': _cl(name_el.get_text()) if name_el else '',
435
+ 'played': _cl(cells[1].get_text()) if len(cells) > 1 else '',
436
+ 'wins': _cl(cells[2].get_text()) if len(cells) > 2 else '',
437
+ 'draws': _cl(cells[3].get_text()) if len(cells) > 3 else '',
438
+ 'losses': _cl(cells[4].get_text()) if len(cells) > 4 else '',
439
+ 'gf': _cl(cells[5].get_text()) if len(cells) > 5 else '',
440
+ 'ga': _cl(cells[6].get_text()) if len(cells) > 6 else '',
441
+ 'points': _cl(cells[8].get_text()) if len(cells) > 8 else '',
442
+ })
443
+ if h2h_data: result['h2h_standings'] = h2h_data; result['sections'].append('h2h_standings')
444
+
445
+ recent_matches = []
446
+ matches_list = sp.select_one('.matches-list')
447
+ if matches_list:
448
+ for item in matches_list.select('.match-detail, .match-item, li'):
449
+ date_el = item.select_one('.date, .time, .match-time')
450
+ league_el = item.select_one('.league')
451
+ home_el = item.select_one('.home, .team-home')
452
+ away_el = item.select_one('.away, .team-away')
453
+ score_el = item.select_one('.score, .result')
454
+ if home_el or away_el:
455
+ recent_matches.append({
456
+ 'date': _cl(date_el.get_text()) if date_el else '',
457
+ 'league': _cl(league_el.get_text()) if league_el else '',
458
+ 'home': _cl(home_el.get_text()) if home_el else '',
459
+ 'away': _cl(away_el.get_text()) if away_el else '',
460
+ 'score': _cl(score_el.get_text()) if score_el else 'vs',
461
+ })
462
+ if recent_matches: result['recent_matches'] = recent_matches; result['sections'].append('recent')
463
+
464
+ return result
patch_extra.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Extra CSS/JS fixes injected AFTER main PATCH_INJECT."""
2
+ EXTRA_FIX = r'''
3
+ <style>
4
+ /* Force correct position for Short AI interaction buttons */
5
+ .tiktok-slide{position:relative!important}
6
+ .tiktok-right{position:absolute!important;right:8px!important;bottom:100px!important;display:flex!important;flex-direction:column!important;align-items:center!important;gap:14px!important;z-index:5!important}
7
+ .tiktok-right-btn{display:flex!important;flex-direction:column!important;align-items:center!important;gap:2px!important;background:none!important;border:0!important;color:#fff!important;font-size:10px!important;cursor:pointer!important}
8
+ .tiktok-right-btn .icon{width:42px!important;height:42px!important;border-radius:50%!important;background:rgba(255,255,255,.12)!important;display:flex!important;align-items:center!important;justify-content:center!important;font-size:20px!important}
9
+ .tiktok-right-btn .count{font-size:10px!important;color:#ddd!important}
10
+ #short-progress-toast{position:fixed;bottom:70px;left:50%;transform:translateX(-50%);background:#2d8659;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;z-index:99998;box-shadow:0 4px 12px rgba(0,0,0,.4);display:none;white-space:nowrap}
11
+ /* Kill ALL duplicate short AI slides from old layers */
12
+ #ai-short-home,.ai-short-home,.ai-short-card-final,[id*="ai-shorts-patched"]{display:none!important}
13
+ </style>
14
+ <div id="short-progress-toast"></div>
15
+ <script>
16
+ (function(){
17
+ // Kill old renderers that create duplicate Short AI slides
18
+ window.renderAIShortHome=function(){};
19
+ window.renderAIShorts7=function(){};
20
+ window.renderTopicWallE=function(){};
21
+ window.renderAiShorts=function(){};
22
+ // Also remove any already-rendered duplicate slides
23
+ setInterval(function(){
24
+ document.querySelectorAll('#ai-short-home,.ai-short-home,[id*="ai-shorts-patched"]').forEach(function(el){el.remove()});
25
+ },2000);
26
+ // Progress toast for short creation
27
+ window.showShortProgress=function(msg){var t=document.getElementById('short-progress-toast');if(t){t.textContent=msg;t.style.display='block';}};
28
+ window.hideShortProgress=function(){var t=document.getElementById('short-progress-toast');if(t)t.style.display='none';};
29
+ // Override makeShortFromPost to use progress toast
30
+ var _origMakeShort=window.makeShortFromPost;
31
+ window.makeShortFromPost=async function(pid,btn){
32
+ showShortProgress('⏳ Đang tạo Short AI...');
33
+ if(btn){btn.disabled=true;btn.textContent='Đang tạo...';}
34
+ try{
35
+ var r=await fetch('/api/ai/short/'+pid,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:'nu',emotion:'neutral',speed:1.2})});
36
+ var j=await r.json();
37
+ if(!r.ok||j.error)throw new Error(j.error||'Lỗi');
38
+ showShortProgress('✅ Đ�ã tạo Short AI!');
39
+ setTimeout(hideShortProgress,3000);
40
+ if(typeof renderShortAISlide==='function')renderShortAISlide();
41
+ }catch(e){
42
+ showShortProgress('❌ Lỗi: '+e.message);
43
+ setTimeout(hideShortProgress,4000);
44
+ }finally{
45
+ if(btn){btn.disabled=false;btn.textContent='🎬 Tạo Short AI';}
46
+ }
47
+ };
48
+ })();
49
+ </script>
50
+ '''
patch_runtime.py ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Runtime patch layer for VNEWS.
2
+ Keeps the current large app intact, but replaces fragile AI wall endpoints with
3
+ stable JSON endpoints and injects frontend safeJson wrappers.
4
+ """
5
+ import hashlib
6
+ import time
7
+ import os
8
+ from urllib.parse import quote
9
+
10
+ import requests
11
+ from bs4 import BeautifulSoup
12
+ from fastapi import Request
13
+ from fastapi.responses import JSONResponse, HTMLResponse
14
+
15
+ import main as _main
16
+
17
+ app = _main.app
18
+ DEFAULT_IMG = "https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg"
19
+
20
+
21
+ def _remove_routes(paths):
22
+ app.router.routes = [r for r in app.router.routes if getattr(r, "path", None) not in set(paths)]
23
+
24
+
25
+ def _safe_text(v):
26
+ return (v or "").strip()
27
+
28
+
29
+ def _ensure_article(url: str):
30
+ data = None
31
+ try:
32
+ if hasattr(_main, "_article_by_url"):
33
+ data = _main._article_by_url(url)
34
+ except Exception:
35
+ data = None
36
+ if not data:
37
+ try:
38
+ data = _main._scrape_generic_article(url) if hasattr(_main, "_scrape_generic_article") else None
39
+ except Exception:
40
+ data = None
41
+ if not data:
42
+ data = {"title": "", "summary": "", "og_image": "", "body": [], "url": url, "source": "generic"}
43
+ title = _safe_text(data.get("title"))
44
+ summary = _safe_text(data.get("summary"))
45
+ img = _safe_text(data.get("og_image"))
46
+ body = data.get("body") or []
47
+ if not title or not summary or not img or not body:
48
+ try:
49
+ r = requests.get(url, headers=getattr(_main, "HEADERS", {}), timeout=15)
50
+ r.encoding = "utf-8"
51
+ soup = BeautifulSoup(r.text, "lxml")
52
+ if not title:
53
+ tag = soup.find("meta", property="og:title") or soup.find("title")
54
+ title = tag.get("content", "").strip() if tag and tag.name == "meta" else (tag.get_text(strip=True) if tag else "")
55
+ if not summary:
56
+ tag = soup.find("meta", property="og:description") or soup.find("meta", attrs={"name": "description"})
57
+ summary = tag.get("content", "").strip() if tag else ""
58
+ if not img:
59
+ tag = soup.find("meta", property="og:image") or soup.find("meta", attrs={"name": "twitter:image"})
60
+ img = tag.get("content", "").strip() if tag else ""
61
+ if not body:
62
+ ps = []
63
+ for p in soup.find_all("p"):
64
+ t = p.get_text(" ", strip=True)
65
+ if len(t) > 40:
66
+ ps.append({"type": "p", "text": t})
67
+ if len(ps) >= 30:
68
+ break
69
+ body = ps
70
+ except Exception:
71
+ pass
72
+ if not summary and body:
73
+ first = next((b.get("text", "") for b in body if b.get("type") == "p" and b.get("text")), "")
74
+ summary = first[:360]
75
+ if not title:
76
+ title = url
77
+ if not img:
78
+ img = DEFAULT_IMG
79
+ if not body and summary:
80
+ body = [{"type": "p", "text": summary}]
81
+ data.update({"title": title, "summary": summary, "og_image": img, "body": body, "url": url})
82
+ return data
83
+
84
+
85
+ def _rewrite(data, tone="tu-nhien"):
86
+ try:
87
+ if hasattr(_main, "_ai_rewrite_article"):
88
+ text = _main._ai_rewrite_article(data, tone=tone)
89
+ if text and len(text.strip()) > 50:
90
+ return text.strip()
91
+ except Exception:
92
+ pass
93
+ title = data.get("title", "")
94
+ summary = data.get("summary", "")
95
+ ps = [b.get("text", "") for b in data.get("body", []) if b.get("type") == "p" and b.get("text")]
96
+ lead = summary or (ps[0] if ps else "")
97
+ points = "\n".join(["• " + p[:220] + ("..." if len(p) > 220 else "") for p in ps[:5]])
98
+ body = "\n\n".join(ps[:10])
99
+ return (f"Bản tin AI viết lại: {title}\n\n{lead}\n\n{body}\n\nĐiểm chính:\n{points}").strip()
100
+
101
+
102
+ def _topic_image(topic):
103
+ try:
104
+ if hasattr(_main, "_image_for_topic"):
105
+ return _main._image_for_topic(topic)
106
+ except Exception:
107
+ pass
108
+ return "https://image.pollinations.ai/prompt/" + quote("editorial illustration Vietnamese news " + topic, safe="") + "?width=1024&height=576&nologo=true"
109
+
110
+
111
+ def _save_post(post):
112
+ try:
113
+ posts = _main._load_wall() if hasattr(_main, "_load_wall") else []
114
+ except Exception:
115
+ posts = []
116
+ posts.insert(0, post)
117
+ try:
118
+ if hasattr(_main, "_save_wall"):
119
+ _main._save_wall(posts)
120
+ except Exception:
121
+ pass
122
+ return post
123
+
124
+
125
+ _remove_routes(["/api/url_wall", "/api/topic_post", "/api/rewrite_share", "/"])
126
+
127
+
128
+ @app.post("/api/url_wall")
129
+ async def patched_url_wall(request: Request):
130
+ try:
131
+ body = await request.json()
132
+ except Exception:
133
+ body = {}
134
+ url = _safe_text(body.get("url"))
135
+ tone = _safe_text(body.get("tone")) or "tu-nhien"
136
+ if not url:
137
+ return JSONResponse({"error": "missing url"}, status_code=400)
138
+ try:
139
+ data = _ensure_article(url)
140
+ text = _rewrite(data, tone=tone)
141
+ post = {
142
+ "id": hashlib.md5((url + str(time.time())).encode()).hexdigest()[:12],
143
+ "url": url,
144
+ "title": data.get("title") or url,
145
+ "summary": data.get("summary") or "",
146
+ "img": data.get("og_image") or DEFAULT_IMG,
147
+ "text": text or (data.get("summary") or data.get("title") or url),
148
+ "source": data.get("source", "url"),
149
+ "ts": int(time.time()),
150
+ }
151
+ _save_post(post)
152
+ return JSONResponse({"post": post})
153
+ except Exception as e:
154
+ return JSONResponse({"error": "Không tạo được tóm tắt URL", "detail": str(e)[:300]}, status_code=500)
155
+
156
+
157
+ @app.post("/api/rewrite_share")
158
+ async def patched_rewrite_share(request: Request):
159
+ return await patched_url_wall(request)
160
+
161
+
162
+ @app.post("/api/topic_post")
163
+ async def patched_topic_post(request: Request):
164
+ try:
165
+ body = await request.json()
166
+ except Exception:
167
+ body = {}
168
+ topic = _safe_text(body.get("topic"))
169
+ tone = _safe_text(body.get("tone")) or "tu-nhien"
170
+ if not topic:
171
+ return JSONResponse({"error": "missing topic"}, status_code=400)
172
+ try:
173
+ context = ""
174
+ try:
175
+ if hasattr(_main, "_topic_article_context"):
176
+ context = _main._topic_article_context(topic)
177
+ if not context and hasattr(_main, "_web_context"):
178
+ context = _main._web_context(topic)
179
+ except Exception:
180
+ context = ""
181
+ if not context:
182
+ context = f"Chủ đề: {topic}"
183
+ data = {"title": topic, "summary": context[:420], "og_image": _topic_image(topic), "body": [{"type": "p", "text": context}], "source": "topic", "url": ""}
184
+ text = _rewrite(data, tone=tone)
185
+ post = {
186
+ "id": hashlib.md5((topic + str(time.time())).encode()).hexdigest()[:12],
187
+ "url": "",
188
+ "title": topic,
189
+ "summary": data["summary"],
190
+ "img": data["og_image"] or DEFAULT_IMG,
191
+ "text": text or context,
192
+ "source": "topic",
193
+ "ts": int(time.time()),
194
+ }
195
+ _save_post(post)
196
+ return JSONResponse({"post": post})
197
+ except Exception as e:
198
+ return JSONResponse({"error": "Không tạo được bài theo chủ đề", "detail": str(e)[:300]}, status_code=500)
199
+
200
+
201
+ _FRONTEND_PATCH = r'''
202
+ <script>
203
+ (function(){
204
+ async function safeJson(res){
205
+ const text = await res.text();
206
+ try { return JSON.parse(text); }
207
+ catch(e){ return { error: (text || 'Server không trả JSON').slice(0,500) }; }
208
+ }
209
+ window.safeJson = safeJson;
210
+ window.createUrlPost = function(){
211
+ let inp=document.getElementById('ai-url-input');
212
+ let url=(inp&&inp.value||'').trim();
213
+ if(!url){ alert('Dán URL trước'); return; }
214
+ fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})})
215
+ .then(safeJson).then(j=>{
216
+ if(j&&j.post){
217
+ if(!j.post.img) j.post.img='https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg';
218
+ if(!j.post.text) j.post.text=j.post.summary||j.post.title||'Không lấy được nội dung tóm tắt.';
219
+ if(typeof prependWallPost==='function') prependWallPost(j.post);
220
+ alert('Đã tóm tắt URL và đăng lên tường');
221
+ if(inp) inp.value='';
222
+ } else alert((j&&j.error)||'Lỗi URL');
223
+ }).catch(e=>alert('Lỗi URL: '+e.message));
224
+ };
225
+ window.createTopicPost = function(){
226
+ let inp=document.getElementById('ai-topic-input');
227
+ let topic=(inp&&inp.value||'').trim();
228
+ if(!topic){ alert('Nhập chủ đề trước'); return; }
229
+ fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})})
230
+ .then(safeJson).then(j=>{
231
+ if(j&&j.post){
232
+ if(!j.post.img) j.post.img='https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg';
233
+ if(!j.post.text) j.post.text=j.post.summary||j.post.title||'Không lấy được nội dung.';
234
+ if(typeof prependWallPost==='function') prependWallPost(j.post);
235
+ alert('Đã tạo bài và đăng lên tường');
236
+ if(inp) inp.value='';
237
+ } else alert((j&&j.error)||'Lỗi tạo bài');
238
+ }).catch(e=>alert('Lỗi tạo bài: '+e.message));
239
+ };
240
+ window.rewriteCurrentArticle = function(){
241
+ if(!window._currentArticle && typeof _currentArticle!=='undefined') window._currentArticle=_currentArticle;
242
+ let ca = (typeof _currentArticle!=='undefined') ? _currentArticle : window._currentArticle;
243
+ if(!ca || !ca.url){ alert('Chưa có bài viết để rewrite'); return; }
244
+ let tone=document.getElementById('rewrite-tone')?.value||'nghiem-tuc';
245
+ let btn=document.querySelector('.article-actions button.primary');
246
+ if(btn){btn.textContent='Đang rewrite...';btn.disabled=true;}
247
+ fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:ca.url,tone})})
248
+ .then(safeJson).then(j=>{
249
+ if(j&&j.post){
250
+ if(!j.post.img) j.post.img='https://s1.vnecdn.net/vnexpress/restruct/i/v9505/logo_default.jpg';
251
+ if(!j.post.text) j.post.text=j.post.summary||j.post.title||'Không lấy được nội dung.';
252
+ let box=document.getElementById('rewrite-result');
253
+ if(box) box.innerHTML='<div class="rewrite-box"><div class="rewrite-title">Đã rewrite và đăng lên Tường AI</div><div class="rewrite-text">'+(j.post.text||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]))+'</div></div>';
254
+ if(typeof prependWallPost==='function') prependWallPost(j.post);
255
+ alert('Đã đăng lên Tường AI');
256
+ } else alert((j&&j.error)||'Không tạo được bài AI');
257
+ }).catch(e=>alert('Lỗi tạo bài AI: '+e.message))
258
+ .finally(()=>{if(btn){btn.textContent='🤖 AI viết lại & đăng tường';btn.disabled=false;}});
259
+ };
260
+ })();
261
+ </script>
262
+ '''
263
+
264
+
265
+ @app.get("/")
266
+ async def patched_index():
267
+ try:
268
+ with open("/app/static/index.html", "r", encoding="utf-8") as f:
269
+ html = f.read()
270
+ if "window.safeJson" not in html:
271
+ html = html.replace("</body>", _FRONTEND_PATCH + "</body>")
272
+ return HTMLResponse(content=html)
273
+ except Exception as e:
274
+ return HTMLResponse(content=f"<pre>Index error: {str(e)}</pre>", status_code=500)
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ requests
4
+ beautifulsoup4>=4.12.0
5
+ lxml
6
+ jinja2
7
+ yt-dlp
8
+ huggingface_hub
9
+ gTTS
10
+ pillow
11
+ edge-tts
12
+ python-dateutil
13
+ httpx
restore_runner.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import subprocess
4
+ from huggingface_hub import snapshot_download
5
+
6
+ REVISION = os.environ.get("VNEWS_RESTORE_REVISION", "bcaa2dc")
7
+ REPO_ID = os.environ.get("VNEWS_REPO_ID", "bep40/vnews")
8
+
9
+ # Download exact Space snapshot from Hugging Face Hub.
10
+ # This avoids manually copying huge files from an old commit.
11
+ snapshot_dir = snapshot_download(
12
+ repo_id=REPO_ID,
13
+ repo_type="space",
14
+ revision=REVISION,
15
+ local_dir="/tmp/vnews_restore",
16
+ local_dir_use_symlinks=False,
17
+ )
18
+
19
+ os.chdir(snapshot_dir)
20
+ sys.path.insert(0, snapshot_dir)
21
+
22
+ # Commit bcaa2dc Dockerfile ran ai_patch:app.
23
+ cmd = [
24
+ "uvicorn",
25
+ "ai_patch:app",
26
+ "--host",
27
+ "0.0.0.0",
28
+ "--port",
29
+ "7860",
30
+ ]
31
+ os.execvp(cmd[0], cmd)
rewrite_slide.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fast rewrite as slides - no AI needed, extracts key points + images from article."""
2
+ from main import app
3
+ from fastapi import Request
4
+ from fastapi.responses import JSONResponse
5
+ import requests, re, time, random, json, os
6
+ from bs4 import BeautifulSoup
7
+ from urllib.parse import quote
8
+
9
+ UA = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept-Language': 'vi-VN,vi;q=0.9'}
10
+
11
+ try:
12
+ from main import _load_wall, _save_wall
13
+ except:
14
+ _data_dir = "/data" if os.path.isdir("/data") else "/app/data"
15
+ _wall_file = os.path.join(_data_dir, "wall_posts.json")
16
+ def _load_wall():
17
+ try:
18
+ if os.path.exists(_wall_file):
19
+ with open(_wall_file, 'r', encoding='utf-8') as f: return json.load(f)
20
+ except: pass
21
+ return []
22
+ def _save_wall(posts):
23
+ try:
24
+ os.makedirs(os.path.dirname(_wall_file), exist_ok=True)
25
+ with open(_wall_file+'.tmp', 'w', encoding='utf-8') as f: json.dump(posts[:100], f, ensure_ascii=False)
26
+ os.replace(_wall_file+'.tmp', _wall_file)
27
+ except: pass
28
+
29
+
30
+ def _clean(s): return re.sub(r'\s+', ' ', str(s or '')).strip()
31
+
32
+
33
+ def _scrape_article_full(url):
34
+ """Scrape article: extract paragraphs + ALL images."""
35
+ try:
36
+ r = requests.get(url, headers=UA, timeout=15, allow_redirects=True)
37
+ r.encoding = 'utf-8'
38
+ soup = BeautifulSoup(r.text, 'lxml')
39
+ for tag in soup.find_all(['script', 'style', 'nav', 'footer', 'aside', 'form']): tag.decompose()
40
+
41
+ # Title
42
+ h1 = soup.find('h1')
43
+ ogt = soup.find('meta', property='og:title')
44
+ title = (h1.get_text(strip=True) if h1 else '') or (ogt.get('content', '') if ogt else '')
45
+
46
+ # OG image
47
+ ogi = soup.find('meta', property='og:image')
48
+ og_img = ogi.get('content', '') if ogi else ''
49
+ if og_img and og_img.startswith('//'): og_img = 'https:' + og_img
50
+
51
+ # Find content block
52
+ block = None
53
+ for sel in ['article', '.singular-content', '.detail-content', '.fck_detail', '.content-detail', '.knc-content', 'main', '.cms-body', '.article__body']:
54
+ el = soup.select_one(sel)
55
+ if el and len(el.find_all('p')) >= 2: block = el; break
56
+ if not block: block = soup.body or soup
57
+
58
+ # Extract paragraphs and images IN ORDER
59
+ paragraphs = []
60
+ images = []
61
+ seen_imgs = set()
62
+
63
+ if og_img and og_img not in seen_imgs:
64
+ images.append(og_img)
65
+ seen_imgs.add(og_img)
66
+
67
+ for el in block.find_all(['p', 'h2', 'h3', 'figure', 'img'], recursive=True):
68
+ if el.name == 'p':
69
+ t = _clean(el.get_text(strip=True))
70
+ if t and len(t) > 40:
71
+ paragraphs.append(t)
72
+ elif el.name in ('figure', 'img'):
73
+ im = el if el.name == 'img' else el.find('img')
74
+ if im:
75
+ src = im.get('data-src') or im.get('src') or im.get('data-original') or ''
76
+ if src and 'base64' not in src:
77
+ if src.startswith('//'): src = 'https:' + src
78
+ if src not in seen_imgs:
79
+ images.append(src)
80
+ seen_imgs.add(src)
81
+
82
+ return {'title': _clean(title), 'paragraphs': paragraphs, 'images': images, 'og_img': og_img}
83
+ except Exception as e:
84
+ return None
85
+
86
+
87
+ def _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
101
+ if any(sentence[:50] in existing for existing in points): continue
102
+
103
+ points.append(sentence)
104
+
105
+ return points
106
+
107
+
108
+ @app.post("/api/rewrite_slide")
109
+ async def api_rewrite_slide(request: Request):
110
+ """
111
+ Fast rewrite as SLIDES:
112
+ - Extract key points from article (1 sentence each, full and complete)
113
+ - Pair each point with an image from the article
114
+ - Return as slides array for frontend to display
115
+ - Save to Tường AI
116
+ NO AI NEEDED - instant response.
117
+ """
118
+ body = await request.json()
119
+ url = _clean(body.get("url", ""))
120
+ context = body.get("context", "")
121
+
122
+ if not url and not context:
123
+ return JSONResponse({"error": "Cần URL hoặc nội dung"}, status_code=400)
124
+
125
+ # Scrape article
126
+ data = None
127
+ if url and url.startswith("http"):
128
+ data = _scrape_article_full(url)
129
+
130
+ if not data and context:
131
+ # Use context passed from frontend
132
+ paragraphs = [_clean(p) for p in context.split('\n') if len(_clean(p)) > 40]
133
+ data = {'title': paragraphs[0][:80] if paragraphs else 'Bài viết', 'paragraphs': paragraphs, 'images': [], 'og_img': ''}
134
+
135
+ if not data or not data.get('paragraphs'):
136
+ return JSONResponse({"error": "Không đọc được bài viết"}, status_code=422)
137
+
138
+ # Extract key points
139
+ points = _extract_key_points(data['paragraphs'], max_points=6)
140
+ if not points:
141
+ return JSONResponse({"error": "Không tìm được ý chính"}, status_code=422)
142
+
143
+ # Build slides: pair each point with an image
144
+ images = data.get('images', [])
145
+ slides = []
146
+ for i, point in enumerate(points):
147
+ img = images[i] if i < len(images) else (images[-1] if images else '')
148
+ # Proxy dantri images
149
+ if img and 'cdnphoto.dantri' in img:
150
+ img = '/api/proxy/img?url=' + quote(img, safe='')
151
+ slides.append({
152
+ 'text': point,
153
+ 'image': img,
154
+ 'index': i + 1
155
+ })
156
+
157
+ # Create post for Tường AI
158
+ summary_text = '\n\n'.join([f"• {s['text']}" for s in slides])
159
+ # Auto voice + emotion based on topic (reuse ai_ext detector if available)
160
+ try:
161
+ from ai_ext import _detect_voice_emotion
162
+ _voice, _emotion = _detect_voice_emotion(data['title'], summary_text)
163
+ except Exception:
164
+ _voice, _emotion = "hoaimy", "trung_tinh"
165
+ post = {
166
+ "id": str(int(time.time() * 1000)) + str(random.randint(100, 999)),
167
+ "title": data['title'],
168
+ "text": summary_text,
169
+ "img": images[0] if images else '',
170
+ "url": url,
171
+ "kind": "slide_summary",
172
+ "slides": slides,
173
+ "images": images[:10],
174
+ "video": "",
175
+ "voice": _voice,
176
+ "emotion": _emotion,
177
+ "ts": int(time.time())
178
+ }
179
+
180
+ # Save to wall
181
+ posts = _load_wall()
182
+ posts.insert(0, post)
183
+ _save_wall(posts)
184
+
185
+ return JSONResponse({"post": post, "slides": slides})
static/app_v2.js ADDED
@@ -0,0 +1,490 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // === VNEWS Frontend v2 - Full Functions + VTV Player ===
2
+ // Updated: VTV multi-source player, team stats, fast API
3
+
4
+ // ===== VTV CHANNELS =====
5
+ const VTV_CHANNELS = [
6
+ {id:'vtv1', name:'VTV1', badge:'Tin tức'},
7
+ {id:'vtv2', name:'VTV2', badge:'Khoa học'},
8
+ {id:'vtv3', name:'VTV3', badge:'Giải trí'},
9
+ {id:'vtv4', name:'VTV4', badge:'Quốc tế'},
10
+ {id:'vtv5', name:'VTV5', badge:'Miền Nam'},
11
+ {id:'vtv6', name:'VTV6', badge:'Thanh niên'},
12
+ {id:'vtv7', name:'VTV7', badge:'Giáo dục'},
13
+ {id:'vtv8', name:'VTV8', badge:'Miền Trung'},
14
+ {id:'vtv9', name:'VTV9', badge:'Miền Bắc'},
15
+ {id:'vtv10', name:'VTV10', badge:'VTV10'},
16
+ {id:'vtvprime', name:'VTVPrime', badge:'Prime'},
17
+ ];
18
+
19
+ const VTV_EPG = {
20
+ 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:'19:00',n:'Thời sự tối'},{t:'21:00',n:'Thời sự đêm'}],
21
+ vtv2:[{t:'06:00',n:'Khoa học & CN'},{t:'08:00',n:'Thế giới tự nhiên'},{t:'10:00',n:'Khoa học 360'},{t:'14:00',n:'Sức khỏe'},{t:'20:00',n:'Khoa học & Tương lai'}],
22
+ vtv3:[{t:'06:00',n:'Sáng vui'},{t:'08:00',n:'Phim truyện'},{t:'12:00',n:'Âm nhạc'},{t:'16:00',n:'Giải trí chiều'},{t:'20:00',n:'Phim đặc biệt'}],
23
+ 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'}],
24
+ vtv5:[{t:'06:00',n:'Thời sự miền Nam'},{t:'10:00',n:'Phim truyện'},{t:'14:00',n:'Giải trí'},{t:'18:00',n:'Thời sự chiều'},{t:'22:00',n:'Thời sự tối'}],
25
+ vtv6:[{t:'06:00',n:'Khởi động ngày mới'},{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'}],
26
+ 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:'16:00',n:'Thiếu nhi'},{t:'20:00',n:'Tài liệu GD'}],
27
+ vtv8:[{t:'06:00',n:'Thời sự miền Trung'},{t:'10:00',n:'Phim truyện'},{t:'14:00',n:'Giải trí'},{t:'18:00',n:'Thời sự chiều'},{t:'22:00',n:'Thời sự tối'}],
28
+ vtv9:[{t:'06:00',n:'Thời sự miền Bắc'},{t:'10:00',n:'Phim truyện'},{t:'14:00',n:'Giải trí'},{t:'18:00',n:'Thời sự chiều'},{t:'22:00',n:'Thời sự tối'}],
29
+ vtv10:[{t:'06:00',n:'Thời sự Tây Nam Bộ'},{t:'10:00',n:'Phim truyện'},{t:'14:00',n:'Giải trí'},{t:'18:00',n:'Thời sự chiều'},{t:'22:00',n:'Thời sự tối'}],
30
+ vtvprime:[{t:'06:00',n:'Prime Morning'},{t:'10:00',n:'Prime Sports'},{t:'14:00',n:'Prime Drama'},{t:'18:00',n:'Prime Evening'},{t:'20:00',n:'Prime Night'}],
31
+ };
32
+
33
+ // Fallback static sources (when API is down)
34
+ const VTV_STATIC_SOURCES = {
35
+ vtv1:["https://live-a.fptplay53.net/live/media/vtv1/live247-hls-avc/index.m3u8","https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8","https://live.fptplay53.net/fnxch2/vtv1hd_abr.smil/chunklist.m3u8"],
36
+ vtv2:["https://live-a.fptplay53.net/live/media/vtv2/live247-hls-avc/index.m3u8","https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv2-manifest.m3u8","https://live.fptplay53.net/fnxch2/vtv2hd_abr.smil/chunklist.m3u8"],
37
+ vtv3:["https://live-a.fptplay53.net/live/media/vtv3/live247-hls-avc/index.m3u8","https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv3-manifest.m3u8","https://live.fptplay53.net/fnxch2/vtv3hd_abr.smil/chunklist.m3u8"],
38
+ vtv4:["https://live-a.fptplay53.net/live/media/vtv4/live247-hls-avc/index.m3u8","https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv4-manifest.m3u8"],
39
+ vtv5:["https://live-a.fptplay53.net/live/media/vtv5/live247-hls-avc/index.m3u8","https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv5-manifest.m3u8","https://live-a.fptplay53.net/live/media/VTV5HD/live_hls_avc/index.m3u8"],
40
+ vtv6:["https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/vtv6-avc1_5600000=10000-mp4a_131600=20000.m3u8","https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/index.m3u8"],
41
+ vtv7:["https://live-a.fptplay53.net/live/media/vtv7/live247-hls-avc/index.m3u8","https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv7-manifest.m3u8","https://live.fptplay53.net/fnxhd1/vtv7hd_vhls.smil/chunklist_b5000000.m3u8"],
42
+ vtv8:["https://live.fptplay53.net/epzhd1/vtv8hd_vhls.smil/chunklist.m3u8","https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv8-manifest.m3u8"],
43
+ vtv9:["https://live.fptplay53.net/fnxhd1/vtv9hd_vhls.smil/chunklist.m3u8","https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv9-manifest.m3u8"],
44
+ vtv10:["https://live-a.fptplay53.net/live/media/vtv10/live247-hls-avc/vtv10-avc1_5600000=10000-mp4a_131600=20000.m3u8","https://live-a.fptplay53.net/live/media/vtv10/live247-hls-avc/index.m3u8"],
45
+ vtvprime:["https://live.fptplay53.net/fnxch2/vtvprime_abr.smil/chunklist.m3u8"],
46
+ };
47
+
48
+ let _vtvStreams = {};
49
+ let _vtvCurrentCh = null;
50
+ let _vtvHls = null;
51
+
52
+ function buildVTVEPG(chId){
53
+ const epg = VTV_EPG[chId] || [];
54
+ if(!epg.length) return '';
55
+ const curH = new Date().getHours();
56
+ let items = '';
57
+ epg.forEach(item => {
58
+ const itemH = parseInt(item.t.split(':')[0], 10);
59
+ const isNow = itemH <= curH && (itemH + 2) > curH;
60
+ items += `<div class="vtv-epg-item${isNow?' now':''}"><span class="epg-t">${item.t}</span><span class="epg-n">${item.n}</span></div>`;
61
+ });
62
+ return `<div class="vtv-epg"><div class="vtv-epg-title">📋 Lịch phát sóng</div><div class="vtv-epg-list">${items}</div></div>`;
63
+ }
64
+
65
+ function buildVTVBlockHTML(){
66
+ let tabs = '';
67
+ VTV_CHANNELS.forEach(ch => {
68
+ tabs += `<button class="vtv-tab off" id="vtvt-${ch.id}" onclick="_vtvPlay('${ch.id}')">${ch.name}</button>`;
69
+ });
70
+ return `<div class="vtv-wrap" id="vtv-block">
71
+ <div class="vtv-head"><span class="vtv-title">📺 VTV Trực Tuyến</span><span class="vtv-badge">● LIVE</span></div>
72
+ <div class="vtv-tabs">${tabs}</div>
73
+ <div class="vtv-player-area">
74
+ <div class="vtv-load" id="vtv-load"><div class="vtv-spinner"></div>Đang tải danh sách kênh...</div>
75
+ <video id="vtv-player" playsinline muted controls preload="auto" style="display:none"></video>
76
+ <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>
77
+ </div>
78
+ <div id="vtv-epg-wrap"></div>
79
+ </div>`;
80
+ }
81
+
82
+ async function loadVTVStreams(){
83
+ let apiOk = false;
84
+ try {
85
+ const r = await fetch('/api/vtv/streams', {signal: AbortSignal.timeout(8000)});
86
+ if(r.ok){
87
+ const data = await r.json();
88
+ VTV_CHANNELS.forEach(ch => {
89
+ const info = data[ch.id];
90
+ const sources = (info && info.all_sources && info.all_sources.length > 0) ? info.all_sources : (info && info.stream_url ? [info.stream_url] : []);
91
+ if(sources.length > 0){
92
+ _vtvStreams[ch.id] = sources.map(u => '/api/proxy/m3u8/vtv?url=' + encodeURIComponent(u));
93
+ apiOk = true;
94
+ }
95
+ });
96
+ }
97
+ } catch(e) {
98
+ console.warn('VTV API error:', e);
99
+ }
100
+ if(!apiOk){
101
+ VTV_CHANNELS.forEach(ch => {
102
+ if(!_vtvStreams[ch.id] || _vtvStreams[ch.id].length === 0){
103
+ const fallback = VTV_STATIC_SOURCES[ch.id] || [];
104
+ _vtvStreams[ch.id] = fallback.map(u => '/api/proxy/m3u8/vtv?url=' + encodeURIComponent(u));
105
+ }
106
+ });
107
+ }
108
+ VTV_CHANNELS.forEach(ch => {
109
+ const tab = document.getElementById('vtvt-'+ch.id);
110
+ if(tab){
111
+ if(_vtvStreams[ch.id] && _vtvStreams[ch.id].length > 0){
112
+ tab.classList.remove('off');
113
+ tab.textContent = ch.name;
114
+ } else {
115
+ tab.style.opacity = '0.35';
116
+ tab.textContent = ch.name + ' ✕';
117
+ }
118
+ }
119
+ });
120
+ }
121
+
122
+ function _vtvRetry(){
123
+ if(_vtvCurrentCh) _vtvPlay(_vtvCurrentCh);
124
+ }
125
+
126
+ function _vtvPlay(chId){
127
+ const ch = VTV_CHANNELS.find(c => c.id === chId);
128
+ if(!ch) return;
129
+ _vtvCurrentCh = chId;
130
+ document.querySelectorAll('.vtv-tab').forEach(t => t.classList.remove('on'));
131
+ const tab = document.getElementById('vtvt-'+chId);
132
+ if(tab) tab.classList.add('on');
133
+ const video = document.getElementById('vtv-player');
134
+ const errEl = document.getElementById('vtv-err');
135
+ const loadEl = document.getElementById('vtv-load');
136
+ const errMsg = document.getElementById('vtv-err-msg');
137
+ video.style.display = 'none';
138
+ errEl.style.display = 'none';
139
+ loadEl.style.display = 'flex';
140
+ loadEl.innerHTML = '<div class="vtv-spinner"></div>Đang kết nối ' + ch.name + '...';
141
+ if(_vtvHls){ _vtvHls.destroy(); _vtvHls = null; }
142
+ const urls = _vtvStreams[chId] || [];
143
+ if(urls.length === 0){
144
+ loadEl.style.display = 'none';
145
+ errEl.style.display = 'flex';
146
+ errMsg.textContent = chId === 'vtvprime' ? 'VTVPrime: Kênh trả phí.' : ch.name + ': Không tìm thấy luồng.';
147
+ return;
148
+ }
149
+ const epgWrap = document.getElementById('vtv-epg-wrap');
150
+ if(epgWrap) epgWrap.innerHTML = buildVTVEPG(chId);
151
+ _vtvTryPlay(video, urls, 0, ch.name, loadEl, errEl, errMsg);
152
+ }
153
+
154
+ function _vtvTryPlay(video, urls, idx, name, loadEl, errEl, errMsg){
155
+ if(idx >= urls.length){
156
+ loadEl.style.display = 'none';
157
+ errEl.style.display = 'flex';
158
+ errMsg.textContent = name + ': Tất cả nguồn đều lỗi. Thử lại sau.';
159
+ return;
160
+ }
161
+ const src = urls[idx];
162
+ loadEl.innerHTML = '<div class="vtv-spinner"></div>Đang kết nối ' + name + ' (' + (idx+1) + '/' + urls.length + ')...';
163
+ if(typeof Hls !== 'undefined' && Hls.isSupported()){
164
+ const hls = new Hls({enableWorker:true,lowLatencyMode:true,startLevel:-1,capLevelToPlayerSize:true,maxBufferLength:15,maxMaxBufferLength:30});
165
+ _vtvHls = hls;
166
+ hls.loadSource(src);
167
+ hls.attachMedia(video);
168
+ hls.on(Hls.Events.MANIFEST_PARSED, () => { video.play().catch(()=>{}); loadEl.style.display='none'; video.style.display='block'; });
169
+ let recAttempts = 0;
170
+ hls.on(Hls.Events.ERROR, (ev, data) => {
171
+ if(data.fatal){
172
+ if(data.type === Hls.ErrorTypes.NETWORK_ERROR){
173
+ recAttempts++;
174
+ if(recAttempts <= 2){ setTimeout(() => hls.startLoad(), 1500); }
175
+ else { hls.destroy(); _vtvHls = null; _vtvTryPlay(video, urls, idx+1, name, loadEl, errEl, errMsg); }
176
+ } else if(data.type === Hls.ErrorTypes.MEDIA_ERROR){
177
+ try { hls.recoverMediaError(); } catch(e) {}
178
+ } else { hls.destroy(); _vtvHls = null; _vtvTryPlay(video, urls, idx+1, name, loadEl, errEl, errMsg); }
179
+ }
180
+ });
181
+ } else if(video.canPlayType('application/vnd.apple.mpegurl')){
182
+ video.src = src;
183
+ video.addEventListener('loadedmetadata', () => { video.play().catch(()=>{}); loadEl.style.display='none'; video.style.display='block'; }, {once:true});
184
+ video.addEventListener('error', () => { _vtvTryPlay(video, urls, idx+1, name, loadEl, errEl, errMsg); }, {once:true});
185
+ } else {
186
+ loadEl.style.display = 'none'; errEl.style.display = 'flex'; errMsg.textContent = 'Trình duyệt không hỗ trợ HLS';
187
+ }
188
+ }
189
+
190
+ // === LOAD HOME ===
191
+ async function loadHome(){
192
+ // Load VTV streams in parallel (non-blocking)
193
+ loadVTVStreams();
194
+
195
+ const[featured,sh,wall,hlLeagues,ai,wcData]=await Promise.all([
196
+ fetch('/api/livescore/featured').then(r=>r.json()).catch(()=>null),
197
+ fetch('/api/shorts').then(r=>r.json()).catch(()=>[]),
198
+ fetch('/api/wall').then(r=>r.json()).catch(()=>({posts:[]})),
199
+ fetch('/api/highlights/leagues').then(r=>r.json()).catch(()=>({})),
200
+ fetch('/api/genk_ai').then(r=>r.json()).catch(()=>[]),
201
+ fetch('/api/wc2026').then(r=>r.json()).catch(()=>null)
202
+ ]);
203
+ _hlLeagueData=hlLeagues;
204
+ _wc2026Data=wcData;
205
+ _shortsData=interleaveShorts(sh||[]);
206
+ _wallPosts=(wall&&wall.posts)||[];
207
+ let h='';
208
+
209
+ // VTV BLOCK — first thing on homepage
210
+ h += buildVTVBlockHTML();
211
+
212
+ if(featured&&featured.home&&featured.event_id){
213
+ const sc=featured.status==='live'?'':'upcoming';
214
+ const st=featured.status==='live'?`🔴 ${featured.minute||'LIVE'}`:`⏰ ${featured.time}`;
215
+ const eid=String(featured.event_id||'').replace(/[<>&"']/g,'');
216
+ const mUrl=String(featured.url||'').replace(/[<>&"']/g,'');
217
+ const fH=String(featured.home||'').replace(/[<>&"']/g,'');
218
+ const fA=String(featured.away||'').replace(/[<>&"']/g,'');
219
+ const fL=String(featured.league||'').replace(/[<>&"']/g,'');
220
+ const fS=String(featured.score||'VS').replace(/[<>&"']/g,'');
221
+ const fHL=String(featured.home_logo||'').replace(/[<>&"']/g,'');
222
+ const fAL=String(featured.away_logo||'').replace(/[<>&"']/g,'');
223
+ h+=`<div class="featured-match" data-event-id="${eid}" data-url="${mUrl}" onclick="openMatch('${eid}','${mUrl}')" title="${fH} vs ${fA} — ${fL}">`+
224
+ `<div class="fm-league">${fL}</div>`+
225
+ `<div class="fm-teams">`+
226
+ `<div class="fm-team"><img src="${fHL}" onerror="this.style.display='none'"><span>${fH}</span></div>`+
227
+ `<div class="fm-score">${fS}</div>`+
228
+ `<div class="fm-team"><img src="${fAL}" onerror="this.style.display='none'"><span>${fA}</span></div>`+
229
+ `</div>`+
230
+ `<div class="fm-status ${sc}">${st}</div>`+
231
+ `</div>`;
232
+ }
233
+ 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>`;
234
+ h+='<div id="hashtag-box"></div>';
235
+ 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>`;
236
+ 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>`;
237
+ const wallPosts=_wallPosts;
238
+ const aiShorts=wallPosts.filter(p=>p.video);
239
+ 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>';}
240
+ 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>';}
241
+ 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>';}
242
+ 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:"🤝"}};
243
+ 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>';}
244
+ 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>';}
245
+ document.getElementById('view-home').innerHTML=h;
246
+ loadLivescore('today');loadHotTopics();
247
+ if(_wc2026Data)switchWCTab('news');
248
+ }
249
+
250
+ // === WALL POST HELPERS ===
251
+ function makeWallItem(p,i){
252
+ const hasVideo = p.video && p.video.length > 0;
253
+ const thumbContent = p.img
254
+ ? `<img src="${esc(p.img)}" onerror="this.style.display='none'">`
255
+ : (hasVideo ? `<video src="${esc(p.video)}" muted></video>` : '');
256
+ const videoBadge = hasVideo
257
+ ? `<div class="wall-video-badge">🎬</div>`
258
+ : '';
259
+ const videoBtn = hasVideo
260
+ ? `<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed(${i})">▶ Xem Short</button>`
261
+ : `<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo('${esc(p.id||i)}',this)">🎬 Tạo Video</button>`;
262
+
263
+ return `<div class="wall-item" id="wall-item-${esc(p.id||i)}">
264
+ <div class="wall-thumb">
265
+ ${thumbContent}
266
+ ${videoBadge}
267
+ </div>
268
+ <div class="wall-title">${esc(p.title)}</div>
269
+ <div class="wall-text">${esc((p.text||'').slice(0,180))}</div>
270
+ <div class="wall-actions">
271
+ <button class="primary" onclick="readWallPost(${i})">Xem</button>
272
+ ${videoBtn}
273
+ </div>
274
+ </div>`;
275
+ }
276
+
277
+ // === GENERATE SHORT VIDEO FOR A WALL POST ===
278
+ async function makeShortVideo(postId, btn, voice, speed){
279
+ if(!postId)return;
280
+ const origText = btn ? btn.textContent : '🎬 Tạo Video';
281
+ if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo...';}
282
+ toast('⏳ Đang tạo video shorts...');
283
+ try{
284
+ let url = '/api/ai/short/'+encodeURIComponent(postId);
285
+ const params = [];
286
+ if(voice) params.push('voice='+encodeURIComponent(voice));
287
+ if(speed) params.push('speed='+encodeURIComponent(speed));
288
+ if(params.length) url += '?' + params.join('&');
289
+ const r = await fetch(url, {method:'POST'});
290
+ const j = await r.json();
291
+ if(!r.ok || j.error) throw new Error(j.error||'Lỗi tạo video');
292
+ toast('✅ Đã tạo video shorts!');
293
+ const p = _wallPosts.find(x => String(x.id) === String(postId));
294
+ if(p){
295
+ p.video = j.video;
296
+ const itemId = 'wall-item-'+postId;
297
+ const el = document.getElementById(itemId);
298
+ if(el){
299
+ const idx = _wallPosts.indexOf(p);
300
+ el.outerHTML = makeWallItem(p, idx);
301
+ const newEl = document.getElementById(itemId);
302
+ if(newEl) newEl.className = 'wall-item wall-item-new';
303
+ }
304
+ }
305
+ refreshShortAISlider();
306
+ }catch(e){
307
+ toast('❌ '+e.message);
308
+ if(btn){btn.disabled=false;btn.textContent=origText;}
309
+ }
310
+ }
311
+
312
+ // Refresh Short AI slider after video generation
313
+ function refreshShortAISlider(){
314
+ const aiShorts = _wallPosts.filter(p=>p.video);
315
+ let shortAISection = document.getElementById('short-ai-section');
316
+ if(aiShorts.length === 0){
317
+ if(shortAISection) shortAISection.remove();
318
+ return;
319
+ }
320
+ if(shortAISection){
321
+ const track = shortAISection.querySelector('.slider-track');
322
+ if(track){
323
+ let h = '';
324
+ aiShorts.slice(0,20).forEach((p,i)=>{
325
+ 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>`;
326
+ });
327
+ track.innerHTML = h;
328
+ }
329
+ }
330
+ }
331
+
332
+ function prependWallPost(post){
333
+ _wallPosts.unshift(post);
334
+ const track=document.getElementById('ai-wall-track');
335
+ const wrap=document.getElementById('ai-wall-wrap');
336
+ const homeEl=document.getElementById('view-home');
337
+ if(!track||!wrap){
338
+ if(homeEl){
339
+ let insertBefore=homeEl.querySelector('.slider-wrap');
340
+ const newWrap=document.createElement('div');
341
+ newWrap.className='slider-wrap';
342
+ newWrap.id='ai-wall-wrap';
343
+ 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>`;
344
+ if(insertBefore){
345
+ homeEl.insertBefore(newWrap,insertBefore);
346
+ }else{
347
+ homeEl.appendChild(newWrap);
348
+ }
349
+ const firstItem=newWrap.querySelector('.wall-item');
350
+ if(firstItem)firstItem.className='wall-item wall-item-new';
351
+ }
352
+ return;
353
+ }
354
+ const div=document.createElement('div');
355
+ div.className='wall-item wall-item-new';
356
+ div.id='wall-item-'+(post.id||'new-'+Date.now());
357
+ const hasVideo = post.video && post.video.length > 0;
358
+ const thumbContent = post.img
359
+ ? `<img src="${esc(post.img)}" onerror="this.style.display='none'">`
360
+ : (hasVideo ? `<video src="${esc(post.video)}" muted></video>` : '');
361
+ const videoBadge = hasVideo ? `<div class="wall-video-badge">🎬</div>` : '';
362
+ const videoBtn = hasVideo
363
+ ? `<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed(0)">▶ Xem Short</button>`
364
+ : `<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo('${esc(post.id)}',this)">🎬 Tạo Video</button>`;
365
+ 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>`;
366
+ track.prepend(div);
367
+ track.scrollTo({left:0,behavior:'smooth'});
368
+ if(hasVideo) refreshShortAISlider();
369
+ }
370
+
371
+ // === REST OF FUNCTIONS ===
372
+ let _shortsData=[];
373
+ let _wallPosts=[];
374
+ let _currentView='home';
375
+ let _currentEventId=null;
376
+ let _currentMatchUrl=null;
377
+ 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;}
378
+ let _htPage=0,_htTopic='';
379
+ 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);}}
380
+ 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);}
381
+ 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>`;}}
382
+ function loadMoreHashtag(){_htPage++;const btn=document.getElementById('ht-more');if(btn){btn.disabled=true;btn.textContent='Đang tải...';}showHashtagSources(_htTopic,_htPage);}
383
+ 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';}}}
384
+ 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>';}}
385
+ function bindMatchClicks(el){
386
+ if(!el) return;
387
+ el.querySelectorAll('.match-detail').forEach(md=>{
388
+ if(md._bound) return;
389
+ md._bound = true;
390
+ md.style.cursor='pointer';
391
+ md.addEventListener('click',function(e){
392
+ // Skip if clicking directly on a link
393
+ if(e.target.closest('a')) return;
394
+ const statusA=this.querySelector('.status a');
395
+ const teamA=this.querySelector('.teams a[href*="/tran-dau/"]');
396
+ const a = statusA || teamA;
397
+ if(a){
398
+ e.preventDefault();
399
+ e.stopPropagation();
400
+ const href=a.getAttribute('href')||'';
401
+ const m=href.match(/\/tran-dau\/(\d+)\//);
402
+ if(m){
403
+ const fullUrl=href.startsWith('http')?href:'https://bongda.com.vn'+href;
404
+ openMatch(m[1],fullUrl);
405
+ }
406
+ }
407
+ });
408
+ });
409
+ }
410
+ 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')}
411
+ function closeMatch(){document.getElementById('match-overlay').classList.remove('active');document.body.style.overflow=''}
412
+ 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>'}}
413
+ 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;}}
414
+ 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};}}
415
+ 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[];}}
416
+ 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[];}}
417
+ 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>`;}
418
+ 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);}}
419
+ 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);}}
420
+ 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);}
421
+ 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);}}
422
+ 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);}
423
+ 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;}
424
+ 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);}
425
+ 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)}
426
+ 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();}
427
+ 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();}
428
+ 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();}
429
+ 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>`;}
430
+ 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)}}
431
+ 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)}}
432
+ 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}}
433
+ async function readWallPost(i){const p=_wallPosts[i];if(!p)return;showView('view-article');
434
+ const images = p.images || [];
435
+ let imgGallery = '';
436
+ if(images.length > 0){
437
+ imgGallery = '<div class="article-image-gallery">';
438
+ images.forEach((imgUrl, idx) => {
439
+ if(idx === 0){
440
+ imgGallery += `<img class="article-img article-hero-img" src="${esc(imgUrl)}" onerror="this.style.display='none" loading="eager">`;
441
+ } else {
442
+ if(idx === 1) imgGallery += '<div class="gallery-thumbs">';
443
+ imgGallery += `<div class="gallery-thumb"><img src="${esc(imgUrl)}" onerror="this.parentElement.style.display='none'" loading="lazy"></div>`;
444
+ }
445
+ });
446
+ if(images.length > 1) imgGallery += '</div>';
447
+ imgGallery += '</div>';
448
+ }
449
+ const hasVideo = p.video && p.video.length > 0;
450
+ const voiceOptions = [
451
+ {id:'hoaimy', label:'🎙️ Nữ — Hoài My'},
452
+ {id:'namminh', label:'🎙️ Nam — Nam Minh'},
453
+ ];
454
+ let voiceSelector = '';
455
+ if(!hasVideo){
456
+ voiceSelector = `<div class="tts-selector"><div class="tts-selector-label">🎙️ Chọn giọng đọc:</div><div class="tts-voice-btns">`;
457
+ voiceOptions.forEach(v=>{
458
+ 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>`;
459
+ });
460
+ 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>`;
461
+ voiceSelector += `<input type="hidden" id="selected-voice" value="hoaimy"></div>`;
462
+ }
463
+ 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>`;
464
+ const firstVoiceBtn = document.querySelector('.tts-voice-btn');
465
+ if(firstVoiceBtn) firstVoiceBtn.classList.add('active');
466
+ window.scrollTo(0,0)}
467
+ 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>'}}
468
+ 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}
469
+ 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(()=>{});
470
+
471
+ // === AUTO-OPEN SHARE LINKS (/s?url=... sets pending_article) ===
472
+ (function(){
473
+ try{
474
+ const pa=localStorage.getItem('pending_article');
475
+ const pv=localStorage.getItem('pending_video');
476
+ if(pa){
477
+ localStorage.removeItem('pending_article');
478
+ setTimeout(()=>{
479
+ if(typeof readArticle==='function') readArticle(pa);
480
+ },1500);
481
+ }
482
+ if(pv){
483
+ localStorage.removeItem('pending_video');
484
+ try{
485
+ const v=JSON.parse(pv);
486
+ if(v&&v.url) setTimeout(()=>{window.open(v.url,'_blank')},1500);
487
+ }catch(e){}
488
+ }
489
+ }catch(e){}
490
+ })();
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/app_v5.js ADDED
@@ -0,0 +1,391 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // === VNEWS Frontend v5 — VTV multi-source + fixed CSS/EPG/livescore ===
2
+
3
+ // ===== VTV CHANNELS =====
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:'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ý'}],
20
+ vtv2:[{t:'06:00',n:'Khoa học & CN'},{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'},{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 KH'}],
21
+ 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 đặc biệt'},{t:'22:00',n:'Đêm giải trí'}],
22
+ 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'}],
23
+ vtv5:[{t:'06:00',n:'Thời sự miền Nam'},{t:'08:00',n:'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 MN'},{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'}],
24
+ 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'}],
25
+ 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 GD'},{t:'22:00',n:'Học suốt đời'}],
26
+ 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 MT'},{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'}],
27
+ 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 MB'},{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'}],
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:'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 TNB'},{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'}],
29
+ 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'}],
30
+ };
31
+
32
+ let _vtvStreams = {}; // chId -> [proxyUrl, proxyUrl, ...]
33
+ let _vtvCurrentCh = null;
34
+ let _vtvHls = null;
35
+
36
+ // ===== VTV EPG =====
37
+ function buildVTVEPG(chId){
38
+ const epg = VTV_EPG[chId] || [];
39
+ if(!epg.length) return '';
40
+ const curH = new Date().getHours();
41
+ let items = '';
42
+ epg.forEach(item => {
43
+ const itemH = parseInt(item.t.split(':')[0], 10);
44
+ const isNow = itemH <= curH && (itemH + 2) > curH;
45
+ items += `<div class="vtv-epg-item${isNow?' now':''}"><span class="epg-t">${item.t}</span><span class="epg-n">${item.n}</span></div>`;
46
+ });
47
+ return `<div class="vtv-epg" id="vtv-epg">
48
+ <div class="vtv-epg-title">📋 Lịch phát sóng</div>
49
+ <div class="vtv-epg-list" id="vtv-epg-list">${items}</div>
50
+ </div>`;
51
+ }
52
+
53
+ // ===== VTV BLOCK HTML =====
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-player-area">
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 id="vtv-epg-wrap"></div>
68
+ </div>`;
69
+ }
70
+
71
+ // ===== LOAD VTV STREAMS from API (multi-source) =====
72
+ async function loadVTVStreams(){
73
+ try {
74
+ const r = await fetch('/api/vtv/streams', {signal: AbortSignal.timeout(10000)});
75
+ if(r.ok){
76
+ const data = await r.json();
77
+ VTV_CHANNELS.forEach(ch => {
78
+ const info = data[ch.id];
79
+ const sources = (info && info.all_sources) ? info.all_sources : (info && info.stream_url ? [info.stream_url] : []);
80
+ _vtvStreams[ch.id] = sources.map(u => '/api/proxy/m3u8/vtv?url=' + encodeURIComponent(u));
81
+ });
82
+ }
83
+ } catch(e) {
84
+ console.warn('VTV API error:', e);
85
+ }
86
+ VTV_CHANNELS.forEach(ch => {
87
+ const tab = document.getElementById('vtvt-'+ch.id);
88
+ if(tab){
89
+ if(_vtvStreams[ch.id] && _vtvStreams[ch.id].length > 0){
90
+ tab.classList.remove('off');
91
+ tab.textContent = ch.name;
92
+ } else {
93
+ tab.style.opacity = '0.35';
94
+ tab.textContent = ch.name + ' ✕';
95
+ }
96
+ }
97
+ });
98
+ }
99
+
100
+ function _vtvRetry(){
101
+ if(_vtvCurrentCh) _vtvPlay(_vtvCurrentCh);
102
+ }
103
+
104
+ // ===== PLAY CHANNEL with multi-source failover =====
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
+ // Update EPG
129
+ const epgWrap = document.getElementById('vtv-epg-wrap');
130
+ if(epgWrap) epgWrap.innerHTML = buildVTVEPG(chId);
131
+ _vtvTryPlay(video, urls, 0, ch.name, loadEl, errEl, errMsg);
132
+ }
133
+
134
+ function _vtvTryPlay(video, urls, idx, name, loadEl, errEl, errMsg){
135
+ if(idx >= urls.length){
136
+ loadEl.style.display = 'none';
137
+ errEl.style.display = 'flex';
138
+ errMsg.textContent = name + ': Tất cả nguồn đều lỗi. Thử lại sau.';
139
+ return;
140
+ }
141
+ const src = urls[idx];
142
+ loadEl.innerHTML = '<div class="vtv-spinner"></div>Đang kết nối ' + name + ' (' + (idx+1) + '/' + urls.length + ')...';
143
+ if(typeof Hls !== 'undefined' && Hls.isSupported()){
144
+ const hls = new Hls({
145
+ enableWorker: true,
146
+ lowLatencyMode: true,
147
+ startLevel: -1,
148
+ capLevelToPlayerSize: true,
149
+ maxBufferLength: 15,
150
+ maxMaxBufferLength: 30,
151
+ });
152
+ _vtvHls = hls;
153
+ hls.loadSource(src);
154
+ hls.attachMedia(video);
155
+ hls.on(Hls.Events.MANIFEST_PARSED, () => {
156
+ video.play().catch(()=>{});
157
+ loadEl.style.display = 'none';
158
+ video.style.display = 'block';
159
+ });
160
+ let recAttempts = 0;
161
+ hls.on(Hls.Events.ERROR, (ev, data) => {
162
+ if(data.fatal){
163
+ if(data.type === Hls.ErrorTypes.NETWORK_ERROR){
164
+ recAttempts++;
165
+ if(recAttempts <= 2){ setTimeout(() => hls.startLoad(), 1500); }
166
+ else { hls.destroy(); _vtvHls = null; _vtvTryPlay(video, urls, idx+1, name, loadEl, errEl, errMsg); }
167
+ } else if(data.type === Hls.ErrorTypes.MEDIA_ERROR){
168
+ try { hls.recoverMediaError(); } catch(e) {}
169
+ } else {
170
+ hls.destroy(); _vtvHls = null; _vtvTryPlay(video, urls, idx+1, name, loadEl, errEl, errMsg);
171
+ }
172
+ }
173
+ });
174
+ } else if(video.canPlayType('application/vnd.apple.mpegurl')){
175
+ video.src = src;
176
+ video.addEventListener('loadedmetadata', () => { video.play().catch(()=>{}); loadEl.style.display='none'; video.style.display='block'; }, {once:true});
177
+ video.addEventListener('error', () => { _vtvTryPlay(video, urls, idx+1, name, loadEl, errEl, errMsg); }, {once:true});
178
+ } else {
179
+ loadEl.style.display = 'none'; errEl.style.display = 'flex'; errMsg.textContent = 'Trình duyệt không hỗ trợ HLS';
180
+ }
181
+ }
182
+
183
+ // ===== LOAD HOME =====
184
+ async function loadHome(){
185
+ const[featured,sh,wall,hlLeagues,ai,wcData]=await Promise.all([
186
+ fetch('/api/livescore/featured').then(r=>r.json()).catch(()=>null),
187
+ fetch('/api/shorts').then(r=>r.json()).catch(()=>[]),
188
+ fetch('/api/wall').then(r=>r.json()).catch(()=>({posts:[]})),
189
+ fetch('/api/highlights/leagues').then(r=>r.json()).catch(()=>({})),
190
+ fetch('/api/genk_ai').then(r=>r.json()).catch(()=>[]),
191
+ fetch('/api/wc2026').then(r=>r.json()).catch(()=>null)
192
+ ]);
193
+ _hlLeagueData=hlLeagues;
194
+ _wc2026Data=wcData;
195
+ _shortsData=interleaveShorts(sh||[]);
196
+ _wallPosts=(wall&&wall.posts)||[];
197
+ let h='';
198
+
199
+ // VTV BLOCK — first thing on homepage
200
+ h += buildVTVBlockHTML();
201
+
202
+ if(featured&&featured.home){
203
+ const sc=featured.status==='live'?'':'upcoming';
204
+ const st=featured.status==='live'?`🔴 ${featured.minute||'LIVE'}`:`⏰ ${featured.time}`;
205
+ const eid=String(featured.event_id||'').replace(/[<>&"']/g,'');
206
+ const mUrl=String(featured.url||'').replace(/[<>&"']/g,'');
207
+ const fH=String(featured.home||'').replace(/[<>&"']/g,'');
208
+ const fA=String(featured.away||'').replace(/[<>&"']/g,'');
209
+ const fL=String(featured.league||'').replace(/[<>&"']/g,'');
210
+ const fS=String(featured.score||'VS').replace(/[<>&"']/g,'');
211
+ const fHL=String(featured.home_logo||'').replace(/[<>&"']/g,'');
212
+ const fAL=String(featured.away_logo||'').replace(/[<>&"']/g,'');
213
+ h+=`<div class="featured-match" data-event-id="${eid}" data-url="${mUrl}" onclick="openMatch('${eid}','${mUrl}')" title="${fH} vs ${fA} — ${fL}">`+
214
+ `<div class="fm-league">${fL}</div>`+
215
+ `<div class="fm-teams">`+
216
+ `<div class="fm-team"><img src="${fHL}" onerror="this.style.display='none'"><span>${fH}</span></div>`+
217
+ `<div class="fm-score">${fS}</div>`+
218
+ `<div class="fm-team"><img src="${fAL}" onerror="this.style.display='none'"><span>${fA}</span></div>`+
219
+ `</div>`+
220
+ `<div class="fm-status ${sc}">${st}</div>`+
221
+ `</div>`;
222
+ }
223
+ 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>`;
224
+ h+='<div id="hashtag-box"></div>';
225
+ 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>`;
226
+ 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>`;
227
+ const wallPosts=_wallPosts;
228
+ const aiShorts=wallPosts.filter(p=>p.video);
229
+ 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>';}
230
+ 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>';}
231
+ 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>';}
232
+ 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:"🤝"}};
233
+ 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>';}
234
+ 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>';}
235
+ document.getElementById('view-home').innerHTML=h;
236
+
237
+ // Load VTV streams + auto-play
238
+ loadVTVStreams().then(() => {
239
+ const tryOrder = ['vtv6','vtv1','vtv2','vtv3','vtv4','vtv5','vtv7','vtv8','vtv9','vtv10'];
240
+ for(const chId of tryOrder){
241
+ if(_vtvStreams[chId] && _vtvStreams[chId].length > 0){
242
+ setTimeout(() => _vtvPlay(chId), 300);
243
+ return;
244
+ }
245
+ }
246
+ });
247
+
248
+ loadLivescore('today');loadHotTopics();
249
+ if(_wc2026Data)switchWCTab('news');
250
+ }
251
+
252
+ // ===== WALL POST HELPERS =====
253
+ function makeWallItem(p,i){
254
+ const hasVideo=p.video&&p.video.length>0;
255
+ const thumb=p.img?`<img src="${esc(p.img)}" onerror="this.style.display='none'">`:(hasVideo?`<video src="${esc(p.video)}" muted></video>`:'');
256
+ const badge=hasVideo?`<div class="wall-video-badge">🎬</div>`:'';
257
+ const btn=hasVideo?`<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed(${i})">▶ Xem Short</button>`:`<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo('${esc(p.id||i)}',this)">🎬 Tạo Video</button>`;
258
+ return `<div class="wall-item" id="wall-item-${esc(p.id||i)}"><div class="wall-thumb">${thumb}${badge}</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>${btn}</div></div>`;
259
+ }
260
+
261
+ async function makeShortVideo(postId,btn,voice,speed){
262
+ if(!postId)return;
263
+ const orig=btn?btn.textContent:'🎬 Tạo Video';
264
+ if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo...';}
265
+ toast('⏳ Đang tạo video shorts...');
266
+ try{
267
+ let url='/api/ai/short/'+encodeURIComponent(postId);
268
+ const params=[];if(voice)params.push('voice='+encodeURIComponent(voice));if(speed)params.push('speed='+encodeURIComponent(speed));
269
+ if(params.length)url+='?'+params.join('&');
270
+ const r=await fetch(url,{method:'POST'});const j=await r.json();
271
+ if(!r.ok||j.error)throw new Error(j.error||'Lỗi tạo video');
272
+ toast('✅ Đã tạo video shorts!');
273
+ const p=_wallPosts.find(x=>String(x.id)===String(postId));
274
+ if(p){p.video=j.video;const itemId='wall-item-'+postId;const el=document.getElementById(itemId);if(el){const idx=_wallPosts.indexOf(p);el.outerHTML=makeWallItem(p,idx);const n=document.getElementById(itemId);if(n)n.className='wall-item wall-item-new';}}
275
+ refreshShortAISlider();
276
+ }catch(e){toast('❌ '+e.message);if(btn){btn.disabled=false;btn.textContent=orig;}}
277
+ }
278
+
279
+ function refreshShortAISlider(){
280
+ const aiShorts=_wallPosts.filter(p=>p.video);
281
+ let sec=document.getElementById('short-ai-section');
282
+ if(aiShorts.length===0){if(sec)sec.remove();return;}
283
+ if(sec){const t=sec.querySelector('.slider-track');if(t){let h='';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>`;});t.innerHTML=h;}}
284
+ }
285
+
286
+ function prependWallPost(post){
287
+ _wallPosts.unshift(post);
288
+ const track=document.getElementById('ai-wall-track');
289
+ const wrap=document.getElementById('ai-wall-wrap');
290
+ const homeEl=document.getElementById('view-home');
291
+ if(!track||!wrap){
292
+ if(homeEl){
293
+ let ib=homeEl.querySelector('.slider-wrap');
294
+ const nw=document.createElement('div');nw.className='slider-wrap';nw.id='ai-wall-wrap';
295
+ nw.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>`;
296
+ if(ib)homeEl.insertBefore(nw,ib);else homeEl.appendChild(nw);
297
+ const fi=nw.querySelector('.wall-item');if(fi)fi.className='wall-item wall-item-new';
298
+ }
299
+ return;
300
+ }
301
+ const div=document.createElement('div');div.className='wall-item wall-item-new';div.id='wall-item-'+(post.id||'new-'+Date.now());
302
+ const hasVideo=post.video&&post.video.length>0;
303
+ const thumb=post.img?`<img src="${esc(post.img)}" onerror="this.style.display='none'">`:(hasVideo?`<video src="${esc(post.video)}" muted></video>`:'');
304
+ const badge=hasVideo?`<div class="wall-video-badge">🎬</div>`:'';
305
+ const btn=hasVideo?`<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed(0)">▶ Xem Short</button>`:`<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo('${esc(post.id)}',this)">🎬 Tạo Video</button>`;
306
+ div.innerHTML=`<div class="wall-thumb">${thumb}${badge}</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>${btn}</div>`;
307
+ track.prepend(div);track.scrollTo({left:0,behavior:'smooth'});
308
+ if(hasVideo)refreshShortAISlider();
309
+ }
310
+
311
+ let _shortsData=[];let _wallPosts=[];let _currentView='home';let _currentEventId=null;let _currentMatchUrl=null;
312
+ function interleaveShorts(shorts){const dt=shorts.filter(s=>s.channel==='baodantri7941');const sk=shorts.filter(s=>s.channel==='baosuckhoedoisongboyte');const r=[];let i=0,j=0;while(i<dt.length||j<sk.length){if(i<dt.length)r.push(dt[i++]);if(j<sk.length)r.push(sk[j++]);}return r;}
313
+ let _htPage=0,_htTopic='';
314
+ 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 tt=t.topic||t.label.replace(/^#/,'');return`<button class="hot-chip" onclick="searchTopic('${tt.replace(/'/g,"\\'")}')">${esc(t.label)}</button>`;}).join('');if(j.topics&&j.topics[0]){const ft=j.topics[0].topic||j.topics[0].label.replace(/^#/,'');setTimeout(()=>searchTopic(ft),800);}}
315
+ 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);}
316
+ 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>`;}}
317
+ function loadMoreHashtag(){_htPage++;const btn=document.getElementById('ht-more');if(btn){btn.disabled=true;btn.textContent='Đang tải...';}showHashtagSources(_htTopic,_htPage);}
318
+ 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';}}}
319
+
320
+ // ===== LIVESCORE — FIXED with closest() =====
321
+ async function loadLivescore(tab){
322
+ document.querySelectorAll('.ls-tab').forEach(t=>t.classList.remove('active'));
323
+ document.querySelector(`.ls-tab[data-tab="${tab}"]`)?.classList.add('active');
324
+ const el=document.getElementById('ls-content');
325
+ if(!el)return;
326
+ el.innerHTML='<div class="loading">Đang tải...</div>';
327
+ let ep='/api/livescore/'+tab;
328
+ if(tab.startsWith('bxh_'))ep='/api/livescore/standings/'+tab.replace('bxh_','');
329
+ try{
330
+ const r=await fetch(ep);const d=await r.json();
331
+ el.innerHTML=d.html&&d.html.length>50?d.html:'<div class="loading">Không có dữ liệu</div>';
332
+ bindMatchClicks(el);
333
+ }catch(e){el.innerHTML='<div class="loading">Lỗi</div>';}
334
+ }
335
+
336
+ function bindMatchClicks(el){
337
+ if(!el) return;
338
+ el.querySelectorAll('.match-detail').forEach(md=>{
339
+ if(md._bound) return;
340
+ md._bound = true;
341
+ md.style.cursor='pointer';
342
+ md.addEventListener('click',function(e){
343
+ const a = e.target.closest('a[href*="/tran-dau/"]');
344
+ if(!a) return;
345
+ e.preventDefault();e.stopPropagation();
346
+ const href = a.getAttribute('href') || '';
347
+ const m = href.match(/\/tran-dau\/(\d+)\//);
348
+ if(m){
349
+ const fullUrl = href.startsWith('http') ? href : 'https://bongda.com.vn' + href;
350
+ openMatch(m[1], fullUrl);
351
+ }
352
+ });
353
+ });
354
+ }
355
+
356
+ 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')}
357
+ function closeMatch(){document.getElementById('match-overlay').classList.remove('active');document.body.style.overflow=''}
358
+ 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>'}}
359
+ 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;}}
360
+ 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};}}
361
+ 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[];}}
362
+ 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[];}}
363
+ 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>`;}
364
+ 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);}}
365
+ 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);}}
366
+ 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);}
367
+ 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);}}
368
+ 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);}
369
+ 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;}
370
+ 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);}
371
+ 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)}
372
+ 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();}
373
+ 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();}
374
+ 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();}
375
+ 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>`;}
376
+ 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)}}
377
+ 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)}}
378
+ 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}}
379
+ async function readWallPost(i){const p=_wallPosts[i];if(!p)return;showView('view-article');
380
+ const images=p.images||[];let imgGallery='';
381
+ if(images.length>0){imgGallery='<div class="article-image-gallery">';images.forEach((imgUrl,idx)=>{if(idx===0)imgGallery+=`<img class="article-img article-hero-img" src="${esc(imgUrl)}" onerror="this.style.display='none" loading="eager">`;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>`;}});if(images.length>1)imgGallery+='</div>';imgGallery+='</div>';}
382
+ const hasVideo=p.video&&p.video.length>0;
383
+ const voiceOptions=[{id:'hoaimy',label:'🎙️ Nữ — Hoài My'},{id:'namminh',label:'🎙️ Nam — Nam Minh'}];
384
+ let voiceSelector='';
385
+ if(!hasVideo){voiceSelector=`<div class="tts-selector"><div class="tts-selector-label">🎙️ Chọn giọng đọc:</div><div class="tts-voice-btns">`;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>`;});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>`;voiceSelector+=`<input type="hidden" id="selected-voice" value="hoaimy"></div>`;}
386
+ 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>`;
387
+ const firstVoiceBtn=document.querySelector('.tts-voice-btn');if(firstVoiceBtn)firstVoiceBtn.classList.add('active');window.scrollTo(0,0)}
388
+ 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>'}}
389
+ 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}
390
+ 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(()=>{});
391
+ (function(){try{const pa=localStorage.getItem('pending_article');const pv=localStorage.getItem('pending_video');if(pa){localStorage.removeItem('pending_article');setTimeout(()=>{if(typeof readArticle==='function')readArticle(pa);},1500);}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){}}}catch(e){}})();
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 ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* v2 - Force override broken inline CSS (.fm-league missing } + -wall-img-count bad selector) */
2
+ .featured-match{margin:6px 4px!important;background:linear-gradient(135deg,#1a2a1f,#0d1117)!important;border:1px solid #2d8659!important;border-radius:10px!important;padding:12px!important;cursor:pointer!important}
3
+ .fm-league{text-align:center!important;color:#5cb87a!important;font-size:9px!important;font-weight:700!important;text-transform:uppercase!important;display:block!important}
4
+ .fm-teams{display:flex!important;align-items:center!important;justify-content:center!important;gap:10px!important;margin-top:6px!important}
5
+ .fm-team{flex:1!important;display:flex!important;flex-direction:column!important;align-items:center!important;gap:4px!important}
6
+ .fm-team img{width:32px!important;height:32px!important;object-fit:contain!important}
7
+ .fm-team span{font-size:10px!important;color:#ccc!important;text-align:center!important}
8
+ .fm-score{font-size:22px!important;font-weight:900!important;min-width:60px!important;text-align:center!important;color:#fff!important}
9
+ .fm-status{text-align:center!important;margin-top:6px!important;font-size:9px!important;color:#e74c3c!important;font-weight:700!important}
10
+ .fm-status.upcoming{color:#f0c040!important}
11
+ .ai-compose{margin:6px 4px!important;background:#141414!important;border:1px solid #2a2a2a!important;border-radius:10px!important;padding:10px!important}
12
+ .ai-compose-title{font-size:13px!important;font-weight:800!important;color:#5cb87a!important;margin-bottom:8px!important}
13
+ .ai-compose-row{display:flex!important;gap:6px!important;margin-top:6px!important}
14
+ .ai-compose input{flex:1!important;background:#222!important;border:1px solid #333!important;color:#eee!important;border-radius:18px!important;padding:9px 12px!important;font-size:12px!important;min-width:0!important}
15
+ .ai-compose button{background:#2d8659!important;border:0!important;color:#fff!important;border-radius:18px!important;padding:9px 12px!important;font-size:11px!important;font-weight:700!important;cursor:pointer!important;white-space:nowrap!important}
16
+ .ai-compose button.secondary{background:#333!important}
17
+ .hot-topic-row{display:flex!important;gap:6px!important;overflow-x:auto!important;padding:4px 0!important}
18
+ .hot-chip{flex:0 0 auto!important;background:#222!important;border:1px solid #333!important;color:#ddd!important;border-radius:16px!important;padding:5px 10px!important;font-size:11px!important;cursor:pointer!important;white-space:nowrap!important}
19
+ .hashtag-sources{margin:8px 4px!important;background:#1a1a1a!important;border:1px solid #2a2a2a!important;border-radius:10px!important;padding:10px!important}
20
+ .hashtag-sources h3{font-size:13px!important;color:#5cb87a!important;margin-bottom:8px!important}
21
+ .hashtag-src-item{display:flex!important;gap:8px!important;padding:8px!important;background:#202020!important;border-radius:8px!important;margin:6px 0!important;cursor:pointer!important}
22
+ .hashtag-src-img{flex:0 0 80px!important;aspect-ratio:16/9!important;background:#333!important;border-radius:6px!important;overflow:hidden!important}
23
+ .hashtag-src-img img{width:100%!important;height:100%!important;object-fit:cover!important}
24
+ .hashtag-src-text{flex:1!important;min-width:0!important}
25
+ .hashtag-src-title{font-size:12px!important;font-weight:700!important;color:#eee!important;display:-webkit-box!important;-webkit-line-clamp:2!important;-webkit-box-orient:vertical!important;overflow:hidden!important}
26
+ .hashtag-src-via{font-size:10px!important;color:#888!important;margin-top:2px!important}
27
+ .hashtag-rewrite-btn{width:100%!important;margin-top:8px!important;background:#2d8659!important;border:0!important;color:#fff!important;padding:9px!important;border-radius:10px!important;font-size:12px!important;font-weight:700!important;cursor:pointer!important}
28
+ .hashtag-load-more{width:100%!important;margin-top:8px!important;background:#222!important;border:1px solid #333!important;color:#ccc!important;padding:9px!important;border-radius:10px!important;font-size:12px!important;cursor:pointer!important}
29
+ .hashtag-loading{display:flex!important;align-items:center!important;gap:8px!important;padding:12px!important;color:#888!important;font-size:12px!important}
30
+ .hashtag-spinner{width:16px!important;height:16px!important;border:2px solid #333!important;border-top-color:#5cb87a!important;border-radius:50%!important;animation:ht-spin .8s linear infinite!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}
37
+ .article-p{font-size:14px!important;line-height:1.7!important;color:#ccc!important;margin-bottom:10px!important}
38
+ .article-img{width:100%!important;border-radius:6px!important;margin:10px 0!important}
39
+ .article-actions{display:flex!important;gap:8px!important;flex-wrap:wrap!important;border-top:1px solid #333!important;margin-top:16px!important;padding-top:10px!important}
40
+ .article-actions button{background:#1a1a1a!important;border:1px solid #333!important;color:#ccc!important;padding:7px 12px!important;border-radius:14px!important;font-size:11px!important;cursor:pointer!important}
41
+ .article-actions button.primary{background:#2d8659!important;border-color:#2d8659!important;color:#fff!important}
42
+ .badge-ai{background:#2d8659!important;color:#fff!important;font-size:8px!important;padding:1px 5px!important;border-radius:3px!important;font-weight:700!important;display:inline-block!important}
43
+ /* TTS voice selector */
44
+ .tts-selector{margin:12px 0!important;padding:10px!important;background:#1a1a1a!important;border:1px solid #2a2a2a!important;border-radius:10px!important;width:100%!important}
45
+ .tts-selector-label{font-size:12px!important;font-weight:700!important;color:#5cb87a!important;margin-bottom:8px!important}
46
+ .tts-voice-groups{display:flex!important;flex-direction:column!important;gap:8px!important;max-height:280px!important;overflow-y:auto!important}
47
+ .tts-voice-group{background:#1e1e1e!important;border:1px solid #2a2a2a!important;border-radius:8px!important;padding:6px 8px!important}
48
+ .tts-voice-group-label{font-size:10px!important;font-weight:700!important;color:#888!important;margin-bottom:4px!important;text-transform:uppercase!important}
49
+ .tts-voice-btns{display:flex!important;gap:6px!important;flex-wrap:wrap!important}
50
+ .tts-voice-btn{background:#222!important;border:1px solid #333!important;color:#ddd!important;border-radius:12px!important;padding:6px 10px!important;font-size:10px!important;cursor:pointer!important;white-space:nowrap!important}
51
+ .tts-voice-btn.active{background:#2d8659!important;border-color:#2d8659!important;color:#fff!important;font-weight:700!important}
52
+ .tts-speed-row{display:flex!important;align-items:center!important;gap:8px!important;font-size:12px!important;color:#aaa!important;margin-top:10px!important}
53
+ .tts-speed-row select{flex:1!important;background:#222!important;border:1px solid #333!important;color:#eee!important;border-radius:14px!important;padding:6px 10px!important;font-size:11px!important}
54
+ /* Image gallery */
55
+ .article-image-gallery{margin:10px 0!important}
56
+ .article-hero-img{width:100%!important;border-radius:8px!important;margin-bottom:6px!important}
57
+ .gallery-thumbs{display:flex!important;gap:6px!important;overflow-x:auto!important;padding-bottom:4px!important}
58
+ .gallery-thumb{flex:0 0 100px!important;aspect-ratio:16/9!important;border-radius:6px!important;overflow:hidden!important;background:#222!important;cursor:pointer!important}
59
+ .gallery-thumb img{width:100%!important;height:100%!important;object-fit:cover!important}
60
+ /* === BADGE FIX (article AI badge) === */
61
+ .badge{display:inline-block!important;font-size:8px!important;padding:1px 5px!important;border-radius:3px!important;font-weight:700!important;vertical-align:middle!important}
62
+ .badge.badge-ai,.badge-ai{background:#2d8659!important;color:#fff!important}
63
+ /* article-view top spacing so badge+title not clipped */
64
+ .article-view .badge{margin-bottom:6px!important}
65
+ .article-view .tts-selector{width:auto!important;display:block!important}
66
+
67
+ /* ============================================================
68
+ 1:1 ("ratio-square") view for normal horizontal Dan tri / SKDS videos.
69
+ Spec: fill 100% of the slide HEIGHT, crop the excess WIDTH (cut left/right).
70
+ A YouTube <iframe> ignores object-fit, so instead of letterboxing we size the
71
+ iframe to FULL slide height and 16:9 width (177.78vh -> wider than a phone),
72
+ center it, and let the slide's overflow:hidden clip the sides.
73
+ The #tiktok-feed ancestor raises specificity above app_v2.js's injected
74
+ `.tiktok-slide.ratio-square>iframe` rule, so this wins regardless of CSS
75
+ load order (not just !important + source order).
76
+ ============================================================ */
77
+ #tiktok-feed .tiktok-slide.ratio-square{position:relative!important;overflow:hidden!important;display:block!important;background:#000!important}
78
+ #tiktok-feed .tiktok-slide.ratio-square>iframe,
79
+ #tiktok-feed .tiktok-slide.ratio-square>video{
80
+ position:absolute!important;
81
+ top:50%!important;
82
+ left:50%!important;
83
+ transform:translate(-50%,-50%)!important;
84
+ height:100%!important; /* 100% full height */
85
+ width:177.78vh!important; /* 16/9 of the height -> overflow on the sides */
86
+ min-width:100%!important; /* never narrower than the screen */
87
+ max-width:none!important;
88
+ max-height:none!important;
89
+ object-fit:cover!important; /* affects <video>; harmless for <iframe> */
90
+ }
91
+ /* Fallback copy at plain specificity too (in case the feed id ever differs) */
92
+ .tiktok-slide.ratio-square{position:relative!important;overflow:hidden!important;display:block!important;background:#000!important}
93
+ .tiktok-slide.ratio-square>iframe,
94
+ .tiktok-slide.ratio-square>video{
95
+ position:absolute!important;top:50%!important;left:50%!important;
96
+ transform:translate(-50%,-50%)!important;
97
+ height:100%!important;width:177.78vh!important;
98
+ min-width:100%!important;max-width:none!important;max-height:none!important;
99
+ object-fit:cover!important;
100
+ }
static/hot_multi.js ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }
18
+ }
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=>
25
+ fetch(`/api/hashtag/sources?topic=${encodeURIComponent(topic)}&page=0`).then(r=>r.json()).catch(()=>({sources:[]}))
26
+ ));
27
+ // Interleave from all topics
28
+ const all=[];const seen=new Set();
29
+ const mx=Math.max(...results.map(r=>(r.sources||[]).length));
30
+ for(let i=0;i<mx;i++){
31
+ for(let j=0;j<results.length;j++){
32
+ const src=(results[j].sources||[])[i];
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 ADDED
@@ -0,0 +1,769 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ /* VTV styles */
39
+ .vtv-wrap{margin:6px 4px;background:#111;border:1px solid #0066cc;border-radius:10px;overflow:hidden}
40
+ .vtv-head{display:flex;align-items:center;gap:8px;padding:8px 10px;background:linear-gradient(90deg,#003366,#1a1a1a)}
41
+ .vtv-title{font-size:13px;font-weight:800;color:#00ccff}
42
+ .vtv-badge{font-size:10px;font-weight:800;color:#00ccff;animation:vtvp 1.3s infinite}
43
+ @keyframes vtvp{0%,100%{opacity:1}50%{opacity:.3}}
44
+ .vtv-tabs{display:flex;gap:3px;padding:6px 8px;overflow-x:auto;scrollbar-width:none;background:#0d1a2a}
45
+ .vtv-tabs::-webkit-scrollbar{display:none}
46
+ .vtv-tab{padding:4px 8px;background:#1a2a3a;border:1px solid #2a3a4a;border-radius:10px;color:#8ab4d8;font-size:9px;cursor:pointer;white-space:nowrap;flex-shrink:0;transition:all .2s}
47
+ .vtv-tab:hover{background:#0b4a7a;color:#fff}
48
+ .vtv-tab.on{background:#0066cc;border-color:#00ccff;color:#fff;font-weight:700}
49
+ .vtv-tab.off{opacity:.35;pointer-events:none}
50
+ .vtv-frame{position:relative;width:100%;aspect-ratio:16/9;background:#000;min-height:180px}
51
+ .vtv-frame video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
52
+ .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}
53
+ .vtv-err button{background:#0066cc;border:none;color:#fff;padding:6px 14px;border-radius:8px;font-size:11px;cursor:pointer}
54
+ .vtv-load{display:flex;align-items:center;justify-content:center;height:180px;color:#00ccff;font-size:12px;flex-direction:column;gap:8px}
55
+ .vtv-spinner{width:24px;height:24px;border:2px solid #333;border-top-color:#00ccff;border-radius:50%;animation:vtvspin .8s linear infinite}
56
+ @keyframes vtvspin{to{transform:rotate(360deg)}}
57
+ .vtv-epg{margin:0;padding:6px 10px;background:#0a1628;border-top:1px solid #1a2a3a}
58
+ .vtv-epg-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}
59
+ .vtv-epg-title{font-size:10px;font-weight:700;color:#00ccff}
60
+ .vtv-epg-toggle{background:none;border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:2px 8px;border-radius:6px;cursor:pointer}
61
+ .vtv-epg-list{display:flex;gap:4px;overflow-x:auto;scrollbar-width:none;padding-bottom:4px}
62
+ .vtv-epg-list::-webkit-scrollbar{display:none}
63
+ .vtv-epg-item{flex:0 0 auto;padding:3px 6px;background:#1a2a3a;border-radius:4px;font-size:8px;color:#8ab4d8;white-space:nowrap}
64
+ .vtv-epg-item.now{background:#0066cc;color:#fff;font-weight:700}
65
+ .vtv-epg-item .epg-t{font-size:7px;color:#6a8aaa}
66
+ .vtv-epg-item.now .epg-t{color:#aaccee}
67
+ .vtv-epg-item .epg-n{color:#ccc;font-size:8px}
68
+ .vtv-epg-item.now .epg-n{color:#fff}
69
+ </style>
70
+ </head>
71
+ <body>
72
+ <div class="header"><h1>📰 VNEWS</h1><p>Tin tức · Bóng đá LIVE · Video · AI · World Cup 2026</p></div>
73
+ <div class="cats" id="cat-bar"></div>
74
+ <div id="view-home" class="view active"><div class="loading">Đang tải...</div></div>
75
+ <div id="view-cat" class="view"></div>
76
+ <div id="view-video" class="view"></div>
77
+ <div id="view-tiktok" class="view"></div>
78
+ <div id="view-article" class="view"></div>
79
+ <div class="match-overlay" id="match-overlay">
80
+ <div class="mo-header"><h3 id="mo-title">Chi tiết trận đấu</h3><button class="mo-close" onclick="closeMatch()">✕</button></div>
81
+ <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>
82
+ <div class="mo-body" id="mo-body"><div class="loading">Đang tải...</div></div>
83
+ </div>
84
+ <div id="progress-toast"></div>
85
+ <script>
86
+ var _cats=[],_hlLeagueData={},_currentArticle=null;window._currentEventId='';
87
+ function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]))}
88
+ function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')}
89
+ 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)}}
90
+ function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}}
91
+ 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(()=>{})}
92
+ var SPACE=location.origin;
93
+ </script>
94
+ <script>
95
+ // === VNEWS Frontend v2 - Full Functions ===
96
+ // Updated: Voice selector + speed control + image gallery + auto voice detect
97
+
98
+ // === LOAD HOME ===
99
+ async function loadHome(){
100
+ const[featured,sh,wall,hlLeagues,ai,wcData]=await Promise.all([
101
+ fetch('/api/livescore/featured').then(r=>r.json()).catch(()=>null),
102
+ fetch('/api/shorts').then(r=>r.json()).catch(()=>[]),
103
+ fetch('/api/wall').then(r=>r.json()).catch(()=>({posts:[]})),
104
+ fetch('/api/highlights/leagues').then(r=>r.json()).catch(()=>({})),
105
+ fetch('/api/genk_ai').then(r=>r.json()).catch(()=>[]),
106
+ fetch('/api/wc2026').then(r=>r.json()).catch(()=>null)
107
+ ]);
108
+ _hlLeagueData=hlLeagues;
109
+ _wc2026Data=wcData;
110
+ _shortsData=interleaveShorts(sh||[]);
111
+ _wallPosts=(wall&&wall.posts)||[];
112
+ let h='';
113
+ if(featured&&featured.home){
114
+ const sc=featured.status==='live'?'':'upcoming';
115
+ const st=featured.status==='live'?`🔴 ${featured.minute||'LIVE'}`:`⏰ ${featured.time}`;
116
+ // Safely encode for HTML attribute: escape quotes, angle brackets, ampersands
117
+ const eid = String(featured.event_id||'').replace(/[<>&"']/g,'');
118
+ const mUrl = String(featured.url||'').replace(/[<>&"']/g,'');
119
+ const fHome = String(featured.home||'').replace(/[<>&"']/g,'');
120
+ const fAway = String(featured.away||'').replace(/[<>&"']/g,'');
121
+ const fLeague = String(featured.league||'').replace(/[<>&"']/g,'');
122
+ const fScore = String(featured.score||'VS').replace(/[<>&"']/g,'');
123
+ const fHomeLogo = String(featured.home_logo||'').replace(/[<>&"']/g,'');
124
+ const fAwayLogo = String(featured.away_logo||'').replace(/[<>&"']/g,'');
125
+ const safeTitle = `${fHome} vs ${fAway} — ${fLeague}`;
126
+ h+=`<div class="featured-match" data-event-id="${eid}" data-url="${mUrl}" onclick="openMatch('${eid}','${mUrl}')" title="${safeTitle}">`+
127
+ `<div class="fm-league">${fLeague}</div>`+
128
+ `<div class="fm-teams">`+
129
+ `<div class="fm-team"><img src="${fHomeLogo}" onerror="this.style.display='none'"><span>${fHome}</span></div>`+
130
+ `<div class="fm-score">${fScore}</div>`+
131
+ `<div class="fm-team"><img src="${fAwayLogo}" onerror="this.style.display='none'"><span>${fAway}</span></div>`+
132
+ `</div>`+
133
+ `<div class="fm-status ${sc}">${st}</div>`+
134
+ `</div>`;
135
+ }
136
+ 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>`;
137
+ h+='<div id="hashtag-box"></div>';
138
+ 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>`;
139
+ 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>`;
140
+ const wallPosts=_wallPosts;
141
+ const aiShorts=wallPosts.filter(p=>p.video);
142
+ 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>';}
143
+ 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>';}
144
+ 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>';}
145
+ 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:"🤝"}};
146
+ 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>';}
147
+ 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>';}
148
+ document.getElementById('view-home').innerHTML=h;
149
+ loadLivescore('today');loadHotTopics();
150
+ if(_wc2026Data)switchWCTab('news');
151
+ }
152
+
153
+ // === WALL POST HELPERS ===
154
+ function makeWallItem(p,i){
155
+ const hasVideo = p.video && p.video.length > 0;
156
+ const thumbContent = p.img
157
+ ? `<img src="${esc(p.img)}" onerror="this.style.display='none'">`
158
+ : (hasVideo ? `<video src="${esc(p.video)}" muted></video>` : '');
159
+ const videoBadge = hasVideo
160
+ ? `<div class="wall-video-badge">🎬</div>`
161
+ : '';
162
+ const videoBtn = hasVideo
163
+ ? `<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed(${i})">▶ Xem Short</button>`
164
+ : `<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo('${esc(p.id||i)}',this)">🎬 Tạo Video</button>`;
165
+
166
+ return `<div class="wall-item" id="wall-item-${esc(p.id||i)}">
167
+ <div class="wall-thumb">
168
+ ${thumbContent}
169
+ ${videoBadge}
170
+ </div>
171
+ <div class="wall-title">${esc(p.title)}</div>
172
+ <div class="wall-text">${esc((p.text||'').slice(0,180))}</div>
173
+ <div class="wall-actions">
174
+ <button class="primary" onclick="readWallPost(${i})">Xem</button>
175
+ ${videoBtn}
176
+ </div>
177
+ </div>`;
178
+ }
179
+
180
+ // === GENERATE SHORT VIDEO FOR A WALL POST ===
181
+ async function makeShortVideo(postId, btn, voice, speed){
182
+ if(!postId)return;
183
+ const origText = btn ? btn.textContent : '🎬 Tạo Video';
184
+ if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo...';}
185
+ toast('⏳ Đang tạo video shorts...');
186
+ try{
187
+ let url = '/api/ai/short/'+encodeURIComponent(postId);
188
+ const params = [];
189
+ if(voice) params.push('voice='+encodeURIComponent(voice));
190
+ if(speed) params.push('speed='+encodeURIComponent(speed));
191
+ if(params.length) url += '?' + params.join('&');
192
+ const r = await fetch(url, {method:'POST'});
193
+ const j = await r.json();
194
+ if(!r.ok || j.error) throw new Error(j.error||'Lỗi tạo video');
195
+ toast('✅ Đã tạo video shorts!');
196
+ const p = _wallPosts.find(x => String(x.id) === String(postId));
197
+ if(p){
198
+ p.video = j.video;
199
+ const itemId = 'wall-item-'+postId;
200
+ const el = document.getElementById(itemId);
201
+ if(el){
202
+ const idx = _wallPosts.indexOf(p);
203
+ el.outerHTML = makeWallItem(p, idx);
204
+ const newEl = document.getElementById(itemId);
205
+ if(newEl) newEl.className = 'wall-item wall-item-new';
206
+ }
207
+ }
208
+ refreshShortAISlider();
209
+ }catch(e){
210
+ toast('❌ '+e.message);
211
+ if(btn){btn.disabled=false;btn.textContent=origText;}
212
+ }
213
+ }
214
+
215
+ // Refresh Short AI slider after video generation
216
+ function refreshShortAISlider(){
217
+ const aiShorts = _wallPosts.filter(p=>p.video);
218
+ let shortAISection = document.getElementById('short-ai-section');
219
+ if(aiShorts.length === 0){
220
+ if(shortAISection) shortAISection.remove();
221
+ return;
222
+ }
223
+ if(shortAISection){
224
+ const track = shortAISection.querySelector('.slider-track');
225
+ if(track){
226
+ let h = '';
227
+ aiShorts.slice(0,20).forEach((p,i)=>{
228
+ 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>`;
229
+ });
230
+ track.innerHTML = h;
231
+ }
232
+ }
233
+ }
234
+
235
+ function prependWallPost(post){
236
+ _wallPosts.unshift(post);
237
+ const track=document.getElementById('ai-wall-track');
238
+ const wrap=document.getElementById('ai-wall-wrap');
239
+ const homeEl=document.getElementById('view-home');
240
+ if(!track||!wrap){
241
+ if(homeEl){
242
+ let insertBefore=homeEl.querySelector('.slider-wrap');
243
+ const newWrap=document.createElement('div');
244
+ newWrap.className='slider-wrap';
245
+ newWrap.id='ai-wall-wrap';
246
+ 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>`;
247
+ if(insertBefore){
248
+ homeEl.insertBefore(newWrap,insertBefore);
249
+ }else{
250
+ homeEl.appendChild(newWrap);
251
+ }
252
+ const firstItem=newWrap.querySelector('.wall-item');
253
+ if(firstItem)firstItem.className='wall-item wall-item-new';
254
+ }
255
+ return;
256
+ }
257
+ const div=document.createElement('div');
258
+ div.className='wall-item wall-item-new';
259
+ div.id='wall-item-'+(post.id||'new-'+Date.now());
260
+ const hasVideo = post.video && post.video.length > 0;
261
+ const thumbContent = post.img
262
+ ? `<img src="${esc(post.img)}" onerror="this.style.display='none'">`
263
+ : (hasVideo ? `<video src="${esc(post.video)}" muted></video>` : '');
264
+ const videoBadge = hasVideo ? `<div class="wall-video-badge">🎬</div>` : '';
265
+ const videoBtn = hasVideo
266
+ ? `<button class="wall-btn-video" onclick="event.stopPropagation();openShortAIFeed(0)">▶ Xem Short</button>`
267
+ : `<button class="wall-btn-make" onclick="event.stopPropagation();makeShortVideo('${esc(post.id)}',this)">🎬 Tạo Video</button>`;
268
+ 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>`;
269
+ track.prepend(div);
270
+ track.scrollTo({left:0,behavior:'smooth'});
271
+ if(hasVideo) refreshShortAISlider();
272
+ }
273
+
274
+ // === REST OF FUNCTIONS ===
275
+ let _shortsData=[];
276
+ let _wallPosts=[];
277
+ let _currentView='home';
278
+ let _currentEventId=null;
279
+ let _currentMatchUrl=null;
280
+ 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;}
281
+ let _htPage=0,_htTopic='';
282
+ 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);}}
283
+ 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);}
284
+ 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>`;}}
285
+ function loadMoreHashtag(){_htPage++;const btn=document.getElementById('ht-more');if(btn){btn.disabled=true;btn.textContent='Đang tải...';}showHashtagSources(_htTopic,_htPage);}
286
+ 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';}}}
287
+ 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>';}}
288
+ function bindMatchClicks(el){
289
+ if(!el) return;
290
+ el.querySelectorAll('.match-detail').forEach(md=>{
291
+ md.style.cursor='pointer';
292
+ // Remove old listeners to avoid duplicates (mark as bound)
293
+ if(md._bound) return;
294
+ md._bound = true;
295
+ md.addEventListener('click',function(e){
296
+ // Don't intercept clicks on interactive elements inside the row
297
+ const tag = e.target.tagName?.toLowerCase();
298
+ if(tag === 'a' || tag === 'button' || tag === 'input') {
299
+ e.preventDefault();
300
+ e.stopPropagation();
301
+ }
302
+ // Find ANY link with /tran-dau/ inside this match-detail row
303
+ const links = this.querySelectorAll('a[href*="/tran-dau/"]');
304
+ let bestA = null;
305
+ links.forEach(a => {
306
+ const href = a.getAttribute('href') || '';
307
+ // Prefer links with both event_id AND slug (fuller URL)
308
+ if(href.match(/\/tran-dau\/\d+\/(centre|preview|quan-cau|video)\//)) {
309
+ bestA = a;
310
+ } else if(!bestA && href.match(/\/tran-dau\/\d+\//)) {
311
+ bestA = a;
312
+ }
313
+ });
314
+ if(!bestA) return;
315
+ e.preventDefault();
316
+ e.stopPropagation();
317
+ const href = bestA.getAttribute('href') || '';
318
+ const m = href.match(/\/tran-dau\/(\d+)\//);
319
+ if(m){
320
+ const fullUrl = href.startsWith('http') ? href : 'https://bongda.com.vn' + href;
321
+ openMatch(m[1], fullUrl);
322
+ }
323
+ });
324
+ });
325
+ // Prevent default navigation on all links inside livescore (but let match-detail click handler work)
326
+ el.querySelectorAll('a').forEach(a=>{
327
+ a.addEventListener('click',e=>{
328
+ e.preventDefault();
329
+ e.stopPropagation();
330
+ });
331
+ });
332
+ }
333
+ 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')}
334
+ function closeMatch(){document.getElementById('match-overlay').classList.remove('active');document.body.style.overflow=''}
335
+ 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>'}}
336
+ 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;}}
337
+ 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};}}
338
+ 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[];}}
339
+ 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[];}}
340
+ 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>`;}
341
+ 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);}}
342
+ 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);}}
343
+ 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);}
344
+ 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);}}
345
+ 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);}
346
+ 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;}
347
+ 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);}
348
+ 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)}
349
+ 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();}
350
+ 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();}
351
+ 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();}
352
+ 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>`;}
353
+ 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)}}
354
+ 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)}}
355
+ 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}}
356
+ async function readWallPost(i){const p=_wallPosts[i];if(!p)return;showView('view-article');
357
+ const images = p.images || [];
358
+ let imgGallery = '';
359
+ if(images.length > 0){
360
+ imgGallery = '<div class="article-image-gallery">';
361
+ images.forEach((imgUrl, idx) => {
362
+ if(idx === 0){
363
+ imgGallery += `<img class="article-img article-hero-img" src="${esc(imgUrl)}" onerror="this.style.display='none" loading="eager">`;
364
+ } else {
365
+ if(idx === 1) imgGallery += '<div class="gallery-thumbs">';
366
+ imgGallery += `<div class="gallery-thumb"><img src="${esc(imgUrl)}" onerror="this.parentElement.style.display='none'" loading="lazy"></div>`;
367
+ }
368
+ });
369
+ if(images.length > 1) imgGallery += '</div>';
370
+ imgGallery += '</div>';
371
+ }
372
+ const hasVideo = p.video && p.video.length > 0;
373
+ const voiceOptions = [
374
+ {id:'hoaimy', label:'🎙️ Nữ — Hoài My'},
375
+ {id:'namminh', label:'🎙️ Nam — Nam Minh'},
376
+ ];
377
+ let voiceSelector = '';
378
+ if(!hasVideo){
379
+ voiceSelector = `<div class="tts-selector"><div class="tts-selector-label">🎙️ Chọn giọng đọc:</div><div class="tts-voice-btns">`;
380
+ voiceOptions.forEach(v=>{
381
+ 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>`;
382
+ });
383
+ 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>`;
384
+ voiceSelector += `<input type="hidden" id="selected-voice" value="hoaimy"></div>`;
385
+ }
386
+ 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>`;
387
+ const firstVoiceBtn = document.querySelector('.tts-voice-btn');
388
+ if(firstVoiceBtn) firstVoiceBtn.classList.add('active');
389
+ window.scrollTo(0,0)}
390
+ 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>'}}
391
+ 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}
392
+ 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(()=>{});
393
+
394
+ // === AUTO-OPEN SHARE LINKS (/s?url=... sets pending_article) ===
395
+ (function(){
396
+ try{
397
+ const pa=localStorage.getItem('pending_article');
398
+ const pv=localStorage.getItem('pending_video');
399
+ if(pa){
400
+ localStorage.removeItem('pending_article');
401
+ setTimeout(()=>{
402
+ if(typeof readArticle==='function') readArticle(pa);
403
+ },1500);
404
+ }
405
+ if(pv){
406
+ localStorage.removeItem('pending_video');
407
+ try{
408
+ const v=JSON.parse(pv);
409
+ if(v&&v.url) setTimeout(()=>{window.open(v.url,'_blank')},1500);
410
+ }catch(e){}
411
+ }
412
+ }catch(e){}
413
+ })();
414
+ </script>
415
+ <script>
416
+ // === VNEWS — VTV1-VTV10 + VTVPrime LIVE CHANNELS + EPG ===
417
+ // Uses backend /api/vtv/streams for stream URLs
418
+ // Default channel: VTV6 | No double-load | EPG schedule
419
+
420
+ (function(){
421
+ if(window._ytLiveLoaded) return;
422
+ window._ytLiveLoaded = true;
423
+
424
+ const CHANNELS = [
425
+ {id:'vtv1', name:'VTV1', badge:'Tin tức'},
426
+ {id:'vtv2', name:'VTV2', badge:'Khoa học'},
427
+ {id:'vtv3', name:'VTV3', badge:'Giải trí'},
428
+ {id:'vtv4', name:'VTV4', badge:'Quốc tế'},
429
+ {id:'vtv5', name:'VTV5', badge:'Miền Nam'},
430
+ {id:'vtv6', name:'VTV6', badge:'Thanh niên'},
431
+ {id:'vtv7', name:'VTV7', badge:'Giáo dục'},
432
+ {id:'vtv8', name:'VTV8', badge:'Miền Trung'},
433
+ {id:'vtv9', name:'VTV9', badge:'Miền Bắc'},
434
+ {id:'vtv10', name:'VTV10', badge:'VTV10'},
435
+ {id:'vtvprime', name:'VTVPrime', badge:'Prime'},
436
+ ];
437
+
438
+ // ===== EPG — Lịch phát sóng mẫu cho từng kênh =====
439
+ const EPG = {
440
+ vtv1: [
441
+ {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ự'},
442
+ {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'},
443
+ {t:'21:00',n:'Thời sự đêm'},{t:'23:00',n:'Nhật ký ngày mai'},
444
+ ],
445
+ vtv2: [
446
+ {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'},
447
+ {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à'},
448
+ {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'},
449
+ ],
450
+ vtv3: [
451
+ {t:'06:00',n:'Sáng vui'},{t:'08:00',n:'Phim truyện'},{t:'10:00',n:'Gameshow'},
452
+ {t:'12:00',n:'Âm nhạc'},{t:'14:00',n:'Phim truyện'},{t:'16:00',n:'Giải trí chiều'},
453
+ {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í'},
454
+ ],
455
+ vtv4: [
456
+ {t:'06:00',n:'News'},{t:'08:00',n:'World News'},{t:'10:00',n:'Culture'},
457
+ {t:'12:00',n:'Midday News'},{t:'14:00',n:'Documentary'},{t:'16:00',n:'Sports'},
458
+ {t:'18:00',n:'Evening News'},{t:'20:00',n:'World Today'},{t:'22:00',n:'Nightline'},
459
+ ],
460
+ vtv5: [
461
+ {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'},
462
+ {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'},
463
+ {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'},
464
+ ],
465
+ vtv6: [
466
+ {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ẻ'},
467
+ {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'},
468
+ {t:'18:00',n:'Thời sự trẻ'},{t:'20:00',n:'Đêm nhạc'},{t:'22:00',n:'Thanh niên & Đêm'},
469
+ ],
470
+ vtv7: [
471
+ {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'},
472
+ {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'},
473
+ {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'},
474
+ ],
475
+ vtv8: [
476
+ {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'},
477
+ {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'},
478
+ {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'},
479
+ ],
480
+ vtv9: [
481
+ {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'},
482
+ {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'},
483
+ {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'},
484
+ ],
485
+ vtv10: [
486
+ {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'},
487
+ {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ộ'},
488
+ {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'},
489
+ ],
490
+ vtvprime: [
491
+ {t:'06:00',n:'Prime Morning'},{t:'08:00',n:'Prime Cinema'},{t:'10:00',n:'Prime Sports'},
492
+ {t:'12:00',n:'Prime News'},{t:'14:00',n:'Prime Drama'},{t:'16:00',n:'Prime Entertainment'},
493
+ {t:'18:00',n:'Prime Evening'},{t:'20:00',n:'Prime Night'},{t:'22:00',n:'Prime Late'},
494
+ ],
495
+ };
496
+
497
+ // ALL external streams need proxy — VTVGo/fptplay CDNs don't send CORS headers
498
+ const STREAMS = {};
499
+ let _currentCh = null;
500
+ let _hls = null;
501
+ let _loading = false;
502
+ let _epgVisible = false;
503
+
504
+ const s = document.createElement('style');
505
+ s.textContent = `
506
+ .vtv-wrap{margin:6px 4px;background:#111;border:1px solid #0066cc;border-radius:10px;overflow:hidden}
507
+ .vtv-head{display:flex;align-items:center;gap:8px;padding:8px 10px;background:linear-gradient(90deg,#003366,#1a1a1a)}
508
+ .vtv-title{font-size:13px;font-weight:800;color:#00ccff}
509
+ .vtv-badge{font-size:10px;font-weight:800;color:#00ccff;animation:vtvp 1.3s infinite}
510
+ @keyframes vtvp{0%,100%{opacity:1}50%{opacity:.3}}
511
+ .vtv-tabs{display:flex;gap:3px;padding:6px 8px;overflow-x:auto;scrollbar-width:none;background:#0d1a2a}
512
+ .vtv-tabs::-webkit-scrollbar{display:none}
513
+ .vtv-tab{padding:4px 8px;background:#1a2a3a;border:1px solid #2a3a4a;border-radius:10px;color:#8ab4d8;font-size:9px;cursor:pointer;white-space:nowrap;flex-shrink:0;transition:all .2s}
514
+ .vtv-tab:hover{background:#0b4a7a;color:#fff}
515
+ .vtv-tab.on{background:#0066cc;border-color:#00ccff;color:#fff;font-weight:700}
516
+ .vtv-tab.off{opacity:.35;pointer-events:none}
517
+ .vtv-frame{position:relative;width:100%;aspect-ratio:16/9;background:#000;min-height:180px}
518
+ .vtv-frame video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
519
+ .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}
520
+ .vtv-err button{background:#0066cc;border:none;color:#fff;padding:6px 14px;border-radius:8px;font-size:11px;cursor:pointer}
521
+ .vtv-load{display:flex;align-items:center;justify-content:center;height:180px;color:#00ccff;font-size:12px;flex-direction:column;gap:8px}
522
+ .vtv-spinner{width:24px;height:24px;border:2px solid #333;border-top-color:#00ccff;border-radius:50%;animation:vtvspin .8s linear infinite}
523
+ @keyframes vtvspin{to{transform:rotate(360deg)}}
524
+ .vtv-epg{margin:0;padding:6px 10px;background:#0a1628;border-top:1px solid #1a2a3a}
525
+ .vtv-epg-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}
526
+ .vtv-epg-title{font-size:10px;font-weight:700;color:#00ccff}
527
+ .vtv-epg-toggle{background:none;border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:2px 8px;border-radius:6px;cursor:pointer}
528
+ .vtv-epg-list{display:flex;gap:4px;overflow-x:auto;scrollbar-width:none;padding-bottom:4px}
529
+ .vtv-epg-list::-webkit-scrollbar{display:none}
530
+ .vtv-epg-item{flex:0 0 auto;padding:3px 6px;background:#1a2a3a;border-radius:4px;font-size:8px;color:#8ab4d8;white-space:nowrap}
531
+ .vtv-epg-item.now{background:#0066cc;color:#fff;font-weight:700}
532
+ .vtv-epg-item .epg-t{font-size:7px;color:#6a8aaa}
533
+ .vtv-epg-item.now .epg-t{color:#aaccee}
534
+ .vtv-epg-item .epg-n{color:#ccc;font-size:8px}
535
+ .vtv-epg-item.now .epg-n{color:#fff}
536
+ `;
537
+ document.head.appendChild(s);
538
+
539
+ function getCurrentHour(){
540
+ return new Date().getHours();
541
+ }
542
+
543
+ function buildEPGHTML(chId){
544
+ const epg = EPG[chId] || [];
545
+ if(!epg.length) return '';
546
+ const curH = getCurrentHour();
547
+ let items = '';
548
+ epg.forEach(item => {
549
+ const itemH = parseInt(item.t.split(':')[0], 10);
550
+ const isNow = itemH <= curH && (itemH + 2) > curH;
551
+ items += `<div class="vtv-epg-item${isNow?' now':''}"><div class="epg-t">${item.t}</div><div class="epg-n">${item.n}</div></div>`;
552
+ });
553
+ return `<div class="vtv-epg" id="vtv-epg">' +
554
+ '<div class="vtv-epg-header"><span class="vtv-epg-title">📋 Lịch phát sóng</span>' +
555
+ '<button class="vtv-epg-toggle" onclick="window._vtvToggleEPG()">Ẩn/Hiện</button></div>' +
556
+ '<div class="vtv-epg-list" id="vtv-epg-list">' + items + '</div></div>';
557
+ }
558
+
559
+ window._vtvToggleEPG = function(){
560
+ const list = document.getElementById('vtv-epg-list');
561
+ if(list) list.style.display = list.style.display === 'none' ? 'flex' : 'none';
562
+ };
563
+
564
+ async function loadAllStreams(){
565
+ if(_loading) return;
566
+ _loading = true;
567
+ const loadEl = document.getElementById('vtv-load');
568
+ if(loadEl) loadEl.innerHTML = '<div class="vtv-spinner"></div>Đang tải danh sách kênh...';
569
+
570
+ try {
571
+ const r = await fetch('/api/vtv/streams', {signal: AbortSignal.timeout(10000)});
572
+ if(r.ok){
573
+ const data = await r.json();
574
+ CHANNELS.forEach(ch => {
575
+ const info = data[ch.id];
576
+ if(info && info.stream_url){
577
+ // Always proxy through backend to avoid CORS issues
578
+ const url = '/api/proxy/m3u8/vtv?url=' + encodeURIComponent(info.stream_url);
579
+ STREAMS[ch.id] = [url];
580
+ } else {
581
+ STREAMS[ch.id] = [];
582
+ }
583
+ });
584
+ }
585
+ } catch(e) {
586
+ console.warn('VTV API error:', e);
587
+ }
588
+
589
+ CHANNELS.forEach(ch => {
590
+ const tab = document.getElementById('vtvt-'+ch.id);
591
+ if(tab){
592
+ if(STREAMS[ch.id] && STREAMS[ch.id].length > 0){
593
+ tab.classList.remove('off');
594
+ tab.textContent = ch.name;
595
+ } else {
596
+ tab.style.opacity = '0.35';
597
+ tab.textContent = ch.name + ' ✕';
598
+ }
599
+ }
600
+ });
601
+ _loading = false;
602
+ }
603
+
604
+ function buildBlock(){
605
+ const w = document.createElement('div');
606
+ w.className = 'vtv-wrap';
607
+ w.id = 'vtv-block';
608
+ let tabs = '';
609
+ CHANNELS.forEach(ch => {
610
+ tabs += '<button class="vtv-tab off" id="vtvt-'+ch.id+'" onclick="window._vtvPlay(\''+ch.id+'\')">'+ch.name+'</button>';
611
+ });
612
+ w.innerHTML =
613
+ '<div class="vtv-head"><span class="vtv-title">📺 VTV Trực Tuyến</span><span class="vtv-badge">● LIVE</span></div>' +
614
+ '<div class="vtv-tabs">' + tabs + '</div>' +
615
+ '<div class="vtv-frame">' +
616
+ '<div class="vtv-load" id="vtv-load"><div class="vtv-spinner"></div>Đang tải danh sách kênh...</div>' +
617
+ '<video id="vtv-player" playsinline muted controls preload="auto" style="display:none"></video>' +
618
+ '<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>' +
619
+ '</div>';
620
+ return w;
621
+ }
622
+
623
+ // ===== PIN BLOCK — called only once via loadHome wrapper =====
624
+ function pinBlock(){
625
+ const h = document.getElementById('view-home');
626
+ if(!h || document.getElementById('vtv-block')) return;
627
+ h.insertBefore(buildBlock(), h.firstChild);
628
+ loadAllStreams().then(() => {
629
+ // Default to VTV6 if available, otherwise first available channel
630
+ const tryOrder = ['vtv6','vtv1','vtv2','vtv3','vtv4','vtv5','vtv7','vtv8','vtv9','vtv10'];
631
+ for(const chId of tryOrder){
632
+ if(STREAMS[chId] && STREAMS[chId].length > 0){
633
+ setTimeout(() => window._vtvPlay(chId), 300);
634
+ return;
635
+ }
636
+ }
637
+ });
638
+ }
639
+
640
+ window._vtvRetry = function(){
641
+ if(_currentCh) window._vtvPlay(_currentCh);
642
+ };
643
+
644
+ window._vtvPlay = function(chId){
645
+ const ch = CHANNELS.find(c => c.id === chId);
646
+ if(!ch) return;
647
+ _currentCh = chId;
648
+ document.querySelectorAll('.vtv-tab').forEach(t => t.classList.remove('on'));
649
+ const tab = document.getElementById('vtvt-'+chId);
650
+ if(tab) tab.classList.add('on');
651
+ const video = document.getElementById('vtv-player');
652
+ const errEl = document.getElementById('vtv-err');
653
+ const loadEl = document.getElementById('vtv-load');
654
+ const errMsg = document.getElementById('vtv-err-msg');
655
+ video.style.display = 'none';
656
+ errEl.style.display = 'none';
657
+ loadEl.style.display = 'flex';
658
+ loadEl.innerHTML = '<div class="vtv-spinner"></div>Đang kết nối ' + ch.name + '...';
659
+ if(_hls){ _hls.destroy(); _hls = null; }
660
+ const urls = STREAMS[chId] || [];
661
+ if(urls.length === 0){
662
+ loadEl.style.display = 'none';
663
+ errEl.style.display = 'flex';
664
+ if(chId === 'vtvprime'){
665
+ errMsg.textContent = 'VTVPrime: Kênh trả phí, không có luồng miễn phí.';
666
+ } else {
667
+ errMsg.textContent = ch.name + ': Không tìm thấy luồng. Thử lại sau.';
668
+ }
669
+ return;
670
+ }
671
+ // Update EPG
672
+ const epgEl = document.getElementById('vtv-epg');
673
+ if(epgEl) epgEl.remove();
674
+ const frame = document.querySelector('.vtv-frame');
675
+ if(frame){
676
+ const epgDiv = document.createElement('div');
677
+ epgDiv.innerHTML = buildEPGHTML(chId);
678
+ frame.appendChild(epgDiv.firstElementChild);
679
+ }
680
+ _tryPlay(video, urls, 0, ch.name, loadEl, errEl, errMsg);
681
+ };
682
+
683
+ function _tryPlay(video, urls, idx, name, loadEl, errEl, errMsg){
684
+ if(idx >= urls.length){
685
+ loadEl.style.display = 'none';
686
+ errEl.style.display = 'flex';
687
+ errMsg.textContent = name + ': Tất cả nguồn đều lỗi. Thử lại sau.';
688
+ return;
689
+ }
690
+ const src = urls[idx];
691
+ const sourceLabel = ' (' + (idx+1) + '/' + urls.length + ')';
692
+ loadEl.innerHTML = '<div class="vtv-spinner"></div>Đang kết nối ' + name + sourceLabel + '...';
693
+ if(typeof Hls !== 'undefined' && Hls.isSupported()){
694
+ const hls = new Hls({
695
+ enableWorker: true,
696
+ lowLatencyMode: true,
697
+ startLevel: -1,
698
+ capLevelToPlayerSize: true,
699
+ maxBufferLength: 20,
700
+ xhrSetup: function(xhr, url){
701
+ if(url.includes('fptplay')){
702
+ xhr.setRequestHeader('Referer', 'https://fptplay.vn/');
703
+ xhr.setRequestHeader('Origin', 'https://fptplay.vn');
704
+ }
705
+ }
706
+ });
707
+ _hls = hls;
708
+ hls.loadSource(src);
709
+ hls.attachMedia(video);
710
+ hls.on(Hls.Events.MANIFEST_PARSED, () => {
711
+ video.play().catch(() => {});
712
+ loadEl.style.display = 'none';
713
+ video.style.display = 'block';
714
+ });
715
+ let recoverAttempts = 0;
716
+ hls.on(Hls.Events.ERROR, (ev, data) => {
717
+ if(data.fatal){
718
+ if(data.type === Hls.ErrorTypes.NETWORK_ERROR){
719
+ recoverAttempts++;
720
+ if(recoverAttempts <= 3){
721
+ setTimeout(() => hls.startLoad(), 2000);
722
+ } else {
723
+ hls.destroy();
724
+ _hls = null;
725
+ _tryPlay(video, urls, idx + 1, name, loadEl, errEl, errMsg);
726
+ }
727
+ } else if(data.type === Hls.ErrorTypes.MEDIA_ERROR){
728
+ try { hls.recoverMediaError(); } catch(e) {}
729
+ } else {
730
+ hls.destroy();
731
+ _hls = null;
732
+ _tryPlay(video, urls, idx + 1, name, loadEl, errEl, errMsg);
733
+ }
734
+ }
735
+ });
736
+ } else if(video.canPlayType('application/vnd.apple.mpegurl')){
737
+ video.src = src;
738
+ video.addEventListener('loadedmetadata', () => {
739
+ video.play().catch(() => {});
740
+ loadEl.style.display = 'none';
741
+ video.style.display = 'block';
742
+ }, {once: true});
743
+ video.addEventListener('error', () => {
744
+ _tryPlay(video, urls, idx + 1, name, loadEl, errEl, errMsg);
745
+ }, {once: true});
746
+ } else {
747
+ loadEl.style.display = 'none';
748
+ errEl.style.display = 'flex';
749
+ errMsg.textContent = 'Trình duyệt không hỗ trợ HLS';
750
+ }
751
+ }
752
+
753
+ // ===== ONLY wrap loadHome — no DOMContentLoaded listener to avoid double-load =====
754
+ const orig = window.loadHome;
755
+ if(orig && !orig.__vtvWrapped){
756
+ window.loadHome = async function(){
757
+ const r = await orig.apply(this, arguments);
758
+ try{ pinBlock(); }catch(e){}
759
+ return r;
760
+ };
761
+ window.loadHome.__vtvWrapped = true;
762
+ }
763
+ })();
764
+ </script>
765
+ <!-- hot_multi.js --><script src="/static/hot_multi.js"></script>
766
+ <!-- wc2026_v2.js --><script src="/static/wc2026_v2.js"></script>
767
+ <!-- live_mode.js --><script src="/static/live_mode.js"></script>
768
+ <!-- match_detail_v6.js --><script src="/static/match_detail_v6.js"></script>
769
+ <script>init();</script><!-- v1781058532 --></body></html>
static/index_v3.html ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, TV trực tuyến, 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:#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}.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}
14
+ /* ===== VTV PLAYER ===== */
15
+ .vtv-wrap{margin:6px 4px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:10px;overflow:hidden}
16
+ .vtv-head{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:linear-gradient(90deg,#1a2a1f,#0d1117);border-bottom:1px solid #2d8659}
17
+ .vtv-title{color:#5cb87a;font-size:13px;font-weight:800}
18
+ .vtv-badge{font-size:9px;color:#e74c3c;font-weight:700;animation:vtv-pulse 1.5s infinite}
19
+ @keyframes vtv-pulse{0%,100%{opacity:1}50%{opacity:.4}}
20
+ .vtv-tabs{display:flex;gap:3px;padding:6px 8px;overflow-x:auto;scrollbar-width:none;background:#111;border-bottom:1px solid #222}
21
+ .vtv-tabs::-webkit-scrollbar{display:none}
22
+ .vtv-tab{padding:5px 10px;background:#222;border:1px solid #333;border-radius:12px;color:#999;font-size:10px;white-space:nowrap;cursor:pointer;flex-shrink:0;transition:all .2s}
23
+ .vtv-tab.on{background:#2d8659;border-color:#2d8659;color:#fff;font-weight:700;box-shadow:0 0 8px rgba(45,134,89,.4)}
24
+ .vtv-tab.off{opacity:.4;cursor:not-allowed}
25
+ .vtv-tab:not(.off):hover{background:#2a3a2a;border-color:#5cb87a}
26
+ .vtv-player-area{position:relative;width:100%;aspect-ratio:16/9;background:#000}
27
+ .vtv-player-area video{width:100%;height:100%;object-fit:contain}
28
+ .vtv-load{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#000;color:#888;font-size:12px;gap:8px}
29
+ .vtv-spinner{width:28px;height:28px;border:3px solid #333;border-top-color:#5cb87a;border-radius:50%;animation:vtv-spin .8s linear infinite}
30
+ @keyframes vtv-spin{to{transform:rotate(360deg)}}
31
+ .vtv-err{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#000;color:#e74c3c;font-size:12px;gap:8px;text-align:center;padding:16px}
32
+ .vtv-err button{background:#2d8659;border:0;color:#fff;padding:6px 16px;border-radius:12px;font-size:11px;cursor:pointer}
33
+ .vtv-epg{padding:8px 10px;background:#111;border-top:1px solid #222}
34
+ .vtv-epg-title{font-size:11px;font-weight:700;color:#5cb87a;margin-bottom:6px}
35
+ .vtv-epg-list{display:flex;flex-direction:column;gap:2px}
36
+ .vtv-epg-item{display:flex;gap:8px;padding:4px 6px;border-radius:4px;font-size:10px}
37
+ .vtv-epg-item.now{background:#1a2a1f;border-left:2px solid #5cb87a}
38
+ .vtv-epg-item .epg-t{color:#f0c040;min-width:40px;font-weight:700}
39
+ .vtv-epg-item .epg-n{color:#ccc}
40
+ </style>
41
+ </head>
42
+ <body>
43
+ <div class="header"><h1>📰 VNEWS</h1><p>Tin tức · Bóng đá LIVE · TV Trực Tuyến · Video · AI · World Cup 2026</p></div>
44
+ <div class="cats" id="cat-bar"></div>
45
+ <div id="view-home" class="view active"><div class="loading">Đang tải...</div></div>
46
+ <div id="view-cat" class="view"></div>
47
+ <div id="view-video" class="view"></div>
48
+ <div id="view-tiktok" class="view"></div>
49
+ <div id="view-article" class="view"></div>
50
+ <div class="match-overlay" id="match-overlay">
51
+ <div class="mo-header"><h3 id="mo-title">Chi tiết trận đấu</h3><button class="mo-close" onclick="closeMatch()">✕</button></div>
52
+ <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>
53
+ <div class="mo-body" id="mo-body"><div class="loading">Đang tải...</div></div>
54
+ </div>
55
+ <div id="progress-toast"></div>
56
+ <script>
57
+ var _cats=[],_hlLeagueData={},_currentArticle=null;window._currentEventId='';
58
+ function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]))}
59
+ function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')}
60
+ 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)}}
61
+ function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}}
62
+ 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(()=>{})}
63
+ 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()}
64
+ var SPACE=location.origin;
65
+ </script>
66
+ <script src="/static/app_v2.js"></script>
67
+ <script src="/static/yt_live.js"></script>
68
+ <script src="/static/hot_multi.js"></script>
69
+ <script src="/static/wc2026_v2.js"></script>
70
+ <script src="/static/live_mode.js"></script>
71
+ <script src="/static/match_detail_v6.js"></script>
72
+ <script>init();</script>
73
+ </body>
74
+ </html>
static/index_v4.html ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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}
14
+ /* ===== VTV PLAYER FIXED CSS ===== */
15
+ .vtv-wrap{margin:6px 4px;background:#0a0a0a;border:1px solid #1a3a5a;border-radius:10px;overflow:hidden}
16
+ .vtv-head{display:flex;align-items:center;gap:8px;padding:8px 12px;background:linear-gradient(90deg,#001a33,#0d1a2a);border-bottom:1px solid #1a3a5a}
17
+ .vtv-title{font-size:14px;font-weight:800;color:#00ccff;letter-spacing:.5px}
18
+ .vtv-badge{font-size:10px;font-weight:800;color:#ff4444;animation:vtvp 1.2s infinite}
19
+ @keyframes vtvp{0%,100%{opacity:1}50%{opacity:.3}}
20
+ .vtv-tabs{display:flex;gap:4px;padding:6px 10px;overflow-x:auto;scrollbar-width:none;background:#0d1520}
21
+ .vtv-tabs::-webkit-scrollbar{display:none}
22
+ .vtv-tab{padding:5px 10px;background:#112233;border:1px solid #1a3a4a;border-radius:8px;color:#6a9fca;font-size:9px;font-weight:700;cursor:pointer;white-space:nowrap;flex-shrink:0;transition:all .15s}
23
+ .vtv-tab:hover{background:#1a3a5a;color:#fff}
24
+ .vtv-tab.on{background:#0066cc;border-color:#00aaff;color:#fff;font-weight:800;box-shadow:0 0 8px rgba(0,102,204,.4)}
25
+ .vtv-tab.off{opacity:.3;pointer-events:none}
26
+ .vtv-player-area{position:relative;width:100%;aspect-ratio:16/9;background:#000;min-height:200px}
27
+ .vtv-player-area video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;background:#000}
28
+ .vtv-load{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#00ccff;font-size:12px;flex-direction:column;gap:10px;background:#000}
29
+ .vtv-err{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#ff6666;font-size:12px;text-align:center;padding:20px;flex-direction:column;gap:10px;background:#000}
30
+ .vtv-err button{background:#0066cc;border:none;color:#fff;padding:8px 18px;border-radius:8px;font-size:11px;cursor:pointer;font-weight:700}
31
+ .vtv-err button:hover{background:#0088ff}
32
+ .vtv-spinner{width:28px;height:28px;border:3px solid #222;border-top-color:#00ccff;border-radius:50%;animation:vtvspin .7s linear infinite}
33
+ @keyframes vtvspin{to{transform:rotate(360deg)}}
34
+ .vtv-epg{padding:8px 12px;background:#080e18;border-top:1px solid #1a2a3a}
35
+ .vtv-epg-title{font-size:10px;font-weight:700;color:#00ccff;margin-bottom:6px}
36
+ .vtv-epg-list{display:flex;gap:6px;overflow-x:auto;scrollbar-width:none;padding-bottom:4px}
37
+ .vtv-epg-list::-webkit-scrollbar{display:none}
38
+ .vtv-epg-item{flex:0 0 auto;padding:4px 8px;background:#112233;border-radius:6px;font-size:9px;color:#8ab4d8;white-space:nowrap;border:1px solid #1a2a3a}
39
+ .vtv-epg-item.now{background:#0066cc;color:#fff;font-weight:700;border-color:#00aaff}
40
+ .vtv-epg-item .epg-t{font-size:8px;color:#5a7a9a;display:block}
41
+ .vtv-epg-item.now .epg-t{color:#aaddff}
42
+ .vtv-epg-item .epg-n{color:#ccc;font-size:9px;display:block;margin-top:1px}
43
+ .vtv-epg-item.now .epg-n{color:#fff}
44
+ </style>
45
+ </head>
46
+ <body>
47
+ <div class="header"><h1>📰 VNEWS</h1><p>Tin tức · Bóng đá LIVE · Video · AI · World Cup 2026</p></div>
48
+ <div class="cats" id="cat-bar"></div>
49
+ <div id="view-home" class="view active"><div class="loading">Đang tải...</div></div>
50
+ <div id="view-cat" class="view"></div>
51
+ <div id="view-video" class="view"></div>
52
+ <div id="view-tiktok" class="view"></div>
53
+ <div id="view-article" class="view"></div>
54
+ <div class="match-overlay" id="match-overlay">
55
+ <div class="mo-header"><h3 id="mo-title">Chi tiết trận đấu</h3><button class="mo-close" onclick="closeMatch()">✕</button></div>
56
+ <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>
57
+ <div class="mo-body" id="mo-body"><div class="loading">Đang tải...</div></div>
58
+ </div>
59
+ <div id="progress-toast"></div>
60
+ <script>
61
+ var _cats=[],_hlLeagueData={},_currentArticle=null;window._currentEventId='';
62
+ function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]))}
63
+ function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')}
64
+ 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)}}
65
+ function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}}
66
+ 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(()=>{})}
67
+ 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()}
68
+ var SPACE=location.origin;
69
+ </script>
70
+ <script src="/static/app_v5.js?v=1781061783"></script>
71
+ <script src="/static/hot_multi.js?v=1781059323"></script>
72
+ <script src="/static/wc2026_v2.js?v=1781059323"></script>
73
+ <script src="/static/live_mode.js?v=1781059323"></script>
74
+ <script src="/static/match_detail_v6.js?v=1781059323"></script>
75
+ <script>init();</script>
76
+ </body>
77
+ </html>
static/live_mode.js ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // === INJECT CSS FIX with cache-bust + inline backup ===
2
+ (function(){
3
+ // Method 1: link with cache-bust
4
+ const link = document.createElement('link');
5
+ link.rel = 'stylesheet';
6
+ link.href = '/static/fm_fix.css?v=' + Date.now();
7
+ document.head.appendChild(link);
8
+
9
+ // Method 2: inline critical CSS directly (guaranteed, no cache) — highest priority
10
+ const style = document.createElement('style');
11
+ style.textContent = `
12
+ .featured-match{margin:6px 4px!important;background:linear-gradient(135deg,#1a2a1f,#0d1117)!important;border:1px solid #2d8659!important;border-radius:10px!important;padding:12px!important;cursor:pointer!important}
13
+ .fm-league{text-align:center!important;color:#5cb87a!important;font-size:9px!important;font-weight:700!important;text-transform:uppercase!important;display:block!important}
14
+ .fm-teams{display:flex!important;align-items:center!important;justify-content:center!important;gap:10px!important;margin-top:6px!important}
15
+ .fm-team{flex:1!important;display:flex!important;flex-direction:column!important;align-items:center!important;gap:4px!important}
16
+ .fm-team img{width:32px!important;height:32px!important;object-fit:contain!important}
17
+ .fm-team span{font-size:10px!important;color:#ccc!important;text-align:center!important}
18
+ .fm-score{font-size:22px!important;font-weight:900!important;min-width:60px!important;text-align:center!important;color:#fff!important}
19
+ .fm-status{text-align:center!important;margin-top:6px!important;font-size:9px!important;color:#e74c3c!important;font-weight:700!important}
20
+ .fm-status.upcoming{color:#f0c040!important}
21
+ .hashtag-src-item{display:flex!important;gap:8px!important;padding:8px!important;background:#202020!important;border-radius:8px!important;margin:6px 0!important;cursor:pointer!important}
22
+ .hashtag-src-img{flex:0 0 80px!important;aspect-ratio:16/9!important;background:#333!important;border-radius:6px!important;overflow:hidden!important}
23
+ .hashtag-src-img img{width:100%!important;height:100%!important;object-fit:cover!important}
24
+ .hashtag-src-text{flex:1!important;min-width:0!important}
25
+ .hashtag-src-title{font-size:12px!important;font-weight:700!important;color:#eee!important;display:-webkit-box!important;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden!important}
26
+ .hashtag-src-via{font-size:10px!important;color:#888!important;margin-top:2px!important}`;
27
+ document.head.appendChild(style);
28
+
29
+ // Load rewrite fix
30
+ const script = document.createElement('script');
31
+ script.src = '/static/rewrite_fix.js?v=' + Date.now();
32
+ document.body.appendChild(script);
33
+ })();
34
+
35
+ let _liveInterval = null;
36
+ let _liveTick = 0;
37
+ function startHomepageLive() {
38
+ if (_liveInterval) clearInterval(_liveInterval);
39
+ setTimeout(addLiveBadges, 2000);
40
+ _liveInterval = setInterval(async () => {
41
+ if (!document.getElementById('view-home')?.classList.contains('active')) return;
42
+ _liveTick++;
43
+ const lsTab = document.querySelector('.ls-tab.active');
44
+ if (lsTab && lsTab.dataset.tab) loadLivescore(lsTab.dataset.tab);
45
+ try {
46
+ const f = await fetch('/api/livescore/featured').then(r => r.json()).catch(() => null);
47
+ if (f && f.home) {
48
+ const el = document.querySelector('.featured-match');
49
+ if (el) {
50
+ const sc = f.status === 'live' ? '' : 'upcoming';
51
+ const st = f.status === 'live' ? `🔴 ${f.minute || 'LIVE'}` : `⏰ ${f.time}`;
52
+ el.innerHTML = `<div class="fm-league">${f.league}</div><div class="fm-teams"><div class="fm-team"><img src="${f.home_logo}" onerror="this.style.display='none'"><span>${f.home}</span></div><div class="fm-score">${f.score || 'VS'}</div><div class="fm-team"><img src="${f.away_logo}" onerror="this.style.display='none'"><span>${f.away}</span></div></div><div class="fm-status ${sc}">${st}</div>`;
53
+ }
54
+ }
55
+ } catch(e) {}
56
+ if (_liveTick % 3 === 0) refreshHashtag();
57
+ if (_liveTick % 5 === 0) loadHotTopics();
58
+ pulseLiveBadges();
59
+ }, 60000);
60
+ }
61
+ function addLiveBadges() {
62
+ document.querySelectorAll('.ls-header h3, .slider-header .slider-label').forEach(el => {
63
+ if (!el.querySelector('.live-dot')) {
64
+ const dot = document.createElement('span');
65
+ dot.className = 'live-dot';
66
+ dot.style.cssText = 'font-size:8px;color:#e74c3c;margin-left:6px;animation:wc-pulse 1.5s infinite';
67
+ dot.textContent = '● LIVE';
68
+ el.appendChild(dot);
69
+ }
70
+ });
71
+ }
72
+ function pulseLiveBadges() {
73
+ document.querySelectorAll('.live-dot').forEach(d => {
74
+ d.style.opacity = '1';
75
+ setTimeout(() => { d.style.opacity = '0.4'; }, 500);
76
+ setTimeout(() => { d.style.opacity = '1'; }, 1000);
77
+ });
78
+ }
79
+ function refreshHashtag() {
80
+ if (typeof _htTopic !== 'undefined' && _htTopic) {
81
+ const box = document.getElementById('hashtag-box');
82
+ if (box && box.innerHTML.length > 50) {
83
+ fetch(`/api/hashtag/sources?topic=${encodeURIComponent(_htTopic)}&page=0`)
84
+ .then(r => r.json())
85
+ .then(j => {
86
+ const sources = j.sources || [];
87
+ if (!sources.length) return;
88
+ const list = document.getElementById('ht-list');
89
+ if (!list) return;
90
+ let h = '';
91
+ sources.forEach((s, i) => {
92
+ 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 || '')}</div></div></div>`;
93
+ });
94
+ list.innerHTML = h;
95
+ sources.forEach((s, i) => {
96
+ if (!s.url) return;
97
+ fetch('/api/article?url=' + encodeURIComponent(s.url)).then(r => r.json()).then(d => {
98
+ if (d && (d.og_image || d.img)) {
99
+ const el = document.getElementById('ht-img-' + i);
100
+ if (el) el.innerHTML = `<img src="${esc(d.og_image || d.img)}" onerror="this.style.display='none'">`;
101
+ }
102
+ }).catch(() => {});
103
+ });
104
+ }).catch(() => {});
105
+ }
106
+ }
107
+ }
108
+ setTimeout(startHomepageLive, 6000);
static/match_detail_v6.js ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // === VNEWS — Match Detail UI (v6-final)
2
+ (function(){
3
+ if(window._mdLoadedV6) return;
4
+ window._mdLoadedV6 = true;
5
+
6
+ if(!document.getElementById('mdv6-css')){
7
+ const s=document.createElement('style');
8
+ s.id='mdv6-css';
9
+ s.textContent=`
10
+ .mdv6-sec{margin-bottom:14px}
11
+ .mdv6-sec-title{font-size:12px;font-weight:800;color:#5cb87a;margin-bottom:6px;padding-bottom:4px;border-bottom:1px solid #2a2a2a}
12
+ .mdv6-header{background:linear-gradient(135deg,#1a2a1f,#0d1117);border:1px solid #2d8659;border-radius:10px;padding:12px;text-align:center;margin-bottom:12px}
13
+ .mdv6-teams{display:flex;align-items:center;justify-content:center;gap:12px}
14
+ .mdv6-team{display:flex;flex-direction:column;align-items:center;gap:4px;flex:1}
15
+ .mdv6-team img{width:44px;height:44px;object-fit:contain;background:#111;border-radius:50%;padding:4px;border:2px solid #333}
16
+ .mdv6-vs .mdv6-score{font-size:26px;font-weight:900;color:#f0c040}
17
+ .mdv6-vs .mdv6-status{font-size:9px;font-weight:700;padding:2px 10px;border-radius:4px}
18
+ .mdv6-vs .mdv6-status.finished{background:#2d8659;color:#a8ffb8}
19
+ .mdv6-vs .mdv6-status.live{background:#c0392b;color:#fff;animation:mdv6pulse 1.5s infinite}
20
+ .mdv6-tabs{display:flex;gap:4px;margin-bottom:10px}
21
+ .mdv6-tab-btn{flex:1;padding:9px;border:none;border-radius:6px;font-size:11px;font-weight:700;cursor:pointer}
22
+ .mdv6-tab-btn.active{background:#2d8659;color:#fff}
23
+ .mdv6-tab-btn:not(.active){background:#1a1a1a;color:#888}
24
+ .mdv6-tab-content{display:none}
25
+ .mdv6-tab-content.active{display:block}
26
+ .mdv6-timeline{}
27
+ .mdv6-period{font-size:11px;font-weight:800;color:#5cb87a;padding:6px 0;border-bottom:1px solid #2a2a2a;margin-bottom:2px}
28
+ .mdv6-ev{display:flex;align-items:flex-start;gap:8px;padding:7px 0;border-bottom:1px solid #1a1a1a}
29
+ .mdv6-ev-time{font-size:11px;font-weight:800;color:#f0c040;min-width:40px;text-align:center}
30
+ .mdv6-ev-icon{width:20px;height:20px;display:flex;align-items:center;justify-content:center}
31
+ .mdv6-ev-body{flex:1}
32
+ .mdv6-ev-team{font-size:8px;font-weight:800;padding:1px 5px;border-radius:3px;margin-right:4px}
33
+ .mdv6-ev-team.home{background:#1a3a2a;color:#5cb87a}
34
+ .mdv6-ev-team.away{background:#3a2a1a;color:#e85d04}
35
+ .mdv6-ev-title{font-size:12px;color:#fff;font-weight:700}
36
+ .mdv6-ev-detail{font-size:11px;color:#999;margin-top:3px}
37
+ .mdv6-stat-row{display:flex;align-items:center;gap:8px;padding:5px 0;font-size:11px;border-bottom:1px solid #1a1a1a}
38
+ .mdv6-stat-home{width:36px;text-align:right;color:#5cb87a;font-weight:700}
39
+ .mdv6-stat-label{flex:1;text-align:center;color:#999;font-size:10px}
40
+ .mdv6-stat-away{width:36px;text-align:left;color:#e85d04;font-weight:700}
41
+ .mdv6-pred{background:linear-gradient(135deg,#1a2a1f,#0d1117);border:1px solid #2d8659;border-radius:10px;padding:10px;margin-bottom:12px}
42
+ .mdv6-pred-title{font-size:9px;color:#5cb87a;font-weight:700;text-align:center;margin-bottom:8px}
43
+ .mdv6-pred-teams{display:flex;align-items:center;justify-content:center;gap:8px}
44
+ .mdv6-pred-vs{font-size:14px;font-weight:800;color:#f0c040}
45
+ .mdv6-recent-row{display:flex;align-items:center;gap:8px;padding:6px 8px;background:#111;border-radius:6px;margin-bottom:3px;font-size:11px}
46
+ .mdv6-recent-date{font-size:9px;color:#888;min-width:55px}
47
+ .mdv6-recent-score{font-size:11px;font-weight:800;color:#f0c040;min-width:36px;text-align:center}
48
+ .mdv6-no-data{text-align:center;color:#666;padding:16px 8px;font-size:12px}
49
+ .mdv6-warn{color:#f0c040;font-size:12px;padding:8px;background:#2a2a1a;border-radius:6px}
50
+ .mdv6-source{font-size:9px;color:#5cb87a;text-align:center;padding:3px;background:#1a2a1f;border-radius:4px;margin-bottom:8px}
51
+ @keyframes mdv6spin{to{transform:rotate(360deg)}}
52
+ @keyframes mdv6pulse{0%,100%{opacity:1}50%{opacity:.5}}
53
+ `;
54
+ document.head.appendChild(s);
55
+ }
56
+
57
+ function esc(s){const d=document.createElement('div');d.textContent=s||'';return d.innerHTML;}
58
+
59
+ // Hook: override loadMatchTab to use fast endpoint
60
+ window._origLoadMatchTab = window.loadMatchTab;
61
+ window.loadMatchTab = async function(tab){
62
+ if(tab!=='detail'){
63
+ if(window._origLoadMatchTab) return window._origLoadMatchTab.call(this, tab);
64
+ return;
65
+ }
66
+ document.querySelectorAll('.mo-tab').forEach(t=>t.classList.remove('active'));
67
+ const el=document.getElementById('mo-body');
68
+ if(!el)return;
69
+ el.innerHTML='<div class="mdv6-no-data"><div style="display:inline-block;width:20px;height:20px;border:2px solid #333;border-top-color:#5cb87a;border-radius:50%;animation:mdv6spin .8s linear infinite"></div><div style="margin-top:8px">Đang tải...</div></div>';
70
+
71
+ const eventId=window._currentEventId;
72
+ let matchUrl = window._currentMatchUrl;
73
+ if(!eventId){el.innerHTML='<div class="mdv6-warn">⚠️ Không tìm thấy mã trận đấu.</div>';return;}
74
+
75
+ // Auto-fetch slug if missing (for featured matches clicked without URL)
76
+ if(!matchUrl && eventId){
77
+ try{
78
+ matchUrl = 'https://bongda.com.vn/tran-dau/' + eventId;
79
+ // Store for next time
80
+ window._currentMatchUrl = matchUrl;
81
+ }catch(e){}
82
+ }
83
+
84
+ try{
85
+ const url = '/api/match/' + eventId + '/detail' + (matchUrl ? '?url=' + encodeURIComponent(matchUrl) : '');
86
+ const r = await fetch(url, {signal: AbortSignal.timeout(25000)});
87
+ const data = await r.json();
88
+ if(data.found && data.info && data.info.home_team){
89
+ el.innerHTML = '<div class="mdv6-source">📡 Dữ liệu server-side nhanh</div>';
90
+ renderMatchDetail(el, data);
91
+ }else{
92
+ el.innerHTML = '<div class="mdv6-warn">⚠️ Không thể tải dữ liệu.<br>Event ID: ' + esc(String(eventId)) + '<br><a href="https://bongda.com.vn/tran-dau/' + eventId + '" target="_blank">Xem trên bongda.com.vn</a></div>';
93
+ }
94
+ }catch(e){
95
+ el.innerHTML = '<div class="mdv6-warn">⚠️ Lỗi: ' + esc(e.message) + '</div>';
96
+ }
97
+ };
98
+
99
+ function renderMatchDetail(el, data){
100
+ const info=data.info||{};
101
+ let h='';
102
+
103
+ h+='<div class="mdv6-header"><div class="mdv6-teams">';
104
+ h+='<div class="mdv6-team">';
105
+ if(info.home_logo)h+='<img src="' + esc(info.home_logo) + '" alt="' + esc(info.home_team) + '">';
106
+ h+='<span>' + esc(info.home_team) + '</span></div>';
107
+ h+='<div class="mdv6-vs">';
108
+ if(info.score)h+='<span class="mdv6-score">' + esc(info.score) + '</span>';
109
+ if(info.status_label){
110
+ const sl=info.status_label;let cls='finished';
111
+ if(sl==='LIVE'||sl==='H1'||sl==='H2')cls='live';
112
+ h+='<span class="mdv6-status ' + cls + '">' + esc(sl) + '</span>';
113
+ }
114
+ h+='</div><div class="mdv6-team">';
115
+ if(info.away_logo)h+='<img src="' + esc(info.away_logo) + '" alt="' + esc(info.away_team) + '">';
116
+ h+='<span>' + esc(info.away_team) + '</span></div></div></div>';
117
+
118
+ const hasEvents=data.events&&data.events.length>0;
119
+ const hasStats=data.h2h_stats_parsed||data.prediction||data.recent_matches;
120
+
121
+ if(hasStats||hasEvents){
122
+ h+='<div class="mdv6-tabs">';
123
+ if(hasStats)h+='<button class="mdv6-tab-btn active" onclick="window._mdv6ShowTab(\'stats\',this)">📊 Thống kê</button>';
124
+ if(hasEvents)h+='<button class="mdv6-tab-btn" onclick="window._mdv6ShowTab(\'events\',this)">📢 Diễn biến</button>';
125
+ h+='</div>';
126
+ }
127
+
128
+ if(hasStats){
129
+ h+='<div id="mdv6-tab-stats" class="mdv6-tab-content" style="display:' + (hasEvents?'none':'block') + '">';
130
+ if(data.h2h_stats_parsed){
131
+ h+='<div class="mdv6-sec"><div class="mdv6-sec-title">📊 Tỷ lệ đối đầu</div>';
132
+ for(const label in data.h2h_stats_parsed){
133
+ const sv=data.h2h_stats_parsed[label];
134
+ h+='<div class="mdv6-stat-row"><span class="mdv6-stat-home">' + esc(sv.home) + '</span><span class="mdv6-stat-label">' + esc(label) + '</span><span class="mdv6-stat-away">' + esc(sv.away) + '</span></div>';
135
+ }
136
+ h+='</div>';
137
+ }
138
+ if(data.prediction){
139
+ h+='<div class="mdv6-pred"><div class="mdv6-pred-title">🎯 Dự đoán</div><div class="mdv6-pred-teams">';
140
+ h+='<span>' + esc(data.prediction.home_name || '') + '</span><span class="mdv6-pred-vs">' + esc(data.prediction.result || 'VS') + '</span><span>' + esc(data.prediction.away_name || '') + '</span></div></div>';
141
+ }
142
+ if(data.recent_matches){
143
+ h+='<div class="mdv6-sec"><div class="mdv6-sec-title">📋 Kết quả gần nhất</div>';
144
+ data.recent_matches.slice(0,6).forEach(m=>{
145
+ h+='<div class="mdv6-recent-row"><span class="mdv6-recent-date">' + esc(m.date) + '</span><span>' + esc(m.home) + '</span><span class="mdv6-recent-score">' + esc(m.score) + '</span><span>' + esc(m.away) + '</span></div>';
146
+ });
147
+ h+='</div>';
148
+ }
149
+ h+='</div>';
150
+ }
151
+
152
+ if(hasEvents){
153
+ h+='<div id="mdv6-tab-events" class="mdv6-tab-content" style="display:' + (hasStats?'none':'block') + '">';
154
+ h+='<div class="mdv6-sec"><div class="mdv6-sec-title">📢 Diễn biến trận đấu</div><div class="mdv6-timeline">';
155
+ let lastPeriod='';
156
+ data.events.forEach(ev=>{
157
+ if(ev.period&&ev.period!==lastPeriod){
158
+ h+='<div class="mdv6-period">' + esc(ev.period) + '</div>';
159
+ lastPeriod=ev.period;
160
+ }
161
+ const isHome=ev.team==='home';
162
+ const badge=isHome?'<span class="mdv6-ev-team home">HOME</span>':'<span class="mdv6-ev-team away">AWAY</span>';
163
+ let icon='', title='', detail='';
164
+
165
+ if(ev.type==='goal'){icon='⚽';title='BÀN THẮNG';detail=esc(ev.players);}
166
+ else if(ev.type==='redcard'){icon='🟥';title='THỺ ĐỎ';detail=esc(ev.players);}
167
+ else if(ev.type==='yellowcard'){icon='🟨';title='THỺ VÀNG';detail=esc(ev.players);}
168
+ else if(ev.type==='substitution'){icon='↔️';title='THAY ĐỔI';detail=esc(ev.players);}
169
+ else{icon='•';detail=esc(ev.players);}
170
+
171
+ h+='<div class="mdv6-ev"><span class="mdv6-ev-time">' + esc(ev.time||'') + '</span><span class="mdv6-ev-icon">' + icon + '</span><div class="mdv6-ev-body">' + badge + ' <span class="mdv6-ev-title">' + title + '</span>';
172
+ if(detail)h+='<div class="mdv6-ev-detail">' + detail + '</div>';
173
+ h+='</div></div>';
174
+ });
175
+ h+='</div></div>';
176
+ }
177
+
178
+ el.innerHTML=h;
179
+
180
+ window._mdv6ShowTab=(tab,btn)=>{
181
+ document.querySelectorAll('.mdv6-tab-btn').forEach(b=>{b.classList.remove('active');b.style.background='#1a1a1a';b.style.color='#888';});
182
+ if(btn){btn.classList.add('active');btn.style.background='#2d8659';btn.style.color='#fff';}
183
+ const stats=document.getElementById('mdv6-tab-stats');
184
+ const events=document.getElementById('mdv6-tab-events');
185
+ if(stats)stats.style.display=tab==='stats'?'block':'none';
186
+ if(events)events.style.display=tab==='events'?'block':'none';
187
+ };
188
+ }
189
+
190
+ // Auto-extract match ID/slug on click
191
+ document.addEventListener('click',e=>{
192
+ const md=e.target.closest('.match-detail, .match-item');
193
+ if(!md)return;
194
+ const link=md.querySelector('a[href*="tran-dau"]');
195
+ if(link){
196
+ const href=link.getAttribute('href')||'';
197
+ const m=href.match(/\/tran-dau\/(\d+)\/(?:centre|preview)\/(.+)/);
198
+ if(m){
199
+ window._currentEventId=m[1];
200
+ window._currentSlug=m[2];
201
+ window._currentMatchUrl='https://bongda.com.vn' + href;
202
+ }else{
203
+ const m2=href.match(/\/tran-dau\/(\d+)/);
204
+ if(m2){
205
+ window._currentEventId=m2[1];
206
+ window._currentMatchUrl='https://bongda.com.vn' + href;
207
+ }
208
+ }
209
+ }
210
+ },true);
211
+
212
+ })();
static/rewrite_fix.js ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Fix rewriteArticle - call correct endpoint
2
+ // This file patches the rewriteArticle function to use /api/rewrite_slide instead of /api/rewrite_share
3
+
4
+ (function(){
5
+ // Override rewriteArticle to call /api/rewrite_slide
6
+ const origRewrite = window.rewriteArticle;
7
+ window.rewriteArticle = async function(){
8
+ const url = _currentArticle?.url;
9
+ if(!url) return;
10
+ toast('⏳ Đang tạo slide tóm tắt...');
11
+ try {
12
+ const r = await fetch('/api/rewrite_slide', {
13
+ method: 'POST',
14
+ headers: {'Content-Type': 'application/json'},
15
+ body: JSON.stringify({url, context: document.querySelector('.article-view')?.innerText?.slice(0,14000) || ''})
16
+ });
17
+ const j = await r.json();
18
+ if (!r.ok || j.error) throw new Error(j.error);
19
+ toast('✅ Đã đăng Tường AI!');
20
+ if (j.post) prependWallPost(j.post);
21
+ // Navigate to the new post on Tường AI (home). Slide overlay (if any) stays on top.
22
+ if (j.post && typeof goToWallPost === 'function') goToWallPost(j.post.id);
23
+ // Show slides preview
24
+ if (j.slides && j.slides.length) {
25
+ showSlidePreview(j.slides, j.post?.title || '');
26
+ }
27
+ } catch(e) {
28
+ // Fallback: try /api/rewrite_share (old endpoint from ai_ext)
29
+ try {
30
+ const r2 = await fetch('/api/rewrite_share', {
31
+ method: 'POST',
32
+ headers: {'Content-Type': 'application/json'},
33
+ body: JSON.stringify({url, context: document.querySelector('.article-view')?.innerText?.slice(0,14000) || ''})
34
+ });
35
+ const j2 = await r2.json();
36
+ if (r2.ok && !j2.error) {
37
+ toast('✅ Đã đăng Tường AI!');
38
+ if (j2.post) prependWallPost(j2.post);
39
+ if (j2.post && typeof goToWallPost === 'function') goToWallPost(j2.post.id);
40
+ return;
41
+ }
42
+ } catch(e2) {}
43
+ toast('❌ ' + e.message);
44
+ }
45
+ };
46
+
47
+ // Show slides as fullscreen overlay
48
+ window.showSlidePreview = function(slides, title) {
49
+ if (!slides || !slides.length) return;
50
+ const overlay = document.createElement('div');
51
+ overlay.id = 'slide-preview';
52
+ overlay.style.cssText = 'position:fixed;inset:0;background:#000;z-index:99999;display:flex;flex-direction:column;overflow:hidden';
53
+
54
+ let currentSlide = 0;
55
+ function renderSlide(idx) {
56
+ const s = slides[idx];
57
+ overlay.innerHTML = `
58
+ <div style="position:absolute;top:10px;left:10px;right:10px;display:flex;justify-content:space-between;align-items:center;z-index:2">
59
+ <button onclick="document.getElementById('slide-preview').remove()" style="background:rgba(0,0,0,.6);border:0;color:#fff;padding:8px 14px;border-radius:20px;font-size:12px;cursor:pointer">✕ Đóng</button>
60
+ <span style="color:#fff;font-size:11px;background:rgba(0,0,0,.6);padding:4px 10px;border-radius:10px">${idx+1}/${slides.length}</span>
61
+ </div>
62
+ <div style="flex:1;display:flex;align-items:center;justify-content:center;padding:20px">
63
+ ${s.image ? `<img src="${esc(s.image)}" style="max-width:100%;max-height:60vh;border-radius:10px;object-fit:contain" onerror="this.style.display='none'">` : ''}
64
+ </div>
65
+ <div style="padding:16px 20px;background:linear-gradient(transparent,rgba(0,0,0,.9));min-height:100px">
66
+ <p style="color:#fff;font-size:14px;line-height:1.6">${esc(s.text)}</p>
67
+ </div>
68
+ <div style="display:flex;gap:10px;padding:10px 20px 20px;justify-content:center">
69
+ <button onclick="prevSlide()" style="background:#333;border:0;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;cursor:pointer" ${idx===0?'disabled style="opacity:.3"':''}>← Trước</button>
70
+ <button onclick="nextSlide()" style="background:#2d8659;border:0;color:#fff;padding:10px 20px;border-radius:20px;font-size:12px;cursor:pointer" ${idx===slides.length-1?'disabled style="opacity:.3"':''}>Tiếp →</button>
71
+ </div>
72
+ `;
73
+ }
74
+
75
+ window.nextSlide = function() { if (currentSlide < slides.length - 1) { currentSlide++; renderSlide(currentSlide); } };
76
+ window.prevSlide = function() { if (currentSlide > 0) { currentSlide--; renderSlide(currentSlide); } };
77
+
78
+ renderSlide(0);
79
+ document.body.appendChild(overlay);
80
+
81
+ // Swipe support
82
+ let startX = 0;
83
+ overlay.addEventListener('touchstart', e => { startX = e.touches[0].clientX; });
84
+ overlay.addEventListener('touchend', e => {
85
+ const diff = e.changedTouches[0].clientX - startX;
86
+ if (diff < -50) nextSlide();
87
+ else if (diff > 50) prevSlide();
88
+ });
89
+ };
90
+ })();